From 98ad3b984fc891b3ced4e4187370c4dc58d3df38 Mon Sep 17 00:00:00 2001 From: "Yassmine.Mestiri" Date: Fri, 10 Mar 2023 11:57:04 +0100 Subject: [PATCH] iot --- .gitignore | 39 + LICENSE | 201 + README.md | 100 +- application/.gitignore | 2 + application/pom.xml | 451 + application/src/main/conf/logback.xml | 46 + application/src/main/conf/thingsboard.conf | 24 + .../azure/BaltimoreCyberTrustRoot.crt.pem | 22 + .../data/json/demo/dashboards/firmware.json | 2492 ++++ .../data/json/demo/dashboards/gateways.json | 1267 ++ .../dashboards/rule_engine_statistics.json | 520 + .../data/json/demo/dashboards/software.json | 2492 ++++ .../json/demo/dashboards/thermostats.json | 1239 ++ .../oauth2_config_templates/apple_config.json | 24 + .../facebook_config.json | 23 + .../github_config.json | 21 + .../google_config.json | 24 + .../system/widget_bundles/alarm_widgets.json | 30 + .../widget_bundles/analogue_gauges.json | 105 + .../json/system/widget_bundles/cards.json | 222 + .../json/system/widget_bundles/charts.json | 209 + .../widget_bundles/control_widgets.json | 200 + .../data/json/system/widget_bundles/date.json | 29 + .../system/widget_bundles/digital_gauges.json | 257 + .../system/widget_bundles/edge_widgets.json | 29 + .../widget_bundles/entity_admin_widgets.json | 50 + .../widget_bundles/gateway_widgets.json | 67 + .../system/widget_bundles/gpio_widgets.json | 86 + .../system/widget_bundles/input_widgets.json | 505 + .../data/json/system/widget_bundles/maps.json | 181 + .../widget_bundles/navigation_widgets.json | 48 + .../device_profile/rule_chain_template.json | 140 + .../rule_chains/edge_root_rule_chain.json | 181 + .../tenant/rule_chains/root_rule_chain.json | 140 + .../sql/schema-entities-idx-psql-addon.sql | 38 + .../src/main/data/sql/schema-entities-idx.sql | 81 + .../src/main/data/sql/schema-entities.sql | 778 ++ .../src/main/data/sql/schema-timescale.sql | 157 + .../src/main/data/sql/schema-ts-psql.sql | 339 + .../main/data/upgrade/1.3.0/schema_update.cql | 187 + .../main/data/upgrade/1.3.1/schema_update.sql | 17 + .../main/data/upgrade/1.4.0/schema_update.cql | 112 + .../main/data/upgrade/1.4.0/schema_update.sql | 41 + .../main/data/upgrade/2.0.0/schema_update.cql | 103 + .../main/data/upgrade/2.0.0/schema_update.sql | 44 + .../main/data/upgrade/2.1.1/schema_update.cql | 74 + .../main/data/upgrade/2.1.1/schema_update.sql | 29 + .../main/data/upgrade/2.1.2/schema_update.cql | 110 + .../main/data/upgrade/2.1.2/schema_update.sql | 32 + .../main/data/upgrade/2.2.0/schema_update.sql | 19 + .../main/data/upgrade/2.3.1/schema_update.sql | 17 + .../main/data/upgrade/2.4.0/schema_update.sql | 23 + .../main/data/upgrade/2.4.2/schema_update.sql | 31 + .../schema_update_psql_drop_partitions.sql | 209 + .../upgrade/2.4.3/schema_update_psql_ts.sql | 359 + .../2.4.3/schema_update_timescale_ts.sql | 208 + .../data/upgrade/2.4.3/schema_update_ttl.sql | 150 + .../data/upgrade/3.0.1/schema_ts_latest.sql | 35 + .../upgrade/3.0.1/schema_update_to_uuid.sql | 878 ++ .../main/data/upgrade/3.1.0/schema_update.sql | 17 + .../upgrade/3.1.1/schema_update_after.sql | 28 + .../upgrade/3.1.1/schema_update_before.sql | 154 + .../main/data/upgrade/3.2.1/schema_update.sql | 23 + .../data/upgrade/3.2.1/schema_update_ttl.sql | 87 + .../main/data/upgrade/3.2.2/schema_update.sql | 216 + .../upgrade/3.2.2/schema_update_event.sql | 90 + .../data/upgrade/3.2.2/schema_update_ttl.sql | 32 + .../main/data/upgrade/3.3.2/schema_update.sql | 71 + .../3.3.2/schema_update_lwm2m_bootstrap.sql | 213 + .../3.3.3/schema_event_ttl_procedure.sql | 50 + .../main/data/upgrade/3.3.3/schema_update.sql | 29 + .../main/data/upgrade/3.3.4/schema_update.sql | 140 + .../main/data/upgrade/3.4.0/schema_update.sql | 234 + .../main/data/upgrade/3.4.1/schema_update.sql | 142 + .../upgrade/3.4.1/schema_update_after.sql | 21 + .../upgrade/3.4.1/schema_update_before.sql | 46 + .../kafka/common/network/NetworkReceive.java | 184 + .../server/ThingsboardInstallApplication.java | 65 + .../server/ThingsboardServerApplication.java | 48 + .../server/actors/ActorSystemContext.java | 674 + .../actors/TbEntityTypeActorIdPredicate.java | 37 + .../server/actors/app/AppActor.java | 229 + .../server/actors/app/AppInitMsg.java | 27 + .../server/actors/device/DeviceActor.java | 97 + .../actors/device/DeviceActorCreator.java | 47 + .../device/DeviceActorMessageProcessor.java | 1003 ++ .../server/actors/device/SessionInfo.java | 28 + .../actors/device/SessionInfoMetaData.java | 39 + .../actors/device/SessionTimeoutCheckMsg.java | 39 + .../device/ToDeviceRpcRequestMetadata.java | 30 + .../device/ToServerRpcRequestMetadata.java | 31 + .../actors/ruleChain/DefaultTbContext.java | 823 ++ .../actors/ruleChain/RuleChainActor.java | 109 + .../RuleChainActorMessageProcessor.java | 404 + .../actors/ruleChain/RuleChainInputMsg.java | 41 + .../ruleChain/RuleChainManagerActor.java | 104 + .../actors/ruleChain/RuleChainOutputMsg.java | 49 + .../ruleChain/RuleChainToRuleChainMsg.java | 51 + .../ruleChain/RuleChainToRuleNodeMsg.java | 44 + .../actors/ruleChain/RuleNodeActor.java | 141 + .../RuleNodeActorMessageProcessor.java | 163 + .../server/actors/ruleChain/RuleNodeCtx.java | 34 + .../actors/ruleChain/RuleNodeRelation.java | 32 + .../RuleNodeToRuleChainTellNextMsg.java | 68 + .../actors/ruleChain/RuleNodeToSelfMsg.java | 43 + .../ruleChain/TbToRuleChainActorMsg.java | 50 + .../ruleChain/TbToRuleNodeActorMsg.java | 42 + .../server/actors/service/ActorService.java | 21 + .../server/actors/service/ComponentActor.java | 189 + .../actors/service/ContextAwareActor.java | 66 + .../actors/service/ContextBasedCreator.java | 29 + .../actors/service/DefaultActorService.java | 138 + .../AbstractContextAwareMsgProcessor.java | 50 + .../actors/shared/ActorTerminationMsg.java | 31 + .../actors/shared/ComponentMsgProcessor.java | 108 + .../server/actors/stats/StatsActor.java | 89 + .../server/actors/stats/StatsPersistMsg.java | 45 + .../server/actors/stats/StatsPersistTick.java | 26 + .../actors/tenant/DebugTbRateLimits.java | 29 + .../server/actors/tenant/TenantActor.java | 305 + ...tomOAuth2AuthorizationRequestResolver.java | 302 + .../server/config/MvcCorsProperties.java | 46 + .../config/RateLimitProcessingFilter.java | 121 + .../config/SchedulingConfiguration.java | 43 + .../server/config/SwaggerConfiguration.java | 397 + .../ThingsboardMessageConfiguration.java | 35 + .../ThingsboardSecurityConfiguration.java | 252 + .../thingsboard/server/config/WebConfig.java | 40 + .../server/config/WebSocketConfiguration.java | 98 + .../controller/AbstractRpcController.java | 183 + .../server/controller/AdminController.java | 402 + .../server/controller/AlarmController.java | 317 + .../server/controller/AssetController.java | 561 + .../controller/AssetProfileController.java | 227 + .../server/controller/AuditLogController.java | 233 + .../server/controller/AuthController.java | 326 + .../controller/AutoCommitController.java | 42 + .../server/controller/BaseController.java | 962 ++ .../ComponentDescriptorController.java | 119 + .../controller/ControllerConstants.java | 1546 +++ .../server/controller/CustomerController.java | 209 + .../controller/DashboardController.java | 717 ++ .../server/controller/DeviceController.java | 789 ++ .../controller/DeviceProfileController.java | 294 + .../server/controller/EdgeController.java | 598 + .../controller/EdgeEventController.java | 94 + .../EntitiesVersionControlController.java | 519 + .../controller/EntityQueryController.java | 126 + .../controller/EntityRelationController.java | 380 + .../controller/EntityViewController.java | 528 + .../server/controller/EventController.java | 270 + .../controller/HttpValidationCallback.java | 32 + .../server/controller/Lwm2mController.java | 84 + .../OAuth2ConfigTemplateController.java | 96 + .../server/controller/OAuth2Controller.java | 136 + .../controller/OtaPackageController.java | 262 + .../server/controller/QueueController.java | 162 + .../server/controller/RpcV1Controller.java | 70 + .../server/controller/RpcV2Controller.java | 258 + .../controller/RuleChainController.java | 677 + .../controller/SystemInfoController.java | 71 + .../controller/TbResourceController.java | 243 + .../server/controller/TbUrlConstants.java | 25 + .../controller/TelemetryController.java | 990 ++ .../server/controller/TenantController.java | 190 + .../controller/TenantProfileController.java | 271 + .../TwoFactorAuthConfigController.java | 272 + .../controller/TwoFactorAuthController.java | 152 + .../controller/UiSettingsController.java | 46 + .../server/controller/UserController.java | 381 + .../controller/WidgetTypeController.java | 250 + .../controller/WidgetsBundleController.java | 175 + .../controller/plugin/TbWebSocketHandler.java | 491 + .../controller/plugin/TbWebSocketMsg.java | 24 + .../controller/plugin/TbWebSocketMsgType.java | 21 + .../controller/plugin/TbWebSocketPingMsg.java | 38 + .../controller/plugin/TbWebSocketTextMsg.java | 34 + ...ThingsboardCredentialsExpiredResponse.java | 41 + .../exception/ThingsboardErrorResponse.java | 81 + .../ThingsboardErrorResponseHandler.java | 203 + .../ThingsboardInstallConfiguration.java | 36 + .../install/ThingsboardInstallException.java | 30 + .../install/ThingsboardInstallService.java | 299 + .../service/action/EntityActionService.java | 267 + .../service/apiusage/BaseApiUsageState.java | 153 + .../apiusage/CustomerApiUsageState.java | 30 + .../apiusage/DefaultRateLimitService.java | 76 + .../DefaultTbApiUsageStateService.java | 550 + .../service/apiusage/RateLimitService.java | 26 + .../apiusage/TbApiUsageStateService.java | 42 + .../service/apiusage/TenantApiUsageState.java | 102 + .../service/asset/AssetBulkImportService.java | 99 + .../AnnotationComponentDiscoveryService.java | 274 + .../component/ComponentDiscoveryService.java | 38 + .../device/ClaimDevicesServiceImpl.java | 256 + .../device/DeviceBulkImportService.java | 272 + .../device/DeviceProvisionServiceImpl.java | 260 + .../edge/DefaultEdgeNotificationService.java | 255 + .../service/edge/EdgeBulkImportService.java | 98 + .../service/edge/EdgeContextComponent.java | 193 + .../service/edge/EdgeNotificationService.java | 29 + .../edge/rpc/EdgeEventStorageSettings.java | 32 + .../service/edge/rpc/EdgeGrpcService.java | 426 + .../service/edge/rpc/EdgeGrpcSession.java | 694 + .../service/edge/rpc/EdgeRpcService.java | 36 + .../service/edge/rpc/EdgeSessionState.java | 33 + .../service/edge/rpc/EdgeSyncCursor.java | 88 + .../AdminSettingsMsgConstructor.java | 38 + .../rpc/constructor/AlarmMsgConstructor.java | 80 + .../rpc/constructor/AssetMsgConstructor.java | 60 + .../AssetProfileMsgConstructor.java | 62 + .../constructor/CustomerMsgConstructor.java | 72 + .../constructor/DashboardMsgConstructor.java | 50 + .../rpc/constructor/DeviceMsgConstructor.java | 151 + .../DeviceProfileMsgConstructor.java | 78 + .../rpc/constructor/EdgeMsgConstructor.java | 46 + .../constructor/EntityDataMsgConstructor.java | 109 + .../constructor/EntityViewMsgConstructor.java | 68 + .../constructor/OtaPackageMsgConstructor.java | 80 + .../rpc/constructor/QueueMsgConstructor.java | 75 + .../constructor/RelationMsgConstructor.java | 47 + .../constructor/RuleChainMsgConstructor.java | 69 + .../rpc/constructor/UserMsgConstructor.java | 70 + .../constructor/WidgetTypeMsgConstructor.java | 67 + .../WidgetsBundleMsgConstructor.java | 59 + .../AbstractRuleChainMetadataConstructor.java | 137 + .../rule/RuleChainMetadataConstructor.java | 28 + .../RuleChainMetadataConstructorFactory.java | 32 + .../RuleChainMetadataConstructorV330.java | 165 + .../RuleChainMetadataConstructorV340.java | 42 + .../fetch/AdminSettingsEdgeEventFetcher.java | 160 + .../fetch/AssetProfilesEdgeEventFetcher.java | 47 + .../rpc/fetch/AssetsEdgeEventFetcher.java | 47 + .../fetch/BasePageableEdgeEventFetcher.java | 53 + .../rpc/fetch/BaseUsersEdgeEventFetcher.java | 49 + .../BaseWidgetsBundlesEdgeEventFetcher.java | 49 + .../rpc/fetch/CustomerEdgeEventFetcher.java | 49 + .../fetch/CustomerUsersEdgeEventFetcher.java | 38 + .../rpc/fetch/DashboardsEdgeEventFetcher.java | 47 + .../fetch/DeviceProfilesEdgeEventFetcher.java | 47 + .../rpc/fetch/DevicesEdgeEventFetcher.java | 47 + .../edge/rpc/fetch/EdgeEventFetcher.java | 29 + .../fetch/EntityViewsEdgeEventFetcher.java | 47 + .../rpc/fetch/GeneralEdgeEventFetcher.java | 49 + .../fetch/OtaPackagesEdgeEventFetcher.java | 47 + .../rpc/fetch/QueuesEdgeEventFetcher.java | 47 + .../rpc/fetch/RuleChainsEdgeEventFetcher.java | 47 + .../SystemWidgetsBundlesEdgeEventFetcher.java | 36 + .../TenantAdminUsersEdgeEventFetcher.java | 34 + .../TenantWidgetsBundlesEdgeEventFetcher.java | 37 + .../processor/AdminSettingsEdgeProcessor.java | 42 + .../rpc/processor/AlarmEdgeProcessor.java | 195 + .../rpc/processor/AssetEdgeProcessor.java | 79 + .../processor/AssetProfileEdgeProcessor.java | 70 + .../edge/rpc/processor/BaseEdgeProcessor.java | 465 + .../rpc/processor/CustomerEdgeProcessor.java | 107 + .../rpc/processor/DashboardEdgeProcessor.java | 73 + .../rpc/processor/DeviceEdgeProcessor.java | 502 + .../processor/DeviceProfileEdgeProcessor.java | 69 + .../edge/rpc/processor/EdgeProcessor.java | 114 + .../processor/EntityViewEdgeProcessor.java | 73 + .../processor/OtaPackageEdgeProcessor.java | 69 + .../rpc/processor/QueueEdgeProcessor.java | 69 + .../rpc/processor/RelationEdgeProcessor.java | 153 + .../rpc/processor/RuleChainEdgeProcessor.java | 99 + .../rpc/processor/TelemetryEdgeProcessor.java | 348 + .../edge/rpc/processor/UserEdgeProcessor.java | 76 + .../processor/WidgetBundleEdgeProcessor.java | 69 + .../processor/WidgetTypeEdgeProcessor.java | 69 + .../rpc/sync/DefaultEdgeRequestsService.java | 403 + .../edge/rpc/sync/EdgeRequestsService.java | 44 + .../entitiy/AbstractTbEntityService.java | 131 + .../DefaultTbNotificationEntityService.java | 327 + .../entitiy/SimpleTbEntityService.java | 30 + .../entitiy/TbNotificationEntityService.java | 112 + .../entitiy/alarm/DefaultTbAlarmService.java | 86 + .../service/entitiy/alarm/TbAlarmService.java | 32 + .../entitiy/asset/DefaultTbAssetService.java | 178 + .../service/entitiy/asset/TbAssetService.java | 43 + .../profile/DefaultTbAssetProfileService.java | 110 + .../asset/profile/TbAssetProfileService.java | 26 + .../customer/DefaultTbCustomerService.java | 67 + .../entitiy/customer/TbCustomerService.java | 23 + .../dashboard/DefaultTbDashboardService.java | 293 + .../entitiy/dashboard/TbDashboardService.java | 50 + .../device/DefaultTbDeviceService.java | 283 + .../entitiy/device/TbDeviceService.java | 59 + .../DefaultTbDeviceProfileService.java | 119 + .../profile/TbDeviceProfileService.java | 26 + .../entitiy/edge/DefaultTbEdgeService.java | 150 + .../service/entitiy/edge/TbEdgeService.java | 39 + .../DefaultTbEntityRelationService.java | 85 + .../relation/TbEntityRelationService.java | 33 + .../DefaultTbEntityViewService.java | 450 + .../entityview/TbEntityViewService.java | 52 + .../ota/DefaultTbOtaPackageService.java | 114 + .../entitiy/ota/TbOtaPackageService.java | 33 + .../entitiy/queue/DefaultTbQueueService.java | 227 + .../service/entitiy/queue/TbQueueService.java | 34 + .../tenant/DefaultTbTenantService.java | 76 + .../entitiy/tenant/TbTenantService.java | 26 + .../DefaultTbTenantProfileService.java | 65 + .../profile/TbTenantProfileService.java | 26 + .../entitiy/user/DefaultUserService.java | 92 + .../service/entitiy/user/TbUserService.java | 29 + .../bundle/DefaultWidgetsBundleService.java | 50 + .../bundle/TbWidgetsBundleService.java | 27 + .../executors/DbCallbackExecutorService.java | 33 + .../ExternalCallExecutorService.java | 34 + .../GrpcCallbackExecutorService.java | 33 + .../SharedEventLoopGroupService.java | 47 + .../DefaultGatewayNotificationsService.java | 106 + .../GatewayNotificationsService.java | 25 + ...stractCassandraDatabaseUpgradeService.java | 48 + .../AbstractSqlTsDatabaseUpgradeService.java | 117 + ...assandraAbstractDatabaseSchemaService.java | 75 + .../install/CassandraKeyspaceService.java | 37 + .../CassandraTsDatabaseSchemaService.java | 30 + .../CassandraTsDatabaseUpgradeService.java | 61 + ...assandraTsLatestDatabaseSchemaService.java | 30 + .../DatabaseEntitiesUpgradeService.java | 22 + .../service/install/DatabaseHelper.java | 115 + .../install/DatabaseSchemaService.java | 26 + .../install/DatabaseTsUpgradeService.java | 22 + .../install/DbUpgradeExecutorService.java | 26 + .../DefaultSystemDataLoaderService.java | 674 + .../install/EntityDatabaseSchemaService.java | 19 + .../service/install/InstallScripts.java | 263 + .../service/install/NoSqlKeyspaceService.java | 19 + .../SqlAbstractDatabaseSchemaService.java | 97 + .../install/SqlDatabaseUpgradeService.java | 754 ++ .../SqlEntityDatabaseSchemaService.java | 42 + .../install/SqlTsDatabaseSchemaService.java | 40 + .../install/SqlTsDatabaseUpgradeService.java | 260 + .../install/SystemDataLoaderService.java | 41 + .../TbRuleEngineQueueConfigService.java | 48 + .../TimescaleTsDatabaseSchemaService.java | 43 + .../TimescaleTsDatabaseUpgradeService.java | 217 + .../install/TsDatabaseSchemaService.java | 19 + .../TsLatestDatabaseSchemaService.java | 19 + .../install/cql/CQLStatementsParser.java | 168 + .../install/cql/CassandraDbHelper.java | 218 + .../CassandraEntitiesToSqlMigrateService.java | 327 + .../install/migrate/CassandraToSqlColumn.java | 179 + .../migrate/CassandraToSqlColumnData.java | 64 + .../migrate/CassandraToSqlColumnType.java | 28 + .../migrate/CassandraToSqlEventTsColumn.java | 40 + .../install/migrate/CassandraToSqlTable.java | 304 + .../CassandraTsLatestToSqlMigrateService.java | 233 + .../migrate/EntitiesMigrateService.java | 22 + .../migrate/TsLatestMigrateService.java | 21 + .../service/install/sql/SqlDbHelper.java | 176 + .../install/update/CacheCleanupService.java | 22 + .../install/update/DataUpdateService.java | 22 + .../update/DefaultCacheCleanupService.java | 112 + .../update/DefaultDataUpdateService.java | 683 + .../install/update/PaginatedUpdater.java | 66 + .../install/update/RateLimitsUpdater.java | 115 + .../server/service/lwm2m/LwM2MService.java | 24 + .../service/lwm2m/LwM2MServiceImpl.java | 99 + .../service/mail/DefaultMailService.java | 510 + .../service/mail/MailExecutorService.java | 33 + .../mail/PasswordResetExecutorService.java | 33 + .../ota/DefaultOtaPackageStateService.java | 373 + .../service/ota/OtaPackageStateService.java | 30 + .../AbstractPartitionBasedService.java | 187 + .../profile/DefaultTbAssetProfileCache.java | 162 + .../profile/DefaultTbDeviceProfileCache.java | 162 + .../service/profile/TbAssetProfileCache.java | 33 + .../service/profile/TbDeviceProfileCache.java | 33 + .../query/DefaultEntityQueryService.java | 318 + .../service/query/EntityQueryService.java | 40 + .../queue/DefaultQueueRoutingInfoService.java | 44 + .../queue/DefaultTbClusterService.java | 608 + .../queue/DefaultTbCoreConsumerService.java | 581 + .../DefaultTbRuleEngineConsumerService.java | 495 + .../DefaultTenantRoutingInfoService.java | 52 + .../service/queue/TbCoreConsumerService.java | 23 + .../service/queue/TbCoreConsumerStats.java | 147 + .../service/queue/TbMsgPackCallback.java | 85 + .../queue/TbMsgPackProcessingContext.java | 165 + .../service/queue/TbMsgProfilerInfo.java | 85 + .../server/service/queue/TbPackCallback.java | 44 + .../queue/TbPackProcessingContext.java | 90 + .../queue/TbRuleEngineConsumerService.java | 23 + .../queue/TbRuleEngineConsumerStats.java | 167 + .../service/queue/TbRuleNodeProfilerInfo.java | 75 + .../queue/TbTenantRuleEngineStats.java | 92 + .../TbTopicWithConsumerPerPartition.java | 43 + .../processing/AbstractConsumerService.java | 230 + .../AbstractTbRuleEngineSubmitStrategy.java | 71 + .../BatchTbRuleEngineSubmitStrategy.java | 87 + .../BurstTbRuleEngineSubmitStrategy.java | 44 + .../service/queue/processing/IdMsgPair.java | 33 + ...lByEntityIdTbRuleEngineSubmitStrategy.java | 100 + ...riginatorIdTbRuleEngineSubmitStrategy.java | 44 + ...lByTenantIdTbRuleEngineSubmitStrategy.java | 34 + .../SequentialTbRuleEngineSubmitStrategy.java | 71 + .../TbRuleEngineProcessingDecision.java | 31 + .../TbRuleEngineProcessingResult.java | 62 + .../TbRuleEngineProcessingStrategy.java | 24 + ...TbRuleEngineProcessingStrategyFactory.java | 175 + .../TbRuleEngineSubmitStrategy.java | 39 + .../TbRuleEngineSubmitStrategyFactory.java | 44 + .../resource/DefaultTbResourceService.java | 249 + .../service/resource/TbResourceService.java | 55 + .../rpc/DefaultTbCoreDeviceRpcService.java | 215 + .../rpc/DefaultTbRuleEngineRpcService.java | 193 + .../rpc/FromDeviceRpcResponseActorMsg.java | 45 + .../service/rpc/LocalRequestMetaData.java | 32 + .../server/service/rpc/RemoveRpcActorMsg.java | 44 + .../service/rpc/TbCoreDeviceRpcService.java | 61 + .../server/service/rpc/TbRpcService.java | 77 + .../rpc/TbRuleEngineDeviceRpcService.java | 32 + .../rpc/ToDeviceRpcRequestActorMsg.java | 55 + .../rule/DefaultTbRuleChainService.java | 444 + .../service/rule/TbRuleChainService.java | 57 + .../script/RuleNodeJsScriptEngine.java | 152 + .../service/script/RuleNodeScriptEngine.java | 133 + .../script/RuleNodeTbelScriptEngine.java | 171 + .../service/security/AccessValidator.java | 589 + .../service/security/ValidationCallback.java | 74 + .../service/security/ValidationResult.java | 49 + .../security/ValidationResultCode.java | 27 + .../auth/AbstractJwtAuthenticationToken.java | 66 + .../auth/DefaultTokenOutdatingService.java | 71 + .../security/auth/JwtAuthenticationToken.java | 32 + .../security/auth/MfaAuthenticationToken.java | 24 + .../auth/RefreshAuthenticationToken.java | 32 + .../security/auth/TokenOutdatingService.java | 25 + .../auth/jwt/JwtAuthenticationProvider.java | 53 + ...wtTokenAuthenticationProcessingFilter.java | 70 + .../RefreshTokenAuthenticationProvider.java | 137 + .../jwt/RefreshTokenProcessingFilter.java | 93 + .../auth/jwt/RefreshTokenRequest.java | 32 + .../auth/jwt/SkipPathRequestMatcher.java | 45 + .../extractor/JwtHeaderTokenExtractor.java | 45 + .../jwt/extractor/JwtQueryTokenExtractor.java | 43 + .../auth/jwt/extractor/TokenExtractor.java | 22 + .../settings/DefaultJwtSettingsService.java | 162 + .../settings/DefaultJwtSettingsValidator.java | 69 + .../settings/InstallJwtSettingsValidator.java | 39 + .../auth/jwt/settings/JwtSettingsService.java | 35 + .../jwt/settings/JwtSettingsValidator.java | 23 + .../auth/mfa/DefaultTwoFactorAuthService.java | 190 + .../auth/mfa/TwoFactorAuthService.java | 45 + .../mfa/config/DefaultTwoFaConfigManager.java | 187 + .../auth/mfa/config/TwoFaConfigManager.java | 46 + .../auth/mfa/provider/TwoFaProvider.java | 39 + .../impl/BackupCodeTwoFaProvider.java | 73 + .../mfa/provider/impl/EmailTwoFaProvider.java | 68 + .../provider/impl/OtpBasedTwoFaProvider.java | 77 + .../mfa/provider/impl/SmsTwoFaProvider.java | 76 + .../mfa/provider/impl/TotpTwoFaProvider.java | 74 + .../oauth2/AbstractOAuth2ClientMapper.java | 229 + .../auth/oauth2/AppleOAuth2ClientMapper.java | 103 + .../auth/oauth2/BasicMapperUtils.java | 78 + .../auth/oauth2/BasicOAuth2ClientMapper.java | 44 + .../security/auth/oauth2/CookieUtils.java | 72 + .../auth/oauth2/CustomOAuth2ClientMapper.java | 86 + .../auth/oauth2/GithubOAuth2ClientMapper.java | 96 + ...eOAuth2AuthorizationRequestRepository.java | 60 + .../auth/oauth2/OAuth2ClientMapper.java | 26 + .../oauth2/OAuth2ClientMapperProvider.java | 60 + .../Oauth2AuthenticationFailureHandler.java | 75 + .../Oauth2AuthenticationSuccessHandler.java | 140 + .../auth/oauth2/TbOAuth2ParameterNames.java | 22 + .../security/auth/rest/LoginRequest.java | 45 + .../security/auth/rest/LoginResponse.java | 34 + .../auth/rest/PublicLoginRequest.java | 34 + .../auth/rest/RestAuthenticationDetails.java | 53 + .../rest/RestAuthenticationDetailsSource.java | 28 + .../auth/rest/RestAuthenticationProvider.java | 160 + ...RestAwareAuthenticationFailureHandler.java | 44 + ...RestAwareAuthenticationSuccessHandler.java | 87 + .../auth/rest/RestLoginProcessingFilter.java | 98 + .../rest/RestPublicLoginProcessingFilter.java | 95 + .../device/DefaultDeviceAuthService.java | 70 + .../AuthMethodNotSupportedException.java | 26 + .../exception/JwtExpiredTokenException.java | 38 + .../UserPasswordExpiredException.java | 33 + .../security/model/ActivateUserRequest.java | 30 + .../security/model/ChangePasswordRequest.java | 31 + .../model/ResetPasswordEmailRequest.java | 29 + .../security/model/ResetPasswordRequest.java | 30 + .../service/security/model/SecurityUser.java | 84 + .../service/security/model/UserPrincipal.java | 43 + .../security/model/token/AccessJwtToken.java | 31 + .../security/model/token/JwtTokenFactory.java | 223 + .../model/token/OAuth2AppTokenFactory.java | 69 + .../model/token/RawAccessJwtToken.java | 36 + .../permission/AbstractPermissions.java | 32 + .../permission/AccessControlService.java | 29 + .../permission/CustomerUserPermissions.java | 172 + .../DefaultAccessControlService.java | 85 + .../security/permission/Operation.java | 24 + .../permission/PermissionChecker.java | 73 + .../security/permission/Permissions.java | 24 + .../service/security/permission/Resource.java | 70 + .../permission/SysAdminPermissions.java | 69 + .../permission/TenantAdminPermissions.java | 143 + .../system/DefaultSystemSecurityService.java | 323 + .../system/SystemSecurityService.java | 48 + .../DefaultDeviceSessionCacheService.java | 58 + .../session/DeviceSessionCacheService.java | 30 + .../service/session/SessionCaffeineCache.java | 34 + .../service/session/SessionRedisCache.java | 53 + .../server/service/sms/AbstractSmsSender.java | 53 + .../service/sms/DefaultSmsSenderFactory.java | 46 + .../server/service/sms/DefaultSmsService.java | 150 + .../service/sms/SmsExecutorService.java | 33 + .../server/service/sms/aws/AwsSmsSender.java | 86 + .../service/sms/smpp/SmppSmsSender.java | 186 + .../service/sms/twilio/TwilioSmsSender.java | 69 + .../state/DefaultDeviceStateService.java | 798 ++ .../server/service/state/DeviceState.java | 35 + .../server/service/state/DeviceStateData.java | 39 + .../service/state/DeviceStateService.java | 41 + .../service/stats/DefaultJsInvokeStats.java | 84 + .../DefaultRuleEngineStatisticsService.java | 141 + .../stats/RuleEngineStatisticsService.java | 23 + .../DefaultSubscriptionManagerService.java | 603 + ...efaultTbEntityDataSubscriptionService.java | 711 ++ .../DefaultTbLocalSubscriptionService.java | 212 + .../subscription/ReadTsKvQueryInfo.java | 29 + .../SubscriptionManagerService.java | 50 + .../SubscriptionServiceStatistics.java | 31 + .../subscription/TbAbstractDataSubCtx.java | 255 + .../subscription/TbAbstractSubCtx.java | 342 + .../subscription/TbAlarmDataSubCtx.java | 351 + .../subscription/TbAlarmsSubscription.java | 50 + .../subscription/TbAttributeSubscription.java | 52 + .../TbAttributeSubscriptionScope.java | 22 + .../subscription/TbEntityCountSubCtx.java | 56 + .../subscription/TbEntityDataSubCtx.java | 242 + .../TbEntityDataSubscriptionService.java | 37 + .../TbLocalSubscriptionService.java | 39 + .../service/subscription/TbSubscription.java | 54 + .../subscription/TbSubscriptionType.java | 20 + .../subscription/TbSubscriptionUtils.java | 344 + .../TbTimeseriesSubscription.java | 61 + .../DefaultEntitiesExportImportService.java | 172 + .../sync/ie/EntitiesExportImportService.java | 40 + .../DefaultExportableEntitiesService.java | 215 + .../ie/exporting/EntityExportService.java | 28 + .../exporting/ExportableEntitiesService.java | 42 + .../ie/exporting/impl/AssetExportService.java | 43 + .../impl/AssetProfileExportService.java | 43 + .../impl/BaseEntityExportService.java | 50 + .../impl/DashboardExportService.java | 61 + .../impl/DefaultEntityExportService.java | 184 + .../exporting/impl/DeviceExportService.java | 59 + .../impl/DeviceProfileExportService.java | 43 + .../impl/EntityViewExportService.java | 43 + .../impl/RuleChainExportService.java | 76 + .../impl/WidgetsBundleExportService.java | 59 + .../ie/importing/EntityImportService.java | 32 + .../csv/AbstractBulkImportService.java | 321 + .../ie/importing/csv/ImportedEntityInfo.java | 25 + .../ie/importing/impl/AssetImportService.java | 71 + .../impl/AssetProfileImportService.java | 80 + .../impl/BaseEntityImportService.java | 400 + .../importing/impl/CustomerImportService.java | 73 + .../impl/DashboardImportService.java | 130 + .../importing/impl/DeviceImportService.java | 106 + .../impl/DeviceProfileImportService.java | 93 + .../impl/EntityViewImportService.java | 89 + .../impl/ImportServiceException.java | 20 + .../impl/MissingEntityException.java | 30 + .../impl/RuleChainImportService.java | 150 + .../impl/WidgetsBundleImportService.java | 110 + .../DefaultEntitiesVersionControlService.java | 635 + .../DefaultGitVersionControlQueueService.java | 605 + .../vc/EntitiesVersionControlService.java | 78 + .../vc/GitVersionControlQueueService.java | 74 + .../service/sync/vc/LoadEntityException.java | 32 + ...AbstractVersionControlSettingsService.java | 80 + .../sync/vc/VersionControlTaskCacheEntry.java | 43 + .../vc/VersionControlTaskCaffeineCache.java | 36 + .../sync/vc/VersionControlTaskRedisCache.java | 36 + .../AutoCommitSettingsCaffeineCache.java | 34 + .../AutoCommitSettingsRedisCache.java | 36 + .../DefaultTbAutoCommitSettingsService.java | 36 + .../TbAutoCommitSettingsService.java | 30 + .../vc/data/ClearRepositoryGitRequest.java | 30 + .../sync/vc/data/CommitGitRequest.java | 37 + .../vc/data/ComplexEntitiesExportCtx.java | 43 + .../sync/vc/data/ContentsDiffGitRequest.java | 33 + .../vc/data/EntitiesContentGitRequest.java | 36 + .../sync/vc/data/EntitiesExportCtx.java | 88 + .../sync/vc/data/EntitiesImportCtx.java | 143 + .../sync/vc/data/EntityContentGitRequest.java | 34 + .../sync/vc/data/EntityTypeExportCtx.java | 46 + .../sync/vc/data/ListBranchesGitRequest.java | 29 + .../sync/vc/data/ListEntitiesGitRequest.java | 29 + .../sync/vc/data/ListVersionsGitRequest.java | 28 + .../sync/vc/data/PendingGitRequest.java | 46 + .../service/sync/vc/data/ReimportTask.java | 28 + .../sync/vc/data/SimpleEntitiesExportCtx.java | 36 + .../sync/vc/data/VersionsDiffGitRequest.java | 38 + .../service/sync/vc/data/VoidGitRequest.java | 26 + .../DefaultTbRepositorySettingsService.java | 63 + .../RepositorySettingsCaffeineCache.java | 34 + .../RepositorySettingsRedisCache.java | 36 + .../TbRepositorySettingsService.java | 31 + .../AbstractSubscriptionService.java | 101 + .../telemetry/AlarmSubscriptionService.java | 27 + .../service/telemetry/AttributeData.java | 55 + .../DefaultAlarmSubscriptionService.java | 219 + .../DefaultTelemetrySubscriptionService.java | 499 + .../DefaultTelemetryWebSocketService.java | 943 ++ .../telemetry/InternalTelemetryService.java | 46 + .../service/telemetry/SessionEvent.java | 53 + .../service/telemetry/TelemetryFeature.java | 29 + .../TelemetrySubscriptionService.java | 26 + .../TelemetryWebSocketMsgEndpoint.java | 32 + .../telemetry/TelemetryWebSocketService.java | 37 + .../TelemetryWebSocketSessionRef.java | 72 + .../telemetry/TelemetryWebSocketTextMsg.java | 29 + .../server/service/telemetry/TsData.java | 48 + .../service/telemetry/WsSessionMetaData.java | 52 + .../cmd/TelemetryPluginCmdsWrapper.java | 55 + .../cmd/v1/AttributesSubscriptionCmd.java | 32 + .../telemetry/cmd/v1/GetHistoryCmd.java | 40 + .../telemetry/cmd/v1/SubscriptionCmd.java | 42 + .../telemetry/cmd/v1/TelemetryPluginCmd.java | 29 + .../cmd/v1/TimeseriesSubscriptionCmd.java | 41 + .../telemetry/cmd/v2/AggHistoryCmd.java | 29 + .../service/telemetry/cmd/v2/AggKey.java | 32 + .../telemetry/cmd/v2/AggTimeSeriesCmd.java | 29 + .../telemetry/cmd/v2/AlarmDataCmd.java | 33 + .../cmd/v2/AlarmDataUnsubscribeCmd.java | 25 + .../telemetry/cmd/v2/AlarmDataUpdate.java | 63 + .../service/telemetry/cmd/v2/CmdUpdate.java | 33 + .../telemetry/cmd/v2/CmdUpdateType.java | 22 + .../service/telemetry/cmd/v2/DataCmd.java | 32 + .../service/telemetry/cmd/v2/DataUpdate.java | 45 + .../telemetry/cmd/v2/EntityCountCmd.java | 35 + .../cmd/v2/EntityCountUnsubscribeCmd.java | 25 + .../telemetry/cmd/v2/EntityCountUpdate.java | 57 + .../telemetry/cmd/v2/EntityDataCmd.java | 65 + .../cmd/v2/EntityDataUnsubscribeCmd.java | 25 + .../telemetry/cmd/v2/EntityDataUpdate.java | 57 + .../telemetry/cmd/v2/EntityHistoryCmd.java | 34 + .../service/telemetry/cmd/v2/GetTsCmd.java | 38 + .../telemetry/cmd/v2/LatestValueCmd.java | 28 + .../telemetry/cmd/v2/TimeSeriesCmd.java | 40 + .../telemetry/cmd/v2/UnsubscribeCmd.java | 22 + .../exception/AccessDeniedException.java | 31 + .../exception/EntityNotFoundException.java | 31 + .../exception/InternalErrorException.java | 31 + .../exception/InvalidParametersException.java | 31 + .../exception/ToErrorResponseEntity.java | 26 + .../exception/UnauthorizedException.java | 34 + .../exception/UncheckedApiException.java | 36 + .../sub/AlarmSubscriptionUpdate.java | 70 + .../telemetry/sub/SubscriptionErrorCode.java | 50 + .../telemetry/sub/SubscriptionState.java | 72 + .../sub/TelemetrySubscriptionUpdate.java | 99 + .../BasicCredentialsValidationResult.java | 18 + .../DefaultTbCoreToTransportService.java | 93 + .../transport/DefaultTransportApiService.java | 667 + .../transport/TbCoreToTransportService.java | 28 + .../transport/TbCoreTransportApiService.java | 111 + .../transport/TransportApiService.java | 27 + .../msg/TransportToDeviceActorMsgWrapper.java | 55 + .../service/ttl/AbstractCleanUpService.java | 43 + .../service/ttl/AlarmsCleanUpService.java | 105 + .../service/ttl/AuditLogsCleanUpService.java | 61 + .../service/ttl/EdgeEventsCleanUpService.java | 70 + .../service/ttl/EventsCleanUpService.java | 61 + .../service/ttl/TimeseriesCleanUpService.java | 52 + .../service/ttl/rpc/RpcCleanUpService.java | 83 + .../service/update/DefaultUpdateService.java | 139 + .../server/service/update/UpdateService.java | 24 + ...ngfoxHandlerProviderBeanPostProcessor.java | 60 + .../thingsboard/server/utils/CsvUtils.java | 46 + .../utils/EventDeduplicationExecutor.java | 85 + .../thingsboard/server/utils/MiscUtils.java | 109 + .../server/utils/TypeCastUtil.java | 55 + application/src/main/resources/banner.txt | 10 + .../main/resources/i18n/messages.properties | 8 + application/src/main/resources/logback.xml | 61 + .../templates/2fa.verification.code.ftl | 117 + .../resources/templates/account.activated.ftl | 124 + .../resources/templates/account.lockout.ftl | 114 + .../main/resources/templates/activation.ftl | 124 + .../templates/password.was.reset.ftl | 124 + .../resources/templates/reset.password.ftl | 124 + .../resources/templates/state.disabled.ftl | 145 + .../resources/templates/state.enabled.ftl | 142 + .../resources/templates/state.warning.ftl | 145 + .../src/main/resources/templates/test.ftl | 114 + .../src/main/resources/thingsboard.yml | 1206 ++ .../DeviceActorMessageProcessorTest.java | 58 + .../server/actors/stats/StatsActorTest.java | 70 + .../actors/stats/StatsPersistMsgTest.java | 38 + ...CaffeineCacheDefaultConfigurationTest.java | 55 + .../controller/AbstractControllerTest.java | 90 + .../AbstractInMemoryStorageTest.java | 26 + .../controller/AbstractNotifyEntityTest.java | 621 + .../AbstractRuleEngineControllerTest.java | 88 + .../server/controller/AbstractWebTest.java | 778 ++ .../controller/BaseAdminControllerTest.java | 194 + .../controller/BaseAlarmControllerTest.java | 474 + .../controller/BaseAssetControllerTest.java | 960 ++ .../BaseAssetProfileControllerTest.java | 463 + .../BaseAuditLogControllerTest.java | 232 + .../controller/BaseAuthControllerTest.java | 85 + ...BaseComponentDescriptorControllerTest.java | 96 + .../BaseCustomerControllerTest.java | 442 + .../BaseDashboardControllerTest.java | 504 + .../controller/BaseDeviceControllerTest.java | 1360 ++ .../BaseDeviceProfileControllerTest.java | 1004 ++ .../controller/BaseEdgeControllerTest.java | 879 ++ .../BaseEdgeEventControllerTest.java | 206 + .../BaseEntityQueryControllerTest.java | 406 + .../BaseEntityRelationControllerTest.java | 631 + .../BaseEntityViewControllerTest.java | 820 ++ .../BaseOtaPackageControllerTest.java | 432 + .../controller/BaseRpcControllerTest.java | 196 + .../BaseRuleChainControllerTest.java | 293 + .../BaseTbResourceControllerTest.java | 360 + .../controller/BaseTenantControllerTest.java | 590 + .../BaseTenantProfileControllerTest.java | 389 + .../controller/BaseUserControllerTest.java | 755 ++ .../controller/BaseWebsocketApiTest.java | 602 + .../BaseWidgetTypeControllerTest.java | 246 + .../BaseWidgetsBundleControllerTest.java | 399 + .../controller/TbTestWebSocketClient.java | 239 + .../controller/TwoFactorAuthConfigTest.java | 475 + .../server/controller/TwoFactorAuthTest.java | 446 + .../sql/AdminControllerSqlTest.java | 26 + .../sql/AlarmControllerSqlTest.java | 23 + .../sql/AssetControllerSqlTest.java | 26 + .../sql/AssetProfileControllerSqlTest.java | 23 + .../sql/AuditLogControllerSqlTest.java | 23 + .../controller/sql/AuthControllerSqlTest.java | 26 + .../ComponentDescriptorControllerSqlTest.java | 23 + .../sql/CustomerControllerSqlTest.java | 26 + .../sql/DashboardControllerSqlTest.java | 26 + .../sql/DeviceControllerSqlTest.java | 26 + .../sql/DeviceProfileControllerSqlTest.java | 23 + .../controller/sql/EdgeControllerSqlTest.java | 23 + .../sql/EdgeEventControllerSqlTest.java | 23 + .../sql/EntityQueryControllerSqlTest.java | 23 + .../sql/EntityRelationControllerSqlTest.java | 23 + .../sql/EntityViewControllerSqlTest.java | 31 + .../sql/OtaPackageControllerSqlTest.java | 23 + .../controller/sql/RpcControllerTest.java | 23 + .../sql/RuleChainControllerSqlTest.java | 23 + .../sql/TbResourceControllerSqlTest.java | 23 + .../sql/TenantControllerSqlTest.java | 26 + .../sql/TenantProfileControllerSqlTest.java | 23 + .../sql/TwoFactorAuthConfigSqlTest.java | 23 + .../controller/sql/TwoFactorAuthSqlTest.java | 23 + .../controller/sql/UserControllerSqlTest.java | 26 + .../controller/sql/WebsocketApiSqlTest.java | 23 + .../sql/WidgetTypeControllerSqlTest.java | 26 + .../sql/WidgetsBundleControllerSqlTest.java | 26 + .../server/edge/AbstractEdgeTest.java | 587 + .../server/edge/BaseAlarmEdgeTest.java | 136 + .../server/edge/BaseAssetEdgeTest.java | 157 + .../server/edge/BaseAssetProfileEdgeTest.java | 70 + .../server/edge/BaseCustomerEdgeTest.java | 87 + .../server/edge/BaseDashboardEdgeTest.java | 150 + .../server/edge/BaseDeviceEdgeTest.java | 643 + .../edge/BaseDeviceProfileEdgeTest.java | 319 + .../thingsboard/server/edge/BaseEdgeTest.java | 78 + .../server/edge/BaseEntityViewEdgeTest.java | 176 + .../server/edge/BaseOtaPackageEdgeTest.java | 151 + .../server/edge/BaseQueueEdgeTest.java | 107 + .../server/edge/BaseRelationEdgeTest.java | 172 + .../server/edge/BaseRuleChainEdgeTest.java | 213 + .../server/edge/BaseTelemetryEdgeTest.java | 236 + .../server/edge/BaseUserEdgeTest.java | 237 + .../server/edge/BaseWidgetEdgeTest.java | 118 + .../server/edge/imitator/EdgeImitator.java | 379 + .../server/edge/sql/AlarmEdgeSqlTest.java | 24 + .../server/edge/sql/AssetEdgeSqlTest.java | 24 + .../edge/sql/AssetProfileEdgeSqlTest.java | 24 + .../server/edge/sql/CustomerEdgeSqlTest.java | 24 + .../server/edge/sql/DashboardEdgeSqlTest.java | 24 + .../server/edge/sql/DeviceEdgeSqlTest.java | 24 + .../edge/sql/DeviceProfileEdgeSqlTest.java | 24 + .../server/edge/sql/EdgeSqlTest.java | 24 + .../edge/sql/EntityViewEdgeSqlTest.java | 24 + .../edge/sql/OtaPackageEdgeSqlTest.java | 24 + .../server/edge/sql/QueueEdgeSqlTest.java | 24 + .../server/edge/sql/RelationEdgeSqlTest.java | 24 + .../server/edge/sql/RuleChainEdgeSqlTest.java | 24 + .../server/edge/sql/TelemetryEdgeSqlTest.java | 24 + .../server/edge/sql/UserEdgeSqlTest.java | 24 + .../server/edge/sql/WidgetEdgeSqlTest.java | 24 + .../discovery/HashPartitionServiceTest.java | 166 + ...AbstractRuleEngineFlowIntegrationTest.java | 339 + .../sql/RuleEngineFlowSqlIntegrationTest.java | 26 + ...actRuleEngineLifecycleIntegrationTest.java | 173 + ...RuleEngineLifecycleSqlIntegrationTest.java | 26 + .../DefaultTbApiUsageStateServiceTest.java | 86 + .../RuleChainMsgConstructorTest.java | 412 + .../alarm/DefaultTbAlarmServiceTest.java | 115 + .../SqlEntityDatabaseSchemaServiceTest.java | 54 + .../update/DefaultDataUpdateServiceTest.java | 97 + .../server/service/mail/TestMailService.java | 58 + .../queue/DefaultTbClusterServiceTest.java | 247 + .../queue/TbMsgPackProcessingContextTest.java | 102 + .../sql/BaseTbResourceServiceTest.java | 448 + .../service/script/MockJsInvokeService.java | 52 + .../script/NashornJsInvokeServiceTest.java | 126 + .../script/RemoteJsInvokeServiceTest.java | 221 + .../service/script/TbelInvokeServiceTest.java | 219 + .../security/auth/JwtTokenFactoryTest.java | 171 + .../security/auth/TokenOutdatingTest.java | 244 + ...auth2AuthenticationSuccessHandlerTest.java | 68 + .../service/sms/smpp/SmppSmsSenderTest.java | 116 + .../SequentialTimeseriesPersistenceTest.java | 203 + .../state/DefaultDeviceStateServiceTest.java | 132 + .../sync/ie/BaseExportImportServiceTest.java | 472 + .../sync/ie/ExportImportServiceSqlTest.java | 620 + .../service/ttl/EventsCleanUpServiceTest.java | 49 + .../server/system/BaseHttpDeviceApiTest.java | 88 + .../server/system/BaseRestApiLimitsTest.java | 196 + .../server/system/sql/DeviceApiSqlTest.java | 26 + .../system/sql/RestApiLimitsSqlTest.java | 24 + .../AbstractTransportIntegrationTest.java | 167 + .../transport/TransportNoSqlTestSuite.java | 44 + .../coap/AbstractCoapIntegrationTest.java | 155 + .../transport/coap/CoapTestCallback.java | 68 + .../server/transport/coap/CoapTestClient.java | 118 + .../coap/CoapTestConfigProperties.java | 46 + ...AbstractCoapAttributesIntegrationTest.java | 423 + .../CoapAttributesRequestIntegrationTest.java | 47 + ...pAttributesRequestJsonIntegrationTest.java | 50 + ...AttributesRequestProtoIntegrationTest.java | 46 + .../CoapAttributesUpdatesIntegrationTest.java | 71 + ...pAttributesUpdatesJsonIntegrationTest.java | 56 + ...AttributesUpdatesProtoIntegrationTest.java | 56 + .../coap/claim/CoapClaimDeviceTest.java | 152 + .../coap/claim/CoapClaimJsonDeviceTest.java | 56 + .../coap/claim/CoapClaimProtoDeviceTest.java | 89 + .../CoapProvisionJsonDeviceTest.java | 237 + .../CoapProvisionProtoDeviceTest.java | 252 + ...tractCoapServerSideRpcIntegrationTest.java | 259 + ...apServerSideRpcDefaultIntegrationTest.java | 95 + .../CoapServerSideRpcJsonIntegrationTest.java | 55 + ...CoapServerSideRpcProtoIntegrationTest.java | 56 + .../CoapAttributesIntegrationTest.java | 170 + .../CoapAttributesJsonIntegrationTest.java | 50 + .../CoapAttributesProtoIntegrationTest.java | 132 + ...AbstractCoapTimeseriesIntegrationTest.java | 207 + ...ractCoapTimeseriesJsonIntegrationTest.java | 56 + ...actCoapTimeseriesProtoIntegrationTest.java | 318 + .../CoapTimeseriesNoSqlIntegrationTest.java | 23 + ...oapTimeseriesNoSqlJsonIntegrationTest.java | 23 + ...apTimeseriesNoSqlProtoIntegrationTest.java | 23 + .../sql/CoapTimeseriesSqlIntegrationTest.java | 26 + .../CoapTimeseriesSqlJsonIntegrationTest.java | 26 + ...CoapTimeseriesSqlProtoIntegrationTest.java | 26 + .../lwm2m/AbstractLwM2MIntegrationTest.java | 425 + .../transport/lwm2m/Lwm2mTestHelper.java | 132 + .../transport/lwm2m/client/FwLwM2MDevice.java | 156 + .../lwm2m/client/LwM2MLocationParams.java | 41 + .../lwm2m/client/LwM2MTestClient.java | 324 + .../client/LwM2mBinaryAppDataContainer.java | 230 + .../transport/lwm2m/client/LwM2mLocation.java | 151 + .../lwm2m/client/LwM2mTemperatureSensor.java | 129 + .../transport/lwm2m/client/Lwm2mServer.java | 235 + .../lwm2m/client/SimpleLwM2MDevice.java | 221 + .../transport/lwm2m/client/SwLwM2MDevice.java | 157 + .../ota/AbstractOtaLwM2MIntegrationTest.java | 79 + .../ota/sql/OtaLwM2MIntegrationTest.java | 196 + .../rpc/AbstractRpcLwM2MIntegrationTest.java | 162 + .../sql/RpcLwm2mIntegrationCreateTest.java | 133 + .../sql/RpcLwm2mIntegrationDeleteTest.java | 96 + .../sql/RpcLwm2mIntegrationDiscoverTest.java | 165 + .../sql/RpcLwm2mIntegrationExecuteTest.java | 185 + .../sql/RpcLwm2mIntegrationObserveTest.java | 200 + .../rpc/sql/RpcLwm2mIntegrationReadTest.java | 245 + ...pcLwm2mIntegrationWriteAttributesTest.java | 56 + .../rpc/sql/RpcLwm2mIntegrationWriteTest.java | 334 + .../AbstractSecurityLwM2MIntegrationTest.java | 445 + .../sql/NoSecLwM2MIntegrationTest.java | 95 + .../security/sql/PskLwm2mIntegrationTest.java | 114 + .../security/sql/RpkLwM2MIntegrationTest.java | 131 + .../sql/X509_NoTrustLwM2MIntegrationTest.java | 131 + .../sql/X509_TrustLwM2MIntegrationTest.java | 91 + .../LwM2mTransportServerHelperTest.java | 130 + .../mqtt/AbstractMqttIntegrationTest.java | 179 + .../transport/mqtt/MqttTestCallback.java | 88 + .../server/transport/mqtt/MqttTestClient.java | 126 + .../mqtt/MqttTestConfigProperties.java | 48 + ...AbstractMqttAttributesIntegrationTest.java | 632 + ...tBackwardCompatibilityIntegrationTest.java | 125 + .../MqttAttributesRequestIntegrationTest.java | 58 + ...tAttributesRequestJsonIntegrationTest.java | 60 + ...AttributesRequestProtoIntegrationTest.java | 103 + ...sBackwardCompatibilityIntegrationTest.java | 104 + .../MqttAttributesUpdatesIntegrationTest.java | 64 + ...tAttributesUpdatesJsonIntegrationTest.java | 63 + ...AttributesUpdatesProtoIntegrationTest.java | 70 + ...tClaimBackwardCompatibilityDeviceTest.java | 56 + .../mqtt/claim/MqttClaimDeviceTest.java | 244 + .../mqtt/claim/MqttClaimJsonDeviceTest.java | 59 + .../mqtt/claim/MqttClaimProtoDeviceTest.java | 92 + .../credentials/BasicMqttCredentialsTest.java | 199 + .../MqttProvisionJsonDeviceTest.java | 285 + .../MqttProvisionProtoDeviceTest.java | 299 + ...tractMqttServerSideRpcIntegrationTest.java | 460 + ...cBackwardCompatibilityIntegrationTest.java | 162 + ...ttServerSideRpcDefaultIntegrationTest.java | 128 + .../MqttServerSideRpcJsonIntegrationTest.java | 93 + ...MqttServerSideRpcProtoIntegrationTest.java | 84 + .../MqttAttributesIntegrationTest.java | 249 + .../MqttAttributesJsonIntegrationTest.java | 65 + .../MqttAttributesProtoIntegrationTest.java | 223 + ...AbstractMqttTimeseriesIntegrationTest.java | 338 + ...ractMqttTimeseriesJsonIntegrationTest.java | 189 + ...actMqttTimeseriesProtoIntegrationTest.java | 530 + .../MqttTimeseriesNoSqlIntegrationTest.java | 23 + ...qttTimeseriesNoSqlJsonIntegrationTest.java | 23 + ...ttTimeseriesNoSqlProtoIntegrationTest.java | 23 + .../sql/MqttTimeseriesSqlIntegrationTest.java | 23 + .../MqttTimeseriesSqlJsonIntegrationTest.java | 23 + ...MqttTimeseriesSqlProtoIntegrationTest.java | 23 + .../util/EventDeduplicationExecutorTest.java | 173 + .../resources/application-test.properties | 65 + .../src/test/resources/logback-test.xml | 26 + application/src/test/resources/lwm2m/0.xml | 405 + application/src/test/resources/lwm2m/1.xml | 360 + application/src/test/resources/lwm2m/19.xml | 144 + application/src/test/resources/lwm2m/2.xml | 123 + application/src/test/resources/lwm2m/3.xml | 331 + application/src/test/resources/lwm2m/3303.xml | 103 + application/src/test/resources/lwm2m/5.xml | 204 + application/src/test/resources/lwm2m/6.xml | 143 + application/src/test/resources/lwm2m/9.xml | 321 + .../lwm2m/credentials/lwm2mclient.jks | Bin 0 -> 20462 bytes .../lwm2m/credentials/lwm2mserver.jks | Bin 0 -> 6448 bytes .../credentials/lwm2mtruststorechain.jks | Bin 0 -> 2982 bytes .../org.mockito.plugins.MockMaker | 1 + .../src/test/resources/update/330/README.md | 3 + .../update/330/device_profile_001_in.json | 173 + .../update/330/device_profile_001_out.json | 189 + common/actor/pom.xml | 80 + .../server/actors/AbstractTbActor.java | 34 + .../server/actors/DefaultTbActorSystem.java | 215 + .../thingsboard/server/actors/Dispatcher.java | 28 + .../server/actors/InitFailureStrategy.java | 45 + .../server/actors/JsInvokeStats.java | 44 + .../server/actors/ProcessFailureStrategy.java | 38 + .../thingsboard/server/actors/TbActor.java | 43 + .../server/actors/TbActorCreator.java | 24 + .../thingsboard/server/actors/TbActorCtx.java | 44 + .../server/actors/TbActorException.java | 25 + .../thingsboard/server/actors/TbActorId.java | 30 + .../server/actors/TbActorMailbox.java | 243 + .../actors/TbActorNotRegisteredException.java | 29 + .../thingsboard/server/actors/TbActorRef.java | 28 + .../server/actors/TbActorSystem.java | 54 + .../server/actors/TbActorSystemSettings.java | 27 + .../server/actors/TbEntityActorId.java | 55 + .../actors/TbRuleNodeUpdateException.java | 26 + .../server/actors/TbStringActorId.java | 52 + .../server/actors/ActorSystemTest.java | 259 + .../server/actors/ActorTestCtx.java | 39 + .../server/actors/FailedToInitActor.java | 72 + .../server/actors/IntTbActorMsg.java | 35 + .../server/actors/SlowCreateActor.java | 60 + .../server/actors/SlowInitActor.java | 57 + .../server/actors/TestRootActor.java | 87 + common/actor/src/test/resources/logback.xml | 14 + common/cache/pom.xml | 124 + .../thingsboard/server/cache/CacheSpecs.java | 24 + .../server/cache/CacheSpecsMap.java | 49 + .../cache/CaffeineTbCacheTransaction.java | 59 + .../cache/CaffeineTbTransactionalCache.java | 193 + .../server/cache/RedisTbCacheTransaction.java | 58 + .../cache/RedisTbTransactionalCache.java | 203 + .../cache/SimpleTbCacheValueWrapper.java | 46 + .../cache/TBRedisCacheConfiguration.java | 129 + .../cache/TBRedisClusterConfiguration.java | 77 + .../cache/TBRedisStandaloneConfiguration.java | 88 + .../server/cache/TbCacheTransaction.java | 26 + .../server/cache/TbCacheValueWrapper.java | 22 + .../cache/TbCaffeineCacheConfiguration.java | 99 + .../server/cache/TbFSTRedisSerializer.java | 32 + .../server/cache/TbRedisSerializer.java | 29 + .../server/cache/TbTransactionalCache.java | 95 + .../cache/device/DeviceCacheEvictEvent.java | 30 + .../server/cache/device/DeviceCacheKey.java | 54 + .../cache/device/DeviceCaffeineCache.java | 33 + .../server/cache/device/DeviceRedisCache.java | 35 + .../cache/ota/CaffeineOtaPackageCache.java | 68 + .../server/cache/ota/OtaPackageDataCache.java | 32 + .../cache/ota/RedisOtaPackageDataCache.java | 68 + ...UsersSessionInvalidationCaffeineCache.java | 34 + .../UsersSessionInvalidationRedisCache.java | 36 + .../server/cache/CacheSpecsMapTest.java | 64 + common/cache/src/test/resources/logback.xml | 15 + common/cluster-api/pom.xml | 149 + .../server/cluster/TbClusterService.java | 98 + .../server/queue/TbQueueAdmin.java | 25 + .../server/queue/TbQueueCallback.java | 23 + .../server/queue/TbQueueClusterService.java | 24 + .../server/queue/TbQueueConsumer.java | 39 + .../server/queue/TbQueueHandler.java | 27 + .../thingsboard/server/queue/TbQueueMsg.java | 27 + .../server/queue/TbQueueMsgDecoder.java | 24 + .../server/queue/TbQueueMsgHeaders.java | 27 + .../server/queue/TbQueueMsgMetadata.java | 19 + .../server/queue/TbQueueProducer.java | 29 + .../server/queue/TbQueueRequestTemplate.java | 32 + .../server/queue/TbQueueResponseTemplate.java | 23 + .../cluster-api/src/main/proto/jsinvoke.proto | 79 + common/cluster-api/src/main/proto/queue.proto | 1002 ++ common/coap-server/pom.xml | 73 + .../server/coapserver/CoapServerContext.java | 57 + .../server/coapserver/CoapServerService.java | 34 + .../coapserver/DefaultCoapServerService.java | 151 + .../TbCoapDtlsCertificateVerifier.java | 152 + .../TbCoapDtlsSessionInMemoryStorage.java | 56 + .../coapserver/TbCoapDtlsSessionInfo.java | 36 + .../server/coapserver/TbCoapDtlsSettings.java | 115 + .../coapserver/TbCoapServerComponent.java | 26 + .../TbCoapServerMessageDeliverer.java | 63 + common/dao-api/pom.xml | 144 + .../dao/alarm/AlarmOperationResult.java | 46 + .../server/dao/alarm/AlarmService.java | 70 + .../server/dao/asset/AssetProfileService.java | 53 + .../server/dao/asset/AssetService.java | 90 + .../dao/attributes/AttributesService.java | 50 + .../server/dao/audit/AuditLogService.java | 50 + .../cassandra/AbstractCassandraCluster.java | 137 + .../dao/cassandra/CassandraCluster.java | 36 + .../dao/cassandra/CassandraDriverOptions.java | 258 + .../cassandra/CassandraInstallCluster.java | 34 + .../cassandra/guava/DefaultGuavaSession.java | 26 + .../cassandra/guava/GuavaDriverContext.java | 66 + .../guava/GuavaMultiPageResultSet.java | 123 + .../guava/GuavaRequestAsyncProcessor.java | 79 + .../dao/cassandra/guava/GuavaSession.java | 74 + .../cassandra/guava/GuavaSessionBuilder.java | 36 + .../cassandra/guava/GuavaSessionUtils.java | 22 + .../component/ComponentDescriptorService.java | 46 + .../server/dao/customer/CustomerService.java | 45 + .../dao/dashboard/DashboardService.java | 72 + .../dao/device/ClaimDevicesService.java | 34 + .../dao/device/DeviceCredentialsService.java | 39 + .../dao/device/DeviceProfileService.java | 54 + .../dao/device/DeviceProvisionService.java | 25 + .../server/dao/device/DeviceService.java | 121 + .../server/dao/device/claim/ClaimData.java | 32 + .../dao/device/claim/ClaimResponse.java | 24 + .../server/dao/device/claim/ClaimResult.java | 30 + .../dao/device/claim/ReclaimResult.java | 26 + .../provision/ProvisionFailedException.java | 22 + .../device/provision/ProvisionRequest.java | 31 + .../device/provision/ProvisionResponse.java | 25 + .../provision/ProvisionResponseStatus.java | 23 + .../server/dao/edge/EdgeEventService.java | 36 + .../server/dao/edge/EdgeService.java | 90 + .../server/dao/entity/EntityService.java | 37 + .../dao/entityview/EntityViewService.java | 89 + .../server/dao/event/EventService.java | 47 + .../dao/nosql/CassandraStatementTask.java | 46 + .../server/dao/nosql/TbResultSet.java | 131 + .../server/dao/nosql/TbResultSetFuture.java | 92 + .../oauth2/OAuth2ConfigTemplateService.java | 34 + .../server/dao/oauth2/OAuth2Service.java | 38 + .../server/dao/oauth2/OAuth2User.java | 33 + .../server/dao/ota/OtaPackageService.java | 54 + .../server/dao/queue/QueueService.java | 45 + .../server/dao/relation/RelationService.java | 91 + .../server/dao/resource/ResourceService.java | 53 + .../server/dao/rpc/RpcService.java | 41 + .../server/dao/rule/RuleChainService.java | 105 + .../server/dao/rule/RuleNodeStateService.java | 36 + .../dao/settings/AdminSettingsService.java | 36 + .../dao/tenant/TbTenantProfileCache.java | 41 + .../dao/tenant/TenantProfileService.java | 49 + .../server/dao/tenant/TenantService.java | 51 + .../dao/timeseries/TimeseriesService.java | 66 + .../dao/usagerecord/ApiUsageStateService.java | 38 + .../server/dao/user/UserService.java | 76 + .../server/dao/util/AsyncTask.java | 27 + .../server/dao/util/DbTypeInfoComponent.java | 22 + .../dao/util/DefaultDbTypeInfoComponent.java | 33 + .../server/dao/util/NoSqlAnyDao.java | 26 + .../server/dao/util/NoSqlAnyDaoNonCloud.java | 27 + .../server/dao/util/NoSqlTsDao.java | 26 + .../server/dao/util/NoSqlTsLatestDao.java | 26 + .../thingsboard/server/dao/util/SqlDao.java | 26 + .../thingsboard/server/dao/util/SqlTsDao.java | 26 + .../server/dao/util/SqlTsLatestAnyDao.java | 26 + .../server/dao/util/SqlTsLatestDao.java | 26 + .../dao/util/SqlTsOrTsLatestAnyDao.java | 26 + .../server/dao/util/TbAutoConfiguration.java | 29 + .../server/dao/util/TimescaleDBTsDao.java | 26 + .../dao/util/TimescaleDBTsLatestDao.java | 26 + .../dao/util/TimescaleDBTsOrTsLatestDao.java | 26 + .../server/dao/widget/WidgetTypeService.java | 46 + .../dao/widget/WidgetsBundleService.java | 48 + common/data/pom.xml | 141 + .../server/common/data/AdminSettings.java | 140 + .../server/common/data/ApiFeature.java | 39 + .../server/common/data/ApiUsageRecordKey.java | 73 + .../server/common/data/ApiUsageState.java | 92 + .../common/data/ApiUsageStateMailMessage.java | 25 + .../common/data/ApiUsageStateValue.java | 26 + .../server/common/data/BaseData.java | 82 + .../server/common/data/CacheConstants.java | 42 + .../server/common/data/ClaimRequest.java | 25 + .../server/common/data/CoapDeviceType.java | 21 + .../server/common/data/ContactBased.java | 135 + .../server/common/data/Customer.java | 204 + .../server/common/data/Dashboard.java | 108 + .../server/common/data/DashboardInfo.java | 229 + .../server/common/data/DataConstants.java | 121 + .../server/common/data/Device.java | 262 + .../server/common/data/DeviceIdInfo.java | 42 + .../server/common/data/DeviceInfo.java | 48 + .../server/common/data/DeviceProfile.java | 176 + .../server/common/data/DeviceProfileInfo.java | 71 + .../data/DeviceProfileProvisionType.java | 22 + .../server/common/data/DeviceProfileType.java | 20 + .../common/data/DeviceTransportType.java | 24 + .../server/common/data/DynamicProtoUtils.java | 301 + .../server/common/data/EdgeUtils.java | 115 + .../server/common/data/EntityFieldsData.java | 94 + .../server/common/data/EntityInfo.java | 49 + .../server/common/data/EntitySubtype.java | 94 + .../server/common/data/EntityType.java | 23 + .../server/common/data/EntityView.java | 128 + .../server/common/data/EntityViewInfo.java | 43 + .../server/common/data/EventInfo.java | 61 + .../server/common/data/ExportableEntity.java | 38 + .../server/common/data/FSTUtils.java | 35 + .../server/common/data/HasAdditionalInfo.java | 24 + .../server/common/data/HasCustomerId.java | 23 + .../server/common/data/HasName.java | 22 + .../server/common/data/HasOtaPackage.java | 25 + .../common/data/HasRuleEngineProfile.java | 26 + .../server/common/data/HasTenantId.java | 23 + .../server/common/data/HomeDashboard.java | 36 + .../server/common/data/HomeDashboardInfo.java | 32 + .../server/common/data/OtaPackage.java | 48 + .../server/common/data/OtaPackageInfo.java | 144 + .../server/common/data/ResourceType.java | 20 + .../server/common/data/ResourceUtils.java | 127 + .../SaveDeviceWithCredentialsRequest.java | 32 + .../data/SaveOtaPackageInfoRequest.java | 41 + .../server/common/data/SearchTextBased.java | 41 + .../SearchTextBasedWithAdditionalInfo.java | 108 + .../server/common/data/ShortCustomerInfo.java | 62 + .../server/common/data/StringUtils.java | 183 + .../server/common/data/TbProperty.java | 28 + .../server/common/data/TbResource.java | 80 + .../server/common/data/TbResourceInfo.java | 113 + .../common/data/TbTransportService.java | 20 + .../server/common/data/Tenant.java | 208 + .../server/common/data/TenantInfo.java | 42 + .../server/common/data/TenantProfile.java | 152 + .../server/common/data/TenantProfileType.java | 20 + .../common/data/TransportPayloadType.java | 21 + .../server/common/data/UUIDConverter.java | 49 + .../server/common/data/UpdateMessage.java | 31 + .../thingsboard/server/common/data/User.java | 194 + .../server/common/data/alarm/Alarm.java | 135 + .../server/common/data/alarm/AlarmInfo.java | 68 + .../server/common/data/alarm/AlarmQuery.java | 38 + .../common/data/alarm/AlarmSearchStatus.java | 40 + .../common/data/alarm/AlarmSeverity.java | 25 + .../server/common/data/alarm/AlarmStatus.java | 42 + .../server/common/data/alarm/EntityAlarm.java | 40 + .../server/common/data/asset/Asset.java | 198 + .../server/common/data/asset/AssetInfo.java | 50 + .../common/data/asset/AssetProfile.java | 119 + .../common/data/asset/AssetProfileInfo.java | 62 + .../common/data/asset/AssetSearchQuery.java | 50 + .../common/data/audit/ActionStatus.java | 20 + .../server/common/data/audit/ActionType.java | 58 + .../server/common/data/audit/AuditLog.java | 88 + .../common/data/device/DeviceSearchQuery.java | 49 + .../credentials/BasicMqttCredentials.java | 27 + .../ProvisionDeviceCredentialsData.java | 27 + ...wM2MBootstrapClientCredentialWithKeys.java | 45 + .../lwm2m/AbstractLwM2MClientCredential.java | 27 + ...AbstractLwM2MClientSecurityCredential.java | 30 + .../lwm2m/LwM2MBootstrapClientCredential.java | 37 + .../LwM2MBootstrapClientCredentials.java | 26 + .../lwm2m/LwM2MClientCredential.java | 39 + .../lwm2m/LwM2MDeviceCredentials.java | 26 + .../credentials/lwm2m/LwM2MSecurityMode.java | 20 + .../lwm2m/NoSecBootstrapClientCredential.java | 24 + .../lwm2m/NoSecClientCredential.java | 24 + .../lwm2m/PSKBootstrapClientCredential.java | 24 + .../lwm2m/PSKClientCredential.java | 40 + .../lwm2m/RPKBootstrapClientCredential.java | 24 + .../lwm2m/RPKClientCredential.java | 35 + .../lwm2m/X509BootstrapClientCredential.java | 24 + .../lwm2m/X509ClientCredential.java | 41 + .../CoapDeviceTransportConfiguration.java | 50 + .../data/DefaultDeviceConfiguration.java | 33 + .../DefaultDeviceTransportConfiguration.java | 30 + .../data/device/data/DeviceConfiguration.java | 40 + .../common/data/device/data/DeviceData.java | 35 + .../data/DeviceTransportConfiguration.java | 46 + .../Lwm2mDeviceTransportConfiguration.java | 48 + .../MqttDeviceTransportConfiguration.java | 48 + .../common/data/device/data/PowerMode.java | 20 + .../device/data/PowerSavingConfiguration.java | 31 + .../SnmpDeviceTransportConfiguration.java | 89 + .../data/device/profile/AlarmCondition.java | 38 + .../device/profile/AlarmConditionFilter.java | 45 + .../profile/AlarmConditionFilterKey.java | 35 + .../device/profile/AlarmConditionKeyType.java | 23 + .../device/profile/AlarmConditionSpec.java | 39 + .../profile/AlarmConditionSpecType.java | 24 + .../common/data/device/profile/AlarmRule.java | 44 + .../data/device/profile/AlarmSchedule.java | 40 + .../device/profile/AlarmScheduleType.java | 24 + ...esDeviceProfileProvisionConfiguration.java | 31 + .../data/device/profile/AnyTimeSchedule.java | 32 + ...esDeviceProfileProvisionConfiguration.java | 31 + ...apDeviceProfileTransportConfiguration.java | 40 + .../profile/CoapDeviceTypeConfiguration.java | 39 + .../device/profile/CustomTimeSchedule.java | 36 + .../profile/CustomTimeScheduleItem.java | 30 + .../DefaultCoapDeviceTypeConfiguration.java | 41 + .../DefaultDeviceProfileConfiguration.java | 29 + ...ltDeviceProfileTransportConfiguration.java | 30 + .../device/profile/DeviceProfileAlarm.java | 62 + .../profile/DeviceProfileConfiguration.java | 38 + .../device/profile/DeviceProfileData.java | 41 + .../DeviceProfileProvisionConfiguration.java | 42 + .../DeviceProfileTransportConfiguration.java | 46 + ...edDeviceProfileProvisionConfiguration.java | 31 + .../profile/DurationAlarmConditionSpec.java | 35 + .../EfentoCoapDeviceTypeConfiguration.java | 30 + .../JsonTransportPayloadConfiguration.java | 28 + ...2mDeviceProfileTransportConfiguration.java | 41 + ...ttDeviceProfileTransportConfiguration.java | 46 + .../data/device/profile/MqttTopics.java | 119 + .../ProtoTransportPayloadConfiguration.java | 95 + .../ProvisionDeviceProfileCredentials.java | 24 + .../profile/RepeatingAlarmConditionSpec.java | 32 + .../profile/SimpleAlarmConditionSpec.java | 28 + ...mpDeviceProfileTransportConfiguration.java | 52 + .../device/profile/SpecificTimeSchedule.java | 38 + .../TransportPayloadTypeConfiguration.java | 39 + .../profile/lwm2m/ObjectAttributes.java | 37 + .../profile/lwm2m/OtherConfiguration.java | 42 + .../lwm2m/TelemetryMappingConfiguration.java | 39 + ...bstractLwM2MBootstrapServerCredential.java | 37 + .../LwM2MBootstrapServerCredential.java | 39 + .../bootstrap/LwM2MServerSecurityConfig.java | 68 + .../LwM2MServerSecurityConfigDefault.java | 29 + .../NoSecLwM2MBootstrapServerCredential.java | 28 + .../PSKLwM2MBootstrapServerCredential.java | 28 + .../RPKLwM2MBootstrapServerCredential.java | 28 + .../X509LwM2MBootstrapServerCredential.java | 28 + .../server/common/data/edge/Edge.java | 155 + .../server/common/data/edge/EdgeEvent.java | 49 + .../common/data/edge/EdgeEventActionType.java | 38 + .../common/data/edge/EdgeEventType.java | 38 + .../server/common/data/edge/EdgeInfo.java | 40 + .../common/data/edge/EdgeSearchQuery.java | 47 + .../entityview/EntityViewSearchQuery.java | 49 + .../common/data/event/DebugEventFilter.java | 47 + .../server/common/data/event/ErrorEvent.java | 66 + .../common/data/event/ErrorEventFilter.java | 43 + .../server/common/data/event/Event.java | 71 + .../server/common/data/event/EventFilter.java | 42 + .../server/common/data/event/EventType.java | 44 + .../data/event/LifeCycleEventFilter.java | 45 + .../common/data/event/LifecycleEvent.java | 71 + .../data/event/RuleChainDebugEvent.java | 63 + .../data/event/RuleChainDebugEventFilter.java | 41 + .../common/data/event/RuleNodeDebugEvent.java | 102 + .../data/event/RuleNodeDebugEventFilter.java | 57 + .../common/data/event/StatisticsEvent.java | 60 + .../data/event/StatisticsEventFilter.java | 49 + .../ApiUsageLimitsExceededException.java | 25 + .../data/exception/ThingsboardErrorCode.java | 45 + .../data/exception/ThingsboardException.java | 51 + .../ThingsboardKafkaClientError.java | 23 + .../common/data/id/AdminSettingsId.java | 30 + .../server/common/data/id/AlarmId.java | 46 + .../common/data/id/ApiUsageStateId.java | 45 + .../server/common/data/id/AssetId.java | 46 + .../server/common/data/id/AssetProfileId.java | 43 + .../server/common/data/id/AuditLogId.java | 35 + .../common/data/id/ComponentDescriptorId.java | 31 + .../server/common/data/id/CustomerId.java | 42 + .../server/common/data/id/DashboardId.java | 44 + .../common/data/id/DeviceCredentialsId.java | 29 + .../server/common/data/id/DeviceId.java | 46 + .../common/data/id/DeviceProfileId.java | 44 + .../server/common/data/id/EdgeEventId.java | 35 + .../server/common/data/id/EdgeId.java | 54 + .../server/common/data/id/EntityId.java | 50 + .../common/data/id/EntityIdDeserializer.java | 43 + .../common/data/id/EntityIdFactory.java | 126 + .../common/data/id/EntityIdSerializer.java | 37 + .../server/common/data/id/EntityViewId.java | 46 + .../server/common/data/id/EventId.java | 35 + .../server/common/data/id/HasId.java | 24 + .../server/common/data/id/HasUUID.java | 24 + .../server/common/data/id/IdBased.java | 80 + .../server/common/data/id/NodeId.java | 25 + .../OAuth2ClientRegistrationTemplateId.java | 33 + .../server/common/data/id/OAuth2DomainId.java | 33 + .../server/common/data/id/OAuth2MobileId.java | 33 + .../server/common/data/id/OAuth2ParamsId.java | 33 + .../common/data/id/OAuth2RegistrationId.java | 33 + .../server/common/data/id/OtaPackageId.java | 45 + .../server/common/data/id/QueueId.java | 41 + .../server/common/data/id/RpcId.java | 40 + .../server/common/data/id/RuleChainId.java | 38 + .../server/common/data/id/RuleNodeId.java | 38 + .../common/data/id/RuleNodeStateId.java | 35 + .../server/common/data/id/TbResourceId.java | 40 + .../server/common/data/id/TenantId.java | 53 + .../common/data/id/TenantProfileId.java | 44 + .../server/common/data/id/UUIDBased.java | 75 + .../common/data/id/UserAuthSettingsId.java | 26 + .../common/data/id/UserCredentialsId.java | 25 + .../server/common/data/id/UserId.java | 43 + .../server/common/data/id/WidgetTypeId.java | 40 + .../common/data/id/WidgetsBundleId.java | 40 + .../server/common/data/kv/AggTsKvEntry.java | 37 + .../server/common/data/kv/Aggregation.java | 25 + .../server/common/data/kv/AttributeKey.java | 29 + .../common/data/kv/AttributeKvEntry.java | 25 + .../common/data/kv/BaseAttributeKvEntry.java | 117 + .../common/data/kv/BaseDeleteTsKvQuery.java | 34 + .../common/data/kv/BaseReadTsKvQuery.java | 58 + .../server/common/data/kv/BaseTsKvQuery.java | 42 + .../server/common/data/kv/BasicKvEntry.java | 81 + .../server/common/data/kv/BasicTsKvEntry.java | 119 + .../common/data/kv/BooleanDataEntry.java | 69 + .../server/common/data/kv/DataType.java | 22 + .../common/data/kv/DeleteTsKvQuery.java | 22 + .../common/data/kv/DoubleDataEntry.java | 70 + .../server/common/data/kv/JsonDataEntry.java | 69 + .../server/common/data/kv/KvEntry.java | 45 + .../server/common/data/kv/LongDataEntry.java | 70 + .../server/common/data/kv/ReadTsKvQuery.java | 28 + .../common/data/kv/ReadTsKvQueryResult.java | 60 + .../common/data/kv/StringDataEntry.java | 73 + .../server/common/data/kv/TsKvEntry.java | 39 + .../common/data/kv/TsKvEntryAggWrapper.java | 26 + .../data/kv/TsKvLatestRemovingResult.java | 36 + .../server/common/data/kv/TsKvQuery.java | 28 + .../common/data/lwm2m/LwM2mConstants.java | 23 + .../common/data/lwm2m/LwM2mInstance.java | 30 + .../server/common/data/lwm2m/LwM2mObject.java | 37 + .../data/lwm2m/LwM2mResourceObserve.java | 72 + .../server/common/data/oauth2/MapperType.java | 20 + .../data/oauth2/OAuth2BasicMapperConfig.java | 57 + .../common/data/oauth2/OAuth2ClientInfo.java | 46 + .../OAuth2ClientRegistrationTemplate.java | 103 + .../data/oauth2/OAuth2CustomMapperConfig.java | 36 + .../common/data/oauth2/OAuth2Domain.java | 42 + .../common/data/oauth2/OAuth2DomainInfo.java | 39 + .../server/common/data/oauth2/OAuth2Info.java | 41 + .../data/oauth2/OAuth2MapperConfig.java | 43 + .../common/data/oauth2/OAuth2Mobile.java | 42 + .../common/data/oauth2/OAuth2MobileInfo.java | 39 + .../common/data/oauth2/OAuth2Params.java | 40 + .../common/data/oauth2/OAuth2ParamsInfo.java | 47 + .../data/oauth2/OAuth2Registration.java | 79 + .../data/oauth2/OAuth2RegistrationInfo.java | 66 + .../common/data/oauth2/PlatformType.java | 20 + .../server/common/data/oauth2/SchemeType.java | 20 + .../data/oauth2/TenantNameStrategyType.java | 20 + .../data/objects/AttributesEntityView.java | 54 + .../data/objects/TelemetryEntityView.java | 49 + .../common/data/ota/ChecksumAlgorithm.java | 26 + .../server/common/data/ota/OtaPackageKey.java | 30 + .../common/data/ota/OtaPackageType.java | 30 + .../data/ota/OtaPackageUpdateStatus.java | 20 + .../common/data/ota/OtaPackageUtil.java | 100 + .../data/page/BasePageDataIterable.java | 73 + .../server/common/data/page/PageData.java | 76 + .../common/data/page/PageDataIterable.java | 35 + .../data/page/PageDataIterableByTenant.java | 39 + .../PageDataIterableByTenantIdEntityId.java | 43 + .../server/common/data/page/PageLink.java | 93 + .../server/common/data/page/SortOrder.java | 39 + .../server/common/data/page/TimePageLink.java | 95 + .../data/plugin/ComponentDescriptor.java | 114 + .../data/plugin/ComponentLifecycleEvent.java | 25 + .../data/plugin/ComponentLifecycleState.java | 23 + .../common/data/plugin/ComponentScope.java | 23 + .../common/data/plugin/ComponentType.java | 25 + .../common/data/query/AbstractDataQuery.java | 52 + .../server/common/data/query/AlarmData.java | 39 + .../common/data/query/AlarmDataPageLink.java | 71 + .../common/data/query/AlarmDataQuery.java | 46 + .../data/query/ApiUsageStateFilter.java | 31 + .../data/query/AssetSearchQueryFilter.java | 36 + .../common/data/query/AssetTypeFilter.java | 32 + .../data/query/BooleanFilterPredicate.java | 35 + .../common/data/query/ComparisonTsValue.java | 29 + .../data/query/ComplexFilterPredicate.java | 37 + .../data/query/DeviceSearchQueryFilter.java | 36 + .../common/data/query/DeviceTypeFilter.java | 36 + .../common/data/query/DynamicValue.java | 43 + .../data/query/DynamicValueSourceType.java | 23 + .../data/query/EdgeSearchQueryFilter.java | 36 + .../common/data/query/EdgeTypeFilter.java | 32 + .../common/data/query/EntityCountQuery.java | 46 + .../server/common/data/query/EntityData.java | 47 + .../common/data/query/EntityDataPageLink.java | 43 + .../common/data/query/EntityDataQuery.java | 43 + .../data/query/EntityDataSortOrder.java | 41 + .../common/data/query/EntityFilter.java | 47 + .../common/data/query/EntityFilterType.java | 39 + .../server/common/data/query/EntityKey.java | 30 + .../common/data/query/EntityKeyType.java | 26 + .../common/data/query/EntityKeyValueType.java | 23 + .../common/data/query/EntityListFilter.java | 35 + .../common/data/query/EntityNameFilter.java | 32 + .../data/query/EntitySearchQueryFilter.java | 31 + .../common/data/query/EntityTypeFilter.java | 30 + .../query/EntityViewSearchQueryFilter.java | 36 + .../data/query/EntityViewTypeFilter.java | 32 + .../data/query/FilterPredicateType.java | 23 + .../data/query/FilterPredicateValue.java | 79 + .../server/common/data/query/KeyFilter.java | 31 + .../common/data/query/KeyFilterPredicate.java | 38 + .../data/query/NumericFilterPredicate.java | 39 + .../data/query/RelationsQueryFilter.java | 44 + .../data/query/SimpleKeyFilterPredicate.java | 22 + .../common/data/query/SingleEntityFilter.java | 30 + .../data/query/StringFilterPredicate.java | 45 + .../server/common/data/query/TsValue.java | 37 + .../common/data/queue/ProcessingStrategy.java | 27 + .../data/queue/ProcessingStrategyType.java | 36 + .../server/common/data/queue/Queue.java | 68 + .../common/data/queue/SubmitStrategy.java | 24 + .../common/data/queue/SubmitStrategyType.java | 20 + .../common/data/relation/EntityRelation.java | 142 + .../data/relation/EntityRelationInfo.java | 71 + .../data/relation/EntityRelationsQuery.java | 36 + .../data/relation/EntitySearchDirection.java | 25 + .../relation/RelationEntityTypeFilter.java | 39 + .../data/relation/RelationTypeGroup.java | 27 + .../relation/RelationsSearchParameters.java | 67 + .../server/common/data/rpc/Rpc.java | 80 + .../server/common/data/rpc/RpcError.java | 23 + .../server/common/data/rpc/RpcStatus.java | 20 + .../data/rpc/ToDeviceRpcRequestBody.java | 29 + .../rule/DefaultRuleChainCreateRequest.java | 35 + .../common/data/rule/NodeConnectionInfo.java | 34 + .../server/common/data/rule/RuleChain.java | 117 + .../data/rule/RuleChainConnectionInfo.java | 38 + .../common/data/rule/RuleChainData.java | 32 + .../data/rule/RuleChainImportResult.java | 36 + .../common/data/rule/RuleChainMetaData.java | 61 + .../data/rule/RuleChainOutputLabelsUsage.java | 44 + .../common/data/rule/RuleChainType.java | 20 + .../data/rule/RuleChainUpdateResult.java | 44 + .../server/common/data/rule/RuleNode.java | 115 + .../common/data/rule/RuleNodeState.java | 42 + .../data/rule/RuleNodeUpdateResult.java | 33 + .../server/common/data/rule/RuleType.java | 28 + .../server/common/data/rule/Scope.java | 28 + .../common/data/script/ScriptLanguage.java | 20 + .../common/data/security/Authority.java | 48 + .../data/security/DeviceCredentials.java | 115 + .../security/DeviceCredentialsFilter.java | 27 + .../data/security/DeviceCredentialsType.java | 24 + .../data/security/DeviceTokenCredentials.java | 41 + .../data/security/DeviceX509Credentials.java | 39 + .../data/security/UserAuthSettings.java | 34 + .../common/data/security/UserCredentials.java | 112 + .../event/UserAuthDataChangedEvent.java | 23 + .../UserCredentialsInvalidationEvent.java | 40 + .../event/UserSessionInvalidationEvent.java | 39 + .../common/data/security/model/JwtPair.java | 41 + .../data/security/model/JwtSettings.java | 55 + .../common/data/security/model/JwtToken.java | 22 + .../data/security/model/SecuritySettings.java | 36 + .../security/model/UserPasswordPolicy.java | 46 + .../model/mfa/PlatformTwoFaSettings.java | 57 + .../mfa/account/AccountTwoFaSettings.java | 26 + .../account/BackupCodeTwoFaAccountConfig.java | 57 + .../mfa/account/EmailTwoFaAccountConfig.java | 38 + .../account/OtpBasedTwoFaAccountConfig.java | 24 + .../mfa/account/SmsTwoFaAccountConfig.java | 38 + .../mfa/account/TotpTwoFaAccountConfig.java | 39 + .../model/mfa/account/TwoFaAccountConfig.java | 49 + .../BackupCodeTwoFaProviderConfig.java | 33 + .../provider/EmailTwoFaProviderConfig.java | 30 + .../provider/OtpBasedTwoFaProviderConfig.java | 28 + .../mfa/provider/SmsTwoFaProviderConfig.java | 37 + .../mfa/provider/TotpTwoFaProviderConfig.java | 33 + .../mfa/provider/TwoFaProviderConfig.java | 39 + .../model/mfa/provider/TwoFaProviderType.java | 23 + .../AwsSnsSmsProviderConfiguration.java | 38 + .../config/SmppSmsProviderConfiguration.java | 117 + .../sms/config/SmsProviderConfiguration.java | 38 + .../data/sms/config/SmsProviderType.java | 22 + .../data/sms/config/TestSmsRequest.java | 33 + .../TwilioSmsProviderConfiguration.java | 38 + .../server/common/data/sync/JsonTbEntity.java | 55 + .../common/data/sync/ThrowingRunnable.java | 31 + .../data/sync/ie/AttributeExportData.java | 30 + .../common/data/sync/ie/DeviceExportData.java | 41 + .../common/data/sync/ie/EntityExportData.java | 97 + .../data/sync/ie/EntityExportSettings.java | 31 + .../data/sync/ie/EntityImportResult.java | 48 + .../data/sync/ie/EntityImportSettings.java | 32 + .../data/sync/ie/RuleChainExportData.java | 35 + .../data/sync/ie/WidgetsBundleExportData.java | 42 + .../importing/csv/BulkImportColumnType.java | 75 + .../ie/importing/csv/BulkImportRequest.java | 41 + .../ie/importing/csv/BulkImportResult.java | 30 + .../data/sync/vc/AutoCommitSettings.java | 27 + .../common/data/sync/vc/BranchInfo.java | 42 + .../common/data/sync/vc/EntityDataDiff.java | 27 + .../common/data/sync/vc/EntityDataInfo.java | 29 + .../common/data/sync/vc/EntityLoadError.java | 55 + .../data/sync/vc/EntityTypeLoadResult.java | 41 + .../common/data/sync/vc/EntityVersion.java | 35 + .../data/sync/vc/EntityVersionsDiff.java | 34 + .../data/sync/vc/RepositoryAuthMethod.java | 21 + .../data/sync/vc/RepositorySettings.java | 52 + .../data/sync/vc/RepositorySettingsInfo.java | 30 + .../data/sync/vc/VersionCreationResult.java | 49 + .../data/sync/vc/VersionLoadResult.java | 53 + .../data/sync/vc/VersionedEntityInfo.java | 30 + .../create/AutoVersionCreateConfig.java | 29 + .../create/ComplexVersionCreateRequest.java | 37 + .../create/EntityTypeVersionCreateConfig.java | 33 + .../SingleEntityVersionCreateRequest.java | 34 + .../sync/vc/request/create/SyncStrategy.java | 21 + .../request/create/VersionCreateConfig.java | 29 + .../request/create/VersionCreateRequest.java | 36 + .../create/VersionCreateRequestType.java | 21 + .../load/EntityTypeVersionLoadConfig.java | 28 + .../load/EntityTypeVersionLoadRequest.java | 35 + .../load/SingleEntityVersionLoadRequest.java | 35 + .../vc/request/load/VersionLoadConfig.java | 27 + .../vc/request/load/VersionLoadRequest.java | 36 + .../request/load/VersionLoadRequestType.java | 21 + .../DefaultTenantProfileConfiguration.java | 119 + .../profile/TenantProfileConfiguration.java | 47 + .../tenant/profile/TenantProfileData.java | 34 + .../TenantProfileQueueConfiguration.java | 34 + .../data/transport/resource/ResourceType.java | 20 + .../snmp/AuthenticationProtocol.java | 45 + .../data/transport/snmp/PrivacyProtocol.java | 43 + .../transport/snmp/SnmpCommunicationSpec.java | 25 + .../data/transport/snmp/SnmpMapping.java | 45 + .../data/transport/snmp/SnmpMethod.java | 32 + .../transport/snmp/SnmpProtocolVersion.java | 32 + ...ltipleMappingsSnmpCommunicationConfig.java | 36 + ...eatingQueryingSnmpCommunicationConfig.java | 36 + .../snmp/config/SnmpCommunicationConfig.java | 57 + ...ibutesQueryingSnmpCommunicationConfig.java | 30 + ...ributesSettingSnmpCommunicationConfig.java | 36 + ...emetryQueryingSnmpCommunicationConfig.java | 34 + ...viceRpcRequestSnmpCommunicationConfig.java | 29 + .../common/data/util/ReflectionUtils.java | 31 + .../server/common/data/util/TbPair.java | 26 + .../server/common/data/validation/Length.java | 38 + .../server/common/data/validation/NoXss.java | 34 + .../common/data/widget/BaseWidgetType.java | 77 + .../server/common/data/widget/WidgetType.java | 46 + .../common/data/widget/WidgetTypeDetails.java | 54 + .../common/data/widget/WidgetTypeInfo.java | 53 + .../common/data/widget/WidgetsBundle.java | 132 + .../common/data/DynamicProtoUtilsTest.java | 169 + .../server/common/data/UUIDConverterTest.java | 91 + .../server/common/data/id/EntityIdTest.java | 28 + common/edge-api/pom.xml | 128 + .../exception/EdgeConnectionException.java | 29 + .../thingsboard/edge/rpc/EdgeGrpcClient.java | 242 + .../thingsboard/edge/rpc/EdgeRpcClient.java | 44 + common/edge-api/src/main/proto/edge.proto | 565 + common/message/pom.xml | 117 + .../server/common/msg/EncryptionUtil.java | 80 + .../server/common/msg/MsgType.java | 133 + .../server/common/msg/TbActorMsg.java | 33 + .../server/common/msg/TbActorStopReason.java | 22 + .../thingsboard/server/common/msg/TbMsg.java | 316 + .../server/common/msg/TbMsgDataType.java | 26 + .../server/common/msg/TbMsgMetaData.java | 69 + .../server/common/msg/TbMsgProcessingCtx.java | 95 + .../common/msg/TbMsgProcessingStackItem.java | 48 + .../common/msg/TbRuleEngineActorMsg.java | 32 + .../msg/ToDeviceActorNotificationMsg.java | 28 + .../common/msg/aware/CustomerAwareMsg.java | 24 + .../common/msg/aware/DeviceAwareMsg.java | 24 + .../server/common/msg/aware/NodeAwareMsg.java | 24 + .../common/msg/aware/RuleChainAwareMsg.java | 25 + .../common/msg/aware/TenantAwareMsg.java | 25 + .../common/msg/cluster/ToAllNodesMsg.java | 26 + .../common/msg/edge/EdgeEventUpdateMsg.java | 40 + .../common/msg/edge/EdgeSessionMsg.java | 24 + .../common/msg/edge/FromEdgeSyncResponse.java | 39 + .../common/msg/edge/ToEdgeSyncRequest.java | 37 + .../plugin/ComponentLifecycleListener.java | 20 + .../msg/plugin/ComponentLifecycleMsg.java | 58 + .../common/msg/plugin/RuleNodeUpdatedMsg.java | 40 + .../common/msg/queue/PartitionChangeMsg.java | 40 + .../msg/queue/QueueToRuleEngineMsg.java | 72 + .../common/msg/queue/RuleEngineException.java | 43 + .../common/msg/queue/RuleNodeException.java | 69 + .../server/common/msg/queue/RuleNodeInfo.java | 35 + .../server/common/msg/queue/ServiceType.java | 25 + .../server/common/msg/queue/TbCallback.java | 37 + .../common/msg/queue/TbMsgCallback.java | 58 + .../common/msg/queue/TopicPartitionInfo.java | 84 + .../common/msg/rpc/FromDeviceRpcResponse.java | 46 + .../common/msg/rpc/ToDeviceRpcRequest.java | 43 + .../common/msg/session/FeatureType.java | 20 + .../common/msg/session/SessionMsgType.java | 48 + .../ex/ProcessingTimeoutException.java | 22 + .../msg/session/ex/SessionAuthException.java | 26 + .../msg/session/ex/SessionException.java | 35 + .../DeviceActorServerSideRpcTimeoutMsg.java | 33 + .../server/common/msg/timeout/TimeoutMsg.java | 28 + .../common/msg/tools/SchedulerUtils.java | 75 + .../server/common/msg/tools/TbRateLimits.java | 66 + .../msg/tools/TbRateLimitsException.java | 32 + common/message/src/main/proto/tbmsg.proto | 69 + .../server/common/msg/TbMsgMetaDataTest.java | 56 + .../msg/TbMsgProcessingStackItemTest.java | 37 + .../msg/queue/TopicPartitionInfoTest.java | 127 + .../common/msg/tools/RateLimitsTest.java | 88 + common/pom.xml | 53 + common/queue/pom.xml | 140 + .../queue/RuleEngineTbQueueAdminFactory.java | 114 + .../azure/servicebus/TbServiceBusAdmin.java | 135 + .../TbServiceBusConsumerTemplate.java | 175 + .../TbServiceBusProducerTemplate.java | 111 + .../servicebus/TbServiceBusQueueConfigs.java | 78 + .../servicebus/TbServiceBusSettings.java | 37 + ...stractParallelTbQueueConsumerTemplate.java | 54 + .../AbstractTbQueueConsumerTemplate.java | 190 + .../queue/common/AbstractTbQueueTemplate.java | 58 + .../queue/common/AsyncCallbackTemplate.java | 67 + .../queue/common/DefaultTbQueueMsg.java | 37 + .../common/DefaultTbQueueMsgHeaders.java | 41 + .../common/DefaultTbQueueRequestTemplate.java | 300 + .../DefaultTbQueueResponseTemplate.java | 172 + .../MultipleTbQueueCallbackWrapper.java | 45 + .../MultipleTbQueueTbMsgCallbackWrapper.java | 47 + .../queue/common/TbProtoJsQueueMsg.java | 43 + .../server/queue/common/TbProtoQueueMsg.java | 55 + .../common/TbQueueTbMsgCallbackWrapper.java | 41 + .../queue/discovery/ConsistentHashCircle.java | 61 + .../DefaultTbServiceInfoProvider.java | 102 + .../queue/discovery/DiscoveryService.java | 20 + .../discovery/DummyDiscoveryService.java | 49 + .../queue/discovery/HashPartitionService.java | 432 + .../discovery/NotificationsTopicService.java | 54 + .../queue/discovery/PartitionService.java | 66 + .../server/queue/discovery/QueueKey.java | 63 + .../queue/discovery/QueueRoutingInfo.java | 60 + .../discovery/QueueRoutingInfoService.java | 24 + .../discovery/TbApplicationEventListener.java | 56 + .../discovery/TbServiceInfoProvider.java | 31 + .../queue/discovery/TenantRoutingInfo.java | 25 + .../discovery/TenantRoutingInfoService.java | 23 + .../queue/discovery/ZkDiscoveryService.java | 303 + .../event/ClusterTopologyChangeEvent.java | 34 + .../discovery/event/PartitionChangeEvent.java | 45 + .../event/ServiceListChangedEvent.java | 35 + .../discovery/event/TbApplicationEvent.java | 39 + .../environment/EnvironmentLogService.java | 39 + .../server/queue/kafka/KafkaTbQueueMsg.java | 54 + .../queue/kafka/KafkaTbQueueMsgMetadata.java | 27 + .../server/queue/kafka/TbKafkaAdmin.java | 114 + .../kafka/TbKafkaConsumerStatisticConfig.java | 37 + .../kafka/TbKafkaConsumerStatsService.java | 189 + .../queue/kafka/TbKafkaConsumerTemplate.java | 126 + .../server/queue/kafka/TbKafkaDecoder.java | 29 + .../server/queue/kafka/TbKafkaEncoder.java | 25 + .../queue/kafka/TbKafkaProducerTemplate.java | 121 + .../server/queue/kafka/TbKafkaSettings.java | 170 + .../queue/kafka/TbKafkaTopicConfigs.java | 94 + .../queue/memory/DefaultInMemoryStorage.java | 75 + .../server/queue/memory/InMemoryStorage.java | 32 + .../queue/memory/InMemoryTbQueueConsumer.java | 106 + .../queue/memory/InMemoryTbQueueProducer.java | 59 + .../provider/AwsSqsMonolithQueueFactory.java | 253 + .../provider/AwsSqsTbCoreQueueFactory.java | 232 + .../AwsSqsTbRuleEngineQueueFactory.java | 183 + .../AwsSqsTbVersionControlQueueFactory.java | 91 + .../provider/AwsSqsTransportQueueFactory.java | 139 + .../InMemoryMonolithQueueFactory.java | 169 + .../InMemoryTbTransportQueueFactory.java | 113 + .../provider/KafkaMonolithQueueFactory.java | 379 + .../provider/KafkaTbCoreQueueFactory.java | 336 + .../KafkaTbRuleEngineQueueFactory.java | 254 + .../KafkaTbTransportQueueFactory.java | 178 + .../KafkaTbVersionControlQueueFactory.java | 116 + .../provider/PubSubMonolithQueueFactory.java | 251 + .../provider/PubSubTbCoreQueueFactory.java | 219 + .../PubSubTbRuleEngineQueueFactory.java | 179 + .../PubSubTbVersionControlQueueFactory.java | 90 + .../provider/PubSubTransportQueueFactory.java | 138 + .../RabbitMqMonolithQueueFactory.java | 248 + .../provider/RabbitMqTbCoreQueueFactory.java | 219 + .../RabbitMqTbRuleEngineQueueFactory.java | 178 + .../RabbitMqTbVersionControlQueueFactory.java | 90 + .../RabbitMqTransportQueueFactory.java | 139 + .../ServiceBusMonolithQueueFactory.java | 247 + .../ServiceBusTbCoreQueueFactory.java | 219 + .../ServiceBusTbRuleEngineQueueFactory.java | 178 + ...erviceBusTbVersionControlQueueFactory.java | 90 + .../ServiceBusTransportQueueFactory.java | 140 + .../queue/provider/TbCoreQueueFactory.java | 133 + .../provider/TbCoreQueueProducerProvider.java | 94 + .../provider/TbQueueProducerProvider.java | 81 + .../TbRuleEngineProducerProvider.java | 92 + .../provider/TbRuleEngineQueueFactory.java | 96 + .../provider/TbTransportQueueFactory.java | 38 + .../TbTransportQueueProducerProvider.java | 86 + .../TbUsageStatsClientQueueFactory.java | 26 + .../TbVersionControlProducerProvider.java | 84 + .../TbVersionControlQueueFactory.java | 44 + .../server/queue/pubsub/TbPubSubAdmin.java | 231 + .../pubsub/TbPubSubConsumerTemplate.java | 174 + .../pubsub/TbPubSubProducerTemplate.java | 134 + .../server/queue/pubsub/TbPubSubSettings.java | 58 + .../pubsub/TbPubSubSubscriptionSettings.java | 79 + .../queue/rabbitmq/TbRabbitMqAdmin.java | 87 + .../rabbitmq/TbRabbitMqConsumerTemplate.java | 126 + .../rabbitmq/TbRabbitMqProducerTemplate.java | 125 + .../rabbitmq/TbRabbitMqQueueArguments.java | 106 + .../queue/rabbitmq/TbRabbitMqSettings.java | 65 + .../scheduler/DefaultSchedulerComponent.java | 61 + .../queue/scheduler/SchedulerComponent.java | 32 + .../queue/settings/TbQueueCoreSettings.java | 39 + .../TbQueueRemoteJsInvokeSettings.java | 41 + .../settings/TbQueueRuleEngineSettings.java | 31 + .../settings/TbQueueTransportApiSettings.java | 49 + .../TbQueueTransportNotificationSettings.java | 34 + .../TbQueueVersionControlSettings.java | 36 + ...leEngineQueueAckStrategyConfiguration.java | 30 + .../TbRuleEngineQueueConfiguration.java | 33 + ...ngineQueueSubmitStrategyConfiguration.java | 27 + .../queue/sqs/AwsSqsTbQueueMsgMetadata.java | 28 + .../server/queue/sqs/TbAwsSqsAdmin.java | 106 + .../queue/sqs/TbAwsSqsConsumerTemplate.java | 188 + .../queue/sqs/TbAwsSqsProducerTemplate.java | 129 + .../queue/sqs/TbAwsSqsQueueAttributes.java | 94 + .../server/queue/sqs/TbAwsSqsSettings.java | 45 + .../DefaultTbApiUsageReportClient.java | 166 + .../server/queue/util/AfterContextReady.java | 35 + .../server/queue/util/AfterStartUp.java | 46 + .../util/DataDecodingEncodingService.java | 27 + .../queue/util/ProtoWithFSTService.java | 48 + .../server/queue/util/TbCoreComponent.java | 26 + .../TbLwM2mBootstrapTransportComponent.java | 26 + .../queue/util/TbLwM2mTransportComponent.java | 26 + .../queue/util/TbRuleEngineComponent.java | 26 + .../queue/util/TbSnmpTransportComponent.java | 29 + .../queue/util/TbTransportComponent.java | 26 + .../queue/util/TbVersionControlComponent.java | 26 + .../DefaultTbQueueRequestTemplateTest.java | 212 + .../server/queue/discovery/QueueKeyTest.java | 44 + .../memory/DefaultInMemoryStorageTest.java | 115 + common/script/pom.xml | 42 + common/script/remote-js-client/pom.xml | 89 + .../service/script/JsExecutorService.java | 33 + .../service/script/RemoteJsInvokeService.java | 284 + .../script/RemoteJsRequestEncoder.java | 38 + .../script/RemoteJsResponseDecoder.java | 38 + common/script/script-api/pom.xml | 125 + .../api/AbstractScriptInvokeService.java | 288 + .../script/api/BlockedScriptInfo.java | 44 + .../script/api/RuleNodeScriptFactory.java | 52 + .../script/api/ScriptInvokeService.java | 35 + .../script/api/ScriptStatCallback.java | 47 + .../thingsboard/script/api/ScriptType.java | 20 + .../script/api/TbScriptException.java | 40 + .../script/api/TbScriptExecutionTask.java | 30 + .../api/js/AbstractJsInvokeService.java | 106 + .../script/api/js/JsInvokeService.java | 28 + .../script/api/js/JsScriptExecutionTask.java | 31 + .../script/api/js/JsScriptInfo.java | 26 + .../script/api/js/NashornJsInvokeService.java | 179 + .../api/tbel/DefaultTbelInvokeService.java | 236 + .../thingsboard/script/api/tbel/TbDate.java | 125 + .../thingsboard/script/api/tbel/TbJson.java | 61 + .../thingsboard/script/api/tbel/TbUtils.java | 319 + .../script/api/tbel/TbelInvokeService.java | 28 + .../script/api/tbel/TbelScript.java | 39 + .../api/tbel/TbelScriptExecutionTask.java | 36 + .../script/api/tbel/TbUtilsTest.java | 98 + common/stats/pom.xml | 100 + .../server/common/stats/DefaultCounter.java | 48 + .../common/stats/DefaultMessagesStats.java | 65 + .../common/stats/DefaultStatsFactory.java | 120 + .../server/common/stats/MessagesStats.java | 44 + .../server/common/stats/StatsCounter.java | 33 + .../server/common/stats/StatsFactory.java | 30 + .../server/common/stats/StatsType.java | 30 + .../common/stats/TbApiUsageReportClient.java | 28 + .../common/stats/TbApiUsageStateClient.java | 25 + common/transport/coap/pom.xml | 109 + .../coap/AbstractCoapTransportResource.java | 59 + .../transport/coap/CoapTransportContext.java | 61 + .../transport/coap/CoapTransportResource.java | 484 + .../transport/coap/CoapTransportService.java | 83 + .../coap/OtaPackageTransportResource.java | 153 + .../transport/coap/TbCoapMessageObserver.java | 108 + .../coap/TransportConfigurationContainer.java | 42 + .../coap/adaptors/CoapAdaptorUtils.java | 61 + .../coap/adaptors/CoapTransportAdaptor.java | 54 + .../coap/adaptors/JsonCoapAdaptor.java | 169 + .../coap/adaptors/ProtoCoapAdaptor.java | 164 + .../callback/AbstractSyncSessionCallback.java | 88 + .../coap/callback/CoapDeviceAuthCallback.java | 52 + .../coap/callback/CoapEfentoCallback.java | 51 + .../coap/callback/CoapNoOpCallback.java | 37 + .../coap/callback/CoapOkCallback.java | 48 + .../GetAttributesSyncSessionCallback.java | 44 + .../ToServerRpcSyncSessionCallback.java | 42 + .../coap/client/CoapClientContext.java | 51 + .../coap/client/DefaultCoapClientContext.java | 840 ++ .../transport/coap/client/NoSecClient.java | 98 + .../coap/client/NoSecObserveClient.java | 104 + .../coap/client/SecureClientNoAuth.java | 148 + .../coap/client/SecureClientX509.java | 147 + .../coap/client/TbCoapClientState.java | 152 + .../coap/client/TbCoapContentFormatUtil.java | 33 + .../coap/client/TbCoapObservationState.java | 34 + .../efento/CoapEfentoTransportResource.java | 284 + .../efento/adaptor/EfentoCoapAdaptor.java | 43 + .../coap/efento/utils/CoapEfentoUtils.java | 53 + .../main/proto/proto_measurement_types.proto | 97 + .../src/main/proto/proto_measurements.proto | 121 + common/transport/http/pom.xml | 81 + .../transport/http/DeviceApiController.java | 628 + .../transport/http/HttpTransportContext.java | 58 + common/transport/lwm2m/pom.xml | 124 + .../LwM2MTransportBootstrapService.java | 133 + .../secure/LwM2MBootstrapConfig.java | 137 + .../secure/LwM2MBootstrapServers.java | 27 + .../secure/LwM2MServerBootstrap.java | 59 + .../LwM2mDefaultBootstrapSessionManager.java | 250 + ...LwM2MDtlsBootstrapCertificateVerifier.java | 162 + .../LwM2MBootstrapClientInstanceIds.java | 31 + ...LwM2MBootstrapConfigStoreTaskProvider.java | 341 + .../store/LwM2MBootstrapSecurityStore.java | 213 + .../store/LwM2MBootstrapTaskProvider.java | 26 + .../store/LwM2MConfigurationChecker.java | 83 + .../LwM2MInMemoryBootstrapConfigStore.java | 99 + .../lwm2m/config/LwM2MSecureServerConfig.java | 34 + .../config/LwM2MTransportBootstrapConfig.java | 69 + .../config/LwM2MTransportServerConfig.java | 136 + .../lwm2m/config/TbLwM2mVersion.java | 69 + ...LwM2mCredentialsSecurityInfoValidator.java | 178 + .../lwm2m/secure/LwM2mRPkCredentials.java | 85 + .../lwm2m/secure/TbLwM2MAuthorizer.java | 82 + .../TbLwM2MDtlsCertificateVerifier.java | 194 + .../lwm2m/secure/TbLwM2MSecurityInfo.java | 40 + .../lwm2m/secure/TbX509DtlsSessionInfo.java | 29 + .../credentials/LwM2MClientCredentials.java | 26 + .../AbstractLwM2mTransportResource.java | 43 + .../server/DefaultLwM2mTransportService.java | 174 + .../lwm2m/server/LwM2MNetworkConfig.java | 110 + .../lwm2m/server/LwM2MOperationType.java | 94 + .../lwm2m/server/LwM2MTransportService.java | 22 + .../lwm2m/server/LwM2mOtaConvert.java | 25 + .../lwm2m/server/LwM2mQueuedRequest.java | 20 + .../lwm2m/server/LwM2mServerListener.java | 137 + .../lwm2m/server/LwM2mSessionMsgListener.java | 117 + .../server/LwM2mTransportCoapResource.java | 162 + .../lwm2m/server/LwM2mTransportContext.java | 32 + .../server/LwM2mTransportServerHelper.java | 243 + .../server/LwM2mVersionedModelProvider.java | 161 + .../server/adaptors/LwM2MJsonAdaptor.java | 70 + .../adaptors/LwM2MTransportAdaptor.java | 31 + .../DefaultLwM2MAttributesService.java | 319 + .../attributes/LwM2MAttributesService.java | 34 + .../server/client/LwM2MAuthException.java | 22 + .../lwm2m/server/client/LwM2MClientState.java | 22 + .../client/LwM2MClientStateException.java | 31 + .../lwm2m/server/client/LwM2mClient.java | 463 + .../server/client/LwM2mClientContext.java | 72 + .../server/client/LwM2mClientContextImpl.java | 535 + .../lwm2m/server/client/ModelObject.java | 46 + .../client/ParametersAnalyzeResult.java | 32 + .../lwm2m/server/client/ResourceValue.java | 62 + .../client/ResultsAddKeyValueProto.java | 34 + .../common/LwM2MExecutorAwareService.java | 40 + .../AbstractTbLwM2MRequestCallback.java | 46 + ...bstractTbLwM2MTargetedDownlinkRequest.java | 32 + .../DefaultLwM2mDownlinkMsgHandler.java | 657 + .../downlink/DownlinkRequestCallback.java | 30 + .../server/downlink/HasContentFormat.java | 29 + .../lwm2m/server/downlink/HasVersionedId.java | 28 + .../server/downlink/HasVersionedIds.java | 35 + .../downlink/LwM2mDownlinkMsgHandler.java | 79 + .../TbLwM2MCancelAllObserveCallback.java | 37 + .../downlink/TbLwM2MCancelAllRequest.java | 37 + .../TbLwM2MCancelObserveCallback.java | 40 + .../downlink/TbLwM2MCancelObserveRequest.java | 35 + .../server/downlink/TbLwM2MCreateRequest.java | 47 + .../TbLwM2MCreateResponseCallback.java | 36 + .../downlink/TbLwM2MDeleteCallback.java | 29 + .../server/downlink/TbLwM2MDeleteRequest.java | 36 + .../downlink/TbLwM2MDiscoverAllRequest.java | 39 + .../downlink/TbLwM2MDiscoverCallback.java | 29 + .../downlink/TbLwM2MDiscoverRequest.java | 36 + .../downlink/TbLwM2MDownlinkRequest.java | 26 + .../downlink/TbLwM2MExecuteCallback.java | 29 + .../downlink/TbLwM2MExecuteRequest.java | 41 + .../server/downlink/TbLwM2MLatchCallback.java | 45 + .../downlink/TbLwM2MObserveAllRequest.java | 41 + .../downlink/TbLwM2MObserveCallback.java | 37 + .../downlink/TbLwM2MObserveRequest.java | 45 + .../server/downlink/TbLwM2MReadCallback.java | 57 + .../server/downlink/TbLwM2MReadRequest.java | 45 + .../downlink/TbLwM2MTargetedCallback.java | 69 + .../TbLwM2MUplinkTargetedCallback.java | 38 + .../TbLwM2MWriteAttributesCallback.java | 29 + .../TbLwM2MWriteAttributesRequest.java | 42 + .../downlink/TbLwM2MWriteReplaceRequest.java | 45 + .../TbLwM2MWriteResponseCallback.java | 38 + .../downlink/TbLwM2MWriteUpdateRequest.java | 45 + ...LwM2MTargetedDownlinkCompositeRequest.java | 34 + .../TbLwM2MReadCompositeCallback.java | 39 + .../TbLwM2MReadCompositeRequest.java | 51 + .../TbLwM2MWriteCompositeRequest.java | 46 + ...TbLwM2MWriteResponseCompositeCallback.java | 37 + .../log/DefaultLwM2MTelemetryLogService.java | 45 + .../server/log/LwM2MTelemetryLogService.java | 24 + .../lwm2m/server/model/LwM2MModelConfig.java | 92 + .../server/model/LwM2MModelConfigService.java | 29 + .../model/LwM2MModelConfigServiceImpl.java | 235 + .../ota/DefaultLwM2MOtaUpdateService.java | 721 ++ .../lwm2m/server/ota/LwM2MClientOtaInfo.java | 113 + .../lwm2m/server/ota/LwM2MClientOtaState.java | 22 + .../server/ota/LwM2MOtaUpdateService.java | 60 + .../ota/firmware/FirmwareDeliveryMethod.java | 48 + .../ota/firmware/FirmwareUpdateResult.java | 75 + .../ota/firmware/FirmwareUpdateState.java | 60 + .../ota/firmware/LwM2MClientFwOtaInfo.java | 58 + .../firmware/LwM2MFirmwareUpdateStrategy.java | 48 + .../ota/software/LwM2MClientSwOtaInfo.java | 55 + .../software/LwM2MSoftwareUpdateStrategy.java | 48 + .../ota/software/SoftwareUpdateResult.java | 89 + .../ota/software/SoftwareUpdateState.java | 66 + .../rpc/DefaultLwM2MRpcRequestHandler.java | 428 + .../server/rpc/LwM2MRpcRequestHandler.java | 29 + .../server/rpc/LwM2MRpcRequestHeader.java | 28 + .../server/rpc/LwM2MRpcResponseBody.java | 31 + .../rpc/RpcCancelAllObserveCallback.java | 35 + .../server/rpc/RpcCancelObserveCallback.java | 35 + .../lwm2m/server/rpc/RpcCreateRequest.java | 31 + .../server/rpc/RpcCreateResponseCallback.java | 38 + .../lwm2m/server/rpc/RpcDiscoverCallback.java | 41 + .../rpc/RpcDownlinkRequestCallbackProxy.java | 121 + .../server/rpc/RpcEmptyResponseCallback.java | 37 + .../lwm2m/server/rpc/RpcLinkSetCallback.java | 36 + .../server/rpc/RpcLwM2MDownlinkCallback.java | 51 + .../server/rpc/RpcReadResponseCallback.java | 39 + .../server/rpc/RpcWriteAttributesRequest.java | 28 + .../server/rpc/RpcWriteReplaceRequest.java | 27 + .../server/rpc/RpcWriteUpdateRequest.java | 28 + .../composite/RpcReadCompositeRequest.java | 28 + .../RpcReadResponseCompositeCallback.java | 40 + .../composite/RpcWriteCompositeRequest.java | 29 + .../session/DefaultLwM2MSessionManager.java | 71 + .../server/session/LwM2MSessionManager.java | 27 + .../store/TbDummyLwM2MClientOtaInfoStore.java | 42 + .../server/store/TbDummyLwM2MClientStore.java | 43 + .../store/TbDummyLwM2MModelConfigStore.java | 38 + .../server/store/TbEditableSecurityStore.java | 27 + .../server/store/TbInMemorySecurityStore.java | 137 + .../TbL2M2MDtlsSessionInMemoryStore.java | 40 + .../store/TbLwM2MClientOtaInfoStore.java | 30 + .../server/store/TbLwM2MClientStore.java | 31 + .../store/TbLwM2MDtlsSessionRedisStore.java | 67 + .../server/store/TbLwM2MDtlsSessionStore.java | 29 + .../server/store/TbLwM2MModelConfigStore.java | 28 + .../store/TbLwM2mRedisClientOtaInfoStore.java | 66 + .../store/TbLwM2mRedisRegistrationStore.java | 794 ++ .../store/TbLwM2mRedisSecurityStore.java | 168 + .../server/store/TbLwM2mSecurityStore.java | 123 + .../server/store/TbLwM2mStoreFactory.java | 76 + .../server/store/TbMainSecurityStore.java | 29 + .../server/store/TbRedisLwM2MClientStore.java | 102 + .../store/TbRedisLwM2MModelConfigStore.java | 79 + .../lwm2m/server/store/TbSecurityStore.java | 25 + .../server/store/util/LwM2MClientSerDes.java | 349 + .../uplink/DefaultLwM2mUplinkMsgHandler.java | 1009 ++ .../lwm2m/server/uplink/LwM2mTypeServer.java | 39 + .../server/uplink/LwM2mUplinkMsgHandler.java | 73 + .../lwm2m/utils/LwM2MTransportUtil.java | 523 + .../lwm2m/utils/LwM2mValueConverterImpl.java | 192 + .../lwm2m/server/client/LwM2mClientTest.java | 38 + .../store/util/LwM2MClientSerDesTest.java | 101 + common/transport/mqtt/pom.xml | 102 + .../mqtt/MqttSslHandlerProvider.java | 184 + .../transport/mqtt/MqttTransportContext.java | 106 + .../transport/mqtt/MqttTransportHandler.java | 1116 ++ .../mqtt/MqttTransportServerInitializer.java | 64 + .../transport/mqtt/MqttTransportService.java | 125 + .../server/transport/mqtt/TopicType.java | 46 + .../BackwardCompatibilityAdaptor.java | 153 + .../mqtt/adaptors/JsonMqttAdaptor.java | 274 + .../mqtt/adaptors/MqttTransportAdaptor.java | 91 + .../mqtt/adaptors/ProtoMqttAdaptor.java | 222 + .../transport/mqtt/limits/IpFilter.java | 48 + .../transport/mqtt/limits/ProxyIpFilter.java | 77 + .../mqtt/session/DeviceSessionCtx.java | 258 + .../mqtt/session/GatewayDeviceSessionCtx.java | 149 + .../mqtt/session/GatewaySessionHandler.java | 749 ++ .../MqttDeviceAwareSessionContext.java | 62 + .../mqtt/session/MqttTopicMatcher.java | 55 + .../mqtt/util/AlwaysTrueTopicFilter.java | 27 + .../mqtt/util/EqualsTopicFilter.java | 29 + .../transport/mqtt/util/MqttTopicFilter.java | 22 + .../mqtt/util/MqttTopicFilterFactory.java | 58 + .../transport/mqtt/util/RegexTopicFilter.java | 35 + .../mqtt/MqttTransportHandlerTest.java | 209 + .../session/GatewaySessionHandlerTest.java | 61 + .../mqtt/util/MqttTopicFilterFactoryTest.java | 69 + common/transport/pom.xml | 46 + common/transport/snmp/pom.xml | 68 + .../transport/snmp/SnmpTransportContext.java | 275 + .../ServiceListChangedEventListener.java | 35 + .../event/SnmpTransportListChangedEvent.java | 24 + ...SnmpTransportListChangedEventListener.java | 34 + .../transport/snmp/service/PduService.java | 171 + .../service/ProtoTransportEntityService.java | 88 + .../snmp/service/SnmpAuthService.java | 121 + .../SnmpTransportBalancingService.java | 92 + .../snmp/service/SnmpTransportService.java | 363 + .../snmp/session/DeviceSessionContext.java | 152 + .../transport/snmp/SnmpDeviceSimulatorV2.java | 198 + .../transport/snmp/SnmpDeviceSimulatorV3.java | 701 ++ .../server/transport/snmp/SnmpTestV2.java | 49 + .../server/transport/snmp/SnmpTestV3.java | 46 + .../snmp-device-profile-transport-config.json | 43 + .../snmp-device-transport-config-v3.json | 13 + .../snmp-device-transport-config.json | 6 + common/transport/transport-api/pom.xml | 147 + .../common/transport/DeviceDeletedEvent.java | 33 + .../transport/DeviceProfileUpdatedEvent.java | 31 + .../common/transport/DeviceUpdatedEvent.java | 28 + .../common/transport/SessionMsgListener.java | 62 + .../common/transport/TransportAdaptor.java | 22 + .../common/transport/TransportContext.java | 82 + .../TransportDeviceProfileCache.java | 36 + .../transport/TransportResourceCache.java | 31 + .../common/transport/TransportService.java | 147 + .../transport/TransportServiceCallback.java | 39 + .../TransportTenantProfileCache.java | 38 + .../transport/adaptor/AdaptorException.java | 38 + .../transport/adaptor/JsonConverter.java | 653 + .../adaptor/JsonConverterConfig.java | 37 + .../transport/adaptor/ProtoConverter.java | 206 + .../transport/auth/DeviceAuthResult.java | 58 + .../transport/auth/DeviceAuthService.java | 28 + .../transport/auth/DeviceProfileAware.java | 24 + .../GetOrCreateDeviceFromGatewayResponse.java | 29 + .../transport/auth/SessionInfoCreator.java | 52 + .../transport/auth/TransportDeviceInfo.java | 41 + .../ValidateDeviceCredentialsResponse.java | 35 + .../config/ssl/AbstractSslCredentials.java | 219 + .../config/ssl/KeystoreSslCredentials.java | 57 + .../config/ssl/PemSslCredentials.java | 143 + .../transport/config/ssl/SslCredentials.java | 53 + .../config/ssl/SslCredentialsConfig.java | 66 + .../config/ssl/SslCredentialsType.java | 21 + .../SslCredentialsWebServerCustomizer.java | 71 + .../DefaultTransportRateLimitService.java | 271 + .../limits/DummyTransportRateLimit.java | 35 + .../limits/EntityTransportRateLimits.java | 29 + .../limits/InetAddressRateLimitStats.java | 33 + .../limits/SimpleTransportRateLimit.java | 43 + .../transport/limits/TransportRateLimit.java | 26 + .../limits/TransportRateLimitService.java | 47 + .../profile/TenantProfileUpdateResult.java | 30 + .../DefaultTransportDeviceProfileCache.java | 124 + .../DefaultTransportResourceCache.java | 130 + .../service/DefaultTransportService.java | 1245 ++ .../DefaultTransportTenantProfileCache.java | 163 + .../transport/service/RpcRequestMetadata.java | 26 + .../service/SessionActivityData.java | 42 + .../transport/service/SessionMetaData.java | 57 + .../service/ToRuleEngineMsgEncoder.java | 29 + .../ToTransportMsgResponseDecoder.java | 33 + .../service/TransportApiRequestEncoder.java | 29 + .../service/TransportApiResponseDecoder.java | 33 + .../TransportQueueRoutingInfoService.java | 48 + .../TransportTenantRoutingInfoService.java | 44 + .../session/DeviceAwareSessionContext.java | 84 + .../transport/session/SessionContext.java | 34 + .../common/transport/util/JsonUtils.java | 59 + .../server/common/transport/util/SslUtil.java | 38 + .../src/main/proto/transport.proto | 101 + .../src/test/java/JsonConverterTest.java | 102 + common/util/pom.xml | 98 + .../util/AbstractListeningExecutor.java | 67 + .../common/util/AzureIotHubUtil.java | 99 + .../common/util/CollectionsUtil.java | 50 + .../thingsboard/common/util/DonAsynchron.java | 68 + .../thingsboard/common/util/JacksonUtil.java | 256 + .../org/thingsboard/common/util/KvUtil.java | 89 + .../util/LinkedHashMapRemoveEldest.java | 53 + .../common/util/ListeningExecutor.java | 42 + .../thingsboard/common/util/RegexUtils.java | 40 + .../thingsboard/common/util/TbStopWatch.java | 68 + .../common/util/ThingsBoardExecutors.java | 55 + ...hingsBoardForkJoinWorkerThreadFactory.java | 41 + .../common/util/ThingsBoardThreadFactory.java | 54 + .../common/util/JacksonUtilTest.java | 35 + .../util/LinkedHashMapRemoveEldestTest.java | 64 + common/version-control/pom.xml | 125 + .../sync/vc/ClusterVersionControlService.java | 22 + .../DefaultClusterVersionControlService.java | 583 + .../sync/vc/DefaultGitRepositoryService.java | 279 + .../server/service/sync/vc/GitRepository.java | 529 + .../service/sync/vc/GitRepositoryService.java | 70 + .../server/service/sync/vc/PendingCommit.java | 58 + .../sync/vc/VersionControlRequestCtx.java | 49 + dao/pom.xml | 266 + .../java/org/thingsboard/server/dao/Dao.java | 48 + .../org/thingsboard/server/dao/DaoUtil.java | 126 + .../server/dao/ExportableEntityDao.java | 35 + .../dao/ExportableEntityRepository.java | 24 + .../thingsboard/server/dao/JpaDaoConfig.java | 36 + .../server/dao/SqlTimeseriesDaoConfig.java | 33 + .../server/dao/SqlTsDaoConfig.java | 35 + .../server/dao/SqlTsLatestDaoConfig.java | 35 + .../server/dao/TenantEntityDao.java | 23 + .../server/dao/TenantEntityWithDataDao.java | 23 + .../server/dao/TimescaleDaoConfig.java | 35 + .../dao/TimescaleTsLatestDaoConfig.java | 35 + .../server/dao/alarm/AlarmDao.java | 70 + .../server/dao/alarm/BaseAlarmService.java | 414 + .../server/dao/aspect/DbCallStats.java | 59 + .../dao/aspect/DbCallStatsSnapshot.java | 38 + .../server/dao/aspect/MethodCallStats.java | 33 + .../dao/aspect/MethodCallStatsSnapshot.java | 25 + .../server/dao/aspect/SqlDaoCallsAspect.java | 242 + .../dao/asset/AssetCacheEvictEvent.java | 30 + .../server/dao/asset/AssetCacheKey.java | 42 + .../server/dao/asset/AssetCaffeineCache.java | 33 + .../server/dao/asset/AssetDao.java | 228 + .../dao/asset/AssetProfileCacheKey.java | 63 + .../dao/asset/AssetProfileCaffeineCache.java | 33 + .../server/dao/asset/AssetProfileDao.java | 46 + .../dao/asset/AssetProfileEvictEvent.java | 31 + .../dao/asset/AssetProfileRedisCache.java | 35 + .../dao/asset/AssetProfileServiceImpl.java | 286 + .../server/dao/asset/AssetRedisCache.java | 35 + .../server/dao/asset/AssetTypeFilter.java | 32 + .../server/dao/asset/BaseAssetService.java | 432 + .../dao/attributes/AttributeCacheKey.java | 39 + .../attributes/AttributeCaffeineCache.java | 33 + .../dao/attributes/AttributeRedisCache.java | 113 + .../server/dao/attributes/AttributeUtils.java | 39 + .../server/dao/attributes/AttributesDao.java | 47 + .../dao/attributes/BaseAttributesService.java | 102 + .../attributes/CachedAttributesService.java | 247 + .../server/dao/audit/AuditLogDao.java | 47 + .../server/dao/audit/AuditLogLevelFilter.java | 52 + .../server/dao/audit/AuditLogLevelMask.java | 34 + .../dao/audit/AuditLogLevelProperties.java | 41 + .../server/dao/audit/AuditLogServiceImpl.java | 406 + .../dao/audit/DummyAuditLogServiceImpl.java | 61 + .../server/dao/audit/sink/AuditLogSink.java | 23 + .../dao/audit/sink/DummyAuditLogSink.java | 29 + .../audit/sink/ElasticsearchAuditLogSink.java | 160 + .../dao/cache/CacheExecutorService.java | 33 + .../BaseComponentDescriptorService.java | 104 + .../dao/component/ComponentDescriptorDao.java | 48 + .../server/dao/customer/CustomerDao.java | 61 + .../dao/customer/CustomerServiceImpl.java | 181 + .../server/dao/dashboard/DashboardDao.java | 43 + .../dao/dashboard/DashboardInfoDao.java | 80 + .../dao/dashboard/DashboardServiceImpl.java | 350 + .../server/dao/device/ClaimDataInfo.java | 30 + .../DeviceCredentialsCaffeineCache.java | 33 + .../dao/device/DeviceCredentialsDao.java | 56 + .../device/DeviceCredentialsEvictEvent.java | 26 + .../device/DeviceCredentialsRedisCache.java | 35 + .../device/DeviceCredentialsServiceImpl.java | 404 + .../server/dao/device/DeviceDao.java | 281 + .../dao/device/DeviceProfileCacheKey.java | 63 + .../device/DeviceProfileCaffeineCache.java | 33 + .../server/dao/device/DeviceProfileDao.java | 48 + .../dao/device/DeviceProfileEvictEvent.java | 31 + .../dao/device/DeviceProfileRedisCache.java | 35 + .../dao/device/DeviceProfileServiceImpl.java | 308 + .../server/dao/device/DeviceServiceImpl.java | 708 ++ .../server/dao/edge/BaseEdgeEventService.java | 53 + .../server/dao/edge/EdgeCacheEvictEvent.java | 30 + .../server/dao/edge/EdgeCacheKey.java | 40 + .../server/dao/edge/EdgeCaffeineCache.java | 33 + .../thingsboard/server/dao/edge/EdgeDao.java | 173 + .../server/dao/edge/EdgeEventDao.java | 59 + .../server/dao/edge/EdgeRedisCache.java | 35 + .../server/dao/edge/EdgeServiceImpl.java | 522 + .../entity/AbstractCachedEntityService.java | 43 + .../dao/entity/AbstractEntityService.java | 127 + .../server/dao/entity/BaseEntityService.java | 254 + .../server/dao/entity/EntityQueryDao.java | 31 + .../dao/entityview/EntityViewCacheKey.java | 67 + .../dao/entityview/EntityViewCacheValue.java | 36 + .../entityview/EntityViewCaffeineCache.java | 32 + .../server/dao/entityview/EntityViewDao.java | 184 + .../dao/entityview/EntityViewEvictEvent.java | 35 + .../dao/entityview/EntityViewRedisCache.java | 34 + .../dao/entityview/EntityViewServiceImpl.java | 411 + .../server/dao/event/BaseEventService.java | 151 + .../server/dao/event/EventDao.java | 96 + .../dao/exception/BufferLimitException.java | 25 + .../exception/DataValidationException.java | 29 + .../dao/exception/DatabaseException.java | 38 + .../DeviceCredentialsValidationException.java | 22 + .../IncorrectParameterException.java | 30 + .../server/dao/model/BaseEntity.java | 30 + .../server/dao/model/BaseSqlEntity.java | 59 + .../server/dao/model/ModelConstants.java | 693 + .../server/dao/model/SearchTextEntity.java | 24 + .../thingsboard/server/dao/model/ToData.java | 31 + .../dao/model/sql/AbstractAlarmEntity.java | 205 + .../dao/model/sql/AbstractAssetEntity.java | 156 + .../dao/model/sql/AbstractDeviceEntity.java | 180 + .../dao/model/sql/AbstractEdgeEntity.java | 159 + .../model/sql/AbstractEntityViewEntity.java | 187 + .../dao/model/sql/AbstractTenantEntity.java | 158 + .../dao/model/sql/AbstractTsKvEntity.java | 129 + .../model/sql/AbstractWidgetTypeEntity.java | 86 + .../dao/model/sql/AdminSettingsEntity.java | 81 + .../server/dao/model/sql/AlarmEntity.java | 53 + .../server/dao/model/sql/AlarmInfoEntity.java | 40 + .../dao/model/sql/ApiUsageStateEntity.java | 120 + .../server/dao/model/sql/AssetEntity.java | 49 + .../server/dao/model/sql/AssetInfoEntity.java | 62 + .../dao/model/sql/AssetProfileEntity.java | 136 + .../model/sql/AttributeKvCompositeKey.java | 49 + .../dao/model/sql/AttributeKvEntity.java | 85 + .../server/dao/model/sql/AuditLogEntity.java | 154 + .../model/sql/ComponentDescriptorEntity.java | 112 + .../server/dao/model/sql/CustomerEntity.java | 139 + .../server/dao/model/sql/DashboardEntity.java | 147 + .../dao/model/sql/DashboardInfoEntity.java | 132 + .../model/sql/DeviceCredentialsEntity.java | 84 + .../server/dao/model/sql/DeviceEntity.java | 52 + .../dao/model/sql/DeviceInfoEntity.java | 62 + .../dao/model/sql/DeviceProfileEntity.java | 198 + .../server/dao/model/sql/EdgeEntity.java | 48 + .../server/dao/model/sql/EdgeEventEntity.java | 129 + .../server/dao/model/sql/EdgeInfoEntity.java | 59 + .../model/sql/EntityAlarmCompositeKey.java | 42 + .../dao/model/sql/EntityAlarmEntity.java | 99 + .../dao/model/sql/EntityViewEntity.java | 47 + .../dao/model/sql/EntityViewInfoEntity.java | 58 + .../dao/model/sql/ErrorEventEntity.java | 64 + .../server/dao/model/sql/EventEntity.java | 99 + .../dao/model/sql/LifecycleEventEntity.java | 69 + ...Auth2ClientRegistrationTemplateEntity.java | 171 + .../dao/model/sql/OAuth2DomainEntity.java | 76 + .../dao/model/sql/OAuth2MobileEntity.java | 72 + .../dao/model/sql/OAuth2ParamsEntity.java | 65 + .../model/sql/OAuth2RegistrationEntity.java | 221 + .../dao/model/sql/OtaPackageEntity.java | 175 + .../dao/model/sql/OtaPackageInfoEntity.java | 190 + .../server/dao/model/sql/QueueEntity.java | 116 + .../dao/model/sql/RelationCompositeKey.java | 50 + .../server/dao/model/sql/RelationEntity.java | 112 + .../server/dao/model/sql/RpcEntity.java | 109 + .../model/sql/RuleChainDebugEventEntity.java | 63 + .../server/dao/model/sql/RuleChainEntity.java | 134 + .../model/sql/RuleNodeDebugEventEntity.java | 109 + .../server/dao/model/sql/RuleNodeEntity.java | 119 + .../dao/model/sql/RuleNodeStateEntity.java | 77 + .../dao/model/sql/StatisticsEventEntity.java | 64 + .../dao/model/sql/TbResourceEntity.java | 105 + .../dao/model/sql/TbResourceInfoEntity.java | 91 + .../server/dao/model/sql/TenantEntity.java | 47 + .../dao/model/sql/TenantInfoEntity.java | 49 + .../dao/model/sql/TenantProfileEntity.java | 105 + .../dao/model/sql/UserAuthSettingsEntity.java | 81 + .../dao/model/sql/UserCredentialsEntity.java | 85 + .../server/dao/model/sql/UserEntity.java | 125 + .../model/sql/WidgetTypeDetailsEntity.java | 69 + .../dao/model/sql/WidgetTypeEntity.java | 55 + .../dao/model/sql/WidgetTypeInfoEntity.java | 56 + .../dao/model/sql/WidgetsBundleEntity.java | 107 + .../sqlts/dictionary/TsKvDictionary.java | 45 + .../TsKvDictionaryCompositeKey.java | 34 + .../sqlts/latest/TsKvLatestCompositeKey.java | 36 + .../model/sqlts/latest/TsKvLatestEntity.java | 87 + .../ts/TimescaleTsKvCompositeKey.java | 37 + .../timescale/ts/TimescaleTsKvEntity.java | 184 + .../dao/model/sqlts/ts/TsKvCompositeKey.java | 37 + .../server/dao/model/sqlts/ts/TsKvEntity.java | 102 + .../dao/nosql/CassandraAbstractAsyncDao.java | 73 + .../dao/nosql/CassandraAbstractDao.java | 109 + .../CassandraBufferedRateReadExecutor.java | 98 + .../CassandraBufferedRateWriteExecutor.java | 95 + .../HybridClientRegistrationRepository.java | 59 + .../OAuth2ClientRegistrationTemplateDao.java | 29 + .../OAuth2ConfigTemplateServiceImpl.java | 90 + .../dao/oauth2/OAuth2Configuration.java | 30 + .../server/dao/oauth2/OAuth2DomainDao.java | 28 + .../server/dao/oauth2/OAuth2MobileDao.java | 28 + .../server/dao/oauth2/OAuth2ParamsDao.java | 23 + .../dao/oauth2/OAuth2RegistrationDao.java | 34 + .../server/dao/oauth2/OAuth2ServiceImpl.java | 291 + .../server/dao/oauth2/OAuth2Utils.java | 130 + .../server/dao/ota/BaseOtaPackageService.java | 237 + .../dao/ota/OtaPackageCacheEvictEvent.java | 28 + .../server/dao/ota/OtaPackageCacheKey.java | 39 + .../dao/ota/OtaPackageCaffeineCache.java | 33 + .../server/dao/ota/OtaPackageDao.java | 25 + .../server/dao/ota/OtaPackageInfoDao.java | 35 + .../server/dao/ota/OtaPackageRedisCache.java | 35 + .../server/dao/queue/BaseQueueService.java | 146 + .../server/dao/queue/QueueDao.java | 38 + .../dao/relation/BaseRelationService.java | 640 + .../dao/relation/EntityRelationEvent.java | 38 + .../server/dao/relation/RelationCacheKey.java | 68 + .../dao/relation/RelationCacheValue.java | 38 + .../dao/relation/RelationCaffeineCache.java | 32 + .../server/dao/relation/RelationDao.java | 72 + .../dao/relation/RelationRedisCache.java | 34 + .../dao/resource/BaseResourceService.java | 165 + .../server/dao/resource/TbResourceDao.java | 42 + .../dao/resource/TbResourceInfoDao.java | 31 + .../server/dao/rpc/BaseRpcService.java | 108 + .../thingsboard/server/dao/rpc/RpcDao.java | 34 + .../server/dao/rule/BaseRuleChainService.java | 757 ++ .../dao/rule/BaseRuleNodeStateService.java | 119 + .../server/dao/rule/RuleChainDao.java | 84 + .../server/dao/rule/RuleNodeDao.java | 40 + .../server/dao/rule/RuleNodeStateDao.java | 37 + .../dao/service/ConstraintValidator.java | 80 + .../server/dao/service/DataValidator.java | 161 + .../server/dao/service/NoXssValidator.java | 68 + .../server/dao/service/PaginatedRemover.java | 43 + .../dao/service/StringLengthValidator.java | 41 + .../dao/service/TimePaginatedRemover.java | 43 + .../server/dao/service/Validator.java | 160 + .../AbstractHasOtaPackageValidator.java | 68 + .../validator/AdminSettingsDataValidator.java | 62 + .../service/validator/AlarmDataValidator.java | 55 + .../validator/ApiUsageDataValidator.java | 50 + .../service/validator/AssetDataValidator.java | 98 + .../validator/AssetProfileDataValidator.java | 109 + .../validator/AuditLogDataValidator.java | 39 + .../BaseOtaPackageDataValidator.java | 123 + ...ientRegistrationTemplateDataValidator.java | 52 + .../ComponentDescriptorDataValidator.java | 43 + .../validator/CustomerDataValidator.java | 94 + .../validator/DashboardDataValidator.java | 66 + .../DeviceCredentialsDataValidator.java | 76 + .../validator/DeviceDataValidator.java | 101 + .../validator/DeviceProfileDataValidator.java | 366 + .../service/validator/EdgeDataValidator.java | 83 + .../validator/EdgeEventDataValidator.java | 36 + .../validator/EntityViewDataValidator.java | 87 + .../service/validator/EventDataValidator.java | 40 + .../validator/OtaPackageDataValidator.java | 106 + .../OtaPackageInfoDataValidator.java | 41 + .../dao/service/validator/QueueValidator.java | 118 + .../validator/ResourceDataValidator.java | 80 + .../validator/RuleChainDataValidator.java | 151 + .../validator/TenantDataValidator.java | 52 + .../validator/TenantProfileDataValidator.java | 151 + .../UserCredentialsDataValidator.java | 68 + .../service/validator/UserDataValidator.java | 154 + .../validator/WidgetTypeDataValidator.java | 97 + .../validator/WidgetsBundleDataValidator.java | 80 + .../server/dao/settings/AdminSettingsDao.java | 46 + .../settings/AdminSettingsServiceImpl.java | 86 + .../server/dao/sql/JpaAbstractDao.java | 124 + ...paAbstractDaoListeningExecutorService.java | 51 + .../dao/sql/JpaAbstractSearchTextDao.java | 30 + .../server/dao/sql/JpaExecutorService.java | 33 + .../sql/ScheduledLogExecutorComponent.java | 47 + .../server/dao/sql/TbSqlBlockingQueue.java | 124 + .../dao/sql/TbSqlBlockingQueueParams.java | 33 + .../dao/sql/TbSqlBlockingQueueWrapper.java | 65 + .../server/dao/sql/TbSqlQueue.java | 31 + .../server/dao/sql/TbSqlQueueElement.java | 35 + .../server/dao/sql/alarm/AlarmRepository.java | 145 + .../dao/sql/alarm/EntityAlarmRepository.java | 37 + .../server/dao/sql/alarm/JpaAlarmDao.java | 203 + .../dao/sql/asset/AssetProfileRepository.java | 63 + .../server/dao/sql/asset/AssetRepository.java | 203 + .../server/dao/sql/asset/JpaAssetDao.java | 286 + .../dao/sql/asset/JpaAssetProfileDao.java | 126 + .../AttributeKvInsertRepository.java | 178 + .../sql/attributes/AttributeKvRepository.java | 63 + .../dao/sql/attributes/JpaAttributeDao.java | 192 + .../SqlAttributesInsertRepository.java | 27 + .../dao/sql/audit/AuditLogRepository.java | 110 + .../server/dao/sql/audit/JpaAuditLogDao.java | 185 + ...ctComponentDescriptorInsertRepository.java | 91 + .../ComponentDescriptorInsertRepository.java | 24 + .../ComponentDescriptorRepository.java | 55 + .../JpaBaseComponentDescriptorDao.java | 117 + ...qlComponentDescriptorInsertRepository.java | 53 + .../dao/sql/customer/CustomerRepository.java | 46 + .../dao/sql/customer/JpaCustomerDao.java | 102 + .../dashboard/DashboardInfoRepository.java | 75 + .../sql/dashboard/DashboardRepository.java | 43 + .../dao/sql/dashboard/JpaDashboardDao.java | 88 + .../sql/dashboard/JpaDashboardInfoDao.java | 121 + .../device/DefaultNativeDeviceRepository.java | 64 + .../device/DeviceCredentialsRepository.java | 31 + .../sql/device/DeviceProfileRepository.java | 74 + .../dao/sql/device/DeviceRepository.java | 255 + .../sql/device/JpaDeviceCredentialsDao.java | 69 + .../server/dao/sql/device/JpaDeviceDao.java | 355 + .../dao/sql/device/JpaDeviceProfileDao.java | 144 + .../sql/device/NativeDeviceRepository.java | 26 + .../sql/edge/EdgeEventInsertRepository.java | 78 + .../dao/sql/edge/EdgeEventRepository.java | 58 + .../server/dao/sql/edge/EdgeRepository.java | 133 + .../dao/sql/edge/JpaBaseEdgeEventDao.java | 233 + .../server/dao/sql/edge/JpaEdgeDao.java | 203 + .../sql/entityview/EntityViewRepository.java | 146 + .../dao/sql/entityview/JpaEntityViewDao.java | 232 + .../dao/sql/event/ErrorEventRepository.java | 114 + .../dao/sql/event/EventCleanupRepository.java | 23 + .../dao/sql/event/EventInsertRepository.java | 239 + .../event/EventPartitionConfiguration.java | 48 + .../server/dao/sql/event/EventRepository.java | 39 + .../server/dao/sql/event/JpaBaseEventDao.java | 441 + .../sql/event/LifecycleEventRepository.java | 118 + .../event/RuleChainDebugEventRepository.java | 117 + .../event/RuleNodeDebugEventRepository.java | 153 + .../sql/event/SqlEventCleanupRepository.java | 101 + .../sql/event/StatisticsEventRepository.java | 120 + ...paOAuth2ClientRegistrationTemplateDao.java | 62 + .../dao/sql/oauth2/JpaOAuth2DomainDao.java | 54 + .../dao/sql/oauth2/JpaOAuth2MobileDao.java | 54 + .../dao/sql/oauth2/JpaOAuth2ParamsDao.java | 49 + .../sql/oauth2/JpaOAuth2RegistrationDao.java | 66 + ...2ClientRegistrationTemplateRepository.java | 27 + .../sql/oauth2/OAuth2DomainRepository.java | 29 + .../sql/oauth2/OAuth2MobileRepository.java | 28 + .../sql/oauth2/OAuth2ParamsRepository.java | 24 + .../oauth2/OAuth2RegistrationRepository.java | 53 + .../server/dao/sql/ota/JpaOtaPackageDao.java | 60 + .../dao/sql/ota/JpaOtaPackageInfoDao.java | 95 + .../dao/sql/ota/OtaPackageInfoRepository.java | 59 + .../dao/sql/ota/OtaPackageRepository.java | 28 + .../dao/sql/query/AlarmDataAdapter.java | 111 + .../dao/sql/query/AlarmQueryRepository.java | 31 + .../query/DefaultAlarmQueryRepository.java | 334 + .../query/DefaultEntityQueryRepository.java | 870 ++ .../sql/query/DefaultQueryLogComponent.java | 110 + .../dao/sql/query/EntityDataAdapter.java | 107 + .../dao/sql/query/EntityKeyMapping.java | 655 + .../dao/sql/query/EntityQueryRepository.java | 33 + .../dao/sql/query/JpaEntityQueryDao.java | 43 + .../server/dao/sql/query/QueryContext.java | 153 + .../dao/sql/query/QueryLogComponent.java | 21 + .../dao/sql/query/QuerySecurityContext.java | 39 + .../server/dao/sql/queue/JpaQueueDao.java | 88 + .../server/dao/sql/queue/QueueRepository.java | 43 + .../dao/sql/relation/JpaRelationDao.java | 235 + .../JpaRelationQueryExecutorService.java | 33 + .../relation/RelationInsertRepository.java | 28 + .../dao/sql/relation/RelationRepository.java | 85 + .../relation/SqlRelationInsertRepository.java | 104 + .../dao/sql/resource/JpaTbResourceDao.java | 108 + .../sql/resource/JpaTbResourceInfoDao.java | 71 + .../resource/TbResourceInfoRepository.java | 49 + .../sql/resource/TbResourceRepository.java | 83 + .../server/dao/sql/rpc/JpaRpcDao.java | 80 + .../server/dao/sql/rpc/RpcRepository.java | 38 + .../server/dao/sql/rule/JpaRuleChainDao.java | 137 + .../server/dao/sql/rule/JpaRuleNodeDao.java | 88 + .../dao/sql/rule/JpaRuleNodeStateDao.java | 73 + .../dao/sql/rule/RuleChainRepository.java | 73 + .../dao/sql/rule/RuleNodeRepository.java | 51 + .../dao/sql/rule/RuleNodeStateRepository.java | 38 + .../sql/settings/AdminSettingsRepository.java | 36 + .../dao/sql/settings/JpaAdminSettingsDao.java | 72 + .../server/dao/sql/tenant/JpaTenantDao.java | 98 + .../dao/sql/tenant/JpaTenantProfileDao.java | 82 + .../sql/tenant/TenantProfileRepository.java | 55 + .../dao/sql/tenant/TenantRepository.java | 56 + .../usagerecord/ApiUsageStateRepository.java | 47 + .../sql/usagerecord/JpaApiUsageStateDao.java | 80 + .../dao/sql/user/JpaUserAuthSettingsDao.java | 58 + .../dao/sql/user/JpaUserCredentialsDao.java | 65 + .../server/dao/sql/user/JpaUserDao.java | 113 + .../sql/user/UserAuthSettingsRepository.java | 38 + .../sql/user/UserCredentialsRepository.java | 33 + .../server/dao/sql/user/UserRepository.java | 55 + .../dao/sql/widget/JpaWidgetTypeDao.java | 85 + .../dao/sql/widget/JpaWidgetsBundleDao.java | 122 + .../dao/sql/widget/WidgetTypeRepository.java | 49 + .../sql/widget/WidgetsBundleRepository.java | 59 + ...stractChunkedAggregationTimeseriesDao.java | 189 + .../dao/sqlts/AbstractSqlTimeseriesDao.java | 122 + .../dao/sqlts/AggregationTimeseriesDao.java | 30 + .../sqlts/BaseAbstractSqlTimeseriesDao.java | 107 + .../server/dao/sqlts/EntityContainer.java | 29 + .../dao/sqlts/SqlTimeseriesLatestDao.java | 271 + .../thingsboard/server/dao/sqlts/TsKey.java | 26 + .../dictionary/TsKvDictionaryRepository.java | 30 + .../insert/AbstractInsertRepository.java | 47 + .../dao/sqlts/insert/InsertTsRepository.java | 26 + .../latest/InsertLatestTsRepository.java | 26 + .../sql/SqlLatestInsertTsRepository.java | 172 + .../insert/sql/SqlInsertTsRepository.java | 88 + .../insert/sql/SqlPartitioningRepository.java | 157 + .../TimescaleInsertTsRepository.java | 88 + .../latest/SearchTsKvLatestRepository.java | 46 + .../sqlts/latest/TsKvLatestRepository.java | 44 + .../dao/sqlts/sql/JpaSqlTimeseriesDao.java | 165 + .../timescale/AggregationRepository.java | 124 + .../timescale/TimescaleTimeseriesDao.java | 225 + .../timescale/TsKvTimescaleRepository.java | 52 + .../server/dao/sqlts/ts/TsKvRepository.java | 129 + .../tenant/DefaultTbTenantProfileCache.java | 140 + .../dao/tenant/TenantCaffeineCache.java | 34 + .../server/dao/tenant/TenantDao.java | 54 + .../server/dao/tenant/TenantEvictEvent.java | 25 + .../dao/tenant/TenantExistsCaffeineCache.java | 35 + .../dao/tenant/TenantExistsRedisCache.java | 35 + .../dao/tenant/TenantProfileCacheKey.java | 53 + .../tenant/TenantProfileCaffeineCache.java | 33 + .../server/dao/tenant/TenantProfileDao.java | 41 + .../dao/tenant/TenantProfileEvictEvent.java | 25 + .../dao/tenant/TenantProfileRedisCache.java | 35 + .../dao/tenant/TenantProfileServiceImpl.java | 221 + .../server/dao/tenant/TenantRedisCache.java | 36 + .../server/dao/tenant/TenantServiceImpl.java | 259 + .../AbstractCassandraBaseTimeseriesDao.java | 119 + .../AggregatePartitionsFunction.java | 306 + .../dao/timeseries/BaseTimeseriesService.java | 310 + .../CassandraBaseTimeseriesDao.java | 784 ++ .../CassandraBaseTimeseriesLatestDao.java | 237 + .../CassandraPartitionCacheKey.java | 30 + .../CassandraTsPartitionsCache.java | 46 + .../dao/timeseries/NoSqlTsPartitionDate.java | 71 + .../server/dao/timeseries/QueryCursor.java | 60 + .../timeseries/SimpleListenableFuture.java | 29 + .../server/dao/timeseries/SqlPartition.java | 40 + .../dao/timeseries/SqlTsPartitionDate.java | 85 + .../server/dao/timeseries/TimeseriesDao.java | 42 + .../dao/timeseries/TimeseriesLatestDao.java | 52 + .../dao/timeseries/TsInsertExecutorType.java | 37 + .../dao/timeseries/TsKvQueryCursor.java | 81 + .../dao/usagerecord/ApiUsageStateDao.java | 53 + .../usagerecord/ApiUsageStateServiceImpl.java | 164 + .../server/dao/user/UserAuthSettingsDao.java | 28 + .../server/dao/user/UserCredentialsDao.java | 61 + .../thingsboard/server/dao/user/UserDao.java | 82 + .../server/dao/user/UserServiceImpl.java | 400 + .../util/AbstractBufferedRateExecutor.java | 342 + .../server/dao/util/AsyncRateLimiter.java | 25 + .../server/dao/util/AsyncTaskContext.java | 34 + .../server/dao/util/BufferedRateExecutor.java | 27 + .../dao/util/BufferedRateExecutorStats.java | 90 + .../dao/util/TenantRateLimitException.java | 19 + .../AbstractJsonSqlTypeDescriptor.java | 78 + .../mapping/JsonBinarySqlTypeDescriptor.java | 52 + .../dao/util/mapping/JsonBinaryType.java | 46 + .../mapping/JsonStringSqlTypeDescriptor.java | 61 + .../dao/util/mapping/JsonStringType.java | 48 + .../dao/util/mapping/JsonTypeDescriptor.java | 96 + .../server/dao/widget/WidgetTypeDao.java | 86 + .../dao/widget/WidgetTypeServiceImpl.java | 115 + .../server/dao/widget/WidgetsBundleDao.java | 78 + .../dao/widget/WidgetsBundleServiceImpl.java | 168 + .../resources/cassandra/schema-keyspace.cql | 21 + .../resources/cassandra/schema-ts-latest.cql | 28 + .../main/resources/cassandra/schema-ts.cql | 38 + .../sql/schema-entities-idx-psql-addon.sql | 38 + .../resources/sql/schema-entities-idx.sql | 81 + .../main/resources/sql/schema-entities.sql | 778 ++ .../main/resources/sql/schema-timescale.sql | 157 + dao/src/main/resources/sql/schema-ts-psql.sql | 339 + dao/src/main/resources/xss-policy.xml | 162 + .../cassandra/io/sstable/Descriptor.java | 366 + .../io/sstable/format/SSTableFormat.java | 86 + .../apache/cassandra/io/util/FileUtils.java | 760 ++ .../server/dao/AbstractDaoServiceTest.java | 42 + .../server/dao/AbstractJpaDaoTest.java | 43 + .../server/dao/CustomCassandraCQLUnit.java | 88 + .../server/dao/NoSqlDaoServiceTestSuite.java | 42 + .../server/dao/PostgreSqlInitializer.java | 65 + .../server/dao/RedisSqlTestSuite.java | 51 + .../dao/TimescaleDaoServiceTestSuite.java | 28 + .../server/dao/TimescaleSqlInitializer.java | 65 + .../nosql/CassandraPartitionsCacheTest.java | 111 + .../dao/service/AbstractServiceTest.java | 302 + .../service/BaseAdminSettingsServiceTest.java | 70 + .../dao/service/BaseAlarmServiceTest.java | 697 ++ .../service/BaseApiUsageStateServiceTest.java | 65 + .../service/BaseAssetProfileServiceTest.java | 279 + .../dao/service/BaseAssetServiceTest.java | 810 ++ .../dao/service/BaseCustomerServiceTest.java | 266 + .../dao/service/BaseDashboardServiceTest.java | 429 + .../BaseDeviceCredentialsCacheTest.java | 162 + .../BaseDeviceCredentialsServiceTest.java | 187 + .../service/BaseDeviceProfileServiceTest.java | 373 + .../dao/service/BaseDeviceServiceTest.java | 976 ++ .../dao/service/BaseEdgeEventServiceTest.java | 158 + .../dao/service/BaseEdgeServiceTest.java | 663 + .../dao/service/BaseEntityServiceTest.java | 1858 +++ .../BaseOAuth2ConfigTemplateServiceTest.java | 135 + .../dao/service/BaseOAuth2ServiceTest.java | 660 + .../service/BaseOtaPackageServiceTest.java | 720 ++ .../dao/service/BaseQueueServiceTest.java | 517 + .../dao/service/BaseRelationCacheTest.java | 102 + .../dao/service/BaseRelationServiceTest.java | 646 + .../dao/service/BaseRuleChainServiceTest.java | 577 + .../service/BaseTenantProfileServiceTest.java | 319 + .../dao/service/BaseTenantServiceTest.java | 698 ++ .../dao/service/BaseUserServiceTest.java | 477 + .../service/BaseWidgetTypeServiceTest.java | 324 + .../service/BaseWidgetsBundleServiceTest.java | 430 + .../server/dao/service/DaoNoSqlTest.java | 33 + .../server/dao/service/DaoSqlTest.java | 33 + .../server/dao/service/DaoTimescaleTest.java | 33 + .../server/dao/service/DataValidatorTest.java | 63 + .../dao/service/NoXssValidatorTest.java | 61 + .../attributes/BaseAttributesServiceTest.java | 292 + .../sql/AttributesServiceSqlTest.java | 23 + .../service/event/BaseEventServiceTest.java | 136 + .../event/sql/EventServiceSqlTest.java | 23 + .../install/sql/EntitiesSchemaSqlTest.java | 72 + .../sql/AdminSettingsServiceSqlTest.java | 23 + .../dao/service/sql/AlarmServiceSqlTest.java | 23 + .../sql/ApiUsageStateServiceSqlTest.java | 23 + .../sql/AssetProfileServiceSqlTest.java | 23 + .../dao/service/sql/AssetServiceSqlTest.java | 23 + .../service/sql/CustomerServiceSqlTest.java | 23 + .../service/sql/DashboardServiceSqlTest.java | 23 + .../DeviceCredentialsCacheServiceSqlTest.java | 23 + .../sql/DeviceCredentialsServiceSqlTest.java | 23 + .../sql/DeviceProfileServiceSqlTest.java | 23 + .../dao/service/sql/DeviceServiceSqlTest.java | 23 + .../service/sql/EdgeEventServiceSqlTest.java | 23 + .../dao/service/sql/EdgeServiceSqlTest.java | 23 + .../dao/service/sql/EntityServiceSqlTest.java | 23 + .../OAuth2ConfigTemplateServiceSqlTest.java | 23 + .../dao/service/sql/OAuth2ServiceSqlTest.java | 23 + .../service/sql/OtaPackageServiceSqlTest.java | 23 + .../dao/service/sql/QueueServiceSqlTest.java | 23 + .../dao/service/sql/RelationCacheSqlTest.java | 23 + .../service/sql/RelationServiceSqlTest.java | 23 + .../service/sql/RuleChainServiceSqlTest.java | 23 + .../sql/TenantProfileServiceSqlTest.java | 23 + .../dao/service/sql/TenantServiceSqlTest.java | 23 + .../dao/service/sql/UserServiceSqlTest.java | 23 + .../service/sql/WidgetTypeServiceSqlTest.java | 23 + .../sql/WidgetsBundleServiceSqlTest.java | 23 + .../timeseries/BaseTimeseriesServiceTest.java | 706 ++ .../nosql/TimeseriesServiceNoSqlTest.java | 23 + .../nosql/TimeseriesServiceTimescaleTest.java | 23 + .../sql/TimeseriesServiceSqlTest.java | 23 + .../server/dao/sql/alarm/JpaAlarmDaoTest.java | 84 + .../server/dao/sql/asset/JpaAssetDaoTest.java | 256 + .../dao/sql/audit/JpaAuditLogDaoTest.java | 158 + .../JpaBaseComponentDescriptorDaoTest.java | 98 + .../dao/sql/customer/JpaCustomerDaoTest.java | 83 + .../dashboard/JpaDashboardInfoDaoTest.java | 67 + .../device/JpaDeviceCredentialsDaoTest.java | 84 + .../dao/sql/device/JpaDeviceDaoTest.java | 171 + .../dao/sql/event/JpaBaseEventDaoTest.java | 120 + .../DefaultEntityQueryRepositoryTest.java | 69 + .../query/DefaultQueryLogComponentTest.java | 154 + .../dao/sql/query/EntityDataAdapterTest.java | 30 + .../dao/sql/query/EntityKeyMappingTest.java | 103 + .../dao/sql/tenant/JpaTenantDaoTest.java | 120 + .../sql/user/JpaUserCredentialsDaoTest.java | 103 + .../server/dao/sql/user/JpaUserDaoTest.java | 158 + .../dao/sql/widget/JpaWidgetTypeDaoTest.java | 83 + .../sql/widget/JpaWidgetsBundleDaoTest.java | 172 + ...ctChunkedAggregationTimeseriesDaoTest.java | 156 + ...esDaoPartitioningDaysAlwaysExistsTest.java | 133 + ...sDaoPartitioningHoursAlwaysExistsTest.java | 134 + ...artitioningIndefiniteAlwaysExistsTest.java | 78 + ...aoPartitioningMinutesAlwaysExistsTest.java | 121 + ...DaoPartitioningMonthsAlwaysExistsTest.java | 134 + ...sDaoPartitioningYearsAlwaysExistsTest.java | 116 + dao/src/test/resources/TestJsonData.json | 5 + .../test/resources/TestJsonDescriptor.json | 27 + .../resources/application-test.properties | 126 + .../test/resources/cassandra-test.properties | 71 + dao/src/test/resources/cassandra-test.yaml | 592 + dao/src/test/resources/logback.xml | 19 + dao/src/test/resources/nosql-test.properties | 29 + dao/src/test/resources/sql-test.properties | 55 + .../resources/sql/hsql/drop-all-tables.sql | 42 + .../resources/sql/psql/drop-all-tables.sql | 43 + dao/src/test/resources/sql/system-data.sql | 51 + .../test/resources/sql/system-test-psql.sql | 2 + dao/src/test/resources/sql/system-test.sql | 8 + .../sql/timescale/drop-all-tables.sql | 38 + .../test/resources/timescale-test.properties | 18 + dao/src/test/resources/xss-policy.xml | 162 + docker/.env | 31 + docker/.gitignore | 17 + docker/README.md | 119 + docker/cache-redis-cluster.env | 5 + docker/cache-redis.env | 2 + docker/compose-utils.sh | 229 + docker/docker-check-log-folders.sh | 21 + docker/docker-compose.aws-sqs.yml | 61 + docker/docker-compose.cassandra.volumes.yml | 27 + docker/docker-compose.confluent.yml | 61 + docker/docker-compose.hybrid.yml | 60 + docker/docker-compose.kafka.yml | 98 + docker/docker-compose.postgres.volumes.yml | 27 + docker/docker-compose.postgres.yml | 49 + docker/docker-compose.prometheus-grafana.yml | 47 + docker/docker-compose.pubsub.yml | 61 + docker/docker-compose.rabbitmq.yml | 61 + .../docker-compose.redis-cluster.volumes.yml | 58 + docker/docker-compose.redis-cluster.yml | 156 + docker/docker-compose.redis.volumes.yml | 27 + docker/docker-compose.redis.yml | 97 + docker/docker-compose.service-bus.yml | 61 + docker/docker-compose.volumes.yml | 81 + docker/docker-compose.yml | 328 + docker/docker-create-log-folders.sh | 20 + docker/docker-install-tb.sh | 92 + docker/docker-remove-services.sh | 46 + docker/docker-start-services.sh | 48 + docker/docker-stop-services.sh | 46 + docker/docker-update-service.sh | 50 + docker/docker-upgrade-tb.sh | 83 + docker/haproxy/config/haproxy.cfg | 121 + docker/kafka.env | 11 + docker/monitoring/grafana/config.monitoring | 2 + .../dashboards/attributes_cache.json | 330 + .../dashboards/core_and_js_metrics.json | 335 + .../provisioning/dashboards/dashboard.yml | 27 + .../provisioning/dashboards/db_metrics.json | 406 + .../dashboards/hybrid_db_metrics.json | 474 + .../dashboards/rule_engine_latency.json | 386 + .../dashboards/rule_engine_metrics.json | 338 + .../dashboards/single_service_metrics.json | 992 ++ .../dashboards/transport_metrics.json | 532 + .../provisioning/datasources/datasource.yml | 66 + docker/monitoring/prometheus/prometheus.yml | 92 + docker/queue-aws-sqs.env | 4 + docker/queue-confluent.env | 18 + docker/queue-kafka.env | 2 + docker/queue-pubsub.env | 3 + docker/queue-rabbitmq.env | 5 + docker/queue-service-bus.env | 4 + docker/tb-coap-transport.env | 12 + docker/tb-http-transport.env | 9 + docker/tb-js-executor.env | 7 + docker/tb-lwm2m-transport.env | 12 + docker/tb-mqtt-transport.env | 12 + docker/tb-node.env | 11 + docker/tb-node.hybrid.env | 8 + docker/tb-node.postgres.env | 7 + docker/tb-node/conf/logback.xml | 77 + docker/tb-node/conf/thingsboard.conf | 24 + docker/tb-snmp-transport.env | 8 + docker/tb-transports/coap/conf/logback.xml | 55 + .../coap/conf/tb-coap-transport.conf | 23 + docker/tb-transports/http/conf/logback.xml | 55 + .../http/conf/tb-http-transport.conf | 23 + docker/tb-transports/lwm2m/conf/logback.xml | 54 + .../lwm2m/conf/tb-lwm2m-transport.conf | 23 + docker/tb-transports/mqtt/conf/logback.xml | 54 + .../mqtt/conf/tb-mqtt-transport.conf | 23 + docker/tb-transports/snmp/conf/logback.xml | 54 + .../snmp/conf/tb-snmp-transport.conf | 23 + docker/tb-vc-executor.env | 2 + docker/tb-vc-executor/conf/logback.xml | 54 + .../tb-vc-executor/conf/tb-vc-executor.conf | 23 + docker/tb-web-ui.env | 8 + img/logo.png | Bin 0 -> 6257 bytes license-header-template.txt | 13 + lombok.config | 3 + msa/black-box-tests/README.md | 38 + msa/black-box-tests/pom.xml | 196 + .../server/msa/AbstractContainerTest.java | 187 + .../server/msa/ContainerTestSuite.java | 233 + .../server/msa/DockerComposeExecutor.java | 118 + .../server/msa/TestCoapClient.java | 121 + .../server/msa/TestCoapClientCallback.java | 68 + .../thingsboard/server/msa/TestListener.java | 53 + .../server/msa/TestProperties.java | 61 + .../server/msa/TestRestClient.java | 299 + .../server/msa/ThingsBoardDbInstaller.java | 227 + .../org/thingsboard/server/msa/WsClient.java | 110 + .../msa/connectivity/CoapClientTest.java | 120 + .../msa/connectivity/HttpClientTest.java | 171 + .../msa/connectivity/MqttClientTest.java | 491 + .../connectivity/MqttGatewayClientTest.java | 436 + .../server/msa/mapper/AttributesResponse.java | 26 + .../msa/mapper/WsTelemetryResponse.java | 40 + .../msa/prototypes/DevicePrototypes.java | 40 + .../RpcResponseRuleChainMetadata.json | 59 + .../src/test/resources/config.properties | 2 + .../docker-compose.rabbitmq-server.yml | 69 + .../src/test/resources/logback.xml | 32 + .../src/test/resources/testNG.xml | 27 + msa/js-executor/.gitignore | 31 + msa/js-executor/api/httpServer.ts | 65 + msa/js-executor/api/jsExecutor.models.ts | 70 + msa/js-executor/api/jsExecutor.ts | 93 + .../api/jsInvokeMessageProcessor.ts | 385 + msa/js-executor/api/utils.ts | 64 + .../config/custom-environment-variables.yml | 83 + msa/js-executor/config/default.yml | 72 + msa/js-executor/config/logger.ts | 104 + msa/js-executor/config/tb-js-executor.conf | 19 + msa/js-executor/docker/Dockerfile | 42 + msa/js-executor/docker/start-js-executor.sh | 30 + msa/js-executor/install.js | 42 + msa/js-executor/package.json | 61 + msa/js-executor/pom.xml | 255 + msa/js-executor/queue/awsSqsTemplate.ts | 197 + msa/js-executor/queue/kafkaTemplate.ts | 280 + msa/js-executor/queue/pubSubTemplate.ts | 161 + msa/js-executor/queue/queue.models.ts | 22 + msa/js-executor/queue/rabbitmqTemplate.ts | 127 + msa/js-executor/queue/serviceBusTemplate.ts | 174 + msa/js-executor/server.ts | 99 + msa/js-executor/tsconfig.json | 13 + msa/js-executor/yarn.lock | 4157 ++++++ msa/pom.xml | 143 + msa/tb-node/docker/Dockerfile | 32 + msa/tb-node/docker/start-tb-node.sh | 73 + msa/tb-node/pom.xml | 190 + msa/tb/README.md | 94 + msa/tb/docker-cassandra/Dockerfile | 93 + msa/tb/docker-cassandra/start-db.sh | 60 + msa/tb/docker-cassandra/stop-db.sh | 32 + msa/tb/docker-postgres/Dockerfile | 75 + msa/tb/docker-postgres/start-db.sh | 42 + msa/tb/docker-postgres/stop-db.sh | 20 + msa/tb/docker/install-tb.sh | 58 + msa/tb/docker/logback.xml | 53 + msa/tb/docker/start-tb.sh | 42 + msa/tb/docker/thingsboard.conf | 24 + msa/tb/docker/upgrade-tb.sh | 47 + msa/tb/pom.xml | 385 + msa/transport/coap/docker/Dockerfile | 30 + .../coap/docker/start-tb-coap-transport.sh | 33 + msa/transport/coap/pom.xml | 190 + msa/transport/http/docker/Dockerfile | 30 + .../http/docker/start-tb-http-transport.sh | 33 + msa/transport/http/pom.xml | 190 + msa/transport/lwm2m/docker/Dockerfile | 30 + .../lwm2m/docker/start-tb-lwm2m-transport.sh | 33 + msa/transport/lwm2m/pom.xml | 191 + msa/transport/mqtt/docker/Dockerfile | 30 + .../mqtt/docker/start-tb-mqtt-transport.sh | 33 + msa/transport/mqtt/pom.xml | 190 + msa/transport/pom.xml | 44 + msa/transport/snmp/docker/Dockerfile | 30 + .../snmp/docker/start-tb-snmp-transport.sh | 33 + msa/transport/snmp/pom.xml | 191 + msa/vc-executor-docker/docker/Dockerfile | 32 + .../docker/start-tb-vc-executor.sh | 33 + msa/vc-executor-docker/pom.xml | 190 + msa/vc-executor/pom.xml | 140 + msa/vc-executor/src/main/conf/logback.xml | 47 + .../src/main/conf/tb-vc-executor.conf | 22 + ...oardVersionControlExecutorApplication.java | 47 + ...VersionControlQueueRoutingInfoService.java | 31 + ...ersionControlTenantRoutingInfoService.java | 30 + .../src/main/resources/logback.xml | 38 + .../src/main/resources/tb-vc-executor.yml | 180 + msa/web-ui/.gitignore | 30 + .../config/custom-environment-variables.yml | 31 + msa/web-ui/config/default.yml | 31 + msa/web-ui/config/logger.ts | 63 + msa/web-ui/config/tb-web-ui.conf | 20 + msa/web-ui/docker/Dockerfile | 41 + msa/web-ui/docker/start-web-ui.sh | 30 + msa/web-ui/install.js | 42 + msa/web-ui/package.json | 57 + msa/web-ui/pom.xml | 302 + msa/web-ui/server.ts | 154 + msa/web-ui/tsconfig.json | 13 + msa/web-ui/yarn.lock | 2532 ++++ netty-mqtt/.gitignore | 7 + netty-mqtt/pom.xml | 121 + .../mqtt/ChannelClosedException.java | 43 + .../thingsboard/mqtt/MqttChannelHandler.java | 264 + .../java/org/thingsboard/mqtt/MqttClient.java | 199 + .../thingsboard/mqtt/MqttClientCallback.java | 35 + .../thingsboard/mqtt/MqttClientConfig.java | 185 + .../org/thingsboard/mqtt/MqttClientImpl.java | 583 + .../thingsboard/mqtt/MqttConnectResult.java | 45 + .../org/thingsboard/mqtt/MqttHandler.java | 23 + .../mqtt/MqttIncomingQos2Publish.java | 31 + .../org/thingsboard/mqtt/MqttLastWill.java | 154 + .../thingsboard/mqtt/MqttPendingPublish.java | 111 + .../mqtt/MqttPendingSubscription.java | 107 + .../mqtt/MqttPendingUnsubscription.java | 60 + .../org/thingsboard/mqtt/MqttPingHandler.java | 106 + .../thingsboard/mqtt/MqttSubscription.java | 84 + .../thingsboard/mqtt/PendingOperation.java | 22 + .../mqtt/RetransmissionHandler.java | 83 + .../thingsboard/mqtt/MqttPingHandlerTest.java | 63 + .../integration/IntegrationTestSuite.java | 27 + .../mqtt/integration/MqttIntegrationTest.java | 139 + .../mqtt/integration/server/MqttServer.java | 84 + .../server/MqttTransportHandler.java | 141 + packaging/java/assembly/windows.xml | 89 + packaging/java/build.gradle | 169 + packaging/java/filters/unix.properties | 1 + packaging/java/filters/windows.properties | 2 + packaging/java/scripts/control/deb/postinst | 10 + packaging/java/scripts/control/deb/postrm | 7 + packaging/java/scripts/control/deb/preinst | 22 + packaging/java/scripts/control/deb/prerm | 9 + packaging/java/scripts/control/rpm/postinst | 10 + packaging/java/scripts/control/rpm/postrm | 6 + packaging/java/scripts/control/rpm/preinst | 6 + packaging/java/scripts/control/rpm/prerm | 6 + .../java/scripts/control/template.service | 11 + packaging/java/scripts/install/install.sh | 63 + .../java/scripts/install/install_dev_db.sh | 47 + packaging/java/scripts/install/logback.xml | 69 + packaging/java/scripts/install/upgrade.sh | 62 + .../java/scripts/install/upgrade_dev_db.sh | 68 + packaging/java/scripts/windows/install.bat | 58 + .../java/scripts/windows/install_dev_db.bat | 29 + packaging/java/scripts/windows/service.xml | 30 + packaging/java/scripts/windows/uninstall.bat | 9 + packaging/java/scripts/windows/upgrade.bat | 50 + packaging/js/assembly/windows.xml | 75 + packaging/js/build.gradle | 128 + packaging/js/filters/unix.properties | 1 + packaging/js/filters/windows.properties | 2 + packaging/js/scripts/control/deb/postinst | 8 + packaging/js/scripts/control/deb/postrm | 8 + packaging/js/scripts/control/deb/preinst | 18 + packaging/js/scripts/control/deb/prerm | 5 + packaging/js/scripts/control/rpm/postinst | 10 + packaging/js/scripts/control/rpm/postrm | 6 + packaging/js/scripts/control/rpm/preinst | 6 + packaging/js/scripts/control/rpm/prerm | 6 + packaging/js/scripts/control/template.service | 11 + packaging/js/scripts/init/template | 233 + packaging/js/scripts/windows/install.bat | 31 + packaging/js/scripts/windows/service.xml | 30 + packaging/js/scripts/windows/uninstall.bat | 25 + pom.xml | 2005 +++ pull_request_template.md | 31 + rest-client/pom.xml | 75 + .../thingsboard/rest/client/RestClient.java | 3545 ++++++ .../rest/client/utils/RestJsonConverter.java | 101 + rest-client/src/main/resources/logback.xml | 34 + rule-engine/pom.xml | 41 + rule-engine/rule-engine-api/pom.xml | 141 + .../engine/api/EmptyNodeConfiguration.java | 35 + .../rule/engine/api/MailService.java | 56 + .../rule/engine/api/NodeConfiguration.java | 22 + .../rule/engine/api/NodeDefinition.java | 38 + .../engine/api/RuleEngineAlarmService.java | 67 + .../api/RuleEngineApiUsageStateService.java | 26 + .../api/RuleEngineAssetProfileCache.java | 40 + .../api/RuleEngineDeviceProfileCache.java | 40 + .../api/RuleEngineDeviceRpcRequest.java | 45 + .../api/RuleEngineDeviceRpcResponse.java | 37 + .../rule/engine/api/RuleEngineRpcService.java | 35 + .../api/RuleEngineTelemetryService.java | 74 + .../thingsboard/rule/engine/api/RuleNode.java | 65 + .../rule/engine/api/ScriptEngine.java | 41 + .../rule/engine/api/SmsService.java | 33 + .../rule/engine/api/TbContext.java | 332 + .../thingsboard/rule/engine/api/TbEmail.java | 36 + .../thingsboard/rule/engine/api/TbNode.java | 36 + .../rule/engine/api/TbNodeConfiguration.java | 29 + .../rule/engine/api/TbNodeException.java | 33 + .../rule/engine/api/TbNodeState.java | 22 + .../rule/engine/api/TbRelationTypes.java | 26 + .../rule/engine/api/msg/DeviceAttributes.java | 103 + .../DeviceAttributesEventNotificationMsg.java | 66 + ...eviceCredentialsUpdateNotificationMsg.java | 46 + .../engine/api/msg/DeviceEdgeUpdateMsg.java | 38 + .../rule/engine/api/msg/DeviceMetaData.java | 34 + .../api/msg/DeviceNameOrTypeUpdateMsg.java | 40 + .../rule/engine/api/sms/SmsSender.java | 26 + .../rule/engine/api/sms/SmsSenderFactory.java | 24 + .../api/sms/exception/SmsException.java | 28 + .../api/sms/exception/SmsParseException.java | 28 + .../api/sms/exception/SmsSendException.java | 27 + .../rule/engine/api/util/TbNodeUtils.java | 119 + .../rule/engine/api/util/TbNodeUtilsTest.java | 145 + rule-engine/rule-engine-components/pom.xml | 188 + .../engine/action/TbAbstractAlarmNode.java | 117 + .../TbAbstractAlarmNodeConfiguration.java | 56 + .../action/TbAbstractCustomerActionNode.java | 138 + ...stractCustomerActionNodeConfiguration.java | 26 + .../action/TbAbstractRelationActionNode.java | 284 + ...stractRelationActionNodeConfiguration.java | 32 + .../rule/engine/action/TbAlarmResult.java | 37 + .../engine/action/TbAssignToCustomerNode.java | 108 + .../TbAssignToCustomerNodeConfiguration.java | 34 + .../rule/engine/action/TbClearAlarmNode.java | 94 + .../action/TbClearAlarmNodeConfiguration.java | 34 + .../TbCopyAttributesToEntityViewNode.java | 166 + .../rule/engine/action/TbCreateAlarmNode.java | 219 + .../TbCreateAlarmNodeConfiguration.java | 59 + .../engine/action/TbCreateRelationNode.java | 237 + .../TbCreateRelationNodeConfiguration.java | 41 + .../engine/action/TbDeleteRelationNode.java | 114 + .../TbDeleteRelationNodeConfiguration.java | 37 + .../rule/engine/action/TbLogNode.java | 103 + .../engine/action/TbLogNodeConfiguration.java | 37 + .../rule/engine/action/TbMsgCountNode.java | 101 + .../action/TbMsgCountNodeConfiguration.java | 34 + .../TbSaveToCustomCassandraTableNode.java | 244 + ...CustomCassandraTableNodeConfiguration.java | 41 + .../action/TbUnassignFromCustomerNode.java | 100 + ...UnassignFromCustomerNodeConfiguration.java | 31 + .../rule/engine/aws/sns/TbSnsNode.java | 121 + .../aws/sns/TbSnsNodeConfiguration.java | 36 + .../rule/engine/aws/sqs/TbSqsNode.java | 150 + .../aws/sqs/TbSqsNodeConfiguration.java | 50 + .../credentials/AnonymousCredentials.java | 26 + .../engine/credentials/BasicCredentials.java | 31 + .../credentials/CertPemCredentials.java | 314 + .../engine/credentials/ClientCredentials.java | 41 + .../engine/credentials/CredentialsType.java | 29 + .../engine/data/DeviceRelationsQuery.java | 30 + .../rule/engine/data/RelationsQuery.java | 31 + .../rule/engine/debug/TbMsgGeneratorNode.java | 172 + .../TbMsgGeneratorNodeConfiguration.java | 50 + .../rule/engine/delay/TbMsgDelayNode.java | 106 + .../delay/TbMsgDelayNodeConfiguration.java | 38 + .../engine/edge/AbstractTbMsgPushNode.java | 178 + .../edge/BaseTbMsgPushNodeConfiguration.java | 33 + .../engine/edge/TbMsgPushToCloudNode.java | 95 + .../TbMsgPushToCloudNodeConfiguration.java | 32 + .../rule/engine/edge/TbMsgPushToEdgeNode.java | 180 + .../TbMsgPushToEdgeNodeConfiguration.java | 32 + .../engine/filter/TbCheckAlarmStatusNode.java | 96 + .../filter/TbCheckAlarmStatusNodeConfig.java | 35 + .../engine/filter/TbCheckMessageNode.java | 130 + .../TbCheckMessageNodeConfiguration.java | 41 + .../engine/filter/TbCheckRelationNode.java | 111 + .../TbCheckRelationNodeConfiguration.java | 42 + .../rule/engine/filter/TbJsFilterNode.java | 78 + .../filter/TbJsFilterNodeConfiguration.java | 37 + .../rule/engine/filter/TbJsSwitchNode.java | 90 + .../filter/TbJsSwitchNodeConfiguration.java | 53 + .../engine/filter/TbMsgTypeFilterNode.java | 55 + .../TbMsgTypeFilterNodeConfiguration.java | 43 + .../engine/filter/TbMsgTypeSwitchNode.java | 126 + .../filter/TbOriginatorTypeFilterNode.java | 54 + ...OriginatorTypeFilterNodeConfiguration.java | 38 + .../filter/TbOriginatorTypeSwitchNode.java | 93 + .../rule/engine/flow/TbAckNode.java | 54 + .../rule/engine/flow/TbCheckpointNode.java | 57 + .../flow/TbCheckpointNodeConfiguration.java | 31 + .../engine/flow/TbRuleChainInputNode.java | 66 + .../TbRuleChainInputNodeConfiguration.java | 32 + .../engine/flow/TbRuleChainOutputNode.java | 54 + .../rule/engine/gcp/pubsub/TbPubSubNode.java | 138 + .../gcp/pubsub/TbPubSubNodeConfiguration.java | 41 + .../engine/geo/AbstractGeofencingNode.java | 151 + .../rule/engine/geo/Coordinates.java | 24 + .../engine/geo/EntityGeofencingState.java | 29 + .../thingsboard/rule/engine/geo/GeoUtil.java | 206 + .../rule/engine/geo/Perimeter.java | 34 + .../rule/engine/geo/PerimeterType.java | 20 + .../rule/engine/geo/RangeUnit.java | 30 + .../engine/geo/TbGpsGeofencingActionNode.java | 130 + ...bGpsGeofencingActionNodeConfiguration.java | 51 + .../engine/geo/TbGpsGeofencingFilterNode.java | 49 + ...bGpsGeofencingFilterNodeConfiguration.java | 54 + .../rule/engine/kafka/TbKafkaNode.java | 189 + .../kafka/TbKafkaNodeConfiguration.java | 60 + .../rule/engine/mail/TbMsgToEmailNode.java | 113 + .../mail/TbMsgToEmailNodeConfiguration.java | 43 + .../rule/engine/mail/TbSendEmailNode.java | 144 + .../mail/TbSendEmailNodeConfiguration.java | 52 + .../rule/engine/math/TbMathArgument.java | 41 + .../rule/engine/math/TbMathArgumentType.java | 22 + .../rule/engine/math/TbMathArgumentValue.java | 108 + .../rule/engine/math/TbMathNode.java | 397 + .../engine/math/TbMathNodeConfiguration.java | 40 + .../rule/engine/math/TbMathResult.java | 35 + .../math/TbRuleNodeMathFunctionType.java | 51 + .../engine/metadata/CalculateDeltaNode.java | 184 + .../CalculateDeltaNodeConfiguration.java | 45 + .../metadata/TbAbstractGetAttributesNode.java | 228 + .../TbAbstractGetEntityDetailsNode.java | 182 + ...ractGetEntityDetailsNodeConfiguration.java | 31 + .../engine/metadata/TbEntityGetAttrNode.java | 109 + .../TbFetchDeviceCredentialsNode.java | 101 + ...tchDeviceCredentialsNodeConfiguration.java | 34 + .../engine/metadata/TbGetAttributesNode.java | 56 + .../TbGetAttributesNodeConfiguration.java | 52 + .../metadata/TbGetCustomerAttributeNode.java | 44 + .../metadata/TbGetCustomerDetailsNode.java | 139 + ...TbGetCustomerDetailsNodeConfiguration.java | 33 + .../engine/metadata/TbGetDeviceAttrNode.java | 53 + .../TbGetDeviceAttrNodeConfiguration.java | 51 + .../TbGetEntityAttrNodeConfiguration.java | 40 + .../TbGetOriginatorFieldsConfiguration.java | 40 + .../metadata/TbGetOriginatorFieldsNode.java | 85 + .../TbGetRelatedAttrNodeConfiguration.java | 50 + .../metadata/TbGetRelatedAttributeNode.java | 56 + .../engine/metadata/TbGetTelemetryNode.java | 233 + .../TbGetTelemetryNodeConfiguration.java | 72 + .../metadata/TbGetTenantAttributeNode.java | 46 + .../metadata/TbGetTenantDetailsNode.java | 65 + .../TbGetTenantDetailsNodeConfiguration.java | 33 + .../rule/engine/mqtt/TbMqttNode.java | 147 + .../engine/mqtt/TbMqttNodeConfiguration.java | 51 + .../mqtt/azure/AzureIotHubSasCredentials.java | 66 + .../engine/mqtt/azure/TbAzureIotHubNode.java | 77 + .../azure/TbAzureIotHubNodeConfiguration.java | 37 + .../rule/engine/profile/AlarmEvalResult.java | 22 + .../rule/engine/profile/AlarmRuleState.java | 602 + .../rule/engine/profile/AlarmState.java | 349 + .../profile/AlarmStateUpdateResult.java | 22 + .../rule/engine/profile/DataSnapshot.java | 86 + .../rule/engine/profile/DeviceState.java | 424 + .../profile/DynamicPredicateValueCtx.java | 25 + .../profile/DynamicPredicateValueCtxImpl.java | 74 + .../rule/engine/profile/EntityKeyValue.java | 111 + .../engine/profile/NumericParseException.java | 22 + .../rule/engine/profile/ProfileState.java | 188 + .../rule/engine/profile/SnapshotUpdate.java | 41 + .../engine/profile/TbDeviceProfileNode.java | 239 + .../TbDeviceProfileNodeConfiguration.java | 33 + .../state/PersistedAlarmRuleState.java | 31 + .../profile/state/PersistedAlarmState.java | 29 + .../profile/state/PersistedDeviceState.java | 27 + .../rule/engine/rabbitmq/TbRabbitMqNode.java | 154 + .../rabbitmq/TbRabbitMqNodeConfiguration.java | 58 + .../rule/engine/rest/TbHttpClient.java | 337 + .../rule/engine/rest/TbRestApiCallNode.java | 73 + .../rest/TbRestApiCallNodeConfiguration.java | 76 + .../rule/engine/rpc/TbSendRPCReplyNode.java | 122 + .../rule/engine/rpc/TbSendRPCRequestNode.java | 144 + .../rpc/TbSendRpcReplyNodeConfiguration.java | 55 + .../TbSendRpcRequestNodeConfiguration.java | 32 + .../rule/engine/sms/TbSendSmsNode.java | 98 + .../sms/TbSendSmsNodeConfiguration.java | 38 + .../AttributesDeleteNodeCallback.java | 45 + .../AttributesUpdateNodeCallback.java | 44 + .../engine/telemetry/TbMsgAttributesNode.java | 107 + .../TbMsgAttributesNodeConfiguration.java | 38 + .../telemetry/TbMsgDeleteAttributesNode.java | 97 + ...bMsgDeleteAttributesNodeConfiguration.java | 42 + .../engine/telemetry/TbMsgTimeseriesNode.java | 122 + .../TbMsgTimeseriesNodeConfiguration.java | 36 + .../telemetry/TelemetryNodeCallback.java | 42 + .../TbSynchronizationBeginNode.java | 52 + .../transaction/TbSynchronizationEndNode.java | 51 + .../MultipleTbMsgsCallbackWrapper.java | 45 + .../transform/TbAbstractTransformNode.java | 97 + .../transform/TbChangeOriginatorNode.java | 135 + .../TbChangeOriginatorNodeConfiguration.java | 50 + .../rule/engine/transform/TbCopyKeysNode.java | 107 + .../TbCopyKeysNodeConfiguration.java | 38 + .../engine/transform/TbDeleteKeysNode.java | 105 + .../TbDeleteKeysNodeConfiguration.java | 38 + .../rule/engine/transform/TbJsonPathNode.java | 82 + .../TbJsonPathNodeConfiguration.java | 34 + .../transform/TbMsgCallbackWrapper.java | 23 + .../engine/transform/TbRenameKeysNode.java | 97 + .../TbRenameKeysNodeConfiguration.java | 37 + .../engine/transform/TbSplitArrayMsgNode.java | 87 + .../engine/transform/TbTransformMsgNode.java | 83 + .../TbTransformMsgNodeConfiguration.java | 37 + .../TbTransformNodeConfiguration.java | 23 + .../EntitiesAlarmOriginatorIdAsyncLoader.java | 45 + .../util/EntitiesByNameAndTypeLoader.java | 67 + .../util/EntitiesCustomerIdAsyncLoader.java | 53 + .../util/EntitiesFieldsAsyncLoader.java | 76 + .../EntitiesRelatedDeviceIdAsyncLoader.java | 55 + .../EntitiesRelatedEntityIdAsyncLoader.java | 58 + .../rule/engine/util/EntityContainer.java | 28 + .../rule/engine/util/EntityDetails.java | 22 + .../rule/engine/util/TenantIdLoader.java | 132 + .../static/rulenode/rulenode-core-config.js | 1 + .../rule/engine/action/TbAlarmNodeTest.java | 640 + .../action/TbCreateRelationNodeTest.java | 227 + .../rule/engine/action/TbLogNodeTest.java | 80 + .../engine/edge/TbMsgPushToEdgeNodeTest.java | 109 + .../engine/filter/TbJsFilterNodeTest.java | 119 + .../engine/filter/TbJsSwitchNodeTest.java | 92 + .../rule/engine/geo/TbGeoUtilTest.java | 163 + .../engine/mail/TbMsgToEmailNodeTest.java | 112 + .../engine/math/TbMathArgumentValueTest.java | 117 + .../rule/engine/math/TbMathNodeTest.java | 505 + .../metadata/AbstractAttributeNodeTest.java | 234 + .../TbAbstractGetAttributesNodeTest.java | 338 + .../TbFetchDeviceCredentialsNodeTest.java | 156 + .../TbGetCustomerAttributeNodeTest.java | 115 + .../TbGetRelatedAttributeNodeTest.java | 164 + .../metadata/TbGetTelemetryNodeTest.java | 83 + .../TbGetTenantAttributeNodeTest.java | 108 + .../rule/engine/profile/AlarmStateTest.java | 87 + .../rule/engine/profile/DeviceStateTest.java | 176 + .../profile/TbDeviceProfileNodeTest.java | 1604 +++ .../rule/engine/rest/TbHttpClientTest.java | 226 + .../engine/rest/TbRestApiCallNodeTest.java | 223 + .../engine/rpc/TbSendRPCReplyNodeTest.java | 119 + .../TbMsgDeleteAttributesNodeTest.java | 161 + .../transform/TbChangeOriginatorNodeTest.java | 170 + .../engine/transform/TbCopyKeysNodeTest.java | 166 + .../transform/TbDeleteKeysNodeTest.java | 149 + .../engine/transform/TbJsonPathNodeTest.java | 178 + .../transform/TbRenameKeysNodeTest.java | 159 + .../transform/TbSplitArrayMsgNodeTest.java | 139 + .../transform/TbTransformMsgNodeTest.java | 118 + .../rule/engine/util/TenantIdLoaderTest.java | 351 + tools/pom.xml | 88 + .../client/tools/MqttSslClient.java | 84 + .../tools/migrator/DictionaryParser.java | 74 + .../client/tools/migrator/MigratorTool.java | 102 + .../client/tools/migrator/PgCaMigrator.java | 282 + .../client/tools/migrator/README.md | 92 + .../tools/migrator/RelatedEntitiesParser.java | 88 + .../client/tools/migrator/WriterBuilder.java | 86 + tools/src/main/python/mqtt-send-telemetry.py | 35 + .../main/python/one-way-ssl-mqtt-client.py | 56 + tools/src/main/python/simple-mqtt-client.py | 51 + .../main/python/two-way-ssl-mqtt-client.py | 55 + tools/src/main/shell/client.keygen.sh | 147 + tools/src/main/shell/keygen.properties | 40 + .../lwM2M_cfssl_chain_clients_for_test.sh | 423 + .../lwm2m/lwm2m_cfssl_chain_all_for_test.sh | 115 + .../lwm2m_cfssl_chain_server_for_test.sh | 314 + tools/src/main/shell/server.keygen.sh | 197 + transport/coap/pom.xml | 132 + transport/coap/src/main/conf/logback.xml | 47 + .../coap/src/main/conf/tb-coap-transport.conf | 22 + .../ThingsboardCoapTransportApplication.java | 50 + transport/coap/src/main/resources/logback.xml | 38 + .../src/main/resources/tb-coap-transport.yml | 282 + transport/http/pom.xml | 132 + transport/http/src/main/conf/logback.xml | 47 + .../http/src/main/conf/tb-http-transport.conf | 22 + .../ThingsboardHttpTransportApplication.java | 49 + transport/http/src/main/resources/logback.xml | 39 + .../src/main/resources/tb-http-transport.yml | 267 + transport/lwm2m/pom.xml | 187 + transport/lwm2m/src/main/conf/logback.xml | 47 + .../src/main/conf/tb-lwm2m-transport.conf | 22 + .../ThingsboardLwm2mTransportApplication.java | 50 + .../lwm2m/src/main/resources/logback.xml | 39 + .../src/main/resources/tb-lwm2m-transport.yml | 349 + transport/mqtt/pom.xml | 132 + transport/mqtt/src/main/conf/logback.xml | 47 + .../mqtt/src/main/conf/tb-mqtt-transport.conf | 22 + .../ThingsboardMqttTransportApplication.java | 50 + transport/mqtt/src/main/resources/logback.xml | 39 + .../src/main/resources/tb-mqtt-transport.yml | 297 + transport/pom.xml | 44 + transport/snmp/pom.xml | 112 + transport/snmp/src/main/conf/logback.xml | 47 + .../snmp/src/main/conf/tb-snmp-transport.conf | 22 + .../ThingsboardSnmpTransportApplication.java | 50 + transport/snmp/src/main/resources/logback.xml | 39 + .../src/main/resources/tb-snmp-transport.yml | 247 + ui-ngx/.browserslistrc | 16 + ui-ngx/.editorconfig | 32 + ui-ngx/.gitignore | 46 + ui-ngx/.yarnrc | 1 + ui-ngx/LICENSE | 201 + ui-ngx/angular.json | 272 + ui-ngx/e2e/protractor.conf.js | 43 + ui-ngx/e2e/src/app.e2e-spec.ts | 39 + ui-ngx/e2e/src/app.po.ts | 27 + ui-ngx/e2e/tsconfig.e2e.json | 13 + ui-ngx/extra-webpack.config.js | 95 + ui-ngx/generate-types.js | 68 + ui-ngx/package.json | 158 + ui-ngx/patches/canvas-gauges+2.1.7.patch | 13 + ui-ngx/pom.xml | 138 + ui-ngx/proxy.conf.js | 48 + ui-ngx/src/app/app-routing.module.ts | 36 + ui-ngx/src/app/app.component.html | 20 + ui-ngx/src/app/app.component.scss | 15 + ui-ngx/src/app/app.component.ts | 134 + ui-ngx/src/app/app.module.ts | 61 + .../app/core/api/alarm-data-subscription.ts | 198 + ui-ngx/src/app/core/api/alarm-data.service.ts | 91 + ui-ngx/src/app/core/api/alias-controller.ts | 399 + ui-ngx/src/app/core/api/data-aggregator.ts | 453 + .../app/core/api/entity-data-subscription.ts | 1204 ++ .../src/app/core/api/entity-data.service.ts | 190 + ui-ngx/src/app/core/api/public-api.ts | 22 + ui-ngx/src/app/core/api/widget-api.models.ts | 351 + .../src/app/core/api/widget-subscription.ts | 1638 +++ ui-ngx/src/app/core/auth/auth.actions.ts | 65 + ui-ngx/src/app/core/auth/auth.models.ts | 37 + ui-ngx/src/app/core/auth/auth.reducer.ts | 65 + ui-ngx/src/app/core/auth/auth.selectors.ts | 82 + ui-ngx/src/app/core/auth/auth.service.spec.ts | 28 + ui-ngx/src/app/core/auth/auth.service.ts | 714 ++ ui-ngx/src/app/core/auth/public-api.ts | 21 + ui-ngx/src/app/core/core.module.ts | 108 + ui-ngx/src/app/core/core.state.ts | 61 + ui-ngx/src/app/core/css/css.js | 686 + ui-ngx/src/app/core/guards/auth.guard.ts | 163 + .../app/core/guards/confirm-on-exit.guard.ts | 88 + ui-ngx/src/app/core/http/admin.service.ts | 134 + ui-ngx/src/app/core/http/alarm.service.ts | 85 + .../app/core/http/asset-profile.service.ts | 67 + ui-ngx/src/app/core/http/asset.service.ts | 126 + ui-ngx/src/app/core/http/attribute.service.ts | 153 + ui-ngx/src/app/core/http/audit-log.service.ts | 59 + .../core/http/component-descriptor.service.ts | 108 + ui-ngx/src/app/core/http/customer.service.ts | 51 + ui-ngx/src/app/core/http/dashboard.service.ts | 191 + .../app/core/http/device-profile.service.ts | 168 + ui-ngx/src/app/core/http/device.service.ts | 199 + ui-ngx/src/app/core/http/edge.service.ts | 116 + .../http/entities-version-control.service.ts | 190 + .../app/core/http/entity-relation.service.ts | 113 + .../src/app/core/http/entity-view.service.ts | 103 + ui-ngx/src/app/core/http/entity.service.ts | 1459 +++ ui-ngx/src/app/core/http/event.service.ts | 55 + ui-ngx/src/app/core/http/http-utils.ts | 49 + ui-ngx/src/app/core/http/oauth2.service.ts | 48 + .../src/app/core/http/ota-package.service.ts | 167 + ui-ngx/src/app/core/http/public-api.ts | 42 + ui-ngx/src/app/core/http/queue.service.ts | 56 + ui-ngx/src/app/core/http/resource.service.ts | 103 + .../src/app/core/http/rule-chain.service.ts | 288 + .../app/core/http/tenant-profile.service.ts | 67 + ui-ngx/src/app/core/http/tenant.service.ts | 58 + .../http/two-factor-authentication.service.ts | 88 + .../src/app/core/http/ui-settings.service.ts | 43 + ui-ngx/src/app/core/http/user.service.ts | 87 + ui-ngx/src/app/core/http/widget.service.ts | 345 + .../interceptors/global-http-interceptor.ts | 226 + .../core/interceptors/interceptor-config.ts | 21 + .../interceptors/interceptor-http-params.ts | 27 + .../src/app/core/interceptors/load.actions.ts | 32 + .../src/app/core/interceptors/load.models.ts | 19 + .../src/app/core/interceptors/load.reducer.ts | 38 + .../app/core/interceptors/load.selectors.ts | 43 + .../local-storage/local-storage.service.ts | 88 + .../app/core/meta-reducers/debug.reducer.ts | 33 + .../init-state-from-local-storage.reducer.ts | 32 + .../core/notification/notification.actions.ts | 38 + .../core/notification/notification.effects.ts | 51 + .../core/notification/notification.models.ts | 40 + .../core/notification/notification.reducer.ts | 37 + ui-ngx/src/app/core/operator/enterZone.ts | 44 + ui-ngx/src/app/core/public-api.ts | 25 + .../src/app/core/services/broadcast.models.ts | 27 + .../app/core/services/broadcast.service.ts | 50 + .../core/services/dashboard-utils.service.ts | 637 + .../src/app/core/services/dialog.service.ts | 136 + .../dynamic-component-factory.service.ts | 121 + ui-ngx/src/app/core/services/help.service.ts | 134 + .../app/core/services/item-buffer.service.ts | 589 + .../services/material-icons-codepoints.raw | 2142 ++++ ui-ngx/src/app/core/services/menu.models.ts | 41 + ui-ngx/src/app/core/services/menu.service.ts | 728 ++ .../src/app/core/services/mobile.service.ts | 190 + .../app/core/services/notification.service.ts | 52 + ui-ngx/src/app/core/services/public-api.ts | 32 + ui-ngx/src/app/core/services/raf.service.ts | 68 + .../app/core/services/resources.service.ts | 249 + .../script/node-script-test.service.ts | 107 + ui-ngx/src/app/core/services/time.service.ts | 163 + ui-ngx/src/app/core/services/title.service.ts | 55 + ui-ngx/src/app/core/services/utils.service.ts | 514 + .../src/app/core/services/window.service.ts | 72 + .../src/app/core/settings/settings.actions.ts | 30 + .../src/app/core/settings/settings.effects.ts | 99 + .../src/app/core/settings/settings.models.ts | 20 + .../src/app/core/settings/settings.reducer.ts | 34 + .../app/core/settings/settings.selectors.ts | 34 + .../src/app/core/settings/settings.utils.ts | 65 + .../translate/missing-translate-handler.ts | 26 + .../translate/translate-default-compiler.ts | 76 + .../translate/translate-default-parser.ts | 76 + ui-ngx/src/app/core/utils.ts | 765 ++ .../core/ws/telemetry-websocket.service.ts | 370 + .../app/modules/common/modules-map.models.ts | 19 + ui-ngx/src/app/modules/common/modules-map.ts | 610 + .../dashboard/dashboard-pages.module.ts | 33 + .../dashboard-pages.routing.module.ts | 101 + .../dashboard/dashboard-routing.module.ts | 43 + .../alarm/alarm-details-dialog.component.html | 119 + .../alarm/alarm-details-dialog.component.ts | 171 + .../components/alarm/alarm-table-config.ts | 145 + .../alarm/alarm-table-header.component.html | 26 + .../alarm/alarm-table-header.component.scss | 47 + .../alarm/alarm-table-header.component.ts | 48 + .../alarm/alarm-table.component.html | 18 + .../alarm/alarm-table.component.scss | 22 + .../components/alarm/alarm-table.component.ts | 91 + ...aliases-entity-autocomplete.component.html | 43 + .../aliases-entity-autocomplete.component.ts | 161 + ...aliases-entity-select-panel.component.html | 28 + ...aliases-entity-select-panel.component.scss | 35 + .../aliases-entity-select-panel.component.ts | 46 + .../aliases-entity-select.component.html | 32 + .../aliases-entity-select.component.scss | 38 + .../alias/aliases-entity-select.component.ts | 201 + .../alias/entity-alias-dialog.component.html | 74 + .../alias/entity-alias-dialog.component.scss | 28 + .../alias/entity-alias-dialog.component.ts | 163 + .../alias/entity-alias-select.component.html | 61 + .../entity-alias-select.component.models.ts | 23 + .../alias/entity-alias-select.component.scss | 24 + .../alias/entity-alias-select.component.ts | 260 + .../entity-aliases-dialog.component.html | 111 + .../entity-aliases-dialog.component.scss | 52 + .../alias/entity-aliases-dialog.component.ts | 272 + .../add-attribute-dialog.component.html | 62 + .../add-attribute-dialog.component.ts | 88 + ...-widget-to-dashboard-dialog.component.html | 76 + ...-widget-to-dashboard-dialog.component.scss | 28 + ...dd-widget-to-dashboard-dialog.component.ts | 219 + .../attribute/attribute-table.component.html | 258 + .../attribute/attribute-table.component.scss | 145 + .../attribute/attribute-table.component.ts | 545 + .../edit-attribute-value-panel.component.html | 40 + .../edit-attribute-value-panel.component.scss | 19 + .../edit-attribute-value-panel.component.ts | 74 + .../audit-log-details-dialog.component.html | 43 + .../audit-log-details-dialog.component.scss | 25 + .../audit-log-details-dialog.component.ts | 124 + .../audit-log/audit-log-table-config.ts | 136 + .../audit-log/audit-log-table.component.html | 18 + .../audit-log/audit-log-table.component.scss | 22 + .../audit-log/audit-log-table.component.ts | 137 + .../add-widget-dialog.component.html | 56 + .../add-widget-dialog.component.ts | 146 + .../dashboard-image-dialog.component.html | 88 + .../dashboard-image-dialog.component.scss | 58 + .../dashboard-image-dialog.component.ts | 174 + .../dashboard-page.component.html | 308 + .../dashboard-page.component.scss | 155 + .../dashboard-page.component.ts | 1555 +++ .../dashboard-page/dashboard-page.models.ts | 155 + .../dashboard-settings-dialog.component.html | 196 + .../dashboard-settings-dialog.component.scss | 62 + .../dashboard-settings-dialog.component.ts | 206 + .../dashboard-state.component.html | 29 + .../dashboard-state.component.scss | 21 + .../dashboard-state.component.ts | 124 + .../dashboard-toolbar.component.html | 33 + .../dashboard-toolbar.component.scss | 204 + .../dashboard-toolbar.component.ts | 46 + .../dashboard-widget-select.component.html | 85 + .../dashboard-widget-select.component.scss | 80 + .../dashboard-widget-select.component.ts | 201 + .../dashboard-page/edit-widget.component.html | 28 + .../dashboard-page/edit-widget.component.ts | 131 + .../layout/dashboard-layout.component.html | 64 + .../layout/dashboard-layout.component.scss | 30 + .../layout/dashboard-layout.component.ts | 266 + .../dashboard-page/layout/layout.models.ts | 40 + ...ge-dashboard-layouts-dialog.component.html | 186 + ...ge-dashboard-layouts-dialog.component.scss | 180 + ...nage-dashboard-layouts-dialog.component.ts | 365 + .../dashboard-state-dialog.component.html | 67 + .../dashboard-state-dialog.component.ts | 144 + .../default-state-controller.component.html | 23 + .../default-state-controller.component.scss | 30 + .../default-state-controller.component.ts | 257 + .../entity-state-controller.component.html | 35 + .../entity-state-controller.component.scss | 53 + .../entity-state-controller.component.ts | 340 + ...age-dashboard-states-dialog.component.html | 151 + ...ashboard-states-dialog.component.models.ts | 99 + ...age-dashboard-states-dialog.component.scss | 45 + ...anage-dashboard-states-dialog.component.ts | 250 + .../states/state-controller.component.ts | 214 + .../states/state-controller.models.ts | 34 + .../states/states-component.directive.ts | 150 + .../states/states-controller.module.ts | 50 + .../states/states-controller.service.ts | 63 + .../widget-types-panel.component.html | 23 + .../widget-types-panel.component.scss | 36 + .../widget-types-panel.component.ts | 48 + .../dashboard/dashboard.component.html | 85 + .../dashboard/dashboard.component.scss | 53 + .../dashboard/dashboard.component.ts | 596 + .../components/dashboard/layout-button.scss | 43 + ...select-target-layout-dialog.component.html | 50 + .../select-target-layout-dialog.component.ts | 50 + .../select-target-state-dialog.component.html | 56 + .../select-target-state-dialog.component.ts | 83 + .../components/details-panel.component.html | 70 + .../components/details-panel.component.scss | 66 + .../components/details-panel.component.ts | 121 + .../copy-device-credentials.component.html | 26 + .../copy-device-credentials.component.ts | 132 + ...ce-credentials-lwm2m-server.component.html | 59 + ...vice-credentials-lwm2m-server.component.ts | 152 + .../device-credentials-lwm2m.component.html | 107 + .../device-credentials-lwm2m.component.scss | 30 + .../device-credentials-lwm2m.component.ts | 190 + ...vice-credentials-mqtt-basic.component.html | 44 + ...device-credentials-mqtt-basic.component.ts | 131 + .../device/device-credentials.component.html | 58 + .../device/device-credentials.component.ts | 184 + .../device/device-credentials.module.ts | 46 + .../edge/edge-downlink-table-config.ts | 189 + .../edge-downlink-table-header.component.html | 19 + .../edge-downlink-table-header.component.scss | 41 + .../edge-downlink-table-header.component.ts | 38 + .../edge/edge-downlink-table.component.html | 18 + .../edge/edge-downlink-table.component.scss | 22 + .../edge/edge-downlink-table.component.ts | 93 + .../entity/add-entity-dialog.component.html | 49 + .../entity/add-entity-dialog.component.scss | 18 + .../entity/add-entity-dialog.component.ts | 122 + .../entity/contact-based.component.ts | 89 + .../entity/entities-table.component.html | 276 + .../entity/entities-table.component.scss | 75 + .../entity/entities-table.component.ts | 695 ++ .../entity/entity-details-page.component.html | 57 + .../entity/entity-details-page.component.scss | 119 + .../entity/entity-details-page.component.ts | 173 + .../entity-details-panel.component.html | 36 + .../entity-details-panel.component.scss | 42 + .../entity/entity-details-panel.component.ts | 322 + .../entity/entity-filter-view.component.html | 25 + .../entity/entity-filter-view.component.scss | 36 + .../entity/entity-filter-view.component.ts | 256 + .../entity/entity-filter.component.html | 294 + .../entity/entity-filter.component.scss | 39 + .../entity/entity-filter.component.ts | 231 + .../entity/entity-table-header.component.ts | 55 + .../entity/entity-tabs.component.ts | 128 + .../components/entity/entity.component.ts | 133 + .../event/event-content-dialog.component.html | 39 + .../event/event-content-dialog.component.scss | 23 + .../event/event-content-dialog.component.ts | 158 + .../event/event-filter-panel.component.html | 74 + .../event/event-filter-panel.component.scss | 35 + .../event/event-filter-panel.component.ts | 115 + .../components/event/event-table-config.ts | 476 + .../event/event-table-header.component.html | 26 + .../event/event-table-header.component.scss | 47 + .../event/event-table-header.component.ts | 45 + .../event/event-table.component.html | 18 + .../event/event-table.component.scss | 22 + .../components/event/event-table.component.ts | 122 + .../boolean-filter-predicate.component.html | 33 + .../boolean-filter-predicate.component.ts | 120 + ...lex-filter-predicate-dialog.component.html | 64 + ...mplex-filter-predicate-dialog.component.ts | 95 + .../complex-filter-predicate.component.html | 28 + .../complex-filter-predicate.component.ts | 107 + .../filter/filter-component.models.ts | 29 + .../filter/filter-dialog.component.html | 70 + .../filter/filter-dialog.component.scss | 28 + .../filter/filter-dialog.component.ts | 137 + .../filter-predicate-list.component.html | 94 + .../filter-predicate-list.component.scss | 29 + .../filter/filter-predicate-list.component.ts | 201 + .../filter-predicate-value.component.html | 90 + .../filter-predicate-value.component.ts | 225 + .../filter/filter-predicate.component.html | 61 + .../filter/filter-predicate.component.ts | 121 + .../components/filter/filter-predicate.scss | 36 + .../filter/filter-select.component.html | 61 + .../filter/filter-select.component.models.ts | 22 + .../filter/filter-select.component.scss | 24 + .../filter/filter-select.component.ts | 249 + .../filter/filter-text.component.html | 19 + .../filter/filter-text.component.scss | 83 + .../filter/filter-text.component.ts | 104 + .../filter-user-info-dialog.component.html | 63 + .../filter-user-info-dialog.component.ts | 115 + .../filter/filter-user-info.component.html | 25 + .../filter/filter-user-info.component.ts | 105 + .../filter/filters-dialog.component.html | 104 + .../filter/filters-dialog.component.scss | 52 + .../filter/filters-dialog.component.ts | 246 + .../filter/filters-edit-panel.component.html | 33 + .../filter/filters-edit-panel.component.scss | 35 + .../filter/filters-edit-panel.component.ts | 62 + .../filter/filters-edit.component.html | 32 + .../filter/filters-edit.component.scss | 36 + .../filter/filters-edit.component.ts | 186 + .../filter/key-filter-dialog.component.html | 132 + .../filter/key-filter-dialog.component.scss | 26 + .../filter/key-filter-dialog.component.ts | 267 + .../filter/key-filter-list.component.html | 92 + .../filter/key-filter-list.component.scss | 42 + .../filter/key-filter-list.component.ts | 208 + .../numeric-filter-predicate.component.html | 33 + .../numeric-filter-predicate.component.ts | 119 + .../string-filter-predicate.component.html | 37 + .../string-filter-predicate.component.ts | 119 + .../filter/user-filter-dialog.component.html | 76 + .../filter/user-filter-dialog.component.ts | 121 + .../home/components/home-components.module.ts | 471 + .../import-dialog-csv.component.html | 152 + .../import-dialog-csv.component.scss | 55 + .../import-dialog-csv.component.ts | 303 + .../import-dialog.component.html | 58 + .../import-export/import-dialog.component.ts | 91 + .../import-export/import-export.models.ts | 221 + .../import-export/import-export.service.ts | 982 ++ .../table-columns-assignment.component.html | 61 + .../table-columns-assignment.component.scss | 35 + .../table-columns-assignment.component.ts | 237 + .../add-device-profile-dialog.component.html | 150 + .../add-device-profile-dialog.component.scss | 64 + .../add-device-profile-dialog.component.ts | 253 + ...rm-duration-predicate-value.component.html | 79 + ...rm-duration-predicate-value.component.scss | 22 + ...larm-duration-predicate-value.component.ts | 155 + .../alarm/alarm-dynamic-value.component.html | 55 + .../alarm/alarm-dynamic-value.component.ts | 99 + ...alarm-rule-condition-dialog.component.html | 91 + ...alarm-rule-condition-dialog.component.scss | 20 + .../alarm-rule-condition-dialog.component.ts | 144 + .../alarm/alarm-rule-condition.component.html | 37 + .../alarm/alarm-rule-condition.component.scss | 44 + .../alarm/alarm-rule-condition.component.ts | 209 + .../profile/alarm/alarm-rule.component.html | 50 + .../profile/alarm/alarm-rule.component.scss | 41 + .../profile/alarm/alarm-rule.component.ts | 163 + .../alarm-schedule-dialog.component.html | 53 + .../alarm/alarm-schedule-dialog.component.ts | 86 + .../alarm/alarm-schedule-info.component.html | 30 + .../alarm/alarm-schedule-info.component.scss | 34 + .../alarm/alarm-schedule-info.component.ts | 137 + .../alarm/alarm-schedule.component.html | 109 + .../alarm/alarm-schedule.component.scss | 20 + .../profile/alarm/alarm-schedule.component.ts | 290 + .../alarm/create-alarm-rules.component.html | 64 + .../alarm/create-alarm-rules.component.scss | 36 + .../alarm/create-alarm-rules.component.ts | 197 + .../alarm/device-profile-alarm.component.html | 132 + .../alarm/device-profile-alarm.component.scss | 62 + .../alarm/device-profile-alarm.component.ts | 206 + .../device-profile-alarms.component.html | 41 + .../device-profile-alarms.component.scss | 32 + .../alarm/device-profile-alarms.component.ts | 175 + .../edit-alarm-details-dialog.component.html | 54 + .../edit-alarm-details-dialog.component.ts | 84 + .../asset-profile-autocomplete.component.html | 74 + .../asset-profile-autocomplete.component.scss | 21 + .../asset-profile-autocomplete.component.ts | 369 + .../asset-profile-dialog.component.html | 53 + .../profile/asset-profile-dialog.component.ts | 100 + .../profile/asset-profile.component.html | 91 + .../profile/asset-profile.component.ts | 113 + ...device-profile-autocomplete.component.html | 74 + ...device-profile-autocomplete.component.scss | 21 + .../device-profile-autocomplete.component.ts | 403 + .../device-profile-dialog.component.html | 53 + .../device-profile-dialog.component.ts | 100 + ...ile-provision-configuration.component.html | 62 + ...ofile-provision-configuration.component.ts | 169 + .../profile/device-profile.component.html | 185 + .../profile/device-profile.component.ts | 231 + ...ile-transport-configuration.component.html | 106 + ...ile-transport-configuration.component.scss | 31 + ...ofile-transport-configuration.component.ts | 189 + .../common/device-profile-common.module.ts | 36 + .../common/power-mode-setting.component.html | 52 + .../common/power-mode-setting.component.ts | 80 + .../common/time-unit-select.component.html | 40 + .../common/time-unit-select.component.ts | 194 + ...evice-profile-configuration.component.html | 20 + ...-device-profile-configuration.component.ts | 97 + ...ile-transport-configuration.component.html | 20 + ...ofile-transport-configuration.component.ts | 97 + ...evice-profile-configuration.component.html | 27 + .../device-profile-configuration.component.ts | 114 + ...ile-transport-configuration.component.html | 52 + ...ofile-transport-configuration.component.ts | 108 + .../lwm2m-attributes-dialog.component.html | 52 + .../lwm2m-attributes-dialog.component.ts | 85 + .../lwm2m-attributes-key-list.component.html | 74 + .../lwm2m-attributes-key-list.component.scss | 69 + .../lwm2m-attributes-key-list.component.ts | 219 + .../lwm2m/lwm2m-attributes.component.html | 28 + .../lwm2m/lwm2m-attributes.component.ts | 154 + ...ap-add-config-server-dialog.component.html | 55 + ...trap-add-config-server-dialog.component.ts | 60 + ...2m-bootstrap-config-servers.component.html | 40 + ...wm2m-bootstrap-config-servers.component.ts | 221 + .../lwm2m-device-config-server.component.html | 184 + .../lwm2m-device-config-server.component.ts | 221 + ...ile-transport-configuration.component.html | 132 + ...ile-transport-configuration.component.scss | 31 + ...ofile-transport-configuration.component.ts | 577 + ...object-add-instances-dialog.component.html | 52 + ...m-object-add-instances-dialog.component.ts | 62 + ...m-object-add-instances-list.component.html | 50 + ...m2m-object-add-instances-list.component.ts | 146 + .../lwm2m/lwm2m-object-list.component.html | 61 + .../lwm2m/lwm2m-object-list.component.ts | 215 + ...ve-attr-telemetry-instances.component.html | 73 + ...ve-attr-telemetry-instances.component.scss | 34 + ...erve-attr-telemetry-instances.component.ts | 235 + ...ve-attr-telemetry-resources.component.html | 75 + ...ve-attr-telemetry-resources.component.scss | 60 + ...erve-attr-telemetry-resources.component.ts | 197 + ...wm2m-observe-attr-telemetry.component.html | 47 + ...wm2m-observe-attr-telemetry.component.scss | 24 + .../lwm2m-observe-attr-telemetry.component.ts | 269 + .../lwm2m/lwm2m-profile-components.module.ts | 75 + .../lwm2m/lwm2m-profile-config.models.ts | 251 + ...ile-transport-configuration.component.html | 143 + ...ile-transport-configuration.component.scss | 32 + ...ofile-transport-configuration.component.ts | 235 + ...rofile-communication-config.component.html | 72 + ...rofile-communication-config.component.scss | 44 + ...-profile-communication-config.component.ts | 218 + ...snmp-device-profile-mapping.component.html | 81 + ...snmp-device-profile-mapping.component.scss | 49 + .../snmp-device-profile-mapping.component.ts | 165 + ...ile-transport-configuration.component.html | 46 + ...ofile-transport-configuration.component.ts | 141 + .../snmp-device-profile-transport.module.ts | 38 + .../tenant-profile-queues.component.html | 55 + .../tenant-profile-queues.component.scss | 26 + .../queue/tenant-profile-queues.component.ts | 203 + ...tenant-profile-autocomplete.component.html | 72 + ...tenant-profile-autocomplete.component.scss | 21 + .../tenant-profile-autocomplete.component.ts | 268 + .../tenant-profile-data.component.html | 23 + .../profile/tenant-profile-data.component.ts | 101 + .../tenant-profile-dialog.component.html | 53 + .../tenant-profile-dialog.component.ts | 101 + .../profile/tenant-profile.component.html | 104 + .../profile/tenant-profile.component.scss | 49 + .../profile/tenant-profile.component.ts | 141 + ...enant-profile-configuration.component.html | 482 + ...enant-profile-configuration.component.scss | 54 + ...-tenant-profile-configuration.component.ts | 138 + .../rate-limits-details-dialog.component.html | 43 + .../rate-limits-details-dialog.component.ts | 59 + .../rate-limits-list.component.html | 66 + .../rate-limits-list.component.scss | 57 + .../rate-limits/rate-limits-list.component.ts | 152 + .../rate-limits-text.component.html | 18 + .../rate-limits-text.component.scss | 41 + .../rate-limits/rate-limits-text.component.ts | 55 + .../rate-limits/rate-limits.component.html | 33 + .../rate-limits/rate-limits.component.scss | 35 + .../rate-limits/rate-limits.component.ts | 149 + .../tenant/rate-limits/rate-limits.models.ts | 125 + ...enant-profile-configuration.component.html | 27 + .../tenant-profile-configuration.component.ts | 103 + .../app/modules/home/components/public-api.ts | 17 + .../queue/queue-form.component.html | 211 + .../queue/queue-form.component.scss | 50 + .../components/queue/queue-form.component.ts | 217 + .../relation/relation-dialog.component.html | 68 + .../relation/relation-dialog.component.scss | 18 + .../relation/relation-dialog.component.ts | 142 + .../relation/relation-filters.component.html | 68 + .../relation/relation-filters.component.scss | 75 + .../relation/relation-filters.component.ts | 123 + .../relation/relation-table.component.html | 175 + .../relation/relation-table.component.scss | 95 + .../relation/relation-table.component.ts | 352 + .../rule-chain-autocomplete.component.html | 57 + .../rule-chain-autocomplete.component.ts | 216 + .../shared-home-components.module.ts | 39 + ...-sns-provider-configuration.component.html | 41 + ...ws-sns-provider-configuration.component.ts | 104 + ...-sms-provider-configuration.component.html | 162 + ...pp-sms-provider-configuration.component.ts | 137 + .../sms-provider-configuration.component.html | 50 + .../sms-provider-configuration.component.ts | 123 + ...-sms-provider-configuration.component.html | 45 + ...io-sms-provider-configuration.component.ts | 107 + .../src/app/modules/home/components/tokens.ts | 30 + .../vc/auto-commit-settings.component.html | 128 + .../vc/auto-commit-settings.component.scss | 69 + .../vc/auto-commit-settings.component.ts | 217 + .../vc/complex-version-create.component.html | 90 + .../vc/complex-version-create.component.ts | 152 + .../vc/complex-version-load.component.html | 68 + .../vc/complex-version-load.component.ts | 148 + ...entity-types-version-create.component.html | 114 + .../entity-types-version-create.component.ts | 255 + .../entity-types-version-load.component.html | 101 + .../vc/entity-types-version-load.component.ts | 250 + .../vc/entity-types-version.component.scss | 68 + .../vc/entity-version-create.component.html | 90 + .../vc/entity-version-create.component.ts | 147 + .../vc/entity-version-diff.component.html | 72 + .../vc/entity-version-diff.component.scss | 71 + .../vc/entity-version-diff.component.ts | 324 + .../vc/entity-version-restore.component.html | 79 + .../vc/entity-version-restore.component.ts | 147 + .../vc/entity-versions-table.component.html | 176 + .../vc/entity-versions-table.component.scss | 107 + .../vc/entity-versions-table.component.ts | 459 + ...move-other-entities-confirm.component.html | 41 + ...remove-other-entities-confirm.component.ts | 66 + .../vc/repository-settings.component.html | 118 + .../vc/repository-settings.component.scss | 44 + .../vc/repository-settings.component.ts | 223 + .../vc/version-control.component.html | 31 + .../vc/version-control.component.scss | 18 + .../vc/version-control.component.ts | 78 + .../home/components/vc/version-control.scss | 34 + ...custom-action-pretty-editor.component.html | 56 + ...custom-action-pretty-editor.component.scss | 107 + .../custom-action-pretty-editor.component.ts | 120 + ...ction-pretty-resources-tabs.component.html | 103 + ...ction-pretty-resources-tabs.component.scss | 78 + ...-action-pretty-resources-tabs.component.ts | 244 + .../widget/action/custom-action.models.ts | 77 + .../widget/action/custom-sample-css.raw | 106 + .../widget/action/custom-sample-html.raw | 353 + .../widget/action/custom-sample-js.raw | 405 + .../manage-widget-actions.component.html | 146 + .../manage-widget-actions.component.models.ts | 166 + .../manage-widget-actions.component.scss | 57 + .../action/manage-widget-actions.component.ts | 357 + .../mobile-action-editor.component.html | 116 + .../action/mobile-action-editor.component.ts | 260 + .../action/mobile-action-editor.models.ts | 311 + .../widget-action-dialog.component.html | 257 + .../widget-action-dialog.component.scss | 20 + .../action/widget-action-dialog.component.ts | 495 + .../data-key-config-dialog.component.html | 58 + .../data-key-config-dialog.component.scss | 22 + .../data-key-config-dialog.component.ts | 92 + .../widget/data-key-config.component.html | 178 + .../widget/data-key-config.component.scss | 132 + .../widget/data-key-config.component.ts | 427 + .../widget/data-keys.component.html | 169 + .../widget/data-keys.component.models.ts | 24 + .../widget/data-keys.component.scss | 74 + .../components/widget/data-keys.component.ts | 549 + .../custom-dialog-container.component.ts | 85 + .../widget/dialog/custom-dialog.component.ts | 50 + .../widget/dialog/custom-dialog.service.ts | 74 + .../dialog/embed-dashboard-dialog-token.ts | 22 + .../embed-dashboard-dialog.component.html | 41 + .../embed-dashboard-dialog.component.scss | 42 + .../embed-dashboard-dialog.component.ts | 82 + .../widget/dynamic-widget.component.ts | 139 + .../widget/legend-config.component.html | 64 + .../widget/legend-config.component.ts | 138 + .../components/widget/legend.component.html | 87 + .../components/widget/legend.component.scss | 91 + .../components/widget/legend.component.ts | 73 + .../lib/alarm-filter-panel.component.html | 65 + .../lib/alarm-filter-panel.component.scss | 36 + .../lib/alarm-filter-panel.component.ts | 119 + .../lib/alarms-table-widget.component.html | 153 + .../lib/alarms-table-widget.component.scss | 39 + .../lib/alarms-table-widget.component.ts | 1231 ++ .../widget/lib/analogue-compass.models.ts | 38 + .../components/widget/lib/analogue-compass.ts | 101 + .../widget/lib/analogue-gauge.models.ts | 274 + .../lib/analogue-linear-gauge.models.ts | 26 + .../widget/lib/analogue-linear-gauge.ts | 59 + .../lib/analogue-radial-gauge.models.ts | 23 + .../widget/lib/analogue-radial-gauge.ts | 58 + .../widget/lib/canvas-digital-gauge.ts | 1036 ++ .../date-range-navigator-panel.component.html | 35 + .../date-range-navigator-panel.component.scss | 34 + .../date-range-navigator.component.html | 56 + .../date-range-navigator.component.scss | 98 + .../date-range-navigator.component.ts | 315 + .../date-range-navigator.models.ts | 109 + .../widget/lib/digital-gauge.models.ts | 82 + .../components/widget/lib/digital-gauge.ts | 434 + .../lib/display-columns-panel.component.html | 24 + .../lib/display-columns-panel.component.scss | 36 + .../lib/display-columns-panel.component.ts | 43 + .../lib/edges-overview-widget.component.html | 28 + .../lib/edges-overview-widget.component.scss | 128 + .../lib/edges-overview-widget.component.ts | 197 + .../lib/edges-overview-widget.models.ts | 108 + .../entities-hierarchy-widget.component.html | 51 + .../entities-hierarchy-widget.component.scss | 121 + .../entities-hierarchy-widget.component.ts | 480 + .../lib/entities-hierarchy-widget.models.ts | 171 + .../lib/entities-table-widget.component.html | 112 + .../lib/entities-table-widget.component.scss | 39 + .../lib/entities-table-widget.component.ts | 892 ++ .../widget/lib/flot-widget.models.ts | 243 + .../home/components/widget/lib/flot-widget.ts | 1587 +++ .../lib/gateway/gateway-form.component.html | 266 + .../lib/gateway/gateway-form.component.scss | 40 + .../lib/gateway/gateway-form.component.ts | 426 + .../widget/lib/gateway/gateway-form.models.ts | 380 + .../lib/json-input-widget.component.html | 59 + .../lib/json-input-widget.component.scss | 34 + .../widget/lib/json-input-widget.component.ts | 221 + .../home/components/widget/lib/maps/circle.ts | 166 + .../widget/lib/maps/common-maps-utils.ts | 314 + .../select-entity-dialog.component.html | 54 + .../select-entity-dialog.component.scss | 21 + .../dialogs/select-entity-dialog.component.ts | 56 + .../components/widget/lib/maps/leaflet-map.ts | 1242 ++ .../components/widget/lib/maps/map-models.ts | 685 + .../widget/lib/maps/map-widget.interface.ts | 28 + .../components/widget/lib/maps/map-widget2.ts | 300 + .../components/widget/lib/maps/maps-utils.ts | 85 + .../components/widget/lib/maps/markers.scss | 55 + .../components/widget/lib/maps/markers.ts | 260 + .../components/widget/lib/maps/polygon.ts | 193 + .../components/widget/lib/maps/polyline.ts | 95 + .../widget/lib/maps/providers/google-map.ts | 66 + .../widget/lib/maps/providers/here-map.ts | 33 + .../widget/lib/maps/providers/image-map.ts | 379 + .../widget/lib/maps/providers/index.ts | 31 + .../lib/maps/providers/openstreet-map.ts | 38 + .../widget/lib/maps/providers/tencent-map.ts | 39 + .../widget/lib/markdown-widget.component.html | 19 + .../widget/lib/markdown-widget.component.ts | 134 + .../lib/multiple-input-widget.component.html | 169 + .../lib/multiple-input-widget.component.scss | 87 + .../lib/multiple-input-widget.component.ts | 721 ++ .../lib/navigation-card-widget.component.html | 21 + .../lib/navigation-card-widget.component.scss | 59 + .../lib/navigation-card-widget.component.ts | 66 + .../navigation-cards-widget.component.html | 37 + .../navigation-cards-widget.component.scss | 85 + .../lib/navigation-cards-widget.component.ts | 114 + .../lib/photo-camera-input.component.html | 65 + .../lib/photo-camera-input.component.scss | 82 + .../lib/photo-camera-input.component.ts | 330 + .../widget/lib/qrcode-widget.component.html | 22 + .../widget/lib/qrcode-widget.component.ts | 135 + .../widget/lib/rpc/knob.component.html | 45 + .../widget/lib/rpc/knob.component.scss | 165 + .../widget/lib/rpc/knob.component.ts | 467 + .../lib/rpc/led-indicator.component.html | 32 + .../lib/rpc/led-indicator.component.scss | 69 + .../widget/lib/rpc/led-indicator.component.ts | 361 + .../rpc/persistent-add-dialog.component.html | 92 + .../rpc/persistent-add-dialog.component.scss | 30 + .../rpc/persistent-add-dialog.component.ts | 75 + .../persistent-details-dialog.component.html | 118 + .../persistent-details-dialog.component.scss | 34 + .../persistent-details-dialog.component.ts | 126 + .../persistent-filter-panel.component.html | 44 + .../persistent-filter-panel.component.scss | 36 + .../rpc/persistent-filter-panel.component.ts | 67 + .../lib/rpc/persistent-table.component.html | 126 + .../lib/rpc/persistent-table.component.scss | 53 + .../lib/rpc/persistent-table.component.ts | 548 + .../lib/rpc/round-switch.component.html | 38 + .../lib/rpc/round-switch.component.scss | 193 + .../widget/lib/rpc/round-switch.component.ts | 357 + .../widget/lib/rpc/rpc-widgets.module.ts | 55 + .../components/widget/lib/rpc/svg/knob.svg | 36 + .../widget/lib/rpc/svg/thumb-bar-checked.svg | 43 + .../widget/lib/rpc/svg/thumb-bar.svg | 42 + .../widget/lib/rpc/svg/thumb-checked.svg | 40 + .../components/widget/lib/rpc/svg/thumb.svg | 37 + .../widget/lib/rpc/switch.component.html | 50 + .../widget/lib/rpc/switch.component.scss | 148 + .../widget/lib/rpc/switch.component.ts | 402 + .../components/widget/lib/settings.models.ts | 44 + .../alarms-table-key-settings.component.html | 99 + .../alarms-table-key-settings.component.ts | 88 + ...larms-table-widget-settings.component.html | 110 + .../alarms-table-widget-settings.component.ts | 104 + ...board-state-widget-settings.component.html | 65 + ...shboard-state-widget-settings.component.ts | 99 + ...ck-overview-widget-settings.component.html | 22 + ...uick-overview-widget-settings.component.ts | 52 + ...s-hierarchy-widget-settings.component.html | 74 + ...ies-hierarchy-widget-settings.component.ts | 64 + ...entities-table-key-settings.component.html | 102 + .../entities-table-key-settings.component.ts | 88 + ...ities-table-widget-settings.component.html | 116 + ...ntities-table-widget-settings.component.ts | 118 + .../html-card-widget-settings.component.html | 25 + .../html-card-widget-settings.component.ts | 54 + .../cards/label-widget-label.component.html | 73 + .../cards/label-widget-label.component.scss | 40 + .../cards/label-widget-label.component.ts | 113 + .../label-widget-settings.component.html | 50 + .../cards/label-widget-settings.component.ts | 110 + .../markdown-widget-settings.component.html | 37 + .../markdown-widget-settings.component.ts | 76 + .../qrcode-widget-settings.component.html | 38 + .../cards/qrcode-widget-settings.component.ts | 74 + ...simple-card-widget-settings.component.html | 30 + .../simple-card-widget-settings.component.ts | 52 + ...meseries-table-key-settings.component.html | 69 + ...timeseries-table-key-settings.component.ts | 80 + ...s-table-latest-key-settings.component.html | 76 + ...ies-table-latest-key-settings.component.ts | 96 + ...eries-table-widget-settings.component.html | 96 + ...eseries-table-widget-settings.component.ts | 98 + .../chart-widget-settings.component.html | 25 + .../chart/chart-widget-settings.component.ts | 52 + ...ghnut-chart-widget-settings.component.html | 64 + ...oughnut-chart-widget-settings.component.ts | 87 + .../flot-bar-key-settings.component.html | 23 + .../chart/flot-bar-key-settings.component.ts | 61 + .../flot-bar-widget-settings.component.html | 22 + .../flot-bar-widget-settings.component.ts | 61 + .../chart/flot-key-settings.component.html | 239 + .../chart/flot-key-settings.component.ts | 333 + .../flot-latest-key-settings.component.html | 48 + .../flot-latest-key-settings.component.ts | 74 + .../flot-line-key-settings.component.html | 23 + .../chart/flot-line-key-settings.component.ts | 61 + .../flot-line-widget-settings.component.html | 22 + .../flot-line-widget-settings.component.ts | 62 + .../flot-pie-key-settings.component.html | 31 + .../chart/flot-pie-key-settings.component.ts | 60 + .../flot-pie-widget-settings.component.html | 62 + .../flot-pie-widget-settings.component.ts | 79 + .../chart/flot-threshold.component.html | 58 + .../chart/flot-threshold.component.scss | 40 + .../chart/flot-threshold.component.ts | 136 + .../chart/flot-widget-settings.component.html | 336 + .../chart/flot-widget-settings.component.ts | 410 + .../chart/label-data-key.component.html | 63 + .../chart/label-data-key.component.scss | 40 + .../chart/label-data-key.component.ts | 132 + .../common/value-source.component.html | 77 + .../settings/common/value-source.component.ts | 255 + .../common/widget-font.component.html | 86 + .../settings/common/widget-font.component.ts | 114 + .../device-key-autocomplete.component.html | 41 + .../device-key-autocomplete.component.ts | 216 + ...nob-control-widget-settings.component.html | 80 + .../knob-control-widget-settings.component.ts | 95 + ...d-indicator-widget-settings.component.html | 103 + ...led-indicator-widget-settings.component.ts | 133 + ...stent-table-widget-settings.component.html | 111 + ...sistent-table-widget-settings.component.ts | 221 + ...ound-switch-widget-settings.component.html | 30 + .../round-switch-widget-settings.component.ts | 79 + .../control/rpc-button-style.component.html | 40 + .../control/rpc-button-style.component.ts | 98 + .../rpc-shell-widget-settings.component.html | 26 + .../rpc-shell-widget-settings.component.ts | 53 + ...pc-terminal-widget-settings.component.html | 48 + .../rpc-terminal-widget-settings.component.ts | 75 + .../send-rpc-widget-settings.component.html | 78 + .../send-rpc-widget-settings.component.ts | 99 + ...lide-toggle-widget-settings.component.html | 57 + .../slide-toggle-widget-settings.component.ts | 87 + ...tch-control-widget-settings.component.html | 33 + ...witch-control-widget-settings.component.ts | 83 + .../switch-rpc-settings.component.html | 108 + .../control/switch-rpc-settings.component.ts | 219 + ...e-attribute-widget-settings.component.html | 50 + ...ice-attribute-widget-settings.component.ts | 68 + ...e-navigator-widget-settings.component.html | 146 + ...nge-navigator-widget-settings.component.ts | 120 + ...ngle-device-widget-settings.component.html | 26 + ...single-device-widget-settings.component.ts | 54 + ...eway-config-widget-settings.component.html | 45 + ...ateway-config-widget-settings.component.ts | 60 + ...eway-events-widget-settings.component.html | 41 + ...ateway-events-widget-settings.component.ts | 82 + ...gue-compass-widget-settings.component.html | 178 + ...logue-compass-widget-settings.component.ts | 171 + ...logue-gauge-widget-settings.component.html | 353 + ...nalogue-gauge-widget-settings.component.ts | 257 + ...-linear-gauge-widget-settings.component.ts | 66 + ...-radial-gauge-widget-settings.component.ts | 57 + ...gital-gauge-widget-settings.component.html | 382 + ...digital-gauge-widget-settings.component.ts | 388 + .../gauge/fixed-color-level.component.html | 60 + .../gauge/fixed-color-level.component.scss | 40 + .../gauge/fixed-color-level.component.ts | 147 + .../gauge/gauge-highlight.component.html | 61 + .../gauge/gauge-highlight.component.scss | 40 + .../gauge/gauge-highlight.component.ts | 116 + .../settings/gauge/tick-value.component.html | 44 + .../settings/gauge/tick-value.component.scss | 40 + .../settings/gauge/tick-value.component.ts | 120 + ...pio-control-widget-settings.component.html | 96 + .../gpio-control-widget-settings.component.ts | 145 + .../settings/gpio/gpio-item.component.html | 73 + .../settings/gpio/gpio-item.component.scss | 40 + .../lib/settings/gpio/gpio-item.component.ts | 150 + .../gpio-panel-widget-settings.component.html | 55 + .../gpio-panel-widget-settings.component.ts | 114 + .../datakey-select-option.component.html | 53 + .../datakey-select-option.component.scss | 40 + .../input/datakey-select-option.component.ts | 128 + ...ce-claiming-widget-settings.component.html | 84 + ...vice-claiming-widget-settings.component.ts | 110 + ...amera-input-widget-settings.component.html | 59 + ...-camera-input-widget-settings.component.ts | 67 + ...-attribute-general-settings.component.html | 47 + ...te-attribute-general-settings.component.ts | 172 + ...n-attribute-widget-settings.component.html | 42 + ...ean-attribute-widget-settings.component.ts | 58 + ...e-attribute-widget-settings.component.html | 29 + ...ate-attribute-widget-settings.component.ts | 73 + ...e-attribute-widget-settings.component.html | 35 + ...ble-attribute-widget-settings.component.ts | 77 + ...e-attribute-widget-settings.component.html | 46 + ...age-attribute-widget-settings.component.ts | 62 + ...r-attribute-widget-settings.component.html | 35 + ...ger-attribute-widget-settings.component.ts | 77 + ...n-attribute-widget-settings.component.html | 68 + ...son-attribute-widget-settings.component.ts | 93 + ...n-attribute-widget-settings.component.html | 84 + ...ion-attribute-widget-settings.component.ts | 109 + ...ple-attributes-key-settings.component.html | 247 + ...tiple-attributes-key-settings.component.ts | 250 + ...-attributes-widget-settings.component.html | 94 + ...le-attributes-widget-settings.component.ts | 117 + ...g-attribute-widget-settings.component.html | 35 + ...ing-attribute-widget-settings.component.ts | 77 + .../map/circle-settings.component.html | 199 + .../settings/map/circle-settings.component.ts | 243 + .../map/common-map-settings.component.html | 96 + .../map/common-map-settings.component.ts | 179 + ...atasources-key-autocomplete.component.html | 39 + .../datasources-key-autocomplete.component.ts | 152 + ...oogle-map-provider-settings.component.html | 31 + .../google-map-provider-settings.component.ts | 122 + .../here-map-provider-settings.component.html | 40 + .../here-map-provider-settings.component.ts | 125 + ...image-map-provider-settings.component.html | 71 + .../image-map-provider-settings.component.ts | 253 + .../map/map-editor-settings.component.html | 52 + .../map/map-editor-settings.component.ts | 141 + .../map/map-provider-settings.component.html | 51 + .../map/map-provider-settings.component.ts | 204 + .../settings/map/map-settings.component.html | 53 + .../settings/map/map-settings.component.ts | 217 + .../map/map-widget-settings.component.html | 24 + .../map/map-widget-settings.component.ts | 63 + .../marker-clustering-settings.component.html | 94 + .../marker-clustering-settings.component.ts | 145 + .../map/markers-settings.component.html | 197 + .../map/markers-settings.component.ts | 264 + ...treet-map-provider-settings.component.html | 46 + ...nstreet-map-provider-settings.component.ts | 139 + .../map/polygon-settings.component.html | 199 + .../map/polygon-settings.component.ts | 243 + .../map/route-map-settings.component.html | 32 + .../map/route-map-settings.component.ts | 115 + .../route-map-widget-settings.component.html | 24 + .../route-map-widget-settings.component.ts | 63 + ...ncent-map-provider-settings.component.html | 31 + ...tencent-map-provider-settings.component.ts | 122 + ...p-animation-common-settings.component.html | 94 + ...rip-animation-common-settings.component.ts | 173 + ...p-animation-marker-settings.component.html | 97 + ...rip-animation-marker-settings.component.ts | 175 + ...rip-animation-path-settings.component.html | 116 + .../trip-animation-path-settings.component.ts | 187 + ...ip-animation-point-settings.component.html | 94 + ...trip-animation-point-settings.component.ts | 163 + ...p-animation-widget-settings.component.html | 45 + ...rip-animation-widget-settings.component.ts | 101 + ...gation-card-widget-settings.component.html | 32 + ...vigation-card-widget-settings.component.ts | 56 + ...ation-cards-widget-settings.component.html | 56 + ...igation-cards-widget-settings.component.ts | 156 + .../lib/settings/widget-settings.module.ts | 531 + .../widget/lib/settings/widget-settings.scss | 157 + .../widget/lib/table-widget.models.ts | 485 + .../components/widget/lib/table-widget.scss | 61 + .../timeseries-table-widget.component.html | 121 + .../timeseries-table-widget.component.scss | 50 + .../lib/timeseries-table-widget.component.ts | 854 ++ .../trip-animation.component.html | 45 + .../trip-animation.component.scss | 91 + .../trip-animation.component.ts | 339 + .../widget/widget-component.service.ts | 578 + .../widget/widget-components.module.ts | 99 + .../widget/widget-config.component.html | 538 + .../widget/widget-config.component.models.ts | 22 + .../widget/widget-config.component.scss | 228 + .../widget/widget-config.component.ts | 998 ++ .../widget/widget-container.component.html | 116 + .../widget/widget-container.component.scss | 121 + .../widget/widget-container.component.ts | 194 + .../widget/widget-settings.component.html | 24 + .../widget/widget-settings.component.scss | 22 + .../widget/widget-settings.component.ts | 231 + .../components/widget/widget.component.html | 46 + .../components/widget/widget.component.scss | 71 + .../components/widget/widget.component.ts | 1550 +++ .../device-wizard-dialog.component.html | 204 + .../device-wizard-dialog.component.scss | 74 + .../wizard/device-wizard-dialog.component.ts | 402 + ...entities-to-customer-dialog.component.html | 56 + ...d-entities-to-customer-dialog.component.ts | 140 + ...add-entities-to-edge-dialog.component.html | 57 + .../add-entities-to-edge-dialog.component.ts | 146 + .../assign-to-customer-dialog.component.html | 55 + .../assign-to-customer-dialog.component.ts | 132 + .../home/dialogs/home-dialogs.module.ts | 45 + .../home/dialogs/home-dialogs.service.ts | 57 + .../app/modules/home/home-routing.module.ts | 45 + .../src/app/modules/home/home.component.html | 83 + .../src/app/modules/home/home.component.scss | 74 + ui-ngx/src/app/modules/home/home.component.ts | 174 + ui-ngx/src/app/modules/home/home.module.ts | 41 + .../home/menu/menu-link.component.html | 22 + .../home/menu/menu-link.component.scss | 18 + .../modules/home/menu/menu-link.component.ts | 36 + .../home/menu/menu-toggle.component.html | 30 + .../home/menu/menu-toggle.component.scss | 46 + .../home/menu/menu-toggle.component.ts | 52 + .../home/menu/side-menu.component.html | 23 + .../home/menu/side-menu.component.scss | 69 + .../modules/home/menu/side-menu.component.ts | 41 + .../app/modules/home/models/contact.models.ts | 291 + .../home/models/dashboard-component.models.ts | 637 + .../models/datasource/attribute-datasource.ts | 152 + .../models/datasource/entity-datasource.ts | 141 + .../models/datasource/relation-datasource.ts | 145 + .../entity/entities-table-config.models.ts | 249 + .../models/entity/entity-component.models.ts | 28 + .../entity-details-page-component.models.ts | 19 + .../entity/entity-table-component.models.ts | 88 + .../models/searchable-component.models.ts | 23 + .../app/modules/home/models/services.map.ts | 74 + .../home/models/widget-component.models.ts | 616 + .../home/pages/admin/admin-routing.module.ts | 282 + .../modules/home/pages/admin/admin.module.ts | 59 + .../auto-commit-admin-settings.component.html | 23 + .../auto-commit-admin-settings.component.ts | 51 + .../admin/general-settings.component.html | 51 + .../admin/general-settings.component.scss | 18 + .../pages/admin/general-settings.component.ts | 75 + .../pages/admin/home-settings.component.html | 54 + .../pages/admin/home-settings.component.scss | 35 + .../pages/admin/home-settings.component.ts | 84 + .../pages/admin/mail-server.component.html | 152 + .../pages/admin/mail-server.component.scss | 18 + .../home/pages/admin/mail-server.component.ts | 167 + .../admin/oauth2-settings.component.html | 600 + .../admin/oauth2-settings.component.scss | 71 + .../pages/admin/oauth2-settings.component.ts | 573 + .../pages/admin/queue/queue.component.html | 48 + .../home/pages/admin/queue/queue.component.ts | 87 + .../queue/queues-table-config.resolver.ts | 133 + .../repository-admin-settings.component.html | 18 + .../repository-admin-settings.component.ts | 44 + .../resources-library-table-config.resolve.ts | 156 + .../resource/resources-library.component.html | 89 + .../resource/resources-library.component.ts | 147 + .../admin/security-settings.component.html | 253 + .../admin/security-settings.component.scss | 39 + .../admin/security-settings.component.ts | 193 + .../admin/send-test-sms-dialog.component.html | 64 + .../admin/send-test-sms-dialog.component.ts | 87 + .../home/pages/admin/settings-card.scss | 30 + .../pages/admin/sms-provider.component.html | 50 + .../pages/admin/sms-provider.component.scss | 18 + .../pages/admin/sms-provider.component.ts | 95 + .../two-factor-auth-settings.component.html | 202 + .../two-factor-auth-settings.component.scss | 89 + .../two-factor-auth-settings.component.ts | 246 + .../api-usage/api-usage-routing.module.ts | 41 + .../pages/api-usage/api-usage.component.html | 18 + .../pages/api-usage/api-usage.component.scss | 19 + .../pages/api-usage/api-usage.component.ts | 41 + .../home/pages/api-usage/api-usage.module.ts | 36 + .../home/pages/api-usage/api_usage_json.raw | 4774 +++++++ .../asset-profile-routing.module.ts | 74 + .../asset-profile-tabs.component.html | 27 + .../asset-profile-tabs.component.ts | 38 + .../asset-profile/asset-profile.module.ts | 35 + .../asset-profiles-table-config.resolver.ts | 190 + .../home/pages/asset/asset-routing.module.ts | 78 + .../asset/asset-table-header.component.html | 23 + .../asset/asset-table-header.component.scss | 49 + .../asset/asset-table-header.component.ts | 43 + .../pages/asset/asset-tabs.component.html | 57 + .../home/pages/asset/asset-tabs.component.ts | 38 + .../home/pages/asset/asset.component.html | 111 + .../home/pages/asset/asset.component.scss | 18 + .../home/pages/asset/asset.component.ts | 103 + .../modules/home/pages/asset/asset.module.ts | 41 + .../asset/assets-table-config.resolver.ts | 565 + .../audit-log/audit-log-routing.module.ts | 42 + .../home/pages/audit-log/audit-log.module.ts | 31 + .../pages/customer/customer-routing.module.ts | 280 + .../customer/customer-tabs.component.html | 57 + .../pages/customer/customer-tabs.component.ts | 38 + .../pages/customer/customer.component.html | 110 + .../pages/customer/customer.component.scss | 35 + .../home/pages/customer/customer.component.ts | 99 + .../home/pages/customer/customer.module.ts | 37 + .../customers-table-config.resolver.ts | 209 + .../dashboard/dashboard-form.component.html | 142 + .../dashboard/dashboard-form.component.scss | 18 + .../dashboard/dashboard-form.component.ts | 139 + .../dashboard/dashboard-routing.module.ts | 100 + .../dashboard/dashboard-tabs.component.html | 27 + .../dashboard/dashboard-tabs.component.ts | 38 + .../home/pages/dashboard/dashboard.module.ts | 43 + .../dashboards-table-config.resolver.ts | 650 + ...ake-dashboard-public-dialog.component.html | 63 + .../make-dashboard-public-dialog.component.ts | 77 + ...-dashboard-customers-dialog.component.html | 57 + ...ge-dashboard-customers-dialog.component.ts | 130 + .../device-profile-routing.module.ts | 74 + .../device-profile-tabs.component.html | 83 + .../device-profile-tabs.component.ts | 54 + .../device-profile/device-profile.module.ts | 35 + .../device-profiles-table-config.resolver.ts | 224 + ...ice-transport-configuration.component.html | 21 + ...evice-transport-configuration.component.ts | 119 + ...efault-device-configuration.component.html | 24 + .../default-device-configuration.component.ts | 97 + ...ice-transport-configuration.component.html | 24 + ...evice-transport-configuration.component.ts | 97 + .../data/device-configuration.component.html | 27 + .../data/device-configuration.component.ts | 103 + .../device/data/device-data.component.html | 43 + .../device/data/device-data.component.ts | 129 + ...ice-transport-configuration.component.html | 51 + ...evice-transport-configuration.component.ts | 126 + ...ice-transport-configuration.component.html | 21 + ...evice-transport-configuration.component.ts | 119 + ...ice-transport-configuration.component.html | 24 + ...evice-transport-configuration.component.ts | 96 + ...ice-transport-configuration.component.html | 133 + ...evice-transport-configuration.component.ts | 178 + .../device-credentials-dialog.component.html | 63 + .../device-credentials-dialog.component.ts | 110 + .../pages/device/device-routing.module.ts | 78 + .../device/device-table-header.component.html | 23 + .../device/device-table-header.component.scss | 49 + .../device/device-table-header.component.ts | 43 + .../pages/device/device-tabs.component.html | 57 + .../pages/device/device-tabs.component.ts | 38 + .../home/pages/device/device.component.html | 152 + .../home/pages/device/device.component.scss | 18 + .../home/pages/device/device.component.ts | 175 + .../home/pages/device/device.module.ts | 65 + .../device/devices-table-config.resolver.ts | 644 + .../home/pages/edge/edge-routing.module.ts | 379 + .../edge/edge-table-header.component.html | 23 + .../edge/edge-table-header.component.scss | 49 + .../pages/edge/edge-table-header.component.ts | 42 + .../home/pages/edge/edge-tabs.component.html | 56 + .../home/pages/edge/edge-tabs.component.ts | 38 + .../home/pages/edge/edge.component.html | 183 + .../home/pages/edge/edge.component.scss | 18 + .../modules/home/pages/edge/edge.component.ts | 136 + .../modules/home/pages/edge/edge.module.ts | 42 + .../pages/edge/edges-table-config.resolver.ts | 564 + .../entity-view/entity-view-routing.module.ts | 78 + .../entity-view-table-header.component.html | 23 + .../entity-view-table-header.component.scss | 49 + .../entity-view-table-header.component.ts | 42 + .../entity-view-tabs.component.html | 57 + .../entity-view/entity-view-tabs.component.ts | 38 + .../entity-view/entity-view.component.html | 171 + .../entity-view/entity-view.component.scss | 20 + .../entity-view/entity-view.component.ts | 141 + .../pages/entity-view/entity-view.module.ts | 41 + .../entity-views-table-config.resolver.ts | 537 + .../home-links/home-links-routing.module.ts | 62 + .../home-links/home-links.component.html | 40 + .../home-links/home-links.component.scss | 85 + .../pages/home-links/home-links.component.ts | 76 + .../pages/home-links/home-links.module.ts | 37 + .../modules/home/pages/home-pages.models.ts | 24 + .../modules/home/pages/home-pages.module.ts | 68 + .../ota-update/ota-update-routing.module.ts | 75 + .../ota-update-table-config.resolve.ts | 170 + .../ota-update/ota-update.component.html | 184 + .../pages/ota-update/ota-update.component.ts | 197 + .../pages/ota-update/ota-update.module.ts | 35 + .../pages/profile/profile-routing.module.ts | 69 + .../home/pages/profile/profile.component.html | 92 + .../home/pages/profile/profile.component.scss | 58 + .../home/pages/profile/profile.component.ts | 129 + .../home/pages/profile/profile.module.ts | 33 + .../pages/profiles/profiles-routing.module.ts | 51 + .../home/pages/profiles/profiles.module.ts | 30 + .../src/app/modules/home/pages/public-api.ts | 17 + .../add-rule-node-dialog.component.html | 56 + .../add-rule-node-dialog.component.scss | 26 + .../add-rule-node-link-dialog.component.html | 55 + .../add-rule-node-link-dialog.component.scss | 22 + ...ate-nested-rulechain-dialog.component.html | 65 + .../rulechain/link-labels.component.html | 66 + .../pages/rulechain/link-labels.component.ts | 289 + .../pages/rulechain/rule-node-colors.scss | 45 + .../rulechain/rule-node-config.component.html | 28 + .../rulechain/rule-node-config.component.scss | 27 + .../rulechain/rule-node-config.component.ts | 210 + .../rule-node-details.component.html | 58 + .../rule-node-details.component.scss | 20 + .../rulechain/rule-node-details.component.ts | 133 + .../rulechain/rule-node-link.component.html | 28 + .../rulechain/rule-node-link.component.ts | 114 + .../rulechain/rulechain-page.component.html | 267 + .../rulechain/rulechain-page.component.scss | 398 + .../rulechain/rulechain-page.component.ts | 1856 +++ .../pages/rulechain/rulechain-page.models.ts | 45 + .../rulechain/rulechain-routing.module.ts | 205 + .../rulechain/rulechain-tabs.component.html | 60 + .../rulechain/rulechain-tabs.component.ts | 38 + .../pages/rulechain/rulechain.component.html | 102 + .../pages/rulechain/rulechain.component.scss | 18 + .../pages/rulechain/rulechain.component.ts | 115 + .../home/pages/rulechain/rulechain.module.ts | 65 + .../rulechains-table-config.resolver.ts | 593 + .../pages/rulechain/rulenode.component.html | 67 + .../pages/rulechain/rulenode.component.scss | 154 + .../pages/rulechain/rulenode.component.ts | 97 + .../authentication-dialog.component.scss | 120 + .../authentication-dialog.map.ts | 33 + .../backup-code-auth-dialog.component.html | 51 + .../backup-code-auth-dialog.component.ts | 88 + .../backup-code-print-template.raw | 50 + .../email-auth-dialog.component.html | 111 + .../email-auth-dialog.component.ts | 113 + .../sms-auth-dialog.component.html | 104 + .../sms-auth-dialog.component.ts | 111 + .../totp-auth-dialog.component.html | 94 + .../totp-auth-dialog.component.ts | 93 + .../pages/security/security-routing.module.ts | 84 + .../pages/security/security.component.html | 173 + .../pages/security/security.component.scss | 130 + .../home/pages/security/security.component.ts | 382 + .../home/pages/security/security.module.ts | 43 + .../tenant-profile-routing.module.ts | 76 + .../tenant-profile-tabs.component.html | 43 + .../tenant-profile-tabs.component.ts | 38 + .../tenant-profile/tenant-profile.module.ts | 35 + .../tenant-profiles-table-config.resolver.ts | 182 + .../pages/tenant/tenant-routing.module.ts | 115 + .../pages/tenant/tenant-tabs.component.html | 47 + .../pages/tenant/tenant-tabs.component.ts | 38 + .../home/pages/tenant/tenant.component.html | 92 + .../home/pages/tenant/tenant.component.scss | 35 + .../home/pages/tenant/tenant.component.ts | 104 + .../home/pages/tenant/tenant.module.ts | 37 + .../tenant/tenants-table-config.resolver.ts | 117 + .../activation-link-dialog.component.html | 57 + .../user/activation-link-dialog.component.ts | 66 + .../pages/user/add-user-dialog.component.html | 57 + .../pages/user/add-user-dialog.component.scss | 18 + .../pages/user/add-user-dialog.component.ts | 118 + .../home/pages/user/user-routing.module.ts | 67 + .../home/pages/user/user-tabs.component.html | 42 + .../home/pages/user/user-tabs.component.ts | 38 + .../home/pages/user/user.component.html | 133 + .../home/pages/user/user.component.scss | 35 + .../modules/home/pages/user/user.component.ts | 118 + .../modules/home/pages/user/user.module.ts | 41 + .../pages/user/users-table-config.resolver.ts | 264 + .../home/pages/vc/vc-routing.module.ts | 44 + .../app/modules/home/pages/vc/vc.module.ts | 31 + .../save-widget-type-as-dialog.component.html | 62 + .../save-widget-type-as-dialog.component.ts | 81 + .../select-widget-type-dialog.component.html | 57 + .../select-widget-type-dialog.component.scss | 48 + .../select-widget-type-dialog.component.ts | 52 + .../pages/widget/widget-editor.component.html | 321 + .../pages/widget/widget-editor.component.scss | 208 + .../pages/widget/widget-editor.component.ts | 790 ++ .../home/pages/widget/widget-editor.models.ts | 93 + .../widget/widget-library-routing.module.ts | 217 + .../widget/widget-library.component.html | 42 + .../widget/widget-library.component.scss | 31 + .../pages/widget/widget-library.component.ts | 208 + .../pages/widget/widget-library.module.ts | 45 + .../widget/widgets-bundle-tabs.component.html | 23 + .../widget/widgets-bundle-tabs.component.ts | 43 + .../widget/widgets-bundle.component.html | 63 + .../widget/widgets-bundle.component.scss | 18 + .../pages/widget/widgets-bundle.component.ts | 65 + .../widgets-bundles-table-config.resolver.ts | 185 + ui-ngx/src/app/modules/home/public-api.ts | 17 + .../app/modules/login/login-routing.module.ts | 91 + ui-ngx/src/app/modules/login/login.module.ts | 42 + .../login/create-password.component.html | 58 + .../login/create-password.component.scss | 29 + .../pages/login/create-password.component.ts | 74 + .../login/pages/login/login.component.html | 71 + .../login/pages/login/login.component.scss | 93 + .../login/pages/login/login.component.ts | 79 + .../reset-password-request.component.html | 55 + .../reset-password-request.component.scss | 29 + .../login/reset-password-request.component.ts | 68 + .../pages/login/reset-password.component.html | 61 + .../pages/login/reset-password.component.scss | 34 + .../pages/login/reset-password.component.ts | 77 + .../two-factor-auth-login.component.html | 95 + .../two-factor-auth-login.component.scss | 94 + .../login/two-factor-auth-login.component.ts | 204 + .../shared/adapter/custom-datatime-adapter.ts | 36 + .../animations/speed-dial-fab.animations.ts | 58 + .../components/breadcrumb.component.html | 41 + .../components/breadcrumb.component.scss | 57 + .../shared/components/breadcrumb.component.ts | 136 + .../src/app/shared/components/breadcrumb.ts | 38 + .../button/copy-button.component.html | 31 + .../button/copy-button.component.scss | 30 + .../button/copy-button.component.ts | 87 + .../button/toggle-password.component.html | 20 + .../button/toggle-password.component.ts | 44 + .../shared/components/cheatsheet.component.ts | 167 + .../components/circular-progress.directive.ts | 82 + .../components/color-input.component.html | 36 + .../components/color-input.component.scss | 26 + .../components/color-input.component.ts | 165 + .../shared/components/contact.component.html | 70 + .../shared/components/contact.component.ts | 34 + .../app/shared/components/css.component.html | 41 + .../app/shared/components/css.component.scss | 65 + .../app/shared/components/css.component.ts | 214 + .../dashboard-autocomplete.component.html | 50 + .../dashboard-autocomplete.component.ts | 245 + .../dashboard-select-panel.component.html | 28 + .../dashboard-select-panel.component.scss | 51 + .../dashboard-select-panel.component.ts | 48 + .../dashboard-select.component.html | 35 + .../dashboard-select.component.scss | 41 + .../components/dashboard-select.component.ts | 227 + .../app/shared/components/dialog.component.ts | 51 + .../dialog/alert-dialog.component.html | 22 + .../dialog/alert-dialog.component.scss | 20 + .../dialog/alert-dialog.component.ts | 34 + .../dialog/color-picker-dialog.component.html | 41 + .../dialog/color-picker-dialog.component.ts | 78 + .../dialog/confirm-dialog.component.html | 23 + .../dialog/confirm-dialog.component.scss | 20 + .../dialog/confirm-dialog.component.ts | 36 + .../json-object-edit-dialog.component.html | 56 + .../json-object-edit-dialog.component.ts | 66 + .../material-icons-dialog.component.html | 78 + .../material-icons-dialog.component.scss | 39 + .../dialog/material-icons-dialog.component.ts | 106 + .../node-script-test-dialog.component.html | 127 + .../node-script-test-dialog.component.scss | 109 + .../node-script-test-dialog.component.ts | 233 + .../dialog/todo-dialog.component.html | 24 + .../dialog/todo-dialog.component.scss | 23 + .../dialog/todo-dialog.component.ts | 28 + .../directives/component-outlet.directive.ts | 127 + .../sring-template-outlet.directive.ts | 118 + .../directives/tb-json-to-string.directive.ts | 103 + .../entity/entity-autocomplete.component.html | 46 + .../entity/entity-autocomplete.component.scss | 21 + .../entity/entity-autocomplete.component.ts | 387 + .../entity-gateway-select.component.html | 56 + .../entity/entity-gateway-select.component.ts | 249 + .../entity/entity-keys-list.component.html | 50 + .../entity/entity-keys-list.component.scss | 27 + .../entity/entity-keys-list.component.ts | 208 + .../entity/entity-list-select.component.html | 36 + .../entity/entity-list-select.component.scss | 27 + .../entity/entity-list-select.component.ts | 170 + .../entity/entity-list.component.html | 54 + .../entity/entity-list.component.scss | 22 + .../entity/entity-list.component.ts | 244 + .../entity/entity-select.component.html | 37 + .../entity/entity-select.component.scss | 17 + .../entity/entity-select.component.ts | 163 + ...entity-subtype-autocomplete.component.html | 46 + .../entity-subtype-autocomplete.component.ts | 262 + .../entity/entity-subtype-list.component.html | 57 + .../entity/entity-subtype-list.component.scss | 22 + .../entity/entity-subtype-list.component.ts | 309 + .../entity-subtype-select.component.html | 28 + .../entity-subtype-select.component.scss | 18 + .../entity/entity-subtype-select.component.ts | 258 + .../entity/entity-type-list.component.html | 54 + .../entity/entity-type-list.component.ts | 243 + .../entity/entity-type-select.component.html | 29 + .../entity/entity-type-select.component.scss | 30 + .../entity/entity-type-select.component.ts | 160 + .../components/fab-toolbar.component.html | 22 + .../components/fab-toolbar.component.scss | 189 + .../components/fab-toolbar.component.ts | 194 + .../components/file-input.component.html | 52 + .../components/file-input.component.scss | 99 + .../shared/components/file-input.component.ts | 284 + .../footer-fab-buttons.component.html | 39 + .../footer-fab-buttons.component.scss | 54 + .../footer-fab-buttons.component.ts | 95 + .../shared/components/footer.component.html | 20 + .../shared/components/footer.component.scss | 28 + .../app/shared/components/footer.component.ts | 28 + .../shared/components/fullscreen.directive.ts | 160 + .../components/help-markdown.component.html | 18 + .../components/help-markdown.component.scss | 30 + .../components/help-markdown.component.ts | 106 + .../components/help-popup.component.html | 47 + .../components/help-popup.component.scss | 55 + .../shared/components/help-popup.component.ts | 102 + .../app/shared/components/help.component.html | 24 + .../app/shared/components/help.component.ts | 41 + .../shared/components/hotkeys.directive.ts | 88 + .../app/shared/components/html.component.html | 41 + .../app/shared/components/html.component.scss | 65 + .../app/shared/components/html.component.ts | 216 + .../components/image-input.component.html | 65 + .../components/image-input.component.scss | 152 + .../components/image-input.component.ts | 157 + .../shared/components/js-func.component.html | 48 + .../shared/components/js-func.component.scss | 79 + .../shared/components/js-func.component.ts | 415 + .../components/json-content.component.html | 47 + .../components/json-content.component.scss | 59 + .../components/json-content.component.ts | 332 + .../json-form/json-form-component.models.ts | 23 + .../json-form/json-form.component.html | 23 + .../json-form/json-form.component.scss | 20 + .../json-form/json-form.component.ts | 310 + .../json-form/react/json-form-ace-editor.tsx | 236 + .../json-form/react/json-form-array.tsx | 179 + .../react/json-form-base-component.tsx | 120 + .../json-form/react/json-form-checkbox.tsx | 45 + .../json-form/react/json-form-color.tsx | 186 + .../json-form/react/json-form-css.tsx | 40 + .../json-form/react/json-form-date.tsx | 79 + .../json-form/react/json-form-fieldset.tsx | 44 + .../json-form/react/json-form-help.tsx | 27 + .../json-form/react/json-form-html.tsx | 40 + .../json-form/react/json-form-icon.tsx | 159 + .../json-form/react/json-form-image.tsx | 108 + .../json-form/react/json-form-javascript.tsx | 40 + .../json-form/react/json-form-json.tsx | 40 + .../json-form/react/json-form-markdown.tsx | 33 + .../json-form/react/json-form-number.tsx | 97 + .../json-form/react/json-form-radios.tsx | 51 + .../json-form/react/json-form-rc-select.tsx | 202 + .../json-form/react/json-form-react.tsx | 53 + .../json-form/react/json-form-schema-form.tsx | 216 + .../json-form/react/json-form-select.tsx | 86 + .../json-form/react/json-form-text.tsx | 91 + .../json-form/react/json-form-utils.ts | 592 + .../json-form/react/json-form.models.ts | 142 + .../components/json-form/react/json-form.scss | 361 + .../react/styles/thingsboardTheme.ts | 62 + .../json-object-edit.component.html | 45 + .../json-object-edit.component.scss | 57 + .../components/json-object-edit.component.ts | 297 + .../json-object-view.component.html | 22 + .../json-object-view.component.scss | 27 + .../components/json-object-view.component.ts | 171 + .../shared/components/kv-map.component.html | 59 + .../shared/components/kv-map.component.scss | 40 + .../app/shared/components/kv-map.component.ts | 163 + .../components/led-light.component.html | 18 + .../shared/components/led-light.component.ts | 128 + .../app/shared/components/logo.component.html | 19 + .../app/shared/components/logo.component.scss | 29 + .../app/shared/components/logo.component.ts | 32 + .../components/markdown-editor.component.html | 52 + .../components/markdown-editor.component.scss | 72 + .../components/markdown-editor.component.ts | 162 + .../shared/components/markdown.component.html | 26 + .../shared/components/markdown.component.ts | 206 + .../components/marked-options.service.ts | 233 + .../mat-chip-draggable.directive.ts | 271 + .../material-icon-select.component.html | 30 + .../material-icon-select.component.scss | 29 + .../material-icon-select.component.ts | 138 + .../message-type-autocomplete.component.html | 43 + .../message-type-autocomplete.component.ts | 179 + .../multiple-image-input.component.html | 69 + .../multiple-image-input.component.scss | 169 + .../multiple-image-input.component.ts | 210 + .../shared/components/nav-tree.component.html | 18 + .../shared/components/nav-tree.component.scss | 391 + .../shared/components/nav-tree.component.ts | 278 + .../ota-package-autocomplete.component.html | 60 + .../ota-package-autocomplete.component.scss | 21 + .../ota-package-autocomplete.component.ts | 289 + .../app/shared/components/page.component.ts | 57 + .../components/phone-input.component.html | 49 + .../components/phone-input.component.scss | 62 + .../components/phone-input.component.ts | 286 + .../shared/components/popover.component.scss | 215 + .../shared/components/popover.component.ts | 637 + .../app/shared/components/popover.models.ts | 95 + .../app/shared/components/popover.service.ts | 204 + .../protobuf-content.component.html | 43 + .../protobuf-content.component.scss | 57 + .../components/protobuf-content.component.ts | 196 + .../src/app/shared/components/public-api.ts | 22 + .../queue/queue-autocomplete.component.html | 58 + .../queue/queue-autocomplete.component.scss | 53 + .../queue/queue-autocomplete.component.ts | 215 + .../relation-type-autocomplete.component.html | 45 + .../relation-type-autocomplete.component.ts | 156 + .../components/script-lang.component.html | 25 + .../components/script-lang.component.scss | 60 + .../components/script-lang.component.ts | 87 + .../components/snack-bar-component.html | 29 + .../components/snack-bar-component.scss | 73 + .../socialshare-panel.component.html | 53 + .../socialshare-panel.component.scss | 18 + .../components/socialshare-panel.component.ts | 53 + .../shared/components/tb-anchor.component.ts | 25 + .../components/tb-checkbox.component.html | 24 + .../components/tb-checkbox.component.ts | 74 + .../shared/components/tb-error.component.ts | 67 + .../time/datetime-period.component.html | 49 + .../time/datetime-period.component.scss | 32 + .../time/datetime-period.component.ts | 138 + .../components/time/datetime.component.html | 40 + .../components/time/datetime.component.scss | 34 + .../components/time/datetime.component.ts | 114 + .../history-selector.component.html | 54 + .../history-selector.component.scss | 131 + .../history-selector.component.ts | 143 + .../time/quick-time-interval.component.html | 27 + .../time/quick-time-interval.component.scss | 25 + .../time/quick-time-interval.component.ts | 79 + .../time/timeinterval.component.html | 58 + .../time/timeinterval.component.scss | 54 + .../components/time/timeinterval.component.ts | 302 + .../time/timewindow-panel.component.html | 237 + .../time/timewindow-panel.component.scss | 88 + .../time/timewindow-panel.component.ts | 364 + .../components/time/timewindow.component.html | 46 + .../components/time/timewindow.component.scss | 35 + .../components/time/timewindow.component.ts | 377 + .../time/timezone-select.component.html | 50 + .../time/timezone-select.component.ts | 250 + .../app/shared/components/toast.directive.ts | 376 + ui-ngx/src/app/shared/components/tokens.ts | 24 + .../components/user-menu.component.html | 45 + .../components/user-menu.component.scss | 50 + .../shared/components/user-menu.component.ts | 117 + .../components/value-input.component.html | 79 + .../components/value-input.component.scss | 28 + .../components/value-input.component.ts | 140 + .../vc/branch-autocomplete.component.html | 49 + .../vc/branch-autocomplete.component.scss | 30 + .../vc/branch-autocomplete.component.ts | 296 + .../widgets-bundle-search.component.html | 29 + .../widgets-bundle-search.component.scss | 55 + .../widgets-bundle-search.component.ts | 74 + .../widgets-bundle-select.component.html | 40 + .../widgets-bundle-select.component.scss | 112 + .../widgets-bundle-select.component.ts | 167 + .../src/app/shared/decorators/enumerable.ts | 25 + ui-ngx/src/app/shared/decorators/tb-inject.ts | 23 + .../src/app/shared/models/ace/ace.models.ts | 348 + .../shared/models/ace/completion.models.ts | 215 + .../models/ace/service-completion.models.ts | 1431 +++ .../models/ace/widget-completion.models.ts | 741 ++ ui-ngx/src/app/shared/models/alarm.models.ts | 234 + ui-ngx/src/app/shared/models/alias.models.ts | 196 + ui-ngx/src/app/shared/models/asset.models.ts | 63 + .../src/app/shared/models/audit-log.models.ts | 120 + .../src/app/shared/models/authority.enum.ts | 24 + ui-ngx/src/app/shared/models/base-data.ts | 42 + .../src/app/shared/models/beautify.models.ts | 79 + .../models/component-descriptor.models.ts | 40 + ui-ngx/src/app/shared/models/constants.ts | 275 + .../app/shared/models/contact-based.model.ts | 28 + .../src/app/shared/models/country.models.ts | 520 + .../src/app/shared/models/customer.model.ts | 32 + .../src/app/shared/models/dashboard.models.ts | 167 + ui-ngx/src/app/shared/models/device.models.ts | 837 ++ ui-ngx/src/app/shared/models/edge.models.ts | 169 + .../app/shared/models/entity-type.models.ts | 464 + .../app/shared/models/entity-view.models.ts | 54 + ui-ngx/src/app/shared/models/entity.models.ts | 164 + ui-ngx/src/app/shared/models/error.models.ts | 23 + ui-ngx/src/app/shared/models/event.models.ts | 126 + ui-ngx/src/app/shared/models/id/alarm-id.ts | 26 + ui-ngx/src/app/shared/models/id/asset-id.ts | 26 + .../app/shared/models/id/asset-profile-id.ts | 26 + .../src/app/shared/models/id/audit-log-id.ts | 24 + .../src/app/shared/models/id/customer-id.ts | 26 + .../src/app/shared/models/id/dashboard-id.ts | 26 + .../shared/models/id/device-credentials-id.ts | 24 + ui-ngx/src/app/shared/models/id/device-id.ts | 26 + .../app/shared/models/id/device-profile-id.ts | 26 + ui-ngx/src/app/shared/models/id/edge-id.ts | 26 + ui-ngx/src/app/shared/models/id/entity-id.ts | 31 + .../app/shared/models/id/entity-view-id.ts | 26 + ui-ngx/src/app/shared/models/id/event-id.ts | 24 + ui-ngx/src/app/shared/models/id/has-uuid.ts | 21 + .../app/shared/models/id/ota-package-id.ts | 26 + ui-ngx/src/app/shared/models/id/public-api.ts | 39 + ui-ngx/src/app/shared/models/id/queue-id.ts | 26 + ui-ngx/src/app/shared/models/id/rpc-id.ts | 26 + .../src/app/shared/models/id/rule-chain-id.ts | 26 + .../src/app/shared/models/id/rule-node-id.ts | 26 + .../app/shared/models/id/tb-resource-id.ts | 26 + ui-ngx/src/app/shared/models/id/tenant-id.ts | 26 + .../app/shared/models/id/tenant-profile-id.ts | 26 + ui-ngx/src/app/shared/models/id/user-id.ts | 26 + .../app/shared/models/id/widget-type-id.ts | 26 + .../app/shared/models/id/widgets-bundle-id.ts | 26 + ui-ngx/src/app/shared/models/login.models.ts | 32 + .../models/lwm2m-security-config.models.ts | 113 + .../src/app/shared/models/material.models.ts | 369 + ui-ngx/src/app/shared/models/oauth2.models.ts | 141 + .../app/shared/models/ota-package.models.ts | 109 + .../src/app/shared/models/page/page-data.ts | 31 + .../src/app/shared/models/page/page-link.ts | 193 + .../src/app/shared/models/page/public-api.ts | 19 + .../src/app/shared/models/page/sort-order.ts | 42 + ui-ngx/src/app/shared/models/public-api.ts | 53 + .../app/shared/models/query/query.models.ts | 865 ++ ui-ngx/src/app/shared/models/queue.models.ts | 125 + .../src/app/shared/models/relation.models.ts | 93 + .../src/app/shared/models/resource.models.ts | 66 + ui-ngx/src/app/shared/models/rpc.models.ts | 88 + .../app/shared/models/rule-chain.models.ts | 90 + .../src/app/shared/models/rule-node.models.ts | 488 + .../src/app/shared/models/settings.models.ts | 439 + .../models/telemetry/telemetry.models.ts | 718 ++ ui-ngx/src/app/shared/models/tenant.model.ts | 153 + .../src/app/shared/models/time/time.models.ts | 928 ++ .../shared/models/two-factor-auth.models.ts | 185 + ui-ngx/src/app/shared/models/user.model.ts | 56 + ui-ngx/src/app/shared/models/vc.models.ts | 244 + ui-ngx/src/app/shared/models/widget.models.ts | 830 ++ .../app/shared/models/widgets-bundle.model.ts | 27 + .../app/shared/models/window-message.model.ts | 34 + .../src/app/shared/pipe/enum-to-array.pipe.ts | 27 + ui-ngx/src/app/shared/pipe/file-size.pipe.ts | 56 + ui-ngx/src/app/shared/pipe/highlight.pipe.ts | 28 + .../app/shared/pipe/keyboard-shortcut.pipe.ts | 49 + .../pipe/milliseconds-to-time-string.pipe.ts | 76 + ui-ngx/src/app/shared/pipe/nospace.pipe.ts | 28 + ui-ngx/src/app/shared/pipe/public-api.ts | 24 + ui-ngx/src/app/shared/pipe/safe.pipe.ts | 37 + .../shared/pipe/selectable-columns.pipe.ts | 25 + ui-ngx/src/app/shared/pipe/tbJson.pipe.ts | 30 + ui-ngx/src/app/shared/pipe/truncate.pipe.ts | 42 + ui-ngx/src/app/shared/public-api.ts | 20 + .../shared/services/custom-paginator-intl.ts | 40 + ui-ngx/src/app/shared/shared.module.ts | 506 + ui-ngx/src/assets/copy-code-icon.svg | 4 + .../assets/fonts/MaterialIcons-Regular.ttf | Bin 0 -> 337868 bytes ui-ngx/src/assets/fonts/material-icons.css | 48 + .../alarm_custom_schedule_format.md | 79 + .../alarm_specific_schedule_format.md | 31 + .../rulenode/clear_alarm_node_script_fn.md | 69 + .../en_US/rulenode/common_node_script_args.md | 11 + .../rulenode/create_alarm_node_script_fn.md | 70 + .../en_US/rulenode/filter_node_script_fn.md | 84 + .../rulenode/generator_node_script_fn.md | 112 + .../help/en_US/rulenode/log_node_script_fn.md | 37 + .../en_US/rulenode/switch_node_script_fn.md | 101 + .../tbel/clear_alarm_node_script_fn.md | 69 + .../rulenode/tbel/common_node_script_args.md | 11 + .../tbel/create_alarm_node_script_fn.md | 69 + .../rulenode/tbel/filter_node_script_fn.md | 84 + .../rulenode/tbel/generator_node_script_fn.md | 110 + .../en_US/rulenode/tbel/log_node_script_fn.md | 37 + .../rulenode/tbel/switch_node_script_fn.md | 101 + .../tbel/transformation_node_script_fn.md | 89 + .../rulenode/transformation_node_script_fn.md | 89 + .../en_US/widget/action/custom_action_args.md | 19 + .../en_US/widget/action/custom_action_fn.md | 81 + .../widget/action/custom_additional_params.md | 53 + .../widget/action/custom_pretty_action_fn.md | 151 + .../custom_pretty_create_dialog_html.md | 164 + .../custom_pretty_create_dialog_js.md | 136 + .../custom_pretty_edit_dialog_html.md | 196 + .../examples/custom_pretty_edit_dialog_js.md | 224 + ...custom_action_back_first_and_open_state.md | 27 + .../custom_action_copy_access_token.md | 44 + .../custom_action_delete_device_confirm.md | 33 + .../custom_action_display_alert.md | 35 + ...ustom_action_open_state_save_parameters.md | 34 + .../custom_action_return_previous_state.md | 18 + .../custom_pretty_clone_device_html.md | 46 + .../custom_pretty_clone_device_js.md | 56 + .../custom_pretty_create_dialog_html.md | 164 + .../custom_pretty_create_dialog_js.md | 136 + .../custom_pretty_create_user_html.md | 81 + .../custom_pretty_create_user_js.md | 137 + .../custom_pretty_edit_dialog_html.md | 196 + .../custom_pretty_edit_dialog_js.md | 224 + .../custom_pretty_edit_image_html.md | 42 + .../custom_pretty_edit_image_js.md | 85 + .../widget/action/mobile_get_location_fn.md | 49 + .../action/mobile_get_phone_number_fn.md | 48 + .../action/mobile_handle_empty_result_fn.md | 31 + .../widget/action/mobile_handle_error_fn.md | 33 + .../widget/action/mobile_process_image_fn.md | 92 + .../action/mobile_process_launch_result_fn.md | 33 + .../action/mobile_process_location_fn.md | 59 + .../action/mobile_process_qr_code_fn.md | 76 + .../action/show_widget_action_cell_fn.md | 48 + .../action/show_widget_action_header_fn.md | 41 + .../widget/config/datakey_generation_fn.md | 62 + .../widget/config/datakey_postprocess_fn.md | 86 + .../widget/editor/examples/alarm_widget.md | 147 + .../examples/ext_latest_values_example.md | 67 + .../editor/examples/ext_timeseries_example.md | 112 + .../editor/examples/latest_values_widget.md | 47 + .../widget/editor/examples/rpc_widget.md | 187 + .../widget/editor/examples/static_widget.md | 72 + .../editor/examples/timeseries_widget.md | 93 + .../editor/widget_js_action_sources_object.md | 17 + .../widget/editor/widget_js_existing_code.md | 62 + .../help/en_US/widget/editor/widget_js_fn.md | 135 + .../editor/widget_js_markdown_pattern.md | 68 + .../editor/widget_js_subscription_object.md | 113 + .../widget_js_type_parameters_object.md | 14 + .../en_US/widget/lib/alarm/cell_content_fn.md | 52 + .../en_US/widget/lib/alarm/cell_style_fn.md | 62 + .../en_US/widget/lib/alarm/row_style_fn.md | 59 + .../entities_hierarchy/node_disabled_fn.md | 43 + .../node_has_children_fn.md | 50 + .../lib/entities_hierarchy/node_icon_fn.md | 56 + .../lib/entities_hierarchy/node_opened_fn.md | 38 + .../node_relation_query_fn.md | 52 + .../lib/entities_hierarchy/node_text_fn.md | 44 + .../lib/entities_hierarchy/nodes_sort_fn.md | 47 + .../widget/lib/entity/cell_content_fn.md | 143 + .../en_US/widget/lib/entity/cell_style_fn.md | 62 + .../en_US/widget/lib/entity/row_style_fn.md | 60 + .../widget/lib/flot/point_shape_format_fn.md | 111 + .../widget/lib/flot/ticks_formatter_fn.md | 104 + .../lib/flot/tooltip_value_format_fn.md | 51 + .../widget/lib/map/clustering_color_fn.md | 65 + .../help/en_US/widget/lib/map/color_fn.md | 42 + .../help/en_US/widget/lib/map/label_fn.md | 40 + .../help/en_US/widget/lib/map/map_fn_args.md | 10 + .../en_US/widget/lib/map/marker_image_fn.md | 62 + .../en_US/widget/lib/map/path_color_fn.md | 42 + .../widget/lib/map/path_point_color_fn.md | 43 + .../en_US/widget/lib/map/polygon_color_fn.md | 42 + .../widget/lib/map/polygon_tooltip_fn.md | 40 + .../help/en_US/widget/lib/map/position_fn.md | 65 + .../help/en_US/widget/lib/map/tooltip_fn.md | 40 + .../widget/lib/map/trip_point_as_anchor_fn.md | 34 + .../widget/lib/markdown/markdown_text_fn.md | 61 + .../en_US/widget/lib/qrcode/qrcode_text_fn.md | 55 + .../en_US/widget/lib/rpc/convert_value_fn.md | 40 + .../widget/lib/rpc/parse_gpio_status_fn.md | 35 + .../en_US/widget/lib/rpc/parse_value_fn.md | 41 + .../widget/lib/timeseries/cell_content_fn.md | 53 + .../widget/lib/timeseries/cell_style_fn.md | 52 + .../widget/lib/timeseries/row_style_fn.md | 49 + .../images/rulenode/examples/filter-node.png | Bin 0 -> 21939 bytes .../images/rulenode/examples/switch-node.png | Bin 0 -> 37494 bytes .../editor/examples/add-rpc-device-alias.png | Bin 0 -> 16840 bytes .../editor/examples/alarm-widget-sample.png | Bin 0 -> 22831 bytes ...control-widget-sample-response-one-way.png | Bin 0 -> 10978 bytes ...control-widget-sample-response-timeout.png | Bin 0 -> 9898 bytes ...control-widget-sample-response-two-way.png | Bin 0 -> 10430 bytes .../control-widget-sample-settings.png | Bin 0 -> 4241 bytes .../editor/examples/control-widget-sample.png | Bin 0 -> 20147 bytes .../dashboard-create-new-widget-button.png | Bin 0 -> 6989 bytes .../dashboard-toolbar-entity-aliases.png | Bin 0 -> 4592 bytes .../external-js-timeseries-widget-sample.png | Bin 0 -> 44295 bytes .../examples/external-js-widget-sample.png | Bin 0 -> 9426 bytes .../examples/latest-values-widget-sample.png | Bin 0 -> 13324 bytes .../editor/examples/static-widget-sample.png | Bin 0 -> 12636 bytes .../examples/timeseries-widget-sample.png | Bin 0 -> 28945 bytes ui-ngx/src/assets/jstree/tb32px.png | Bin 0 -> 19444 bytes ui-ngx/src/assets/jstree/tb40px.png | Bin 0 -> 1880 bytes .../assets/locale/locale.constant-ca_ES.json | 5830 +++++++++ .../assets/locale/locale.constant-cs_CZ.json | 3101 +++++ .../assets/locale/locale.constant-da_DK.json | 4002 ++++++ .../assets/locale/locale.constant-de_DE.json | 1929 +++ .../assets/locale/locale.constant-el_GR.json | 2606 ++++ .../assets/locale/locale.constant-en_US.json | 4817 +++++++ .../assets/locale/locale.constant-es_ES.json | 4581 +++++++ .../assets/locale/locale.constant-fa_IR.json | 1630 +++ .../assets/locale/locale.constant-fr_FR.json | 2332 ++++ .../assets/locale/locale.constant-it_IT.json | 1708 +++ .../assets/locale/locale.constant-ja_JP.json | 1516 +++ .../assets/locale/locale.constant-ka_GE.json | 1813 +++ .../assets/locale/locale.constant-ko_KR.json | 2476 ++++ .../assets/locale/locale.constant-lv_LV.json | 1682 +++ .../assets/locale/locale.constant-pt_BR.json | 2045 +++ .../assets/locale/locale.constant-ro_RO.json | 1798 +++ .../assets/locale/locale.constant-ru_RU.json | 1881 +++ .../assets/locale/locale.constant-sl_SI.json | 2476 ++++ .../assets/locale/locale.constant-tr_TR.json | 3121 +++++ .../assets/locale/locale.constant-uk_UA.json | 2493 ++++ .../assets/locale/locale.constant-zh_CN.json | 4786 +++++++ .../assets/locale/locale.constant-zh_TW.json | 4618 +++++++ ui-ngx/src/assets/logo_title_white.svg | 37 + ui-ngx/src/assets/logo_white.svg | 10 + ui-ngx/src/assets/mdi.svg | 1 + ui-ngx/src/assets/shadow.png | Bin 0 -> 712 bytes .../src/assets/split.js/grips/horizontal.png | Bin 0 -> 104 bytes ui-ngx/src/assets/split.js/grips/vertical.png | Bin 0 -> 91 bytes ui-ngx/src/assets/widget-preview-empty.svg | 1 + ui-ngx/src/environments/environment.prod.ts | 25 + ui-ngx/src/environments/environment.ts | 38 + ui-ngx/src/index.html | 107 + ui-ngx/src/karma.conf.js | 47 + ui-ngx/src/main.ts | 30 + ui-ngx/src/polyfills.ts | 89 + ui-ngx/src/scss/animations.scss | 46 + ui-ngx/src/scss/constants.scss | 31 + ui-ngx/src/scss/fonts.scss | 21 + ui-ngx/src/scss/mixins.scss | 61 + ui-ngx/src/styles.scss | 1488 +++ ui-ngx/src/test.ts | 36 + ui-ngx/src/theme.scss | 254 + ui-ngx/src/thingsboard.ico | Bin 0 -> 4286 bytes ui-ngx/src/tsconfig.app.json | 22 + ui-ngx/src/tsconfig.spec.json | 18 + ui-ngx/src/tslint.json | 17 + ui-ngx/src/typings/jquery.flot.typings.d.ts | 129 + ui-ngx/src/typings/jquery.jstree.typings.d.ts | 29 + ui-ngx/src/typings/jquery.typings.d.ts | 19 + ui-ngx/src/typings/leaflet-extend-tb.d.ts | 24 + ui-ngx/src/typings/leaflet-geoman-extend.d.ts | 1520 +++ ui-ngx/src/typings/rawloader.typings.d.ts | 20 + ui-ngx/src/typings/split.js.typings.d.ts | 39 + ui-ngx/src/zone-flags.ts | 19 + ui-ngx/tsconfig.json | 68 + ui-ngx/tslint.json | 139 + ui-ngx/yarn.lock | 10420 ++++++++++++++++ 5030 files changed, 649608 insertions(+), 75 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 application/.gitignore create mode 100644 application/pom.xml create mode 100644 application/src/main/conf/logback.xml create mode 100644 application/src/main/conf/thingsboard.conf create mode 100644 application/src/main/data/certs/azure/BaltimoreCyberTrustRoot.crt.pem create mode 100644 application/src/main/data/json/demo/dashboards/firmware.json create mode 100644 application/src/main/data/json/demo/dashboards/gateways.json create mode 100644 application/src/main/data/json/demo/dashboards/rule_engine_statistics.json create mode 100644 application/src/main/data/json/demo/dashboards/software.json create mode 100644 application/src/main/data/json/demo/dashboards/thermostats.json create mode 100644 application/src/main/data/json/system/oauth2_config_templates/apple_config.json create mode 100644 application/src/main/data/json/system/oauth2_config_templates/facebook_config.json create mode 100644 application/src/main/data/json/system/oauth2_config_templates/github_config.json create mode 100644 application/src/main/data/json/system/oauth2_config_templates/google_config.json create mode 100644 application/src/main/data/json/system/widget_bundles/alarm_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/analogue_gauges.json create mode 100644 application/src/main/data/json/system/widget_bundles/cards.json create mode 100644 application/src/main/data/json/system/widget_bundles/charts.json create mode 100644 application/src/main/data/json/system/widget_bundles/control_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/date.json create mode 100644 application/src/main/data/json/system/widget_bundles/digital_gauges.json create mode 100644 application/src/main/data/json/system/widget_bundles/edge_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/entity_admin_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/gateway_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/gpio_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/input_widgets.json create mode 100644 application/src/main/data/json/system/widget_bundles/maps.json create mode 100644 application/src/main/data/json/system/widget_bundles/navigation_widgets.json create mode 100644 application/src/main/data/json/tenant/device_profile/rule_chain_template.json create mode 100644 application/src/main/data/json/tenant/edge_management/rule_chains/edge_root_rule_chain.json create mode 100644 application/src/main/data/json/tenant/rule_chains/root_rule_chain.json create mode 100644 application/src/main/data/sql/schema-entities-idx-psql-addon.sql create mode 100644 application/src/main/data/sql/schema-entities-idx.sql create mode 100644 application/src/main/data/sql/schema-entities.sql create mode 100644 application/src/main/data/sql/schema-timescale.sql create mode 100644 application/src/main/data/sql/schema-ts-psql.sql create mode 100644 application/src/main/data/upgrade/1.3.0/schema_update.cql create mode 100644 application/src/main/data/upgrade/1.3.1/schema_update.sql create mode 100644 application/src/main/data/upgrade/1.4.0/schema_update.cql create mode 100644 application/src/main/data/upgrade/1.4.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.0.0/schema_update.cql create mode 100644 application/src/main/data/upgrade/2.0.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.1.1/schema_update.cql create mode 100644 application/src/main/data/upgrade/2.1.1/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.1.2/schema_update.cql create mode 100644 application/src/main/data/upgrade/2.1.2/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.2.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.3.1/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.4.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.4.2/schema_update.sql create mode 100644 application/src/main/data/upgrade/2.4.3/schema_update_psql_drop_partitions.sql create mode 100644 application/src/main/data/upgrade/2.4.3/schema_update_psql_ts.sql create mode 100644 application/src/main/data/upgrade/2.4.3/schema_update_timescale_ts.sql create mode 100644 application/src/main/data/upgrade/2.4.3/schema_update_ttl.sql create mode 100644 application/src/main/data/upgrade/3.0.1/schema_ts_latest.sql create mode 100644 application/src/main/data/upgrade/3.0.1/schema_update_to_uuid.sql create mode 100644 application/src/main/data/upgrade/3.1.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.1.1/schema_update_after.sql create mode 100644 application/src/main/data/upgrade/3.1.1/schema_update_before.sql create mode 100644 application/src/main/data/upgrade/3.2.1/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.2.1/schema_update_ttl.sql create mode 100644 application/src/main/data/upgrade/3.2.2/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.2.2/schema_update_event.sql create mode 100644 application/src/main/data/upgrade/3.2.2/schema_update_ttl.sql create mode 100644 application/src/main/data/upgrade/3.3.2/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.3.2/schema_update_lwm2m_bootstrap.sql create mode 100644 application/src/main/data/upgrade/3.3.3/schema_event_ttl_procedure.sql create mode 100644 application/src/main/data/upgrade/3.3.3/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.3.4/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.4.0/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.4.1/schema_update.sql create mode 100644 application/src/main/data/upgrade/3.4.1/schema_update_after.sql create mode 100644 application/src/main/data/upgrade/3.4.1/schema_update_before.sql create mode 100644 application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java create mode 100644 application/src/main/java/org/thingsboard/server/ThingsboardInstallApplication.java create mode 100644 application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/TbEntityTypeActorIdPredicate.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/app/AppActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/app/AppInitMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/ToDeviceRpcRequestMetadata.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainInputMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainManagerActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainOutputMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleChainMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleNodeMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeRelation.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToRuleChainTellNextMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToSelfMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleChainActorMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleNodeActorMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/service/ActorService.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/service/ContextBasedCreator.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/shared/ActorTerminationMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/tenant/DebugTbRateLimits.java create mode 100644 application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java create mode 100644 application/src/main/java/org/thingsboard/server/config/CustomOAuth2AuthorizationRequestResolver.java create mode 100644 application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java create mode 100644 application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/config/WebConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AdminController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AlarmController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AssetController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AuditLogController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AuthController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/AutoCommitController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/BaseController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/CustomerController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/DashboardController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/DeviceController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EdgeController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EntityViewController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/EventController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/HttpValidationCallback.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/QueueController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/RuleChainController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TbResourceController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TelemetryController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TenantController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/UserController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsgType.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketPingMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketTextMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java create mode 100644 application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java create mode 100644 application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/install/ThingsboardInstallConfiguration.java create mode 100644 application/src/main/java/org/thingsboard/server/install/ThingsboardInstallException.java create mode 100644 application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/CustomerApiUsageState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/DefaultRateLimitService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/RateLimitService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/TbApiUsageStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/asset/AssetBulkImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java create mode 100644 application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/DefaultEdgeNotificationService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/EdgeBulkImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/EdgeNotificationService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AdminSettingsMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AlarmMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetProfileMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/CustomerMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DashboardMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EdgeMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityViewMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/OtaPackageMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/QueueMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RelationMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/UserMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetTypeMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetsBundleMsgConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/AbstractRuleChainMetadataConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV330.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV340.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetProfilesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetsEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BasePageableEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseUsersEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseWidgetsBundlesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerUsersEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DashboardsEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DeviceProfilesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DevicesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EntityViewsEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OtaPackagesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/QueuesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/RuleChainsEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantAdminUsersEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetsBundlesEdgeEventFetcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AdminSettingsEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AlarmEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetProfileEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/CustomerEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DashboardEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceProfileEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EntityViewEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/OtaPackageEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/QueueEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RelationEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RuleChainEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/TelemetryEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/UserEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetBundleEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetTypeEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbNotificationEntityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/TbNotificationEntityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/alarm/TbAlarmService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/DefaultTbAssetProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/TbAssetProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/DefaultTbDeviceProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/TbDeviceProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/edge/TbEdgeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/ota/TbOtaPackageService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/queue/TbQueueService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/tenant/TbTenantService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/executors/DbCallbackExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/executors/ExternalCallExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/executors/GrpcCallbackExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/gateway_device/DefaultGatewayNotificationsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/gateway_device/GatewayNotificationsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/AbstractCassandraDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/AbstractSqlTsDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraKeyspaceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/CassandraTsLatestDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DatabaseEntitiesUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DatabaseTsUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DbUpgradeExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/NoSqlKeyspaceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TbRuleEngineQueueConfigService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseUpgradeService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/TsLatestDatabaseSchemaService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/cql/CQLStatementsParser.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraEntitiesToSqlMigrateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumn.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnType.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlEventTsColumn.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlTable.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/EntitiesMigrateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/migrate/TsLatestMigrateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/sql/SqlDbHelper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/CacheCleanupService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/DataUpdateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/PaginatedUpdater.java create mode 100644 application/src/main/java/org/thingsboard/server/service/install/update/RateLimitsUpdater.java create mode 100644 application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MServiceImpl.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/MailExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/mail/PasswordResetExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ota/OtaPackageStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/partition/AbstractPartitionBasedService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/profile/TbAssetProfileCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/profile/TbDeviceProfileCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultQueueRoutingInfoService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContext.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbMsgProfilerInfo.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbPackProcessingContext.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerStats.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbRuleNodeProfilerInfo.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbTenantRuleEngineStats.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbTopicWithConsumerPerPartition.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByOriginatorIdTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByTenantIdTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingDecision.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingResult.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategyFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategy.java create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategyFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbCoreDeviceRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponseActorMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/LocalRequestMetaData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/RemoveRpcActorMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/TbCoreDeviceRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/TbRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/TbRuleEngineDeviceRpcService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rpc/ToDeviceRpcRequestActorMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rule/DefaultTbRuleChainService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/rule/TbRuleChainService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java create mode 100644 application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/ValidationResult.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/ValidationResultCode.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/AbstractJwtAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/JwtAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/RefreshAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicMapperUtils.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/TbOAuth2ParameterNames.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/device/DefaultDeviceAuthService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/exception/AuthMethodNotSupportedException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/ActivateUserRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/ChangePasswordRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordEmailRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/AbstractPermissions.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/AccessControlService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/Operation.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/PermissionChecker.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/Permissions.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/SessionCaffeineCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/session/SessionRedisCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/AbstractSmsSender.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/SmsExecutorService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/aws/AwsSmsSender.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sms/twilio/TwilioSmsSender.java create mode 100644 application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/state/DeviceState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/state/DeviceStateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java create mode 100644 application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/stats/RuleEngineStatisticsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/ReadTsKvQueryInfo.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionServiceStatistics.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmsSubscription.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscription.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscriptionScope.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbEntityCountSubCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionType.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/EntityExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetProfileExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DashboardExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/EntityViewExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/EntityImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/ImportedEntityInfo.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/CustomerImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceProfileImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/EntityViewImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ImportServiceException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/MissingEntityException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/RuleChainImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/WidgetsBundleImportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/GitVersionControlQueueService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/LoadEntityException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/TbAbstractVersionControlSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCacheEntry.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCaffeineCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskRedisCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsCaffeineCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsRedisCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/DefaultTbAutoCommitSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/TbAutoCommitSettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ClearRepositoryGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/CommitGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ComplexEntitiesExportCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ContentsDiffGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesContentGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityContentGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListBranchesGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListEntitiesGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListVersionsGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/PendingGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/ReimportTask.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/SimpleEntitiesExportCtx.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/VersionsDiffGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/data/VoidGitRequest.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/repository/DefaultTbRepositorySettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsCaffeineCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsRedisCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/sync/vc/repository/TbRepositorySettingsService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/AlarmSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/AttributeData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/SessionEvent.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryFeature.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetrySubscriptionService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketMsgEndpoint.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketSessionRef.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketTextMsg.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/TsData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/WsSessionMetaData.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/TelemetryPluginCmdsWrapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/AttributesSubscriptionCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/GetHistoryCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/SubscriptionCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TelemetryPluginCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TimeseriesSubscriptionCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggHistoryCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggKey.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggTimeSeriesCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUnsubscribeCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdateType.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUnsubscribeCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUnsubscribeCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityHistoryCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/GetTsCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/LatestValueCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/TimeSeriesCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/UnsubscribeCmd.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/AccessDeniedException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/EntityNotFoundException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/InternalErrorException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/InvalidParametersException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/ToErrorResponseEntity.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/UnauthorizedException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/exception/UncheckedApiException.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/sub/AlarmSubscriptionUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionErrorCode.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionState.java create mode 100644 application/src/main/java/org/thingsboard/server/service/telemetry/sub/TelemetrySubscriptionUpdate.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/BasicCredentialsValidationResult.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/DefaultTbCoreToTransportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TbCoreToTransportService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/AuditLogsCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/EventsCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/TimeseriesCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/update/UpdateService.java create mode 100644 application/src/main/java/org/thingsboard/server/springfox/SpringfoxHandlerProviderBeanPostProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/utils/CsvUtils.java create mode 100644 application/src/main/java/org/thingsboard/server/utils/EventDeduplicationExecutor.java create mode 100644 application/src/main/java/org/thingsboard/server/utils/MiscUtils.java create mode 100644 application/src/main/java/org/thingsboard/server/utils/TypeCastUtil.java create mode 100644 application/src/main/resources/banner.txt create mode 100644 application/src/main/resources/i18n/messages.properties create mode 100644 application/src/main/resources/logback.xml create mode 100644 application/src/main/resources/templates/2fa.verification.code.ftl create mode 100644 application/src/main/resources/templates/account.activated.ftl create mode 100644 application/src/main/resources/templates/account.lockout.ftl create mode 100644 application/src/main/resources/templates/activation.ftl create mode 100644 application/src/main/resources/templates/password.was.reset.ftl create mode 100644 application/src/main/resources/templates/reset.password.ftl create mode 100644 application/src/main/resources/templates/state.disabled.ftl create mode 100644 application/src/main/resources/templates/state.enabled.ftl create mode 100644 application/src/main/resources/templates/state.warning.ftl create mode 100644 application/src/main/resources/templates/test.ftl create mode 100644 application/src/main/resources/thingsboard.yml create mode 100644 application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java create mode 100644 application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java create mode 100644 application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java create mode 100644 application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAlarmControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAssetControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAssetProfileControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAuditLogControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseAuthControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseComponentDescriptorControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseCustomerControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseDashboardControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEntityRelationControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseOtaPackageControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseRpcControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseRuleChainControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseTbResourceControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseTenantControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseTenantProfileControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseUserControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseWebsocketApiTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseWidgetTypeControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/BaseWidgetsBundleControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AdminControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AlarmControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AssetControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AssetProfileControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AuditLogControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/AuthControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/ComponentDescriptorControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/CustomerControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/DashboardControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/DeviceControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/DeviceProfileControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EdgeControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EdgeEventControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EntityQueryControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EntityRelationControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/OtaPackageControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/RpcControllerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/RuleChainControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TbResourceControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TenantControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TenantProfileControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthConfigSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/UserControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/WebsocketApiSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/WidgetTypeControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/WidgetsBundleControllerSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseAlarmEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseAssetEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseAssetProfileEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseCustomerEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseDashboardEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseDeviceEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseDeviceProfileEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseEntityViewEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseOtaPackageEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseQueueEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseRelationEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseRuleChainEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseTelemetryEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseUserEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/BaseWidgetEdgeTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/AlarmEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/AssetEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/AssetProfileEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/CustomerEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/DashboardEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/DeviceEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/DeviceProfileEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/EdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/EntityViewEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/OtaPackageEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/QueueEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/RelationEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/RuleChainEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/TelemetryEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/UserEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/edge/sql/WidgetEdgeSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/rules/flow/sql/RuleEngineFlowSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/rules/lifecycle/sql/RuleEngineLifecycleSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/install/update/DefaultDataUpdateServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/mail/TestMailService.java create mode 100644 application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/script/MockJsInvokeService.java create mode 100644 application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/sql/SequentialTimeseriesPersistenceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/sync/ie/BaseExportImportServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/ttl/EventsCleanUpServiceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java create mode 100644 application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java create mode 100644 application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/TransportNoSqlTestSuite.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/CoapTestCallback.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/CoapTestConfigProperties.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcDefaultIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MLocationParams.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mBinaryAppDataContainer.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mLocation.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/Lwm2mServer.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/OtaLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationCreateTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDeleteTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteAttributesTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelperTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestCallback.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestClient.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/AbstractMqttAttributesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/MqttAttributesRequestBackwardCompatibilityIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/MqttAttributesRequestIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/MqttAttributesRequestJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/MqttAttributesRequestProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/updates/MqttAttributesUpdatesBackwardCompatibilityIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/updates/MqttAttributesUpdatesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/updates/MqttAttributesUpdatesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/updates/MqttAttributesUpdatesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/claim/MqttClaimBackwardCompatibilityDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/claim/MqttClaimDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/claim/MqttClaimJsonDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/claim/MqttClaimProtoDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/credentials/BasicMqttCredentialsTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/provision/MqttProvisionJsonDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/provision/MqttProvisionProtoDeviceTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/MqttServerSideRpcBackwardCompatibilityIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/MqttServerSideRpcDefaultIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/MqttServerSideRpcJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/MqttServerSideRpcProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/attributes/MqttAttributesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/attributes/MqttAttributesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/attributes/MqttAttributesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/nosql/MqttTimeseriesNoSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/nosql/MqttTimeseriesNoSqlJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/nosql/MqttTimeseriesNoSqlProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/sql/MqttTimeseriesSqlIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/sql/MqttTimeseriesSqlJsonIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/sql/MqttTimeseriesSqlProtoIntegrationTest.java create mode 100644 application/src/test/java/org/thingsboard/server/util/EventDeduplicationExecutorTest.java create mode 100644 application/src/test/resources/application-test.properties create mode 100644 application/src/test/resources/logback-test.xml create mode 100644 application/src/test/resources/lwm2m/0.xml create mode 100644 application/src/test/resources/lwm2m/1.xml create mode 100644 application/src/test/resources/lwm2m/19.xml create mode 100644 application/src/test/resources/lwm2m/2.xml create mode 100644 application/src/test/resources/lwm2m/3.xml create mode 100644 application/src/test/resources/lwm2m/3303.xml create mode 100644 application/src/test/resources/lwm2m/5.xml create mode 100644 application/src/test/resources/lwm2m/6.xml create mode 100644 application/src/test/resources/lwm2m/9.xml create mode 100644 application/src/test/resources/lwm2m/credentials/lwm2mclient.jks create mode 100644 application/src/test/resources/lwm2m/credentials/lwm2mserver.jks create mode 100644 application/src/test/resources/lwm2m/credentials/lwm2mtruststorechain.jks create mode 100644 application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 application/src/test/resources/update/330/README.md create mode 100644 application/src/test/resources/update/330/device_profile_001_in.json create mode 100644 application/src/test/resources/update/330/device_profile_001_out.json create mode 100644 common/actor/pom.xml create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/AbstractTbActor.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/DefaultTbActorSystem.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/Dispatcher.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/InitFailureStrategy.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/ProcessFailureStrategy.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActor.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorCreator.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorCtx.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorException.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorId.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorMailbox.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorNotRegisteredException.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorRef.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorSystem.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbActorSystemSettings.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbEntityActorId.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbRuleNodeUpdateException.java create mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/TbStringActorId.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/ActorSystemTest.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/ActorTestCtx.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/FailedToInitActor.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/IntTbActorMsg.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/SlowCreateActor.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/SlowInitActor.java create mode 100644 common/actor/src/test/java/org/thingsboard/server/actors/TestRootActor.java create mode 100644 common/actor/src/test/resources/logback.xml create mode 100644 common/cache/pom.xml create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecs.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/CacheSpecsMap.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbCacheTransaction.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/CaffeineTbTransactionalCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/RedisTbCacheTransaction.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/RedisTbTransactionalCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/SimpleTbCacheValueWrapper.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TBRedisCacheConfiguration.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TBRedisClusterConfiguration.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TBRedisStandaloneConfiguration.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbCacheTransaction.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbCacheValueWrapper.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbCaffeineCacheConfiguration.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbFSTRedisSerializer.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbRedisSerializer.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheEvictEvent.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheKey.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCaffeineCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceRedisCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/ota/CaffeineOtaPackageCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/ota/OtaPackageDataCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/ota/RedisOtaPackageDataCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/usersUpdateTime/UsersSessionInvalidationCaffeineCache.java create mode 100644 common/cache/src/main/java/org/thingsboard/server/cache/usersUpdateTime/UsersSessionInvalidationRedisCache.java create mode 100644 common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java create mode 100644 common/cache/src/test/resources/logback.xml create mode 100644 common/cluster-api/pom.xml create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueAdmin.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueCallback.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueClusterService.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueConsumer.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueMsg.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueMsgDecoder.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueMsgHeaders.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueMsgMetadata.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueProducer.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueRequestTemplate.java create mode 100644 common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueResponseTemplate.java create mode 100644 common/cluster-api/src/main/proto/jsinvoke.proto create mode 100644 common/cluster-api/src/main/proto/queue.proto create mode 100644 common/coap-server/pom.xml create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/CoapServerContext.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/CoapServerService.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsCertificateVerifier.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSessionInMemoryStorage.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSessionInfo.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java create mode 100644 common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerMessageDeliverer.java create mode 100644 common/dao-api/pom.xml create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmOperationResult.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/AbstractCassandraCluster.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraDriverOptions.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/DefaultGuavaSession.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaDriverContext.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaMultiPageResultSet.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaRequestAsyncProcessor.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaSession.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaSessionBuilder.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/cassandra/guava/GuavaSessionUtils.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/component/ComponentDescriptorService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/dashboard/DashboardService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/ClaimDevicesService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/claim/ClaimData.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/claim/ClaimResponse.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/claim/ClaimResult.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/claim/ReclaimResult.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionRequest.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionResponse.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionResponseStatus.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeEventService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ConfigTemplateService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2Service.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2User.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/queue/QueueService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/rpc/RpcService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleNodeStateService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/DbTypeInfoComponent.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/DefaultDbTypeInfoComponent.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDaoNonCloud.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/NoSqlTsLatestDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestAnyDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsLatestDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/SqlTsOrTsLatestAnyDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/TbAutoConfiguration.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/TimescaleDBTsDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/TimescaleDBTsLatestDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/util/TimescaleDBTsOrTsLatestDao.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeService.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleService.java create mode 100644 common/data/pom.xml create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/AdminSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ApiFeature.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageState.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageStateMailMessage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageStateValue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ClaimRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/CoapDeviceType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ContactBased.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/Customer.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/Device.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DeviceTransportType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/DynamicProtoUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EdgeUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityFieldsData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntitySubtype.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EntityViewInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/EventInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ExportableEntity.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/FSTUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasAdditionalInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasCustomerId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasName.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasOtaPackage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasRuleEngineProfile.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HasTenantId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HomeDashboard.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/HomeDashboardInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/OtaPackage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/SaveDeviceWithCredentialsRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/SaveOtaPackageInfoRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/SearchTextBased.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/SearchTextBasedWithAdditionalInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbProperty.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbTransportService.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TenantInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TenantProfileType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TransportPayloadType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UUIDConverter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/User.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSearchStatus.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmStatus.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/alarm/EntityAlarm.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfile.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetSearchQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionStatus.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/DeviceSearchQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/BasicMqttCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/ProvisionDeviceCredentialsData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/AbstractLwM2MBootstrapClientCredentialWithKeys.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/AbstractLwM2MClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/AbstractLwM2MClientSecurityCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/LwM2MBootstrapClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/LwM2MBootstrapClientCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/LwM2MClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/LwM2MDeviceCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/LwM2MSecurityMode.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/NoSecBootstrapClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/NoSecClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/PSKBootstrapClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/PSKClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/RPKBootstrapClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/RPKClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/X509BootstrapClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/X509ClientCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerMode.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilterKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionKeyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpecType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttTopics.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProvisionDeviceProfileCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/AbstractLwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfigDefault.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/NoSecLwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/PSKLwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/RPKLwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/X509LwM2MBootstrapServerCredential.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventActionType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeSearchQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/Event.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/LifecycleEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/RuleChainDebugEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/RuleChainDebugEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/exception/ApiUsageLimitsExceededException.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardException.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardKafkaClientError.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/AuditLogId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/ComponentDescriptorId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceCredentialsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeEventId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdDeserializer.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdSerializer.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/EventId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/HasId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/HasUUID.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/IdBased.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/NodeId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientRegistrationTemplateId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2DomainId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2MobileId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ParamsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2RegistrationId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeStateId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/UUIDBased.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/UserCredentialsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/AggTsKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/Aggregation.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseDeleteTsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseTsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/BooleanDataEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/DeleteTsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/DoubleDataEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/KvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/LongDataEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/ReadTsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/ReadTsKvQueryResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntryAggWrapper.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/lwm2m/LwM2mConstants.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/lwm2m/LwM2mInstance.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/lwm2m/LwM2mObject.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/lwm2m/LwM2mResourceObserve.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/MapperType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2BasicMapperConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ClientRegistrationTemplate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2CustomMapperConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Domain.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2DomainInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Info.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MapperConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Mobile.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2MobileInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Params.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2ParamsInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Registration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2RegistrationInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/PlatformType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/SchemeType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/oauth2/TenantNameStrategyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ota/ChecksumAlgorithm.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ota/OtaPackageKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ota/OtaPackageType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ota/OtaPackageUpdateStatus.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ota/OtaPackageUtil.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/BasePageDataIterable.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/PageData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/PageDataIterable.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/PageDataIterableByTenant.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/PageDataIterableByTenantIdEntityId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/PageLink.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/page/TimePageLink.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleState.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentScope.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataPageLink.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/ApiUsageStateFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AssetSearchQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/AssetTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/ComparisonTsValue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceSearchQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/DeviceTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValueSourceType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeSearchQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EdgeTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataPageLink.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilterType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityKey.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityKeyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityKeyValueType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityListFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityNameFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewSearchQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/query/TsValue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/queue/ProcessingStrategyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/queue/SubmitStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/queue/SubmitStrategyType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationsQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/EntitySearchDirection.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationTypeGroup.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationsSearchParameters.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rpc/Rpc.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcStatus.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rpc/ToDeviceRpcRequestBody.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/DefaultRuleChainCreateRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/NodeConnectionInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainConnectionInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainImportResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainOutputLabelsUsage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainUpdateResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNodeState.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNodeUpdateResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/rule/Scope.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/script/ScriptLanguage.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceTokenCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceX509Credentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserAuthDataChangedEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserCredentialsInvalidationEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/event/UserSessionInvalidationEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtToken.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/UserPasswordPolicy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/AwsSnsSmsProviderConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/TestSmsRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sms/config/TwilioSmsProviderConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ThrowingRunnable.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/AttributeExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityImportSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/importing/csv/BulkImportColumnType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/importing/csv/BulkImportRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/importing/csv/BulkImportResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/AutoCommitSettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/BranchInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityLoadError.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityTypeLoadResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityVersion.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityVersionsDiff.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositoryAuthMethod.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettings.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/RepositorySettingsInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/VersionCreationResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/VersionLoadResult.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/VersionedEntityInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/EntityTypeVersionCreateConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SyncStrategy.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequestType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequestType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileQueueConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/AuthenticationProtocol.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/PrivacyProtocol.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/SnmpCommunicationSpec.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/SnmpMapping.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/SnmpMethod.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/SnmpProtocolVersion.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/MultipleMappingsSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/RepeatingQueryingSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/impl/ClientAttributesQueryingSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/impl/SharedAttributesSettingSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/impl/TelemetryQueryingSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/impl/ToDeviceRpcRequestSnmpCommunicationConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/util/ReflectionUtils.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/DynamicProtoUtilsTest.java create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/UUIDConverterTest.java create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java create mode 100644 common/edge-api/pom.xml create mode 100644 common/edge-api/src/main/java/org/thingsboard/edge/exception/EdgeConnectionException.java create mode 100644 common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java create mode 100644 common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeRpcClient.java create mode 100644 common/edge-api/src/main/proto/edge.proto create mode 100644 common/message/pom.xml create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbActorMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbActorStopReason.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgDataType.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingStackItem.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/TbRuleEngineActorMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/ToDeviceActorNotificationMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/CustomerAwareMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/DeviceAwareMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/NodeAwareMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/RuleChainAwareMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/aware/TenantAwareMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ToAllNodesMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/edge/EdgeEventUpdateMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/edge/EdgeSessionMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/edge/FromEdgeSyncResponse.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/edge/ToEdgeSyncRequest.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleListener.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/plugin/RuleNodeUpdatedMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/PartitionChangeMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/QueueToRuleEngineMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleEngineException.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleNodeException.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/RuleNodeInfo.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbCallback.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/TbMsgCallback.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfo.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/rpc/FromDeviceRpcResponse.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/rpc/ToDeviceRpcRequest.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/FeatureType.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsgType.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/ex/ProcessingTimeoutException.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/ex/SessionAuthException.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/session/ex/SessionException.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorServerSideRpcTimeoutMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/timeout/TimeoutMsg.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java create mode 100644 common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimitsException.java create mode 100644 common/message/src/main/proto/tbmsg.proto create mode 100644 common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgMetaDataTest.java create mode 100644 common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingStackItemTest.java create mode 100644 common/message/src/test/java/org/thingsboard/server/common/msg/queue/TopicPartitionInfoTest.java create mode 100644 common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java create mode 100644 common/pom.xml create mode 100644 common/queue/pom.xml create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/RuleEngineTbQueueAdminFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusQueueConfigs.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/azure/servicebus/TbServiceBusSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractParallelTbQueueConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/AsyncCallbackTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueMsg.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueMsgHeaders.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/DefaultTbQueueResponseTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueCallbackWrapper.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/MultipleTbQueueTbMsgCallbackWrapper.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoJsQueueMsg.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/TbProtoQueueMsg.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/TbQueueTbMsgCallbackWrapper.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/ConsistentHashCircle.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/DefaultTbServiceInfoProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/DiscoveryService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/DummyDiscoveryService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/NotificationsTopicService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueKey.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfo.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfoService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbApplicationEventListener.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/TbServiceInfoProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/TenantRoutingInfo.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/TenantRoutingInfoService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/ClusterTopologyChangeEvent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/PartitionChangeEvent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/ServiceListChangedEvent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/discovery/event/TbApplicationEvent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/environment/EnvironmentLogService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsg.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/KafkaTbQueueMsgMetadata.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatisticConfig.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaDecoder.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaEncoder.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaTopicConfigs.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/memory/DefaultInMemoryStorage.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryStorage.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueConsumer.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/memory/InMemoryTbQueueProducer.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/AwsSqsTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/InMemoryTbTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/PubSubTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/RabbitMqTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusMonolithQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/ServiceBusTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbCoreQueueProducerProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbQueueProducerProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineProducerProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbRuleEngineQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbTransportQueueProducerProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbUsageStatsClientQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlProducerProvider.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/provider/TbVersionControlQueueFactory.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/pubsub/TbPubSubSubscriptionSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqQueueArguments.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/rabbitmq/TbRabbitMqSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/scheduler/DefaultSchedulerComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/scheduler/SchedulerComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueCoreSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueRemoteJsInvokeSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueRuleEngineSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueTransportApiSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueTransportNotificationSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbQueueVersionControlSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbRuleEngineQueueAckStrategyConfiguration.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbRuleEngineQueueConfiguration.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/settings/TbRuleEngineQueueSubmitStrategyConfiguration.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/AwsSqsTbQueueMsgMetadata.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsAdmin.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsConsumerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsProducerTemplate.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsQueueAttributes.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/sqs/TbAwsSqsSettings.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/AfterContextReady.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/AfterStartUp.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/DataDecodingEncodingService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/ProtoWithFSTService.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java create mode 100644 common/queue/src/test/java/org/thingsboard/server/queue/common/DefaultTbQueueRequestTemplateTest.java create mode 100644 common/queue/src/test/java/org/thingsboard/server/queue/discovery/QueueKeyTest.java create mode 100644 common/queue/src/test/java/org/thingsboard/server/queue/memory/DefaultInMemoryStorageTest.java create mode 100644 common/script/pom.xml create mode 100644 common/script/remote-js-client/pom.xml create mode 100644 common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/JsExecutorService.java create mode 100644 common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java create mode 100644 common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java create mode 100644 common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java create mode 100644 common/script/script-api/pom.xml create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/BlockedScriptInfo.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/RuleNodeScriptFactory.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptException.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/TbScriptExecutionTask.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsScriptExecutionTask.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/JsScriptInfo.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/js/NashornJsInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbDate.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbJson.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelInvokeService.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelScript.java create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelScriptExecutionTask.java create mode 100644 common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java create mode 100644 common/stats/pom.xml create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultMessagesStats.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultStatsFactory.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/MessagesStats.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/StatsCounter.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/StatsFactory.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/TbApiUsageReportClient.java create mode 100644 common/stats/src/main/java/org/thingsboard/server/common/stats/TbApiUsageStateClient.java create mode 100644 common/transport/coap/pom.xml create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/AbstractCoapTransportResource.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/OtaPackageTransportResource.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/TbCoapMessageObserver.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/TransportConfigurationContainer.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/AbstractSyncSessionCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapDeviceAuthCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapEfentoCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapNoOpCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapOkCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/GetAttributesSyncSessionCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/ToServerRpcSyncSessionCallback.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/CoapClientContext.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DefaultCoapClientContext.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/NoSecClient.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/NoSecObserveClient.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/SecureClientNoAuth.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/SecureClientX509.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/TbCoapClientState.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/TbCoapContentFormatUtil.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/TbCoapObservationState.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/adaptor/EfentoCoapAdaptor.java create mode 100644 common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java create mode 100644 common/transport/coap/src/main/proto/proto_measurement_types.proto create mode 100644 common/transport/coap/src/main/proto/proto_measurements.proto create mode 100644 common/transport/http/pom.xml create mode 100644 common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java create mode 100644 common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java create mode 100644 common/transport/lwm2m/pom.xml create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapServers.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MServerBootstrap.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2mDefaultBootstrapSessionManager.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/TbLwM2MDtlsBootstrapCertificateVerifier.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapClientInstanceIds.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapConfigStoreTaskProvider.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapTaskProvider.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MConfigurationChecker.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MInMemoryBootstrapConfigStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MSecureServerConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/TbLwM2mVersion.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mCredentialsSecurityInfoValidator.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/LwM2mRPkCredentials.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MAuthorizer.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MDtlsCertificateVerifier.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbLwM2MSecurityInfo.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/TbX509DtlsSessionInfo.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/secure/credentials/LwM2MClientCredentials.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/AbstractLwM2mTransportResource.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2MNetworkConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2MOperationType.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2MTransportService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mOtaConvert.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mSessionMsgListener.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportContext.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/adaptors/LwM2MJsonAdaptor.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/adaptors/LwM2MTransportAdaptor.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/attributes/DefaultLwM2MAttributesService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/attributes/LwM2MAttributesService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2MAuthException.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2MClientState.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2MClientStateException.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContext.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ModelObject.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ParametersAnalyzeResult.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResourceValue.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsAddKeyValueProto.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/common/LwM2MExecutorAwareService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/AbstractTbLwM2MRequestCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/AbstractTbLwM2MTargetedDownlinkRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DownlinkRequestCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/HasContentFormat.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/HasVersionedId.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/HasVersionedIds.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/LwM2mDownlinkMsgHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCancelAllObserveCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCancelAllRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCancelObserveCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCancelObserveRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCreateRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MCreateResponseCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDeleteCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDeleteRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDiscoverAllRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDiscoverCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDiscoverRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MDownlinkRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MExecuteCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MExecuteRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MLatchCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MObserveAllRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MObserveCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MObserveRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MReadCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MReadRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MTargetedCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MUplinkTargetedCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MWriteAttributesCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MWriteAttributesRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MWriteReplaceRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MWriteResponseCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/TbLwM2MWriteUpdateRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/AbstractTbLwM2MTargetedDownlinkCompositeRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MReadCompositeCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MReadCompositeRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MWriteCompositeRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MWriteResponseCompositeCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/log/DefaultLwM2MTelemetryLogService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/log/LwM2MTelemetryLogService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfig.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/model/LwM2MModelConfigServiceImpl.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/LwM2MClientOtaInfo.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/LwM2MClientOtaState.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/LwM2MOtaUpdateService.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/firmware/FirmwareDeliveryMethod.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/firmware/FirmwareUpdateResult.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/firmware/FirmwareUpdateState.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/firmware/LwM2MClientFwOtaInfo.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/firmware/LwM2MFirmwareUpdateStrategy.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/software/LwM2MClientSwOtaInfo.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/software/LwM2MSoftwareUpdateStrategy.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/software/SoftwareUpdateResult.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/software/SoftwareUpdateState.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/LwM2MRpcRequestHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/LwM2MRpcRequestHeader.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/LwM2MRpcResponseBody.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcCancelAllObserveCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcCancelObserveCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcCreateRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcCreateResponseCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDiscoverCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcDownlinkRequestCallbackProxy.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcEmptyResponseCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcLinkSetCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcLwM2MDownlinkCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcReadResponseCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcWriteAttributesRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcWriteReplaceRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcWriteUpdateRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/composite/RpcReadCompositeRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/composite/RpcReadResponseCompositeCallback.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/composite/RpcWriteCompositeRequest.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/session/DefaultLwM2MSessionManager.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/session/LwM2MSessionManager.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbDummyLwM2MClientOtaInfoStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbDummyLwM2MClientStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbDummyLwM2MModelConfigStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbEditableSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbInMemorySecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbL2M2MDtlsSessionInMemoryStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2MClientOtaInfoStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2MClientStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2MDtlsSessionRedisStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2MDtlsSessionStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2MModelConfigStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisClientOtaInfoStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mStoreFactory.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbMainSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbSecurityStore.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mTypeServer.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mUplinkMsgHandler.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java create mode 100644 common/transport/lwm2m/src/test/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientTest.java create mode 100644 common/transport/lwm2m/src/test/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDesTest.java create mode 100644 common/transport/mqtt/pom.xml create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TopicType.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/BackwardCompatibilityAdaptor.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/limits/IpFilter.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/limits/ProxyIpFilter.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/AlwaysTrueTopicFilter.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/EqualsTopicFilter.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/MqttTopicFilter.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/MqttTopicFilterFactory.java create mode 100644 common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/RegexTopicFilter.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportHandlerTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/util/MqttTopicFilterFactoryTest.java create mode 100644 common/transport/pom.xml create mode 100644 common/transport/snmp/pom.xml create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/event/ServiceListChangedEventListener.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/event/SnmpTransportListChangedEvent.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/event/SnmpTransportListChangedEventListener.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/PduService.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/ProtoTransportEntityService.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpAuthService.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportBalancingService.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java create mode 100644 common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/session/DeviceSessionContext.java create mode 100644 common/transport/snmp/src/test/java/org/thingsboard/server/transport/snmp/SnmpDeviceSimulatorV2.java create mode 100644 common/transport/snmp/src/test/java/org/thingsboard/server/transport/snmp/SnmpDeviceSimulatorV3.java create mode 100644 common/transport/snmp/src/test/java/org/thingsboard/server/transport/snmp/SnmpTestV2.java create mode 100644 common/transport/snmp/src/test/java/org/thingsboard/server/transport/snmp/SnmpTestV3.java create mode 100644 common/transport/snmp/src/test/resources/snmp-device-profile-transport-config.json create mode 100644 common/transport/snmp/src/test/resources/snmp-device-transport-config-v3.json create mode 100644 common/transport/snmp/src/test/resources/snmp-device-transport-config.json create mode 100644 common/transport/transport-api/pom.xml create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceProfileUpdatedEvent.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceUpdatedEvent.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportDeviceProfileCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportResourceCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportTenantProfileCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceProfileAware.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/GetOrCreateDeviceFromGatewayResponse.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/SessionInfoCreator.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/TransportDeviceInfo.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceCredentialsResponse.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsType.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DummyTransportRateLimit.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/EntityTransportRateLimits.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/InetAddressRateLimitStats.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/SimpleTransportRateLimit.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimit.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/profile/TenantProfileUpdateResult.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportDeviceProfileCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportResourceCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RpcRequestMetadata.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionActivityData.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportQueueRoutingInfoService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportTenantRoutingInfoService.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java create mode 100644 common/transport/transport-api/src/main/proto/transport.proto create mode 100644 common/transport/transport-api/src/test/java/JsonConverterTest.java create mode 100644 common/util/pom.xml create mode 100644 common/util/src/main/java/org/thingsboard/common/util/AbstractListeningExecutor.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/CollectionsUtil.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/KvUtil.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/LinkedHashMapRemoveEldest.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ListeningExecutor.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/RegexUtils.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/TbStopWatch.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThingsBoardExecutors.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThingsBoardForkJoinWorkerThreadFactory.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java create mode 100644 common/util/src/test/java/org/thingsboard/common/util/JacksonUtilTest.java create mode 100644 common/util/src/test/java/org/thingsboard/common/util/LinkedHashMapRemoveEldestTest.java create mode 100644 common/version-control/pom.xml create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/ClusterVersionControlService.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitRepositoryService.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepositoryService.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/PendingCommit.java create mode 100644 common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlRequestCtx.java create mode 100644 dao/pom.xml create mode 100644 dao/src/main/java/org/thingsboard/server/dao/Dao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ExportableEntityDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ExportableEntityRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/TenantEntityDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/TenantEntityWithDataDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/aspect/DbCallStats.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/aspect/DbCallStatsSnapshot.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/aspect/MethodCallStats.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/aspect/MethodCallStatsSnapshot.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/aspect/SqlDaoCallsAspect.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetCacheEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/AssetTypeFilter.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogLevelFilter.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogLevelMask.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogLevelProperties.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/DummyAuditLogServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/sink/AuditLogSink.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/sink/DummyAuditLogSink.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/audit/sink/ElasticsearchAuditLogSink.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/cache/CacheExecutorService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/component/BaseComponentDescriptorService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/component/ComponentDescriptorDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/ClaimDataInfo.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/BaseEdgeEventService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeCacheEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeEventDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entity/AbstractCachedEntityService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entity/EntityQueryDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCacheValue.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/exception/BufferLimitException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/exception/DataValidationException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/exception/DatabaseException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/exception/DeviceCredentialsValidationException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/exception/IncorrectParameterException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/BaseEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/SearchTextEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/ToData.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAlarmEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractAssetEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractDeviceEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEdgeEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEntityViewEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTenantEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractTsKvEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractWidgetTypeEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AdminSettingsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AlarmInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiUsageStateEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AssetProfileEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/CustomerEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DashboardInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceCredentialsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceProfileEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EdgeEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EdgeEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EdgeInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityAlarmEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/ErrorEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/EventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/LifecycleEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ClientRegistrationTemplateEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2DomainEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2MobileEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2ParamsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OAuth2RegistrationEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/QueueEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RelationEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RpcEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDebugEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeDebugEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleNodeStateEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/StatisticsEventEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/TenantEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/TenantInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/TenantProfileEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/UserEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeDetailsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetTypeInfoEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/WidgetsBundleEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/TsKvDictionary.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/dictionary/TsKvDictionaryCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/timescale/ts/TimescaleTsKvCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/timescale/ts/TimescaleTsKvEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvCompositeKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sqlts/ts/TsKvEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateReadExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateWriteExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ClientRegistrationTemplateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ConfigTemplateServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2Configuration.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2DomainDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2MobileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ParamsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2RegistrationDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2Utils.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageCacheEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/queue/QueueDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/EntityRelationEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/RelationCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/RelationCacheValue.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/RelationCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/relation/RelationRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rpc/BaseRpcService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleNodeStateService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rule/RuleNodeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/rule/RuleNodeStateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/PaginatedRemover.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/StringLengthValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/TimePaginatedRemover.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/Validator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AbstractHasOtaPackageValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AdminSettingsDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/ApiUsageDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AssetProfileDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/AuditLogDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/BaseOtaPackageDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/ClientRegistrationTemplateDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/ComponentDescriptorDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/CustomerDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/EdgeDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/EdgeEventDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/EntityViewDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/EventDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageInfoDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/QueueValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/RuleChainDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/UserCredentialsDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetTypeDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/WidgetsBundleDataValidator.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractSearchTextDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/JpaExecutorService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ScheduledLogExecutorComponent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueue.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueParams.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlBlockingQueueWrapper.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueue.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/TbSqlQueueElement.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/alarm/EntityAlarmRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetProfileRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/attributes/SqlAttributesInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/component/ComponentDescriptorInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/component/ComponentDescriptorRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/component/SqlComponentDescriptorInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceProfileRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/device/NativeDeviceRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeEventInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/edge/EdgeRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaEdgeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/ErrorEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/EventCleanupRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/EventInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/EventPartitionConfiguration.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/EventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/LifecycleEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleChainDebugEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/RuleNodeDebugEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/SqlEventCleanupRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/event/StatisticsEventRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/JpaOAuth2ClientRegistrationTemplateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/JpaOAuth2DomainDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/JpaOAuth2MobileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/JpaOAuth2ParamsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/JpaOAuth2RegistrationDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/OAuth2ClientRegistrationTemplateRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/OAuth2DomainRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/OAuth2MobileRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/OAuth2ParamsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/oauth2/OAuth2RegistrationRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmQueryRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityDataAdapter.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityQueryRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/JpaEntityQueryDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryContext.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/QueryLogComponent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/query/QuerySecurityContext.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/queue/JpaQueueDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/queue/QueueRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationQueryExecutorService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/relation/SqlRelationInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceInfoDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeStateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleNodeRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleNodeStateRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/tenant/JpaTenantProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantProfileRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/tenant/TenantRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/ApiUsageStateRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/usagerecord/JpaApiUsageStateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/UserCredentialsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetsBundleRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractSqlTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/AggregationTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/BaseAbstractSqlTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/EntityContainer.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/TsKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/dictionary/TsKvDictionaryRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/AbstractInsertRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/InsertTsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/InsertLatestTsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/sql/SqlLatestInsertTsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlInsertTsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/sql/SqlPartitioningRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/timescale/TimescaleInsertTsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/TsKvLatestRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/sql/JpaSqlTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/AggregationRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TsKvTimescaleRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/DefaultTbTenantProfileCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantExistsCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantExistsRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/AbstractCassandraBaseTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraPartitionCacheKey.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraTsPartitionsCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/QueryCursor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/SqlPartition.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/SqlTsPartitionDate.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TsInsertExecutorType.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/timeseries/TsKvQueryCursor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/user/UserAuthSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/user/UserCredentialsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/AsyncRateLimiter.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutorStats.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/TenantRateLimitException.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/AbstractJsonSqlTypeDescriptor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/JsonBinarySqlTypeDescriptor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/JsonBinaryType.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/JsonStringSqlTypeDescriptor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/JsonStringType.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/mapping/JsonTypeDescriptor.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java create mode 100644 dao/src/main/resources/cassandra/schema-keyspace.cql create mode 100644 dao/src/main/resources/cassandra/schema-ts-latest.cql create mode 100644 dao/src/main/resources/cassandra/schema-ts.cql create mode 100644 dao/src/main/resources/sql/schema-entities-idx-psql-addon.sql create mode 100644 dao/src/main/resources/sql/schema-entities-idx.sql create mode 100644 dao/src/main/resources/sql/schema-entities.sql create mode 100644 dao/src/main/resources/sql/schema-timescale.sql create mode 100644 dao/src/main/resources/sql/schema-ts-psql.sql create mode 100644 dao/src/main/resources/xss-policy.xml create mode 100644 dao/src/test/java/org/apache/cassandra/io/sstable/Descriptor.java create mode 100644 dao/src/test/java/org/apache/cassandra/io/sstable/format/SSTableFormat.java create mode 100644 dao/src/test/java/org/apache/cassandra/io/util/FileUtils.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/AbstractDaoServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/AbstractJpaDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/CustomCassandraCQLUnit.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/PostgreSqlInitializer.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/RedisSqlTestSuite.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/TimescaleDaoServiceTestSuite.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/TimescaleSqlInitializer.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/nosql/CassandraPartitionsCacheTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseAdminSettingsServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseAlarmServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseApiUsageStateServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetProfileServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseAssetServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseCustomerServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseDashboardServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceCredentialsCacheTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceCredentialsServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceProfileServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseEdgeEventServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseEdgeServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseEntityServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ConfigTemplateServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseOAuth2ServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseOtaPackageServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseQueueServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseRelationCacheTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseRelationServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseRuleChainServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseTenantProfileServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseTenantServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseUserServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseWidgetTypeServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/BaseWidgetsBundleServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/DaoNoSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/DaoSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/DaoTimescaleTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/DataValidatorTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/NoXssValidatorTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributesServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/install/sql/EntitiesSchemaSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/AdminSettingsServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/AlarmServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/ApiUsageStateServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetProfileServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/CustomerServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/DashboardServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsCacheServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceProfileServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeEventServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/EntityServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ConfigTemplateServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/OtaPackageServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/QueueServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationCacheSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/RuleChainServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantProfileServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/UserServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetTypeServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetsBundleServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceTimescaleTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityDataAdapterTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityKeyMappingTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningDaysAlwaysExistsTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningHoursAlwaysExistsTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningIndefiniteAlwaysExistsTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java create mode 100644 dao/src/test/resources/TestJsonData.json create mode 100644 dao/src/test/resources/TestJsonDescriptor.json create mode 100644 dao/src/test/resources/application-test.properties create mode 100644 dao/src/test/resources/cassandra-test.properties create mode 100644 dao/src/test/resources/cassandra-test.yaml create mode 100644 dao/src/test/resources/logback.xml create mode 100644 dao/src/test/resources/nosql-test.properties create mode 100644 dao/src/test/resources/sql-test.properties create mode 100644 dao/src/test/resources/sql/hsql/drop-all-tables.sql create mode 100644 dao/src/test/resources/sql/psql/drop-all-tables.sql create mode 100644 dao/src/test/resources/sql/system-data.sql create mode 100644 dao/src/test/resources/sql/system-test-psql.sql create mode 100644 dao/src/test/resources/sql/system-test.sql create mode 100644 dao/src/test/resources/sql/timescale/drop-all-tables.sql create mode 100644 dao/src/test/resources/timescale-test.properties create mode 100644 dao/src/test/resources/xss-policy.xml create mode 100644 docker/.env create mode 100644 docker/.gitignore create mode 100644 docker/README.md create mode 100644 docker/cache-redis-cluster.env create mode 100644 docker/cache-redis.env create mode 100644 docker/compose-utils.sh create mode 100644 docker/docker-check-log-folders.sh create mode 100644 docker/docker-compose.aws-sqs.yml create mode 100644 docker/docker-compose.cassandra.volumes.yml create mode 100644 docker/docker-compose.confluent.yml create mode 100644 docker/docker-compose.hybrid.yml create mode 100644 docker/docker-compose.kafka.yml create mode 100644 docker/docker-compose.postgres.volumes.yml create mode 100644 docker/docker-compose.postgres.yml create mode 100644 docker/docker-compose.prometheus-grafana.yml create mode 100644 docker/docker-compose.pubsub.yml create mode 100644 docker/docker-compose.rabbitmq.yml create mode 100644 docker/docker-compose.redis-cluster.volumes.yml create mode 100644 docker/docker-compose.redis-cluster.yml create mode 100644 docker/docker-compose.redis.volumes.yml create mode 100644 docker/docker-compose.redis.yml create mode 100644 docker/docker-compose.service-bus.yml create mode 100644 docker/docker-compose.volumes.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/docker-create-log-folders.sh create mode 100644 docker/docker-install-tb.sh create mode 100644 docker/docker-remove-services.sh create mode 100644 docker/docker-start-services.sh create mode 100644 docker/docker-stop-services.sh create mode 100644 docker/docker-update-service.sh create mode 100644 docker/docker-upgrade-tb.sh create mode 100644 docker/haproxy/config/haproxy.cfg create mode 100644 docker/kafka.env create mode 100644 docker/monitoring/grafana/config.monitoring create mode 100644 docker/monitoring/grafana/provisioning/dashboards/attributes_cache.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/dashboard.yml create mode 100644 docker/monitoring/grafana/provisioning/dashboards/db_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/hybrid_db_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/rule_engine_latency.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/rule_engine_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/single_service_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/dashboards/transport_metrics.json create mode 100644 docker/monitoring/grafana/provisioning/datasources/datasource.yml create mode 100644 docker/monitoring/prometheus/prometheus.yml create mode 100644 docker/queue-aws-sqs.env create mode 100644 docker/queue-confluent.env create mode 100644 docker/queue-kafka.env create mode 100644 docker/queue-pubsub.env create mode 100644 docker/queue-rabbitmq.env create mode 100644 docker/queue-service-bus.env create mode 100644 docker/tb-coap-transport.env create mode 100644 docker/tb-http-transport.env create mode 100644 docker/tb-js-executor.env create mode 100644 docker/tb-lwm2m-transport.env create mode 100644 docker/tb-mqtt-transport.env create mode 100644 docker/tb-node.env create mode 100644 docker/tb-node.hybrid.env create mode 100644 docker/tb-node.postgres.env create mode 100644 docker/tb-node/conf/logback.xml create mode 100644 docker/tb-node/conf/thingsboard.conf create mode 100644 docker/tb-snmp-transport.env create mode 100644 docker/tb-transports/coap/conf/logback.xml create mode 100644 docker/tb-transports/coap/conf/tb-coap-transport.conf create mode 100644 docker/tb-transports/http/conf/logback.xml create mode 100644 docker/tb-transports/http/conf/tb-http-transport.conf create mode 100644 docker/tb-transports/lwm2m/conf/logback.xml create mode 100644 docker/tb-transports/lwm2m/conf/tb-lwm2m-transport.conf create mode 100644 docker/tb-transports/mqtt/conf/logback.xml create mode 100644 docker/tb-transports/mqtt/conf/tb-mqtt-transport.conf create mode 100644 docker/tb-transports/snmp/conf/logback.xml create mode 100644 docker/tb-transports/snmp/conf/tb-snmp-transport.conf create mode 100644 docker/tb-vc-executor.env create mode 100644 docker/tb-vc-executor/conf/logback.xml create mode 100644 docker/tb-vc-executor/conf/tb-vc-executor.conf create mode 100644 docker/tb-web-ui.env create mode 100644 img/logo.png create mode 100644 license-header-template.txt create mode 100644 lombok.config create mode 100644 msa/black-box-tests/README.md create mode 100644 msa/black-box-tests/pom.xml create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClient.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClientCallback.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestListener.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java create mode 100644 msa/black-box-tests/src/test/java/org/thingsboard/server/msa/prototypes/DevicePrototypes.java create mode 100644 msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json create mode 100644 msa/black-box-tests/src/test/resources/config.properties create mode 100644 msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml create mode 100644 msa/black-box-tests/src/test/resources/logback.xml create mode 100644 msa/black-box-tests/src/test/resources/testNG.xml create mode 100644 msa/js-executor/.gitignore create mode 100644 msa/js-executor/api/httpServer.ts create mode 100644 msa/js-executor/api/jsExecutor.models.ts create mode 100644 msa/js-executor/api/jsExecutor.ts create mode 100644 msa/js-executor/api/jsInvokeMessageProcessor.ts create mode 100644 msa/js-executor/api/utils.ts create mode 100644 msa/js-executor/config/custom-environment-variables.yml create mode 100644 msa/js-executor/config/default.yml create mode 100644 msa/js-executor/config/logger.ts create mode 100644 msa/js-executor/config/tb-js-executor.conf create mode 100644 msa/js-executor/docker/Dockerfile create mode 100644 msa/js-executor/docker/start-js-executor.sh create mode 100644 msa/js-executor/install.js create mode 100644 msa/js-executor/package.json create mode 100644 msa/js-executor/pom.xml create mode 100644 msa/js-executor/queue/awsSqsTemplate.ts create mode 100644 msa/js-executor/queue/kafkaTemplate.ts create mode 100644 msa/js-executor/queue/pubSubTemplate.ts create mode 100644 msa/js-executor/queue/queue.models.ts create mode 100644 msa/js-executor/queue/rabbitmqTemplate.ts create mode 100644 msa/js-executor/queue/serviceBusTemplate.ts create mode 100644 msa/js-executor/server.ts create mode 100644 msa/js-executor/tsconfig.json create mode 100644 msa/js-executor/yarn.lock create mode 100644 msa/pom.xml create mode 100644 msa/tb-node/docker/Dockerfile create mode 100644 msa/tb-node/docker/start-tb-node.sh create mode 100644 msa/tb-node/pom.xml create mode 100644 msa/tb/README.md create mode 100644 msa/tb/docker-cassandra/Dockerfile create mode 100644 msa/tb/docker-cassandra/start-db.sh create mode 100644 msa/tb/docker-cassandra/stop-db.sh create mode 100644 msa/tb/docker-postgres/Dockerfile create mode 100644 msa/tb/docker-postgres/start-db.sh create mode 100644 msa/tb/docker-postgres/stop-db.sh create mode 100644 msa/tb/docker/install-tb.sh create mode 100644 msa/tb/docker/logback.xml create mode 100644 msa/tb/docker/start-tb.sh create mode 100644 msa/tb/docker/thingsboard.conf create mode 100644 msa/tb/docker/upgrade-tb.sh create mode 100644 msa/tb/pom.xml create mode 100644 msa/transport/coap/docker/Dockerfile create mode 100644 msa/transport/coap/docker/start-tb-coap-transport.sh create mode 100644 msa/transport/coap/pom.xml create mode 100644 msa/transport/http/docker/Dockerfile create mode 100644 msa/transport/http/docker/start-tb-http-transport.sh create mode 100644 msa/transport/http/pom.xml create mode 100644 msa/transport/lwm2m/docker/Dockerfile create mode 100644 msa/transport/lwm2m/docker/start-tb-lwm2m-transport.sh create mode 100644 msa/transport/lwm2m/pom.xml create mode 100644 msa/transport/mqtt/docker/Dockerfile create mode 100644 msa/transport/mqtt/docker/start-tb-mqtt-transport.sh create mode 100644 msa/transport/mqtt/pom.xml create mode 100644 msa/transport/pom.xml create mode 100644 msa/transport/snmp/docker/Dockerfile create mode 100644 msa/transport/snmp/docker/start-tb-snmp-transport.sh create mode 100644 msa/transport/snmp/pom.xml create mode 100644 msa/vc-executor-docker/docker/Dockerfile create mode 100644 msa/vc-executor-docker/docker/start-tb-vc-executor.sh create mode 100644 msa/vc-executor-docker/pom.xml create mode 100644 msa/vc-executor/pom.xml create mode 100644 msa/vc-executor/src/main/conf/logback.xml create mode 100644 msa/vc-executor/src/main/conf/tb-vc-executor.conf create mode 100644 msa/vc-executor/src/main/java/org/thingsboard/server/vc/ThingsboardVersionControlExecutorApplication.java create mode 100644 msa/vc-executor/src/main/java/org/thingsboard/server/vc/service/VersionControlQueueRoutingInfoService.java create mode 100644 msa/vc-executor/src/main/java/org/thingsboard/server/vc/service/VersionControlTenantRoutingInfoService.java create mode 100644 msa/vc-executor/src/main/resources/logback.xml create mode 100644 msa/vc-executor/src/main/resources/tb-vc-executor.yml create mode 100644 msa/web-ui/.gitignore create mode 100644 msa/web-ui/config/custom-environment-variables.yml create mode 100644 msa/web-ui/config/default.yml create mode 100644 msa/web-ui/config/logger.ts create mode 100644 msa/web-ui/config/tb-web-ui.conf create mode 100644 msa/web-ui/docker/Dockerfile create mode 100644 msa/web-ui/docker/start-web-ui.sh create mode 100644 msa/web-ui/install.js create mode 100644 msa/web-ui/package.json create mode 100644 msa/web-ui/pom.xml create mode 100644 msa/web-ui/server.ts create mode 100644 msa/web-ui/tsconfig.json create mode 100644 msa/web-ui/yarn.lock create mode 100644 netty-mqtt/.gitignore create mode 100644 netty-mqtt/pom.xml create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttHandler.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttIncomingQos2Publish.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttLastWill.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java create mode 100644 netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/IntegrationTestSuite.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java create mode 100644 netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java create mode 100644 packaging/java/assembly/windows.xml create mode 100644 packaging/java/build.gradle create mode 100644 packaging/java/filters/unix.properties create mode 100644 packaging/java/filters/windows.properties create mode 100644 packaging/java/scripts/control/deb/postinst create mode 100644 packaging/java/scripts/control/deb/postrm create mode 100644 packaging/java/scripts/control/deb/preinst create mode 100644 packaging/java/scripts/control/deb/prerm create mode 100644 packaging/java/scripts/control/rpm/postinst create mode 100644 packaging/java/scripts/control/rpm/postrm create mode 100644 packaging/java/scripts/control/rpm/preinst create mode 100644 packaging/java/scripts/control/rpm/prerm create mode 100644 packaging/java/scripts/control/template.service create mode 100644 packaging/java/scripts/install/install.sh create mode 100644 packaging/java/scripts/install/install_dev_db.sh create mode 100644 packaging/java/scripts/install/logback.xml create mode 100644 packaging/java/scripts/install/upgrade.sh create mode 100644 packaging/java/scripts/install/upgrade_dev_db.sh create mode 100644 packaging/java/scripts/windows/install.bat create mode 100644 packaging/java/scripts/windows/install_dev_db.bat create mode 100644 packaging/java/scripts/windows/service.xml create mode 100644 packaging/java/scripts/windows/uninstall.bat create mode 100644 packaging/java/scripts/windows/upgrade.bat create mode 100644 packaging/js/assembly/windows.xml create mode 100644 packaging/js/build.gradle create mode 100644 packaging/js/filters/unix.properties create mode 100644 packaging/js/filters/windows.properties create mode 100644 packaging/js/scripts/control/deb/postinst create mode 100644 packaging/js/scripts/control/deb/postrm create mode 100644 packaging/js/scripts/control/deb/preinst create mode 100644 packaging/js/scripts/control/deb/prerm create mode 100644 packaging/js/scripts/control/rpm/postinst create mode 100644 packaging/js/scripts/control/rpm/postrm create mode 100644 packaging/js/scripts/control/rpm/preinst create mode 100644 packaging/js/scripts/control/rpm/prerm create mode 100644 packaging/js/scripts/control/template.service create mode 100644 packaging/js/scripts/init/template create mode 100644 packaging/js/scripts/windows/install.bat create mode 100644 packaging/js/scripts/windows/service.xml create mode 100644 packaging/js/scripts/windows/uninstall.bat create mode 100644 pom.xml create mode 100644 pull_request_template.md create mode 100644 rest-client/pom.xml create mode 100644 rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java create mode 100644 rest-client/src/main/java/org/thingsboard/rest/client/utils/RestJsonConverter.java create mode 100644 rest-client/src/main/resources/logback.xml create mode 100644 rule-engine/pom.xml create mode 100644 rule-engine/rule-engine-api/pom.xml create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/EmptyNodeConfiguration.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NodeConfiguration.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/NodeDefinition.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineApiUsageStateService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAssetProfileCache.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceProfileCache.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcResponse.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineRpcService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineTelemetryService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleNode.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/ScriptEngine.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/SmsService.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbEmail.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNode.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeConfiguration.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeException.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbNodeState.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbRelationTypes.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceAttributes.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceAttributesEventNotificationMsg.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceCredentialsUpdateNotificationMsg.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceEdgeUpdateMsg.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceMetaData.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/msg/DeviceNameOrTypeUpdateMsg.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/sms/SmsSender.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/sms/SmsSenderFactory.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/sms/exception/SmsException.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/sms/exception/SmsParseException.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/sms/exception/SmsSendException.java create mode 100644 rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java create mode 100644 rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java create mode 100644 rule-engine/rule-engine-components/pom.xml create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractAlarmNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractAlarmNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAlarmResult.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/credentials/AnonymousCredentials.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/credentials/BasicCredentials.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/credentials/CertPemCredentials.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/credentials/ClientCredentials.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/credentials/CredentialsType.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/data/DeviceRelationsQuery.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/data/RelationsQuery.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/BaseTbMsgPushNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNodeConfig.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/AbstractGeofencingNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/Coordinates.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/EntityGeofencingState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/GeoUtil.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/Perimeter.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/PerimeterType.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/RangeUnit.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathArgument.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathArgumentType.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathArgumentValue.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathResult.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbRuleNodeMathFunctionType.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetAttributesNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDetailsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDetailsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbEntityGetAttrNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetEntityAttrNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttrNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/AzureIotHubSasCredentials.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmEvalResult.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmStateUpdateResult.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DataSnapshot.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtx.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtxImpl.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/EntityKeyValue.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/NumericParseException.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/SnapshotUpdate.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmRuleState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedDeviceState.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRpcReplyNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRpcRequestNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/AttributesDeleteNodeCallback.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/AttributesUpdateNodeCallback.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TelemetryNodeCallback.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transaction/TbSynchronizationBeginNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transaction/TbSynchronizationEndNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/MultipleTbMsgsCallbackWrapper.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbAbstractTransformNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbMsgCallbackWrapper.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformNodeConfiguration.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesAlarmOriginatorIdAsyncLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesByNameAndTypeLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesCustomerIdAsyncLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesRelatedDeviceIdAsyncLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesRelatedEntityIdAsyncLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntityContainer.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntityDetails.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java create mode 100644 rule-engine/rule-engine-components/src/main/resources/public/static/rulenode/rulenode-core-config.js create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAlarmNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbLogNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbJsFilterNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbJsSwitchNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGeoUtilTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathArgumentValueTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/AbstractAttributeNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbAbstractGetAttributesNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/AlarmStateTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/DeviceStateTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbCopyKeysNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbJsonPathNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbRenameKeysNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbTransformMsgNodeTest.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java create mode 100644 tools/pom.xml create mode 100644 tools/src/main/java/org/thingsboard/client/tools/MqttSslClient.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/DictionaryParser.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/MigratorTool.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/PgCaMigrator.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/README.md create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/RelatedEntitiesParser.java create mode 100644 tools/src/main/java/org/thingsboard/client/tools/migrator/WriterBuilder.java create mode 100644 tools/src/main/python/mqtt-send-telemetry.py create mode 100644 tools/src/main/python/one-way-ssl-mqtt-client.py create mode 100644 tools/src/main/python/simple-mqtt-client.py create mode 100644 tools/src/main/python/two-way-ssl-mqtt-client.py create mode 100644 tools/src/main/shell/client.keygen.sh create mode 100644 tools/src/main/shell/keygen.properties create mode 100644 tools/src/main/shell/lwm2m/lwM2M_cfssl_chain_clients_for_test.sh create mode 100644 tools/src/main/shell/lwm2m/lwm2m_cfssl_chain_all_for_test.sh create mode 100644 tools/src/main/shell/lwm2m/lwm2m_cfssl_chain_server_for_test.sh create mode 100644 tools/src/main/shell/server.keygen.sh create mode 100644 transport/coap/pom.xml create mode 100644 transport/coap/src/main/conf/logback.xml create mode 100644 transport/coap/src/main/conf/tb-coap-transport.conf create mode 100644 transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java create mode 100644 transport/coap/src/main/resources/logback.xml create mode 100644 transport/coap/src/main/resources/tb-coap-transport.yml create mode 100644 transport/http/pom.xml create mode 100644 transport/http/src/main/conf/logback.xml create mode 100644 transport/http/src/main/conf/tb-http-transport.conf create mode 100644 transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java create mode 100644 transport/http/src/main/resources/logback.xml create mode 100644 transport/http/src/main/resources/tb-http-transport.yml create mode 100644 transport/lwm2m/pom.xml create mode 100644 transport/lwm2m/src/main/conf/logback.xml create mode 100644 transport/lwm2m/src/main/conf/tb-lwm2m-transport.conf create mode 100644 transport/lwm2m/src/main/java/org/thingsboard/server/lwm2m/ThingsboardLwm2mTransportApplication.java create mode 100644 transport/lwm2m/src/main/resources/logback.xml create mode 100644 transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml create mode 100644 transport/mqtt/pom.xml create mode 100644 transport/mqtt/src/main/conf/logback.xml create mode 100644 transport/mqtt/src/main/conf/tb-mqtt-transport.conf create mode 100644 transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java create mode 100644 transport/mqtt/src/main/resources/logback.xml create mode 100644 transport/mqtt/src/main/resources/tb-mqtt-transport.yml create mode 100644 transport/pom.xml create mode 100644 transport/snmp/pom.xml create mode 100644 transport/snmp/src/main/conf/logback.xml create mode 100644 transport/snmp/src/main/conf/tb-snmp-transport.conf create mode 100644 transport/snmp/src/main/java/org/thingsboard/server/snmp/ThingsboardSnmpTransportApplication.java create mode 100644 transport/snmp/src/main/resources/logback.xml create mode 100644 transport/snmp/src/main/resources/tb-snmp-transport.yml create mode 100644 ui-ngx/.browserslistrc create mode 100644 ui-ngx/.editorconfig create mode 100644 ui-ngx/.gitignore create mode 100644 ui-ngx/.yarnrc create mode 100644 ui-ngx/LICENSE create mode 100644 ui-ngx/angular.json create mode 100644 ui-ngx/e2e/protractor.conf.js create mode 100644 ui-ngx/e2e/src/app.e2e-spec.ts create mode 100644 ui-ngx/e2e/src/app.po.ts create mode 100644 ui-ngx/e2e/tsconfig.e2e.json create mode 100644 ui-ngx/extra-webpack.config.js create mode 100644 ui-ngx/generate-types.js create mode 100644 ui-ngx/package.json create mode 100644 ui-ngx/patches/canvas-gauges+2.1.7.patch create mode 100644 ui-ngx/pom.xml create mode 100644 ui-ngx/proxy.conf.js create mode 100644 ui-ngx/src/app/app-routing.module.ts create mode 100644 ui-ngx/src/app/app.component.html create mode 100644 ui-ngx/src/app/app.component.scss create mode 100644 ui-ngx/src/app/app.component.ts create mode 100644 ui-ngx/src/app/app.module.ts create mode 100644 ui-ngx/src/app/core/api/alarm-data-subscription.ts create mode 100644 ui-ngx/src/app/core/api/alarm-data.service.ts create mode 100644 ui-ngx/src/app/core/api/alias-controller.ts create mode 100644 ui-ngx/src/app/core/api/data-aggregator.ts create mode 100644 ui-ngx/src/app/core/api/entity-data-subscription.ts create mode 100644 ui-ngx/src/app/core/api/entity-data.service.ts create mode 100644 ui-ngx/src/app/core/api/public-api.ts create mode 100644 ui-ngx/src/app/core/api/widget-api.models.ts create mode 100644 ui-ngx/src/app/core/api/widget-subscription.ts create mode 100644 ui-ngx/src/app/core/auth/auth.actions.ts create mode 100644 ui-ngx/src/app/core/auth/auth.models.ts create mode 100644 ui-ngx/src/app/core/auth/auth.reducer.ts create mode 100644 ui-ngx/src/app/core/auth/auth.selectors.ts create mode 100644 ui-ngx/src/app/core/auth/auth.service.spec.ts create mode 100644 ui-ngx/src/app/core/auth/auth.service.ts create mode 100644 ui-ngx/src/app/core/auth/public-api.ts create mode 100644 ui-ngx/src/app/core/core.module.ts create mode 100644 ui-ngx/src/app/core/core.state.ts create mode 100644 ui-ngx/src/app/core/css/css.js create mode 100644 ui-ngx/src/app/core/guards/auth.guard.ts create mode 100644 ui-ngx/src/app/core/guards/confirm-on-exit.guard.ts create mode 100644 ui-ngx/src/app/core/http/admin.service.ts create mode 100644 ui-ngx/src/app/core/http/alarm.service.ts create mode 100644 ui-ngx/src/app/core/http/asset-profile.service.ts create mode 100644 ui-ngx/src/app/core/http/asset.service.ts create mode 100644 ui-ngx/src/app/core/http/attribute.service.ts create mode 100644 ui-ngx/src/app/core/http/audit-log.service.ts create mode 100644 ui-ngx/src/app/core/http/component-descriptor.service.ts create mode 100644 ui-ngx/src/app/core/http/customer.service.ts create mode 100644 ui-ngx/src/app/core/http/dashboard.service.ts create mode 100644 ui-ngx/src/app/core/http/device-profile.service.ts create mode 100644 ui-ngx/src/app/core/http/device.service.ts create mode 100644 ui-ngx/src/app/core/http/edge.service.ts create mode 100644 ui-ngx/src/app/core/http/entities-version-control.service.ts create mode 100644 ui-ngx/src/app/core/http/entity-relation.service.ts create mode 100644 ui-ngx/src/app/core/http/entity-view.service.ts create mode 100644 ui-ngx/src/app/core/http/entity.service.ts create mode 100644 ui-ngx/src/app/core/http/event.service.ts create mode 100644 ui-ngx/src/app/core/http/http-utils.ts create mode 100644 ui-ngx/src/app/core/http/oauth2.service.ts create mode 100644 ui-ngx/src/app/core/http/ota-package.service.ts create mode 100644 ui-ngx/src/app/core/http/public-api.ts create mode 100644 ui-ngx/src/app/core/http/queue.service.ts create mode 100644 ui-ngx/src/app/core/http/resource.service.ts create mode 100644 ui-ngx/src/app/core/http/rule-chain.service.ts create mode 100644 ui-ngx/src/app/core/http/tenant-profile.service.ts create mode 100644 ui-ngx/src/app/core/http/tenant.service.ts create mode 100644 ui-ngx/src/app/core/http/two-factor-authentication.service.ts create mode 100644 ui-ngx/src/app/core/http/ui-settings.service.ts create mode 100644 ui-ngx/src/app/core/http/user.service.ts create mode 100644 ui-ngx/src/app/core/http/widget.service.ts create mode 100644 ui-ngx/src/app/core/interceptors/global-http-interceptor.ts create mode 100644 ui-ngx/src/app/core/interceptors/interceptor-config.ts create mode 100644 ui-ngx/src/app/core/interceptors/interceptor-http-params.ts create mode 100644 ui-ngx/src/app/core/interceptors/load.actions.ts create mode 100644 ui-ngx/src/app/core/interceptors/load.models.ts create mode 100644 ui-ngx/src/app/core/interceptors/load.reducer.ts create mode 100644 ui-ngx/src/app/core/interceptors/load.selectors.ts create mode 100644 ui-ngx/src/app/core/local-storage/local-storage.service.ts create mode 100644 ui-ngx/src/app/core/meta-reducers/debug.reducer.ts create mode 100644 ui-ngx/src/app/core/meta-reducers/init-state-from-local-storage.reducer.ts create mode 100644 ui-ngx/src/app/core/notification/notification.actions.ts create mode 100644 ui-ngx/src/app/core/notification/notification.effects.ts create mode 100644 ui-ngx/src/app/core/notification/notification.models.ts create mode 100644 ui-ngx/src/app/core/notification/notification.reducer.ts create mode 100644 ui-ngx/src/app/core/operator/enterZone.ts create mode 100644 ui-ngx/src/app/core/public-api.ts create mode 100644 ui-ngx/src/app/core/services/broadcast.models.ts create mode 100644 ui-ngx/src/app/core/services/broadcast.service.ts create mode 100644 ui-ngx/src/app/core/services/dashboard-utils.service.ts create mode 100644 ui-ngx/src/app/core/services/dialog.service.ts create mode 100644 ui-ngx/src/app/core/services/dynamic-component-factory.service.ts create mode 100644 ui-ngx/src/app/core/services/help.service.ts create mode 100644 ui-ngx/src/app/core/services/item-buffer.service.ts create mode 100644 ui-ngx/src/app/core/services/material-icons-codepoints.raw create mode 100644 ui-ngx/src/app/core/services/menu.models.ts create mode 100644 ui-ngx/src/app/core/services/menu.service.ts create mode 100644 ui-ngx/src/app/core/services/mobile.service.ts create mode 100644 ui-ngx/src/app/core/services/notification.service.ts create mode 100644 ui-ngx/src/app/core/services/public-api.ts create mode 100644 ui-ngx/src/app/core/services/raf.service.ts create mode 100644 ui-ngx/src/app/core/services/resources.service.ts create mode 100644 ui-ngx/src/app/core/services/script/node-script-test.service.ts create mode 100644 ui-ngx/src/app/core/services/time.service.ts create mode 100644 ui-ngx/src/app/core/services/title.service.ts create mode 100644 ui-ngx/src/app/core/services/utils.service.ts create mode 100644 ui-ngx/src/app/core/services/window.service.ts create mode 100644 ui-ngx/src/app/core/settings/settings.actions.ts create mode 100644 ui-ngx/src/app/core/settings/settings.effects.ts create mode 100644 ui-ngx/src/app/core/settings/settings.models.ts create mode 100644 ui-ngx/src/app/core/settings/settings.reducer.ts create mode 100644 ui-ngx/src/app/core/settings/settings.selectors.ts create mode 100644 ui-ngx/src/app/core/settings/settings.utils.ts create mode 100644 ui-ngx/src/app/core/translate/missing-translate-handler.ts create mode 100644 ui-ngx/src/app/core/translate/translate-default-compiler.ts create mode 100644 ui-ngx/src/app/core/translate/translate-default-parser.ts create mode 100644 ui-ngx/src/app/core/utils.ts create mode 100644 ui-ngx/src/app/core/ws/telemetry-websocket.service.ts create mode 100644 ui-ngx/src/app/modules/common/modules-map.models.ts create mode 100644 ui-ngx/src/app/modules/common/modules-map.ts create mode 100644 ui-ngx/src/app/modules/dashboard/dashboard-pages.module.ts create mode 100644 ui-ngx/src/app/modules/dashboard/dashboard-pages.routing.module.ts create mode 100644 ui-ngx/src/app/modules/dashboard/dashboard-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-table-config.ts create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-image-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-image-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-image-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-state.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/layout.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/layout/manage-dashboard-layouts-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/state-controller.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/state-controller.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/states-component.directive.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/states-controller.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/states/states-controller.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/layout-button.scss create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/details-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/details-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/details-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.html create mode 100644 ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials.component.html create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/device/device-credentials.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-config.ts create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entities-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entities-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table-config.ts create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-text.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-text.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/home-components.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog-csv.component.html create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog-csv.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog-csv.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/import-export/table-columns-assignment.component.html create mode 100644 ui-ngx/src/app/modules/home/components/import-export/table-columns-assignment.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/import-export/table-columns-assignment.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/asset-profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/common/device-profile-common.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-components.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/public-api.ts create mode 100644 ui-ngx/src/app/modules/home/components/queue/queue-form.component.html create mode 100644 ui-ngx/src/app/modules/home/components/queue/queue-form.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/shared-home-components.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/tokens.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-types-version-load.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-types-version.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/repository-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/version-control.component.html create mode 100644 ui-ngx/src/app/modules/home/components/vc/version-control.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/vc/version-control.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/vc/version-control.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-action.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-sample-css.raw create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/custom-sample-js.raw create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/mobile-action-editor.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-key-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-keys.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog-token.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/legend-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/legend.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/legend.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/legend.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarm-filter-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-compass.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-compass.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-gauge.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-linear-gauge.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-linear-gauge.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-radial-gauge.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/analogue-radial-gauge.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/digital-gauge.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/index.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-details-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-filter-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-hierarchy-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-hierarchy-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-table-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-table-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-table-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/entities-table-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/markdown-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/qrcode-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/qrcode-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/simple-card-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/simple-card-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/fixed-color-level.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/fixed-color-level.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/fixed-color-level.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/gauge-highlight.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-integer-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-integer-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-json-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-json-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/datasources-key-autocomplete.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/trip-animation/trip-animation.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/trip-animation/trip-animation.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/trip-animation/trip-animation.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-container.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-container.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/dialogs/home-dialogs.module.ts create mode 100644 ui-ngx/src/app/modules/home/dialogs/home-dialogs.service.ts create mode 100644 ui-ngx/src/app/modules/home/home-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/home.component.html create mode 100644 ui-ngx/src/app/modules/home/home.component.scss create mode 100644 ui-ngx/src/app/modules/home/home.component.ts create mode 100644 ui-ngx/src/app/modules/home/home.module.ts create mode 100644 ui-ngx/src/app/modules/home/menu/menu-link.component.html create mode 100644 ui-ngx/src/app/modules/home/menu/menu-link.component.scss create mode 100644 ui-ngx/src/app/modules/home/menu/menu-link.component.ts create mode 100644 ui-ngx/src/app/modules/home/menu/menu-toggle.component.html create mode 100644 ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss create mode 100644 ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts create mode 100644 ui-ngx/src/app/modules/home/menu/side-menu.component.html create mode 100644 ui-ngx/src/app/modules/home/menu/side-menu.component.scss create mode 100644 ui-ngx/src/app/modules/home/menu/side-menu.component.ts create mode 100644 ui-ngx/src/app/modules/home/models/contact.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/dashboard-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts create mode 100644 ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts create mode 100644 ui-ngx/src/app/modules/home/models/datasource/relation-datasource.ts create mode 100644 ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/entity/entity-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/entity/entity-details-page-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/searchable-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/models/services.map.ts create mode 100644 ui-ngx/src/app/modules/home/models/widget-component.models.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/admin.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/home-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/home-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/queue/queue.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/queue/queue.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/queue/queues-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/repository-admin-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/repository-admin-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/settings-card.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api-usage.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/api-usage/api_usage_json.raw create mode 100644 ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/asset.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/audit-log/audit-log.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customer.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/device/device.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/device/device.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/device.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edge.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-view.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/home-links/home-links.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/home-links/home-links.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/home-pages.models.ts create mode 100644 ui-ngx/src/app/modules/home/pages/home-pages.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profiles/profiles-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profiles/profiles.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/public-api.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/vc/vc.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/public-api.ts create mode 100644 ui-ngx/src/app/modules/login/login-routing.module.ts create mode 100644 ui-ngx/src/app/modules/login/login.module.ts create mode 100644 ui-ngx/src/app/modules/login/pages/login/create-password.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/create-password.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/create-password.component.ts create mode 100644 ui-ngx/src/app/modules/login/pages/login/login.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/login.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/login.component.ts create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts create mode 100644 ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts create mode 100644 ui-ngx/src/app/shared/adapter/custom-datatime-adapter.ts create mode 100644 ui-ngx/src/app/shared/animations/speed-dial-fab.animations.ts create mode 100644 ui-ngx/src/app/shared/components/breadcrumb.component.html create mode 100644 ui-ngx/src/app/shared/components/breadcrumb.component.scss create mode 100644 ui-ngx/src/app/shared/components/breadcrumb.component.ts create mode 100644 ui-ngx/src/app/shared/components/breadcrumb.ts create mode 100644 ui-ngx/src/app/shared/components/button/copy-button.component.html create mode 100644 ui-ngx/src/app/shared/components/button/copy-button.component.scss create mode 100644 ui-ngx/src/app/shared/components/button/copy-button.component.ts create mode 100644 ui-ngx/src/app/shared/components/button/toggle-password.component.html create mode 100644 ui-ngx/src/app/shared/components/button/toggle-password.component.ts create mode 100644 ui-ngx/src/app/shared/components/cheatsheet.component.ts create mode 100644 ui-ngx/src/app/shared/components/circular-progress.directive.ts create mode 100644 ui-ngx/src/app/shared/components/color-input.component.html create mode 100644 ui-ngx/src/app/shared/components/color-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/color-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/contact.component.html create mode 100644 ui-ngx/src/app/shared/components/contact.component.ts create mode 100644 ui-ngx/src/app/shared/components/css.component.html create mode 100644 ui-ngx/src/app/shared/components/css.component.scss create mode 100644 ui-ngx/src/app/shared/components/css.component.ts create mode 100644 ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/dashboard-select-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/dashboard-select-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/dashboard-select.component.html create mode 100644 ui-ngx/src/app/shared/components/dashboard-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/dashboard-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/dialog/todo-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/todo-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts create mode 100644 ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts create mode 100644 ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-keys-list.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/fab-toolbar.component.html create mode 100644 ui-ngx/src/app/shared/components/fab-toolbar.component.scss create mode 100644 ui-ngx/src/app/shared/components/fab-toolbar.component.ts create mode 100644 ui-ngx/src/app/shared/components/file-input.component.html create mode 100644 ui-ngx/src/app/shared/components/file-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/file-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.html create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss create mode 100644 ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts create mode 100644 ui-ngx/src/app/shared/components/footer.component.html create mode 100644 ui-ngx/src/app/shared/components/footer.component.scss create mode 100644 ui-ngx/src/app/shared/components/footer.component.ts create mode 100644 ui-ngx/src/app/shared/components/fullscreen.directive.ts create mode 100644 ui-ngx/src/app/shared/components/help-markdown.component.html create mode 100644 ui-ngx/src/app/shared/components/help-markdown.component.scss create mode 100644 ui-ngx/src/app/shared/components/help-markdown.component.ts create mode 100644 ui-ngx/src/app/shared/components/help-popup.component.html create mode 100644 ui-ngx/src/app/shared/components/help-popup.component.scss create mode 100644 ui-ngx/src/app/shared/components/help-popup.component.ts create mode 100644 ui-ngx/src/app/shared/components/help.component.html create mode 100644 ui-ngx/src/app/shared/components/help.component.ts create mode 100644 ui-ngx/src/app/shared/components/hotkeys.directive.ts create mode 100644 ui-ngx/src/app/shared/components/html.component.html create mode 100644 ui-ngx/src/app/shared/components/html.component.scss create mode 100644 ui-ngx/src/app/shared/components/html.component.ts create mode 100644 ui-ngx/src/app/shared/components/image-input.component.html create mode 100644 ui-ngx/src/app/shared/components/image-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/image-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/js-func.component.html create mode 100644 ui-ngx/src/app/shared/components/js-func.component.scss create mode 100644 ui-ngx/src/app/shared/components/js-func.component.ts create mode 100644 ui-ngx/src/app/shared/components/json-content.component.html create mode 100644 ui-ngx/src/app/shared/components/json-content.component.scss create mode 100644 ui-ngx/src/app/shared/components/json-content.component.ts create mode 100644 ui-ngx/src/app/shared/components/json-form/json-form-component.models.ts create mode 100644 ui-ngx/src/app/shared/components/json-form/json-form.component.html create mode 100644 ui-ngx/src/app/shared/components/json-form/json-form.component.scss create mode 100644 ui-ngx/src/app/shared/components/json-form/json-form.component.ts create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-color.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-css.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-date.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-html.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-icon.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-image.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-javascript.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-json.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-markdown.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form.scss create mode 100644 ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.html create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.scss create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.ts create mode 100644 ui-ngx/src/app/shared/components/json-object-view.component.html create mode 100644 ui-ngx/src/app/shared/components/json-object-view.component.scss create mode 100644 ui-ngx/src/app/shared/components/json-object-view.component.ts create mode 100644 ui-ngx/src/app/shared/components/kv-map.component.html create mode 100644 ui-ngx/src/app/shared/components/kv-map.component.scss create mode 100644 ui-ngx/src/app/shared/components/kv-map.component.ts create mode 100644 ui-ngx/src/app/shared/components/led-light.component.html create mode 100644 ui-ngx/src/app/shared/components/led-light.component.ts create mode 100644 ui-ngx/src/app/shared/components/logo.component.html create mode 100644 ui-ngx/src/app/shared/components/logo.component.scss create mode 100644 ui-ngx/src/app/shared/components/logo.component.ts create mode 100644 ui-ngx/src/app/shared/components/markdown-editor.component.html create mode 100644 ui-ngx/src/app/shared/components/markdown-editor.component.scss create mode 100644 ui-ngx/src/app/shared/components/markdown-editor.component.ts create mode 100644 ui-ngx/src/app/shared/components/markdown.component.html create mode 100644 ui-ngx/src/app/shared/components/markdown.component.ts create mode 100644 ui-ngx/src/app/shared/components/marked-options.service.ts create mode 100644 ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts create mode 100644 ui-ngx/src/app/shared/components/material-icon-select.component.html create mode 100644 ui-ngx/src/app/shared/components/material-icon-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/material-icon-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/message-type-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/multiple-image-input.component.html create mode 100644 ui-ngx/src/app/shared/components/multiple-image-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/multiple-image-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/nav-tree.component.html create mode 100644 ui-ngx/src/app/shared/components/nav-tree.component.scss create mode 100644 ui-ngx/src/app/shared/components/nav-tree.component.ts create mode 100644 ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.scss create mode 100644 ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/page.component.ts create mode 100644 ui-ngx/src/app/shared/components/phone-input.component.html create mode 100644 ui-ngx/src/app/shared/components/phone-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/phone-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/popover.component.scss create mode 100644 ui-ngx/src/app/shared/components/popover.component.ts create mode 100644 ui-ngx/src/app/shared/components/popover.models.ts create mode 100644 ui-ngx/src/app/shared/components/popover.service.ts create mode 100644 ui-ngx/src/app/shared/components/protobuf-content.component.html create mode 100644 ui-ngx/src/app/shared/components/protobuf-content.component.scss create mode 100644 ui-ngx/src/app/shared/components/protobuf-content.component.ts create mode 100644 ui-ngx/src/app/shared/components/public-api.ts create mode 100644 ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss create mode 100644 ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/script-lang.component.html create mode 100644 ui-ngx/src/app/shared/components/script-lang.component.scss create mode 100644 ui-ngx/src/app/shared/components/script-lang.component.ts create mode 100644 ui-ngx/src/app/shared/components/snack-bar-component.html create mode 100644 ui-ngx/src/app/shared/components/snack-bar-component.scss create mode 100644 ui-ngx/src/app/shared/components/socialshare-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/socialshare-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/socialshare-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/tb-anchor.component.ts create mode 100644 ui-ngx/src/app/shared/components/tb-checkbox.component.html create mode 100644 ui-ngx/src/app/shared/components/tb-checkbox.component.ts create mode 100644 ui-ngx/src/app/shared/components/tb-error.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.html create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/datetime.component.html create mode 100644 ui-ngx/src/app/shared/components/time/datetime.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/datetime.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html create mode 100644 ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/quick-time-interval.component.html create mode 100644 ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timezone-select.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timezone-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/toast.directive.ts create mode 100644 ui-ngx/src/app/shared/components/tokens.ts create mode 100644 ui-ngx/src/app/shared/components/user-menu.component.html create mode 100644 ui-ngx/src/app/shared/components/user-menu.component.scss create mode 100644 ui-ngx/src/app/shared/components/user-menu.component.ts create mode 100644 ui-ngx/src/app/shared/components/value-input.component.html create mode 100644 ui-ngx/src/app/shared/components/value-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/value-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss create mode 100644 ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.html create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-select.component.html create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts create mode 100644 ui-ngx/src/app/shared/decorators/enumerable.ts create mode 100644 ui-ngx/src/app/shared/decorators/tb-inject.ts create mode 100644 ui-ngx/src/app/shared/models/ace/ace.models.ts create mode 100644 ui-ngx/src/app/shared/models/ace/completion.models.ts create mode 100644 ui-ngx/src/app/shared/models/ace/service-completion.models.ts create mode 100644 ui-ngx/src/app/shared/models/ace/widget-completion.models.ts create mode 100644 ui-ngx/src/app/shared/models/alarm.models.ts create mode 100644 ui-ngx/src/app/shared/models/alias.models.ts create mode 100644 ui-ngx/src/app/shared/models/asset.models.ts create mode 100644 ui-ngx/src/app/shared/models/audit-log.models.ts create mode 100644 ui-ngx/src/app/shared/models/authority.enum.ts create mode 100644 ui-ngx/src/app/shared/models/base-data.ts create mode 100644 ui-ngx/src/app/shared/models/beautify.models.ts create mode 100644 ui-ngx/src/app/shared/models/component-descriptor.models.ts create mode 100644 ui-ngx/src/app/shared/models/constants.ts create mode 100644 ui-ngx/src/app/shared/models/contact-based.model.ts create mode 100644 ui-ngx/src/app/shared/models/country.models.ts create mode 100644 ui-ngx/src/app/shared/models/customer.model.ts create mode 100644 ui-ngx/src/app/shared/models/dashboard.models.ts create mode 100644 ui-ngx/src/app/shared/models/device.models.ts create mode 100644 ui-ngx/src/app/shared/models/edge.models.ts create mode 100644 ui-ngx/src/app/shared/models/entity-type.models.ts create mode 100644 ui-ngx/src/app/shared/models/entity-view.models.ts create mode 100644 ui-ngx/src/app/shared/models/entity.models.ts create mode 100644 ui-ngx/src/app/shared/models/error.models.ts create mode 100644 ui-ngx/src/app/shared/models/event.models.ts create mode 100644 ui-ngx/src/app/shared/models/id/alarm-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/asset-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/asset-profile-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/audit-log-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/customer-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/dashboard-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/device-credentials-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/device-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/device-profile-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/edge-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/entity-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/entity-view-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/event-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/has-uuid.ts create mode 100644 ui-ngx/src/app/shared/models/id/ota-package-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/public-api.ts create mode 100644 ui-ngx/src/app/shared/models/id/queue-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/rpc-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/rule-chain-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/rule-node-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/tb-resource-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/tenant-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/tenant-profile-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/user-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/widget-type-id.ts create mode 100644 ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts create mode 100644 ui-ngx/src/app/shared/models/login.models.ts create mode 100644 ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts create mode 100644 ui-ngx/src/app/shared/models/material.models.ts create mode 100644 ui-ngx/src/app/shared/models/oauth2.models.ts create mode 100644 ui-ngx/src/app/shared/models/ota-package.models.ts create mode 100644 ui-ngx/src/app/shared/models/page/page-data.ts create mode 100644 ui-ngx/src/app/shared/models/page/page-link.ts create mode 100644 ui-ngx/src/app/shared/models/page/public-api.ts create mode 100644 ui-ngx/src/app/shared/models/page/sort-order.ts create mode 100644 ui-ngx/src/app/shared/models/public-api.ts create mode 100644 ui-ngx/src/app/shared/models/query/query.models.ts create mode 100644 ui-ngx/src/app/shared/models/queue.models.ts create mode 100644 ui-ngx/src/app/shared/models/relation.models.ts create mode 100644 ui-ngx/src/app/shared/models/resource.models.ts create mode 100644 ui-ngx/src/app/shared/models/rpc.models.ts create mode 100644 ui-ngx/src/app/shared/models/rule-chain.models.ts create mode 100644 ui-ngx/src/app/shared/models/rule-node.models.ts create mode 100644 ui-ngx/src/app/shared/models/settings.models.ts create mode 100644 ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts create mode 100644 ui-ngx/src/app/shared/models/tenant.model.ts create mode 100644 ui-ngx/src/app/shared/models/time/time.models.ts create mode 100644 ui-ngx/src/app/shared/models/two-factor-auth.models.ts create mode 100644 ui-ngx/src/app/shared/models/user.model.ts create mode 100644 ui-ngx/src/app/shared/models/vc.models.ts create mode 100644 ui-ngx/src/app/shared/models/widget.models.ts create mode 100644 ui-ngx/src/app/shared/models/widgets-bundle.model.ts create mode 100644 ui-ngx/src/app/shared/models/window-message.model.ts create mode 100644 ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/file-size.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/highlight.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/nospace.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/public-api.ts create mode 100644 ui-ngx/src/app/shared/pipe/safe.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/tbJson.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/truncate.pipe.ts create mode 100644 ui-ngx/src/app/shared/public-api.ts create mode 100644 ui-ngx/src/app/shared/services/custom-paginator-intl.ts create mode 100644 ui-ngx/src/app/shared/shared.module.ts create mode 100644 ui-ngx/src/assets/copy-code-icon.svg create mode 100644 ui-ngx/src/assets/fonts/MaterialIcons-Regular.ttf create mode 100644 ui-ngx/src/assets/fonts/material-icons.css create mode 100644 ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md create mode 100644 ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/create_alarm_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/filter_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/generator_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/log_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/clear_alarm_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/create_alarm_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/filter_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/generator_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/log_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/tbel/transformation_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/transformation_node_script_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_back_first_and_open_state.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_copy_access_token.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_delete_device_confirm.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_display_alert.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_open_state_save_parameters.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_return_previous_state.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_html.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_js.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/alarm_widget.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_latest_values_example.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_timeseries_example.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/latest_values_widget.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/rpc_widget.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/static_widget.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/examples/timeseries_widget.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_action_sources_object.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_existing_code.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_markdown_pattern.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_subscription_object.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/editor/widget_js_type_parameters_object.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_content_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_style_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/alarm/row_style_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_disabled_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_has_children_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_icon_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_opened_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_relation_query_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_text_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/nodes_sort_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_content_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_style_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/entity/row_style_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/flot/point_shape_format_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/flot/ticks_formatter_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/flot/tooltip_value_format_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/markdown/markdown_text_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/qrcode/qrcode_text_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/rpc/convert_value_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_gpio_status_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_value_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_content_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_style_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/timeseries/row_style_fn.md create mode 100644 ui-ngx/src/assets/help/images/rulenode/examples/filter-node.png create mode 100644 ui-ngx/src/assets/help/images/rulenode/examples/switch-node.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/add-rpc-device-alias.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/alarm-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-one-way.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-timeout.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-two-way.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-settings.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/dashboard-create-new-widget-button.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/dashboard-toolbar-entity-aliases.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/external-js-timeseries-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/external-js-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/latest-values-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/static-widget-sample.png create mode 100644 ui-ngx/src/assets/help/images/widget/editor/examples/timeseries-widget-sample.png create mode 100644 ui-ngx/src/assets/jstree/tb32px.png create mode 100644 ui-ngx/src/assets/jstree/tb40px.png create mode 100644 ui-ngx/src/assets/locale/locale.constant-ca_ES.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-cs_CZ.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-da_DK.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-de_DE.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-el_GR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-en_US.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-es_ES.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-fa_IR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-fr_FR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-it_IT.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-ja_JP.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-ka_GE.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-ko_KR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-lv_LV.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-pt_BR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-ro_RO.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-ru_RU.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-sl_SI.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-tr_TR.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-uk_UA.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-zh_CN.json create mode 100644 ui-ngx/src/assets/locale/locale.constant-zh_TW.json create mode 100644 ui-ngx/src/assets/logo_title_white.svg create mode 100644 ui-ngx/src/assets/logo_white.svg create mode 100644 ui-ngx/src/assets/mdi.svg create mode 100644 ui-ngx/src/assets/shadow.png create mode 100644 ui-ngx/src/assets/split.js/grips/horizontal.png create mode 100644 ui-ngx/src/assets/split.js/grips/vertical.png create mode 100644 ui-ngx/src/assets/widget-preview-empty.svg create mode 100644 ui-ngx/src/environments/environment.prod.ts create mode 100644 ui-ngx/src/environments/environment.ts create mode 100644 ui-ngx/src/index.html create mode 100644 ui-ngx/src/karma.conf.js create mode 100644 ui-ngx/src/main.ts create mode 100644 ui-ngx/src/polyfills.ts create mode 100644 ui-ngx/src/scss/animations.scss create mode 100644 ui-ngx/src/scss/constants.scss create mode 100644 ui-ngx/src/scss/fonts.scss create mode 100644 ui-ngx/src/scss/mixins.scss create mode 100644 ui-ngx/src/styles.scss create mode 100644 ui-ngx/src/test.ts create mode 100644 ui-ngx/src/theme.scss create mode 100644 ui-ngx/src/thingsboard.ico create mode 100644 ui-ngx/src/tsconfig.app.json create mode 100644 ui-ngx/src/tsconfig.spec.json create mode 100644 ui-ngx/src/tslint.json create mode 100644 ui-ngx/src/typings/jquery.flot.typings.d.ts create mode 100644 ui-ngx/src/typings/jquery.jstree.typings.d.ts create mode 100644 ui-ngx/src/typings/jquery.typings.d.ts create mode 100644 ui-ngx/src/typings/leaflet-extend-tb.d.ts create mode 100644 ui-ngx/src/typings/leaflet-geoman-extend.d.ts create mode 100644 ui-ngx/src/typings/rawloader.typings.d.ts create mode 100644 ui-ngx/src/typings/split.js.typings.d.ts create mode 100644 ui-ngx/src/zone-flags.ts create mode 100644 ui-ngx/tsconfig.json create mode 100644 ui-ngx/tslint.json create mode 100644 ui-ngx/yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9a4e11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +*.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/ +build/ +tmp_deb_control/ +tmp_rpm_control/ +tmp_sh/ +.gwt/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/Californium.properties +**/Californium3.properties +**/.env +.instance_id +rebuild-docker.sh +*/.run/** +.run/** +.run diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8f142f --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c632287..581e719 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,42 @@ -# thingsboard +# ThingsBoard +[![Join the chat at https://gitter.im/thingsboard/chat](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/thingsboard/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![ThingsBoard Builds Server Status](https://img.shields.io/teamcity/build/e/ThingsBoard_Build?label=TB%20builds%20server&server=https%3A%2F%2Fbuilds.thingsboard.io&logo=data:image/png;base64,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&labelColor=305680)](https://builds.thingsboard.io/viewType.html?buildTypeId=ThingsBoard_Build&guest=1) +ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management. + -## Getting started +## Documentation -To make it easy for you to get started with GitLab, here's a list of recommended next steps. +ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs). -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! +## IoT use cases -## Add your files +[**Smart energy**](https://thingsboard.io/smart-energy/) +[![Smart energy](https://user-images.githubusercontent.com/8308069/152984256-eb48564a-645c-468d-912b-f554b63104a5.gif "Smart energy")](https://thingsboard.io/smart-energy/) -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: +[**Fleet tracking**](https://thingsboard.io/fleet-tracking/) +[![Fleet tracking](https://user-images.githubusercontent.com/8308069/152984528-0054ed55-8b8b-4cda-ba45-02fe95a81222.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/) -``` -cd existing_repo -git remote add origin https://interne.hydatis.fr/gitlab/Yassmine.Mestiri/thingsboard.git -git branch -M main -git push -uf origin main -``` +[**Smart farming**](https://thingsboard.io/smart-farming/) +[![Smart farming](https://user-images.githubusercontent.com/8308069/152984443-a98b7d3d-ff7a-4037-9011-e71e1e6f755f.gif "Smart farming")](https://thingsboard.io/smart-farming/) -## Integrate with your tools +[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) +[![IoT Rule Engine](https://thingsboard.io/images/demo/send-email-rule-chain.gif "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) -- [ ] [Set up project integrations](https://interne.hydatis.fr/gitlab/Yassmine.Mestiri/thingsboard/-/settings/integrations) +[**Smart metering**](https://thingsboard.io/smart-metering/) +[![Smart metering](https://user-images.githubusercontent.com/8308069/31455788-6888a948-aec1-11e7-9819-410e0ba785e0.gif "Smart metering")](https://thingsboard.io/smart-metering/) -## Collaborate with your team +## Getting Started -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +Collect and Visualize your IoT data in minutes by following this [guide](https://thingsboard.io/docs/getting-started-guides/helloworld/). ## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. + - [Community chat](https://gitter.im/thingsboard/chat) + - [Q&A forum](https://groups.google.com/forum/#!forum/thingsboard) + - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard) -## License -For open source projects, say how it is licensed. +## Licenses -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +This project is released under [Apache 2.0 License](./LICENSE). diff --git a/application/.gitignore b/application/.gitignore new file mode 100644 index 0000000..b5246c6 --- /dev/null +++ b/application/.gitignore @@ -0,0 +1,2 @@ +!bin/ +/bin/ diff --git a/application/pom.xml b/application/pom.xml new file mode 100644 index 0000000..597f878 --- /dev/null +++ b/application/pom.xml @@ -0,0 +1,451 @@ + + + 4.0.0 + + org.thingsboard + 3.4.3 + thingsboard + + application + jar + + ThingsBoard Server Application + https://thingsboard.io + Open-source IoT Platform - Device management, data collection, processing and visualization + + + + UTF-8 + ${basedir}/.. + java + false + process-resources + package + thingsboard + ${project.build.directory}/windows + true + ThingsBoard + org.thingsboard.server.ThingsboardServerApplication + + + + + io.netty + netty-transport-native-epoll + ${netty.version} + + linux-x86_64 + + + org.thingsboard.common + actor + + + org.thingsboard.common + util + + + org.thingsboard.rule-engine + rule-engine-api + + + org.thingsboard.common + cluster-api + + + org.thingsboard.common + version-control + + + org.thingsboard.rule-engine + rule-engine-components + + + org.thingsboard.common.transport + transport-api + + + org.thingsboard.common.transport + mqtt + + + org.thingsboard.common.transport + http + + + org.thingsboard.common.transport + coap + + + org.thingsboard.common.transport + lwm2m + + + org.thingsboard.common.transport + snmp + + + org.thingsboard + dao + + + org.thingsboard.common + queue + + + org.thingsboard.common.script + script-api + + + org.thingsboard.common.script + remote-js-client + + + org.thingsboard.common + stats + + + org.thingsboard.common + edge-api + + + org.thingsboard + dao + test-jar + test + + + io.takari.junit + takari-cpsuite + test + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + + + org.cassandraunit + cassandra-unit + + + org.slf4j + slf4j-log4j12 + + + org.hibernate + hibernate-validator + + + test + + + org.thingsboard + ui-ngx + ${project.version} + runtime + + + org.springframework.integration + spring-integration-redis + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.security + spring-security-oauth2-client + + + org.springframework.security + spring-security-oauth2-jose + + + io.jsonwebtoken + jjwt + + + org.freemarker + freemarker + + + commons-io + commons-io + + + org.apache.commons + commons-csv + + + org.springframework + spring-context-support + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + com.sun.mail + javax.mail + + + com.twilio.sdk + twilio + + + com.amazonaws + aws-java-sdk-sns + + + org.apache.curator + curator-recipes + + + com.google.protobuf + protobuf-java + + + io.netty + netty-all + + + io.netty + netty-tcnative-boringssl-static + + + io.grpc + grpc-netty-shaded + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + org.opensmpp + opensmpp-core + + + org.thingsboard + springfox-boot-starter + + + com.sun.winsw + winsw + bin + exe + provided + + + org.thingsboard + tools + test + + + org.thingsboard + rest-client + test + + + org.springframework.security + spring-security-test + test + + + com.jayway.jsonpath + json-path + + + com.jayway.jsonpath + json-path-assert + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.awaitility + awaitility + test + + + org.dbunit + dbunit + test + + + com.github.springtestdbunit + spring-test-dbunit + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + jdbc + test + + + org.javadelight + delight-nashorn-sandbox + + + org.passay + passay + + + com.github.ua-parser + uap-java + + + org.java-websocket + Java-WebSocket + test + + + org.jboss.aerogear + aerogear-otp-java + + + + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + true + + thingsboard.yml + + + + ${project.basedir}/src/main/resources + false + + thingsboard.yml + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + thingsboard + + + **/nosql/*Test.java + + + **/*Test.java + **/*TestSuite.java + + + + + org.apache.maven.plugins + maven-resources-plugin + + + org.apache.maven.plugins + maven-dependency-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + com.places.Main + + + + org.thingsboard + gradle-maven-plugin + + + org.apache.maven.plugins + maven-assembly-plugin + + + org.apache.maven.plugins + maven-install-plugin + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + org.codehaus.mojo + build-helper-maven-plugin + + + + + + jenkins + Jenkins Repository + https://repo.jenkins-ci.org/releases + + false + + + + diff --git a/application/src/main/conf/logback.xml b/application/src/main/conf/logback.xml new file mode 100644 index 0000000..284af71 --- /dev/null +++ b/application/src/main/conf/logback.xml @@ -0,0 +1,46 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/application/src/main/conf/thingsboard.conf b/application/src/main/conf/thingsboard.conf new file mode 100644 index 0000000..0ac9eb1 --- /dev/null +++ b/application/src/main/conf/thingsboard.conf @@ -0,0 +1,24 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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=@pkg.platform@ -Dinstall.data_dir=@pkg.installFolder@/data" +export JAVA_OPTS="$JAVA_OPTS -Xlog:gc*,heap*,age*,safepoint=debug:file=@pkg.logFolder@/gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf,${pkg.installFolder}/extensions +export SQL_DATA_FOLDER=${pkg.installFolder}/data/sql diff --git a/application/src/main/data/certs/azure/BaltimoreCyberTrustRoot.crt.pem b/application/src/main/data/certs/azure/BaltimoreCyberTrustRoot.crt.pem new file mode 100644 index 0000000..2bd16eb --- /dev/null +++ b/application/src/main/data/certs/azure/BaltimoreCyberTrustRoot.crt.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ +RTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYD +VQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoX +DTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9y +ZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVy +VHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKr +mD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjr +IZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeK +mpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSu +XmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZy +dc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/ye +jl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1 +BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3 +DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT92 +9hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3Wgx +jkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0 +Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhz +ksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLS +R9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp +-----END CERTIFICATE----- + diff --git a/application/src/main/data/json/demo/dashboards/firmware.json b/application/src/main/data/json/demo/dashboards/firmware.json new file mode 100644 index 0000000..779f2cb --- /dev/null +++ b/application/src/main/data/json/demo/dashboards/firmware.json @@ -0,0 +1,2492 @@ +{ + "title": "Firmware", + "image": null, + "configuration": { + "description": "", + "widgets": { + "cd03188e-cd9d-9601-fd57-da4cb95fc016": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": false, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "8fdb88d0-50ac-2232-fdb7-69c30c16544e", + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current FW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current FW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target FW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target FW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_ts", + "type": "timeseries", + "label": "Target FW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_checksum", + "type": "attribute", + "label": "fw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_url", + "type": "attribute", + "label": "fw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.4204673738685043, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History firmware update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_firmware_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit firmware", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit firmware {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n firmwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n firmwareId: vm.entity.firmwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.firmwareId = formValues.firmwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download firmware", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceFirmware();\n\nfunction getDeviceFirmware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(data.firmwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.firmwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_checksum');\nvar checksum = data.data[0][1];\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Firmware checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Firmware direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "cd03188e-cd9d-9601-fd57-da4cb95fc016" + }, + "100b756c-0082-6505-3ae1-3603e6deea48": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 8, + "sizeY": 6.5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "19f41c21-d9af-e666-8f50-e1748778f955", + "filterId": null, + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current firmware title", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.5978079905579401, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current firmware version", + "color": "#4caf50", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.027392025058568192, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target firmware title", + "color": "#f44336", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.9496350796287059, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target firmware version", + "color": "#ffc107", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.6734152252264187, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#607d8b", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.2983399718643074, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "function capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\nif (value !== '') {\n return capitalize(value);\n}\nreturn value;" + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "timewindowMs": 2592000000, + "quickInterval": "CURRENT_DAY", + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 10, + "enableSearch": true, + "enableStickyHeader": true, + "enableStickyAction": true + }, + "title": "Firmware history", + "dropShadow": false, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "displayTimewindow": true, + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "100b756c-0082-6505-3ae1-3603e6deea48" + }, + "17543c57-af4a-2c1e-bf12-53a7b46791e6": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "waitingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${waitingDevicesNumber:0}\n
\n
\n Device Waiting\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_waiting", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "4d9a77a2-f0a5-690c-a83b-b0e940be788c" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "17543c57-af4a-2c1e-bf12-53a7b46791e6" + }, + "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${updatingDevicesNumber:0}\n
\n
\n Device Updating\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_updating", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "57d39904-2350-b29b-78ed-56b8268814cb" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6" + }, + "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "6044e198-df64-cd76-f339-696f220c4943", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatedDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${updatedDevicesNumber:0}\n
\n
\n Device Updated\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_updated", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "d787c212-8c56-34f0-349a-5aae2ffd1eae" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81" + }, + "77b10144-b904-edd5-8c7c-8fb75616c6d8": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n
\n \n \n \n
\n
\n ${updatingDevicesNumber:0}\n
\n \n
\n Device Failed\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .container-svg {\n height: 40px;\n width: 40px;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .container-svg {\n height: 28px;\n width: 28px;\n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_error", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "0b3d2887-9929-84d5-3795-0763dca15cba" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "77b10144-b904-edd5-8c7c-8fb75616c6d8" + }, + "21be08bb-ec90-f760-ad6f-e7678f12c401": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current FW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current FW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target FW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target FW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_ts", + "type": "timeseries", + "label": "Target FW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_checksum", + "type": "attribute", + "label": "fw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_url", + "type": "attribute", + "label": "fw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.4204673738685043, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History firmware update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_firmware_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit firmware", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit firmware {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n firmwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n firmwareId: vm.entity.firmwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.firmwareId = formValues.firmwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download firmware", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceFirmware();\n\nfunction getDeviceFirmware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(data.firmwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.firmwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_checksum');\nvar checksum = data.data[0][1];\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Firmware checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Firmware direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "21be08bb-ec90-f760-ad6f-e7678f12c401" + }, + "e8280043-d3dc-7acb-c2ff-a4522972ff91": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current FW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current FW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target FW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target FW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_ts", + "type": "timeseries", + "label": "Target FW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_checksum", + "type": "attribute", + "label": "fw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_url", + "type": "attribute", + "label": "fw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.4204673738685043, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History firmware update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_firmware_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit firmware", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit firmware {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n firmwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n firmwareId: vm.entity.firmwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.firmwareId = formValues.firmwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download firmware", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceFirmware();\n\nfunction getDeviceFirmware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(data.firmwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.firmwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_checksum');\nvar checksum = data.data[0][1];\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Firmware checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Firmware direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "e8280043-d3dc-7acb-c2ff-a4522972ff91" + }, + "3624013b-378c-f110-5eba-ae95c25a4dcc": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current FW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current FW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target FW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target FW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_ts", + "type": "timeseries", + "label": "Target FW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_checksum", + "type": "attribute", + "label": "fw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_url", + "type": "attribute", + "label": "fw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.4204673738685043, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History firmware update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_firmware_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit firmware", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit firmware {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n firmwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n firmwareId: vm.entity.firmwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.firmwareId = formValues.firmwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download firmware", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceFirmware();\n\nfunction getDeviceFirmware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(data.firmwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.firmwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_checksum');\nvar checksum = data.data[0][1];\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Firmware checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Firmware direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "3624013b-378c-f110-5eba-ae95c25a4dcc" + }, + "d2d13e0d-4e71-889f-9343-ad2f0af9f176": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "6044e198-df64-cd76-f339-696f220c4943", + "dataKeys": [ + { + "name": "current_fw_title", + "type": "timeseries", + "label": "Current FW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_fw_version", + "type": "timeseries", + "label": "Current FW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_title", + "type": "timeseries", + "label": "Target FW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_version", + "type": "timeseries", + "label": "Target FW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_fw_ts", + "type": "timeseries", + "label": "Target FW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_checksum", + "type": "attribute", + "label": "fw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "fw_url", + "type": "attribute", + "label": "fw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.4204673738685043, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History firmware update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_firmware_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit firmware", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit firmware {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n firmwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n firmwareId: vm.entity.firmwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.firmwareId = formValues.firmwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download firmware", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceFirmware();\n\nfunction getDeviceFirmware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(data.firmwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.firmwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.firmwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_checksum');\nvar checksum = data.data[0][1];\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Firmware checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'fw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Firmware direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not firmware set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "d2d13e0d-4e71-889f-9343-ad2f0af9f176" + } + }, + "states": { + "default": { + "name": "Device list", + "root": true, + "layouts": { + "main": { + "widgets": { + "cd03188e-cd9d-9601-fd57-da4cb95fc016": { + "sizeX": 19, + "sizeY": 12, + "row": 0, + "col": 0 + }, + "17543c57-af4a-2c1e-bf12-53a7b46791e6": { + "sizeX": 5, + "sizeY": 3, + "row": 0, + "col": 19 + }, + "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6": { + "sizeX": 5, + "sizeY": 3, + "row": 3, + "col": 19 + }, + "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81": { + "sizeX": 5, + "sizeY": 3, + "row": 9, + "col": 19 + }, + "77b10144-b904-edd5-8c7c-8fb75616c6d8": { + "sizeX": 5, + "sizeY": 3, + "row": 6, + "col": 19 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 12, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70 + } + } + } + }, + "device_firmware_history": { + "name": "Firmware history: ${entityName}", + "root": false, + "layouts": { + "main": { + "widgets": { + "100b756c-0082-6505-3ae1-3603e6deea48": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_waiting": { + "name": "Device waiting", + "root": false, + "layouts": { + "main": { + "widgets": { + "21be08bb-ec90-f760-ad6f-e7678f12c401": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_updating": { + "name": "Device updating", + "root": false, + "layouts": { + "main": { + "widgets": { + "e8280043-d3dc-7acb-c2ff-a4522972ff91": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_updated": { + "name": "Device updated", + "root": false, + "layouts": { + "main": { + "widgets": { + "d2d13e0d-4e71-889f-9343-ad2f0af9f176": { + "sizeX": 27, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_error": { + "name": "Device failed", + "root": false, + "layouts": { + "main": { + "widgets": { + "3624013b-378c-f110-5eba-ae95c25a4dcc": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + } + }, + "entityAliases": { + "639da5b4-31f0-0151-6282-c37a3897b7e8": { + "id": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "alias": "All devices", + "filter": { + "type": "entityType", + "resolveMultiple": true, + "entityType": "DEVICE" + } + }, + "19f41c21-d9af-e666-8f50-e1748778f955": { + "id": "19f41c21-d9af-e666-8f50-e1748778f955", + "alias": "State entity", + "filter": { + "type": "stateEntity", + "resolveMultiple": false, + "stateEntityParamName": null, + "defaultStateEntity": null + } + } + }, + "filters": { + "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e": { + "id": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "filter": "WaitingDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "fw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "QUEUED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "579f0468-9ce9-7e3e-b34c-88dd3de59897": { + "id": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "filter": "UpdatingDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "fw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "OR", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "INITIATED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "fw_state equel", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "DOWNLOADING", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "fw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "DOWNLOADED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "fw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "VERIFIED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "fw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "UPDATING", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "fw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + } + ], + "type": "COMPLEX" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "6044e198-df64-cd76-f339-696f220c4943": { + "id": "6044e198-df64-cd76-f339-696f220c4943", + "filter": "UpdetedDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "fw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "UPDATED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "bdbc6ea1-95a7-3912-341a-58dc7704a00f": { + "id": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "filter": "FailedDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "fw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "FAILED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "8fdb88d0-50ac-2232-fdb7-69c30c16544e": { + "id": "8fdb88d0-50ac-2232-fdb7-69c30c16544e", + "filter": "DeviceSearch", + "keyFilters": [ + { + "key": { + "type": "ENTITY_FIELD", + "key": "name" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "CONTAINS", + "value": { + "defaultValue": "" + }, + "ignoreCase": true, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "Device name", + "autogeneratedLabel": false, + "order": 0 + } + } + ] + } + ], + "editable": true + } + }, + "timewindow": { + "displayValue": "", + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY" + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1618998609030, + "endTimeMs": 1619085009030 + }, + "quickInterval": "CURRENT_DAY" + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "settings": { + "stateControllerId": "entity", + "showTitle": false, + "showDashboardsSelect": false, + "showEntitiesSelect": false, + "showDashboardTimewindow": true, + "showDashboardExport": false, + "toolbarAlwaysOpen": true, + "titleColor": "rgba(0,0,0,0.870588)", + "showFilters": true, + "showDashboardLogo": false, + "dashboardLogoUrl": null, + "showUpdateDashboardImage": false + } + }, + "name": "Firmware" +} \ No newline at end of file diff --git a/application/src/main/data/json/demo/dashboards/gateways.json b/application/src/main/data/json/demo/dashboards/gateways.json new file mode 100644 index 0000000..0a18b10 --- /dev/null +++ b/application/src/main/data/json/demo/dashboards/gateways.json @@ -0,0 +1,1267 @@ +{ + "title": "Gateways", + "configuration": { + "widgets": { + "94715984-ae74-76e4-20b7-2f956b01ed80": { + "isSystemType": true, + "bundleAlias": "entity_admin_widgets", + "typeAlias": "device_admin_table", + "type": "latest", + "title": "New widget", + "sizeX": 24, + "sizeY": 12, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "entitiesTitle": "List of gateways", + "enableSelectColumnDisplay": true, + "displayEntityLabel": false, + "entityNameColumnTitle": "Gateway Name" + }, + "title": "Devices gateway table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "active", + "type": "attribute", + "label": "Active", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "cellContentFunction": "value = '⬤';\nreturn value;", + "cellStyleFunction": "var color;\nif (value == 'false') {\n color = '#EB5757';\n} else {\n color = '#27AE60';\n}\nreturn {\n color: color,\n fontSize: '18px'\n};" + }, + "_hash": 0.3646047595211721 + }, + { + "name": "eventsSent", + "type": "timeseries", + "label": "Sent", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.7235710720767985 + }, + { + "name": "eventsProduced", + "type": "timeseries", + "label": "Events", + "color": "#f44336", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.5085933386303254 + }, + { + "name": "LOGS", + "type": "timeseries", + "label": "Latest log", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.3504240371585048, + "postFuncBody": "if(value) {\n return value.substring(0, 31) + \"...\";\n} else {\n return '';\n}" + }, + { + "name": "RemoteLoggingLevel", + "type": "attribute", + "label": "Log level", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.9785994222542516 + } + ], + "entityAliasId": "3e0f533a-0db1-3292-184f-06e73535061a" + } + ], + "showTitleIcon": true, + "titleIcon": "list", + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "List device", + "widgetStyle": {}, + "displayTimewindow": true, + "actions": { + "headerButton": [ + { + "name": "Add device", + "icon": "add", + "type": "customPretty", + "customHtml": "
\n \n

Add device

\n \n \n
\n \n \n
\n
\n
\n \n Device name\n \n \n Device name is required.\n \n \n
\n \n Latitude\n \n \n \n Longitude\n \n \n
\n \n Label\n \n \n
\n
\n
\n \n \n \n
\n
\n", + "customCss": "", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\n\nopenAddDeviceDialog();\n\nfunction openAddDeviceDialog() {\n customDialog.customDialog(htmlTemplate, AddDeviceDialogController).subscribe();\n}\n\nfunction AddDeviceDialogController(instance) {\n let vm = instance;\n \n vm.addDeviceFormGroup = vm.fb.group({\n deviceName: ['', [vm.validators.required]],\n deviceLabel: [''],\n attributes: vm.fb.group({\n latitude: [null],\n longitude: [null]\n }) \n });\n \n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n \n vm.save = function() {\n vm.addDeviceFormGroup.markAsPristine();\n let device = {\n additionalInfo: {gateway: true},\n name: vm.addDeviceFormGroup.get('deviceName').value,\n type: 'gateway',\n label: vm.addDeviceFormGroup.get('deviceLabel').value\n };\n deviceService.saveDevice(device).subscribe(\n function (device) {\n saveAttributes(device.id).subscribe(\n function () {\n widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n }\n );\n };\n \n function saveAttributes(entityId) {\n let attributes = vm.addDeviceFormGroup.get('attributes').value;\n let attributesArray = [];\n for (let key in attributes) {\n attributesArray.push({key: key, value: attributes[key]});\n }\n if (attributesArray.length > 0) {\n return attributeService.saveEntityAttributes(entityId, \"SERVER_SCOPE\", attributesArray);\n } else {\n return widgetContext.rxjs.of([]);\n }\n }\n}\n", + "customResources": [], + "id": "70837a9d-c3de-a9a7-03c5-dccd14998758" + } + ], + "actionCellButton": [ + { + "id": "78845501-234e-a452-6819-82b5b776e99f", + "name": "Configuration", + "icon": "settings", + "type": "openDashboardState", + "targetDashboardStateId": "__entityname__config", + "openRightLayout": false, + "setEntityId": true + }, + { + "id": "f6ffdba8-e40f-2b8d-851b-f5ecaf18606b", + "name": "Graphs", + "icon": "show_chart", + "type": "openDashboardState", + "targetDashboardStateId": "__entityname_grafic", + "setEntityId": true + }, + { + "name": "Edit device", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit device

\n \n \n
\n \n \n
\n
\n
\n \n Device name\n \n \n Device name is required.\n \n \n
\n \n Latitude\n \n \n \n Longitude\n \n \n
\n \n Label\n \n \n
\n
\n
\n \n \n \n
\n
\n", + "customCss": "/*=======================================================================*/\n/*========== There are two examples: for edit and add entity ==========*/\n/*=======================================================================*/\n/*======================== Edit entity example ========================*/\n/*=======================================================================*/\n/*\n.edit-entity-form md-input-container {\n padding-right: 10px;\n}\n\n.edit-entity-form .boolean-value-input {\n padding-left: 5px;\n}\n\n.edit-entity-form .boolean-value-input .checkbox-label {\n margin-bottom: 8px;\n color: rgba(0,0,0,0.54);\n font-size: 12px;\n}\n\n.relations-list .header {\n padding-right: 5px;\n padding-bottom: 5px;\n padding-left: 5px;\n}\n\n.relations-list .header .cell {\n padding-right: 5px;\n padding-left: 5px;\n font-size: 12px;\n font-weight: 700;\n color: rgba(0, 0, 0, .54);\n white-space: nowrap;\n}\n\n.relations-list .body {\n padding-right: 5px;\n padding-bottom: 15px;\n padding-left: 5px;\n}\n\n.relations-list .body .row {\n padding-top: 5px;\n}\n\n.relations-list .body .cell {\n padding-right: 5px;\n padding-left: 5px;\n}\n\n.relations-list .body md-autocomplete-wrap md-input-container {\n height: 30px;\n}\n\n.relations-list .body .md-button {\n margin: 0;\n}\n\n.relations-list.old-relations tb-entity-select tb-entity-autocomplete button {\n display: none;\n} \n*/\n/*========================================================================*/\n/*========================= Add entity example =========================*/\n/*========================================================================*/\n/*\n.add-entity-form md-input-container {\n padding-right: 10px;\n}\n\n.add-entity-form .boolean-value-input {\n padding-left: 5px;\n}\n\n.add-entity-form .boolean-value-input .checkbox-label {\n margin-bottom: 8px;\n color: rgba(0,0,0,0.54);\n font-size: 12px;\n}\n\n.relations-list .header {\n padding-right: 5px;\n padding-bottom: 5px;\n padding-left: 5px;\n}\n\n.relations-list .header .cell {\n padding-right: 5px;\n padding-left: 5px;\n font-size: 12px;\n font-weight: 700;\n color: rgba(0, 0, 0, .54);\n white-space: nowrap;\n}\n\n.relations-list .body {\n padding-right: 5px;\n padding-bottom: 15px;\n padding-left: 5px;\n}\n\n.relations-list .body .row {\n padding-top: 5px;\n}\n\n.relations-list .body .cell {\n padding-right: 5px;\n padding-left: 5px;\n}\n\n.relations-list .body md-autocomplete-wrap md-input-container {\n height: 30px;\n}\n\n.relations-list .body .md-button {\n margin: 0;\n}\n*/\n", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\n\nopenEditDeviceDialog();\n\nfunction openEditDeviceDialog() {\n customDialog.customDialog(htmlTemplate, EditDeviceDialogController).subscribe();\n}\n\nfunction EditDeviceDialogController(instance) {\n let vm = instance;\n \n vm.device = null;\n vm.attributes = {};\n \n vm.editDeviceFormGroup = vm.fb.group({\n deviceName: ['', [vm.validators.required]],\n deviceLabel: [''],\n attributes: vm.fb.group({\n latitude: [null],\n longitude: [null]\n }) \n });\n \n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n \n vm.save = function() {\n vm.editDeviceFormGroup.markAsPristine();\n vm.device.name = vm.editDeviceFormGroup.get('deviceName').value;\n vm.device.label = vm.editDeviceFormGroup.get('deviceLabel').value;\n deviceService.saveDevice(vm.device).subscribe(\n function () {\n saveAttributes().subscribe(\n function () {\n widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n }\n );\n };\n \n getEntityInfo();\n \n function getEntityInfo() {\n deviceService.getDevice(entityId.id).subscribe(\n function (device) {\n attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE',\n ['latitude', 'longitude']).subscribe(\n function (attributes) {\n for (let i = 0; i < attributes.length; i++) {\n vm.attributes[attributes[i].key] = attributes[i].value; \n }\n vm.device = device;\n vm.editDeviceFormGroup.patchValue(\n {\n deviceName: vm.device.name,\n deviceLabel: vm.device.label,\n attributes: {\n latitude: vm.attributes.latitude,\n longitude: vm.attributes.longitude\n }\n }, {emitEvent: false}\n );\n } \n );\n }\n ); \n }\n \n function saveAttributes() {\n let attributes = vm.editDeviceFormGroup.get('attributes').value;\n let attributesArray = [];\n for (let key in attributes) {\n attributesArray.push({key: key, value: attributes[key]});\n }\n if (attributesArray.length > 0) {\n return attributeService.saveEntityAttributes(entityId, 'SERVER_SCOPE', attributesArray);\n } else {\n return widgetContext.rxjs.of([]);\n }\n }\n}\n", + "customResources": [], + "id": "242671f3-76c6-6982-7acc-6f12addf0ccc" + }, + { + "name": "Delete device", + "icon": "delete", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenDeleteDeviceDialog();\n\nfunction openDeleteDeviceDialog() {\n let title = \"Are you sure you want to delete the device \" + entityName + \"?\";\n let content = \"Be careful, after the confirmation, the device and all related data will become unrecoverable!\";\n dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe(\n function (result) {\n if (result) {\n deleteDevice();\n }\n }\n );\n}\n\nfunction deleteDevice() {\n deviceService.deleteDevice(entityId.id).subscribe(\n function () {\n widgetContext.updateAliases();\n }\n );\n}\n", + "id": "862ec2b7-fbcf-376e-f85f-b77c07f36efa" + } + ], + "rowClick": [ + { + "id": "ad5fc7e1-5e60-e056-6940-a75a383466a1", + "name": "to_entityname__config", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "__entityname__config", + "setEntityId": true, + "stateEntityParamName": "" + } + ] + } + }, + "id": "94715984-ae74-76e4-20b7-2f956b01ed80" + }, + "eadabbc7-519e-76fc-ba10-b3fe8c18da10": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "sizeX": 14, + "sizeY": 13, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "LOGS", + "type": "timeseries", + "label": "LOGS", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.3496649158709739, + "postFuncBody": "return value.replace(/ - (.*) - \\[/gi, ' - $1 - [');" + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 2592000000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 10 + }, + "title": "Debug events (logs)", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "eadabbc7-519e-76fc-ba10-b3fe8c18da10" + }, + "f928afc4-30d1-8d0c-e3cf-777f9f9d1155": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 17, + "sizeY": 4, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "opcuaEventsProduced", + "type": "timeseries", + "label": "opcuaEventsProduced", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.1477920581839779 + }, + { + "name": "opcuaEventsSent", + "type": "timeseries", + "label": "opcuaEventsSent", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.6500957113784758 + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 120000 + }, + "aggregation": { + "type": "NONE", + "limit": 25000 + }, + "hideInterval": false, + "hideAggregation": false + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Real time information", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "legendConfig": { + "direction": "column", + "position": "right", + "showMin": true, + "showMax": true, + "showAvg": true, + "showTotal": true + }, + "actions": {} + }, + "id": "f928afc4-30d1-8d0c-e3cf-777f9f9d1155" + }, + "2a95b473-042d-59d0-2da2-40d0cccb6c8a": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "sizeX": 7, + "sizeY": 7, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "eventsSent", + "type": "timeseries", + "label": "Events", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.8156044798125357 + }, + { + "name": "eventsProduced", + "type": "timeseries", + "label": "Produced", + "color": "#4caf50", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.6538259344015449 + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 604800000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 6, + "hideEmptyLines": true + }, + "title": "Total Messages", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": false, + "showMax": false, + "showAvg": true, + "showTotal": false + } + }, + "id": "2a95b473-042d-59d0-2da2-40d0cccb6c8a" + }, + "aaa69366-aacc-9028-65aa-645c0f8533ec": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 17, + "sizeY": 4, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "eventsSent", + "type": "timeseries", + "label": "eventsSent", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.41414001784591314 + }, + { + "name": "eventsProduced", + "type": "timeseries", + "label": "eventsProduced", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.7819101846284422 + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "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 + }, + "stack": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "History information", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showLegend": true, + "legendConfig": { + "direction": "column", + "position": "right", + "showMin": true, + "showMax": true, + "showAvg": true, + "showTotal": true + }, + "actions": {} + }, + "id": "aaa69366-aacc-9028-65aa-645c0f8533ec" + }, + "ce5c7d01-a3ef-5cf0-4578-8505135c23a0": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 17, + "sizeY": 4, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "bleEventsProduced", + "type": "timeseries", + "label": "bleEventsProduced", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.5625165504526104 + }, + { + "name": "bleEventsSent", + "type": "timeseries", + "label": "bleEventsSent", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "tooltipValueFormatter": "", + "showSeparateAxis": false, + "axisTitle": "", + "axisPosition": "left", + "axisTicksFormatter": "", + "comparisonSettings": { + "showValuesForComparison": true, + "comparisonValuesLabel": "", + "color": "" + } + }, + "_hash": 0.6817950080745288 + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "interval": 5000, + "timewindowMs": 120000 + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Real time information", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "legendConfig": { + "direction": "column", + "position": "right", + "showMin": true, + "showMax": true, + "showAvg": true, + "showTotal": true + }, + "actions": {} + }, + "id": "ce5c7d01-a3ef-5cf0-4578-8505135c23a0" + }, + "466f046d-6005-a168-b107-60fcb2469cd5": { + "isSystemType": true, + "bundleAlias": "gateway_widgets", + "typeAlias": "attributes_card", + "type": "latest", + "title": "New widget", + "sizeX": 7, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": true, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "eventsTitle": "Gateway Events Form", + "eventsReg": [ + "EventsProduced", + "EventsSent" + ] + }, + "title": "Gateway events", + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": true, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showLegend": false, + "actions": {} + }, + "id": "466f046d-6005-a168-b107-60fcb2469cd5" + }, + "8fc32225-164f-3258-73f7-e6b6d959cf0b": { + "isSystemType": true, + "bundleAlias": "gateway_widgets", + "typeAlias": "config_form_latest", + "type": "latest", + "title": "New widget", + "sizeX": 10, + "sizeY": 9, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": true, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "gatewayTitle": "Gateway configuration (Single device)", + "readOnly": false + }, + "title": "New Gateway configuration (Single device)", + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": true, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "displayTimewindow": true, + "showLegend": false, + "actions": {} + }, + "id": "8fc32225-164f-3258-73f7-e6b6d959cf0b" + }, + "063fc179-c9fd-f952-e714-f24e9c43c05c": { + "isSystemType": true, + "bundleAlias": "control_widgets", + "typeAlias": "rpcbutton", + "type": "rpc", + "title": "New widget", + "sizeX": 4, + "sizeY": 2, + "config": { + "targetDeviceAliases": [], + "showTitle": false, + "backgroundColor": "#e6e7e8", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "requestTimeout": 5000, + "oneWayElseTwoWay": true, + "styleButton": { + "isRaised": true, + "isPrimary": false + }, + "methodParams": "{}", + "methodName": "gateway_reboot", + "buttonText": "GATEWAY REBOOT" + }, + "title": "New RPC Button", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": {}, + "datasources": [], + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true, + "targetDeviceAliasIds": [ + "b2487e75-2fa4-f211-142c-434dfd50c70c" + ] + }, + "id": "063fc179-c9fd-f952-e714-f24e9c43c05c" + }, + "3c2134cc-27a0-93e1-dbe1-2fa7c1ce16b7": { + "isSystemType": true, + "bundleAlias": "control_widgets", + "typeAlias": "rpcbutton", + "type": "rpc", + "title": "New widget", + "sizeX": 4, + "sizeY": 2, + "config": { + "targetDeviceAliases": [], + "showTitle": false, + "backgroundColor": "#e6e7e8", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "requestTimeout": 5000, + "oneWayElseTwoWay": true, + "styleButton": { + "isRaised": true, + "isPrimary": false + }, + "methodName": "gateway_restart", + "methodParams": "{}", + "buttonText": "GATEWAY RESTART" + }, + "title": "New RPC Button", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": {}, + "datasources": [], + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true, + "targetDeviceAliasIds": [ + "b2487e75-2fa4-f211-142c-434dfd50c70c" + ] + }, + "id": "3c2134cc-27a0-93e1-dbe1-2fa7c1ce16b7" + }, + "6770b6ba-eff8-df05-75f8-c1f9326d4842": { + "isSystemType": true, + "bundleAlias": "input_widgets", + "typeAlias": "markers_placement_openstreetmap", + "type": "latest", + "title": "New widget", + "sizeX": 6, + "sizeY": 4, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "latitude", + "type": "attribute", + "label": "latitude", + "color": "#2196f3", + "settings": {}, + "_hash": 0.9743324774725604 + }, + { + "name": "longitude", + "type": "attribute", + "label": "longitude", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5530093635101525 + } + ], + "entityAliasId": "b2487e75-2fa4-f211-142c-434dfd50c70c" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "fitMapBounds": true, + "latKeyName": "latitude", + "lngKeyName": "longitude", + "showLabel": true, + "label": "${entityName}", + "tooltipPattern": "${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}

Delete", + "markerImageSize": 34, + "useColorFunction": false, + "markerImages": [], + "useMarkerImageFunction": false, + "color": "#fe7569", + "mapProvider": "OpenStreetMap.Mapnik", + "showTooltip": true, + "autocloseTooltip": true, + "defaultCenterPosition": [ + 0, + 0 + ], + "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "showTooltipAction": "click", + "polygonKeyName": "coordinates", + "polygonOpacity": 0.5, + "polygonStrokeOpacity": 1, + "polygonStrokeWeight": 1, + "zoomOnClick": true, + "showCoverageOnHover": true, + "animate": true, + "maxClusterRadius": 80, + "removeOutsideVisibleBounds": true, + "defaultZoomLevel": 5 + }, + "title": "Gateway Location", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "widgetStyle": {}, + "actions": { + "tooltipAction": [ + { + "id": "54c293c4-9ca6-e34f-dc6a-0271944c1c66", + "name": "delete", + "icon": "more_horiz", + "type": "custom", + "customFunction": "var $rootScope = widgetContext.$scope.$injector.get('$rootScope');\nvar entityDatasource = widgetContext.map.subscription.datasources.filter(\n function(entity) {\n return entity.entityId === entityId.id\n });\n\nwidgetContext.map.saveMarkerLocation(entityDatasource[0],\n widgetContext.map.locations[0], {\n \"lat\": null,\n \"lng\": null\n }).then(function succes() {\n $rootScope.$broadcast('widgetForceReInit');\n });" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "6770b6ba-eff8-df05-75f8-c1f9326d4842" + } + }, + "states": { + "main_gateway": { + "name": "Gateways", + "root": true, + "layouts": { + "main": { + "widgets": { + "94715984-ae74-76e4-20b7-2f956b01ed80": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + }, + "__entityname__config": { + "name": "${entityName} Configuration", + "root": false, + "layouts": { + "main": { + "widgets": { + "eadabbc7-519e-76fc-ba10-b3fe8c18da10": { + "sizeX": 14, + "sizeY": 13, + "row": 0, + "col": 10 + }, + "8fc32225-164f-3258-73f7-e6b6d959cf0b": { + "sizeX": 10, + "sizeY": 9, + "row": 0, + "col": 0 + }, + "063fc179-c9fd-f952-e714-f24e9c43c05c": { + "sizeX": 4, + "sizeY": 2, + "row": 9, + "col": 0 + }, + "3c2134cc-27a0-93e1-dbe1-2fa7c1ce16b7": { + "sizeX": 4, + "sizeY": 2, + "row": 11, + "col": 0 + }, + "6770b6ba-eff8-df05-75f8-c1f9326d4842": { + "sizeX": 6, + "sizeY": 4, + "row": 9, + "col": 4 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + }, + "__entityname_grafic": { + "name": "${entityName} Details", + "root": false, + "layouts": { + "main": { + "widgets": { + "f928afc4-30d1-8d0c-e3cf-777f9f9d1155": { + "sizeX": 17, + "sizeY": 4, + "mobileHeight": null, + "row": 4, + "col": 7 + }, + "2a95b473-042d-59d0-2da2-40d0cccb6c8a": { + "sizeX": 7, + "sizeY": 7, + "row": 5, + "col": 0 + }, + "aaa69366-aacc-9028-65aa-645c0f8533ec": { + "sizeX": 17, + "sizeY": 4, + "mobileHeight": null, + "row": 0, + "col": 7 + }, + "ce5c7d01-a3ef-5cf0-4578-8505135c23a0": { + "sizeX": 17, + "sizeY": 4, + "mobileHeight": null, + "row": 8, + "col": 7 + }, + "466f046d-6005-a168-b107-60fcb2469cd5": { + "sizeX": 7, + "sizeY": 5, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "auto 100%", + "autoFillHeight": true, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + } + }, + "entityAliases": { + "3e0f533a-0db1-3292-184f-06e73535061a": { + "id": "3e0f533a-0db1-3292-184f-06e73535061a", + "alias": "Gateways", + "filter": { + "type": "deviceType", + "resolveMultiple": true, + "deviceType": "gateway", + "deviceNameFilter": "" + } + }, + "b2487e75-2fa4-f211-142c-434dfd50c70c": { + "id": "b2487e75-2fa4-f211-142c-434dfd50c70c", + "alias": "Current Gateway", + "filter": { + "type": "stateEntity", + "resolveMultiple": false, + "stateEntityParamName": "", + "defaultStateEntity": null + } + } + }, + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 25000 + }, + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false + }, + "settings": { + "stateControllerId": "entity", + "showTitle": true, + "showDashboardsSelect": true, + "showEntitiesSelect": true, + "showDashboardTimewindow": true, + "showDashboardExport": true, + "toolbarAlwaysOpen": true, + "titleColor": "rgba(0,0,0,0.870588)" + } + }, + "name": "Gateways" +} diff --git a/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json b/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json new file mode 100644 index 0000000..b8231ab --- /dev/null +++ b/application/src/main/data/json/demo/dashboards/rule_engine_statistics.json @@ -0,0 +1,520 @@ +{ + "title": "Rule Engine Statistics", + "configuration": { + "widgets": { + "81987f19-3eac-e4ce-b790-d96e9b54d9a0": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 12, + "sizeY": 7, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "successfulMsgs", + "type": "timeseries", + "label": "${entityName} Successful", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.15490750967648736 + }, + { + "name": "failedMsgs", + "type": "timeseries", + "label": "${entityName} Permanent Failures", + "color": "#ef5350", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.4186621166514697 + }, + { + "name": "tmpFailed", + "type": "timeseries", + "label": "${entityName} Processing Failures", + "color": "#ffc107", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.49891007198715376 + } + ], + "entityAliasId": "140f23dd-e3a0-ed98-6189-03c49d2d8018" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 300000 + }, + "aggregation": { + "type": "NONE", + "limit": 8640 + }, + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Queue Stats", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "actions": {}, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": false, + "showTotal": true + } + }, + "id": "81987f19-3eac-e4ce-b790-d96e9b54d9a0" + }, + "5eb79712-5c24-3060-7e4f-6af36b8f842d": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "sizeX": 24, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Rule Chain", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).ruleChainName;" + }, + "_hash": 0.9954481282345906 + }, + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Rule Node", + "color": "#4caf50", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).ruleNodeName;" + }, + "_hash": 0.18580357036589978 + }, + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Latest Error", + "color": "#f44336", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).message;" + }, + "_hash": 0.7255162989552142 + } + ], + "entityAliasId": "140f23dd-e3a0-ed98-6189-03c49d2d8018" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 10 + }, + "title": "Exceptions", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "5eb79712-5c24-3060-7e4f-6af36b8f842d" + }, + "ad3f1417-87a8-750e-fc67-49a2de1466d4": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 12, + "sizeY": 7, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "timeoutMsgs", + "type": "timeseries", + "label": "${entityName} Permanent Timeouts", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.565222981550328 + }, + { + "name": "tmpTimeout", + "type": "timeseries", + "label": "${entityName} Processing Timeouts", + "color": "#9c27b0", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.2679547062508352 + } + ], + "entityAliasId": "140f23dd-e3a0-ed98-6189-03c49d2d8018" + } + ], + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 300000 + }, + "aggregation": { + "type": "NONE", + "limit": 8640 + }, + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Processing Failures and Timeouts", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "actions": {}, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": false, + "showTotal": true + } + }, + "id": "ad3f1417-87a8-750e-fc67-49a2de1466d4" + } + }, + "states": { + "default": { + "name": "Rule Engine Statistics", + "root": true, + "layouts": { + "main": { + "widgets": { + "81987f19-3eac-e4ce-b790-d96e9b54d9a0": { + "sizeX": 12, + "sizeY": 7, + "mobileHeight": null, + "row": 0, + "col": 0 + }, + "5eb79712-5c24-3060-7e4f-6af36b8f842d": { + "sizeX": 24, + "sizeY": 5, + "row": 7, + "col": 0 + }, + "ad3f1417-87a8-750e-fc67-49a2de1466d4": { + "sizeX": 12, + "sizeY": 7, + "mobileHeight": null, + "row": 0, + "col": 12 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margins": [ + 10, + 10 + ], + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + } + }, + "entityAliases": { + "140f23dd-e3a0-ed98-6189-03c49d2d8018": { + "id": "140f23dd-e3a0-ed98-6189-03c49d2d8018", + "alias": "TbServiceQueues", + "filter": { + "type": "assetType", + "resolveMultiple": true, + "assetType": "TbServiceQueue", + "assetNameFilter": "" + } + } + }, + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "realtime": { + "interval": 1000, + "timewindowMs": 60000 + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1586176634823, + "endTimeMs": 1586263034823 + } + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "settings": { + "stateControllerId": "entity", + "showTitle": false, + "showDashboardsSelect": true, + "showEntitiesSelect": true, + "showDashboardTimewindow": true, + "showDashboardExport": true, + "toolbarAlwaysOpen": true + } + }, + "name": "Rule Engine Statistics" +} \ No newline at end of file diff --git a/application/src/main/data/json/demo/dashboards/software.json b/application/src/main/data/json/demo/dashboards/software.json new file mode 100644 index 0000000..e9d7e9b --- /dev/null +++ b/application/src/main/data/json/demo/dashboards/software.json @@ -0,0 +1,2492 @@ +{ + "title": "Software", + "image": null, + "configuration": { + "description": "", + "widgets": { + "cd03188e-cd9d-9601-fd57-da4cb95fc016": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": false, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "8fdb88d0-50ac-2232-fdb7-69c30c16544e", + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current SW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current SW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target SW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target SW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_ts", + "type": "timeseries", + "label": "Target SW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_checksum", + "type": "attribute", + "label": "sw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_url", + "type": "attribute", + "label": "sw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.3355829384124256, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History software update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_software_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit software", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit software {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n softwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n softwareId: vm.entity.softwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.softwareId = formValues.softwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download software", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceSoftware();\n\nfunction getDeviceSoftware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.softwareId !== null) {\n otaPackageService.downloadOtaPackage(data.softwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.softwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.softwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_checksum');\nvar checksum = data.data[0][1];\nconsole.log(checksum);\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Software checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Software direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "cd03188e-cd9d-9601-fd57-da4cb95fc016" + }, + "100b756c-0082-6505-3ae1-3603e6deea48": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 8, + "sizeY": 6.5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "19f41c21-d9af-e666-8f50-e1748778f955", + "filterId": null, + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current software title", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.5978079905579401, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current software version", + "color": "#4caf50", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.027392025058568192, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target software title", + "color": "#f44336", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.9496350796287059, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target software version", + "color": "#ffc107", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.6734152252264187, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#607d8b", + "settings": { + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.2983399718643074, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "function capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\nif (value !== '') {\n return capitalize(value);\n}\nreturn value;" + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "timewindowMs": 2592000000, + "quickInterval": "CURRENT_DAY", + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 10, + "enableSearch": true, + "enableStickyHeader": true, + "enableStickyAction": true + }, + "title": "Software history", + "dropShadow": false, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "displayTimewindow": true, + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "100b756c-0082-6505-3ae1-3603e6deea48" + }, + "17543c57-af4a-2c1e-bf12-53a7b46791e6": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "waitingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${waitingDevicesNumber:0}\n
\n
\n Device Waiting\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_waiting", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "4d9a77a2-f0a5-690c-a83b-b0e940be788c" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "17543c57-af4a-2c1e-bf12-53a7b46791e6" + }, + "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${updatingDevicesNumber:0}\n
\n
\n Device Updating\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_updating", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "57d39904-2350-b29b-78ed-56b8268814cb" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6" + }, + "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "6044e198-df64-cd76-f339-696f220c4943", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatedDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n \n
\n ${updatedDevicesNumber:0}\n
\n
\n Device Updated\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .content img {\n height: 28px; \n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_updated", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "d787c212-8c56-34f0-349a-5aae2ffd1eae" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81" + }, + "77b10144-b904-edd5-8c7c-8fb75616c6d8": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 8, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entityCount", + "name": "", + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "dataKeys": [ + { + "name": "count", + "type": "count", + "label": "updatingDevicesNumber", + "color": "#4caf50", + "settings": {}, + "_hash": 0.7404827038869322, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "cardHtml": "
\n
\n
\n \n \n \n
\n
\n ${updatingDevicesNumber:0}\n
\n \n
\n Device Failed\n
\n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n border: 1px solid #E0E0E0;\n box-sizing: border-box;\n}\n\n.card .content {\n padding: 20px 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 100%;\n box-sizing: border-box;\n}\n\n.card .container-svg {\n height: 40px;\n width: 40px;\n}\n\n.card .value {\n margin: 18px 0 5px;\n font-weight: 500;\n font-size: 3em;\n line-height: 1.1em;\n text-align: center;\n letter-spacing: -0.02em;\n color: #333333;\n}\n\n.card .description {\n font-size: 1em;\n line-height: 1.1em;\n color: #000000;\n opacity: 0.6;\n text-align: center;\n letter-spacing: -0.02em;\n}\n\n@media (min-width: 960px) and (max-width: 1200px) {\n .card .container-svg {\n height: 28px;\n width: 28px;\n }\n \n .card .value {\n margin: 12px 0 5px;\n font-size: 2em;\n line-height: 1;\n }\n \n .card .description {\n font-size: 0.8em;\n line-height: 1;\n }\n}" + }, + "title": "New HTML Value Card", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "activeDevices", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "device_error", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "0b3d2887-9929-84d5-3795-0763dca15cba" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "enableDataExport": false, + "displayTimewindow": true + }, + "id": "77b10144-b904-edd5-8c7c-8fb75616c6d8" + }, + "21be08bb-ec90-f760-ad6f-e7678f12c401": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current SW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current SW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target SW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target SW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_ts", + "type": "timeseries", + "label": "Target SW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_checksum", + "type": "attribute", + "label": "sw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_url", + "type": "attribute", + "label": "sw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.3355829384124256, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History software update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_software_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit software", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit software {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n softwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n softwareId: vm.entity.softwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.softwareId = formValues.softwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download software", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceSoftware();\n\nfunction getDeviceSoftware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.softwareId !== null) {\n otaPackageService.downloadOtaPackage(data.softwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.softwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.softwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_checksum');\nvar checksum = data.data[0][1];\nconsole.log(checksum);\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Software checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Software direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "21be08bb-ec90-f760-ad6f-e7678f12c401" + }, + "e8280043-d3dc-7acb-c2ff-a4522972ff91": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current SW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current SW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target SW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target SW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_ts", + "type": "timeseries", + "label": "Target SW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_checksum", + "type": "attribute", + "label": "sw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_url", + "type": "attribute", + "label": "sw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.3355829384124256, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History software update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_software_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit software", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit software {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n softwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n softwareId: vm.entity.softwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.softwareId = formValues.softwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download software", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceSoftware();\n\nfunction getDeviceSoftware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.softwareId !== null) {\n otaPackageService.downloadOtaPackage(data.softwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.softwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.softwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_checksum');\nvar checksum = data.data[0][1];\nconsole.log(checksum);\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Software checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Software direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "e8280043-d3dc-7acb-c2ff-a4522972ff91" + }, + "3624013b-378c-f110-5eba-ae95c25a4dcc": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current SW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current SW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target SW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target SW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_ts", + "type": "timeseries", + "label": "Target SW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_checksum", + "type": "attribute", + "label": "sw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_url", + "type": "attribute", + "label": "sw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.3355829384124256, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History software update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_software_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit software", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit software {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n softwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n softwareId: vm.entity.softwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.softwareId = formValues.softwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download software", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceSoftware();\n\nfunction getDeviceSoftware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.softwareId !== null) {\n otaPackageService.downloadOtaPackage(data.softwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.softwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.softwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_checksum');\nvar checksum = data.data[0][1];\nconsole.log(checksum);\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Software checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Software direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "3624013b-378c-f110-5eba-ae95c25a4dcc" + }, + "d2d13e0d-4e71-889f-9343-ad2f0af9f176": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "image": null, + "description": null, + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "enableStickyHeader": true, + "enableStickyAction": true, + "entitiesTitle": "Devices", + "displayEntityLabel": false, + "entityNameColumnTitle": "Device" + }, + "title": "New Entities table", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "filterId": "6044e198-df64-cd76-f339-696f220c4943", + "dataKeys": [ + { + "name": "current_sw_title", + "type": "timeseries", + "label": "Current SW title", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.09545533885166413, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "current_sw_version", + "type": "timeseries", + "label": "Current SW version", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.7206056602328659, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_title", + "type": "timeseries", + "label": "Target SW title", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.9934225682766313, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_version", + "type": "timeseries", + "label": "Target SW version", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled" + }, + "_hash": 0.5251724416842531, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "target_sw_ts", + "type": "timeseries", + "label": "Target SW set time", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellContentFunction": "if (value !== '') {\n return ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss');\n}\nreturn '';" + }, + "_hash": 0.31823244858578237, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Progress", + "color": "#9c27b0", + "settings": { + "columnWidth": "30%", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "return {\n 'padding-right': '30px'\n}", + "cellContentFunction": "if (value !== '') {\n var mapProgress = {\n 'QUEUED': 0,\n 'INITIATED': 5,\n 'DOWNLOADING': 10,\n 'DOWNLOADED': 55,\n 'VERIFIED': 60,\n 'UPDATING': 70,\n 'FAILED': 99,\n 'UPDATED': 100\n }\n var color = 'mat-primary';\n var progress = mapProgress[value];\n if (value == 'FAILED') {\n color = 'mat-accent';\n }\n return `
`;\n}" + }, + "_hash": 0.8174211757846257, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_state", + "type": "timeseries", + "label": "Status", + "color": "#f44336", + "settings": { + "columnWidth": "130px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "defaultColumnVisibility": "visible", + "columnSelectionToDisplay": "enabled", + "cellStyleFunction": "if (value == 'FAILED') {\n return {'color' : '#D93025'};\n}\nreturn {};", + "cellContentFunction": "function icon(value) {\n if (value == 'QUEUED') {\n return '';\n }\n if (value == 'INITIATED' || value == 'DOWNLOADING' || value == 'DOWNLOADED') {\n return '';\n }\n if (value == 'VERIFIED' || value == 'UPDATING' ) {\n return 'update';\n }\n if (value == 'UPDATED') {\n return '';\n }\n if (value == 'FAILED') {\n return 'warning';\n }\n return '';\n}\nfunction capitalize (s) {\n if (typeof s !== 'string') return '';\n return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\nreturn icon(value) + '' + capitalize(value) + '';" + }, + "_hash": 0.7764426948615217, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_checksum", + "type": "attribute", + "label": "sw_checksum", + "color": "#3f51b5", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.5594087842471693, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "sw_url", + "type": "attribute", + "label": "sw_url", + "color": "#e91e63", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "", + "defaultColumnVisibility": "hidden", + "columnSelectionToDisplay": "disabled" + }, + "_hash": 0.3355829384124256, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "actions": { + "actionCellButton": [ + { + "name": "History software update", + "icon": "history", + "type": "openDashboardState", + "targetDashboardStateId": "device_software_history", + "setEntityId": true, + "stateEntityParamName": null, + "openInSeparateDialog": false, + "dialogTitle": "", + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "98a1406c-3301-bc2f-2c5d-d637ce3b663b" + }, + { + "name": "Edit software", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit software {{entityName}}

\n \n \n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
", + "customCss": "form {\n min-width: 300px !important;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n\n vm.entityName = entityName;\n vm.entity = {};\n\n vm.editEntityFormGroup = vm.fb.group({\n softwareId: [null]\n });\n\n getEntityInfo();\n\n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n\n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveEntity().subscribe(\n function () {\n // widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n };\n\n\n function getEntityInfo() {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n vm.entity = data;\n vm.editEntityFormGroup.patchValue({\n softwareId: vm.entity.softwareId\n }, {emitEvent: false});\n }\n );\n }\n\n function saveEntity() {\n const formValues = vm.editEntityFormGroup.value;\n vm.entity.softwareId = formValues.softwareId;\n return deviceService.saveDevice(vm.entity);\n }\n}", + "customResources": [], + "id": "23099c1d-454b-25dc-8bc0-7cf33c21c5d5" + }, + { + "name": "Download software", + "icon": "file_download", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet entityService = $injector.get(widgetContext.servicesMap.get('entityService'));\nlet otaPackageService = $injector.get(widgetContext.servicesMap.get('otaPackageService'));\nlet deviceProfileService = $injector.get(widgetContext.servicesMap.get('deviceProfileService'));\n\ngetDeviceSoftware();\n\nfunction getDeviceSoftware() {\n var entityIdValue = entityId.id;\n var data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url === '') {\n entityService.getEntity(entityId.entityType, entityId.id).subscribe(\n function (data) {\n if (data.softwareId !== null) {\n otaPackageService.downloadOtaPackage(data.softwareId.id).subscribe(); \n } else {\n deviceProfileService.getDeviceProfile(data.deviceProfileId.id).subscribe(\n function (deviceProfile) {\n if (deviceProfile.softwareId !== null) {\n otaPackageService.downloadOtaPackage(deviceProfile.softwareId.id).subscribe();\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n });\n }\n }\n );\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "12533058-42f6-e75f-620c-219c48d01ec0" + }, + { + "name": "Copy checksum/URL", + "icon": "content_copy", + "type": "custom", + "customFunction": "function copyToClipboard(text) {\n if (window.clipboardData && window.clipboardData.setData) {\n return window.clipboardData.setData(\"Text\", text);\n\n }\n else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n var textarea = document.createElement(\"textarea\");\n textarea.textContent = text;\n textarea.style.position = \"fixed\";\n document.body.appendChild(textarea);\n textarea.select();\n try {\n return document.execCommand(\"copy\");\n }\n catch (ex) {\n console.warn(\"Copy to clipboard failed.\", ex);\n return false;\n }\n document.body.removeChild(textarea);\n }\n}\nvar entityIdValue = entityId.id;\nvar data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_checksum');\nvar checksum = data.data[0][1];\nconsole.log(checksum);\nif (checksum !== '') {\n copyToClipboard(checksum);\n widgetContext.showSuccessToast('Software checksum has been copied to clipboard', 2000, 'top');\n} else {\n data = widgetContext.data.find((el) => el.datasource.entityId === entityIdValue && el.dataKey.name === 'sw_url');\n var url = data.data[0][1];\n if (url !== '') {\n copyToClipboard(url);\n widgetContext.showSuccessToast('Software direct URL has been copied to clipboard', 2000, 'top');\n } else {\n widgetContext.showToast('warn', 'Device ' + entityName +' has not software set.', 2000, 'top');\n }\n}", + "id": "09323079-7111-87f7-90d1-c62cd7d85dc7" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {} + }, + "row": 0, + "col": 0, + "id": "d2d13e0d-4e71-889f-9343-ad2f0af9f176" + } + }, + "states": { + "default": { + "name": "Device list", + "root": true, + "layouts": { + "main": { + "widgets": { + "cd03188e-cd9d-9601-fd57-da4cb95fc016": { + "sizeX": 19, + "sizeY": 12, + "row": 0, + "col": 0 + }, + "17543c57-af4a-2c1e-bf12-53a7b46791e6": { + "sizeX": 5, + "sizeY": 3, + "row": 0, + "col": 19 + }, + "6c1c4e1a-bce0-f5ad-ff8b-ba1dfc5a4ec6": { + "sizeX": 5, + "sizeY": 3, + "row": 3, + "col": 19 + }, + "e6674227-9cf3-a2f6-ecac-5ccfc38a3c81": { + "sizeX": 5, + "sizeY": 3, + "row": 9, + "col": 19 + }, + "77b10144-b904-edd5-8c7c-8fb75616c6d8": { + "sizeX": 5, + "sizeY": 3, + "row": 6, + "col": 19 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 12, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70 + } + } + } + }, + "device_software_history": { + "name": "Software history: ${entityName}", + "root": false, + "layouts": { + "main": { + "widgets": { + "100b756c-0082-6505-3ae1-3603e6deea48": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_waiting": { + "name": "Device waiting", + "root": false, + "layouts": { + "main": { + "widgets": { + "21be08bb-ec90-f760-ad6f-e7678f12c401": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_updating": { + "name": "Device updating", + "root": false, + "layouts": { + "main": { + "widgets": { + "e8280043-d3dc-7acb-c2ff-a4522972ff91": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_updated": { + "name": "Device updated", + "root": false, + "layouts": { + "main": { + "widgets": { + "d2d13e0d-4e71-889f-9343-ad2f0af9f176": { + "sizeX": 27, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "device_error": { + "name": "Device failed", + "root": false, + "layouts": { + "main": { + "widgets": { + "3624013b-378c-f110-5eba-ae95c25a4dcc": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + } + }, + "entityAliases": { + "639da5b4-31f0-0151-6282-c37a3897b7e8": { + "id": "639da5b4-31f0-0151-6282-c37a3897b7e8", + "alias": "All devices", + "filter": { + "type": "entityType", + "resolveMultiple": true, + "entityType": "DEVICE" + } + }, + "19f41c21-d9af-e666-8f50-e1748778f955": { + "id": "19f41c21-d9af-e666-8f50-e1748778f955", + "alias": "State entity", + "filter": { + "type": "stateEntity", + "resolveMultiple": false, + "stateEntityParamName": null, + "defaultStateEntity": null + } + } + }, + "filters": { + "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e": { + "id": "19a0ad1c-b31d-4a29-9d7b-5d87e2a8ea6e", + "filter": "WaitingDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "sw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "QUEUED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "579f0468-9ce9-7e3e-b34c-88dd3de59897": { + "id": "579f0468-9ce9-7e3e-b34c-88dd3de59897", + "filter": "UpdatingDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "sw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "OR", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "INITIATED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "sw_state equel", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "DOWNLOADING", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "sw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "DOWNLOADED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "sw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "VERIFIED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "sw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + }, + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "UPDATING", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": false, + "label": "sw_state equal", + "autogeneratedLabel": true, + "order": 0 + } + } + ], + "type": "COMPLEX" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "6044e198-df64-cd76-f339-696f220c4943": { + "id": "6044e198-df64-cd76-f339-696f220c4943", + "filter": "UpdetedDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "sw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "UPDATED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "bdbc6ea1-95a7-3912-341a-58dc7704a00f": { + "id": "bdbc6ea1-95a7-3912-341a-58dc7704a00f", + "filter": "FailedDevicesFilter", + "keyFilters": [ + { + "key": { + "type": "TIME_SERIES", + "key": "sw_state" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "EQUAL", + "value": { + "defaultValue": "FAILED", + "dynamicValue": null + }, + "ignoreCase": false, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "", + "autogeneratedLabel": true, + "order": 0 + } + } + ] + } + ], + "editable": false + }, + "8fdb88d0-50ac-2232-fdb7-69c30c16544e": { + "id": "8fdb88d0-50ac-2232-fdb7-69c30c16544e", + "filter": "DeviceSearch", + "keyFilters": [ + { + "key": { + "type": "ENTITY_FIELD", + "key": "name" + }, + "valueType": "STRING", + "predicates": [ + { + "keyFilterPredicate": { + "operation": "CONTAINS", + "value": { + "defaultValue": "" + }, + "ignoreCase": true, + "type": "STRING" + }, + "userInfo": { + "editable": true, + "label": "Device name", + "autogeneratedLabel": false, + "order": 0 + } + } + ] + } + ], + "editable": true + } + }, + "timewindow": { + "displayValue": "", + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY" + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1618998609030, + "endTimeMs": 1619085009030 + }, + "quickInterval": "CURRENT_DAY" + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "settings": { + "stateControllerId": "entity", + "showTitle": false, + "showDashboardsSelect": false, + "showEntitiesSelect": false, + "showDashboardTimewindow": true, + "showDashboardExport": false, + "toolbarAlwaysOpen": true, + "titleColor": "rgba(0,0,0,0.870588)", + "showFilters": true, + "showDashboardLogo": false, + "dashboardLogoUrl": null, + "showUpdateDashboardImage": false + } + }, + "name": "Software" +} \ No newline at end of file diff --git a/application/src/main/data/json/demo/dashboards/thermostats.json b/application/src/main/data/json/demo/dashboards/thermostats.json new file mode 100644 index 0000000..4f486ef --- /dev/null +++ b/application/src/main/data/json/demo/dashboards/thermostats.json @@ -0,0 +1,1239 @@ +{ + "title": "Thermostats", + "configuration": { + "widgets": { + "f33c746c-0dfc-c212-395b-b448c8a17209": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "entities_table", + "type": "latest", + "title": "New widget", + "sizeX": 11, + "sizeY": 11, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSearch": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "entityName", + "displayEntityName": true, + "displayEntityType": false, + "enableSelectColumnDisplay": false, + "entitiesTitle": "Thermostats", + "displayEntityLabel": false, + "entityNameColumnTitle": "Thermostat name" + }, + "title": "Thermostats", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e", + "dataKeys": [ + { + "name": "active", + "type": "attribute", + "label": "Active", + "color": "#2196f3", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": true, + "useCellContentFunction": true, + "cellContentFunction": "value = '⬤';\nreturn value;", + "cellStyleFunction": "var color;\nif (value === \"true\") {\n color = 'rgb(39, 134, 34)';\n} else {\n color = 'rgb(255, 0, 0)';\n}\nreturn {\n color: color,\n fontSize: '18px'\n};" + }, + "_hash": 0.9264526512320641 + }, + { + "name": "temperature", + "type": "timeseries", + "label": "Temperature", + "color": "#4caf50", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.9801965063904188, + "units": "°C", + "decimals": 1 + }, + { + "name": "humidity", + "type": "timeseries", + "label": "Humidity", + "color": "#f44336", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "useCellContentFunction": false + }, + "_hash": 0.5726727868178358, + "units": "%", + "decimals": 0 + }, + { + "name": "latitude", + "type": "attribute", + "label": "latitude", + "color": "#ffc107", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.16055765877264894 + }, + { + "name": "longitude", + "type": "attribute", + "label": "longitude", + "color": "#607d8b", + "settings": { + "columnWidth": "0px", + "useCellStyleFunction": false, + "cellStyleFunction": "", + "useCellContentFunction": false, + "cellContentFunction": "" + }, + "_hash": 0.10969512220289346 + } + ] + } + ], + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "displayTimewindow": true, + "actions": { + "headerButton": [ + { + "id": "85b803db-90f2-5c63-1388-a378e0eb10d6", + "name": "Edit location", + "icon": "map", + "type": "openDashboardState", + "targetDashboardStateId": "map", + "setEntityId": false + }, + { + "name": "Add", + "icon": "add", + "type": "customPretty", + "customHtml": "
\n \n

Add thermostat

\n \n \n
\n \n \n
\n
\n \n Thermostat name\n \n \n Thermostat name is required.\n \n \n
\n \n High temperature alarm\n \n \n High temperature threshold, °C\n \n \n High temperature threshold is required.\n \n \n \n \n Low humidity alarm\n \n \n \n Low humidity threshold, %\n \n \n Low humidity threshold is required.\n \n \n
\n
\n
\n \n \n
\n
", + "customCss": ".add-entity-form{\n width: 300px;\n}\n", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\n\nopenAddEntityDialog();\n\nfunction openAddEntityDialog() {\n customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();\n}\n\nfunction AddEntityDialogController(instance) {\n let vm = instance;\n \n vm.addEntityFormGroup = vm.fb.group({\n entityName: ['', [vm.validators.required]],\n attributes: vm.fb.group({\n temperatureAlarmFlag: [false],\n temperatureAlarmThreshold: [{value: null, disabled: true}],\n humidityAlarmFlag: [false],\n humidityAlarmThreshold: [{value: null, disabled: true}]\n })\n });\n \n vm.addEntityFormGroup.get('attributes').get('temperatureAlarmFlag').valueChanges\n .subscribe(activate => {\n if (activate) {\n vm.addEntityFormGroup.get('attributes').get('temperatureAlarmThreshold').enable();\n } else {\n vm.addEntityFormGroup.get('attributes').get('temperatureAlarmThreshold').disable();\n }\n });\n \n vm.addEntityFormGroup.get('attributes').get('humidityAlarmFlag').valueChanges\n .subscribe(activate => {\n if (activate) {\n vm.addEntityFormGroup.get('attributes').get('humidityAlarmThreshold').enable();\n } else {\n vm.addEntityFormGroup.get('attributes').get('humidityAlarmThreshold').disable();\n }\n });\n\n vm.save = function() {\n vm.addEntityFormGroup.markAsPristine();\n saveEntityObservable().subscribe(\n function (entity) {\n saveAttributes(entity.id).subscribe(\n function () {\n widgetContext.updateAliases();\n vm.dialogRef.close(null);\n }\n );\n }\n );\n };\n \n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n \n function saveEntityObservable() {\n const formValues = vm.addEntityFormGroup.value;\n let entity = {\n name: formValues.entityName,\n type: \"thermostat\"\n };\n return deviceService.saveDevice(entity);\n }\n \n function saveAttributes(entityId) {\n let attributes = vm.addEntityFormGroup.get('attributes').value;\n let attributesArray = [];\n for (let key in attributes) {\n if(attributes[key] !== null) {\n attributesArray.push({key: key, value: attributes[key]});\n }\n }\n if (attributesArray.length > 0) {\n return attributeService.saveEntityAttributes(entityId, \"SERVER_SCOPE\", attributesArray);\n } else {\n return widgetContext.rxjs.of([]);\n }\n }\n}", + "customResources": [], + "id": "8ab5a518-67d2-b6a2-956d-81fd512294b2" + } + ], + "actionCellButton": [ + { + "id": "ca241cd8-788d-5508-a9ce-74b03ef42a7f", + "name": "Chart", + "icon": "show_chart", + "type": "openDashboardState", + "targetDashboardStateId": "chart", + "setEntityId": true + }, + { + "name": "Edit", + "icon": "edit", + "type": "customPretty", + "customHtml": "
\n \n

Edit thermostat {{entityName}}

\n \n \n
\n \n \n
\n
\n \n Thermostat name\n \n \n
\n \n High temperature alarm\n \n \n High temperature threshold, °C\n \n \n High temperature threshold is required.\n \n \n\n \n Low humidity alarm\n \n\n \n Low humidity threshold, %\n \n \n Low humidity threshold is required.\n \n \n
\n
\n
\n \n \n
\n
", + "customCss": ".edit-entity-form{\n width: 300px;\n}", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\n\nopenEditEntityDialog();\n\nfunction openEditEntityDialog() {\n customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe();\n}\n\nfunction EditEntityDialogController(instance) {\n let vm = instance;\n \n vm.entityId = entityId;\n vm.entityName = entityName;\n vm.attributes = {};\n \n vm.editEntityFormGroup = vm.fb.group({\n entityName: [''],\n attributes: vm.fb.group({\n temperatureAlarmFlag: [false],\n temperatureAlarmThreshold: [{value: null, disabled: true}],\n humidityAlarmFlag: [false],\n humidityAlarmThreshold: [{value: null, disabled: true}]\n })\n });\n \n vm.editEntityFormGroup.get('attributes').get('temperatureAlarmFlag').valueChanges\n .subscribe(activate => {\n if (activate) {\n vm.editEntityFormGroup.get('attributes').get('temperatureAlarmThreshold').enable();\n } else {\n vm.editEntityFormGroup.get('attributes').get('temperatureAlarmThreshold').disable();\n }\n });\n \n vm.editEntityFormGroup.get('attributes').get('humidityAlarmFlag').valueChanges\n .subscribe(activate => {\n if (activate) {\n vm.editEntityFormGroup.get('attributes').get('humidityAlarmThreshold').enable();\n } else {\n vm.editEntityFormGroup.get('attributes').get('humidityAlarmThreshold').disable();\n }\n });\n \n \n getEntityInfo();\n \n \n vm.save = function() {\n vm.editEntityFormGroup.markAsPristine();\n saveAttributes(entityId).subscribe(\n function () {\n vm.dialogRef.close(null);\n }\n );\n };\n \n vm.cancel = function() {\n vm.dialogRef.close(null);\n };\n \n function getEntityAttributes(attributes) {\n for (var i = 0; i < attributes.length; i++) {\n vm.attributes[attributes[i].key] = attributes[i].value;\n }\n }\n \n function getEntityInfo() {\n attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE').subscribe(\n function (attributes) {\n getEntityAttributes(attributes);\n vm.editEntityFormGroup.patchValue({\n entityName: vm.entityName,\n attributes: vm.attributes\n });\n // if(vm.attributes.temperatureAlarmFlag) {\n // vm.editEntityFormGroup.get('attributes').get('temperatureAlarmThreshold').enable();\n // }\n // if(vm.attributes.humidityAlarmFlag) {\n // vm.editEntityFormGroup.get('attributes').get('humidityAlarmThreshold').enable();\n // }\n }\n );\n }\n \n function saveAttributes(entityId) {\n let attributes = vm.editEntityFormGroup.get('attributes').value;\n let attributesArray = [];\n for (let key in attributes) {\n if (attributes[key] !== vm.attributes[key]) {\n attributesArray.push({key: key, value: attributes[key]});\n }\n }\n if (attributesArray.length > 0) {\n return attributeService.saveEntityAttributes(entityId, \"SERVER_SCOPE\", attributesArray);\n } else {\n return widgetContext.rxjs.of([]);\n }\n }\n}", + "customResources": [], + "id": "7506576f-87ba-d3a0-88fb-e304d451776d" + }, + { + "name": "Delete", + "icon": "delete", + "type": "custom", + "customFunction": "let $injector = widgetContext.$scope.$injector;\nlet dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\n\nopenDeleteEntityDialog();\n\nfunction openDeleteEntityDialog() {\n let title = 'Delete thermostat \"' + entityName + '\"';\n let content = 'Are you sure you want to delete the thermostat \"' +\n entityName + '\"?';\n dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe(\n function(result) {\n if (result) {\n deleteEntity();\n }\n }\n );\n}\n\nfunction deleteEntity() {\n deviceService.deleteDevice(entityId.id).subscribe(\n function success() {\n widgetContext.updateAliases();\n },\n function fail() {\n showErrorDialog();\n }\n );\n}\n\nfunction showErrorDialog() {\n let title = 'Error';\n let content = 'An error occurred while deleting the thermostat. Please try again.';\n dialogs.alert(title, content, 'CLOSE').subscribe(\n function(result) {}\n );\n}", + "id": "3488848b-e47d-6af6-659f-5d78369ece5e" + } + ], + "rowClick": [] + } + }, + "id": "f33c746c-0dfc-c212-395b-b448c8a17209" + }, + "7943196b-eedb-d422-f9c3-b32d379ad172": { + "isSystemType": true, + "bundleAlias": "alarm_widgets", + "typeAlias": "alarms_table", + "type": "alarm", + "title": "New widget", + "sizeX": 13, + "sizeY": 5, + "config": { + "timewindow": { + "realtime": { + "interval": 1000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 200 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "4px", + "settings": { + "enableSelection": true, + "enableSearch": true, + "displayDetails": true, + "allowAcknowledgment": true, + "allowClear": true, + "displayPagination": true, + "defaultPageSize": 10, + "defaultSortOrder": "-createdTime", + "enableSelectColumnDisplay": false, + "alarmsTitle": "Alarms", + "enableFilter": true + }, + "title": "New Alarms table", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400, + "padding": "5px 10px 5px 10px" + }, + "useDashboardTimewindow": false, + "showLegend": false, + "alarmSource": { + "type": "entity", + "name": "alarms", + "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e", + "filterId": null, + "dataKeys": [ + { + "name": "createdTime", + "type": "alarm", + "label": "Created time", + "color": "#2196f3", + "settings": {}, + "_hash": 0.7308410188824108 + }, + { + "name": "originator", + "type": "alarm", + "label": "Originator", + "color": "#4caf50", + "settings": {}, + "_hash": 0.056085530105439485 + }, + { + "name": "type", + "type": "alarm", + "label": "Type", + "color": "#f44336", + "settings": {}, + "_hash": 0.10212012352561795 + }, + { + "name": "severity", + "type": "alarm", + "label": "Severity", + "color": "#ffc107", + "settings": {}, + "_hash": 0.1777349980531262 + }, + { + "name": "status", + "type": "alarm", + "label": "Status", + "color": "#607d8b", + "settings": {}, + "_hash": 0.7977920750136249 + } + ] + }, + "alarmSearchStatus": "ANY", + "alarmsPollingInterval": 5, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "displayTimewindow": true, + "actions": {}, + "datasources": [], + "alarmsMaxCountLoad": 0, + "alarmsFetchSize": 100 + }, + "id": "7943196b-eedb-d422-f9c3-b32d379ad172" + }, + "14a19183-f0b2-d6be-0f62-9863f0a51111": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 18, + "sizeY": 6, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "temperature", + "type": "timeseries", + "label": "Temperature", + "color": "#ef5350", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": true, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.7852346160709658, + "units": "°C", + "decimals": 1 + } + ], + "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547" + } + ], + "timewindow": { + "realtime": { + "interval": 30000, + "timewindowMs": 3600000 + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + }, + "smoothLines": true + }, + "title": "Temperature", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": true, + "showTotal": false + }, + "actions": {} + }, + "id": "14a19183-f0b2-d6be-0f62-9863f0a51111" + }, + "07f49fd5-a73b-d74c-c220-362c20af81f4": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 18, + "sizeY": 6, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "humidity", + "type": "timeseries", + "label": "Humidity", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": true, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.28640715926957183, + "units": "%", + "decimals": 0 + } + ], + "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547" + } + ], + "timewindow": { + "realtime": { + "interval": 30000, + "timewindowMs": 3600000 + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "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": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + }, + "smoothLines": true + }, + "title": "Humidity", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": true, + "showTotal": false + }, + "actions": {} + }, + "id": "07f49fd5-a73b-d74c-c220-362c20af81f4" + }, + "c4631f94-2db3-523b-4d09-2a1a0a75d93f": { + "isSystemType": true, + "bundleAlias": "input_widgets", + "typeAlias": "update_multiple_attributes", + "type": "latest", + "title": "New widget", + "sizeX": 6, + "sizeY": 6, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "temperatureAlarmFlag", + "type": "attribute", + "label": "High temperature alarm", + "color": "#4caf50", + "settings": { + "dataKeyType": "server", + "dataKeyValueType": "booleanCheckbox", + "required": false, + "isEditable": "editable", + "dataKeyHidden": false, + "step": 1 + }, + "_hash": 0.8725278440159361 + }, + { + "name": "temperatureAlarmThreshold", + "type": "attribute", + "label": "High temperature threshold, °C", + "color": "#f44336", + "settings": { + "dataKeyType": "server", + "dataKeyValueType": "double", + "required": false, + "isEditable": "editable", + "dataKeyHidden": false, + "step": 1, + "disabledOnDataKey": "temperatureAlarmFlag" + }, + "_hash": 0.7316078472857874 + }, + { + "name": "humidityAlarmFlag", + "type": "attribute", + "label": "Low humidity alarm", + "color": "#ffc107", + "settings": { + "dataKeyType": "server", + "dataKeyValueType": "booleanCheckbox", + "required": false, + "isEditable": "editable", + "dataKeyHidden": false, + "step": 1 + }, + "_hash": 0.5339673667431057 + }, + { + "name": "humidityAlarmThreshold", + "type": "attribute", + "label": "Low humidity threshold, %", + "color": "#607d8b", + "settings": { + "dataKeyType": "server", + "dataKeyValueType": "double", + "required": false, + "isEditable": "editable", + "dataKeyHidden": false, + "step": 1, + "disabledOnDataKey": "humidityAlarmFlag" + }, + "_hash": 0.2687091190358901 + } + ], + "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": true, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showActionButtons": false, + "showResultMessage": true, + "fieldsAlignment": "column", + "fieldsInRow": 2, + "groupTitle": "${entityName}", + "widgetTitle": "Termostat settings" + }, + "title": "New Update Multiple Attributes", + "dropShadow": true, + "enableFullscreen": false, + "enableDataExport": false, + "widgetStyle": {}, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": {}, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "c4631f94-2db3-523b-4d09-2a1a0a75d93f" + }, + "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb": { + "isSystemType": true, + "bundleAlias": "maps_v2", + "typeAlias": "openstreetmap", + "type": "latest", + "title": "New widget", + "sizeX": 13, + "sizeY": 6, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#2196f3", + "settings": {}, + "_hash": 0.1371919646686739, + "decimals": 1, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#4caf50", + "settings": {}, + "_hash": 0.043177186765847475, + "decimals": 0, + "postFuncBody": "return value || \"\";" + }, + { + "name": "longitude", + "type": "attribute", + "label": "longitude", + "color": "#f44336", + "settings": {}, + "_hash": 0.5548964320315584 + }, + { + "name": "latitude", + "type": "attribute", + "label": "latitude", + "color": "#ffc107", + "settings": {}, + "_hash": 0.1803778014971602 + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#607d8b", + "settings": {}, + "_hash": 0.30926987994082844 + } + ], + "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "fitMapBounds": true, + "latKeyName": "latitude", + "lngKeyName": "longitude", + "showLabel": true, + "label": "${entityName}", + "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Thermostat details
", + "markerImageSize": 48, + "useColorFunction": false, + "markerImages": [ + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiNmNDQzMzZ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+", + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiMyNzg2MjJ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+Cg==" + ], + "useMarkerImageFunction": true, + "colorFunction": "\n", + "color": "#fe7569", + "mapProvider": "OpenStreetMap.HOT", + "showTooltip": true, + "autocloseTooltip": true, + "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "defaultCenterPosition": [ + 0, + 0 + ], + "showTooltipAction": "click", + "polygonKeyName": "coordinates", + "polygonOpacity": 0.5, + "polygonStrokeOpacity": 1, + "polygonStrokeWeight": 1, + "zoomOnClick": true, + "showCoverageOnHover": true, + "animate": true, + "maxClusterRadius": 80, + "removeOutsideVisibleBounds": true, + "useLabelFunction": true, + "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", + "defaultZoomLevel": 14, + "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;" + }, + "title": "Thermostat maps", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "widgetStyle": {}, + "actions": { + "headerButton": [], + "tooltipAction": [ + { + "id": "bef25673-b37a-8821-bc0f-5d6dd3680f24", + "name": "navigate_to_details", + "icon": "more_horiz", + "type": "openDashboardState", + "targetDashboardStateId": "chart", + "setEntityId": true + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb" + }, + "00fb2742-ba1f-7e43-673f-d6c08b72ed06": { + "isSystemType": true, + "bundleAlias": "input_widgets", + "typeAlias": "markers_placement_openstreetmap", + "type": "latest", + "title": "New widget", + "sizeX": 24, + "sizeY": 12, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "longitude", + "type": "attribute", + "label": "longitude", + "color": "#2196f3", + "settings": {}, + "_hash": 0.3640193654284214 + }, + { + "name": "latitude", + "type": "attribute", + "label": "latitude", + "color": "#4caf50", + "settings": {}, + "_hash": 0.49020393887695923 + }, + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#f44336", + "settings": {}, + "_hash": 0.5885892766009955, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#ffc107", + "settings": {}, + "_hash": 0.21077893588180707, + "postFuncBody": "return value || \"\";" + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#607d8b", + "settings": {}, + "_hash": 0.34722983638504346 + } + ], + "entityAliasId": "68a058e1-fdda-8482-715b-3ae4a488568e" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "fitMapBounds": true, + "latKeyName": "latitude", + "lngKeyName": "longitude", + "showLabel": true, + "label": "${entityName}", + "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", + "markerImageSize": 34, + "useColorFunction": false, + "markerImages": [ + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiNmNDQzMzZ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+", + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiMyNzg2MjJ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+Cg==" + ], + "useMarkerImageFunction": true, + "color": "#fe7569", + "mapProvider": "OpenStreetMap.HOT", + "showTooltip": true, + "autocloseTooltip": true, + "defaultCenterPosition": "0,0", + "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "showTooltipAction": "click", + "polygonKeyName": "coordinates", + "polygonOpacity": 0.5, + "polygonStrokeOpacity": 1, + "polygonStrokeWeight": 1, + "zoomOnClick": true, + "showCoverageOnHover": true, + "animate": true, + "maxClusterRadius": 80, + "removeOutsideVisibleBounds": true, + "defaultZoomLevel": 12, + "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", + "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;", + "useLabelFunction": true, + "provider": "openstreet-map", + "draggableMarker": true + }, + "title": "New Markers Placement - OpenStreetMap", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "widgetStyle": {}, + "actions": { + "tooltipAction": [ + { + "name": "delete", + "icon": "more_horiz", + "type": "custom", + "customFunction": "var entityDatasource = widgetContext.mapInstance.datasources.filter(\n function(entity) {\n return entity.entityId === entityId.id\n });\n\nwidgetContext.mapInstance.saveMarkerLocation(entityDatasource[0], null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", + "id": "54c293c4-9ca6-e34f-dc6a-0271944c1c66" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "00fb2742-ba1f-7e43-673f-d6c08b72ed06" + }, + "0a430429-9078-9ae6-2b67-e4a15a2bf8bf": { + "isSystemType": true, + "bundleAlias": "input_widgets", + "typeAlias": "markers_placement_openstreetmap", + "type": "latest", + "title": "New widget", + "sizeX": 6, + "sizeY": 6, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "longitude", + "type": "attribute", + "label": "longitude", + "color": "#2196f3", + "settings": {}, + "_hash": 0.3640193654284214 + }, + { + "name": "latitude", + "type": "attribute", + "label": "latitude", + "color": "#4caf50", + "settings": {}, + "_hash": 0.49020393887695923 + }, + { + "name": "temperature", + "type": "timeseries", + "label": "temperature", + "color": "#f44336", + "settings": {}, + "_hash": 0.5885892766009955, + "postFuncBody": "return value || \"\";" + }, + { + "name": "humidity", + "type": "timeseries", + "label": "humidity", + "color": "#ffc107", + "settings": {}, + "_hash": 0.21077893588180707, + "postFuncBody": "return value || \"\";" + }, + { + "name": "active", + "type": "attribute", + "label": "active", + "color": "#607d8b", + "settings": {}, + "_hash": 0.34722983638504346 + } + ], + "entityAliasId": "12ae98c7-1ea2-52cf-64d5-763e9d993547" + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "fitMapBounds": true, + "latKeyName": "latitude", + "lngKeyName": "longitude", + "showLabel": true, + "label": "${entityName}", + "tooltipPattern": "${entityName}

Temperature: ${temperature:1} °C
Humidity: ${humidity:0} %

Delete", + "markerImageSize": 34, + "useColorFunction": false, + "markerImages": [ + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiNmNDQzMzZ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+", + "data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiMyNzg2MjJ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+Cg==" + ], + "useMarkerImageFunction": true, + "color": "#fe7569", + "mapProvider": "OpenStreetMap.HOT", + "showTooltip": true, + "autocloseTooltip": true, + "defaultCenterPosition": "0,0", + "customProviderTileUrl": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "showTooltipAction": "click", + "polygonKeyName": "coordinates", + "polygonOpacity": 0.5, + "polygonStrokeOpacity": 1, + "polygonStrokeWeight": 1, + "zoomOnClick": true, + "showCoverageOnHover": true, + "animate": true, + "maxClusterRadius": 80, + "removeOutsideVisibleBounds": true, + "defaultZoomLevel": 5, + "labelFunction": "var color;\nif(dsData[dsIndex].active !== \"true\"){\n color = 'rgb(255, 0, 0)';\n} else {\n color = 'rgb(39, 134, 34)';\n}\nreturn '' + \n '${entityLabel}' + \n ''", + "markerImageFunction": "var res;\nif(dsData[dsIndex].active !== \"true\"){\n\tvar res = {\n\t url: images[0],\n\t size: 48\n\t}\n} else {\n var res = {\n\t url: images[1],\n\t size: 48\n\t}\n}\nreturn res;", + "useLabelFunction": true, + "provider": "openstreet-map", + "draggableMarker": true, + "editablePolygon": true + }, + "title": "New Markers Placement - OpenStreetMap", + "dropShadow": true, + "enableFullscreen": false, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "widgetStyle": {}, + "actions": { + "tooltipAction": [ + { + "name": "delete", + "icon": "more_horiz", + "type": "custom", + "customFunction": "var entityDatasource = widgetContext.mapInstance.datasources.filter(\n function(entity) {\n return entity.entityId === entityId.id\n });\n\nwidgetContext.mapInstance.saveMarkerLocation(entityDatasource[0], null, null).subscribe(function success() {\n widgetContext.updateAliases();\n});", + "id": "54c293c4-9ca6-e34f-dc6a-0271944c1c66" + } + ] + }, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "0a430429-9078-9ae6-2b67-e4a15a2bf8bf" + } + }, + "states": { + "default": { + "name": "Thermostats", + "root": true, + "layouts": { + "main": { + "widgets": { + "f33c746c-0dfc-c212-395b-b448c8a17209": { + "sizeX": 11, + "sizeY": 11, + "row": 0, + "col": 0 + }, + "7943196b-eedb-d422-f9c3-b32d379ad172": { + "sizeX": 13, + "sizeY": 5, + "row": 0, + "col": 11 + }, + "3da9a9a1-0b9a-2e1f-0dcb-0ff34a695abb": { + "sizeX": 13, + "sizeY": 6, + "row": 5, + "col": 11 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + }, + "map": { + "name": "Edit location", + "root": false, + "layouts": { + "main": { + "widgets": { + "00fb2742-ba1f-7e43-673f-d6c08b72ed06": { + "sizeX": 24, + "sizeY": 12, + "row": 0, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + }, + "chart": { + "name": "${entityName}", + "root": false, + "layouts": { + "main": { + "widgets": { + "14a19183-f0b2-d6be-0f62-9863f0a51111": { + "sizeX": 18, + "sizeY": 6, + "mobileHeight": null, + "row": 0, + "col": 6 + }, + "07f49fd5-a73b-d74c-c220-362c20af81f4": { + "sizeX": 18, + "sizeY": 6, + "mobileHeight": null, + "row": 6, + "col": 6 + }, + "c4631f94-2db3-523b-4d09-2a1a0a75d93f": { + "sizeX": 6, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "0a430429-9078-9ae6-2b67-e4a15a2bf8bf": { + "sizeX": 6, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "margin": 10 + } + } + } + } + }, + "entityAliases": { + "68a058e1-fdda-8482-715b-3ae4a488568e": { + "id": "68a058e1-fdda-8482-715b-3ae4a488568e", + "alias": "Thermostats", + "filter": { + "type": "deviceType", + "resolveMultiple": true, + "deviceType": "thermostat", + "deviceNameFilter": "" + } + }, + "12ae98c7-1ea2-52cf-64d5-763e9d993547": { + "id": "12ae98c7-1ea2-52cf-64d5-763e9d993547", + "alias": "Thermostat", + "filter": { + "type": "stateEntity", + "resolveMultiple": false, + "stateEntityParamName": null, + "defaultStateEntity": null + } + } + }, + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "realtime": { + "interval": 1000, + "timewindowMs": 60000 + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1587473857304, + "endTimeMs": 1587560257304 + } + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "settings": { + "stateControllerId": "entity", + "showTitle": false, + "showDashboardsSelect": true, + "showEntitiesSelect": true, + "showDashboardTimewindow": true, + "showDashboardExport": true, + "toolbarAlwaysOpen": true + }, + "filters": {} + }, + "name": "Thermostats" +} \ No newline at end of file diff --git a/application/src/main/data/json/system/oauth2_config_templates/apple_config.json b/application/src/main/data/json/system/oauth2_config_templates/apple_config.json new file mode 100644 index 0000000..a956920 --- /dev/null +++ b/application/src/main/data/json/system/oauth2_config_templates/apple_config.json @@ -0,0 +1,24 @@ +{ + "providerId": "Apple", + "additionalInfo": null, + "accessTokenUri": "https://appleid.apple.com/auth/token", + "authorizationUri": "https://appleid.apple.com/auth/authorize?response_mode=form_post", + "scope": ["email","openid","name"], + "jwkSetUri": "https://appleid.apple.com/auth/keys", + "userInfoUri": null, + "clientAuthenticationMethod": "POST", + "userNameAttributeName": "email", + "mapperConfig": { + "type": "APPLE", + "basic": { + "emailAttributeKey": "email", + "firstNameAttributeKey": "firstName", + "lastNameAttributeKey": "lastName", + "tenantNameStrategy": "DOMAIN" + } + }, + "comment": null, + "loginButtonIcon": "apple-logo", + "loginButtonLabel": "Apple", + "helpLink": "https://developer.apple.com/sign-in-with-apple/get-started/" +} diff --git a/application/src/main/data/json/system/oauth2_config_templates/facebook_config.json b/application/src/main/data/json/system/oauth2_config_templates/facebook_config.json new file mode 100644 index 0000000..04847a9 --- /dev/null +++ b/application/src/main/data/json/system/oauth2_config_templates/facebook_config.json @@ -0,0 +1,23 @@ +{ + "providerId": "Facebook", + "accessTokenUri": "https://graph.facebook.com/v2.8/oauth/access_token", + "authorizationUri": "https://www.facebook.com/v2.8/dialog/oauth", + "scope": ["email","public_profile"], + "jwkSetUri": null, + "userInfoUri": "https://graph.facebook.com/me?fields=id,name,first_name,last_name,email", + "clientAuthenticationMethod": "BASIC", + "userNameAttributeName": "email", + "mapperConfig": { + "type": "BASIC", + "basic": { + "emailAttributeKey": "email", + "firstNameAttributeKey": "first_name", + "lastNameAttributeKey": "last_name", + "tenantNameStrategy": "DOMAIN" + } + }, + "comment": null, + "loginButtonIcon": "facebook-logo", + "loginButtonLabel": "Facebook", + "helpLink": "https://developers.facebook.com/docs/facebook-login/web#logindialog" +} diff --git a/application/src/main/data/json/system/oauth2_config_templates/github_config.json b/application/src/main/data/json/system/oauth2_config_templates/github_config.json new file mode 100644 index 0000000..439043d --- /dev/null +++ b/application/src/main/data/json/system/oauth2_config_templates/github_config.json @@ -0,0 +1,21 @@ +{ + "providerId": "Github", + "accessTokenUri": "https://github.com/login/oauth/access_token", + "authorizationUri": "https://github.com/login/oauth/authorize", + "scope": ["read:user","user:email"], + "jwkSetUri": null, + "userInfoUri": "https://api.github.com/user", + "clientAuthenticationMethod": "BASIC", + "userNameAttributeName": "login", + "mapperConfig": { + "type": "GITHUB", + "basic": { + "firstNameAttributeKey": "name", + "tenantNameStrategy": "DOMAIN" + } + }, + "comment": "In order to log into ThingsBoard you need to have user's email. You may configure and use Custom OAuth2 Mapper to get email information. Please refer to Github Documentation", + "loginButtonIcon": "github-logo", + "loginButtonLabel": "Github", + "helpLink": "https://docs.github.com/en/developers/apps/creating-an-oauth-app" +} diff --git a/application/src/main/data/json/system/oauth2_config_templates/google_config.json b/application/src/main/data/json/system/oauth2_config_templates/google_config.json new file mode 100644 index 0000000..f862643 --- /dev/null +++ b/application/src/main/data/json/system/oauth2_config_templates/google_config.json @@ -0,0 +1,24 @@ +{ + "providerId": "Google", + "additionalInfo": null, + "accessTokenUri": "https://oauth2.googleapis.com/token", + "authorizationUri": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": ["email","openid","profile"], + "jwkSetUri": "https://www.googleapis.com/oauth2/v3/certs", + "userInfoUri": "https://openidconnect.googleapis.com/v1/userinfo", + "clientAuthenticationMethod": "BASIC", + "userNameAttributeName": "email", + "mapperConfig": { + "type": "BASIC", + "basic": { + "emailAttributeKey": "email", + "firstNameAttributeKey": "given_name", + "lastNameAttributeKey": "family_name", + "tenantNameStrategy": "DOMAIN" + } + }, + "comment": null, + "loginButtonIcon": "google-logo", + "loginButtonLabel": "Google", + "helpLink": "https://developers.google.com/adwords/api/docs/guides/authentication" +} diff --git a/application/src/main/data/json/system/widget_bundles/alarm_widgets.json b/application/src/main/data/json/system/widget_bundles/alarm_widgets.json new file mode 100644 index 0000000..448fcaf --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/alarm_widgets.json @@ -0,0 +1,30 @@ +{ + "widgetsBundle": { + "alias": "alarm_widgets", + "title": "Alarm widgets", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAOPElEQVR42u2deVsTVx+G/Xb9ALVeffuHtmql2kVrVWytWltr64KoqIgbolRFRMQNtCwioiLIqii7grIoShARwnbee3Js3pgibzYQzPNcXLmGyUwmc+ae35kE7jOzjDHDw8MvFCVCGRoaAqpZlqqxsTGjKGEHkMAJqGaJKmUy2HLAUlsokY0DVk9PjxpCiWyASmApAksRWIrAEliKwFI+DLD4pst3gbKyss7OTjWcEi5Yubm5X3zxxejoqP01ISGhtLRUDaeEC9ZPP/30xx9/UKj8wBoYGLh9+3Z5ebllrrW1taOj49atW263u6mpqaWl5ebNm/39/c+ePbt+/bq3yD1//vzatWsNDQ1q+qgG6/Hjx+vWrauvr9+8ebMvWPyVccWKFZmZmXv37k1MTGT+rl27li1bduLECfD67LPPjh07lpyc/N133+3YsSMrK2vhwoUvX77s7u5mzvnz59evX3/lyhW1fvSCBR95eXlMAERvb68XLMoSzLW3t9+9e3fx4sUWrJKSEiYAKzY21q4+b948ChsT27Ztq6mpoZKtXLmSFYHs0aNHav0oBYs+jquruLi4PXv2fPPNN1QaL1ivX7/++eefjx49mpaWFhMTY8G6c+eOBWv16tX2FebPn287SupWZWUlE3SdW7duXbVqFVVQrR+lYAEQPWCnJw8ePKCn84IFJfBh+8pFixYFCFZtbS0XXvxaUVFBDVPrRylYf/75p/ea3V7FNzY2WrC4Kl++fDld3u+///7pp59CTyBg0ZlydbVp0yYY9X1lJRo/FU4Qe/0UbAYHB71fXigCS1EEliKwFIGlKAJLEViKwFIUgaUILEVgKYrAUgSWIrAURWApAksRWIoisBSBpXz4YO3evXvleEHameJ3WVBQoEP14YAFQ0HNn7xM/RZHRkaCWp4RLt7j1qMOLLyd77///qOPPlq6dOmpU6dmBFjV1dWYkrzt1NTUwNc6c+YMCi7jXIQv4m7YsIH9xRB++vQptrDfC+JjFhUVqWI5WbBgAboYLYLyigN98ODBnJwchDCU1ydPnvT19WEvopG5XC6/FfGt/bZoBxCf1Ozfv/+vv/7CI2JkAEb/PXLkCO8TrRIpnIYCHexIhp9g5qVLl3DdEHoZZODcuXNYcXPnzv31119Pnz798OHDGzduWDU8qKAwffzxx69evUIc5xVQyeGMRkPDZAKLk+3SnnV1dbxJe7lCecO941lWiUawkKcZwYEDk5KSQnNw2VRYWMhR2bdvH3MOHTrEkfNdC4eR1gQ77xZpbpZn/qS2CEeRQVC+/vpryi2jnvz444/FxcWIkOCSnZ3NBOOaoFJyFBmi4t69exRj9o63SqnjbGEt9vHw4cPIlZw2IbyBq1evfvXVV7wUkO3cuZMXbGtr4zWTkpIOHDgA0zxCGJqn8QxiANw4w7wf63VGHVhM/Pbbb19++SWNAliczRwJ5lCrOOE44/99wgEfvjWaK1u0VE3BUCKXL19mKGnomTNnDiQx9gnvLT8/n5n0j2vXrmX8nNmzZzOTksZ7pkQZz+AUXrA4hehM6ctC2DrbZR+plCDFJixYbJ2CRO2kNFqwQJZhCsw/o2Pgl9OMdAVRChb7D1i2gHOQOC/Bi2PDYaDCc2D+vS6tDE9scWqoMp6BBRiogi1u374dpildHFR7gYj8zTFmgpJGwaAHZzwmX7BYzDYOJ0xo15T0a7/88gutQXVvbm5OT0/nBRkcyl72bdmyhSsKOlwuvBhWg01/8sknjOFD1WQV2zlG3adCPjfR2XGRa8ECKe8nKS5oJvgcRN1ii1M87JHvxdy4n/je9TGQFbno5g1T2CKydTvtuzk7x6/RpuDqM2Jg8X3VuN9jMSZWCFuiaFHbbVtQpWj9wNdl/KMZ9EmboZ0Y9UTfY+mbd0VgKQJLEVgCSxFYisBSBJaiCCxlRoDFXzF143UlsgEqVSxFXaEisBSBJbAUgaUILEVgKYrAUgSWIrD8M30Ue+WDAmv6KPbTKSNmbNLviee1JDAD8N6CXZ07vX/gYCFB4KOiK/GI/BmpN42CPIlN0pRsbi14M125xtRufuvZzlzTM7nCMboi5hl3IcXIOHv2bAh+M45hVFQsa95ZICAM/47GQn1GRqXhsFXxQnkP9LDcvhUP2C6JnFnjCQtwO9aMjAy6Zqxz5EScO3xo5hiPeIi7zKuxOiIQE2zCaoyhg1U027xsMO4eU/wfB6zhl6Z2m6neYPrbTFehcVWb/sfm3iZzP84M95vWDHNvc6RoQ1XFSbRF6+LFixYs7EUkVSsqImTTSjQCFj86K24c06iqqNKsgi3NNI5hFIGFvLtx40YGQUD1ZKwLWoQ/gNtGRNFk2lpl69atwyjn9r4suWbNmlxPcOiQNquqqhDevWekfQQjjE2W5JDQprwswOGZhQVWfaKpTzCt6c4EYIERhaphv2lIMi1HTcffpiLWmfnkvGlJNTUbTddVp4uM0PFAp/b+asFir2kNWg+J3LYDbcgjd3oHph9++IGhHDC2UTVh0SqvUQQWDUR5t2cYYFF7qDSccxYRwKLYGI9DTJMhGbMkjQhVLE8FYj53leaW0l6kLJQWLDuHtRhuhBU5m8MC60mOKVvm/DwvdcDqLDA1Gxyk6ve+Aaskxgy5nPnQBli9dRG8ukK89qtYGNgFnrS3t9t2AB1aiUFWaBkMafssbcUwE1HXFdJSmOmMlnHhwoVxwUJsT/IE69cuSctasBgNgTOSzpFlWJ5SzzlKj0CRYzwML1gM0QG7LBbWWEKA1Z5jWk+ZugTzvNwBq7vEIenOcqfLs2B15pmKFQ55/a2RBctWKXYNm55RGyxY6OPsFDuL8m/b4dtvv7VgsTy/UqqxzBlAhaEl6DSp31H3qfBdZrqtWL7P+i3pa5SPeGLG88ontvXDuPbxvJnhAefSqqvozcfDSQv77nc/9omHI/A+y/XANBm0beoU+4lDSecyYrp/1eB+YVrPvIFMCRksRRFYisBSBJaiCCxFYCkCS1EEljKdwJJir0ixV9QVKgJLDaEILEVgKQJLUQSWIrAUgeUXmdDKpIAV1Sb02/9y7vzq+7/kE9zDDQs5HKvRE5nQ/ycz0oTu7jbcHHX1ahTH/8HETTrz8kxdncGvev3aJCW9c3V8obqwpB2Z0IFmhpnQKSkmJ8eZ4IalbW0mPt6sXeuAVVho0GUXLjS4ZYcPO8whleAc44Bw7+dNm5wf7mAdHlgyoYMGa8aY0Bs3Gt8iMWcOHZLJzXWAKyhwkHrxwixdyj3BTUKC89TZs6a21sGO3fz77zDBkgkdNFgzxoQGHTAimZlOKZo715n+N1gXL5rUVKduUdWYmZhotmyh0oYJlkzooMGaMSY0RWjJEqeP4wdufMGi15s3z+EGsFwu51KMprhwgf7JmV60yNBbhX2NJRM6lMwYE3pwcPz5brff/vhPRCIyoSOWmWFCKxEBS1EEliKwFIGlKAJLEViKwFIUgaVMJ7BkQisyoRV1hYrAUkMoAksRWIrAUhSBpQgsRWD5RSa0MilgTXMTmv/v9k5b99d3zlRmZHTkvex1VIMVlONmzacA17KOio21dOycMD3pQ3eTXwz0lHXeyWzI4tftZfFjZmxiqi42Z0f2qKAhlZeX+81EbRoYGEAe7OrqElhvIYJKiquE1YTShMrMI9OoWjyOC5Z1ne0jRhRd8PHjx9GbUDpxUex8BClec/HixXbJoqIiPGnMuxyPzWzt4aCS+zCv+llNen3G/qoDPYM9KfdSe929p+szTj5I63W/LOkoOV13pvb5/cyGc8fvnyxoLRweGb788Epnf+e5pgssXNlVNWrGeDajPjO7JSeEQ8If2rgIsdoqAzewv7jg9fX1S5YswZxDm6PF7CnE/iLG4cnRArhfUQpWbW0terilh7EuUlJSsOFoEY496rNdBj72eTJ//nzztvGMIc0j6qbb7T5y5Ah9H/NdLldcXBzz8en8lme7HIBUnNIg0+hqutScDSKZjVnFT24WtBV29HdUP7sLK9cfF4NLRVely+3aVbFn1IxuLY0bHB3cU7mvsacp7cGpvqE+pmu677Lu4Mjg9rIdIRySkydPMuYFxiXmUnJyMpIqEjnnHnhhYmI/AxnWIY4hDcUOcgrBWXp6epSCxamWn5/PBDIqFwrx8fGHPKG93tUVWtfZFxc0c1uHaFzmdHZ2ckL7LWMfaXE8fY5KsC3iHnFvK4271JINTFtKtz3qbb3RfutMQxb9XeHja4D1sPdRn7vvQNVBFo6/s9MLVlbjebrFXeV76EZzH+XbZ0M4JCjzEMObZ6AA6hZ4UZ4pSL5gARMNiLXLApQxNOjm5uboAouOyRYhKjxIQRJnJPMxnlGcORHp494FFhix4ueff/4usJhGfabysRXvMrGxsdQzLkToO0JrFKpR3Yv6V0Ov1havh5XKp5V7q/YlViXR5QUCFs/urkg8cT8thIqFzG3bh3OPRgCdzZ6wv3R8aZ4wTQ8YExPDYkzTqrQSlxZR/akwWE93OADD2O81WYU5IBvW0CBvZ3RsNPCF6QRvdty63VF6rPZ4BD5y/qN9O9XUz8O2722SboM9eWBNpQkd2cAWVL3H5qbglbTfHhgZ0PdYiiKwFIGlCCxFEViKwFIElqIILGWagyUTWpEJragrVASWGkIRWIrAUgSWoggsRWApAssvMqGVSQFLJnSAkQn9HsCSCR1CZEIHB5ZM6EAiEzo4sGRCBxiZ0MGBJRM6wMiEDigyoYOKTOhQP0DJhA6+xWRCT5fIhJ7WYCmKwFIEliKwFEVgKdMMLL5hUkMokQ1QOWDNlL+ZKzMi4OSANTQ0JLaUyFLFl4izjOcbxe7ubv709lRRwggIAZL9avq/0p2LbK71A+cAAAAASUVORK5CYII=", + "description": "Visualization of alarms for devices, assets and other entities." + }, + "widgetTypes": [ + { + "alias": "alarms_table", + "name": "Alarms table", + "image": "data:image/png;base64,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", + "description": "Displays alarms based on defined time window and other filters.", + "descriptor": { + "type": "alarm", + "sizeX": 10.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.alarmsTableWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-alarms-table-widget-settings", + "dataKeySettingsDirective": "tb-alarms-table-key-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originator\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/analogue_gauges.json b/application/src/main/data/json/system/widget_bundles/analogue_gauges.json new file mode 100644 index 0000000..b2ae314 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/analogue_gauges.json @@ -0,0 +1,105 @@ +{ + "widgetsBundle": { + "alias": "analogue_gauges", + "title": "Analogue gauges", + "image": "data:image/png;base64,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", + "description": "Display temperature, humidity, speed, and other latest values on various analog gauge widgets." + }, + "widgetTypes": [ + { + "alias": "analogue_compass", + "name": "Compass", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAh70lEQVR42u2d+b9V0//H799yJTKTIUPGMiXKGJlSxjJkqkwJCSGRhPDVTESKjImKkCZRKZkyD6mIhL7Pz3k9PuvztvY++5zb2WcP9+73D/ex7xn22eu9Xus9r/dq2FLQli3//PPPn3/+uXHjxvXr1//8888//PDDd9999/XXX69Zs+aLEn3++eeflYgLvcJbfICP8WG+whf5OjfhVgU/oYaWOey///4bHPzyyy/ff//9V1999VmsxA25LTfnJ/ihAljNH0y//vrrjz/+GDuSKuKMH+WnWxTImj+wUE8Ij2+++eazDBCPwcPwSAWw8o2nKoXT6tWr33rrraeffvr++++/4YYbLrjggtNOO+2YY45p3779brvttuuuu+64446NJeKCf3mRt/gAH+PDfGXkyJFTpkx5++23uVWVYqx5I6y5AQvb+bfffvv222+j5/WTTz557bXXhg8ffskllxx99NEON7UTt+rUqRO3vffee2fOnMkPRT8JHsCGDRuan8nffIC1efNmvDP8tXJT+Omnn7766qtDhgw5+eSTY0RSNO20006nnHIKPwqOeYByz8Zj8/AMoQBWhmjTpk34/BHC6cknn+zdu/dee+3VmCrtvffeffr0mTx5cjkxRiyDgTCcAljpG1I49uXk09SpU5lI7KGtw0GrVq34u8022+y+++5cHFMiLviXF90Hqqc2rVvfe9JJXfbZh0dCVz733HPlZBiqPO/wamh+kPrggw9GjBhx2GGHNWnW9913X9QWF+ecc45e6devH39bt2595ZVX6l+9wr/bbrut+wDUs2dPaT1uEvETvQ4++Jcbb3yzd2/3Ch4AWnLRokWhA2GA+bXu8wcsokGYIy4UbmnOnDnMeps2bapB0n777Sc0nHvuuVwce+yxEkhXXHHFdtttZ3GjCwes/v37e8i7/PLLJdK4CRfcUBjVTziacOaZAOvGzp29J+EOuJavv/56qHIkBpbHAFjOgEXmJNQ8nzZtGjayNFQ0nXDCCVJhAwYM0CsCyp577nneeedxcfbZZwsQ5YDlvc6HzzrrLC7OP//8tm3bWuTpJ/g5frR1q1ZfDBgAsA7cddfQB+Phu3XrNn369FDTHs+xAFa9nL7QIMKMGTO6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", + "description": "Displays latest value of the attribute or timeseries key on the compass. Expects value to be in range of 0 to 360.", + "descriptor": { + "type": "latest", + "sizeX": 6, + "sizeY": 5, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbAnalogueCompass(self.ctx, 'compass');\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.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-analogue-compass-widget-settings", + "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\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"minorTicks\":22,\"needleCircleSize\":15,\"showBorder\":true,\"borderOuterWidth\":10,\"colorPlate\":\"#222\",\"colorMajorTicks\":\"#f5f5f5\",\"colorMinorTicks\":\"#ddd\",\"colorNeedle\":\"#f08080\",\"colorNeedleCircle\":\"#e8e8e8\",\"colorBorder\":\"#ccc\",\"majorTickFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#ccc\"},\"animation\":true,\"animationDuration\":500,\"animationRule\":\"cycle\",\"animationTarget\":\"needle\",\"majorTicks\":[\"N\",\"NE\",\"E\",\"SE\",\"S\",\"SW\",\"W\",\"NW\"]},\"title\":\"Compass\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "temperature_gauge_canvas_gauges", + "name": "Thermometer scale", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAUNklEQVR42u2daVMVRxeA80/85her/KCW+07UuCSlxoq4lEnc4p64JAoiolGTGJFF1igCoigSX4jIGkAwiqwCCqIgiMoOolHcxbwPtI5XLnBn5uJ+TnVRM3NnzvScefqc0z1Dzyf/iYi8BvlETCAiYIkIWN3Ks2fP7j982FVpbW2VG9ON3L13v7qhqfXZsw7b2dLQfOtGXeODh48+UrDutNzb4Lmvq1J+o+Y9us2PHj+5efvOk6dP38zpLlfe2OIf9tv+I3uPxVm2wPySK78GHdFs+Mefsc3/3vnowHra2lpV36hKbnEphkjJOqdtefjo8XsEVlHZVep/vbbhzZzO/2jMhbKruHyfw3+VXqtSG3OKLlOHo4lptY3NNFqqtGNf+M7giEePH39cYFnKtdp6jHKupKxT/m7dufv4SefOoOX+g9t3W7R4gM9g56dPW7vykUSQrvTwa6c/EZf5VVuF+Nt3WjpEahNgUWf0WDo5NFN56+hmLfujEtJyCjn895CjmE4d6+Z/IPR4kuXBl67eoFYAJ2CVdUjC8GGb/UL5aeOe/VEppxUx/GXLXyczaLjK5+8Kjay/eSs1K1/t7Oy9P/50tlLy7922gJuYkesRdkztHHA0Bha1s9Q03qTdq592H/hT3Sck9lTWJp8QDnT2CvI5HM2W5n/vBkcnsMqeLnuCE87kqLvo6huiRZ9f9h1mS+Hlcpar65u0s3iHRwX9L14tbw0IC49NYQv7nC28qLAOO5Hs1K7558CDeRfLujdXXVMzl0MdqKTaos5IiHwl32pt9Y+ISc48J2C9YtDEMznYLqOgGGgyz5fAVtw/WRpYm3yCuSs1DU0cxa0l58DWWBa3EZGQxg5l16o1sFx9Q7MvXCLCns4vQqfvkb8UEzR6GjqZCkdRB24Dd135J+4ZB7qHRnKWS1ev4zhZhryKqlrSKYjXnAFJ9Km886zmXSwlDOkBi1+PxJ+k5lwamr0PRRGzqDyH//n3KSfPfVerag0Z8O+zeegEfekV2gDr3oOHkJRwOkfbEp16BgievQDreNpZ7adjyf+wBW60mMK9US1VgZWSla/tzLK2MzohUutAEYmcXngRwGKZw7VYiRfU/BlCmozj6TQU2gSLWGaZcbPztZp6zc2g+UjCSUMGxH+j5I31Ht5jsIquVLKFhni+tEIVIGALmZACKykjr0N7ffzkibYFf6bIU2CdyS+yjCPauYihXgf/p52CArsx7QcCFlHPOhUrLq88U1BMirN9b7jGh1GwaAnaT5FJ6dS20KIOOFSis4D1WsAi9rGF24xHsSy1jTftBAs42JKee55l9BMZO5yCYNQpWOm5hWRdRMOwmL8PxaZwbI+AFRydyIk61IGwbsiA5IISCnWBpQJEp/2sHvFYKkH+JegwlHRapQ5gNTbfJjKmZhdoW7wORXUJVmmFfrDC41K3/3HITgOebz8jbr6DoWgGlumEgPUfCTI3UmXrWvKhBgtMgEXk0n5imS1k/eqm4h7I57RftaSqA1gXXkWHOgDlS7DaA7eWJzG6yyq9fa17+3vw0a7Aoj/BzpXVdZaj6k8NPnhghJYLocP7zGK0QuFecOmKgPVKrzAy6dRG7/04CbwFuTZdfc+DxzCcCbC4l3gRhqHpuNFDpBuojTWoTiJ9PeII8ZcDSyquWYPVdOtfKsNYNnvSfUODGrmwrH/cP9m4Q9XzYOCDPiaPVvhJ9VK7Aouuxm/7IxjMhF3qQA8UZBnLUCF1T3i0zoczWI+meDA2BUa5Lq6FkQv6m12N6n28YGGRmPSzpDVqiIhRKzrn5jwWfXv30D+VHr+I44wyaHsCijbExS3Hnz3rzGOpO4dXUINYJ9IzAZ39lYfgL9zwEzuonQsul2uDakRb+OgKLDVCxq9OL0bISPLUcPk/5y6wRf9DiAulFbhGdS3YjVF4y6HdjxGsbgSMCIuW0cqQWOZYKOlqeJ3tbUP2tgIQw+JA2dVunMvSu7BzN48BOh3i50otnzHQYWRs3eglcy1aQP/Yk/fXJ9bJ+/siQIrffYsPZASsDxMsRBvyFbDeOSFBIW163++QgCUiImCJCFgiApaIiIAlImCJCFgiIgKWiIAlImCJiAhYIgKWiIAlImCJiAhYIgKWiIAlIiJgiQhYIgKWiIiAJSJgiQhY3cqdOy+n4L1582ZFRcVTgxMy1dTUPH4x16pRDffv3798+fLdu3ftrENVu5gwU319fVlZmTb7KAucnToYUvLo0aPa2lp7NPDv/JoRTGhoaGgwfRVU/onFPAYdLMmdLS0ttbxBtsFKSkqaOHHiqlWr1OqRI0e++OKLH374YdasWfonZC8sLOzVq9elS5dMaMjLy6MCLi4uY8eOzczMNF2HzZs3f/PNN19//bWbm5uh27ljx445c+asXr162rRpD9unoXd0dGSVOkREROjX4+rqOnnyZHVHjWrgpjo7O3/++edRUVHmNISEhEydOnXNmjUzZsyAA/0aaNIbN27s06dPZWVlp5ZsbGycMGHChg0bxo8fn5ubqwsscGZvS6hHjBihvNfy5cszMjJ0GuXLL78cM2aMAsuohm+//TY7O1shDkzm6tDU1PTpp5+qZQcHh+bmZp00sOfo0aPV/B8LFiw4e/bs6dOnV65cqbz4qFGjdOqheUyfPl2BZUJDaGjoli1btImKTGhgt9u3b7Mwf/587KlfQ1xcHFcNfwosa0v6+/sHBgayClULFy7UBdaBAweA+qefftq5c2dLSwukDx06VP20e/fuQ4d0TRrm7e198ODBmTNnApY5DUr27t3LBZjTUFBQsGjRIrUMH3hQo9GQJo7jpI1xLV5eXmrjkCFDLANEN3EEb0cdFFgmNHBgQEAA/iYmJsachhUrVsTGxlZXV3MVRGSjGvB2CixrS65fvz45OZnVW7duacx1DhY14MQ5OTnbt2/H2RQXF3t4eKxduxa6R44cqfbZs2dPUFBQV/XQNBDUZ8+eTVNTYJnQoFbRQ6Mh2dKvwVJoc8uWLVPLS5cuVSHVkHh6eqo7Ad++vr5q4/Dhwy0Ti67E3d2d8E1SosAyoaFv374+Pj5EpUmTJmVlZZnQkJaWNmjQIA7H63M7jGrQwLK25Pfff49yVnE9qOoOrPT0dFpGUVERQTQ6um2ic3KLYcOGwTV0a8bqJjZrGvD/W7duBRH8LYfgSI1qYLmurg6LlJeXq8CqU0OHJI8QoJZZUGr1y7FjxxYvXqzyufDwcM6rtXWbHQho6N+/f1hYGGQMHjw4ISHBqAakX79+WqpEBDCqgR3YTeUPuL34+HijGjSwrC1J8peYmKgSp88++0xXKCRVJHFT1sF1sYCvo4vEAulbfn6+TYvgeGLahdSbQEbL0DSQ/enRgDmII5bBy6gGLZMASizIgmUnV09bJ3knnGnZEteuOlnjxo3T0xVVFiBPonGS3BjVgODvlQVopZGRkUY1cOEDBgxQl0AfwoQGDSxrS3Jdu3btUobSOnmf2CR9yZIlxDJIpCpsIZqSzrNF5dH6RYVCExqcnJwGDhzo+EKokrk6kKNMmTKFeMqC/qOwYO/evTlQnV2FD9UhpSukcgv9gx0qFJrQUFJSQm4E3/PmzVN8GNWAq+Psc+fORcODBw+MatDAsrYkzuKrr76ikVNDHJCBcSwyG8uJU4kIqmam5W1peNwuPTIAyNnt/PydCQ337t2zRwONhFvZI1dhbckOdZORd5G3OvIuIiJgiQhYIgKWiIiAJSJgiQhYH7B8aiViEwFLwHqfwcLWntmP7CloiLvyxJ6ChtLmVnsKGlovl1gXa7A63U3t+eRElD0FDQ9377CnoOH+0nn2FDS0OAy0p3Tf9gQsAUvAErAELAFLwBKwBKx3GyzXo+fsBGtvQoGdYMVnFL51sAr+8LMTrHObfrITrNxvHO0E66rD4HcFrGW/7LcTrE2ewXaC5REY8tbBCnZxshOs/UsX2QlW0FfT7AQr02GIgCVgCVgCloAlYAlYApaAJWAJWAKWgCVgvQmwRER0vgAir83IazPyPpaAJWAJWAKWiIAlYAlYApaAJWCJCFgCloD1XoN1/fp1ps388ccfmbSYWbaYU4vJtdatW8f8XVeuXBGwRAyDxbx7ADRr1uwtv3r6RqYFxF4ISK0KPFkTmFjqdzRtu7vfrNlzmAzu/PnzApaILrCY8Y2JgR0dZ3mGRPun1funNfqnNQQ8L/UBJ7VS4x12AryYSJhJbz8MQ3z33XeWVLEqcPQMWMxSyoTbPzptwj/5pze2U9UVWHWUwKRKZ9efmYpZz1zQ774wL7IlWKwKHD0AFr4Kqrb85h0ATOmNusBKrQtIqd32+x7Y+gD8Fl9tYAJfRRUL6iMOIvaCRQRs81VtVDXpB6udrRrnzdvkOb+82tAJWGTr5FX+qdXtVBkDKzC19mVJqfnjZaluK8ltZW9y1fPyN+XGi3J9HyVJlWvPS+K1oMRKi3I1KOF52Z9Q0VbiVSlXJTj+Ci9wvShlbSW2LKStlL4sJy6rEtpWLrWVGFVKKAcoxykXKds8jiiTMRW7ZqBntqTVljy1JU9syWNb8siWPLQlD2zJ/VdFF1j0AT1Cov2eU/XxghUSfWHy51P5ToLlh2UELJNgMV7FyILfqQYBi7Jy3TY/Pz9LAwlYJsE6fPjw1p3efqeaBCyKV1BSh+FfAcskWIyt+xxNF7BelOIOBhKwTILFJ1ACEi4JWAJWD4PF12D9T9YIWAJWD4PFN5wELAGr58Hio3X+8RcFLAGrh8Hie8+SvAtYPQ8WL125/eopYMlwQ88PkPI8RwZIZYD09TzSCY4SsOSRTg+DxXd825xWSpU8hJaH0D0JFuLt7b1u/caAdFOvzbj+LO+QyGszXb7oR0DcavxFv62/ecmLfiLdvZrMG8awtXb9Rv+UGzpfTd7g4sZ/VcirySK2/5liz549bf9MERzVzT9TBKZWex84zss2BFDit/wzhYgNsJSUlpbyygN4uf3i4RORGhh7ITDlRkBqdUD8Jb+jJ7e5+/LT6tWri4qKPiRDyL9/vXawlFRVVUVERKxfv55/WOVB9aRJk/iHVYbpw8LCrl69+uEZQsB6Q2B9bCJgCVgCloAlYAlYApaAJWAJWAKWgCVgCVgCloAlYH0oYMnzfJHX8skT+UiTfKRJvv4lYAlYApaAJWAJWAKWgCVgCVgCloAlYL1psFzCs+wEyz8m206wjqfnvnWwsv287QQr03mdnWBlfD3TTrCuOAx+V8CyWWyCZbPYBMtmeQNg2Sw2wbJZbIJls9gEy2YRsAQsAUvAErAELAFLwBKw3mGw5LUZEXkfS8ASsEQELBERAUtEwBIRsEREBCyRdx4sPkWuLd+8ebOiooJZWQ2dqaamhmlYzWlgNlVm4LWcOtBcHaraxYSZ6uvry8rKmKlWrbLA2amDISVMUldbW2uPBubS1YxgQkNDQ4Ppq6DylpNJd7Akd5Zp1SxvkG2wkpKSJk6cuGrVKrXKBwf4/A4zRM6aNUurok0pLCzs1asX3wg2oSEvL48KuLi4jB07NjMz03QdNm/ezPdd+NqZm5ubodu5Y8eOOXPmMNfctGnTmG2VMzo6OrJKHZhCTL8eV1fXyZMnqztqVAM31dnZmYnKoqKizGkICQmZOnXqmjVrZsyYAQf6NdCkN27c2KdPn8rKyk4t2djYOGHChA0bNowfPz43N1cXWODM3pZQjxgxQnmv5cuXZ2Rk6DQKk6ePGTNGgWVUAzO/ZWdnK8SByVwdmpqatDFPBweH5uZmnTSw5+jRo3EVLC9YsODs2bNM1r1y5UrlxUeNGqVTD81j+vTpCiwTGkJDQ7ds2aKqYU4Du6kZe+fPn4899WuIi4vjquFPgWVtSX9//8DAQFahauHChbrAOnDgAFAzkd/OnTtbWlogfejQoeqn3bt3Hzp0SM8lMVXpwYMHZ86cCVjmNCjZu3cvF2BOQ0FBwaJFi9QyfOBBjUZDmjiOkzbGtXh5eamNQ4YMsQwQ3cQRvB11UGCZ0MCBAQEB+JuYmBhzGlasWBEbG1tdXc1VEJGNasDbKbCsLcm0j8nJyazeunVLY65zsKgBJ87Jydm+fTvOpri42MPDY+3atdA9cuRItQ/T4AYFBXVVD00DQX327Nk0NQWWCQ1qFT00GpIt/RoshTa3bNkytbx06VIVUg2Jp6enuhPw7evrqzYOHz5cz6TR7u7uhG+SEgWWCQ19+/b18fEhKjFnZ1ZWlgkNaWlpgwYN4nC8PrfDqAYNLGtLMh87ylnF9aCqO7DS09NpGUxfSxCNjo5mC7nFsGHD4Bq6NWN1E5s1Dfj/rVu3ggj+lkNwpEY1sFxXV4dFysvLVWDVqaFDkkcIUMssGJ2Zl9m5Fy9erPK58PBwzqu1dZsdCGjo378/c7dCxuDBgxMSEoxqQPr166elSkQAoxrYgd1U/oDbi4+PN6pBA8vakiR/iYmJKnFiinxdoZBUkcRNWQfX9V/7w1q6SCyQvuXn59u0CI4npl1IvQlktAxNA9mfHg2YgzhiGbyMatAyCaDEgixYdnL1tHWSd23icbIlrl11ssaNG6enK6osQJ5E4yS5MaoBwd8rC9BKIyMjjWrgwgcMGKAugT6ECQ0aWNaW5Lp27dqlDKV18j6xSfqSJUuIZZBIVdhCNCWdZ4vKo/WLCoUmNDg5OQ0cONDxhVAlc3UgR5kyZQrxlAX9R2HB3r17c6A6uwofqkNKV0jlFvoHO1QoNKGhpKSE3Ai+582bp/gwqgFXx9nnzp2LBr6NY1SDBpa1JXEWzKhNI6eGOCAD41hkNlp/RKWxqmam5W1pUJ806pEBQM6uf6SjpzTcu3fPHg00Em5lj1yFtSU71E1G3kXe6si7iIiAJSJgiQhYIiICloiAJSJgiYgIWCICloiAJSIiYIkIWCIflvwfitp+zIgm0XcAAAAASUVORK5CYII=", + "description": "Preconfigured widget to display temperature. Allows to configure temperature range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbAnalogueLinearGauge(self.ctx, 'linearGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-analogue-linear-gauge-widget-settings", + "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\":\"Thermometer scale\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "temperature_radial_gauge_canvas_gauges", + "name": "Temperature radial gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display temperature. Allows to configure temperature range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 6, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, 'radialGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-analogue-radial-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "speed_gauge_canvas_gauges", + "name": "Speed gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display speed. Allows to configure speed range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, 'radialGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-analogue-radial-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "radial_gauge_canvas_gauges", + "name": "Radial gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 6, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, 'radialGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-analogue-radial-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json new file mode 100644 index 0000000..4930219 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -0,0 +1,222 @@ +{ + "widgetsBundle": { + "alias": "cards", + "title": "Cards", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAT5UlEQVR42u3d95MUVffHcf86/UEtqwxVapmzaH0VE2YR82PAgBkTKCYUAyrGR0mSxERwBXRV0EdFRcAc5vvaPnptZ5bdZWbDwJ5TW1s93T23b5/7vufe7t3PuXv8+OOP36YNZrzUqCzdNUR37fHdd9810gaz4qV01xDdlWAlWAlWgpVgJVgJVnoqwUqwEqwEK8FKsNISrAQrwdqlwfrtt9/+V7Off/55gIJ++OGH8m46bOvWrb/88sv4Aeubb76pu8vb5z/++MPvEaqPwocL8RUrVqhwfc8rr7zSYdsNBFZvb+/hhx/+n79t/fr1ree8+eabn376qY03KrPx2muvff311zaeffbZd955Z/yANWvWLF76v8psPPLII19++eVdd901QvX57LPP7rvvvqGfv3nz5ldffbXfQzfffPPy5cvre9zC999/P4JgnXfeefU9n3/+uX6pEkHMxo0bL7vsMh5UCU786quvfGXSpElz584VwByNkvUtX3nrrbdsRDmrVq1auHDhtm3bdr+h8LHHHpszZ05s//TTT9EbN2zYwG+LFy/mH+OACMGBf/75Z5zW09Pj0Pbt2wsxuuimTZvi46+//qr3vvvuu3H+hx9++MUXX/g6D3/88cdxzkcffbRo0aItW7bER+FHCa5VamW0ef75588555z4iootW7Zs5cqV0SLA0hyuUmJHAUutHOo3pnQElqpsq8xt2DNjxowzzjjjySefPP/8819++eUPPvjAxh133AGpJ5544plnnnHDp512mtNUa/r06QsWLOCOKVOmPPTQQ7rXDTfcoJCHH374+uuvd/KECROaRs/dDCwOvPjii21Mnjz58ssvF8JPPvnkK664wsYll1zCAw5xpvD21FNPcazRR5fj8xdeeIEbuff333/nYWXedttt/On8s88++8ILL5w3b56jV155ZQxbPKxMNJh+QEdRgVGJQ1pw5syZTnj77beRfdZZZ6nk7bffrtgAK/b4CowKWO5UUUoWPlxlOME69NBDL67MtQOsl156yYYa33rrrTbUbMmSJTYCLBvuVhe0EWDpYVwT0w5u1VduvPFG96AP6cfjByxBJToVkmysXr0aTwLGUUcdJbRzztSpU5cuXWq0uvbaa83MvqyMn/nTUVHq2GOP1UuBZdxQQgHrxBNPVLhzOFxv95VLL73UdxVSwh5bt24duG3A1xUVog5aJMBy6agwagtYjz/+OByV7FqYG8GhEFgxThvXArVBwRJgTznllFv/NtUFkxqrqI965DgBK8YgNy4AFCy44pBDDinO0dLQMUlFhpBmVqQbT5w4sZyACWAZH+pg1UuIEGVYdEh/BlArWPo2ejTlo48+esIJJ9TnWFoHUgUsE0T1j5IRNqpgCafmB01grV27toCl0winMZbHfZqTRaDiPvFsPINlQ8ww/bIhxhikjEQIiCFy9uzZTlNCzK7Ce61gnXnmmfH8BETjgAnce++9F0PkvffeWypmnsThNhwVLG1omuOPPz7AevHFF+Px8Oqrry5gGXDvueeemCVHmBz+oZC9//77rWC9/vrrpkpr1qwpYHGr+YFbDbDCR27eXfkKH82fP98E30fu3oXeR4wQWGalp59+ulgiRCn5k08+4RyH9EazeCc88MADorsSoo1bwdI5nawEcSgGULHKIcXG0FHeB8HIo6sN57uKrxx88MGg0S6TK0N51DPAAjrO3IICQTbaL0iFn/KAU+6h6RxDnlqWj84XkPMFaf2pbQAHct2gc4amElobJcopPbnV/w6Vx/ah7M837/nmvcvevKclWAlWgpVgJVgJVlqClWAlWAlWgpVgpSVYCVaClWAlWAlWWoKVYCVYCVaClWClJVgJVoKVYCVYCVZ6KsFKsBKscQPWJ5UlWLu9o0YbrP9W1t53y/9c07iFfr+9QuirmnYOXX+2s2BRMI++o9qwej2JYjhk0JoPxWkDgUU8FCITOl1Ky7ECi+6FaOTUU0+lCrdNv9+2B4877rj6HhIU0hRyFAKkzsG66qqrjj766IMOOogSkDyGym2swKK9adoYwOiHi+yCzDAUxdH6/Z5PpnXNNddQZIVQu02w9ttvP7LBAIsMje8oxOm4faQZuuiii8iJzj33XJo1whISXoqipvwqRRZS99fQe7Oq0wq7c7o2mklg0S25or5FBYUM6m+6PDV0txRzNGf2kPD7Lpn5BRdcQG3mctddd50TmsBypv0kWdRswxKxyN1CIM5X9HCK1UihkfebcN4h2QbotEL6N4yO2hFYio3EJKTFEjG4XzozLct12ksUnzZtWqMSe+pjBx54ILDsIZrdZ599MHTLLbfEd4sKyL3wtj0kpe1HLKWg2FWRRIYq1YTGIG7kGp1S0dzHCza0qPPvv//+JtKlbAj5dvGXwDP0NClaiCaxHr0Y7TV00KxAnlIHbqLPNNKR3WGRQ2EnfghFpJHcpIER3wQWIn1F5d3LoOF9p8BSoB6oW1NiwkutdLyoj3hGdnzSSSc1fbdDR9Vtzz33nFjZ3nvvTQcW13J1HqDTd6cHHHCA+sjXQP55xBFH8BXP2H/YYYcByx7nH3nkkY1KDavdCUXrXV2CCSJH9xJS23bA0smkA9h///2B5SZxqiGfe+45YJlgPvjgg5EsANfigewDXCkvSr0QKVBuuukmKIS/aG2dNvSsUbosj8TsSmiJoRANSCIUhpfuRSruo50yXoipTuYagVqoU5+nn37aV0S71qEw7lFSEyXThY4EWCpMUKo3Rq3oew3r9kcKjGF01I4iFlDirgMsM4oCTWSUUCtNCX17KFqbwAKArwtR9fJptTV9iGzbB8sGtCObBadIQaEhC1gcFGAZmxyyv3VYUT4fRQoAkWNnnxVchaiXR6SBqIMlcE6oTO8JsKAjT4RW3HfffXUs46D6uKiOJe7a77dmDsV2dD5BQjkAxd8ogCV+8CEVuJvq9xKdOGpHcyz6ZlfkkwJWoFPAilwjzjHzKWDxlcYV2Pbaay81EVBK+RrCfvPvuK9heCqsq5lbTf12NKDojjwljVF7XRAxrbre1vqYMaDfzKAMNOUEh6JuABIM2niCG8bXDQO4sUNHtddw/T7r/V6ZMCEs6Q8h5286YQCF9Oi9bviuspG+ioFf3B5g7G87v82ovccaHUcN0QxZ4cyd9Vu+ec8377v4C9IEK8FKS7ASrAQrwUqwEqy0BCvBSrASrAQrwUpLsDoDy993t6QNZmUZoy5x1/LlWx5+uHt/Vq/+X0asXTJi+aP5scd278+SJTkUJlgJVoKVYCVYCVaClWAlWAlWgpVgJVhpCVaClWDttmB9W1l9T79ih+Gy+j/tt7HE2ZiANXA9O19athOwLH85xmBRkNXFisXeqKy+J9Z3HAmjWqEpJf3TGHRapGaEjeUoiRWxVOsKiaMPliUkaYsJnRvV8pa2qaPqmSboZkMv5VDcVOy3dOUtlTWqdQZpSp0wqMp+ALCuuIKopKE827NmNSy/SvMbhyZMsMKqFakp5ccUrNsrizWr3TmlIdRo3zgIWCTIjUpl5gS6KzHMyVxGlqm/Ut9rcsLRTprTcrSxejv5kZYjaYztOOrPdmpFUTgKa0sPCpa/39FahnbZ2rj07CRDoZilc3eUc2JVZoI+v8mqiPh0yO2VxXK6pMlkkkTenUQsa/9Ondo444y+baWuWfMPWJMm0cs3KFg16ZiBRYym62i8WDSW/tNvOkwqPFJGYNluVMvR0mdqbHhFIgCOs961Dqq9p0yZ0klzhu6UJpYel/eJJGP18jgqrwRJqhwNIRMd86HQYrsBVnhGT4gA1qjSH2ArwNL33Ehde428AMtq9fYrhNK1PbAoUrdvb+iA0BGf7Fm16h+w4ufJJ/si2ZiBJRpDhAKdeFcDR5wI18RQGIG9DlYkbuBWeuWVK1c2qhwsHbao0YRsMpS4/G5byIxDIJOywUZkxekesFCiW6pqqK5Dw86TRMbSgdCjNqpcN2ViWsCS+wR/9geabYAFJmOfDUPe+ef3A5Yo8frrfUFrzMDCTShohQQ5BQIsgnR+ESeARSeu88GuFazonUaxzude5itIkprCNkW8bZHApEqtxE7XEq5GbobXHliySMQcS74NbJVMMnyii6rz3XffzY2GwpgvFrD4UGd2j01T2J0aCuWFIPZ+++2/6AmwTNgXLuxDTV0cXb68MXFilz0V1vXaA4vTR3TqU7r7UPTjo/9U6N6jhv0+LxfUWo+WbACdPBWeeGK+x8r3WPkeK8FKsBKsBCvBSrASrAQrwUqwEqwEK8FKsBKsBCvBSrDGDqxUQu+KSug5c7ZMnty9P2++mUroXTNizZ7dOOSQ7v1ZvDiHwgQrwUqwEqwEK8FKsBKsBCvBSrASrAQrLcFKsBKs3Qespn/NpmUob6L7teH9b/eBSxsFUWGj+5TQuzBY9KhludQmGYzFHUM72qjWy+zt7V24cKG/M8Qea5FRa1EtU4BZQZOAh/KJvKc9pHydKpWsxVXuqMxioSHiIO90lMJsFJZ9Hx0ldKPShljdlA6sbbAuukgFGvRy8dGZixf/c5TciU7acqVdAZZty00TWoXCPcCiI+VBYFE7UeIXjSUBk0XudFm6P2B1IoZ2Uddq/K1nbFRpI+68887YVrKP27Zts+jtmIM1LEroRrWirMVOOwGrp4do0cKqfdtnndWnhK6DxZ2kx/5O3BVgkcIZ/opfNDaNqIXsS8SilqYmjZM5xRLRIha8SsQKPtow6JAxWvC8BMhyoTCrtGvRbhgKO1dCRxoBzmwbLMRs24bOxsaN1oHu0w9eeum/wDJykEH39jYOPbRrwOKR2EbJpEmTyOcNVa1gUb77TatJ7NthxHJR6xaXiCVnRn1xYiZnhFG4S+ZYnSuh3Z1hwULXg65fvyOwjjqqDxobBKsiuywQ2nDz5sa55/bttIK4nAc2Nmzok7OOGVjmBNwRaRqawNKxOE63C7DmzZsXMmhm8Vzf4ikpHkrEIlxuoznNpQwZBhE93kej7SrC3go4BZrDHXPMMQrvd+3uMQSrbSV0WCcRy48ily7tkztHTDrnnL6Iddhhfcp6e8yuliz5Vwzr9tcNdVV0LFU9XI1aGmaAB9VueypsWwk9LE+FFqJv+2i+x8r3WPmCNMFKsNISrAQrwUqwEqwEKy3BSrASrG4Fq/zxOG0oYHWJu7ocrD7Bqj/GjU62xV3XvPstYHWJu7oZrNNO+7W397s9vPz1DwLfpu3Y+Ke8Iu8Sd/X0fLtwYTf+LFnybW9vn7v2yICUNhL2V8T6Pi1tmCwCfM6x0obZ4ASqfCpMG5GH6HyPlZZgpSVYaQlWgpWWYKUlWGkJVlpagpWWYKUlWGlpCVZagpWWYKWlJVhpCVZagpWWlmClJVhpCdYomYTecgVGJshGlaTPx5Kcsm3z39bKkVA5Pkpx5uPq1aslJ36jxaQmlCg1tuvp3eQNjG/ZXrt2bdPRDk2yPxkuBz5HbZ02OlkwdzewZESeNm2a1LQh2Zs/fz5XasUOi4WUchYtWhQfpS31UfZKDN1dmRyNUn3GtpbbsGHDtZVJchxfoQWQhtmeOXPmNKpU5LYpTxKsXWYoXLp0KfetWbMGZBJ9y2gakGlFoUtLy1MdZ2r+iG2yfPf09MTyBTZk1JVpuJ41eUdglRNkMo70sqVkJ8jxXNKJKzb2DBEs+UKlJVdOEbWKviovj21dqKLOThMg62BxvoSluhMP9AuWYrmiXN3t2yP56kZZkSvbtGmTPZs3b474Wk6wf/yCZXyRtXbGjBlSxvNjpOzmMll0r6tM5AgPTp8+PZLnajNnyqXbqHIMi3k+yqLbBJb491NlRsahgIUqJAXHMrPLlgv0oYCF4FgQICIctiS0VSuluYr9uG9UKeljUYU4M8By6Rsqs1/4VNsmsJQzdepUGxL4iqMKl3G4XE4a/UaVN1oJUqNLf9/vCeN08r5ixYpwxMyZM6PHz5o1y8dtlWndSJW+I7BwuX79eiLJJrCabFCwVEMT+q11HZUQ2u9BwVIZTChczRcsWOA0kSNaWmQSLAEhBbIzBWOUOL8esdyUmYDeJZjZKX43gWWNDyWb4dnWwdxp4GLn3LlzXcV3FRX195V169a1njBOwZJdmPe5QwPHHlSV5QL0P97hph2B5YR+51iSYPdUZkgaCljGDucIVMYdrMBoKHMsQ7BDKKzvlDy8LA4g27ZKGuZQW6oaYCHYhp4zo7JyU61zrFhAxUzARxtIdX44TXRUVMl933pCfc2Vcfe6IaK3/h0feaQs6GBDRx8gYu0IrJ2dY4kW0d1dSJnR6oOCZYbn0LJly+JCAo8IgSqFxAkRvXQMd1F6S4BlMqT+burdv608ye4IrJgwWJOnfIU36mCpiROsLFQ/IcH6CyzLOfGUMMChgofhwE4LEdhpphLjwkiApaVjxuZhIr5VB8sJ0VSiYPmueU8MdgrRwGq7devWqKHzvbMQqGLUUwG1dUeW7ShDocr7ilFMmbYNowOD5QTnC6sup3xOgGwdrDjBDdZPSLB+LCho0esrcyj2c2uZ9vLjSIBl20OAxwW4tIJVrCzAEWY1CvMk+30RAfFE4ivx8CFKRajzDIG/WB7MoiYBlmeF2bNnu83YX3+E7BeseAgw1MasNI7WwYq3bk0n5Jv3f5nWrS910agSUzU9k3ePAbFpgUIfW2tbf+6rzzJLp2r7cjt7wvgFK22XtgQrLcFKS7DSEqwEKy3BSkuw0hKsBCstwUpLsNISrLS0BCstwUpLsNLSEqy0BCstwUpLS7DSEqy08QiW/4/2r/65LmjacBmcQPX/9jjCDkWUOTQAAAAASUVORK5CYII=", + "description": "Tables and cards to display latest and historical values for multiple entities simultaneously." + }, + "widgetTypes": [ + { + "alias": "attributes_card", + "name": "Attributes card", + "image": "data:image/png;base64,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", + "description": "Displays one or more latest values of the entity. Supports multiple entities.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", + "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n 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 datasourceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasourceTitleFontSize = self.ctx.width/12;\n }\n datasourceTitleFontSize = Math.min(datasourceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasourceTitleFontSize+'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\"}" + } + }, + { + "alias": "html_card", + "name": "HTML Card", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAATFSURBVHja7d3tUxNXFMdx/u7vEkAJsKQK6bilKaBV1Eh5GGNaSyq0WodhRuqMtQJqW5UHCwKGMRJiQpL99cWGEGaaTjtjgaTnvNpz7mYnn8neu/duXtwWFbdWlxs8Vrf21VJcy5TU4FHKrBVbtjJqgshstayWmgFSWm1ZVlPEskEMYhCDGMQgBjGIQQxiEIMYxCAGMYhBDGIQgxjEIAYxiEEMYhCDGMQgBjGIQQxiEIMYxCAG+T9ACrlcLjjyc7lcWflcbfhBfnByOZfL5cpSKZfL+f/2G+WAJ/8dJAHh4GgFeK4BaiOjMYDtyskzAIvSQ+BtI0JmKif3NRBk3PM8Lwwhz/M8LxtAzgf30RoNBJEkjUOk0j4GwJok6XaDQ8LwtSSVXVrP1of4v1w978bu5YNh4fHlfnfwfpDo15Hevm/eHkCyP8Tc/rGV44d8FaLbl/QCRrrqQopXg251bkdSdjBIIpuSNAtAbwXyqgsA595Hh3Sm0+l0Or1QDzJxLWiYhCftdSFJoKMHiPnyvwTOhIHeD9KGA3SfIYBkOsGJtIPz/GNDauIvIfEFGJdKnZwtOPUgOw4kfT0E1vQMmPb12IEZaRJCz1RKBpBJCG+ocAVixw25XArTUdACTBbqdvY56MhLisK8xisDXRyiUi+MStoDnqjUCT9Keg3Oh48MCcXj8Xg8PlwPMqQE/Kzr8HK3LiQBFyozhaI8uClJ8+AUi8CD6qi1DYymUqkksH7Mnf0zLcNIvo2In64LGYXhauLCHUlaAnb3gIUq5FXN7//qmCH98s8RmoXv9KYuZBI+ryZ9cFsKzsvngUdVyAowcDGIjWOGRKRpaINNrdWF3IUeX9LS/PymLsMlSZqCLikM01XIe+CnE3ogRqRtAE9arQtZCVqynbCoWXBWpfddMCaNgLsnLQSj1gVwM5Je3/RPAKIBYPYIJOp5nud5dw8uFIPQxO1eCOf1oRs6Jm71QOtG0FMiydFQAHkKnJlIjTgkTwIyB87uEUglbh5c6G13UGhdkvSyPUicB5J0HYBQe/Bkv+tUPnr/JCDZVq7obyF6d6MNnC+COdTmSCs4A78Hs5dkG3zyYqAy13oec8CJ/XZ6l7qFzfW9apJ/80dNsr5V2yNyGxt7tmY3iEEMYhCDGOTEIJnD6YtBDGKQpocsDrvR5K4kyX96PeoOzu5Lysbj8b2Zvv7SkepphgwBcD4vqRAPVlXR91IauASUVAhWgny6e7ohtEdCwcpdCaDTBYYDCED5sHrxdEOuFZRx4Ya06cC3ZS05sKI04EzM3demA1NlLTqweuo7ewI86U7lHdYQzCgNzEnSHXB9SYPw/amHpKBPGoFzqVQqFYUxpYEX0pHqeINAav4wvXoI+eyweq1BIDHorry+nT6E1FRnGgQyCtFqUxVyo7baGJBHlVG4mFivgRxU9xMbjQIpR4HBqYRLz84hpBwFhqYSLu5Og0C07VZ6dXTvEHKk2iAQZZOdQHcqX3NrSdlb1WrjLHXL26/T/j+s2prdIAYxiEEMYhCDGMQgBjGIQQxiEIMYxCAGMYhBDGIQgxjEIAYxiEEMYhCDGMQgBjGIQQxiEIMY5PRAmmaD4ObYsvndVst+c2yiXWppjm3NS/oTe0OjFEeU1MMAAAAASUVORK5CYII=", + "description": "Useful to inject custom HTML code. Designed to display static information only.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n var $injector = self.ctx.$scope.$injector;\n var utils = $injector.get(self.ctx.servicesMap.get('utils'));\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 var evtFnPrefix = 'htmlCard_' + Math.abs(hashCode(self.ctx.settings.cardCss + self.ctx.settings.cardHtml + self.ctx.widget.id));\n cardHtml = '
' + \n self.ctx.settings.cardHtml + \n '
';\n cardHtml = replaceCustomTranslations(cardHtml);\n self.ctx.$container.html(cardHtml);\n\n window[evtFnPrefix + '_onClickFn'] = function (event) {\n self.ctx.actionsApi.elementClick(event);\n }\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 replaceCustomTranslations (pattern) {\n var customTranslationRegex = new RegExp('{i18n:[^{}]+}', 'g');\n pattern = pattern.replace(customTranslationRegex, getTranslationText);\n return pattern;\n }\n \n function getTranslationText (variable) {\n return utils.customTranslation(variable, variable);\n \n }\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-html-card-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"cardHtml\":\"
HTML code here
\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\"},\"title\":\"HTML Card\",\"dropShadow\":true}" + } + }, + { + "alias": "timeseries_table", + "name": "Timeseries table", + "image": "data:image/png;base64,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", + "description": "Displays time series data for one or more entities. Data for each entity is displayed in a separate tab.", + "descriptor": { + "type": "timeseries", + "sizeX": 8, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "latestDataKeySettingsSchema": "", + "settingsDirective": "tb-timeseries-table-widget-settings", + "dataKeySettingsDirective": "tb-timeseries-table-key-settings", + "latestDataKeySettingsDirective": "tb-timeseries-table-latest-key-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"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', 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}\",\"useCellContentFunction\":false},\"_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;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"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;\"}],\"latestDataKeys\":null}],\"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,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"displayTimewindow\":true}" + } + }, + { + "alias": "html_value_card", + "name": "HTML Value Card", + "image": "data:image/png;base64,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", + "description": "Displays configurable HTML with ability to inject values from the selected datasource. For example, display single or multiple attribute values.", + "descriptor": { + "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 var evtFnPrefix = 'htmlValueCard_' + Math.abs(hashCode(self.ctx.settings.cardCss + self.ctx.settings.cardHtml + self.ctx.widget.id));\n self.ctx.html = '
' + \n self.ctx.settings.cardHtml + \n '
';\n\n self.ctx.replaceInfo = processHtmlPattern(self.ctx.html, self.ctx.data);\n \n updateHtml();\n \n window[evtFnPrefix + '_onClickFn'] = function (event) {\n self.ctx.actionsApi.elementClick(event);\n }\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 if (label == 'entityName') {\n variableInfo.isEntityName = true;\n } else if (label == 'entityLabel') {\n variableInfo.isEntityLabel = true;\n } else 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.isEntityName && !variableInfo.isEntityLabel && 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.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true,\n dataKeysOptional: true\n };\n}\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 $injector = self.ctx.$scope.$injector;\n var utils = $injector.get(self.ctx.servicesMap.get('utils'));\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 } else if (variableInfo.isEntityName) {\n if (self.ctx.defaultSubscription.datasources.length) {\n txtVal = self.ctx.defaultSubscription.datasources[0].entityName;\n } else {\n txtVal = 'Unknown';\n }\n } else if (variableInfo.isEntityLabel) {\n if (self.ctx.defaultSubscription.datasources.length) {\n txtVal = self.ctx.defaultSubscription.datasources[0].entityLabel || self.ctx.defaultSubscription.datasources[0].entityName;\n } else {\n txtVal = 'Unknown';\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 text = replaceCustomTranslations(text);\n self.ctx.$container.html(text);\n if (!self.ctx.htmlSet) {\n self.ctx.htmlSet = true;\n }\n }\n \n function replaceCustomTranslations (pattern) {\n var customTranslationRegex = new RegExp('{i18n:[^{}]+}', 'g');\n pattern = pattern.replace(customTranslationRegex, getTranslationText);\n return pattern;\n }\n \n function getTranslationText (variable) {\n return utils.customTranslation(variable, variable);\n \n }\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-html-card-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"My value\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"return Math.random() * 5.45;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"cardCss\":\".card {\\n width: 100%;\\n height: 100%;\\n border: 2px solid #ccc;\\n box-sizing: border-box;\\n}\\n\\n.card .content {\\n padding: 20px;\\n display: flex;\\n flex-direction: row;\\n align-items: center;\\n justify-content: space-around;\\n height: 100%;\\n box-sizing: border-box;\\n}\\n\\n.card .content .column {\\n display: flex;\\n flex-direction: column; \\n justify-content: space-around;\\n height: 100%;\\n}\\n\\n.card h1 {\\n text-transform: uppercase;\\n color: #999;\\n font-size: 20px;\\n font-weight: bold;\\n margin: 0;\\n padding-bottom: 10px;\\n line-height: 32px;\\n}\\n\\n.card .value {\\n font-size: 38px;\\n font-weight: 200;\\n}\\n\\n.card .description {\\n font-size: 20px;\\n color: #999;\\n}\\n\",\"cardHtml\":\"
\\n
\\n
\\n

Value title

\\n
\\n ${My value:2} units.\\n
\\n
\\n Value description text\\n
\\n
\\n \\n
\\n
\"},\"title\":\"HTML Value Card\",\"dropShadow\":false,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "simple_card", + "name": "Simple card", + "image": "data:image/png;base64,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", + "description": "Designed to display single value of the selected attribute or timeseries data. Widget styles are customizable.", + "descriptor": { + "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\n self.ctx.labelPosition = self.ctx.settings.labelPosition || 'left';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = 'tbDatasource' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === 'top') {\n table.css('text-align', 'left');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = 'labelCell' + 0;\n var cellId = 'cell' + 0;\n if (self.ctx.labelPosition === 'left') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $('#' + labelCellId, table);\n self.ctx.valueCell = $('#' + cellId, table);\n self.ctx.valueCell.html(0 + ' ' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '' + html_org + '';\n $(this).html(html_calc);\n var width = $(this).find('span:first').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n};\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n\n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (self.ctx.datasources.length > 0 && self.ctx.datasources[0].dataKeys.length > 0) {\n dataKey = self.ctx.datasources[0].dataKeys[0];\n if (dataKey.decimals || dataKey.decimals === 0) {\n decimals = dataKey.decimals;\n }\n if (dataKey.units) {\n units = dataKey.units;\n }\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === 'left') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css('font-size', fontSize+'px');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n};\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === 'top') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = self.ctx.height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css('font-size', labelFontSize+'px');\n self.ctx.labelCell.css('padding', self.ctx.padding+'px');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css('font-size', self.ctx.valueFontSize+'px');\n self.ctx.valueCell.css('padding', self.ctx.padding+'px');\n } \n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n};\n\n\nself.onDestroy = function() {\n};\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-simple-card-widget-settings", + "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\":{}}" + } + }, + { + "alias": "label_widget", + "name": "Label widget", + "image": "data:image/png;base64,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", + "description": "Displays static image and multiple values of selected attributes or timeseries keys on top of it. Position of the values on the image is configurable using advanced settings.", + "descriptor": { + "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 || 'Roboto';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : 'normal';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : '500';\n localConfig.font.color = settingsFont.color ? settingsFont.color : '#fff';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $('
');\n labelElement.css('position', 'absolute');\n labelElement.css('display', 'none');\n labelElement.css('top', '0');\n labelElement.css('left', '0');\n labelElement.css('backgroundColor', localConfig.backgroundColor);\n labelElement.css('color', localConfig.font.color);\n labelElement.css('fontFamily', localConfig.font.family);\n labelElement.css('fontStyle', localConfig.font.style);\n labelElement.css('fontWeight', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n\n var bgImg = $('');\n bgImg.hide();\n bgImg.bind('load', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr('src', imageUrl);\n \n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css('top', labelTop + '%');\n label.element.css('left', labelLeft + '%');\n label.element.css('fontSize', fontSize + 'px');\n if (!label.visible) {\n label.element.css('display', 'block');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split('.');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = '0' + strVal[0];\n }\n\n strVal = (n ? '-' : '') + strVal[0] + '.' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = '0' + strVal;\n }\n\n strVal = (n ? '-' : '') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true\n };\n};\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-label-widget-settings", + "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}}" + } + }, + { + "alias": "entities_table", + "name": "Entities table", + "image": "data:image/png;base64,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", + "description": "Displays list of entities that match selected alias and filter with ability of additional full text search and pagination. Highly customizable using widget styles, data source keys and widget actions.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-entities-table-widget-settings", + "dataKeySettingsDirective": "tb-entities-table-key-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"entitiesTitle\":\"\",\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"reserveSpaceForHiddenAction\":\"true\",\"displayEntityName\":true,\"entityNameColumnTitle\":\"\",\"displayEntityLabel\":false,\"displayEntityType\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"entityName\",\"useRowStyleFunction\":false},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"useCellContentFunction\":false,\"defaultColumnVisibility\":\"visible\",\"columnSelectionToDisplay\":\"enabled\"},\"_hash\":0.6401141393938932,\"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;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}]}" + } + }, + { + "alias": "entities_hierarchy", + "name": "Entities hierarchy", + "image": "data:image/png;base64,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", + "description": "Displays hierarchy of entities based on their relations. The root of the hierarchy is defined using entity alias. By default, displays entities related using \"Contains\" relation. You may change the behaviour using advanced settings.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-entities-hierarchy-widget-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"nodeRelationQueryFunction\":\"var entity = nodeCtx.entity;\\nvar query = {\\n parameters: {\\n rootId: entity.id.id,\\n rootType: entity.id.entityType,\\n direction: \\\"FROM\\\",\\n maxLevel: 1\\n },\\n filters: [{\\n relationType: \\\"Contains\\\",\\n entityTypes: []\\n }]\\n};\\nreturn query;\\n\\n/**\\n\\n// Function should return relations query object for current node used to fetch entity children.\\n// Function can return 'default' string value. In this case default relations query will be used.\\n\\n// The following example code will construct simple relations query that will fetch relations of type 'Contains'\\n// from the current entity.\\n\\nvar entity = nodeCtx.entity;\\nvar query = {\\n parameters: {\\n rootId: entity.id.id,\\n rootType: entity.id.entityType,\\n direction: \\\"FROM\\\",\\n maxLevel: 1\\n },\\n filters: [{\\n relationType: \\\"Contains\\\",\\n entityTypes: []\\n }]\\n};\\nreturn query;\\n\\n**/\\n\",\"nodeHasChildrenFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node has children (whether it can be expanded).\\n\\n// The following example code will restrict entities hierarchy expansion up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n// The next example code will restrict entities expansion according to the value of example 'nodeHasChildren' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeHasChildren') && data['nodeHasChildren'] !== null) {\\n return data['nodeHasChildren'] === 'true';\\n} else {\\n return true;\\n}\\n \\n**/\\n \",\"nodeOpenedFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be opened (expanded) when it first loaded.\\n\\n// The following example code will open by default nodes up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n**/\\n \",\"nodeDisabledFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be disabled (not selectable).\\n\\n// The following example code will disable current node according to the value of example 'nodeDisabled' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeDisabled') && data['nodeDisabled'] !== null) {\\n return data['nodeDisabled'] === 'true';\\n} else {\\n return false;\\n}\\n \\n**/\\n\",\"nodeIconFunction\":\"/** \\n\\n// Function should return node icon info object.\\n// Resulting object should contain either 'materialIcon' or 'iconUrl' property. \\n// Where:\\n - 'materialIcon' - name of the material icon to be used from the Material Icons Library (https://material.io/tools/icons);\\n - 'iconUrl' - url of the external image to be used as node icon.\\n// Function can return 'default' string value. In this case default icons according to entity type will be used.\\n\\n// The following example code shows how to use external image for devices which name starts with 'Test' and use \\n// default icons for the rest of entities.\\n\\nvar entity = nodeCtx.entity;\\nif (entity.id.entityType === 'DEVICE' && entity.name.startsWith('Test')) {\\n return {iconUrl: 'https://avatars1.githubusercontent.com/u/14793288?v=4&s=117'};\\n} else {\\n return 'default';\\n}\\n \\n**/\",\"nodeTextFunction\":\"/**\\n\\n// Function should return text (can be HTML code) for the current node.\\n\\n// The following example code will generate node text consisting of entity name and temperature if temperature value is present in entity attributes/timeseries.\\n\\nvar data = nodeCtx.data;\\nvar entity = nodeCtx.entity;\\nvar text = entity.name;\\nif (data.hasOwnProperty('temperature') && data['temperature'] !== null) {\\n text += \\\" \\\"+ data['temperature'] +\\\" °C\\\";\\n}\\nreturn text;\\n\\n**/\",\"nodesSortFunction\":\"/**\\n\\n// This function is used to sort nodes of the same level. Function should compare two nodes and return \\n// integer value: \\n// - less than 0 - sort nodeCtx1 to an index lower than nodeCtx2\\n// - 0 - leave nodeCtx1 and nodeCtx2 unchanged with respect to each other\\n// - greater than 0 - sort nodeCtx2 to an index lower than nodeCtx1\\n\\n// The following example code will sort entities first by entity type in alphabetical order then\\n// by entity name in alphabetical order.\\n\\nvar result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType);\\nif (result === 0) {\\n result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name);\\n}\\nreturn result;\\n \\n**/\"},\"title\":\"Entities hierarchy\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"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;\"}]}],\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "qr_code", + "name": "QR Code", + "image": "data:image/png;base64,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", + "description": "Displays QR code of calculated text from configured pattern or function with applied attributes or timeseries values.", + "descriptor": { + "type": "latest", + "sizeX": 4, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n", + "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\nself.onDataUpdated = function() {\n self.ctx.$scope.qrCodeWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-qrcode-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7036904308224163,\"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\":{\"qrCodeTextPattern\":\"${entityName}\",\"useQrCodeTextFunction\":false,\"qrCodeTextFunction\":\"return data[0] ? data[0]['entityName'] : '';\"},\"title\":\"QR Code\"}" + } + }, + { + "alias": "markdown_card", + "name": "Markdown/HTML Card", + "image": "data:image/png;base64,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", + "description": "Renders markdown/HTML using configurable pattern or function with applied attributes or timeseries values.", + "descriptor": { + "type": "latest", + "sizeX": 5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "#container tb-markdown-widget {\n height: 100%;\n display: block;\n}\n\n#container tb-markdown-widget .tb-markdown-view {\n height: 100%;\n overflow: auto;\n}\n", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-markdown-widget-settings", + "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\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"useMarkdownTextFunction\":false},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } + }, + { + "alias": "dashboard_state_widget", + "name": "Dashboard state widget", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAIABJREFUeJztnXd8VFX6/993+iSTMmmkACEghN6UKij8RBF0XSxgb1hAFiyg7iIqu9YvKoLoIjbsywo2RIrSRKQlFBECIQkhQAppk0mZPnPv748xIwnJpE1IcO/79eJFZuaU59xzP/eec+5znitYLBbJ5XIhCAIybYMkSajVaoKCggCwWq3IfdK2VPeJyul0Eh4e3tb2/M9jNpt9ApH7pH1gNptRtLURMjLtGVkgMjJ+kAUiI+MHWSAyMn6QBSIj4wdZIDIyfpAFIiPjB1kgMoiieM532dnZlJaW1vu7P4qLi8nJyWlUPe2dBgVitVqpqqrC7XY3qwJJkrBarc3KK1OTf//736SkpPg+l5WV8eijj7aozIyMDD744INzvt+0aRMZGRnk5uayZMkSADIzM8nLy2uwzLS0NLZt23bO96+++iolJSX15vvpp58ab/h5QtVQgrlz5xIUFIRCoUCtVvPggw8SHx/f6ApOnjzJ1q1buffee1tkqAx4PJ5zrsLVF67y8nL0ej0ajQabzYbH40Gv11NRUYFKpcJkMtGxY0dEUSQ3N5fY2Fj0ej1dunShQ4cOvvLOnDmDQvHHdbNDhw7cfffdVFVVsWXLFqKjo7nqqquw2+1ERUX56tZqteh0uhq2lZaWYrPZ6NixIwAPPPAAYWFhgPfCm5+fT3x8PEFBQRQXF7N8+XJ69+5NTEwMAEVFRbhcLuLj4xEEwdcus9lMcHAwarUag8EAQGFhYY12BIoGBQIwe/ZsoqOjycrK4pVXXmHhwoUolUrsdjtHjhwhNjaW+Ph4ioqKMBgMBAUF4XQ6KS4uJjY2lokTJ/rKysnJoaysjF69evkOaGlpKSdPnqRbt26+A1hWVsbx48fp2LEjsbGxAW/4n40PPviAcePG0b9/f7Zv305JSQnjxo3jiSeeYNCgQbhcLkpLSzEajRgMBg4dOsTChQvJzMzkp59+4tFHH+XLL79k3759JCQk8Ouvv9KvXz9OnTrFihUruO6668jOzqawsJB+/fqxYMECFi1aREhICPPnz2fu3Lk1BJKSkkJ5eTlnzpxh0KBBTJ48mQULFjB79mwcDgevv/46gwYNYu/evcyZM4fU1FScTiebNm3itttuY/ny5Zw5cwaDwUBFRQXz5s1jx44drFq1ik6dOjFmzBi2bNnCs88+S35+PosWLeLVV18N+HFtlECqueiiizAajeTk5BAZGcnzzz/PiBEj+Pbbb5k4cSImk4mqqiqmTJnCnj17yMrKYujQoaxbt44nnniClStXcuLECZKSkvjss8944YUXyMrK4rPPPmP48OF8/vnnPPnkk7jdbhYsWMAVV1zBihUruP322xk8eHDAG38h8tlnn7F69WqARg17jUajbxh2zz338I9//IPw8HAWLlzI8ePHfencbjfr169n2bJlqNVq3nrrrRrl9O/fn0OHDtGhQwe6d+/OiBEj2LNnD/3790en051z9e7Tpw8zZswgPz+fN998k8mTJ/t+S09Pp0uXLtx2222MGjUKgMmTJ7Nu3Tpuu+028vLyOHbsGAsWLABgyZIlvqHl4MGDmTZtGpIk8emnn1JZWcmePXsYPXp0Uw9lo2iSQAAiIiIoKSkhNTWVkSNHMn78ePr27ct//vMfnnzySf75z38yZcoUdu3axU033YTNZgO8w4Mff/yRZcuWoVKpiImJobS0lJUrV3L33XfTuXNnJEli27ZtJCUlYTQaufzyyxk+fLivDBm44447GD58OOC9yz7zzDN+02s0Gt/fWq3W5xCp0WhwuVwolUrA65gXERGBWq0+J19djBkzhs8//xybzVbnyRkcHFyjnrMZO3YslZWVzJs3j44dO54z/C4oKCAxMdH3OTExkYKCAgwGg69cQRC49NJL2bNnDykpKTz++ON+7W0uTRZISUkJUVFRpKamkpeXR3Z2NuBthMFgwGg0kp2dTXFxMV27diUtLQ2AiooKQkNDUam8VY4ZMwbwrnisXr3a9/2AAQO45JJLsNvtLFmyBLfbzfTp0wPR1j81Go0Gp9MJgMPhaHL+8PBwTCYTLpcLtVrdYBndunXDZDKxbds25s2b16S60tLSGD58ODfccAMrVqxg7dq13HLLLUiSBEBcXBwnT570pT958iTDhw+noqKiRjljxoxhyZIlBAUFERkZ2SQbGkuTBJKenk55eTldunShU6dOREZGcuuttyJJEiaTyWf0O++8w7Bhw2rkDQ8Px2KxYLFYCA4OZtOmTfTu3ZuOHTtyzTXX0L9/f99qV2pq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", + "description": "Displays specified dashboard state inside widget. Advanced widget settings allows you to configure target dashboard state to be displayed.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n\n
\n
Dashboard state widget
\n
(Specify dashboard state id in the advanced widget settings)
\n
\n", + "templateCss": ".dashboard-state-widget-prompt {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 32px;\n font-weight: 500;\n color: #999;\n}\n\n.dashboard-state-widget-prompt .title {\n font-size: 32px;\n font-weight: 500;\n color: #999;\n}\n\n.dashboard-state-widget-prompt .subtitle {\n font-size: 24px;\n font-weight: normal;\n color: #999;\n text-align: center;\n}", + "controllerScript": "self.onInit = function() {\n var $injector = self.ctx.$scope.$injector;\n self.ctx.$scope.stateId = self.ctx.settings.stateId || \"\";\n self.ctx.$scope.defaultAutofillLayout = self.ctx.settings.defaultAutofillLayout;\n self.ctx.$scope.defaultMargin = self.ctx.settings.defaultMargin;\n self.ctx.$scope.defaultBackgroundColor = self.ctx.settings.defaultBackgroundColor;\n self.ctx.$scope.syncParentStateParams = self.ctx.settings.syncParentStateParams !== false;\n}\n\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-dashboard-state-widget-settings", + "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\":\"0px\",\"settings\":{\"syncParentStateParams\":true,\"defaultAutofillLayout\":true,\"defaultMargin\":0,\"defaultBackgroundColor\":\"#fff\"},\"title\":\"Dashboard state widget\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"noDataDisplayMessage\":\"\",\"showLegend\":false}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/charts.json b/application/src/main/data/json/system/widget_bundles/charts.json new file mode 100644 index 0000000..de32604 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/charts.json @@ -0,0 +1,209 @@ +{ + "widgetsBundle": { + "alias": "charts", + "title": "Charts", + "image": "data:image/png;base64,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", + "description": "Display timeseries data using customizable line and bar charts. Use various pie charts to display latest values." + }, + "widgetTypes": [ + { + "alias": "bars", + "name": "Bars", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAA8FBMVEUhlvNMr1Bqamp5eXl7e3t8fHx9fX1+fn5/f3+AgICCgoKDg4OEhISGhoaHh4eKioqMjIyNjY2Ojo6QkJCRkZGSkpKWlpaXl5ebm5udnZ2enp6goKChoaGkpKSnp6epqamsrKyurq6xsbGzs7O1tbW2tra3t7e4uLi7u7u9vb3BwcHCwsLDw8PGxsbKysrNzc3Ozs7R0dHS0tLT09PZ2dna2trc3Nzd3d3e3t7g4ODh4eHj4+Pk5OTm5ubn5+fo6Ojp6enu7u7w8PDz8/P0Qzb09PT29vb39/f5+fn6+vr7+/v8/Pz9/f3+/v7/wQf///+dc+aLAAAAAWJLR0RPbmZBSQAAAcFJREFUeNrt3ds2AgEYhuHsaSOZbAvZi0r2YYjCJOW7/7txZhkcDNbM6h/vdwfPmlX/ybtmEorJErGCeJLadz3rkKPpZamaLTp925DHdFvSpKelU9uQ/cLKhtcdk7YqtiHruevtojch7ZZtQ0o1dcdfRqXNqm1IbVU3OaVamm/YhvQW5zIXOknnC5JUt7qEpE5fUv/5HVePy2UHAgQIECBAgAABAgQIECBxgrwGHBAgQIAAAQIECBAgQIAAAQIECJC/QRIBN0iQ+66voDMLuRp2fQWdVUhvNun6CjqrkJ0Dx/UVdEYhzXzfcX0FnVFIrlSZ2mx/LOiMQuqHh6k972NBZ/fv13G/KeiCQkIu4358EL8UdBafSGwuOxAgQIAAAQIECJDYQB4CDggQIECAAAECBAgQIECAAAECBAgQIECA/BrSufn0DjqjkEZmLXkWaUEXEmThXMeFSAu68H4j5b1IC7rQILfZTqQFXViQ1nRTL1EWdCFBnmYuJUVZ0IUEWR1xHKcXZUEXFDLwBR2XHQgQIECAAAEC5H9ChgIOCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgxiBmv+L6Bl9pkxYph15gAAAAAElFTkSuQmCC", + "description": "Displays latest values of the attributes or timeseries data for multiple entities as separate bars.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 5, + "resources": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js" + } + ], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\n \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 dataKey = datasource.dataKeys[d];\n var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n var dataset = {\n label: dataKey.label + units,\n data: [0],\n backgroundColor: [dataKey.color],\n borderColor: [dataKey.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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-chart-widget-settings", + "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\"}" + } + }, + { + "alias": "doughnut_chart_js", + "name": "Doughnut", + "image": "data:image/png;base64,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", + "description": "Displays latest values of the attributes or timeseries data for multiple entities in a doughnut chart. Supports numeric values only.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 5, + "resources": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js" + } + ], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\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 var borderColor = self.ctx.settings.borderColor || '#fff';\n var borderWidth = typeof self.ctx.settings.borderWidth !== 'undefined' ? 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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n pieData.labels.push(dataKey.label + units);\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-doughnut-chart-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "pie", + "name": "Pie - Flot", + "image": "data:image/png;base64,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", + "description": "Displays latest values of the attributes or timeseries data for multiple entities in a pie chart. Supports numeric values only.", + "descriptor": { + "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.onDestroy = function() {\n self.ctx.flot.destroy();\n}\nself.actionSources = function() {\n return {\n 'sliceClick': {\n name: 'widget-action.pie-slice-click',\n multiple: false\n }\n };\n}\n", + "settingsSchema": "{}\n", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-flot-pie-widget-settings", + "dataKeySettingsDirective": "tb-flot-pie-key-settings", + "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,\"showPercentages\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "pie_chart_js", + "name": "Pie - Chart.js", + "image": "data:image/png;base64,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", + "description": "Displays latest values of the attributes or timeseries data for multiple entities in a pie chart. Supports numeric values only.", + "descriptor": { + "type": "latest", + "sizeX": 8, + "sizeY": 5, + "resources": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js" + } + ], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n pieData.labels.push(dataKey.label + units);\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-chart-widget-settings", + "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\"}" + } + }, + { + "alias": "polar_area_chart_js", + "name": "Polar Area", + "image": "data:image/png;base64,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", + "description": "Displays latest values of the attributes or timeseries data for multiple entities in a polar area chart. Supports numeric values only.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 5, + "resources": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js" + } + ], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n pieData.labels.push(dataKey.label + units);\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 floatingPoint;\n if (typeof self.ctx.decimals !== 'undefined' && self.ctx.decimals !== null) {\n floatingPoint = self.ctx.widget.config.decimals;\n } else {\n floatingPoint = 2;\n }\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 scale: {\n ticks: {\n callback: function(tick) {\n \treturn tick.toFixed(floatingPoint);\n }\n }\n }\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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 try {\n self.ctx.chart.resize();\n } catch (e) {}\n }\n}\n\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-chart-widget-settings", + "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\"}" + } + }, + { + "alias": "radar_chart_js", + "name": "Radar", + "image": "data:image/png;base64,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", + "description": "Displays latest values of the attributes or timeseries data for multiple entities in a radar chart. Supports numeric values only.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 5, + "resources": [ + { + "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js" + } + ], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\n \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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n barData.labels.push(dataKey.label + units);\n dataset.data.push(0);\n }\n \n var floatingPoint;\n if (typeof self.ctx.decimals !== 'undefined' && self.ctx.decimals !== null) {\n floatingPoint = self.ctx.widget.config.decimals;\n } else {\n floatingPoint = 2;\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 scale: {\n ticks: {\n callback: function(tick) {\n \treturn tick.toFixed(floatingPoint);\n }\n }\n }\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-chart-widget-settings", + "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\"}" + } + }, + { + "alias": "state_chart", + "name": "State Chart", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAB9VBMVEUAAAAhlvMilvMymeE+nNVDoetInslNq/VZqOdpuPd3d3d5p5Z5suB6enp8fHyBgYGDg4ODqoqEhISIiIiKioqKuN2MjIyNjY2Ojo6QkJCRkZGSkpKUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKCgr3KhoaGioqKisXSjo6OjvsmkpKSlpaWlwdempqanp6eoqKiosGOo1vqpqampsGOqqqqqsWOrq6usrKysw9Wurq6wsLCysrK0tLS1tbW1wbK2tra3t7e4wau5ubm6urq7u7u8vLy9vb2+vr6/xbTAwMDAxbjCwsLC3ejDw8PExMTFxcXGxsbHx8fIv3jIyMjJycnKysrK5vzMzc7Nzc3Ozs7Pz8/QuDnRzcHS0tLT09PT39HU1NTU6/3VzLLV1dXV3sjW1tbX19fYuTHY2NjZ2dna0Ira2trb29vc3Nzex4De3t7f39/guyvhvCfhvCvh4eHh6Nbi4uLjvCTj4+PkyXbk5OTlvCTm5ubm693o6Ojpxl7q6urr6+vswTTs7Ozt7e3uvhju7u7vvhjv7+/wvxjw8PDx8fHyvxX0yTv09PT19fX29vb39/f4wyL4+Pj5+fn6+vr75J37+/v8whP8/Pz9/f39/v/+/v7/wQf/xRb/3HT/5JH/9tz/++/////APs7XAAAAAWJLR0Smt7AblQAAA1RJREFUeNrt3dlT01AUBvAEd8UNl2oLrdrFolZArUul1hWlVhQXFAUF1xaxIqi4FUQUrDsUClZi4nb+Th96S9NQkjDOOKZ+3wuZw8md++M25OXMlKOccJQnTUYocdRqdw8UAqTfOjG4lvr7uowOqbtAtG6o5OiGFoNDapuJHO8tFK4xOKS9msTVgoUiaQjPclnSl01snZ/y4ly2yBNZbRarbc+3yvdpt/hLawOPJp9ur7O0mRiEmzFkY1M6N/4I8qJpulzR2sBx1sgRjQuyA2pk+STdylw2VqR/FPFfWdcCvjhduiM7kVF2db1Nuspu7JGes+I76SRba7f0Wfnn/6F6It+mfo7m8TvYvh7KTiT3PQIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggPwXkFa7e7AQIP3WiUFzdl7LuBDFvJZxIYp5LeNCFPNaBn7Yc+e1KlheSYcrFCniL7LZqPl8cbp0TjavNcqu9rZJB9gdPdIJVnwjbWG1ndI95UzWM9V5rS9Ti3P4VWy1+5jXAgQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAMK+FeS3Ma2FeC/NamNfCCxEQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBA/hZkTKSkwSGn7VUJOmOLV241NiRSRZ2uYcvt+Io+Y0N83USm+MqGWEnU2JCqXqI1ZE+QhYiIy36tXR5INpOQbB5kftcmK85mtdey2iJekeWqX6/3kc+TLCQTrq6eEuZCgKTsbnNXBsIZOERJMedNQnry73XpW8UAKVhIyBPScVfQE9RqEaP7iMT6g+pdw7vKbxKl3IJqV8K7OUqXPHvG9UMGykk2lz1d4g5yvNXo6QqZiA41aHT5OwQTUWBJSrWrvlMwj1jFU2f1Q8K1dCysCUmVhdenNLvMRKZt3hGNriEnxfxlGqt9aPYRkb9bP6Q1SMFrmltMukKuMT2QpckWjc/WhP2lYB/TgjzdHyCKVM/gGXnsp+qY5hbba+hIVA/ETL0+9SepsoNiTs/igGpXZIhKqVv9QVJARIfXIWqfiM1nS+qBnHdae1V7Qss8ngEijRO5a6sMCAtdqv+HfgPwpNPbU6ipOwAAAABJRU5ErkJggg==", + "description": "Displays changes to the state of the entity over time. For example, online and offline.", + "descriptor": { + "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, 'state'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.flot.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true,\n hasAdditionalLatestDataKeys: true\n };\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}", + "settingsDirective": "tb-flot-line-widget-settings", + "dataKeySettingsDirective": "tb-flot-line-key-settings", + "latestDataKeySettingsDirective": "tb-flot-latest-key-settings", + "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\":{\"direction\":\"column\",\",position\":\"bottom\",\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false}}" + } + }, + { + "alias": "basic_timeseries", + "name": "Timeseries Line Chart", + "image": "data:image/png;base64,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", + "description": "Displays changes to timeseries data over time. For example, temperature or humidity readings.", + "descriptor": { + "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.onLatestDataUpdated = function() {\n self.ctx.flot.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasAdditionalLatestDataKeys: true\n };\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}", + "latestDataKeySettingsSchema": "{}", + "settingsDirective": "tb-flot-line-widget-settings", + "dataKeySettingsDirective": "tb-flot-line-key-settings", + "latestDataKeySettingsDirective": "tb-flot-latest-key-settings", + "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 Line Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}" + } + }, + { + "alias": "timeseries_bars_flot", + "name": "Timeseries Bar Chart", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABOFBMVEUAAAA3oPR3d3d6enp8fHyBgYGDg4OGhoaNjY2RkZGSkpKTk5OUlJSVlZWWlpaXl5eYmJiZmZmampqcnJydnZ2enp6goKChoaGioqKjo6OkpKSlpaWmpqanp6eoqKipqamqqqqrq6usrKytra2urq6wsLCxsbGysrK0tLS1tbW2tra3t7e4uLi5ubm6urq8vLy9vb2+vr6/v7/AwMDBwcHDw8PExMTHx8fIyMjJycnLy8vNzc3Ozs7Pz8/S0tLT09PU1NTV1dXW1tbX19fZ2dna2trb29vc3Nzd3d3e3t7f39/h4eHi4uLj4+Pk5OTl5eXm5ubn5+fo6Ojq6urr6+vs7Ozt7e3u7u7v7+/w8PDx8fH09PT19fX29vb39/f4+Pj5+fn6+vr7+/v8/Pz9/f3+/v7/xx////8KXFhiAAAAAWJLR0RnW9PpswAAAvtJREFUeNrt3GtXElEUBuDpZlAqylVHCyMwyi4SpkaWAl4qk8wwUhJlmOn9//+gLzbKbc6AAm5991p82WvPOedZZw6zYM3aGupCQ4tYkZDSsKDrocNVvz8jHQKYY1ZiT/6OAJllRPSpsnzIqIG9SjYmHpKfAzI4CIuHjJeBtyHfZwCatiI1/m+BYV2Dw35NniOEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQUp/6Wx+DgbRbxOAhEb/fXJ98akiH1AIAHtSWlqRDSvdDGcOHnYR0SPV7zVsaQyHeO0jTJapEV5C9bUz9fIjsvHRIefhRxHqpjxzA3ftajXOudHGJOtHV+1rV04/wHenDc2QQkFbLJISQwUDaP90IIYQQQgghpAvIxRO9gFxG4lZ9XBFIx6sihJD+QVRjEEIIIYQQcoMgahkhhBDSe0iLX3ydQdSLIKR9ghCJkIYEIYQQIhPS8d+AhPQboppFw9HMxBrs/lqCIbObpgezP67YjnQ8iwagHDzrryUZUgsUz/prCYZY05vn+msJhiwM6XrR7q/V9OJU4xQuEpcxRsezNPbXEn3Yzz9Hulg3IYT0C3LxxI2CNCQIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQlpA7P5a0iF2fy3hkLP+WsIhVbu/lvRby+6vJR1i99eSG6ffu1XHxlRNcfWq3I0iIK4tJK2nXVyV0lOqEmvjGWAtvnCu+jMzkQWMiOlYdRSd3MAH/UnVPWR/ApFfSkcpiOBvRc3XtAeYW1ZUJbZMD5C8azhWLX4xvZVR692Se0huHm9ySogxkhs3lFVewPM4WlFUlUMoJEYUox1+jAFIbLuHrKaQWlMu8TicDp+4gdw7/qS4t2qBohk4UUF2nieBfLyDM/ItgXhBucT113i14QbixW7M+SRNb6EQ0u8kHavyZQxj2/kgNUCsYDRoqXfEF/Mdu4G8D43uOtakh3R9H1DsyKZvOmneDjt+D/0DTzolrPMHmggAAAAASUVORK5CYII=", + "description": "Displays changes to timeseries data over time. For example, daily water consumption for last month.", + "descriptor": { + "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.onLatestDataUpdated = function() {\n self.ctx.flot.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasAdditionalLatestDataKeys: true\n };\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}", + "settingsDirective": "tb-flot-bar-widget-settings", + "dataKeySettingsDirective": "tb-flot-bar-key-settings", + "latestDataKeySettingsDirective": "tb-flot-latest-key-settings", + "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 Bar Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/control_widgets.json b/application/src/main/data/json/system/widget_bundles/control_widgets.json new file mode 100644 index 0000000..2dd348c --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/control_widgets.json @@ -0,0 +1,200 @@ +{ + "widgetsBundle": { + "alias": "control_widgets", + "title": "Control widgets", + "image": "data:image/png;base64,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", + "description": "Send commands to devices." + }, + "widgetTypes": [ + { + "alias": "rpc_debug_terminal", + "name": "RPC debug terminal", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAWcklEQVR42u2dB5QWRZeGi6QgklFUDJhFMCJGRFHMomMAMStiBvUXRTFgRjGLERMoiIBZ1GVXdNcF4yIe5agYdvVnfkAwIAoiIs4+/72na3u+NN8wAzsD73vmzOnur7rCrbfuvV3VfSuUlZXNnDnzmGOOadKkSRCEKgAKlZSUzJgxA1IFWNWyZUsJRagutGjRAlIFdJVkIVQvevbsGWQBhWpH06ZNJQRBEARBEARBEARBEARBEARBEARBEARBEARBEARBEARBEARBEITlRK9evc5KwAdAnTt3rlevXvz1oIMOir+efvrp3bp1a9WqVUYOderUOfDAAy+77LJLL7308MMPX2ONNYop99577+ULyaOPPrpwsvfff59kG264Ya0W8uDBg6+55pqq5PDRRx8hh3XXXbfWtPnzzz8vK4/PPvtsjz328F9ffvnljF9///33q6++Ot6+/vrrv/fee+kEX3zxxeabb15biHXqqafecsstK5S4a6655pIlS/7444+11lprtSPWEUcc0alTp/333//hhx/mdNasWa54nFj9+vXj19122+2MM8747rvvuHLiiSe6rnrrrbc4feyxxzp27Aifbr/9dk6nTp1aW4j10ksvkf8uu+yyQoVM/piC1VFjbbbZZtGu+RUXhBMLgxjTQymuvPrqqxzvs88+HH/44Ydps/jJJ59wccstt8wua+ONN7722mvvuOOOQw89NJtYlAIv7777buwp+aSJtd122w0YMIBbUDDRUjMYsL8bbbSRn+63336ctm/f3k/XXnvtiy66iFsw4htssAE/MWzSlWncuDEXUc/kT7L4cXn9+vVxD+65557bbrttr732iunJ7dxzz91mm22o4QEHHECIA27v0aMHgkKFDxs2jIsk23PPPWng0KFD995773jv3wx+3Ldv34svvrh58+a06L777jv77LMbNGiQtgBcf+CBB8hz6623XnWIBd59990CxOrSpQtX3n77bY6HDBnCMfJNZ4gfRl9uuummGQWhz3788UfS//nnn/z/4Ycf0sR6/PHHOcVkLF26lAMkmybW3LlzMSVuaseOHes/jRkzhlPI7aeuLI8//niOGzVq5PxetmzZX3/9VVpayvFdd92Vrs8666zz008/ebYLFix46qmnuNiwYcM33niDK4sXL+ZeDuCTp58/f/7ChQvnzZvHRe91DubMmUMyrzMFPfnkk/z3PDk47LDD/N5fDFHg3PLVV1/5XeCJJ57wn3bffXeSIZ9vvvlmqeGQQw5ZFYiFN3DaaafRbOSFiLOJhX0cNWoUVxhqnCIRjk844YRiCvIbUQNk0qFDB0JQRGIdd9xxrgXxQvjQG9ZyuvPOO0di3XTTTQzrTTbZBAeO01133bUwsWADxxMnTkQx8LTxyiuvZBMrpymEMU5r9CJ6jg7Gp2zdurUTi58YAEiDYePEgppemYEDB/rA8PowtDh94YUXchKLn2644QYai9jhNExC8vzknO7evXscwFOmTFl1nHfUQ9euXdPO+5dffonbRNtcuHCibdu2/Dpu3Lhi/CQHfbBo0aLowKZN4YQJE9K9i71zxy7bx+rfvz+nV155ZWFiTZo0KVLTn3yLJBbEpZsJveKnN954I7/yzOvEQo3VrVvXf3JiQQU/hWqcouz9tE2bNpxOmzYtH7FioITJkydzmn7cYeBh3+EcNaEvajexnn766eHDh0+fPp1jnID4a5pYSA0zdPnll8cISu7pp9PnAx6PPzDmdN4//fTTsixAlGxi4Zxx+tBDDxUmFhXmOJK4SGLh2EWDmwbqx4nFwIg3ZhALKnD6zjvv+ClqktOPP/64QmK9+eabnG611VZOx2eeecZdBQeWd1UwhfhVeAbof9fMOX2sNC655JLIgAgmbF5//XU3EBFYFszEt99+m5NYTuhBgwadlYI7zhnEYqaNU7zjwsT64IMPOI6Kp3iNheGDWzjU6ZpguFcOsbDdHJ9//vn4f5xSk1WEWOD555/nND7CFCZWVNcuCIAywytPW5MInr/w3lyCGWZ05MiRHJ933nnx0XLHHXdMO+877LBDmo6uQh588MG0IXb16cRyf86PAUTJR6wXX3yRn5hJ8VOYEb0cgGPnrFoJxKLV5M/zTdTxiHHVIRbTUXT/999/36xZswqJBW6++WYSoOSYZsQj4WGHU57Vs1PyjO1WlQl6Z1KkBYX+9ttveDC49nhRr732GjJl5iwS6+uvv+Z2suU6vpr3CisBPp2Lq/7oo4/6Q5aTCR8R1YtfjPrE8f/111/zEcvZSTOPPfZYN7XcSBHMlVMi1p97vTuLIVb0sZZPYzFx448OmAL8M44pfdWZbhg9ejRXYEkxxGKcMe/is6aAg6uuuiq9KBSB24u+8e7HqWJONa1voALd4Jmg85Bs1FjYUOY1oB0/zZ49211pVyduDZ15zz77bFpLodXoS39ww4Pk4M4778yuFZz2ykeKYDcZJ54tjykxw5VALCrj7ibjh8dPfwRu165drSRWtQB68XBeTFhUFCEuar5fERw9xBRl9k8+3ZBNWYIb5hQ3pgRPkSlZCM0DB72CLsxZKDmTLGOJs60hztOuTKy33npM3gahBoJ5fzy/K664gnnwU045BccFTcmkuSQjVAnMi6aXz3/++edo0QShqsA7YdmRORRZFkEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEEQBEGoFPiMvY0hRh6rjSAqTs5o4XzU///1QX2luiAdrbRG48gjj9x2223jKQFe09FB0iAsB6Fgxo8fnx2faEXjggsuqGyhxOT4W3kQ7zlYpNPtt98+Oz1Btmga4SeqXlsiRHiJRNAkrkQ1yoHYTDF0b00HUjj44IPT7ClQdXp35RMLLUJImcp2ObH89jAQughecuBhI/MRi/AhW2yxRZEai2BdhLLJpzzofkIp77TTToQzjTFzVjtiEWIKLUUkKihFIBcCUHl0ayKrEueTcMjEwS5ALBJfeOGFxGkl0J6bSMRNpNrrrrsOyjobiEqNXuQKIYGJAEuIDr+XuEWInitplZkBBj3VoFDCe6ADiMccLEQngRi8CAKzFG4gyUpKSuIpxCICFrlRLoGvvVGuYGJ0ZNfclEXTiJIVA68RaITIXgTcIhIYkdnqGWj49ddfTzQbYnE7L+l+v4Xo3zFOGIHdiFdIWwhS4lewvORP7K4zzzzTFRvC9JoQXpDRzq/EfiY4wDnnnEMRMLU2EQu5nGwgWhWhfB555BEEDc8IZIUcGZojRoyI2iKDWGgFwp1BGsiHgLzNWBzER7whIlET1JTQy8SFJ4DWUUcdReBQJE4gP9QDgkboRHRFXhSRL7ARWoTKUOi+++5LKV4TwvPRkRRB0cQoi1EtiyQWt5MtY4aedl1FzgQAi1G+g8VO9oDbDANaESzUJfei9ohrhRb0CIBElidoJREiSMkQ9TjTdD/yJIw2bPDIgEgG8VIKzSfsoAcrZPjBRWQOd+FWsLjzpLn11lsZqORG6xhC1JbQdsTJwVhTw1pDLNrPgECIffr0oeoYHdxYNAFUcFOCX4Viz0ksBpYHmU0Dasawjn4MsVAASATyBYvgTd/40PQi6JsYzTwb9FaG9wMRoxbhmGBllSKWm0LK9YjikcEZxKJfg4Xzp/TGBoaE7yNEZDmvMFFSGVqMGQiHdokGC2VDqyGW798BS9h5IJqIeIzSgklY1fQ2O/AsKnXA8ItBVmuTxsJOwSGGICFZGDfEv+ciCh+tc2SC2IsZxEKsKLZsPyCmJwojIzuDWASJhFiDDLGIAvLKIBbmhkj/ccsGuOsRJStLLCp2//33V0gsHsQonRhuHKOH6GYkg1KJXjn5IDeaBsM87lyUAGbaxQWTXCd5Jh7flf8wj/iUaF9260gTix034ikyjLtj1CZioZYhB3LEGHHg+0EgU8YQqgu3CXUVhZhBLARHHxPPmM7G8PkeJ2SFefXhSMhJQu/nJBZXPKKkF1EgzBDmIENjwRWUhDslFFH4YaK6iEUb4QFWD6UVfXz0jcdnpyFRVUdiMVaRD6YWZwsu4n2SDCHg5KWHB/IpQCx4jDUkPRMlqMxaQywEiuAI2E/VUVfsAONuB2oMtwASIGLvdcJ4jk+AxYyWlDT0FjrP/ST4RKhPOMoI9r1rchKLItCLFIFlxJWOdiQnGNz0OuViR5xPFMfeNRSB71VZ5z2DWG7sIlwCOTUW3Y8QML74SXgOSIzHETQKagxW0Rx/ToRYTxi46FYMPuFdcIq7Rls8GW2h+bhTjCsOGJnQMV0TnLBg+8TwKyWedNJJtUljFQA2qJhpGKSGzsh4UOfGnPFtMwCflnumB3auzKlaPDkfY4DnBhjmD5U0nOYXHhhxRiMjGW13f79CCXsEa2EVBFGN0VVsNIQCxitCsdWaSXChhgO1gReF54R5quGLP4IgCEJNAJ41z4n5XHj81prvkOKVV7aSmMg2CVZrc8lzSlz/Z8LX54R4FPcrLN+yKOEC4qGGFToeubked8st7JHwzJy95b2DCY58+0QsN3r37p3e67bqYNOXym49j5RYSWTChbav1t49mgMRMB3F9C7LZz7VxP42iIZFD1jFJIpPxjDvgsh8iYaVnMIrdCuCWKwKF97KlXX09BbOVQeTWHFXs0qBgSdi/ZNYPiXjK8QYL4gV54LRT8z8YteYPvXVUxQYKi2f1PxFKAY6y2eRWMzRQyPyJGffJwdioQ6ZA2Q6O24tThrfuJU1Vx7sPUP2PWSOnglopjp55yJnoQwA5ieZtmVgULpPLToXmXziJ9/shFa4Jmbyk8VviuP5jsTMoJI5V6ihjyL+e8q4PzlbXXDKjCgpWaLxLTaRHovuXmjcUVHE+j9i8XIfCgZL55PRdD8zwswEQgimlVk/8cUc519hQAIWAVneofOcWKzJsFLE6wOQhvUKDJYTi9lzlkToKiaUnbJxZhkCsVO86wwIzY24LEzZO+2y4au56FEyj8ssaC+K4JjFRKag0D3Ql1/hE1tm8p8a0iJaCrFYaWABgJdV0MrBpuNJyRV/PTAkC3/Mj8NRsvVlKyqP/ma+lIl4n7IXscoRi3UJhjsvMvh6H8TytQtWIXhLCT+MfiVZgV27outKPv5+VTSF9BBZ+YsM8Mn3dnON5XehUfwtgGxi0YsswqBpyBnepF8OywZjIG0KydZf8QPkxjsqfp1d4zCaMRnEQlUzeFge5iUWL9fBex8ZxPLVLVLGl7dYDOV2CEflRawcphCLgEp3kwSxMpxWtA5W0mnnpzlzw4zGZJFYzChCjvgig79wkvaxsCO+CJhNLN8RjheSqBsHhZeJMoiFEcRrjOVGvx5iwbnlI5ZbQFw9f0OBZT60F5qelxdYDhexchCLsUj3M7hzEivYMjtvDsE8eogV1nzvI9AxvrclFHFiAcyZP0iy4uavAEAsuAJR6Crsr6sTFKfvuMnLJN7BmGCsJ9XDxamwLfRxmlgMFYyyv+fJ+wLpjceri1j4W+gqt4kVEotGrUY7kKWdd4Y1byXgXOcklr9TAEXQK3GP02zg0JAApwohRueddxBgLbaMVXp/uQBioU5wluEoXevPmNALS4riwYR5B9ORVIk3TDCm0M57MR94h4KUFMoDgc+kUIS/QIG2Y7kXhqVfH/A25iQWx+mUVCwnsXg4wPNjhFA01e7Xr5+/PRaBLxGrhw+QflFHyCRizu1P0yBB9jsLGDXUT4Yty163b2SIpzAYNenvFPB4CG8qO+tIbv7SywpCke+AeGMrFJ2wkoCqQ8/5W/lMV7qeEIRqAI+izDBhYYuZ7hcEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEQRAEYXUCrxKzn0IN2YpjU96Eri2C48vO8Xy4Ysd85DC0MvfuZPfyR+zOK3m73C4OTC6OIJBkkpJXP4muPNn+ty6Y52l8Z7GCm9yX6JWV6cuyEFoWFOCjy1sTvki5vDLpFxIcPznuYEJuXGOJ1dkE94kd8wXWtMrce6g1lS+CTw5hOh9+2cUXQ3jJLhJDfSnfe9nF94x8+9uv/1Iwz2F8zVK1FhFYfXhBiUPuW6qPWLuGMG95q9ovhFeWl1jdrGItajKx/h7C/9hBJBbfwZwVwutGke4FifVTcoyi+q+EWFHtfWvqgY+X/yKmg11ZN3+G+4XwTAjfh/CFjcUxiSWCoFNCmGjFOYZaguGWM9qid9KQF0L4jxA+ZIeBxGScbq2gSl0SDUERP4TwueUwKn/TMH/PhjCJT+JSxGpvtfp3aywVa2CZUNzviZL2PSeIIvJgCP8Zwu0ESk0y3MWKftvurWNZkf6bEGYn966T6KGxVsQAvuqxKy2sjZNN/aeJtbdVrElNJhasGhzCAyliEb7+K0Lsh0BU6AXW2nzEmm8tJxrGhBDGJcS6xy7uHsJi01IllqxCtDXOTbIe7W48A4TyGG290sf6zzuYPSGuMgqONhPc1XyguVZQa9O+TrXeRiDuPdwqsInVs7uRb6wddMtfGRjwnBV0TUKspkaCPiaNKUb3upYJBz/bAX9tjDRTjPp8dj3SGAPWMzGeb8yeZi5Ec0s/0nS539vILn5nwu9o1wfYvQ/biO1iHsKfKWK1tSaHGk4shP4jH4wnxIIlVycJ0sfZxFpmt5eZgWubEGuxabKZierqbZkXiVFZprCVifVAK2Xn5CJc+S3x6oIN3GWmZsCrIfjGSCiwp8wo8zeDz5qTxOMqMoVrmortlKguJ1YPI5bnhkL6IEl8cHlTuJGlP9mSDTRR1DOuTM9V0ODyppDvx0uTIh4xcoMv7XbHohSxvGtqOrGCmYwRCbHeNKE4nsvfDdEU/ptpqZBlCqPSXmqGIyQWpHhiEVJolnlvl1mHdUoRa3b5G4dYH0w0jdUmadEEu9H/9iqaWE2trI7lfSy+jZ+Tyu2cPMRqb+kHpVJilNnhZGoRxEIn/SN141l2sTRRwBk+lj881QJiHW/Dy4l1k3VJXdMEfzc/ozCxyGQJexHkIdYa1iV97fgic0oKYGR5Yg0xZrs1KUAsDNBbps82TE0NXG/aq4GxmVAN8YPrMUU476VW1WCejROL1v3CBmDJxX1Szwrzyjd2rqk3sG2ibKjYryG0s7rBuR2SxFeUJ9Y2ZjFd7/ZIRsK/mvaqY7cvSxEL//IO81lrOrEaGkucWM1t6JeaHzPMWlWh8/6cOac5iRWMmvPMF5lb0LNxgS6wvlySdMk8q9Uoc4z+2y5OtQT+tyi5Ef30h9Vnjj33+aiYYE1YYP23dsri/GL3LsxfjWPN1PLwcXPKeT/X8p9lkokd3Nx0pFfmuOTh+h+WZr49PUQO/WK3fxTCBimVMye5t51d6Z8UMTMhVmdrAsluLa+x+pfnWW1C8+qejmuTPOlUCuiACsO9dTWXvIX9dTDV2yHlfrVcrto2zHVjPatMMa1Y32qeRgO7WGHkifpZRdTPo5nW0vz1StC7883PQ1m+HMLHKb9eEKoE5oEOMpvbucaswAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAiCIAjLjSZNmkgIQvWiWbNmoaSkRIIQqhe9evUKM2bMaNGihWQhVBdatWpVWloaysrKZs6c2bNnz6ZNm0ooQlUAhdBVsApS/S856Z9QcCOqUQAAAABJRU5ErkJggg==", + "description": "Allows to send any RPC command using its name and parameters to device. Useful for debug.", + "descriptor": { + "type": "rpc", + "sizeX": 9.5, + "sizeY": 5.5, + "resources": [], + "templateHtml": "
", + "templateCss": ".cmd .cursor.blink {\n -webkit-animation-name: terminal-underline;\n -moz-animation-name: terminal-underline;\n -ms-animation-name: terminal-underline;\n animation-name: terminal-underline;\n}\n.terminal .inverted, .cmd .inverted {\n border-bottom-color: #aaa;\n}\n\n", + "controllerScript": "var requestTimeout = 500;\nvar requestPersistent = false;\nvar persistentPollingInterval = 5000;\n\nself.onInit = function() {\n var subscription = self.ctx.defaultSubscription;\n var rpcEnabled = subscription.rpcEnabled;\n var deviceName = 'Simulated';\n var prompt;\n if (subscription.targetDeviceName && subscription.targetDeviceName.length) {\n deviceName = subscription.targetDeviceName;\n }\n if (self.ctx.settings.requestTimeout) {\n requestTimeout = self.ctx.settings.requestTimeout;\n }\n if (self.ctx.settings.requestPersistent) {\n requestPersistent = self.ctx.settings.requestPersistent;\n }\n if (self.ctx.settings.persistentPollingInterval) {\n persistentPollingInterval = self.ctx.settings.persistentPollingInterval;\n }\n var greetings = 'Welcome to ThingsBoard RPC debug terminal.\\n\\n';\n if (!rpcEnabled) {\n greetings += 'Target device is not set!\\n\\n';\n prompt = '';\n } else {\n greetings += 'Current target device for RPC commands: [[b;#fff;]' + deviceName + ']\\n\\n';\n greetings += 'Please type [[b;#fff;]\\'help\\'] to see usage.\\n';\n prompt = '[[b;#8bc34a;]' + deviceName +']> ';\n }\n \n var terminal = $('#device-terminal', self.ctx.$container).terminal(\n function(command) {\n if (command !== '') {\n try {\n var localCommand = command.trim();\n var requestUUID = uuidv4();\n if (localCommand === 'help') {\n printUsage(this);\n } else {\n var spaceIndex = localCommand.indexOf(' ');\n if (spaceIndex === -1 && !localCommand.length) {\n this.error(\"Wrong number of arguments!\");\n this.echo(' ');\n } else {\n var params;\n if (spaceIndex === -1) {\n spaceIndex = localCommand.length;\n }\n var name = localCommand.substr(0, spaceIndex);\n var args = localCommand.substr(spaceIndex + 1);\n if (args.length) {\n try {\n params = JSON.parse(args);\n } catch (e) {\n params = args;\n }\n }\n performRpc(this, name, params, requestUUID);\n }\n }\n } catch(e) {\n this.error(new String(e));\n }\n } else {\n this.echo('');\n }\n }, {\n greetings: greetings,\n prompt: prompt,\n enabled: rpcEnabled\n });\n \n if (!rpcEnabled) {\n terminal.error('No RPC target detected!').pause();\n }\n}\n\n\nfunction printUsage(terminal) {\n var commandsListText = '\\n[[b;#fff;]Usage:]\\n';\n commandsListText += ' [params body]]\\n\\n';\n commandsListText += '[[b;#fff;]Example 1:]\\n'; \n commandsListText += ' myRemoteMethod1 myText\\n\\n'; \n commandsListText += '[[b;#fff;]Example 2:]\\n'; \n commandsListText += ' myOtherRemoteMethod \"{\\\\\"key1\\\\\": 2, \\\\\"key2\\\\\": \\\\\"myVal\\\\\"}\"\\n'; \n terminal.echo(new String(commandsListText));\n}\n\n\nfunction performRpc(terminal, method, params, requestUUID) {\n terminal.pause();\n self.ctx.controlApi.sendTwoWayCommand(method, params, requestTimeout, requestPersistent, persistentPollingInterval, requestUUID).subscribe(\n function success(responseBody) {\n terminal.echo(JSON.stringify(responseBody));\n terminal.echo(' ');\n terminal.resume();\n },\n function fail() {\n var errorText = self.ctx.defaultSubscription.rpcErrorText;\n terminal.error(errorText);\n terminal.echo(' ');\n terminal.resume();\n }\n );\n}\n\n\nfunction uuidv4() {\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {\n var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);\n return v.toString(16);\n });\n}\n\n \nself.onDestroy = function() {\n self.ctx.controlApi.completedCommand();\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-rpc-terminal-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":true,\"backgroundColor\":\"#010101\",\"color\":\"rgba(255, 254, 254, 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\":\"RPC debug terminal\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "rpc_remote_shell", + "name": "RPC remote shell", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAU6klEQVR42u2dCbSW0xrHtyZDOpVkyFCKIplCZpLMIVJIhsgYmSUakLniInOGyFRXaCn3ilws89Q1RrpRSq4k15Tx3N96Ht9e73m/4Xzn9EVH//8666z97u99997v3v/9PM8e3meH8vLyWbNmdevWrUGDBkEQFgNQqGvXrtOmTYNUAVatssoqqhShVGjcuDGkCsgq1YVQWnTv3j1IAwolR1lZmSpBEARBEARBEARBEARBEARBEARBEARBEARBEARBEARBEARBEAShmujRo8fxGfAB0NZbb127du3465577hl/7d2796677tqkSZNUCsstt9wee+zRv3//c845p0uXLvXq1ftLVtQ222zDJ50PP/xwlZ7abbfdeOruu+8mXKtWLcKzZ89eJoj1/vvvl1fEe++9t9122/mvEyZMSP26aNGiQYMGxcfXXHPNl156KXnDBx980KpVq6Xh1aD7FVdcsdVWW4lYfxqx9t9//y233JJauPXWW7mcM2eOCx4n1imnnMKv1Oyxxx47b948Yg4//HCXVc888wyXt99+e7t27eDT8OHDuXzttdeWhlcbOnQohTnmmGNErD+NWC1btox6zWPQiZFYKMR4P5QiZuLEiYR32WUXwq+//npSLb711ltEbrDBBqmMDjnkENTluuuue+mllx533HEe2axZs/POO++mm24666yzVl11VY9s27Ytd3bo0GHvvfe+/PLLhw0btummmxJ/0EEHXXfddRdddFGbNm2SKcPpCy+8kET69evXqFEjj6QzPPXUU5Rk3LhxJ554Yry5c+fOJHjttdfisIDS5qyTpk2bnn322eR1/vnnR+kbiUV2Q4YMufLKK7fffvvkU+uss86AAQMoxhlnnBEdIIhYLWPMiy++WIBYO+64IzHPP/884csuu4wwJEgmiB2GQbbeeuulMpo8eTI3z5w5k/8kS8xOO+20cOFCLr/77jv+z507t3nz5sT37NnTpSb/f/75Z/5///33Dz74IIGffvrJ74/pQ5pffvmFyB9++MGbbf311yceLxcew81Tp071m2l1T8TTueOOO7IrZOONN/7qq6/IlxTQ+9y57777RmKRPsl6qX799VdeNhLom2++ie+CH4S11lpLxPqd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", + "description": "Allows to emulate remote shell. Requires custom implementation on the target device to work properly.", + "descriptor": { + "type": "rpc", + "sizeX": 9.5, + "sizeY": 5.5, + "resources": [], + "templateHtml": "
", + "templateCss": ".cmd .cursor.blink {\n -webkit-animation-name: terminal-underline;\n -moz-animation-name: terminal-underline;\n -ms-animation-name: terminal-underline;\n animation-name: terminal-underline;\n}\n.terminal .inverted, .cmd .inverted {\n border-bottom-color: #aaa;\n}\n", + "controllerScript": "var requestTimeout = 500;\nvar commandStatusPollingInterval = 200;\n\nvar welcome = 'Welcome to ThingsBoard RPC remote shell.\\n';\n\nvar terminal, rpcEnabled, simulated, deviceName, cwd;\nvar commandExecuting = false;\n\nself.onInit = function() {\n var subscription = self.ctx.defaultSubscription;\n rpcEnabled = subscription.rpcEnabled;\n if (subscription.targetDeviceName && subscription.targetDeviceName.length) {\n deviceName = subscription.targetDeviceName;\n } else {\n deviceName = 'Simulated';\n simulated = true;\n }\n if (self.ctx.settings.requestTimeout) {\n requestTimeout = self.ctx.settings.requestTimeout;\n }\n \n terminal = $('#device-terminal', self.ctx.$container).terminal(\n function (command) {\n if (command && command.trim().length) {\n try {\n if (simulated) {\n this.echo(command);\n } else {\n sendCommand(this, command);\n }\n } catch(e) {\n this.error(e + '');\n }\n } else {\n this.echo('');\n }\n }, {\n greetings: false,\n enabled: rpcEnabled,\n prompt: rpcEnabled ? currentPrompt : '',\n name: 'shell',\n pauseEvents: false,\n keydown: function (e, term) {\n if ((e.which == 67 || e.which == 68) && e.ctrlKey) { // CTRL+C || CTRL+D\n if (commandExecuting) {\n terminateCommand(term);\n return false;\n }\n }\n },\n onInit: initTerm\n }\n );\n \n};\n\nfunction initTerm(terminal) {\n terminal.echo(welcome);\n if (!rpcEnabled) {\n terminal.error('Target device is not set!\\n');\n } else {\n terminal.echo('Current target device for RPC terminal: [[b;#fff;]' + deviceName + ']\\n');\n if (!simulated) {\n terminal.pause();\n getTermInfo(terminal,\n function (remoteTermInfo) {\n if (remoteTermInfo) {\n terminal.echo('Remote platform info:');\n terminal.echo('OS: [[b;#fff;]' + remoteTermInfo.platform + ']');\n if (remoteTermInfo.release) {\n terminal.echo('OS release: [[b;#fff;]' + remoteTermInfo.release + ']');\n }\n terminal.echo('\\r');\n } else {\n terminal.echo('[[;#f00;]Unable to get remote platform info.\\nDevice is not responding.]\\n');\n }\n terminal.resume();\n });\n }\n }\n}\n\nfunction currentPrompt(callback) {\n if (cwd) {\n callback('[[b;#2196f3;]' + deviceName +']: [[b;#8bc34a;]' + cwd +']> ');\n } else {\n callback('[[b;#8bc34a;]' + deviceName +']> ');\n }\n}\n\nfunction getTermInfo(terminal, callback) {\n self.ctx.controlApi.sendTwoWayCommand('getTermInfo', null, requestTimeout).subscribe(\n function (termInfo) {\n cwd = termInfo.cwd;\n if (callback) {\n callback(termInfo);\n } \n },\n function () {\n if (callback) {\n callback(null);\n }\n }\n );\n}\n\nfunction sendCommand(terminal, command) {\n terminal.pause();\n var sendCommandRequest = {\n command: command,\n cwd: cwd\n };\n self.ctx.controlApi.sendTwoWayCommand('sendCommand', sendCommandRequest, requestTimeout).subscribe(\n function (responseBody) {\n if (responseBody && responseBody.ok) {\n commandExecuting = true;\n setTimeout( pollCommandStatus.bind(null,terminal), commandStatusPollingInterval );\n } else {\n var error = responseBody ? responseBody.error : 'Unhandled error.';\n terminal.error(error);\n terminal.resume();\n }\n },\n function () {\n onRpcError(terminal);\n }\n );\n}\n\nfunction terminateCommand(terminal) {\n self.ctx.controlApi.sendTwoWayCommand('terminateCommand', null, requestTimeout).subscribe(\n function (responseBody) {\n if (!responseBody.ok) {\n commandExecuting = false;\n terminal.error(responseBody.error);\n terminal.resume();\n } \n },\n function () {\n onRpcError(terminal);\n }\n ); \n}\n\nfunction onRpcError(terminal) {\n var errorText = self.ctx.defaultSubscription.rpcErrorText;\n terminal.error(errorText);\n terminal.resume();\n}\n\nfunction pollCommandStatus(terminal) {\n self.ctx.controlApi.sendTwoWayCommand('getCommandStatus', null, requestTimeout).subscribe(\n function (commandStatusResponse) {\n for (var i=0;i", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-knob-control-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"maxValue\":100,\"initialValue\":50,\"minValue\":0,\"title\":\"Knob control\",\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\"},\"title\":\"Knob Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" + } + }, + { + "alias": "switch_control", + "name": "Switch Control", + "image": "data:image/png;base64,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", + "description": "Allows to send the RPC call to device when user toggle the switch. Advanced widget settings allow you to configure how to fetch the initial value of the switch.", + "descriptor": { + "type": "rpc", + "sizeX": 4, + "sizeY": 2.5, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-switch-control-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"showOnOffLabels\":true,\"title\":\"Switch control\"},\"title\":\"Switch Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" + } + }, + { + "alias": "round_switch", + "name": "Round switch", + "image": "data:image/png;base64,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", + "description": "Allows to send the RPC call to device when user toggle the switch. Advanced widget settings allow you to configure how to fetch the initial value of the switch.", + "descriptor": { + "type": "rpc", + "sizeX": 2.5, + "sizeY": 2, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-round-switch-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"title\":\"Round switch\",\"retrieveValueMethod\":\"rpc\",\"valueKey\":\"value\",\"parseValueFunction\":\"return data ? true : false;\",\"convertValueFunction\":\"return value;\"},\"title\":\"Round switch\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" + } + }, + { + "alias": "led_indicator", + "name": "Led indicator", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAABCoklEQVR42u19aZBkV3XmPS8zq6qr90ZILQkEaEXsm0ACg9hNBCZmBsZAYMcYRxA22GGH/csRjvAP//QPhyPswI7xTBgCM8yAPfaETdh4AAMDQgIEQmKxQGhF+95bdVVl5jtz7j3rfZlZ3S1R1ZXtTpWqX77KfPnyvu+d5TsbIGI6+zj7+Fk/mrNLcPZxFlhnH3Pz6J/B3+2GG25YXV196Utfun///sm/Hj16dOfOnQDwFI58/fXXr62tvexlL9u3bx89PX78+GAw6Pf7Z/G0TYH1wAMP/M3f/A1d7N/6rd96+kf70pe+9MQTTzzrWc+aBNYXv/jFf/mXf3n961//rne96ykc+V//9V8PHTp00UUXEbDonP/sz/5s9+7dv/d7v9c0ZzXAtgQWiYF77733qUmRU3qMx2P7/TQfbduSA0S/n/6hvlEez3/+89/+9refBdZcPujKXX311SRmnv6hLrzwwj/4gz8gVfj0xdXhw4fpvjr33HPPSqzT8LjvvvtuuummJ598ktTQK1/5yvPPP9/+RBYVWT+km0j3vfzlL9/gILfeeusdd9xBuuxFL3oRa0YSlm984xu///3v33777YuLi7T/8ssvj0f++te//uCDD9KRX/3qV8dD0ZnQnxYWFt761rfazn8rDzrmOeecc9VVV7Epxg/Szt/+9rcffvjhpaWl5zznOXSehMjhcPj5z3/+rrvu4i/4T//0T/TVzjvvPHq6srJy44033n///bT9vOc9j/abMUf76TgveclL6I333HPPW97yFn7LWWCd8uOrX/3qZz/7WePevva1r33gAx+gleVr/9GPfvShhx6yP21wHELVl7/85de85jUMrOuuu45s+R/96EcESjP8f+mXfokMf7bN6ch0Cc1yjzr0yJEjdKjl5WUGFp3bpz/96e985zv2gq985Ssf/OAHL730Utqmj/jEJz5BMOr1enQQ+hQ62oc//OHRaEQH4dc/VB4EekIJgexjH/sYSTL+Ex2WQPxrv/Zr5HbQ0+9973sE35tvvpnASk8Ni2eBdWqPn/70p4QqUjoEpuc+97m0rP+7PMgoIYFBUoeux969e9///veTnCBj5Qtf+MIpHZ+O/Lu/+7s7duygT7nlllsI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", + "description": "Visualize the state of the device. Fetches the value from device using RPC or using attribute subscription.", + "descriptor": { + "type": "rpc", + "sizeX": 2.5, + "sizeY": 2.5, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-led-indicator-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":true,\"title\":\"Led indicator\",\"ledColor\":\"#4caf50\",\"valueAttribute\":\"value\",\"retrieveValueMethod\":\"attribute\",\"parseValueFunction\":\"return data ? true : false;\",\"performCheckStatus\":true,\"checkStatusMethod\":\"checkStatus\"},\"title\":\"Led indicator\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}" + } + }, + { + "alias": "rpcbutton", + "name": "RPC Button", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAN8SURBVHja7dvbSxRRHAfw/X9+bhcvrJZSmllhZFSCWBDaY4FUhBRZCQXR7akLyoJYlqxU0g0tukF0oYQM3bxsYVRgbuuyu7k7szNn9vSb2Wx9qB56ac72/T7MHGYZmA/nMjvDbzzSTkbpOASPw7CUjkPx2AwhTNNQNKYphE1hiCXMtK5pWkrB8GXraZMl0sMOQ9dEdqApmIzQdIMlDDH1lLIMh5LSTYZYIp1MS6XDAGF5uEPiltoQK85dwhAtKhVPVLMhRj5ADAcyqzpkNgtJqQ9JAQIIIIAAAggggAACCCB/8wog8btfAjd+f9Y3t0GmW5bS8rO/fvavaXB2jWVlZbVHwjLC+7L1x2P8rN1ZQcWtX90EyWws7r7bSuf+BKnz+f1t3norTPV+/0HvNiFP0+5b54sahYsgn+mQrdksZfL6hYcZGe2betoxyG/LtP7OiXlIJW/a6F2Y9nPjKI3MLtrBjbM05CJIsqT6tdOI1BbWFuyRQVpbupw7yGygqqLCBZCTNJqFdNGTAQpwY2bwo5vmyL1iqr+uS3l4yaTsoaEg7bT0VXWyn/xyquQHpDz4JuCrFWHaG48OrSuJXqLHLly1Yl01VDkuq6r7+i5Qd5ABsqlctlMiN0eI0/BZhu09rXggu10J4TVoYMlWWeKr51x2IM3lcp83k4NUfAqVbrQYsn3w/lvuvTt0lQ9Hn027CNJLt3m7YaXctIaXYCHnIWfog5Src3Okg/pldo7Ys8O7S9qHXrgIEllWee35uYJ9MkAHXgVq3s9Dxguan56iHCRZUa3/hPDAa3/U7dtiuGloTTZ7aXFrnO9xPio8Zs5D5JViaqpbsGpdpK4cxDxRRItbpl02R1Jfsje2zIy+8LCI/ekkc0a4b7Lj3y8ggAACCCCAAAIIIIAA8v9C8qaoRosqXngmsmVOXHgWVxsSyxaemXosJFR2mBMxpxRQpOPB0YihKsOIjAbjdnEml8vOhUaGX97s7VEwvTdfDo+E5kyn7pe7JDQWVDZjIbtDMk5JuZGcSyQScQXDlz2XNJyS8vwp8s+bzy6kzJsPYfIigAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggg/yTfATinNXQTM7TQAAAAAElFTkSuQmCC", + "description": "Allows to send RPC command when user press the button.", + "descriptor": { + "type": "rpc", + "sizeX": 4, + "sizeY": 2, + "resources": [], + "templateHtml": "
\n
\n {{title}}\n
\n
\n
\n \n
\n
\n
\n {{ error }}\n
\n
", + "templateCss": ".tb-rpc-button {\n width: 100%;\n height: 100%;\n}\n\n.tb-rpc-button .title-container {\n font-weight: 500;\n white-space: nowrap;\n margin: 10px 0;\n}\n\n.tb-rpc-button .button-container div{\n min-width: 80%\n}\n\n.tb-rpc-button .button-container .mat-button{\n width: 100%;\n margin: 0;\n}\n\n.tb-rpc-button .error-container {\n position: absolute;\n top: 2%;\n right: 0;\n left: 0;\n z-index: 4;\n height: 14px;\n}\n\n.tb-rpc-button .error-container .button-error {\n color: #ff3315;\n white-space: nowrap;\n}", + "controllerScript": "var requestPersistent = false;\nvar persistentPollingInterval = 5000;\n\nself.onInit = function() {\n if (self.ctx.settings.requestPersistent) {\n requestPersistent = self.ctx.settings.requestPersistent;\n }\n if (self.ctx.settings.persistentPollingInterval) {\n persistentPollingInterval = self.ctx.settings.persistentPollingInterval;\n }\n \n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges();\n });\n};\n\nfunction init() {\n let rpcEnabled = self.ctx.defaultSubscription.rpcEnabled;\n\n self.ctx.$scope.buttonLable = self.ctx.settings.buttonText;\n self.ctx.$scope.showTitle = self.ctx.settings.title &&\n self.ctx.settings.title.length ? true : false;\n self.ctx.$scope.title = self.ctx.settings.title;\n self.ctx.$scope.styleButton = self.ctx.settings.styleButton;\n\n if (self.ctx.settings.styleButton.isPrimary ===\n false) {\n self.ctx.$scope.customStyle = {\n 'background-color': self.ctx.$scope.styleButton.bgColor,\n 'color': self.ctx.$scope.styleButton.textColor\n };\n }\n\n if (!rpcEnabled) {\n self.ctx.$scope.error =\n 'Target device is not set!';\n }\n\n self.ctx.$scope.sendCommand = function() {\n var rpcMethod = self.ctx.settings.methodName;\n var rpcParams = self.ctx.settings.methodParams;\n if (rpcParams.length) {\n try {\n rpcParams = JSON.parse(rpcParams);\n } catch (e) {}\n }\n var timeout = self.ctx.settings.requestTimeout;\n var oneWayElseTwoWay = self.ctx.settings.oneWayElseTwoWay ?\n true : false;\n\n var commandPromise;\n if (oneWayElseTwoWay) {\n commandPromise = self.ctx.controlApi.sendOneWayCommand(\n rpcMethod, rpcParams, timeout, requestPersistent, persistentPollingInterval);\n } else {\n commandPromise = self.ctx.controlApi.sendTwoWayCommand(\n rpcMethod, rpcParams, timeout, requestPersistent, persistentPollingInterval);\n }\n commandPromise.subscribe(\n function success() {\n self.ctx.$scope.error = \"\";\n self.ctx.detectChanges();\n },\n function fail(rejection) {\n if (self.ctx.settings.showError) {\n self.ctx.$scope.error =\n rejection.status + \": \" +\n rejection.statusText;\n self.ctx.detectChanges();\n }\n }\n );\n };\n}\n\nself.onDestroy = function() {\n self.ctx.controlApi.completedCommand();\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-send-rpc-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":5000,\"oneWayElseTwoWay\":true,\"buttonText\":\"Send RPC\",\"styleButton\":{\"isRaised\":true,\"isPrimary\":false},\"methodName\":\"rpcCommand\",\"methodParams\":\"{}\"},\"title\":\"RPC Button\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_attributes", + "name": "Update device attribute", + "image": "data:image/png;base64,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", + "description": "Allows to send shared attribute update when user press the button.", + "descriptor": { + "type": "rpc", + "sizeX": 4, + "sizeY": 2, + "resources": [], + "templateHtml": "
\n
\n {{title}}\n
\n
\n
\n \n
\n
\n
\n {{ error }}\n
\n
", + "templateCss": ".tb-rpc-button {\n width: 100%;\n height: 100%;\n}\n\n.tb-rpc-button .title-container {\n font-weight: 500;\n white-space: nowrap;\n margin: 10px 0;\n}\n\n.tb-rpc-button .button-container div{\n min-width: 80%\n}\n\n.tb-rpc-button .button-container .mat-button{\n width: 100%;\n margin: 0;\n}\n\n.tb-rpc-button .error-container {\n position: absolute;\n top: 2%;\n right: 0;\n left: 0;\n z-index: 4;\n height: 14px;\n}\n\n.tb-rpc-button .error-container .button-error {\n color: #ff3315;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges();\n });\n};\n\nfunction init() {\n self.ctx.$scope.buttonLable = self.ctx.settings.buttonText;\n self.ctx.$scope.showTitle = self.ctx.settings.title &&\n self.ctx.settings.title.length ? true : false;\n self.ctx.$scope.title = self.ctx.settings.title;\n self.ctx.$scope.styleButton = self.ctx.settings.styleButton;\n let entityAttributeType = self.ctx.settings.entityAttributeType;\n let entityParameters = JSON.parse(self.ctx.settings.entityParameters);\n\n if (self.ctx.settings.styleButton.isPrimary ===\n false) {\n self.ctx.$scope.customStyle = {\n 'background-color': self.ctx.$scope.styleButton\n .bgColor,\n 'color': self.ctx.$scope.styleButton.textColor\n };\n }\n\n let attributeService = self.ctx.$scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n\n self.ctx.$scope.sendUpdate = function() {\n let attributes = [];\n for (let key in entityParameters) {\n attributes.push({\n \"key\": key,\n \"value\": entityParameters[key]\n });\n }\n \n let entityId = {\n entityType: \"DEVICE\",\n id: self.ctx.defaultSubscription.targetDeviceId\n };\n attributeService.saveEntityAttributes(entityId,\n entityAttributeType, attributes).subscribe(\n function success() {\n self.ctx.$scope.error = \"\";\n self.ctx.detectChanges();\n },\n function fail(rejection) {\n if (self.ctx.settings.showError) {\n self.ctx.$scope.error =\n rejection.status + \": \" +\n rejection.statusText;\n self.ctx.detectChanges();\n }\n }\n\n );\n };\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-device-attribute-widget-settings", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"#e6e7e8\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"styleButton\":{\"isRaised\":true,\"isPrimary\":false},\"entityParameters\":\"{}\",\"entityAttributeType\":\"SERVER_SCOPE\",\"buttonText\":\"Update device attribute\"},\"title\":\"Update device attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"targetDeviceAliases\":[]}" + } + }, + { + "alias": "persistent_table", + "name": "Persistent RPC table", + "image": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yMDAgMEgwVjE2MEgyMDBWMFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0yMDAgMTIwSDBWMTIxSDIwMFYxMjBaIiBmaWxsPSIjRTBFMEUwIi8+CjxwYXRoIGQ9Ik0yMDAgODBIMFY4MUgyMDBWODBaIiBmaWxsPSIjRTBFMEUwIi8+CjxwYXRoIGQ9Ik0yMDAgMzlIMFY0MEgyMDBWMzlaIiBmaWxsPSIjRTBFMEUwIi8+CjxwYXRoIGQ9Ik0xNS42Njg1IDE5Ljk4NjhIMTQuMTUzOFYyM0gxMi43OTQ5VjE1LjE3OTdIMTUuNTQ0OUMxNi40NDczIDE1LjE3OTcgMTcuMTQzNyAxNS4zODIgMTcuNjM0MyAxNS43ODY2QzE4LjEyNDggMTYuMTkxMiAxOC4zNzAxIDE2Ljc3NjcgMTguMzcwMSAxNy41NDNDMTguMzcwMSAxOC4wNjU4IDE4LjI0MyAxOC41MDQ0IDE3Ljk4ODggMTguODU4OUMxNy43MzgxIDE5LjIwOTggMTcuMzg3MiAxOS40ODAxIDE2LjkzNiAxOS42Njk5TDE4LjY5MjQgMjIuOTMwMlYyM0gxNy4yMzY4TDE1LjY2ODUgMTkuOTg2OFpNMTQuMTUzOCAxOC44OTY1SDE1LjU1MDNDMTYuMDA4NiAxOC44OTY1IDE2LjM2NjcgMTguNzgxOSAxNi42MjQ1IDE4LjU1MjdDMTYuODgyMyAxOC4zMiAxNy4wMTEyIDE4LjAwMzEgMTcuMDExMiAxNy42MDIxQzE3LjAxMTIgMTcuMTgzMSAxNi44OTEzIDE2Ljg1OSAxNi42NTE0IDE2LjYyOTlDMTYuNDE1IDE2LjQwMDcgMTYuMDYwNSAxNi4yODI2IDE1LjU4NzkgMTYuMjc1NEgxNC4xNTM4VjE4Ljg5NjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0yMS4wMTgxIDIwLjA5NDJWMjNIMTkuNjU5MlYxNS4xNzk3SDIyLjY1MDlDMjMuNTI0NiAxNS4xNzk3IDI0LjIxNzQgMTUuNDA3MSAyNC43Mjk1IDE1Ljg2MThDMjUuMjQ1MSAxNi4zMTY2IDI1LjUwMjkgMTYuOTE4MSAyNS41MDI5IDE3LjY2NjVDMjUuNTAyOSAxOC40MzI4IDI1LjI1MDUgMTkuMDI5IDI0Ljc0NTYgMTkuNDU1MUMyNC4yNDQzIDE5Ljg4MTIgMjMuNTQwNyAyMC4wOTQyIDIyLjYzNDggMjAuMDk0MkgyMS4wMTgxWk0yMS4wMTgxIDE5LjAwMzlIMjIuNjUwOUMyMy4xMzQzIDE5LjAwMzkgMjMuNTAzMSAxOC44OTExIDIzLjc1NzMgMTguNjY1NUMyNC4wMTE2IDE4LjQzNjQgMjQuMTM4NyAxOC4xMDY5IDI0LjEzODcgMTcuNjc3MkMyNC4xMzg3IDE3LjI1NDcgMjQuMDA5OCAxNi45MTgxIDIzLjc1MiAxNi42Njc1QzIzLjQ5NDEgMTYuNDEzMiAyMy4xMzk2IDE2LjI4MjYgMjIuNjg4NSAxNi4yNzU0SDIxLjAxODFWMTkuMDAzOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTMyLjY2MjYgMjAuNDU0MUMzMi41ODM4IDIxLjI4ODQgMzIuMjc1OSAyMS45NDAxIDMxLjczODggMjIuNDA5MkMzMS4yMDE3IDIyLjg3NDcgMzAuNDg3MyAyMy4xMDc0IDI5LjU5NTcgMjMuMTA3NEMyOC45NzI3IDIzLjEwNzQgMjguNDIzIDIyLjk2MDYgMjcuOTQ2OCAyMi42NjdDMjcuNDc0MSAyMi4zNjk4IDI3LjEwODkgMjEuOTQ5MSAyNi44NTExIDIxLjQwNDhDMjYuNTkzMyAyMC44NjA1IDI2LjQ1OSAyMC4yMjg1IDI2LjQ0ODIgMTkuNTA4OFYxOC43NzgzQzI2LjQ0ODIgMTguMDQwNyAyNi41Nzg5IDE3LjM5MDggMjYuODQwMyAxNi44Mjg2QzI3LjEwMTcgMTYuMjY2NCAyNy40NzU5IDE1LjgzMzIgMjcuOTYyOSAxNS41Mjg4QzI4LjQ1MzUgMTUuMjI0NCAyOS4wMTkyIDE1LjA3MjMgMjkuNjYwMiAxNS4wNzIzQzMwLjUyMzEgMTUuMDcyMyAzMS4yMTc4IDE1LjMwNjggMzEuNzQ0MSAxNS43NzU5QzMyLjI3MDUgMTYuMjQ1IDMyLjU3NjcgMTYuOTA3NCAzMi42NjI2IDE3Ljc2MzJIMzEuMzA5MUMzMS4yNDQ2IDE3LjIwMSAzMS4wNzk5IDE2Ljc5NjQgMzAuODE0OSAxNi41NDkzQzMwLjU1MzUgMTYuMjk4NyAzMC4xNjg2IDE2LjE3MzMgMjkuNjYwMiAxNi4xNzMzQzI5LjA2OTMgMTYuMTczMyAyOC42MTQ2IDE2LjM5IDI4LjI5NTkgMTYuODIzMkMyNy45ODA4IDE3LjI1MjkgMjcuODE5NyAxNy44ODQ5IDI3LjgxMjUgMTguNzE5MlYxOS40MTIxQzI3LjgxMjUgMjAuMjU3MiAyNy45NjI5IDIwLjkwMTcgMjguMjYzNyAyMS4zNDU3QzI4LjU2OCAyMS43ODk3IDI5LjAxMiAyMi4wMTE3IDI5LjU5NTcgMjIuMDExN0MzMC4xMjkyIDIyLjAxMTcgMzAuNTMwMyAyMS44OTE4IDMwLjc5ODggMjEuNjUxOUMzMS4wNjc0IDIxLjQxMTkgMzEuMjM3NSAyMS4wMTI3IDMxLjMwOTEgMjAuNDU0MUgzMi42NjI2WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMzguMDU1MiAyM0gzNi43MDE3VjE1LjE3OTdIMzguMDU1MlYyM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTM5LjcyNTYgMjNWMTUuMTc5N0g0Mi4wMzUyQzQyLjcyNjIgMTUuMTc5NyA0My4zMzg1IDE1LjMzMzcgNDMuODcyMSAxNS42NDE2QzQ0LjQwOTIgMTUuOTQ5NSA0NC44MjQ1IDE2LjM4NjQgNDUuMTE4MiAxNi45NTIxQzQ1LjQxMTggMTcuNTE3OSA0NS41NTg2IDE4LjE2NiA0NS41NTg2IDE4Ljg5NjVWMTkuMjg4NkM0NS41NTg2IDIwLjAyOTggNDUuNDEgMjAuNjgxNSA0NS4xMTI4IDIxLjI0MzdDNDQuODE5MiAyMS44MDU4IDQ0LjM5ODQgMjIuMjM5MSA0My44NTA2IDIyLjU0MzVDNDMuMzA2MyAyMi44NDc4IDQyLjY4MTUgMjMgNDEuOTc2MSAyM0gzOS43MjU2Wk00MS4wODQ1IDE2LjI3NTRWMjEuOTE1SDQxLjk3MDdDNDIuNjgzMyAyMS45MTUgNDMuMjI5MyAyMS42OTMgNDMuNjA4OSAyMS4yNDlDNDMuOTkyIDIwLjgwMTQgNDQuMTg3MiAyMC4xNjA1IDQ0LjE5NDMgMTkuMzI2MlYxOC44OTExQzQ0LjE5NDMgMTguMDQyNSA0NC4wMDk5IDE3LjM5NDQgNDMuNjQxMSAxNi45NDY4QzQzLjI3MjMgMTYuNDk5MiA0Mi43MzcgMTYuMjc1NCA0Mi4wMzUyIDE2LjI3NTRINDEuMDg0NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTYxLjU1NjYgMTUuMTc5N0w2My44MTI1IDIxLjE3MzhMNjYuMDYzIDE1LjE3OTdINjcuODE5M1YyM0g2Ni40NjU4VjIwLjQyMTlMNjYuNjAwMSAxNi45NzM2TDY0LjI5MDUgMjNINjMuMzE4NEw2MS4wMTQyIDE2Ljk3OUw2MS4xNDg0IDIwLjQyMTlWMjNINTkuNzk0OVYxNS4xNzk3SDYxLjU1NjZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik03MS44NjM4IDIzLjEwNzRDNzEuMDM2NiAyMy4xMDc0IDcwLjM2NTIgMjIuODQ3OCA2OS44NDk2IDIyLjMyODZDNjkuMzM3NiAyMS44MDU4IDY5LjA4MTUgMjEuMTExMiA2OS4wODE1IDIwLjI0NDZWMjAuMDgzNUM2OS4wODE1IDE5LjUwMzQgNjkuMTkyNSAxOC45ODYgNjkuNDE0NiAxOC41MzEyQzY5LjY0MDEgMTguMDcyOSA2OS45NTUyIDE3LjcxNjYgNzAuMzU5OSAxNy40NjI0QzcwLjc2NDUgMTcuMjA4MiA3MS4yMTU3IDE3LjA4MTEgNzEuNzEzNCAxNy4wODExQzcyLjUwNDcgMTcuMDgxMSA3My4xMTUyIDE3LjMzMzUgNzMuNTQ0OSAxNy44Mzg0QzczLjk3ODIgMTguMzQzMyA3NC4xOTQ4IDE5LjA1NzYgNzQuMTk0OCAxOS45ODE0VjIwLjUwNzhINzAuMzk3NUM3MC40MzY4IDIwLjk4NzYgNzAuNTk2MiAyMS4zNjcyIDcwLjg3NTUgMjEuNjQ2NUM3MS4xNTg0IDIxLjkyNTggNzEuNTEyOSAyMi4wNjU0IDcxLjkzOSAyMi4wNjU0QzcyLjUzNjkgMjIuMDY1NCA3My4wMjM5IDIxLjgyMzcgNzMuMzk5OSAyMS4zNDAzTDc0LjEwMzUgMjIuMDExN0M3My44NzA4IDIyLjM1OSA3My41NTkyIDIyLjYyOTQgNzMuMTY4OSAyMi44MjI4QzcyLjc4MjIgMjMuMDEyNSA3Mi4zNDcyIDIzLjEwNzQgNzEuODYzOCAyMy4xMDc0Wk03MS43MDggMTguMTI4NEM3MS4zNDk5IDE4LjEyODQgNzEuMDU5OSAxOC4yNTM3IDcwLjgzNzkgMTguNTA0NEM3MC42MTk1IDE4Ljc1NSA3MC40Nzk4IDE5LjEwNDIgNzAuNDE4OSAxOS41NTE4SDcyLjkwNThWMTkuNDU1MUM3Mi44NzcxIDE5LjAxODIgNzIuNzYwNyAxOC42ODg4IDcyLjU1NjYgMTguNDY2OEM3Mi4zNTI1IDE4LjI0MTIgNzIuMDY5NyAxOC4xMjg0IDcxLjcwOCAxOC4xMjg0WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNNzguNDcwMiAyMS40MjA5Qzc4LjQ3MDIgMjEuMTg4MiA3OC4zNzM1IDIxLjAxMDkgNzguMTgwMiAyMC44ODkyQzc3Ljk5MDQgMjAuNzY3NCA3Ny42NzM1IDIwLjY2IDc3LjIyOTUgMjAuNTY2OUM3Ni43ODU1IDIwLjQ3MzggNzYuNDE0OSAyMC4zNTU2IDc2LjExNzcgMjAuMjEyNEM3NS40NjYgMTkuODk3MyA3NS4xNDAxIDE5LjQ0MDggNzUuMTQwMSAxOC44NDI4Qzc1LjE0MDEgMTguMzQxNSA3NS4zNTE0IDE3LjkyMjUgNzUuNzczOSAxNy41ODU5Qzc2LjE5NjUgMTcuMjQ5MyA3Ni43MzM2IDE3LjA4MTEgNzcuMzg1MyAxNy4wODExQzc4LjA3OTkgMTcuMDgxMSA3OC42NDAzIDE3LjI1MjkgNzkuMDY2NCAxNy41OTY3Qzc5LjQ5NjEgMTcuOTQwNCA3OS43MTA5IDE4LjM4NjIgNzkuNzEwOSAxOC45MzQxSDc4LjQwNThDNzguNDA1OCAxOC42ODM0IDc4LjMxMjcgMTguNDc1NyA3OC4xMjY1IDE4LjMxMUM3Ny45NDAzIDE4LjE0MjcgNzcuNjkzMiAxOC4wNTg2IDc3LjM4NTMgMTguMDU4NkM3Ny4wOTg4IDE4LjA1ODYgNzYuODY0MyAxOC4xMjQ4IDc2LjY4MTYgMTguMjU3M0M3Ni41MDI2IDE4LjM4OTggNzYuNDEzMSAxOC41NjcxIDc2LjQxMzEgMTguNzg5MUM3Ni40MTMxIDE4Ljk4OTYgNzYuNDk3MiAxOS4xNDUzIDc2LjY2NTUgMTkuMjU2M0M3Ni44MzM4IDE5LjM2NzQgNzcuMTc0IDE5LjQ4MDEgNzcuNjg2IDE5LjU5NDdDNzguMTk4MSAxOS43MDU3IDc4LjU5OTEgMTkuODQgNzguODg5MiAxOS45OTc2Qzc5LjE4MjggMjAuMTUxNSA3OS4zOTk0IDIwLjMzNzcgNzkuNTM5MSAyMC41NTYyQzc5LjY4MjMgMjAuNzc0NiA3OS43NTM5IDIxLjAzOTYgNzkuNzUzOSAyMS4zNTExQzc5Ljc1MzkgMjEuODczOSA3OS41MzczIDIyLjI5ODIgNzkuMTA0IDIyLjYyNEM3OC42NzA3IDIyLjk0NjMgNzguMTAzMiAyMy4xMDc0IDc3LjQwMTQgMjMuMTA3NEM3Ni45MjUxIDIzLjEwNzQgNzYuNTAwOCAyMy4wMjE1IDc2LjEyODQgMjIuODQ5NkM3NS43NTYgMjIuNjc3NyA3NS40NjYgMjIuNDQxNCA3NS4yNTgzIDIyLjE0MDZDNzUuMDUwNiAyMS44Mzk4IDc0Ljk0NjggMjEuNTE1OCA3NC45NDY4IDIxLjE2ODVINzYuMjE0NEM3Ni4yMzIzIDIxLjQ3NjQgNzYuMzQ4NiAyMS43MTQ1IDc2LjU2MzUgMjEuODgyOEM3Ni43NzgzIDIyLjA0NzUgNzcuMDYzIDIyLjEyOTkgNzcuNDE3NSAyMi4xMjk5Qzc3Ljc2MTIgMjIuMTI5OSA3OC4wMjI2IDIyLjA2NTQgNzguMjAxNyAyMS45MzY1Qzc4LjM4MDcgMjEuODA0IDc4LjQ3MDIgMjEuNjMyMiA3OC40NzAyIDIxLjQyMDlaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik04NC4xNTI4IDIxLjQyMDlDODQuMTUyOCAyMS4xODgyIDg0LjA1NjIgMjEuMDEwOSA4My44NjI4IDIwLjg4OTJDODMuNjczIDIwLjc2NzQgODMuMzU2MSAyMC42NiA4Mi45MTIxIDIwLjU2NjlDODIuNDY4MSAyMC40NzM4IDgyLjA5NzUgMjAuMzU1NiA4MS44MDAzIDIwLjIxMjRDODEuMTQ4NiAxOS44OTczIDgwLjgyMjggMTkuNDQwOCA4MC44MjI4IDE4Ljg0MjhDODAuODIyOCAxOC4zNDE1IDgxLjAzNCAxNy45MjI1IDgxLjQ1NjUgMTcuNTg1OUM4MS44NzkxIDE3LjI0OTMgODIuNDE2MiAxNy4wODExIDgzLjA2NzkgMTcuMDgxMUM4My43NjI1IDE3LjA4MTEgODQuMzIyOSAxNy4yNTI5IDg0Ljc0OSAxNy41OTY3Qzg1LjE3ODcgMTcuOTQwNCA4NS4zOTM2IDE4LjM4NjIgODUuMzkzNiAxOC45MzQxSDg0LjA4ODRDODQuMDg4NCAxOC42ODM0IDgzLjk5NTMgMTguNDc1NyA4My44MDkxIDE4LjMxMUM4My42MjI5IDE4LjE0MjcgODMuMzc1OCAxOC4wNTg2IDgzLjA2NzkgMTguMDU4NkM4Mi43ODE0IDE4LjA1ODYgODIuNTQ2OSAxOC4xMjQ4IDgyLjM2NDMgMTguMjU3M0M4Mi4xODUyIDE4LjM4OTggODIuMDk1NyAxOC41NjcxIDgyLjA5NTcgMTguNzg5MUM4Mi4wOTU3IDE4Ljk4OTYgODIuMTc5OSAxOS4xNDUzIDgyLjM0ODEgMTkuMjU2M0M4Mi41MTY0IDE5LjM2NzQgODIuODU2NiAxOS40ODAxIDgzLjM2ODcgMTkuNTk0N0M4My44ODA3IDE5LjcwNTcgODQuMjgxNyAxOS44NCA4NC41NzE4IDE5Ljk5NzZDODQuODY1NCAyMC4xNTE1IDg1LjA4MiAyMC4zMzc3IDg1LjIyMTcgMjAuNTU2MkM4NS4zNjQ5IDIwLjc3NDYgODUuNDM2NSAyMS4wMzk2IDg1LjQzNjUgMjEuMzUxMUM4NS40MzY1IDIxLjg3MzkgODUuMjE5OSAyMi4yOTgyIDg0Ljc4NjYgMjIuNjI0Qzg0LjM1MzQgMjIuOTQ2MyA4My43ODU4IDIzLjEwNzQgODMuMDg0IDIzLjEwNzRDODIuNjA3NyAyMy4xMDc0IDgyLjE4MzQgMjMuMDIxNSA4MS44MTEgMjIuODQ5NkM4MS40Mzg2IDIyLjY3NzcgODEuMTQ4NiAyMi40NDE0IDgwLjk0MDkgMjIuMTQwNkM4MC43MzMyIDIxLjgzOTggODAuNjI5NCAyMS41MTU4IDgwLjYyOTQgMjEuMTY4NUg4MS44OTdDODEuOTE0OSAyMS40NzY0IDgyLjAzMTIgMjEuNzE0NSA4Mi4yNDYxIDIxLjg4MjhDODIuNDYwOSAyMi4wNDc1IDgyLjc0NTYgMjIuMTI5OSA4My4xMDAxIDIyLjEyOTlDODMuNDQzOCAyMi4xMjk5IDgzLjcwNTIgMjIuMDY1NCA4My44ODQzIDIxLjkzNjVDODQuMDYzMyAyMS44MDQgODQuMTUyOCAyMS42MzIyIDg0LjE1MjggMjEuNDIwOVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTkwLjA1MDMgMjNDODkuOTkzIDIyLjg4OSA4OS45NDI5IDIyLjcwODIgODkuODk5OSAyMi40NTc1Qzg5LjQ4NDUgMjIuODkwOCA4OC45NzYxIDIzLjEwNzQgODguMzc0NSAyMy4xMDc0Qzg3Ljc5MDkgMjMuMTA3NCA4Ny4zMTQ2IDIyLjk0MDkgODYuOTQ1OCAyMi42MDc5Qzg2LjU3NyAyMi4yNzQ5IDg2LjM5MjYgMjEuODYzMSA4Ni4zOTI2IDIxLjM3MjZDODYuMzkyNiAyMC43NTMxIDg2LjYyMTcgMjAuMjc4NiA4Ny4wODAxIDE5Ljk0OTJDODcuNTQyIDE5LjYxNjIgODguMjAwOCAxOS40NDk3IDg5LjA1NjYgMTkuNDQ5N0g4OS44NTY5VjE5LjA2ODRDODkuODU2OSAxOC43Njc2IDg5Ljc3MjggMTguNTI3NyA4OS42MDQ1IDE4LjM0ODZDODkuNDM2MiAxOC4xNjYgODkuMTgwMiAxOC4wNzQ3IDg4LjgzNjQgMTguMDc0N0M4OC41MzkyIDE4LjA3NDcgODguMjk1NyAxOC4xNDk5IDg4LjEwNiAxOC4zMDAzQzg3LjkxNjIgMTguNDQ3MSA4Ny44MjEzIDE4LjYzNTEgODcuODIxMyAxOC44NjQzSDg2LjUxNjFDODYuNTE2MSAxOC41NDU2IDg2LjYyMTcgMTguMjQ4NCA4Ni44MzMgMTcuOTcyN0M4Ny4wNDQzIDE3LjY5MzQgODcuMzMwNyAxNy40NzQ5IDg3LjY5MjQgMTcuMzE3NEM4OC4wNTc2IDE3LjE1OTggODguNDY0IDE3LjA4MTEgODguOTExNiAxNy4wODExQzg5LjU5MiAxNy4wODExIDkwLjEzNDQgMTcuMjUyOSA5MC41MzkxIDE3LjU5NjdDOTAuOTQzNyAxNy45MzY4IDkxLjE1MTQgMTguNDE2NyA5MS4xNjIxIDE5LjAzNjFWMjEuNjU3MkM5MS4xNjIxIDIyLjE4IDkxLjIzNTUgMjIuNTk3MiA5MS4zODIzIDIyLjkwODdWMjNIOTAuMDUwM1pNODguNjE2MiAyMi4wNjAxQzg4Ljg3NCAyMi4wNjAxIDg5LjExNTcgMjEuOTk3NCA4OS4zNDEzIDIxLjg3MjFDODkuNTcwNSAyMS43NDY3IDg5Ljc0MjQgMjEuNTc4NSA4OS44NTY5IDIxLjM2NzJWMjAuMjcxNUg4OS4xNTMzQzg4LjY2OTkgMjAuMjcxNSA4OC4zMDY1IDIwLjM1NTYgODguMDYzIDIwLjUyMzlDODcuODE5NSAyMC42OTIyIDg3LjY5NzggMjAuOTMwMyA4Ny42OTc4IDIxLjIzODNDODcuNjk3OCAyMS40ODg5IDg3Ljc4MDEgMjEuNjg5NSA4Ny45NDQ4IDIxLjgzOThDODguMTEzMSAyMS45ODY3IDg4LjMzNjkgMjIuMDYwMSA4OC42MTYyIDIyLjA2MDFaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik05Mi4zMDA4IDIwLjA1MTNDOTIuMzAwOCAxOS4xNDg5IDkyLjUxMiAxOC40MjkyIDkyLjkzNDYgMTcuODkyMUM5My4zNjA3IDE3LjM1MTQgOTMuOTI0NiAxNy4wODExIDk0LjYyNjUgMTcuMDgxMUM5NS4yODg5IDE3LjA4MTEgOTUuODA5OSAxNy4zMTIgOTYuMTg5NSAxNy43NzM5TDk2LjI0ODUgMTcuMTg4NUg5Ny40MjQ4VjIyLjgyMjhDOTcuNDI0OCAyMy41ODU0IDk3LjE4NjcgMjQuMTg3IDk2LjcxMDQgMjQuNjI3NEM5Ni4yMzc4IDI1LjA2NzkgOTUuNTk4NiAyNS4yODgxIDk0Ljc5MyAyNS4yODgxQzk0LjM2NjkgMjUuMjg4MSA5My45NDk3IDI1LjE5ODYgOTMuNTQxNSAyNS4wMTk1QzkzLjEzNjkgMjQuODQ0MSA5Mi44Mjg5IDI0LjYxMzEgOTIuNjE3NyAyNC4zMjY3TDkzLjIzNTQgMjMuNTQyNUM5My42MzY0IDI0LjAxODcgOTQuMTMwNSAyNC4yNTY4IDk0LjcxNzggMjQuMjU2OEM5NS4xNTEgMjQuMjU2OCA5NS40OTMgMjQuMTM4NyA5NS43NDM3IDIzLjkwMjNDOTUuOTk0MyAyMy42Njk2IDk2LjExOTYgMjMuMzI1OCA5Ni4xMTk2IDIyLjg3MTFWMjIuNDc5Qzk1Ljc0MzcgMjIuODk3OSA5NS4yNDI0IDIzLjEwNzQgOTQuNjE1NyAyMy4xMDc0QzkzLjkzNTQgMjMuMTA3NCA5My4zNzg2IDIyLjgzNzEgOTIuOTQ1MyAyMi4yOTY0QzkyLjUxNTYgMjEuNzU1NyA5Mi4zMDA4IDIxLjAwNzMgOTIuMzAwOCAyMC4wNTEzWk05My42MDA2IDIwLjE2NDFDOTMuNjAwNiAyMC43NDc3IDkzLjcxODggMjEuMjA3OCA5My45NTUxIDIxLjU0NDRDOTQuMTk1IDIxLjg3NzQgOTQuNTI2MiAyMi4wNDM5IDk0Ljk0ODcgMjIuMDQzOUM5NS40NzUxIDIyLjA0MzkgOTUuODY1NCAyMS44MTg0IDk2LjExOTYgMjEuMzY3MlYxOC44MTA1Qzk1Ljg3MjYgMTguMzcwMSA5NS40ODU4IDE4LjE0OTkgOTQuOTU5NSAxOC4xNDk5Qzk0LjUyOTggMTguMTQ5OSA5NC4xOTUgMTguMzIgOTMuOTU1MSAxOC42NjAyQzkzLjcxODggMTkuMDAwMyA5My42MDA2IDE5LjUwMTYgOTMuNjAwNiAyMC4xNjQxWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTAxLjMzIDIzLjEwNzRDMTAwLjUwMiAyMy4xMDc0IDk5LjgzMTEgMjIuODQ3OCA5OS4zMTU0IDIyLjMyODZDOTguODAzNCAyMS44MDU4IDk4LjU0NzQgMjEuMTExMiA5OC41NDc0IDIwLjI0NDZWMjAuMDgzNUM5OC41NDc0IDE5LjUwMzQgOTguNjU4NCAxOC45ODYgOTguODgwNCAxOC41MzEyQzk5LjEwNiAxOC4wNzI5IDk5LjQyMTEgMTcuNzE2NiA5OS44MjU3IDE3LjQ2MjRDMTAwLjIzIDE3LjIwODIgMTAwLjY4MSAxNy4wODExIDEwMS4xNzkgMTcuMDgxMUMxMDEuOTcxIDE3LjA4MTEgMTAyLjU4MSAxNy4zMzM1IDEwMy4wMTEgMTcuODM4NEMxMDMuNDQ0IDE4LjM0MzMgMTAzLjY2MSAxOS4wNTc2IDEwMy42NjEgMTkuOTgxNFYyMC41MDc4SDk5Ljg2MzNDOTkuOTAyNyAyMC45ODc2IDEwMC4wNjIgMjEuMzY3MiAxMDAuMzQxIDIxLjY0NjVDMTAwLjYyNCAyMS45MjU4IDEwMC45NzkgMjIuMDY1NCAxMDEuNDA1IDIyLjA2NTRDMTAyLjAwMyAyMi4wNjU0IDEwMi40OSAyMS44MjM3IDEwMi44NjYgMjEuMzQwM0wxMDMuNTY5IDIyLjAxMTdDMTAzLjMzNyAyMi4zNTkgMTAzLjAyNSAyMi42Mjk0IDEwMi42MzUgMjIuODIyOEMxMDIuMjQ4IDIzLjAxMjUgMTAxLjgxMyAyMy4xMDc0IDEwMS4zMyAyMy4xMDc0Wk0xMDEuMTc0IDE4LjEyODRDMTAwLjgxNiAxOC4xMjg0IDEwMC41MjYgMTguMjUzNyAxMDAuMzA0IDE4LjUwNDRDMTAwLjA4NSAxOC43NTUgOTkuOTQ1NiAxOS4xMDQyIDk5Ljg4NDggMTkuNTUxOEgxMDIuMzcyVjE5LjQ1NTFDMTAyLjM0MyAxOS4wMTgyIDEwMi4yMjcgMTguNjg4OCAxMDIuMDIyIDE4LjQ2NjhDMTAxLjgxOCAxOC4yNDEyIDEwMS41MzUgMTguMTI4NCAxMDEuMTc0IDE4LjEyODRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0xMDkuMDUzIDE1Ljc3NTlWMTcuMTg4NUgxMTAuMDc5VjE4LjE1NTNIMTA5LjA1M1YyMS4zOTk0QzEwOS4wNTMgMjEuNjIxNCAxMDkuMDk2IDIxLjc4MjYgMTA5LjE4MiAyMS44ODI4QzEwOS4yNzIgMjEuOTc5NSAxMDkuNDI5IDIyLjAyNzggMTA5LjY1NSAyMi4wMjc4QzEwOS44MDUgMjIuMDI3OCAxMDkuOTU3IDIyLjAwOTkgMTEwLjExMSAyMS45NzQxVjIyLjk4MzlDMTA5LjgxNCAyMy4wNjYyIDEwOS41MjggMjMuMTA3NCAxMDkuMjUyIDIzLjEwNzRDMTA4LjI0OSAyMy4xMDc0IDEwNy43NDggMjIuNTU0MiAxMDcuNzQ4IDIxLjQ0NzhWMTguMTU1M0gxMDYuNzkyVjE3LjE4ODVIMTA3Ljc0OFYxNS43NzU5SDEwOS4wNTNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0xMTMuMTE0IDIxLjEzMDlMMTE0LjI5NSAxNy4xODg1SDExNS42ODdMMTEzLjM3NyAyMy44ODA5QzExMy4wMjIgMjQuODU4NCAxMTIuNDIxIDI1LjM0NzIgMTExLjU3MiAyNS4zNDcyQzExMS4zODIgMjUuMzQ3MiAxMTEuMTczIDI1LjMxNDkgMTEwLjk0NCAyNS4yNTA1VjI0LjI0MDdMMTExLjE5MSAyNC4yNTY4QzExMS41MiAyNC4yNTY4IDExMS43NjcgMjQuMTk2IDExMS45MzIgMjQuMDc0MkMxMTIuMSAyMy45NTYxIDExMi4yMzMgMjMuNzU1NSAxMTIuMzMgMjMuNDcyN0wxMTIuNTE4IDIyLjk3MzFMMTEwLjQ3NyAxNy4xODg1SDExMS44ODRMMTEzLjExNCAyMS4xMzA5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTIxLjUzIDIwLjE1MzNDMTIxLjUzIDIxLjA1MjEgMTIxLjMyNiAyMS43NyAxMjAuOTE4IDIyLjMwNzFDMTIwLjUxIDIyLjg0MDcgMTE5Ljk2MiAyMy4xMDc0IDExOS4yNzQgMjMuMTA3NEMxMTguNjM3IDIzLjEwNzQgMTE4LjEyNyAyMi44OTc5IDExNy43NDQgMjIuNDc5VjI1LjIzNDRIMTE2LjQzOFYxNy4xODg1SDExNy42NDJMMTE3LjY5NSAxNy43NzkzQzExOC4wNzggMTcuMzEzOCAxMTguNTk5IDE3LjA4MTEgMTE5LjI1OCAxNy4wODExQzExOS45NjcgMTcuMDgxMSAxMjAuNTIyIDE3LjM0NiAxMjAuOTIzIDE3Ljg3NkMxMjEuMzI4IDE4LjQwMjMgMTIxLjUzIDE5LjEzNDYgMTIxLjUzIDIwLjA3MjhWMjAuMTUzM1pNMTIwLjIzIDIwLjA0MDVDMTIwLjIzIDE5LjQ2MDQgMTIwLjExNCAxOS4wMDAzIDExOS44ODEgMTguNjYwMkMxMTkuNjUyIDE4LjMyIDExOS4zMjMgMTguMTQ5OSAxMTguODkzIDE4LjE0OTlDMTE4LjM2IDE4LjE0OTkgMTE3Ljk3NiAxOC4zNzAxIDExNy43NDQgMTguODEwNVYyMS4zODg3QzExNy45OCAyMS44Mzk4IDExOC4zNjcgMjIuMDY1NCAxMTguOTA0IDIyLjA2NTRDMTE5LjMxOSAyMi4wNjU0IDExOS42NDMgMjEuODk4OSAxMTkuODc2IDIxLjU2NTlDMTIwLjExMiAyMS4yMjkzIDEyMC4yMyAyMC43MjA5IDEyMC4yMyAyMC4wNDA1WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTI1LjE5OSAyMy4xMDc0QzEyNC4zNzIgMjMuMTA3NCAxMjMuNyAyMi44NDc4IDEyMy4xODUgMjIuMzI4NkMxMjIuNjczIDIxLjgwNTggMTIyLjQxNyAyMS4xMTEyIDEyMi40MTcgMjAuMjQ0NlYyMC4wODM1QzEyMi40MTcgMTkuNTAzNCAxMjIuNTI4IDE4Ljk4NiAxMjIuNzUgMTguNTMxMkMxMjIuOTc1IDE4LjA3MjkgMTIzLjI5IDE3LjcxNjYgMTIzLjY5NSAxNy40NjI0QzEyNC4wOTkgMTcuMjA4MiAxMjQuNTUxIDE3LjA4MTEgMTI1LjA0OCAxNy4wODExQzEyNS44NCAxNy4wODExIDEyNi40NSAxNy4zMzM1IDEyNi44OCAxNy44Mzg0QzEyNy4zMTMgMTguMzQzMyAxMjcuNTMgMTkuMDU3NiAxMjcuNTMgMTkuOTgxNFYyMC41MDc4SDEyMy43MzJDMTIzLjc3MiAyMC45ODc2IDEyMy45MzEgMjEuMzY3MiAxMjQuMjEgMjEuNjQ2NUMxMjQuNDkzIDIxLjkyNTggMTI0Ljg0OCAyMi4wNjU0IDEyNS4yNzQgMjIuMDY1NEMxMjUuODcyIDIyLjA2NTQgMTI2LjM1OSAyMS44MjM3IDEyNi43MzUgMjEuMzQwM0wxMjcuNDM4IDIyLjAxMTdDMTI3LjIwNiAyMi4zNTkgMTI2Ljg5NCAyMi42Mjk0IDEyNi41MDQgMjIuODIyOEMxMjYuMTE3IDIzLjAxMjUgMTI1LjY4MiAyMy4xMDc0IDEyNS4xOTkgMjMuMTA3NFpNMTI1LjA0MyAxOC4xMjg0QzEyNC42ODUgMTguMTI4NCAxMjQuMzk1IDE4LjI1MzcgMTI0LjE3MyAxOC41MDQ0QzEyMy45NTQgMTguNzU1IDEyMy44MTUgMTkuMTA0MiAxMjMuNzU0IDE5LjU1MThIMTI2LjI0MVYxOS40NTUxQzEyNi4yMTIgMTkuMDE4MiAxMjYuMDk2IDE4LjY4ODggMTI1Ljg5MiAxOC40NjY4QzEyNS42ODggMTguMjQxMiAxMjUuNDA1IDE4LjEyODQgMTI1LjA0MyAxOC4xMjg0WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTQ0Ljg4MiAyMC45ODU4QzE0NC44ODIgMjAuNjQyMSAxNDQuNzYxIDIwLjM3NzEgMTQ0LjUxNyAyMC4xOTA5QzE0NC4yNzcgMjAuMDA0NyAxNDMuODQyIDE5LjgxNjcgMTQzLjIxMiAxOS42MjdDMTQyLjU4MiAxOS40MzcyIDE0Mi4wOCAxOS4yMjU5IDE0MS43MDggMTguOTkzMkMxNDAuOTk1IDE4LjU0NTYgMTQwLjYzOSAxNy45NjE5IDE0MC42MzkgMTcuMjQyMkMxNDAuNjM5IDE2LjYxMiAxNDAuODk1IDE2LjA5MjggMTQxLjQwNyAxNS42ODQ2QzE0MS45MjMgMTUuMjc2NCAxNDIuNTkxIDE1LjA3MjMgMTQzLjQxMSAxNS4wNzIzQzE0My45NTUgMTUuMDcyMyAxNDQuNDQgMTUuMTcyNSAxNDQuODY2IDE1LjM3M0MxNDUuMjkyIDE1LjU3MzYgMTQ1LjYyNyAxNS44NiAxNDUuODcxIDE2LjIzMjRDMTQ2LjExNCAxNi42MDEyIDE0Ni4yMzYgMTcuMDExMiAxNDYuMjM2IDE3LjQ2MjRIMTQ0Ljg4MkMxNDQuODgyIDE3LjA1NDIgMTQ0Ljc1MyAxNi43MzU1IDE0NC40OTYgMTYuNTA2M0MxNDQuMjQxIDE2LjI3MzYgMTQzLjg3NiAxNi4xNTcyIDE0My40IDE2LjE1NzJDMTQyLjk1NiAxNi4xNTcyIDE0Mi42MSAxNi4yNTIxIDE0Mi4zNjMgMTYuNDQxOUMxNDIuMTIgMTYuNjMxNyAxNDEuOTk4IDE2Ljg5NjYgMTQxLjk5OCAxNy4yMzY4QzE0MS45OTggMTcuNTIzMyAxNDIuMTMxIDE3Ljc2MzIgMTQyLjM5NiAxNy45NTY1QzE0Mi42NiAxOC4xNDYzIDE0My4wOTcgMTguMzMyNSAxNDMuNzA2IDE4LjUxNTFDMTQ0LjMxNSAxOC42OTQyIDE0NC44MDQgMTguOTAwMSAxNDUuMTcyIDE5LjEzMjhDMTQ1LjU0MSAxOS4zNjIgMTQ1LjgxMiAxOS42MjcgMTQ1Ljk4MyAxOS45Mjc3QzE0Ni4xNTUgMjAuMjI0OSAxNDYuMjQxIDIwLjU3NDEgMTQ2LjI0MSAyMC45NzUxQzE0Ni4yNDEgMjEuNjI2OCAxNDUuOTkxIDIyLjE0NiAxNDUuNDg5IDIyLjUzMjdDMTQ0Ljk5MiAyMi45MTU5IDE0NC4zMTUgMjMuMTA3NCAxNDMuNDU5IDIzLjEwNzRDMTQyLjg5MyAyMy4xMDc0IDE0Mi4zNzIgMjMuMDAzNiAxNDEuODk2IDIyLjc5NTlDMTQxLjQyMyAyMi41ODQ2IDE0MS4wNTUgMjIuMjk0NiAxNDAuNzkgMjEuOTI1OEMxNDAuNTI4IDIxLjU1NyAxNDAuMzk3IDIxLjEyNzMgMTQwLjM5NyAyMC42MzY3SDE0MS43NTZDMTQxLjc1NiAyMS4wODA3IDE0MS45MDMgMjEuNDI0NSAxNDIuMTk3IDIxLjY2OEMxNDIuNDkgMjEuOTExNSAxNDIuOTExIDIyLjAzMzIgMTQzLjQ1OSAyMi4wMzMyQzE0My45MzIgMjIuMDMzMiAxNDQuMjg2IDIxLjkzODMgMTQ0LjUyMiAyMS43NDg1QzE0NC43NjIgMjEuNTU1MiAxNDQuODgyIDIxLjMwMDkgMTQ0Ljg4MiAyMC45ODU4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTQ4Ljk0MyAxNS43NzU5VjE3LjE4ODVIMTQ5Ljk2OVYxOC4xNTUzSDE0OC45NDNWMjEuMzk5NEMxNDguOTQzIDIxLjYyMTQgMTQ4Ljk4NiAyMS43ODI2IDE0OS4wNzIgMjEuODgyOEMxNDkuMTYxIDIxLjk3OTUgMTQ5LjMxOSAyMi4wMjc4IDE0OS41NDQgMjIuMDI3OEMxNDkuNjk1IDIyLjAyNzggMTQ5Ljg0NyAyMi4wMDk5IDE1MC4wMDEgMjEuOTc0MVYyMi45ODM5QzE0OS43MDQgMjMuMDY2MiAxNDkuNDE3IDIzLjEwNzQgMTQ5LjE0MiAyMy4xMDc0QzE0OC4xMzkgMjMuMTA3NCAxNDcuNjM4IDIyLjU1NDIgMTQ3LjYzOCAyMS40NDc4VjE4LjE1NTNIMTQ2LjY4MlYxNy4xODg1SDE0Ny42MzhWMTUuNzc1OUgxNDguOTQzWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTU0LjQ0MyAyM0MxNTQuMzg2IDIyLjg4OSAxNTQuMzM1IDIyLjcwODIgMTU0LjI5MiAyMi40NTc1QzE1My44NzcgMjIuODkwOCAxNTMuMzY5IDIzLjEwNzQgMTUyLjc2NyAyMy4xMDc0QzE1Mi4xODMgMjMuMTA3NCAxNTEuNzA3IDIyLjk0MDkgMTUxLjMzOCAyMi42MDc5QzE1MC45NyAyMi4yNzQ5IDE1MC43ODUgMjEuODYzMSAxNTAuNzg1IDIxLjM3MjZDMTUwLjc4NSAyMC43NTMxIDE1MS4wMTQgMjAuMjc4NiAxNTEuNDczIDE5Ljk0OTJDMTUxLjkzNSAxOS42MTYyIDE1Mi41OTMgMTkuNDQ5NyAxNTMuNDQ5IDE5LjQ0OTdIMTU0LjI1VjE5LjA2ODRDMTU0LjI1IDE4Ljc2NzYgMTU0LjE2NSAxOC41Mjc3IDE1My45OTcgMTguMzQ4NkMxNTMuODI5IDE4LjE2NiAxNTMuNTczIDE4LjA3NDcgMTUzLjIyOSAxOC4wNzQ3QzE1Mi45MzIgMTguMDc0NyAxNTIuNjg4IDE4LjE0OTkgMTUyLjQ5OSAxOC4zMDAzQzE1Mi4zMDkgMTguNDQ3MSAxNTIuMjE0IDE4LjYzNTEgMTUyLjIxNCAxOC44NjQzSDE1MC45MDlDMTUwLjkwOSAxOC41NDU2IDE1MS4wMTQgMTguMjQ4NCAxNTEuMjI2IDE3Ljk3MjdDMTUxLjQzNyAxNy42OTM0IDE1MS43MjMgMTcuNDc0OSAxNTIuMDg1IDE3LjMxNzRDMTUyLjQ1IDE3LjE1OTggMTUyLjg1NyAxNy4wODExIDE1My4zMDQgMTcuMDgxMUMxNTMuOTg1IDE3LjA4MTEgMTU0LjUyNyAxNy4yNTI5IDE1NC45MzIgMTcuNTk2N0MxNTUuMzM2IDE3LjkzNjggMTU1LjU0NCAxOC40MTY3IDE1NS41NTUgMTkuMDM2MVYyMS42NTcyQzE1NS41NTUgMjIuMTggMTU1LjYyOCAyMi41OTcyIDE1NS43NzUgMjIuOTA4N1YyM0gxNTQuNDQzWk0xNTMuMDA5IDIyLjA2MDFDMTUzLjI2NyAyMi4wNjAxIDE1My41MDggMjEuOTk3NCAxNTMuNzM0IDIxLjg3MjFDMTUzLjk2MyAyMS43NDY3IDE1NC4xMzUgMjEuNTc4NSAxNTQuMjUgMjEuMzY3MlYyMC4yNzE1SDE1My41NDZDMTUzLjA2MiAyMC4yNzE1IDE1Mi42OTkgMjAuMzU1NiAxNTIuNDU2IDIwLjUyMzlDMTUyLjIxMiAyMC42OTIyIDE1Mi4wOSAyMC45MzAzIDE1Mi4wOSAyMS4yMzgzQzE1Mi4wOSAyMS40ODg5IDE1Mi4xNzMgMjEuNjg5NSAxNTIuMzM3IDIxLjgzOThDMTUyLjUwNiAyMS45ODY3IDE1Mi43MjkgMjIuMDYwMSAxNTMuMDA5IDIyLjA2MDFaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0xNTguNTU3IDE1Ljc3NTlWMTcuMTg4NUgxNTkuNTgzVjE4LjE1NTNIMTU4LjU1N1YyMS4zOTk0QzE1OC41NTcgMjEuNjIxNCAxNTguNiAyMS43ODI2IDE1OC42ODYgMjEuODgyOEMxNTguNzc2IDIxLjk3OTUgMTU4LjkzMyAyMi4wMjc4IDE1OS4xNTkgMjIuMDI3OEMxNTkuMzA5IDIyLjAyNzggMTU5LjQ2MSAyMi4wMDk5IDE1OS42MTUgMjEuOTc0MVYyMi45ODM5QzE1OS4zMTggMjMuMDY2MiAxNTkuMDMyIDIzLjEwNzQgMTU4Ljc1NiAyMy4xMDc0QzE1Ny43NTMgMjMuMTA3NCAxNTcuMjUyIDIyLjU1NDIgMTU3LjI1MiAyMS40NDc4VjE4LjE1NTNIMTU2LjI5NlYxNy4xODg1SDE1Ny4yNTJWMTUuNzc1OUgxNTguNTU3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTY0LjEwNSAyMi40MzA3QzE2My43MjIgMjIuODgxOCAxNjMuMTc4IDIzLjEwNzQgMTYyLjQ3MyAyMy4xMDc0QzE2MS44NDIgMjMuMTA3NCAxNjEuMzY0IDIyLjkyMyAxNjEuMDM5IDIyLjU1NDJDMTYwLjcxNiAyMi4xODU0IDE2MC41NTUgMjEuNjUxOSAxNjAuNTU1IDIwLjk1MzZWMTcuMTg4NUgxNjEuODZWMjAuOTM3NUMxNjEuODYgMjEuNjc1MSAxNjIuMTY3IDIyLjA0MzkgMTYyLjc3OSAyMi4wNDM5QzE2My40MTMgMjIuMDQzOSAxNjMuODQgMjEuODE2NiAxNjQuMDYyIDIxLjM2MThWMTcuMTg4NUgxNjUuMzY4VjIzSDE2NC4xMzhMMTY0LjEwNSAyMi40MzA3WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTY5Ljk1NSAyMS40MjA5QzE2OS45NTUgMjEuMTg4MiAxNjkuODU4IDIxLjAxMDkgMTY5LjY2NSAyMC44ODkyQzE2OS40NzUgMjAuNzY3NCAxNjkuMTU4IDIwLjY2IDE2OC43MTQgMjAuNTY2OUMxNjguMjcgMjAuNDczOCAxNjcuODk5IDIwLjM1NTYgMTY3LjYwMiAyMC4yMTI0QzE2Ni45NSAxOS44OTczIDE2Ni42MjUgMTkuNDQwOCAxNjYuNjI1IDE4Ljg0MjhDMTY2LjYyNSAxOC4zNDE1IDE2Ni44MzYgMTcuOTIyNSAxNjcuMjU4IDE3LjU4NTlDMTY3LjY4MSAxNy4yNDkzIDE2OC4yMTggMTcuMDgxMSAxNjguODcgMTcuMDgxMUMxNjkuNTY0IDE3LjA4MTEgMTcwLjEyNSAxNy4yNTI5IDE3MC41NTEgMTcuNTk2N0MxNzAuOTggMTcuOTQwNCAxNzEuMTk1IDE4LjM4NjIgMTcxLjE5NSAxOC45MzQxSDE2OS44OUMxNjkuODkgMTguNjgzNCAxNjkuNzk3IDE4LjQ3NTcgMTY5LjYxMSAxOC4zMTFDMTY5LjQyNSAxOC4xNDI3IDE2OS4xNzggMTguMDU4NiAxNjguODcgMTguMDU4NkMxNjguNTgzIDE4LjA1ODYgMTY4LjM0OSAxOC4xMjQ4IDE2OC4xNjYgMTguMjU3M0MxNjcuOTg3IDE4LjM4OTggMTY3Ljg5NyAxOC41NjcxIDE2Ny44OTcgMTguNzg5MUMxNjcuODk3IDE4Ljk4OTYgMTY3Ljk4MiAxOS4xNDUzIDE2OC4xNSAxOS4yNTYzQzE2OC4zMTggMTkuMzY3NCAxNjguNjU4IDE5LjQ4MDEgMTY5LjE3IDE5LjU5NDdDMTY5LjY4MiAxOS43MDU3IDE3MC4wODMgMTkuODQgMTcwLjM3NCAxOS45OTc2QzE3MC42NjcgMjAuMTUxNSAxNzAuODg0IDIwLjMzNzcgMTcxLjAyMyAyMC41NTYyQzE3MS4xNjcgMjAuNzc0NiAxNzEuMjM4IDIxLjAzOTYgMTcxLjIzOCAyMS4zNTExQzE3MS4yMzggMjEuODczOSAxNzEuMDIyIDIyLjI5ODIgMTcwLjU4OCAyMi42MjRDMTcwLjE1NSAyMi45NDYzIDE2OS41ODggMjMuMTA3NCAxNjguODg2IDIzLjEwNzRDMTY4LjQxIDIzLjEwNzQgMTY3Ljk4NSAyMy4wMjE1IDE2Ny42MTMgMjIuODQ5NkMxNjcuMjQgMjIuNjc3NyAxNjYuOTUgMjIuNDQxNCAxNjYuNzQzIDIyLjE0MDZDMTY2LjUzNSAyMS44Mzk4IDE2Ni40MzEgMjEuNTE1OCAxNjYuNDMxIDIxLjE2ODVIMTY3LjY5OUMxNjcuNzE3IDIxLjQ3NjQgMTY3LjgzMyAyMS43MTQ1IDE2OC4wNDggMjEuODgyOEMxNjguMjYzIDIyLjA0NzUgMTY4LjU0NyAyMi4xMjk5IDE2OC45MDIgMjIuMTI5OUMxNjkuMjQ2IDIyLjEyOTkgMTY5LjUwNyAyMi4wNjU0IDE2OS42ODYgMjEuOTM2NUMxNjkuODY1IDIxLjgwNCAxNjkuOTU1IDIxLjYzMjIgMTY5Ljk1NSAyMS40MjA5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTIuNzczNCA2My40NzlDMTIuNzczNCA2My4zMDcxIDEyLjgyMzYgNjMuMTYzOSAxMi45MjM4IDYzLjA0OTNDMTMuMDI3NyA2Mi45MzQ3IDEzLjE4MTYgNjIuODc3NCAxMy4zODU3IDYyLjg3NzRDMTMuNTg5OCA2Mi44Nzc0IDEzLjc0MzggNjIuOTM0NyAxMy44NDc3IDYzLjA0OTNDMTMuOTU1MSA2My4xNjM5IDE0LjAwODggNjMuMzA3MSAxNC4wMDg4IDYzLjQ3OUMxNC4wMDg4IDYzLjY0MzcgMTMuOTU1MSA2My43ODE2IDEzLjg0NzcgNjMuODkyNkMxMy43NDM4IDY0LjAwMzYgMTMuNTg5OCA2NC4wNTkxIDEzLjM4NTcgNjQuMDU5MUMxMy4xODE2IDY0LjA1OTEgMTMuMDI3NyA2NC4wMDM2IDEyLjkyMzggNjMuODkyNkMxMi44MjM2IDYzLjc4MTYgMTIuNzczNCA2My42NDM3IDEyLjc3MzQgNjMuNDc5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTUuNjczOCA2My40NzlDMTUuNjczOCA2My4zMDcxIDE1LjcyNCA2My4xNjM5IDE1LjgyNDIgNjMuMDQ5M0MxNS45MjgxIDYyLjkzNDcgMTYuMDgyIDYyLjg3NzQgMTYuMjg2MSA2Mi44Nzc0QzE2LjQ5MDIgNjIuODc3NCAxNi42NDQyIDYyLjkzNDcgMTYuNzQ4IDYzLjA0OTNDMTYuODU1NSA2My4xNjM5IDE2LjkwOTIgNjMuMzA3MSAxNi45MDkyIDYzLjQ3OUMxNi45MDkyIDYzLjY0MzcgMTYuODU1NSA2My43ODE2IDE2Ljc0OCA2My44OTI2QzE2LjY0NDIgNjQuMDAzNiAxNi40OTAyIDY0LjA1OTEgMTYuMjg2MSA2NC4wNTkxQzE2LjA4MiA2NC4wNTkxIDE1LjkyODEgNjQuMDAzNiAxNS44MjQyIDYzLjg5MjZDMTUuNzI0IDYzLjc4MTYgMTUuNjczOCA2My42NDM3IDE1LjY3MzggNjMuNDc5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMjAuODgzOCA2My4yOTY0QzIxLjIzODMgNjMuMjk2NCAyMS41NDggNjMuMTg5IDIxLjgxMyA2Mi45NzQxQzIyLjA3OCA2Mi43NTkzIDIyLjIyNDggNjIuNDkwNyAyMi4yNTM0IDYyLjE2ODVIMjMuMTkzNEMyMy4xNzU1IDYyLjUwMTUgMjMuMDYwOSA2Mi44MTg0IDIyLjg0OTYgNjMuMTE5MUMyMi42MzgzIDYzLjQxOTkgMjIuMzU1NSA2My42NTk4IDIyLjAwMSA2My44Mzg5QzIxLjY1MDEgNjQuMDE3OSAyMS4yNzc3IDY0LjEwNzQgMjAuODgzOCA2NC4xMDc0QzIwLjA5MjQgNjQuMTA3NCAxOS40NjIyIDYzLjg0NDIgMTguOTkzMiA2My4zMTc5QzE4LjUyNzcgNjIuNzg3OSAxOC4yOTQ5IDYyLjA2NDYgMTguMjk0OSA2MS4xNDc5VjYwLjk4MTRDMTguMjk0OSA2MC40MTU3IDE4LjM5ODggNTkuOTEyNiAxOC42MDY0IDU5LjQ3MjJDMTguODE0MSA1OS4wMzE3IDE5LjExMTMgNTguNjg5OCAxOS40OTggNTguNDQ2M0MxOS44ODgzIDU4LjIwMjggMjAuMzQ4NSA1OC4wODExIDIwLjg3ODQgNTguMDgxMUMyMS41MzAxIDU4LjA4MTEgMjIuMDcwOCA1OC4yNzYyIDIyLjUwMDUgNTguNjY2NUMyMi45MzM4IDU5LjA1NjggMjMuMTY0NyA1OS41NjM1IDIzLjE5MzQgNjAuMTg2NUgyMi4yNTM0QzIyLjIyNDggNTkuODEwNSAyMi4wODE1IDU5LjUwMjYgMjEuODIzNyA1OS4yNjI3QzIxLjU2OTUgNTkuMDE5MiAyMS4yNTQ0IDU4Ljg5NzUgMjAuODc4NCA1OC44OTc1QzIwLjM3MzUgNTguODk3NSAxOS45ODE0IDU5LjA4MDEgMTkuNzAyMSA1OS40NDUzQzE5LjQyNjQgNTkuODA3IDE5LjI4ODYgNjAuMzMxNSAxOS4yODg2IDYxLjAxOVY2MS4yMDdDMTkuMjg4NiA2MS44NzY2IDE5LjQyNjQgNjIuMzkyMyAxOS43MDIxIDYyLjc1MzlDMTkuOTc3OSA2My4xMTU2IDIwLjM3MTcgNjMuMjk2NCAyMC44ODM4IDYzLjI5NjRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0yNC4wNDc0IDYxLjA0MDVDMjQuMDQ3NCA2MC40NzEyIDI0LjE1ODQgNTkuOTU5MSAyNC4zODA0IDU5LjUwNDRDMjQuNjA2IDU5LjA0OTYgMjQuOTE3NSA1OC42OTg3IDI1LjMxNDkgNTguNDUxN0MyNS43MTYgNTguMjA0NiAyNi4xNzI1IDU4LjA4MTEgMjYuNjg0NiA1OC4wODExQzI3LjQ3NTkgNTguMDgxMSAyOC4xMTUxIDU4LjM1NSAyOC42MDIxIDU4LjkwMjhDMjkuMDkyNiA1OS40NTA3IDI5LjMzNzkgNjAuMTc5NCAyOS4zMzc5IDYxLjA4ODlWNjEuMTU4N0MyOS4zMzc5IDYxLjcyNDQgMjkuMjI4NyA2Mi4yMzI5IDI5LjAxMDMgNjIuNjg0MUMyOC43OTU0IDYzLjEzMTcgMjguNDg1NyA2My40ODA4IDI4LjA4MTEgNjMuNzMxNEMyNy42OCA2My45ODIxIDI3LjIxODEgNjQuMTA3NCAyNi42OTUzIDY0LjEwNzRDMjUuOTA3NiA2NC4xMDc0IDI1LjI2ODQgNjMuODMzNSAyNC43Nzc4IDYzLjI4NTZDMjQuMjkwOSA2Mi43Mzc4IDI0LjA0NzQgNjIuMDEyNyAyNC4wNDc0IDYxLjExMDRWNjEuMDQwNVpNMjUuMDQ2NCA2MS4xNTg3QzI1LjA0NjQgNjEuODAzMiAyNS4xOTUgNjIuMzIwNiAyNS40OTIyIDYyLjcxMDlDMjUuNzkzIDYzLjEwMTIgMjYuMTk0IDYzLjI5NjQgMjYuNjk1MyA2My4yOTY0QzI3LjIwMDIgNjMuMjk2NCAyNy42MDEyIDYzLjA5OTQgMjcuODk4NCA2Mi43MDU2QzI4LjE5NTYgNjIuMzA4MSAyOC4zNDQyIDYxLjc1MzEgMjguMzQ0MiA2MS4wNDA1QzI4LjM0NDIgNjAuNDAzMiAyOC4xOTIxIDU5Ljg4NzUgMjcuODg3NyA1OS40OTM3QzI3LjU4NjkgNTkuMDk2MiAyNy4xODU5IDU4Ljg5NzUgMjYuNjg0NiA1OC44OTc1QzI2LjE5NCA1OC44OTc1IDI1Ljc5ODMgNTkuMDkyNiAyNS40OTc2IDU5LjQ4MjlDMjUuMTk2OCA1OS44NzMyIDI1LjA0NjQgNjAuNDMxOCAyNS4wNDY0IDYxLjE1ODdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0zNC42NzY4IDYxLjM3MzVIMzUuNzYxN1Y2Mi4xODQ2SDM0LjY3NjhWNjRIMzMuNjc3N1Y2Mi4xODQ2SDMwLjExNjdWNjEuNTk5MUwzMy42MTg3IDU2LjE3OTdIMzQuNjc2OFY2MS4zNzM1Wk0zMS4yNDQ2IDYxLjM3MzVIMzMuNjc3N1Y1Ny41Mzg2TDMzLjU1OTYgNTcuNzUzNEwzMS4yNDQ2IDYxLjM3MzVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0zNy43ODY2IDU4LjE4ODVWNjQuNjcxNEMzNy43ODY2IDY1Ljc4ODYgMzcuMjc5OSA2Ni4zNDcyIDM2LjI2NjYgNjYuMzQ3MkMzNi4wNDgyIDY2LjM0NzIgMzUuODQ1OSA2Ni4zMTQ5IDM1LjY1OTcgNjYuMjUwNVY2NS40NTU2QzM1Ljc3NDMgNjUuNDg0MiAzNS45MjQ2IDY1LjQ5ODUgMzYuMTEwOCA2NS40OTg1QzM2LjMzMjggNjUuNDk4NSAzNi41MDExIDY1LjQzNzcgMzYuNjE1NyA2NS4zMTU5QzM2LjczMzkgNjUuMTk3OCAzNi43OTMgNjQuOTkwMSAzNi43OTMgNjQuNjkyOVY1OC4xODg1SDM3Ljc4NjZaTTM2LjY5MDkgNTYuNjQ3QzM2LjY5MDkgNTYuNDg5NCAzNi43MzkzIDU2LjM1NTEgMzYuODM1OSA1Ni4yNDQxQzM2LjkzNjIgNTYuMTI5NiAzNy4wODEyIDU2LjA3MjMgMzcuMjcxIDU2LjA3MjNDMzcuNDY0NCA1Ni4wNzIzIDM3LjYxMTIgNTYuMTI3OCAzNy43MTE0IDU2LjIzODhDMzcuODExNyA1Ni4zNDk4IDM3Ljg2MTggNTYuNDg1OCAzNy44NjE4IDU2LjY0N0MzNy44NjE4IDU2LjgwODEgMzcuODExNyA1Ni45NDI0IDM3LjcxMTQgNTcuMDQ5OEMzNy42MTEyIDU3LjE1NzIgMzcuNDY0NCA1Ny4yMTA5IDM3LjI3MSA1Ny4yMTA5QzM3LjA3NzYgNTcuMjEwOSAzNi45MzI2IDU3LjE1NzIgMzYuODM1OSA1Ny4wNDk4QzM2LjczOTMgNTYuOTQyNCAzNi42OTA5IDU2LjgwODEgMzYuNjkwOSA1Ni42NDdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik00Mi45ODA1IDYzLjQyNTNDNDIuNTkzOCA2My44OCA0Mi4wMjYyIDY0LjEwNzQgNDEuMjc3OCA2NC4xMDc0QzQwLjY1ODQgNjQuMTA3NCA0MC4xODU3IDYzLjkyODQgMzkuODU5OSA2My41NzAzQzM5LjUzNzYgNjMuMjA4NyAzOS4zNzQ3IDYyLjY3NTEgMzkuMzcxMSA2MS45Njk3VjU4LjE4ODVINDAuMzY0N1Y2MS45NDI5QzQwLjM2NDcgNjIuODIzNyA0MC43MjI4IDYzLjI2NDIgNDEuNDM5IDYzLjI2NDJDNDIuMTk4MSA2My4yNjQyIDQyLjcwMyA2Mi45ODEzIDQyLjk1MzYgNjIuNDE1NVY1OC4xODg1SDQzLjk0NzNWNjRINDMuMDAyTDQyLjk4MDUgNjMuNDI1M1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTY1LjMxMSA1Ny4wMjgzSDYyLjc5NzRWNjRINjEuNzcxNVY1Ny4wMjgzSDU5LjI2MzJWNTYuMTc5N0g2NS4zMTFWNTcuMDI4M1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTcxLjE0OTQgNjIuNjMwNEw3Mi4yNjY2IDU4LjE4ODVINzMuMjYwM0w3MS41Njg0IDY0SDcwLjc2MjdMNjkuMzUwMSA1OS41OTU3TDY3Ljk3NTEgNjRINjcuMTY5NEw2NS40ODI5IDU4LjE4ODVINjYuNDcxMkw2Ny42MTUyIDYyLjUzOTFMNjguOTY4OCA1OC4xODg1SDY5Ljc2OUw3MS4xNDk0IDYyLjYzMDRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik03NC4wMTIyIDYxLjA0MDVDNzQuMDEyMiA2MC40NzEyIDc0LjEyMzIgNTkuOTU5MSA3NC4zNDUyIDU5LjUwNDRDNzQuNTcwOCA1OS4wNDk2IDc0Ljg4MjMgNTguNjk4NyA3NS4yNzk4IDU4LjQ1MTdDNzUuNjgwOCA1OC4yMDQ2IDc2LjEzNzQgNTguMDgxMSA3Ni42NDk0IDU4LjA4MTFDNzcuNDQwOCA1OC4wODExIDc4LjA3OTkgNTguMzU1IDc4LjU2NjkgNTguOTAyOEM3OS4wNTc1IDU5LjQ1MDcgNzkuMzAyNyA2MC4xNzk0IDc5LjMwMjcgNjEuMDg4OVY2MS4xNTg3Qzc5LjMwMjcgNjEuNzI0NCA3OS4xOTM1IDYyLjIzMjkgNzguOTc1MSA2Mi42ODQxQzc4Ljc2MDMgNjMuMTMxNyA3OC40NTA1IDYzLjQ4MDggNzguMDQ1OSA2My43MzE0Qzc3LjY0NDkgNjMuOTgyMSA3Ny4xODI5IDY0LjEwNzQgNzYuNjYwMiA2NC4xMDc0Qzc1Ljg3MjQgNjQuMTA3NCA3NS4yMzMyIDYzLjgzMzUgNzQuNzQyNyA2My4yODU2Qzc0LjI1NTcgNjIuNzM3OCA3NC4wMTIyIDYyLjAxMjcgNzQuMDEyMiA2MS4xMTA0VjYxLjA0MDVaTTc1LjAxMTIgNjEuMTU4N0M3NS4wMTEyIDYxLjgwMzIgNzUuMTU5OCA2Mi4zMjA2IDc1LjQ1NyA2Mi43MTA5Qzc1Ljc1NzggNjMuMTAxMiA3Ni4xNTg5IDYzLjI5NjQgNzYuNjYwMiA2My4yOTY0Qzc3LjE2NSA2My4yOTY0IDc3LjU2NjEgNjMuMDk5NCA3Ny44NjMzIDYyLjcwNTZDNzguMTYwNSA2Mi4zMDgxIDc4LjMwOTEgNjEuNzUzMSA3OC4zMDkxIDYxLjA0MDVDNzguMzA5MSA2MC40MDMyIDc4LjE1NjkgNTkuODg3NSA3Ny44NTI1IDU5LjQ5MzdDNzcuNTUxOCA1OS4wOTYyIDc3LjE1MDcgNTguODk3NSA3Ni42NDk0IDU4Ljg5NzVDNzYuMTU4OSA1OC44OTc1IDc1Ljc2MzIgNTkuMDkyNiA3NS40NjI0IDU5LjQ4MjlDNzUuMTYxNiA1OS44NzMyIDc1LjAxMTIgNjAuNDMxOCA3NS4wMTEyIDYxLjE1ODdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik04Mi42MTY3IDYxLjA4MzVINzkuOTk1NlY2MC4yNzI1SDgyLjYxNjdWNjEuMDgzNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTg4LjczNDQgNjIuNjMwNEw4OS44NTE2IDU4LjE4ODVIOTAuODQ1Mkw4OS4xNTMzIDY0SDg4LjM0NzdMODYuOTM1MSA1OS41OTU3TDg1LjU2MDEgNjRIODQuNzU0NEw4My4wNjc5IDU4LjE4ODVIODQuMDU2Mkw4NS4yMDAyIDYyLjUzOTFMODYuNTUzNyA1OC4xODg1SDg3LjM1NEw4OC43MzQ0IDYyLjYzMDRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik05NS40NDgyIDY0Qzk1LjM5MSA2My44ODU0IDk1LjM0NDQgNjMuNjgxMyA5NS4zMDg2IDYzLjM4NzdDOTQuODQ2NyA2My44Njc1IDk0LjI5NTIgNjQuMTA3NCA5My42NTQzIDY0LjEwNzRDOTMuMDgxNCA2NC4xMDc0IDkyLjYxMDUgNjMuOTQ2MyA5Mi4yNDE3IDYzLjYyNEM5MS44NzY1IDYzLjI5ODIgOTEuNjkzOCA2Mi44ODY0IDkxLjY5MzggNjIuMzg4N0M5MS42OTM4IDYxLjc4MzUgOTEuOTIzIDYxLjMxNDUgOTIuMzgxMyA2MC45ODE0QzkyLjg0MzMgNjAuNjQ0OSA5My40OTE0IDYwLjQ3NjYgOTQuMzI1NyA2MC40NzY2SDk1LjI5MjVWNjAuMDJDOTUuMjkyNSA1OS42NzI3IDk1LjE4ODYgNTkuMzk3IDk0Ljk4MSA1OS4xOTI5Qzk0Ljc3MzMgNTguOTg1MiA5NC40NjcxIDU4Ljg4MTMgOTQuMDYyNSA1OC44ODEzQzkzLjcwOCA1OC44ODEzIDkzLjQxMDggNTguOTcwOSA5My4xNzA5IDU5LjE0OTlDOTIuOTMxIDU5LjMyODkgOTIuODExIDU5LjU0NTYgOTIuODExIDU5Ljc5OThIOTEuODEyQzkxLjgxMiA1OS41MDk4IDkxLjkxNDEgNTkuMjMwNSA5Mi4xMTgyIDU4Ljk2MTlDOTIuMzI1OCA1OC42ODk4IDkyLjYwNTEgNTguNDc0OSA5Mi45NTYxIDU4LjMxNzRDOTMuMzEwNSA1OC4xNTk4IDkzLjY5OTEgNTguMDgxMSA5NC4xMjE2IDU4LjA4MTFDOTQuNzkxMiA1OC4wODExIDk1LjMxNTggNTguMjQ5MyA5NS42OTUzIDU4LjU4NTlDOTYuMDc0OSA1OC45MTg5IDk2LjI3MTggNTkuMzc5MSA5Ni4yODYxIDU5Ljk2NjNWNjIuNjQxMUM5Ni4yODYxIDYzLjE3NDYgOTYuMzU0MiA2My41OTkgOTYuNDkwMiA2My45MTQxVjY0SDk1LjQ0ODJaTTkzLjc5OTMgNjMuMjQyN0M5NC4xMTA4IDYzLjI0MjcgOTQuNDA2MiA2My4xNjIxIDk0LjY4NTUgNjMuMDAxQzk0Ljk2NDggNjIuODM5OCA5NS4xNjcyIDYyLjYzMDQgOTUuMjkyNSA2Mi4zNzI2VjYxLjE4MDJIOTQuNTEzN0M5My4yOTYyIDYxLjE4MDIgOTIuNjg3NSA2MS41MzY1IDkyLjY4NzUgNjIuMjQ5QzkyLjY4NzUgNjIuNTYwNSA5Mi43OTEzIDYyLjgwNCA5Mi45OTkgNjIuOTc5NUM5My4yMDY3IDYzLjE1NDkgOTMuNDczNSA2My4yNDI3IDkzLjc5OTMgNjMuMjQyN1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTk5LjY1OTIgNjIuNTQ0NEwxMDEuMDEzIDU4LjE4ODVIMTAyLjA3Nkw5OS43Mzk3IDY0Ljg5N0M5OS4zNzgxIDY1Ljg2MzggOTguODAzNCA2Ni4zNDcyIDk4LjAxNTYgNjYuMzQ3Mkw5Ny44Mjc2IDY2LjMzMTFMOTcuNDU3IDY2LjI2MTJWNjUuNDU1Nkw5Ny43MjU2IDY1LjQ3NzFDOTguMDYyMiA2NS40NzcxIDk4LjMyMzYgNjUuNDA5IDk4LjUwOTggNjUuMjcyOUM5OC42OTk1IDY1LjEzNjkgOTguODU1MyA2NC44ODggOTguOTc3MSA2NC41MjY0TDk5LjE5NzMgNjMuOTM1NUw5Ny4xMjQgNTguMTg4NUg5OC4yMDlMOTkuNjU5MiA2Mi41NDQ0WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNNjUuOTIzMyAxMDAuMzQyQzY1LjkyMzMgMTAxLjEwOSA2NS43OTQ0IDEwMS43NzggNjUuNTM2NiAxMDIuMzUxQzY1LjI3ODggMTAyLjkyIDY0LjkxMzYgMTAzLjM1NSA2NC40NDA5IDEwMy42NTZDNjMuOTY4MyAxMDMuOTU3IDYzLjQxNjggMTA0LjEwNyA2Mi43ODY2IDEwNC4xMDdDNjIuMTcwNyAxMDQuMTA3IDYxLjYyNDcgMTAzLjk1NyA2MS4xNDg0IDEwMy42NTZDNjAuNjcyMiAxMDMuMzUyIDYwLjMwMTYgMTAyLjkyIDYwLjAzNjYgMTAyLjM2MkM1OS43NzUyIDEwMS44IDU5LjY0MSAxMDEuMTUgNTkuNjMzOCAxMDAuNDEyVjk5Ljg0ODFDNTkuNjMzOCA5OS4wOTYyIDU5Ljc2NDUgOTguNDMyIDYwLjAyNTkgOTcuODU1NUM2MC4yODczIDk3LjI3OSA2MC42NTYxIDk2LjgzODUgNjEuMTMyMyA5Ni41MzQyQzYxLjYxMjEgOTYuMjI2MiA2Mi4xNiA5Ni4wNzIzIDYyLjc3NTkgOTYuMDcyM0M2My40MDI1IDk2LjA3MjMgNjMuOTUzOSA5Ni4yMjQ0IDY0LjQzMDIgOTYuNTI4OEM2NC45MSA5Ni44Mjk2IDY1LjI3ODggOTcuMjY4MiA2NS41MzY2IDk3Ljg0NDdDNjUuNzk0NCA5OC40MTc2IDY1LjkyMzMgOTkuMDg1NCA2NS45MjMzIDk5Ljg0ODFWMTAwLjM0MlpNNjQuODk3NSA5OS44Mzc0QzY0Ljg5NzUgOTguOTEgNjQuNzExMyA5OC4xOTkyIDY0LjMzODkgOTcuNzA1MUM2My45NjY1IDk3LjIwNzQgNjMuNDQ1NSA5Ni45NTg1IDYyLjc3NTkgOTYuOTU4NUM2Mi4xMjQyIDk2Ljk1ODUgNjEuNjEwNCA5Ny4yMDc0IDYxLjIzNDQgOTcuNzA1MUM2MC44NjIgOTguMTk5MiA2MC42NzA0IDk4Ljg4NjcgNjAuNjU5NyA5OS43Njc2VjEwMC4zNDJDNjAuNjU5NyAxMDEuMjQxIDYwLjg0NzcgMTAxLjk0OCA2MS4yMjM2IDEwMi40NjRDNjEuNjAzMiAxMDIuOTc2IDYyLjEyNDIgMTAzLjIzMiA2Mi43ODY2IDEwMy4yMzJDNjMuNDUyNiAxMDMuMjMyIDYzLjk2ODMgMTAyLjk5IDY0LjMzMzUgMTAyLjUwN0M2NC42OTg3IDEwMi4wMiA2NC44ODY3IDEwMS4zMjMgNjQuODk3NSAxMDAuNDE3Vjk5LjgzNzRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik02OC4yNTQ0IDk4LjE4ODVMNjguMjg2NiA5OC45MTg5QzY4LjczMDYgOTguMzYwNCA2OS4zMTA3IDk4LjA4MTEgNzAuMDI2OSA5OC4wODExQzcxLjI1NSA5OC4wODExIDcxLjg3NDUgOTguNzczOSA3MS44ODUzIDEwMC4xNlYxMDRINzAuODkxNlYxMDAuMTU0QzcwLjg4OCA5OS43MzU0IDcwLjc5MTMgOTkuNDI1NiA3MC42MDE2IDk5LjIyNTFDNzAuNDE1NCA5OS4wMjQ2IDcwLjEyMzUgOTguOTI0MyA2OS43MjYxIDk4LjkyNDNDNjkuNDAzOCA5OC45MjQzIDY5LjEyMDkgOTkuMDEwMyA2OC44Nzc0IDk5LjE4MjFDNjguNjM0IDk5LjM1NCA2OC40NDQyIDk5LjU3OTYgNjguMzA4MSA5OS44NTg5VjEwNEg2Ny4zMTQ1Vjk4LjE4ODVINjguMjU0NFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTc1Ljc5NTQgMTA0LjEwN0M3NS4wMDc2IDEwNC4xMDcgNzQuMzY2NyAxMDMuODUgNzMuODcyNiAxMDMuMzM0QzczLjM3ODQgMTAyLjgxNSA3My4xMzEzIDEwMi4xMjIgNzMuMTMxMyAxMDEuMjU1VjEwMS4wNzNDNzMuMTMxMyAxMDAuNDk2IDczLjI0MDYgOTkuOTgyNCA3My40NTkgOTkuNTMxMkM3My42ODEgOTkuMDc2NSA3My45ODg5IDk4LjcyMiA3NC4zODI4IDk4LjQ2NzhDNzQuNzgwMyA5OC4yMSA3NS4yMSA5OC4wODExIDc1LjY3MTkgOTguMDgxMUM3Ni40Mjc0IDk4LjA4MTEgNzcuMDE0NiA5OC4zMjk5IDc3LjQzMzYgOTguODI3NkM3Ny44NTI1IDk5LjMyNTQgNzguMDYyIDEwMC4wMzggNzguMDYyIDEwMC45NjVWMTAxLjM3OUg3NC4xMjVDNzQuMTM5MyAxMDEuOTUyIDc0LjMwNTggMTAyLjQxNiA3NC42MjQ1IDEwMi43N0M3NC45NDY4IDEwMy4xMjEgNzUuMzU1IDEwMy4yOTYgNzUuODQ5MSAxMDMuMjk2Qzc2LjIgMTAzLjI5NiA3Ni40OTcyIDEwMy4yMjUgNzYuNzQwNyAxMDMuMDgyQzc2Ljk4NDIgMTAyLjkzOCA3Ny4xOTczIDEwMi43NDkgNzcuMzc5OSAxMDIuNTEyTDc3Ljk4NjggMTAyLjk4NUM3Ny40OTk4IDEwMy43MzMgNzYuNzY5NCAxMDQuMTA3IDc1Ljc5NTQgMTA0LjEwN1pNNzUuNjcxOSA5OC44OTc1Qzc1LjI3MDggOTguODk3NSA3NC45MzQyIDk5LjA0NDMgNzQuNjYyMSA5OS4zMzc5Qzc0LjM5IDk5LjYyNzkgNzQuMjIxNyAxMDAuMDM2IDc0LjE1NzIgMTAwLjU2Mkg3Ny4wNjg0VjEwMC40ODdDNzcuMDM5NyA5OS45ODI0IDc2LjkwMzYgOTkuNTkyMSA3Ni42NjAyIDk5LjMxNjRDNzYuNDE2NyA5OS4wMzcxIDc2LjA4NzIgOTguODk3NSA3NS42NzE5IDk4Ljg5NzVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik04MS4yODQ3IDEwMS4wODNINzguNjYzNlYxMDAuMjcySDgxLjI4NDdWMTAxLjA4M1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTg3LjQwMjMgMTAyLjYzTDg4LjUxOTUgOTguMTg4NUg4OS41MTMyTDg3LjgyMTMgMTA0SDg3LjAxNTZMODUuNjAzIDk5LjU5NTdMODQuMjI4IDEwNEg4My40MjI0TDgxLjczNTggOTguMTg4NUg4Mi43MjQxTDgzLjg2ODIgMTAyLjUzOUw4NS4yMjE3IDk4LjE4ODVIODYuMDIyTDg3LjQwMjMgMTAyLjYzWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNOTQuMTE2MiAxMDRDOTQuMDU4OSAxMDMuODg1IDk0LjAxMjQgMTAzLjY4MSA5My45NzY2IDEwMy4zODhDOTMuNTE0NiAxMDMuODY4IDkyLjk2MzIgMTA0LjEwNyA5Mi4zMjIzIDEwNC4xMDdDOTEuNzQ5MyAxMDQuMTA3IDkxLjI3ODUgMTAzLjk0NiA5MC45MDk3IDEwMy42MjRDOTAuNTQ0NCAxMDMuMjk4IDkwLjM2MTggMTAyLjg4NiA5MC4zNjE4IDEwMi4zODlDOTAuMzYxOCAxMDEuNzg0IDkwLjU5MSAxMDEuMzE0IDkxLjA0OTMgMTAwLjk4MUM5MS41MTEyIDEwMC42NDUgOTIuMTU5MyAxMDAuNDc3IDkyLjk5MzcgMTAwLjQ3N0g5My45NjA0VjEwMC4wMkM5My45NjA0IDk5LjY3MjcgOTMuODU2NiA5OS4zOTcgOTMuNjQ4OSA5OS4xOTI5QzkzLjQ0MTIgOTguOTg1MiA5My4xMzUxIDk4Ljg4MTMgOTIuNzMwNSA5OC44ODEzQzkyLjM3NiA5OC44ODEzIDkyLjA3ODggOTguOTcwOSA5MS44Mzg5IDk5LjE0OTlDOTEuNTk5IDk5LjMyODkgOTEuNDc5IDk5LjU0NTYgOTEuNDc5IDk5Ljc5OThIOTAuNDhDOTAuNDggOTkuNTA5OCA5MC41ODIgOTkuMjMwNSA5MC43ODYxIDk4Ljk2MTlDOTAuOTkzOCA5OC42ODk4IDkxLjI3MzEgOTguNDc0OSA5MS42MjQgOTguMzE3NEM5MS45Nzg1IDk4LjE1OTggOTIuMzY3IDk4LjA4MTEgOTIuNzg5NiA5OC4wODExQzkzLjQ1OTEgOTguMDgxMSA5My45ODM3IDk4LjI0OTMgOTQuMzYzMyA5OC41ODU5Qzk0Ljc0MjggOTguOTE4OSA5NC45Mzk4IDk5LjM3OTEgOTQuOTU0MSA5OS45NjYzVjEwMi42NDFDOTQuOTU0MSAxMDMuMTc1IDk1LjAyMjEgMTAzLjU5OSA5NS4xNTgyIDEwMy45MTRWMTA0SDk0LjExNjJaTTkyLjQ2NzMgMTAzLjI0M0M5Mi43Nzg4IDEwMy4yNDMgOTMuMDc0MiAxMDMuMTYyIDkzLjM1MzUgMTAzLjAwMUM5My42MzI4IDEwMi44NCA5My44MzUxIDEwMi42MyA5My45NjA0IDEwMi4zNzNWMTAxLjE4SDkzLjE4MTZDOTEuOTY0MiAxMDEuMTggOTEuMzU1NSAxMDEuNTM2IDkxLjM1NTUgMTAyLjI0OUM5MS4zNTU1IDEwMi41NjEgOTEuNDU5MyAxMDIuODA0IDkxLjY2NyAxMDIuOTc5QzkxLjg3NDcgMTAzLjE1NSA5Mi4xNDE0IDEwMy4yNDMgOTIuNDY3MyAxMDMuMjQzWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNOTguMzI3MSAxMDIuNTQ0TDk5LjY4MDcgOTguMTg4NUgxMDAuNzQ0TDk4LjQwNzcgMTA0Ljg5N0M5OC4wNDYxIDEwNS44NjQgOTcuNDcxNCAxMDYuMzQ3IDk2LjY4MzYgMTA2LjM0N0w5Ni40OTU2IDEwNi4zMzFMOTYuMTI1IDEwNi4yNjFWMTA1LjQ1Nkw5Ni4zOTM2IDEwNS40NzdDOTYuNzMwMSAxMDUuNDc3IDk2Ljk5MTUgMTA1LjQwOSA5Ny4xNzc3IDEwNS4yNzNDOTcuMzY3NSAxMDUuMTM3IDk3LjUyMzMgMTA0Ljg4OCA5Ny42NDUgMTA0LjUyNkw5Ny44NjUyIDEwMy45MzZMOTUuNzkyIDk4LjE4ODVIOTYuODc3TDk4LjMyNzEgMTAyLjU0NFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTY1LjkyMzMgMTQwLjM0MkM2NS45MjMzIDE0MS4xMDkgNjUuNzk0NCAxNDEuNzc4IDY1LjUzNjYgMTQyLjM1MUM2NS4yNzg4IDE0Mi45MiA2NC45MTM2IDE0My4zNTUgNjQuNDQwOSAxNDMuNjU2QzYzLjk2ODMgMTQzLjk1NyA2My40MTY4IDE0NC4xMDcgNjIuNzg2NiAxNDQuMTA3QzYyLjE3MDcgMTQ0LjEwNyA2MS42MjQ3IDE0My45NTcgNjEuMTQ4NCAxNDMuNjU2QzYwLjY3MjIgMTQzLjM1MiA2MC4zMDE2IDE0Mi45MiA2MC4wMzY2IDE0Mi4zNjJDNTkuNzc1MiAxNDEuOCA1OS42NDEgMTQxLjE1IDU5LjYzMzggMTQwLjQxMlYxMzkuODQ4QzU5LjYzMzggMTM5LjA5NiA1OS43NjQ1IDEzOC40MzIgNjAuMDI1OSAxMzcuODU1QzYwLjI4NzMgMTM3LjI3OSA2MC42NTYxIDEzNi44MzkgNjEuMTMyMyAxMzYuNTM0QzYxLjYxMjEgMTM2LjIyNiA2Mi4xNiAxMzYuMDcyIDYyLjc3NTkgMTM2LjA3MkM2My40MDI1IDEzNi4wNzIgNjMuOTUzOSAxMzYuMjI0IDY0LjQzMDIgMTM2LjUyOUM2NC45MSAxMzYuODMgNjUuMjc4OCAxMzcuMjY4IDY1LjUzNjYgMTM3Ljg0NUM2NS43OTQ0IDEzOC40MTggNjUuOTIzMyAxMzkuMDg1IDY1LjkyMzMgMTM5Ljg0OFYxNDAuMzQyWk02NC44OTc1IDEzOS44MzdDNjQuODk3NSAxMzguOTEgNjQuNzExMyAxMzguMTk5IDY0LjMzODkgMTM3LjcwNUM2My45NjY1IDEzNy4yMDcgNjMuNDQ1NSAxMzYuOTU4IDYyLjc3NTkgMTM2Ljk1OEM2Mi4xMjQyIDEzNi45NTggNjEuNjEwNCAxMzcuMjA3IDYxLjIzNDQgMTM3LjcwNUM2MC44NjIgMTM4LjE5OSA2MC42NzA0IDEzOC44ODcgNjAuNjU5NyAxMzkuNzY4VjE0MC4zNDJDNjAuNjU5NyAxNDEuMjQxIDYwLjg0NzcgMTQxLjk0OCA2MS4yMjM2IDE0Mi40NjRDNjEuNjAzMiAxNDIuOTc2IDYyLjEyNDIgMTQzLjIzMiA2Mi43ODY2IDE0My4yMzJDNjMuNDUyNiAxNDMuMjMyIDYzLjk2ODMgMTQyLjk5IDY0LjMzMzUgMTQyLjUwN0M2NC42OTg3IDE0Mi4wMiA2NC44ODY3IDE0MS4zMjMgNjQuODk3NSAxNDAuNDE3VjEzOS44MzdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik02OC4yNTQ0IDEzOC4xODhMNjguMjg2NiAxMzguOTE5QzY4LjczMDYgMTM4LjM2IDY5LjMxMDcgMTM4LjA4MSA3MC4wMjY5IDEzOC4wODFDNzEuMjU1IDEzOC4wODEgNzEuODc0NSAxMzguNzc0IDcxLjg4NTMgMTQwLjE2VjE0NEg3MC44OTE2VjE0MC4xNTRDNzAuODg4IDEzOS43MzUgNzAuNzkxMyAxMzkuNDI2IDcwLjYwMTYgMTM5LjIyNUM3MC40MTU0IDEzOS4wMjUgNzAuMTIzNSAxMzguOTI0IDY5LjcyNjEgMTM4LjkyNEM2OS40MDM4IDEzOC45MjQgNjkuMTIwOSAxMzkuMDEgNjguODc3NCAxMzkuMTgyQzY4LjYzNCAxMzkuMzU0IDY4LjQ0NDIgMTM5LjU4IDY4LjMwODEgMTM5Ljg1OVYxNDRINjcuMzE0NVYxMzguMTg4SDY4LjI1NDRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik03NS43OTU0IDE0NC4xMDdDNzUuMDA3NiAxNDQuMTA3IDc0LjM2NjcgMTQzLjg1IDczLjg3MjYgMTQzLjMzNEM3My4zNzg0IDE0Mi44MTUgNzMuMTMxMyAxNDIuMTIyIDczLjEzMTMgMTQxLjI1NVYxNDEuMDczQzczLjEzMTMgMTQwLjQ5NiA3My4yNDA2IDEzOS45ODIgNzMuNDU5IDEzOS41MzFDNzMuNjgxIDEzOS4wNzYgNzMuOTg4OSAxMzguNzIyIDc0LjM4MjggMTM4LjQ2OEM3NC43ODAzIDEzOC4yMSA3NS4yMSAxMzguMDgxIDc1LjY3MTkgMTM4LjA4MUM3Ni40Mjc0IDEzOC4wODEgNzcuMDE0NiAxMzguMzMgNzcuNDMzNiAxMzguODI4Qzc3Ljg1MjUgMTM5LjMyNSA3OC4wNjIgMTQwLjAzOCA3OC4wNjIgMTQwLjk2NVYxNDEuMzc5SDc0LjEyNUM3NC4xMzkzIDE0MS45NTIgNzQuMzA1OCAxNDIuNDE2IDc0LjYyNDUgMTQyLjc3Qzc0Ljk0NjggMTQzLjEyMSA3NS4zNTUgMTQzLjI5NiA3NS44NDkxIDE0My4yOTZDNzYuMiAxNDMuMjk2IDc2LjQ5NzIgMTQzLjIyNSA3Ni43NDA3IDE0My4wODJDNzYuOTg0MiAxNDIuOTM4IDc3LjE5NzMgMTQyLjc0OSA3Ny4zNzk5IDE0Mi41MTJMNzcuOTg2OCAxNDIuOTg1Qzc3LjQ5OTggMTQzLjczMyA3Ni43Njk0IDE0NC4xMDcgNzUuNzk1NCAxNDQuMTA3Wk03NS42NzE5IDEzOC44OTdDNzUuMjcwOCAxMzguODk3IDc0LjkzNDIgMTM5LjA0NCA3NC42NjIxIDEzOS4zMzhDNzQuMzkgMTM5LjYyOCA3NC4yMjE3IDE0MC4wMzYgNzQuMTU3MiAxNDAuNTYySDc3LjA2ODRWMTQwLjQ4N0M3Ny4wMzk3IDEzOS45ODIgNzYuOTAzNiAxMzkuNTkyIDc2LjY2MDIgMTM5LjMxNkM3Ni40MTY3IDEzOS4wMzcgNzYuMDg3MiAxMzguODk3IDc1LjY3MTkgMTM4Ljg5N1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTgxLjI4NDcgMTQxLjA4M0g3OC42NjM2VjE0MC4yNzJIODEuMjg0N1YxNDEuMDgzWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNODcuNDAyMyAxNDIuNjNMODguNTE5NSAxMzguMTg4SDg5LjUxMzJMODcuODIxMyAxNDRIODcuMDE1Nkw4NS42MDMgMTM5LjU5Nkw4NC4yMjggMTQ0SDgzLjQyMjRMODEuNzM1OCAxMzguMTg4SDgyLjcyNDFMODMuODY4MiAxNDIuNTM5TDg1LjIyMTcgMTM4LjE4OEg4Ni4wMjJMODcuNDAyMyAxNDIuNjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik05NC4xMTYyIDE0NEM5NC4wNTg5IDE0My44ODUgOTQuMDEyNCAxNDMuNjgxIDkzLjk3NjYgMTQzLjM4OEM5My41MTQ2IDE0My44NjggOTIuOTYzMiAxNDQuMTA3IDkyLjMyMjMgMTQ0LjEwN0M5MS43NDkzIDE0NC4xMDcgOTEuMjc4NSAxNDMuOTQ2IDkwLjkwOTcgMTQzLjYyNEM5MC41NDQ0IDE0My4yOTggOTAuMzYxOCAxNDIuODg2IDkwLjM2MTggMTQyLjM4OUM5MC4zNjE4IDE0MS43ODQgOTAuNTkxIDE0MS4zMTQgOTEuMDQ5MyAxNDAuOTgxQzkxLjUxMTIgMTQwLjY0NSA5Mi4xNTkzIDE0MC40NzcgOTIuOTkzNyAxNDAuNDc3SDkzLjk2MDRWMTQwLjAyQzkzLjk2MDQgMTM5LjY3MyA5My44NTY2IDEzOS4zOTcgOTMuNjQ4OSAxMzkuMTkzQzkzLjQ0MTIgMTM4Ljk4NSA5My4xMzUxIDEzOC44ODEgOTIuNzMwNSAxMzguODgxQzkyLjM3NiAxMzguODgxIDkyLjA3ODggMTM4Ljk3MSA5MS44Mzg5IDEzOS4xNUM5MS41OTkgMTM5LjMyOSA5MS40NzkgMTM5LjU0NiA5MS40NzkgMTM5LjhIOTAuNDhDOTAuNDggMTM5LjUxIDkwLjU4MiAxMzkuMjMgOTAuNzg2MSAxMzguOTYyQzkwLjk5MzggMTM4LjY5IDkxLjI3MzEgMTM4LjQ3NSA5MS42MjQgMTM4LjMxN0M5MS45Nzg1IDEzOC4xNiA5Mi4zNjcgMTM4LjA4MSA5Mi43ODk2IDEzOC4wODFDOTMuNDU5MSAxMzguMDgxIDkzLjk4MzcgMTM4LjI0OSA5NC4zNjMzIDEzOC41ODZDOTQuNzQyOCAxMzguOTE5IDk0LjkzOTggMTM5LjM3OSA5NC45NTQxIDEzOS45NjZWMTQyLjY0MUM5NC45NTQxIDE0My4xNzUgOTUuMDIyMSAxNDMuNTk5IDk1LjE1ODIgMTQzLjkxNFYxNDRIOTQuMTE2MlpNOTIuNDY3MyAxNDMuMjQzQzkyLjc3ODggMTQzLjI0MyA5My4wNzQyIDE0My4xNjIgOTMuMzUzNSAxNDMuMDAxQzkzLjYzMjggMTQyLjg0IDkzLjgzNTEgMTQyLjYzIDkzLjk2MDQgMTQyLjM3M1YxNDEuMThIOTMuMTgxNkM5MS45NjQyIDE0MS4xOCA5MS4zNTU1IDE0MS41MzYgOTEuMzU1NSAxNDIuMjQ5QzkxLjM1NTUgMTQyLjU2MSA5MS40NTkzIDE0Mi44MDQgOTEuNjY3IDE0Mi45NzlDOTEuODc0NyAxNDMuMTU1IDkyLjE0MTQgMTQzLjI0MyA5Mi40NjczIDE0My4yNDNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik05OC4zMjcxIDE0Mi41NDRMOTkuNjgwNyAxMzguMTg4SDEwMC43NDRMOTguNDA3NyAxNDQuODk3Qzk4LjA0NjEgMTQ1Ljg2NCA5Ny40NzE0IDE0Ni4zNDcgOTYuNjgzNiAxNDYuMzQ3TDk2LjQ5NTYgMTQ2LjMzMUw5Ni4xMjUgMTQ2LjI2MVYxNDUuNDU2TDk2LjM5MzYgMTQ1LjQ3N0M5Ni43MzAxIDE0NS40NzcgOTYuOTkxNSAxNDUuNDA5IDk3LjE3NzcgMTQ1LjI3M0M5Ny4zNjc1IDE0NS4xMzcgOTcuNTIzMyAxNDQuODg4IDk3LjY0NSAxNDQuNTI2TDk3Ljg2NTIgMTQzLjkzNkw5NS43OTIgMTM4LjE4OEg5Ni44NzdMOTguMzI3MSAxNDIuNTQ0WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTIuNzczNCAxMDMuNDc5QzEyLjc3MzQgMTAzLjMwNyAxMi44MjM2IDEwMy4xNjQgMTIuOTIzOCAxMDMuMDQ5QzEzLjAyNzcgMTAyLjkzNSAxMy4xODE2IDEwMi44NzcgMTMuMzg1NyAxMDIuODc3QzEzLjU4OTggMTAyLjg3NyAxMy43NDM4IDEwMi45MzUgMTMuODQ3NyAxMDMuMDQ5QzEzLjk1NTEgMTAzLjE2NCAxNC4wMDg4IDEwMy4zMDcgMTQuMDA4OCAxMDMuNDc5QzE0LjAwODggMTAzLjY0NCAxMy45NTUxIDEwMy43ODIgMTMuODQ3NyAxMDMuODkzQzEzLjc0MzggMTA0LjAwNCAxMy41ODk4IDEwNC4wNTkgMTMuMzg1NyAxMDQuMDU5QzEzLjE4MTYgMTA0LjA1OSAxMy4wMjc3IDEwNC4wMDQgMTIuOTIzOCAxMDMuODkzQzEyLjgyMzYgMTAzLjc4MiAxMi43NzM0IDEwMy42NDQgMTIuNzczNCAxMDMuNDc5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTUuNjczOCAxMDMuNDc5QzE1LjY3MzggMTAzLjMwNyAxNS43MjQgMTAzLjE2NCAxNS44MjQyIDEwMy4wNDlDMTUuOTI4MSAxMDIuOTM1IDE2LjA4MiAxMDIuODc3IDE2LjI4NjEgMTAyLjg3N0MxNi40OTAyIDEwMi44NzcgMTYuNjQ0MiAxMDIuOTM1IDE2Ljc0OCAxMDMuMDQ5QzE2Ljg1NTUgMTAzLjE2NCAxNi45MDkyIDEwMy4zMDcgMTYuOTA5MiAxMDMuNDc5QzE2LjkwOTIgMTAzLjY0NCAxNi44NTU1IDEwMy43ODIgMTYuNzQ4IDEwMy44OTNDMTYuNjQ0MiAxMDQuMDA0IDE2LjQ5MDIgMTA0LjA1OSAxNi4yODYxIDEwNC4wNTlDMTYuMDgyIDEwNC4wNTkgMTUuOTI4MSAxMDQuMDA0IDE1LjgyNDIgMTAzLjg5M0MxNS43MjQgMTAzLjc4MiAxNS42NzM4IDEwMy42NDQgMTUuNjczOCAxMDMuNDc5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMTguMjg5NiAxMDEuMDQxQzE4LjI4OTYgMTAwLjQ3MSAxOC40MDA2IDk5Ljk1OTEgMTguNjIyNiA5OS41MDQ0QzE4Ljg0ODEgOTkuMDQ5NiAxOS4xNTk3IDk4LjY5ODcgMTkuNTU3MSA5OC40NTE3QzE5Ljk1ODIgOTguMjA0NiAyMC40MTQ3IDk4LjA4MTEgMjAuOTI2OCA5OC4wODExQzIxLjcxODEgOTguMDgxMSAyMi4zNTczIDk4LjM1NSAyMi44NDQyIDk4LjkwMjhDMjMuMzM0OCA5OS40NTA3IDIzLjU4MDEgMTAwLjE3OSAyMy41ODAxIDEwMS4wODlWMTAxLjE1OUMyMy41ODAxIDEwMS43MjQgMjMuNDcwOSAxMDIuMjMzIDIzLjI1MjQgMTAyLjY4NEMyMy4wMzc2IDEwMy4xMzIgMjIuNzI3OSAxMDMuNDgxIDIyLjMyMzIgMTAzLjczMUMyMS45MjIyIDEwMy45ODIgMjEuNDYwMyAxMDQuMTA3IDIwLjkzNzUgMTA0LjEwN0MyMC4xNDk3IDEwNC4xMDcgMTkuNTEwNiAxMDMuODMzIDE5LjAyIDEwMy4yODZDMTguNTMzIDEwMi43MzggMTguMjg5NiAxMDIuMDEzIDE4LjI4OTYgMTAxLjExVjEwMS4wNDFaTTE5LjI4ODYgMTAxLjE1OUMxOS4yODg2IDEwMS44MDMgMTkuNDM3MiAxMDIuMzIxIDE5LjczNDQgMTAyLjcxMUMyMC4wMzUyIDEwMy4xMDEgMjAuNDM2MiAxMDMuMjk2IDIwLjkzNzUgMTAzLjI5NkMyMS40NDI0IDEwMy4yOTYgMjEuODQzNCAxMDMuMDk5IDIyLjE0MDYgMTAyLjcwNkMyMi40Mzc4IDEwMi4zMDggMjIuNTg2NCAxMDEuNzUzIDIyLjU4NjQgMTAxLjA0MUMyMi41ODY0IDEwMC40MDMgMjIuNDM0MiA5OS44ODc1IDIyLjEyOTkgOTkuNDkzN0MyMS44MjkxIDk5LjA5NjIgMjEuNDI4MSA5OC44OTc1IDIwLjkyNjggOTguODk3NUMyMC40MzYyIDk4Ljg5NzUgMjAuMDQwNSA5OS4wOTI2IDE5LjczOTcgOTkuNDgyOUMxOS40MzkgOTkuODczMiAxOS4yODg2IDEwMC40MzIgMTkuMjg4NiAxMDEuMTU5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMjYuMTY4OSA5OS42MDY0SDI2LjkxNTVDMjcuMzg0NiA5OS41OTkzIDI3Ljc1MzQgOTkuNDc1NyAyOC4wMjIgOTkuMjM1OEMyOC4yOTA1IDk4Ljk5NTkgMjguNDI0OCA5OC42NzE5IDI4LjQyNDggOTguMjYzN0MyOC40MjQ4IDk3LjM0NyAyNy45NjgzIDk2Ljg4ODcgMjcuMDU1MiA5Ni44ODg3QzI2LjYyNTUgOTYuODg4NyAyNi4yODE3IDk3LjAxMjIgMjYuMDIzOSA5Ny4yNTkzQzI1Ljc2OTcgOTcuNTAyOCAyNS42NDI2IDk3LjgyNjggMjUuNjQyNiA5OC4yMzE0SDI0LjY0ODlDMjQuNjQ4OSA5Ny42MTIgMjQuODc0NSA5Ny4wOTgxIDI1LjMyNTcgOTYuNjg5OUMyNS43ODA0IDk2LjI3ODIgMjYuMzU2OSA5Ni4wNzIzIDI3LjA1NTIgOTYuMDcyM0MyNy43OTI4IDk2LjA3MjMgMjguMzcxMSA5Ni4yNjc0IDI4Ljc5IDk2LjY1NzdDMjkuMjA5IDk3LjA0OCAyOS40MTg1IDk3LjU5MDUgMjkuNDE4NSA5OC4yODUyQzI5LjQxODUgOTguNjI1MyAyOS4zMDc1IDk4Ljk1NDggMjkuMDg1NCA5OS4yNzM0QzI4Ljg2NyA5OS41OTIxIDI4LjU2OCA5OS44MzAyIDI4LjE4ODUgOTkuOTg3OEMyOC42MTgyIDEwMC4xMjQgMjguOTQ5NCAxMDAuMzQ5IDI5LjE4MjEgMTAwLjY2NUMyOS40MTg1IDEwMC45OCAyOS41MzY2IDEwMS4zNjUgMjkuNTM2NiAxMDEuODE5QzI5LjUzNjYgMTAyLjUyMSAyOS4zMDc1IDEwMy4wNzggMjguODQ5MSAxMDMuNDlDMjguMzkwOCAxMDMuOTAyIDI3Ljc5NDYgMTA0LjEwNyAyNy4wNjA1IDEwNC4xMDdDMjYuMzI2NSAxMDQuMTA3IDI1LjcyODUgMTAzLjkwOSAyNS4yNjY2IDEwMy41MTFDMjQuODA4MyAxMDMuMTE0IDI0LjU3OTEgMTAyLjU4OSAyNC41NzkxIDEwMS45MzhIMjUuNTc4MUMyNS41NzgxIDEwMi4zNDkgMjUuNzEyNCAxMDIuNjc5IDI1Ljk4MSAxMDIuOTI2QzI2LjI0OTUgMTAzLjE3MyAyNi42MDk0IDEwMy4yOTYgMjcuMDYwNSAxMDMuMjk2QzI3LjU0MDQgMTAzLjI5NiAyNy45MDc0IDEwMy4xNzEgMjguMTYxNiAxMDIuOTJDMjguNDE1OSAxMDIuNjcgMjguNTQzIDEwMi4zMSAyOC41NDMgMTAxLjg0MUMyOC41NDMgMTAxLjM4NiAyOC40MDMzIDEwMS4wMzcgMjguMTI0IDEwMC43OTNDMjcuODQ0NyAxMDAuNTUgMjcuNDQxOSAxMDAuNDI1IDI2LjkxNTUgMTAwLjQxN0gyNi4xNjg5Vjk5LjYwNjRaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0zMC43NjEyIDEwMS4wNDZDMzAuNzYxMiAxMDAuMTU0IDMwLjk3MjUgOTkuNDM4MiAzMS4zOTUgOTguODk3NUMzMS44MTc1IDk4LjM1MzIgMzIuMzcwOCA5OC4wODExIDMzLjA1NDcgOTguMDgxMUMzMy43MzUgOTguMDgxMSAzNC4yNzM5IDk4LjMxMzggMzQuNjcxNCA5OC43NzkzVjk1Ljc1SDM1LjY2NVYxMDRIMzQuNzUyTDM0LjcwMzYgMTAzLjM3N0MzNC4zMDYyIDEwMy44NjQgMzMuNzUyOSAxMDQuMTA3IDMzLjA0MzkgMTA0LjEwN0MzMi4zNzA4IDEwNC4xMDcgMzEuODIxMSAxMDMuODMyIDMxLjM5NSAxMDMuMjhDMzAuOTcyNSAxMDIuNzI5IDMwLjc2MTIgMTAyLjAwOSAzMC43NjEyIDEwMS4xMjFWMTAxLjA0NlpNMzEuNzU0OSAxMDEuMTU5QzMxLjc1NDkgMTAxLjgxOCAzMS44OTEgMTAyLjMzMyAzMi4xNjMxIDEwMi43MDZDMzIuNDM1MiAxMDMuMDc4IDMyLjgxMTIgMTAzLjI2NCAzMy4yOTEgMTAzLjI2NEMzMy45MjEyIDEwMy4yNjQgMzQuMzgxMyAxMDIuOTgxIDM0LjY3MTQgMTAyLjQxNlY5OS43NDYxQzM0LjM3NDIgOTkuMTk4MiAzMy45MTc2IDk4LjkyNDMgMzMuMzAxOCA5OC45MjQzQzMyLjgxNDggOTguOTI0MyAzMi40MzUyIDk5LjExMjMgMzIuMTYzMSA5OS40ODgzQzMxLjg5MSA5OS44NjQzIDMxLjc1NDkgMTAwLjQyMSAzMS43NTQ5IDEwMS4xNTlaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik00MC4wMjEgOTkuMDgwMUMzOS44NzA2IDk5LjA1NSAzOS43MDc3IDk5LjA0MjUgMzkuNTMyMiA5OS4wNDI1QzM4Ljg4MDUgOTkuMDQyNSAzOC40MzgzIDk5LjMyIDM4LjIwNTYgOTkuODc1VjEwNEgzNy4yMTE5Vjk4LjE4ODVIMzguMTc4N0wzOC4xOTQ4IDk4Ljg1OTlDMzguNTIwNyA5OC4zNDA3IDM4Ljk4MjYgOTguMDgxMSAzOS41ODA2IDk4LjA4MTFDMzkuNzczOSA5OC4wODExIDM5LjkyMDcgOTguMTA2MSA0MC4wMjEgOTguMTU2MlY5OS4wODAxWiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNNDEuODc5NCA5OC4xODg1TDQxLjkxMTYgOTguOTE4OUM0Mi4zNTU2IDk4LjM2MDQgNDIuOTM1NyA5OC4wODExIDQzLjY1MTkgOTguMDgxMUM0NC44OCA5OC4wODExIDQ1LjQ5OTUgOTguNzczOSA0NS41MTAzIDEwMC4xNlYxMDRINDQuNTE2NlYxMDAuMTU0QzQ0LjUxMyA5OS43MzU0IDQ0LjQxNjMgOTkuNDI1NiA0NC4yMjY2IDk5LjIyNTFDNDQuMDQwNCA5OS4wMjQ2IDQzLjc0ODUgOTguOTI0MyA0My4zNTExIDk4LjkyNDNDNDMuMDI4OCA5OC45MjQzIDQyLjc0NTkgOTkuMDEwMyA0Mi41MDI0IDk5LjE4MjFDNDIuMjU5IDk5LjM1NCA0Mi4wNjkyIDk5LjU3OTYgNDEuOTMzMSA5OS44NTg5VjEwNEg0MC45Mzk1Vjk4LjE4ODVINDEuODc5NFoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTEyLjc3MzQgMTQzLjQ3OUMxMi43NzM0IDE0My4zMDcgMTIuODIzNiAxNDMuMTY0IDEyLjkyMzggMTQzLjA0OUMxMy4wMjc3IDE0Mi45MzUgMTMuMTgxNiAxNDIuODc3IDEzLjM4NTcgMTQyLjg3N0MxMy41ODk4IDE0Mi44NzcgMTMuNzQzOCAxNDIuOTM1IDEzLjg0NzcgMTQzLjA0OUMxMy45NTUxIDE0My4xNjQgMTQuMDA4OCAxNDMuMzA3IDE0LjAwODggMTQzLjQ3OUMxNC4wMDg4IDE0My42NDQgMTMuOTU1MSAxNDMuNzgyIDEzLjg0NzcgMTQzLjg5M0MxMy43NDM4IDE0NC4wMDQgMTMuNTg5OCAxNDQuMDU5IDEzLjM4NTcgMTQ0LjA1OUMxMy4xODE2IDE0NC4wNTkgMTMuMDI3NyAxNDQuMDA0IDEyLjkyMzggMTQzLjg5M0MxMi44MjM2IDE0My43ODIgMTIuNzczNCAxNDMuNjQ0IDEyLjc3MzQgMTQzLjQ3OVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTE1LjY3MzggMTQzLjQ3OUMxNS42NzM4IDE0My4zMDcgMTUuNzI0IDE0My4xNjQgMTUuODI0MiAxNDMuMDQ5QzE1LjkyODEgMTQyLjkzNSAxNi4wODIgMTQyLjg3NyAxNi4yODYxIDE0Mi44NzdDMTYuNDkwMiAxNDIuODc3IDE2LjY0NDIgMTQyLjkzNSAxNi43NDggMTQzLjA0OUMxNi44NTU1IDE0My4xNjQgMTYuOTA5MiAxNDMuMzA3IDE2LjkwOTIgMTQzLjQ3OUMxNi45MDkyIDE0My42NDQgMTYuODU1NSAxNDMuNzgyIDE2Ljc0OCAxNDMuODkzQzE2LjY0NDIgMTQ0LjAwNCAxNi40OTAyIDE0NC4wNTkgMTYuMjg2MSAxNDQuMDU5QzE2LjA4MiAxNDQuMDU5IDE1LjkyODEgMTQ0LjAwNCAxNS44MjQyIDE0My44OTNDMTUuNzI0IDE0My43ODIgMTUuNjczOCAxNDMuNjQ0IDE1LjY3MzggMTQzLjQ3OVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTIxLjM2MTggMTM5LjA4QzIxLjIxMTQgMTM5LjA1NSAyMS4wNDg1IDEzOS4wNDIgMjAuODczIDEzOS4wNDJDMjAuMjIxNCAxMzkuMDQyIDE5Ljc3OTEgMTM5LjMyIDE5LjU0NjQgMTM5Ljg3NVYxNDRIMTguNTUyN1YxMzguMTg4SDE5LjUxOTVMMTkuNTM1NiAxMzguODZDMTkuODYxNSAxMzguMzQxIDIwLjMyMzQgMTM4LjA4MSAyMC45MjE0IDEzOC4wODFDMjEuMTE0NyAxMzguMDgxIDIxLjI2MTYgMTM4LjEwNiAyMS4zNjE4IDEzOC4xNTZWMTM5LjA4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNMjQuMjk0NCAxNDIuNjUyTDI1LjczMzkgMTM4LjE4OEgyNi43NDlMMjQuNjY1IDE0NEgyMy45MDc3TDIxLjgwMjIgMTM4LjE4OEgyMi44MTc0TDI0LjI5NDQgMTQyLjY1MloiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuODciLz4KPHBhdGggZD0iTTMxLjQxMTEgMTQwLjU2MkMzMS4yMDM1IDE0MC44MSAzMC45NTQ2IDE0MS4wMDggMzAuNjY0NiAxNDEuMTU5QzMwLjM3ODEgMTQxLjMwOSAzMC4wNjMgMTQxLjM4NCAyOS43MTkyIDE0MS4zODRDMjkuMjY4MSAxNDEuMzg0IDI4Ljg3NDIgMTQxLjI3MyAyOC41Mzc2IDE0MS4wNTFDMjguMjA0NiAxNDAuODI5IDI3Ljk0NjggMTQwLjUxOCAyNy43NjQyIDE0MC4xMTdDMjcuNTgxNSAxMzkuNzEyIDI3LjQ5MDIgMTM5LjI2NiAyNy40OTAyIDEzOC43NzlDMjcuNDkwMiAxMzguMjU3IDI3LjU4ODcgMTM3Ljc4NiAyNy43ODU2IDEzNy4zNjdDMjcuOTg2MiAxMzYuOTQ4IDI4LjI2OSAxMzYuNjI3IDI4LjYzNDMgMTM2LjQwNUMyOC45OTk1IDEzNi4xODMgMjkuNDI1NiAxMzYuMDcyIDI5LjkxMjYgMTM2LjA3MkMzMC42ODYgMTM2LjA3MiAzMS4yOTQ4IDEzNi4zNjIgMzEuNzM4OCAxMzYuOTQyQzMyLjE4NjQgMTM3LjUxOSAzMi40MTAyIDEzOC4zMDcgMzIuNDEwMiAxMzkuMzA2VjEzOS41OTZDMzIuNDEwMiAxNDEuMTE4IDMyLjEwOTQgMTQyLjIyOSAzMS41MDc4IDE0Mi45MzFDMzAuOTA2MiAxNDMuNjI5IDI5Ljk5ODUgMTQzLjk4NyAyOC43ODQ3IDE0NC4wMDVIMjguNTkxM1YxNDMuMTY3SDI4LjgwMDhDMjkuNjIwOCAxNDMuMTUzIDMwLjI1MSAxNDIuOTQgMzAuNjkxNCAxNDIuNTI4QzMxLjEzMTggMTQyLjExMyAzMS4zNzE3IDE0MS40NTggMzEuNDExMSAxNDAuNTYyWk0yOS44ODA0IDE0MC41NjJDMzAuMjEzNCAxNDAuNTYyIDMwLjUxOTUgMTQwLjQ2IDMwLjc5ODggMTQwLjI1NkMzMS4wODE3IDE0MC4wNTIgMzEuMjg3NiAxMzkuOCAzMS40MTY1IDEzOS40OTlWMTM5LjEwMkMzMS40MTY1IDEzOC40NSAzMS4yNzUxIDEzNy45MiAzMC45OTIyIDEzNy41MTJDMzAuNzA5MyAxMzcuMTA0IDMwLjM1MTIgMTM2Ljg5OSAyOS45MTggMTM2Ljg5OUMyOS40ODExIDEzNi44OTkgMjkuMTMwMiAxMzcuMDY4IDI4Ljg2NTIgMTM3LjQwNEMyOC42MDAzIDEzNy43MzcgMjguNDY3OCAxMzguMTc4IDI4LjQ2NzggMTM4LjcyNkMyOC40Njc4IDEzOS4yNTkgMjguNTk0OSAxMzkuNyAyOC44NDkxIDE0MC4wNDdDMjkuMTA2OSAxNDAuMzkxIDI5LjQ1MDcgMTQwLjU2MiAyOS44ODA0IDE0MC41NjJaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0zNS41MDM5IDE0MS4zMDlMMzQuODgwOSAxNDEuOTU5VjE0NEgzMy44ODcyVjEzNS43NUgzNC44ODA5VjE0MC43NEwzNS40MTI2IDE0MC4xMDFMMzcuMjIyNyAxMzguMTg4SDM4LjQzMTJMMzYuMTY5OSAxNDAuNjE2TDM4LjY5NDMgMTQ0SDM3LjUyODhMMzUuNTAzOSAxNDEuMzA5WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC44NyIvPgo8cGF0aCBkPSJNNDEuNzYxMiAxNDQuMTA3QzQwLjk3MzUgMTQ0LjEwNyA0MC4zMzI1IDE0My44NSAzOS44Mzg0IDE0My4zMzRDMzkuMzQ0MiAxNDIuODE1IDM5LjA5NzIgMTQyLjEyMiAzOS4wOTcyIDE0MS4yNTVWMTQxLjA3M0MzOS4wOTcyIDE0MC40OTYgMzkuMjA2NCAxMzkuOTgyIDM5LjQyNDggMTM5LjUzMUMzOS42NDY4IDEzOS4wNzYgMzkuOTU0OCAxMzguNzIyIDQwLjM0ODYgMTM4LjQ2OEM0MC43NDYxIDEzOC4yMSA0MS4xNzU4IDEzOC4wODEgNDEuNjM3NyAxMzguMDgxQzQyLjM5MzIgMTM4LjA4MSA0Mi45ODA1IDEzOC4zMyA0My4zOTk0IDEzOC44MjhDNDMuODE4NCAxMzkuMzI1IDQ0LjAyNzggMTQwLjAzOCA0NC4wMjc4IDE0MC45NjVWMTQxLjM3OUg0MC4wOTA4QzQwLjEwNTEgMTQxLjk1MiA0MC4yNzE2IDE0Mi40MTYgNDAuNTkwMyAxNDIuNzdDNDAuOTEyNiAxNDMuMTIxIDQxLjMyMDggMTQzLjI5NiA0MS44MTQ5IDE0My4yOTZDNDIuMTY1OSAxNDMuMjk2IDQyLjQ2MzEgMTQzLjIyNSA0Mi43MDY1IDE0My4wODJDNDIuOTUgMTQyLjkzOCA0My4xNjMxIDE0Mi43NDkgNDMuMzQ1NyAxNDIuNTEyTDQzLjk1MjYgMTQyLjk4NUM0My40NjU3IDE0My43MzMgNDIuNzM1MiAxNDQuMTA3IDQxLjc2MTIgMTQ0LjEwN1pNNDEuNjM3NyAxMzguODk3QzQxLjIzNjcgMTM4Ljg5NyA0MC45MDAxIDEzOS4wNDQgNDAuNjI3OSAxMzkuMzM4QzQwLjM1NTggMTM5LjYyOCA0MC4xODc1IDE0MC4wMzYgNDAuMTIzIDE0MC41NjJINDMuMDM0MlYxNDAuNDg3QzQzLjAwNTUgMTM5Ljk4MiA0Mi44Njk1IDEzOS41OTIgNDIuNjI2IDEzOS4zMTZDNDIuMzgyNSAxMzkuMDM3IDQyLjA1MzEgMTM4Ljg5NyA0MS42Mzc3IDEzOC44OTdaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjg3Ii8+CjxwYXRoIGQ9Ik0xNDYuNDQ1IDU3LjI3NTRIMTQ0LjAwN1Y2NEgxNDIuNjU5VjU3LjI3NTRIMTQwLjI0MlY1Ni4xNzk3SDE0Ni40NDVWNTcuMjc1NFoiIGZpbGw9IiNGRkE1MDAiLz4KPHBhdGggZD0iTTE0OC43MzkgNjRIMTQ3LjQzNFY1OC4xODg1SDE0OC43MzlWNjRaTTE0Ny4zNTMgNTYuNjc5MkMxNDcuMzUzIDU2LjQ3ODcgMTQ3LjQxNiA1Ni4zMTIyIDE0Ny41NDEgNTYuMTc5N0MxNDcuNjcgNTYuMDQ3MiAxNDcuODUzIDU1Ljk4MSAxNDguMDg5IDU1Ljk4MUMxNDguMzI1IDU1Ljk4MSAxNDguNTA4IDU2LjA0NzIgMTQ4LjYzNyA1Ni4xNzk3QzE0OC43NjYgNTYuMzEyMiAxNDguODMgNTYuNDc4NyAxNDguODMgNTYuNjc5MkMxNDguODMgNTYuODc2MSAxNDguNzY2IDU3LjA0MDkgMTQ4LjYzNyA1Ny4xNzMzQzE0OC41MDggNTcuMzAyMiAxNDguMzI1IDU3LjM2NjcgMTQ4LjA4OSA1Ny4zNjY3QzE0Ny44NTMgNTcuMzY2NyAxNDcuNjcgNTcuMzAyMiAxNDcuNTQxIDU3LjE3MzNDMTQ3LjQxNiA1Ny4wNDA5IDE0Ny4zNTMgNTYuODc2MSAxNDcuMzUzIDU2LjY3OTJaIiBmaWxsPSIjRkZBNTAwIi8+CjxwYXRoIGQ9Ik0xNTEuMzkyIDU4LjE4ODVMMTUxLjQzIDU4Ljc5NTRDMTUxLjgzOCA1OC4zMTkyIDE1Mi4zOTYgNTguMDgxMSAxNTMuMTA1IDU4LjA4MTFDMTUzLjg4MiA1OC4wODExIDE1NC40MTQgNTguMzc4MyAxNTQuNzAxIDU4Ljk3MjdDMTU1LjEyMyA1OC4zNzgzIDE1NS43MTggNTguMDgxMSAxNTYuNDg0IDU4LjA4MTFDMTU3LjEyNSA1OC4wODExIDE1Ny42MDEgNTguMjU4MyAxNTcuOTEzIDU4LjYxMjhDMTU4LjIyOCA1OC45NjczIDE1OC4zODkgNTkuNDkwMSAxNTguMzk2IDYwLjE4MTJWNjRIMTU3LjA5MVY2MC4yMTg4QzE1Ny4wOTEgNTkuODQ5OSAxNTcuMDEgNTkuNTc5NiAxNTYuODQ5IDU5LjQwNzdDMTU2LjY4OCA1OS4yMzU4IDE1Ni40MjEgNTkuMTQ5OSAxNTYuMDQ5IDU5LjE0OTlDMTU1Ljc1MiA1OS4xNDk5IDE1NS41MDggNTkuMjMwNSAxNTUuMzE4IDU5LjM5MTZDMTU1LjEzMiA1OS41NDkyIDE1NS4wMDEgNTkuNzU2OCAxNTQuOTI2IDYwLjAxNDZMMTU0LjkzMiA2NEgxNTMuNjI2VjYwLjE3NThDMTUzLjYwOSA1OS40OTE5IDE1My4yNTkgNTkuMTQ5OSAxNTIuNTc5IDU5LjE0OTlDMTUyLjA1NiA1OS4xNDk5IDE1MS42ODYgNTkuMzYzIDE1MS40NjcgNTkuNzg5MVY2NEgxNTAuMTYyVjU4LjE4ODVIMTUxLjM5MloiIGZpbGw9IiNGRkE1MDAiLz4KPHBhdGggZD0iTTE2Mi4yOTUgNjQuMTA3NEMxNjEuNDY4IDY0LjEwNzQgMTYwLjc5NyA2My44NDc4IDE2MC4yODEgNjMuMzI4NkMxNTkuNzY5IDYyLjgwNTggMTU5LjUxMyA2Mi4xMTEyIDE1OS41MTMgNjEuMjQ0NlY2MS4wODM1QzE1OS41MTMgNjAuNTAzNCAxNTkuNjI0IDU5Ljk4NiAxNTkuODQ2IDU5LjUzMTJDMTYwLjA3MiA1OS4wNzI5IDE2MC4zODcgNTguNzE2NiAxNjAuNzkyIDU4LjQ2MjRDMTYxLjE5NiA1OC4yMDgyIDE2MS42NDcgNTguMDgxMSAxNjIuMTQ1IDU4LjA4MTFDMTYyLjkzNiA1OC4wODExIDE2My41NDcgNTguMzMzNSAxNjMuOTc3IDU4LjgzODRDMTY0LjQxIDU5LjM0MzMgMTY0LjYyNiA2MC4wNTc2IDE2NC42MjYgNjAuOTgxNFY2MS41MDc4SDE2MC44MjlDMTYwLjg2OCA2MS45ODc2IDE2MS4wMjggNjIuMzY3MiAxNjEuMzA3IDYyLjY0NjVDMTYxLjU5IDYyLjkyNTggMTYxLjk0NCA2My4wNjU0IDE2Mi4zNzEgNjMuMDY1NEMxNjIuOTY5IDYzLjA2NTQgMTYzLjQ1NiA2Mi44MjM3IDE2My44MzIgNjIuMzQwM0wxNjQuNTM1IDYzLjAxMTdDMTY0LjMwMiA2My4zNTkgMTYzLjk5MSA2My42Mjk0IDE2My42MDEgNjMuODIyOEMxNjMuMjE0IDY0LjAxMjUgMTYyLjc3OSA2NC4xMDc0IDE2Mi4yOTUgNjQuMTA3NFpNMTYyLjE0IDU5LjEyODRDMTYxLjc4MiA1OS4xMjg0IDE2MS40OTIgNTkuMjUzNyAxNjEuMjcgNTkuNTA0NEMxNjEuMDUxIDU5Ljc1NSAxNjAuOTExIDYwLjEwNDIgMTYwLjg1MSA2MC41NTE4SDE2My4zMzdWNjAuNDU1MUMxNjMuMzA5IDYwLjAxODIgMTYzLjE5MiA1OS42ODg4IDE2Mi45ODggNTkuNDY2OEMxNjIuNzg0IDU5LjI0MTIgMTYyLjUwMSA1OS4xMjg0IDE2Mi4xNCA1OS4xMjg0WiIgZmlsbD0iI0ZGQTUwMCIvPgo8cGF0aCBkPSJNMTY1LjQgNjEuMDQwNUMxNjUuNCA2MC40NzEyIDE2NS41MTMgNTkuOTU5MSAxNjUuNzM4IDU5LjUwNDRDMTY1Ljk2NCA1OS4wNDYxIDE2Ni4yODEgNTguNjk1MSAxNjYuNjg5IDU4LjQ1MTdDMTY3LjA5NyA1OC4yMDQ2IDE2Ny41NjYgNTguMDgxMSAxNjguMDk2IDU4LjA4MTFDMTY4Ljg4IDU4LjA4MTEgMTY5LjUxNiA1OC4zMzM1IDE3MC4wMDMgNTguODM4NEMxNzAuNDkzIDU5LjM0MzMgMTcwLjc1OCA2MC4wMTI5IDE3MC43OTggNjAuODQ3MkwxNzAuODAzIDYxLjE1MzNDMTcwLjgwMyA2MS43MjYyIDE3MC42OTIgNjIuMjM4MyAxNzAuNDcgNjIuNjg5NUMxNzAuMjUyIDYzLjE0MDYgMTY5LjkzNyA2My40ODk3IDE2OS41MjUgNjMuNzM2OEMxNjkuMTE3IDYzLjk4MzkgMTY4LjY0NCA2NC4xMDc0IDE2OC4xMDcgNjQuMTA3NEMxNjcuMjg3IDY0LjEwNzQgMTY2LjYzIDYzLjgzNTMgMTY2LjEzNiA2My4yOTFDMTY1LjY0NSA2Mi43NDMyIDE2NS40IDYyLjAxNDUgMTY1LjQgNjEuMTA1VjYxLjA0MDVaTTE2Ni43MDUgNjEuMTUzM0MxNjYuNzA1IDYxLjc1MTMgMTY2LjgyOSA2Mi4yMjA0IDE2Ny4wNzYgNjIuNTYwNUMxNjcuMzIzIDYyLjg5NzEgMTY3LjY2NyA2My4wNjU0IDE2OC4xMDcgNjMuMDY1NEMxNjguNTQ3IDYzLjA2NTQgMTY4Ljg4OSA2Mi44OTM2IDE2OS4xMzMgNjIuNTQ5OEMxNjkuMzggNjIuMjA2MSAxNjkuNTAzIDYxLjcwMyAxNjkuNTAzIDYxLjA0MDVDMTY5LjUwMyA2MC40NTMzIDE2OS4zNzYgNTkuOTg3OCAxNjkuMTIyIDU5LjY0NEMxNjguODcxIDU5LjMwMDMgMTY4LjUyOSA1OS4xMjg0IDE2OC4wOTYgNTkuMTI4NEMxNjcuNjcgNTkuMTI4NCAxNjcuMzMyIDU5LjI5ODUgMTY3LjA4MSA1OS42Mzg3QzE2Ni44MyA1OS45NzUzIDE2Ni43MDUgNjAuNDgwMSAxNjYuNzA1IDYxLjE1MzNaIiBmaWxsPSIjRkZBNTAwIi8+CjxwYXRoIGQ9Ik0xNzUuNDI4IDYzLjQzMDdDMTc1LjA0NSA2My44ODE4IDE3NC41IDY0LjEwNzQgMTczLjc5NSA2NC4xMDc0QzE3My4xNjUgNjQuMTA3NCAxNzIuNjg3IDYzLjkyMyAxNzIuMzYxIDYzLjU1NDJDMTcyLjAzOSA2My4xODU0IDE3MS44NzcgNjIuNjUxOSAxNzEuODc3IDYxLjk1MzZWNTguMTg4NUgxNzMuMTgzVjYxLjkzNzVDMTczLjE4MyA2Mi42NzUxIDE3My40ODkgNjMuMDQzOSAxNzQuMTAxIDYzLjA0MzlDMTc0LjczNSA2My4wNDM5IDE3NS4xNjMgNjIuODE2NiAxNzUuMzg1IDYyLjM2MThWNTguMTg4NUgxNzYuNjlWNjRIMTc1LjQ2TDE3NS40MjggNjMuNDMwN1oiIGZpbGw9IiNGRkE1MDAiLz4KPHBhdGggZD0iTTE3OS42NTUgNTYuNzc1OVY1OC4xODg1SDE4MC42ODFWNTkuMTU1M0gxNzkuNjU1VjYyLjM5OTRDMTc5LjY1NSA2Mi42MjE0IDE3OS42OTggNjIuNzgyNiAxNzkuNzg0IDYyLjg4MjhDMTc5Ljg3MyA2Mi45Nzk1IDE4MC4wMzEgNjMuMDI3OCAxODAuMjU2IDYzLjAyNzhDMTgwLjQwNyA2My4wMjc4IDE4MC41NTkgNjMuMDA5OSAxODAuNzEzIDYyLjk3NDFWNjMuOTgzOUMxODAuNDE2IDY0LjA2NjIgMTgwLjEyOSA2NC4xMDc0IDE3OS44NTQgNjQuMTA3NEMxNzguODUxIDY0LjEwNzQgMTc4LjM1IDYzLjU1NDIgMTc4LjM1IDYyLjQ0NzhWNTkuMTU1M0gxNzcuMzk0VjU4LjE4ODVIMTc4LjM1VjU2Ljc3NTlIMTc5LjY1NVoiIGZpbGw9IiNGRkE1MDAiLz4KPHBhdGggZD0iTTE0NS4zMDEgMTAwLjY4NkgxNDIuMTU0VjEwNEgxNDAuNzk1Vjk2LjE3OTdIMTQ1Ljc2M1Y5Ny4yNzU0SDE0Mi4xNTRWOTkuNjAxMUgxNDUuMzAxVjEwMC42ODZaIiBmaWxsPSIjRkYzRTNFIi8+CjxwYXRoIGQ9Ik0xNTAuMDA2IDEwNEMxNDkuOTQ5IDEwMy44ODkgMTQ5Ljg5OSAxMDMuNzA4IDE0OS44NTYgMTAzLjQ1OEMxNDkuNDQxIDEwMy44OTEgMTQ4LjkzMiAxMDQuMTA3IDE0OC4zMzEgMTA0LjEwN0MxNDcuNzQ3IDEwNC4xMDcgMTQ3LjI3MSAxMDMuOTQxIDE0Ni45MDIgMTAzLjYwOEMxNDYuNTMzIDEwMy4yNzUgMTQ2LjM0OSAxMDIuODYzIDE0Ni4zNDkgMTAyLjM3M0MxNDYuMzQ5IDEwMS43NTMgMTQ2LjU3OCAxMDEuMjc5IDE0Ny4wMzYgMTAwLjk0OUMxNDcuNDk4IDEwMC42MTYgMTQ4LjE1NyAxMDAuNDUgMTQ5LjAxMyAxMDAuNDVIMTQ5LjgxM1YxMDAuMDY4QzE0OS44MTMgOTkuNzY3NiAxNDkuNzI5IDk5LjUyNzcgMTQ5LjU2MSA5OS4zNDg2QzE0OS4zOTIgOTkuMTY2IDE0OS4xMzYgOTkuMDc0NyAxNDguNzkyIDk5LjA3NDdDMTQ4LjQ5NSA5OS4wNzQ3IDE0OC4yNTIgOTkuMTQ5OSAxNDguMDYyIDk5LjMwMDNDMTQ3Ljg3MiA5OS40NDcxIDE0Ny43NzcgOTkuNjM1MSAxNDcuNzc3IDk5Ljg2NDNIMTQ2LjQ3MkMxNDYuNDcyIDk5LjU0NTYgMTQ2LjU3OCA5OS4yNDg0IDE0Ni43ODkgOTguOTcyN0MxNDcgOTguNjkzNCAxNDcuMjg3IDk4LjQ3NDkgMTQ3LjY0OCA5OC4zMTc0QzE0OC4wMTQgOTguMTU5OCAxNDguNDIgOTguMDgxMSAxNDguODY4IDk4LjA4MTFDMTQ5LjU0OCA5OC4wODExIDE1MC4wOSA5OC4yNTI5IDE1MC40OTUgOTguNTk2N0MxNTAuOSA5OC45MzY4IDE1MS4xMDcgOTkuNDE2NyAxNTEuMTE4IDEwMC4wMzZWMTAyLjY1N0MxNTEuMTE4IDEwMy4xOCAxNTEuMTkyIDEwMy41OTcgMTUxLjMzOCAxMDMuOTA5VjEwNEgxNTAuMDA2Wk0xNDguNTcyIDEwMy4wNkMxNDguODMgMTAzLjA2IDE0OS4wNzIgMTAyLjk5NyAxNDkuMjk3IDEwMi44NzJDMTQ5LjUyNyAxMDIuNzQ3IDE0OS42OTggMTAyLjU3OCAxNDkuODEzIDEwMi4zNjdWMTAxLjI3MUgxNDkuMTA5QzE0OC42MjYgMTAxLjI3MSAxNDguMjYzIDEwMS4zNTYgMTQ4LjAxOSAxMDEuNTI0QzE0Ny43NzYgMTAxLjY5MiAxNDcuNjU0IDEwMS45MyAxNDcuNjU0IDEwMi4yMzhDMTQ3LjY1NCAxMDIuNDg5IDE0Ny43MzYgMTAyLjY4OSAxNDcuOTAxIDEwMi44NEMxNDguMDY5IDEwMi45ODcgMTQ4LjI5MyAxMDMuMDYgMTQ4LjU3MiAxMDMuMDZaIiBmaWxsPSIjRkYzRTNFIi8+CjxwYXRoIGQ9Ik0xNTMuODc0IDEwNEgxNTIuNTY4Vjk4LjE4ODVIMTUzLjg3NFYxMDRaTTE1Mi40ODggOTYuNjc5MkMxNTIuNDg4IDk2LjQ3ODcgMTUyLjU1IDk2LjMxMjIgMTUyLjY3NiA5Ni4xNzk3QzE1Mi44MDUgOTYuMDQ3MiAxNTIuOTg3IDk1Ljk4MSAxNTMuMjI0IDk1Ljk4MUMxNTMuNDYgOTUuOTgxIDE1My42NDMgOTYuMDQ3MiAxNTMuNzcxIDk2LjE3OTdDMTUzLjkgOTYuMzEyMiAxNTMuOTY1IDk2LjQ3ODcgMTUzLjk2NSA5Ni42NzkyQzE1My45NjUgOTYuODc2MSAxNTMuOSA5Ny4wNDA5IDE1My43NzEgOTcuMTczM0MxNTMuNjQzIDk3LjMwMjIgMTUzLjQ2IDk3LjM2NjcgMTUzLjIyNCA5Ny4zNjY3QzE1Mi45ODcgOTcuMzY2NyAxNTIuODA1IDk3LjMwMjIgMTUyLjY3NiA5Ny4xNzMzQzE1Mi41NSA5Ny4wNDA5IDE1Mi40ODggOTYuODc2MSAxNTIuNDg4IDk2LjY3OTJaIiBmaWxsPSIjRkYzRTNFIi8+CjxwYXRoIGQ9Ik0xNTYuNjg4IDEwNEgxNTUuMzgzVjk1Ljc1SDE1Ni42ODhWMTA0WiIgZmlsbD0iI0ZGM0UzRSIvPgo8cGF0aCBkPSJNMTYwLjY3MyAxMDQuMTA3QzE1OS44NDYgMTA0LjEwNyAxNTkuMTc1IDEwMy44NDggMTU4LjY1OSAxMDMuMzI5QzE1OC4xNDcgMTAyLjgwNiAxNTcuODkxIDEwMi4xMTEgMTU3Ljg5MSAxMDEuMjQ1VjEwMS4wODNDMTU3Ljg5MSAxMDAuNTAzIDE1OC4wMDIgOTkuOTg2IDE1OC4yMjQgOTkuNTMxMkMxNTguNDUgOTkuMDcyOSAxNTguNzY1IDk4LjcxNjYgMTU5LjE2OSA5OC40NjI0QzE1OS41NzQgOTguMjA4MiAxNjAuMDI1IDk4LjA4MTEgMTYwLjUyMyA5OC4wODExQzE2MS4zMTQgOTguMDgxMSAxNjEuOTI1IDk4LjMzMzUgMTYyLjM1NCA5OC44Mzg0QzE2Mi43ODggOTkuMzQzMyAxNjMuMDA0IDEwMC4wNTggMTYzLjAwNCAxMDAuOTgxVjEwMS41MDhIMTU5LjIwN0MxNTkuMjQ2IDEwMS45ODggMTU5LjQwNiAxMDIuMzY3IDE1OS42ODUgMTAyLjY0NkMxNTkuOTY4IDEwMi45MjYgMTYwLjMyMiAxMDMuMDY1IDE2MC43NDkgMTAzLjA2NUMxNjEuMzQ3IDEwMy4wNjUgMTYxLjgzMyAxMDIuODI0IDE2Mi4yMDkgMTAyLjM0TDE2Mi45MTMgMTAzLjAxMkMxNjIuNjggMTAzLjM1OSAxNjIuMzY5IDEwMy42MjkgMTYxLjk3OSAxMDMuODIzQzE2MS41OTIgMTA0LjAxMyAxNjEuMTU3IDEwNC4xMDcgMTYwLjY3MyAxMDQuMTA3Wk0xNjAuNTE4IDk5LjEyODRDMTYwLjE2IDk5LjEyODQgMTU5Ljg2OSA5OS4yNTM3IDE1OS42NDcgOTkuNTA0NEMxNTkuNDI5IDk5Ljc1NSAxNTkuMjg5IDEwMC4xMDQgMTU5LjIyOSAxMDAuNTUySDE2MS43MTVWMTAwLjQ1NUMxNjEuNjg3IDEwMC4wMTggMTYxLjU3IDk5LjY4ODggMTYxLjM2NiA5OS40NjY4QzE2MS4xNjIgOTkuMjQxMiAxNjAuODc5IDk5LjEyODQgMTYwLjUxOCA5OS4xMjg0WiIgZmlsbD0iI0ZGM0UzRSIvPgo8cGF0aCBkPSJNMTYzLjc3OCAxMDEuMDUxQzE2My43NzggMTAwLjE1NiAxNjMuOTg2IDk5LjQzODIgMTY0LjQwMSA5OC44OTc1QzE2NC44MTYgOTguMzUzMiAxNjUuMzczIDk4LjA4MTEgMTY2LjA3MSA5OC4wODExQzE2Ni42ODcgOTguMDgxMSAxNjcuMTg1IDk4LjI5NTkgMTY3LjU2NCA5OC43MjU2Vjk1Ljc1SDE2OC44N1YxMDRIMTY3LjY4OEwxNjcuNjI0IDEwMy4zOThDMTY3LjIzMyAxMDMuODcxIDE2Ni43MTIgMTA0LjEwNyAxNjYuMDYxIDEwNC4xMDdDMTY1LjM4IDEwNC4xMDcgMTY0LjgyOSAxMDMuODMzIDE2NC40MDYgMTAzLjI4NkMxNjMuOTg3IDEwMi43MzggMTYzLjc3OCAxMDEuOTkzIDE2My43NzggMTAxLjA1MVpNMTY1LjA4MyAxMDEuMTY0QzE2NS4wODMgMTAxLjc1NSAxNjUuMTk2IDEwMi4yMTcgMTY1LjQyMSAxMDIuNTVDMTY1LjY1MSAxMDIuODc5IDE2NS45NzUgMTAzLjA0NCAxNjYuMzk0IDEwMy4wNDRDMTY2LjkyNyAxMDMuMDQ0IDE2Ny4zMTcgMTAyLjgwNiAxNjcuNTY0IDEwMi4zM1Y5OS44NDgxQzE2Ny4zMjUgOTkuMzgyNiAxNjYuOTM4IDk5LjE0OTkgMTY2LjQwNCA5OS4xNDk5QzE2NS45ODIgOTkuMTQ5OSAxNjUuNjU2IDk5LjMxODIgMTY1LjQyNyA5OS42NTQ4QzE2NS4xOTggOTkuOTg3OCAxNjUuMDgzIDEwMC40OTEgMTY1LjA4MyAxMDEuMTY0WiIgZmlsbD0iI0ZGM0UzRSIvPgo8cGF0aCBkPSJNMTQwLjc5NSAxNDRWMTM2LjE4SDE0My4xMDRDMTQzLjc5NiAxMzYuMTggMTQ0LjQwOCAxMzYuMzM0IDE0NC45NDEgMTM2LjY0MkMxNDUuNDc5IDEzNi45NSAxNDUuODk0IDEzNy4zODYgMTQ2LjE4OCAxMzcuOTUyQzE0Ni40ODEgMTM4LjUxOCAxNDYuNjI4IDEzOS4xNjYgMTQ2LjYyOCAxMzkuODk2VjE0MC4yODlDMTQ2LjYyOCAxNDEuMDMgMTQ2LjQ3OSAxNDEuNjgxIDE0Ni4xODIgMTQyLjI0NEMxNDUuODg5IDE0Mi44MDYgMTQ1LjQ2OCAxNDMuMjM5IDE0NC45MiAxNDMuNTQzQzE0NC4zNzYgMTQzLjg0OCAxNDMuNzUxIDE0NCAxNDMuMDQ1IDE0NEgxNDAuNzk1Wk0xNDIuMTU0IDEzNy4yNzVWMTQyLjkxNUgxNDMuMDRDMTQzLjc1MyAxNDIuOTE1IDE0NC4yOTkgMTQyLjY5MyAxNDQuNjc4IDE0Mi4yNDlDMTQ1LjA2MSAxNDEuODAxIDE0NS4yNTcgMTQxLjE2IDE0NS4yNjQgMTQwLjMyNlYxMzkuODkxQzE0NS4yNjQgMTM5LjA0MiAxNDUuMDc5IDEzOC4zOTQgMTQ0LjcxIDEzNy45NDdDMTQ0LjM0MiAxMzcuNDk5IDE0My44MDYgMTM3LjI3NSAxNDMuMTA0IDEzNy4yNzVIMTQyLjE1NFoiIGZpbGw9IiM0Q0FGNTAiLz4KPHBhdGggZD0iTTE1MC40MTUgMTQ0LjEwN0MxNDkuNTg3IDE0NC4xMDcgMTQ4LjkxNiAxNDMuODQ4IDE0OC40IDE0My4zMjlDMTQ3Ljg4OCAxNDIuODA2IDE0Ny42MzIgMTQyLjExMSAxNDcuNjMyIDE0MS4yNDVWMTQxLjA4M0MxNDcuNjMyIDE0MC41MDMgMTQ3Ljc0MyAxMzkuOTg2IDE0Ny45NjUgMTM5LjUzMUMxNDguMTkxIDEzOS4wNzMgMTQ4LjUwNiAxMzguNzE3IDE0OC45MTEgMTM4LjQ2MkMxNDkuMzE1IDEzOC4yMDggMTQ5Ljc2NiAxMzguMDgxIDE1MC4yNjQgMTM4LjA4MUMxNTEuMDU2IDEzOC4wODEgMTUxLjY2NiAxMzguMzMzIDE1Mi4wOTYgMTM4LjgzOEMxNTIuNTI5IDEzOS4zNDMgMTUyLjc0NiAxNDAuMDU4IDE1Mi43NDYgMTQwLjk4MVYxNDEuNTA4SDE0OC45NDhDMTQ4Ljk4OCAxNDEuOTg4IDE0OS4xNDcgMTQyLjM2NyAxNDkuNDI2IDE0Mi42NDZDMTQ5LjcwOSAxNDIuOTI2IDE1MC4wNjQgMTQzLjA2NSAxNTAuNDkgMTQzLjA2NUMxNTEuMDg4IDE0My4wNjUgMTUxLjU3NSAxNDIuODI0IDE1MS45NTEgMTQyLjM0TDE1Mi42NTQgMTQzLjAxMkMxNTIuNDIyIDE0My4zNTkgMTUyLjExIDE0My42MjkgMTUxLjcyIDE0My44MjNDMTUxLjMzMyAxNDQuMDEzIDE1MC44OTggMTQ0LjEwNyAxNTAuNDE1IDE0NC4xMDdaTTE1MC4yNTkgMTM5LjEyOEMxNDkuOTAxIDEzOS4xMjggMTQ5LjYxMSAxMzkuMjU0IDE0OS4zODkgMTM5LjUwNEMxNDkuMTcgMTM5Ljc1NSAxNDkuMDMxIDE0MC4xMDQgMTQ4Ljk3IDE0MC41NTJIMTUxLjQ1N1YxNDAuNDU1QzE1MS40MjggMTQwLjAxOCAxNTEuMzEyIDEzOS42ODkgMTUxLjEwNyAxMzkuNDY3QzE1MC45MDMgMTM5LjI0MSAxNTAuNjIgMTM5LjEyOCAxNTAuMjU5IDEzOS4xMjhaIiBmaWxsPSIjNENBRjUwIi8+CjxwYXRoIGQ9Ik0xNTUuMTUyIDE0NEgxNTMuODQ3VjEzNS43NUgxNTUuMTUyVjE0NFoiIGZpbGw9IiM0Q0FGNTAiLz4KPHBhdGggZD0iTTE1Ny45NjYgMTQ0SDE1Ni42NjFWMTM4LjE4OEgxNTcuOTY2VjE0NFpNMTU2LjU4MSAxMzYuNjc5QzE1Ni41ODEgMTM2LjQ3OSAxNTYuNjQzIDEzNi4zMTIgMTU2Ljc2OSAxMzYuMThDMTU2Ljg5NyAxMzYuMDQ3IDE1Ny4wOCAxMzUuOTgxIDE1Ny4zMTYgMTM1Ljk4MUMxNTcuNTUzIDEzNS45ODEgMTU3LjczNSAxMzYuMDQ3IDE1Ny44NjQgMTM2LjE4QzE1Ny45OTMgMTM2LjMxMiAxNTguMDU4IDEzNi40NzkgMTU4LjA1OCAxMzYuNjc5QzE1OC4wNTggMTM2Ljg3NiAxNTcuOTkzIDEzNy4wNDEgMTU3Ljg2NCAxMzcuMTczQzE1Ny43MzUgMTM3LjMwMiAxNTcuNTUzIDEzNy4zNjcgMTU3LjMxNiAxMzcuMzY3QzE1Ny4wOCAxMzcuMzY3IDE1Ni44OTcgMTM3LjMwMiAxNTYuNzY5IDEzNy4xNzNDMTU2LjY0MyAxMzcuMDQxIDE1Ni41ODEgMTM2Ljg3NiAxNTYuNTgxIDEzNi42NzlaIiBmaWxsPSIjNENBRjUwIi8+CjxwYXRoIGQ9Ik0xNjEuNDQxIDE0Mi4zNDZMMTYyLjY3MSAxMzguMTg4SDE2NC4wMkwxNjIuMDA1IDE0NEgxNjAuODcyTDE1OC44NDIgMTM4LjE4OEgxNjAuMTk1TDE2MS40NDEgMTQyLjM0NloiIGZpbGw9IiM0Q0FGNTAiLz4KPHBhdGggZD0iTTE2Ny4zMjMgMTQ0LjEwN0MxNjYuNDk2IDE0NC4xMDcgMTY1LjgyNCAxNDMuODQ4IDE2NS4zMDkgMTQzLjMyOUMxNjQuNzk3IDE0Mi44MDYgMTY0LjU0MSAxNDIuMTExIDE2NC41NDEgMTQxLjI0NVYxNDEuMDgzQzE2NC41NDEgMTQwLjUwMyAxNjQuNjUyIDEzOS45ODYgMTY0Ljg3NCAxMzkuNTMxQzE2NS4wOTkgMTM5LjA3MyAxNjUuNDE0IDEzOC43MTcgMTY1LjgxOSAxMzguNDYyQzE2Ni4yMjMgMTM4LjIwOCAxNjYuNjc1IDEzOC4wODEgMTY3LjE3MiAxMzguMDgxQzE2Ny45NjQgMTM4LjA4MSAxNjguNTc0IDEzOC4zMzMgMTY5LjAwNCAxMzguODM4QzE2OS40MzcgMTM5LjM0MyAxNjkuNjU0IDE0MC4wNTggMTY5LjY1NCAxNDAuOTgxVjE0MS41MDhIMTY1Ljg1NkMxNjUuODk2IDE0MS45ODggMTY2LjA1NSAxNDIuMzY3IDE2Ni4zMzQgMTQyLjY0NkMxNjYuNjE3IDE0Mi45MjYgMTY2Ljk3MiAxNDMuMDY1IDE2Ny4zOTggMTQzLjA2NUMxNjcuOTk2IDE0My4wNjUgMTY4LjQ4MyAxNDIuODI0IDE2OC44NTkgMTQyLjM0TDE2OS41NjIgMTQzLjAxMkMxNjkuMzMgMTQzLjM1OSAxNjkuMDE4IDE0My42MjkgMTY4LjYyOCAxNDMuODIzQzE2OC4yNDEgMTQ0LjAxMyAxNjcuODA2IDE0NC4xMDcgMTY3LjMyMyAxNDQuMTA3Wk0xNjcuMTY3IDEzOS4xMjhDMTY2LjgwOSAxMzkuMTI4IDE2Ni41MTkgMTM5LjI1NCAxNjYuMjk3IDEzOS41MDRDMTY2LjA3OCAxMzkuNzU1IDE2NS45MzkgMTQwLjEwNCAxNjUuODc4IDE0MC41NTJIMTY4LjM2NVYxNDAuNDU1QzE2OC4zMzYgMTQwLjAxOCAxNjguMjIgMTM5LjY4OSAxNjguMDE2IDEzOS40NjdDMTY3LjgxMiAxMzkuMjQxIDE2Ny41MjkgMTM5LjEyOCAxNjcuMTY3IDEzOS4xMjhaIiBmaWxsPSIjNENBRjUwIi8+CjxwYXRoIGQ9Ik0xNzMuNzE0IDEzOS4zODFDMTczLjU0MiAxMzkuMzUyIDE3My4zNjUgMTM5LjMzOCAxNzMuMTgzIDEzOS4zMzhDMTcyLjU4NSAxMzkuMzM4IDE3Mi4xODIgMTM5LjU2NyAxNzEuOTc0IDE0MC4wMjVWMTQ0SDE3MC42NjlWMTM4LjE4OEgxNzEuOTE1TDE3MS45NDcgMTM4LjgzOEMxNzIuMjYyIDEzOC4zMzMgMTcyLjY5OSAxMzguMDgxIDE3My4yNTggMTM4LjA4MUMxNzMuNDQ0IDEzOC4wODEgMTczLjU5OCAxMzguMTA2IDE3My43MiAxMzguMTU2TDE3My43MTQgMTM5LjM4MVoiIGZpbGw9IiM0Q0FGNTAiLz4KPHBhdGggZD0iTTE3Ni45OTEgMTQ0LjEwN0MxNzYuMTY0IDE0NC4xMDcgMTc1LjQ5MiAxNDMuODQ4IDE3NC45NzcgMTQzLjMyOUMxNzQuNDY1IDE0Mi44MDYgMTc0LjIwOCAxNDIuMTExIDE3NC4yMDggMTQxLjI0NVYxNDEuMDgzQzE3NC4yMDggMTQwLjUwMyAxNzQuMzE5IDEzOS45ODYgMTc0LjU0MiAxMzkuNTMxQzE3NC43NjcgMTM5LjA3MyAxNzUuMDgyIDEzOC43MTcgMTc1LjQ4NyAxMzguNDYyQzE3NS44OTEgMTM4LjIwOCAxNzYuMzQzIDEzOC4wODEgMTc2Ljg0IDEzOC4wODFDMTc3LjYzMiAxMzguMDgxIDE3OC4yNDIgMTM4LjMzMyAxNzguNjcyIDEzOC44MzhDMTc5LjEwNSAxMzkuMzQzIDE3OS4zMjIgMTQwLjA1OCAxNzkuMzIyIDE0MC45ODFWMTQxLjUwOEgxNzUuNTI0QzE3NS41NjQgMTQxLjk4OCAxNzUuNzIzIDE0Mi4zNjcgMTc2LjAwMiAxNDIuNjQ2QzE3Ni4yODUgMTQyLjkyNiAxNzYuNjQgMTQzLjA2NSAxNzcuMDY2IDE0My4wNjVDMTc3LjY2NCAxNDMuMDY1IDE3OC4xNTEgMTQyLjgyNCAxNzguNTI3IDE0Mi4zNEwxNzkuMjMgMTQzLjAxMkMxNzguOTk4IDE0My4zNTkgMTc4LjY4NiAxNDMuNjI5IDE3OC4yOTYgMTQzLjgyM0MxNzcuOTA5IDE0NC4wMTMgMTc3LjQ3NCAxNDQuMTA3IDE3Ni45OTEgMTQ0LjEwN1pNMTc2LjgzNSAxMzkuMTI4QzE3Ni40NzcgMTM5LjEyOCAxNzYuMTg3IDEzOS4yNTQgMTc1Ljk2NSAxMzkuNTA0QzE3NS43NDYgMTM5Ljc1NSAxNzUuNjA3IDE0MC4xMDQgMTc1LjU0NiAxNDAuNTUySDE3OC4wMzNWMTQwLjQ1NUMxNzguMDA0IDE0MC4wMTggMTc3Ljg4OCAxMzkuNjg5IDE3Ny42ODQgMTM5LjQ2N0MxNzcuNDc5IDEzOS4yNDEgMTc3LjE5NyAxMzkuMTI4IDE3Ni44MzUgMTM5LjEyOFoiIGZpbGw9IiM0Q0FGNTAiLz4KPHBhdGggZD0iTTE4MC4wOTUgMTQxLjA1MUMxODAuMDk1IDE0MC4xNTYgMTgwLjMwMyAxMzkuNDM4IDE4MC43MTggMTM4Ljg5N0MxODEuMTM0IDEzOC4zNTMgMTgxLjY5IDEzOC4wODEgMTgyLjM4OSAxMzguMDgxQzE4My4wMDUgMTM4LjA4MSAxODMuNTAyIDEzOC4yOTYgMTgzLjg4MiAxMzguNzI2VjEzNS43NUgxODUuMTg3VjE0NEgxODQuMDA1TDE4My45NDEgMTQzLjM5OEMxODMuNTUxIDE0My44NzEgMTgzLjAzIDE0NC4xMDcgMTgyLjM3OCAxNDQuMTA3QzE4MS42OTggMTQ0LjEwNyAxODEuMTQ2IDE0My44MzMgMTgwLjcyNCAxNDMuMjg2QzE4MC4zMDUgMTQyLjczOCAxODAuMDk1IDE0MS45OTMgMTgwLjA5NSAxNDEuMDUxWk0xODEuNCAxNDEuMTY0QzE4MS40IDE0MS43NTUgMTgxLjUxMyAxNDIuMjE3IDE4MS43MzkgMTQyLjU1QzE4MS45NjggMTQyLjg3OSAxODIuMjkyIDE0My4wNDQgMTgyLjcxMSAxNDMuMDQ0QzE4My4yNDQgMTQzLjA0NCAxODMuNjM1IDE0Mi44MDYgMTgzLjg4MiAxNDIuMzNWMTM5Ljg0OEMxODMuNjQyIDEzOS4zODMgMTgzLjI1NSAxMzkuMTUgMTgyLjcyMiAxMzkuMTVDMTgyLjI5OSAxMzkuMTUgMTgxLjk3MyAxMzkuMzE4IDE4MS43NDQgMTM5LjY1NUMxODEuNTE1IDEzOS45ODggMTgxLjQgMTQwLjQ5MSAxODEuNCAxNDEuMTY0WiIgZmlsbD0iIzRDQUY1MCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE5NiAxSDRDMi4zNDMxNCAxIDEgMi4zNDMxNCAxIDRWMTU2QzEgMTU3LjY1NyAyLjM0MzE0IDE1OSA0IDE1OUgxOTZDMTk3LjY1NyAxNTkgMTk5IDE1Ny42NTcgMTk5IDE1NlY0QzE5OSAyLjM0MzE1IDE5Ny42NTcgMSAxOTYgMVpNNCAwSDE5NkMxOTguMjA5IDAgMjAwIDEuNzkwODYgMjAwIDRWMTU2QzIwMCAxNTguMjA5IDE5OC4yMDkgMTYwIDE5NiAxNjBINEMxLjc5MDg2IDE2MCAwIDE1OC4yMDkgMCAxNTZWNEMwIDEuNzkwODYgMS43OTA4NiAwIDQgMFoiIGZpbGw9IiNFMEUwRTAiLz4KPC9zdmc+Cg==", + "description": "Displays Persistent RPC requests that match selected alias and filter with the ability of pagination and sending persistent RPC requests.", + "descriptor": { + "type": "rpc", + "sizeX": 7.5, + "sizeY": 4, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-persistent-table-widget-settings", + "defaultConfig": "{\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableStickyAction\":true,\"enableFilter\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"enableStickyHeader\":true,\"displayColumns\":[\"rpcId\",\"messageType\",\"status\",\"method\",\"createdTime\",\"expirationTime\"],\"displayDetails\":true,\"defaultSortOrder\":\"-createdTime\",\"allowSendRequest\":true,\"allowDelete\":true},\"title\":\"Persistent RPC table\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px\"},\"targetDeviceAliasIds\":[]}" + } + }, + { + "alias": "slide_toggle_control", + "name": "Slide Toggle Control", + "image": "data:image/png;base64,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", + "description": "Allows to send the RPC call to device when user toggle the slider. Advanced widget settings allow you to configure how to fetch the initial value of the slider.", + "descriptor": { + "type": "rpc", + "sizeX": 3, + "sizeY": 1, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-slide-toggle-widget-settings", + "defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"title\":\"Slide toggle control\",\"retrieveValueMethod\":\"rpc\",\"valueKey\":\"value\",\"parseValueFunction\":\"return data ? true : false;\",\"convertValueFunction\":\"return value;\",\"requestPersistent\":false,\"labelPosition\":\"after\",\"sliderColor\":\"accent\"},\"title\":\"Slide Toggle Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2,\"widgetCss\":\"\",\"noDataDisplayMessage\":\"\"}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/date.json b/application/src/main/data/json/system/widget_bundles/date.json new file mode 100644 index 0000000..dc86823 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/date.json @@ -0,0 +1,29 @@ +{ + "widgetsBundle": { + "alias": "date", + "title": "Date", + "image": "data:image/png;base64,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", + "description": "Contains widgets to change the data range for other widgets on the dashboard." + }, + "widgetTypes": [ + { + "alias": "date_range_navigator", + "name": "Date-range-navigator", + "image": "data:image/png;base64,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", + "description": "Allows to change the data range for other widgets on the dashboard.", + "descriptor": { + "type": "static", + "sizeX": 5, + "sizeY": 5.5, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-date-range-navigator-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"defaultInterval\":\"week\",\"stepSize\":\"day\"},\"title\":\"Date-range-navigator\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/digital_gauges.json b/application/src/main/data/json/system/widget_bundles/digital_gauges.json new file mode 100644 index 0000000..7974ae2 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/digital_gauges.json @@ -0,0 +1,257 @@ +{ + "widgetsBundle": { + "alias": "digital_gauges", + "title": "Digital gauges", + "image": "data:image/png;base64,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", + "description": "Display temperature, humidity, speed, and other latest values on various digital gauge widgets." + }, + "widgetTypes": [ + { + "alias": "gauge_justgage", + "name": "Gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 4, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "digital_thermometer", + "name": "Digital thermometer", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display temperature. Allows to configure temperature range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 3, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[\"#304ffe\",\"#7e57c2\",\"#ff4081\",\"#d32f2f\"],\"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,\"minValue\":-60,\"gaugeColor\":\"#333333\",\"neonGlowBrightness\":35,\"gaugeType\":\"donut\",\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Digital thermometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "digital_speedometer", + "name": "Digital speedometer", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display speed. Allows to configure speed range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 5, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"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,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"arc\",\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Digital speedometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "neon_gauge_justgage", + "name": "Neon gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as an arc. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 5, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"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,\"gaugeType\":\"arc\",\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Neon gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "lcd_gauge", + "name": "LCD gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as an arc. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 5, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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}}" + } + }, + { + "alias": "lcd_bar_gauge", + "name": "LCD bar gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a bar. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 2, + "sizeY": 3.5, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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}}" + } + }, + { + "alias": "simple_neon_gauge_justgage", + "name": "Simple neon gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a doughnut. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 3, + "sizeY": 3, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[],\"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,\"gaugeType\":\"donut\",\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Simple neon gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "vertical_bar_justgage", + "name": "Vertical bar", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABQVBMVEVqampra2tsbGxtbW1ubm5vb29wcHBycnJzc3N0dHR2dnZ3d3d5eXl6enp7e3t8fHx+fn6BgYGDg4OFhYWHh4eIiIiKioqNjY2Ojo6RkZGTk5OWlpaZmZmbm5uenp6fn5+goKCkpKSlpaWmpqanp6eoqKiqqqqrq6usrKytra2xsbGysrK0tLS1tbW2tra4uLi6urq7u7u9vb2+vr7AwMDBwcHExMTFxcXHx8fIyMjJycnKysrMzMzNzc3Ozs7Pz8/Q0NDR0dHS0tLT09PU1NTV1dXX19fY2NjZ2dnb29vc3Nzd3d3f39/g4ODh4eHj4+Pk5OTl5eXm5ubn5+fo6Ojp6enq6urr6+vs7Ozt7e3u7u7v7+/w8PDx8fHy8vLz8/P09PT1fAD19fX29vb39/f6+vr7+/v8/Pz9/f3+/v7///8uFkw3AAAAAWJLR0RqJWKVDgAAAkBJREFUeNrt3ElTE3EQhvGebKMIiUExGkSNuCEiKAqKRKJsIuISN0giwQFC5v3+H8BD5uDBE2WV9tTTp1x/lTyd/6lNKRkDAgQIECBA/jtIHEXx4FOv3fMMWTSLJOn9eN4+OoZ0zg4gjZxduvXDMWQim7FIaoe5hutGXtn90CJp2mZcx340cv44tEgas9kLufKyW8ht21BokVSwTPWKBdtOIdvBDSm0SH2zFWnaxn1CeqPB03o9b88+9C2IpV0r+YTsWDJ3NWQdqWNDPiGH9Xp98I3ous1Ji1b1/NYKLZI+FzKTd3LBG/cQbZXNii9T8frttGKe8UCAAJGaS5J6j2/O96TuzOTz2CfkxZhVJF0tTY1MqF++eK/w0Cdkar5akdrBa60EnTXb04NzXn9a1yrSln1V094+yUvLduQYsmp7+m5rs2ekhrUdQ9ZtV99sYy6UGrbvGPLJdvTOmkvBsRay8T+CtE45v0NOirVurdRvZR/tX67KMUSbwza8KS2ENvrFKySZA0lSfMgTBUjqIH+pESBAiB0IjQBhawEBQuxAaAQIWwsIEGIHQiNA2FpAgBA7EBoBwtYCAoTYgdAIELYWECDEDoRGgKQK8vOUAwQIsQOhESBsLWIHQuxAaAQIW4vYgRA7EBoBwtYidiDEDoRGgLC1iB0IsQOhESBsrXTHnlws9g8ZXCz230hysdg/JLlY7B+SXCz230hysdg/JLlY7B9yUqx1a8V+CtZvcrE4Df/sBzxRgAABAgQIECB/mF+DKtRemhqPQgAAAABJRU5ErkJggg==", + "description": "Preconfigured gauge to display any value reading as a bar. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 2, + "sizeY": 3.5, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "simple_gauge_justgage", + "name": "Simple gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a circle. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 2, + "sizeY": 2, + "resources": [], + "templateHtml": "\n", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "\nself.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "digital_bar", + "name": "Digital horizontal bar", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a horizontal bar. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 6, + "sizeY": 2.5, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"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,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"horizontalBar\",\"showTitle\":false,\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Digital horizontal bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "mini_gauge_justgage", + "name": "Mini gauge", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a circle. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 2, + "sizeY": 2, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "horizontal_bar_justgage", + "name": "Horizontal bar", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a horizontal bar. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + } + }, + { + "alias": "digital_vertical_bar", + "name": "Digital vertical bar", + "image": "data:image/png;base64,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", + "description": "Preconfigured gauge to display any value reading as a vertical bar. Allows to configure value range, gradient colors and other settings.", + "descriptor": { + "type": "latest", + "sizeX": 2.5, + "sizeY": 4.5, + "resources": [], + "templateHtml": "", + "templateCss": "#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, 'digitalGauge'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n };\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n self.ctx.gauge.destroy();\n}\n\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-digital-gauge-widget-settings", + "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(255, 254, 254, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#3d5afe\",\"#f44336\"],\"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,\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"verticalBar\",\"showTitle\":false,\"minValue\":-60,\"dashThickness\":1.2,\"animation\":true,\"animationDuration\":500,\"animationRule\":\"linear\"},\"title\":\"Digital vertical bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/edge_widgets.json b/application/src/main/data/json/system/widget_bundles/edge_widgets.json new file mode 100644 index 0000000..5d9c952 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/edge_widgets.json @@ -0,0 +1,29 @@ +{ + "widgetsBundle": { + "alias": "edge_widgets", + "title": "Edge widgets", + "image": "data:image/png;base64,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", + "description": "Widgets to manage ThingsBoard Edge." + }, + "widgetTypes": [ + { + "alias": "edges_overview", + "name": "Edge Quick Overview", + "image": "data:image/png;base64,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", + "description": "Overview of entities related to ThingsBoard Edge.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n dataKeysOptional: true\n };\n}\n\nself.onDestroy = function() {\n};\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-edge-quick-overview-widget-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"showTitleIcon\":true,\"titleIcon\":\"router\",\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{},\"title\":\"Edge Quick Overview\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"widgetStyle\":{},\"actions\":{}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/entity_admin_widgets.json b/application/src/main/data/json/system/widget_bundles/entity_admin_widgets.json new file mode 100644 index 0000000..3257ab1 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/entity_admin_widgets.json @@ -0,0 +1,50 @@ +{ + "widgetsBundle": { + "alias": "entity_admin_widgets", + "title": "Entity admin widgets", + "image": "data:image/png;base64,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", + "description": "Templates of complex widgets that allow to list and create/update/delete devices and assets." + }, + "widgetTypes": [ + { + "alias": "device_admin_table", + "name": "Device admin table", + "image": "data:image/png;base64,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", + "description": "Customized entity table widget with preconfigured actions to create, update and delete devices.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-entities-table-widget-settings", + "dataKeySettingsDirective": "tb-entities-table-key-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSearch\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"entityName\",\"displayEntityName\":true,\"displayEntityType\":true,\"entitiesTitle\":\"Device admin table\",\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"reserveSpaceForHiddenAction\":\"true\",\"displayEntityLabel\":false,\"useRowStyleFunction\":false},\"title\":\"Device admin table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"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;\"}]}],\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{\"headerButton\":[{\"name\":\"Add device\",\"icon\":\"add\",\"type\":\"customPretty\",\"customHtml\":\"
\\n \\n

Add device

\\n \\n \\n
\\n \\n \\n
\\n
\\n
\\n \\n Device name\\n \\n \\n Device name is required.\\n \\n \\n
\\n \\n \\n Label\\n \\n \\n
\\n
\\n \\n Latitude\\n \\n \\n \\n Longitude\\n \\n \\n
\\n
\\n
\\n
\\n \\n \\n \\n
\\n
\\n\",\"customCss\":\"\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\\n\\nopenAddDeviceDialog();\\n\\nfunction openAddDeviceDialog() {\\n customDialog.customDialog(htmlTemplate, AddDeviceDialogController).subscribe();\\n}\\n\\nfunction AddDeviceDialogController(instance) {\\n let vm = instance;\\n \\n vm.addDeviceFormGroup = vm.fb.group({\\n deviceName: ['', [vm.validators.required]],\\n deviceType: ['', [vm.validators.required]],\\n deviceLabel: [''],\\n attributes: vm.fb.group({\\n latitude: [null],\\n longitude: [null]\\n }) \\n });\\n \\n vm.cancel = function() {\\n vm.dialogRef.close(null);\\n };\\n \\n vm.save = function() {\\n vm.addDeviceFormGroup.markAsPristine();\\n let device = {\\n name: vm.addDeviceFormGroup.get('deviceName').value,\\n type: vm.addDeviceFormGroup.get('deviceType').value,\\n label: vm.addDeviceFormGroup.get('deviceLabel').value\\n };\\n deviceService.saveDevice(device).subscribe(\\n function (device) {\\n saveAttributes(device.id).subscribe(\\n function () {\\n widgetContext.updateAliases();\\n vm.dialogRef.close(null);\\n }\\n );\\n }\\n );\\n };\\n \\n function saveAttributes(entityId) {\\n let attributes = vm.addDeviceFormGroup.get('attributes').value;\\n let attributesArray = [];\\n for (let key in attributes) {\\n attributesArray.push({key: key, value: attributes[key]});\\n }\\n if (attributesArray.length > 0) {\\n return attributeService.saveEntityAttributes(entityId, \\\"SERVER_SCOPE\\\", attributesArray);\\n } else {\\n return widgetContext.rxjs.of([]);\\n }\\n }\\n}\",\"customResources\":[],\"id\":\"70837a9d-c3de-a9a7-03c5-dccd14998758\"}],\"actionCellButton\":[{\"name\":\"Edit device\",\"icon\":\"edit\",\"useShowWidgetActionFunction\":null,\"showWidgetActionFunction\":\"return true;\",\"type\":\"customPretty\",\"customHtml\":\"
\\n \\n

Edit device

\\n \\n \\n
\\n \\n \\n
\\n
\\n
\\n \\n Device name\\n \\n \\n Device name is required.\\n \\n \\n
\\n \\n \\n Label\\n \\n \\n
\\n
\\n \\n Latitude\\n \\n \\n \\n Longitude\\n \\n \\n
\\n
\\n
\\n
\\n \\n \\n \\n
\\n
\\n\",\"customCss\":\"\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\\n\\nopenEditDeviceDialog();\\n\\nfunction openEditDeviceDialog() {\\n customDialog.customDialog(htmlTemplate, EditDeviceDialogController).subscribe();\\n}\\n\\nfunction EditDeviceDialogController(instance) {\\n let vm = instance;\\n \\n vm.device = null;\\n vm.attributes = {};\\n \\n vm.editDeviceFormGroup = vm.fb.group({\\n deviceName: ['', [vm.validators.required]],\\n deviceType: ['', [vm.validators.required]],\\n deviceLabel: [''],\\n attributes: vm.fb.group({\\n latitude: [null],\\n longitude: [null]\\n }) \\n });\\n \\n vm.cancel = function() {\\n vm.dialogRef.close(null);\\n };\\n \\n vm.save = function() {\\n vm.editDeviceFormGroup.markAsPristine();\\n if (vm.editDeviceFormGroup.get('deviceType').value !== vm.device.type) {\\n delete vm.device.deviceProfileId;\\n }\\n vm.device.name = vm.editDeviceFormGroup.get('deviceName').value,\\n vm.device.type = vm.editDeviceFormGroup.get('deviceType').value,\\n vm.device.label = vm.editDeviceFormGroup.get('deviceLabel').value\\n deviceService.saveDevice(vm.device).subscribe(\\n function () {\\n saveAttributes().subscribe(\\n function () {\\n widgetContext.updateAliases();\\n vm.dialogRef.close(null);\\n }\\n );\\n }\\n );\\n };\\n \\n getEntityInfo();\\n \\n function getEntityInfo() {\\n deviceService.getDevice(entityId.id).subscribe(\\n function (device) {\\n attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE',\\n ['latitude', 'longitude']).subscribe(\\n function (attributes) {\\n for (let i = 0; i < attributes.length; i++) {\\n vm.attributes[attributes[i].key] = attributes[i].value; \\n }\\n vm.device = device;\\n vm.editDeviceFormGroup.patchValue(\\n {\\n deviceName: vm.device.name,\\n deviceType: vm.device.type,\\n deviceLabel: vm.device.label,\\n attributes: {\\n latitude: vm.attributes.latitude,\\n longitude: vm.attributes.longitude\\n }\\n }, {emitEvent: false}\\n );\\n } \\n );\\n }\\n ); \\n }\\n \\n function saveAttributes() {\\n let attributes = vm.editDeviceFormGroup.get('attributes').value;\\n let attributesArray = [];\\n for (let key in attributes) {\\n attributesArray.push({key: key, value: attributes[key]});\\n }\\n if (attributesArray.length > 0) {\\n return attributeService.saveEntityAttributes(entityId, 'SERVER_SCOPE', attributesArray);\\n } else {\\n return widgetContext.rxjs.of([]);\\n }\\n }\\n}\",\"customResources\":[],\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"93931e52-5d7c-903e-67aa-b9435df44ff4\"},{\"name\":\"Delete device\",\"icon\":\"delete\",\"type\":\"custom\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));\\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\\n\\nopenDeleteDeviceDialog();\\n\\nfunction openDeleteDeviceDialog() {\\n let title = \\\"Are you sure you want to delete the device \\\" + entityName + \\\"?\\\";\\n let content = \\\"Be careful, after the confirmation, the device and all related data will become unrecoverable!\\\";\\n dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe(\\n function (result) {\\n if (result) {\\n deleteDevice();\\n }\\n }\\n );\\n}\\n\\nfunction deleteDevice() {\\n deviceService.deleteDevice(entityId.id).subscribe(\\n function () {\\n widgetContext.updateAliases();\\n }\\n );\\n}\\n\",\"id\":\"ec2708f6-9ff0-186b-e4fc-7635ebfa3074\"}]}}" + } + }, + { + "alias": "asset_admin_table", + "name": "Asset admin table", + "image": "data:image/png;base64,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", + "description": "Customized entity table widget with preconfigured actions to create, update and delete assets.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-entities-table-widget-settings", + "dataKeySettingsDirective": "tb-entities-table-key-settings", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSearch\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"entityName\",\"displayEntityName\":true,\"displayEntityType\":true,\"entitiesTitle\":\"Asset admin table\",\"enableSelectColumnDisplay\":true},\"title\":\"Asset admin table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"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;\"}]}],\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{\"headerButton\":[{\"name\":\"Add asset\",\"icon\":\"add\",\"type\":\"customPretty\",\"customHtml\":\"
\\n \\n

Add asset

\\n \\n \\n
\\n \\n \\n
\\n
\\n
\\n \\n Asset name\\n \\n \\n Asset name is required.\\n \\n \\n
\\n \\n \\n Label\\n \\n \\n
\\n
\\n \\n Latitude\\n \\n \\n \\n Longitude\\n \\n \\n
\\n
\\n
\\n
\\n \\n \\n \\n
\\n
\\n\",\"customCss\":\"\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\\n\\nopenAddAssetDialog();\\n\\nfunction openAddAssetDialog() {\\n customDialog.customDialog(htmlTemplate, AddAssetDialogController).subscribe();\\n}\\n\\nfunction AddAssetDialogController(instance) {\\n let vm = instance;\\n \\n vm.addAssetFormGroup = vm.fb.group({\\n assetName: ['', [vm.validators.required]],\\n assetType: ['', [vm.validators.required]],\\n assetLabel: [''],\\n attributes: vm.fb.group({\\n latitude: [null],\\n longitude: [null]\\n }) \\n });\\n \\n vm.cancel = function() {\\n vm.dialogRef.close(null);\\n };\\n \\n vm.save = function() {\\n vm.addAssetFormGroup.markAsPristine();\\n let asset = {\\n name: vm.addAssetFormGroup.get('assetName').value,\\n type: vm.addAssetFormGroup.get('assetType').value,\\n label: vm.addAssetFormGroup.get('assetLabel').value\\n };\\n assetService.saveAsset(asset).subscribe(\\n function (asset) {\\n saveAttributes(asset.id).subscribe(\\n function () {\\n widgetContext.updateAliases();\\n vm.dialogRef.close(null);\\n }\\n );\\n }\\n );\\n };\\n \\n function saveAttributes(entityId) {\\n let attributes = vm.addAssetFormGroup.get('attributes').value;\\n let attributesArray = [];\\n for (let key in attributes) {\\n attributesArray.push({key: key, value: attributes[key]});\\n }\\n if (attributesArray.length > 0) {\\n return attributeService.saveEntityAttributes(entityId, \\\"SERVER_SCOPE\\\", attributesArray);\\n } else {\\n return widgetContext.rxjs.of([]);\\n }\\n }\\n}\",\"customResources\":[],\"id\":\"70837a9d-c3de-a9a7-03c5-dccd14998758\"}],\"actionCellButton\":[{\"name\":\"Edit asset\",\"icon\":\"edit\",\"type\":\"customPretty\",\"customHtml\":\"
\\n \\n

Edit asset

\\n \\n \\n
\\n \\n \\n
\\n
\\n
\\n \\n Asset name\\n \\n \\n Asset name is required.\\n \\n \\n
\\n \\n \\n Label\\n \\n \\n
\\n
\\n \\n Latitude\\n \\n \\n \\n Longitude\\n \\n \\n
\\n
\\n
\\n
\\n \\n \\n \\n
\\n
\\n\",\"customCss\":\"\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\\n\\nopenEditAssetDialog();\\n\\nfunction openEditAssetDialog() {\\n customDialog.customDialog(htmlTemplate, EditAssetDialogController).subscribe();\\n}\\n\\nfunction EditAssetDialogController(instance) {\\n let vm = instance;\\n \\n vm.asset = null;\\n vm.attributes = {};\\n \\n vm.editAssetFormGroup = vm.fb.group({\\n assetName: ['', [vm.validators.required]],\\n assetType: ['', [vm.validators.required]],\\n assetLabel: [''],\\n attributes: vm.fb.group({\\n latitude: [null],\\n longitude: [null]\\n }) \\n });\\n \\n vm.cancel = function() {\\n vm.dialogRef.close(null);\\n };\\n \\n vm.save = function() {\\n vm.editAssetFormGroup.markAsPristine();\\n vm.asset.name = vm.editAssetFormGroup.get('assetName').value,\\n vm.asset.type = vm.editAssetFormGroup.get('assetType').value,\\n vm.asset.label = vm.editAssetFormGroup.get('assetLabel').value\\n assetService.saveAsset(vm.asset).subscribe(\\n function () {\\n saveAttributes().subscribe(\\n function () {\\n widgetContext.updateAliases();\\n vm.dialogRef.close(null);\\n }\\n );\\n }\\n );\\n };\\n \\n getEntityInfo();\\n \\n function getEntityInfo() {\\n assetService.getAsset(entityId.id).subscribe(\\n function (asset) {\\n attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE',\\n ['latitude', 'longitude']).subscribe(\\n function (attributes) {\\n for (let i = 0; i < attributes.length; i++) {\\n vm.attributes[attributes[i].key] = attributes[i].value; \\n }\\n vm.asset = asset;\\n vm.editAssetFormGroup.patchValue(\\n {\\n assetName: vm.asset.name,\\n assetType: vm.asset.type,\\n assetLabel: vm.asset.label,\\n attributes: {\\n latitude: vm.attributes.latitude,\\n longitude: vm.attributes.longitude\\n }\\n }, {emitEvent: false}\\n );\\n } \\n );\\n }\\n ); \\n }\\n \\n function saveAttributes() {\\n let attributes = vm.editAssetFormGroup.get('attributes').value;\\n let attributesArray = [];\\n for (let key in attributes) {\\n attributesArray.push({key: key, value: attributes[key]});\\n }\\n if (attributesArray.length > 0) {\\n return attributeService.saveEntityAttributes(entityId, 'SERVER_SCOPE', attributesArray);\\n } else {\\n return widgetContext.rxjs.of([]);\\n }\\n }\\n}\",\"customResources\":[],\"id\":\"93931e52-5d7c-903e-67aa-b9435df44ff4\"},{\"name\":\"Delete asset\",\"icon\":\"delete\",\"type\":\"custom\",\"customFunction\":\"let $injector = widgetContext.$scope.$injector;\\nlet dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));\\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\\n\\nopenDeleteAssetDialog();\\n\\nfunction openDeleteAssetDialog() {\\n let title = \\\"Are you sure you want to delete the asset \\\" + entityName + \\\"?\\\";\\n let content = \\\"Be careful, after the confirmation, the asset and all related data will become unrecoverable!\\\";\\n dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe(\\n function (result) {\\n if (result) {\\n deleteAsset();\\n }\\n }\\n );\\n}\\n\\nfunction deleteAsset() {\\n assetService.deleteAsset(entityId.id).subscribe(\\n function () {\\n widgetContext.updateAliases();\\n }\\n );\\n}\\n\",\"id\":\"ec2708f6-9ff0-186b-e4fc-7635ebfa3074\"}]}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/gateway_widgets.json b/application/src/main/data/json/system/widget_bundles/gateway_widgets.json new file mode 100644 index 0000000..dc86c0f --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/gateway_widgets.json @@ -0,0 +1,67 @@ +{ + "widgetsBundle": { + "alias": "gateway_widgets", + "title": "Gateway widgets", + "image": "data:image/png;base64,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", + "description": "Widgets to manage ThingsBoard IoT Gateway." + }, + "widgetTypes": [ + { + "alias": "gateway_configuration", + "name": "Gateway Configuration", + "image": "data:image/png;base64,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", + "description": "Allows to define gateway configuration for a single selected device.", + "descriptor": { + "type": "static", + "sizeX": 8, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gateway-config-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"widgetTitle\":\"Gateway Configuration\",\"archiveFileName\":\"configurationGateway\"},\"title\":\"Gateway Configuration\",\"dropShadow\":true,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "attributes_card", + "name": "Gateway events", + "image": "data:image/png;base64,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", + "description": "Allows to browse events from the gateway.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 8, + "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": "let types;\nlet eventsReg = \"eventsReg\";\n\nself.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n\n if (self.ctx.datasources.length && self.ctx.datasources[0].type === 'entity') {\n getDatasourceKeys(self.ctx.datasources[0]);\n } else {\n processDatasources(self.ctx.datasources);\n }\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, false);\n }\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 datasourceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasourceTitleFontSize = self.ctx.width/12;\n }\n datasourceTitleFontSize = Math.min(datasourceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasourceTitleFontSize+'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\nfunction processDatasources(datasources) {\n var i = 0;\n var tbDatasource = datasources[i];\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n self.onResize();\n}\n\nfunction getDatasourceKeys (datasource) {\n let entityService = self.ctx.$scope.$injector.get(self.ctx.servicesMap.get('entityService'));\n if (datasource.entityId && datasource.entityType) {\n entityService.getEntityKeys({entityType: datasource.entityType, id: datasource.entityId}, '', 'timeseries').subscribe(\n function(data){\n if (data.length) {\n subscribeForKeys (datasource, data);\n }\n });\n }\n}\n\nfunction subscribeForKeys (datasource, data) {\n let eventsRegVals = self.ctx.settings[eventsReg];\n if (eventsRegVals && eventsRegVals.length > 0) {\n var dataKeys = [];\n data.sort();\n data.forEach(dataValue => {eventsRegVals.forEach(event => {\n if (dataValue.toLowerCase().includes(event.toLowerCase())) {\n var dataKey = {\n type: 'timeseries',\n name: dataValue,\n label: dataValue,\n settings: {},\n _hash: Math.random()\n };\n dataKeys.push(dataKey);\n }\n })});\n\n if (dataKeys.length) {\n updateSubscription (datasource, dataKeys);\n }\n }\n}\n\nfunction updateSubscription (datasource, dataKeys) {\n var datasources = [\n {\n type: 'entity',\n name: datasource.aliasName,\n aliasName: datasource.aliasName,\n entityAliasId: datasource.entityAliasId,\n dataKeys: dataKeys\n }\n ];\n \n var subscriptionOptions = {\n datasources: datasources,\n useDashboardTimewindow: false,\n type: 'latest',\n callbacks: {\n onDataUpdated: (subscription) => {\n self.ctx.data = subscription.data;\n self.onDataUpdated();\n }\n }\n };\n \n processDatasources(datasources);\n self.ctx.subscriptionApi.createSubscription(subscriptionOptions, true).subscribe(\n (subscription) => {\n self.ctx.defaultSubscription = subscription;\n }\n );\n}\n\nself.onDestroy = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\t\n dataKeysOptional: true,\n singleEntity: true\n };\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gateway-events-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Function Math.round\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.826503672916844,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"eventsTitle\":\"Gateway Events Form\",\"eventsReg\":[]},\"title\":\"Gateway events\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "config_form_latest", + "name": "Gateway configuration (Single device)", + "image": "data:image/png;base64,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", + "description": "Allows to create or choose the gateway device and edit the configuration.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 9, + "resources": [], + "templateHtml": "\n", + "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\n\nself.onDestroy = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\t\t\t\n dataKeysOptional: true,\n singleEntity: true\n };\n}\n\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gateway-config-single-device-widget-settings", + "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\":{\"gatewayTitle\":\"Gateway configuration (Single device)\"},\"title\":\"Gateway configuration (Single device)\"}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/gpio_widgets.json b/application/src/main/data/json/system/widget_bundles/gpio_widgets.json new file mode 100644 index 0000000..e539824 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/gpio_widgets.json @@ -0,0 +1,86 @@ +{ + "widgetsBundle": { + "alias": "gpio_widgets", + "title": "GPIO widgets", + "image": "data:image/png;base64,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", + "description": "Visualization and control of GPIO state for target devices." + }, + "widgetTypes": [ + { + "alias": "basic_gpio_control", + "name": "Basic GPIO Control", + "image": "data:image/png;base64,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", + "description": "Allows to change state of the GPIO for target device using RPC commands. Requires handling of the RPC commands in the device firmware. Uses 'getGpioStatus' and 'setGpioStatus' RPC calls", + "descriptor": { + "type": "rpc", + "sizeX": 4, + "sizeY": 2, + "resources": [], + "templateHtml": "
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
", + "templateCss": ".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel section[fxflex] {\n min-width: 0px;\n}\n\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel mat-slide-toggle {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel.col-0 mat-slide-toggle {\n margin-left: 8px;\n margin-right: 4px;\n}\n\n.switch-panel.col-1 mat-slide-toggle {\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": "var namespace;\nvar cssParser = new cssjs();\n\nself.onInit = function() {\n var utils = self.ctx.$injector.get(self.ctx.servicesMap.get('utils'));\n namespace = 'gpio-control-' + utils.guid();\n cssParser.testMode = false;\n cssParser.cssPreviewNamespace = namespace;\n self.ctx.$container.addClass(namespace);\n self.ctx.ngZone.run(function() {\n init(); \n });\n}\n\nfunction init() {\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 .subscribe(\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 self.ctx.detectChanges();\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 .subscribe(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n self.ctx.detectChanges();\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 if (scope.rpcEnabled && !scope.executingRpcRequest) {\n changeGpioStatus(gpio);\n }\n };\n \n scope.gpioToggleChange = function($event, gpio) {\n gpio.enabled = !$event.checked;\n $event.source.toggle();\n self.ctx.detectChanges();\n }\n \n if (scope.rpcEnabled) {\n requestGpioStatus(); \n }\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 \n var css = '.mat-slide-toggle .mat-slide-toggle-bar {\\n' +\n ' height: ' + 14*ratio+'px;\\n'+\n ' width: ' + 36*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-thumb-container {\\n' +\n ' height: ' + 20*ratio+'px;\\n'+\n ' width: ' + 20*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-thumb {\\n' +\n ' height: ' + 20*ratio+'px;\\n'+\n ' width: ' + 20*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-ripple {\\n' +\n ' height: ' + 40*ratio+'px;\\n'+\n ' width: ' + 40*ratio+'px;\\n'+\n ' top: calc(50% - '+20*ratio+'px);\\n'+\n ' left: calc(50% - '+20*ratio+'px);\\n'+\n '}\\n';\n css += '.gpio-left-label, .gpio-right-label {\\n' +\n ' font-size: ' + 16*ratio+'px;\\n'+\n '}\\n';\n var pinsFontSize = Math.max(9, 12*ratio);\n css += '.pin {\\n' +\n ' font-size: ' + pinsFontSize+'px;\\n'+\n '}\\n';\n\n cssParser.createStyleElement(namespace, css);\n \n self.ctx.detectChanges();\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gpio-control-widget-settings", + "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\"}" + } + }, + { + "alias": "gpio_panel", + "name": "Basic GPIO Panel", + "image": "data:image/png;base64,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", + "description": "Allows to display state of the GPIO for target device using latest attribute values. You should set the label of the selected data key to GPIO pin number (e.g. '1') and use boolean values for widget to display the data.", + "descriptor": { + "type": "latest", + "sizeX": 5, + "sizeY": 2, + "resources": [], + "templateHtml": "
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
", + "templateCss": ".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel section[fxflex] {\n min-width: 0px;\n}\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": "var namespace;\nvar cssParser = new cssjs();\n\nself.onInit = function() {\n var utils = self.ctx.$injector.get(self.ctx.servicesMap.get('utils'));\n namespace = 'gpio-panel-' + utils.guid();\n cssParser.testMode = false;\n cssParser.cssPreviewNamespace = namespace;\n self.ctx.$container.addClass(namespace);\n self.ctx.ngZone.run(function() {\n init(); \n });\n}\n\nfunction init() {\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.detectChanges();\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 css = '.gpio-left-label, .gpio-right-label {\\n' +\n ' font-size: ' + 16*ratio+'px;\\n'+\n '}\\n';\n var pinsFontSize = Math.max(9, 12*ratio);\n css += '.pin {\\n' +\n ' font-size: ' + pinsFontSize+'px;\\n'+\n '}\\n';\n \n cssParser.createStyleElement(namespace, css); \n \n self.ctx.detectChanges();\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gpio-panel-widget-settings", + "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}}}" + } + }, + { + "alias": "raspberry_pi_gpio_panel", + "name": "Raspberry Pi GPIO Panel", + "image": "data:image/png;base64,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", + "description": "Allows to change state of the GPIO for Raspberry Pi device using RPC commands. Requires handling of the RPC commands in the device firmware. Uses 'getGpioStatus' and 'setGpioStatus' RPC calls", + "descriptor": { + "type": "latest", + "sizeX": 7, + "sizeY": 10.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
", + "templateCss": ".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel section[fxflex] {\n min-width: 0px;\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": "var namespace;\nvar cssParser = new cssjs();\n\nself.onInit = function() {\n var utils = self.ctx.$injector.get(self.ctx.servicesMap.get('utils'));\n namespace = 'gpio-panel-' + utils.guid();\n cssParser.testMode = false;\n cssParser.cssPreviewNamespace = namespace;\n self.ctx.$container.addClass(namespace);\n self.ctx.ngZone.run(function() {\n init(); \n });\n}\n\nfunction init() {\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.detectChanges();\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 css = '.gpio-left-label, .gpio-right-label {\\n' +\n ' font-size: ' + 16*ratio+'px;\\n'+\n '}\\n';\n var pinsFontSize = Math.max(9, 12*ratio);\n css += '.pin {\\n' +\n ' font-size: ' + pinsFontSize+'px;\\n'+\n '}\\n';\n \n cssParser.createStyleElement(namespace, css); \n \n self.ctx.detectChanges();\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gpio-panel-widget-settings", + "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}}}" + } + }, + { + "alias": "raspberry_pi_gpio_control", + "name": "Raspberry Pi GPIO Control", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAUeUlEQVR42u2dCXBV1RnHXwzVSJopbbRCy9KpMo60yNQWOu0YKkhsDFMNSxBCCAIKKoIsQggkQICEsGWDmLAEFEIICVsgCFgXRNwAEVFUFKWCC4qK+4JL+uMdvLm5776QhIg8/J+5w9x3c2/Iu+/3vnPu+c7//3kqKyu/+eab995779ChQ/9TUzuNBkKAdPz4caDyQNXhw4c//vjjb7/9tlJN7TQaCAESOAGVB8R4oZui1lANnIDKQ/hSrFJr2LgFVB66Rt0LtYZtQCWw1ASWmsD6ydvoR0Z7ZnrO/i3x0USBJbAElsCqHVjhueGXL7q89cLWv8z+pcA6R8D67rvvGupXff/993UFq9HsRp1KOvXf2N9sCRUJVy25ypPuqbZlCKwzC9aHH36YkZGRmZn52Wefvf766+zPnDnz6aef5gPeuHEjJzz22GPjx4/ftm2b/apdu3Y999xzZv/rr7/mKnbWrFmTkpLyzDPPsF9QUMDBJUuW8FMyA/PmzZs6deqRI0f4L1544QVz4YYNG8wfUFFR8cknn8yYMWPp0qUfffTRnDlz/IEVNDPoXyv+FZYdZv9EOxZ3tKiytityr/CM9VTbkn9cvARWtda/f39m7iEpOTl58+bNAPH+++9z8K233rr55ptfffXVoUOHwtydd975yiuvmEs+//zza6+9trCw0LxctGjRSy+9VF5enpOT8+mnn8bFxX355Zd9+vSBmAceeCAtLW3y5Mnbt2//4IMP+vXrt2nTpmXLlpkLecmZ8fHxb775Jv/7iy++mJ2dvXPnzvz8fPZdwWq7pG3WrqwW+S2sI0DmSxVbr3W9gsYGOdmaKLDOFFh8uiZswIQBi1n8hIQEAxb0PPHEE5zw+OOPWySlpqauXLnSejl8+PATH//o0e+++y7M8avoGQ1YYDRt2rQBAwaYMwHUDlbfvn27d+9uItzAgQP595FHHikqKoJge9BydIWDNg2yg9Uyv6UrWGyNUxo7wUoSWGcKLD51Zu7ptnr06AFYN910E90iPR0HAeu+++579NFHOY1/6afYIaIAQVZWFiSRoeTI4MGD+ZdOkMh3//33R0ZGEuc6d+7M7ywuLqYfNOzS6AftYPE/Lly4kFho/gxw3LJly4oVKyAyMTGxlmD97p7f+QPr/PHnO8EaL7DOFFjEG3gCFz5adqxP3YBFJ0VXtXfvXtNjcpywBHZ0fxMnTjSpSfpKBmQ7duwYNmzYyy+/bM60YKLRwRGH6G1vu+02wGKwxW8AMnPOggULwHfWrFkMuUaOHEmvum/fPsZk/sC6csmV9jEWI/c+6/v4UnX9iuudVCV6PFMF1pkCi6jDoLu0tJSu8J133rF+iWGFnddee23x4sUHDhywX3X06FHIsMbgDz/8MDvPP/88o3WwYB+M7Ocz2CopKWFExX9R6m1PPfWUdQ6XwygHTbcLiJxW+6dCphgcVMVviA9PDz8Rn6wtxeNJ01NhQE03gOD8+fMb6rcdO3Zs9erVdZ3HalXQKmZNjKEqqjSKOS3NY2mCtMFm3n8x+xfBs4M18y6wlNIRWAJLYAksgSWwBJbAElgCS2AJLIElsASWwBJYAktgCSyBJbAElsASWAJLYAksgSWwBJbAElgCS2AJLIElsH4mYKGq+O2837IoOWhWUD2wCMkMCcsJC54VLLAEVpU8ut2Sdn039D0pVS3v9Yf5f6g9EE1ymkSVRZlr4zbEXXXvVfVDU2D96A3dKUoyhDcof/7rbchfOW7E9SitV61ahfjHfonR9qAFMucjR/MH1kVzLyK62I+AglP+VdG/xYwWTqnqZBcaUJL1Xt/bcfnVhVc7dWPI86dXu7D90vYOfO1gYSyAQQEqX/bffvtt3tFDDz2Ee8BXX32F6LLSK7HkJiB2si5BVon0zexzMtpxgVWtIahHdIqWa8SIEfg73HXXXYi6ECfiZomQEDF0z549cYK45ZZbLB39uHHjIiIi2IE/JIdoxe6++25XsBAVlrxUYhesAlm/in6+usKYshinrnCsC1sRxRGuYtfwqeEuKuoZJ6+KXhVNXJy/Zz7B0hWsQYMGPfjgg+np6ejFUU3m5eWh+O3WrRs3AS3u/v37Ef1yE7gVfPfMl23UqFG9evViH5X57NmzuSdGVCewTjZLqoq0FbCwGGEfww/w4kcEM+TRHOHbye2zrjISe9OmT59+8OBBfxHLoYRm358S+sLkC08psYcP12vbzmvrwmV6tSHdpO2TXCMWPgNDhgyp9Arj8CsALL4q7CPlRdALWOjIzZcKhuDPcRMIV2h6x44du2fPHoFV1dBPV3r9HbBvAKxOnTqNGTNm7ty5hjnUrUY2yF/LOb5gffHFF0iraxhjOcBqnt/cH1ghySH1ByvPDay0qlHd+G3jm+Y1dQUL5xx8AxBIwhBGBICFcQEBae3atYRkDuJnYUYC5d7mCxb2PojCd+/eLbCqGpp9vpdYNhDnrYhlBTPCGLeP4RfhijvoCxZWM08++WQNYHUu6Rw+t0qPesGcC+Ir4n3JuLH0RhcyUp1g2Y217FvTtKY1+D785d6/EK4GbxnMc6hrV9i7d2/cdRhUEaVMxDLHDVh0gvSSDKqwvWAE5rgJeBdw34hkdp8BgXXCUoa7NmnSJO4OSnnjKWIaKnv+ZVjK19fsW62srMx+Tp2eCnkkdGCRsDGh+YzmJwbd1pbkpWqG89rf5P7Gl8vIokjPOE+1y30G7zU/FeJ5gUsKPRrcYKdjOUBhyY9vinm/3ASH65i5CXiiME7lHtKlCqyfeB7rz4v/zEyBwaLnup72vvKUW7O8ZlxiEYmx2/lzztd0g8A6uTG3Sfj5Vc6v7E9qtdy45OJ5Fze/p3loVqgmSAWWUjoCS2AJLIElsASWwBJYPyZYzINTPeDq5Vf/o+gfmLDVY/wusASWc6MURfc13e0TUdeVXBc8NdgzxVO1TXOZxBJYgQQWeXum/shpYEnKn0RqgnlCnJhJlpnEDu7LZKbN1LO5BPNtUh9keyhWkOFt5BZdwWJpw5itY+wz72xdV3X1nTrvsKCDSz5nusAKWLBIYpCxJ+UHT1ZKB7BYQ0JK54033iB1yBFS0eTOKr2rSoxVM5UHzG/A9BaTXFewMN8e/uBw+/wnc1euOZm+6/uel3ieS3EKgRWgYJnVDVu3biXlB1ikwwhF5A3Nspl169aZtCsvSXpYV2GibOIZjWjHuiV/XaEjCc2Iyl8SOjQl1AnWOIEVsGCZOhSAFRMTY8AiXUhG1jDH4hk84tl/9tlnLZJI9ZO6NuWZSKvRS9YwxnKARRrYlSoWaTUa18gJ1gSBFbBg0a+RwIceygX4rm5g1QM5fOy7yf+bigSkWtu3bw9kHGThJWMy+7pKX7DoDe0rSFlG7Bi5m63jvR1dwlW6wArkp0Jy+AQhxuaAwrIk6zhrZiq9NS9Yo2yl7qlg8NoPjUvMOXV6KmRE32dDteIUMatiQqaFeCZ5qrbUH72ynMA6B+exSB53WNYhuiwaWQTLlzF81zyWwNLMu8ASWAJLYAksgSWwBNZZDBYVms6bdZ7AElgNBhZTpv9Z/R+zaP260uvI89QeCFa4d1jaIXZdLFkg0o6t5rcSWGcpWOh6TSVL5rGYnWIi1OSbzRwV+xy0J20okEnq2sy8M+9l9Pj+wGJJjCMsXbbgMmeicENfZyHMZO8CBzeqrEKHVaLCe9pWu3aCV0JdfXFE46zGjkkNB1i8CzOHR+aU92tuCHN4JgnB28dPwErDm8k/M2NM42S0OgKrWkNGh95ywoQJ6H2ZeafwM/o4FJumij3TodQYp4YqR4ykDrkYR5h5J0WIUuqOO+5AX29VEK48lcS+0ZxGjtnRk6V7i6930RX6lO7FgsH32oSKhLBJYS7poBlVwvyJ2ycW7i30J7FHG0gxYoT2VLnmhuA2wN0gPY8sjLTEkSNHePs5OTnk3UlFcD6ie07mKsT4COA4h3tiWTkIrBONO2J2SAtaKZ2CggKyh4DFygUKjHMEjwZuIjuoOskPGvUmhhnmpb9lM3UoNl5Rq2LjPdb1cL28TU6bGiT2aIEQBfmT2BOnjRycsMRXxRKs3n777SjrgYbklSmLTE1kYxzCZ/eetyUlJZHRIrBx38yPBNbJlpCQUOmV2HOPjMSeHYIQtxuwqD9tVjcQ9pOTk62rUJ0bRTmnQRgq9VqC1TK/pb/VDY1TGp9ydYOv1YzZ2uW1q0Fib/4MwqcrWPRi5NSNxJ5IbCT2mHzwbTFKaCITy4c4s6KiAs8ZcxUwEa1NnnT58uWsB3EMCX7uYGEjQ9Qxocs3Cc1YilvM/ipvM8fJWJu1WcQtktDG6McfWLHlsUhM7ctHXcmIXRsbNDbIScYkJ1j/Lv236+UtZ7Z0XptY1RX+9b6/LnxuYQ0Se0gCFEaNfM18JfasHcKQh5csHLLeLOMHbhc73Aq+VzgfcaHAqvan8H0lSuFkxKCVf60fsTTUYMQJaPC59WbQyrcz0dtwc+CTYCc3N7f2T4Usdfcl4/Lsy51kpLhc6+opgu+Dc5FgUrVwdcqnQtai8R5Z0sg6M6iiQ7QeaxgJ8P0horOM1irSDlJ8D3njRDVW3jLc5Atmvp8C6yebbkBJ0bG4o30lVrvF7U6sZbBv/he8X7HoCrvDFg+JYVlhzss13fCznSDF8ZF5hz8u+GPjzMZ1nYLiEkQ+Vy6+ktFb/XwiBZZSOpp5F1gCS2AJLIElsASWwDojYPFcybwDA/86Za8FlsCqaUOZaJ9/jyyJDJkZciJ7Y23TayXPF1gCy2OXJZJydiawV1zvnLUfV22CFEfdIVuGsMbGH1gkrPARJfFs0oXMfDIjSs7K8iAlr4XhtmXjyxw9k6WcQ37aHMHkl+yWwApUsCJXRrqmdH6f8fsaUjpdVnZpvbB1xlMZ/lY3UAqAeXPSMphDm5QOngOxsbEslQEyssswBHOIew1JnEmSB3f7YcOG8RLL5EsvvZR1HwIrUMGqdxKa0djYrWNdIxZhiZwMeRuy0cABWKTeyW5RmYJUKWAh9TY+7yQN169fb71TeCoqKuIS8jkQJrACGCx/y2b+NPdPLmD9YFZzYeaFU56YQofoChbrFlkhA17kRsm7AxZrrViUxqIrk4TGXcdUpiCRauWnyUbDEzusBKEPjYqKIowJrEAFC6M2V7CapDapwammTWGbof8dWsPqBpZ4EH6o70Lg8V3dAFWcwJIsekz44zgrsbp27coKNjLQlF9gsRrrI/bu3SuwAhUsYo9v1ZMOizqcwMi+pdbtqZBxN90fy61YIMrnYg3JGWkZXIhe9HpmvXKl18up1NuoGWaOMNJnta3ACuDpBuT516y4BhWGqXVINNI8lsBqsA2BhmPMJLAEllI6AktgCSyBJbAElsASWAJLYAksgSWwBJbAElgCS2AJLIElsASWwBJYAktgCSyBJbAElsASWAJLYAksgSWwBNa5DRaL35vkNkFrL7AEVsNsv879dfSqaMsgHn/R+pW3EFgCq2oLywnztYmPWBzh1EAnOw0jsXq/bOFl/sBCWoh1Lw7k5rNBfoMwFf2qXaWDNbKl3qn0llgvKytDHo01q1HsoAMTWIEKFny46govmnqRU1eYZBPml0bGrI0p2FPgT2KPz/u2bduoIYAjA7pCHO23b99OkWxcuNEVUhkAXSHGDYjuja4QRSFuv1RlxxQY7eGtt96KWvVs+EAFVj03X1HhyaoneW1rlthT74TiFK4Ri6rE1MOu9Fp9sG8EqyjuUUUTqAArMzPTVJ2wlND4bxPkELIikt65c+cNN9zAaaY4isA6t8Ca17aGyhQEquTHki+ed7ErWBTPwbvBSOxHjx5tJPbUgCEgGSV0Wlqar3cDJiIEKpSuEIYwGkk0dQYEVqCCdU3xNa5gXZJ2iUu5FFsBAcJVDRL73r17U5GKQRV9oq/Enm5xypQpMARwRgxt7B5MWQr6UPzuqcfOOQIrUMHikTB+Q7yDqi7LupwwxEr8YUvyFh/IqMNTIbjgjzV9+nTwwh+L6kDmuOWPRazC/IOhlTlOz2hKKFBAAGU9piBEOyKfwArg6YZL5l3SfW33k3W/NiYwnHfUi9N0g8Cq58aYKTw3vNk9zepRfEBgCSyldASWwBJYAktgCSyBJbAElsASWAJLYAksgSWwBJbAElgCS2AJLIElsASWwBJYAktgCSyBJbAElsASWAJLYAksgSWwBJbAElgCS2AJLIElsASWwBJYAktgCSyBJbAElsASWAJLYAksgSWw3DYMjyOWR3Rb2+3G1Te2X9o+JDNEYAms0wWraV5T3I7tBlc91/QMnRbqmeip2iZX+fEJrIAHKzs7e9SoUXFxcXhvYvM6cODA4cOHT5s2DT9gvMUwvBswYABHUlJSrEuio6MHDx5cWFjoClabwjZYyjbLa2YdwTq757qevpZ8nZd2rtlEVGAFKlgYtoJIpdfeDnPEnJycHTt28DIhIQGDzfT09Nzc3N27d3MkNTX14MGDlV43WNwTd+3ahRWsK1jBs4IHbRrUIr+F3TbN1esxoSKhUVIjJ1gTBFbgg3XgwAHsEqEKB8Ty8nLAAqkRI0YQw/bt2wdYOMACH2cWFRXhPs0OtpxAlp+fjyu1v67QAVarglauYLGFTgytwVJbYAUqWMePH6enY+fYsWP0d1bEohmwiFhYT/MSi3Pzx+PYyY/YodOsJViYiLpSFbc+LigxyAlWisA6J8ZYixYtAqmRI0du3ryZMZbp+GhmjIUBNR0fMcyyB6ZwA876uL6uXLnSH1gXzLnAUZIkqizKF6y/FfzNJVxlCCw9Fdb6qTA0KzRmTUw12+PiLsFpwZ6pnqotTdMNAqvu81hU7Wq9oPU/l//z78v+Tkdpr0SieSyBpZl3gSWwBFagt6NfHN3/4f6zf+PvFFhqagJL7acF69ChQxTn1L1Qa6gGTkDlocidKdepptYgjRlsoPKQQjl8+DBsKW6pnX6sAiRwYsfDa0rdgRjh639qaqfRQAiQTIT6P6smAX3bjvRbAAAAAElFTkSuQmCC", + "description": "Allows to display state of the GPIO for target Raspberry Pi device using latest attribute values. You should set the label of the selected data key to GPIO pin number (e.g. '1') and use boolean values for widget to display the data.", + "descriptor": { + "type": "rpc", + "sizeX": 6, + "sizeY": 10.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
", + "templateCss": ".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel section[fxflex] {\n min-width: 0px;\n}\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel mat-slide-toggle {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel.col-0 mat-slide-toggle {\n margin-left: 8px;\n margin-right: 4px;\n}\n\n.switch-panel.col-1 mat-slide-toggle {\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": "var namespace;\nvar cssParser = new cssjs();\n\nself.onInit = function() {\n var utils = self.ctx.$injector.get(self.ctx.servicesMap.get('utils'));\n namespace = 'gpio-control-' + utils.guid();\n cssParser.testMode = false;\n cssParser.cssPreviewNamespace = namespace;\n self.ctx.$container.addClass(namespace);\n self.ctx.ngZone.run(function() {\n init(); \n });\n}\n\nfunction init() {\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 .subscribe(\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 self.ctx.detectChanges();\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 .subscribe(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n self.ctx.detectChanges();\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 if (scope.rpcEnabled && !scope.executingRpcRequest) {\n changeGpioStatus(gpio);\n }\n };\n \n scope.gpioToggleChange = function($event, gpio) {\n gpio.enabled = !$event.checked;\n $event.source.toggle();\n self.ctx.detectChanges();\n }\n \n if (scope.rpcEnabled) {\n requestGpioStatus(); \n }\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 \n var css = '.mat-slide-toggle .mat-slide-toggle-bar {\\n' +\n ' height: ' + 14*ratio+'px;\\n'+\n ' width: ' + 36*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-thumb-container {\\n' +\n ' height: ' + 20*ratio+'px;\\n'+\n ' width: ' + 20*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-thumb {\\n' +\n ' height: ' + 20*ratio+'px;\\n'+\n ' width: ' + 20*ratio+'px;\\n'+\n '}\\n';\n css += '.mat-slide-toggle .mat-slide-toggle-ripple {\\n' +\n ' height: ' + 40*ratio+'px;\\n'+\n ' width: ' + 40*ratio+'px;\\n'+\n ' top: calc(50% - '+20*ratio+'px);\\n'+\n ' left: calc(50% - '+20*ratio+'px);\\n'+\n '}\\n';\n css += '.gpio-left-label, .gpio-right-label {\\n' +\n ' font-size: ' + 16*ratio+'px;\\n'+\n '}\\n';\n var pinsFontSize = Math.max(9, 12*ratio);\n css += '.pin {\\n' +\n ' font-size: ' + pinsFontSize+'px;\\n'+\n '}\\n';\n\n cssParser.createStyleElement(namespace, css);\n \n self.ctx.detectChanges();\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-gpio-control-widget-settings", + "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\"}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json new file mode 100644 index 0000000..3c9a9fe --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -0,0 +1,505 @@ +{ + "widgetsBundle": { + "alias": "input_widgets", + "title": "Input widgets", + "image": "data:image/png;base64,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", + "description": "Various input forms that allow users to set location, image and other configuration parameters of the target entity" + }, + "widgetTypes": [ + { + "alias": "markers_placement_openstreetmap", + "name": "Markers Placement - OpenStreetMap", + "image": "data:image/png;base64,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", + "description": "Allows configuring location of the selected entities on the OpenStreetMap.", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6.5, + "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 TbMapWidgetV2('openstreet-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"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\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.7867521952070078,\"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\":\"longitude\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.7040053227577256,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}

Delete\",\"markerImageSize\":34,\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"color\":\"#fe7569\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"showTooltip\":true,\"autocloseTooltip\":true,\"defaultCenterPosition\":\"0,0\",\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"showTooltipAction\":\"click\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"zoomOnClick\":true,\"showCoverageOnHover\":true,\"animate\":true,\"maxClusterRadius\":80,\"removeOutsideVisibleBounds\":true,\"defaultZoomLevel\":5,\"provider\":\"openstreet-map\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\"},\"title\":\"Markers Placement - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"54c293c4-9ca6-e34f-dc6a-0271944c1c66\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"6beb7bed-dfd8-388d-b60c-82988ab52f06\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" + } + }, + { + "alias": "update_multiple_attributes", + "name": "Update Multiple Attributes", + "image": "data:image/png;base64,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", + "description": "Allows to create an input form and set values for multiple attributes simultaneously.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": ".tb-toast {\n min-width: 0;\n font-size: 14px !important;\n}", + "controllerScript": "self.onInit = function() {\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n self.ctx.$scope.multipleInputWidget.onDataUpdated();\r\n}\r\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-update-multiple-attributes-widget-settings", + "dataKeySettingsDirective": "tb-update-multiple-attributes-key-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Update Multiple Attributes\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "device_claiming_widget", + "name": "Device claiming widget", + "image": "data:image/png;base64,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", + "description": "Allows to claim the device using name and optional secret key.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 4.5, + "resources": [], + "templateHtml": "
\n
\n \n {{deviceLabel}}\n \n \n {{requiredErrorDevice}}\n \n \n \n {{secretKeyLabel}}\n \n \n {{requiredErrorSecretKey}}\n \n \n
\n
\n \n
\n
\n", + "templateCss": ".claim-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n", + "controllerScript": "let $scope;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n}\n\nfunction init() {\n $scope = self.ctx.$scope;\n let $injector = $scope.$injector;\n let utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n let $translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n let deviceService = $scope.$injector.get(self.ctx.servicesMap.get('deviceService'));\n let settings = self.ctx.settings || {};\n \n $scope.toastTargetId = 'device-claiming-widget' + utils.guid();\n $scope.secretKeyField = settings.deviceSecret;\n $scope.showLabel = settings.showLabel;\n\n let titleTemplate = \"\";\n let successfulClaim = utils.customTranslation(settings.successfulClaimDevice, settings.successfulClaimDevice) || $translate.instant('widgets.input-widgets.claim-successful');\n let failedClaimDevice = utils.customTranslation(settings.failedClaimDevice, settings.failedClaimDevice) || $translate.instant('widgets.input-widgets.claim-failed');\n let deviceNotFound = utils.customTranslation(settings.deviceNotFound, settings.deviceNotFound) || $translate.instant('widgets.input-widgets.claim-not-found');\n \n if (settings.widgetTitle && settings.widgetTitle.length) {\n titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n titleTemplate = self.ctx.widgetConfig.title;\n }\n self.ctx.widgetTitle = titleTemplate;\n \n $scope.deviceLabel = utils.customTranslation(settings.deviceLabel, settings.deviceLabel) || $translate.instant('widgets.input-widgets.device-name');\n $scope.requiredErrorDevice= utils.customTranslation(settings.requiredErrorDevice, settings.requiredErrorDevice) || $translate.instant('widgets.input-widgets.device-name-required');\n \n $scope.secretKeyLabel = utils.customTranslation(settings.secretKeyLabel, settings.secretKeyLabel) || $translate.instant('widgets.input-widgets.secret-key');\n $scope.requiredErrorSecretKey= utils.customTranslation(settings.requiredErrorSecretKey, settings.requiredErrorSecretKey) || $translate.instant('widgets.input-widgets.secret-key-required');\n \n $scope.labelClaimButon = utils.customTranslation(settings.labelClaimButon, settings.labelClaimButon) || $translate.instant('widgets.input-widgets.claim-device');\n \n $scope.claimDeviceFormGroup = $scope.fb.group(\n {deviceName: ['', [$scope.validators.required]]}\n );\n if ($scope.secretKeyField) {\n $scope.claimDeviceFormGroup.addControl('deviceSecret', $scope.fb.control('', [$scope.validators.required]));\n }\n \n $scope.claim = function(claimDeviceForm) {\n $scope.loading = true;\n\n let deviceName = $scope.claimDeviceFormGroup.get('deviceName').value;\n let claimRequest = {};\n if ($scope.secretKeyField) {\n claimRequest.secretKey = $scope.claimDeviceFormGroup.get('deviceSecret').value;\n }\n deviceService.claimDevice(deviceName, claimRequest, { ignoreErrors: true }).subscribe(\n function (data) {\n successClaim(claimDeviceForm);\n self.ctx.detectChanges();\n },\n function (error) {\n $scope.loading = false;\n if(error.status == 404) {\n $scope.showErrorToast(deviceNotFound, 'bottom', 'left', $scope.toastTargetId);\n } else {\n let errorMessage = failedClaimDevice;\n if (error.status !== 400) {\n if (error.error && error.error.message) {\n errorMessage = error.error.message;\n }\n }\n $scope.showErrorToast(errorMessage, 'bottom', 'left', $scope.toastTargetId);\n } \n self.ctx.detectChanges();\n }\n );\n }\n\n function successClaim(claimDeviceForm) {\n let deviceObj = {\n deviceName: ''\n };\n if ($scope.secretKeyField) {\n deviceObj.deviceSecret = '';\n } \n claimDeviceForm.resetForm(); \n $scope.claimDeviceFormGroup.reset(deviceObj);\n $scope.loading = false;\n $scope.showSuccessToast(successfulClaim, 2000, 'bottom', 'left', $scope.toastTargetId);\n self.ctx.updateAliases();\n }\n \n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-device-claiming-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"deviceSecret\":true,\"showLabel\":true},\"title\":\"Device claiming widget\",\"dropShadow\":true,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":false,\"enableDataExport\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "markers_placement_image_map", + "name": "Markers Placement - Image Map", + "image": "data:image/png;base64,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", + "description": "Allows configuring location of the selected entities on the image map.", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6.5, + "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 TbMapWidgetV2('image-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}

Delete\",\"markerImageSize\":34,\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"color\":\"#fe7569\",\"mapImageUrl\":\"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMTEzNC41MTgzIgogICBoZWlnaHQ9Ijc2Mi43ODI0MSIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC41IHIxMDA0MCIKICAgc29kaXBvZGk6ZG9jbmFtZT0id2ljaGl0YW1hcC1ub2xpYi5zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM0IiAvPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0iYmFzZSIKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMS4wIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwLjAiCiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTp6b29tPSIwLjM1IgogICAgIGlua3NjYXBlOmN4PSI4OS45MDc4NTciCiAgICAgaW5rc2NhcGU6Y3k9IjQ1My43ODI0MSIKICAgICBpbmtzY2FwZTpkb2N1bWVudC11bml0cz0icHgiCiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ibGF5ZXIxIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjEzNjYiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iNzIxIgogICAgIGlua3NjYXBlOndpbmRvdy14PSItNCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpvYmplY3QtcGF0aHM9InRydWUiCiAgICAgaW5rc2NhcGU6c25hcC1nbG9iYWw9ImZhbHNlIgogICAgIHNob3dndWlkZXM9InRydWUiCiAgICAgaW5rc2NhcGU6Z3VpZGUtYmJveD0idHJ1ZSIKICAgICBmaXQtbWFyZ2luLXRvcD0iMCIKICAgICBmaXQtbWFyZ2luLWxlZnQ9IjAiCiAgICAgZml0LW1hcmdpbi1yaWdodD0iMCIKICAgICBmaXQtbWFyZ2luLWJvdHRvbT0iMCIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjcuMDcxNDI4LC0zMDcuOTAyOTkpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDM3ODciCiAgICAgICBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMzY0ZTU5O3N0cm9rZS13aWR0aDoyLjk5OTk5OTc2O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmUiCiAgICAgICBkPSJtIDkwNi4wMzMxNSw3MDYuMTMzNjcgMy40MjkyLDE3Ljc5NTUyIE0gMjguNTcxNDI4LDc2NS4wNTA2NyBjIDE1MC40MzUyMDIsNi44MzM0MiAxNDYuMzkyMzIyLC0yNi4zMzQxNSAxNjYuNDM0NTQyLC0yOS4zMjAwOSAzNi4xNDM3NSwtNS4zODQ3NiAxMTQuMjg2NzYsLTYuNTI1NCAxNDguMzI1MDgsLTguNjIzNTQgNDMuMzc4MDgsLTIuNjczODUgMTQxLjc2MjIxLC0xMS4yMzA5OSAxODguODU1NzgsLTE5LjgzNDE4IDM5LjgxMTM4LC03LjI3Mjg0IDIyMS4zNjk5MSwtMC44NjIzNSAzMTkuMDcxNDEsLTAuODYyMzUgNzAuODI3MzUsMCAxNDYuOTE4NjcsLTEuNzI0NyAyMTguMTc1ODYsLTEuNzI0NyAtMzEuNjE5NywwIDExNy44NTUyLC0yLjU4NzA3IDg2LjIzNTUsLTIuNTg3MDcgbSAtMjUuMDkwNywtNjguMTI2MDYgYyAtNTIuNzk5NiwzNC43ODQ4NCAtNjUuODk1MSw1MS43NDg2NSAtOTUuNjM5LDgxLjQ5MjU4IC0yNC45MzEzLDI0LjkzMTI3IC0xNDAuMzk2NTMsLTE5LjEzOTIgLTE3OC45Mzg3MSwzNi42NTAwNyAtMTIuMjgxNCwxNy43NzcxNSAtNDcuMDAyNTcsNDYuNTQ2NTMgLTY1LjEwNzgzLDU5LjA3MTMzIC0yMC4xMDUsMTMuOTA4MTggLTU2LjAzNjcyLDQ0Ljk1NjY0IC02Ny43Njg4NSw3My4wNzgyNyAtNC44MDE0NywxMS41MDkwMiAtMTMuMzgwNDYsMzUuOTkyOTggLTIzLjQ0OTQ5LDQ2LjA2MjAxIC0xMC40OTY5OSwxMC40OTY5OSAtMzguMzc3MzMsNi4zODU2OSAtNDQuMDIzNDUsMTcuNjQ3NjQgLTE5LjAwNTAyLDM3LjkwODEyIC0yNS40NjUzLDEwMC45MjM1MiAtNjcuNjE3ODksMTAyLjA1MTAyIG0gMTkuMjgxNTEsLTYyNC4wMTQ2NCBjIDM0LjY1OTM0LC0xLjg3MzgyIDg0LjAyNzMzLDcuMzkxMzEgMTA5LjkwMDcxLC00LjI4NTQ1IDEzLjI4MTcyLC01Ljk5NDA4IDQxLjQwNzIxLC0yLjQ2MTM1IDY2LjgyODY2LC0yLjMyMDQ2IDM1LjMyMjM4LDAuMTk1NzggNjQuMzgyNDksMC42MzQ3NyAxMDEuOTE2Nyw1LjAyMzIgMjUuMDMwMzYsMi45MjY1IDQ0LjY2MjczLDM0LjI4NzIyIDU4LjUyNjk4LDUwLjY0MzkgMTcuMDk4NzgsMjAuMTcyNjggNjIuNzYzODYsLTEuNzE0NjcgNjYuMzA1NjYsMzIuMTM0MzMgNS4xMDI3LDQ4Ljc2NTg3IC02LjMyODQsNzguNjM3MjUgNi4xNDExLDk3LjM0MTUgMTkuOTY5MiwyOS45NTM3OSA1MC40ODY0LDE3Ljg1NTc5IDQ0LjYxOTMsODMuOTcxMTkgTSA1ODkuMTAyMjcsMzA5LjcyNzE1IGMgNC42NDM0NiwyMy43MjkyMyAxNS4wNjkwNCw3Mi43NzU3NSAxOS4wNjEyOCwxMzAuNjQyODggMC44NzIwNiwxMi42NDA0OCA1LjQ0NzE4LDI0Ljk5MjUzIDQuMjIyMzEsNDUuMjc3NTcgLTIuNTE3MjEsNDEuNjg3NSAtMTUuNzE3MDYsNDMuNjc3MjcgLTE1LjA5MTIyLDYwLjM2NDg2IDEuNDMxOTUsMzguMTgyMjQgMzAuNjEzNjEsOTMuODM3MTkgMzAuNjEzNjEsMTM5LjcwMTU0IDAsMjQuMTgwOCAtMi42Njk2NCwxMTUuMzkwNDUgNy4zMzAwMSwxMzUuMzg5NzYgMC4xNTkxMSwwLjMxODIxIDEwLjA2NDc2LDM1Ljg4MzMyIDEwLjc3OTQ1LDQ5LjE1NDI0IDAuOTQzNzgsMTcuNTI0NjkgLTI0LjQ3OCwzOS40NzAwOCAtMjguMDI2NTUsNDYuNTY3MTYgLTUuNDc3NywxMC45NTUzOSAtMzYuOTczMjQsMTAuODgxOTcgLTQwLjA5OTUsMjQuMTQ1OTUgLTMuODY4ODQsMTYuNDE0NTEgLTMuODY2Myw0My43OTczNSA0LjA0NjQ3LDU5LjQ0MTI5IG0gOTcuMzM3MzQsLTY5MS4wMDk0MSBjIC01LjAxMzMyLDM1LjUxNTk1IC00My42NTkwMSwxMS4zMTY1MiAtNTguNTM4NjEsMjMuNzgxMzEgLTIxLjMzMDE5LDE3Ljg2ODUyIC02Mi40OTk2NCwzMS40MzIxMiAtNzAuMTI0MzcsMzUuMzY3MDggLTM1LjA4NzYzLDE4LjEwNzkzIC0xMTAuNDcyMTUsLTE1LjE0MTk2IC0xMjUuNjE0MSw0LjI2ODQzIC0xNS45NTA2MywyMC40NDcwMyAtMC4wNzM1LDYxLjQ2NjQ4IC05LjE0NjY2LDg0LjE0OTI0IC02LjAzNTcsMTUuMDg5MjYgLTE4Ljg3NjcsMjMuMDE3MzQgLTI3LjQzOTk3LDMyLjkyNzk4IC0xOS43NDgyOSwyMi44NTU1NSAtNjkuOTc0MjgsNjkuODI0MTkgLTg0Ljc1OTA0LDEwMC4wMDM0NiAtNy40OTc0MSwxNS4zMDQwNCAtMy4yODQyNiw0NC40MjA0MSAtMy40NzA1Myw2My4zNDI4NCAtMC4xMjc5MywxMi45OTQxNCAtMC44MTAxNSwyMy4xMDM4NSAyLjQwMzQzLDI4LjI3NjE4IDQuOTYxNTgsNy45ODU4MSAyMy43MjA1LDI4LjExMjA3IDI0LjIzODY1LDUwLjYxMTQ5IDAuMjk0MTEsMTIuNzcxNDYgMC4wMTMzLDc4LjU5MTAxIDMuMDQ4ODgsODcuNjU1NDkgMi4zMTI1Niw2LjkwNTQ2IDQuMjIwMDQsMjYuNTY0OTcgMTAuMjEzNzcsMzYuNTg2NjIgMTEuMzU0MDEsMTguOTg0MTUgNC4zODczNyw0MC4xNTY2MiAyNy44OTczLDUzLjUwNzk1IDE5LjA1MDEyLDEwLjgxODU5IDQ2Ljg3NzgxLDEyLjIxODYyIDgxLjkyNjE4LDE0LjQ2MDU0IDMzLjcwMzQ1LDIuMTU1ODkgNjEuNTEyMTcsLTEuNDMwMzUgNzYuOTIwNzcsNi4xNDExIDExLjU4NTA4LDUuNjkyNjYgOC41ODE1MSwxNy45MzM0NCAxNC4yOTU0MSwyOS4zNjEyMyA1LjY0MDQyLDExLjI4MDg1IDMxLjUwMjYzLDExLjE1NjI3IDQxLjgwNDA5LDQzLjQ1NDg3IDcuNjA1OSwyMy44NDcxIDMuMDg1OTMsNDQuMTU2OSA2LjcwNzU1LDY1Ljg4NjYiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjY2Nzc3NzY2Njc3Nzc3NzY2Nzc3Nzc3NjY3Nzc3Njc3NzY2Nzc3Nzc3Nzc3Nzc3Nzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgIGQ9Im0gNDMuMjc3ODgxLDUxNy45NDY3OSBjIDAsMCAyMzAuODQ4Mjg5LC0zLjYzODA1IDI1MC4wMDg2MzksLTMuNjU4NjcgNy40ODIyMiwtMC4wMDggOC42MTk1NCw1LjE1MTk0IDE0LjAyMDksMTEuNDU4NjkgMjQuNTk2MDgsMjguNzE4OTMgOTMuOTA5NjYsMTEyLjkzNTg1IDkzLjkwOTY2LDExMi45MzU4NSIKICAgICAgIGlkPSJwYXRoMzc4OSIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICBkPSJtIDM1Ljk2MDU1NSw1NzcuNzA0OTQgYyAwLDAgMTY1LjUyNDU2NSwtMS42ODQ1NCAyNDguNzc5NTY1LC0xLjY4NDU0IDQuOTQ3NDksMCA3LjcyOTkzLC0yLjg4MzMgMTAuNTM3NzEsLTUuNzI5NzcgOS42NjEwNywtOS43OTQxNiAyNS42MzE5OSwtMjguNTg5OTUgMjUuNjMxOTksLTI4LjU4OTk1IgogICAgICAgaWQ9InBhdGgzNzkxIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOiMzMzMzNjY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gMzguMzk5NjYzLDY0MS43MzE1NSA0MzEuNzA1OTMsNjM3LjQ2MzExIgogICAgICAgaWQ9InBhdGgzNzk1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOiMzMzMzNjY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gMzkuMDA5NDQyLDcwNC41Mzg1OSA1MjMuMTcyNTMsNjk3LjgzMTA0IgogICAgICAgaWQ9InBhdGgzNzk3IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzAzLjk1NzYyLDY4Mi41ODY2MSAxNDYuNzk1NDIsMS44MjkzMyBjIDEwLjUzNDAzLDAuMTMxMjcgMTQuMzQzNzQsLTIuNjM3MzkgMjUuNDg3MTUsLTYuMzcyOCAxMC40MTIxMiwtMy40OTAyNyAzMS40MjQxNSwtMi42OTg5NiA0MS4zODUzOCwtMi43NzM4NSBsIDQwNS41NjA3OSwtMy4wNDg5IgogICAgICAgaWQ9InBhdGgzNzk5IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgaWQ9InBhdGgzODA0IgogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDQyNi4yMTc5NCwzMTQuODkwOTggYyAyLjA2NzU0LDkuMDUyNzMgMS44NDE3Nyw1MS43Mjc3NyA2LjUwNzk0LDc0LjgzNDY2IDEuNjc0NzUsOC4yOTMzNiA4LjY3NTA4LDE0LjA2NTk4IDEwLjA1NTQxLDE0Ljg1ODYyIDQuOTAxNDcsMi44MTQ2MyAxMC44MTQ3OSw4LjE0OTgyIDEzLjA0NTc5LDE2LjA4ODMxIDYuNzU3NzksMjQuMDQ1OTEgMC44Nzk3Miw2OC40NTIxMiAwLjg3OTcyLDExMC42ODkzIDAsNi4wOTc4MiAxLjY2MDEsMzAuMTQ2NiAtMi4xNTU4OCwzMy45NjI1OSAtMi41NDA4NSwyLjU0MDgzIC0wLjI4MTYzLDEyLjk5MDY5IC0zLjQzNjc1LDE2LjE0Mzc3IGwgLTkuODQ5NDQsOS44NDMxMSBjIC0xMC4zNjcxNSwxMC4zNjA0NyAtMTEuNTkwMTcsNi41MjYxNCAtMTcuNzM4NDgsMTguODIyNzYgLTMuNTY3NzIsNy4xMzU0MyA1LjQwMjM1LDIwLjY3MjEgNy4zNTQzMiwyNC41NzYwMiAxLjkzMjE0LDMuODY0MyAtMS44NDIxNiw0Ljc3NzczIC0xLjc5MjM1LDcuNDQ2MjYgMC4yNTI4NiwxMy41NDQ4MyAyLjI5NzUsMzczLjkyNzEyIDIuMjk3NSwzNzMuOTI3MTIiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDM2NS4yNDAyMiw1MTkuNzc2MTIgNC4xMTU5OSw1MDIuMTUxNTgiCiAgICAgICBpZD0icGF0aDM4MDYiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAxMTYuNTMxNjUsNTA0LjE4Njk5IDMuODgwNTksMzEwLjk2NDM2IgogICAgICAgaWQ9InBhdGgzODMxIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDM4ODkiCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzE3LjY3NzYsNTc2LjQ4NTM5IDEzMC4xODc0MiwxLjUyNDQ0IGMgNC41MTA3OSwzLjI0MTY5IDIwLjM0NDcxLDcuOTY4NTMgMjcuNzQ0ODYsNC4yNjg0NCAzLjE1NTQ2LC0xLjU3NzcyIDkuNDE5LC01LjM4ODE3IDE0LjAyNDg5LC0zLjk2MzU1IDQuMjY2OTgsMS4zMTk4MSA2LjAxNjg5LDMuMTE2MzIgMTAuMzY2MjEsMy4wNDg4OSAxMC4zMDQwMywtMC4xNTk3NSAyMC4yMTE3LDAuMzg3NDEgMzAuNDg4ODYsMC4zMDQ4OSAxNzcuODkwOCwtMS40MjgyNyAzNTYuNTkwMzUsLTIuMTMyNDcgNTM0Ljc3NDU2LC0zLjA0ODg4IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY2Nzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDc1LjMwNTAxLDU4Mi44ODgwNSBjIC0zLjQ0NDE4LDExLjM1MDY2IC0yLjEwMzQzLDEyLjQzMzczIDMuNjU4NjUsMjEuMDM3MzEgMy43OTQ0NSw1LjY2NTY0IDUwLjg2MjYxLDEzLjAzODQ1IDQxLjQ2NDg1LDI3LjEzNTA5IC0xMC41MzY5NywxNS44MDU0NyAtMjIuODk3NDUsLTUuNDc3NzIgLTMzLjg0MjYzLC0xLjgyOTMzIC01LjQ1MjM2LDEuODE3NDUgLTcuMzQ5MDEsNS40NTYzMSAtMy42NTg2Niw5LjE0NjY1IDIuODA2ODMsMi44MDY4NCA0LjA0OCwxLjgwMzk2IDYuNTIwMzQsNS4xMDA0MSIKICAgICAgIGlkPSJwYXRoMzkxMCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDMyLjAxMDgyLDYzNi44NTMzMyBjIDguMzE4OTksMTMuMTEwMTYgMTguODQ2MjEsMTQuNjM0NjUgMzUuNjcxOTYsMTQuNjM0NjUgMi45Mzg2NSwwIDcuODY5OTgsLTAuOTMzNzEgMTAuNjcxMTEsMCAxMS4zNTkxNywzLjc4NjM5IDI3LjE5Mzk4LDEwLjI3NTc3IDM2LjIwMTkzLDIxLjEyOTQ4IDguMjgwMDIsOS45NzY2MSAxMC4yNTI3OCwyMy44ODMwOCA3LjcwMjAyLDM3LjEwNDI0IC02LjE2OTg5LDMxLjk3OTk4IC0xNi43MTQzMSw1Ni45ODg1MyAtMTkuMDQzNTUsODYuNTY5MDUgLTEuMzQ3OTgsMTcuMTE4OCA0LjUwOTU3LDIyLjUzNTIyIDExLjA3MTQzLDMzLjkyODU3IDEwLjY3MDIzLDE4LjUyNjcyIDguNzI0NTMsMTQuMTk5NTUgOC41NzE0MywzNC4yODU3MiAtMC4xMzk2MywxOC4zMTk0NCAwLDYwLjI2Mzg1IDAsODAuNzE0MjkiCiAgICAgICBpZD0icGF0aDM5MTIiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDUyOC41MDgwNiw2NTguOTU3NzYgYyAtMTAuNjgxMjMsMC45MDQ1NCAtNy4xMDgwNCwtNS42MDI1NSAtMTAuODIzNTQsLTguMDc5NTYgLTQuNzg0NTQsLTMuMTg5NjkgLTEyLjIyNzA0LC0xLjI1MTA0IC0xNi43Njg4OCwtNS43OTI4OCAtMC42NjYxMiwtMC42NjYxMiAtOC44MDk2OSwtNC4xMDg3NyAtMTAuMTc0NDcsLTIuNzQzOTkgLTguMzY0NTksOC4zNjQ1OSAtMy4wNDg4OCwyMC41NTE4OCAtMy4wNDg4OCwzMy41Mzc3NCBsIDMuMDIyLDMzOS42OTc0MyIKICAgICAgIGlkPSJwYXRoMzkxNCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA1MTcuOTg5NDEsNjUxLjAzMDY1IGMgLTAuMjIxNzEsLTIuNzAxODQgMS45MDM0NiwtNS41NjIxMyAzLjM1Mzc3LC03LjAxMjQ1IDEuNzk5NDMsLTEuNzk5NDIgNi45MjI5NCwxLjAwNDE5IDguODQxNzgsLTAuOTE0NjYgMC4yODc2NSwtMC4yODc2NiAwLjg0MzI5LC0xMS4xNjQxIDAuMjI4NjYsLTEzLjU2NzUzIC0yLjA2NDgzLC04LjA3NDE2IC0yLjA1ODAxLC0yOC42NTY1OCAtMi4wNTgwMSwtMzguNzIwODYgbCAwLC03My4xNzMyNiIKICAgICAgIGlkPSJwYXRoMzkxNiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNTI4LjY2MDUsNjc1LjQyMTczIC0wLjQ1NzMzLC0zMS41NTU5NiIKICAgICAgIGlkPSJwYXRoMzk3NCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDc2Ni4zMTYyNSw1NzkuNjQ0MzEgMC40MzExOCwxMy43OTc2OCBjIDMuMTM2NDMsNC42NjkxNSAzLjAxODI0LDkuNjAwNjggMy4wMTgyNCwxNi4zODQ3NSBsIDAsMTU3LjM3OTgxIgogICAgICAgaWQ9InBhdGgzOTgyIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMTEyMi45MDAxLDc2NS45MTMwMyBjIC0yMDIuMzA2NjksNC42OTA1IC00MDMuNzQ0MDUsLTEuMTEzODEgLTYwNS45NTQ1NCwzLjM1MzkgLTEwLjg2MzYyLDAuMjQwMDIgLTMuMzYxNDcsLTguNTg2MyAtMjguNTM2OCwtOC41ODYzIgogICAgICAgaWQ9InBhdGgzOTg0IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA4NjAuMDA4MDUsNzM3LjA2NjUxIGMgMCwwIC05Ny40NDc1LDAuODU4MDYgLTE0Ny41Njg5MiwwLjg1ODA2IC01LjI2ODYxLDAgLTQuNTE1NDYsLTguMzI5ODYgLTcuMzAwODksLTguMzI5ODYgLTMuOTc0MzUsMCAtOC42MjkyNSwwLjAyMDEgLTEwLjUwOTQ4LDAuMDM1OSAtMi4zMzQ3NywwLjAxOTcgLTEuODEwOTQsOC4zNjU5NyAtNC4xNDU4LDguMzY2OTIgLTQ2LjE2ODk5LDAuMDE4OCAtMTY3LjQwNzY3LC0xLjMwNzk5IC0xNzUuMDUyNjMsLTEuMzA3OTkgLTQuNDI5NTUsMCAtOC41NzYyNywtNi40Mzk3MiAtMTMuMTMxOTgsLTYuNDM5NzIgLTEuMzYxMTUsMCAtNi4yMzg3MywwIC0xNC4zOTQ2NywwIgogICAgICAgaWQ9InBhdGgzOTg2IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJNIDY3NS4wMDcwMyw4MzEuMTc0MDIgNjc0LjM5NzI1LDMwOS40MDI5OSIKICAgICAgIGlkPSJwYXRoMzk4OCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDc5OS40MDE1NywzMTMuMDYxNjUgMS4yMTk1NSw0OTUuODY2NTMiCiAgICAgICBpZD0icGF0aDM5OTAiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA3MzYuNTk0NTIsMzEyLjQ1MTg4IC0xLjIxOTU1LDcxNi40ODgyMiIKICAgICAgIGlkPSJwYXRoMzk5MiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDUzMC4wMzA5NCw2NDMuNDU4NTkgMzkyLjM3MTU5LC0zLjAxODI1IgogICAgICAgaWQ9InBhdGg0MDQ4IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gODU5LjQ1MDYsMzE0LjkwMTI4IDEuMjkzNTQsNTA3Ljk4MDU4IgogICAgICAgaWQ9InBhdGg0MDUwIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5OTRweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gOTIxLjU0MDE3LDMxMC41ODk0OSAxLjcyNDcxLDUzMS43NTIyNyIKICAgICAgIGlkPSJwYXRoNDA1MiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDczNi4yODk2Myw0NTMuMzEwNCAxODUuNjc3MTUsLTAuMzA0ODkiCiAgICAgICBpZD0icGF0aDQxODciCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAxMDYwLjgxMDUsNTE0Ljk2NzY3IGMgMCwwIC0zNjMuMjgxMjYsLTUuNjI2MTggLTU0NC42NTA0MiwyLjUyMTc4IC00LjE3Nzc2LDAuMTg3NjkgLTEyLjUwMDQ0LDEuMDY3MTEgLTEyLjUwMDQ0LDEuMDY3MTEgLTEuNTcwOTUsMC4xMzQxIC0yLjAwMDkzLC0yLjMyNDk1IC0yLjU5MTU1LC0zLjUwNjIzIC0wLjA5NjcsLTAuMTkzNDMgLTcuMDYwODEsLTEuOTMzNCAtNy42MjIyMSwtMS4zNzE5OSAtMi44OTMxNCwyLjg5MzE0IC03LjYzMTY3LDQuMjQ4NjkgLTEyLjE5NTU1LDQuMTE2IEwgMzY5LjIwMTcsNTE0LjUzNjUiCiAgICAgICBpZD0icGF0aDQyNjEiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAzOTkuODE1MzEsNDc5LjYxMTEyIDExLjY0MTgsNS42MDUzIGMgMi45ODQxMiwxLjQzNjc5IDYuNTI4NzgsLTAuNDc3MTIgOS45MTcwOCwtMC40MzExOCBsIDEyNy4xOTczOSwxLjcyNDcxIgogICAgICAgaWQ9InBhdGg0MjYzIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gNTE5LjI1MTUxLDUxNy4xMjM1NyA1MTguODIwMzIsMzA4LjQzMzYyIgogICAgICAgaWQ9InBhdGg0MjY1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDMyLjkyNTQ5LDM4OS43MTQ5OCBjIDExLjA0NDk2LDAgMzUuNTMzMDcsMC42MTkyNyA0Mi41Nzk3OCwtMS4wMDM5NyA4LjQwNTIyLC0xLjkzNjE4IDcuMDY2LC02Ljk1Mzc4IDE0LjE5NzEyLC02Ljk1Mzc4IDcuODA5NSwwIDYuNTQyOTEsOC4wNjIzNyAyMC4xNDE3LDguMDYyMzcgMTMuOTkwNjgsMCA0NC45NzY4OSwwLjM3ODg2IDYzLjkzOTkyLDAuMzc4ODYgMTIuMDgzOTUsMCA4Mi4wMDI2NiwwLjMwNDg5IDkzLjYwMDgxLDAuMzA0ODkgOC43NjA0NywwIDEzLjE1OTcsLTIuMjg4MjcgMjEuMzQyMTksLTcuMDEyNDMgNy4xOTUxNSwtNC4xNTQxMyAyLjA1NDU5LC05LjQ5MTM3IDIwLjQyNzU0LC04Ljg0MTc3IDIzLjE0NTQsMC44MTgzMyAxMi42NDMzNCwxNC4wMjQ4NyAzMi4zMTgxOSwxNC4wMjQ4NyAyNS4zNTk1NCwwIDEzMC45OTkwMiwwIDE1MC45MTk4NSwwIDE0LjMzMjQ0LDAgLTQuMTE5MTEsLTEzLjExMDIxIDI5LjI2OTMsLTEzLjQxNTEiCiAgICAgICBpZD0icGF0aDQyNjkiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc3NzYyIgLz4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjU4OC42Nzk1NyIKICAgICAgIHk9IjczNS44MDQ2MyIKICAgICAgIGlkPSJ0ZXh0NDMxMCIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMiIKICAgICAgICAgeD0iNTg4LjY3OTU3IgogICAgICAgICB5PSI3MzUuODA0NjMiPkxpbmNvbG48L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjY4Ni4zOTg1IgogICAgICAgeT0iNzY1LjYyODQyIgogICAgICAgaWQ9InRleHQ0MzEwLTciCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNiIKICAgICAgICAgeD0iNjg2LjM5ODUiCiAgICAgICAgIHk9Ijc2NS42Mjg0MiI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjcwOS44NzE4MyIKICAgICAgIHk9Ii04MDIuMzc3MzgiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMi02LTgiCiAgICAgICAgIHg9IjcwOS44NzE4MyIKICAgICAgICAgeT0iLTgwMi4zNzczOCI+V29vZGxhd248L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjU2Mi4xMTkyNiIKICAgICAgIHk9Ii03NzEuOTY4MTQiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xLTkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtOC0yIgogICAgICAgICB4PSI1NjIuMTE5MjYiCiAgICAgICAgIHk9Ii03NzEuOTY4MTQiPkVkZ2Vtb29yPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTguMzA0ODciCiAgICAgICB5PSItNzM4LjM2NjQ2IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTciCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtOC0yLTkiCiAgICAgICAgIHg9IjU5OC4zMDQ4NyIKICAgICAgICAgeT0iLTczOC4zNjY0NiI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTIuMTIyODYiCiAgICAgICB5PSItNjc3LjIwMzk4IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTctNSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNi04LTItOS00IgogICAgICAgICB4PSI1OTIuMTIyODYiCiAgICAgICAgIHk9Ii02NzcuMjAzOTgiPkhpbGxzaWRlPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTcuMzI3MDkiCiAgICAgICB5PSItODYyLjYxNDA3IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTctNS0zIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMi02LTgtMi05LTQtMSIKICAgICAgICAgeD0iNTk3LjMyNzA5IgogICAgICAgICB5PSItODYyLjYxNDA3Ij5Sb2NrPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1ODcuMzcwMTgiCiAgICAgICB5PSItOTI2LjEzNjYiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xLTktNy01LTMtMiIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNi04LTItOS00LTEtMyIKICAgICAgICAgeD0iNTg3LjM3MDE4IgogICAgICAgICB5PSItOTI2LjEzNjYiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9Ijg3MS4xNjEwMSIKICAgICAgIHk9IjYzNy41NzUyIgogICAgICAgaWQ9InRleHQ0NDY1IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NDY3IgogICAgICAgICB4PSI4NzEuMTYxMDEiCiAgICAgICAgIHk9IjYzNy41NzUyIj5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI4NzMuODMyMjgiCiAgICAgICB5PSI1NzcuMDMyNDciCiAgICAgICBpZD0idGV4dDQ0NjUtMyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDQ2Ny00IgogICAgICAgICB4PSI4NzMuODMyMjgiCiAgICAgICAgIHk9IjU3Ny4wMzI0NyI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgaWQ9InRleHQ0NDkwIgogICAgICAgeT0iNTEwLjI2MTgxIgogICAgICAgeD0iODc1Ljk2NjQ5IgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbgogICAgICAgICB5PSI1MTAuMjYxODEiCiAgICAgICAgIHg9Ijg3NS45NjY0OSIKICAgICAgICAgaWQ9InRzcGFuNDQ5MiIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iODgxLjMxNjU5IgogICAgICAgeT0iNDUwLjE5ODc2IgogICAgICAgaWQ9InRleHQ0NDk0IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NDk2IgogICAgICAgICB4PSI4ODEuMzE2NTkiCiAgICAgICAgIHk9IjQ1MC4xOTg3NiI+Mjl0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNjE1Ljc5MjQ4IgogICAgICAgeT0iMzg3Ljc0NzE2IgogICAgICAgaWQ9InRleHQ0NDY1LTMtMSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDQ2Ny00LTEiCiAgICAgICAgIHg9IjYxNS43OTI0OCIKICAgICAgICAgeT0iMzg3Ljc0NzE2Ij4zN3RoPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ1MTkiCiAgICAgICB5PSI0ODEuNjUyODYiCiAgICAgICB4PSI0ODQuNjkwMzciCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9IjQ4MS42NTI4NiIKICAgICAgICAgeD0iNDg0LjY5MDM3IgogICAgICAgICBpZD0idHNwYW40NTIxIgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIj4yNXRoPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1NjMuMDQ2NzUiCiAgICAgICB5PSI1MTMuMzYxMzMiCiAgICAgICBpZD0idGV4dDQ1MjMiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1MjUiCiAgICAgICAgIHg9IjU2My4wNDY3NSIKICAgICAgICAgeT0iNTEzLjM2MTMzIj4yMXN0PC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ1MjciCiAgICAgICB5PSI1NzcuODk0ODQiCiAgICAgICB4PSI1NjUuOTcxNSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNTc3Ljg5NDg0IgogICAgICAgICB4PSI1NjUuOTcxNSIKICAgICAgICAgaWQ9InRzcGFuNDUyOSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDUzMSIKICAgICAgIHk9Ii00NjAuNzMzMTIiCiAgICAgICB4PSI0MzMuNTgwNzUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii00NjAuNzMzMTIiCiAgICAgICAgIHg9IjQzMy41ODA3NSIKICAgICAgICAgaWQ9InRzcGFuNDUzMyIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+QW1pZG9uPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI0MDUuNTMwOTgiCiAgICAgICB5PSItNTIzLjU0MDE2IgogICAgICAgaWQ9InRleHQ0NTM1IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDUzNyIKICAgICAgICAgeD0iNDA1LjUzMDk4IgogICAgICAgICB5PSItNTIzLjU0MDE2Ij5BcmthbnNhczwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDUzOSIKICAgICAgIHk9Ii0zNzIuNTg1OTQiCiAgICAgICB4PSI3NDUuNDg0NjIiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii0zNzIuNTg1OTQiCiAgICAgICAgIHg9Ijc0NS40ODQ2MiIKICAgICAgICAgaWQ9InRzcGFuNDU0MSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+V2VzdDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNTk2LjcyODMzIgogICAgICAgeT0iLTUzMS4yNTkyOCIKICAgICAgIGlkPSJ0ZXh0NDU0MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NDUiCiAgICAgICAgIHg9IjU5Ni43MjgzMyIKICAgICAgICAgeT0iLTUzMS4yNTkyOCI+V2FjbzwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU1NSIKICAgICAgIHk9Ii0xMjIuNTAyOTUiCiAgICAgICB4PSI1OTUuNDM0ODEiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii0xMjIuNTAyOTUiCiAgICAgICAgIHg9IjU5NS40MzQ4MSIKICAgICAgICAgaWQ9InRzcGFuNDU1NyIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+TWF6aWU8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjY5NS43NzI5NSIKICAgICAgIHk9IjE2Mi4wNjg3NyIKICAgICAgIGlkPSJ0ZXh0NDU1OSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC43MDcxMDY3OCwwLjcwNzEwNjc4LC0wLjcwNzEwNjc4LDAuNzA3MTA2NzgsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NjEiCiAgICAgICAgIHg9IjY5NS43NzI5NSIKICAgICAgICAgeT0iMTYyLjA2ODc3Ij5ab288L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjI0MC41ODk5NyIKICAgICAgIHk9IjU3NC40NDU0MyIKICAgICAgIGlkPSJ0ZXh0NDU2MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDU2NSIKICAgICAgICAgeD0iMjQwLjU4OTk3IgogICAgICAgICB5PSI1NzQuNDQ1NDMiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU2NyIKICAgICAgIHk9IjUxMS42MzY2MyIKICAgICAgIHg9IjIwNi4wMzE3NSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNTExLjYzNjYzIgogICAgICAgICB4PSIyMDYuMDMxNzUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NjkiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjYyMC40NDMxMiIKICAgICAgIHk9Ii01MDYuNjgyMTkiCiAgICAgICBpZD0idGV4dDQ1NzEiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NTczIgogICAgICAgICB4PSI2MjAuNDQzMTIiCiAgICAgICAgIHk9Ii01MDYuNjgyMTkiPk5pbXM8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU4MyIKICAgICAgIHk9IjY5OC44NDAwOSIKICAgICAgIHg9IjM3MC4yMTY4NiIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNjk4Ljg0MDA5IgogICAgICAgICB4PSIzNzAuMjE2ODYiCiAgICAgICAgIGlkPSJ0c3BhbjQ1ODUiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1hcGxlPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSIzODQuMDg0MiIKICAgICAgIHk9IjY4MC44NTEzOCIKICAgICAgIGlkPSJ0ZXh0NDU5OSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDYwMSIKICAgICAgICAgeD0iMzg0LjA4NDIiCiAgICAgICAgIHk9IjY4MC44NTEzOCI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAzNjcuOTA4MTcsMTAwOS45NTk2IDI2My4wMTgzMywwIgogICAgICAgaWQ9InBhdGg0NjA1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDx0ZXh0CiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ2MDciCiAgICAgICB5PSItNDMzLjEzNzc2IgogICAgICAgeD0iNzM2LjI2NzQ2IgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbgogICAgICAgICB5PSItNDMzLjEzNzc2IgogICAgICAgICB4PSI3MzYuMjY3NDYiCiAgICAgICAgIGlkPSJ0c3BhbjQ2MDkiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1lcmlkaWFuPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ5NzkiCiAgICAgICB5PSI2NDAuMjA1MjYiCiAgICAgICB4PSI1NzIuODMyMTUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9IjY0MC4yMDUyNiIKICAgICAgICAgeD0iNTcyLjgzMjE1IgogICAgICAgICBpZD0idHNwYW40OTgxIgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIj5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1NzUuMDg5NjYiCiAgICAgICB5PSI2NzAuOTAzNSIKICAgICAgIGlkPSJ0ZXh0NDk4MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDk4NSIKICAgICAgICAgeD0iNTc1LjA4OTY2IgogICAgICAgICB5PSI2NzAuOTAzNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNDk5LjQ4OTYyIgogICAgICAgeT0iMTAwOC42MDY5IgogICAgICAgaWQ9InRleHQ1MDQ3IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDQ5IgogICAgICAgICB4PSI0OTkuNDg5NjIiCiAgICAgICAgIHk9IjEwMDguNjA2OSI+NDd0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iMjE2LjY0NTQzIgogICAgICAgeT0iNzI1Ljk4Mjk3IgogICAgICAgaWQ9InRleHQ1MDUxIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDUzIgogICAgICAgICB4PSIyMTYuNjQ1NDMiCiAgICAgICAgIHk9IjcyNS45ODI5NyI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICAgPGZsb3dSb290CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgaWQ9ImZsb3dSb290NTA1NSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6MThweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwyODcuMzYyMTgpIj48Zmxvd1JlZ2lvbgogICAgICAgICBpZD0iZmxvd1JlZ2lvbjUwNTciPjxyZWN0CiAgICAgICAgICAgaWQ9InJlY3Q1MDU5IgogICAgICAgICAgIHdpZHRoPSIzNDMuNTcxNDQiCiAgICAgICAgICAgaGVpZ2h0PSIxMDMuNTcxNDMiCiAgICAgICAgICAgeD0iMTkuMjg1NzE1IgogICAgICAgICAgIHk9IjE3LjE0Mjg1NyIKICAgICAgICAgICBzdHlsZT0iZm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIgLz48L2Zsb3dSZWdpb24+PGZsb3dQYXJhCiAgICAgICAgIGlkPSJmbG93UGFyYTUwNjEiPjwvZmxvd1BhcmE+PC9mbG93Um9vdD4gICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDYwNy03IgogICAgICAgeT0iLTUwOC4xODk3MyIKICAgICAgIHg9Ijc3NC44NzU2MSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iLTUwOC4xODk3MyIKICAgICAgICAgeD0iNzc0Ljg3NTYxIgogICAgICAgICBpZD0idHNwYW40NjA5LTciCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1jQ2xlYW48L3RzcGFuPjwvdGV4dD4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzY0LjE1OTk5LDY1OC40Mjg5MSAyOTkuNTEwMjMsLTEuMDEwMTYgYyA2LjQ5ODcyLC0wLjAyMTkgNi45NzcxOSw5LjI1NDEyIDE2LjU5NjMxLDkuMzkyNDcgMTIuMDU0MjcsMC4xNzMzOSAyOS4xMTA4MywtMC41MzU3MiA1NC4xMTQzNywtMC4zMDExIgogICAgICAgaWQ9InBhdGg1NDQwIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMjg3LjM2MjE4KSIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjM3My45OTMwNCIKICAgICAgIHk9Ijk0NC4zNTc1NCIKICAgICAgIGlkPSJ0ZXh0NTA0Ny05IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDQ5LTMiCiAgICAgICAgIHg9IjM3My45OTMwNCIKICAgICAgICAgeT0iOTQ0LjM1NzU0Ij5NYWNBcnRodXI8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ2MDctNy0xIgogICAgICAgeT0iLTQ5MC4yNDU5NyIKICAgICAgIHg9Ijc4MC44NDYwNyIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iLTQ5MC4yNDU5NyIKICAgICAgICAgeD0iNzgwLjg0NjA3IgogICAgICAgICBpZD0idHNwYW40NjA5LTctOSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+U2VuZWNhPC90c3Bhbj48L3RleHQ+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDM2Ny42OTU1Myw1MzcuMjEwNiAxNDEuMjgzMDMsLTEuMDEwMTUgYyA2LjQ4OTk5LC0wLjA0NjQgMTIuNzgxMTQsNy4yMzU0NSAxOS4xOTI5LDcuMzIzNiA1NS45MjM2MiwwLjc2ODkgMTU4LjY4OTk3LC0wLjE3MzMzIDIzNi41MTQwMiwtMS4wMTAxNSA3LjgzOTU2LC0wLjA4NDMgMjIuNjMxNDcsLTE5Ljg1MzU1IDMwLjMwNDU3LC0yMC40NTU1OSAyMi4yNjU4OSwtMS4zNTE4MSA0NS4xNzk0NSwtMC41MDUwNyA2Ny42ODAyMiwtMC41MDUwNyAxNi4xNDczMSwtMC42MzI0MSAzLjYxMDE2LDIwLjcwODEzIDI2Ljc2OTA0LDIwLjcwODEzIGwgMjQzLjQ0Njc5LC0xLjAxMDE2IgogICAgICAgaWQ9InBhdGg1NDk2IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMjg3LjM2MjE4KSIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzY2NjY2MiIC8+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI2ODUuMjA4MTMiCiAgICAgICB5PSI4MjcuNTMwODIiCiAgICAgICBpZD0idGV4dDQzMTAtNy04IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtNiIKICAgICAgICAgeD0iNjg1LjIwODEzIgogICAgICAgICB5PSI4MjcuNTMwODIiPlBhd25lZTwvdHNwYW4+PC90ZXh0PgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0iTSA1NTQuMjg1NzIsNzIxLjQyODU3IDU1MCw1NDMuMjE0MjkgNTQ3LjE0Mjg2LDEwMi41IDU0Ni43ODU3MiwyMy4yMTQyODUiCiAgICAgICBpZD0icGF0aDU1MTkiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwyODcuMzYyMTgpIiAvPgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNTI5LjYyNTMxIgogICAgICAgeT0iLTU1MC44NDc3OCIKICAgICAgIGlkPSJ0ZXh0NDU0My01IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDU0NS0wIgogICAgICAgICB4PSI1MjkuNjI1MzEiCiAgICAgICAgIHk9Ii01NTAuODQ3NzgiPkJyb2Fkd2F5PC90c3Bhbj48L3RleHQ+CiAgPC9nPgo8L3N2Zz4K\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"showTooltip\":true,\"autocloseTooltip\":true,\"showTooltipAction\":\"click\",\"defaultCenterPosition\":\"0,0\",\"provider\":\"image-map\",\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"mapProvider\":\"HERE.normalDay\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\"},\"title\":\"Markers Placement - Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"c39f512a-21c6-6b06-3aa1-715262c6553d\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"94bf5ffd-b526-c6c3-ae3b-ab42191217d9\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" + } + }, + { + "alias": "update_integer_timeseries", + "name": "Update integer timeseries", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANBSURBVHja7d3dS1NhHAfw/Sum1IUQBZYJIdSF4VVQIOSNXZiJg5nmWi18CXG9WJgSqd1IBUWu10PY1EBcIFYqyoTCLOZWA8/0uGmbbjs73y7mS+Z2YdjYs76/i3Oe88C5+PB73jhwnkeHiOxyCh4uOQxdxO1XIXiofndEJ/uRBuGXdS41HSCqS+dEWoSTEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYSQfwl59xkA+teqDfLWl9wOESC9twAo5QsJIdHI6EstkvoQX4UP6LsNzNo/hgGDrI2oCI0B0XG7DADuSzcttaMC9JG2t0DTMEZqezuvAwY5UhbEvB5a213J9A0Awg0mnwid/cM1zBpCmPQgWrGwARlv1GDvBABbd88TESDhc3Ov7wM/H99oKp/dgEgXWlqaGgFAWYl6hBh+H9rqp4COV1EYYpAA5vWwdXi9XkWoeeRLjVkDGofxqdwDg4zqrxjTY+b8HDwOoSDaZQnAeNXFVuMEDDIGKs1temCwpsE8JeLMHg2u95qVGHBR4xKFEEIIIeT/gyxvKYgB0d5sXhAtN0uxgtS8LBJEsxrtm2sko/TbTRSI9tzY92ddj1FavYgD0axbHYBklOLnI2Uh8fIRy0kCR8pBgvdcALRn8R3bhMjdrdYlAFh62mpd/Uk81J8ciGKp/56gXW27aQ3uPVqac9AJTOUePpOfMw1gom5/VpJSsnC1zpU4H9vp7KEDlSqUQ3qg6GQI4cKzwIvMU6eTBYFiMSXOx3aG38mM9wCMBUDDAIAr+YDzB9qTBoFiie/4uwmxuGJ1+CgqAYBkQhDcwSVKX+YIANi6SvKmkw7ZwUXjzL5GAEDlsT2l8wJD5o+UrO1o4S0sFhcSOH4isP7wYFdAVEikpEABgGBWF4D27KigEK06u9/hcDgUlOUNLQ7lmkRtWksZsXiEhaqsjN3mgMij1lqEPcJsY8OPD4QQQgghhOws5E5SgxkhhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCElNSNocEJweRzb7ZF04PQ7RVnXpcay5il+KVmW7YpZ2rQAAAABJRU5ErkJggg==", + "description": "Simple form to input new integer value for pre-defined timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n\n
\n \n \n
\n
\n\n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.attribute-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}\n", + "controllerScript": "let $scope;\nlet settings;\nlet utils;\nlet translate;\nlet http;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-timeseries-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n\n $scope.attributeUpdateFormGroup = $scope.fb.group(\n {currentValue: [undefined, [$scope.validators.required,\n $scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue),\n $scope.validators.pattern(/^-?[0-9]+$/)]]}\n );\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n );\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n dataKeyOptional: true\n }\n}\n\nself.onDestroy = function() {\n\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-integer-attribute-widget-settings", + "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\":\"Update integer timeseries\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_double_timeseries", + "name": "Update double timeseries", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANMSURBVHja7d3fS5NRGAfw/SsrhSCKKIugburCi6KCLrropi4izME0c1kLUxquHybmKNPAi4qMJkm9hOkswhVkpaLMfhmlbjHYu+3Vaf7a3r3fLjad5nZh6NiZ3+diO+fAe/HhOc85Z+/Fjg4R2T0ieLjlMHQRT0iF4KGGPBGdHEIWREjWudVsgKhu3QiyIkYIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQghZS8jbbwDQOT9slJc/5HGJAOmoAaAUjKWERCO9z7RI5kPGC8cBxy3A5/wUBoyy1qNirg+I9jtlAPBcvGkt7xWgRmyvgKpu9JR3NF4HjHLk9DSCBmi2eqnsFwCEK8vGRSj2j9fgM85h0Ito4VgC0m/R4GwEgHZ72xMRIOHiwIv7wJ/HN6oKfAmIdL62tsoCAMps1CvE8vuwvWIIaHgehTEGmULQgPYGv9+vCLWP/Cg1a4ClG18LvDDKKPmJPgNGzwXgdQkF0S5JAPrPXqgzDcAo402R2WYAukorzUMi7uzR6YWqmY0BJzQeUQghhBBC1h9kZllDDIj2cumBaKZaijWk6hmRIFqLybl0RDJJi75EgWitJse/Y20mKf4hDkRrWe4AJJOUPB8ZC0mWj1hOUjgyDjJ9zw1Ae5rcsUKIbK9rmVzovatv+hJvfh5ec4hirfidYl6teGp1bd1/Km9XfDhasOnksZxGAIGmA/rWtU/J2NXL7tT5WEmxz+0sUqHsNsR69lwXcDc3AC13b0U6IFCsZanzsZLld1D/AYApP/5wDYBBfTfwPupPCwSKNbnj/zbE44WLOvYcLwCkCYLpVTyiODb2zDe/P7qypQnphKzioXF0myUxMw9t3+MUFBLcd2LxP1qo1bnDQkKmjhydWjqw4YGIkMiJ/MT5ufggAL9eEhCilWzudLlcLgWvbYBDfyfoPpOnCAiZ1MeiGeYdKtCcp9cfHhB11YoXeewFo2+Cv9kJIYQQQghZf5DbaQ1mhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCEkMyFZc0FwdlzZPC7rwtlxibaqy45rzVX8BUWnY6Q1/jMIAAAAAElFTkSuQmCC", + "description": "Simple form to input new double value for pre-defined timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.attribute-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet utils;\nlet translate;\nlet http;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-timeseries-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n\n $scope.attributeUpdateFormGroup = $scope.fb.group(\n {currentValue: [undefined, [$scope.validators.required,\n $scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue)]]}\n );\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n );\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n dataKeyOptional: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-double-attribute-widget-settings", + "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\":\"Update double timeseries\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_boolean_timeseries", + "name": "Update boolean timeseries", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAIQSURBVHja7d3PSxRhHMfx+VcWgmg3ltKD7BJshyBIQusQ3vIYCILCRMyhH6xShlGWMGq1p1gIibxFULRR1F4ixcNqdNhmUXFipWnTYvfZ/TrjJqFQ13ge3p/L8MztBQ/P8/0eZr6WNHyvrHk8vy5WoxIo0TwqqDQsPxADEviWp0yAKM8qixEpAwECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC5H9DSiP2noyUNIXsc9j2qKYQ2/732lzI7LyukN4jUV7uLrtndIWcuP4lzKYBELf9rD66/bzZhqw9vFcM37ReTzz5pR3kc8fAWPriDmT58NDl5F0R51j2zOm6PpB+13VfyNNbIm8O/Iwg430iz87Lu/iaNDKz+kB6Hcd5HH60PDedjfkR5FXi2oeWyI3j+Xy+56puW2vh6KXcnTZEFq90pQripAbC5HSDDDoin9qQjxVRkwk1dUrLU2v4bLAxGFuNIEPnanK/s7WSeNDcct7rBvFOxg7djBUjSLX/YDJdEHmbiccv1PQrUb7++T/B9/Xfd8sPikbK+L81VqP7HEu0ukCAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAADEUYsyAYDNGNn/zrboZQ7SVZcZYcyXbTFGWg8aBYQQAAAAASUVORK5CYII=", + "description": "Simple form to input new boolean value for pre-defined timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{currentValue}}\n \n
\n
\n\n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.attribute-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\r\n overflow: hidden;\r\n height: 100%;\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n\r\n.attribute-update-form__grid {\r\n display: flex;\r\n}\r\n.grid__element:first-child {\r\n flex: 1;\r\n}\r\n\r\n.grid__element {\r\n display: flex;\r\n}\r\n\r\n.attribute-update-form .mat-button.mat-icon-button {\r\n width: 32px;\r\n min-width: 32px;\r\n height: 32px;\r\n min-height: 32px;\r\n padding: 0 !important;\r\n margin: 0 !important;\r\n line-height: 20px;\r\n}\r\n\r\n.attribute-update-form .mat-icon-button mat-icon {\r\n width: 20px;\r\n min-width: 20px;\r\n height: 20px;\r\n min-height: 20px;\r\n font-size: 20px;\r\n}\r\n\r\n.tb-toast {\r\n font-size: 14px!important;\r\n}", + "controllerScript": "let settings;\nlet utils;\nlet translate;\nlet http;\nlet $scope;\nlet map;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init();\n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n settings.trueValue = utils.defaultValue(utils.customTranslation(settings.trueValue, settings.trueValue), true);\n settings.falseValue = utils.defaultValue(utils.customTranslation(settings.falseValue, settings.falseValue), false);\n\n map = {\n true: settings.trueValue,\n false: settings.falseValue\n };\n \n $scope.checkboxValue = false;\n $scope.currentValue = map[$scope.checkboxValue];\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({checkboxValue: [$scope.checkboxValue]});\n\n $scope.changed = function() {\n $scope.checkboxValue = $scope.attributeUpdateFormGroup.get('checkboxValue').value;\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.updateAttribute();\n };\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function() {\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [{\n key: $scope.currentKey,\n value: $scope.checkboxValue\n }]\n );\n\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('checkboxValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n $scope.checkboxValue = self.ctx.data[0].data[0][1] === 'true';\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.attributeUpdateFormGroup.get('checkboxValue').patchValue($scope.checkboxValue);\n self.ctx.detectChanges();\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-boolean-attribute-widget-settings", + "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\":\"Update boolean timeseries\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_string_timeseries", + "name": "Update string timeseries", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAOkSURBVHja7d3bSxRRHAfw/VdG6yklCbPsRk+GhVLYDSXMohC1tczVUkxFXDULzW4a+SA9VIZRm5haIJqBmMqW2cUoddUVR1sved2dnV/nuJaX3X0Q1pjZvj/YM4fDmYfP/uZcdvbhaMgmmnpVHibRShpb/4REKg9pot+mESfIC2JC1Jgkb4BIJk0veUX0AgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAsp6QN194+epPs1Z0vqm/Uw2QuuussMSOuYXYbe3PZJvyIeNx40T1xUTDTe+sHCK3STTfwQDGpgVU/+Vr+ox2FYyRktdEuS3UllFXVsAhtrMz9DOe5JI7htQfvIM1K3VcDYO9NZ+GtfP00Uz2uLEliDFHpqYy3qG2suaxGiDW86PVFURTD6/mxg4vQQwpRUW5ObyDZc5uVsX0+6A2s5uo9LmdtA7INIfUlo6MjFhUtY58S06TiXJa6HOsmc9aSd+pI576Lo6SuVNVEDndwErjhUs3dO85pCExrSSeqDE5K61bjSu7febvqJlzACdlbFEAAQQQQP4/yKxTRR0Q+eXKDdFsocFRMRTOqgkiP9E1rWwx6AzLLmqByE919avbajihxqVDsRCWj3rnVpYM1/lQLMRVPhw5ceNQHGTmnok7qlw7PAuJOrWOEIs+c8DNc+XxR2tdITSWd8XkPh8eHezrC2E5SXWfjzVNvxUdRn0Bf29V3sZvq2GVD82FuUYy5ukbFiBdN7MbeU+pOvvuV96xoyrT4kGJa8eaF8Sg/cHaEN9WIr/ixe9/8+7QxD0bsgITDgqPWMu2refChXIi28mApIiNzOYX7B826MEB76EtStC+SbLuSFkOiZZoaos/ewcUdYh9AodJ1gX8ovubekhO2Ms6hk8pcdMYlM+KmBPLIUWscoy1UF7Q4hjpEpopTMsq9cKYo6NCIacjV0MiY1hRELgImRQqKUBYiC5VQ4aEatqZ2MljWvGQYP6C9LAz5CirvBA+UUyYndXmSPGQ2GDjwG0fZ4hQPPh21xH2l5BP+oCoD7UrHtITIvgej3CGnIkWhAN9fELfLggh7YrNyLIQp92sWUOOq31QxG92QAABBBBAvBNy658GMgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAokyI1xwQ7B1HNo+LGqt3HKItabzjWHOJfgM5tA7UOw6xeQAAAABJRU5ErkJggg==", + "description": "Simple form to input new string value for pre-defined timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.attribute-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet utils;\nlet translate;\nlet http;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n http = $scope.$injector.get(self.ctx.servicesMap.get('http'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-timeseries-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n\n $scope.attributeUpdateFormGroup = $scope.fb.group(\n {currentValue: [undefined, [$scope.validators.required,\n $scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)]]}\n );\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"timeseries\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n let observable = saveEntityTimeseries(\n datasource.entityType,\n datasource.entityId,\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n );\n if (observable) {\n observable.subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n\n function saveEntityTimeseries(entityType, entityId, telemetries) {\n var telemetriesData = {};\n for (var a = 0; a < telemetries.length; a++) {\n if (typeof telemetries[a].value !== 'undefined' && telemetries[a].value !== null) {\n telemetriesData[telemetries[a].key] = telemetries[a].value;\n }\n }\n if (Object.keys(telemetriesData).length) {\n var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/timeseries/scope';\n return http.post(url, telemetriesData);\n }\n return null;\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-string-attribute-widget-settings", + "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\":\"Update string timeseries\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_image_attribute", + "name": "Update server image attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new image for pre-defined server-side attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n \n
\n \n
\n \n \n
\n
\n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n align-items: center;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.tb-image-preview-container div,\n.tb-flow-drop label {\n font-size: 16px !important;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\nfunction init() {\r\n\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.displayPreview = utils.defaultValue(settings.displayPreview, true);\r\n settings.displayClearButton = utils.defaultValue(settings.displayClearButton, false);\r\n settings.displayApplyButton = utils.defaultValue(settings.displayApplyButton, true);\r\n settings.displayDiscardButton = utils.defaultValue(settings.displayDiscardButton, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false;\r\n $scope.entityDetected = false;\r\n \r\n $scope.attributeUpdateFormGroup = $scope.fb.group(\r\n {currentValue: [undefined, []]}\r\n );\r\n \r\n $scope.attributeUpdateFormGroup.valueChanges.subscribe( () => {\r\n self.ctx.detectChanges();\r\n if (!settings.displayApplyButton) {\r\n $scope.updateAttribute();\r\n }\r\n });\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n \r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n\r\n $scope.entityDetected = true;\r\n }\r\n }\r\n if (datasource.dataKeys.length) {\r\n if (datasource.dataKeys[0].type !== \"attribute\") {\r\n $scope.isValidParameter = false;\r\n } else {\r\n $scope.currentKey = datasource.dataKeys[0].name;\r\n $scope.dataKeyType = datasource.dataKeys[0].type;\r\n $scope.dataKeyDetected = true;\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n \r\n $scope.updateAttribute = function () {\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n\r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SERVER_SCOPE',\r\n [\r\n {\r\n key: $scope.currentKey,\r\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n if (settings.displayApplyButton) {\r\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\r\n }\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n var value = self.ctx.data[0].data[0][1];\r\n if (settings.displayApplyButton || !$scope.originalValue) {\r\n $scope.originalValue = value;\r\n }\r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(value, {emitEvent: false});\r\n self.ctx.detectChanges();\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nself.onResize = function() {\r\n\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 1,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n $scope.attributeUpdateFormGroup.valueChanges.unsubscribe();\r\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-image-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"displayPreview\":true,\"displayClearButton\":false,\"displayApplyButton\":true,\"displayDiscardButton\":true},\"title\":\"Update server image attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_date_attribute", + "name": "Update server date attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new date value for pre-defined server-side attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n \n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\nfunction init() {\r\n\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false;\r\n $scope.entityDetected = false;\r\n\r\n $scope.datePickerType = settings.showTimeInput ? 'datetime' : 'date'; \r\n \r\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\r\n $scope.labelValue = translate.instant('widgets.input-widgets.date');\r\n \r\n if (settings.showTimeInput) {\r\n $scope.labelValue += \" & \" + translate.instant('widgets.input-widgets.time');\r\n }\r\n \r\n var validators = [];\r\n \r\n if (settings.isRequired) {\r\n validators.push($scope.validators.required);\r\n }\r\n \r\n $scope.attributeUpdateFormGroup = $scope.fb.group(\r\n {currentValue: [undefined, validators]}\r\n );\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n \r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n\r\n $scope.entityDetected = true;\r\n }\r\n }\r\n if (datasource.dataKeys.length) {\r\n if (datasource.dataKeys[0].type !== \"attribute\") {\r\n $scope.isValidParameter = false;\r\n } else {\r\n $scope.currentKey = datasource.dataKeys[0].name;\r\n $scope.dataKeyType = datasource.dataKeys[0].type;\r\n $scope.dataKeyDetected = true;\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n \r\n $scope.clear = function(event) {\r\n event.stopPropagation();\r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(undefined);\r\n }\r\n \r\n $scope.updateAttribute = function () {\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n var currentValueInMilliseconds;\r\n \r\n if (!$scope.attributeUpdateFormGroup.get('currentValue').value) {\r\n currentValueInMilliseconds = undefined;\r\n } else {\r\n currentValueInMilliseconds = $scope.attributeUpdateFormGroup.get('currentValue').value.getTime();\r\n }\r\n \r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SERVER_SCOPE',\r\n [\r\n {\r\n key: $scope.currentKey,\r\n value: currentValueInMilliseconds\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n \r\n $scope.isValidDate = function(date) {\r\n return date instanceof Date && !isNaN(date);\r\n }\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n if (!$scope.isFocused) {\r\n $scope.originalValue = moment(self.ctx.data[0].data[0][1]).toDate();\r\n\r\n if (!$scope.isValidDate($scope.originalValue)) {\r\n $scope.originalValue = undefined;\r\n }\r\n \r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\r\n self.ctx.detectChanges();\r\n }\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nself.onResize = function() {\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 1,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n}\r\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-date-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Update server date attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_boolean_attribute", + "name": "Update server boolean attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAIQSURBVHja7d3PSxRhHMfx+VcWgmg3ltKD7BJshyBIQusQ3vIYCILCRMyhH6xShlGWMGq1p1gIibxFULRR1F4ixcNqdNhmUXFipWnTYvfZ/TrjJqFQ13ge3p/L8MztBQ/P8/0eZr6WNHyvrHk8vy5WoxIo0TwqqDQsPxADEviWp0yAKM8qixEpAwECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC5H9DSiP2noyUNIXsc9j2qKYQ2/732lzI7LyukN4jUV7uLrtndIWcuP4lzKYBELf9rD66/bzZhqw9vFcM37ReTzz5pR3kc8fAWPriDmT58NDl5F0R51j2zOm6PpB+13VfyNNbIm8O/Iwg430iz87Lu/iaNDKz+kB6Hcd5HH60PDedjfkR5FXi2oeWyI3j+Xy+56puW2vh6KXcnTZEFq90pQripAbC5HSDDDoin9qQjxVRkwk1dUrLU2v4bLAxGFuNIEPnanK/s7WSeNDcct7rBvFOxg7djBUjSLX/YDJdEHmbiccv1PQrUb7++T/B9/Xfd8sPikbK+L81VqP7HEu0ukCAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAADEUYsyAYDNGNn/zrboZQ7SVZcZYcyXbTFGWg8aBYQQAAAAASUVORK5CYII=", + "description": "Simple form to input new boolean value for pre-defined server-side attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\r\n
\r\n
\r\n
\r\n
\r\n \r\n {{currentValue}}\r\n \r\n
\r\n
\r\n\r\n
\r\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\r\n
\r\n
\r\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\r\n
\r\n
\r\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\r\n
\r\n
\r\n
\r\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let settings;\nlet attributeService;\nlet utils;\nlet translate;\nlet $scope;\nlet map;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init();\n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n settings.trueValue = utils.defaultValue(utils.customTranslation(settings.trueValue, settings.trueValue), true);\n settings.falseValue = utils.defaultValue(utils.customTranslation(settings.falseValue, settings.falseValue), false);\n\n map = {\n true: settings.trueValue,\n false: settings.falseValue\n };\n \n $scope.checkboxValue = false;\n $scope.currentValue = map[$scope.checkboxValue];\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({checkboxValue: [$scope.checkboxValue]});\n\n $scope.changed = function() {\n $scope.checkboxValue = $scope.attributeUpdateFormGroup.get('checkboxValue').value;\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.updateAttribute();\n };\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function() {\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.checkboxValue\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('checkboxValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n $scope.checkboxValue = self.ctx.data[0].data[0][1] === 'true';\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.attributeUpdateFormGroup.get('checkboxValue').patchValue($scope.checkboxValue);\n self.ctx.detectChanges();\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-boolean-attribute-widget-settings", + "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\":\"Update server boolean attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_double_attribute", + "name": "Update server double attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANMSURBVHja7d3fS5NRGAfw/SsrhSCKKIugburCi6KCLrropi4izME0c1kLUxquHybmKNPAi4qMJkm9hOkswhVkpaLMfhmlbjHYu+3Vaf7a3r3fLjad5nZh6NiZ3+diO+fAe/HhOc85Z+/Fjg4R2T0ieLjlMHQRT0iF4KGGPBGdHEIWREjWudVsgKhu3QiyIkYIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQghZS8jbbwDQOT9slJc/5HGJAOmoAaAUjKWERCO9z7RI5kPGC8cBxy3A5/wUBoyy1qNirg+I9jtlAPBcvGkt7xWgRmyvgKpu9JR3NF4HjHLk9DSCBmi2eqnsFwCEK8vGRSj2j9fgM85h0Ito4VgC0m/R4GwEgHZ72xMRIOHiwIv7wJ/HN6oKfAmIdL62tsoCAMps1CvE8vuwvWIIaHgehTEGmULQgPYGv9+vCLWP/Cg1a4ClG18LvDDKKPmJPgNGzwXgdQkF0S5JAPrPXqgzDcAo402R2WYAukorzUMi7uzR6YWqmY0BJzQeUQghhBBC1h9kZllDDIj2cumBaKZaijWk6hmRIFqLybl0RDJJi75EgWitJse/Y20mKf4hDkRrWe4AJJOUPB8ZC0mWj1hOUjgyDjJ9zw1Ae5rcsUKIbK9rmVzovatv+hJvfh5ec4hirfidYl6teGp1bd1/Km9XfDhasOnksZxGAIGmA/rWtU/J2NXL7tT5WEmxz+0sUqHsNsR69lwXcDc3AC13b0U6IFCsZanzsZLld1D/AYApP/5wDYBBfTfwPupPCwSKNbnj/zbE44WLOvYcLwCkCYLpVTyiODb2zDe/P7qypQnphKzioXF0myUxMw9t3+MUFBLcd2LxP1qo1bnDQkKmjhydWjqw4YGIkMiJ/MT5ufggAL9eEhCilWzudLlcLgWvbYBDfyfoPpOnCAiZ1MeiGeYdKtCcp9cfHhB11YoXeewFo2+Cv9kJIYQQQghZf5DbaQ1mhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCEkMyFZc0FwdlzZPC7rwtlxibaqy45rzVX8BUWnY6Q1/jMIAAAAAElFTkSuQmCC", + "description": "Simple form to input new double value for pre-defined server-side attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-double-attribute-widget-settings", + "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\":{\"showResultMessage\":true,\"showLabel\":true,\"isRequired\":true},\"title\":\"Update server double attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_image_attribute", + "name": "Update shared image attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new image for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n align-items: center;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.tb-image-preview-container div,\n.tb-flow-drop label {\n font-size: 16px !important;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\nfunction init() {\r\n\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.displayPreview = utils.defaultValue(settings.displayPreview, true);\r\n settings.displayClearButton = utils.defaultValue(settings.displayClearButton, false);\r\n settings.displayApplyButton = utils.defaultValue(settings.displayApplyButton, true);\r\n settings.displayDiscardButton = utils.defaultValue(settings.displayDiscardButton, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false;\r\n $scope.entityDetected = false;\r\n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\r\n \r\n $scope.attributeUpdateFormGroup = $scope.fb.group(\r\n {currentValue: [undefined, []]}\r\n );\r\n \r\n $scope.attributeUpdateFormGroup.valueChanges.subscribe( () => {\r\n self.ctx.detectChanges();\r\n if (!settings.displayApplyButton) {\r\n $scope.updateAttribute();\r\n }\r\n });\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n \r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType === 'DEVICE') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n \r\n $scope.entityDetected = true;\r\n }\r\n } else {\r\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\r\n }\r\n }\r\n if (datasource.dataKeys.length) {\r\n if (datasource.dataKeys[0].type !== \"attribute\") {\r\n $scope.isValidParameter = false;\r\n } else {\r\n $scope.currentKey = datasource.dataKeys[0].name;\r\n $scope.dataKeyType = datasource.dataKeys[0].type;\r\n $scope.dataKeyDetected = true;\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n \r\n $scope.updateAttribute = function () {\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n\r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SHARED_SCOPE',\r\n [\r\n {\r\n key: $scope.currentKey,\r\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n if (settings.displayApplyButton) {\r\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\r\n }\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n var value = self.ctx.data[0].data[0][1];\r\n if (settings.displayApplyButton || !$scope.originalValue) {\r\n $scope.originalValue = value;\r\n }\r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(value, {emitEvent: false});\r\n self.ctx.detectChanges();\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nself.onResize = function() {\r\n\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 1,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n $scope.attributeUpdateFormGroup.valueChanges.unsubscribe();\r\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-image-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"displayPreview\":true,\"displayClearButton\":false,\"displayApplyButton\":true,\"displayDiscardButton\":true},\"title\":\"Update shared image attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_date_attribute", + "name": "Update shared date attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new date for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n \n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\nfunction init() {\r\n\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false;\r\n $scope.entityDetected = false;\r\n\r\n $scope.datePickerType = settings.showTimeInput ? 'datetime' : 'date'; \r\n \r\n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\r\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\r\n $scope.labelValue = translate.instant('widgets.input-widgets.date');\r\n \r\n \r\n if (settings.showTimeInput) {\r\n $scope.labelValue += \" & \" + translate.instant('widgets.input-widgets.time');\r\n }\r\n \r\n var validators = [];\r\n \r\n if (settings.isRequired) {\r\n validators.push($scope.validators.required);\r\n }\r\n \r\n $scope.attributeUpdateFormGroup = $scope.fb.group(\r\n {currentValue: [undefined, validators]}\r\n );\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n \r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType === 'DEVICE') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n \r\n $scope.entityDetected = true;\r\n }\r\n } else {\r\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\r\n }\r\n }\r\n if (datasource.dataKeys.length) {\r\n if (datasource.dataKeys[0].type !== \"attribute\") {\r\n $scope.isValidParameter = false;\r\n } else {\r\n $scope.currentKey = datasource.dataKeys[0].name;\r\n $scope.dataKeyType = datasource.dataKeys[0].type;\r\n $scope.dataKeyDetected = true;\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n \r\n $scope.clear = function(event) {\r\n event.stopPropagation();\r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(undefined);\r\n }\r\n \r\n $scope.updateAttribute = function () {\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n var currentValueInMilliseconds;\r\n \r\n if (!$scope.attributeUpdateFormGroup.get('currentValue').value) {\r\n currentValueInMilliseconds = undefined;\r\n } else {\r\n currentValueInMilliseconds = $scope.attributeUpdateFormGroup.get('currentValue').value.getTime();\r\n }\r\n\r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SHARED_SCOPE',\r\n [\r\n {\r\n key: $scope.currentKey,\r\n value: currentValueInMilliseconds\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n \r\n $scope.isValidDate = function(date) {\r\n return date instanceof Date && !isNaN(date);\r\n }\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n if (!$scope.isFocused) {\r\n $scope.originalValue = moment(self.ctx.data[0].data[0][1]).toDate();\r\n \r\n if (!$scope.isValidDate($scope.originalValue)) {\r\n $scope.originalValue = undefined;\r\n }\r\n \r\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\r\n self.ctx.detectChanges();\r\n }\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nself.onResize = function() {\r\n\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 1,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n\r\n}\r\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-date-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"isRequired\":true,\"showLabel\":true,\"showTimeInput\":true},\"title\":\"Update shared date attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_boolean_attribute", + "name": "Update shared boolean attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAIQSURBVHja7d3PSxRhHMfx+VcWgmg3ltKD7BJshyBIQusQ3vIYCILCRMyhH6xShlGWMGq1p1gIibxFULRR1F4ixcNqdNhmUXFipWnTYvfZ/TrjJqFQ13ge3p/L8MztBQ/P8/0eZr6WNHyvrHk8vy5WoxIo0TwqqDQsPxADEviWp0yAKM8qixEpAwECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC5H9DSiP2noyUNIXsc9j2qKYQ2/732lzI7LyukN4jUV7uLrtndIWcuP4lzKYBELf9rD66/bzZhqw9vFcM37ReTzz5pR3kc8fAWPriDmT58NDl5F0R51j2zOm6PpB+13VfyNNbIm8O/Iwg430iz87Lu/iaNDKz+kB6Hcd5HH60PDedjfkR5FXi2oeWyI3j+Xy+56puW2vh6KXcnTZEFq90pQripAbC5HSDDDoin9qQjxVRkwk1dUrLU2v4bLAxGFuNIEPnanK/s7WSeNDcct7rBvFOxg7djBUjSLX/YDJdEHmbiccv1PQrUb7++T/B9/Xfd8sPikbK+L81VqP7HEu0ukCAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAADEUYsyAYDNGNn/zrboZQ7SVZcZYcyXbTFGWg8aBYQQAAAAASUVORK5CYII=", + "description": "Simple form to input new boolean value for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\r\n
\r\n
\r\n
\r\n
\r\n \r\n {{currentValue}}\r\n \r\n
\r\n
\r\n\r\n
\r\n
\r\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\r\n
\r\n
\r\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\r\n
\r\n
\r\n
\r\n
", + "templateCss": ".attribute-update-form {\r\n overflow: hidden;\r\n height: 100%;\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n\r\n.attribute-update-form__grid {\r\n display: flex;\r\n}\r\n.grid__element:first-child {\r\n flex: 1;\r\n}\r\n\r\n.grid__element {\r\n display: flex;\r\n}\r\n\r\n.attribute-update-form .mat-button.mat-icon-button {\r\n width: 32px;\r\n min-width: 32px;\r\n height: 32px;\r\n min-height: 32px;\r\n padding: 0 !important;\r\n margin: 0 !important;\r\n line-height: 20px;\r\n}\r\n\r\n.attribute-update-form .mat-icon-button mat-icon {\r\n width: 20px;\r\n min-width: 20px;\r\n height: 20px;\r\n min-height: 20px;\r\n font-size: 20px;\r\n}\r\n\r\n.tb-toast {\r\n font-size: 14px!important;\r\n}", + "controllerScript": "let settings;\nlet attributeService;\nlet utils;\nlet translate;\nlet $scope;\nlet map;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init();\n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n\n settings.trueValue = utils.defaultValue(utils.customTranslation(settings.trueValue, settings.trueValue), true);\n settings.falseValue = utils.defaultValue(utils.customTranslation(settings.falseValue, settings.falseValue), false);\n\n map = {\n true: settings.trueValue,\n false: settings.falseValue\n };\n \n $scope.checkboxValue = false;\n $scope.currentValue = map[$scope.checkboxValue];\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({checkboxValue: [$scope.checkboxValue]});\n\n $scope.changed = function() {\n $scope.checkboxValue = $scope.attributeUpdateFormGroup.get('checkboxValue').value;\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.updateAttribute();\n };\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function() {\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.checkboxValue || false\n }\n ]\n ).subscribe(\n function success() {\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n $scope.checkboxValue = self.ctx.data[0].data[0][1] === 'true';\n $scope.currentValue = map[$scope.checkboxValue];\n $scope.attributeUpdateFormGroup.get('checkboxValue').patchValue($scope.checkboxValue);\n self.ctx.detectChanges();\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-boolean-attribute-widget-settings", + "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\":\"Update shared boolean attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_integer_attribute", + "name": "Update server integer attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANBSURBVHja7d3dS1NhHAfw/Sum1IUQBZYJIdSF4VVQIOSNXZiJg5nmWi18CXG9WJgSqd1IBUWu10PY1EBcIFYqyoTCLOZWA8/0uGmbbjs73y7mS+Z2YdjYs76/i3Oe88C5+PB73jhwnkeHiOxyCh4uOQxdxO1XIXiofndEJ/uRBuGXdS41HSCqS+dEWoSTEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYSQfwl59xkA+teqDfLWl9wOESC9twAo5QsJIdHI6EstkvoQX4UP6LsNzNo/hgGDrI2oCI0B0XG7DADuSzcttaMC9JG2t0DTMEZqezuvAwY5UhbEvB5a213J9A0Awg0mnwid/cM1zBpCmPQgWrGwARlv1GDvBABbd88TESDhc3Ov7wM/H99oKp/dgEgXWlqaGgFAWYl6hBh+H9rqp4COV1EYYpAA5vWwdXi9XkWoeeRLjVkDGofxqdwDg4zqrxjTY+b8HDwOoSDaZQnAeNXFVuMEDDIGKs1temCwpsE8JeLMHg2u95qVGHBR4xKFEEIIIeT/gyxvKYgB0d5sXhAtN0uxgtS8LBJEsxrtm2sko/TbTRSI9tzY92ddj1FavYgD0axbHYBklOLnI2Uh8fIRy0kCR8pBgvdcALRn8R3bhMjdrdYlAFh62mpd/Uk81J8ciGKp/56gXW27aQ3uPVqac9AJTOUePpOfMw1gom5/VpJSsnC1zpU4H9vp7KEDlSqUQ3qg6GQI4cKzwIvMU6eTBYFiMSXOx3aG38mM9wCMBUDDAIAr+YDzB9qTBoFiie/4uwmxuGJ1+CgqAYBkQhDcwSVKX+YIANi6SvKmkw7ZwUXjzL5GAEDlsT2l8wJD5o+UrO1o4S0sFhcSOH4isP7wYFdAVEikpEABgGBWF4D27KigEK06u9/hcDgUlOUNLQ7lmkRtWksZsXiEhaqsjN3mgMij1lqEPcJsY8OPD4QQQgghhOws5E5SgxkhhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCElNSNocEJweRzb7ZF04PQ7RVnXpcay5il+KVmW7YpZ2rQAAAABJRU5ErkJggg==", + "description": "Simple form to input new integer value for pre-defined server attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [\n $scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue),\n $scope.validators.pattern(/^-?[0-9]+$/)\n ];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n \n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-integer-attribute-widget-settings", + "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\":\"Update server integer attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_string_attribute", + "name": "Update server string attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAOkSURBVHja7d3bSxRRHAfw/VdG6yklCbPsRk+GhVLYDSXMohC1tczVUkxFXDULzW4a+SA9VIZRm5haIJqBmMqW2cUoddUVR1sved2dnV/nuJaX3X0Q1pjZvj/YM4fDmYfP/uZcdvbhaMgmmnpVHibRShpb/4REKg9pot+mESfIC2JC1Jgkb4BIJk0veUX0AgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAsp6QN194+epPs1Z0vqm/Uw2QuuussMSOuYXYbe3PZJvyIeNx40T1xUTDTe+sHCK3STTfwQDGpgVU/+Vr+ox2FYyRktdEuS3UllFXVsAhtrMz9DOe5JI7htQfvIM1K3VcDYO9NZ+GtfP00Uz2uLEliDFHpqYy3qG2suaxGiDW86PVFURTD6/mxg4vQQwpRUW5ObyDZc5uVsX0+6A2s5uo9LmdtA7INIfUlo6MjFhUtY58S06TiXJa6HOsmc9aSd+pI576Lo6SuVNVEDndwErjhUs3dO85pCExrSSeqDE5K61bjSu7febvqJlzACdlbFEAAQQQQP4/yKxTRR0Q+eXKDdFsocFRMRTOqgkiP9E1rWwx6AzLLmqByE919avbajihxqVDsRCWj3rnVpYM1/lQLMRVPhw5ceNQHGTmnok7qlw7PAuJOrWOEIs+c8DNc+XxR2tdITSWd8XkPh8eHezrC2E5SXWfjzVNvxUdRn0Bf29V3sZvq2GVD82FuUYy5ukbFiBdN7MbeU+pOvvuV96xoyrT4kGJa8eaF8Sg/cHaEN9WIr/ixe9/8+7QxD0bsgITDgqPWMu2refChXIi28mApIiNzOYX7B826MEB76EtStC+SbLuSFkOiZZoaos/ewcUdYh9AodJ1gX8ovubekhO2Ms6hk8pcdMYlM+KmBPLIUWscoy1UF7Q4hjpEpopTMsq9cKYo6NCIacjV0MiY1hRELgImRQqKUBYiC5VQ4aEatqZ2MljWvGQYP6C9LAz5CirvBA+UUyYndXmSPGQ2GDjwG0fZ4hQPPh21xH2l5BP+oCoD7UrHtITIvgej3CGnIkWhAN9fELfLggh7YrNyLIQp92sWUOOq31QxG92QAABBBBAvBNy658GMgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAokyI1xwQ7B1HNo+LGqt3HKItabzjWHOJfgM5tA7UOw6xeQAAAABJRU5ErkJggg==", + "description": "Simple form to input new string value for pre-defined server-side attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n \n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-string-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"showLabel\":true,\"isRequired\":true},\"title\":\"Update server string attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_location_timeseries", + "name": "Update location timeseries", + "image": "data:image/png;base64,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", + "description": "Simple form to input new location for pre-defined timeseries keys.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? latLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n \n \n {{ settings.showLabel ? lngLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-timeseries-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-coordinate-specified' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex-direction: column;\n flex: 1;\n}\n\n.grid__element.horizontal-alignment {\n flex-direction: row;\n}\n\n.grid__element:last-child {\n align-items: center;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n margin: 0;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-button.getLocation {\n margin-right: 10px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.attribute-update-form mat-form-field{\n width: 100%;\n padding-right: 5px;\n}\n\n.attribute-update-form.small-width mat-form-field{\n width: 150px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\n\r\nfunction init() {\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n \r\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.showGetLocation = utils.defaultValue(settings.showGetLocation, true);\r\n settings.enableHighAccuracy = utils.defaultValue(settings.enableHighAccuracy, false);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false; \r\n\r\n $scope.isHorizontal = (settings.inputFieldsAlignment === 'row');\r\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-coordinate-required');\r\n $scope.latLabel = utils.customTranslation(settings.latLabel, settings.latLabel) || translate.instant('widgets.input-widgets.latitude');\r\n $scope.lngLabel = utils.customTranslation(settings.lngLabel, settings.lngLabel) || translate.instant('widgets.input-widgets.longitude');\r\n\r\n $scope.attributeUpdateFormGroup = $scope.fb.group(\r\n {currentLat: [undefined, [$scope.validators.required,\r\n $scope.validators.min(-90),\r\n $scope.validators.max(90)]],\r\n currentLng: [undefined, [$scope.validators.required,\r\n $scope.validators.min(-180),\r\n $scope.validators.max(180)]]}\r\n );\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n\r\n $scope.entityDetected = true;\r\n }\r\n }\r\n if (datasource.dataKeys.length > 1) {\r\n $scope.dataKeyDetected = true;\r\n for (let i = 0; i < datasource.dataKeys.length; i++) {\r\n if (datasource.dataKeys[i].type != \"timeseries\"){\r\n $scope.isValidParameter = false;\r\n }\r\n if (datasource.dataKeys[i].name !== settings.latKeyName && datasource.dataKeys[i].name !== settings.lngKeyName){\r\n $scope.dataKeyDetected = false;\r\n }\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n\r\n $scope.updateAttribute = function () {\r\n $scope.isFocused = false;\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n\r\n attributeService.saveEntityTimeseries(\r\n datasource.entity.id,\r\n 'scope',\r\n [\r\n {\r\n key: settings.latKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLat').value\r\n },{\r\n key: settings.lngKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLng').value\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n $scope.originalLat = $scope.attributeUpdateFormGroup.get('currentLat').value;\r\n $scope.originalLng = $scope.attributeUpdateFormGroup.get('currentLng').value;\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n\r\n $scope.changeFocus = function () {\r\n if ($scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng) {\r\n $scope.isFocused = false;\r\n }\r\n };\r\n \r\n $scope.discardChange = function() {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n 'currentLat': $scope.originalLat,\r\n 'currentLng': $scope.originalLng\r\n });\r\n $scope.isFocused = false;\r\n $scope.attributeUpdateFormGroup.markAsPristine();\r\n self.onDataUpdated();\r\n };\r\n \r\n $scope.disableButton = function () {\r\n return $scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng || $scope.currentLng === null || $scope.currentLat === null;\r\n };\r\n \r\n $scope.getCoordinate = function() {\r\n if (navigator.geolocation) {\r\n navigator.geolocation.getCurrentPosition(showPosition, function (){\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.blocked-location'), \r\n 'bottom', 'left', $scope.toastTargetId);\r\n }, {\r\n enableHighAccuracy: settings.enableHighAccuracy\r\n });\r\n } else {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.no-support-geolocation'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n };\r\n \r\n function showPosition(position) {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n currentLat: correctValue(position.coords.latitude),\r\n currentLng: correctValue(position.coords.longitude)\r\n });\r\n $scope.attributeUpdateFormGroup.markAsDirty();\r\n $scope.isFocused = true;\r\n }\r\n \r\n self.onResize();\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n if (!$scope.isFocused) {\r\n for(let i = 0; i < self.typeParameters().maxDataKeys; i++){\r\n if(self.ctx.data[i].dataKey.name === self.ctx.settings.latKeyName && $scope.attributeUpdateFormGroup.get('currentLat').pristine){\r\n $scope.originalLat = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLat').patchValue(correctValue($scope.originalLat));\r\n } else if(self.ctx.data[i].dataKey.name === self.ctx.settings.lngKeyName && $scope.attributeUpdateFormGroup.get('currentLng').pristine){\r\n $scope.originalLng = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLng').patchValue(correctValue($scope.originalLng));\r\n }\r\n }\r\n self.ctx.detectChanges();\r\n }\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nfunction correctValue(value) {\r\n if (typeof value !== \"number\") {\r\n return 0;\r\n }\r\n return value;\r\n}\r\n\r\nself.onResize = function() {\r\n $scope.smallWidthContainer = (self.ctx.$container && self.ctx.$container[0].offsetWidth < 320);\r\n $scope.changeAlignment = ($scope.isHorizontal && self.ctx.$container && self.ctx.$container[0].offsetWidth < 480);\r\n self.ctx.detectChanges();\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 2,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n\r\n};", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-location-attribute-widget-settings", + "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\":\"Update location timeseries\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_double_attribute", + "name": "Update shared double attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANMSURBVHja7d3fS5NRGAfw/SsrhSCKKIugburCi6KCLrropi4izME0c1kLUxquHybmKNPAi4qMJkm9hOkswhVkpaLMfhmlbjHYu+3Vaf7a3r3fLjad5nZh6NiZ3+diO+fAe/HhOc85Z+/Fjg4R2T0ieLjlMHQRT0iF4KGGPBGdHEIWREjWudVsgKhu3QiyIkYIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQghZS8jbbwDQOT9slJc/5HGJAOmoAaAUjKWERCO9z7RI5kPGC8cBxy3A5/wUBoyy1qNirg+I9jtlAPBcvGkt7xWgRmyvgKpu9JR3NF4HjHLk9DSCBmi2eqnsFwCEK8vGRSj2j9fgM85h0Ito4VgC0m/R4GwEgHZ72xMRIOHiwIv7wJ/HN6oKfAmIdL62tsoCAMps1CvE8vuwvWIIaHgehTEGmULQgPYGv9+vCLWP/Cg1a4ClG18LvDDKKPmJPgNGzwXgdQkF0S5JAPrPXqgzDcAo402R2WYAukorzUMi7uzR6YWqmY0BJzQeUQghhBBC1h9kZllDDIj2cumBaKZaijWk6hmRIFqLybl0RDJJi75EgWitJse/Y20mKf4hDkRrWe4AJJOUPB8ZC0mWj1hOUjgyDjJ9zw1Ae5rcsUKIbK9rmVzovatv+hJvfh5ec4hirfidYl6teGp1bd1/Km9XfDhasOnksZxGAIGmA/rWtU/J2NXL7tT5WEmxz+0sUqHsNsR69lwXcDc3AC13b0U6IFCsZanzsZLld1D/AYApP/5wDYBBfTfwPupPCwSKNbnj/zbE44WLOvYcLwCkCYLpVTyiODb2zDe/P7qypQnphKzioXF0myUxMw9t3+MUFBLcd2LxP1qo1bnDQkKmjhydWjqw4YGIkMiJ/MT5ufggAL9eEhCilWzudLlcLgWvbYBDfyfoPpOnCAiZ1MeiGeYdKtCcp9cfHhB11YoXeewFo2+Cv9kJIYQQQghZf5DbaQ1mhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCEkMyFZc0FwdlzZPC7rwtlxibaqy45rzVX8BUWnY6Q1/jMIAAAAAElFTkSuQmCC", + "description": "Simple form to input new double value for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n \n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [\n $scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue)\n ];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n \n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-double-attribute-widget-settings", + "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\":\"Update shared double attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_integer_attribute", + "name": "Update shared integer attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAANBSURBVHja7d3dS1NhHAfw/Sum1IUQBZYJIdSF4VVQIOSNXZiJg5nmWi18CXG9WJgSqd1IBUWu10PY1EBcIFYqyoTCLOZWA8/0uGmbbjs73y7mS+Z2YdjYs76/i3Oe88C5+PB73jhwnkeHiOxyCh4uOQxdxO1XIXiofndEJ/uRBuGXdS41HSCqS+dEWoSTEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYSQfwl59xkA+teqDfLWl9wOESC9twAo5QsJIdHI6EstkvoQX4UP6LsNzNo/hgGDrI2oCI0B0XG7DADuSzcttaMC9JG2t0DTMEZqezuvAwY5UhbEvB5a213J9A0Awg0mnwid/cM1zBpCmPQgWrGwARlv1GDvBABbd88TESDhc3Ov7wM/H99oKp/dgEgXWlqaGgFAWYl6hBh+H9rqp4COV1EYYpAA5vWwdXi9XkWoeeRLjVkDGofxqdwDg4zqrxjTY+b8HDwOoSDaZQnAeNXFVuMEDDIGKs1temCwpsE8JeLMHg2u95qVGHBR4xKFEEIIIeT/gyxvKYgB0d5sXhAtN0uxgtS8LBJEsxrtm2sko/TbTRSI9tzY92ddj1FavYgD0axbHYBklOLnI2Uh8fIRy0kCR8pBgvdcALRn8R3bhMjdrdYlAFh62mpd/Uk81J8ciGKp/56gXW27aQ3uPVqac9AJTOUePpOfMw1gom5/VpJSsnC1zpU4H9vp7KEDlSqUQ3qg6GQI4cKzwIvMU6eTBYFiMSXOx3aG38mM9wCMBUDDAIAr+YDzB9qTBoFiie/4uwmxuGJ1+CgqAYBkQhDcwSVKX+YIANi6SvKmkw7ZwUXjzL5GAEDlsT2l8wJD5o+UrO1o4S0sFhcSOH4isP7wYFdAVEikpEABgGBWF4D27KigEK06u9/hcDgUlOUNLQ7lmkRtWksZsXiEhaqsjN3mgMij1lqEPcJsY8OPD4QQQgghhOws5E5SgxkhhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCElNSNocEJweRzb7ZF04PQ7RVnXpcay5il+KVmW7YpZ2rQAAAABJRU5ErkJggg==", + "description": "Simple form to input new integer value for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n \n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [\n $scope.validators.min(settings.minValue),\n $scope.validators.max(settings.maxValue),\n $scope.validators.pattern(/^-?[0-9]+$/)\n ];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n\n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value: $scope.attributeUpdateFormGroup.get('currentValue').value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue(correctValue($scope.originalValue));\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nfunction correctValue(value) {\n if (typeof value !== \"number\") {\n return 0;\n }\n return value;\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-integer-attribute-widget-settings", + "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\":\"Update shared integer attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_shared_string_attribute", + "name": "Update shared string attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAOkSURBVHja7d3bSxRRHAfw/VdG6yklCbPsRk+GhVLYDSXMohC1tczVUkxFXDULzW4a+SA9VIZRm5haIJqBmMqW2cUoddUVR1sved2dnV/nuJaX3X0Q1pjZvj/YM4fDmYfP/uZcdvbhaMgmmnpVHibRShpb/4REKg9pot+mESfIC2JC1Jgkb4BIJk0veUX0AgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAsp6QN194+epPs1Z0vqm/Uw2QuuussMSOuYXYbe3PZJvyIeNx40T1xUTDTe+sHCK3STTfwQDGpgVU/+Vr+ox2FYyRktdEuS3UllFXVsAhtrMz9DOe5JI7htQfvIM1K3VcDYO9NZ+GtfP00Uz2uLEliDFHpqYy3qG2suaxGiDW86PVFURTD6/mxg4vQQwpRUW5ObyDZc5uVsX0+6A2s5uo9LmdtA7INIfUlo6MjFhUtY58S06TiXJa6HOsmc9aSd+pI576Lo6SuVNVEDndwErjhUs3dO85pCExrSSeqDE5K61bjSu7febvqJlzACdlbFEAAQQQQP4/yKxTRR0Q+eXKDdFsocFRMRTOqgkiP9E1rWwx6AzLLmqByE919avbajihxqVDsRCWj3rnVpYM1/lQLMRVPhw5ceNQHGTmnok7qlw7PAuJOrWOEIs+c8DNc+XxR2tdITSWd8XkPh8eHezrC2E5SXWfjzVNvxUdRn0Bf29V3sZvq2GVD82FuUYy5ukbFiBdN7MbeU+pOvvuV96xoyrT4kGJa8eaF8Sg/cHaEN9WIr/ixe9/8+7QxD0bsgITDgqPWMu2refChXIi28mApIiNzOYX7B826MEB76EtStC+SbLuSFkOiZZoaos/ewcUdYh9AodJ1gX8ovubekhO2Ms6hk8pcdMYlM+KmBPLIUWscoy1UF7Q4hjpEpopTMsq9cKYo6NCIacjV0MiY1hRELgImRQqKUBYiC5VQ4aEatqZ2MljWvGQYP6C9LAz5CirvBA+UUyYndXmSPGQ2GDjwG0fZ4hQPPh21xH2l5BP+oCoD7UrHtITIvgej3CGnIkWhAN9fELfLggh7YrNyLIQp92sWUOOq31QxG92QAABBBBAvBNy658GMgIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAokyI1xwQ7B1HNo+LGqt3HKItabzjWHOJfgM5tA7UOw6xeQAAAABJRU5ErkJggg==", + "description": "Simple form to input new string value for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false; \n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\n \n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType === 'DEVICE') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n \n $scope.entityDetected = true;\n }\n } else {\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SHARED_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-string-attribute-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Update shared string attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_server_location_attribute", + "name": "Update server location attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new location for pre-defined server attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? latLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n \n \n {{ settings.showLabel ? lngLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-coordinate-specified' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex-direction: column;\n flex: 1;\n}\n\n.grid__element.horizontal-alignment {\n flex-direction: row;\n}\n\n.grid__element:last-child {\n align-items: center;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-button.getLocation {\n margin-right: 10px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.attribute-update-form mat-form-field{\n width: 100%;\n padding-right: 5px;\n}\n\n.attribute-update-form.small-width mat-form-field{\n width: 150px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\nfunction init() {\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n \r\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.showGetLocation = utils.defaultValue(settings.showGetLocation, true);\r\n settings.enableHighAccuracy = utils.defaultValue(settings.enableHighAccuracy, false);\r\n settings.isLatRequired = utils.defaultValue(settings.isLatRequired, true);\r\n settings.isLngRequired = utils.defaultValue(settings.isLngRequired, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false; \r\n\r\n $scope.isHorizontal = (settings.inputFieldsAlignment === 'row');\r\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-coordinate-required');\r\n $scope.latLabel = utils.customTranslation(settings.latLabel, settings.latLabel) || translate.instant('widgets.input-widgets.latitude');\r\n $scope.lngLabel = utils.customTranslation(settings.lngLabel, settings.lngLabel) || translate.instant('widgets.input-widgets.longitude');\r\n \r\n var validatorsLat = [$scope.validators.min(-90), $scope.validators.max(90)];\r\n var validatorsLng = [$scope.validators.min(-180), $scope.validators.max(180)];\r\n \r\n if (settings.isLatRequired) {\r\n validatorsLat.push($scope.validators.required);\r\n }\r\n \r\n if (settings.isLngRequired) {\r\n validatorsLng.push($scope.validators.required);\r\n }\r\n\r\n $scope.attributeUpdateFormGroup = $scope.fb.group({\r\n currentLat: [undefined, validatorsLat],\r\n currentLng: [undefined, validatorsLng]\r\n });\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n\r\n $scope.entityDetected = true;\r\n }\r\n }\r\n if (datasource.dataKeys.length > 1) {\r\n $scope.dataKeyDetected = true;\r\n for (let i = 0; i < datasource.dataKeys.length; i++) {\r\n if (datasource.dataKeys[i].type != \"attribute\") {\r\n $scope.isValidParameter = false;\r\n }\r\n if (datasource.dataKeys[i].name !== settings.latKeyName && datasource.dataKeys[i].name !== settings.lngKeyName){\r\n $scope.dataKeyDetected = false;\r\n }\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n\r\n $scope.updateAttribute = function () {\r\n $scope.isFocused = false;\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n\r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SERVER_SCOPE',\r\n [\r\n {\r\n key: settings.latKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLat').value\r\n },{\r\n key: settings.lngKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLng').value\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n $scope.originalLat = $scope.attributeUpdateFormGroup.get('currentLat').value;\r\n $scope.originalLng = $scope.attributeUpdateFormGroup.get('currentLng').value;\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n\r\n $scope.changeFocus = function () {\r\n if ($scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng) {\r\n $scope.isFocused = false;\r\n }\r\n };\r\n \r\n $scope.discardChange = function() {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n 'currentLat': $scope.originalLat,\r\n 'currentLng': $scope.originalLng\r\n });\r\n $scope.isFocused = false;\r\n $scope.attributeUpdateFormGroup.markAsPristine();\r\n self.onDataUpdated();\r\n };\r\n \r\n $scope.disableButton = function () {\r\n return $scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng || $scope.currentLng === null || $scope.currentLat === null;\r\n };\r\n \r\n $scope.getCoordinate = function() {\r\n if (navigator.geolocation) {\r\n navigator.geolocation.getCurrentPosition(showPosition, function (){\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.blocked-location'), \r\n 'bottom', 'left', $scope.toastTargetId);\r\n }, {\r\n enableHighAccuracy: settings.enableHighAccuracy\r\n });\r\n } else {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.no-support-geolocation'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n };\r\n \r\n function showPosition(position) {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n currentLat: correctValue(position.coords.latitude),\r\n currentLng: correctValue(position.coords.longitude)\r\n });\r\n $scope.attributeUpdateFormGroup.markAsDirty();\r\n $scope.isFocused = true;\r\n }\r\n \r\n self.onResize();\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n if (!$scope.isFocused) {\r\n for(let i = 0; i < self.typeParameters().maxDataKeys; i++){\r\n if(self.ctx.data[i].dataKey.name === self.ctx.settings.latKeyName && $scope.attributeUpdateFormGroup.get('currentLat').pristine){\r\n $scope.originalLat = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLat').patchValue(correctValue($scope.originalLat));\r\n } else if(self.ctx.data[i].dataKey.name === self.ctx.settings.lngKeyName && $scope.attributeUpdateFormGroup.get('currentLng').pristine){\r\n $scope.originalLng = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLng').patchValue(correctValue($scope.originalLng));\r\n }\r\n }\r\n self.ctx.detectChanges();\r\n }\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nfunction correctValue(value) {\r\n if (typeof value !== \"number\") {\r\n return 0;\r\n }\r\n return value;\r\n}\r\n\r\nself.onResize = function() {\r\n $scope.smallWidthContainer = (self.ctx.$container && self.ctx.$container[0].offsetWidth < 320);\r\n $scope.changeAlignment = ($scope.isHorizontal && self.ctx.$container && self.ctx.$container[0].offsetWidth < 480);\r\n self.ctx.detectChanges();\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 2,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n\r\n};", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-location-attribute-widget-settings", + "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\":\"Update server location attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "markers_placement_google_maps", + "name": "Markers Placement - Google Maps", + "image": "data:image/png;base64,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", + "description": "Allows configuring location of the selected entities on the Google Map.", + "descriptor": { + "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: 200px;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('google-map', false, self.ctx, null, true);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"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\":\"Second point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"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\":\"longitude\",\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}

Delete\",\"markerImageSize\":34,\"gmDefaultMapType\":\"roadmap\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"useColorFunction\":false,\"markerImages\":[],\"useMarkerImageFunction\":false,\"colorFunction\":\"\\n\",\"color\":\"#fe7569\",\"showTooltip\":true,\"autocloseTooltip\":true,\"defaultCenterPosition\":\"0,0\",\"showTooltipAction\":\"click\",\"polygonKeyName\":\"coordinates\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"zoomOnClick\":true,\"defaultZoomLevel\":5,\"provider\":\"google-map\",\"showCoverageOnHover\":true,\"animate\":true,\"maxClusterRadius\":80,\"removeOutsideVisibleBounds\":true,\"mapProvider\":\"HERE.normalDay\",\"draggableMarker\":true,\"editablePolygon\":true,\"mapPageSize\":16384,\"showPolygon\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${coordinates|ts:7}

Delete\",\"showPolygonTooltip\":false},\"title\":\"Markers Placement - Google Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"tooltipAction\":[{\"name\":\"delete\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id;\\n });\\n\\nwidgetContext.map.setMarkerLocation(entityDatasource[0], null, null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"8d3c0156-0a14-7a6f-0ddd-0ec16b9ffc91\"},{\"name\":\"delete_polygon\",\"icon\":\"more_horiz\",\"type\":\"custom\",\"customFunction\":\"var entityDatasource = widgetContext.map.map.datasources.filter(\\n function(entity) {\\n return entity.entityId === entityId.id\\n });\\n\\nwidgetContext.map.savePolygonLocation(entityDatasource[0], null).subscribe(() => widgetContext.updateAliases());\",\"id\":\"46bf69cd-8906-234c-a879-e2e4c92f5b67\"}]},\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"displayTimewindow\":true}" + } + }, + { + "alias": "update_shared_location_attribute", + "name": "Update shared location attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new location for pre-defined shared attribute key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? latLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n \n \n {{ settings.showLabel ? lngLabel : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n \n
\n
\n \n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-coordinate-specified' | translate }}\n
\n
\n
\n
", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex-direction: column;\n flex: 1;\n}\n\n.grid__element.horizontal-alignment {\n flex-direction: row;\n}\n\n.grid__element:last-child {\n align-items: center;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n margin: 0;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-button.getLocation {\n margin-right: 10px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.attribute-update-form mat-form-field{\n width: 100%;\n padding-right: 5px;\n}\n\n.attribute-update-form.small-width mat-form-field{\n width: 150px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "let $scope;\r\nlet settings;\r\nlet attributeService;\r\nlet utils;\r\nlet translate;\r\n\r\nself.onInit = function() {\r\n self.ctx.ngZone.run(function() {\r\n init(); \r\n self.ctx.detectChanges(true);\r\n });\r\n};\r\n\r\n\r\nfunction init() {\r\n $scope = self.ctx.$scope;\r\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\r\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\r\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\r\n $scope.toastTargetId = 'input-widget' + utils.guid();\r\n settings = utils.deepClone(self.ctx.settings) || {};\r\n \r\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\r\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\r\n settings.showGetLocation = utils.defaultValue(settings.showGetLocation, true);\r\n settings.enableHighAccuracy = utils.defaultValue(settings.enableHighAccuracy, false);\r\n settings.isLatRequired = utils.defaultValue(settings.isLatRequired, true);\r\n settings.isLngRequired = utils.defaultValue(settings.isLngRequired, true);\r\n $scope.settings = settings;\r\n $scope.isValidParameter = true;\r\n $scope.dataKeyDetected = false; \r\n $scope.message = translate.instant('widgets.input-widgets.no-entity-selected');\r\n\r\n $scope.isHorizontal = (settings.inputFieldsAlignment === 'row');\r\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-coordinate-required');\r\n $scope.latLabel = utils.customTranslation(settings.latLabel, settings.latLabel) || translate.instant('widgets.input-widgets.latitude');\r\n $scope.lngLabel = utils.customTranslation(settings.lngLabel, settings.lngLabel) || translate.instant('widgets.input-widgets.longitude');\r\n\r\n var validatorsLat = [$scope.validators.min(-90), $scope.validators.max(90)];\r\n var validatorsLng = [$scope.validators.min(-180), $scope.validators.max(180)];\r\n \r\n if (settings.isLatRequired) {\r\n validatorsLat.push($scope.validators.required);\r\n }\r\n \r\n if (settings.isLngRequired) {\r\n validatorsLng.push($scope.validators.required);\r\n }\r\n\r\n $scope.attributeUpdateFormGroup = $scope.fb.group({\r\n currentLat: [undefined, validatorsLat],\r\n currentLng: [undefined, validatorsLng],\r\n });\r\n\r\n if (self.ctx.datasources && self.ctx.datasources.length) {\r\n var datasource = self.ctx.datasources[0];\r\n if (datasource.type === 'entity') {\r\n if (datasource.entityType === 'DEVICE') {\r\n if (datasource.entityType && datasource.entityId) {\r\n $scope.entityName = datasource.entityName;\r\n if (settings.widgetTitle && settings.widgetTitle.length) {\r\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\r\n } else {\r\n $scope.titleTemplate = self.ctx.widgetConfig.title;\r\n }\r\n \r\n $scope.entityDetected = true;\r\n }\r\n } else {\r\n $scope.message = translate.instant('widgets.input-widgets.not-allowed-entity');\r\n }\r\n }\r\n if (datasource.dataKeys.length > 1) {\r\n $scope.dataKeyDetected = true;\r\n for (let i = 0; i < datasource.dataKeys.length; i++) {\r\n if (datasource.dataKeys[i].type != \"attribute\"){\r\n $scope.isValidParameter = false;\r\n }\r\n if (datasource.dataKeys[i].name !== settings.latKeyName && datasource.dataKeys[i].name !== settings.lngKeyName){\r\n $scope.dataKeyDetected = false;\r\n }\r\n }\r\n }\r\n }\r\n\r\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\r\n\r\n $scope.updateAttribute = function () {\r\n $scope.isFocused = false;\r\n if ($scope.entityDetected) {\r\n var datasource = self.ctx.datasources[0];\r\n\r\n attributeService.saveEntityAttributes(\r\n datasource.entity.id,\r\n 'SHARED_SCOPE',\r\n [\r\n {\r\n key: settings.latKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLat').value\r\n },{\r\n key: settings.lngKeyName,\r\n value: $scope.attributeUpdateFormGroup.get('currentLng').value\r\n }\r\n ]\r\n ).subscribe(\r\n function success() {\r\n $scope.originalLat = $scope.attributeUpdateFormGroup.get('currentLat').value;\r\n $scope.originalLng = $scope.attributeUpdateFormGroup.get('currentLng').value;\r\n if (settings.showResultMessage) {\r\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n },\r\n function fail() {\r\n if (settings.showResultMessage) {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n }\r\n );\r\n }\r\n };\r\n\r\n $scope.changeFocus = function () {\r\n if ($scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng) {\r\n $scope.isFocused = false;\r\n }\r\n };\r\n \r\n $scope.discardChange = function() {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n 'currentLat': $scope.originalLat,\r\n 'currentLng': $scope.originalLng\r\n });\r\n $scope.isFocused = false;\r\n $scope.attributeUpdateFormGroup.markAsPristine();\r\n self.onDataUpdated();\r\n };\r\n \r\n $scope.disableButton = function () {\r\n return $scope.attributeUpdateFormGroup.get('currentLat').value === $scope.originalLat && $scope.attributeUpdateFormGroup.get('currentLng').value === $scope.originalLng || $scope.currentLng === null || $scope.currentLat === null;\r\n };\r\n \r\n $scope.getCoordinate = function() {\r\n if (navigator.geolocation) {\r\n navigator.geolocation.getCurrentPosition(showPosition, function (){\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.blocked-location'), \r\n 'bottom', 'left', $scope.toastTargetId);\r\n }, {\r\n enableHighAccuracy: settings.enableHighAccuracy\r\n });\r\n } else {\r\n $scope.showErrorToast(translate.instant('widgets.input-widgets.no-support-geolocation'), 'bottom', 'left', $scope.toastTargetId);\r\n }\r\n };\r\n \r\n function showPosition(position) {\r\n $scope.attributeUpdateFormGroup.setValue({\r\n currentLat: correctValue(position.coords.latitude),\r\n currentLng: correctValue(position.coords.longitude)\r\n });\r\n $scope.attributeUpdateFormGroup.markAsDirty();\r\n $scope.isFocused = true;\r\n }\r\n \r\n self.onResize();\r\n}\r\n\r\nself.onDataUpdated = function() {\r\n try {\r\n if ($scope.dataKeyDetected) {\r\n if (!$scope.isFocused) {\r\n for(let i = 0; i < self.typeParameters().maxDataKeys; i++){\r\n if(self.ctx.data[i].dataKey.name === self.ctx.settings.latKeyName && $scope.attributeUpdateFormGroup.get('currentLat').pristine){\r\n $scope.originalLat = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLat').patchValue(correctValue($scope.originalLat));\r\n } else if(self.ctx.data[i].dataKey.name === self.ctx.settings.lngKeyName && $scope.attributeUpdateFormGroup.get('currentLng').pristine){\r\n $scope.originalLng = self.ctx.data[i].data[0][1];\r\n $scope.attributeUpdateFormGroup.get('currentLng').patchValue(correctValue($scope.originalLng));\r\n }\r\n }\r\n self.ctx.detectChanges();\r\n }\r\n }\r\n } catch (e) {\r\n console.log(e);\r\n }\r\n};\r\n\r\nfunction correctValue(value) {\r\n if (typeof value !== \"number\") {\r\n return 0;\r\n }\r\n return value;\r\n}\r\n\r\nself.onResize = function() {\r\n $scope.smallWidthContainer = (self.ctx.$container && self.ctx.$container[0].offsetWidth < 320);\r\n $scope.changeAlignment = ($scope.isHorizontal && self.ctx.$container && self.ctx.$container[0].offsetWidth < 480);\r\n self.ctx.detectChanges();\r\n};\r\n\r\nself.typeParameters = function() {\r\n return {\r\n maxDatasources: 1,\r\n maxDataKeys: 2,\r\n singleEntity: true\r\n };\r\n};\r\n\r\nself.onDestroy = function() {\r\n\r\n};", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-location-attribute-widget-settings", + "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\":\"Update shared location attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "web_camera_input", + "name": "Photo camera input", + "image": "data:image/png;base64,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", + "description": "Simple form to take webcamera image or upload photo.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.photoCameraInputWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-photo-camera-input-widget-settings", + "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\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "update_json_attribute", + "name": "Update JSON attribute", + "image": "data:image/png;base64,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", + "description": "Simple form to input new JSON value for pre-defined attribute/timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.jsonInputWidget.onDataUpdated();\n}\n\nself.onResize = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "{}", + "settingsDirective": "tb-update-json-attribute-widget-settings", + "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\":{\"attributeScope\":\"SERVER_SCOPE\",\"showLabel\":true,\"attributeRequired\":true,\"showResultMessage\":true},\"title\":\"Update JSON attribute\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/maps.json b/application/src/main/data/json/system/widget_bundles/maps.json new file mode 100644 index 0000000..32c93ad --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/maps.json @@ -0,0 +1,181 @@ +{ + "widgetsBundle": { + "alias": "maps_v2", + "title": "Maps", + "image": "data:image/png;base64,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", + "description": "Visualize latest location or trip animation of the devices or other entities on the indoor or outdoor maps." + }, + "widgetTypes": [ + { + "alias": "route_map_tencent_maps", + "name": "Route Map - Tencent Maps", + "image": "data:image/png;base64,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", + "description": "Trip animation on the Tencent maps. Allows to visualize location change over time. Use Trip Animation widget for advanced features.", + "descriptor": { + "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: 200px;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('tencent-map', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.map.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true,\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-route-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First route\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.5851719234007373,\"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.9015113051937396,\"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];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.7253460349565717,\"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\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"tencent-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"
${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details
\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#1976d3\",\"useColorFunction\":true,\"colorFunction\":\"var speed = dsData[dsIndex]['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', percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix('yellow', 'red', percent).toHexString();\\n }\\n}\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = dsData[dsIndex]['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.min(2, Math.floor(3 * percent));\\n res.url = images[index];\\n}\\nreturn res;\",\"markerImages\":[\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABdCAYAAAAyj+FzAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAH3gAAB94BHQKrYQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAACAASURBVHic7b13uB3VdTb+rrX3zJx6i7qQUAEJIQlRBAZc6BgLDDYmIIExLjgJcQk/YkKc4gIGHH+fDSHg2CGOHRuCQ4ltbBODJIroIIoQIJCQdNXLvVe3nT4ze6/1/XHOlYWQAJuWP37refYz58yd3d6zyt5rr1mX8B7S5Xo5/0nPYaNFM1PY0gGqOhfAgQCNBGlWFFUAYEIeihigbhFdZQwt85BV5Gj9r/718R2XX365vFdzoHe7w6d77xnPkn4YpAtU0YiizNJcmPNkMQFkDiSlowHt2HNtGlTSJ6B+pTpsKTfKgTj3Pi8SMtFtEZnFs8d8dPu7OZ93BcCHtt0+OiL+FJjOiqy5K5dtLwD4PBHGvy0dKLYo8B+1+lAldv50FfmFzWX+84i2M3a8Le2/Dr1jAKqCHtl2y1wC/pEMP9ZRLBaYzF8CCN+pPluUkOKfB6qlmk/dBwTyt8eOv2AZCPpOdPaOAPjA1h9/SJX+TyGXuz0TZi4EcPBeOk+U+RErZh2YyMAyQJEoZUjFgtkCAEScgDyx1hmInTglqDj2U1X0WILaPbWvwHO1WummeuLONhaXHTf2wsfe7rm+rQDe133j/i5xPyrmCr+OouhSKPbdQ5fLiezTIYUBQGMJBgYWxMYSISZhbxgQT8wGAgDiwWxUvCiBxKhSKOqdh4OyV5+6XiEfK/kjVOXQ13apG+I0+adKpXaG0/Si0yZdvPbtmvPbAuCNT98YTBhT/8fAmEpHoXgKgPe/6gFGP0nwG8s2YykcaRCAYYQ5tKTkDVuArDEwMRF5AICS4VZ1AQBSr6oEgL36CBAvlKqIsyLOKQl5TZH4uN+TawDuY6o64lWTJX20v1S633uJNvfmvnbRERelb3XubxnAX26+5gDy6Y9HtrU/wERff1XjSt0WwULDmZEMawPOgilgQ4FaGCEygaXQMQyRMaxiUijUkAEAImIGAFURAOrVA1AmI1ZExGuqoqkVFefhyGtKDql4X4eHc6LxJof0VIVM3nVc4uXaHUPlo0Tpc2fv/zer38r83xKAd6y74iImO31EMf9REA7cpdVBY8NbA5+dFNqsCTQipkitBjAUsLUZNd4qm8AyjDMmJAIRhDzDEBEbJkBVAyJWQJ14AEaciIeSGicOgBeBWNHEeXLkXIM8UvFI4bVBCVJNfdk7STd5xOcp0LZzjIqV/eXq/4i61edM/eaN7yqAqpfzf62Nf5LP5lbko/DbCuxU4saEN1mN2kKTzQbIkuEIEWfVagRDEVkOyXCkVq0aDg2p9YYNAySVerU0WN1R27Jjo6ulMQ1V+ggAOgsjNRNEus/IiUFnYUy2kM23AcrivXh2RiTxjhx5iSmVWEWdpmhQ4qvwSBBrXVPfqDmuVsT7C3aZvKslyZcr9dpxdr81F8ynO/w7DuD1q/8y6kDw2872ticN0deG7wvQHXHmdxGK+1ibQag5ikweliIElNUAEayNYBCSRQRiYzf2rNtx11O/rC5d9dj+1aQyM2Pyz3WGozaNisYNWY7SYtgWA0A5KUVO4qAn3t4+lOzYt+Grh+bDwstHzvjA2tPfd1Z+39FTRhGpi7VBKrE4nyBFDKcNJL5OCerqUEXdVeEQb0mk8lECjR0euxe9cqBUOnoQ6RkXT78hfscAvH71X0Z5kf8Z0dH2CgNf2NkI0d0ZbmtElMtFVEAQ5BFIlkKb00AzFJqCGooQcJjv7t868P3/ubayZvua48ZlJt57xLjjB/cpTssXokK7IQNrbeoZ3pIRJm1aYSUW9cwixglZ7xNU40ppY7mr+sy2ezt7G1s+vP+EGfd/+fS/Ko5pH9/pJK04X6MUDSRapcTXkXJN46QKp1UkqNVqvpxVyLzhOajihh1DpVkmrJ7+uak/bbztAF6/+i8j62p3j20vbgXR+cP3LYU/Djg/KcsdEnIWERcRIk+hzWtEOYSch2U76tk1T6+84Tf/NCdni2tOmbRgy6T26WOiKDBhGFEQhrBhiNAyjDGiQp4DFgI8AChg1BGBXOC9p8QJ0kas3jvEcUxxnLgNpTW9izfdOqGWlve7+OOXrThk6qEHKtKehq9xIlWkvoaYytrwFYqlglgrcZxW+oXSz+ycpOLmnsHypDTIfuTNcuKbAvD2288x22dn7hrVnt/ATBftBE/CH2aCtqkZU6CI2hHZomS4YCPK+5AKHFB2ZNe2Nev/739/e9qY3KRnPzHtQp/LtnfkMhnKZDMa2oDCTIjQhghDC2MCCQITAyYxpmkhAIAZDDA7l4bOSeR9YpLEwfkUjXqMOE0QN2LU4waq9aGBX6/+d7O9sXnu3579jbVTx02dlEilL0FDG1pJG64cJX5IGr6MupY5duU1npIv7sTQ4196ytUDx8+sf+TN6MQ3AyBd8+L8W0a15zYw0d8O3ww4vC7ijlkZU5QctVPE7QhNEVlTRNYUjHcy7tu3fuuVSqXBF8z66962fMeIfDaHfD4nmUyWsrk8BdaYIAh9EFoxzExEysYoAQ5A0ioAEIpIBGZmAM459iKaJo6cT209TnyjWkOSNLRWi1GtV9A3sGPg56uvG1vIZ9N/OO9rM8jS9oavSOwqaEhZYh3khq9K3fdpXWsbvdR3MoYCV/UOVadcOvv2C/AG9IYAfue5j1/U0R5mIhNctxM8yvxLyMVpOduJyLRRnto1MkXK23axlB27sXtT1z//8vqDTt3vk/fMGnX4xGyhiEI2Qi6X1Ww2S7lCIQ3DkCxzQEQKYADANgCbW6UHvwcRaO6fAwCjAewLYAKAcao6UkRIBEniEtRqNVOrVKjeSFCP61oaqurKvqe237P2lnkXn/X/PT9l3OT9Eql2V90QN1wZdRqSuhukhi9T3Q2s9ki+NDzHWppeUqnG/qsH/+b7fzSA33ruI7ODIDh/RCH6KkEZAEINfhia4n4ZO0KzphN5005Z06aRaeOAcjP++4Ff3P/86hWTLjr08i3FfEeurS3LUTanhVwe+XxOwjAw1loLoB/ASgBrAdSAV232Gc0NyJGt70+27mlrzNT6nAEwDcBMACO892kcx1KvN6hUqWu9Xka9XsfgUP/Qjcu+Nf3g6bO7zj7urBNT1F+quxLXfUkaMmDrviQ13+8THdqYqvuLZpfq+qrJNXFDbrp87t0v/cEAXr5iduiTMQvHd2QnKDC9+bC9NUfF9kwwgvNmBGW5Q3O2SFkzAkaCg/71Nz9+2MTZ6rlzLs4Vi0WbyWS5o63N5fM5G0VRaoxpA7ChBVw3ANMq1AKoHUAewCwARwHYvzWctQCeaNUrt4pvgeha17Gtevt47+M4jrVSqZlSqepqjQpVyyX/8xU3VBHF2T//+OeOFbgXaq5fa75ENR3SarzDxDToYz846FTORbPRV7oHG9sm+qEPX3TEM3vc9pm9AfiBP53+T6Pbwo0Cd4aog4p/yXK+lDX5IDIFZDinGS7CckEM+JB//u9/e3Z8NGPTgjl/Maq9s8N2FNtcPpc1bW1tFIZhaIxJATwFYA2AtAVWh4hERBQByIgIE1Gsql8gou8AeAjAfQAeVdUvEtE9reFFIpIloiyATgARgCqALQAGmHmUtTYTRWHDhhaGYE0YYmbHEXZj//rBRc/fXTly5qGHEus2FUceCbxP4DShRJ2mvuIFboyqG5kNcNuWVM965MbNd71pAC99+vADA+MnR6F+TeAg6h1TeE/I2bbAFjVLBbJcpIDzZNke8qNf//yxKblZWz42+9Pj2opFbutop7ZCQdva2hAEQZGZXwGwDEBDRCJV7VTVfVV1BDNPUtXZqnomER2tqi8S0REAzgJwUqvMI6JBAM+p6pdU9f1ElGu1E6lqUVVZVYWI6gA2EFFijJmSiUIPsDbXmGT3b59V6Kv0dd334uLGYTPmHK7Q7lRi65DCawqviXWSrEm1PlvgWMh9KPbut+/77Ohtj/97d98bA6igo7aM+O/Ogp0l8BNFPQhyY2RyE0MqcC7Ia2jyGpksBYj2//WDCx9uk/EDZ8783JhiW5HbigXpaG9HNpvNMXMGwAoR6SWiUKS5KhERS0QqIgmAHcz8sqrOA7AdwCcB9AK4CcBvAdwP4EVV3V9VPwGgC8B4Zv4PIqqoqgPQYObEOadExC1A60RUJaLxURQaZqoRW0NEsm/xgI6u7rV9L295vmvGlKmHQ32vk0QdxfA+oYTq+Vgbi70mR4p6BEaKlTid98S/9f4MV7wBgF/66AEnFbPUz+z/VNTBiywLgxxCFDgwGQqR5wznOeR8+6p1657r6uopfu7wv4mKbW0oFvIoFovIZDIBEXkReUlVG6o6Fs2N/EjvfSczj2Hm/YnoY6r6Ae/9w0T0cVXdSkTfE5FsC8iTAZwI4DAAjxDRj0TkUABTACxS1csAzG39MHlmzqvqGCLKt1xZA0Q0QERtQRBkDZMngrcmNAeMmB08uHpxNsrz2pFtbft4TWInDZtSLE5T8i7uSKRS8XDjBX4fYbnusI2jMkt/tGP9rnjxrl+gICP4Riagrzb1ssKa4CkrYRhwwBFHYGSUOZJKo8oPP/vCoV846opSoZCnQj7HxUJRMplMgGblR5h5wHtfbE1oZAvIHBFtVtX7RKTQ4pSrnHOXAThQRK4BcIaqNkTkRRF5UVUTVf1462/TVPVSEfm2974qIm3MvBhAl6pGAEYAaBcR45zLiUiPiDxKRC6bzZpsNhtGUaj5fIG/dNTltYeeWja3ltbVcGgMZX1IWbUUqDUBbBA+OYxDPuDLSORq6KsN76s48MvzZnwwlzNDgaFzAIBAi0LKtGVtEQHlOaQCQpOHoWDWL+9+ZODCuV99cnTbmM5cIY+2JudZIpronHukxUWemavOuZIxpuG9H8fM8wDMJaJHVfV0ANcDOIyIPg5ghTHm+0S0UETWq2oCoA/AI6r6C2PMgyKyD4BPM/MggJ8COIGIFqnqV1T1YADbVXUjEfUaYxrOOcPMBVXdCmCutbZirQGIlIBwavucl2577NaJM6ftO1nJ9aY+YfEpvDryknamSNdAMQ1AGwxdc/DqDjz9k/7Nw5i96ixBSK/MhTRxJ7oUbracmWAoVGNCtRSCYOxLazfcN7VjdjK+beK4KAqpkMtpJpNRABNVdT2AowHUvffjAYgxZpNz7hUiuk9VT1LVWFX/iojuBfA1IrpfVRcS0Xne+6tUX33+M/zdew8AzxljLvPefxTA3xPRIufcpQA8EYUAFhPRSCKaKSL7EFGgqjtU1RDRZmaeGIbh1sh78s7LxM59R09um7585fqNdtqUMZOMMc4igE0DthSppcYWL80VTNbyX1QCPgNN1fJqDvzi0tnjQviObGia3Ee0JEAml+E8DOUo4pxaE4GUJz3yxJr9/vSIv+8uFAu2kM8jl8vBGNNJRE+q6grn3AZV3QRgi6q2AZjHzHNE5FEAp3vvv8HM8wFQSywvADAPwDgAi0TkPwDcBWDhcFHVh9FcXH9ARE4BMI6ZvyEiHwYwSVW/CeB0IlpERJeo6hwiepmIlnrvVzLzemZex8yDzDwZqlUikGGm6R0H66+evuPYafuNynvFkCCF4xjiBd67otN4C4GmEDAqTuVnR3++beWT/z5YfRUHio8/0dEe7DynJTUvswmmEiwxWcCDwGyee37j4ydNO6ucy+YmZMJQM5kMWWvHqmqPc24eADCzENEGAMvTNH2AiM5Q1W1E9GkR2cLM3yOiS0TkO0R0lao+zMy/8N7PBHAmEZ2C3YiIoKrdqnqjqq5i5j/x3n8bTQt8iapeKyKbjDGfFpEhAGOccw8EQdBhjPmQqk723rP3PrTWvhxF0Xgi6vHeayaTyx075fS7nlvxcPGgg8ZNIjHeSKRMdbEUIEHwEuCOA4DOvB25vSRnAfghMGxEFNRb7ZoM0HFNadFeIjvRgMFkhEDKbEl8Oqq7u3bs+/c9cXQUWo2iCGEYsqrG3vvHAPwEwL2qulZETnXO/Zm1FqoKVf2Bqh6qqr8SkW3e++tU9T4i+ntVnem9vw7ARQA6ReQ5AL9yzl3vnLsewK8APIfmovkiIrpWVWeo6t977x/w3l8nIluI6Dcicqiq/quqgpnJOfdnIvJR59wmEVlCRD9S1QeJKLHWmmw2hyAM9bhpp47q7q4d733aSVBlkBoNQGxgYPdVRZ82N5In9lS7dp42GgA483hMyUY0RXgwXzAjQgUtshp1WhOR5YgDzoiB0U2baqsPLB7z0oxxBxWz2Rxls1lh5gNVdbn3/rwWR68moi5VPZWZt4nIvgBGquoRAH5BRH+OprH4oYh8XlVPQXMvfIOI/BJAFxF1qupxRPRBIjpKVSe3dOtdInKbqj5PRIe3RHayiHydiMYDOIuZfyIin0HTfI4kIgAYa4y5UUQaAI4QkY8ZY5YR0aGq0kcE8k5NNS4t665u6G9r47xDCi8pqabsNbFe9WkoRvU0upYl8GunnqebX7kZQ00O9DipLbKjRfQTPWnXYyBTBxMBBiIML2IVkt20sf6B46d9rJjJ5chaQ0EQRAC2pWm6VlVXq+rZIvIXSZKELcX/Y1U9RlW/AWC8iJyqql9V1aOcc99W1SXMfAmAh1X1qy3O+rKIHCMiGRGptUqude9iIrqWiC4brisiDxHRt1X1KFX9qnPuowDGe++vUNUPishNLQkIiOjPVPVs7/02EVkLYHsYhtYYg0wm1FNmnZPftKF2lFPJisCIkhE1DFiFaNLr1i5R+PntGR5lFMcBLWfCxxbhrgkjgqMAjCKgkrWFX48KZ7RHJm8CziJLOXJpUNu4omAuOfbKOMxkKBOGHIbhHBG576qrrtLHH3/8QmaOdtdd/5tIROLTTjvtyc9//vN3BUGQs9aOA3CyiDxXr9dRrzfo2gf/Ljt1TpyYIMnWtQ4nVW2kNd+bri41fOlMADkQerb1p4/f+WGcaS9X8HOLUQIwCgCUdFGi6ehBt7k+3k4DqQ8cOd2+mQdPnP6xijHB+MAYhGEoqppL03T/J5544iRmpvnz5z+4Zs2a1dOnT5/+8ssvr5o5c+aMWq1WSdM0VdXORYsWHW+tXXbmmWcONV2jQG9v744dO3b0jR07dvSIESNG3HbbbbNFpHPBggWPtMTvVUREWL58ee2VV145bcSIEU+ddNJJ1RY4unLlytXTpk2bEoZh2N/f37dw4cKTrLUdxWLxvnnz5pnf/e53unDhwhPa2tpWnnfeecekabopCIIMEYGIyBjGCfufvmbpltuKY6a4LKkzCh8PpZu913g0oIsAOhOKMQTElyvYPrsY43IRP6uK8wCAYHrUo+gpiXoaG+LR0X5VaNgxNEAHz5pz6PIgMGBmBTCKiJZVKpUjjDEmTdPG/PnzPwSgLCJHoLlY/omqXgLgWSJauHjx4uNPP/30obPPPnsAwGNoLl+O32Xdt/a3v/3txnK5HM6fP/+3aJ2JAAi89zkAUwGcdOqpp+YvvPBCnH322fEJJ5yQA3CH9/5YY8yft0C+SkTmP/roo72NRqPjhhtuODCTyRTPOuusRy+88MJVd9xxx8cWLFiwiog+oqp3ARgVBMEO7xVzJ70/v2jdHbNGqu/16uq98WakmuQgANhsU98MRQwMP7N0iYxhUuybD/n3WzqlAMROROElzfY3NrXHrtTNFHTkMvkiGQNiZhGZ7ZzbPDx5IoKIXK2qZzDzd9F0T/0pEV2qqoeKyN8BwLZt27ap6hmq+l0RmQXgZhH5iohcpaqrwzA0RATn3DXOueta5buqeoWqnqWqT9dqte8DwPbt2zeKyBGq+l1m/giA7wL4map+jYj2S5LEA0AYhp0AvsvMp5577rn3Axi/YcOGxaoKEdkCYBYzqzGEMMgUWILRjXSopzfekFUf5wUKYXYQCoZhykcM08C+DMUMw7Rva8sHqHZCJFD1VtTDaYLuoe3xrLGH/Yu1NiZVtcYAQEVVy7vpmPNU9VHv/RUArgZQ9d5f473/qYj8OwBMmDBhPIBnnXNfAfAj59w5AK4F8DURmcfM1JrY/4jIrSJyq/f+XlV9vmVMPlEoFC4GgM7OznEicmPrB3hJRC4Tkc+IyI+897cFQWBay5lrVfVKVX30lFNOOUZV/aJFiz7YMi79RFQiIgbg2NrazHEHf7+70q1eGiwkROoteQkhOmIYp8DQBGUcYIVwOJMepCCAkBCooCAnUPVwXoU1rrXVoyi7nwgoDO1QyymwzTn34d7e3p8B+NsWFx4AYLP3/l4iuoKIHhaR/yaiLw1z6rp169Z57+cR0bUiAiIaVNU7ReR5Y0xcrVbPbf0ek1U1DwCq2qOqG4jofhHZUi6XAeC7IkIAvqCqIKItaG4LZ4jInxERvPevtK5fY+b7W+0eBGD78uXLx6nqd51z85i5G0Bore1rNJJsxuan1EumFo3w3mtKSupAMASNRJEACBk6ixWphWCaKs1tqegVUIWyiBcPIYhRQlLKhQccNDtW9YEIh0TkiciJyGFtbW29LfCCxx577PtHHHHEhdbabd77bzLzFap6jPf+X5o46Jf333//qWh6kP+P934HMx8F4HQA53rvkc/nl9frdYjIQbsw99SWy6opPvl8BQC6u7u3ENFfq+poVb1IRK4iIvHeX7dy5UpKkuR8Zka9Xv9WNps9n4j2B/DNkSNHnrV9+/ZRIvIhIjpMVZeoqlfVEcyQ6WNmpQ8+nyva9m4IO/XeQ1XFE6UKfYkUhyrTEVDEFkAWO4NuZAuAsPnDKlgFzih8ku0cU5y4NQiCxFrLAPYDUCOizxpjrgAAY4y54YYbvtwS5f1E5B9UdSgIgloURR8BIESEO++8c8qmTZtetNYeHYahdnR0wHv/pIhsrVarvX19fQsA5H71q1/dYq01pVKpkCRJXCqVaGBgwDcaDdfX1zcRwDELFy788JIlS96XJEnBOQcADSIKmfkSIsKwpXfO/bmItBljLlHVa6dNm/bIE088sR+AMUT0WRG5kIgmWWtfIWPcuPZJDJ9r90hIRVTEq5KAlBIIdYH0UCg6FMhZUvDvjSDVnZBhUhUSUijICxHCbDFXZGOMqKoH0KmqQ/l8/ptdXV0/rlar38rn8zs5hJmJmUM0jyPb4/j3h/ze+ylLly6dgr2QaepX3Hnnnefv7ZmdoyUamyTJWABoHvTtmbq6un4xa9asSQCuA7DSWvtSo9E4zHt/dbFYvKLRaKwF0E5EwoBENlKVMOPFkcJDCRBVUlEloLQTLgWz1987FAhImCECJVEh8Z6cdzBk20ITkIg4Y4xX1ZFoHuJM3XfffT/S29uLLVu2oFKp7HQ9/W8ia+2RzHyGqv6TiPzjsccei97e3kxbW9uZACYTURVNb7mIiIYmJIOwLUWqTqQVIqFEDFHV6nC7orDMBB22LOzhWbRC0LJRLalqGYqyQWAJVDPGVJIkqQPYrKq9AGCMmQoAaZpix44d2Lx5M/r7+5Gmbzn4822jVatWvei9/9M0Ted77/9j5syZawAk27ZtswCgqt0AtohIzRhTssZWDdvQkA4RtETaxAOqZSWWnXgR1Kr8/kTbG2ThtaAE9QQSZWIQ2EilFteyhoJCa4lxYMvf9xry3qNUKqFUKiEMQxQKBeRyudcVsXeC0jRFrVZDtVrFzTffnOnp6Tl2/Pjx944ePXrt9OnTzyGirY888sjLCxYsOERExhPRDGvtswACrz4m60pOqIMIBIX4ZqCYAWsZLXumAtid6z8A5DSvlgkKFkcMiBERqHUDiUu8994SkQCoEFF+jyPfhZIkQX9/P/r7+xEEAbLZLKIoQhRFbzugzjnEcYxGo4FGo/EqCejp6Tnv5ptvfk2dH/zgB8sWLFgAVS0CqHjvyTlnq2mFYF3VORnJICKwI2IFI0Qi7TCtLaYCVgnbAdoA6GRhaoPXhipIVJkEUCXP7CrleBAd2RHsvYcxpopmfMreaICZN6LpQWYRmZSmaeeuk7LWIggCWGsRhiGstWBmWGuxqwUFABEZ9ilCROCcQ5qmcM7BOYckSYbd/XuiTczcT80YHHjvZ6MZZ4O+vr5hx+14Va1Qa/M9WB0Asa+SUCcIRuAtg5QEBKDYrEJrwdhiIXhBRQyIJkMxQxQvkELh4RUq4kCJ2VHdOLiOx+YmmTC0trWwnQOgsvtoiegFInKdnZ3rRo0aJT09PTw0NAQAm0VkzvBzw5N/B0mMMU+pqhk7dmxXsVjkzZs35xuNhojICDSPRpPt27c/WSgU5hLRC95722g0aOPgWnbcW5VUBYCSJYBBChgQzWnt2J4BsJyheFkVr7Q6Hc2kZYU6ARSejCjZFN259UOrc6reOucMEfWpqnXOPQIAhULhN8PgMXNl3rx5Y4IgOIuZz46i6KyTTz55JBFVmXnFO4nYrmSMeTKKooEPfvCDs40x8621Z3d2dp566qmnxsxcArC1s7PzkVWrVi1X1QBAv/eeiYg2DK0upOgpiCBQIlIBBOrBOgTCCAAQ0jUQrGS1WF1vUPewLlTlKoQCOARewOqVUgzmtlXWTWuKiqiIVAAgjuOtuy1bgtNOO21ET0/PhO9973sQEXznO99BT0/PxJNPPrkDQAO/97C8k7RBVaO5c+ce19nZmb3yyisxZcoU/NVf/RVWrFjx/kMOOWQ9M3dXKpVRjUYjbKmGinOOnPPYWt04PZGhjHoQCZigAQsFpFwbxqlRpx6k6LI6gK5Kpz8zm20d0JHWQFAYTSUlALDexSNdEB+Y+nQxpZRlppSZ4ZybdPvttz9QqVSOt9Y+SkR+xYoVxx522GF4/PHHceCBB2LZsmWYPn06nnrqqQOZ+REiekZERr+T6BFR37hx47rWr18/NwxDvPLKKygWi3jhhRdw5JFHolarzXvuuee60jSdYFordxFJnHNI0rghiGc4jb3xUDEQEngyYEBrwx7KcuJHZzux1t79KZQ++iv5AHTnCadVBZGQhULh1SsIMfoe7KlsGRqTm5Q1xmkQBJtV9dijjz766f06bwAAEgVJREFUnpUrVy4EgIMPPjh300034bjjjsOaNWtQqVQgIjjqqKOwZMkSzJs3b/Xy5cstgFUA3rZF954cr6eccsrYxx57DJ/85CexcOFCDA0N4cQTT0S1WsWjjz4azp49+4l6vc5Tp049TVU3eu/hVXVbZUN/TH33k8c4DVRIiMFEohCjCIdXLC6VY+44DV+zACCEXiiWgnCkEp1EpKsEqqTEIsTq1Axg+eCy/kczp+QmqDZfuXpRVedNmjRpx9VXX32hiEBEsHTpUtx5551YsGABnHM47LDDcNNNN+GAAw7Al770pc8NPzdsUXe1rsOA7n4dBmjXK3NzgbHrZ2beWQDg7rvvxq233oqLL74YS5YswY4dO/Dkk09i7ty5uOCCCz4bx/FPRGSUiNydph71ap2W9T9eGGgsr4iqZSVVsLJ6Z5lIlU5srfmWAlgHtE7lDjgP5SjgAWb6MBTtoroMgpwoERTwniiJhwq5aPrxB+YOWwuQIaKEmWd573NBEHSoKosIpk+fjltvvRWqitWrV6O7uxvLli3DV77yFRQKhVeBtzcgd/2+exmm3bl3dy4kIowfPx4LFy5EpVLBpk2b0Nvbi+7ublx22WWw1ro4jgsARgJYVq/XUG/Uk2fK95+ypXxfrESGGUIEMhYGTP1ovQOYOr2+kcjvVt+K9c130cp4slyX4nDnBqYbRCAGkTZXUELIVtPeezeUu3rjOEaSJFDVpwEcmKbpLcMTnDhxIm644QYEQQDTPDvBNddcg3322ec1IL1e8d6/qryZOruDffTRR+PrX/866vU6kiTBAQccgOuvvx5hGKI15hki8lTz76lura/fUUt6F4siJIKCiREAakhB6BnGp1ST9lwbngJ2CfE99Zd4cPzIcDqg4xl4wQl64EE+BlyicCnYanHz4RMumviR9vO7C4UC5fN5JqKzVfXlKIomtzzGr5nwGwGwOxe+ngi/ntjuXowxe/s+0Gg0+ohofxG5o1KpoFqv6+LBn496dssPt6dcmWAtlCOCNRDKgJgxEopDoLRl60Cy5p5P4Hhgl/A2NbgmTuUGBeCBOUTokVZAtyiIFJSk5QmJlJKeyvaeer2u9XpdVPVxVZ1Zr9dv25PI7Q7M3sDbEwe+0Q+wt/b21vdwqdVqv1XVaar6eJwkqNdj9JY3bW9IKU5cZRwUDNPcuagBE2G7Kg5RAKnI9SD832HcdgJIARYOVdyknXtjoTpBoaRsTPOMHQy7fMutQy/qQzOr1arW63VNvd+kTc/NfO/9I3vTXXub0N5E9/U+v57Yvp7+VFWkabpYVc8DMJSm6aZyqcSNRk1fxOMHPb/5v+pQtWwgUBCxErGCiOJhXHYMuRkU4r7XAHj3aYhTAaC4rakI9dNkMMSWPBhMSsRKmjRKIyuuZ3Bzfe32crnGlVJJReQ+Vc3HcdyuqgPD4re3ib1ZHfhmVcDuYO4JxNaYetI0HYvmMen91WqVqo1YNqVdW2uutz9NSp3KTNpcxMEYgjEYVNULmvVxiwLVu09D/BoAAcAZXL6j7F9SBVRgiUwPkRJYCQaqrEoMWrrqp4WN2ZfmxXGtWq7UqFwuJyJyP4A5cRw/qKryelywNw7ck+58I336ZvtR1Uaj0XgewMEicl+5XPblcpXqtXJtk33x1KUr/6MAbnKdgQKsDFUVMTtUYFWBvpLvohRX7orZqyJU192K6tSz9Qv5HPcQaCpBZyvjRSiyEFIVkDioiBbL1W3LglGduWJ9LKDExnAtCIJEVU/w3t/MzIfsbiD2dn0jHbkrF+1qSPZkXHY3MMNX59ydaB5ePdNoNLZUqlVfrpSxOvO4earr5xvqvm8iGfggBFNIyiGYQwwQ4xwABqqLhmo+c885eJVf7NUx0gDE4iv9Q/JYc1+MDABvDJQs2DDYhlBmxD2Da6YNxOulW9dsr1TLWiqVtF6vrwawXFU/7Zz7TwB/FCf+MUuW1ylJmqY/F5GzVXVZvV5fWy6XaahU5q26asuA22L7hlbvR4a8NVAYKFsgMBACJZDm7mNHSZ41HpfujtdrovS7bkV58p/oRwpZ8zIIhwM0C0SLoBipCmqNnaHAhq3L7MT9D9mfhjIrrYRt3nu0fG9VAKd673+Npq8t82a5cW9ADdOb4bZdljfbRWSpNt9BeSJJknVDQ0MYHBqiwXRHd9+IriPvffpa4YBCE0I5grCFMRlSGFoF4DMt3ffDUtXLPfPxyzcEEADGnoNH01gWFLNmChQhgTJEOqiKQIQEAiPNU09+Zf3jfZNnH3yY9mVWasoFL16sMWVm3gzgNO/9KiJaq6qTdlfyewNv9+f+QNCGPz8qIgLgaFVdVK83egcGBk25UtWBel9f/4Q1x931yFUbYLWNIxgOoDYgDSJYE6IB8CEEjFKg1D2QdscVfHn9r/EaB+YeAdx8B9z0+Sgz8HxgeR6AMVB6hgzaVMk3Q/2JSQHvJOra+GTXlMPmfEi6o+d87NpTLyTeN5j5ZWae6b3fV0RuIaKZqmr3ZJ33BNzuAO4G0B7vMfOQiNyqzcBN8t7fN1QuN0pDJVQqJe2v9u2oTt9w0l0P/uNz3iQjghA2CMmEGXgOCSYDIqJuAk4AgHrDf7We6u/uPx97zO6x13fl1tyOtfucqRcXM+ZFAHNAmA2iu4gwRkBKos0jAVXy4vKvrHvslWlHHHZk2m1eQKJ5VfXOOauqG4Mg6FXVj4nIalVdpKoHqSrtsrzYed1VXAHsDaQ9caAQ0S0iMoqIPkBEDzWSZHWlXI6HBkvBUKWsQ2nf5uSA7SfeueTqFxPUxtpQAxMSmxBqAhKTBZhoBYALAUCBW3ZU/D6Lz8E1e8NprwACwKQv4nf1fvlUMWsJwEgC5oDpIVJ0EhGrJ6sAICCXuvYVqx8uzXj/YZPSWFbWelyHeA/nPRLvqwxa3XRN4COqugrNKPwx2ozifxVww1y3K4CvA95WAHdQ8xWHDwJY4b1/tlwupwNDVVTKQ9rfP6j19h3dsv+Ow29bdEWvUmO0CWBshowJCTZL3kQAW1pPTb1noPTK9oG0no7Cp9b/7LWi+6YAXP8zuMnn4rFG4kfnQ3MYgIgIU5jxDCmKCigBpE1xZlEfvPDSErffrFkU7BNQpSutxQ1PLo6zSerFi9RV/CvMXFXVQ1R1H1VdhGaIbxnAzgQ5u4vtLsUx8yMA7mPmbQAOJKI2VV2XJMlLtVqtViqVaLBUlUqpn0vloTofOhBVMptzv1h4dd4Yn7cR1GSJwwhiQhIbIjUBthBwJoC8ElzvUHqzKL5+/+l4zQuGu9Kbyplw4m04Ix/xjI68+W6r2gZifdI1dFSaEEtdOW2AJYG6hnqXEMaOnL7ptGO/+L5kjVks2/JjM5nIZKJAoihLmUyIIAjIGANjTEBEHSIyWUQ6RWSdqm5V1YqIpC3RDImoQETjiGgKM5eIaKOIDKpq4r2Hcw6NRgO1egzvUq3V6l5Hxhuys9OPP7T0lke7tj41nQNiG0FtBmojeBMR2yzIRNhKQh9U6L6kkMGq/7t6Ii8uXoDfvRE2bzprx0n/hc93FLiQi8x1zYq0CdAHvcdkV4V3Dupi9b6OgosR+wRGvU3PPuXSHcXcPiMGnvAvcJIZlwsjG2UzMESUzWa16SExZGxLGFS9sVbFK5SUAGBYWYoIMzN5BbnUgSCaph5xXCfvvSZJouVaw1NWejrfL3NK1a07frHwmpFsXcgRvA3hTRahNeRsHmKaXpZtIDoa0P0AoBb7SwZqEt+/AP/6ZnD5g/LGnHwbvtlZCAYzAYbzJwwo4U5xOl0aUB8jcDHUxUSuoQ4pJE0gmbCt9vFTLm4UM2NHDCxNlidDweiQOAyCUDkwFLBBEFhSZrVEqkDzHLEVAiA6PFBFE0pFkjhS9YjjVJ1Lkfg0sZ3SO+rI8NBSo7vvznuuz8S+lDMhwBbWhmRtVr3JgmwAmAhqAlolij+h5svfqMW4ZKiaFu49F1e/WUz+4MxFJ92GS3MR246M+bYSGEAizD8mJ4d6p+oa8L4OcQnUJzA+hhWnqU+gUdA2cPKxnylNHj/rmOrW9N7+F5JGOiQjyXIYcgC2zRejiVXFw5Np5Y3xMGxgxBMJPMSlFHtPUI1NG/eNmhNm8uODUzZse+nB+x78WVs9KXXaDMgYspyBNyG8iQATwIRZwIawYPOCQj4LICSFDNX9V6qJ5O5bgH/8Q/D4o3JnnfhzfC6yvM/IdvPXADpaLd0KoaJPNS+xmjSF1QYkTeEkVfYpGR8j9Q5WRKvjRkztPf5DC3j0iCkn+AQvlDdUu6rbXaPWn5KrCEEErTwXTTKALbDmRgSaGxNk26bmppoQc7p7ux546PE7ZHvfutHGUJ4DOGMRmEi9sSQcwgYR2GTgOCRvDFXVaJUU81sA9PcM+X92Trru+yT+8w/F4o/O3nbyrTiaGF8cUwgOIMZRreZegerDgB6YJiQSw0uqgYsh3sFrjMB5eE1gfAovHka9pjaM+ke2TxiaNnWujBkzOcxnO/KFXKHNBpnRAODSRm+lVh6q1odqPT0bkjXrnuW+oS3tLo1HsKGADIQDsAnhjEFAFgmHsDYCmYBSG4BMRgMQvQTQcYBOBwBVPN5TStd6hxvuPx9L/xgc3lL6u5N+hpGwuHl0u33a2N/nDiTSXxBIRHWCNMilMdQ7DSVF6h1YUxXvyKhD6h0CCKCCVLxa9YASKYlyK/AOIJAyCUFBDGImB4KlEEoMbywCCtQbQ8QhxFiEJqDYWLDJakBEm4g1UKFPDI/Rq16xY9AdZQzOXzgf/X8sBm85AeM5t8P0eXwtItYRbfZToOavCyDxKj81RCPgaKJ3iL1TAw9xCVgdvHcw6uBVm/pNvQIKpwJV2pkKBQCEFKoMYoKFITVGQQxPBsZYeLIwNoQQw3BAjiNEzNioQKzAebQzkJRW9lXcbXEqctx5uOryYUv1R9LblkP1+JsxjS1+MDJn7wkDuhKEHACQQqD4OUgExJPFq/EpqTglcXDqEXoPJYETDwbgROBVAQY7ABCIJQKYYQBYZogaWGMAMkhhEJiQPLMaG5BTlvWUsgXjvJahAxS1RqpfH6i5eYjxhfs/i7clj+rbm8VXQSf/HB8T4LOj2uwzgaF/0GZ2oeHuVqjq48zIQzHee4QiSLUZgwN4kDYdt0Kkqq38BM1XhYnAMMwKGDQ979y0rERIRbENQJWIPgDorF0m2Ei9Xt0/5N4njH+//zzc9XamRH5H0iAffiOC9gLOVeD8kXl7bxjyxYC+OqMv0VaoPsCEukAigNqg1EEEFlWBQKHUFC9SBoOYiEUhRDoIaInBiSgyBDpJoeN2m9qG2Mv1/SV3iir+s1zFbc9chLc97vgdzWR+uYIfugUnC/C3keUlHQXTaQiX7LUCox9en1XwIBENCqTcvM1FVe0gSAcMzYVgxN6a8IrrBit+IHFyrCF850Orcf/ll781Pfd69K7l0j/mJxhtLb4+ot2uDy3t1T30Vihxeml/2U1WxpVLPol3PA088O7/MwI6/ib819j2YDOb154vvBVSxfXdA+nEBz6Ns4G3T8e9Eb3mUOkdJsW++NT2UjpHVO/V5vrvrRfVh7f3pTNLdZyLdxE84N0HEEtOgMsRzukdcBUV2vRWwYOnbTuG3HZXw4J3wki8Eb2uQ/WdojW/RLz/n+CluKaZTMhzm4eJwB9aFHADFf1X7+X6h/4MG9+LubzrHDhM934KLyhoaSPB3/yx3Nco42+811UPfBbvWvD67vSu/0eb3enEn/K17RkeNExXvPHTvyfxeuVQQ0be9zn50hs//c7Re8aBw3T/Z+TScl3niuBm9cCbLLeXGjr3mA3yl+/1+N9zAEHQ6oA/rxLLBPF49o1Fl54vxVJ08Ge/kwvkN0vvPYAAHv8K6ur8BbVEnlNF6XUArNQS/ziJv2jJ5/Cm07W/k/SeWOE9UddvUJ5+pimpYhODTtyT1Y29fsOrv2fxhXj+vR3t7+l/BQcO0z2fc0ucEyeil+7OfV7xFYXI4gvx4Hs9zl3pPbfCeyA67cfmFiaziVX/BgCUcL1XGf27z/vz8S7vNN6I3t23oN8caW0//+lcF/0PC+4VIBJgZm2aPw3/y8AD/peJ8DAtOQEuZLfAQ0sK7Q0rbv6SE/Yen/L/017ojH8LZ5/xb+Hs93ocr0f/D6s769KBP+5xAAAAAElFTkSuQmCC\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"strokeWeight\":4,\"strokeOpacity\":0.65},\"title\":\"Route Map - Tencent Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "test", + "name": "Trip Animation", + "image": "data:image/png;base64,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", + "description": "Trip animation on the OpenStreetMap or other map providers. Allows to visualize location and other timeseries data for each point in time.", + "descriptor": { + "type": "timeseries", + "sizeX": 10, + "sizeY": 6.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 var $scope = self.ctx.$scope;\n $scope.self = self;\n}\n\nself.actionSources = function() {\n return {\n 'tooltipAction': {\n name: 'widget-action.tooltip-tag-action',\n multiple: false\n }\n }\n};\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true,\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-trip-animation-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var gpsData = [\\n37.771210000, -122.510960000,\\n 37.771990000, -122.497070000,\\n 37.772730000, -122.480740000,\\n 37.773360000, -122.466870000,\\n 37.774270000, -122.458520000,\\n 37.771980000, -122.454110000,\\n 37.768250000, -122.453380000,\\n 37.765920000, -122.456810000,\\n 37.765930000, -122.467680000,\\n 37.765500000, -122.477180000,\\n 37.765300000, -122.481660000,\\n 37.764780000, -122.493350000,\\n 37.764120000, -122.508360000,\\n 37.766410000, -122.510260000,\\n 37.770010000, -122.510830000,\\n 37.770980000, -122.510930000\\n];\\n let value = gpsData.indexOf(prevValue); \\nreturn gpsData[(value == -1 ? 0 : (value + 2) % gpsData.length)];\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var gpsData = [\\n37.771210000, -122.510960000,\\n 37.771990000, -122.497070000,\\n 37.772730000, -122.480740000,\\n 37.773360000, -122.466870000,\\n 37.774270000, -122.458520000,\\n 37.771980000, -122.454110000,\\n 37.768250000, -122.453380000,\\n 37.765920000, -122.456810000,\\n 37.765930000, -122.467680000,\\n 37.765500000, -122.477180000,\\n 37.765300000, -122.481660000,\\n 37.764780000, -122.493350000,\\n 37.764120000, -122.508360000,\\n 37.766410000, -122.510260000,\\n 37.770010000, -122.510830000,\\n 37.770980000, -122.510930000\\n];\\n let value = gpsData.indexOf(prevValue); \\nreturn gpsData[(value == -1 ? 1 : (value + 2) % gpsData.length)];\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"history\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":500}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"mapProvider\":\"OpenStreetMap.Mapnik\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"showTooltip\":true,\"tooltipColor\":\"#fff\",\"tooltipFontColor\":\"#000\",\"tooltipOpacity\":1,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\",\"strokeWeight\":2,\"strokeOpacity\":1,\"pointSize\":10,\"markerImageSize\":34,\"rotationAngle\":180,\"provider\":\"openstreet-map\",\"normalizationStep\":1000,\"decoratorSymbol\":\"arrowHead\",\"decoratorSymbolSize\":10,\"decoratorCustomColor\":\"#000\",\"decoratorOffset\":\"20px\",\"endDecoratorOffset\":\"20px\",\"decoratorRepeat\":\"20px\",\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonOpacity\":0.5,\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"pointTooltipOnRightPanel\":true,\"autocloseTooltip\":true,\"useCustomProvider\":false,\"useLabelFunction\":false,\"useTooltipFunction\":false,\"useMarkerImageFunction\":false,\"useColorFunction\":false,\"usePolylineDecorator\":false,\"useDecoratorCustomColor\":false,\"showPoints\":false,\"showPolygon\":false},\"title\":\"Trip Animation\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":false,\"showLegend\":false,\"actions\":{},\"legendConfig\":{\"position\":\"bottom\",\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false},\"displayTimewindow\":true}" + } + }, + { + "alias": "route_map", + "name": "Route Map", + "image": "data:image/png;base64,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", + "description": "Trip animation on the Google maps. Allows to visualize location change over time. Use Trip Animation widget for advanced features.", + "descriptor": { + "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: 200px;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('google-map', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.map.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true,\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-route-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First route\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.5851719234007373,\"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.9015113051937396,\"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];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.7253460349565717,\"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\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"google-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#1976d2\",\"useColorFunction\":true,\"colorFunction\":\"var speed = dsData[dsIndex]['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', percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix('yellow', 'red', percent).toHexString();\\n }\\n}\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = dsData[dsIndex]['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.min(2, 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,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\",\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABdCAYAAAAyj+FzAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAH3gAAB94BHQKrYQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAACAASURBVHiczZ15vF1Fle9/a1Xtvc98780cEjJCAgkgiUwiIghCgqLIQyK2qI0D2g6PlmfbbaONCmo/habB4dF+tBX0KdC2PFGZB4GEgECYIQmZp5vc+Yx7qFrr/XHODSEkiDKuz6c+Z9+z9zlV9T2ratWwal3C6yh64YW8Y8Ex4431M4yhOQAtVMUBTOgRQRGEWvtBlJnRgGJABSthdIWHrIyI1l9y3339F154obxedaDXOsPGr2+anFL2TgXOJKZWEIYP5oslL0z7EtE8Zj4M0O49f5qGofKAF32GRTe1GnWTZOkR5DUi0muC0Nxaete7el/L+rwmALdde+34nOcPgei0ILK/j4rlLmbztyBMfkUyUGwT8f+ZNGojWeLeBchvjOR+Xvngqf2vyPe/iLxqABWg/p/+YqFR+WYQREsLXeUuMH8WQPhq5dmRVMRfUR8eqquTt7Din7rOOXsFAfpqZPaqABy88spjlPhfi8XytSbKfZxAB+3l0ZSZ7hXD6wkWCAggClURGWJSggUAUjivokRIoJpq6gkkyl5miOJYqNq9VO6xer3+UxfHp4P9l8Z+8pPLXum6vqIAhy+/fLYXXFksdd1go9wXQZjyggyJH1HDDyEIQ1iawMZAjAWTsWS55QHHbEREGQwPABAYZhLxzjDBIpOcQBx7BwhUvOtDkiXk/WGqcugeirbJxc1LGvXaqY5x7sTPf37NK1XnVwTgg1deGcwcHvkWOKpXuiqLiPjI3XIZJBv91lub59CORWBVAysmDKyQ8QgsQGSNsakaUhAYIAPqlE+hgHpRVeMB730IFcfOK8RbSVPHTghZCsn8IJI0hk/fA8WYXYuhovfVa8O3eudy69eWLzjsP87NXm7dXzbA6oXfnJNBflwaM+4uJrpgt6/fTlFwC+dyY7w1Fvk8KAwYHChHRsQGAVvrEBgSsGE2DtYATAwFE4MBQAUCgkBU4D3EewtRD3WK1FmkzsE7gstI00zQaoEynyFNNkucLCZg+q6lUpVLRoYGj1KWv53wla+sfjn1f1kA+750wblBYGcXunpOJcUBu3zrsIbBryhfnGaiyEghIoSRahiAcgEjyCkCqxwEFsY4BCHBEAnIIzAEsGEGRDVgYgXUiQAMcfAeEDXwqshSFe8t0syxcyRZTIgz0TSDacYkaapoNb2kySaK07MAVEaLqIRnGkNDv3ferR7/rxdd+ZoC1Asv5L6R+k9yudJTuXz+YgA7O3EOwquQjyoo5POI8oRCBC7m1YcRKIgIUUgcRSqhVUSRARtvrGEPkiRuVBsDw83B3m3Ot2KqDQ8TAJR7utXkcjpm4qSgOK4nn88XK1BlOC8iziBOPWeOkCTkk0SROqU0Jm00QEkKtFqqzVZLG406vHxol6q4pNn6XBw3jh23Zf3ZdN11/lUHuPpzn4t6Mr2h3DX2T8T85eeoYjuKuT9QobwPFfPQYp4oX4Tmc0AxD+RyanIBxIZk8hE8sx3cuLnv4ZtubD774MOz01bzQI4KjwTdXRt57NhhDYO00FXOAKA5UgsozUIZGOjOhkemSdI8NMwXnt7vsIVrDl20uDhu2tRxRtT5OCZOUvFJqhTHhFYMbbSIkpai3oSvN0Ct1lY0mqeAMHFn0UUuqo0MHDkU0Kn7X3FF8qoBXL34c1HXxNYNlZ5xawj0qZ03DN3I5UqMYqFgSiX4UhEo5IkKBUUhDxTy4FweEobF4R19g7f+6D8a29etPz6cPPGWniOOHM5Nn1qKyuUKk+UgCMSwigoLMwQARNr9ocucEUC9TzWu1WqNDRvrw8sf6HHbd7xz0uxZd5z0iY+VK+Mm9HCa1aXZJG01iZJYfbVJHDfV1BvwtQbQbDSlVsvB6+JdQHxvZLDvwDq5d8/86U/jVxygfu5zUV//4I1dXeN7QXTWzvdN+GNTKU5DpSJUKYKLRaBShi8UCaWCUr4Ijuy4NY8/9syNP/rPg6mYf3bSu9+zpTJj3/FhaG0YRWRsiCC0yFkLgAWgzFgSMkYBQL0n74iZJRDxnDiHLM3Ue4eklVCaZVlt3fr+3j/8YYrWG7NOOfeTT86cP+8AybIdaLRI63Uy9ZZKowodqZOv10G1WiLVxiD77CO7VPPqkaG+aSMjAyfvf+ONL0kTXxLAa9//fvN2p7+rlMdsYDbn7oQXhj+kSnkmd5cJlS5QpSwoly2KBUGpRFTIj92+YcP663/4g/2CSZMfnnrG6T6sdHUXopByuYKGoaEwDBGEIaIwJGOMhGHovKfEGPVBEGUAkGVJoEqGCGGaJoH3npPUiXcpxXGKJEmRpC3EcapxbWR463X/bZLebQvf95nPrpm4777TpNUYQD1WrtWdr9dCDI8IqjVItcpUra3WNPvMzjqpfH9kZOCACQGd/FL6xD8LUAHaccq7flEuj9/ARP+480YuvIzK3fO4uyzo6iLT1QXpKkMrZZhSyWTQiddd/r1n62lMsz/+sb6ouzKmmC+gWCxImMtTFAQmn89REEQuzIXKABtjhIgUAAPIOgkAAnQMlarCe8+i6tMkYeecbSapxs2WZGmszWaCRquOWt/g0Iaf/WxiJcxl7//8Z+ayoldrdaFaXTEyDD9cI1NtiBseUNtobHDN+FPP1Vkuqg4Pzph40w1nv2yAW4874dxKqZyzJnfZ6Hucz31fyuX9TM9YoKdC6KkIlbuYuyuiheLEbZu3rL3h5z87aNJp77lp7PyDpuZLZZTyEQqFvBaLRQRRzufzeVimoANsAMB2AFsAbAbQByDZDWAEYAKAKQD2ATAZwBhVhXMuSZ3nZrNhm/U6teIUraSl1ZGGDj3xWO/263+76D0f/chjEydPmaWN+nYdqTKGa0B1WGRomDA8QjpYfRZZ/HejdXRZfF6tWfeT77rte381wIHD3zpfQ/s3xfKYL0GVAQA2+iF3lWfpuDFK3V2Erh6Ysd2qlRJToTD3jzffcseza9dMm/s/P7clX6wUKpU8R/mCVkpFLRSKEgTWWmstgEEATwNYC6Cxh3IJgKM6fy9HWyu1c4861wUAswDMA9DtvXdpmvp6vW6arVQazRparRZqQ0Mjq//9+/vvt/9+a4898Z3v0Li1CsNV9cNVz4ODVoeqIsNDHsNDG5G5tiaqukZ96JLM61WT77/nqb8Y4JPz54djw8LN3eVxUxS6PwCA7a+op9JF3V3MEyYQuiqCMV2M7m4gCg/6f7/573uauUJj9t+eXSiXyzaXK3JXuaiFQh6FQsExcxnAJgDPoK1xhHbTpA6g7g6UgwAcDmB2pzhrANwP4KkO7CoA34HoOq9jOp/b13ufJEmi9XpDq9Um4rhOtVrVr/np1Y1CKy68532nH0NZ+gQGhtQPjxCGhlX7Bw2GB70OD4/A65JOvqtGagPbJrK8kx56aI/TPrM3gF8ZO/HfugpdG9W5U+EcyPmnqFioarEYULkCKhWEymVGpUxgc9A11123ws7cb+P+HzprXHdPt+0qV1y5VOCuroqGYRgZYzIADwJ4Fu2mWQHQIyIREUUAciJCRJSo6qeI6NsA7gZwO4ClqvppIrqpU7xIRPJElAfQg/YSWQJgG4AhZh5rrc1HUZgGATMRGRuGKM87yA5v2Tz02N33NOfPPeBQUfSyS1nTDEidauqIUie+Uffk3AQ4NzYy9prBZv307/bt+N1LBrh1zpwDQoTTAzIXtKdO4jSMbuJysULlippKCShXiIolImsPuf6m3y+LDpy7ZeYZp0+qlMvc091FpVJJy+UyBUFQYeanADwCIBWRkIjGiMg+qjqWmaep6nxVPY2IjlLVJ4joMACnAzihkxYR0TCAR1T1M6r6FiIqEVFFRHKqWlFVUlUhohjAWiLKjDEzoyhyxhhlMrCWbGn//UrVoaG1Ty9dGs+fvf+bSWgL0tSqy0BxBmSppSx7FnE8H84zvBzjnLvhf47r2XZpf//AnwWoAFXHTfivclSeR0RTAYA4uJIqpamolJkrJUi5ApTLxLlw9u0P3HdPOnnS0KwzzpjQVSlTsVhAuVxGoVAoMHMOwOMiMkBEkYgAABMRqSqJSAqgn5mfVtVFALYC+Bu0jchVAG4AcAeAJ1R1tqq+T1WfJaJ9mPknABqq6gDEABLvPYjIEFFJRFJVbTDz5CAIDDO1mNmoshRnzOgeWr9+oPfZZ9fOnDr1MFHpJ+cUzqv6lJH5IpL0VqgcAQChCcstFy+6ZKD/Z1/7cwA/PWfeCcWwMMhsP94mqiu4XAJVykzlElG5i6irCCrku9f2bnnk2aGB8rxzz43KlQrKpSKVyxUtFAqWiLyIPKWqCYBJqlpBu5/qEZGJzDybiN6jqkd77+8hoveq6nYi+g4RBar6QQDvBPAOAAuY+W4APyKiBao6A8AtqvpFAAtFBERUZOYKgPGqWlTVHiIa6qRyEAR5a61T9T4IIi4fOCdcd+/SfJGwprvctQ9EEnZi1XmFc6Qu60Ka1gGaDMI+AfFlQ2PG5i7p37F+V168u/YJ9KvWRl/a+UCY+xNyUai5HFMxUuSNIoyQthK6f+XKhQed9/fVUqlIpWKBS6WSz+dzQfurcC+AQe99WVXzqtqtqhNVNSKizap6u4iUVLVFRBc5574IYI6IXOK9f5+qiog8IyLPqKp4798nIpeKyH6qer6IfNN73xCRCjPfCmCt954BdAHoIiIjIkUR6RORZUSURVFki8VykM9HWiyW+KC//3zz/pXPLEySRCm0RiIryAVKuZyafJ45Ch4Y5WBN9EURf7HuZnifp4F/N3f+WwthfoTJvL/9Dt2ihahiKmVoscDIFxWFAlEQzvvtI38anvPpTy/vmjC+p1AqoburolEUBUQ01Tl3D9rW1DNzQ1VrzJyo6j4AFgFYSERLVfXdAC4HsICI3gvgSWPM94joZhFZq6pxpznfraq/Nsb8UUT2AfBhZh4G8FMAxxPRLar6BQBv8t73qeomADuYuQmARKSMdvew0FrTMIYVRAAQFvaf89T9N/x26gGTpkwn7/uQesCnUPHkY99NLlkNYH8AFct6SX+lB5cO9m/eCXZXgF79N4wJp47uvpgo2CxhYYoEgSIMlYKANLB2Ze+W28v7z0m7pk6elM/nUCzkuT20w1Tv/UYAbxWRhqqOrnhscs6tIqJbARyvqomq/j0R3QbgAiK6Q1VvJqKzvPcXqT5//2f0b+89ADxijPmi9/5dAL5MRLc4584H4IkoJKKbiWiMqh4gIlNUNWDmfhExxpgtzDwlDMMtBRHyzvvuqVPGl/af9ejqbVvs3DFjp0kQOAojFROQKQQqLtymSdrmwflPESenAjjxBQDXzZ8/iV1wO4CLAEAJd4kNpyCygAmI2KgGBkjdlCd3bJ+54NOfWFbI5xFFEedzOW+M6QFwFxFtUdXAew9jTMV7fygzv5uZ6977bxNRwXt/gTHmUlV1InIBM38VwDs6oG4RkbuJqLkbxAIRHUdEJzrnDgUQM/OXReQTAKZ0NPBSImIAnwJQAnAnET3mnBs0xkBVM2aeYK09wlo7kM/nDaA44MNnRw9dePFb9ytVMmbaAGtgQqPehIAJ9oFmfwTp20E4gNn+cu3MgybOWvfE9ucBDBJ5X7EY7dynZWOfhjEzjbWkAUOJiRRmxY5t901bfHItly9MyefzUiwW1Vo7SVV3OOcWoz2wtcaYDQBWiMidRHSqqm4jog+LyBZm/i4RnSci3yaii1T1Hmb+tff+QACnEdFJ2E2ICKq6XVWvVNWVzPw/vPff7IA8T1UvFZFNxpgPi8gIgAnOuduDIOgGcKyq7uu9Z+993Vq7PYqifYhou4jXLPOFKSce9/tH7vtTeUHPuGlK7MFWOTAiQQAT8FPe+bcDQCksjvVu5HQAPwQ6RkQB8mtXTwf47e3GQn1gM1WtgbckwqywRN6l4zYn8dsnvfWt46PQahAEFIYhq2rivV8G4Ceq+v9UdbWInOyc+4S1FqoKVf2Bqh6qqr8RkW3e+8tU9XYi+rKqHui9vwzAuWhb6UcA/MY5d7lz7nIAv0F7HNkD4FwiulRV91fVL3vv7/TeXyYiW4jotyJyqKr+H1UFM5Nz7hNEtNg5t0lE7iKiX6vqnUQUW2tNEIQU5iLd9x3vGLclTY7zWdoDqLbrzGBr4GH3VcWAAgDTcbJ29b6jP6xBW99nGJOfEYy0ijSmO1TCLRREPchFZIKANQyFQqPPpunq8C1HPjl+3oHlKMpRsVgQZj5AVR/z3p/V0egBVV0JYDEzbxORfQGMVdXDAPyaiD6JtrH4oYh8TFVPQnscdzkz/5f3fnVnnDiLiBYR0WGqKqq6XkRuNcb8ynv/eGew/W4imi4iXyGiyQBOZ+afiMhH2nqBsdQ2FhONMVeKSAzgMBF5LxGtYOZDiagPquS8mrReWxFv3jw4hrlIaQpJM1LvGN4ZdemDrDRG1qx9VLys+TvJNl8OjFgASGBO6Db58ar+fbxu3dUye78WDBMRVNgAAguHYJ1Lj15w8onLC4UchWGo1tqIiLap6urO2OwMVd3CzFd0xmY/VtVvA/gCgEtFZDERfUlVv+WcewuAW4wxfxCRt6vqlzpGAp0BN9BeUACAQzoJncEyVPW/jTF3O+feTUTfVFUB8CXn3BeIaKL3/nxmvkRV/5GIvq2qARF9QlWnA1gqImuMMbOCIAicc1k+r5h16ruKDz348JGzDe0gIAUzQExEVmBMitVr7obXsyObW5e6+O2Av5oAYC3s78YVxhypinEA1blS/i3PmlnWUtFSPgcuFCjOBc3l3RXz5i9/MQnDkHO5HIVheLCI3P6Nb3xDly9ffg4zR7v3XW8kEZHklFNOuf9jH/vY74IgKFhrJwE40Tn3aJIk2opjevDib+WPGm6lhTTNS6OpiJvQetPrug01P1x9L0ELRNgx0By4byb8aVYBXg+qimIcACj0FsTJOOzojbk40wMSiM90HezwtFNOrhtjJodhKNZaUtVCkiSz7r///hOZmc4888w/rl69etWcOXPmPP300ysPPPDAuc1ms56maQag55ZbbjnOWrvitNNOG2Fuj+H7+vr6+/v7ByZOnDh+zJgxY6655pr5ItKzZMmSezvN73lCRHj00Ucbq1ateteYMWP+dMIJJzQ6cPSZZ55Zvd9++80IwzAcHBwcuPnmm0+w1naXy+XbFy1aZP7whz/ozTfffHylUnnmrLPOeluWZZuCIMgZY5SIyDBj2knvfHbDdb8pz3U+r+otvCayfYeXZnOcQm8BcJoqJgCcKDzbTcCknA0fBnAWAKjyDiWUtZmEfv2mxM6a2VAbdG8L7SGHHzTv0SAIgPZofJyqPtJsNt9sjDFZlsVnnnnmMara6PR3C4noJ6p6HoCHmfmmW2+99bh3v/vdI2ecccYQgGUAzgZw3C7jvjU33HDDxlqtFpx55pk3ABhdUg+89wUAMwGcsHjx4uI555yDM844Izn++OMLAK7z3h9rjPlkB/JFInLm0qVL++I47r7iiisOyOVy5dNPP33pOeecs/K66657z5IlS54mopNV9XcAxllr+7xX2ufNC4sPXH/DvLnS6JPEx1i/UaXZKrR/S9422qtYGz603mUT2AP7Rhxyu89VcOADYnWqHuLSfLpxY5dWa9vZmO6wUCiTseC2+swTkY2jlSciiMjFqvouEbkEQE5VP6mq56vqId77LwPAtm3btqnqqar6HRGZB+BqEfmCiFykqqvCMDRERM65S5xzl3XSd1T1a6p6uqo+2Gw2vwcAvb29G0XkMFX9DjOfDOA7AH6mqhcQ0aw0TT0AhGHYA+A7RLT4Ax/4wB1ENHnDhg23qSpEZDOAedZaMobI5HIltcF4DNd28IZ1OfGuCCiUycP4YJRTyCEE2Jc9zFwY3rf9NqDe9DjhQAUW3oNcipH+HcmEgw/5PhEnDIWIKIC6qtZ362POArCUiP5FRL6lqjVVvURVfy4iPwKAKVOmTAbwsHPuCwB+5Jx7P4BLAVwgIouZmbQtN4jIr0TkV97721T1MREpiMj7SqXS5wGgp6dnkohc2fkBnhKRL3Ys8JUicl0QBKYzhLpMVb8BYOlJJ530NhHxt9xyyzGde0NEVG3rAKVgbk6cP/97IwPb1WWO4T2p91aFAxUaM8rJGp4CmDkWoDcTuON+RqmSlgyJI/Uq3nhKM9SisKX5cJYxrNbamjFmnKpuc869s6+v72cA/rGjhXMAbPbe30ZE/wLgHhG5jog+O6qp69atWy8i7ySiSzuWelhVrxeRx4wxSaPR+EC7K9GZqlrsXO9Q1Q1EdIeIbKnVagDwHVUlAJ9WVRDRJufcBcaYA7335xIRvPerOv3ol9FeFgPaq9a9jz766CTv/XcALGLm7QBCIhokIOJibkY1CJs5X/fshYyqcyQGRBEpUkBDEM0jaGYB7KekC6EEgj7JAOC9aHvnQSAW/cVSuO/cuYn3PmLmnDEm7UzDFlQqlb4OvGDZsmXfO+yww86x1m7z3v8LM39NVd/mvf9+54f77OzZs2eoatF7/x1rba+IHAngFAAf8N6jWCw+2mq1SER29SmcucvQBsVisQ4AW7du3UxE56vqJBE5tzOrEefcZStXruQ0TT/IzGi1Wl/P5/N/Q0SzmfkrY8eOPaO3t3ccgGNEZIGq3sXMKRH1QFXGzp2bbSyWypPTXqgXdd6BvRdVZEr6FBSHMnCYgBMLIA+lCgAo6RZRhFAFeQ8IQSwQR2FPYZ9JW4MgSK21LCJzRaRJRB81xnwNAIwx5oorrvhspynPEpF/BlC11jaiKDoZnd73+uuvn7Fp06bHrLVHhGGo3d3dEJGHsizbGsfxjoGBgTMBFK6//vqrjTFBtVottVqtuNFooL+/X7Msc0NDQ1MBvO3WW289aenSpUfEcVzy3ouIxEQUGmPOA4BRS++c+6SIVIwx53nvL91vv/3uXb58+SwAE4jooyJyjqrOtNauIiJXnLoPJ8Woy2cZwYuyqIoqoEgFWMvAoarUDaBgAfCo96sqWiA15L1CmMApICDJh/l8qcTGGFFVj/aUaqRYLP7L2rVrf9xoNL5eLBZ3aggzEzOHaO9VVJLkuU1+7/2MBx54YAb2Isa0V9h+85vf/Pk9WaKJrVZr4iisUWC7y9q1a389b968aQAuA/CMtfapOI4XeO8vLpfLX4vjeA3aa4gCQE0QqURRDmlKgEK8AAqCihK0iueGV8yA2tGOkYAUIIEoqfckzpPLHBAEFY4iEhFnjPGqOhbt3bGZ++6778l9fX3YsmUL6vX6zqWnN5JYa49g5lNV9d9E5FvHHnss+vr6cpVK5X0AphNRA8BY770AEJsLSYytqMtUUwd4bbMDRBWNUV6AWn6e87WSV9KmGtRhUGdwPYCpE1srzmVE1ErTNFHVLd77AQAwxswEgCzL0N/fj82bN2NwcBBZ9rKdP18xWbVq1ZPe+49nWXam9/4/DzzwwGcBpNu2bTMA0FmE3UpELWNM3RiTsA1CgOpgqpNBnRk1z9QU2J28aFT7dr5BmldFyQIqIPKq5OFh4evqfaiq+c48dC6AjXsqrPce1WoV1WoVYRiiVCqhUCigs+D6mkmWZWg2m2g0Grjqqqui7du3Hzt58uTbJk6cuHb27NnvJ6Kt995779NLlix5E4CJqjqHmR8CYLMkSQP4qgrKBJAwhKBgJVZyFjs9jwEreE4FhajIopRAGSAGkRG1CJwfcnEqlMtZIhIiqhNRcc9Ff07SNMXg4CAGBwcRBAHy7QVYRFH0igN1ziFJEsRxjDiOn9cCduzYcdbVV1/9gs/84Ac/WLFkyRKoahlA3XtPzjmbtVrMiWs66BgQiIWcQpVZQwJ17eQFwBLQC9UNIEyHoqJGYwhIIO21QvbepGktHhpyQXeFOyvNDeCFHvi7yBAzb+zkwSIyLcuynl0rZa1FEASw1iIMQ1hrwcyw1oKInmcQRGR0TREiAuccsiyDcw7OOaRpOrrcvyfZxMyD1PbBgfd+Ptq+NhgYGDgJAIhoHwB1dFQrHhlxuSyteUg3g4xv93VKYFJomaCA0hoCtliBPi4EQ8B0IZ1LgscZUKfwgIoQo7S9v9nYtDGMpkwxYWhtZ2B7SCfT5wkRPU5ErqenZ924ceNkx44dPDIyAgCbReTg0edGK/8qihhj/qSqZuLEiWvL5TJv3ry5GMexV9UxqjoTQNrb27u8VCq9mYgeFxEbxzE11m8Mcn39Dc8QFaiFAkSkKoZID1YlgOQhQB9lBT2tKqsAgBTjhaTmSb0C6kDGiQa8dWuhsWZ9TtVb55wBMKiqxjl3LwCUSqXfjsJj5vqiRYsmBEFwOjOfEUXR6SeeeOJYImow85OvJrFdxRhzfxRFQ0cfffR8a+2ZQRCc0dPTs3jx4sVpZ+q2paen596VK1c+pqoBgMHMeyYiaqxdVwy2bCtnQoEA5Nu+TJ6gI6o0BgC86rMCeoYZfnXm0+0Ytc3CDXiygASqYK9MNFwrNNavnZ2mKURERaQOAEmSbN1t2GIXL148pq+vb8p3v/tdiAi+/e1vo6+vb+qJJ57YjfbK81/syP1XyAZVjRYsWHDc2LFj81//+tcxffp0nHfeeXjyySffsmDBgg3MvKNer49LkiTqdA11FYFzHsn6TbN4qJYnBYmCFQjEU6BKzVFO4tMdgF9rCVib+Oy00OQAAMTUVFEVpUyhUKhFko5F4g5Mk+QOIgqIKGVmOOemX3vttXfW6/XjrLX3EpE89dRTxy5cuBDLly/HvHnz8MADD2D69Ol45JFHDmDmewE8rKrjXk16RDQwadKktRs2bFgYRRFWrVqFSqWCe+65B4cffjhardaihx9+eG2WZVOMMRYARCTN0hQuzRLN0nk+i58ASAOwCOANgcHaVGlb4KbPxjtgjd0fqK4Uf/ROWyxqASYlsVBWBbwCCPv778x6dwzQPvtMMsZoEASbVfVtRx111E3PPPPMzQBwyCGHFK666iocwXXn3QAAEZlJREFUe+yxePbZZ1GtVjF16lQcc8wxWLp0KRYtWrT60Ucf3Wl+X6lB954WXk866aSJy5Ytwwc/+EHcfPPNGBkZwTve8Q40Gg0sXbo0nD9//vJWq8UzZsw4RUQ2dgyVtjZtGgj6+m9X8EQAQlBWKBFYSBBqh1Mq/m3zgQvabrNAnwIPEHAEgBMIspJUIKTkARaFMQ89OlJbvjw/5vTT1DlHxpgnACyaNm1a38UXX/wxEYGI4IEHHsD111+PJUuWgJlxyCGH4Oqrr8acOXPwmc985m9Hnxu1qLta11Ggu7+OAtr1lZlBRM+7Hp3OjVrwG2+8Eddccw3OO+883HXXXRgYGMD999+PBQsW4Oyzz/5okiT/KSLjVPVGL6KNRoOaD64o24cfq3nIFEOkAlIDdYAnAb+jw+sBAtYBnV25v4OphWyHmPidALqgtEIIxbbnIkOg5KrDJT3ggLebgw9ew4aNtTYlonne+2IQBN2qyiKCOXPm4Je//CVEBKtWrUJvby9WrFiB888/H8Vi8Xnw9gZy1793T3vT3t21kIgwefJk3HzzzajX69iwYQN27NiB7du344tf/CKstS5JkhKAsSKyIm61qNVspcnd95yst92RWMAYYjEABYCxsIMKfQsAOPGXpz77w/cg6xkAArj7W65VHs1cGNsZpBYMQKAgiFIevX23tTas64vjmFqtFlT1QQBzkyT5xWgFp0yZgiuuuAJRFIGIEEURLrnkEkyePPkFkF4see+fl17KZ3aHfdRRR+GrX/0qms0m0jTF3LlzcfnllyMMQ2RZ9gtVnauqf0rTVFutBK11m/pNb98tqhIqoKTC3LbAqup3jPJpuVaXh/sTsIun0dMwf+zJd+0PxWRVPA7CjhSglgIJhDxAaSm/OffZT0+17z+9t1AscqlYZCI6Q1WfiaJoWmfF+AUV/nMAdtfCF2vCL9Zsd0/GmL39PRTH8QARzfYi1zXqdbRaseh//fe41uXf326ayZQA0BwMAlLJA8SKsUp4EwhbBlsjz86DPw7Yxb1NgEuc91e0C4qDFboDECK0zY4KyNdbU2iknqT9/X1JHEur1VIRuU9VD0iS5Jo9NbndwewN3p408M/9AHv7vr3lPZriOL5BVfcDsCxNErRaLcRbt/XpSD1zzXiSgtgAyvAwbSPSq4Q3AYB6fzmA/z3KbSfAEP7melqfNrppQqKtAIAhUAiAWD1BbO2XvxyJ7l52QL1eR6vVEieySVWr3vszvff37K3v2luF9tZ0X+z6xZrti/Wfqoosy2713p9FRMNpmm6u1eucpqkU7n/wwPov/m+DAGsgQgAZAhkoWDQZ5TKY1ueuh7/9BQD3BxLfNtHXdHxAPgyiEduZzzJAEFI3PDxO+3aMYP3GvlqtybWRKrz3d6hqMcuyblUdGm1+e6vYi/V7f8n13mDuCWKnTL1pmk4CEKRpeme90aBGKxa/ZsN237d9OKnWxrQdKEm4c9qbCcPKdLa2rcEvAG2c0nZofz7AdjOWC0fSxuiZCMuCHQYg204IIEoA9f7kqlLlqZXvTONGrd5ool6vp9r2OD04juO7te3L8qKa8FIMyksxIC81H1WNW63WU0R0sIjcFsdxVq/VEdfr9a6nn17c95OfFRhCFkohFBZqLKAQ7kfHi62aNtcmkG/syux5AA8GtntxBwB0BwAo0YcD5WFDpAYEA4YBQzPXvf26/75vzP0PlhuNmtZqDWo2m4MAHgLw3iRJfrWrEdj19aU06d0t8K4gX8p37CnvJEl+h7YP4oOtOB6oVqtaq9do3EMrituvuW4ZMt8TgikAwxBgicDKwyD9CAAocIsTN3th22N2zwABIIb8r6G0vqzd4jUnLD4E1HS2OgJAQ6KkuXrlfn79Rp9fs663Xq9ptVbzjUZjjao+AuCDzrlfAHhFNPFlal6aJMkvVfUMAA83m821tWrVjNTqlHt27WbevJmba9bMMgRvALWAsBIFgIA1BRAqgKG08TBBzt+d1wsALgS2irhAIT8CFFCcYYFVAUHzgIYAWRKyUF37o590j9u89ah0247eWrXKw8MjaLZaq1V1uYj8TZZlN6jqyN60cW9a+WLjwJeibaOvALamaXoPgLMUWN5KkjXDw8M0ODSk0rejf0LvjiPX/OA/ihaAhUoHIIekxJCVpLqkzUB+KOLsfOAFUZH2eNDmQ9ClcG5J3uZmKBBCKWcNDQsQCEQ8wSiIRMT0Llvef9CCQxZsjIKnHbikIgKgGQTBJlU9xTm3GsBqANN2b3Z7et2TMdh1/Lf7GHBPY0IigjHmHu89EdERAG6O47hvcGjI1OtNifsHBg5cv/HYJy765gbrfaVAxCGBilDNEWyOTaxCbwJhHIGqQ0ltex/kcz9re9/+eYA/BtynYaqG6HHDZhEIE6D6EClXAHgHYgAsBPLqo833P7Bm4cELjt1A9GhGWnaqEOdbzPS0MWauiExX1Z9re+Qf7KkP2xO43QHuCmhP0Dpz4CERuVZVTyYicc7dPlStJrWRmtZrVY37h/oXbu094bFvfXuFSZOxEZGNFDYCfA6MHDGp6nYiHA8Aqc++lIn//RGQPUb32OtZuR9C1nxc/GcjGz5JoIMBmk+svyPQBAACUqiSVVJIlhW3LLt/5eGHLzx8o8NTKUkh88555wNAN1pr+1T1VBFZx8w3icjB2j6a9bwmt2uzHJU9QdqLBgoz/1xVJxDRUQD+2Izj1fVaLalXq+FwraZZ//DWI/r7j3/4m998gprNSTkgyIE4YmgBLDkiWKUniXAOAAjwi2ra2Ocg+Ev3xmmvAAHgH6A31lz6oZzJEYCxUD6YCHczoUeU2DMMQCBSylzWtfHOu2tHHnXk9DjLHu/1bqzKzj4sZrarARUROUnbHq2/Q9tFrmtXiKPXe1p5GZ2K7QZvC4D/ApADcDSAJ0RkRa1Wy2rVBtXqVQwODMvkoZGtbxoZPuyBr3ytj5JkfJ5gQmWTJ0IR7AsEWKb1qvhIh8uq4WSkVYJ+6N/30HRfEsB/B9zHgWVO3fjIBAsIGhHRDIY+xNAygRVQgjBDlcW7YN0dd2bzDj7YTjXWPJVltVbqOUvTXOYz8c63VGU1M9cBHKKqU1T1NgB3S9uzfho68/Pdm+0uyRHR3UR0B4DtRHQgM5dUdV2apk83m83myMgI1RpNHR4aMLVavX5stV6sbNpaWP71i4qBl2IEaF6ZCyxSAEsemgXEW1RwGkGLANxI1riaVL4yp30YfK/ykmImPApzap7t3EKQ+067ctigwP2xYlwq4AYLN6AcgzQW9S0oeubM2fTmv/v0kY8Q3bUxF3bnCpHJBYFEUZ7CMEQYtnfjmDnsaOE07/047/16Vd3S8RZIvPdgZgugQkSTmHm6MWaYiDZ2oKejO3Rx6rXValAaJ9qMWzohTre9FXTy4z//5dLtDz64fx7gHLdHE3kYXxDhHINyxFsVeCsU+wKQetb6p1T844fA3/jn2LzkqB2PAOeUOSqHQefoP2EThP6YkUxvgnwLoi2QT1RLTZUkUTXehukxXzp/MJw8acyt3j1RZTOhkM/bMBeQNYHmcyEAAxMwhUEgaLurdcJmAUTtU/LS9kfU9jUABrIkJS8eBGiSJJRlmaZpqtV6S3oEAydZO6+1bVvfsv99yVjj0jBH5AsgHzKFecAVwFIAyCi2EeMoKGYBQJrF5zUlSQ4G/s9L4fIXxY15BPhqV5AfsRx2INIQAdd7yP4tQJseQQvQJpRarC4VlRQQU+5qHP2Fz6e5CRPG3dGsr9geBOMtIQzDnLBlCkyAIGBSJTWGoKoZGePEOQ8AbK1R7y0RWSfKDEWaeiiJpnEK5zJkzmfjVftOyOXfnG7v237vv12Wk1qtEAKImGxOyOZBvgClvAHyIDWglar0PwjtfjiV9Lx61iq8CfjWS2XyF0cuegz4+zyHYRTkvwmAFUiJ8WNRPTSGaqzwTYEkDI2hpuVhHSFLoGq7K4NHfvQjtfEHHHj8mlZ828NJq9HnsnHGhNYGhgwD1rYNhaq6dgwZoDM5sCKs4jLy4uEyp+KzrDsIB95aKJSmBdEJ2598+s4HfvaziqtWe3IgChQ2b8hHUJ8TQp5h8gREIEtKjwP4KLU9yKSZxV9IJPmL4P1VAAHgUeAjAduppaD0v9CJd2oIvxKlcgJfjAHTFLIxqcaKLGXlTGBa0CxTsWK40TNjZt9hH1hiumfMeEcs+sS6VmP1hjh221KniXrK4I2gHXiH4cnA+DwZnRQEPCOfs7ML+f0j4gMH1qy/88Hrfikj69aPZ9FijthZUBAxfCQkOYYtClHI6nNgH4AalrThFWd2CAwOp41/F3FrDwV+/pey+Kujtz0EHMXgT48NS3NBGI0XuAqge5TkgFhJEqiPgSCBaiJwKSRogbxTNY7IZ6rGkWYmFw10T506MnXBm3X89Olhvru7GJXLlSCKxgNAliR9rWp1JBkZafZt2JBufPghHtmyuUviZEygFAREYlXZErkIGoTgNMewEYhCIMsBlCMERvkpJX07FPsDACnuG0rrazzk8gXAn/4aDi8r/N2TwJgU+HlPWHqQiL/y3B39NROJE0yJiVwM0VQ0TIE0ZVgH+EzEZKAsgwYegIdmClivnCl5B2KFSHv8xWyhQlBjLElIgCOQDUFqAR9AA8vsQ4ADgc8BgWVKc2DOqQbM2KSqAYHeN1pCUflaNa0fTsCHDgGG/loGLzsA47WA2Q/4Z8sWXUHhQ9o+nAwFUkB/ysAYrzQ1IU0cqYmVxAGcifoU4IwhHmIgTI4FIuQEUAWI2lNGKFQIHZcxVmuElVgQgL0RmBDwAZOxgIQEEylcpBQxY6MRJI4weo4PAJ5pZM1rMnHuTcA36bnjZH+VvGIxVB8HZjvghyVbvCkw9hsKLXRuCSn+rzIEqtMzgnEKTQnkPJwzCJwCHuqkbTUcAeIERNSeAajCWoZqe2XcctuqBJahgUdmGUEAeEswgcIR0XoVWGqD43ZFqZn49CtN11rkgE8d3g7487LlFQ1CqwCtgDmVIH9bDIsPWeJ/1vYUa/T+kwq9j5WKRDQ5UwmFkHmAPRSOQOpJhSHtlcT2fI6gCiYigWGjsAplkBoAAWAMOBPVbSBtEOhotCMZjVYwdioXN9LGmwX844Xwv38lQyK/KmGQHwQCMmYJRD6UM7nbQms+D6V9n/cQ6VYAdwLUIiBSRQVAt5IaArwHgbQ9jFESNu1ItKbtLIFhIlQ73UQOwAlQmrRbzTaIc5c3fHyiMv9Cvb/2sOdicb1i8qpGMleAH7T2BPL6T9aYu3Im7CHQeXsvDQ1CZQVAI6QY0fZ0DqRaVEIXoF0gXgDVMXv7CoVeFvt0KPP+WGPoWwucu/Pl9nMvJq9ZLP0HgXFg89U8h+uZ7SWvRh4i7vyGZNNZ3DcOA171MPDAawhwNL8/sfllzuQ2M/EL9hdejojq5bFrTT1M/RmvVtj3Pclr/t8c7gRswdjfFzjHoOfCh7wcIcU9dYlj6927Xo1+7sVkz0d7XkU5HnDq3ftjadWVdNPOU6J/bSLd1pBWb+bdktcaHvA6AASAo4Bq5s1Xmz79tQKpoN3L/6XJAy7x2VXkzdfe9jJmEy9HXheAAHA00sdJ6QFR/Ye/FmCq+g9edeURSF8z5/Xd5TXvA3eXZRxcam00zMDukeX+nHwj88nYt/jnIvC+HvK6A1SA7jPhb4wJqtSOofBS5NpMsuKtLn3Pha/iGO+lyOsOEACWAXm1we+Ywm4iLPwzjz/mJNuSufT049vHJl5Xed36wF3laKClLjhbJHtEQdW9Gw2qZ97f5505940AD3iDaOCo3GNzx4HxVlZz0Z7ui8o/KrLlxzr3x9e6bHuTN4QGjsrbXHwXRJywnP8C7WP5AkHkjQQPeINpYEfoHpP7hRrapEr/0H5LL2fR8W/z8d/gNZymvRR5IwLEnYBlW/i9gJiACNBYXfOU41/ExeL1kjdUEx6V4wEnLlxCkCogfeqaZ74R4b3h5d6wNP/esDT/9S7Hi8n/B3LrBEUxxEM2AAAAAElFTkSuQmCC\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"strokeWeight\":4,\"strokeOpacity\":0.65},\"title\":\"Route Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "route_map_openstreetmap", + "name": "Route Map - OpenStreetMap", + "image": "data:image/png;base64,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", + "description": "Trip animation on the OpenStreetMap. Allows to visualize location change over time. Use Trip Animation widget for advanced features.", + "descriptor": { + "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 TbMapWidgetV2('openstreet-map', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.map.latestDataUpdate();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true,\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-route-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First route\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.5851719234007373,\"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.9015113051937396,\"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];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.7253460349565717,\"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\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"openstreet-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#1976d3\",\"useColorFunction\":true,\"colorFunction\":\"var speed = dsData[dsIndex]['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', percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix('yellow', 'red', percent).toHexString();\\n }\\n}\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = dsData[dsIndex]['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.min(2, 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,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\",\"data:image/png;base64,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\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"strokeWeight\":4,\"strokeOpacity\":0.65},\"title\":\"Route Map - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "tencent_maps", + "name": "Tencent Maps", + "image": "data:image/png;base64,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", + "description": "Show latest values and location of the entities on Tencent maps.", + "descriptor": { + "type": "latest", + "sizeX": 9, + "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: 200px;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('tencent-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.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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.24727730589425012,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.8437014651129422,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7558240907832925,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second Point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.19266205227372524,\"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\":\"longitude\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.7995830793603149,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.04902495467943502,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.44120841439482095,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"tencent-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"
${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details
\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"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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\"],\"showPolygon\":false,\"polygonKeyName\":\"coordinates\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.5,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true},\"title\":\"Tencent Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } + }, + { + "alias": "here_map", + "name": "HERE Map", + "image": "data:image/png;base64,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", + "description": "Show latest values and location of the entities on HERE maps.", + "descriptor": { + "type": "latest", + "sizeX": 9.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 TbMapWidgetV2('here', 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.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"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\":\"longitude\",\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"here\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"HERE.normalDay\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"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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\"],\"showPolygon\":false,\"polygonKeyName\":\"coordinates\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.5,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":1,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true},\"title\":\"HERE Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "image_map", + "name": "Image Map", + "image": "data:image/png;base64,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", + "description": "Show latest values and location of the entities on image map. Uses configurable background image and coordintates in range from 0 to 1.", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6.5, + "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 TbMapWidgetV2('image-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.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"image-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2ZwogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiCiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB3aWR0aD0iMTEzNC41MTgzIgogICBoZWlnaHQ9Ijc2Mi43ODI0MSIKICAgaWQ9InN2ZzIiCiAgIHZlcnNpb249IjEuMSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMC40OC41IHIxMDA0MCIKICAgc29kaXBvZGk6ZG9jbmFtZT0id2ljaGl0YW1hcC1ub2xpYi5zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnM0IiAvPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0iYmFzZSIKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMS4wIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwLjAiCiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTp6b29tPSIwLjM1IgogICAgIGlua3NjYXBlOmN4PSI4OS45MDc4NTciCiAgICAgaW5rc2NhcGU6Y3k9IjQ1My43ODI0MSIKICAgICBpbmtzY2FwZTpkb2N1bWVudC11bml0cz0icHgiCiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ibGF5ZXIxIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjEzNjYiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iNzIxIgogICAgIGlua3NjYXBlOndpbmRvdy14PSItNCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpvYmplY3QtcGF0aHM9InRydWUiCiAgICAgaW5rc2NhcGU6c25hcC1nbG9iYWw9ImZhbHNlIgogICAgIHNob3dndWlkZXM9InRydWUiCiAgICAgaW5rc2NhcGU6Z3VpZGUtYmJveD0idHJ1ZSIKICAgICBmaXQtbWFyZ2luLXRvcD0iMCIKICAgICBmaXQtbWFyZ2luLWxlZnQ9IjAiCiAgICAgZml0LW1hcmdpbi1yaWdodD0iMCIKICAgICBmaXQtbWFyZ2luLWJvdHRvbT0iMCIgLz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE3Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICAgIDxkYzp0aXRsZT48L2RjOnRpdGxlPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIgogICAgIGlua3NjYXBlOmdyb3VwbW9kZT0ibGF5ZXIiCiAgICAgaWQ9ImxheWVyMSIKICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjcuMDcxNDI4LC0zMDcuOTAyOTkpIj4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDM3ODciCiAgICAgICBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMzY0ZTU5O3N0cm9rZS13aWR0aDoyLjk5OTk5OTc2O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmUiCiAgICAgICBkPSJtIDkwNi4wMzMxNSw3MDYuMTMzNjcgMy40MjkyLDE3Ljc5NTUyIE0gMjguNTcxNDI4LDc2NS4wNTA2NyBjIDE1MC40MzUyMDIsNi44MzM0MiAxNDYuMzkyMzIyLC0yNi4zMzQxNSAxNjYuNDM0NTQyLC0yOS4zMjAwOSAzNi4xNDM3NSwtNS4zODQ3NiAxMTQuMjg2NzYsLTYuNTI1NCAxNDguMzI1MDgsLTguNjIzNTQgNDMuMzc4MDgsLTIuNjczODUgMTQxLjc2MjIxLC0xMS4yMzA5OSAxODguODU1NzgsLTE5LjgzNDE4IDM5LjgxMTM4LC03LjI3Mjg0IDIyMS4zNjk5MSwtMC44NjIzNSAzMTkuMDcxNDEsLTAuODYyMzUgNzAuODI3MzUsMCAxNDYuOTE4NjcsLTEuNzI0NyAyMTguMTc1ODYsLTEuNzI0NyAtMzEuNjE5NywwIDExNy44NTUyLC0yLjU4NzA3IDg2LjIzNTUsLTIuNTg3MDcgbSAtMjUuMDkwNywtNjguMTI2MDYgYyAtNTIuNzk5NiwzNC43ODQ4NCAtNjUuODk1MSw1MS43NDg2NSAtOTUuNjM5LDgxLjQ5MjU4IC0yNC45MzEzLDI0LjkzMTI3IC0xNDAuMzk2NTMsLTE5LjEzOTIgLTE3OC45Mzg3MSwzNi42NTAwNyAtMTIuMjgxNCwxNy43NzcxNSAtNDcuMDAyNTcsNDYuNTQ2NTMgLTY1LjEwNzgzLDU5LjA3MTMzIC0yMC4xMDUsMTMuOTA4MTggLTU2LjAzNjcyLDQ0Ljk1NjY0IC02Ny43Njg4NSw3My4wNzgyNyAtNC44MDE0NywxMS41MDkwMiAtMTMuMzgwNDYsMzUuOTkyOTggLTIzLjQ0OTQ5LDQ2LjA2MjAxIC0xMC40OTY5OSwxMC40OTY5OSAtMzguMzc3MzMsNi4zODU2OSAtNDQuMDIzNDUsMTcuNjQ3NjQgLTE5LjAwNTAyLDM3LjkwODEyIC0yNS40NjUzLDEwMC45MjM1MiAtNjcuNjE3ODksMTAyLjA1MTAyIG0gMTkuMjgxNTEsLTYyNC4wMTQ2NCBjIDM0LjY1OTM0LC0xLjg3MzgyIDg0LjAyNzMzLDcuMzkxMzEgMTA5LjkwMDcxLC00LjI4NTQ1IDEzLjI4MTcyLC01Ljk5NDA4IDQxLjQwNzIxLC0yLjQ2MTM1IDY2LjgyODY2LC0yLjMyMDQ2IDM1LjMyMjM4LDAuMTk1NzggNjQuMzgyNDksMC42MzQ3NyAxMDEuOTE2Nyw1LjAyMzIgMjUuMDMwMzYsMi45MjY1IDQ0LjY2MjczLDM0LjI4NzIyIDU4LjUyNjk4LDUwLjY0MzkgMTcuMDk4NzgsMjAuMTcyNjggNjIuNzYzODYsLTEuNzE0NjcgNjYuMzA1NjYsMzIuMTM0MzMgNS4xMDI3LDQ4Ljc2NTg3IC02LjMyODQsNzguNjM3MjUgNi4xNDExLDk3LjM0MTUgMTkuOTY5MiwyOS45NTM3OSA1MC40ODY0LDE3Ljg1NTc5IDQ0LjYxOTMsODMuOTcxMTkgTSA1ODkuMTAyMjcsMzA5LjcyNzE1IGMgNC42NDM0NiwyMy43MjkyMyAxNS4wNjkwNCw3Mi43NzU3NSAxOS4wNjEyOCwxMzAuNjQyODggMC44NzIwNiwxMi42NDA0OCA1LjQ0NzE4LDI0Ljk5MjUzIDQuMjIyMzEsNDUuMjc3NTcgLTIuNTE3MjEsNDEuNjg3NSAtMTUuNzE3MDYsNDMuNjc3MjcgLTE1LjA5MTIyLDYwLjM2NDg2IDEuNDMxOTUsMzguMTgyMjQgMzAuNjEzNjEsOTMuODM3MTkgMzAuNjEzNjEsMTM5LjcwMTU0IDAsMjQuMTgwOCAtMi42Njk2NCwxMTUuMzkwNDUgNy4zMzAwMSwxMzUuMzg5NzYgMC4xNTkxMSwwLjMxODIxIDEwLjA2NDc2LDM1Ljg4MzMyIDEwLjc3OTQ1LDQ5LjE1NDI0IDAuOTQzNzgsMTcuNTI0NjkgLTI0LjQ3OCwzOS40NzAwOCAtMjguMDI2NTUsNDYuNTY3MTYgLTUuNDc3NywxMC45NTUzOSAtMzYuOTczMjQsMTAuODgxOTcgLTQwLjA5OTUsMjQuMTQ1OTUgLTMuODY4ODQsMTYuNDE0NTEgLTMuODY2Myw0My43OTczNSA0LjA0NjQ3LDU5LjQ0MTI5IG0gOTcuMzM3MzQsLTY5MS4wMDk0MSBjIC01LjAxMzMyLDM1LjUxNTk1IC00My42NTkwMSwxMS4zMTY1MiAtNTguNTM4NjEsMjMuNzgxMzEgLTIxLjMzMDE5LDE3Ljg2ODUyIC02Mi40OTk2NCwzMS40MzIxMiAtNzAuMTI0MzcsMzUuMzY3MDggLTM1LjA4NzYzLDE4LjEwNzkzIC0xMTAuNDcyMTUsLTE1LjE0MTk2IC0xMjUuNjE0MSw0LjI2ODQzIC0xNS45NTA2MywyMC40NDcwMyAtMC4wNzM1LDYxLjQ2NjQ4IC05LjE0NjY2LDg0LjE0OTI0IC02LjAzNTcsMTUuMDg5MjYgLTE4Ljg3NjcsMjMuMDE3MzQgLTI3LjQzOTk3LDMyLjkyNzk4IC0xOS43NDgyOSwyMi44NTU1NSAtNjkuOTc0MjgsNjkuODI0MTkgLTg0Ljc1OTA0LDEwMC4wMDM0NiAtNy40OTc0MSwxNS4zMDQwNCAtMy4yODQyNiw0NC40MjA0MSAtMy40NzA1Myw2My4zNDI4NCAtMC4xMjc5MywxMi45OTQxNCAtMC44MTAxNSwyMy4xMDM4NSAyLjQwMzQzLDI4LjI3NjE4IDQuOTYxNTgsNy45ODU4MSAyMy43MjA1LDI4LjExMjA3IDI0LjIzODY1LDUwLjYxMTQ5IDAuMjk0MTEsMTIuNzcxNDYgMC4wMTMzLDc4LjU5MTAxIDMuMDQ4ODgsODcuNjU1NDkgMi4zMTI1Niw2LjkwNTQ2IDQuMjIwMDQsMjYuNTY0OTcgMTAuMjEzNzcsMzYuNTg2NjIgMTEuMzU0MDEsMTguOTg0MTUgNC4zODczNyw0MC4xNTY2MiAyNy44OTczLDUzLjUwNzk1IDE5LjA1MDEyLDEwLjgxODU5IDQ2Ljg3NzgxLDEyLjIxODYyIDgxLjkyNjE4LDE0LjQ2MDU0IDMzLjcwMzQ1LDIuMTU1ODkgNjEuNTEyMTcsLTEuNDMwMzUgNzYuOTIwNzcsNi4xNDExIDExLjU4NTA4LDUuNjkyNjYgOC41ODE1MSwxNy45MzM0NCAxNC4yOTU0MSwyOS4zNjEyMyA1LjY0MDQyLDExLjI4MDg1IDMxLjUwMjYzLDExLjE1NjI3IDQxLjgwNDA5LDQzLjQ1NDg3IDcuNjA1OSwyMy44NDcxIDMuMDg1OTMsNDQuMTU2OSA2LjcwNzU1LDY1Ljg4NjYiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjY2Nzc3NzY2Njc3Nzc3NzY2Nzc3Nzc3NjY3Nzc3Njc3NzY2Nzc3Nzc3Nzc3Nzc3Nzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgIGQ9Im0gNDMuMjc3ODgxLDUxNy45NDY3OSBjIDAsMCAyMzAuODQ4Mjg5LC0zLjYzODA1IDI1MC4wMDg2MzksLTMuNjU4NjcgNy40ODIyMiwtMC4wMDggOC42MTk1NCw1LjE1MTk0IDE0LjAyMDksMTEuNDU4NjkgMjQuNTk2MDgsMjguNzE4OTMgOTMuOTA5NjYsMTEyLjkzNTg1IDkzLjkwOTY2LDExMi45MzU4NSIKICAgICAgIGlkPSJwYXRoMzc4OSIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICBkPSJtIDM1Ljk2MDU1NSw1NzcuNzA0OTQgYyAwLDAgMTY1LjUyNDU2NSwtMS42ODQ1NCAyNDguNzc5NTY1LC0xLjY4NDU0IDQuOTQ3NDksMCA3LjcyOTkzLC0yLjg4MzMgMTAuNTM3NzEsLTUuNzI5NzcgOS42NjEwNywtOS43OTQxNiAyNS42MzE5OSwtMjguNTg5OTUgMjUuNjMxOTksLTI4LjU4OTk1IgogICAgICAgaWQ9InBhdGgzNzkxIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOiMzMzMzNjY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gMzguMzk5NjYzLDY0MS43MzE1NSA0MzEuNzA1OTMsNjM3LjQ2MzExIgogICAgICAgaWQ9InBhdGgzNzk1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOiMzMzMzNjY7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOm5vbnplcm87c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gMzkuMDA5NDQyLDcwNC41Mzg1OSA1MjMuMTcyNTMsNjk3LjgzMTA0IgogICAgICAgaWQ9InBhdGgzNzk3IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzAzLjk1NzYyLDY4Mi41ODY2MSAxNDYuNzk1NDIsMS44MjkzMyBjIDEwLjUzNDAzLDAuMTMxMjcgMTQuMzQzNzQsLTIuNjM3MzkgMjUuNDg3MTUsLTYuMzcyOCAxMC40MTIxMiwtMy40OTAyNyAzMS40MjQxNSwtMi42OTg5NiA0MS4zODUzOCwtMi43NzM4NSBsIDQwNS41NjA3OSwtMy4wNDg5IgogICAgICAgaWQ9InBhdGgzNzk5IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgaWQ9InBhdGgzODA0IgogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDQyNi4yMTc5NCwzMTQuODkwOTggYyAyLjA2NzU0LDkuMDUyNzMgMS44NDE3Nyw1MS43Mjc3NyA2LjUwNzk0LDc0LjgzNDY2IDEuNjc0NzUsOC4yOTMzNiA4LjY3NTA4LDE0LjA2NTk4IDEwLjA1NTQxLDE0Ljg1ODYyIDQuOTAxNDcsMi44MTQ2MyAxMC44MTQ3OSw4LjE0OTgyIDEzLjA0NTc5LDE2LjA4ODMxIDYuNzU3NzksMjQuMDQ1OTEgMC44Nzk3Miw2OC40NTIxMiAwLjg3OTcyLDExMC42ODkzIDAsNi4wOTc4MiAxLjY2MDEsMzAuMTQ2NiAtMi4xNTU4OCwzMy45NjI1OSAtMi41NDA4NSwyLjU0MDgzIC0wLjI4MTYzLDEyLjk5MDY5IC0zLjQzNjc1LDE2LjE0Mzc3IGwgLTkuODQ5NDQsOS44NDMxMSBjIC0xMC4zNjcxNSwxMC4zNjA0NyAtMTEuNTkwMTcsNi41MjYxNCAtMTcuNzM4NDgsMTguODIyNzYgLTMuNTY3NzIsNy4xMzU0MyA1LjQwMjM1LDIwLjY3MjEgNy4zNTQzMiwyNC41NzYwMiAxLjkzMjE0LDMuODY0MyAtMS44NDIxNiw0Ljc3NzczIC0xLjc5MjM1LDcuNDQ2MjYgMC4yNTI4NiwxMy41NDQ4MyAyLjI5NzUsMzczLjkyNzEyIDIuMjk3NSwzNzMuOTI3MTIiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDM2NS4yNDAyMiw1MTkuNzc2MTIgNC4xMTU5OSw1MDIuMTUxNTgiCiAgICAgICBpZD0icGF0aDM4MDYiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAxMTYuNTMxNjUsNTA0LjE4Njk5IDMuODgwNTksMzEwLjk2NDM2IgogICAgICAgaWQ9InBhdGgzODMxIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBpZD0icGF0aDM4ODkiCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzE3LjY3NzYsNTc2LjQ4NTM5IDEzMC4xODc0MiwxLjUyNDQ0IGMgNC41MTA3OSwzLjI0MTY5IDIwLjM0NDcxLDcuOTY4NTMgMjcuNzQ0ODYsNC4yNjg0NCAzLjE1NTQ2LC0xLjU3NzcyIDkuNDE5LC01LjM4ODE3IDE0LjAyNDg5LC0zLjk2MzU1IDQuMjY2OTgsMS4zMTk4MSA2LjAxNjg5LDMuMTE2MzIgMTAuMzY2MjEsMy4wNDg4OSAxMC4zMDQwMywtMC4xNTk3NSAyMC4yMTE3LDAuMzg3NDEgMzAuNDg4ODYsMC4zMDQ4OSAxNzcuODkwOCwtMS40MjgyNyAzNTYuNTkwMzUsLTIuMTMyNDcgNTM0Ljc3NDU2LC0zLjA0ODg4IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY2Nzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDc1LjMwNTAxLDU4Mi44ODgwNSBjIC0zLjQ0NDE4LDExLjM1MDY2IC0yLjEwMzQzLDEyLjQzMzczIDMuNjU4NjUsMjEuMDM3MzEgMy43OTQ0NSw1LjY2NTY0IDUwLjg2MjYxLDEzLjAzODQ1IDQxLjQ2NDg1LDI3LjEzNTA5IC0xMC41MzY5NywxNS44MDU0NyAtMjIuODk3NDUsLTUuNDc3NzIgLTMzLjg0MjYzLC0xLjgyOTMzIC01LjQ1MjM2LDEuODE3NDUgLTcuMzQ5MDEsNS40NTYzMSAtMy42NTg2Niw5LjE0NjY1IDIuODA2ODMsMi44MDY4NCA0LjA0OCwxLjgwMzk2IDYuNTIwMzQsNS4xMDA0MSIKICAgICAgIGlkPSJwYXRoMzkxMCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDMyLjAxMDgyLDYzNi44NTMzMyBjIDguMzE4OTksMTMuMTEwMTYgMTguODQ2MjEsMTQuNjM0NjUgMzUuNjcxOTYsMTQuNjM0NjUgMi45Mzg2NSwwIDcuODY5OTgsLTAuOTMzNzEgMTAuNjcxMTEsMCAxMS4zNTkxNywzLjc4NjM5IDI3LjE5Mzk4LDEwLjI3NTc3IDM2LjIwMTkzLDIxLjEyOTQ4IDguMjgwMDIsOS45NzY2MSAxMC4yNTI3OCwyMy44ODMwOCA3LjcwMjAyLDM3LjEwNDI0IC02LjE2OTg5LDMxLjk3OTk4IC0xNi43MTQzMSw1Ni45ODg1MyAtMTkuMDQzNTUsODYuNTY5MDUgLTEuMzQ3OTgsMTcuMTE4OCA0LjUwOTU3LDIyLjUzNTIyIDExLjA3MTQzLDMzLjkyODU3IDEwLjY3MDIzLDE4LjUyNjcyIDguNzI0NTMsMTQuMTk5NTUgOC41NzE0MywzNC4yODU3MiAtMC4xMzk2MywxOC4zMTk0NCAwLDYwLjI2Mzg1IDAsODAuNzE0MjkiCiAgICAgICBpZD0icGF0aDM5MTIiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDUyOC41MDgwNiw2NTguOTU3NzYgYyAtMTAuNjgxMjMsMC45MDQ1NCAtNy4xMDgwNCwtNS42MDI1NSAtMTAuODIzNTQsLTguMDc5NTYgLTQuNzg0NTQsLTMuMTg5NjkgLTEyLjIyNzA0LC0xLjI1MTA0IC0xNi43Njg4OCwtNS43OTI4OCAtMC42NjYxMiwtMC42NjYxMiAtOC44MDk2OSwtNC4xMDg3NyAtMTAuMTc0NDcsLTIuNzQzOTkgLTguMzY0NTksOC4zNjQ1OSAtMy4wNDg4OCwyMC41NTE4OCAtMy4wNDg4OCwzMy41Mzc3NCBsIDMuMDIyLDMzOS42OTc0MyIKICAgICAgIGlkPSJwYXRoMzkxNCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzc3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA1MTcuOTg5NDEsNjUxLjAzMDY1IGMgLTAuMjIxNzEsLTIuNzAxODQgMS45MDM0NiwtNS41NjIxMyAzLjM1Mzc3LC03LjAxMjQ1IDEuNzk5NDMsLTEuNzk5NDIgNi45MjI5NCwxLjAwNDE5IDguODQxNzgsLTAuOTE0NjYgMC4yODc2NSwtMC4yODc2NiAwLjg0MzI5LC0xMS4xNjQxIDAuMjI4NjYsLTEzLjU2NzUzIC0yLjA2NDgzLC04LjA3NDE2IC0yLjA1ODAxLC0yOC42NTY1OCAtMi4wNTgwMSwtMzguNzIwODYgbCAwLC03My4xNzMyNiIKICAgICAgIGlkPSJwYXRoMzkxNiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNzY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNTI4LjY2MDUsNjc1LjQyMTczIC0wLjQ1NzMzLC0zMS41NTU5NiIKICAgICAgIGlkPSJwYXRoMzk3NCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDc2Ni4zMTYyNSw1NzkuNjQ0MzEgMC40MzExOCwxMy43OTc2OCBjIDMuMTM2NDMsNC42NjkxNSAzLjAxODI0LDkuNjAwNjggMy4wMTgyNCwxNi4zODQ3NSBsIDAsMTU3LjM3OTgxIgogICAgICAgaWQ9InBhdGgzOTgyIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMTEyMi45MDAxLDc2NS45MTMwMyBjIC0yMDIuMzA2NjksNC42OTA1IC00MDMuNzQ0MDUsLTEuMTEzODEgLTYwNS45NTQ1NCwzLjM1MzkgLTEwLjg2MzYyLDAuMjQwMDIgLTMuMzYxNDcsLTguNTg2MyAtMjguNTM2OCwtOC41ODYzIgogICAgICAgaWQ9InBhdGgzOTg0IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA4NjAuMDA4MDUsNzM3LjA2NjUxIGMgMCwwIC05Ny40NDc1LDAuODU4MDYgLTE0Ny41Njg5MiwwLjg1ODA2IC01LjI2ODYxLDAgLTQuNTE1NDYsLTguMzI5ODYgLTcuMzAwODksLTguMzI5ODYgLTMuOTc0MzUsMCAtOC42MjkyNSwwLjAyMDEgLTEwLjUwOTQ4LDAuMDM1OSAtMi4zMzQ3NywwLjAxOTcgLTEuODEwOTQsOC4zNjU5NyAtNC4xNDU4LDguMzY2OTIgLTQ2LjE2ODk5LDAuMDE4OCAtMTY3LjQwNzY3LC0xLjMwNzk5IC0xNzUuMDUyNjMsLTEuMzA3OTkgLTQuNDI5NTUsMCAtOC41NzYyNywtNi40Mzk3MiAtMTMuMTMxOTgsLTYuNDM5NzIgLTEuMzYxMTUsMCAtNi4yMzg3MywwIC0xNC4zOTQ2NywwIgogICAgICAgaWQ9InBhdGgzOTg2IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3Nzc3Nzc2MiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJNIDY3NS4wMDcwMyw4MzEuMTc0MDIgNjc0LjM5NzI1LDMwOS40MDI5OSIKICAgICAgIGlkPSJwYXRoMzk4OCIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDc5OS40MDE1NywzMTMuMDYxNjUgMS4yMTk1NSw0OTUuODY2NTMiCiAgICAgICBpZD0icGF0aDM5OTAiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSA3MzYuNTk0NTIsMzEyLjQ1MTg4IC0xLjIxOTU1LDcxNi40ODgyMiIKICAgICAgIGlkPSJwYXRoMzk5MiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDUzMC4wMzA5NCw2NDMuNDU4NTkgMzkyLjM3MTU5LC0zLjAxODI1IgogICAgICAgaWQ9InBhdGg0MDQ4IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gODU5LjQ1MDYsMzE0LjkwMTI4IDEuMjkzNTQsNTA3Ljk4MDU4IgogICAgICAgaWQ9InBhdGg0MDUwIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjAuOTk5OTk5OTRweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gOTIxLjU0MDE3LDMxMC41ODk0OSAxLjcyNDcxLDUzMS43NTIyNyIKICAgICAgIGlkPSJwYXRoNDA1MiIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDczNi4yODk2Myw0NTMuMzEwNCAxODUuNjc3MTUsLTAuMzA0ODkiCiAgICAgICBpZD0icGF0aDQxODciCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAxMDYwLjgxMDUsNTE0Ljk2NzY3IGMgMCwwIC0zNjMuMjgxMjYsLTUuNjI2MTggLTU0NC42NTA0MiwyLjUyMTc4IC00LjE3Nzc2LDAuMTg3NjkgLTEyLjUwMDQ0LDEuMDY3MTEgLTEyLjUwMDQ0LDEuMDY3MTEgLTEuNTcwOTUsMC4xMzQxIC0yLjAwMDkzLC0yLjMyNDk1IC0yLjU5MTU1LC0zLjUwNjIzIC0wLjA5NjcsLTAuMTkzNDMgLTcuMDYwODEsLTEuOTMzNCAtNy42MjIyMSwtMS4zNzE5OSAtMi44OTMxNCwyLjg5MzE0IC03LjYzMTY3LDQuMjQ4NjkgLTEyLjE5NTU1LDQuMTE2IEwgMzY5LjIwMTcsNTE0LjUzNjUiCiAgICAgICBpZD0icGF0aDQyNjEiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3NjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAzOTkuODE1MzEsNDc5LjYxMTEyIDExLjY0MTgsNS42MDUzIGMgMi45ODQxMiwxLjQzNjc5IDYuNTI4NzgsLTAuNDc3MTIgOS45MTcwOCwtMC40MzExOCBsIDEyNy4xOTczOSwxLjcyNDcxIgogICAgICAgaWQ9InBhdGg0MjYzIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Ik0gNTE5LjI1MTUxLDUxNy4xMjM1NyA1MTguODIwMzIsMzA4LjQzMzYyIgogICAgICAgaWQ9InBhdGg0MjY1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gNDMyLjkyNTQ5LDM4OS43MTQ5OCBjIDExLjA0NDk2LDAgMzUuNTMzMDcsMC42MTkyNyA0Mi41Nzk3OCwtMS4wMDM5NyA4LjQwNTIyLC0xLjkzNjE4IDcuMDY2LC02Ljk1Mzc4IDE0LjE5NzEyLC02Ljk1Mzc4IDcuODA5NSwwIDYuNTQyOTEsOC4wNjIzNyAyMC4xNDE3LDguMDYyMzcgMTMuOTkwNjgsMCA0NC45NzY4OSwwLjM3ODg2IDYzLjkzOTkyLDAuMzc4ODYgMTIuMDgzOTUsMCA4Mi4wMDI2NiwwLjMwNDg5IDkzLjYwMDgxLDAuMzA0ODkgOC43NjA0NywwIDEzLjE1OTcsLTIuMjg4MjcgMjEuMzQyMTksLTcuMDEyNDMgNy4xOTUxNSwtNC4xNTQxMyAyLjA1NDU5LC05LjQ5MTM3IDIwLjQyNzU0LC04Ljg0MTc3IDIzLjE0NTQsMC44MTgzMyAxMi42NDMzNCwxNC4wMjQ4NyAzMi4zMTgxOSwxNC4wMjQ4NyAyNS4zNTk1NCwwIDEzMC45OTkwMiwwIDE1MC45MTk4NSwwIDE0LjMzMjQ0LDAgLTQuMTE5MTEsLTEzLjExMDIxIDI5LjI2OTMsLTEzLjQxNTEiCiAgICAgICBpZD0icGF0aDQyNjkiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc3Nzc3Nzc3NzYyIgLz4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjU4OC42Nzk1NyIKICAgICAgIHk9IjczNS44MDQ2MyIKICAgICAgIGlkPSJ0ZXh0NDMxMCIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMiIKICAgICAgICAgeD0iNTg4LjY3OTU3IgogICAgICAgICB5PSI3MzUuODA0NjMiPkxpbmNvbG48L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjY4Ni4zOTg1IgogICAgICAgeT0iNzY1LjYyODQyIgogICAgICAgaWQ9InRleHQ0MzEwLTciCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNiIKICAgICAgICAgeD0iNjg2LjM5ODUiCiAgICAgICAgIHk9Ijc2NS42Mjg0MiI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjcwOS44NzE4MyIKICAgICAgIHk9Ii04MDIuMzc3MzgiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMi02LTgiCiAgICAgICAgIHg9IjcwOS44NzE4MyIKICAgICAgICAgeT0iLTgwMi4zNzczOCI+V29vZGxhd248L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjU2Mi4xMTkyNiIKICAgICAgIHk9Ii03NzEuOTY4MTQiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xLTkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtOC0yIgogICAgICAgICB4PSI1NjIuMTE5MjYiCiAgICAgICAgIHk9Ii03NzEuOTY4MTQiPkVkZ2Vtb29yPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTguMzA0ODciCiAgICAgICB5PSItNzM4LjM2NjQ2IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTciCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtOC0yLTkiCiAgICAgICAgIHg9IjU5OC4zMDQ4NyIKICAgICAgICAgeT0iLTczOC4zNjY0NiI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTIuMTIyODYiCiAgICAgICB5PSItNjc3LjIwMzk4IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTctNSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNi04LTItOS00IgogICAgICAgICB4PSI1OTIuMTIyODYiCiAgICAgICAgIHk9Ii02NzcuMjAzOTgiPkhpbGxzaWRlPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1OTcuMzI3MDkiCiAgICAgICB5PSItODYyLjYxNDA3IgogICAgICAgaWQ9InRleHQ0MzEwLTctMS05LTctNS0zIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDMxMi02LTgtMi05LTQtMSIKICAgICAgICAgeD0iNTk3LjMyNzA5IgogICAgICAgICB5PSItODYyLjYxNDA3Ij5Sb2NrPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1ODcuMzcwMTgiCiAgICAgICB5PSItOTI2LjEzNjYiCiAgICAgICBpZD0idGV4dDQzMTAtNy0xLTktNy01LTMtMiIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQzMTItNi04LTItOS00LTEtMyIKICAgICAgICAgeD0iNTg3LjM3MDE4IgogICAgICAgICB5PSItOTI2LjEzNjYiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9Ijg3MS4xNjEwMSIKICAgICAgIHk9IjYzNy41NzUyIgogICAgICAgaWQ9InRleHQ0NDY1IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NDY3IgogICAgICAgICB4PSI4NzEuMTYxMDEiCiAgICAgICAgIHk9IjYzNy41NzUyIj5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI4NzMuODMyMjgiCiAgICAgICB5PSI1NzcuMDMyNDciCiAgICAgICBpZD0idGV4dDQ0NjUtMyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDQ2Ny00IgogICAgICAgICB4PSI4NzMuODMyMjgiCiAgICAgICAgIHk9IjU3Ny4wMzI0NyI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgaWQ9InRleHQ0NDkwIgogICAgICAgeT0iNTEwLjI2MTgxIgogICAgICAgeD0iODc1Ljk2NjQ5IgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbgogICAgICAgICB5PSI1MTAuMjYxODEiCiAgICAgICAgIHg9Ijg3NS45NjY0OSIKICAgICAgICAgaWQ9InRzcGFuNDQ5MiIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iODgxLjMxNjU5IgogICAgICAgeT0iNDUwLjE5ODc2IgogICAgICAgaWQ9InRleHQ0NDk0IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NDk2IgogICAgICAgICB4PSI4ODEuMzE2NTkiCiAgICAgICAgIHk9IjQ1MC4xOTg3NiI+Mjl0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNjE1Ljc5MjQ4IgogICAgICAgeT0iMzg3Ljc0NzE2IgogICAgICAgaWQ9InRleHQ0NDY1LTMtMSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDQ2Ny00LTEiCiAgICAgICAgIHg9IjYxNS43OTI0OCIKICAgICAgICAgeT0iMzg3Ljc0NzE2Ij4zN3RoPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ1MTkiCiAgICAgICB5PSI0ODEuNjUyODYiCiAgICAgICB4PSI0ODQuNjkwMzciCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9IjQ4MS42NTI4NiIKICAgICAgICAgeD0iNDg0LjY5MDM3IgogICAgICAgICBpZD0idHNwYW40NTIxIgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIj4yNXRoPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1NjMuMDQ2NzUiCiAgICAgICB5PSI1MTMuMzYxMzMiCiAgICAgICBpZD0idGV4dDQ1MjMiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1MjUiCiAgICAgICAgIHg9IjU2My4wNDY3NSIKICAgICAgICAgeT0iNTEzLjM2MTMzIj4yMXN0PC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ1MjciCiAgICAgICB5PSI1NzcuODk0ODQiCiAgICAgICB4PSI1NjUuOTcxNSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNTc3Ljg5NDg0IgogICAgICAgICB4PSI1NjUuOTcxNSIKICAgICAgICAgaWQ9InRzcGFuNDUyOSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDUzMSIKICAgICAgIHk9Ii00NjAuNzMzMTIiCiAgICAgICB4PSI0MzMuNTgwNzUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii00NjAuNzMzMTIiCiAgICAgICAgIHg9IjQzMy41ODA3NSIKICAgICAgICAgaWQ9InRzcGFuNDUzMyIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+QW1pZG9uPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI0MDUuNTMwOTgiCiAgICAgICB5PSItNTIzLjU0MDE2IgogICAgICAgaWQ9InRleHQ0NTM1IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDUzNyIKICAgICAgICAgeD0iNDA1LjUzMDk4IgogICAgICAgICB5PSItNTIzLjU0MDE2Ij5BcmthbnNhczwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDUzOSIKICAgICAgIHk9Ii0zNzIuNTg1OTQiCiAgICAgICB4PSI3NDUuNDg0NjIiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii0zNzIuNTg1OTQiCiAgICAgICAgIHg9Ijc0NS40ODQ2MiIKICAgICAgICAgaWQ9InRzcGFuNDU0MSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+V2VzdDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNTk2LjcyODMzIgogICAgICAgeT0iLTUzMS4yNTkyOCIKICAgICAgIGlkPSJ0ZXh0NDU0MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMCwxLC0xLDAsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NDUiCiAgICAgICAgIHg9IjU5Ni43MjgzMyIKICAgICAgICAgeT0iLTUzMS4yNTkyOCI+V2FjbzwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU1NSIKICAgICAgIHk9Ii0xMjIuNTAyOTUiCiAgICAgICB4PSI1OTUuNDM0ODEiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9Ii0xMjIuNTAyOTUiCiAgICAgICAgIHg9IjU5NS40MzQ4MSIKICAgICAgICAgaWQ9InRzcGFuNDU1NyIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+TWF6aWU8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjY5NS43NzI5NSIKICAgICAgIHk9IjE2Mi4wNjg3NyIKICAgICAgIGlkPSJ0ZXh0NDU1OSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIgogICAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC43MDcxMDY3OCwwLjcwNzEwNjc4LC0wLjcwNzEwNjc4LDAuNzA3MTA2NzgsMCwwKSI+PHRzcGFuCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NjEiCiAgICAgICAgIHg9IjY5NS43NzI5NSIKICAgICAgICAgeT0iMTYyLjA2ODc3Ij5ab288L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjI0MC41ODk5NyIKICAgICAgIHk9IjU3NC40NDU0MyIKICAgICAgIGlkPSJ0ZXh0NDU2MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDU2NSIKICAgICAgICAgeD0iMjQwLjU4OTk3IgogICAgICAgICB5PSI1NzQuNDQ1NDMiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU2NyIKICAgICAgIHk9IjUxMS42MzY2MyIKICAgICAgIHg9IjIwNi4wMzE3NSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNTExLjYzNjYzIgogICAgICAgICB4PSIyMDYuMDMxNzUiCiAgICAgICAgIGlkPSJ0c3BhbjQ1NjkiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjYyMC40NDMxMiIKICAgICAgIHk9Ii01MDYuNjgyMTkiCiAgICAgICBpZD0idGV4dDQ1NzEiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40NTczIgogICAgICAgICB4PSI2MjAuNDQzMTIiCiAgICAgICAgIHk9Ii01MDYuNjgyMTkiPk5pbXM8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDU4MyIKICAgICAgIHk9IjY5OC44NDAwOSIKICAgICAgIHg9IjM3MC4yMTY4NiIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iNjk4Ljg0MDA5IgogICAgICAgICB4PSIzNzAuMjE2ODYiCiAgICAgICAgIGlkPSJ0c3BhbjQ1ODUiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1hcGxlPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSIzODQuMDg0MiIKICAgICAgIHk9IjY4MC44NTEzOCIKICAgICAgIGlkPSJ0ZXh0NDU5OSIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDYwMSIKICAgICAgICAgeD0iMzg0LjA4NDIiCiAgICAgICAgIHk9IjY4MC44NTEzOCI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0ibSAzNjcuOTA4MTcsMTAwOS45NTk2IDI2My4wMTgzMywwIgogICAgICAgaWQ9InBhdGg0NjA1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDx0ZXh0CiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ2MDciCiAgICAgICB5PSItNDMzLjEzNzc2IgogICAgICAgeD0iNzM2LjI2NzQ2IgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbgogICAgICAgICB5PSItNDMzLjEzNzc2IgogICAgICAgICB4PSI3MzYuMjY3NDYiCiAgICAgICAgIGlkPSJ0c3BhbjQ2MDkiCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1lcmlkaWFuPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ5NzkiCiAgICAgICB5PSI2NDAuMjA1MjYiCiAgICAgICB4PSI1NzIuODMyMTUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuCiAgICAgICAgIHk9IjY0MC4yMDUyNiIKICAgICAgICAgeD0iNTcyLjgzMjE1IgogICAgICAgICBpZD0idHNwYW40OTgxIgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIj5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI1NzUuMDg5NjYiCiAgICAgICB5PSI2NzAuOTAzNSIKICAgICAgIGlkPSJ0ZXh0NDk4MyIKICAgICAgIHNvZGlwb2RpOmxpbmVzcGFjaW5nPSIxMjUlIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDk4NSIKICAgICAgICAgeD0iNTc1LjA4OTY2IgogICAgICAgICB5PSI2NzAuOTAzNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNDk5LjQ4OTYyIgogICAgICAgeT0iMTAwOC42MDY5IgogICAgICAgaWQ9InRleHQ1MDQ3IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDQ5IgogICAgICAgICB4PSI0OTkuNDg5NjIiCiAgICAgICAgIHk9IjEwMDguNjA2OSI+NDd0aDwvdHNwYW4+PC90ZXh0PgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iMjE2LjY0NTQzIgogICAgICAgeT0iNzI1Ljk4Mjk3IgogICAgICAgaWQ9InRleHQ1MDUxIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDUzIgogICAgICAgICB4PSIyMTYuNjQ1NDMiCiAgICAgICAgIHk9IjcyNS45ODI5NyI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICAgPGZsb3dSb290CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgaWQ9ImZsb3dSb290NTA1NSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6MThweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwyODcuMzYyMTgpIj48Zmxvd1JlZ2lvbgogICAgICAgICBpZD0iZmxvd1JlZ2lvbjUwNTciPjxyZWN0CiAgICAgICAgICAgaWQ9InJlY3Q1MDU5IgogICAgICAgICAgIHdpZHRoPSIzNDMuNTcxNDQiCiAgICAgICAgICAgaGVpZ2h0PSIxMDMuNTcxNDMiCiAgICAgICAgICAgeD0iMTkuMjg1NzE1IgogICAgICAgICAgIHk9IjE3LjE0Mjg1NyIKICAgICAgICAgICBzdHlsZT0iZm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIgLz48L2Zsb3dSZWdpb24+PGZsb3dQYXJhCiAgICAgICAgIGlkPSJmbG93UGFyYTUwNjEiPjwvZmxvd1BhcmE+PC9mbG93Um9vdD4gICAgPHRleHQKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAsMSwtMSwwLDAsMCkiCiAgICAgICBzb2RpcG9kaTpsaW5lc3BhY2luZz0iMTI1JSIKICAgICAgIGlkPSJ0ZXh0NDYwNy03IgogICAgICAgeT0iLTUwOC4xODk3MyIKICAgICAgIHg9Ijc3NC44NzU2MSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iLTUwOC4xODk3MyIKICAgICAgICAgeD0iNzc0Ljg3NTYxIgogICAgICAgICBpZD0idHNwYW40NjA5LTciCiAgICAgICAgIHNvZGlwb2RpOnJvbGU9ImxpbmUiPk1jQ2xlYW48L3RzcGFuPjwvdGV4dD4KICAgIDxwYXRoCiAgICAgICBzdHlsZT0iY29sb3I6IzAwMDAwMDtmaWxsOm5vbmU7c3Ryb2tlOiMzMzMzNjY7c3Ryb2tlLXdpZHRoOjFweDtzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1kYXNob2Zmc2V0OjA7bWFya2VyOm5vbmU7dmlzaWJpbGl0eTp2aXNpYmxlO2Rpc3BsYXk6aW5saW5lO292ZXJmbG93OnZpc2libGU7ZW5hYmxlLWJhY2tncm91bmQ6YWNjdW11bGF0ZSIKICAgICAgIGQ9Im0gMzY0LjE1OTk5LDY1OC40Mjg5MSAyOTkuNTEwMjMsLTEuMDEwMTYgYyA2LjQ5ODcyLC0wLjAyMTkgNi45NzcxOSw5LjI1NDEyIDE2LjU5NjMxLDkuMzkyNDcgMTIuMDU0MjcsMC4xNzMzOSAyOS4xMTA4MywtMC41MzU3MiA1NC4xMTQzNywtMC4zMDExIgogICAgICAgaWQ9InBhdGg1NDQwIgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMjg3LjM2MjE4KSIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzYyIgLz4KICAgIDx0ZXh0CiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIgogICAgICAgc3R5bGU9ImZvbnQtc2l6ZTo5LjY1ODM3NzY1cHg7Zm9udC1zdHlsZTpub3JtYWw7Zm9udC12YXJpYW50Om5vcm1hbDtmb250LXdlaWdodDpub3JtYWw7Zm9udC1zdHJldGNoOm5vcm1hbDtsaW5lLWhlaWdodDoxMjUlO2xldHRlci1zcGFjaW5nOjBweDt3b3JkLXNwYWNpbmc6MHB4O2ZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZTtmb250LWZhbWlseTpWZXJkYW5hOy1pbmtzY2FwZS1mb250LXNwZWNpZmljYXRpb246VmVyZGFuYSIKICAgICAgIHg9IjM3My45OTMwNCIKICAgICAgIHk9Ijk0NC4zNTc1NCIKICAgICAgIGlkPSJ0ZXh0NTA0Ny05IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW41MDQ5LTMiCiAgICAgICAgIHg9IjM3My45OTMwNCIKICAgICAgICAgeT0iOTQ0LjM1NzU0Ij5NYWNBcnRodXI8L3RzcGFuPjwvdGV4dD4KICAgIDx0ZXh0CiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICBpZD0idGV4dDQ2MDctNy0xIgogICAgICAgeT0iLTQ5MC4yNDU5NyIKICAgICAgIHg9Ijc4MC44NDYwNyIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4KICAgICAgICAgeT0iLTQ5MC4yNDU5NyIKICAgICAgICAgeD0iNzgwLjg0NjA3IgogICAgICAgICBpZD0idHNwYW40NjA5LTctOSIKICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSI+U2VuZWNhPC90c3Bhbj48L3RleHQ+CiAgICA8cGF0aAogICAgICAgc3R5bGU9ImNvbG9yOiMwMDAwMDA7ZmlsbDpub25lO3N0cm9rZTojMzMzMzY2O3N0cm9rZS13aWR0aDoxcHg7c3Ryb2tlLWxpbmVjYXA6YnV0dDtzdHJva2UtbGluZWpvaW46bWl0ZXI7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2UtZGFzaG9mZnNldDowO21hcmtlcjpub25lO3Zpc2liaWxpdHk6dmlzaWJsZTtkaXNwbGF5OmlubGluZTtvdmVyZmxvdzp2aXNpYmxlO2VuYWJsZS1iYWNrZ3JvdW5kOmFjY3VtdWxhdGUiCiAgICAgICBkPSJtIDM2Ny42OTU1Myw1MzcuMjEwNiAxNDEuMjgzMDMsLTEuMDEwMTUgYyA2LjQ4OTk5LC0wLjA0NjQgMTIuNzgxMTQsNy4yMzU0NSAxOS4xOTI5LDcuMzIzNiA1NS45MjM2MiwwLjc2ODkgMTU4LjY4OTk3LC0wLjE3MzMzIDIzNi41MTQwMiwtMS4wMTAxNSA3LjgzOTU2LC0wLjA4NDMgMjIuNjMxNDcsLTE5Ljg1MzU1IDMwLjMwNDU3LC0yMC40NTU1OSAyMi4yNjU4OSwtMS4zNTE4MSA0NS4xNzk0NSwtMC41MDUwNyA2Ny42ODAyMiwtMC41MDUwNyAxNi4xNDczMSwtMC42MzI0MSAzLjYxMDE2LDIwLjcwODEzIDI2Ljc2OTA0LDIwLjcwODEzIGwgMjQzLjQ0Njc5LC0xLjAxMDE2IgogICAgICAgaWQ9InBhdGg1NDk2IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMjg3LjM2MjE4KSIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NzY2NjY2MiIC8+CiAgICA8dGV4dAogICAgICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIKICAgICAgIHN0eWxlPSJmb250LXNpemU6OS42NTgzNzc2NXB4O2ZvbnQtc3R5bGU6bm9ybWFsO2ZvbnQtdmFyaWFudDpub3JtYWw7Zm9udC13ZWlnaHQ6bm9ybWFsO2ZvbnQtc3RyZXRjaDpub3JtYWw7bGluZS1oZWlnaHQ6MTI1JTtsZXR0ZXItc3BhY2luZzowcHg7d29yZC1zcGFjaW5nOjBweDtmaWxsOiMwMDAwMDA7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6VmVyZGFuYTstaW5rc2NhcGUtZm9udC1zcGVjaWZpY2F0aW9uOlZlcmRhbmEiCiAgICAgICB4PSI2ODUuMjA4MTMiCiAgICAgICB5PSI4MjcuNTMwODIiCiAgICAgICBpZD0idGV4dDQzMTAtNy04IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiPjx0c3BhbgogICAgICAgICBzb2RpcG9kaTpyb2xlPSJsaW5lIgogICAgICAgICBpZD0idHNwYW40MzEyLTYtNiIKICAgICAgICAgeD0iNjg1LjIwODEzIgogICAgICAgICB5PSI4MjcuNTMwODIiPlBhd25lZTwvdHNwYW4+PC90ZXh0PgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJjb2xvcjojMDAwMDAwO2ZpbGw6bm9uZTtzdHJva2U6IzMzMzM2NjtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLW9wYWNpdHk6MTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLWRhc2hvZmZzZXQ6MDttYXJrZXI6bm9uZTt2aXNpYmlsaXR5OnZpc2libGU7ZGlzcGxheTppbmxpbmU7b3ZlcmZsb3c6dmlzaWJsZTtlbmFibGUtYmFja2dyb3VuZDphY2N1bXVsYXRlIgogICAgICAgZD0iTSA1NTQuMjg1NzIsNzIxLjQyODU3IDU1MCw1NDMuMjE0MjkgNTQ3LjE0Mjg2LDEwMi41IDU0Ni43ODU3MiwyMy4yMTQyODUiCiAgICAgICBpZD0icGF0aDU1MTkiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwyODcuMzYyMTgpIiAvPgogICAgPHRleHQKICAgICAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgICAgICBzdHlsZT0iZm9udC1zaXplOjkuNjU4Mzc3NjVweDtmb250LXN0eWxlOm5vcm1hbDtmb250LXZhcmlhbnQ6bm9ybWFsO2ZvbnQtd2VpZ2h0Om5vcm1hbDtmb250LXN0cmV0Y2g6bm9ybWFsO2xpbmUtaGVpZ2h0OjEyNSU7bGV0dGVyLXNwYWNpbmc6MHB4O3dvcmQtc3BhY2luZzowcHg7ZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lO2ZvbnQtZmFtaWx5OlZlcmRhbmE7LWlua3NjYXBlLWZvbnQtc3BlY2lmaWNhdGlvbjpWZXJkYW5hIgogICAgICAgeD0iNTI5LjYyNTMxIgogICAgICAgeT0iLTU1MC44NDc3OCIKICAgICAgIGlkPSJ0ZXh0NDU0My01IgogICAgICAgc29kaXBvZGk6bGluZXNwYWNpbmc9IjEyNSUiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDEsLTEsMCwwLDApIj48dHNwYW4KICAgICAgICAgc29kaXBvZGk6cm9sZT0ibGluZSIKICAgICAgICAgaWQ9InRzcGFuNDU0NS0wIgogICAgICAgICB4PSI1MjkuNjI1MzEiCiAgICAgICAgIHk9Ii01NTAuODQ3NzgiPkJyb2Fkd2F5PC90c3Bhbj48L3RleHQ+CiAgPC9nPgo8L3N2Zz4K\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAB/CAYAAAD4mHJdAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACWAAAAlgB7MGOJQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA3vSURBVGiB7Vt7cFzVef+dc+/d90OrJyO/JSO/4ncxxfULMCYIAyEW08amJJgmM4GmnZjJdNq4gcSGzLQxk3bsaWcaaIHyR8CJrWAbpjgG/AhINsbYxkaSDY6xJFvSrrS7Wu3uvfecr3+cu1pbXhkJs/4nujNndufec77f+d7fd+4uw8gvIxwOfocBaz0e91yXyx0BgKyZiWUz5kcEvBKPJ18EYI+C5rWvkpKSyZGS8LGHGtbQR8ePUUdnB50/f57OfnqWWlpbaN++39O99fdQpCR0NBKJTBwJTfZFE4LBYLmh8+YXXvifKctWrEBPTze9+cbu8/3JVMoWNjwer3/ZsuUTvV4P239gP36yceNZW9CtyWQyei262hcB+7zurU/99Ge3r1nTgJdfevFsqr8/Wlc3rWbGzFkV8+fPr1iwYEEJgLadO3cmbr/jjohh6KXHPjxamsmar39pjoPBYHl5aUnnqZY2/b1Dh9LdPd39kUgk6PP5PD6fH36/Dz6fDx6PF+fOfdZ9+pPTgbq6Ou+aBx+0k/0DVYlEIjYcbX4tYM5pxeK/WKIDwM7Gxt0TJox/dtLESXC53JuHzvV4PBVHDjfvAYDZs+fonMsV16R9rYeM8XG1tbUAgMrKsrDP659DRJ5gMNhbaH5NTU0IAMaPHw9IPv5LAxORy+31AgBcLsO41lwAcLu9BgAYLheIkftLAxfzGgMeAx4DHgMeAx4DHgMeAx4DHgMeAx4DHgMeAx4D/lME1ke7gDF8ltbOHe3W923oEwYi1jxftWfZWgAziwacZkd2pfyN96XN5IIu7dMtIKA9/TI+zqCnFps2Alg5UlojFnVqIHZUlO2sl4RyC4CU+SEEylux8Z/iyc7mrxw4U7UnYwvGpXMYKIgNGdwXC/76C48oRw3sDWfnCgIkARJXcpwbvpA1e6T0Rq5jDr8EAHKA6OpjUOJwfeXAJAEhAXAGgEPKq+dIMVJqowDO4RAAC0rHV21u5LijAJaABAOIAY5Oh15iFMgj1zEpcUuuXjpIWeCouxjAtnIZcGKA5AVFbRfazPUC50QrKe8+Qy8qiqjBYIODA5DgBd1pBO9WRg9sy7yOhXBca+icYrgTOUGOiKnIVdCdisAxJGBTPsYW0nHRrJqgfNmGVtiqaeR1xchF7Vgz40q/BUNmISlcL7CUgJAMnOUiVwEdF0PURIAAVHaC8ucbAiwcQAb1KQpwXMjFrhtYMcOVO8lhOB457ujcKZd9hBguSYwcelTupKyaQWKYJFEU4xJw/Dhfcw29ilSBcNjEoTucFnSnkeOOvvTJpcVC1cYoGB5NAGEQTukjMAzHoghJghyWCRjenYoTuZjKx8xJiwU4LrSZ6waWpIoBjTuRqxDHRUkSUMWAJAZp6QU5FqOw65HHapG3bGVcBTZXDI5VnFaFgBL1yC34uoBJqEJeIwD2MMY1ilZidAFEMlDOqm9UdpJ0ZawumI+LU9ArwhyqWxyNz14XsBAMUnLVH0ttGB0XococdCGWE3XhOV85MF1WV2OY3omK0S2SkxgYAZYYJoAUpcqEEjG/Ru80isA1ysMXYNCnCum4aKUPgTu90w3sFinXL6nO/MadCAhiKloxBjFMeSuK0S1Kylv1cE1bUVoYyHwhoI6bCswpjjuxK5u2G2lcti2jzNCRTluioHEVw52EBA5/2LKsLBL+h2gs/o+Fjpa+MqtmjCbkqQJSYFF3T3zRsPMvA75i7UiBA4FApa6z5+fNnbd6/frHADghk7QdlhAHdMY0KXkZAHAuozaRMDRtKYMdAYDVq1fjcHPTD860nZlsS3qsv7+/+6pNDr0RDAanGTrf85Onnq75/uNPIJ1O4+dbnj34Ot6B4eFLqksqUeEvgcflAREhZabR09+Li/EorLQ4eFv317D2oW8t0XUdu3a9jud/9auztqD6ZDLZOixwOByeouv8D1u3brtpxYrb0XS4Kfbj3//8VHC8d0nDLXfj67OWIeQJgDGADfoOAxHQl05i14l92PHBXiTPp/c/OrFh9vwF8yMnjp/A5s2bOqXEbX19fX+8CriqqspvmunDTz/10xkr71qFnY07Tr1i7aqsLg2Vb6h/GOPCpdAYgTPlNLmF5AzpvBRp74viX3a/hO6+ge47+hZG61fVTz9y+DCee27Lx15fYFFHR8cAcNkPuw2DPXfP1+vvvf+BB7Br967WX9Mbk70eCn33zlWoCrsgKAFBCdgy/2nLBCyZgCUSMGUSpkzC0G1MrKzE0XMt/la9I0QnM+cWL15cmkwmK1tOnwpksuabg8YVifjnhEOlj69dtw6nT51Kv2q96fYG4fG7gbJwFhn7cxicIJgEZwAfEiokGASpWG1KhvIwg1/91ti1N9DEJ7ZOzKxdt87T1Nz8A67jv2Kx/o85AJDk//zXjzzCAeA/D7zU6PZjkkuXcBuEjN2OrGiHabfDFB2w7HZYoh3mVaMDWWdu1m6Hy5Bw6RIuP6b87+HXdgDAww8/zIXgGwFADwQCFYFA4BuLFi3CoUN/6LRmyL/y6gSXTtC4QDTVgQo/B5iEJFJ6Rt64lI6Vfi3JYBFHd1JA5wIunUNIQvpr/C+bm5u65s9fWBnwe9dISWVc0/DNhQsX6gDwTuuhd3WNYOSGTjjSehGp7EVYsguWuJQfssu51wVTXIIpLsGWlzBgXsSRM5dg6Hk6uk787Zb39gHA7NlzDM7xoM4Yli5fvgJSSiRmmbP9HNA0Qm4D6axEc6uJ6eOzuCloQuOOjlneqiUx2BK4lDBwut2DTFaHoXFYGilaHEjMMOdKKXHb4tvw/nvvL9UZ+Lyb6+pw/PjxpOZhsziX0DigcYLG1QaEBD69ZKA7wRHx2/C7BDSNwEi9AEmZGmJJA/1Z9SJM12hwvcYBzgmaj89obW3pr62dGmCcz+cuQ68GgEtdl7oYU40CZwSeW+As1rmy5KzNkbY1WILDlOp71ubgnKA7czVO4NyhwQhcFS7o6urq5pzDMLRqnXEtCACpdCrFHOHlAsTgYEq0nCnj0jnBY6i8KCTLBxbmzB2yPkczmU4lAYAxHtKFECYAPeDzBQZD4GU+motMueXklECWc7QkSaVDGoTAVetz8AGfLwQAQoisbtt2N4BJZaVlpZQjkntdS8w5UFOFni0YLMGhWfny1rbVPVuoOVKyK9ZeTrMsUl7qAHdzkPyktzeG2tqbw8KihCQlPjVUl2hLBkswmDZD1mJIWxwDWTXSFkfWUs8sZ64QzlqHjiRA2tQ7ZcqUYCwWgyT6hBNjb+3ZvQehUIi52tje3M6FyHHIYNkOqM2RsTjS2cuAs+pe1uYKPLcBkduA+m60sH1+v5/t3fsWGGP/x6VkjR98cAQAMNc7bXJepAyWzWHaimjW4siYDGmTY8DkGMhqapgcaVM9yw5ugMOyeX4DkmGub1otABz/6DiI2O94IpE4E+3p+aCzsxP333PfAvOi2G8JBtMRbU68GZMj44Ao0BzXmgOsRk7spq1oWILB6rQP3nt3/byLnZ2IxWKH4/H4pxoAeFzuC21tretW3rUKnk5mtWiflzAGxhgDQ66IYyrnOnqzBFfDZjAdLk1HMnkpMWRNLldmFomamtrIL/71F+iPJ/8mnc2e4QDQm0jsOXfu3L6TJ0/ivtX3T607M26P6SzMWI5eB7ktPHLPc/MV5xwTjpe9sfLOu2pOHD+JCxc+fyeWSLyZdzCoWsvjNpqef/6F8KTJU/DDLT/a3jM90eDWCS5dqmDvxF7NCRSAOikQhCuMUXHMEDjm3v7jb/+oIRrtxpMbnuzNmvatiUSi7QpgAAiFQneXlZbs3rGjUauorMSmLc+8dShy7HbDELqeA3bC4GCScHxWSMDOgVuaPb2t+t3vPfK9O1P9A/j7v3vC7ov318fj8bdyWFf8YCSbzZ7VNHb+tVdfrV911ypt/bcfq52J2uTBg+//LhWwZ0nJYTtWf6WrcccDGFgLdn5nwkPVD9Q/MLOzsxNPbvhhNpUc+G5vPL7jcqxBjonozwEsBzD5lVde9jy5YcPqTZufKX90/WOwbRv7330nsffDt08dSB41EkZyHPfwmwBAZuTFsBm48GeuWfai2oUzp02fFjKzJhp3NuLFF/+765e//Pfd31q71gLwGYC3GWNNAMCIaBKAJwBUO3uQnZ2d/MyZNv1vn/j+LUuXLq/Z/MyzCIfDTmxW8Y+IVFyWqjKRQkDYNqKxGDb97GkcOXLk7LZt/9F8c12dqKqqYM4LYALQCWAbI6J/A1AGgKK9vSBhoa8vEe+N9TwejcZYU1MTfrN9O6puqkJDw0NYtnwFpk6dCsZUMrFtG22trTiw/11s3/4aotEo1jQ04NZFt6KsrJTCoZKtJaWRiGG4KBKJ5BJWnw4gDedAx+0yMJCywLnQGWOSMabV1NbikUfX40J7B367sxFbt25DMhGHZZkgAC7DhWAojOpx4zF3wS0YP64aVZUVYCoQSN2la4bhIsNlcOS73H5GRBUAHgcwBYABAD09PZROp1gq2V8WTybq4vH4xEQ8oSWSSfSnUkinM7As9RdUw9Dh9XoR8PsQCgYRCodESTj0x1Aw2OrxBXsDgYBdXl6eM2IB4CyAbZcb12wASwBMB1Dq7C4ACJZIJHstM5PWdC2TTmcom80wEtySAFwupum6wbxeDxeCuT0et8/v94UBTTrSJABRAKcAHGCMnbrKjy/bRBjAHAATAFQ5NuAF4IFqAtyOKzKo83MLgAkgA2AAQB+ADgCfAzjBGIsPxfh/6wbDK7xbMFYAAAAASUVORK5CYII=\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false},\"title\":\"Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "google_maps", + "name": "Google Maps", + "image": "data:image/png;base64,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", + "description": "Show latest values and location of the entities on Google Maps.", + "descriptor": { + "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: 200px;\n white-space: nowrap;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('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.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"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\":\"longitude\",\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"google-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7568\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"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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\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true},\"title\":\"Google Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + }, + { + "alias": "openstreetmap", + "name": "OpenStreetMap", + "image": "data:image/png;base64,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", + "description": "Show latest values and location of the entities on OpenStreetMap.", + "descriptor": { + "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 TbMapWidgetV2('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.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"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\":\"longitude\",\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"openstreet-map\",\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"gmDefaultMapType\":\"roadmap\",\"mapProvider\":\"OpenStreetMap.Mapnik\",\"useCustomProvider\":false,\"customProviderTileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"mapProviderHere\":\"HERE.normalDay\",\"credentials\":{\"app_id\":\"AhM6TzD9ThyK78CT3ptx\",\"app_code\":\"p6NPiITB3Vv0GMUFnkLOOg\"},\"mapImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"tmDefaultMapType\":\"roadmap\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"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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\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false,\"useClusterMarkers\":false,\"zoomOnClick\":true,\"maxClusterRadius\":80,\"animate\":true,\"spiderfyOnMaxZoom\":false,\"showCoverageOnHover\":true,\"chunkedLoading\":false,\"removeOutsideVisibleBounds\":true},\"title\":\"OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/navigation_widgets.json b/application/src/main/data/json/system/widget_bundles/navigation_widgets.json new file mode 100644 index 0000000..9d06c01 --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/navigation_widgets.json @@ -0,0 +1,48 @@ +{ + "widgetsBundle": { + "alias": "navigation_widgets", + "title": "Navigation widgets", + "image": "data:image/png;base64,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", + "description": "Useful to define home dashboard of the user. Contains widgets that enable navigation to other dashboards and menu items." + }, + "widgetTypes": [ + { + "alias": "navigation_cards", + "name": "Navigation cards", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAbAklEQVR42u2d6VcVV9aH+Rf6Q6/Vn7r7Uz509+pONCZtq9Fo4hA1xlliiPM8D1HjEBVQkaCgIhFFxQmNioozQRRBZBBUkEkBUUYZRGYZ9X1gJ/VeL3BBBS/qPqvWXXVPnTp1zu88Z+9ddatu2Tx//rympiYvLy89Pf2BphcTmqBMdXX1c5OkcrVGLhtkysjIKC4urq2tfa7pxYQmKIM+qGRQpXK1Ri4bEOOLimIhoQ8qybrK1Uq5bDBfOvlanIioJOsqVyvlssE1qhYtJkMllauVcilYCpaCpWC97WBFRUVNnTp1/vz5Tk5OxgmRkQYPHtxhu1RUVJSVlfXmwfL19R09evSSJUsKCwst1JOfn08L31JcuI7QynnVLFinT59evXo1K3v27PH09GSoysvL+ZqQkGCAVVlZee3aNdGxoqIiMDDQdESTk5OTkpKio6NpTWhoKKegEtaFh4dLJUj86NGjK1euiNDUEBwcbNQQFhZ2//59KuG4RIJsevjwYW5ublBQkFwmKSsru3z5shydCuPi4m7evMm6q6srU6LNB88yWLT2yy+/5GyInk6aNMmAm/ZXNySaSnfoy4oVK2ghOayTKSeb1JmdnU3Xqqqq6EV8fLxUywpyPXv2jArZndluaJuZmRkSEsKcR2F0lvw7d+5ERkbK7KLCq1ev5uTkCBC0MCUlRYqhVWxsLJVIVTdu3Lh3715paSkNEN1oBuMiXUBb9GegacalS5eGDh3KwLUBWO7u7h4eHqxLrzp37ixglZSUjBw58tChQ1999RXtGDBggJeXV58+fYzT8m7durHv+PHjBw4cuG/fvu7du1Ns0aJFW7ZsGTt2LKLA6/Dhw3fs2MG+dXV1dnZ2QNy1a1dYWbdu3YQJE7CXHIjjkrl3796PPvpo6dKlLi4uVIIKVGsc/U9/+tO2bduGDRuGNJShYW1utCyDRYP9/f2NUTfUo/20BEtGZwcNGnT9+nWgp4WPHz/m64EDB3r06EGFZK5cuXLt2rX0cdeuXXQtMTHx6NGjlMRj/PTTT1QIuOfOnTM8Bvlz587997//jYB9+/aFDNSjhtmzZzNelEdwRqR3796UnzVrFg0YMmRITEwMlSCUvb39Bx98wCY+0ZYhQHBq+Oabb5j8o0aNYshGjBjBTGbEKUwNbm5uZH7xxRepqamvBRZVMNi0Bt4bg4WODCrd69mzJxOLkvSKccWMmbpLZsOqVatYmTJlCnOISbN8+fJ+/frt3LmTrnIUNiExn0zfhQsXdurUiQN16dJF/K+AJYM0efJkMXtknjlzxvTo0io/Pz+qNQb1DYMVERHReFoKWMyQZcuWBQQEYKWk14cPH960aRMFyGTAAItimAQ6RSZl6CD7Ojo6rl+/nil6uiGZhSKoIeVhi/IYJwozaamNwlRilMQ+/fDDD0CMPt99952YedlEYTniqVOnJBOzxyigLZD5+PiIthIa0Ugp/1pgcTBGF+QxTg4ODhh5WDbAwuQsWLAAeJmg2A/mIq4KCsXNGe02sJBmgRS7nDhxwrMhiViUxNhi7ZgfeAp26dWrF3aIo5uCJTVIeRiSozNTQVlaJW22Clj0yNvbW5yOs7PzxYsXIYmvWALajEvC6TP3WJEWnj9/fsOGDRQ4fvy4AZYhmpTBbuEW6SPCNgkWu5iVZxTwmI3BQlsq2b59O5lsFf9I20zBMh0L/J2MLM69XcBi5cKFC9gYPC6TY/r06QZYOC+MELEqzo7wiImCZcZcA4cFsJi7M2fOxHQzIUw7Qw24Rer/8MMPOdaxY8fInDZtWnNgwRwGjKOPGzeOfU3BwonApQQQbwwsphzNAylaRaz55MkTzAOC/P3vf6fNuCdcCVOUaQBS9JRQhvJYEVtbWwo3CRbBEyyiGBFFa8DavHkzI4L/ok4zsLBSiEmTCCTwhv3796dt//znP5sEi0+CDeYtY8p8MAWLOYz5lFi2zS43MH6Nrzib/jqLgW18/tg4Yf+azIdUOT+QbhCF0AGGxPJJSoe63MD5BL0wUHv69KnpuSo2+NVOxFpfuDltTTfhUugC8x++20/bDnodC6vDFGdCWz511+tYr5A4p8MKMmPb3KjrBVK9QKpX3hUsBUvB0qRgKVgKloL1DoAVHH2v7/RNH45ao0u/GZuv3Uq2DJbKZUGuF8Bis2pkLP1nbLYMlsplQa4XwFJ1zBbLYKk+FuRSsBQsBUvBUrAULAVLlVKwFCwFS8FSsBQsVUrBUrAULAVLwVKwVCkFS8F6j8Hq8q3DXr9r+YWleYUle05d46uC1eyy41gQGrU+/30GC0FMb1Cxrj4dGizo4dA1tXVDF7ib5vOVTDZRQMEyFgyVKVh8lfwBs1wj7tyvqKwOj03tP9P1fQfLoGqhy5HGW+c4HaqqrrXivHyLwIIqIzMsJvW9BssyVR2BrbfAFZ4Mkfzyp///3GJZRdX7C1ZrqLI6Wx0zeEcHDJVZ8I4HVIvVBFVGAxqvWJGtt+hyAzEWPGG3rsekvL8x1pZDAc/rH6t/ttTteGvAWuTyqwTyvxy9omDpdayXY6u5xVpUdTSwek7auNL95MXQuPuZ+URRJWVPAyMSP5vgpGC9CltWpKrjgPXJ2Pq4qrLqhf9cSc3I6zZuA1u7j9/guPPM1ei7jwqKq2tqib3wiZv2+/eZ8vP7e7nBMlvWparjgOV35VbjZ/em2u9j08z1B4vLnjb5cN/Tyuqlbsf0AqleIG12qapu4v+hMGNsKiwut/DgKO7yff9Jp0l6mst/38BqEhoLmxoX0x+h9UdoBUvBUokULAVLwVKwFCwFSxcFS8FSsBQsBUvB0kXBUrAULAVLwVKwdGlDsBZt+pV7E1jcDgZwb8Kn3zla+CnQuOf/pZaE1OzjAVEtFlvzi1873f7QHmB9u2znsYCos8ExLvsuWhDt/QUrKOouN2z8FhYfcvMeTx1x99n/vl9vFbAcdp45cjHyrQCLe6y5ZSMuJevs1ZiikoqYuxkv9cQpB6Wz7z5YDLysc28Qu89zPvzltE0/bD4qmbbLPKc57DcD67926zAw67zO9m/0l8MfjV4z18mHO9QmrdlrCtas9QfJNG6qYSQwkJsP/Dbxpz1mB2Jl/Krdtks9KS85Ui33dbke/G32hkOsWxesB9kFobdTOo1eW9/apfVvfqNtIppYr+ELtxsCjl6yA6u2ZoefzFjyKY8glOEr/wTutOf8eq9zX81yk/L1fW+QAnGwix/b2q/2OPWz90XjFniOu+DnI9Rpt3yX5FDnN/O3MSIjFnmINUU69mrORrxpsEQjxnveRh+jHiSQAgZYKJiV9wRx7z3Mxdp98+INWL6XorF8N+LTuEPt4LkwAau0vDI5PTctq6CsorLHBCfUT0rLycx9wh26tbV19p6nTQ/ECvcnpec8pgzNwDyQefLyzYInpWeu3qbawxcirAgWBLAXtxcbOQwzIohoiGPcncbKDMcDdc+eBYTF0zUUY6RxDmziKzQMmrMFg5fxqDAxLYebrtBfRgS5yHxSUs7tprfvpt9KSn9cVJadV8TNW53GrIXpgqKym4kPeZur4EuFFBZDSGyDpPhoakjLzH8FN91mYOU+LiHG2n0yhKbkFBRzj6xlsPafuc6oY3KYOvGpWcdM3Bwzhh25H5J1cOG+28629uweeisZM9Nt3Hq2TlnrjaDcpMsnxWArOuGBGViIiCLUn5NfJHSiu9zUhZQHzoZZEawxS3aIXTfLbxIsb79Q5gNagRT9+nreVlNXeDroNprLvX7c+Y6YMiKQhFy9p/xMSZFXnMmwhdsXNxg8MfxEDkxXqfBOcmavSc6sgxQ+GumwYRxRRLZmjIV54N7iwXO3Gho1BxbdZp6RycIKU8eobflWX3Y0myWmMVb9kGz0YYUnxqITHyIrNszgyWzFdF+vE8Ey0d2PBPZ+ydvA2xYsmTyL//B0lsGCwqLSCtg6ERg9cfVesxgrNjmTKE0yN+69gAViVjMiLEZJmU44R9b53H7kMsMk4jPruCtVihkKgyBGHWOBXRcTaGVXSLTEQ39yXkaQRD0SyjQGC/MTey8DyyGLEQbVi+t8mB17NDxzQhzGdMF0NwZr7I87xYwTQDTmqUmwWIbM20akgj9NuJ9tRbB49oHInWjPeEQCNXBAIpq8PcUAiwU16PLlyESAkIDSAIsTpkvhCVIMYnBh1GYZLPhjhfjJ0N8MLBZMF0cMupHEgA6c7Wb9GAvGeVSSx0KICqnn+5VekIE7MwOLQJLnlqY77uehpa0+l8TxyfL5ZGdqoCQzz+d8OJNVXKEZWBLA2q3wYn5zHkqEYRks3koCiMx4WCfYolrrBu+4b6Sg47D+q38kZgMfJy5y2ZbjX0xzwRhLzbyoByeFCaencOPYwBPlMcCoR3iOdUFnZjXGm8IyIhbAkjmJtvCKILt8g83AYtYBK65w3Mrd5PNpfbBQhH7iuRm/SxH1L7KHEnQxA4uIAcMuj0jwrNLIxR5mF8b42yd5BID1Jl0hxkz+/QJECLAIPKnTssViplIYv0lhHK51wWLaEB5hgdgdpyPdRDT/63HkwA3RgtRMaEGoQAzOwlYJEugRWzFRzDoQkYcsYFHe6mMZLAleiUDkQKBpBhbTNSU9j4Fj61H/G3Lq2rGuvHe1W4cja24rolg4m5Vn5V6n/iafy+s5ceNrKtWGV94/GevI6UjjTqGMWSbWhcJm+hh9t6xksw++TtzY3GUX8tn6yn/mpj/p6E86+luhgqVgKVgql4KlYClYCpaCpWCpXPpO6BYWfSf068j1Ali8iLy/ivWHTKEtvcVe5bIgl016enptbe1zTc0n9EElWVe5WimXTV5eXnFxscphIRUVFaHS7/+rrnK1Ti6b6urqjIwMxNKJ2OTkQxn0McRRuVopl039v+nV1IAY5uuBphcTmqCMGUMqV2vkstF5pqk9koKlqd3AUtuuqQ1dIWFoPVhQpdGoprYN3oFKLzdoauMETvWXG/SKn6Z2uUBq+huFJk1tksx/K9SkScHS9BaCFRUVNWPGjKlTp+7cudNyFYMHDzbWT58+7enp+Qrt4EBZWVkdSprk5GTlo+3BEkR4vnHt2rXCSkFBweXLl/l9kfXKysrAwED5wR+wWAkODq6rq2MvNze3oKCgx48fs4ndw8PD4+Pjpc7c3FxqKC8vl2GLjIysqKhg39DQ0MmTJxtgsenevXvXr1/ncgibOHd9Xv96sDrKS1X5+fkUvnLlSmFhIV+phKNLMVJYWBi7CxYciyPScqk2Njb2zp07T58+pTyNkWpDQkLu378vBXJycqiW4yYkJPTo0aOjsf7ugMVKSUnJ559/ztiMGjVq7969AwYMIHPcuHFsHTJkSFpaGmDZ29svXrzYxcWFvXr16kWxzz77DPjmzZvn7u4+Z86c/fv3p6Sk2Nraenl5ff3119Twj3/8w8HBgWHu0qWLs7PzX//6V2MUu3XrtnXrVjs7u379+u3bt6979+5VVVU//vjjli1bxo4dGxAQwKGHDh1KVWIshw8fvnv3bgpziktVEyZMmD179qeffkrLhw0b5uPjQ5up4V//+hfWl5zRo0d7e3v36dOHfadPn0497AJz1LZo0aKVK1fyyVE6d+4cFxeniLQXWPDRtWtXVnx9fRmwv/3tb6yzwgCgPiZBRjczMxN3ZuzF2GAVGGyxCnCA9YKSWbNm/fnPfzYcqKurK8yZuULZhH1atWoVK1OmTMnOzk5NTV2+fDkVrlmzhkNwIKMktm3hwoVwgPuGIS7NkclXf3///v37Ozk59ezZE1Mnhdnxl19+kX0xXdBGASDeuHEjOVkNSUqaunhNbQ8WPC1YsIDBgwlsgFgs/MWjR48YD8ZSBoDxELC2b9/O12nTpkVERIhxYvwGDhzo5+e3evXq0tLS//znP8awcQiMBysTJ040A4uaKW8wRw04uBMnTpBpChYV9u3bF7+2YsUKaQwzAbYACx9HyyESH0emAZb0S0pijCmANcV3K1hvCCyCjO8bEnEVBgmrQySExSKyGT9+PJz17t2bCMkMLDwX9mzEiBGYqF27dkEYexEq4VYoiQ/9y1/+AqCy15MnT6hk/vz5WA7LYFEnVcEQttAULBgaOXIkmz7++GMsKNNg0KBBfAUsLCXWbsmSJbTWsKymYPHp4eExd+5cPGxSUpIZWPhK5o8i0u6XGwhTsD3GV2gjp8mSZWVlTeZjIRrvwvATfbemAeDYZD41GEcER/zmrVu3xowZIznyg6iFxO76w4Nex2oh4S45V8ADgpeOroKlScHSpEnB0qRgaVKwNGl6WbD4j1T5715djIU/aLh264UfpFWlFlXSPwV56b+7UJVe5U9BVJ0W/6BHVdL/x1KwFCwFS8FSsFQNBUvBUrAULAVLwdLlHQGrz0u+Z1DBUrBaXng1Mi/u4sWqCpaC9SoD1uTg1b9otOT3W0ytyJZ1wbKsUosaKljmovCWPV41aJpvLbYUrHcHLN7wydsrG2+yClsK1jsC1vhVTVNlLbYUrHcELF6mbeF2H6KuPlNdFCwF66Ul493PkXFpTW7ijdHfLtupFkvBekXJmmSrtLySt7FrjNUxL3y8NWeFZmwRddkt36VnhQpWGywGWxWV1RNX79XrWOoK22zpareOWH7CT3ve2yvvCta7L1lHdkAKloKlYClY6goVLAVLwVKwFCyVTMFSsBQsBUvBUrD0plu9NVnBUrAULAVLwVLJVCUFS8FSsBQsBUslU5WsDhaP2fBU4BvozKffOf6w+ehnE5wUrLcPrC7fOsQmZ85zPsz6l9M2ZTwq5JN1cq7HpHAjXllF1cx1B0x3CYq6ezwgqpUNGjDLdfDcrZbLnA2OcT8SaJpzJzlz2+FAWkLL2xviVwOLTm05FMCy+cBv0x33dxq99mUP6rDzjGnOvI0+bd5ZamurOl/FYiWkZh86F87Ksi3HKckn6z7nwwGIldkbDnUfv+GVwaKk1GO5AWYV8kTrV7PcOjJYwkF4bCp3VzP3ohMedBu3QcF6YYGhW0npAkFm7hMZ45i7GVt8LsmoT2y4dXjD7nPJ6blIee/hIykzw/EAW1kOX4jA6pADgscCojB7WLuv523FDvGfHzx+c/BcGFv5mpZVcPfBozU7/CyDRW3oLmCxfj8znzuY5S+NQ28lnwiMTknPG7HIw97zdFxKVlpmPo+5YjOGLnCnql/9I6/cSNq0339rQ/sxmbTHwlNlrwOWjBkNK3hSSqtkU/+Zri77Lq73Otf7jz/S+WSs40r3kzTJboWXKVgLXY787H2RKWRUiHNgx6VuxzuN+d0E9prk7LjrrNOe8+hpHH3QnC0cgvyekzbylWiBmOGb+dso9vlkZ/alho17L5g2knrWeZ119r7Avm8IrKVux3icgdY8yC6g/3x+bGtfWVUjPLEv7eMPPHhN4WqPU2TmFZbAAQxBjO+l6JGLPZivjCiF/a/H3Ux8OGzh9vMhsVHxDxjUwIjE6MSHOI7vV3pR1VT7fYRojHRvk38vagwWOTuOBQlYHIJHLW7Ep12OTGQTR4dvBLJd6kk9S1yP8XgPrZ3jdEgm6KXwhMWbj8IuzaMjzIf8wlJjnNoDLBbILn9aBdwz1x+srqllGtAFjsufNNGG+NQs2nwx9E5NbZ0YKvbNynvCFOWTHhFNSoUFRWUIWFVdQ68pBiU5+UWPCooJV55WVk9z2E/mok2/so6w1MmmbuPWS8flH1ZYB3HaQD08pyk5kMdRGJHQ2ylV1bWMxZsAq1/Dv3hjfh4XlTEAfOL+ODzzzACLiYUxM3WFEMYm4QMI0JFwDeEuRSQQeVCgru4ZNRiu8ItpLvScUV++1bfHi/G4ZbBk8JiR/ImIgGU4EZj2OHqFfRGaTNFXYkSkRFw4xsKJo29XsDAGosaPW31ZF+NNDp0dONuNlUUuv5KJjcHSyEH5V36RRf52QCqc3xDsMoHrnj3j39W9/UKLy55KKIIlBqOPRq9Z4X7CsUEBDidzVTq+52QIgGIv2ZfxosCCn49II5mZrNCS+jlwMgQ039BZYXZeEc7rYmgc63wSN+AKjRrpM2YcF2YK1pS13vJYswEW/ceqsZXpKwthh2mM1a9BKSxi7uMS+v+yYEGPKViYdAzV/jPXMVqobwaWtJO5ywyx/MRim4CFl6fvqIFvOnIxEk+NNTUCKWwV0wySYO6TsQ5yUKPLorBphXRNiMEVRNy5L8VQgEz8HU6NeCM14/dDsKN0nBWKyYSXEyYjxqJh2Dwe3vwtLB653tzlBjwXuhCysM4n5fedDjXt9uQ13ozQyMW/8EaQ9JzHiMJ0IcftYAD0YIfEFRLxYLHQjo4RK5CDyngxfARmPOF+NrYK6TFsyNRKsDgEJ6dnr8ZIIGiAhQcXjNC6opHFYsF41NbWoT6zvL3Bun03XRQ4FxKLg5NpYxqh8xdzrgd/KywuP3P1dotgESyyzhkMwaIxw3HuZKL/1ei7HIJZKvqYgSVvkSAaMQveO9vaYxeZ2PhZQrE3BBYQUEbCOpkuWFHTbrNyMvAmNhZ/ZATvhIe4GxZmD2EEOUPmbeNKAYaEHCw2OXOdfMAI0Zk0p4Nu4/WJRQCxa4OpMzAyHV2EMAWLWAHoiVfkAXwDLIBmE0fH/uEjth+5bAbW/75fj5HzOhHcfmeFK7adwHKfunILZTAw5GNggIxDC/f4LC7csELYTpjBqU9YTKoFsDDzOPGj/jcIGwiegImaiduYxnKeRHm05SjoiQ4yUqZgMYuIvZCaenb5BouenseCqBC3S3By72GucZ7RUS6Q4sJNo2CmFN3DPgGKRNZ/nAQ5mD3xzI6yTiQrodvLPjPdnNXB/za3iYMyDTh5bD+w5DFugmIJq1nAC7NEPuEEZw+EgJgK4AZxZhTmU/xyc2CBHZ8YFaEEtbExHILMxLQccXAYe4nKKUxJLLopWCxAzDwkhwBfwMJR4JQoTDMAq8Uri1b+SSfoRhIRPR3mXIbTxg51pZhQIyktBwvx5q+84/exWI0pN7scaOGScs+JG1+ocMxas4tkTR6i8VPmZjns0so2WP+3QuIbhhCj3dF+giBM4VpOY3H1Jx39EVp/hFawFCwFSxcFS8FSsBQsBev9BEvfdtzkou+EflmVzMHih6r+qlojvUJffIu9qtSiSvVgpaen19bWPtekqY0SOAGVTV5eXnFxscqhqa1SUVERUNlUV1dnZGTAltotTa9vqwAJnFix4XtNTQ2IYb4eaNL0GgmEAEks1P8BxyX9iQv3XRYAAAAASUVORK5CYII=", + "description": "Allows to open one or more specific pages using filter defined in the advanced settings of the widget.", + "descriptor": { + "type": "static", + "sizeX": 7, + "sizeY": 6, + "resources": [], + "templateHtml": "", + "templateCss": "/*#widget-container {\n overflow-y: auto;\n box-sizing: content-box !important;\n cursor: auto;\n}*/\n\n#widget-container #container {\n overflow-y: auto;\n box-sizing: content-box;\n cursor: auto;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.navigationCardsWidget.resize();\n}\n\nself.onResize = function() {\n self.ctx.$scope.navigationCardsWidget.resize();\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-navigation-cards-widget-settings", + "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\":\"rgba(255,255,255,0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"filterType\":\"all\"},\"title\":\"Navigation cards\",\"dropShadow\":false,\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } + }, + { + "alias": "navigation_card", + "name": "Navigation card", + "image": "data:image/png;base64,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", + "description": "Allows to open specific page using relative path defined in the advanced settings of the widget.", + "descriptor": { + "type": "static", + "sizeX": 2.5, + "sizeY": 2, + "resources": [], + "templateHtml": "", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n\n}\n\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-navigation-card-widget-settings", + "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\":\"rgba(255,255,255,0)\",\"color\":\"rgba(255,255,255,0.87)\",\"padding\":\"8px\",\"settings\":{\"name\":\"{i18n:device.devices}\",\"icon\":\"devices_other\",\"path\":\"/devices\"},\"title\":\"Navigation card\",\"dropShadow\":false,\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json new file mode 100644 index 0000000..4776ef2 --- /dev/null +++ b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json @@ -0,0 +1,140 @@ +{ + "ruleChain": { + "additionalInfo": { + "description": "" + }, + "name": "Device Profile Rule Chain Template", + "firstRuleNodeId": null, + "root": false, + "debugMode": false, + "configuration": null + }, + "metadata": { + "firstNodeIndex": 6, + "nodes": [ + { + "additionalInfo": { + "layoutX": 822, + "layoutY": 294 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode", + "name": "Save Timeseries", + "debugMode": false, + "configuration": { + "defaultTTL": 0 + } + }, + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 221 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", + "name": "Save Client Attributes", + "debugMode": false, + "configuration": { + "scope": "CLIENT_SCOPE" + } + }, + { + "additionalInfo": { + "layoutX": 494, + "layoutY": 309 + }, + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", + "name": "Message Type Switch", + "debugMode": false, + "configuration": { + "version": 0 + } + }, + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 383 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log RPC from Device", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 823, + "layoutY": 444 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log Other", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 822, + "layoutY": 507 + }, + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode", + "name": "RPC Call Request", + "debugMode": false, + "configuration": { + "timeoutInSeconds": 60 + } + }, + { + "additionalInfo": { + "description": "", + "layoutX": 209, + "layoutY": 307 + }, + "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", + "name": "Device Profile Node", + "debugMode": false, + "configuration": { + "persistAlarmRulesState": false, + "fetchAlarmRulesStateOnStart": false + } + } + ], + "connections": [ + { + "fromIndex": 2, + "toIndex": 4, + "type": "Other" + }, + { + "fromIndex": 2, + "toIndex": 1, + "type": "Post attributes" + }, + { + "fromIndex": 2, + "toIndex": 0, + "type": "Post telemetry" + }, + { + "fromIndex": 2, + "toIndex": 3, + "type": "RPC Request from Device" + }, + { + "fromIndex": 2, + "toIndex": 5, + "type": "RPC Request to Device" + }, + { + "fromIndex": 6, + "toIndex": 2, + "type": "Success" + } + ], + "ruleChainConnections": null + } +} \ No newline at end of file diff --git a/application/src/main/data/json/tenant/edge_management/rule_chains/edge_root_rule_chain.json b/application/src/main/data/json/tenant/edge_management/rule_chains/edge_root_rule_chain.json new file mode 100644 index 0000000..f908b16 --- /dev/null +++ b/application/src/main/data/json/tenant/edge_management/rule_chains/edge_root_rule_chain.json @@ -0,0 +1,181 @@ +{ + "ruleChain": { + "additionalInfo": null, + "name": "Edge Root Rule Chain", + "type": "EDGE", + "firstRuleNodeId": null, + "root": true, + "debugMode": false, + "configuration": null + }, + "metadata": { + "firstNodeIndex": 0, + "nodes": [ + { + "additionalInfo": { + "description": "Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \"Success\" relation type.", + "layoutX": 187, + "layoutY": 468 + }, + "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", + "name": "Device Profile Node", + "debugMode": false, + "configuration": { + "persistAlarmRulesState": false, + "fetchAlarmRulesStateOnStart": false + } + }, + { + "additionalInfo": { + "layoutX": 823, + "layoutY": 157 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode", + "name": "Save Timeseries", + "debugMode": false, + "configuration": { + "defaultTTL": 0 + } + }, + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 52 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", + "name": "Save Client Attributes", + "debugMode": false, + "configuration": { + "scope": "CLIENT_SCOPE" + } + }, + { + "additionalInfo": { + "layoutX": 347, + "layoutY": 149 + }, + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", + "name": "Message Type Switch", + "debugMode": false, + "configuration": { + "version": 0 + } + }, + { + "additionalInfo": { + "layoutX": 825, + "layoutY": 266 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log RPC from Device", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 378 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log Other", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 466 + }, + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode", + "name": "RPC Call Request", + "debugMode": false, + "configuration": { + "timeoutInSeconds": 60 + } + }, + { + "additionalInfo": { + "layoutX": 1129, + "layoutY": 52 + }, + "type": "org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode", + "name": "Push to cloud", + "debugMode": false, + "configuration": { + "scope": "SERVER_SCOPE" + } + } + ], + "connections": [ + { + "fromIndex": 0, + "toIndex": 3, + "type": "Success" + }, + { + "fromIndex": 1, + "toIndex": 7, + "type": "Success" + }, + { + "fromIndex": 2, + "toIndex": 7, + "type": "Success" + }, + { + "fromIndex": 3, + "toIndex": 6, + "type": "RPC Request to Device" + }, + { + "fromIndex": 3, + "toIndex": 5, + "type": "Other" + }, + { + "fromIndex": 3, + "toIndex": 2, + "type": "Post attributes" + }, + { + "fromIndex": 3, + "toIndex": 1, + "type": "Post telemetry" + }, + { + "fromIndex": 3, + "toIndex": 4, + "type": "RPC Request from Device" + }, + { + "fromIndex": 3, + "toIndex": 7, + "type": "Attributes Updated" + }, + { + "fromIndex": 3, + "toIndex": 7, + "type": "Attributes Deleted" + }, + { + "fromIndex": 3, + "toIndex": 7, + "type": "Timeseries Deleted" + }, + { + "fromIndex": 3, + "toIndex": 7, + "type": "Timeseries Updated" + } + ], + "ruleChainConnections": null + } +} \ No newline at end of file diff --git a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json new file mode 100644 index 0000000..88ef27e --- /dev/null +++ b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json @@ -0,0 +1,140 @@ +{ + "ruleChain": { + "additionalInfo": null, + "name": "Root Rule Chain", + "type": "CORE", + "firstRuleNodeId": null, + "root": true, + "debugMode": false, + "configuration": null + }, + "metadata": { + "firstNodeIndex": 6, + "nodes": [ + { + "additionalInfo": { + "layoutX": 824, + "layoutY": 156 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode", + "name": "Save Timeseries", + "debugMode": false, + "configuration": { + "defaultTTL": 0 + } + }, + { + "additionalInfo": { + "layoutX": 825, + "layoutY": 52 + }, + "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", + "name": "Save Client Attributes", + "debugMode": false, + "configuration": { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false" + } + }, + { + "additionalInfo": { + "layoutX": 347, + "layoutY": 149 + }, + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", + "name": "Message Type Switch", + "debugMode": false, + "configuration": { + "version": 0 + } + }, + { + "additionalInfo": { + "layoutX": 825, + "layoutY": 266 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log RPC from Device", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 825, + "layoutY": 379 + }, + "type": "org.thingsboard.rule.engine.action.TbLogNode", + "name": "Log Other", + "debugMode": false, + "configuration": { + "scriptLang": "TBEL", + "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", + "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" + } + }, + { + "additionalInfo": { + "layoutX": 825, + "layoutY": 468 + }, + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode", + "name": "RPC Call Request", + "debugMode": false, + "configuration": { + "timeoutInSeconds": 60 + } + }, + { + "additionalInfo": { + "description": "Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \"Success\" relation type.", + "layoutX": 204, + "layoutY": 240 + }, + "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", + "name": "Device Profile Node", + "debugMode": false, + "configuration": { + "persistAlarmRulesState": false, + "fetchAlarmRulesStateOnStart": false + } + } + ], + "connections": [ + { + "fromIndex": 6, + "toIndex": 2, + "type": "Success" + }, + { + "fromIndex": 2, + "toIndex": 4, + "type": "Other" + }, + { + "fromIndex": 2, + "toIndex": 1, + "type": "Post attributes" + }, + { + "fromIndex": 2, + "toIndex": 0, + "type": "Post telemetry" + }, + { + "fromIndex": 2, + "toIndex": 3, + "type": "RPC Request from Device" + }, + { + "fromIndex": 2, + "toIndex": 5, + "type": "RPC Request to Device" + } + ], + "ruleChainConnections": null + } +} \ No newline at end of file diff --git a/application/src/main/data/sql/schema-entities-idx-psql-addon.sql b/application/src/main/data/sql/schema-entities-idx-psql-addon.sql new file mode 100644 index 0000000..d78257a --- /dev/null +++ b/application/src/main/data/sql/schema-entities-idx-psql-addon.sql @@ -0,0 +1,38 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- This file describes PostgreSQL-specific indexes that not supported by hsql +-- It is not a stand-alone file! Run schema-entities-idx.sql before! +-- Note: Hibernate DESC order translates to native SQL "ORDER BY .. DESC NULLS LAST" +-- While creating index PostgreSQL transforms short notation (ts DESC) to the full (DESC NULLS FIRST) +-- That difference between NULLS LAST and NULLS FIRST prevents to hit index while querying latest by ts +-- That why we need to define DESC index explicitly as (ts DESC NULLS LAST) + +CREATE INDEX IF NOT EXISTS idx_rule_node_debug_event_main + ON rule_node_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_rule_chain_debug_event_main + ON rule_chain_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_stats_event_main + ON stats_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_lc_event_main + ON lc_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_error_event_main + ON error_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + diff --git a/application/src/main/data/sql/schema-entities-idx.sql b/application/src/main/data/sql/schema-entities-idx.sql new file mode 100644 index 0000000..e4c766e --- /dev/null +++ b/application/src/main/data/sql/schema-entities-idx.sql @@ -0,0 +1,81 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 INDEX IF NOT EXISTS idx_alarm_originator_alarm_type ON alarm(originator_id, type, start_ts DESC); + +CREATE INDEX IF NOT EXISTS idx_alarm_originator_created_time ON alarm(originator_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_created_time ON alarm(tenant_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_status_created_time ON alarm(tenant_id, status, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_alarm_type_created_time ON alarm(tenant_id, type, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_entity_alarm_created_time ON entity_alarm(tenant_id, entity_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_entity_alarm_alarm_id ON entity_alarm(alarm_id); + +CREATE INDEX IF NOT EXISTS idx_relation_to_id ON relation(relation_type_group, to_type, to_id); + +CREATE INDEX IF NOT EXISTS idx_relation_from_id ON relation(relation_type_group, from_type, from_id); + +CREATE INDEX IF NOT EXISTS idx_device_customer_id ON device(tenant_id, customer_id); + +CREATE INDEX IF NOT EXISTS idx_device_customer_id_and_type ON device(tenant_id, customer_id, type); + +CREATE INDEX IF NOT EXISTS idx_device_type ON device(tenant_id, type); + +CREATE INDEX IF NOT EXISTS idx_device_device_profile_id ON device(tenant_id, device_profile_id); + +CREATE INDEX IF NOT EXISTS idx_asset_customer_id ON asset(tenant_id, customer_id); + +CREATE INDEX IF NOT EXISTS idx_asset_customer_id_and_type ON asset(tenant_id, customer_id, type); + +CREATE INDEX IF NOT EXISTS idx_asset_type ON asset(tenant_id, type); + +CREATE INDEX IF NOT EXISTS idx_attribute_kv_by_key_and_last_update_ts ON attribute_kv(entity_id, attribute_key, last_update_ts desc); + +CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_id_and_created_time ON audit_log(tenant_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_log_id ON audit_log(id); + +CREATE INDEX IF NOT EXISTS idx_edge_event_tenant_id_and_created_time ON edge_event(tenant_id, created_time DESC); + +CREATE INDEX IF NOT EXISTS idx_edge_event_id ON edge_event(id); + +CREATE INDEX IF NOT EXISTS idx_rpc_tenant_id_device_id ON rpc(tenant_id, device_id); + +CREATE INDEX IF NOT EXISTS idx_device_external_id ON device(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_device_profile_external_id ON device_profile(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_asset_external_id ON asset(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_entity_view_external_id ON entity_view(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_rule_chain_external_id ON rule_chain(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_dashboard_external_id ON dashboard(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_customer_external_id ON customer(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_widgets_bundle_external_id ON widgets_bundle(tenant_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_rule_node_external_id ON rule_node(rule_chain_id, external_id); + +CREATE INDEX IF NOT EXISTS idx_rule_node_type ON rule_node(type); + +CREATE INDEX IF NOT EXISTS idx_api_usage_state_entity_id ON api_usage_state(entity_id); diff --git a/application/src/main/data/sql/schema-entities.sql b/application/src/main/data/sql/schema-entities.sql new file mode 100644 index 0000000..7303927 --- /dev/null +++ b/application/src/main/data/sql/schema-entities.sql @@ -0,0 +1,778 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 tb_schema_settings +( + schema_version bigint NOT NULL, + CONSTRAINT tb_schema_settings_pkey PRIMARY KEY (schema_version) +); + +CREATE OR REPLACE PROCEDURE insert_tb_schema_settings() + LANGUAGE plpgsql AS +$$ +BEGIN + IF (SELECT COUNT(*) FROM tb_schema_settings) = 0 THEN + INSERT INTO tb_schema_settings (schema_version) VALUES (3003000); + END IF; +END; +$$; + +call insert_tb_schema_settings(); + +CREATE TABLE IF NOT EXISTS admin_settings ( + id uuid NOT NULL CONSTRAINT admin_settings_pkey PRIMARY KEY, + tenant_id uuid NOT NULL, + created_time bigint NOT NULL, + json_value varchar, + key varchar(255) +); + +CREATE TABLE IF NOT EXISTS alarm ( + id uuid NOT NULL CONSTRAINT alarm_pkey PRIMARY KEY, + created_time bigint NOT NULL, + ack_ts bigint, + clear_ts bigint, + additional_info varchar, + end_ts bigint, + originator_id uuid, + originator_type integer, + propagate boolean, + severity varchar(255), + start_ts bigint, + status varchar(255), + tenant_id uuid, + customer_id uuid, + propagate_relation_types varchar, + type varchar(255), + propagate_to_owner boolean, + propagate_to_tenant boolean +); + +CREATE TABLE IF NOT EXISTS entity_alarm ( + tenant_id uuid NOT NULL, + entity_type varchar(32), + entity_id uuid NOT NULL, + created_time bigint NOT NULL, + alarm_type varchar(255) NOT NULL, + customer_id uuid, + alarm_id uuid, + CONSTRAINT entity_alarm_pkey PRIMARY KEY (entity_id, alarm_id), + CONSTRAINT fk_entity_alarm_id FOREIGN KEY (alarm_id) REFERENCES alarm(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id uuid NOT NULL, + created_time bigint NOT NULL, + tenant_id uuid, + customer_id uuid, + entity_id uuid, + entity_type varchar(255), + entity_name varchar(255), + user_id uuid, + user_name varchar(255), + action_type varchar(255), + action_data varchar(1000000), + action_status varchar(255), + action_failure_details varchar(1000000) +) PARTITION BY RANGE (created_time); + +CREATE TABLE IF NOT EXISTS attribute_kv ( + entity_type varchar(255), + entity_id uuid, + attribute_type varchar(255), + attribute_key varchar(255), + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + last_update_ts bigint, + CONSTRAINT attribute_kv_pkey PRIMARY KEY (entity_type, entity_id, attribute_type, attribute_key) +); + +CREATE TABLE IF NOT EXISTS component_descriptor ( + id uuid NOT NULL CONSTRAINT component_descriptor_pkey PRIMARY KEY, + created_time bigint NOT NULL, + actions varchar(255), + clazz varchar UNIQUE, + configuration_descriptor varchar, + name varchar(255), + scope varchar(255), + search_text varchar(255), + type varchar(255) +); + +CREATE TABLE IF NOT EXISTS customer ( + id uuid NOT NULL CONSTRAINT customer_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + address varchar, + address2 varchar, + city varchar(255), + country varchar(255), + email varchar(255), + phone varchar(255), + search_text varchar(255), + state varchar(255), + tenant_id uuid, + title varchar(255), + zip varchar(255), + external_id uuid, + CONSTRAINT customer_external_id_unq_key UNIQUE (tenant_id, external_id) +); + +CREATE TABLE IF NOT EXISTS dashboard ( + id uuid NOT NULL CONSTRAINT dashboard_pkey PRIMARY KEY, + created_time bigint NOT NULL, + configuration varchar, + assigned_customers varchar(1000000), + search_text varchar(255), + tenant_id uuid, + title varchar(255), + mobile_hide boolean DEFAULT false, + mobile_order int, + image varchar(1000000), + external_id uuid, + CONSTRAINT dashboard_external_id_unq_key UNIQUE (tenant_id, external_id) +); + +CREATE TABLE IF NOT EXISTS rule_chain ( + id uuid NOT NULL CONSTRAINT rule_chain_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + configuration varchar(10000000), + name varchar(255), + type varchar(255), + first_rule_node_id uuid, + root boolean, + debug_mode boolean, + search_text varchar(255), + tenant_id uuid, + external_id uuid, + CONSTRAINT rule_chain_external_id_unq_key UNIQUE (tenant_id, external_id) +); + +CREATE TABLE IF NOT EXISTS rule_node ( + id uuid NOT NULL CONSTRAINT rule_node_pkey PRIMARY KEY, + created_time bigint NOT NULL, + rule_chain_id uuid, + additional_info varchar, + configuration varchar(10000000), + type varchar(255), + name varchar(255), + debug_mode boolean, + search_text varchar(255), + external_id uuid +); + +CREATE TABLE IF NOT EXISTS rule_node_state ( + id uuid NOT NULL CONSTRAINT rule_node_state_pkey PRIMARY KEY, + created_time bigint NOT NULL, + rule_node_id uuid NOT NULL, + entity_type varchar(32) NOT NULL, + entity_id uuid NOT NULL, + state_data varchar(16384) NOT NULL, + CONSTRAINT rule_node_state_unq_key UNIQUE (rule_node_id, entity_id), + CONSTRAINT fk_rule_node_state_node_id FOREIGN KEY (rule_node_id) REFERENCES rule_node(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ota_package ( + id uuid NOT NULL CONSTRAINT ota_package_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + device_profile_id uuid , + type varchar(32) NOT NULL, + title varchar(255) NOT NULL, + version varchar(255) NOT NULL, + tag varchar(255), + url varchar(255), + file_name varchar(255), + content_type varchar(255), + checksum_algorithm varchar(32), + checksum varchar(1020), + data oid, + data_size bigint, + additional_info varchar, + search_text varchar(255), + CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version) +); + +CREATE TABLE IF NOT EXISTS queue ( + id uuid NOT NULL CONSTRAINT queue_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid, + name varchar(255), + topic varchar(255), + poll_interval int, + partitions int, + consumer_per_partition boolean, + pack_processing_timeout bigint, + submit_strategy varchar(255), + processing_strategy varchar(255), + additional_info varchar +); + +CREATE TABLE IF NOT EXISTS asset_profile ( + id uuid NOT NULL CONSTRAINT asset_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + image varchar(1000000), + description varchar, + search_text varchar(255), + is_default boolean, + tenant_id uuid, + default_rule_chain_id uuid, + default_dashboard_id uuid, + default_queue_name varchar(255), + external_id uuid, + CONSTRAINT asset_profile_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT asset_profile_external_id_unq_key UNIQUE (tenant_id, external_id), + CONSTRAINT fk_default_rule_chain_asset_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id), + CONSTRAINT fk_default_dashboard_asset_profile FOREIGN KEY (default_dashboard_id) REFERENCES dashboard(id) + ); + +CREATE TABLE IF NOT EXISTS asset ( + id uuid NOT NULL CONSTRAINT asset_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + customer_id uuid, + asset_profile_id uuid NOT NULL, + name varchar(255), + label varchar(255), + search_text varchar(255), + tenant_id uuid, + type varchar(255), + external_id uuid, + CONSTRAINT asset_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT asset_external_id_unq_key UNIQUE (tenant_id, external_id), + CONSTRAINT fk_asset_profile FOREIGN KEY (asset_profile_id) REFERENCES asset_profile(id) +); + +CREATE TABLE IF NOT EXISTS device_profile ( + id uuid NOT NULL CONSTRAINT device_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + type varchar(255), + image varchar(1000000), + transport_type varchar(255), + provision_type varchar(255), + profile_data jsonb, + description varchar, + search_text varchar(255), + is_default boolean, + tenant_id uuid, + firmware_id uuid, + software_id uuid, + default_rule_chain_id uuid, + default_dashboard_id uuid, + default_queue_name varchar(255), + provision_device_key varchar, + external_id uuid, + CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), + CONSTRAINT device_profile_external_id_unq_key UNIQUE (tenant_id, external_id), + CONSTRAINT fk_default_rule_chain_device_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id), + CONSTRAINT fk_default_dashboard_device_profile FOREIGN KEY (default_dashboard_id) REFERENCES dashboard(id), + CONSTRAINT fk_firmware_device_profile FOREIGN KEY (firmware_id) REFERENCES ota_package(id), + CONSTRAINT fk_software_device_profile FOREIGN KEY (software_id) REFERENCES ota_package(id) +); + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'fk_device_profile_ota_package') THEN + ALTER TABLE ota_package + ADD CONSTRAINT fk_device_profile_ota_package + FOREIGN KEY (device_profile_id) REFERENCES device_profile (id) + ON DELETE CASCADE; + END IF; + END; +$$; + +-- We will use one-to-many relation in the first release and extend this feature in case of user requests +-- CREATE TABLE IF NOT EXISTS device_profile_firmware ( +-- device_profile_id uuid NOT NULL, +-- firmware_id uuid NOT NULL, +-- CONSTRAINT device_profile_firmware_unq_key UNIQUE (device_profile_id, firmware_id), +-- CONSTRAINT fk_device_profile_firmware_device_profile FOREIGN KEY (device_profile_id) REFERENCES device_profile(id) ON DELETE CASCADE, +-- CONSTRAINT fk_device_profile_firmware_firmware FOREIGN KEY (firmware_id) REFERENCES firmware(id) ON DELETE CASCADE, +-- ); + +CREATE TABLE IF NOT EXISTS device ( + id uuid NOT NULL CONSTRAINT device_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + customer_id uuid, + device_profile_id uuid NOT NULL, + device_data jsonb, + type varchar(255), + name varchar(255), + label varchar(255), + search_text varchar(255), + tenant_id uuid, + firmware_id uuid, + software_id uuid, + external_id uuid, + CONSTRAINT device_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT device_external_id_unq_key UNIQUE (tenant_id, external_id), + CONSTRAINT fk_device_profile FOREIGN KEY (device_profile_id) REFERENCES device_profile(id), + CONSTRAINT fk_firmware_device FOREIGN KEY (firmware_id) REFERENCES ota_package(id), + CONSTRAINT fk_software_device FOREIGN KEY (software_id) REFERENCES ota_package(id) +); + +CREATE TABLE IF NOT EXISTS device_credentials ( + id uuid NOT NULL CONSTRAINT device_credentials_pkey PRIMARY KEY, + created_time bigint NOT NULL, + credentials_id varchar, + credentials_type varchar(255), + credentials_value varchar, + device_id uuid, + CONSTRAINT device_credentials_id_unq_key UNIQUE (credentials_id), + CONSTRAINT device_credentials_device_id_unq_key UNIQUE (device_id) +); + +CREATE TABLE IF NOT EXISTS rule_node_debug_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL , + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar, + e_type varchar, + e_entity_id uuid, + e_entity_type varchar, + e_msg_id uuid, + e_msg_type varchar, + e_data_type varchar, + e_relation_type varchar, + e_data varchar, + e_metadata varchar, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS rule_chain_debug_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_message varchar, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS stats_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_messages_processed bigint NOT NULL, + e_errors_occurred bigint NOT NULL +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS lc_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_type varchar NOT NULL, + e_success boolean NOT NULL, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS error_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_method varchar NOT NULL, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS relation ( + from_id uuid, + from_type varchar(255), + to_id uuid, + to_type varchar(255), + relation_type_group varchar(255), + relation_type varchar(255), + additional_info varchar, + CONSTRAINT relation_pkey PRIMARY KEY (from_id, from_type, relation_type_group, relation_type, to_id, to_type) +); +-- ) PARTITION BY LIST (relation_type_group); +-- +-- CREATE TABLE other_relations PARTITION OF relation DEFAULT; +-- CREATE TABLE common_relations PARTITION OF relation FOR VALUES IN ('COMMON'); +-- CREATE TABLE alarm_relations PARTITION OF relation FOR VALUES IN ('ALARM'); +-- CREATE TABLE dashboard_relations PARTITION OF relation FOR VALUES IN ('DASHBOARD'); +-- CREATE TABLE rule_relations PARTITION OF relation FOR VALUES IN ('RULE_CHAIN', 'RULE_NODE'); + +CREATE TABLE IF NOT EXISTS tb_user ( + id uuid NOT NULL CONSTRAINT tb_user_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + authority varchar(255), + customer_id uuid, + email varchar(255) UNIQUE, + first_name varchar(255), + last_name varchar(255), + search_text varchar(255), + tenant_id uuid +); + +CREATE TABLE IF NOT EXISTS tenant_profile ( + id uuid NOT NULL CONSTRAINT tenant_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + profile_data jsonb, + description varchar, + search_text varchar(255), + is_default boolean, + isolated_tb_core boolean, + isolated_tb_rule_engine boolean, + CONSTRAINT tenant_profile_name_unq_key UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS tenant ( + id uuid NOT NULL CONSTRAINT tenant_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + tenant_profile_id uuid NOT NULL, + address varchar, + address2 varchar, + city varchar(255), + country varchar(255), + email varchar(255), + phone varchar(255), + region varchar(255), + search_text varchar(255), + state varchar(255), + title varchar(255), + zip varchar(255), + CONSTRAINT fk_tenant_profile FOREIGN KEY (tenant_profile_id) REFERENCES tenant_profile(id) +); + +CREATE TABLE IF NOT EXISTS user_credentials ( + id uuid NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY, + created_time bigint NOT NULL, + activate_token varchar(255) UNIQUE, + enabled boolean, + password varchar(255), + reset_token varchar(255) UNIQUE, + user_id uuid UNIQUE +); + +CREATE TABLE IF NOT EXISTS widget_type ( + id uuid NOT NULL CONSTRAINT widget_type_pkey PRIMARY KEY, + created_time bigint NOT NULL, + alias varchar(255), + bundle_alias varchar(255), + descriptor varchar(1000000), + name varchar(255), + tenant_id uuid, + image varchar(1000000), + description varchar(255) +); + +CREATE TABLE IF NOT EXISTS widgets_bundle ( + id uuid NOT NULL CONSTRAINT widgets_bundle_pkey PRIMARY KEY, + created_time bigint NOT NULL, + alias varchar(255), + search_text varchar(255), + tenant_id uuid, + title varchar(255), + image varchar(1000000), + description varchar(255), + external_id uuid, + CONSTRAINT widgets_bundle_external_id_unq_key UNIQUE (tenant_id, external_id) +); + +CREATE TABLE IF NOT EXISTS entity_view ( + id uuid NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + created_time bigint NOT NULL, + entity_id uuid, + entity_type varchar(255), + tenant_id uuid, + customer_id uuid, + type varchar(255), + name varchar(255), + keys varchar(10000000), + start_ts bigint, + end_ts bigint, + search_text varchar(255), + additional_info varchar, + external_id uuid, + CONSTRAINT entity_view_external_id_unq_key UNIQUE (tenant_id, external_id) +); + +CREATE TABLE IF NOT EXISTS ts_kv_latest +( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) +); + +CREATE TABLE IF NOT EXISTS ts_kv_dictionary +( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) +); + +CREATE TABLE IF NOT EXISTS oauth2_params ( + id uuid NOT NULL CONSTRAINT oauth2_params_pkey PRIMARY KEY, + enabled boolean, + tenant_id uuid, + created_time bigint NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth2_registration ( + id uuid NOT NULL CONSTRAINT oauth2_registration_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + additional_info varchar, + client_id varchar(255), + client_secret varchar(2048), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + platforms varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + login_button_label varchar(255), + login_button_icon varchar(255), + allow_user_creation boolean, + activate_user boolean, + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + custom_url varchar(255), + custom_username varchar(255), + custom_password varchar(255), + custom_send_token boolean, + CONSTRAINT fk_registration_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS oauth2_domain ( + id uuid NOT NULL CONSTRAINT oauth2_domain_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + domain_name varchar(255), + domain_scheme varchar(31), + CONSTRAINT fk_domain_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, + CONSTRAINT oauth2_domain_unq_key UNIQUE (oauth2_params_id, domain_name, domain_scheme) +); + +CREATE TABLE IF NOT EXISTS oauth2_mobile ( + id uuid NOT NULL CONSTRAINT oauth2_mobile_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + pkg_name varchar(255), + app_secret varchar(2048), + CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, + CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name) +); + +CREATE TABLE IF NOT EXISTS oauth2_client_registration_template ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_template_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + provider_id varchar(255), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + comment varchar, + login_button_icon varchar(255), + login_button_label varchar(255), + help_link varchar(255), + CONSTRAINT oauth2_template_provider_id_unq_key UNIQUE (provider_id) +); + +-- Deprecated +CREATE TABLE IF NOT EXISTS oauth2_client_registration_info ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_info_pkey PRIMARY KEY, + enabled boolean, + created_time bigint NOT NULL, + additional_info varchar, + client_id varchar(255), + client_secret varchar(255), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + login_button_label varchar(255), + login_button_icon varchar(255), + allow_user_creation boolean, + activate_user boolean, + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + custom_url varchar(255), + custom_username varchar(255), + custom_password varchar(255), + custom_send_token boolean +); + +-- Deprecated +CREATE TABLE IF NOT EXISTS oauth2_client_registration ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_pkey PRIMARY KEY, + created_time bigint NOT NULL, + domain_name varchar(255), + domain_scheme varchar(31), + client_registration_info_id uuid +); + +CREATE TABLE IF NOT EXISTS api_usage_state ( + id uuid NOT NULL CONSTRAINT usage_record_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid, + entity_type varchar(32), + entity_id uuid, + transport varchar(32), + db_storage varchar(32), + re_exec varchar(32), + js_exec varchar(32), + email_exec varchar(32), + sms_exec varchar(32), + alarm_exec varchar(32), + CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id) +); + +CREATE TABLE IF NOT EXISTS resource ( + id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + title varchar(255) NOT NULL, + resource_type varchar(32) NOT NULL, + resource_key varchar(255) NOT NULL, + search_text varchar(255), + file_name varchar(255) NOT NULL, + data varchar, + CONSTRAINT resource_unq_key UNIQUE (tenant_id, resource_type, resource_key) +); + +CREATE TABLE IF NOT EXISTS edge ( + id uuid NOT NULL CONSTRAINT edge_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + customer_id uuid, + root_rule_chain_id uuid, + type varchar(255), + name varchar(255), + label varchar(255), + routing_key varchar(255), + secret varchar(255), + search_text varchar(255), + tenant_id uuid, + CONSTRAINT edge_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT edge_routing_key_unq_key UNIQUE (routing_key) +); + +CREATE TABLE IF NOT EXISTS edge_event ( + id uuid NOT NULL, + created_time bigint NOT NULL, + edge_id uuid, + edge_event_type varchar(255), + edge_event_uid varchar(255), + entity_id uuid, + edge_event_action varchar(255), + body varchar(10000000), + tenant_id uuid, + ts bigint NOT NULL +) PARTITION BY RANGE(created_time); + +CREATE TABLE IF NOT EXISTS rpc ( + id uuid NOT NULL CONSTRAINT rpc_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + device_id uuid NOT NULL, + expiration_time bigint NOT NULL, + request varchar(10000000) NOT NULL, + response varchar(10000000), + additional_info varchar(10000000), + status varchar(255) NOT NULL +); + +CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE PROCEDURE cleanup_edge_events_by_ttl(IN ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + ttl_ts bigint; + ttl_deleted_count bigint DEFAULT 0; +BEGIN + IF ttl > 0 THEN + ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - ttl::bigint * 1000)::bigint; + EXECUTE format( + 'WITH deleted AS (DELETE FROM edge_event WHERE ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', ttl_ts) into ttl_deleted_count; + END IF; + RAISE NOTICE 'Edge events removed by ttl: %', ttl_deleted_count; + deleted := ttl_deleted_count; +END +$$; + + +CREATE TABLE IF NOT EXISTS user_auth_settings ( + id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), + two_fa_settings varchar +); diff --git a/application/src/main/data/sql/schema-timescale.sql b/application/src/main/data/sql/schema-timescale.sql new file mode 100644 index 0000000..81b71f1 --- /dev/null +++ b/application/src/main/data/sql/schema-timescale.sql @@ -0,0 +1,157 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 EXTENSION IF NOT EXISTS timescaledb CASCADE; + +CREATE TABLE IF NOT EXISTS ts_kv ( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_pkey PRIMARY KEY (entity_id, key, ts) +); + +CREATE TABLE IF NOT EXISTS ts_kv_dictionary ( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) +); + +CREATE TABLE IF NOT EXISTS ts_kv_latest ( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) +); + +CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_device_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT device.id as entity_id FROM device WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_asset_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT asset.id as entity_id FROM asset WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_customer_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT customer.id as entity_id FROM customer WHERE tenant_id = %L and id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE cleanup_timeseries_by_ttl(IN null_uuid uuid, + IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + tenant_cursor CURSOR FOR select tenant.id as tenant_id + from tenant; + tenant_id_record uuid; + customer_id_record uuid; + tenant_ttl bigint; + customer_ttl bigint; + deleted_for_entities bigint; + tenant_ttl_ts bigint; + customer_ttl_ts bigint; +BEGIN + OPEN tenant_cursor; + FETCH tenant_cursor INTO tenant_id_record; + WHILE FOUND + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + tenant_id_record, 'TTL') INTO tenant_ttl; + if tenant_ttl IS NULL THEN + tenant_ttl := system_ttl; + END IF; + IF tenant_ttl > 0 THEN + tenant_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - tenant_ttl::bigint * 1000)::bigint; + deleted_for_entities := delete_device_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = %', deleted_for_entities, tenant_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = %', deleted_for_entities, tenant_id_record; + END IF; + FOR customer_id_record IN + SELECT customer.id AS customer_id FROM customer WHERE customer.tenant_id = tenant_id_record + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + customer_id_record, 'TTL') INTO customer_ttl; + IF customer_ttl IS NULL THEN + customer_ttl_ts := tenant_ttl_ts; + ELSE + IF customer_ttl > 0 THEN + customer_ttl_ts := + (EXTRACT(EPOCH FROM current_timestamp) * 1000 - + customer_ttl::bigint * 1000)::bigint; + END IF; + END IF; + IF customer_ttl_ts IS NOT NULL AND customer_ttl_ts > 0 THEN + deleted_for_entities := + delete_customer_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for customer with id = % where tenant_id = %', deleted_for_entities, customer_id_record, tenant_id_record; + deleted_for_entities := + delete_device_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, + customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + END IF; + END LOOP; + FETCH tenant_cursor INTO tenant_id_record; + END LOOP; +END +$$; diff --git a/application/src/main/data/sql/schema-ts-psql.sql b/application/src/main/data/sql/schema-ts-psql.sql new file mode 100644 index 0000000..c95ac4c --- /dev/null +++ b/application/src/main/data/sql/schema-ts-psql.sql @@ -0,0 +1,339 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_pkey PRIMARY KEY (entity_id, key, ts) +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS ts_kv_dictionary +( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) +); + +CREATE OR REPLACE PROCEDURE drop_partitions_by_max_ttl(IN partition_type varchar, IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + max_tenant_ttl bigint; + max_customer_ttl bigint; + max_ttl bigint; + date timestamp; + partition_by_max_ttl_date varchar; + partition_by_max_ttl_month varchar; + partition_by_max_ttl_day varchar; + partition_by_max_ttl_year varchar; + partition varchar; + partition_year integer; + partition_month integer; + partition_day integer; + +BEGIN + SELECT max(attribute_kv.long_v) + FROM tenant + INNER JOIN attribute_kv ON tenant.id = attribute_kv.entity_id + WHERE attribute_kv.attribute_key = 'TTL' + into max_tenant_ttl; + SELECT max(attribute_kv.long_v) + FROM customer + INNER JOIN attribute_kv ON customer.id = attribute_kv.entity_id + WHERE attribute_kv.attribute_key = 'TTL' + into max_customer_ttl; + max_ttl := GREATEST(system_ttl, max_customer_ttl, max_tenant_ttl); + if max_ttl IS NOT NULL AND max_ttl > 0 THEN + date := to_timestamp(EXTRACT(EPOCH FROM current_timestamp) - max_ttl); + partition_by_max_ttl_date := get_partition_by_max_ttl_date(partition_type, date); + RAISE NOTICE 'Date by max ttl: %', date; + RAISE NOTICE 'Partition by max ttl: %', partition_by_max_ttl_date; + IF partition_by_max_ttl_date IS NOT NULL THEN + CASE + WHEN partition_type = 'DAYS' THEN + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + partition_by_max_ttl_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); + partition_by_max_ttl_day := SPLIT_PART(partition_by_max_ttl_date, '_', 5); + WHEN partition_type = 'MONTHS' THEN + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + partition_by_max_ttl_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); + ELSE + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + END CASE; + IF partition_by_max_ttl_year IS NULL THEN + RAISE NOTICE 'Failed to remove partitions by max ttl date due to partition_by_max_ttl_year is null!'; + ELSE + IF partition_type = 'YEARS' THEN + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END LOOP; + ELSE + IF partition_type = 'MONTHS' THEN + IF partition_by_max_ttl_month IS NULL THEN + RAISE NOTICE 'Failed to remove months partitions by max ttl date due to partition_by_max_ttl_month is null!'; + ELSE + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year > partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_month := SPLIT_PART(partition, '_', 4)::integer; + IF partition_year = partition_by_max_ttl_year::integer THEN + IF partition_month >= partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END IF; + END IF; + END IF; + END LOOP; + END IF; + ELSE + IF partition_type = 'DAYS' THEN + IF partition_by_max_ttl_month IS NULL THEN + RAISE NOTICE 'Failed to remove days partitions by max ttl date due to partition_by_max_ttl_month is null!'; + ELSE + IF partition_by_max_ttl_day IS NULL THEN + RAISE NOTICE 'Failed to remove days partitions by max ttl date due to partition_by_max_ttl_day is null!'; + ELSE + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year > partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_month := SPLIT_PART(partition, '_', 4)::integer; + IF partition_month > partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_month < partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_day := SPLIT_PART(partition, '_', 5)::integer; + IF partition_day >= partition_by_max_ttl_day::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_day < partition_by_max_ttl_day::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END LOOP; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; +END +$$; + +CREATE OR REPLACE FUNCTION get_partition_by_max_ttl_date(IN partition_type varchar, IN date timestamp, OUT partition varchar) AS +$$ +BEGIN + CASE + WHEN partition_type = 'DAYS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM') || '_' || to_char(date, 'dd'); + WHEN partition_type = 'MONTHS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM'); + WHEN partition_type = 'YEARS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy'); + ELSE + partition := NULL; + END CASE; + IF partition IS NOT NULL THEN + IF NOT EXISTS(SELECT + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = partition) THEN + partition := NULL; + RAISE NOTICE 'Failed to found partition by ttl'; + END IF; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_device_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT device.id as entity_id FROM device WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_asset_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT asset.id as entity_id FROM asset WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_customer_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT customer.id as entity_id FROM customer WHERE tenant_id = %L and id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE cleanup_timeseries_by_ttl(IN null_uuid uuid, + IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + tenant_cursor CURSOR FOR select tenant.id as tenant_id + from tenant; + tenant_id_record uuid; + customer_id_record uuid; + tenant_ttl bigint; + customer_ttl bigint; + deleted_for_entities bigint; + tenant_ttl_ts bigint; + customer_ttl_ts bigint; +BEGIN + OPEN tenant_cursor; + FETCH tenant_cursor INTO tenant_id_record; + WHILE FOUND + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + tenant_id_record, 'TTL') INTO tenant_ttl; + if tenant_ttl IS NULL THEN + tenant_ttl := system_ttl; + END IF; + IF tenant_ttl > 0 THEN + tenant_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - tenant_ttl::bigint * 1000)::bigint; + deleted_for_entities := delete_device_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = %', deleted_for_entities, tenant_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = %', deleted_for_entities, tenant_id_record; + END IF; + FOR customer_id_record IN + SELECT customer.id AS customer_id FROM customer WHERE customer.tenant_id = tenant_id_record + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + customer_id_record, 'TTL') INTO customer_ttl; + IF customer_ttl IS NULL THEN + customer_ttl_ts := tenant_ttl_ts; + ELSE + IF customer_ttl > 0 THEN + customer_ttl_ts := + (EXTRACT(EPOCH FROM current_timestamp) * 1000 - + customer_ttl::bigint * 1000)::bigint; + END IF; + END IF; + IF customer_ttl_ts IS NOT NULL AND customer_ttl_ts > 0 THEN + deleted_for_entities := + delete_customer_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for customer with id = % where tenant_id = %', deleted_for_entities, customer_id_record, tenant_id_record; + deleted_for_entities := + delete_device_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, + customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + END IF; + END LOOP; + FETCH tenant_cursor INTO tenant_id_record; + END LOOP; +END +$$; diff --git a/application/src/main/data/upgrade/1.3.0/schema_update.cql b/application/src/main/data/upgrade/1.3.0/schema_update.cql new file mode 100644 index 0000000..88cb1dc --- /dev/null +++ b/application/src/main/data/upgrade/1.3.0/schema_update.cql @@ -0,0 +1,187 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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.device_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.device_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.device_by_tenant_by_type_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.device_by_customer_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.device_by_customer_by_type_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.device_types_by_tenant; + +DROP TABLE IF EXISTS thingsboard.device; + +CREATE TABLE IF NOT EXISTS thingsboard.device ( + id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + search_text text, + additional_info text, + PRIMARY KEY (id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_name AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, name, id, customer_id, type) + WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) + WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_by_type_and_search_text AS + SELECT * + from thingsboard.device + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_by_tenant_by_type_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_by_customer_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_by_customer_by_type_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.asset_types_by_tenant; + +DROP TABLE IF EXISTS thingsboard.asset; + +CREATE TABLE IF NOT EXISTS thingsboard.asset ( + id timeuuid, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + search_text text, + additional_info text, + PRIMARY KEY (id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_name AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, name, id, customer_id, type) + WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) + WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.asset_by_customer_by_type_and_search_text AS + SELECT * + from thingsboard.asset + WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) + WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.entity_subtype ( + tenant_id timeuuid, + entity_type text, // (DEVICE, ASSET) + type text, + PRIMARY KEY (tenant_id, entity_type, type) +); + +CREATE TABLE IF NOT EXISTS thingsboard.alarm ( + id timeuuid, + tenant_id timeuuid, + type text, + originator_id timeuuid, + originator_type text, + severity text, + status text, + start_ts bigint, + end_ts bigint, + ack_ts bigint, + clear_ts bigint, + details text, + propagate boolean, + PRIMARY KEY ((tenant_id, originator_id, originator_type), type, id) +) WITH CLUSTERING ORDER BY ( type ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.alarm_by_id AS + SELECT * + from thingsboard.alarm + WHERE tenant_id IS NOT NULL AND originator_id IS NOT NULL AND originator_type IS NOT NULL AND type IS NOT NULL + AND type IS NOT NULL AND id IS NOT NULL + PRIMARY KEY (id, tenant_id, originator_id, originator_type, type) + WITH CLUSTERING ORDER BY ( tenant_id ASC, originator_id ASC, originator_type ASC, type ASC); + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.relation_by_type_and_child_type; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.reverse_relation; + +DROP TABLE IF EXISTS thingsboard.relation; + +CREATE TABLE IF NOT EXISTS thingsboard.relation ( + from_id timeuuid, + from_type text, + to_id timeuuid, + to_type text, + relation_type_group text, + relation_type text, + additional_info text, + PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_id, to_type) +) WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_id ASC, to_type ASC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.relation_by_type_and_child_type AS + SELECT * + from thingsboard.relation + WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL + PRIMARY KEY ((from_id, from_type), relation_type_group, relation_type, to_type, to_id) + WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, to_type ASC, to_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.reverse_relation AS + SELECT * + from thingsboard.relation + WHERE from_id IS NOT NULL AND from_type IS NOT NULL AND relation_type_group IS NOT NULL AND relation_type IS NOT NULL AND to_id IS NOT NULL AND to_type IS NOT NULL + PRIMARY KEY ((to_id, to_type), relation_type_group, relation_type, from_id, from_type) + WITH CLUSTERING ORDER BY ( relation_type_group ASC, relation_type ASC, from_id ASC, from_type ASC); diff --git a/application/src/main/data/upgrade/1.3.1/schema_update.sql b/application/src/main/data/upgrade/1.3.1/schema_update.sql new file mode 100644 index 0000000..1ca6590 --- /dev/null +++ b/application/src/main/data/upgrade/1.3.1/schema_update.sql @@ -0,0 +1,17 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 ts_kv_latest ALTER COLUMN str_v SET DATA TYPE varchar(10000000); diff --git a/application/src/main/data/upgrade/1.4.0/schema_update.cql b/application/src/main/data/upgrade/1.4.0/schema_update.cql new file mode 100644 index 0000000..138c9f9 --- /dev/null +++ b/application/src/main/data/upgrade/1.4.0/schema_update.cql @@ -0,0 +1,112 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 thingsboard.audit_log_by_entity_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, entity_id, entity_type), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_customer_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, customer_id), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_user_id ( + tenant_id timeuuid, + id timeuuid, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, user_id), id) +); + + + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id ( + tenant_id timeuuid, + id timeuuid, + partition bigint, + customer_id timeuuid, + entity_id timeuuid, + entity_type text, + entity_name text, + user_id timeuuid, + user_name text, + action_type text, + action_data text, + action_status text, + action_failure_details text, + PRIMARY KEY ((tenant_id, partition), id) +); + +CREATE TABLE IF NOT EXISTS thingsboard.audit_log_by_tenant_id_partitions ( + tenant_id timeuuid, + partition bigint, + PRIMARY KEY (( tenant_id ), partition) +) WITH CLUSTERING ORDER BY ( partition ASC ) +AND compaction = { 'class' : 'LeveledCompactionStrategy' }; + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.dashboard_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.dashboard_by_customer_and_search_text; + +DROP TABLE IF EXISTS thingsboard.dashboard; + +CREATE TABLE IF NOT EXISTS thingsboard.dashboard ( + id timeuuid, + tenant_id timeuuid, + title text, + search_text text, + assigned_customers text, + configuration text, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.dashboard_by_tenant_and_search_text AS + SELECT * + from thingsboard.dashboard + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + diff --git a/application/src/main/data/upgrade/1.4.0/schema_update.sql b/application/src/main/data/upgrade/1.4.0/schema_update.sql new file mode 100644 index 0000000..c999c6b --- /dev/null +++ b/application/src/main/data/upgrade/1.4.0/schema_update.sql @@ -0,0 +1,41 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 audit_log ( + id varchar(31) NOT NULL CONSTRAINT audit_log_pkey PRIMARY KEY, + tenant_id varchar(31), + customer_id varchar(31), + entity_id varchar(31), + entity_type varchar(255), + entity_name varchar(255), + user_id varchar(31), + user_name varchar(255), + action_type varchar(255), + action_data varchar(1000000), + action_status varchar(255), + action_failure_details varchar(1000000) +); + +DROP TABLE IF EXISTS dashboard; + +CREATE TABLE IF NOT EXISTS dashboard ( + id varchar(31) NOT NULL CONSTRAINT dashboard_pkey PRIMARY KEY, + configuration varchar(10000000), + assigned_customers varchar(1000000), + search_text varchar(255), + tenant_id varchar(31), + title varchar(255) +); diff --git a/application/src/main/data/upgrade/2.0.0/schema_update.cql b/application/src/main/data/upgrade/2.0.0/schema_update.cql new file mode 100644 index 0000000..5f7daa0 --- /dev/null +++ b/application/src/main/data/upgrade/2.0.0/schema_update.cql @@ -0,0 +1,103 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 thingsboard.msg_queue ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + ts bigint, + msg blob, + PRIMARY KEY ((node_id, cluster_partition, ts_partition), ts)) +WITH CLUSTERING ORDER BY (ts DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.msg_ack_queue ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + msg_id timeuuid, + PRIMARY KEY ((node_id, cluster_partition, ts_partition), msg_id)) +WITH CLUSTERING ORDER BY (msg_id DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.processed_msg_partitions ( + node_id timeuuid, + cluster_partition bigint, + ts_partition bigint, + PRIMARY KEY ((node_id, cluster_partition), ts_partition)) +WITH CLUSTERING ORDER BY (ts_partition DESC) +AND compaction = { + 'class': 'org.apache.cassandra.db.compaction.DateTieredCompactionStrategy', + 'min_threshold': '5', + 'base_time_seconds': '43200', + 'max_window_size_seconds': '43200', + 'tombstone_threshold': '0.9', + 'unchecked_tombstone_compaction': 'true' +}; + +CREATE TABLE IF NOT EXISTS thingsboard.rule_chain ( + id uuid, + tenant_id uuid, + name text, + search_text text, + first_rule_node_id uuid, + root boolean, + debug_mode boolean, + configuration text, + additional_info text, + PRIMARY KEY (id, tenant_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.rule_chain_by_tenant_and_search_text AS + SELECT * + from thingsboard.rule_chain + WHERE tenant_id IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + PRIMARY KEY ( tenant_id, search_text, id ) + WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); + +CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( + id uuid, + rule_chain_id uuid, + type text, + name text, + debug_mode boolean, + search_text text, + configuration text, + additional_info text, + PRIMARY KEY (id) +); + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.rule_by_plugin_token; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.rule_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.plugin_by_api_token; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.plugin_by_tenant_and_search_text; + +DROP TABLE IF EXISTS thingsboard.rule; +DROP TABLE IF EXISTS thingsboard.plugin; diff --git a/application/src/main/data/upgrade/2.0.0/schema_update.sql b/application/src/main/data/upgrade/2.0.0/schema_update.sql new file mode 100644 index 0000000..4a07d57 --- /dev/null +++ b/application/src/main/data/upgrade/2.0.0/schema_update.sql @@ -0,0 +1,44 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 rule_chain ( + id varchar(31) NOT NULL CONSTRAINT rule_chain_pkey PRIMARY KEY, + additional_info varchar, + configuration varchar(10000000), + name varchar(255), + first_rule_node_id varchar(31), + root boolean, + debug_mode boolean, + search_text varchar(255), + tenant_id varchar(31) +); + +CREATE TABLE IF NOT EXISTS rule_node ( + id varchar(31) NOT NULL CONSTRAINT rule_node_pkey PRIMARY KEY, + rule_chain_id varchar(31), + additional_info varchar, + configuration varchar(10000000), + type varchar(255), + name varchar(255), + debug_mode boolean, + search_text varchar(255) +); + +DROP TABLE rule; +DROP TABLE plugin; + +DELETE FROM alarm WHERE originator_type = 3 OR originator_type = 4; +UPDATE alarm SET originator_type = (originator_type - 2) where originator_type > 2; 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..49aa012 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -0,0 +1,74 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 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..a840af7 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -0,0 +1,29 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 entity_views ( + id varchar(31) NOT NULL CONSTRAINT entity_views_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..0a27a91 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.cql @@ -0,0 +1,110 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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..d7baf2e --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.sql @@ -0,0 +1,32 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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..9fc8d11 --- /dev/null +++ b/application/src/main/data/upgrade/2.2.0/schema_update.sql @@ -0,0 +1,19 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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); + +ALTER TABLE entity_view ALTER COLUMN keys SET DATA TYPE varchar(10000000); diff --git a/application/src/main/data/upgrade/2.3.1/schema_update.sql b/application/src/main/data/upgrade/2.3.1/schema_update.sql new file mode 100644 index 0000000..7fd717e --- /dev/null +++ b/application/src/main/data/upgrade/2.3.1/schema_update.sql @@ -0,0 +1,17 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 event ALTER COLUMN body SET DATA TYPE varchar(10000000); diff --git a/application/src/main/data/upgrade/2.4.0/schema_update.sql b/application/src/main/data/upgrade/2.4.0/schema_update.sql new file mode 100644 index 0000000..835b194 --- /dev/null +++ b/application/src/main/data/upgrade/2.4.0/schema_update.sql @@ -0,0 +1,23 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 INDEX IF NOT EXISTS idx_alarm_originator_alarm_type ON alarm(tenant_id, type, originator_type, originator_id); + +CREATE INDEX IF NOT EXISTS idx_event_type_entity_id ON event(tenant_id, event_type, entity_type, entity_id); + +CREATE INDEX IF NOT EXISTS idx_relation_to_id ON relation(relation_type_group, to_type, to_id); + +CREATE INDEX IF NOT EXISTS idx_relation_from_id ON relation(relation_type_group, from_type, from_id); diff --git a/application/src/main/data/upgrade/2.4.2/schema_update.sql b/application/src/main/data/upgrade/2.4.2/schema_update.sql new file mode 100644 index 0000000..d99aaa4 --- /dev/null +++ b/application/src/main/data/upgrade/2.4.2/schema_update.sql @@ -0,0 +1,31 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 INDEX IF EXISTS idx_alarm_originator_alarm_type; + +CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type ON alarm(originator_id, type, start_ts DESC); + +CREATE INDEX IF NOT EXISTS idx_device_customer_id ON device(tenant_id, customer_id); + +CREATE INDEX IF NOT EXISTS idx_device_customer_id_and_type ON device(tenant_id, customer_id, type); + +CREATE INDEX IF NOT EXISTS idx_device_type ON device(tenant_id, type); + +CREATE INDEX IF NOT EXISTS idx_asset_customer_id ON asset(tenant_id, customer_id); + +CREATE INDEX IF NOT EXISTS idx_asset_customer_id_and_type ON asset(tenant_id, customer_id, type); + +CREATE INDEX IF NOT EXISTS idx_asset_type ON asset(tenant_id, type); \ No newline at end of file diff --git a/application/src/main/data/upgrade/2.4.3/schema_update_psql_drop_partitions.sql b/application/src/main/data/upgrade/2.4.3/schema_update_psql_drop_partitions.sql new file mode 100644 index 0000000..936911b --- /dev/null +++ b/application/src/main/data/upgrade/2.4.3/schema_update_psql_drop_partitions.sql @@ -0,0 +1,209 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE PROCEDURE drop_partitions_by_max_ttl(IN partition_type varchar, IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + max_tenant_ttl bigint; + max_customer_ttl bigint; + max_ttl bigint; + date timestamp; + partition_by_max_ttl_date varchar; + partition_by_max_ttl_month varchar; + partition_by_max_ttl_day varchar; + partition_by_max_ttl_year varchar; + partition varchar; + partition_year integer; + partition_month integer; + partition_day integer; + +BEGIN + SELECT max(attribute_kv.long_v) + FROM tenant + INNER JOIN attribute_kv ON tenant.id = attribute_kv.entity_id + WHERE attribute_kv.attribute_key = 'TTL' + into max_tenant_ttl; + SELECT max(attribute_kv.long_v) + FROM customer + INNER JOIN attribute_kv ON customer.id = attribute_kv.entity_id + WHERE attribute_kv.attribute_key = 'TTL' + into max_customer_ttl; + max_ttl := GREATEST(system_ttl, max_customer_ttl, max_tenant_ttl); + if max_ttl IS NOT NULL AND max_ttl > 0 THEN + date := to_timestamp(EXTRACT(EPOCH FROM current_timestamp) - max_ttl); + partition_by_max_ttl_date := get_partition_by_max_ttl_date(partition_type, date); + RAISE NOTICE 'Date by max ttl: %', date; + RAISE NOTICE 'Partition by max ttl: %', partition_by_max_ttl_date; + IF partition_by_max_ttl_date IS NOT NULL THEN + CASE + WHEN partition_type = 'DAYS' THEN + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + partition_by_max_ttl_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); + partition_by_max_ttl_day := SPLIT_PART(partition_by_max_ttl_date, '_', 5); + WHEN partition_type = 'MONTHS' THEN + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + partition_by_max_ttl_month := SPLIT_PART(partition_by_max_ttl_date, '_', 4); + ELSE + partition_by_max_ttl_year := SPLIT_PART(partition_by_max_ttl_date, '_', 3); + END CASE; + IF partition_by_max_ttl_year IS NULL THEN + RAISE NOTICE 'Failed to remove partitions by max ttl date due to partition_by_max_ttl_year is null!'; + ELSE + IF partition_type = 'YEARS' THEN + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END LOOP; + ELSE + IF partition_type = 'MONTHS' THEN + IF partition_by_max_ttl_month IS NULL THEN + RAISE NOTICE 'Failed to remove months partitions by max ttl date due to partition_by_max_ttl_month is null!'; + ELSE + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year > partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_month := SPLIT_PART(partition, '_', 4)::integer; + IF partition_year = partition_by_max_ttl_year::integer THEN + IF partition_month >= partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END IF; + END IF; + END IF; + END LOOP; + END IF; + ELSE + IF partition_type = 'DAYS' THEN + IF partition_by_max_ttl_month IS NULL THEN + RAISE NOTICE 'Failed to remove days partitions by max ttl date due to partition_by_max_ttl_month is null!'; + ELSE + IF partition_by_max_ttl_day IS NULL THEN + RAISE NOTICE 'Failed to remove days partitions by max ttl date due to partition_by_max_ttl_day is null!'; + ELSE + FOR partition IN SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename like 'ts_kv_' || '%' + AND tablename != 'ts_kv_latest' + AND tablename != 'ts_kv_dictionary' + AND tablename != 'ts_kv_indefinite' + AND tablename != partition_by_max_ttl_date + LOOP + partition_year := SPLIT_PART(partition, '_', 3)::integer; + IF partition_year > partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_year < partition_by_max_ttl_year::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_month := SPLIT_PART(partition, '_', 4)::integer; + IF partition_month > partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_month < partition_by_max_ttl_month::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + ELSE + partition_day := SPLIT_PART(partition, '_', 5)::integer; + IF partition_day >= partition_by_max_ttl_day::integer THEN + RAISE NOTICE 'Skip iteration! Partition: % is valid!', partition; + CONTINUE; + ELSE + IF partition_day < partition_by_max_ttl_day::integer THEN + RAISE NOTICE 'Partition to delete by max ttl: %', partition; + EXECUTE format('DROP TABLE IF EXISTS %I', partition); + deleted := deleted + 1; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END LOOP; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; + END IF; +END +$$; + +CREATE OR REPLACE FUNCTION get_partition_by_max_ttl_date(IN partition_type varchar, IN date timestamp, OUT partition varchar) AS +$$ +BEGIN + CASE + WHEN partition_type = 'DAYS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM') || '_' || to_char(date, 'dd'); + WHEN partition_type = 'MONTHS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy') || '_' || to_char(date, 'MM'); + WHEN partition_type = 'YEARS' THEN + partition := 'ts_kv_' || to_char(date, 'yyyy'); + ELSE + partition := NULL; + END CASE; + IF partition IS NOT NULL THEN + IF NOT EXISTS(SELECT + FROM pg_tables + WHERE schemaname = 'public' + AND tablename = partition) THEN + partition := NULL; + RAISE NOTICE 'Failed to found partition by ttl'; + END IF; + END IF; +END; +$$ LANGUAGE plpgsql; diff --git a/application/src/main/data/upgrade/2.4.3/schema_update_psql_ts.sql b/application/src/main/data/upgrade/2.4.3/schema_update_psql_ts.sql new file mode 100644 index 0000000..5cf51d1 --- /dev/null +++ b/application/src/main/data/upgrade/2.4.3/schema_update_psql_ts.sql @@ -0,0 +1,359 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- call create_partition_ts_kv_table(); + +CREATE OR REPLACE PROCEDURE create_partition_ts_kv_table() + LANGUAGE plpgsql AS +$$ + +BEGIN + ALTER TABLE ts_kv + DROP CONSTRAINT IF EXISTS ts_kv_unq_key; + ALTER TABLE ts_kv + DROP CONSTRAINT IF EXISTS ts_kv_pkey; + ALTER TABLE ts_kv + ADD CONSTRAINT ts_kv_pkey PRIMARY KEY (entity_type, entity_id, key, ts); + ALTER TABLE ts_kv + RENAME TO ts_kv_old; + ALTER TABLE ts_kv_old + RENAME CONSTRAINT ts_kv_pkey TO ts_kv_pkey_old; + CREATE TABLE IF NOT EXISTS ts_kv + ( + LIKE ts_kv_old + ) + PARTITION BY RANGE (ts); + ALTER TABLE ts_kv + DROP COLUMN entity_type; + ALTER TABLE ts_kv + ALTER COLUMN entity_id TYPE uuid USING entity_id::uuid; + ALTER TABLE ts_kv + ALTER COLUMN key TYPE integer USING key::integer; + ALTER TABLE ts_kv + ADD CONSTRAINT ts_kv_pkey PRIMARY KEY (entity_id, key, ts); + CREATE TABLE IF NOT EXISTS ts_kv_indefinite PARTITION OF ts_kv DEFAULT; +END; +$$; + +-- call create_new_ts_kv_latest_table(); + +CREATE OR REPLACE PROCEDURE create_new_ts_kv_latest_table() + LANGUAGE plpgsql AS +$$ + +BEGIN + IF NOT EXISTS(SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'ts_kv_latest_old') THEN + ALTER TABLE ts_kv_latest + DROP CONSTRAINT IF EXISTS ts_kv_latest_unq_key; + ALTER TABLE ts_kv_latest + DROP CONSTRAINT IF EXISTS ts_kv_latest_pkey; + ALTER TABLE ts_kv_latest + ADD CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_type, entity_id, key); + ALTER TABLE ts_kv_latest + RENAME TO ts_kv_latest_old; + ALTER TABLE ts_kv_latest_old + RENAME CONSTRAINT ts_kv_latest_pkey TO ts_kv_latest_pkey_old; + CREATE TABLE IF NOT EXISTS ts_kv_latest + ( + LIKE ts_kv_latest_old + ); + ALTER TABLE ts_kv_latest + DROP COLUMN entity_type; + ALTER TABLE ts_kv_latest + ALTER COLUMN entity_id TYPE uuid USING entity_id::uuid; + ALTER TABLE ts_kv_latest + ALTER COLUMN key TYPE integer USING key::integer; + ALTER TABLE ts_kv_latest + ADD CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key); + ELSE + RAISE NOTICE 'ts_kv_latest_old table already exists!'; + IF NOT EXISTS(SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'ts_kv_latest') THEN + CREATE TABLE IF NOT EXISTS ts_kv_latest + ( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) + ); + END IF; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION get_partitions_data(IN partition_type varchar) + RETURNS + TABLE + ( + partition_date text, + from_ts bigint, + to_ts bigint + ) +AS +$$ +BEGIN + CASE + WHEN partition_type = 'DAYS' THEN + RETURN QUERY SELECT day_date.day AS partition_date, + (extract(epoch from (day_date.day)::timestamp) * 1000)::bigint AS from_ts, + (extract(epoch from (day_date.day::date + INTERVAL '1 DAY')::timestamp) * + 1000)::bigint AS to_ts + FROM (SELECT DISTINCT TO_CHAR(TO_TIMESTAMP(ts / 1000), 'YYYY_MM_DD') AS day + FROM ts_kv_old) AS day_date; + WHEN partition_type = 'MONTHS' THEN + RETURN QUERY SELECT SUBSTRING(month_date.first_date, 1, 7) AS partition_date, + (extract(epoch from (month_date.first_date)::timestamp) * 1000)::bigint AS from_ts, + (extract(epoch from (month_date.first_date::date + INTERVAL '1 MONTH')::timestamp) * + 1000)::bigint AS to_ts + FROM (SELECT DISTINCT TO_CHAR(TO_TIMESTAMP(ts / 1000), 'YYYY_MM_01') AS first_date + FROM ts_kv_old) AS month_date; + WHEN partition_type = 'YEARS' THEN + RETURN QUERY SELECT SUBSTRING(year_date.year, 1, 4) AS partition_date, + (extract(epoch from (year_date.year)::timestamp) * 1000)::bigint AS from_ts, + (extract(epoch from (year_date.year::date + INTERVAL '1 YEAR')::timestamp) * + 1000)::bigint AS to_ts + FROM (SELECT DISTINCT TO_CHAR(TO_TIMESTAMP(ts / 1000), 'YYYY_01_01') AS year + FROM ts_kv_old) AS year_date; + ELSE + RAISE EXCEPTION 'Failed to parse partitioning property: % !', partition_type; + END CASE; +END; +$$ LANGUAGE plpgsql; + +-- call create_partitions(); + +CREATE OR REPLACE PROCEDURE create_partitions(IN partition_type varchar) + LANGUAGE plpgsql AS +$$ + +DECLARE + partition_date varchar; + from_ts bigint; + to_ts bigint; + partitions_cursor CURSOR FOR SELECT * + FROM get_partitions_data(partition_type); +BEGIN + OPEN partitions_cursor; + LOOP + FETCH partitions_cursor INTO partition_date, from_ts, to_ts; + EXIT WHEN NOT FOUND; + EXECUTE 'CREATE TABLE IF NOT EXISTS ts_kv_' || partition_date || + ' PARTITION OF ts_kv FOR VALUES FROM (' || from_ts || + ') TO (' || to_ts || ');'; + RAISE NOTICE 'A partition % has been created!',CONCAT('ts_kv_', partition_date); + END LOOP; + + CLOSE partitions_cursor; +END; +$$; + +-- call create_ts_kv_dictionary_table(); + +CREATE OR REPLACE PROCEDURE create_ts_kv_dictionary_table() + LANGUAGE plpgsql AS +$$ + +BEGIN + CREATE TABLE IF NOT EXISTS ts_kv_dictionary + ( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) + ); +END; +$$; + +-- call insert_into_dictionary(); + +CREATE OR REPLACE PROCEDURE insert_into_dictionary() + LANGUAGE plpgsql AS +$$ + +DECLARE + insert_record RECORD; + key_cursor CURSOR FOR SELECT DISTINCT key + FROM ts_kv_old + ORDER BY key; +BEGIN + OPEN key_cursor; + LOOP + FETCH key_cursor INTO insert_record; + EXIT WHEN NOT FOUND; + IF NOT EXISTS(SELECT key FROM ts_kv_dictionary WHERE key = insert_record.key) THEN + INSERT INTO ts_kv_dictionary(key) VALUES (insert_record.key); + RAISE NOTICE 'Key: % has been inserted into the dictionary!',insert_record.key; + ELSE + RAISE NOTICE 'Key: % already exists in the dictionary!',insert_record.key; + END IF; + END LOOP; + CLOSE key_cursor; +END; +$$; + +CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv(IN path_to_file varchar) + LANGUAGE plpgsql AS +$$ +BEGIN + EXECUTE format('COPY (SELECT to_uuid(entity_id) AS entity_id, + ts_kv_records.key AS key, + ts_kv_records.ts AS ts, + ts_kv_records.bool_v AS bool_v, + ts_kv_records.str_v AS str_v, + ts_kv_records.long_v AS long_v, + ts_kv_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM ts_kv_old + INNER JOIN ts_kv_dictionary ON (ts_kv_old.key = ts_kv_dictionary.key)) AS ts_kv_records) TO %L;', + path_to_file); + EXECUTE format('COPY ts_kv FROM %L', path_to_file); +END +$$; + +-- call insert_into_ts_kv_latest(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv_latest(IN path_to_file varchar) + LANGUAGE plpgsql AS +$$ +BEGIN + EXECUTE format('COPY (SELECT to_uuid(entity_id) AS entity_id, + ts_kv_latest_records.key AS key, + ts_kv_latest_records.ts AS ts, + ts_kv_latest_records.bool_v AS bool_v, + ts_kv_latest_records.str_v AS str_v, + ts_kv_latest_records.long_v AS long_v, + ts_kv_latest_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM ts_kv_latest_old + INNER JOIN ts_kv_dictionary ON (ts_kv_latest_old.key = ts_kv_dictionary.key)) AS ts_kv_latest_records) TO %L;', + path_to_file); + EXECUTE format('COPY ts_kv_latest FROM %L', path_to_file); +END; +$$; + +-- call insert_into_ts_kv_cursor(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv_cursor() + LANGUAGE plpgsql AS +$$ +DECLARE + insert_size CONSTANT integer := 10000; + insert_counter integer DEFAULT 0; + insert_record RECORD; + insert_cursor CURSOR FOR SELECT to_uuid(entity_id) AS entity_id, + ts_kv_records.key AS key, + ts_kv_records.ts AS ts, + ts_kv_records.bool_v AS bool_v, + ts_kv_records.str_v AS str_v, + ts_kv_records.long_v AS long_v, + ts_kv_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM ts_kv_old + INNER JOIN ts_kv_dictionary ON (ts_kv_old.key = ts_kv_dictionary.key)) AS ts_kv_records; +BEGIN + OPEN insert_cursor; + LOOP + insert_counter := insert_counter + 1; + FETCH insert_cursor INTO insert_record; + IF NOT FOUND THEN + RAISE NOTICE '% records have been inserted into the partitioned ts_kv!',insert_counter - 1; + EXIT; + END IF; + INSERT INTO ts_kv(entity_id, key, ts, bool_v, str_v, long_v, dbl_v) + VALUES (insert_record.entity_id, insert_record.key, insert_record.ts, insert_record.bool_v, insert_record.str_v, + insert_record.long_v, insert_record.dbl_v); + IF MOD(insert_counter, insert_size) = 0 THEN + RAISE NOTICE '% records have been inserted into the partitioned ts_kv!',insert_counter; + END IF; + END LOOP; + CLOSE insert_cursor; +END; +$$; + +-- call insert_into_ts_kv_latest_cursor(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv_latest_cursor() + LANGUAGE plpgsql AS +$$ +DECLARE + insert_size CONSTANT integer := 10000; + insert_counter integer DEFAULT 0; + insert_record RECORD; + insert_cursor CURSOR FOR SELECT to_uuid(entity_id) AS entity_id, + ts_kv_latest_records.key AS key, + ts_kv_latest_records.ts AS ts, + ts_kv_latest_records.bool_v AS bool_v, + ts_kv_latest_records.str_v AS str_v, + ts_kv_latest_records.long_v AS long_v, + ts_kv_latest_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM ts_kv_latest_old + INNER JOIN ts_kv_dictionary ON (ts_kv_latest_old.key = ts_kv_dictionary.key)) AS ts_kv_latest_records; +BEGIN + OPEN insert_cursor; + LOOP + insert_counter := insert_counter + 1; + FETCH insert_cursor INTO insert_record; + IF NOT FOUND THEN + RAISE NOTICE '% records have been inserted into the ts_kv_latest!',insert_counter - 1; + EXIT; + END IF; + INSERT INTO ts_kv_latest(entity_id, key, ts, bool_v, str_v, long_v, dbl_v) + VALUES (insert_record.entity_id, insert_record.key, insert_record.ts, insert_record.bool_v, insert_record.str_v, + insert_record.long_v, insert_record.dbl_v); + IF MOD(insert_counter, insert_size) = 0 THEN + RAISE NOTICE '% records have been inserted into the ts_kv_latest!',insert_counter; + END IF; + END LOOP; + CLOSE insert_cursor; +END; +$$; + diff --git a/application/src/main/data/upgrade/2.4.3/schema_update_timescale_ts.sql b/application/src/main/data/upgrade/2.4.3/schema_update_timescale_ts.sql new file mode 100644 index 0000000..58f69d0 --- /dev/null +++ b/application/src/main/data/upgrade/2.4.3/schema_update_timescale_ts.sql @@ -0,0 +1,208 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- call create_new_ts_kv_table(); + +CREATE OR REPLACE PROCEDURE create_new_ts_kv_table() LANGUAGE plpgsql AS $$ + +BEGIN + ALTER TABLE tenant_ts_kv + RENAME TO tenant_ts_kv_old; + CREATE TABLE IF NOT EXISTS ts_kv + ( + LIKE tenant_ts_kv_old + ); + ALTER TABLE ts_kv ALTER COLUMN entity_id TYPE uuid USING entity_id::uuid; + ALTER TABLE ts_kv ALTER COLUMN key TYPE integer USING key::integer; + ALTER INDEX ts_kv_pkey RENAME TO tenant_ts_kv_pkey_old; + ALTER INDEX idx_tenant_ts_kv RENAME TO idx_tenant_ts_kv_old; + ALTER INDEX tenant_ts_kv_ts_idx RENAME TO tenant_ts_kv_ts_idx_old; + ALTER TABLE ts_kv ADD CONSTRAINT ts_kv_pkey PRIMARY KEY(entity_id, key, ts); +-- CREATE INDEX IF NOT EXISTS ts_kv_ts_idx ON ts_kv(ts DESC); + ALTER TABLE ts_kv DROP COLUMN IF EXISTS tenant_id; +END; +$$; + + +-- call create_ts_kv_latest_table(); + +CREATE OR REPLACE PROCEDURE create_ts_kv_latest_table() LANGUAGE plpgsql AS $$ + +BEGIN + CREATE TABLE IF NOT EXISTS ts_kv_latest + ( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) + ); +END; +$$; + + +-- call create_ts_kv_dictionary_table(); + +CREATE OR REPLACE PROCEDURE create_ts_kv_dictionary_table() LANGUAGE plpgsql AS $$ + +BEGIN + CREATE TABLE IF NOT EXISTS ts_kv_dictionary + ( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) + ); +END; +$$; + +-- call insert_into_dictionary(); + +CREATE OR REPLACE PROCEDURE insert_into_dictionary() LANGUAGE plpgsql AS $$ + +DECLARE + insert_record RECORD; + key_cursor CURSOR FOR SELECT DISTINCT key + FROM tenant_ts_kv_old + ORDER BY key; +BEGIN + OPEN key_cursor; + LOOP + FETCH key_cursor INTO insert_record; + EXIT WHEN NOT FOUND; + IF NOT EXISTS(SELECT key FROM ts_kv_dictionary WHERE key = insert_record.key) THEN + INSERT INTO ts_kv_dictionary(key) VALUES (insert_record.key); + RAISE NOTICE 'Key: % has been inserted into the dictionary!',insert_record.key; + ELSE + RAISE NOTICE 'Key: % already exists in the dictionary!',insert_record.key; + END IF; + END LOOP; + CLOSE key_cursor; +END; +$$; + +CREATE OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +-- call insert_into_ts_kv(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv(IN path_to_file varchar) LANGUAGE plpgsql AS $$ +BEGIN + + EXECUTE format ('COPY (SELECT to_uuid(entity_id) AS entity_id, + new_ts_kv_records.key AS key, + new_ts_kv_records.ts AS ts, + new_ts_kv_records.bool_v AS bool_v, + new_ts_kv_records.str_v AS str_v, + new_ts_kv_records.long_v AS long_v, + new_ts_kv_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM tenant_ts_kv_old + INNER JOIN ts_kv_dictionary ON (tenant_ts_kv_old.key = ts_kv_dictionary.key)) AS new_ts_kv_records) TO %L;', path_to_file); + EXECUTE format ('COPY ts_kv FROM %L', path_to_file); +END; +$$; + +-- call insert_into_ts_kv_latest(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv_latest() LANGUAGE plpgsql AS $$ + +DECLARE + insert_size CONSTANT integer := 10000; + insert_counter integer DEFAULT 0; + latest_record RECORD; + insert_record RECORD; + insert_cursor CURSOR FOR SELECT + latest_records.key AS key, + latest_records.entity_id AS entity_id, + latest_records.ts AS ts + FROM (SELECT DISTINCT key AS key, entity_id AS entity_id, MAX(ts) AS ts FROM ts_kv GROUP BY key, entity_id) AS latest_records; +BEGIN + OPEN insert_cursor; + LOOP + insert_counter := insert_counter + 1; + FETCH insert_cursor INTO latest_record; + IF NOT FOUND THEN + RAISE NOTICE '% records have been inserted into the ts_kv_latest table!',insert_counter - 1; + EXIT; + END IF; + SELECT entity_id AS entity_id, key AS key, ts AS ts, bool_v AS bool_v, str_v AS str_v, long_v AS long_v, dbl_v AS dbl_v INTO insert_record FROM ts_kv WHERE entity_id = latest_record.entity_id AND key = latest_record.key AND ts = latest_record.ts; + INSERT INTO ts_kv_latest(entity_id, key, ts, bool_v, str_v, long_v, dbl_v) + VALUES (insert_record.entity_id, insert_record.key, insert_record.ts, insert_record.bool_v, insert_record.str_v, insert_record.long_v, insert_record.dbl_v); + IF MOD(insert_counter, insert_size) = 0 THEN + RAISE NOTICE '% records have been inserted into the ts_kv_latest table!',insert_counter; + END IF; + END LOOP; + CLOSE insert_cursor; +END; +$$; + +-- call insert_into_ts_kv_cursor(); + +CREATE OR REPLACE PROCEDURE insert_into_ts_kv_cursor() LANGUAGE plpgsql AS $$ + +DECLARE + insert_size CONSTANT integer := 10000; + insert_counter integer DEFAULT 0; + insert_record RECORD; + insert_cursor CURSOR FOR SELECT to_uuid(entity_id) AS entity_id, + new_ts_kv_records.key AS key, + new_ts_kv_records.ts AS ts, + new_ts_kv_records.bool_v AS bool_v, + new_ts_kv_records.str_v AS str_v, + new_ts_kv_records.long_v AS long_v, + new_ts_kv_records.dbl_v AS dbl_v + FROM (SELECT entity_id AS entity_id, + key_id AS key, + ts, + bool_v, + str_v, + long_v, + dbl_v + FROM tenant_ts_kv_old + INNER JOIN ts_kv_dictionary ON (tenant_ts_kv_old.key = ts_kv_dictionary.key)) AS new_ts_kv_records; +BEGIN + OPEN insert_cursor; + LOOP + insert_counter := insert_counter + 1; + FETCH insert_cursor INTO insert_record; + IF NOT FOUND THEN + RAISE NOTICE '% records have been inserted into the new ts_kv table!',insert_counter - 1; + EXIT; + END IF; + INSERT INTO ts_kv(entity_id, key, ts, bool_v, str_v, long_v, dbl_v) + VALUES (insert_record.entity_id, insert_record.key, insert_record.ts, insert_record.bool_v, insert_record.str_v, + insert_record.long_v, insert_record.dbl_v); + IF MOD(insert_counter, insert_size) = 0 THEN + RAISE NOTICE '% records have been inserted into the new ts_kv table!',insert_counter; + END IF; + END LOOP; + CLOSE insert_cursor; +END; +$$; \ No newline at end of file diff --git a/application/src/main/data/upgrade/2.4.3/schema_update_ttl.sql b/application/src/main/data/upgrade/2.4.3/schema_update_ttl.sql new file mode 100644 index 0000000..2a5ca58 --- /dev/null +++ b/application/src/main/data/upgrade/2.4.3/schema_update_ttl.sql @@ -0,0 +1,150 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_device_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT device.id as entity_id FROM device WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_asset_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT asset.id as entity_id FROM asset WHERE tenant_id = %L and customer_id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION delete_customer_records_from_ts_kv(tenant_id uuid, customer_id uuid, ttl bigint, + OUT deleted bigint) AS +$$ +BEGIN + EXECUTE format( + 'WITH deleted AS (DELETE FROM ts_kv WHERE entity_id IN (SELECT customer.id as entity_id FROM customer WHERE tenant_id = %L and id = %L) AND ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', + tenant_id, customer_id, ttl) into deleted; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE cleanup_timeseries_by_ttl(IN null_uuid uuid, + IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + tenant_cursor CURSOR FOR select tenant.id as tenant_id + from tenant; + tenant_id_record uuid; + customer_id_record uuid; + tenant_ttl bigint; + customer_ttl bigint; + deleted_for_entities bigint; + tenant_ttl_ts bigint; + customer_ttl_ts bigint; +BEGIN + OPEN tenant_cursor; + FETCH tenant_cursor INTO tenant_id_record; + WHILE FOUND + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + tenant_id_record, 'TTL') INTO tenant_ttl; + if tenant_ttl IS NULL THEN + tenant_ttl := system_ttl; + END IF; + IF tenant_ttl > 0 THEN + tenant_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - tenant_ttl::bigint * 1000)::bigint; + deleted_for_entities := delete_device_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = %', deleted_for_entities, tenant_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = %', deleted_for_entities, tenant_id_record; + END IF; + FOR customer_id_record IN + SELECT customer.id AS customer_id FROM customer WHERE customer.tenant_id = tenant_id_record + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + customer_id_record, 'TTL') INTO customer_ttl; + IF customer_ttl IS NULL THEN + customer_ttl_ts := tenant_ttl_ts; + ELSE + IF customer_ttl > 0 THEN + customer_ttl_ts := + (EXTRACT(EPOCH FROM current_timestamp) * 1000 - + customer_ttl::bigint * 1000)::bigint; + END IF; + END IF; + IF customer_ttl_ts IS NOT NULL AND customer_ttl_ts > 0 THEN + deleted_for_entities := + delete_customer_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for customer with id = % where tenant_id = %', deleted_for_entities, customer_id_record, tenant_id_record; + deleted_for_entities := + delete_device_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, + customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + END IF; + END LOOP; + FETCH tenant_cursor INTO tenant_id_record; + END LOOP; +END +$$; + +CREATE OR REPLACE PROCEDURE cleanup_events_by_ttl(IN ttl bigint, IN debug_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + ttl_ts bigint; + debug_ttl_ts bigint; + ttl_deleted_count bigint DEFAULT 0; + debug_ttl_deleted_count bigint DEFAULT 0; +BEGIN + IF ttl > 0 THEN + ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - ttl::bigint * 1000)::bigint; + EXECUTE format( + 'WITH deleted AS (DELETE FROM event WHERE ts < %L::bigint AND (event_type != %L::varchar AND event_type != %L::varchar) RETURNING *) SELECT count(*) FROM deleted', ttl_ts, 'DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN') into ttl_deleted_count; + END IF; + IF debug_ttl > 0 THEN + debug_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - debug_ttl::bigint * 1000)::bigint; + EXECUTE format( + 'WITH deleted AS (DELETE FROM event WHERE ts < %L::bigint AND (event_type = %L::varchar OR event_type = %L::varchar) RETURNING *) SELECT count(*) FROM deleted', debug_ttl_ts, 'DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN') into debug_ttl_deleted_count; + END IF; + RAISE NOTICE 'Events removed by ttl: %', ttl_deleted_count; + RAISE NOTICE 'Debug Events removed by ttl: %', debug_ttl_deleted_count; + deleted := ttl_deleted_count + debug_ttl_deleted_count; +END +$$; diff --git a/application/src/main/data/upgrade/3.0.1/schema_ts_latest.sql b/application/src/main/data/upgrade/3.0.1/schema_ts_latest.sql new file mode 100644 index 0000000..a270af1 --- /dev/null +++ b/application/src/main/data/upgrade/3.0.1/schema_ts_latest.sql @@ -0,0 +1,35 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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_latest +( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) +); + +CREATE TABLE IF NOT EXISTS ts_kv_dictionary +( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) +); \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.0.1/schema_update_to_uuid.sql b/application/src/main/data/upgrade/3.0.1/schema_update_to_uuid.sql new file mode 100644 index 0000000..01f4431 --- /dev/null +++ b/application/src/main/data/upgrade/3.0.1/schema_update_to_uuid.sql @@ -0,0 +1,878 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE FUNCTION to_uuid(IN entity_id varchar, OUT uuid_id uuid) AS +$$ +BEGIN + uuid_id := substring(entity_id, 8, 8) || '-' || substring(entity_id, 4, 4) || '-1' || substring(entity_id, 1, 3) || + '-' || substring(entity_id, 16, 4) || '-' || substring(entity_id, 20, 12); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION extract_ts(uuid UUID) RETURNS BIGINT AS +$$ +DECLARE + bytes bytea; +BEGIN + bytes := uuid_send(uuid); + RETURN + ( + ( + (get_byte(bytes, 0)::bigint << 24) | + (get_byte(bytes, 1)::bigint << 16) | + (get_byte(bytes, 2)::bigint << 8) | + (get_byte(bytes, 3)::bigint << 0) + ) + ( + ((get_byte(bytes, 4)::bigint << 8 | + get_byte(bytes, 5)::bigint)) << 32 + ) + ( + (((get_byte(bytes, 6)::bigint & 15) << 8 | get_byte(bytes, 7)::bigint) & 4095) << 48 + ) - 122192928000000000 + ) / 10000::double precision; +END +$$ LANGUAGE plpgsql + IMMUTABLE + PARALLEL SAFE + RETURNS NULL ON NULL INPUT; + + +CREATE OR REPLACE FUNCTION column_type_to_uuid(table_name varchar, column_name varchar) RETURNS VOID + LANGUAGE plpgsql AS +$$ +BEGIN + execute format('ALTER TABLE %s RENAME COLUMN %s TO old_%s;', table_name, column_name, column_name); + execute format('ALTER TABLE %s ADD COLUMN %s UUID;', table_name, column_name); + execute format('UPDATE %s SET %s = to_uuid(old_%s) WHERE old_%s is not null;', table_name, column_name, column_name, column_name); + execute format('ALTER TABLE %s DROP COLUMN old_%s;', table_name, column_name); +END; +$$; + +CREATE OR REPLACE FUNCTION get_column_type(table_name varchar, column_name varchar, OUT data_type varchar) RETURNS varchar + LANGUAGE plpgsql AS +$$ +BEGIN + execute (format('SELECT data_type from information_schema.columns where table_name = %L and column_name = %L', + table_name, column_name)) INTO data_type; +END; +$$; + + +CREATE OR REPLACE PROCEDURE drop_all_idx() + LANGUAGE plpgsql AS +$$ +BEGIN + DROP INDEX IF EXISTS idx_alarm_originator_alarm_type; + DROP INDEX IF EXISTS idx_alarm_originator_created_time; + DROP INDEX IF EXISTS idx_alarm_tenant_created_time; + DROP INDEX IF EXISTS idx_event_type_entity_id; + DROP INDEX IF EXISTS idx_relation_to_id; + DROP INDEX IF EXISTS idx_relation_from_id; + DROP INDEX IF EXISTS idx_device_customer_id; + DROP INDEX IF EXISTS idx_device_customer_id_and_type; + DROP INDEX IF EXISTS idx_device_type; + DROP INDEX IF EXISTS idx_asset_customer_id; + DROP INDEX IF EXISTS idx_asset_customer_id_and_type; + DROP INDEX IF EXISTS idx_asset_type; + DROP INDEX IF EXISTS idx_attribute_kv_by_key_and_last_update_ts; +END; +$$; + +CREATE OR REPLACE PROCEDURE create_all_idx() + LANGUAGE plpgsql AS +$$ +BEGIN + CREATE INDEX IF NOT EXISTS idx_alarm_originator_alarm_type ON alarm(originator_id, type, start_ts DESC); + CREATE INDEX IF NOT EXISTS idx_alarm_originator_created_time ON alarm(originator_id, created_time DESC); + CREATE INDEX IF NOT EXISTS idx_alarm_tenant_created_time ON alarm(tenant_id, created_time DESC); + CREATE INDEX IF NOT EXISTS idx_event_type_entity_id ON event(tenant_id, event_type, entity_type, entity_id); + CREATE INDEX IF NOT EXISTS idx_relation_to_id ON relation(relation_type_group, to_type, to_id); + CREATE INDEX IF NOT EXISTS idx_relation_from_id ON relation(relation_type_group, from_type, from_id); + CREATE INDEX IF NOT EXISTS idx_device_customer_id ON device(tenant_id, customer_id); + CREATE INDEX IF NOT EXISTS idx_device_customer_id_and_type ON device(tenant_id, customer_id, type); + CREATE INDEX IF NOT EXISTS idx_device_type ON device(tenant_id, type); + CREATE INDEX IF NOT EXISTS idx_asset_customer_id ON asset(tenant_id, customer_id); + CREATE INDEX IF NOT EXISTS idx_asset_customer_id_and_type ON asset(tenant_id, customer_id, type); + CREATE INDEX IF NOT EXISTS idx_asset_type ON asset(tenant_id, type); + CREATE INDEX IF NOT EXISTS idx_attribute_kv_by_key_and_last_update_ts ON attribute_kv(entity_id, attribute_key, last_update_ts desc); +END; +$$; + + +-- admin_settings +CREATE OR REPLACE PROCEDURE update_admin_settings() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'admin_settings'; + column_id varchar := 'id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE admin_settings DROP CONSTRAINT admin_settings_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE admin_settings ADD CONSTRAINT admin_settings_pkey PRIMARY KEY (id); + ALTER TABLE admin_settings ADD COLUMN created_time BIGINT; + UPDATE admin_settings SET created_time = extract_ts(id) WHERE id is not null; + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; +END; +$$; + + +-- alarm +CREATE OR REPLACE PROCEDURE update_alarm() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'alarm'; + column_id varchar := 'id'; + column_originator_id varchar := 'originator_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE alarm DROP CONSTRAINT alarm_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE alarm ADD COLUMN created_time BIGINT; + UPDATE alarm SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE alarm ADD CONSTRAINT alarm_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_originator_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_originator_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_originator_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_originator_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- asset +CREATE OR REPLACE PROCEDURE update_asset() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'asset'; + column_id varchar := 'id'; + column_customer_id varchar := 'customer_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE asset DROP CONSTRAINT asset_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE asset ADD COLUMN created_time BIGINT; + UPDATE asset SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE asset ADD CONSTRAINT asset_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_customer_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_customer_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_customer_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_customer_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + ALTER TABLE asset DROP CONSTRAINT asset_name_unq_key; + PERFORM column_type_to_uuid(table_name, column_tenant_id); + ALTER TABLE asset ADD CONSTRAINT asset_name_unq_key UNIQUE (tenant_id, name); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; + END; +$$; + +-- attribute_kv +CREATE OR REPLACE PROCEDURE update_attribute_kv() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'attribute_kv'; + column_entity_id varchar := 'entity_id'; +BEGIN + data_type := get_column_type(table_name, column_entity_id); + IF data_type = 'character varying' THEN + ALTER TABLE attribute_kv DROP CONSTRAINT attribute_kv_pkey; + PERFORM column_type_to_uuid(table_name, column_entity_id); + ALTER TABLE attribute_kv ADD CONSTRAINT attribute_kv_pkey PRIMARY KEY (entity_type, entity_id, attribute_type, attribute_key); + RAISE NOTICE 'Table % column % updated!', table_name, column_entity_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_entity_id; + END IF; +END; +$$; + +-- audit_log +CREATE OR REPLACE PROCEDURE update_audit_log() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'audit_log'; + column_id varchar := 'id'; + column_customer_id varchar := 'customer_id'; + column_tenant_id varchar := 'tenant_id'; + column_entity_id varchar := 'entity_id'; + column_user_id varchar := 'user_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE audit_log DROP CONSTRAINT audit_log_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE audit_log ADD COLUMN created_time BIGINT; + UPDATE audit_log SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE audit_log ADD CONSTRAINT audit_log_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_customer_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_customer_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_customer_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_customer_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; + + data_type := get_column_type(table_name, column_entity_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_entity_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_entity_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_entity_id; + END IF; + + data_type := get_column_type(table_name, column_user_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_user_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_user_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_user_id; + END IF; +END; +$$; + + +-- component_descriptor +CREATE OR REPLACE PROCEDURE update_component_descriptor() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'component_descriptor'; + column_id varchar := 'id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE component_descriptor DROP CONSTRAINT component_descriptor_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE component_descriptor ADD CONSTRAINT component_descriptor_pkey PRIMARY KEY (id); + ALTER TABLE component_descriptor ADD COLUMN created_time BIGINT; + UPDATE component_descriptor SET created_time = extract_ts(id) WHERE id is not null; + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; +END; +$$; + +-- customer +CREATE OR REPLACE PROCEDURE update_customer() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'customer'; + column_id varchar := 'id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE customer DROP CONSTRAINT customer_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE customer ADD CONSTRAINT customer_pkey PRIMARY KEY (id); + ALTER TABLE customer ADD COLUMN created_time BIGINT; + UPDATE customer SET created_time = extract_ts(id) WHERE id is not null; + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- dashboard +CREATE OR REPLACE PROCEDURE update_dashboard() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'dashboard'; + column_id varchar := 'id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE dashboard DROP CONSTRAINT dashboard_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE dashboard ADD CONSTRAINT dashboard_pkey PRIMARY KEY (id); + ALTER TABLE dashboard ADD COLUMN created_time BIGINT; + UPDATE dashboard SET created_time = extract_ts(id) WHERE id is not null; + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + +-- device +CREATE OR REPLACE PROCEDURE update_device() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'device'; + column_id varchar := 'id'; + column_customer_id varchar := 'customer_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE device DROP CONSTRAINT device_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE device ADD COLUMN created_time BIGINT; + UPDATE device SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE device ADD CONSTRAINT device_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_customer_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_customer_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_customer_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_customer_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + ALTER TABLE device DROP CONSTRAINT device_name_unq_key; + PERFORM column_type_to_uuid(table_name, column_tenant_id); + ALTER TABLE device ADD CONSTRAINT device_name_unq_key UNIQUE (tenant_id, name); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- device_credentials +CREATE OR REPLACE PROCEDURE update_device_credentials() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'device_credentials'; + column_id varchar := 'id'; + column_device_id varchar := 'device_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE device_credentials DROP CONSTRAINT device_credentials_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE device_credentials ADD COLUMN created_time BIGINT; + UPDATE device_credentials SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE device_credentials ADD CONSTRAINT device_credentials_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_device_id); + IF data_type = 'character varying' THEN + ALTER TABLE device_credentials DROP CONSTRAINT IF EXISTS device_credentials_device_id_unq_key; + PERFORM column_type_to_uuid(table_name, column_device_id); + -- remove duplicate credentials with same device_id + DELETE from device_credentials where id in ( + select dc.id + from ( + SELECT id, device_id, + ROW_NUMBER() OVER ( + PARTITION BY + device_id + ORDER BY + created_time DESC + ) row_num + FROM + device_credentials + ) as dc + WHERE dc.row_num > 1 + ); + ALTER TABLE device_credentials ADD CONSTRAINT device_credentials_device_id_unq_key UNIQUE (device_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_device_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_device_id; + END IF; +END; +$$; + + +-- event +CREATE OR REPLACE PROCEDURE update_event() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'event'; + column_id varchar := 'id'; + column_entity_id varchar := 'entity_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE event DROP CONSTRAINT event_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE event ADD COLUMN created_time BIGINT; + UPDATE event SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE event ADD CONSTRAINT event_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + ALTER TABLE event DROP CONSTRAINT event_unq_key; + + data_type := get_column_type(table_name, column_entity_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_entity_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_entity_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_entity_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; + + ALTER TABLE event ADD CONSTRAINT event_unq_key UNIQUE (tenant_id, entity_type, entity_id, event_type, event_uid); +END; +$$; + + +-- relation +CREATE OR REPLACE PROCEDURE update_relation() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'relation'; + column_from_id varchar := 'from_id'; + column_to_id varchar := 'to_id'; +BEGIN + ALTER TABLE relation DROP CONSTRAINT relation_pkey; + + data_type := get_column_type(table_name, column_from_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_from_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_from_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_from_id; + END IF; + + data_type := get_column_type(table_name, column_to_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_to_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_to_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_to_id; + END IF; + + ALTER TABLE relation ADD CONSTRAINT relation_pkey PRIMARY KEY (from_id, from_type, relation_type_group, relation_type, to_id, to_type); +END; +$$; + + +-- tb_user +CREATE OR REPLACE PROCEDURE update_tb_user() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'tb_user'; + column_id varchar := 'id'; + column_customer_id varchar := 'customer_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE tb_user DROP CONSTRAINT tb_user_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE tb_user ADD COLUMN created_time BIGINT; + UPDATE tb_user SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE tb_user ADD CONSTRAINT tb_user_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_customer_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_customer_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_customer_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_customer_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- tenant +CREATE OR REPLACE PROCEDURE update_tenant() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'tenant'; + column_id varchar := 'id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE tenant DROP CONSTRAINT tenant_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE tenant ADD COLUMN created_time BIGINT; + UPDATE tenant SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE tenant ADD CONSTRAINT tenant_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; +END; +$$; + + +-- user_credentials +CREATE OR REPLACE PROCEDURE update_user_credentials() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'user_credentials'; + column_id varchar := 'id'; + column_user_id varchar := 'user_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE user_credentials DROP CONSTRAINT user_credentials_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE user_credentials ADD COLUMN created_time BIGINT; + UPDATE user_credentials SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE user_credentials ADD CONSTRAINT user_credentials_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_user_id); + IF data_type = 'character varying' THEN + ALTER TABLE user_credentials DROP CONSTRAINT user_credentials_user_id_key; + ALTER TABLE user_credentials RENAME COLUMN user_id TO old_user_id; + ALTER TABLE user_credentials ADD COLUMN user_id UUID UNIQUE; + UPDATE user_credentials SET user_id = to_uuid(old_user_id) WHERE old_user_id is not null; + ALTER TABLE user_credentials DROP COLUMN old_user_id; + RAISE NOTICE 'Table % column % updated!', table_name, column_user_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_user_id; + END IF; +END; +$$; + + +-- widget_type +CREATE OR REPLACE PROCEDURE update_widget_type() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'widget_type'; + column_id varchar := 'id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE widget_type DROP CONSTRAINT widget_type_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE widget_type ADD COLUMN created_time BIGINT; + UPDATE widget_type SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE widget_type ADD CONSTRAINT widget_type_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- widgets_bundle +CREATE OR REPLACE PROCEDURE update_widgets_bundle() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'widgets_bundle'; + column_id varchar := 'id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE widgets_bundle DROP CONSTRAINT widgets_bundle_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE widgets_bundle ADD COLUMN created_time BIGINT; + UPDATE widgets_bundle SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE widgets_bundle ADD CONSTRAINT widgets_bundle_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- rule_chain +CREATE OR REPLACE PROCEDURE update_rule_chain() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'rule_chain'; + column_id varchar := 'id'; + column_first_rule_node_id varchar := 'first_rule_node_id'; + column_tenant_id varchar := 'tenant_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE rule_chain DROP CONSTRAINT rule_chain_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE rule_chain ADD COLUMN created_time BIGINT; + UPDATE rule_chain SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE rule_chain ADD CONSTRAINT rule_chain_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_first_rule_node_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_first_rule_node_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_first_rule_node_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_first_rule_node_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; +END; +$$; + + +-- rule_node +CREATE OR REPLACE PROCEDURE update_rule_node() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'rule_node'; + column_id varchar := 'id'; + column_rule_chain_id varchar := 'rule_chain_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE rule_node DROP CONSTRAINT rule_node_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE rule_node ADD COLUMN created_time BIGINT; + UPDATE rule_node SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE rule_node ADD CONSTRAINT rule_node_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_rule_chain_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_rule_chain_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_rule_chain_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_rule_chain_id; + END IF; +END; +$$; + + +-- entity_view +CREATE OR REPLACE PROCEDURE update_entity_view() + LANGUAGE plpgsql AS +$$ +DECLARE + data_type varchar; + table_name varchar := 'entity_view'; + column_id varchar := 'id'; + column_entity_id varchar := 'entity_id'; + column_tenant_id varchar := 'tenant_id'; + column_customer_id varchar := 'customer_id'; +BEGIN + data_type := get_column_type(table_name, column_id); + IF data_type = 'character varying' THEN + ALTER TABLE entity_view DROP CONSTRAINT entity_view_pkey; + PERFORM column_type_to_uuid(table_name, column_id); + ALTER TABLE entity_view ADD COLUMN created_time BIGINT; + UPDATE entity_view SET created_time = extract_ts(id) WHERE id is not null; + ALTER TABLE entity_view ADD CONSTRAINT entity_view_pkey PRIMARY KEY (id); + RAISE NOTICE 'Table % column % updated!', table_name, column_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_id; + END IF; + + data_type := get_column_type(table_name, column_entity_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_entity_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_entity_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_entity_id; + END IF; + + data_type := get_column_type(table_name, column_tenant_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_tenant_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_tenant_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_tenant_id; + END IF; + + data_type := get_column_type(table_name, column_customer_id); + IF data_type = 'character varying' THEN + PERFORM column_type_to_uuid(table_name, column_customer_id); + RAISE NOTICE 'Table % column % updated!', table_name, column_customer_id; + ELSE + RAISE NOTICE 'Table % column % already updated!', table_name, column_customer_id; + END IF; +END; +$$; + +CREATE TABLE IF NOT EXISTS ts_kv_latest +( + entity_id uuid NOT NULL, + key int NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + json_v json, + CONSTRAINT ts_kv_latest_pkey PRIMARY KEY (entity_id, key) +); + +CREATE TABLE IF NOT EXISTS ts_kv_dictionary +( + key varchar(255) NOT NULL, + key_id serial UNIQUE, + CONSTRAINT ts_key_id_pkey PRIMARY KEY (key) +); diff --git a/application/src/main/data/upgrade/3.1.0/schema_update.sql b/application/src/main/data/upgrade/3.1.0/schema_update.sql new file mode 100644 index 0000000..a23150c --- /dev/null +++ b/application/src/main/data/upgrade/3.1.0/schema_update.sql @@ -0,0 +1,17 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 INDEX IF NOT EXISTS idx_alarm_tenant_alarm_type_created_time ON alarm(tenant_id, type, created_time DESC); diff --git a/application/src/main/data/upgrade/3.1.1/schema_update_after.sql b/application/src/main/data/upgrade/3.1.1/schema_update_after.sql new file mode 100644 index 0000000..75cb865 --- /dev/null +++ b/application/src/main/data/upgrade/3.1.1/schema_update_after.sql @@ -0,0 +1,28 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 PROCEDURE IF EXISTS update_tenant_profiles; +DROP PROCEDURE IF EXISTS update_device_profiles; + +ALTER TABLE tenant ALTER COLUMN tenant_profile_id SET NOT NULL; +ALTER TABLE tenant DROP CONSTRAINT IF EXISTS fk_tenant_profile; +ALTER TABLE tenant ADD CONSTRAINT fk_tenant_profile FOREIGN KEY (tenant_profile_id) REFERENCES tenant_profile(id); +ALTER TABLE tenant DROP COLUMN IF EXISTS isolated_tb_core; +ALTER TABLE tenant DROP COLUMN IF EXISTS isolated_tb_rule_engine; + +ALTER TABLE device ALTER COLUMN device_profile_id SET NOT NULL; +ALTER TABLE device DROP CONSTRAINT IF EXISTS fk_device_profile; +ALTER TABLE device ADD CONSTRAINT fk_device_profile FOREIGN KEY (device_profile_id) REFERENCES device_profile(id); diff --git a/application/src/main/data/upgrade/3.1.1/schema_update_before.sql b/application/src/main/data/upgrade/3.1.1/schema_update_before.sql new file mode 100644 index 0000000..0d74063 --- /dev/null +++ b/application/src/main/data/upgrade/3.1.1/schema_update_before.sql @@ -0,0 +1,154 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 oauth2_client_registration_info ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_info_pkey PRIMARY KEY, + enabled boolean, + created_time bigint NOT NULL, + additional_info varchar, + client_id varchar(255), + client_secret varchar(255), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + login_button_label varchar(255), + login_button_icon varchar(255), + allow_user_creation boolean, + activate_user boolean, + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + custom_url varchar(255), + custom_username varchar(255), + custom_password varchar(255), + custom_send_token boolean +); + +CREATE TABLE IF NOT EXISTS oauth2_client_registration ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_pkey PRIMARY KEY, + created_time bigint NOT NULL, + domain_name varchar(255), + domain_scheme varchar(31), + client_registration_info_id uuid +); + +CREATE TABLE IF NOT EXISTS oauth2_client_registration_template ( + id uuid NOT NULL CONSTRAINT oauth2_client_registration_template_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + provider_id varchar(255), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + comment varchar, + login_button_icon varchar(255), + login_button_label varchar(255), + help_link varchar(255), + CONSTRAINT oauth2_template_provider_id_unq_key UNIQUE (provider_id) +); + +CREATE TABLE IF NOT EXISTS device_profile ( + id uuid NOT NULL CONSTRAINT device_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + type varchar(255), + transport_type varchar(255), + provision_type varchar(255), + profile_data jsonb, + description varchar, + search_text varchar(255), + is_default boolean, + tenant_id uuid, + default_rule_chain_id uuid, + default_queue_name varchar(255), + provision_device_key varchar, + CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), + CONSTRAINT fk_default_rule_chain_device_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id) +); + +CREATE TABLE IF NOT EXISTS tenant_profile ( + id uuid NOT NULL CONSTRAINT tenant_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + profile_data jsonb, + description varchar, + search_text varchar(255), + is_default boolean, + isolated_tb_core boolean, + isolated_tb_rule_engine boolean, + CONSTRAINT tenant_profile_name_unq_key UNIQUE (name) +); + +CREATE OR REPLACE PROCEDURE update_tenant_profiles() + LANGUAGE plpgsql AS +$$ +BEGIN + UPDATE tenant as t SET tenant_profile_id = p.id + FROM + (SELECT id from tenant_profile WHERE isolated_tb_core = false AND isolated_tb_rule_engine = false) as p + WHERE t.tenant_profile_id IS NULL AND t.isolated_tb_core = false AND t.isolated_tb_rule_engine = false; + + UPDATE tenant as t SET tenant_profile_id = p.id + FROM + (SELECT id from tenant_profile WHERE isolated_tb_core = true AND isolated_tb_rule_engine = false) as p + WHERE t.tenant_profile_id IS NULL AND t.isolated_tb_core = true AND t.isolated_tb_rule_engine = false; + + UPDATE tenant as t SET tenant_profile_id = p.id + FROM + (SELECT id from tenant_profile WHERE isolated_tb_core = false AND isolated_tb_rule_engine = true) as p + WHERE t.tenant_profile_id IS NULL AND t.isolated_tb_core = false AND t.isolated_tb_rule_engine = true; + + UPDATE tenant as t SET tenant_profile_id = p.id + FROM + (SELECT id from tenant_profile WHERE isolated_tb_core = true AND isolated_tb_rule_engine = true) as p + WHERE t.tenant_profile_id IS NULL AND t.isolated_tb_core = true AND t.isolated_tb_rule_engine = true; +END; +$$; + +CREATE OR REPLACE PROCEDURE update_device_profiles() + LANGUAGE plpgsql AS +$$ +BEGIN + UPDATE device as d SET device_profile_id = p.id, device_data = '{"configuration":{"type":"DEFAULT"}, "transportConfiguration":{"type":"DEFAULT"}}' + FROM + (SELECT id, tenant_id, name from device_profile) as p + WHERE d.device_profile_id IS NULL AND p.tenant_id = d.tenant_id AND d.type = p.name; +END; +$$; diff --git a/application/src/main/data/upgrade/3.2.1/schema_update.sql b/application/src/main/data/upgrade/3.2.1/schema_update.sql new file mode 100644 index 0000000..04dc38a --- /dev/null +++ b/application/src/main/data/upgrade/3.2.1/schema_update.sql @@ -0,0 +1,23 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 widget_type + ADD COLUMN IF NOT EXISTS image varchar (1000000), + ADD COLUMN IF NOT EXISTS description varchar (255); + +ALTER TABLE widgets_bundle + ADD COLUMN IF NOT EXISTS image varchar (1000000), + ADD COLUMN IF NOT EXISTS description varchar (255); diff --git a/application/src/main/data/upgrade/3.2.1/schema_update_ttl.sql b/application/src/main/data/upgrade/3.2.1/schema_update_ttl.sql new file mode 100644 index 0000000..cecbf5a --- /dev/null +++ b/application/src/main/data/upgrade/3.2.1/schema_update_ttl.sql @@ -0,0 +1,87 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE PROCEDURE cleanup_timeseries_by_ttl(IN null_uuid uuid, + IN system_ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE +tenant_cursor CURSOR FOR select tenant.id as tenant_id + from tenant; + tenant_id_record uuid; + customer_id_record uuid; + tenant_ttl bigint; + customer_ttl bigint; + deleted_for_entities bigint; + tenant_ttl_ts bigint; + customer_ttl_ts bigint; +BEGIN +OPEN tenant_cursor; +FETCH tenant_cursor INTO tenant_id_record; +WHILE FOUND + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + tenant_id_record, 'TTL') INTO tenant_ttl; + if tenant_ttl IS NULL THEN + tenant_ttl := system_ttl; +END IF; + IF tenant_ttl > 0 THEN + tenant_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - tenant_ttl::bigint * 1000)::bigint; + deleted_for_entities := delete_device_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = %', deleted_for_entities, tenant_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, null_uuid, tenant_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = %', deleted_for_entities, tenant_id_record; +END IF; +FOR customer_id_record IN +SELECT customer.id AS customer_id FROM customer WHERE customer.tenant_id = tenant_id_record + LOOP + EXECUTE format( + 'select attribute_kv.long_v from attribute_kv where attribute_kv.entity_id = %L and attribute_kv.attribute_key = %L', + customer_id_record, 'TTL') INTO customer_ttl; +IF customer_ttl IS NULL THEN + customer_ttl_ts := tenant_ttl_ts; +ELSE + IF customer_ttl > 0 THEN + customer_ttl_ts := + (EXTRACT(EPOCH FROM current_timestamp) * 1000 - + customer_ttl::bigint * 1000)::bigint; +END IF; +END IF; + IF customer_ttl_ts IS NOT NULL AND customer_ttl_ts > 0 THEN + deleted_for_entities := + delete_customer_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for customer with id = % where tenant_id = %', deleted_for_entities, customer_id_record, tenant_id_record; + deleted_for_entities := + delete_device_records_from_ts_kv(tenant_id_record, customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for devices where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; + deleted_for_entities := delete_asset_records_from_ts_kv(tenant_id_record, + customer_id_record, + customer_ttl_ts); + deleted := deleted + deleted_for_entities; + RAISE NOTICE '% telemetry removed for assets where tenant_id = % and customer_id = %', deleted_for_entities, tenant_id_record, customer_id_record; +END IF; +END LOOP; +FETCH tenant_cursor INTO tenant_id_record; +END LOOP; +END +$$; diff --git a/application/src/main/data/upgrade/3.2.2/schema_update.sql b/application/src/main/data/upgrade/3.2.2/schema_update.sql new file mode 100644 index 0000000..8685bf3 --- /dev/null +++ b/application/src/main/data/upgrade/3.2.2/schema_update.sql @@ -0,0 +1,216 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 edge ( + id uuid NOT NULL CONSTRAINT edge_pkey PRIMARY KEY, + created_time bigint NOT NULL, + additional_info varchar, + customer_id uuid, + root_rule_chain_id uuid, + type varchar(255), + name varchar(255), + label varchar(255), + routing_key varchar(255), + secret varchar(255), + edge_license_key varchar(30), + cloud_endpoint varchar(255), + search_text varchar(255), + tenant_id uuid, + CONSTRAINT edge_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT edge_routing_key_unq_key UNIQUE (routing_key) + ); + +CREATE TABLE IF NOT EXISTS edge_event ( + id uuid NOT NULL CONSTRAINT edge_event_pkey PRIMARY KEY, + created_time bigint NOT NULL, + edge_id uuid, + edge_event_type varchar(255), + edge_event_uid varchar(255), + entity_id uuid, + edge_event_action varchar(255), + body varchar(10000000), + tenant_id uuid, + ts bigint NOT NULL + ); + +CREATE TABLE IF NOT EXISTS resource ( + id uuid NOT NULL CONSTRAINT resource_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + title varchar(255) NOT NULL, + resource_type varchar(32) NOT NULL, + resource_key varchar(255) NOT NULL, + search_text varchar(255), + file_name varchar(255) NOT NULL, + data varchar, + CONSTRAINT resource_unq_key UNIQUE (tenant_id, resource_type, resource_key) +); + +CREATE TABLE IF NOT EXISTS ota_package ( + id uuid NOT NULL CONSTRAINT ota_package_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + device_profile_id uuid, + type varchar(32) NOT NULL, + title varchar(255) NOT NULL, + version varchar(255) NOT NULL, + tag varchar(255), + url varchar(255), + file_name varchar(255), + content_type varchar(255), + checksum_algorithm varchar(32), + checksum varchar(1020), + data oid, + data_size bigint, + additional_info varchar, + search_text varchar(255), + CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version) +); + +CREATE TABLE IF NOT EXISTS oauth2_params ( + id uuid NOT NULL CONSTRAINT oauth2_params_pkey PRIMARY KEY, + enabled boolean, + tenant_id uuid, + created_time bigint NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth2_registration ( + id uuid NOT NULL CONSTRAINT oauth2_registration_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + additional_info varchar, + client_id varchar(255), + client_secret varchar(2048), + authorization_uri varchar(255), + token_uri varchar(255), + scope varchar(255), + platforms varchar(255), + user_info_uri varchar(255), + user_name_attribute_name varchar(255), + jwk_set_uri varchar(255), + client_authentication_method varchar(255), + login_button_label varchar(255), + login_button_icon varchar(255), + allow_user_creation boolean, + activate_user boolean, + type varchar(31), + basic_email_attribute_key varchar(31), + basic_first_name_attribute_key varchar(31), + basic_last_name_attribute_key varchar(31), + basic_tenant_name_strategy varchar(31), + basic_tenant_name_pattern varchar(255), + basic_customer_name_pattern varchar(255), + basic_default_dashboard_name varchar(255), + basic_always_full_screen boolean, + custom_url varchar(255), + custom_username varchar(255), + custom_password varchar(255), + custom_send_token boolean, + CONSTRAINT fk_registration_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS oauth2_domain ( + id uuid NOT NULL CONSTRAINT oauth2_domain_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + domain_name varchar(255), + domain_scheme varchar(31), + CONSTRAINT fk_domain_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, + CONSTRAINT oauth2_domain_unq_key UNIQUE (oauth2_params_id, domain_name, domain_scheme) +); + +CREATE TABLE IF NOT EXISTS oauth2_mobile ( + id uuid NOT NULL CONSTRAINT oauth2_mobile_pkey PRIMARY KEY, + oauth2_params_id uuid NOT NULL, + created_time bigint NOT NULL, + pkg_name varchar(255), + app_secret varchar(2048), + CONSTRAINT fk_mobile_oauth2_params FOREIGN KEY (oauth2_params_id) REFERENCES oauth2_params(id) ON DELETE CASCADE, + CONSTRAINT oauth2_mobile_unq_key UNIQUE (oauth2_params_id, pkg_name) +); + +ALTER TABLE dashboard + ADD COLUMN IF NOT EXISTS image varchar(1000000), + ADD COLUMN IF NOT EXISTS mobile_hide boolean DEFAULT false, + ADD COLUMN IF NOT EXISTS mobile_order int; + +ALTER TABLE device_profile + ADD COLUMN IF NOT EXISTS image varchar(1000000), + ADD COLUMN IF NOT EXISTS firmware_id uuid, + ADD COLUMN IF NOT EXISTS software_id uuid, + ADD COLUMN IF NOT EXISTS default_dashboard_id uuid; + +ALTER TABLE device + ADD COLUMN IF NOT EXISTS firmware_id uuid, + ADD COLUMN IF NOT EXISTS software_id uuid; + +ALTER TABLE alarm + ADD COLUMN IF NOT EXISTS customer_id uuid; + +DELETE FROM relation WHERE from_type = 'TENANT' AND relation_type_group = 'RULE_CHAIN'; + +DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_firmware_device_profile') THEN + ALTER TABLE device_profile + ADD CONSTRAINT fk_firmware_device_profile + FOREIGN KEY (firmware_id) REFERENCES ota_package(id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_software_device_profile') THEN + ALTER TABLE device_profile + ADD CONSTRAINT fk_software_device_profile + FOREIGN KEY (firmware_id) REFERENCES ota_package(id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_default_dashboard_device_profile') THEN + ALTER TABLE device_profile + ADD CONSTRAINT fk_default_dashboard_device_profile + FOREIGN KEY (default_dashboard_id) REFERENCES dashboard(id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_firmware_device') THEN + ALTER TABLE device + ADD CONSTRAINT fk_firmware_device + FOREIGN KEY (firmware_id) REFERENCES ota_package(id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_software_device') THEN + ALTER TABLE device + ADD CONSTRAINT fk_software_device + FOREIGN KEY (firmware_id) REFERENCES ota_package(id); + END IF; + END; +$$; + + +ALTER TABLE api_usage_state + ADD COLUMN IF NOT EXISTS alarm_exec VARCHAR(32); +UPDATE api_usage_state SET alarm_exec = 'ENABLED' WHERE alarm_exec IS NULL; + +CREATE TABLE IF NOT EXISTS rpc ( + id uuid NOT NULL CONSTRAINT rpc_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + device_id uuid NOT NULL, + expiration_time bigint NOT NULL, + request varchar(10000000) NOT NULL, + response varchar(10000000), + additional_info varchar(10000000), + status varchar(255) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rpc_tenant_id_device_id ON rpc(tenant_id, device_id); diff --git a/application/src/main/data/upgrade/3.2.2/schema_update_event.sql b/application/src/main/data/upgrade/3.2.2/schema_update_event.sql new file mode 100644 index 0000000..9e2e6cf --- /dev/null +++ b/application/src/main/data/upgrade/3.2.2/schema_update_event.sql @@ -0,0 +1,90 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- PROCEDURE: public.cleanup_events_by_ttl(bigint, bigint, bigint) + +DROP PROCEDURE IF EXISTS public.cleanup_events_by_ttl(bigint, bigint, bigint); + +CREATE OR REPLACE PROCEDURE public.cleanup_events_by_ttl( + ttl bigint, + debug_ttl bigint, + INOUT deleted bigint) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + ttl_ts bigint; + debug_ttl_ts bigint; + ttl_deleted_count bigint DEFAULT 0; + debug_ttl_deleted_count bigint DEFAULT 0; +BEGIN + IF ttl > 0 THEN + ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - ttl::bigint * 1000)::bigint; + + DELETE FROM event + WHERE ts < ttl_ts + AND NOT event_type IN ('DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN', 'DEBUG_CONVERTER', 'DEBUG_INTEGRATION'); + + GET DIAGNOSTICS ttl_deleted_count = ROW_COUNT; + END IF; + + IF debug_ttl > 0 THEN + debug_ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - debug_ttl::bigint * 1000)::bigint; + + DELETE FROM event + WHERE ts < debug_ttl_ts + AND event_type IN ('DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN', 'DEBUG_CONVERTER', 'DEBUG_INTEGRATION'); + + GET DIAGNOSTICS debug_ttl_deleted_count = ROW_COUNT; + END IF; + + RAISE NOTICE 'Events removed by ttl: %', ttl_deleted_count; + RAISE NOTICE 'Debug Events removed by ttl: %', debug_ttl_deleted_count; + deleted := ttl_deleted_count + debug_ttl_deleted_count; +END +$BODY$; + + +-- Index: idx_event_ts + +DROP INDEX IF EXISTS public.idx_event_ts; + +-- Hint: add CONCURRENTLY to CREATE INDEX query in case of more then 1 million records or during live update +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_event_ts +CREATE INDEX IF NOT EXISTS idx_event_ts + ON public.event + (ts DESC NULLS LAST) + WITH (FILLFACTOR=95); + +COMMENT ON INDEX public.idx_event_ts + IS 'This index helps to delete events by TTL using timestamp'; + + +-- Index: idx_event_tenant_entity_type_entity_event_type_created_time_des + +DROP INDEX IF EXISTS public.idx_event_tenant_entity_type_entity_event_type_created_time_des; + +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_event_tenant_entity_type_entity_event_type_created_time_des +CREATE INDEX IF NOT EXISTS idx_event_tenant_entity_type_entity_event_type_created_time_des + ON public.event + (tenant_id ASC, entity_type ASC, entity_id ASC, event_type ASC, created_time DESC NULLS LAST) + WITH (FILLFACTOR=95); + +COMMENT ON INDEX public.idx_event_tenant_entity_type_entity_event_type_created_time_des + IS 'This index helps to open latest events on UI fast'; + +-- Index: idx_event_type_entity_id +-- Description: replaced with more suitable idx_event_tenant_entity_type_entity_event_type_created_time_des +DROP INDEX IF EXISTS public.idx_event_type_entity_id; \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.2.2/schema_update_ttl.sql b/application/src/main/data/upgrade/3.2.2/schema_update_ttl.sql new file mode 100644 index 0000000..42a7e8b --- /dev/null +++ b/application/src/main/data/upgrade/3.2.2/schema_update_ttl.sql @@ -0,0 +1,32 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE PROCEDURE cleanup_edge_events_by_ttl(IN ttl bigint, INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + ttl_ts bigint; + ttl_deleted_count bigint DEFAULT 0; +BEGIN + IF ttl > 0 THEN + ttl_ts := (EXTRACT(EPOCH FROM current_timestamp) * 1000 - ttl::bigint * 1000)::bigint; + EXECUTE format( + 'WITH deleted AS (DELETE FROM edge_event WHERE ts < %L::bigint RETURNING *) SELECT count(*) FROM deleted', ttl_ts) into ttl_deleted_count; + END IF; + RAISE NOTICE 'Edge events removed by ttl: %', ttl_deleted_count; + deleted := ttl_deleted_count; +END +$$; diff --git a/application/src/main/data/upgrade/3.3.2/schema_update.sql b/application/src/main/data/upgrade/3.3.2/schema_update.sql new file mode 100644 index 0000000..5501c35 --- /dev/null +++ b/application/src/main/data/upgrade/3.3.2/schema_update.sql @@ -0,0 +1,71 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 entity_alarm ( + tenant_id uuid NOT NULL, + entity_type varchar(32), + entity_id uuid NOT NULL, + created_time bigint NOT NULL, + alarm_type varchar(255) NOT NULL, + customer_id uuid, + alarm_id uuid, + CONSTRAINT entity_alarm_pkey PRIMARY KEY (entity_id, alarm_id), + CONSTRAINT fk_entity_alarm_id FOREIGN KEY (alarm_id) REFERENCES alarm(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_alarm_tenant_status_created_time ON alarm(tenant_id, status, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_entity_alarm_created_time ON entity_alarm(tenant_id, entity_id, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_entity_alarm_alarm_id ON entity_alarm(alarm_id); + +INSERT INTO entity_alarm(tenant_id, entity_type, entity_id, created_time, alarm_type, customer_id, alarm_id) +SELECT tenant_id, + CASE + WHEN originator_type = 0 THEN 'TENANT' + WHEN originator_type = 1 THEN 'CUSTOMER' + WHEN originator_type = 2 THEN 'USER' + WHEN originator_type = 3 THEN 'DASHBOARD' + WHEN originator_type = 4 THEN 'ASSET' + WHEN originator_type = 5 THEN 'DEVICE' + WHEN originator_type = 6 THEN 'ALARM' + WHEN originator_type = 7 THEN 'RULE_CHAIN' + WHEN originator_type = 8 THEN 'RULE_NODE' + WHEN originator_type = 9 THEN 'ENTITY_VIEW' + WHEN originator_type = 10 THEN 'WIDGETS_BUNDLE' + WHEN originator_type = 11 THEN 'WIDGET_TYPE' + WHEN originator_type = 12 THEN 'TENANT_PROFILE' + WHEN originator_type = 13 THEN 'DEVICE_PROFILE' + WHEN originator_type = 14 THEN 'API_USAGE_STATE' + WHEN originator_type = 15 THEN 'TB_RESOURCE' + WHEN originator_type = 16 THEN 'OTA_PACKAGE' + WHEN originator_type = 17 THEN 'EDGE' + WHEN originator_type = 18 THEN 'RPC' + else 'UNKNOWN' + END, + originator_id, + created_time, + type, + customer_id, + id +FROM alarm +ON CONFLICT DO NOTHING; + +INSERT INTO entity_alarm(tenant_id, entity_type, entity_id, created_time, alarm_type, customer_id, alarm_id) +SELECT a.tenant_id, r.from_type, r.from_id, created_time, type, customer_id, id +FROM alarm a + INNER JOIN relation r ON r.relation_type_group = 'ALARM' and r.relation_type = 'ANY' and a.id = r.to_id +ON CONFLICT DO NOTHING; + +DELETE FROM relation r WHERE r.relation_type_group = 'ALARM'; \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.3.2/schema_update_lwm2m_bootstrap.sql b/application/src/main/data/upgrade/3.3.2/schema_update_lwm2m_bootstrap.sql new file mode 100644 index 0000000..dec4f26 --- /dev/null +++ b/application/src/main/data/upgrade/3.3.2/schema_update_lwm2m_bootstrap.sql @@ -0,0 +1,213 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 OR REPLACE PROCEDURE update_profile_bootstrap() + LANGUAGE plpgsql AS +$$ + +BEGIN + + UPDATE device_profile + SET profile_data = jsonb_set( + profile_data, + '{transportConfiguration}', + get_bootstrap( + profile_data::jsonb #> '{transportConfiguration}', + subquery.publickey_bs, + subquery.publickey_lw, + profile_data::json #>> '{transportConfiguration, bootstrap, bootstrapServer, securityMode}', + profile_data::json #>> '{transportConfiguration, bootstrap, lwm2mServer, securityMode}'), + true) + FROM ( + SELECT id, + encode( + decode(profile_data::json #> '{transportConfiguration,bootstrap,bootstrapServer}' ->> + 'serverPublicKey', 'hex')::bytea, 'base64') AS publickey_bs, + encode( + decode(profile_data::json #> '{transportConfiguration,bootstrap,lwm2mServer}' ->> + 'serverPublicKey', 'hex')::bytea, 'base64') AS publickey_lw + FROM device_profile + WHERE transport_type = 'LWM2M' + ) AS subquery + WHERE device_profile.id = subquery.id + AND subquery.publickey_bs IS NOT NULL + AND subquery.publickey_lw IS NOT NULL; + +END; +$$; + +CREATE OR REPLACE FUNCTION get_bootstrap(transport_configuration_in jsonb, publickey_bs text, + publickey_lw text, security_mode_bs text, + security_mode_lw text) RETURNS jsonb AS +$$ + +DECLARE + bootstrap_new jsonb; + bootstrap_in jsonb; + +BEGIN + + IF security_mode_lw IS NULL THEN + security_mode_lw := 'NO_SEC'; + END IF; + + IF security_mode_bs IS NULL THEN + security_mode_bs := 'NO_SEC'; + END IF; + + bootstrap_in := transport_configuration_in::jsonb #> '{bootstrap}'; + bootstrap_new := json_build_array( + json_build_object('shortServerId', bootstrap_in::json #> '{bootstrapServer}' -> 'serverId', + 'securityMode', security_mode_bs, + 'binding', bootstrap_in::json #> '{servers}' ->> 'binding', + 'lifetime', bootstrap_in::json #> '{servers}' -> 'lifetime', + 'notifIfDisabled', bootstrap_in::json #> '{servers}' -> 'notifIfDisabled', + 'defaultMinPeriod', bootstrap_in::json #> '{servers}' -> 'defaultMinPeriod', + 'host', bootstrap_in::json #> '{bootstrapServer}' ->> 'host', + 'port', bootstrap_in::json #> '{bootstrapServer}' -> 'port', + 'serverPublicKey', publickey_bs, + 'bootstrapServerIs', true, + 'clientHoldOffTime', bootstrap_in::json #> '{bootstrapServer}' -> 'clientHoldOffTime', + 'bootstrapServerAccountTimeout', + bootstrap_in::json #> '{bootstrapServer}' -> 'bootstrapServerAccountTimeout' + ), + json_build_object('shortServerId', bootstrap_in::json #> '{lwm2mServer}' -> 'serverId', + 'securityMode', security_mode_lw, + 'binding', bootstrap_in::json #> '{servers}' ->> 'binding', + 'lifetime', bootstrap_in::json #> '{servers}' -> 'lifetime', + 'notifIfDisabled', bootstrap_in::json #> '{servers}' -> 'notifIfDisabled', + 'defaultMinPeriod', bootstrap_in::json #> '{servers}' -> 'defaultMinPeriod', + 'host', bootstrap_in::json #> '{lwm2mServer}' ->> 'host', + 'port', bootstrap_in::json #> '{lwm2mServer}' -> 'port', + 'serverPublicKey', publickey_lw, + 'bootstrapServerIs', false, + 'clientHoldOffTime', bootstrap_in::json #> '{lwm2mServer}' -> 'clientHoldOffTime', + 'bootstrapServerAccountTimeout', + bootstrap_in::json #> '{lwm2mServer}' -> 'bootstrapServerAccountTimeout' + ) + ); + RETURN jsonb_set( + transport_configuration_in, + '{bootstrap}', + bootstrap_new, + true) || '{"bootstrapServerUpdateEnable": true}'; + +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE update_device_credentials_to_base64_and_bootstrap() + LANGUAGE plpgsql AS +$$ + +BEGIN + + UPDATE device_credentials + SET credentials_value = get_device_and_bootstrap(credentials_value::text) + WHERE credentials_type = 'LWM2M_CREDENTIALS'; +END; +$$; + +CREATE OR REPLACE FUNCTION get_device_and_bootstrap(IN credentials_value text, OUT credentials_value_new text) + LANGUAGE plpgsql AS +$$ +DECLARE + client_secret_key text; + client_public_key_or_id text; + client_key_value_object jsonb; + client_bootstrap_server_value_object jsonb; + client_bootstrap_server_object jsonb; + client_bootstrap_object jsonb; + +BEGIN + credentials_value_new := credentials_value; + IF credentials_value::jsonb #> '{client}' ->> 'securityConfigClientMode' = 'RPK' AND + NULLIF((credentials_value::jsonb #> '{client}' ->> 'key' ~ '^[0-9a-fA-F]+$')::text, 'false') = 'true' THEN + client_public_key_or_id := encode(decode(credentials_value::jsonb #> '{client}' ->> 'key', 'hex')::bytea, 'base64'); + client_key_value_object := json_build_object( + 'endpoint', credentials_value::jsonb #> '{client}' ->> 'endpoint', + 'securityConfigClientMode', credentials_value::jsonb #> '{client}' ->> 'securityConfigClientMode', + 'key', client_public_key_or_id); + credentials_value_new := + credentials_value_new::jsonb || json_build_object('client', client_key_value_object)::jsonb; + END IF; + IF credentials_value::jsonb #> '{client}' ->> 'securityConfigClientMode' = 'X509' AND + NULLIF((credentials_value::jsonb #> '{client}' ->> 'cert' ~ '^[0-9a-fA-F]+$')::text, 'false') = 'true' THEN + client_public_key_or_id := + encode(decode(credentials_value::jsonb #> '{client}' ->> 'cert', 'hex')::bytea, 'base64'); + client_key_value_object := json_build_object( + 'endpoint', credentials_value::jsonb #> '{client}' ->> 'endpoint', + 'securityConfigClientMode', credentials_value::jsonb #> '{client}' ->> 'securityConfigClientMode', + 'cert', client_public_key_or_id); + credentials_value_new := + credentials_value_new::jsonb || json_build_object('client', client_key_value_object)::jsonb; + END IF; + + IF credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'securityMode' = 'RPK' OR + credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'securityMode' = 'X509' THEN + IF NULLIF((credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'clientSecretKey' ~ '^[0-9a-fA-F]+$')::text, + 'false') = 'true' AND + NULLIF( + (credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'clientPublicKeyOrId' ~ '^[0-9a-fA-F]+$')::text, + 'false') = 'true' THEN + client_secret_key := + encode(decode(credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'clientSecretKey', 'hex')::bytea, + 'base64'); + client_public_key_or_id := encode( + decode(credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'clientPublicKeyOrId', 'hex')::bytea, + 'base64'); + client_bootstrap_server_value_object := jsonb_build_object( + 'securityMode', credentials_value::jsonb #> '{bootstrap,lwm2mServer}' ->> 'securityMode', + 'clientPublicKeyOrId', client_public_key_or_id, + 'clientSecretKey', client_secret_key + ); + client_bootstrap_server_object := jsonb_build_object('lwm2mServer', client_bootstrap_server_value_object::jsonb); + client_bootstrap_object := credentials_value_new::jsonb #> '{bootstrap}' || client_bootstrap_server_object::jsonb; + credentials_value_new := + jsonb_set(credentials_value_new::jsonb, '{bootstrap}', client_bootstrap_object::jsonb, false)::jsonb; + END IF; + END IF; + + IF credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'securityMode' = 'RPK' OR + credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'securityMode' = 'X509' THEN + IF NULLIF( + (credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'clientSecretKey' ~ '^[0-9a-fA-F]+$')::text, + 'false') = 'true' AND + NULLIF( + (credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'clientPublicKeyOrId' ~ '^[0-9a-fA-F]+$')::text, + 'false') = 'true' THEN + client_secret_key := + encode( + decode(credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'clientSecretKey', 'hex')::bytea, + 'base64'); + client_public_key_or_id := encode( + decode(credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'clientPublicKeyOrId', 'hex')::bytea, + 'base64'); + client_bootstrap_server_value_object := jsonb_build_object( + 'securityMode', credentials_value::jsonb #> '{bootstrap,bootstrapServer}' ->> 'securityMode', + 'clientPublicKeyOrId', client_public_key_or_id, + 'clientSecretKey', client_secret_key + ); + client_bootstrap_server_object := + jsonb_build_object('bootstrapServer', client_bootstrap_server_value_object::jsonb); + client_bootstrap_object := credentials_value_new::jsonb #> '{bootstrap}' || client_bootstrap_server_object::jsonb; + credentials_value_new := + jsonb_set(credentials_value_new::jsonb, '{bootstrap}', client_bootstrap_object::jsonb, false)::jsonb; + END IF; + END IF; + +END; +$$; \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.3.3/schema_event_ttl_procedure.sql b/application/src/main/data/upgrade/3.3.3/schema_event_ttl_procedure.sql new file mode 100644 index 0000000..432fca8 --- /dev/null +++ b/application/src/main/data/upgrade/3.3.3/schema_event_ttl_procedure.sql @@ -0,0 +1,50 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 PROCEDURE IF EXISTS public.cleanup_events_by_ttl(bigint, bigint, bigint); + +CREATE OR REPLACE PROCEDURE cleanup_events_by_ttl( + IN regular_events_start_ts bigint, + IN regular_events_end_ts bigint, + IN debug_events_start_ts bigint, + IN debug_events_end_ts bigint, + INOUT deleted bigint) + LANGUAGE plpgsql AS +$$ +DECLARE + ttl_deleted_count bigint DEFAULT 0; + debug_ttl_deleted_count bigint DEFAULT 0; +BEGIN + IF regular_events_start_ts > 0 AND regular_events_end_ts > 0 THEN + EXECUTE format( + 'WITH deleted AS (DELETE FROM event WHERE id in (SELECT id from event WHERE ts > %L::bigint AND ts < %L::bigint AND ' || + '(event_type != %L::varchar AND event_type != %L::varchar)) RETURNING *) ' || + 'SELECT count(*) FROM deleted', regular_events_start_ts, regular_events_end_ts, + 'DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN') into ttl_deleted_count; + END IF; + IF debug_events_start_ts > 0 AND debug_events_end_ts > 0 THEN + EXECUTE format( + 'WITH deleted AS (DELETE FROM event WHERE id in (SELECT id from event WHERE ts > %L::bigint AND ts < %L::bigint AND ' || + '(event_type = %L::varchar OR event_type = %L::varchar)) RETURNING *) ' || + 'SELECT count(*) FROM deleted', debug_events_start_ts, debug_events_end_ts, + 'DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN') into debug_ttl_deleted_count; + END IF; + RAISE NOTICE 'Events removed by ttl: %', ttl_deleted_count; + RAISE NOTICE 'Debug Events removed by ttl: %', debug_ttl_deleted_count; + deleted := ttl_deleted_count + debug_ttl_deleted_count; +END +$$; diff --git a/application/src/main/data/upgrade/3.3.3/schema_update.sql b/application/src/main/data/upgrade/3.3.3/schema_update.sql new file mode 100644 index 0000000..eed1cab --- /dev/null +++ b/application/src/main/data/upgrade/3.3.3/schema_update.sql @@ -0,0 +1,29 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DELETE from ota_package as op WHERE NOT EXISTS(SELECT * FROM device_profile dp where op.device_profile_id = dp.id); + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'fk_device_profile_ota_package') THEN + ALTER TABLE ota_package + ADD CONSTRAINT fk_device_profile_ota_package + FOREIGN KEY (device_profile_id) REFERENCES device_profile (id) + ON DELETE CASCADE; + END IF; + END; +$$; diff --git a/application/src/main/data/upgrade/3.3.4/schema_update.sql b/application/src/main/data/upgrade/3.3.4/schema_update.sql new file mode 100644 index 0000000..c86a4fe --- /dev/null +++ b/application/src/main/data/upgrade/3.3.4/schema_update.sql @@ -0,0 +1,140 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 device + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE device_profile + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE asset + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE rule_chain + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE rule_node + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE dashboard + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE customer + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE widgets_bundle + ADD COLUMN IF NOT EXISTS external_id UUID; +ALTER TABLE entity_view + ADD COLUMN IF NOT EXISTS external_id UUID; + +CREATE INDEX IF NOT EXISTS idx_rule_node_external_id ON rule_node(rule_chain_id, external_id); +CREATE INDEX IF NOT EXISTS idx_rule_node_type ON rule_node(type); + +ALTER TABLE admin_settings + ADD COLUMN IF NOT EXISTS tenant_id uuid NOT NULL DEFAULT '13814000-1dd2-11b2-8080-808080808080'; + +CREATE TABLE IF NOT EXISTS queue ( + id uuid NOT NULL CONSTRAINT queue_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid, + name varchar(255), + topic varchar(255), + poll_interval int, + partitions int, + consumer_per_partition boolean, + pack_processing_timeout bigint, + submit_strategy varchar(255), + processing_strategy varchar(255), + additional_info varchar +); + +CREATE TABLE IF NOT EXISTS user_auth_settings ( + id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), + two_fa_settings varchar +); + +CREATE INDEX IF NOT EXISTS idx_api_usage_state_entity_id ON api_usage_state(entity_id); + +ALTER TABLE tenant_profile DROP COLUMN IF EXISTS isolated_tb_core; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'device_external_id_unq_key') THEN + ALTER TABLE device ADD CONSTRAINT device_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'device_profile_external_id_unq_key') THEN + ALTER TABLE device_profile ADD CONSTRAINT device_profile_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'asset_external_id_unq_key') THEN + ALTER TABLE asset ADD CONSTRAINT asset_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'rule_chain_external_id_unq_key') THEN + ALTER TABLE rule_chain ADD CONSTRAINT rule_chain_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'dashboard_external_id_unq_key') THEN + ALTER TABLE dashboard ADD CONSTRAINT dashboard_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'customer_external_id_unq_key') THEN + ALTER TABLE customer ADD CONSTRAINT customer_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'widgets_bundle_external_id_unq_key') THEN + ALTER TABLE widgets_bundle ADD CONSTRAINT widgets_bundle_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'entity_view_external_id_unq_key') THEN + ALTER TABLE entity_view ADD CONSTRAINT entity_view_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + diff --git a/application/src/main/data/upgrade/3.4.0/schema_update.sql b/application/src/main/data/upgrade/3.4.0/schema_update.sql new file mode 100644 index 0000000..71588d8 --- /dev/null +++ b/application/src/main/data/upgrade/3.4.0/schema_update.sql @@ -0,0 +1,234 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 rule_node_debug_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL , + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar, + e_type varchar, + e_entity_id uuid, + e_entity_type varchar, + e_msg_id uuid, + e_msg_type varchar, + e_data_type varchar, + e_relation_type varchar, + e_data varchar, + e_metadata varchar, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS rule_chain_debug_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_message varchar, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS stats_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_messages_processed bigint NOT NULL, + e_errors_occurred bigint NOT NULL +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS lc_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_type varchar NOT NULL, + e_success boolean NOT NULL, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE TABLE IF NOT EXISTS error_event ( + id uuid NOT NULL, + tenant_id uuid NOT NULL, + ts bigint NOT NULL, + entity_id uuid NOT NULL, + service_id varchar NOT NULL, + e_method varchar NOT NULL, + e_error varchar +) PARTITION BY RANGE (ts); + +CREATE INDEX IF NOT EXISTS idx_rule_node_debug_event_main + ON rule_node_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_rule_chain_debug_event_main + ON rule_chain_debug_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_stats_event_main + ON stats_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_lc_event_main + ON lc_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE INDEX IF NOT EXISTS idx_error_event_main + ON error_event (tenant_id ASC, entity_id ASC, ts DESC NULLS LAST) WITH (FILLFACTOR=95); + +CREATE OR REPLACE FUNCTION to_safe_json(p_json text) RETURNS json +LANGUAGE plpgsql AS +$$ +BEGIN + return REPLACE(p_json, '\u0000', '' )::json; +EXCEPTION + WHEN OTHERS THEN + return '{}'::json; +END; +$$; + +-- Useful to migrate old events to the new table structure; +CREATE OR REPLACE PROCEDURE migrate_regular_events(IN start_ts_in_ms bigint, IN end_ts_in_ms bigint, IN partition_size_in_hours int) + LANGUAGE plpgsql AS +$$ +DECLARE + partition_size_in_ms bigint; + p record; + table_name varchar; +BEGIN + partition_size_in_ms = partition_size_in_hours * 3600 * 1000; + + FOR p IN SELECT DISTINCT event_type as event_type, (created_time - created_time % partition_size_in_ms) as partition_ts FROM event e WHERE e.event_type in ('STATS', 'LC_EVENT', 'ERROR') and ts >= start_ts_in_ms and ts < end_ts_in_ms + LOOP + IF p.event_type = 'STATS' THEN + table_name := 'stats_event'; + ELSEIF p.event_type = 'LC_EVENT' THEN + table_name := 'lc_event'; + ELSEIF p.event_type = 'ERROR' THEN + table_name := 'error_event'; + END IF; + RAISE NOTICE '[%] Partition to create : [%-%]', table_name, p.partition_ts, (p.partition_ts + partition_size_in_ms); + EXECUTE format('CREATE TABLE IF NOT EXISTS %s_%s PARTITION OF %s FOR VALUES FROM ( %s ) TO ( %s )', table_name, p.partition_ts, table_name, p.partition_ts, (p.partition_ts + partition_size_in_ms)); + END LOOP; + + INSERT INTO stats_event + SELECT id, + tenant_id, + ts, + entity_id, + body ->> 'server', + (body ->> 'messagesProcessed')::bigint, + (body ->> 'errorsOccurred')::bigint + FROM + (select id, tenant_id, ts, entity_id, to_safe_json(body) as body + FROM event WHERE ts >= start_ts_in_ms and ts < end_ts_in_ms AND event_type = 'STATS' AND to_safe_json(body) ->> 'server' IS NOT NULL + ) safe_event + ON CONFLICT DO NOTHING; + + INSERT INTO lc_event + SELECT id, + tenant_id, + ts, + entity_id, + body ->> 'server', + body ->> 'event', + (body ->> 'success')::boolean, + body ->> 'error' + FROM + (select id, tenant_id, ts, entity_id, to_safe_json(body) as body + FROM event WHERE ts >= start_ts_in_ms and ts < end_ts_in_ms AND event_type = 'LC_EVENT' AND to_safe_json(body) ->> 'server' IS NOT NULL + ) safe_event + ON CONFLICT DO NOTHING; + + INSERT INTO error_event + SELECT id, + tenant_id, + ts, + entity_id, + body ->> 'server', + body ->> 'method', + body ->> 'error' + FROM + (select id, tenant_id, ts, entity_id, to_safe_json(body) as body + FROM event WHERE ts >= start_ts_in_ms and ts < end_ts_in_ms AND event_type = 'ERROR' AND to_safe_json(body) ->> 'server' IS NOT NULL + ) safe_event + ON CONFLICT DO NOTHING; + +END +$$; + +-- Useful to migrate old debug events to the new table structure; +CREATE OR REPLACE PROCEDURE migrate_debug_events(IN start_ts_in_ms bigint, IN end_ts_in_ms bigint, IN partition_size_in_hours int) + LANGUAGE plpgsql AS +$$ +DECLARE + partition_size_in_ms bigint; + p record; + table_name varchar; +BEGIN + partition_size_in_ms = partition_size_in_hours * 3600 * 1000; + + FOR p IN SELECT DISTINCT event_type as event_type, (created_time - created_time % partition_size_in_ms) as partition_ts FROM event e WHERE e.event_type in ('DEBUG_RULE_NODE', 'DEBUG_RULE_CHAIN') and ts >= start_ts_in_ms and ts < end_ts_in_ms + LOOP + IF p.event_type = 'DEBUG_RULE_NODE' THEN + table_name := 'rule_node_debug_event'; + ELSEIF p.event_type = 'DEBUG_RULE_CHAIN' THEN + table_name := 'rule_chain_debug_event'; + END IF; + RAISE NOTICE '[%] Partition to create : [%-%]', table_name, p.partition_ts, (p.partition_ts + partition_size_in_ms); + EXECUTE format('CREATE TABLE IF NOT EXISTS %s_%s PARTITION OF %s FOR VALUES FROM ( %s ) TO ( %s )', table_name, p.partition_ts, table_name, p.partition_ts, (p.partition_ts + partition_size_in_ms)); + END LOOP; + + INSERT INTO rule_node_debug_event + SELECT id, + tenant_id, + ts, + entity_id, + body ->> 'server', + body ->> 'type', + (body ->> 'entityId')::uuid, + body ->> 'entityName', + (body ->> 'msgId')::uuid, + body ->> 'msgType', + body ->> 'dataType', + body ->> 'relationType', + body ->> 'data', + body ->> 'metadata', + body ->> 'error' + FROM + (select id, tenant_id, ts, entity_id, to_safe_json(body) as body + FROM event WHERE ts >= start_ts_in_ms and ts < end_ts_in_ms AND event_type = 'DEBUG_RULE_NODE' AND to_safe_json(body) ->> 'server' IS NOT NULL + ) safe_event + ON CONFLICT DO NOTHING; + + INSERT INTO rule_chain_debug_event + SELECT id, + tenant_id, + ts, + entity_id, + body ->> 'server', + body ->> 'message', + body ->> 'error' + FROM + (select id, tenant_id, ts, entity_id, to_safe_json(body) as body + FROM event WHERE ts >= start_ts_in_ms and ts < end_ts_in_ms AND event_type = 'DEBUG_RULE_CHAIN' AND to_safe_json(body) ->> 'server' IS NOT NULL + ) safe_event + ON CONFLICT DO NOTHING; +END +$$; + +UPDATE tb_user + SET additional_info = REPLACE(additional_info, '"lang":"ja_JA"', '"lang":"ja_JP"') + WHERE additional_info LIKE '%"lang":"ja_JA"%'; diff --git a/application/src/main/data/upgrade/3.4.1/schema_update.sql b/application/src/main/data/upgrade/3.4.1/schema_update.sql new file mode 100644 index 0000000..45a75c6 --- /dev/null +++ b/application/src/main/data/upgrade/3.4.1/schema_update.sql @@ -0,0 +1,142 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- AUDIT LOGS MIGRATION START +DO +$$ + DECLARE table_partition RECORD; + BEGIN + -- in case of running the upgrade script a second time: + IF NOT (SELECT exists(SELECT FROM pg_tables WHERE tablename = 'old_audit_log')) THEN + ALTER TABLE audit_log RENAME TO old_audit_log; + CREATE INDEX IF NOT EXISTS idx_old_audit_log_created_time ON old_audit_log(created_time); + + ALTER INDEX IF EXISTS idx_audit_log_tenant_id_and_created_time RENAME TO idx_old_audit_log_tenant_id_and_created_time; + + FOR table_partition IN SELECT tablename AS name, split_part(tablename, '_', 3) AS partition_ts + FROM pg_tables WHERE tablename LIKE 'audit_log_%' + LOOP + EXECUTE format('ALTER TABLE %s RENAME TO old_audit_log_%s', table_partition.name, table_partition.partition_ts); + END LOOP; + ELSE + RAISE NOTICE 'Table old_audit_log already exists, leaving as is'; + END IF; + END; +$$; + +CREATE TABLE IF NOT EXISTS audit_log ( + id uuid NOT NULL, + created_time bigint NOT NULL, + tenant_id uuid, + customer_id uuid, + entity_id uuid, + entity_type varchar(255), + entity_name varchar(255), + user_id uuid, + user_name varchar(255), + action_type varchar(255), + action_data varchar(1000000), + action_status varchar(255), + action_failure_details varchar(1000000) +) PARTITION BY RANGE (created_time); +CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_id_and_created_time ON audit_log(tenant_id, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_id ON audit_log(id); + +CREATE OR REPLACE PROCEDURE migrate_audit_logs(IN start_time_ms BIGINT, IN end_time_ms BIGINT, IN partition_size_ms BIGINT) + LANGUAGE plpgsql AS +$$ +DECLARE + p RECORD; + partition_end_ts BIGINT; +BEGIN + FOR p IN SELECT DISTINCT (created_time - created_time % partition_size_ms) AS partition_ts FROM old_audit_log + WHERE created_time >= start_time_ms AND created_time < end_time_ms + LOOP + partition_end_ts = p.partition_ts + partition_size_ms; + RAISE NOTICE '[audit_log] Partition to create : [%-%]', p.partition_ts, partition_end_ts; + EXECUTE format('CREATE TABLE IF NOT EXISTS audit_log_%s PARTITION OF audit_log ' || + 'FOR VALUES FROM ( %s ) TO ( %s )', p.partition_ts, p.partition_ts, partition_end_ts); + END LOOP; + + INSERT INTO audit_log + SELECT id, created_time, tenant_id, customer_id, entity_id, entity_type, entity_name, user_id, user_name, action_type, action_data, action_status, action_failure_details + FROM old_audit_log + WHERE created_time >= start_time_ms AND created_time < end_time_ms; +END; +$$; +-- AUDIT LOGS MIGRATION END + + +-- EDGE EVENTS MIGRATION START +DO +$$ + DECLARE table_partition RECORD; + BEGIN + -- in case of running the upgrade script a second time: + IF NOT (SELECT exists(SELECT FROM pg_tables WHERE tablename = 'old_edge_event')) THEN + ALTER TABLE edge_event RENAME TO old_edge_event; + CREATE INDEX IF NOT EXISTS idx_old_blob_entity_created_time_tmp ON old_blob_entity(created_time); + ALTER INDEX IF EXISTS idx_edge_event_tenant_id_and_created_time RENAME TO idx_old_edge_event_tenant_id_and_created_time; + + FOR table_partition IN SELECT tablename AS name, split_part(tablename, '_', 3) AS partition_ts + FROM pg_tables WHERE tablename LIKE 'edge_event_%' + LOOP + EXECUTE format('ALTER TABLE %s RENAME TO old_edge_event_%s', table_partition.name, table_partition.partition_ts); + END LOOP; + ELSE + RAISE NOTICE 'Table old_edge_event already exists, leaving as is'; + END IF; +END; +$$; + +CREATE TABLE IF NOT EXISTS edge_event ( + id uuid NOT NULL, + created_time bigint NOT NULL, + edge_id uuid, + edge_event_type varchar(255), + edge_event_uid varchar(255), + entity_id uuid, + edge_event_action varchar(255), + body varchar(10000000), + tenant_id uuid, + ts bigint NOT NULL + ) PARTITION BY RANGE (created_time); +CREATE INDEX IF NOT EXISTS idx_edge_event_tenant_id_and_created_time ON edge_event(tenant_id, created_time DESC); +CREATE INDEX IF NOT EXISTS idx_edge_event_id ON edge_event(id); + +CREATE OR REPLACE PROCEDURE migrate_edge_event(IN start_time_ms BIGINT, IN end_time_ms BIGINT, IN partition_size_ms BIGINT) + LANGUAGE plpgsql AS +$$ +DECLARE + p RECORD; + partition_end_ts BIGINT; +BEGIN + FOR p IN SELECT DISTINCT (created_time - created_time % partition_size_ms) AS partition_ts FROM old_edge_event + WHERE created_time >= start_time_ms AND created_time < end_time_ms + LOOP + partition_end_ts = p.partition_ts + partition_size_ms; + RAISE NOTICE '[edge_event] Partition to create : [%-%]', p.partition_ts, partition_end_ts; + EXECUTE format('CREATE TABLE IF NOT EXISTS edge_event_%s PARTITION OF edge_event ' || + 'FOR VALUES FROM ( %s ) TO ( %s )', p.partition_ts, p.partition_ts, partition_end_ts); + END LOOP; + + INSERT INTO edge_event + SELECT id, created_time, edge_id, edge_event_type, edge_event_uid, entity_id, edge_event_action, body, tenant_id, ts + FROM old_edge_event + WHERE created_time >= start_time_ms AND created_time < end_time_ms; +END; +$$; +-- EDGE EVENTS MIGRATION END diff --git a/application/src/main/data/upgrade/3.4.1/schema_update_after.sql b/application/src/main/data/upgrade/3.4.1/schema_update_after.sql new file mode 100644 index 0000000..78df4d2 --- /dev/null +++ b/application/src/main/data/upgrade/3.4.1/schema_update_after.sql @@ -0,0 +1,21 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 PROCEDURE IF EXISTS update_asset_profiles; + +ALTER TABLE asset ALTER COLUMN asset_profile_id SET NOT NULL; +ALTER TABLE asset DROP CONSTRAINT IF EXISTS fk_asset_profile; +ALTER TABLE asset ADD CONSTRAINT fk_asset_profile FOREIGN KEY (asset_profile_id) REFERENCES asset_profile(id); diff --git a/application/src/main/data/upgrade/3.4.1/schema_update_before.sql b/application/src/main/data/upgrade/3.4.1/schema_update_before.sql new file mode 100644 index 0000000..27f772a --- /dev/null +++ b/application/src/main/data/upgrade/3.4.1/schema_update_before.sql @@ -0,0 +1,46 @@ +-- +-- Copyright © 2016-2022 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT 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 asset_profile ( + id uuid NOT NULL CONSTRAINT asset_profile_pkey PRIMARY KEY, + created_time bigint NOT NULL, + name varchar(255), + image varchar(1000000), + description varchar, + search_text varchar(255), + is_default boolean, + tenant_id uuid, + default_rule_chain_id uuid, + default_dashboard_id uuid, + default_queue_name varchar(255), + external_id uuid, + CONSTRAINT asset_profile_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT asset_profile_external_id_unq_key UNIQUE (tenant_id, external_id), + CONSTRAINT fk_default_rule_chain_asset_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id), + CONSTRAINT fk_default_dashboard_asset_profile FOREIGN KEY (default_dashboard_id) REFERENCES dashboard(id) + ); + +CREATE OR REPLACE PROCEDURE update_asset_profiles() + LANGUAGE plpgsql AS +$$ +BEGIN + UPDATE asset a SET asset_profile_id = COALESCE( + (SELECT id from asset_profile p WHERE p.tenant_id = a.tenant_id AND a.type = p.name), + (SELECT id from asset_profile p WHERE p.tenant_id = a.tenant_id AND p.name = 'default') + ) + WHERE a.asset_profile_id IS NULL; +END; +$$; diff --git a/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java b/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java new file mode 100644 index 0000000..38764d0 --- /dev/null +++ b/application/src/main/java/org/apache/kafka/common/network/NetworkReceive.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Content of this file was modified to addresses the issue https://issues.apache.org/jira/browse/KAFKA-4090 + * + */ +package org.apache.kafka.common.network; + +import org.apache.kafka.common.memory.MemoryPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.thingsboard.server.common.data.exception.ThingsboardKafkaClientError; + +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ScatteringByteChannel; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * A size delimited Receive that consists of a 4 byte network-ordered size N followed by N bytes of content + */ +public class NetworkReceive implements Receive { + + public final static String UNKNOWN_SOURCE = ""; + public final static int UNLIMITED = -1; + public final static int TB_MAX_REQUESTED_BUFFER_SIZE = 100 * 1024 * 1024; + public final static int TB_LOG_REQUESTED_BUFFER_SIZE = 10 * 1024 * 1024; + private static final Logger log = LoggerFactory.getLogger(NetworkReceive.class); + private static final ByteBuffer EMPTY_BUFFER = ByteBuffer.allocate(0); + + private final String source; + private final ByteBuffer size; + private final int maxSize; + private final MemoryPool memoryPool; + private int requestedBufferSize = -1; + private ByteBuffer buffer; + + + public NetworkReceive(String source, ByteBuffer buffer) { + this.source = source; + this.buffer = buffer; + this.size = null; + this.maxSize = TB_MAX_REQUESTED_BUFFER_SIZE; + this.memoryPool = MemoryPool.NONE; + } + + public NetworkReceive(String source) { + this.source = source; + this.size = ByteBuffer.allocate(4); + this.buffer = null; + this.maxSize = TB_MAX_REQUESTED_BUFFER_SIZE; + this.memoryPool = MemoryPool.NONE; + } + + public NetworkReceive(int maxSize, String source) { + this.source = source; + this.size = ByteBuffer.allocate(4); + this.buffer = null; + this.maxSize = getMaxSize(maxSize); + this.memoryPool = MemoryPool.NONE; + } + + public NetworkReceive(int maxSize, String source, MemoryPool memoryPool) { + this.source = source; + this.size = ByteBuffer.allocate(4); + this.buffer = null; + this.maxSize = getMaxSize(maxSize); + this.memoryPool = memoryPool; + } + + public NetworkReceive() { + this(UNKNOWN_SOURCE); + } + + @Override + public String source() { + return source; + } + + @Override + public boolean complete() { + return !size.hasRemaining() && buffer != null && !buffer.hasRemaining(); + } + + public long readFrom(ScatteringByteChannel channel) throws IOException { + int read = 0; + if (size.hasRemaining()) { + int bytesRead = channel.read(size); + if (bytesRead < 0) + throw new EOFException(); + read += bytesRead; + if (!size.hasRemaining()) { + size.rewind(); + int receiveSize = size.getInt(); + if (receiveSize < 0) + throw new InvalidReceiveException("Invalid receive (size = " + receiveSize + ")"); + if (maxSize != UNLIMITED && receiveSize > maxSize) { + throw new ThingsboardKafkaClientError("Invalid receive (size = " + receiveSize + " larger than " + maxSize + ")"); + } + requestedBufferSize = receiveSize; //may be 0 for some payloads (SASL) + if (receiveSize == 0) { + buffer = EMPTY_BUFFER; + } + } + } + if (buffer == null && requestedBufferSize != -1) { //we know the size we want but havent been able to allocate it yet + if (requestedBufferSize > TB_LOG_REQUESTED_BUFFER_SIZE) { + String stackTrace = Arrays.stream(Thread.currentThread().getStackTrace()).map(StackTraceElement::toString).collect(Collectors.joining("|")); + log.error("Allocating buffer of size {} for source {}", requestedBufferSize, source); + log.error("Stack Trace: {}", stackTrace); + } + buffer = memoryPool.tryAllocate(requestedBufferSize); + if (buffer == null) + log.trace("Broker low on memory - could not allocate buffer of size {} for source {}", requestedBufferSize, source); + } + if (buffer != null) { + int bytesRead = channel.read(buffer); + if (bytesRead < 0) + throw new EOFException(); + read += bytesRead; + } + + return read; + } + + @Override + public boolean requiredMemoryAmountKnown() { + return requestedBufferSize != -1; + } + + @Override + public boolean memoryAllocated() { + return buffer != null; + } + + + @Override + public void close() throws IOException { + if (buffer != null && buffer != EMPTY_BUFFER) { + memoryPool.release(buffer); + buffer = null; + } + } + + public ByteBuffer payload() { + return this.buffer; + } + + public int bytesRead() { + if (buffer == null) + return size.position(); + return buffer.position() + size.position(); + } + + /** + * Returns the total size of the receive including payload and size buffer + * for use in metrics. This is consistent with {@link NetworkSend#size()} + */ + public int size() { + return payload().limit() + size.limit(); + } + + private int getMaxSize(int maxSize) { + return maxSize == UNLIMITED ? TB_MAX_REQUESTED_BUFFER_SIZE : Math.min(maxSize, TB_MAX_REQUESTED_BUFFER_SIZE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/ThingsboardInstallApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardInstallApplication.java new file mode 100644 index 0000000..b4a0e01 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/ThingsboardInstallApplication.java @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.thingsboard.server.install.ThingsboardInstallService; + +import java.util.Arrays; + +@Slf4j +@SpringBootConfiguration +@ComponentScan({"org.thingsboard.server.install", + "org.thingsboard.server.service.component", + "org.thingsboard.server.service.install", + "org.thingsboard.server.service.security.auth.jwt.settings", + "org.thingsboard.server.dao", + "org.thingsboard.server.common.stats", + "org.thingsboard.server.common.transport.config.ssl", + "org.thingsboard.server.cache", + "org.thingsboard.server.springfox" +}) +public class ThingsboardInstallApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "thingsboard"; + + public static void main(String[] args) { + try { + SpringApplication application = new SpringApplication(ThingsboardInstallApplication.class); + application.setAdditionalProfiles("install"); + ConfigurableApplicationContext context = application.run(updateArguments(args)); + context.getBean(ThingsboardInstallService.class).performInstall(); + } catch (Exception e) { + log.error(e.getMessage()); + System.exit(1); + } + } + + 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/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java new file mode 100644 index 0000000..a9c462e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server; + +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", "org.thingsboard.script"}) +public class ThingsboardServerApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "thingsboard"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardServerApplication.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/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java new file mode 100644 index 0000000..8b19a69 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -0,0 +1,674 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import 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.context.annotation.Lazy; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; +import org.thingsboard.script.api.js.JsInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.service.ActorService; +import org.thingsboard.server.actors.tenant.DebugTbRateLimits; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.event.ErrorEvent; +import org.thingsboard.server.common.data.event.LifecycleEvent; +import org.thingsboard.server.common.data.event.RuleChainDebugEvent; +import org.thingsboard.server.common.data.event.RuleNodeDebugEvent; +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.TbMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.tools.TbRateLimits; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.asset.AssetProfileService; +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.cassandra.CassandraCluster; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.ClaimDevicesService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.rule.RuleNodeStateService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.component.ComponentDiscoveryService; +import org.thingsboard.server.service.edge.rpc.EdgeRpcService; +import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.executors.ExternalCallExecutorService; +import org.thingsboard.server.service.executors.SharedEventLoopGroupService; +import org.thingsboard.server.service.mail.MailExecutorService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; +import org.thingsboard.server.service.rpc.TbRpcService; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; +import org.thingsboard.server.service.session.DeviceSessionCacheService; +import org.thingsboard.server.service.sms.SmsExecutorService; +import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import org.thingsboard.server.service.transport.TbCoreToTransportService; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class ActorSystemContext { + + private static final FutureCallback RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK = new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void event) { + + } + + @Override + public void onFailure(Throwable th) { + log.error("Could not save debug Event for Rule Chain", th); + } + }; + private static final FutureCallback RULE_NODE_DEBUG_EVENT_ERROR_CALLBACK = new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void event) { + + } + + @Override + public void onFailure(Throwable th) { + log.error("Could not save debug Event for Node", th); + } + }; + + protected final ObjectMapper mapper = new ObjectMapper(); + + private final ConcurrentMap debugPerTenantLimits = new ConcurrentHashMap<>(); + + public ConcurrentMap getDebugPerTenantLimits() { + return debugPerTenantLimits; + } + + @Autowired + @Getter + private TbApiUsageStateService apiUsageStateService; + + @Autowired + @Getter + private TbApiUsageReportClient apiUsageClient; + + @Autowired + @Getter + @Setter + private TbServiceInfoProvider serviceInfoProvider; + + @Getter + @Setter + private ActorService actorService; + + @Autowired + @Getter + @Setter + private ComponentDiscoveryService componentService; + + @Autowired + @Getter + private DataDecodingEncodingService encodingService; + + @Autowired + @Getter + private DeviceService deviceService; + + @Autowired + @Getter + private DeviceProfileService deviceProfileService; + + @Autowired + @Getter + private AssetProfileService assetProfileService; + + @Autowired + @Getter + private DeviceCredentialsService deviceCredentialsService; + + @Autowired + @Getter + private TbTenantProfileCache tenantProfileCache; + + @Autowired + @Getter + private TbDeviceProfileCache deviceProfileCache; + + @Autowired + @Getter + private TbAssetProfileCache assetProfileCache; + + @Autowired + @Getter + private AssetService assetService; + + @Autowired + @Getter + private DashboardService dashboardService; + + @Autowired + @Getter + private TenantService tenantService; + + @Autowired + @Getter + private TenantProfileService tenantProfileService; + + @Autowired + @Getter + private CustomerService customerService; + + @Autowired + @Getter + private UserService userService; + + @Autowired + @Getter + private RuleChainService ruleChainService; + + @Autowired + @Getter + private RuleNodeStateService ruleNodeStateService; + + @Autowired + private PartitionService partitionService; + + @Autowired + @Getter + private TbClusterService clusterService; + + @Autowired + @Getter + private TimeseriesService tsService; + + @Autowired + @Getter + private AttributesService attributesService; + + @Autowired + @Getter + private EventService eventService; + + @Autowired + @Getter + private RelationService relationService; + + @Autowired + @Getter + private AuditLogService auditLogService; + + @Autowired + @Getter + private EntityViewService entityViewService; + + @Lazy + @Autowired(required = false) + @Getter + private TbEntityViewService tbEntityViewService; + + @Autowired + @Getter + private TelemetrySubscriptionService tsSubService; + + @Autowired + @Getter + private AlarmSubscriptionService alarmService; + + @Autowired + @Getter + private JsInvokeService jsInvokeService; + + @Autowired(required = false) + @Getter + private TbelInvokeService tbelInvokeService; + + @Autowired + @Getter + private MailExecutorService mailExecutor; + + @Autowired + @Getter + private SmsExecutorService smsExecutor; + + @Autowired + @Getter + private DbCallbackExecutorService dbCallbackExecutor; + + @Autowired + @Getter + private ExternalCallExecutorService externalCallExecutorService; + + @Autowired + @Getter + private SharedEventLoopGroupService sharedEventLoopGroupService; + + @Autowired + @Getter + private MailService mailService; + + @Autowired + @Getter + private SmsService smsService; + + @Autowired + @Getter + private SmsSenderFactory smsSenderFactory; + + @Lazy + @Autowired(required = false) + @Getter + private ClaimDevicesService claimDevicesService; + + @Autowired + @Getter + private JsInvokeStats jsInvokeStats; + + //TODO: separate context for TbCore and TbRuleEngine + @Autowired(required = false) + @Getter + private DeviceStateService deviceStateService; + + @Autowired(required = false) + @Getter + private DeviceSessionCacheService deviceSessionCacheService; + + @Autowired(required = false) + @Getter + private TbCoreToTransportService tbCoreToTransportService; + + /** + * The following Service will be null if we operate in tb-core mode + */ + @Lazy + @Autowired(required = false) + @Getter + private TbRuleEngineDeviceRpcService tbRuleEngineDeviceRpcService; + + /** + * The following Service will be null if we operate in tb-rule-engine mode + */ + @Lazy + @Autowired(required = false) + @Getter + private TbCoreDeviceRpcService tbCoreDeviceRpcService; + + @Lazy + @Autowired(required = false) + @Getter + private EdgeService edgeService; + + @Lazy + @Autowired(required = false) + @Getter + private EdgeEventService edgeEventService; + + @Lazy + @Autowired(required = false) + @Getter + private EdgeRpcService edgeRpcService; + + @Lazy + @Autowired(required = false) + @Getter + private ResourceService resourceService; + + @Lazy + @Autowired(required = false) + @Getter + private OtaPackageService otaPackageService; + + @Lazy + @Autowired(required = false) + @Getter + private TbRpcService tbRpcService; + + @Lazy + @Autowired(required = false) + @Getter + private QueueService queueService; + + @Lazy + @Autowired(required = false) + @Getter + private WidgetsBundleService widgetsBundleService; + + @Lazy + @Autowired(required = false) + @Getter + private WidgetTypeService widgetTypeService; + + @Value("${actors.session.max_concurrent_sessions_per_device:1}") + @Getter + private long maxConcurrentSessionsPerDevice; + + @Value("${actors.session.sync.timeout:10000}") + @Getter + private long syncSessionTimeout; + + @Value("${actors.rule.chain.error_persist_frequency:3000}") + @Getter + private long ruleChainErrorPersistFrequency; + + @Value("${actors.rule.node.error_persist_frequency:3000}") + @Getter + private long ruleNodeErrorPersistFrequency; + + @Value("${actors.statistics.enabled:true}") + @Getter + private boolean statisticsEnabled; + + @Value("${actors.statistics.persist_frequency:3600000}") + @Getter + private long statisticsPersistFrequency; + + @Value("${edges.enabled:true}") + @Getter + private boolean edgesEnabled; + + @Value("${cache.type:caffeine}") + @Getter + private String cacheType; + + @Getter + private boolean localCacheType; + + @PostConstruct + public void init() { + this.localCacheType = "caffeine".equals(cacheType); + } + + @Scheduled(fixedDelayString = "${actors.statistics.js_print_interval_ms}") + public void printStats() { + if (statisticsEnabled) { + if (jsInvokeStats.getRequests() > 0 || jsInvokeStats.getResponses() > 0 || jsInvokeStats.getFailures() > 0) { + log.info("Rule Engine JS Invoke Stats: requests [{}] responses [{}] failures [{}]", + jsInvokeStats.getRequests(), jsInvokeStats.getResponses(), jsInvokeStats.getFailures()); + jsInvokeStats.reset(); + } + } + } + + @Value("${actors.tenant.create_components_on_init:true}") + @Getter + private boolean tenantComponentsInitEnabled; + + @Value("${actors.rule.allow_system_mail_service:true}") + @Getter + private boolean allowSystemMailService; + + @Value("${actors.rule.allow_system_sms_service:true}") + @Getter + private boolean allowSystemSmsService; + + @Value("${transport.sessions.inactivity_timeout:300000}") + @Getter + private long sessionInactivityTimeout; + + @Value("${transport.sessions.report_timeout:3000}") + @Getter + private long sessionReportTimeout; + + @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled:true}") + @Getter + private boolean debugPerTenantEnabled; + + @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.configuration:50000:3600}") + @Getter + private String debugPerTenantLimitsConfiguration; + + @Value("${actors.rpc.sequential:false}") + @Getter + private boolean rpcSequential; + + @Value("${actors.rpc.max_retries:5}") + @Getter + private int maxRpcRetries; + + @Getter + @Setter + private TbActorSystem actorSystem; + + @Setter + private TbActorRef appActor; + + @Getter + @Setter + private TbActorRef statsActor; + + @Autowired(required = false) + @Getter + private CassandraCluster cassandraCluster; + + @Autowired(required = false) + @Getter + private CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + + @Autowired(required = false) + @Getter + private CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Autowired(required = false) + @Getter + private RedisTemplate redisTemplate; + + public ScheduledExecutorService getScheduler() { + return actorSystem.getScheduler(); + } + + public void persistError(TenantId tenantId, EntityId entityId, String method, Exception e) { + eventService.saveAsync(ErrorEvent.builder() + .tenantId(tenantId) + .entityId(entityId.getId()) + .serviceId(getServiceId()) + .method(method) + .error(toString(e)).build()); + } + + public void persistLifecycleEvent(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent lcEvent, Exception e) { + LifecycleEvent.LifecycleEventBuilder event = LifecycleEvent.builder() + .tenantId(tenantId) + .entityId(entityId.getId()) + .serviceId(getServiceId()) + .lcEventType(lcEvent.name()); + + if (e != null) { + event.success(false).error(toString(e)); + } else { + event.success(true); + } + + eventService.saveAsync(event.build()); + } + + private String toString(Throwable e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } + + public TopicPartitionInfo resolve(ServiceType serviceType, TenantId tenantId, EntityId entityId) { + return partitionService.resolve(serviceType, tenantId, entityId); + } + + public TopicPartitionInfo resolve(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId) { + return partitionService.resolve(serviceType, queueName, tenantId, entityId); + } + + public String getServiceId() { + return serviceInfoProvider.getServiceId(); + } + + public void persistDebugInput(TenantId tenantId, EntityId entityId, TbMsg tbMsg, String relationType) { + persistDebugAsync(tenantId, entityId, "IN", tbMsg, relationType, null, null); + } + + public void persistDebugInput(TenantId tenantId, EntityId entityId, TbMsg tbMsg, String relationType, Throwable error) { + persistDebugAsync(tenantId, entityId, "IN", tbMsg, relationType, error, null); + } + + public void persistDebugOutput(TenantId tenantId, EntityId entityId, TbMsg tbMsg, String relationType, Throwable error, String failureMessage) { + persistDebugAsync(tenantId, entityId, "OUT", tbMsg, relationType, error, failureMessage); + } + + public void persistDebugOutput(TenantId tenantId, EntityId entityId, TbMsg tbMsg, String relationType, Throwable error) { + persistDebugAsync(tenantId, entityId, "OUT", tbMsg, relationType, error, null); + } + + public void persistDebugOutput(TenantId tenantId, EntityId entityId, TbMsg tbMsg, String relationType) { + persistDebugAsync(tenantId, entityId, "OUT", tbMsg, relationType, null, null); + } + + private void persistDebugAsync(TenantId tenantId, EntityId entityId, String type, TbMsg tbMsg, String relationType, Throwable error, String failureMessage) { + if (checkLimits(tenantId, tbMsg, error)) { + try { + RuleNodeDebugEvent.RuleNodeDebugEventBuilder event = RuleNodeDebugEvent.builder() + .tenantId(tenantId) + .entityId(entityId.getId()) + .serviceId(getServiceId()) + .eventType(type) + .eventEntity(tbMsg.getOriginator()) + .msgId(tbMsg.getId()) + .msgType(tbMsg.getType()) + .dataType(tbMsg.getDataType().name()) + .relationType(relationType) + .data(tbMsg.getData()) + .metadata(mapper.writeValueAsString(tbMsg.getMetaData().getData())); + + if (error != null) { + event.error(toString(error)); + } else if (failureMessage != null) { + event.error(failureMessage); + } + + ListenableFuture future = eventService.saveAsync(event.build()); + Futures.addCallback(future, RULE_NODE_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); + } catch (IOException ex) { + log.warn("Failed to persist rule node debug message", ex); + } + } + } + + private boolean checkLimits(TenantId tenantId, TbMsg tbMsg, Throwable error) { + if (debugPerTenantEnabled) { + DebugTbRateLimits debugTbRateLimits = debugPerTenantLimits.computeIfAbsent(tenantId, id -> + new DebugTbRateLimits(new TbRateLimits(debugPerTenantLimitsConfiguration), false)); + + if (!debugTbRateLimits.getTbRateLimits().tryConsume()) { + if (!debugTbRateLimits.isRuleChainEventSaved()) { + persistRuleChainDebugModeEvent(tenantId, tbMsg.getRuleChainId(), error); + debugTbRateLimits.setRuleChainEventSaved(true); + } + if (log.isTraceEnabled()) { + log.trace("[{}] Tenant level debug mode rate limit detected: {}", tenantId, tbMsg); + } + return false; + } + } + return true; + } + + private void persistRuleChainDebugModeEvent(TenantId tenantId, EntityId entityId, Throwable error) { + RuleChainDebugEvent.RuleChainDebugEventBuilder event = RuleChainDebugEvent.builder() + .tenantId(tenantId) + .entityId(entityId.getId()) + .serviceId(getServiceId()) + .message("Reached debug mode rate limit!"); + if (error != null) { + event.error(toString(error)); + } + + ListenableFuture future = eventService.saveAsync(event.build()); + Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); + } + + public static Exception toException(Throwable error) { + return Exception.class.isInstance(error) ? (Exception) error : new Exception(error); + } + + public void tell(TbActorMsg tbActorMsg) { + appActor.tell(tbActorMsg); + } + + public void tellWithHighPriority(TbActorMsg tbActorMsg) { + appActor.tellWithHighPriority(tbActorMsg); + } + + public void schedulePeriodicMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs, long periodInMs) { + log.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); + getScheduler().scheduleWithFixedDelay(() -> ctx.tell(msg), delayInMs, periodInMs, TimeUnit.MILLISECONDS); + } + + public void scheduleMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs) { + log.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); + if (delayInMs > 0) { + getScheduler().schedule(() -> ctx.tell(msg), delayInMs, TimeUnit.MILLISECONDS); + } else { + ctx.tell(msg); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/TbEntityTypeActorIdPredicate.java b/application/src/main/java/org/thingsboard/server/actors/TbEntityTypeActorIdPredicate.java new file mode 100644 index 0000000..9a8232e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/TbEntityTypeActorIdPredicate.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors; + +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.function.Predicate; + +@RequiredArgsConstructor +public class TbEntityTypeActorIdPredicate implements Predicate { + + private final EntityType entityType; + + @Override + public boolean test(TbActorId actorId) { + return actorId instanceof TbEntityActorId && testEntityId(((TbEntityActorId) actorId).getEntityId()); + } + + protected boolean testEntityId(EntityId entityId) { + return entityId.getEntityType().equals(entityType); + } +} 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 new file mode 100644 index 0000000..eeb6d8e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -0,0 +1,229 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.app; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.device.SessionTimeoutCheckMsg; +import org.thingsboard.server.actors.service.ContextAwareActor; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.actors.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.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; +import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import java.util.HashSet; +import java.util.Set; + +@Slf4j +public class AppActor extends ContextAwareActor { + + private final TenantService tenantService; + private final Set deletedTenants; + private volatile boolean ruleChainsInitialized; + + private AppActor(ActorSystemContext systemContext) { + super(systemContext); + this.tenantService = systemContext.getTenantService(); + this.deletedTenants = new HashSet<>(); + } + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + if (systemContext.getServiceInfoProvider().isService(ServiceType.TB_CORE)) { + systemContext.schedulePeriodicMsgWithDelay(ctx, SessionTimeoutCheckMsg.instance(), + systemContext.getSessionReportTimeout(), systemContext.getSessionReportTimeout()); + } + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + if (!ruleChainsInitialized) { + initTenantActors(); + ruleChainsInitialized = true; + if (msg.getMsgType() != MsgType.APP_INIT_MSG && msg.getMsgType() != MsgType.PARTITION_CHANGE_MSG) { + log.warn("Rule Chains initialized by unexpected message: {}", msg); + } + } + switch (msg.getMsgType()) { + case APP_INIT_MSG: + break; + case PARTITION_CHANGE_MSG: + ctx.broadcastToChildren(msg); + break; + case COMPONENT_LIFE_CYCLE_MSG: + onComponentLifecycleMsg((ComponentLifecycleMsg) msg); + break; + case QUEUE_TO_RULE_ENGINE_MSG: + onQueueToRuleEngineMsg((QueueToRuleEngineMsg) msg); + break; + case TRANSPORT_TO_DEVICE_ACTOR_MSG: + onToDeviceActorMsg((TenantAwareMsg) msg, false); + break; + 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: + case DEVICE_EDGE_UPDATE_TO_DEVICE_ACTOR_MSG: + case DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG: + case DEVICE_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: + case SERVER_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: + case REMOVE_RPC_TO_DEVICE_ACTOR_MSG: + onToDeviceActorMsg((TenantAwareMsg) msg, true); + break; + case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG: + case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG: + case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG: + onToEdgeSessionMsg((EdgeSessionMsg) msg); + break; + case SESSION_TIMEOUT_MSG: + ctx.broadcastToChildrenByType(msg, EntityType.TENANT); + break; + default: + return false; + } + return true; + } + + private void initTenantActors() { + log.info("Starting main system actor."); + try { + if (systemContext.isTenantComponentsInitEnabled()) { + PageDataIterable tenantIterator = new PageDataIterable<>(tenantService::findTenants, ENTITY_PACK_LIMIT); + for (Tenant tenant : tenantIterator) { + log.debug("[{}] Creating tenant actor", tenant.getId()); + getOrCreateTenantActor(tenant.getId()); + log.debug("[{}] Tenant actor created.", tenant.getId()); + } + } + log.info("Main system actor started."); + } catch (Exception e) { + log.warn("Unknown failure", e); + } + } + + private void onQueueToRuleEngineMsg(QueueToRuleEngineMsg msg) { + if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) { + msg.getMsg().getCallback().onFailure(new RuleEngineException("Message has system tenant id!")); + } else { + if (!deletedTenants.contains(msg.getTenantId())) { + getOrCreateTenantActor(msg.getTenantId()).tell(msg); + } else { + msg.getMsg().getCallback().onSuccess(); + } + } + } + + private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { + TbActorRef target = null; + if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) { + if (!EntityType.TENANT_PROFILE.equals(msg.getEntityId().getEntityType())) { + log.warn("Message has system tenant id: {}", msg); + } + } else { + if (EntityType.TENANT.equals(msg.getEntityId().getEntityType())) { + TenantId tenantId = TenantId.fromUUID(msg.getEntityId().getId()); + if (msg.getEvent() == ComponentLifecycleEvent.DELETED) { + log.info("[{}] Handling tenant deleted notification: {}", msg.getTenantId(), msg); + deletedTenants.add(tenantId); + ctx.stop(new TbEntityActorId(tenantId)); + } else { + target = getOrCreateTenantActor(msg.getTenantId()); + } + } else { + target = getOrCreateTenantActor(msg.getTenantId()); + } + } + if (target != null) { + target.tellWithHighPriority(msg); + } else { + log.debug("[{}] Invalid component lifecycle msg: {}", msg.getTenantId(), msg); + } + } + + private void onToDeviceActorMsg(TenantAwareMsg msg, boolean priority) { + if (!deletedTenants.contains(msg.getTenantId())) { + TbActorRef tenantActor = getOrCreateTenantActor(msg.getTenantId()); + if (priority) { + tenantActor.tellWithHighPriority(msg); + } else { + tenantActor.tell(msg); + } + } else { + if (msg instanceof TransportToDeviceActorMsgWrapper) { + ((TransportToDeviceActorMsgWrapper) msg).getCallback().onSuccess(); + } + } + } + + private TbActorRef getOrCreateTenantActor(TenantId tenantId) { + return ctx.getOrCreateChildActor(new TbEntityActorId(tenantId), + () -> DefaultActorService.TENANT_DISPATCHER_NAME, + () -> new TenantActor.ActorCreator(systemContext, tenantId)); + } + + private void onToEdgeSessionMsg(EdgeSessionMsg msg) { + TbActorRef target = null; + if (ModelConstants.SYSTEM_TENANT.equals(msg.getTenantId())) { + log.warn("Message has system tenant id: {}", msg); + } else { + target = getOrCreateTenantActor(msg.getTenantId()); + } + if (target != null) { + target.tellWithHighPriority(msg); + } else { + log.debug("[{}] Invalid edge session msg: {}", msg.getTenantId(), msg); + } + } + + public static class ActorCreator extends ContextBasedCreator { + + public ActorCreator(ActorSystemContext context) { + super(context); + } + + @Override + public TbActorId createActorId() { + return new TbEntityActorId(TenantId.SYS_TENANT_ID); + } + + @Override + public TbActor createActor() { + return new AppActor(context); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppInitMsg.java b/application/src/main/java/org/thingsboard/server/actors/app/AppInitMsg.java new file mode 100644 index 0000000..772c9ae --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppInitMsg.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.app; + +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +public class AppInitMsg implements TbActorMsg { + + @Override + public MsgType getMsgType() { + return MsgType.APP_INIT_MSG; + } +} 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 new file mode 100644 index 0000000..a0f49ba --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -0,0 +1,97 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.rule.engine.api.msg.DeviceEdgeUpdateMsg; +import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.actors.service.ContextAwareActor; +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.timeout.DeviceActorServerSideRpcTimeoutMsg; +import org.thingsboard.server.service.rpc.FromDeviceRpcResponseActorMsg; +import org.thingsboard.server.service.rpc.RemoveRpcActorMsg; +import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +@Slf4j +public class DeviceActor extends ContextAwareActor { + + private final DeviceActorMessageProcessor processor; + + DeviceActor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + super(systemContext); + this.processor = new DeviceActorMessageProcessor(systemContext, tenantId, deviceId); + } + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + log.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId); + try { + processor.init(ctx); + log.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId); + } catch (Exception e) { + log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.deviceId, e); + throw new TbActorException("Failed to initialize device actor", e); + } + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + switch (msg.getMsgType()) { + case TRANSPORT_TO_DEVICE_ACTOR_MSG: + processor.process(ctx, (TransportToDeviceActorMsgWrapper) msg); + break; + case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: + processor.processAttributesUpdate(ctx, (DeviceAttributesEventNotificationMsg) msg); + break; + case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: + processor.processCredentialsUpdate(msg); + break; + case DEVICE_NAME_OR_TYPE_UPDATE_TO_DEVICE_ACTOR_MSG: + processor.processNameOrTypeUpdate((DeviceNameOrTypeUpdateMsg) msg); + break; + case DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG: + processor.processRpcRequest(ctx, (ToDeviceRpcRequestActorMsg) msg); + break; + case DEVICE_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: + processor.processRpcResponsesFromEdge(ctx, (FromDeviceRpcResponseActorMsg) msg); + break; + case DEVICE_ACTOR_SERVER_SIDE_RPC_TIMEOUT_MSG: + processor.processServerSideRpcTimeout(ctx, (DeviceActorServerSideRpcTimeoutMsg) msg); + break; + case SESSION_TIMEOUT_MSG: + processor.checkSessionsTimeout(); + break; + case DEVICE_EDGE_UPDATE_TO_DEVICE_ACTOR_MSG: + processor.processEdgeUpdate((DeviceEdgeUpdateMsg) msg); + break; + case REMOVE_RPC_TO_DEVICE_ACTOR_MSG: + processor.processRemoveRpc(ctx, (RemoveRpcActorMsg) msg); + break; + default: + return false; + } + return true; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java new file mode 100644 index 0000000..7a369a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; + +public class DeviceActorCreator extends ContextBasedCreator { + + private final TenantId tenantId; + private final DeviceId deviceId; + + public DeviceActorCreator(ActorSystemContext context, TenantId tenantId, DeviceId deviceId) { + super(context); + this.tenantId = tenantId; + this.deviceId = deviceId; + } + + @Override + public TbActorId createActorId() { + return new TbEntityActorId(deviceId); + } + + @Override + public TbActor createActor() { + 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 new file mode 100644 index 0000000..8a365b4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -0,0 +1,1003 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.InvalidProtocolBufferException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.LinkedHashMapRemoveEldest; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.rule.engine.api.msg.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.rule.engine.api.msg.DeviceEdgeUpdateMsg; +import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +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.EdgeUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.data.rpc.RpcStatus; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +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.ClaimDeviceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; +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.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.SessionSubscriptionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscriptionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseStatusMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportUpdateCredentialsProto; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; +import org.thingsboard.server.service.rpc.FromDeviceRpcResponseActorMsg; +import org.thingsboard.server.service.rpc.RemoveRpcActorMsg; +import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.stream.Collectors; + + +/** + * @author Andrew Shvayka + */ +@Slf4j +class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { + + static final String SESSION_TIMEOUT_MESSAGE = "session timeout!"; + final TenantId tenantId; + final DeviceId deviceId; + final LinkedHashMapRemoveEldest sessions; + private final Map attributeSubscriptions; + private final Map rpcSubscriptions; + private final Map toDeviceRpcPendingMap; + private final boolean rpcSequential; + + private int rpcSeq = 0; + private String deviceName; + private String deviceType; + private TbMsgMetaData defaultMetaData; + private EdgeId edgeId; + + DeviceActorMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + super(systemContext); + this.tenantId = tenantId; + this.deviceId = deviceId; + this.rpcSequential = systemContext.isRpcSequential(); + this.attributeSubscriptions = new HashMap<>(); + this.rpcSubscriptions = new HashMap<>(); + this.toDeviceRpcPendingMap = new LinkedHashMap<>(); + this.sessions = new LinkedHashMapRemoveEldest<>(systemContext.getMaxConcurrentSessionsPerDevice(), this::notifyTransportAboutClosedSessionMaxSessionsLimit); + if (initAttributes()) { + restoreSessions(); + } + } + + boolean initAttributes() { + Device device = systemContext.getDeviceService().findDeviceById(tenantId, deviceId); + if (device != null) { + this.deviceName = device.getName(); + this.deviceType = device.getType(); + this.defaultMetaData = new TbMsgMetaData(); + this.defaultMetaData.putValue("deviceName", deviceName); + this.defaultMetaData.putValue("deviceType", deviceType); + if (systemContext.isEdgesEnabled()) { + this.edgeId = findRelatedEdgeId(); + } + return true; + } else { + return false; + } + } + + private EdgeId findRelatedEdgeId() { + List result = + systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.EDGE_TYPE, RelationTypeGroup.COMMON); + if (result != null && result.size() > 0) { + EntityRelation relationToEdge = result.get(0); + if (relationToEdge.getFrom() != null && relationToEdge.getFrom().getId() != null) { + log.trace("[{}][{}] found edge [{}] for device", tenantId, deviceId, relationToEdge.getFrom().getId()); + return new EdgeId(relationToEdge.getFrom().getId()); + } else { + log.trace("[{}][{}] edge relation is empty {}", tenantId, deviceId, relationToEdge); + } + } else { + log.trace("[{}][{}] device doesn't have any related edge", tenantId, deviceId); + } + return null; + } + + void processRpcRequest(TbActorCtx context, ToDeviceRpcRequestActorMsg msg) { + ToDeviceRpcRequest request = msg.getMsg(); + ToDeviceRpcRequestMsg rpcRequest = creteToDeviceRpcRequestMsg(request); + + long timeout = request.getExpirationTime() - System.currentTimeMillis(); + boolean persisted = request.isPersisted(); + + if (timeout <= 0) { + log.debug("[{}][{}] Ignoring message due to exp time reached, {}", deviceId, request.getId(), request.getExpirationTime()); + if (persisted) { + createRpc(request, RpcStatus.EXPIRED); + } + return; + } else if (persisted) { + createRpc(request, RpcStatus.QUEUED); + } + + boolean sent = false; + if (systemContext.isEdgesEnabled() && edgeId != null) { + log.debug("[{}][{}] device is related to edge [{}]. Saving RPC request to edge queue", tenantId, deviceId, edgeId.getId()); + try { + saveRpcRequestToEdgeQueue(request, rpcRequest.getRequestId()).get(); + sent = true; + } catch (InterruptedException | ExecutionException e) { + log.error("[{}][{}][{}] Failed to save rpc request to edge queue {}", tenantId, deviceId, edgeId.getId(), request, e); + } + } else if (isSendNewRpcAvailable()) { + sent = rpcSubscriptions.size() > 0; + Set syncSessionSet = new HashSet<>(); + rpcSubscriptions.forEach((key, value) -> { + sendToTransport(rpcRequest, key, value.getNodeId()); + if (SessionType.SYNC == value.getType()) { + syncSessionSet.add(key); + } + }); + log.trace("Rpc syncSessionSet [{}] subscription after sent [{}]", syncSessionSet, rpcSubscriptions); + syncSessionSet.forEach(rpcSubscriptions::remove); + } + + if (persisted) { + ObjectNode response = JacksonUtil.newObjectNode(); + response.put("rpcId", request.getId().toString()); + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(new FromDeviceRpcResponse(msg.getMsg().getId(), JacksonUtil.toString(response), null)); + } + + if (!persisted && request.isOneway() && sent) { + log.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(new FromDeviceRpcResponse(msg.getMsg().getId(), null, null)); + } else { + registerPendingRpcRequest(context, msg, sent, rpcRequest, timeout); + } + if (sent) { + log.debug("[{}] RPC request {} is sent!", deviceId, request.getId()); + } else { + log.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); + } + } + + private boolean isSendNewRpcAvailable() { + return !rpcSequential || toDeviceRpcPendingMap.values().stream().filter(md -> !md.isDelivered()).findAny().isEmpty(); + } + + private Rpc createRpc(ToDeviceRpcRequest request, RpcStatus status) { + Rpc rpc = new Rpc(new RpcId(request.getId())); + rpc.setCreatedTime(System.currentTimeMillis()); + rpc.setTenantId(tenantId); + rpc.setDeviceId(deviceId); + rpc.setExpirationTime(request.getExpirationTime()); + rpc.setRequest(JacksonUtil.valueToTree(request)); + rpc.setStatus(status); + rpc.setAdditionalInfo(JacksonUtil.toJsonNode(request.getAdditionalInfo())); + return systemContext.getTbRpcService().save(tenantId, rpc); + } + + private ToDeviceRpcRequestMsg creteToDeviceRpcRequestMsg(ToDeviceRpcRequest request) { + ToDeviceRpcRequestBody body = request.getBody(); + return ToDeviceRpcRequestMsg.newBuilder() + .setRequestId(rpcSeq++) + .setMethodName(body.getMethod()) + .setParams(body.getParams()) + .setExpirationTime(request.getExpirationTime()) + .setRequestIdMSB(request.getId().getMostSignificantBits()) + .setRequestIdLSB(request.getId().getLeastSignificantBits()) + .setOneway(request.isOneway()) + .setPersisted(request.isPersisted()) + .build(); + } + + void processRpcResponsesFromEdge(TbActorCtx context, FromDeviceRpcResponseActorMsg responseMsg) { + log.debug("[{}] Processing rpc command response from edge session", deviceId); + ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); + boolean success = requestMd != null; + if (success) { + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(responseMsg.getMsg()); + } else { + log.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); + } + } + + void processRemoveRpc(TbActorCtx context, RemoveRpcActorMsg msg) { + log.debug("[{}] Processing remove rpc command", msg.getRequestId()); + Map.Entry entry = null; + for (Map.Entry e : toDeviceRpcPendingMap.entrySet()) { + if (e.getValue().getMsg().getMsg().getId().equals(msg.getRequestId())) { + entry = e; + break; + } + } + + if (entry != null) { + if (entry.getValue().isDelivered()) { + toDeviceRpcPendingMap.remove(entry.getKey()); + } else { + Optional> firstRpc = getFirstRpc(); + if (firstRpc.isPresent() && entry.getKey().equals(firstRpc.get().getKey())) { + toDeviceRpcPendingMap.remove(entry.getKey()); + sendNextPendingRequest(context); + } else { + toDeviceRpcPendingMap.remove(entry.getKey()); + } + } + } + } + + private void registerPendingRpcRequest(TbActorCtx context, ToDeviceRpcRequestActorMsg msg, boolean sent, ToDeviceRpcRequestMsg rpcRequest, long timeout) { + toDeviceRpcPendingMap.put(rpcRequest.getRequestId(), new ToDeviceRpcRequestMetadata(msg, sent)); + DeviceActorServerSideRpcTimeoutMsg timeoutMsg = new DeviceActorServerSideRpcTimeoutMsg(rpcRequest.getRequestId(), timeout); + scheduleMsgWithDelay(context, timeoutMsg, timeoutMsg.getTimeout()); + } + + void processServerSideRpcTimeout(TbActorCtx context, DeviceActorServerSideRpcTimeoutMsg msg) { + ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(msg.getId()); + if (requestMd != null) { + log.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); + if (requestMd.getMsg().getMsg().isPersisted()) { + systemContext.getTbRpcService().save(tenantId, new RpcId(requestMd.getMsg().getMsg().getId()), RpcStatus.EXPIRED, null); + } + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); + if (!requestMd.isDelivered()) { + sendNextPendingRequest(context); + } + } + } + + private void sendPendingRequests(TbActorCtx context, UUID sessionId, String nodeId) { + SessionType sessionType = getSessionType(sessionId); + if (!toDeviceRpcPendingMap.isEmpty()) { + log.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); + if (sessionType == SessionType.SYNC) { + log.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); + rpcSubscriptions.remove(sessionId); + } + } else { + log.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); + } + Set sentOneWayIds = new HashSet<>(); + + if (rpcSequential) { + getFirstRpc().ifPresent(processPendingRpc(context, sessionId, nodeId, sentOneWayIds)); + } else if (sessionType == SessionType.ASYNC) { + toDeviceRpcPendingMap.entrySet().forEach(processPendingRpc(context, sessionId, nodeId, sentOneWayIds)); + } else { + toDeviceRpcPendingMap.entrySet().stream().findFirst().ifPresent(processPendingRpc(context, sessionId, nodeId, sentOneWayIds)); + } + + sentOneWayIds.stream().filter(id -> !toDeviceRpcPendingMap.get(id).getMsg().getMsg().isPersisted()).forEach(toDeviceRpcPendingMap::remove); + } + + private Optional> getFirstRpc() { + return toDeviceRpcPendingMap.entrySet().stream().filter(e -> !e.getValue().isDelivered()).findFirst(); + } + + private void sendNextPendingRequest(TbActorCtx context) { + if (rpcSequential) { + rpcSubscriptions.forEach((id, s) -> sendPendingRequests(context, id, s.getNodeId())); + } + } + + private Consumer> processPendingRpc(TbActorCtx context, UUID sessionId, String nodeId, Set sentOneWayIds) { + return entry -> { + ToDeviceRpcRequest request = entry.getValue().getMsg().getMsg(); + ToDeviceRpcRequestBody body = request.getBody(); + if (request.isOneway() && !rpcSequential) { + sentOneWayIds.add(entry.getKey()); + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(new FromDeviceRpcResponse(request.getId(), null, null)); + } + ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder() + .setRequestId(entry.getKey()) + .setMethodName(body.getMethod()) + .setParams(body.getParams()) + .setExpirationTime(request.getExpirationTime()) + .setRequestIdMSB(request.getId().getMostSignificantBits()) + .setRequestIdLSB(request.getId().getLeastSignificantBits()) + .setOneway(request.isOneway()) + .setPersisted(request.isPersisted()) + .build(); + sendToTransport(rpcRequest, sessionId, nodeId); + }; + } + + void process(TbActorCtx context, TransportToDeviceActorMsgWrapper wrapper) { + TransportToDeviceActorMsg msg = wrapper.getMsg(); + TbCallback callback = wrapper.getCallback(); + var sessionInfo = msg.getSessionInfo(); + + if (msg.hasSessionEvent()) { + processSessionStateMsgs(sessionInfo, msg.getSessionEvent()); + } + if (msg.hasSubscribeToAttributes()) { + processSubscriptionCommands(context, sessionInfo, msg.getSubscribeToAttributes()); + } + if (msg.hasSubscribeToRPC()) { + processSubscriptionCommands(context, sessionInfo, msg.getSubscribeToRPC()); + } + if (msg.hasSendPendingRPC()) { + sendPendingRequests(context, getSessionId(sessionInfo), sessionInfo.getNodeId()); + } + if (msg.hasGetAttributes()) { + handleGetAttributesRequest(context, sessionInfo, msg.getGetAttributes()); + } + if (msg.hasToDeviceRPCCallResponse()) { + processRpcResponses(context, sessionInfo, msg.getToDeviceRPCCallResponse()); + } + if (msg.hasSubscriptionInfo()) { + handleSessionActivity(context, sessionInfo, msg.getSubscriptionInfo()); + } + if (msg.hasClaimDevice()) { + handleClaimDeviceMsg(context, sessionInfo, msg.getClaimDevice()); + } + if (msg.hasRpcResponseStatusMsg()) { + processRpcResponseStatus(context, sessionInfo, msg.getRpcResponseStatusMsg()); + } + if (msg.hasUplinkNotificationMsg()) { + processUplinkNotificationMsg(context, sessionInfo, msg.getUplinkNotificationMsg()); + } + callback.onSuccess(); + } + + private void processUplinkNotificationMsg(TbActorCtx context, SessionInfoProto sessionInfo, TransportProtos.UplinkNotificationMsg uplinkNotificationMsg) { + String nodeId = sessionInfo.getNodeId(); + sessions.entrySet().stream() + .filter(kv -> kv.getValue().getSessionInfo().getNodeId().equals(nodeId) && (kv.getValue().isSubscribedToAttributes() || kv.getValue().isSubscribedToRPC())) + .forEach(kv -> { + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(kv.getKey().getMostSignificantBits()) + .setSessionIdLSB(kv.getKey().getLeastSignificantBits()) + .setUplinkNotificationMsg(uplinkNotificationMsg) + .build(); + systemContext.getTbCoreToTransportService().process(kv.getValue().getSessionInfo().getNodeId(), msg); + }); + } + + private void handleClaimDeviceMsg(TbActorCtx context, SessionInfoProto sessionInfo, ClaimDeviceMsg msg) { + DeviceId deviceId = new DeviceId(new UUID(msg.getDeviceIdMSB(), msg.getDeviceIdLSB())); + systemContext.getClaimDevicesService().registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs()); + } + + private void reportSessionOpen() { + systemContext.getDeviceStateService().onDeviceConnect(tenantId, deviceId); + } + + private void reportSessionClose() { + systemContext.getDeviceStateService().onDeviceDisconnect(tenantId, deviceId); + } + + private void handleGetAttributesRequest(TbActorCtx context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { + int requestId = request.getRequestId(); + if (request.getOnlyShared()) { + Futures.addCallback(findAllAttributesByScope(DataConstants.SHARED_SCOPE), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List result) { + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setRequestId(requestId) + .setSharedStateMsg(true) + .addAllSharedAttributeList(toTsKvProtos(result)) + .setIsMultipleAttributesRequest(request.getSharedAttributeNamesCount() > 1) + .build(); + sendToTransport(responseMsg, sessionInfo); + } + + @Override + public void onFailure(Throwable t) { + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setError(t.getMessage()) + .setSharedStateMsg(true) + .build(); + sendToTransport(responseMsg, sessionInfo); + } + }, MoreExecutors.directExecutor()); + } else { + Futures.addCallback(getAttributesKvEntries(request), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List> result) { + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setRequestId(requestId) + .addAllClientAttributeList(toTsKvProtos(result.get(0))) + .addAllSharedAttributeList(toTsKvProtos(result.get(1))) + .setIsMultipleAttributesRequest( + request.getSharedAttributeNamesCount() + request.getClientAttributeNamesCount() > 1) + .build(); + sendToTransport(responseMsg, sessionInfo); + } + + @Override + public void onFailure(Throwable t) { + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setError(t.getMessage()) + .build(); + sendToTransport(responseMsg, sessionInfo); + } + }, MoreExecutors.directExecutor()); + } + } + + private ListenableFuture>> getAttributesKvEntries(GetAttributeRequestMsg request) { + ListenableFuture> clientAttributesFuture; + ListenableFuture> sharedAttributesFuture; + if (CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { + clientAttributesFuture = findAllAttributesByScope(DataConstants.CLIENT_SCOPE); + sharedAttributesFuture = findAllAttributesByScope(DataConstants.SHARED_SCOPE); + } else if (!CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && !CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { + clientAttributesFuture = findAttributesByScope(toSet(request.getClientAttributeNamesList()), DataConstants.CLIENT_SCOPE); + sharedAttributesFuture = findAttributesByScope(toSet(request.getSharedAttributeNamesList()), DataConstants.SHARED_SCOPE); + } else if (CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && !CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { + clientAttributesFuture = Futures.immediateFuture(Collections.emptyList()); + sharedAttributesFuture = findAttributesByScope(toSet(request.getSharedAttributeNamesList()), DataConstants.SHARED_SCOPE); + } else { + sharedAttributesFuture = Futures.immediateFuture(Collections.emptyList()); + clientAttributesFuture = findAttributesByScope(toSet(request.getClientAttributeNamesList()), DataConstants.CLIENT_SCOPE); + } + return Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)); + } + + private ListenableFuture> findAllAttributesByScope(String scope) { + return systemContext.getAttributesService().findAll(tenantId, deviceId, scope); + } + + private ListenableFuture> findAttributesByScope(Set attributesSet, String scope) { + return systemContext.getAttributesService().find(tenantId, deviceId, scope, attributesSet); + } + + private Set toSet(List strings) { + return new HashSet<>(strings); + } + + private SessionType getSessionType(UUID sessionId) { + return sessions.containsKey(sessionId) ? SessionType.ASYNC : SessionType.SYNC; + } + + void processAttributesUpdate(TbActorCtx context, DeviceAttributesEventNotificationMsg msg) { + if (attributeSubscriptions.size() > 0) { + boolean hasNotificationData = false; + AttributeUpdateNotificationMsg.Builder notification = AttributeUpdateNotificationMsg.newBuilder(); + if (msg.isDeleted()) { + List sharedKeys = msg.getDeletedKeys().stream() + .filter(key -> DataConstants.SHARED_SCOPE.equals(key.getScope())) + .map(AttributeKey::getAttributeKey) + .collect(Collectors.toList()); + if (!sharedKeys.isEmpty()) { + notification.addAllSharedDeleted(sharedKeys); + hasNotificationData = true; + } + } else { + if (DataConstants.SHARED_SCOPE.equals(msg.getScope())) { + List attributes = new ArrayList<>(msg.getValues()); + if (attributes.size() > 0) { + List sharedUpdated = msg.getValues().stream().map(this::toTsKvProto) + .collect(Collectors.toList()); + if (!sharedUpdated.isEmpty()) { + notification.addAllSharedUpdated(sharedUpdated); + hasNotificationData = true; + } + } else { + log.debug("[{}] No public shared side attributes changed!", deviceId); + } + } + } + if (hasNotificationData) { + AttributeUpdateNotificationMsg finalNotification = notification.build(); + attributeSubscriptions.forEach((key, value) -> sendToTransport(finalNotification, key, value.getNodeId())); + } + } else { + log.debug("[{}] No registered attributes subscriptions to process!", deviceId); + } + } + + private void processRpcResponses(TbActorCtx 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) { + boolean hasError = StringUtils.isNotEmpty(responseMsg.getError()); + try { + String payload = hasError ? responseMsg.getError() : responseMsg.getPayload(); + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor( + new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + payload, null)); + if (requestMd.getMsg().getMsg().isPersisted()) { + RpcStatus status = hasError ? RpcStatus.FAILED : RpcStatus.SUCCESSFUL; + JsonNode response; + try { + response = JacksonUtil.toJsonNode(payload); + } catch (IllegalArgumentException e) { + response = JacksonUtil.newObjectNode().put("error", payload); + } + systemContext.getTbRpcService().save(tenantId, new RpcId(requestMd.getMsg().getMsg().getId()), status, response); + } + } finally { + if (hasError && !requestMd.isDelivered()) { + sendNextPendingRequest(context); + } + } + } else { + log.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); + } + } + + private void processRpcResponseStatus(TbActorCtx context, SessionInfoProto sessionInfo, ToDeviceRpcResponseStatusMsg responseMsg) { + UUID rpcId = new UUID(responseMsg.getRequestIdMSB(), responseMsg.getRequestIdLSB()); + RpcStatus status = RpcStatus.valueOf(responseMsg.getStatus()); + ToDeviceRpcRequestMetadata md = toDeviceRpcPendingMap.get(responseMsg.getRequestId()); + + if (md != null) { + JsonNode response = null; + if (status.equals(RpcStatus.DELIVERED)) { + if (md.getMsg().getMsg().isOneway()) { + toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); + if (rpcSequential) { + systemContext.getTbCoreDeviceRpcService().processRpcResponseFromDeviceActor(new FromDeviceRpcResponse(rpcId, null, null)); + } + } else { + md.setDelivered(true); + } + } else if (status.equals(RpcStatus.TIMEOUT)) { + Integer maxRpcRetries = md.getMsg().getMsg().getRetries(); + maxRpcRetries = maxRpcRetries == null ? systemContext.getMaxRpcRetries() : Math.min(maxRpcRetries, systemContext.getMaxRpcRetries()); + if (maxRpcRetries <= md.getRetries()) { + toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); + status = RpcStatus.FAILED; + response = JacksonUtil.newObjectNode().put("error", "There was a Timeout and all retry attempts have been exhausted. Retry attempts set: " + maxRpcRetries); + } else { + md.setRetries(md.getRetries() + 1); + } + } + + if (md.getMsg().getMsg().isPersisted()) { + systemContext.getTbRpcService().save(tenantId, new RpcId(rpcId), status, response); + } + if (status != RpcStatus.SENT) { + sendNextPendingRequest(context); + } + } else { + log.info("[{}][{}] Rpc has already removed from pending map.", deviceId, rpcId); + } + } + + private void processSubscriptionCommands(TbActorCtx 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(subscribeCmd.getSessionType(), sessionInfo.getNodeId())); + } + sessionMD.setSubscribedToAttributes(true); + log.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); + attributeSubscriptions.put(sessionId, sessionMD.getSessionInfo()); + dumpSessions(); + } + } + + private UUID getSessionId(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + private void processSubscriptionCommands(TbActorCtx 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(subscribeCmd.getSessionType(), sessionInfo.getNodeId())); + } + sessionMD.setSubscribedToRPC(true); + log.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); + rpcSubscriptions.put(sessionId, sessionMD.getSessionInfo()); + sendPendingRequests(context, sessionId, sessionInfo.getNodeId()); + dumpSessions(); + } + } + + private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { + UUID sessionId = getSessionId(sessionInfo); + Objects.requireNonNull(sessionId); + if (msg.getEvent() == SessionEvent.OPEN) { + if (sessions.containsKey(sessionId)) { + log.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); + return; + } + log.debug("[{}] Processing new session [{}]. Current sessions size {}", deviceId, sessionId, sessions.size()); + + sessions.put(sessionId, new SessionInfoMetaData(new SessionInfo(SessionType.ASYNC, sessionInfo.getNodeId()))); + if (sessions.size() == 1) { + reportSessionOpen(); + } + systemContext.getDeviceStateService().onDeviceActivity(tenantId, deviceId, System.currentTimeMillis()); + 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 handleSessionActivity(TbActorCtx context, SessionInfoProto sessionInfoProto, SubscriptionInfoProto subscriptionInfo) { + UUID sessionId = getSessionId(sessionInfoProto); + Objects.requireNonNull(sessionId); + + 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()); + } + } + systemContext.getDeviceStateService().onDeviceActivity(tenantId, deviceId, subscriptionInfo.getLastActivityTime()); + if (sessionMD != null) { + dumpSessions(); + } + } + + void processCredentialsUpdate(TbActorMsg msg) { + if (((DeviceCredentialsUpdateNotificationMsg) msg).getDeviceCredentials().getCredentialsType() == DeviceCredentialsType.LWM2M_CREDENTIALS) { + sessions.forEach((k, v) -> { + notifyTransportAboutDeviceCredentialsUpdate(k, v, ((DeviceCredentialsUpdateNotificationMsg) msg).getDeviceCredentials()); + }); + } else { + sessions.forEach((sessionId, sessionMd) -> notifyTransportAboutClosedSession(sessionId, sessionMd, "device credentials updated!")); + attributeSubscriptions.clear(); + rpcSubscriptions.clear(); + dumpSessions(); + + } + } + + private void notifyTransportAboutClosedSessionMaxSessionsLimit(UUID sessionId, SessionInfoMetaData sessionMd) { + log.debug("remove eldest session (max concurrent sessions limit reached per device) sessionId [{}] sessionMd [{}]", sessionId, sessionMd); + notifyTransportAboutClosedSession(sessionId, sessionMd, "max concurrent sessions limit reached per device!"); + } + + private void notifyTransportAboutClosedSession(UUID sessionId, SessionInfoMetaData sessionMd, String message) { + SessionCloseNotificationProto sessionCloseNotificationProto = SessionCloseNotificationProto + .newBuilder() + .setMessage(message).build(); + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setSessionCloseNotification(sessionCloseNotificationProto) + .build(); + systemContext.getTbCoreToTransportService().process(sessionMd.getSessionInfo().getNodeId(), msg); + } + + void notifyTransportAboutDeviceCredentialsUpdate(UUID sessionId, SessionInfoMetaData sessionMd, DeviceCredentials deviceCredentials) { + ToTransportUpdateCredentialsProto.Builder notification = ToTransportUpdateCredentialsProto.newBuilder(); + notification.addCredentialsId(deviceCredentials.getCredentialsId()); + notification.addCredentialsValue(deviceCredentials.getCredentialsValue()); + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToTransportUpdateCredentialsNotification(notification).build(); + systemContext.getTbCoreToTransportService().process(sessionMd.getSessionInfo().getNodeId(), msg); + } + + void processNameOrTypeUpdate(DeviceNameOrTypeUpdateMsg msg) { + this.deviceName = msg.getDeviceName(); + this.deviceType = msg.getDeviceType(); + this.defaultMetaData = new TbMsgMetaData(); + this.defaultMetaData.putValue("deviceName", deviceName); + this.defaultMetaData.putValue("deviceType", deviceType); + } + + void processEdgeUpdate(DeviceEdgeUpdateMsg msg) { + log.trace("[{}] Processing edge update {}", deviceId, msg); + this.edgeId = msg.getEdgeId(); + } + + private void sendToTransport(GetAttributeResponseMsg responseMsg, SessionInfoProto sessionInfo) { + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionInfo.getSessionIdMSB()) + .setSessionIdLSB(sessionInfo.getSessionIdLSB()) + .setGetAttributesResponse(responseMsg).build(); + systemContext.getTbCoreToTransportService().process(sessionInfo.getNodeId(), msg); + } + + private void sendToTransport(AttributeUpdateNotificationMsg notificationMsg, UUID sessionId, String nodeId) { + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setAttributeUpdateNotification(notificationMsg).build(); + systemContext.getTbCoreToTransportService().process(nodeId, msg); + } + + private void sendToTransport(ToDeviceRpcRequestMsg rpcMsg, UUID sessionId, String nodeId) { + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToDeviceRequest(rpcMsg).build(); + systemContext.getTbCoreToTransportService().process(nodeId, msg); + } + + private void sendToTransport(ToServerRpcResponseMsg rpcMsg, UUID sessionId, String nodeId) { + ToTransportMsg msg = ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToServerResponse(rpcMsg).build(); + systemContext.getTbCoreToTransportService().process(nodeId, msg); + } + + private ListenableFuture saveRpcRequestToEdgeQueue(ToDeviceRpcRequest msg, Integer requestId) { + ObjectNode body = mapper.createObjectNode(); + body.put("requestId", requestId); + body.put("requestUUID", msg.getId().toString()); + body.put("oneway", msg.isOneway()); + body.put("expirationTime", msg.getExpirationTime()); + body.put("method", msg.getBody().getMethod()); + body.put("params", msg.getBody().getParams()); + body.put("persisted", msg.isPersisted()); + body.put("retries", msg.getRetries()); + body.put("additionalInfo", msg.getAdditionalInfo()); + + EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(tenantId, edgeId, EdgeEventType.DEVICE, EdgeEventActionType.RPC_CALL, deviceId, body); + + return Futures.transform(systemContext.getEdgeEventService().saveAsync(edgeEvent), unused -> { + systemContext.getClusterService().onEdgeEventUpdate(tenantId, edgeId); + return null; + }, systemContext.getDbCallbackExecutor()); + } + + private List toTsKvProtos(@Nullable List result) { + List clientAttributes; + if (result == null || result.isEmpty()) { + clientAttributes = Collections.emptyList(); + } else { + clientAttributes = new ArrayList<>(result.size()); + for (AttributeKvEntry attrEntry : result) { + clientAttributes.add(toTsKvProto(attrEntry)); + } + } + return clientAttributes; + } + + private TsKvProto toTsKvProto(AttributeKvEntry attrEntry) { + return TsKvProto.newBuilder().setTs(attrEntry.getLastUpdateTs()) + .setKv(toKeyValueProto(attrEntry)).build(); + } + + private KeyValueProto toKeyValueProto(KvEntry kvEntry) { + KeyValueProto.Builder builder = KeyValueProto.newBuilder(); + builder.setKey(kvEntry.getKey()); + switch (kvEntry.getDataType()) { + case BOOLEAN: + builder.setType(KeyValueType.BOOLEAN_V); + builder.setBoolV(kvEntry.getBooleanValue().get()); + break; + case DOUBLE: + builder.setType(KeyValueType.DOUBLE_V); + builder.setDoubleV(kvEntry.getDoubleValue().get()); + break; + case LONG: + builder.setType(KeyValueType.LONG_V); + builder.setLongV(kvEntry.getLongValue().get()); + break; + case STRING: + builder.setType(KeyValueType.STRING_V); + builder.setStringV(kvEntry.getStrValue().get()); + break; + case JSON: + builder.setType(KeyValueType.JSON_V); + builder.setJsonV(kvEntry.getJsonValue().get()); + break; + } + return builder.build(); + } + + void restoreSessions() { + if (systemContext.isLocalCacheType()) { + return; + } + log.debug("[{}] Restoring sessions from cache", deviceId); + DeviceSessionsCacheEntry sessionsDump; + try { + sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId); + } catch (Exception e) { + log.warn("[{}] Failed to decode device sessions from cache", deviceId); + return; + } + if (sessionsDump.getSessionsCount() == 0) { + log.debug("[{}] No session information found", deviceId); + return; + } + // TODO: Take latest max allowed sessions size from cache + for (SessionSubscriptionInfoProto sessionSubscriptionInfoProto : sessionsDump.getSessionsList()) { + SessionInfoProto sessionInfoProto = sessionSubscriptionInfoProto.getSessionInfo(); + UUID sessionId = getSessionId(sessionInfoProto); + SessionInfo sessionInfo = new SessionInfo(SessionType.ASYNC, sessionInfoProto.getNodeId()); + 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() { + if (systemContext.isLocalCacheType()) { + return; + } + log.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); + List sessionsList = new ArrayList<>(sessions.size()); + sessions.forEach((uuid, sessionMD) -> { + if (sessionMD.getSessionInfo().getType() == SessionType.SYNC) { + return; + } + SessionInfo sessionInfo = sessionMD.getSessionInfo(); + SubscriptionInfoProto subscriptionInfoProto = SubscriptionInfoProto.newBuilder() + .setLastActivityTime(sessionMD.getLastActivityTime()) + .setAttributeSubscription(sessionMD.isSubscribedToAttributes()) + .setRpcSubscription(sessionMD.isSubscribedToRPC()).build(); + SessionInfoProto sessionInfoProto = SessionInfoProto.newBuilder() + .setSessionIdMSB(uuid.getMostSignificantBits()) + .setSessionIdLSB(uuid.getLeastSignificantBits()) + .setNodeId(sessionInfo.getNodeId()).build(); + sessionsList.add(SessionSubscriptionInfoProto.newBuilder() + .setSessionInfo(sessionInfoProto) + .setSubscriptionInfo(subscriptionInfoProto).build()); + log.debug("[{}] Dumping session: {}", deviceId, sessionMD); + }); + systemContext.getDeviceSessionCacheService() + .put(deviceId, DeviceSessionsCacheEntry.newBuilder() + .addAllSessions(sessionsList).build()); + } + + void init(TbActorCtx ctx) { + PageLink pageLink = new PageLink(1024, 0, null, new SortOrder("createdTime")); + PageData pageData; + do { + pageData = systemContext.getTbRpcService().findAllByDeviceIdAndStatus(tenantId, deviceId, RpcStatus.QUEUED, pageLink); + pageData.getData().forEach(rpc -> { + ToDeviceRpcRequest msg = JacksonUtil.convertValue(rpc.getRequest(), ToDeviceRpcRequest.class); + long timeout = rpc.getExpirationTime() - System.currentTimeMillis(); + if (timeout <= 0) { + rpc.setStatus(RpcStatus.EXPIRED); + systemContext.getTbRpcService().save(tenantId, rpc); + } else { + registerPendingRpcRequest(ctx, new ToDeviceRpcRequestActorMsg(systemContext.getServiceId(), msg), false, creteToDeviceRpcRequestMsg(msg), timeout); + } + }); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + } + + void checkSessionsTimeout() { + final long expTime = System.currentTimeMillis() - systemContext.getSessionInactivityTimeout(); + List expiredIds = null; + + for (Map.Entry kv : sessions.entrySet()) { //entry set are cached for stable sessions + if (kv.getValue().getLastActivityTime() < expTime) { + final UUID id = kv.getKey(); + if (expiredIds == null) { + expiredIds = new ArrayList<>(1); //most of the expired sessions is a single event + } + expiredIds.add(id); + } + } + + if (expiredIds != null) { + int removed = 0; + for (UUID id : expiredIds) { + final SessionInfoMetaData session = sessions.remove(id); + rpcSubscriptions.remove(id); + attributeSubscriptions.remove(id); + if (session != null) { + removed++; + notifyTransportAboutClosedSession(id, session, SESSION_TIMEOUT_MESSAGE); + } + } + if (removed != 0) { + 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 new file mode 100644 index 0000000..01e4765 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; + +/** + * @author Andrew Shvayka + */ +@Data +public class SessionInfo { + private final SessionType type; + private final String nodeId; +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java new file mode 100644 index 0000000..a26fe90 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; + +/** + * @author Andrew Shvayka + */ +@Data +class SessionInfoMetaData { + private final SessionInfo sessionInfo; + private long lastActivityTime; + private boolean subscribedToAttributes; + private boolean subscribedToRPC; + + SessionInfoMetaData(SessionInfo sessionInfo) { + this(sessionInfo, System.currentTimeMillis()); + } + + SessionInfoMetaData(SessionInfo sessionInfo, long lastActivityTime) { + this.sessionInfo = sessionInfo; + this.lastActivityTime = lastActivityTime; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java new file mode 100644 index 0000000..6815aee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +/** + * Created by ashvayka on 29.10.18. + */ +public class SessionTimeoutCheckMsg implements TbActorMsg { + + private static final SessionTimeoutCheckMsg INSTANCE = new SessionTimeoutCheckMsg(); + + private SessionTimeoutCheckMsg() { + } + + public static SessionTimeoutCheckMsg instance() { + return INSTANCE; + } + + @Override + public MsgType getMsgType() { + return MsgType.SESSION_TIMEOUT_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/ToDeviceRpcRequestMetadata.java b/application/src/main/java/org/thingsboard/server/actors/device/ToDeviceRpcRequestMetadata.java new file mode 100644 index 0000000..d003656 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/ToDeviceRpcRequestMetadata.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.Data; +import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; + +/** + * @author Andrew Shvayka + */ +@Data +public class ToDeviceRpcRequestMetadata { + private final ToDeviceRpcRequestActorMsg msg; + private final boolean sent; + private int retries; + private boolean delivered; +} 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 new file mode 100644 index 0000000..c0a68c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; + +/** + * @author Andrew Shvayka + */ +@Data +public class ToServerRpcRequestMetadata { + private final UUID sessionId; + private final TransportProtos.SessionType type; + private final String nodeId; +} 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 new file mode 100644 index 0000000..ba1c4e9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -0,0 +1,823 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.netty.channel.EventLoopGroup; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.util.Arrays; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.RuleEngineAlarmService; +import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; +import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; +import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; +import org.thingsboard.rule.engine.api.RuleEngineRpcService; +import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; +import org.thingsboard.rule.engine.api.ScriptEngine; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbRelationTypes; +import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; +import org.thingsboard.rule.engine.util.TenantIdLoader; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasRuleEngineProfile; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.rule.RuleNodeState; +import org.thingsboard.server.common.data.script.ScriptLanguage; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +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.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.nosql.CassandraStatementTask; +import org.thingsboard.server.dao.nosql.TbResultSetFuture; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.service.script.RuleNodeJsScriptEngine; +import org.thingsboard.server.service.script.RuleNodeTbelScriptEngine; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 19.03.18. + */ +@Slf4j +class DefaultTbContext implements TbContext { + + public final static ObjectMapper mapper = new ObjectMapper(); + + private final ActorSystemContext mainCtx; + private final String ruleChainName; + private final RuleNodeCtx nodeCtx; + + public DefaultTbContext(ActorSystemContext mainCtx, String ruleChainName, RuleNodeCtx nodeCtx) { + this.mainCtx = mainCtx; + this.ruleChainName = ruleChainName; + this.nodeCtx = nodeCtx; + } + + @Override + public void tellSuccess(TbMsg msg) { + tellNext(msg, Collections.singleton(TbRelationTypes.SUCCESS), null); + } + + @Override + public void tellNext(TbMsg msg, String relationType) { + tellNext(msg, Collections.singleton(relationType), null); + } + + @Override + public void tellNext(TbMsg msg, Set relationTypes) { + tellNext(msg, relationTypes, null); + } + + private void tellNext(TbMsg msg, Set relationTypes, Throwable th) { + if (nodeCtx.getSelf().isDebugMode()) { + relationTypes.forEach(relationType -> mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), msg, relationType, th)); + } + msg.getCallback().onProcessingEnd(nodeCtx.getSelf().getId()); + nodeCtx.getChainActor().tell(new RuleNodeToRuleChainTellNextMsg(nodeCtx.getSelf().getRuleChainId(), nodeCtx.getSelf().getId(), relationTypes, msg, th != null ? th.getMessage() : null)); + } + + @Override + public void tellSelf(TbMsg msg, long delayMs) { + //TODO: add persistence layer + scheduleMsgWithDelay(new RuleNodeToSelfMsg(this, msg), delayMs, nodeCtx.getSelfActor()); + } + + @Override + public void input(TbMsg msg, RuleChainId ruleChainId) { + msg.pushToStack(nodeCtx.getSelf().getRuleChainId(), nodeCtx.getSelf().getId()); + nodeCtx.getChainActor().tell(new RuleChainInputMsg(ruleChainId, msg)); + } + + @Override + public void output(TbMsg msg, String relationType) { + TbMsgProcessingStackItem item = msg.popFormStack(); + if (item == null) { + ack(msg); + } else { + if (nodeCtx.getSelf().isDebugMode()) { + mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), msg, relationType); + } + nodeCtx.getChainActor().tell(new RuleChainOutputMsg(item.getRuleChainId(), item.getRuleNodeId(), relationType, msg)); + } + } + + @Override + public void enqueue(TbMsg tbMsg, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = mainCtx.resolve(ServiceType.TB_RULE_ENGINE, getTenantId(), tbMsg.getOriginator()); + enqueue(tpi, tbMsg, onFailure, onSuccess); + } + + @Override + public void enqueue(TbMsg tbMsg, String queueName, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = resolvePartition(tbMsg, queueName); + enqueue(tpi, tbMsg, onFailure, onSuccess); + } + + private void enqueue(TopicPartitionInfo tpi, TbMsg tbMsg, Consumer onFailure, Runnable onSuccess) { + if (!tbMsg.isValid()) { + log.trace("[{}] Skip invalid message: {}", getTenantId(), tbMsg); + if (onFailure != null) { + onFailure.accept(new IllegalArgumentException("Source message is no longer valid!")); + } + return; + } + TransportProtos.ToRuleEngineMsg msg = TransportProtos.ToRuleEngineMsg.newBuilder() + .setTenantIdMSB(getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(getTenantId().getId().getLeastSignificantBits()) + .setTbMsg(TbMsg.toByteString(tbMsg)).build(); + if (nodeCtx.getSelf().isDebugMode()) { + mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), tbMsg, "To Root Rule Chain"); + } + mainCtx.getClusterService().pushMsgToRuleEngine(tpi, tbMsg.getId(), msg, new SimpleTbQueueCallback(onSuccess, onFailure)); + } + + @Override + public void enqueueForTellFailure(TbMsg tbMsg, String failureMessage) { + TopicPartitionInfo tpi = resolvePartition(tbMsg); + enqueueForTellNext(tpi, tbMsg, Collections.singleton(TbRelationTypes.FAILURE), failureMessage, null, null); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, String relationType) { + TopicPartitionInfo tpi = resolvePartition(tbMsg); + enqueueForTellNext(tpi, tbMsg, Collections.singleton(relationType), null, null, null); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, Set relationTypes) { + TopicPartitionInfo tpi = resolvePartition(tbMsg); + enqueueForTellNext(tpi, tbMsg, relationTypes, null, null, null); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, String relationType, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = resolvePartition(tbMsg); + enqueueForTellNext(tpi, tbMsg, Collections.singleton(relationType), null, onSuccess, onFailure); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, Set relationTypes, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = resolvePartition(tbMsg); + enqueueForTellNext(tpi, tbMsg, relationTypes, null, onSuccess, onFailure); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, String queueName, String relationType, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = resolvePartition(tbMsg, queueName); + enqueueForTellNext(tpi, queueName, tbMsg, Collections.singleton(relationType), null, onSuccess, onFailure); + } + + @Override + public void enqueueForTellNext(TbMsg tbMsg, String queueName, Set relationTypes, Runnable onSuccess, Consumer onFailure) { + TopicPartitionInfo tpi = resolvePartition(tbMsg, queueName); + enqueueForTellNext(tpi, queueName, tbMsg, relationTypes, null, onSuccess, onFailure); + } + + private TopicPartitionInfo resolvePartition(TbMsg tbMsg, String queueName) { + return mainCtx.resolve(ServiceType.TB_RULE_ENGINE, queueName, getTenantId(), tbMsg.getOriginator()); + } + + private TopicPartitionInfo resolvePartition(TbMsg tbMsg) { + return resolvePartition(tbMsg, tbMsg.getQueueName()); + } + + private void enqueueForTellNext(TopicPartitionInfo tpi, TbMsg source, Set relationTypes, String failureMessage, Runnable onSuccess, Consumer onFailure) { + enqueueForTellNext(tpi, source.getQueueName(), source, relationTypes, failureMessage, onSuccess, onFailure); + } + + private void enqueueForTellNext(TopicPartitionInfo tpi, String queueName, TbMsg source, Set relationTypes, String failureMessage, Runnable onSuccess, Consumer onFailure) { + if (!source.isValid()) { + log.trace("[{}] Skip invalid message: {}", getTenantId(), source); + if (onFailure != null) { + onFailure.accept(new IllegalArgumentException("Source message is no longer valid!")); + } + return; + } + RuleChainId ruleChainId = nodeCtx.getSelf().getRuleChainId(); + RuleNodeId ruleNodeId = nodeCtx.getSelf().getId(); + TbMsg tbMsg = TbMsg.newMsg(source, queueName, ruleChainId, ruleNodeId); + TransportProtos.ToRuleEngineMsg.Builder msg = TransportProtos.ToRuleEngineMsg.newBuilder() + .setTenantIdMSB(getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(getTenantId().getId().getLeastSignificantBits()) + .setTbMsg(TbMsg.toByteString(tbMsg)) + .addAllRelationTypes(relationTypes); + if (failureMessage != null) { + msg.setFailureMessage(failureMessage); + } + if (nodeCtx.getSelf().isDebugMode()) { + relationTypes.forEach(relationType -> + mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), tbMsg, relationType, null, failureMessage)); + } + mainCtx.getClusterService().pushMsgToRuleEngine(tpi, tbMsg.getId(), msg.build(), new SimpleTbQueueCallback(onSuccess, onFailure)); + } + + @Override + public void ack(TbMsg tbMsg) { + if (nodeCtx.getSelf().isDebugMode()) { + mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), tbMsg, "ACK", null); + } + tbMsg.getCallback().onProcessingEnd(nodeCtx.getSelf().getId()); + tbMsg.getCallback().onSuccess(); + } + + @Override + public boolean isLocalEntity(EntityId entityId) { + return mainCtx.resolve(ServiceType.TB_RULE_ENGINE, getTenantId(), entityId).isMyPartition(); + } + + private void scheduleMsgWithDelay(TbActorMsg msg, long delayInMs, TbActorRef target) { + mainCtx.scheduleMsgWithDelay(target, msg, delayInMs); + } + + @Override + public void tellFailure(TbMsg msg, Throwable th) { + if (nodeCtx.getSelf().isDebugMode()) { + mainCtx.persistDebugOutput(nodeCtx.getTenantId(), nodeCtx.getSelf().getId(), msg, TbRelationTypes.FAILURE, th); + } + String failureMessage; + if (th != null) { + if (!StringUtils.isEmpty(th.getMessage())) { + failureMessage = th.getMessage(); + } else { + failureMessage = th.getClass().getSimpleName(); + } + } else { + failureMessage = null; + } + nodeCtx.getChainActor().tell(new RuleNodeToRuleChainTellNextMsg(nodeCtx.getSelf().getRuleChainId(), + nodeCtx.getSelf().getId(), Collections.singleton(TbRelationTypes.FAILURE), + msg, failureMessage)); + } + + public void updateSelf(RuleNode self) { + nodeCtx.setSelf(self); + } + + @Override + public TbMsg newMsg(String queueName, String type, EntityId originator, TbMsgMetaData metaData, String data) { + return newMsg(queueName, type, originator, null, metaData, data); + } + + @Override + public TbMsg newMsg(String queueName, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, String data) { + return TbMsg.newMsg(queueName, type, originator, customerId, metaData, data, nodeCtx.getSelf().getRuleChainId(), nodeCtx.getSelf().getId()); + } + + @Override + public TbMsg transformMsg(TbMsg origMsg, String type, EntityId originator, TbMsgMetaData metaData, String data) { + return TbMsg.transformMsg(origMsg, type, originator, metaData, data); + } + + public TbMsg customerCreatedMsg(Customer customer, RuleNodeId ruleNodeId) { + return entityActionMsg(customer, customer.getId(), ruleNodeId, DataConstants.ENTITY_CREATED); + } + + public TbMsg deviceCreatedMsg(Device device, RuleNodeId ruleNodeId) { + DeviceProfile deviceProfile = null; + if (device.getDeviceProfileId() != null) { + deviceProfile = mainCtx.getDeviceProfileCache().find(device.getDeviceProfileId()); + } + return entityActionMsg(device, device.getId(), ruleNodeId, DataConstants.ENTITY_CREATED, deviceProfile); + } + + public TbMsg assetCreatedMsg(Asset asset, RuleNodeId ruleNodeId) { + AssetProfile assetProfile = null; + if (asset.getAssetProfileId() != null) { + assetProfile = mainCtx.getAssetProfileCache().find(asset.getAssetProfileId()); + } + return entityActionMsg(asset, asset.getId(), ruleNodeId, DataConstants.ENTITY_CREATED, assetProfile); + } + + public TbMsg alarmActionMsg(Alarm alarm, RuleNodeId ruleNodeId, String action) { + HasRuleEngineProfile profile = null; + if (EntityType.DEVICE.equals(alarm.getOriginator().getEntityType())) { + DeviceId deviceId = new DeviceId(alarm.getOriginator().getId()); + profile = mainCtx.getDeviceProfileCache().get(getTenantId(), deviceId); + } else if (EntityType.ASSET.equals(alarm.getOriginator().getEntityType())) { + AssetId assetId = new AssetId(alarm.getOriginator().getId()); + profile = mainCtx.getAssetProfileCache().get(getTenantId(), assetId); + } + return entityActionMsg(alarm, alarm.getOriginator(), ruleNodeId, action, profile); + } + + public TbMsg attributesUpdatedActionMsg(EntityId originator, RuleNodeId ruleNodeId, String scope, List attributes) { + ObjectNode entityNode = JacksonUtil.newObjectNode(); + if (attributes != null) { + attributes.forEach(attributeKvEntry -> JacksonUtil.addKvEntry(entityNode, attributeKvEntry)); + } + return attributesActionMsg(originator, ruleNodeId, scope, DataConstants.ATTRIBUTES_UPDATED, JacksonUtil.toString(entityNode)); + } + + public TbMsg attributesDeletedActionMsg(EntityId originator, RuleNodeId ruleNodeId, String scope, List keys) { + ObjectNode entityNode = JacksonUtil.newObjectNode(); + ArrayNode attrsArrayNode = entityNode.putArray("attributes"); + if (keys != null) { + keys.forEach(attrsArrayNode::add); + } + return attributesActionMsg(originator, ruleNodeId, scope, DataConstants.ATTRIBUTES_DELETED, JacksonUtil.toString(entityNode)); + } + + private TbMsg attributesActionMsg(EntityId originator, RuleNodeId ruleNodeId, String scope, String action, String msgData) { + TbMsgMetaData tbMsgMetaData = getActionMetaData(ruleNodeId); + tbMsgMetaData.putValue("scope", scope); + HasRuleEngineProfile profile = null; + if (EntityType.DEVICE.equals(originator.getEntityType())) { + DeviceId deviceId = new DeviceId(originator.getId()); + profile = mainCtx.getDeviceProfileCache().get(getTenantId(), deviceId); + } else if (EntityType.ASSET.equals(originator.getEntityType())) { + AssetId assetId = new AssetId(originator.getId()); + profile = mainCtx.getAssetProfileCache().get(getTenantId(), assetId); + } + return entityActionMsg(originator, tbMsgMetaData, msgData, action, profile); + } + + @Override + public void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) { + mainCtx.getClusterService().onEdgeEventUpdate(tenantId, edgeId); + } + + public TbMsg entityActionMsg(E entity, I id, RuleNodeId ruleNodeId, String action) { + return entityActionMsg(entity, id, ruleNodeId, action, null); + } + + public TbMsg entityActionMsg(E entity, I id, RuleNodeId ruleNodeId, String action, K profile) { + try { + return entityActionMsg(id, getActionMetaData(ruleNodeId), mapper.writeValueAsString(mapper.valueToTree(entity)), action, profile); + } catch (JsonProcessingException | IllegalArgumentException e) { + throw new RuntimeException("Failed to process " + id.getEntityType().name().toLowerCase() + " " + action + " msg: " + e); + } + } + + private TbMsg entityActionMsg(I id, TbMsgMetaData msgMetaData, String msgData, String action, K profile) { + String defaultQueueName = null; + RuleChainId defaultRuleChainId = null; + if (profile != null) { + defaultQueueName = profile.getDefaultQueueName(); + defaultRuleChainId = profile.getDefaultRuleChainId(); + } + return TbMsg.newMsg(defaultQueueName, action, id, msgMetaData, msgData, defaultRuleChainId, null); + } + + @Override + public RuleNodeId getSelfId() { + return nodeCtx.getSelf().getId(); + } + + @Override + public RuleNode getSelf() { + return nodeCtx.getSelf(); + } + + @Override + public String getRuleChainName() { + return ruleChainName; + } + + @Override + public TenantId getTenantId() { + return nodeCtx.getTenantId(); + } + + @Override + public ListeningExecutor getMailExecutor() { + return mainCtx.getMailExecutor(); + } + + @Override + public ListeningExecutor getSmsExecutor() { + return mainCtx.getSmsExecutor(); + } + + @Override + public ListeningExecutor getDbCallbackExecutor() { + return mainCtx.getDbCallbackExecutor(); + } + + @Override + public ListeningExecutor getExternalCallExecutor() { + return mainCtx.getExternalCallExecutorService(); + } + + @Override + @Deprecated + public ScriptEngine createJsScriptEngine(String script, String... argNames) { + return new RuleNodeJsScriptEngine(getTenantId(), mainCtx.getJsInvokeService(), script, argNames); + } + + private ScriptEngine createTbelScriptEngine(String script, String... argNames) { + if (mainCtx.getTbelInvokeService() == null) { + throw new RuntimeException("TBEL execution is disabled!"); + } + return new RuleNodeTbelScriptEngine(getTenantId(), mainCtx.getTbelInvokeService(), script, argNames); + } + + @Override + public ScriptEngine createScriptEngine(ScriptLanguage scriptLang, String script, String... argNames) { + if (scriptLang == null) { + scriptLang = ScriptLanguage.JS; + } + if (StringUtils.isBlank(script)) { + throw new RuntimeException(scriptLang.name() + " script is blank!"); + } + switch (scriptLang) { + case JS: + return createJsScriptEngine(script, argNames); + case TBEL: + if (Arrays.isNullOrEmpty(argNames)) { + return createTbelScriptEngine(script, "msg", "metadata", "msgType"); + } else { + return createTbelScriptEngine(script, argNames); + } + default: + throw new RuntimeException("Unsupported script language: " + scriptLang.name()); + } + } + + @Override + public void logJsEvalRequest() { + if (mainCtx.isStatisticsEnabled()) { + mainCtx.getJsInvokeStats().incrementRequests(); + } + } + + @Override + public void logJsEvalResponse() { + if (mainCtx.isStatisticsEnabled()) { + mainCtx.getJsInvokeStats().incrementResponses(); + } + } + + @Override + public void logJsEvalFailure() { + if (mainCtx.isStatisticsEnabled()) { + mainCtx.getJsInvokeStats().incrementFailures(); + } + } + + @Override + public String getServiceId() { + return mainCtx.getServiceInfoProvider().getServiceId(); + } + + @Override + public AttributesService getAttributesService() { + return mainCtx.getAttributesService(); + } + + @Override + public CustomerService getCustomerService() { + return mainCtx.getCustomerService(); + } + + @Override + public TenantService getTenantService() { + return mainCtx.getTenantService(); + } + + @Override + public UserService getUserService() { + return mainCtx.getUserService(); + } + + @Override + public AssetService getAssetService() { + return mainCtx.getAssetService(); + } + + @Override + public DeviceService getDeviceService() { + return mainCtx.getDeviceService(); + } + + @Override + public DeviceProfileService getDeviceProfileService() { + return mainCtx.getDeviceProfileService(); + } + + @Override + public AssetProfileService getAssetProfileService() { + return mainCtx.getAssetProfileService(); + } + + @Override + public DeviceCredentialsService getDeviceCredentialsService() { + return mainCtx.getDeviceCredentialsService(); + } + + @Override + public TbClusterService getClusterService() { + return mainCtx.getClusterService(); + } + + @Override + public DashboardService getDashboardService() { + return mainCtx.getDashboardService(); + } + + @Override + public RuleEngineAlarmService getAlarmService() { + return mainCtx.getAlarmService(); + } + + @Override + public RuleChainService getRuleChainService() { + return mainCtx.getRuleChainService(); + } + + @Override + public TimeseriesService getTimeseriesService() { + return mainCtx.getTsService(); + } + + @Override + public RuleEngineTelemetryService getTelemetryService() { + return mainCtx.getTsSubService(); + } + + @Override + public RelationService getRelationService() { + return mainCtx.getRelationService(); + } + + @Override + public EntityViewService getEntityViewService() { + return mainCtx.getEntityViewService(); + } + + @Override + public ResourceService getResourceService() { + return mainCtx.getResourceService(); + } + + @Override + public OtaPackageService getOtaPackageService() { + return mainCtx.getOtaPackageService(); + } + + @Override + public RuleEngineDeviceProfileCache getDeviceProfileCache() { + return mainCtx.getDeviceProfileCache(); + } + + @Override + public RuleEngineAssetProfileCache getAssetProfileCache() { + return mainCtx.getAssetProfileCache(); + } + + @Override + public EdgeService getEdgeService() { + return mainCtx.getEdgeService(); + } + + @Override + public EdgeEventService getEdgeEventService() { + return mainCtx.getEdgeEventService(); + } + + @Override + public QueueService getQueueService() { + return mainCtx.getQueueService(); + } + + @Override + public EventLoopGroup getSharedEventLoop() { + return mainCtx.getSharedEventLoopGroupService().getSharedEventLoopGroup(); + } + + @Override + public MailService getMailService(boolean isSystem) { + if (!isSystem || mainCtx.isAllowSystemMailService()) { + return mainCtx.getMailService(); + } else { + throw new RuntimeException("Access to System Mail Service is forbidden!"); + } + } + + @Override + public SmsService getSmsService() { + if (mainCtx.isAllowSystemSmsService()) { + return mainCtx.getSmsService(); + } else { + throw new RuntimeException("Access to System SMS Service is forbidden!"); + } + } + + @Override + public SmsSenderFactory getSmsSenderFactory() { + return mainCtx.getSmsSenderFactory(); + } + + @Override + public RuleEngineRpcService getRpcService() { + return mainCtx.getTbRuleEngineDeviceRpcService(); + } + + @Override + public CassandraCluster getCassandraCluster() { + return mainCtx.getCassandraCluster(); + } + + @Override + public TbResultSetFuture submitCassandraReadTask(CassandraStatementTask task) { + return mainCtx.getCassandraBufferedRateReadExecutor().submit(task); + } + + @Override + public TbResultSetFuture submitCassandraWriteTask(CassandraStatementTask task) { + return mainCtx.getCassandraBufferedRateWriteExecutor().submit(task); + } + + @Override + public PageData findRuleNodeStates(PageLink pageLink) { + if (log.isDebugEnabled()) { + log.debug("[{}][{}] Fetch Rule Node States.", getTenantId(), getSelfId()); + } + return mainCtx.getRuleNodeStateService().findByRuleNodeId(getTenantId(), getSelfId(), pageLink); + } + + @Override + public RuleNodeState findRuleNodeStateForEntity(EntityId entityId) { + if (log.isDebugEnabled()) { + log.debug("[{}][{}][{}] Fetch Rule Node State for entity.", getTenantId(), getSelfId(), entityId); + } + return mainCtx.getRuleNodeStateService().findByRuleNodeIdAndEntityId(getTenantId(), getSelfId(), entityId); + } + + @Override + public RuleNodeState saveRuleNodeState(RuleNodeState state) { + if (log.isDebugEnabled()) { + log.debug("[{}][{}][{}] Persist Rule Node State for entity: {}", getTenantId(), getSelfId(), state.getEntityId(), state.getStateData()); + } + state.setRuleNodeId(getSelfId()); + return mainCtx.getRuleNodeStateService().save(getTenantId(), state); + } + + @Override + public void clearRuleNodeStates() { + if (log.isDebugEnabled()) { + log.debug("[{}][{}] Going to clear rule node states", getTenantId(), getSelfId()); + } + mainCtx.getRuleNodeStateService().removeByRuleNodeId(getTenantId(), getSelfId()); + } + + @Override + public void removeRuleNodeStateForEntity(EntityId entityId) { + if (log.isDebugEnabled()) { + log.debug("[{}][{}][{}] Remove Rule Node State for entity.", getTenantId(), getSelfId(), entityId); + } + mainCtx.getRuleNodeStateService().removeByRuleNodeIdAndEntityId(getTenantId(), getSelfId(), entityId); + } + + @Override + public void addTenantProfileListener(Consumer listener) { + mainCtx.getTenantProfileCache().addListener(getTenantId(), getSelfId(), listener); + } + + @Override + public void addDeviceProfileListeners(Consumer profileListener, BiConsumer deviceListener) { + mainCtx.getDeviceProfileCache().addListener(getTenantId(), getSelfId(), profileListener, deviceListener); + } + + @Override + public void addAssetProfileListeners(Consumer profileListener, BiConsumer assetListener) { + mainCtx.getAssetProfileCache().addListener(getTenantId(), getSelfId(), profileListener, assetListener); + } + + @Override + public void removeListeners() { + mainCtx.getDeviceProfileCache().removeListener(getTenantId(), getSelfId()); + mainCtx.getAssetProfileCache().removeListener(getTenantId(), getSelfId()); + mainCtx.getTenantProfileCache().removeListener(getTenantId(), getSelfId()); + } + + @Override + public TenantProfile getTenantProfile() { + return mainCtx.getTenantProfileCache().get(getTenantId()); + } + + @Override + public WidgetsBundleService getWidgetBundleService() { + return mainCtx.getWidgetsBundleService(); + } + + @Override + public WidgetTypeService getWidgetTypeService() { + return mainCtx.getWidgetTypeService(); + } + + @Override + public RuleEngineApiUsageStateService getRuleEngineApiUsageStateService() { + return mainCtx.getApiUsageStateService(); + } + + private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("ruleNodeId", ruleNodeId.toString()); + return metaData; + } + + @Override + public void checkTenantEntity(EntityId entityId) { + if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) { + throw new RuntimeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant."); + } + } + + private class SimpleTbQueueCallback implements TbQueueCallback { + private final Runnable onSuccess; + private final Consumer onFailure; + + public SimpleTbQueueCallback(Runnable onSuccess, Consumer onFailure) { + this.onSuccess = onSuccess; + this.onFailure = onFailure; + } + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + if (onSuccess != null) { + onSuccess.run(); + } + } + + @Override + public void onFailure(Throwable t) { + if (onFailure != null) { + onFailure.accept(t); + } else { + log.debug("[{}] Failed to put item into queue", nodeCtx.getTenantId(), t); + } + } + } +} 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 new file mode 100644 index 0000000..ff9e21d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.service.ComponentActor; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; + +public class RuleChainActor extends ComponentActor { + + private final RuleChain ruleChain; + + private RuleChainActor(ActorSystemContext systemContext, TenantId tenantId, RuleChain ruleChain) { + super(systemContext, tenantId, ruleChain.getId()); + this.ruleChain = ruleChain; + } + + @Override + protected RuleChainActorMessageProcessor createProcessor(TbActorCtx ctx) { + return new RuleChainActorMessageProcessor(tenantId, ruleChain, systemContext, + ctx.getParentRef(), ctx); + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + switch (msg.getMsgType()) { + case COMPONENT_LIFE_CYCLE_MSG: + onComponentLifecycleMsg((ComponentLifecycleMsg) msg); + break; + case QUEUE_TO_RULE_ENGINE_MSG: + processor.onQueueToRuleEngineMsg((QueueToRuleEngineMsg) msg); + break; + case RULE_TO_RULE_CHAIN_TELL_NEXT_MSG: + processor.onTellNext((RuleNodeToRuleChainTellNextMsg) msg); + break; + case RULE_CHAIN_TO_RULE_CHAIN_MSG: + processor.onRuleChainToRuleChainMsg((RuleChainToRuleChainMsg) msg); + break; + case RULE_CHAIN_INPUT_MSG: + processor.onRuleChainInputMsg((RuleChainInputMsg) msg); + break; + case RULE_CHAIN_OUTPUT_MSG: + processor.onRuleChainOutputMsg((RuleChainOutputMsg) msg); + break; + case PARTITION_CHANGE_MSG: + processor.onPartitionChangeMsg((PartitionChangeMsg) msg); + break; + case STATS_PERSIST_TICK_MSG: + onStatsPersistTick(id); + break; + default: + return false; + } + return true; + } + + public static class ActorCreator extends ContextBasedCreator { + private static final long serialVersionUID = 1L; + + private final TenantId tenantId; + private final RuleChain ruleChain; + + public ActorCreator(ActorSystemContext context, TenantId tenantId, RuleChain ruleChain) { + super(context); + this.tenantId = tenantId; + this.ruleChain = ruleChain; + } + + @Override + public TbActorId createActorId() { + return new TbEntityActorId(ruleChain.getId()); + } + + @Override + public TbActor createActor() { + return new RuleChainActor(context, tenantId, ruleChain); + } + } + + @Override + protected long getErrorPersistFrequency() { + return systemContext.getRuleChainErrorPersistFrequency(); + } + +} 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 new file mode 100644 index 0000000..279d750 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -0,0 +1,404 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.TbRelationTypes; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.actors.shared.ComponentMsgProcessor; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.plugin.RuleNodeUpdatedMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.RuleNodeException; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.common.MultipleTbQueueTbMsgCallbackWrapper; +import org.thingsboard.server.queue.common.TbQueueTbMsgCallbackWrapper; +import org.thingsboard.server.cluster.TbClusterService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * @author Andrew Shvayka + */ +@Slf4j +public class RuleChainActorMessageProcessor extends ComponentMsgProcessor { + + private static final String NA_RELATION_TYPE = ""; + private final TbActorRef parent; + private final TbActorRef self; + private final Map nodeActors; + private final Map> nodeRoutes; + private final RuleChainService service; + private final TbClusterService clusterService; + private final TbApiUsageReportClient apiUsageClient; + private String ruleChainName; + + private RuleNodeId firstId; + private RuleNodeCtx firstNode; + private boolean started; + + RuleChainActorMessageProcessor(TenantId tenantId, RuleChain ruleChain, ActorSystemContext systemContext, TbActorRef parent, TbActorRef self) { + super(systemContext, tenantId, ruleChain.getId()); + this.apiUsageClient = systemContext.getApiUsageClient(); + this.ruleChainName = ruleChain.getName(); + this.parent = parent; + this.self = self; + this.nodeActors = new HashMap<>(); + this.nodeRoutes = new HashMap<>(); + this.service = systemContext.getRuleChainService(); + this.clusterService = systemContext.getClusterService(); + } + + @Override + public String getComponentName() { + return null; + } + + @Override + public void start(TbActorCtx context) { + if (!started) { + RuleChain ruleChain = service.findRuleChainById(tenantId, entityId); + if (ruleChain != null && RuleChainType.CORE.equals(ruleChain.getType())) { + List ruleNodeList = service.getRuleChainNodes(tenantId, 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); + TbActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); + nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); + } + initRoutes(ruleChain, ruleNodeList); + started = true; + } + } else { + onUpdate(context); + } + } + + @Override + public void onUpdate(TbActorCtx context) { + RuleChain ruleChain = service.findRuleChainById(tenantId, entityId); + if (ruleChain != null && RuleChainType.CORE.equals(ruleChain.getType())) { + ruleChainName = ruleChain.getName(); + List ruleNodeList = service.getRuleChainNodes(tenantId, 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); + TbActorRef 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().tellWithHighPriority(new RuleNodeUpdatedMsg(tenantId, existing.getSelf().getId())); + } + } + + Set existingNodes = ruleNodeList.stream().map(RuleNode::getId).collect(Collectors.toSet()); + List removedRules = nodeActors.keySet().stream().filter(node -> !existingNodes.contains(node)).collect(Collectors.toList()); + removedRules.forEach(ruleNodeId -> { + log.trace("[{}][{}] Removing rule node [{}]", tenantId, entityId, ruleNodeId); + RuleNodeCtx removed = nodeActors.remove(ruleNodeId); + removed.getSelfActor().tellWithHighPriority(new ComponentLifecycleMsg(tenantId, removed.getSelf().getId(), ComponentLifecycleEvent.DELETED)); + }); + + initRoutes(ruleChain, ruleNodeList); + } + } + + @Override + public void stop(TbActorCtx ctx) { + log.trace("[{}][{}] Stopping rule chain with {} nodes", tenantId, entityId, nodeActors.size()); + nodeActors.values().stream().map(RuleNodeCtx::getSelfActor).map(TbActorRef::getActorId).forEach(ctx::stop); + nodeActors.clear(); + nodeRoutes.clear(); + started = false; + } + + @Override + public void onPartitionChangeMsg(PartitionChangeMsg msg) { + nodeActors.values().stream().map(RuleNodeCtx::getSelfActor).forEach(actorRef -> actorRef.tellWithHighPriority(msg)); + } + + private TbActorRef createRuleNodeActor(TbActorCtx ctx, RuleNode ruleNode) { + return ctx.getOrCreateChildActor(new TbEntityActorId(ruleNode.getId()), + () -> DefaultActorService.RULE_DISPATCHER_NAME, + () -> new RuleNodeActor.ActorCreator(systemContext, tenantId, entityId, ruleChainName, ruleNode.getId())); + } + + private void initRoutes(RuleChain ruleChain, List ruleNodeList) { + nodeRoutes.clear(); + // Populating the routes map; + for (RuleNode ruleNode : ruleNodeList) { + List relations = service.getRuleNodeRelations(TenantId.SYS_TENANT_ID, 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) { + throw new IllegalArgumentException("Rule Node [" + relation.getFrom() + "] has invalid relation to Rule node [" + relation.getTo() + "]"); + } + } + nodeRoutes.computeIfAbsent(ruleNode.getId(), k -> new ArrayList<>()) + .add(new RuleNodeRelation(ruleNode.getId(), relation.getTo(), relation.getType())); + } + } + } + + firstId = ruleChain.getFirstRuleNodeId(); + firstNode = nodeActors.get(firstId); + state = ComponentLifecycleState.ACTIVE; + } + + void onQueueToRuleEngineMsg(QueueToRuleEngineMsg envelope) { + TbMsg msg = envelope.getMsg(); + if (!checkMsgValid(msg)) { + return; + } + log.trace("[{}][{}] Processing message [{}]: {}", entityId, firstId, msg.getId(), msg); + if (envelope.getRelationTypes() == null || envelope.getRelationTypes().isEmpty()) { + onTellNext(msg, true); + } else { + onTellNext(msg, envelope.getMsg().getRuleNodeId(), envelope.getRelationTypes(), envelope.getFailureMessage()); + } + } + + private void onTellNext(TbMsg msg, boolean useRuleNodeIdFromMsg) { + try { + checkComponentStateActive(msg); + RuleNodeId targetId = useRuleNodeIdFromMsg ? msg.getRuleNodeId() : null; + RuleNodeCtx targetCtx; + if (targetId == null) { + targetCtx = firstNode; + msg = msg.copyWithRuleChainId(entityId); + } else { + targetCtx = nodeActors.get(targetId); + } + if (targetCtx != null) { + log.trace("[{}][{}] Pushing message to target rule node", entityId, targetId); + pushMsgToNode(targetCtx, msg, NA_RELATION_TYPE); + } else { + log.trace("[{}][{}] Rule node does not exist. Probably old message", entityId, targetId); + msg.getCallback().onSuccess(); + } + } catch (RuleNodeException rne) { + msg.getCallback().onFailure(rne); + } catch (Exception e) { + msg.getCallback().onFailure(new RuleEngineException(e.getMessage())); + } + } + + public void onRuleChainInputMsg(RuleChainInputMsg envelope) { + var msg = envelope.getMsg(); + if (!checkMsgValid(msg)) { + return; + } + if (entityId.equals(envelope.getRuleChainId())) { + onTellNext(envelope.getMsg(), false); + } else { + parent.tell(envelope); + } + } + + public void onRuleChainOutputMsg(RuleChainOutputMsg envelope) { + var msg = envelope.getMsg(); + if (!checkMsgValid(msg)) { + return; + } + if (entityId.equals(envelope.getRuleChainId())) { + var originatorNodeId = envelope.getTargetRuleNodeId(); + RuleNodeCtx ruleNodeCtx = nodeActors.get(originatorNodeId); + if (ruleNodeCtx != null && ruleNodeCtx.getSelf().isDebugMode()) { + systemContext.persistDebugOutput(tenantId, originatorNodeId, envelope.getMsg(), envelope.getRelationType()); + } + onTellNext(envelope.getMsg(), originatorNodeId, Collections.singleton(envelope.getRelationType()), RuleNodeException.UNKNOWN); + } else { + parent.tell(envelope); + } + } + + void onRuleChainToRuleChainMsg(RuleChainToRuleChainMsg envelope) { + var msg = envelope.getMsg(); + if (!checkMsgValid(msg)) { + return; + } + try { + checkComponentStateActive(envelope.getMsg()); + if (firstNode != null) { + pushMsgToNode(firstNode, envelope.getMsg(), envelope.getFromRelationType()); + } else { + envelope.getMsg().getCallback().onSuccess(); + } + } catch (RuleNodeException e) { + log.debug("Rule Chain is not active. Current state [{}] for processor [{}][{}] tenant [{}]", state, entityId.getEntityType(), entityId, tenantId); + } + } + + void onTellNext(RuleNodeToRuleChainTellNextMsg envelope) { + var msg = envelope.getMsg(); + if (checkMsgValid(msg)) { + onTellNext(msg, envelope.getOriginator(), envelope.getRelationTypes(), envelope.getFailureMessage()); + } + } + + private void onTellNext(TbMsg msg, RuleNodeId originatorNodeId, Set relationTypes, String failureMessage) { + try { + checkComponentStateActive(msg); + EntityId entityId = msg.getOriginator(); + TopicPartitionInfo tpi = systemContext.resolve(ServiceType.TB_RULE_ENGINE, msg.getQueueName(), tenantId, entityId); + + List ruleNodeRelations = nodeRoutes.get(originatorNodeId); + if (ruleNodeRelations == null) { // When unchecked, this will cause NullPointerException when rule node doesn't exist anymore + log.warn("[{}][{}][{}] No outbound relations (null). Probably rule node does not exist. Probably old message.", tenantId, entityId, msg.getId()); + ruleNodeRelations = Collections.emptyList(); + } + + List relationsByTypes = ruleNodeRelations.stream() + .filter(r -> contains(relationTypes, r.getType())) + .collect(Collectors.toList()); + int relationsCount = relationsByTypes.size(); + if (relationsCount == 0) { + log.trace("[{}][{}][{}] No outbound relations to process", tenantId, entityId, msg.getId()); + if (relationTypes.contains(TbRelationTypes.FAILURE)) { + RuleNodeCtx ruleNodeCtx = nodeActors.get(originatorNodeId); + if (ruleNodeCtx != null) { + msg.getCallback().onFailure(new RuleNodeException(failureMessage, ruleChainName, ruleNodeCtx.getSelf())); + } else { + log.debug("[{}] Failure during message processing by Rule Node [{}]. Enable and see debug events for more info", entityId, originatorNodeId.getId()); + msg.getCallback().onFailure(new RuleEngineException("Failure during message processing by Rule Node [" + originatorNodeId.getId().toString() + "]")); + } + } else { + msg.getCallback().onSuccess(); + } + } else if (relationsCount == 1) { + for (RuleNodeRelation relation : relationsByTypes) { + log.trace("[{}][{}][{}] Pushing message to single target: [{}]", tenantId, entityId, msg.getId(), relation.getOut()); + pushToTarget(tpi, msg, relation.getOut(), relation.getType()); + } + } else { + MultipleTbQueueTbMsgCallbackWrapper callbackWrapper = new MultipleTbQueueTbMsgCallbackWrapper(relationsCount, msg.getCallback()); + log.trace("[{}][{}][{}] Pushing message to multiple targets: [{}]", tenantId, entityId, msg.getId(), relationsByTypes); + for (RuleNodeRelation relation : relationsByTypes) { + EntityId target = relation.getOut(); + putToQueue(tpi, msg, callbackWrapper, target); + } + } + } catch (RuleNodeException rne) { + msg.getCallback().onFailure(rne); + } catch (Exception e) { + log.warn("[" + tenantId + "]" + "[" + entityId + "]" + "[" + msg.getId() + "]" + " onTellNext failure", e); + msg.getCallback().onFailure(new RuleEngineException("onTellNext - " + e.getMessage())); + } + } + + private void putToQueue(TopicPartitionInfo tpi, TbMsg msg, TbQueueCallback callbackWrapper, EntityId target) { + switch (target.getEntityType()) { + case RULE_NODE: + putToQueue(tpi, msg.copyWithRuleNodeId(entityId, new RuleNodeId(target.getId()), UUID.randomUUID()), callbackWrapper); + break; + case RULE_CHAIN: + putToQueue(tpi, msg.copyWithRuleChainId(new RuleChainId(target.getId()), UUID.randomUUID()), callbackWrapper); + break; + } + } + + private void pushToTarget(TopicPartitionInfo tpi, TbMsg msg, EntityId target, String fromRelationType) { + if (tpi.isMyPartition()) { + switch (target.getEntityType()) { + case RULE_NODE: + pushMsgToNode(nodeActors.get(new RuleNodeId(target.getId())), msg, fromRelationType); + break; + case RULE_CHAIN: + parent.tell(new RuleChainToRuleChainMsg(new RuleChainId(target.getId()), entityId, msg, fromRelationType)); + break; + } + } else { + putToQueue(tpi, msg, new TbQueueTbMsgCallbackWrapper(msg.getCallback()), target); + } + } + + private void putToQueue(TopicPartitionInfo tpi, TbMsg newMsg, TbQueueCallback callbackWrapper) { + ToRuleEngineMsg toQueueMsg = ToRuleEngineMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTbMsg(TbMsg.toByteString(newMsg)) + .build(); + clusterService.pushMsgToRuleEngine(tpi, newMsg.getId(), toQueueMsg, callbackWrapper); + } + + private boolean contains(Set relationTypes, String type) { + if (relationTypes == null) { + return true; + } + for (String relationType : relationTypes) { + if (relationType.equalsIgnoreCase(type)) { + return true; + } + } + return false; + } + + private void pushMsgToNode(RuleNodeCtx nodeCtx, TbMsg msg, String fromRelationType) { + if (nodeCtx != null) { + nodeCtx.getSelfActor().tell(new RuleChainToRuleNodeMsg(new DefaultTbContext(systemContext, ruleChainName, nodeCtx), msg, fromRelationType)); + } else { + log.error("[{}][{}] RuleNodeCtx is empty", entityId, ruleChainName); + msg.getCallback().onFailure(new RuleEngineException("Rule Node CTX is empty")); + } + } + + @Override + protected RuleNodeException getInactiveException() { + RuleNode firstRuleNode = firstNode != null ? firstNode.getSelf() : null; + return new RuleNodeException("Rule Chain is not active! Failed to initialize.", ruleChainName, firstRuleNode); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainInputMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainInputMsg.java new file mode 100644 index 0000000..1161466 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainInputMsg.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbMsg; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +public final class RuleChainInputMsg extends TbToRuleChainActorMsg { + + public RuleChainInputMsg(RuleChainId target, TbMsg tbMsg) { + super(tbMsg, target); + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_CHAIN_INPUT_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainManagerActor.java new file mode 100644 index 0000000..eac35bf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainManagerActor.java @@ -0,0 +1,104 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.TbEntityTypeActorIdPredicate; +import org.thingsboard.server.actors.service.ContextAwareActor; +import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.dao.rule.RuleChainService; + +import java.util.function.Function; + +/** + * Created by ashvayka on 15.03.18. + */ +@Slf4j +public abstract class RuleChainManagerActor extends ContextAwareActor { + + protected final TenantId tenantId; + private final RuleChainService ruleChainService; + @Getter + protected RuleChain rootChain; + @Getter + protected TbActorRef rootChainActor; + + public RuleChainManagerActor(ActorSystemContext systemContext, TenantId tenantId) { + super(systemContext); + this.tenantId = tenantId; + this.ruleChainService = systemContext.getRuleChainService(); + } + + protected void initRuleChains() { + for (RuleChain ruleChain : new PageDataIterable<>(link -> ruleChainService.findTenantRuleChainsByType(tenantId, RuleChainType.CORE, link), ContextAwareActor.ENTITY_PACK_LIMIT)) { + RuleChainId ruleChainId = ruleChain.getId(); + log.debug("[{}|{}] Creating rule chain actor", ruleChainId.getEntityType(), ruleChain.getId()); + TbActorRef actorRef = getOrCreateActor(ruleChainId, id -> ruleChain); + visit(ruleChain, actorRef); + log.debug("[{}|{}] Rule Chain actor created.", ruleChainId.getEntityType(), ruleChainId.getId()); + } + } + + protected void destroyRuleChains() { + for (RuleChain ruleChain : new PageDataIterable<>(link -> ruleChainService.findTenantRuleChainsByType(tenantId, RuleChainType.CORE, link), ContextAwareActor.ENTITY_PACK_LIMIT)) { + ctx.stop(new TbEntityActorId(ruleChain.getId())); + } + } + + protected void visit(RuleChain entity, TbActorRef actorRef) { + if (entity != null && entity.isRoot() && entity.getType().equals(RuleChainType.CORE)) { + rootChain = entity; + rootChainActor = actorRef; + } + } + + protected TbActorRef getOrCreateActor(RuleChainId ruleChainId) { + return getOrCreateActor(ruleChainId, eId -> ruleChainService.findRuleChainById(TenantId.SYS_TENANT_ID, eId)); + } + + protected TbActorRef getOrCreateActor(RuleChainId ruleChainId, Function provider) { + return ctx.getOrCreateChildActor(new TbEntityActorId(ruleChainId), + () -> DefaultActorService.RULE_DISPATCHER_NAME, + () -> { + RuleChain ruleChain = provider.apply(ruleChainId); + return new RuleChainActor.ActorCreator(systemContext, tenantId, ruleChain); + }); + } + + protected TbActorRef getEntityActorRef(EntityId entityId) { + TbActorRef target = null; + if (entityId.getEntityType() == EntityType.RULE_CHAIN) { + target = getOrCreateActor((RuleChainId) entityId); + } + return target; + } + + protected void broadcast(TbActorMsg msg) { + ctx.broadcastToChildren(msg, new TbEntityTypeActorIdPredicate(EntityType.RULE_CHAIN)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainOutputMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainOutputMsg.java new file mode 100644 index 0000000..d71eeab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainOutputMsg.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbMsg; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +public final class RuleChainOutputMsg extends TbToRuleChainActorMsg { + + @Getter + private final RuleNodeId targetRuleNodeId; + + @Getter + private final String relationType; + + public RuleChainOutputMsg(RuleChainId target, RuleNodeId targetRuleNodeId, String relationType, TbMsg tbMsg) { + super(tbMsg, target); + this.targetRuleNodeId = targetRuleNodeId; + this.relationType = relationType; + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_CHAIN_OUTPUT_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleChainMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleChainMsg.java new file mode 100644 index 0000000..297ca75 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleChainMsg.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorStopReason; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbRuleEngineActorMsg; +import org.thingsboard.server.common.msg.aware.RuleChainAwareMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +public final class RuleChainToRuleChainMsg extends TbToRuleChainActorMsg { + + @Getter + private final RuleChainId source; + @Getter + private final String fromRelationType; + + public RuleChainToRuleChainMsg(RuleChainId target, RuleChainId source, TbMsg tbMsg, String fromRelationType) { + super(tbMsg, target); + this.source = source; + this.fromRelationType = fromRelationType; + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_CHAIN_TO_RULE_CHAIN_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleNodeMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleNodeMsg.java new file mode 100644 index 0000000..431cf3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainToRuleNodeMsg.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbMsg; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +final class RuleChainToRuleNodeMsg extends TbToRuleNodeActorMsg { + + @Getter + private final String fromRelationType; + + public RuleChainToRuleNodeMsg(TbContext ctx, TbMsg tbMsg, String fromRelationType) { + super(ctx, tbMsg); + this.fromRelationType = fromRelationType; + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_CHAIN_TO_RULE_MSG; + } +} 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 new file mode 100644 index 0000000..f5847f7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.service.ComponentActor; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; + +@Slf4j +public class RuleNodeActor extends ComponentActor { + + private final String ruleChainName; + private final RuleChainId ruleChainId; + private final RuleNodeId ruleNodeId; + + private RuleNodeActor(ActorSystemContext systemContext, TenantId tenantId, RuleChainId ruleChainId, String ruleChainName, RuleNodeId ruleNodeId) { + super(systemContext, tenantId, ruleNodeId); + this.ruleChainName = ruleChainName; + this.ruleChainId = ruleChainId; + this.ruleNodeId = ruleNodeId; + } + + @Override + protected RuleNodeActorMessageProcessor createProcessor(TbActorCtx ctx) { + return new RuleNodeActorMessageProcessor(tenantId, this.ruleChainName, ruleNodeId, systemContext, ctx.getParentRef(), ctx); + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + switch (msg.getMsgType()) { + case COMPONENT_LIFE_CYCLE_MSG: + case RULE_NODE_UPDATED_MSG: + onComponentLifecycleMsg((ComponentLifecycleMsg) msg); + break; + case RULE_CHAIN_TO_RULE_MSG: + onRuleChainToRuleNodeMsg((RuleChainToRuleNodeMsg) msg); + break; + case RULE_TO_SELF_MSG: + onRuleNodeToSelfMsg((RuleNodeToSelfMsg) msg); + break; + case STATS_PERSIST_TICK_MSG: + onStatsPersistTick(id); + break; + case PARTITION_CHANGE_MSG: + onClusterEventMsg((PartitionChangeMsg) msg); + break; + default: + return false; + } + return true; + } + + private void onRuleNodeToSelfMsg(RuleNodeToSelfMsg msg) { + if (log.isDebugEnabled()) { + log.debug("[{}][{}][{}] Going to process rule msg: {}", ruleChainId, id, processor.getComponentName(), msg.getMsg()); + } + try { + processor.onRuleToSelfMsg(msg); + increaseMessagesProcessedCount(); + } catch (Exception e) { + logAndPersist("onRuleMsg", e); + } + } + + private void onRuleChainToRuleNodeMsg(RuleChainToRuleNodeMsg envelope) { + TbMsg msg = envelope.getMsg(); + if (!msg.isValid()) { + if (log.isTraceEnabled()) { + log.trace("Skip processing of message: {} because it is no longer valid!", msg); + } + return; + } + if (log.isDebugEnabled()) { + log.debug("[{}][{}][{}] Going to process rule engine msg: {}", ruleChainId, id, processor.getComponentName(), msg); + } + try { + processor.onRuleChainToRuleNodeMsg(envelope); + increaseMessagesProcessedCount(); + } catch (Exception e) { + logAndPersist("onRuleMsg", e); + } + } + + public static class ActorCreator extends ContextBasedCreator { + + private final TenantId tenantId; + private final RuleChainId ruleChainId; + private final String ruleChainName; + private final RuleNodeId ruleNodeId; + + public ActorCreator(ActorSystemContext context, TenantId tenantId, RuleChainId ruleChainId, String ruleChainName, RuleNodeId ruleNodeId) { + super(context); + this.tenantId = tenantId; + this.ruleChainId = ruleChainId; + this.ruleChainName = ruleChainName; + this.ruleNodeId = ruleNodeId; + + } + + @Override + public TbActorId createActorId() { + return new TbEntityActorId(ruleNodeId); + } + + @Override + public TbActor createActor() { + return new RuleNodeActor(context, tenantId, ruleChainId, ruleChainName, ruleNodeId); + } + } + + @Override + protected long getErrorPersistFrequency() { + return systemContext.getRuleNodeErrorPersistFrequency(); + } + +} 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 new file mode 100644 index 0000000..4d10233 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java @@ -0,0 +1,163 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbRuleNodeUpdateException; +import org.thingsboard.server.actors.shared.ComponentMsgProcessor; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.RuleNodeException; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; + +/** + * @author Andrew Shvayka + */ +public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor { + + private final String ruleChainName; + private final TbActorRef self; + private final TbApiUsageReportClient apiUsageClient; + private RuleNode ruleNode; + private TbNode tbNode; + private DefaultTbContext defaultCtx; + private RuleNodeInfo info; + + RuleNodeActorMessageProcessor(TenantId tenantId, String ruleChainName, RuleNodeId ruleNodeId, ActorSystemContext systemContext + , TbActorRef parent, TbActorRef self) { + super(systemContext, tenantId, ruleNodeId); + this.apiUsageClient = systemContext.getApiUsageClient(); + this.ruleChainName = ruleChainName; + this.self = self; + this.ruleNode = systemContext.getRuleChainService().findRuleNodeById(tenantId, entityId); + this.defaultCtx = new DefaultTbContext(systemContext, ruleChainName, new RuleNodeCtx(tenantId, parent, self, ruleNode)); + this.info = new RuleNodeInfo(ruleNodeId, ruleChainName, ruleNode != null ? ruleNode.getName() : "Unknown"); + } + + @Override + public void start(TbActorCtx context) throws Exception { + tbNode = initComponent(ruleNode); + if (tbNode != null) { + state = ComponentLifecycleState.ACTIVE; + } + } + + @Override + public void onUpdate(TbActorCtx context) throws Exception { + RuleNode newRuleNode = systemContext.getRuleChainService().findRuleNodeById(tenantId, entityId); + this.info = new RuleNodeInfo(entityId, ruleChainName, newRuleNode != null ? newRuleNode.getName() : "Unknown"); + boolean restartRequired = state != ComponentLifecycleState.ACTIVE || + !(ruleNode.getType().equals(newRuleNode.getType()) && ruleNode.getConfiguration().equals(newRuleNode.getConfiguration())); + this.ruleNode = newRuleNode; + this.defaultCtx.updateSelf(newRuleNode); + if (restartRequired) { + if (tbNode != null) { + tbNode.destroy(); + } + try { + start(context); + } catch (Exception e) { + throw new TbRuleNodeUpdateException("Failed to update rule node", e); + } + } + } + + @Override + public void stop(TbActorCtx context) { + if (tbNode != null) { + tbNode.destroy(); + state = ComponentLifecycleState.SUSPENDED; + } + } + + @Override + public void onPartitionChangeMsg(PartitionChangeMsg msg) { + if (tbNode != null) { + tbNode.onPartitionChangeMsg(defaultCtx, msg); + } + } + + public void onRuleToSelfMsg(RuleNodeToSelfMsg msg) throws Exception { + checkComponentStateActive(msg.getMsg()); + TbMsg tbMsg = msg.getMsg(); + int ruleNodeCount = tbMsg.getAndIncrementRuleNodeCounter(); + int maxRuleNodeExecutionsPerMessage = getTenantProfileConfiguration().getMaxRuleNodeExecsPerMessage(); + if (maxRuleNodeExecutionsPerMessage == 0 || ruleNodeCount < maxRuleNodeExecutionsPerMessage) { + apiUsageClient.report(tenantId, tbMsg.getCustomerId(), ApiUsageRecordKey.RE_EXEC_COUNT); + if (ruleNode.isDebugMode()) { + systemContext.persistDebugInput(tenantId, entityId, msg.getMsg(), "Self"); + } + try { + tbNode.onMsg(defaultCtx, msg.getMsg()); + } catch (Exception e) { + defaultCtx.tellFailure(msg.getMsg(), e); + } + } else { + tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more then " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode)); + } + } + + void onRuleChainToRuleNodeMsg(RuleChainToRuleNodeMsg msg) throws Exception { + msg.getMsg().getCallback().onProcessingStart(info); + checkComponentStateActive(msg.getMsg()); + TbMsg tbMsg = msg.getMsg(); + int ruleNodeCount = tbMsg.getAndIncrementRuleNodeCounter(); + int maxRuleNodeExecutionsPerMessage = getTenantProfileConfiguration().getMaxRuleNodeExecsPerMessage(); + if (maxRuleNodeExecutionsPerMessage == 0 || ruleNodeCount < maxRuleNodeExecutionsPerMessage) { + apiUsageClient.report(tenantId, tbMsg.getCustomerId(), ApiUsageRecordKey.RE_EXEC_COUNT); + if (ruleNode.isDebugMode()) { + systemContext.persistDebugInput(tenantId, entityId, msg.getMsg(), msg.getFromRelationType()); + } + try { + tbNode.onMsg(msg.getCtx(), msg.getMsg()); + } catch (Exception e) { + msg.getCtx().tellFailure(msg.getMsg(), e); + } + } else { + tbMsg.getCallback().onFailure(new RuleNodeException("Message is processed by more then " + maxRuleNodeExecutionsPerMessage + " rule nodes!", ruleChainName, ruleNode)); + } + } + + @Override + public String getComponentName() { + return ruleNode.getName(); + } + + private TbNode initComponent(RuleNode ruleNode) throws Exception { + TbNode tbNode = null; + if (ruleNode != null) { + Class componentClazz = Class.forName(ruleNode.getType()); + tbNode = (TbNode) (componentClazz.getDeclaredConstructor().newInstance()); + tbNode.init(defaultCtx, new TbNodeConfiguration(ruleNode.getConfiguration())); + } + return tbNode; + } + + @Override + protected RuleNodeException getInactiveException() { + return new RuleNodeException("Rule Node is not active! Failed to initialize.", ruleChainName, ruleNode); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeCtx.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeCtx.java new file mode 100644 index 0000000..3123c8f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeCtx.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleNode; + +/** + * Created by ashvayka on 19.03.18. + */ +@Data +@AllArgsConstructor +final class RuleNodeCtx { + private final TenantId tenantId; + private final TbActorRef chainActor; + private final TbActorRef selfActor; + private RuleNode self; +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeRelation.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeRelation.java new file mode 100644 index 0000000..7dfe0c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeRelation.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; + +/** + * Created by ashvayka on 19.03.18. + */ + +@Data +final class RuleNodeRelation { + + private final EntityId in; + private final EntityId out; + private final String type; + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToRuleChainTellNextMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToRuleChainTellNextMsg.java new file mode 100644 index 0000000..f687045 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToRuleChainTellNextMsg.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorStopReason; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbRuleEngineActorMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; + +import java.io.Serializable; +import java.util.Set; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +class RuleNodeToRuleChainTellNextMsg extends TbRuleEngineActorMsg implements Serializable { + + private static final long serialVersionUID = 4577026446412871820L; + @Getter + private final RuleChainId ruleChainId; + @Getter + private final RuleNodeId originator; + @Getter + private final Set relationTypes; + @Getter + private final String failureMessage; + + public RuleNodeToRuleChainTellNextMsg(RuleChainId ruleChainId, RuleNodeId originator, Set relationTypes, TbMsg tbMsg, String failureMessage) { + super(tbMsg); + this.ruleChainId = ruleChainId; + this.originator = originator; + this.relationTypes = relationTypes; + this.failureMessage = failureMessage; + } + + @Override + public void onTbActorStopped(TbActorStopReason reason) { + String message = reason == TbActorStopReason.STOPPED ? String.format("Rule chain [%s] stopped", ruleChainId.getId()) : String.format("Failed to initialize rule chain [%s]!", ruleChainId.getId()); + msg.getCallback().onFailure(new RuleEngineException(message)); + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_TO_RULE_CHAIN_TELL_NEXT_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToSelfMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToSelfMsg.java new file mode 100644 index 0000000..2748966 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeToSelfMsg.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorStopReason; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbRuleEngineActorMsg; +import org.thingsboard.server.common.msg.queue.RuleNodeException; + +/** + * Created by ashvayka on 19.03.18. + */ +@EqualsAndHashCode(callSuper = true) +@ToString +final class RuleNodeToSelfMsg extends TbToRuleNodeActorMsg { + + public RuleNodeToSelfMsg(TbContext ctx, TbMsg tbMsg) { + super(ctx, tbMsg); + } + + @Override + public MsgType getMsgType() { + return MsgType.RULE_TO_SELF_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleChainActorMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleChainActorMsg.java new file mode 100644 index 0000000..da7489b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleChainActorMsg.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.msg.TbActorStopReason; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbRuleEngineActorMsg; +import org.thingsboard.server.common.msg.aware.RuleChainAwareMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; + +@EqualsAndHashCode(callSuper = true) +@ToString +public abstract class TbToRuleChainActorMsg extends TbRuleEngineActorMsg implements RuleChainAwareMsg { + + @Getter + private final RuleChainId target; + + public TbToRuleChainActorMsg(TbMsg msg, RuleChainId target) { + super(msg); + this.target = target; + } + + @Override + public RuleChainId getRuleChainId() { + return target; + } + + @Override + public void onTbActorStopped(TbActorStopReason reason) { + String message = reason == TbActorStopReason.STOPPED ? String.format("Rule chain [%s] stopped", target.getId()) : String.format("Failed to initialize rule chain [%s]!", target.getId()); + msg.getCallback().onFailure(new RuleEngineException(message)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleNodeActorMsg.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleNodeActorMsg.java new file mode 100644 index 0000000..33c4052 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/TbToRuleNodeActorMsg.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.ruleChain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.msg.TbActorStopReason; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbRuleEngineActorMsg; +import org.thingsboard.server.common.msg.queue.RuleNodeException; + +@EqualsAndHashCode(callSuper = true) +public abstract class TbToRuleNodeActorMsg extends TbRuleEngineActorMsg { + + @Getter + private final TbContext ctx; + + public TbToRuleNodeActorMsg(TbContext ctx, TbMsg tbMsg) { + super(tbMsg); + this.ctx = ctx; + } + + @Override + public void onTbActorStopped(TbActorStopReason reason) { + String message = reason == TbActorStopReason.STOPPED ? "Rule node stopped" : "Failed to initialize rule node!"; + msg.getCallback().onFailure(new RuleNodeException(message, ctx.getRuleChainName(), ctx.getSelf())); + } +} 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 new file mode 100644 index 0000000..f747b24 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.service; + +public interface ActorService { + + +} 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 new file mode 100644 index 0000000..f89872d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java @@ -0,0 +1,189 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.service; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.actors.TbRuleNodeUpdateException; +import org.thingsboard.server.actors.shared.ComponentMsgProcessor; +import org.thingsboard.server.actors.stats.StatsPersistMsg; +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.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; + +/** + * @author Andrew Shvayka + */ +@Slf4j +public abstract class ComponentActor> extends ContextAwareActor { + + private long lastPersistedErrorTs = 0L; + protected final TenantId tenantId; + protected final T id; + protected P processor; + private long messagesProcessed; + private long errorsOccurred; + + public ComponentActor(ActorSystemContext systemContext, TenantId tenantId, T id) { + super(systemContext); + this.tenantId = tenantId; + this.id = id; + } + + abstract protected P createProcessor(TbActorCtx ctx); + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + this.processor = createProcessor(ctx); + initProcessor(ctx); + } + + protected void initProcessor(TbActorCtx ctx) throws TbActorException { + try { + log.debug("[{}][{}][{}] Starting processor.", tenantId, id, id.getEntityType()); + processor.start(ctx); + logLifecycleEvent(ComponentLifecycleEvent.STARTED); + if (systemContext.isStatisticsEnabled()) { + scheduleStatsPersistTick(); + } + } catch (Exception e) { + log.debug("[{}][{}] Failed to start {} processor.", tenantId, id, id.getEntityType(), e); + logAndPersist("OnStart", e, true); + logLifecycleEvent(ComponentLifecycleEvent.STARTED, e); + throw new TbActorException("Failed to init actor", e); + } + } + + private void scheduleStatsPersistTick() { + try { + processor.scheduleStatsPersistTick(ctx, systemContext.getStatisticsPersistFrequency()); + } catch (Exception e) { + log.error("[{}][{}] Failed to schedule statistics store message. No statistics is going to be stored: {}", tenantId, id, e.getMessage()); + logAndPersist("onScheduleStatsPersistMsg", e); + } + } + + @Override + public void destroy() { + try { + log.debug("[{}][{}][{}] Stopping processor.", tenantId, id, id.getEntityType()); + if (processor != null) { + processor.stop(ctx); + } + logLifecycleEvent(ComponentLifecycleEvent.STOPPED); + } catch (Exception e) { + 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: + processor.onCreated(ctx); + break; + case UPDATED: + processor.onUpdate(ctx); + break; + case ACTIVATED: + processor.onActivate(ctx); + break; + case SUSPENDED: + processor.onSuspend(ctx); + break; + case DELETED: + processor.onStop(ctx); + ctx.stop(ctx.getSelf()); + break; + default: + break; + } + logLifecycleEvent(msg.getEvent()); + } catch (Exception e) { + logAndPersist("onLifecycleMsg", e, true); + logLifecycleEvent(msg.getEvent(), e); + if (e instanceof TbRuleNodeUpdateException) { + throw (TbRuleNodeUpdateException) e; + } + } + } + + protected void onClusterEventMsg(PartitionChangeMsg msg) { + try { + processor.onPartitionChangeMsg(msg); + } catch (Exception e) { + logAndPersist("onClusterEventMsg", e); + } + } + + protected void onStatsPersistTick(EntityId entityId) { + try { + systemContext.getStatsActor().tell(new StatsPersistMsg(messagesProcessed, errorsOccurred, tenantId, entityId)); + resetStatsCounters(); + } catch (Exception e) { + logAndPersist("onStatsPersistTick", e); + } + } + + private void resetStatsCounters() { + messagesProcessed = 0; + errorsOccurred = 0; + } + + protected void increaseMessagesProcessedCount() { + messagesProcessed++; + } + + protected void logAndPersist(String method, Exception e) { + logAndPersist(method, e, false); + } + + private void logAndPersist(String method, Exception e, boolean critical) { + errorsOccurred++; + String componentName = processor != null ? processor.getComponentName() : "Unknown"; + if (critical) { + log.debug("[{}][{}][{}] Failed to process method: {}", id, tenantId, componentName, method); + log.debug("Critical Error: ", e); + } else { + log.trace("[{}][{}][{}] Failed to process method: {}", id, tenantId, componentName, method); + log.trace("Debug Error: ", e); + } + long ts = System.currentTimeMillis(); + if (ts - lastPersistedErrorTs > getErrorPersistFrequency()) { + systemContext.persistError(tenantId, id, method, e); + lastPersistedErrorTs = ts; + } + } + + private void logLifecycleEvent(ComponentLifecycleEvent event) { + logLifecycleEvent(event, null); + } + + private void logLifecycleEvent(ComponentLifecycleEvent event, Exception e) { + systemContext.persistLifecycleEvent(tenantId, id, event, e); + } + + protected abstract long 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 new file mode 100644 index 0000000..1a6df8b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.service; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.thingsboard.server.actors.AbstractTbActor; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.ProcessFailureStrategy; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.common.msg.TbActorMsg; + +@Slf4j +public abstract class ContextAwareActor extends AbstractTbActor { + + public static final int ENTITY_PACK_LIMIT = 1024; + + protected final ActorSystemContext systemContext; + + public ContextAwareActor(ActorSystemContext systemContext) { + super(); + this.systemContext = systemContext; + } + + @Override + public boolean process(TbActorMsg msg) { + if (log.isDebugEnabled()) { + log.debug("Processing msg: {}", msg); + } + if (!doProcess(msg)) { + log.warn("Unprocessed message: {}!", msg); + } + return false; + } + + protected abstract boolean doProcess(TbActorMsg msg); + + @Override + public ProcessFailureStrategy onProcessFailure(Throwable t) { + log.debug("[{}] Processing failure: ", getActorRef().getActorId(), t); + return doProcessFailure(t); + } + + protected ProcessFailureStrategy doProcessFailure(Throwable t) { + if (t instanceof Error) { + return ProcessFailureStrategy.stop(); + } else { + return ProcessFailureStrategy.resume(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ContextBasedCreator.java b/application/src/main/java/org/thingsboard/server/actors/service/ContextBasedCreator.java new file mode 100644 index 0000000..6445044 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/service/ContextBasedCreator.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.service; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCreator; + +public abstract class ContextBasedCreator implements TbActorCreator { + + protected final transient ActorSystemContext context; + + public ContextBasedCreator(ActorSystemContext context) { + super(); + this.context = context; + } +} 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 new file mode 100644 index 0000000..3d3bf2b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -0,0 +1,138 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.DefaultTbActorSystem; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbActorSystem; +import org.thingsboard.server.actors.TbActorSystemSettings; +import org.thingsboard.server.actors.app.AppActor; +import org.thingsboard.server.actors.app.AppInitMsg; +import org.thingsboard.server.actors.stats.StatsActor; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.util.AfterStartUp; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Service +@Slf4j +public class DefaultActorService extends TbApplicationEventListener implements ActorService { + + public static final String APP_DISPATCHER_NAME = "app-dispatcher"; + public static final String TENANT_DISPATCHER_NAME = "tenant-dispatcher"; + public static final String DEVICE_DISPATCHER_NAME = "device-dispatcher"; + public static final String RULE_DISPATCHER_NAME = "rule-dispatcher"; + + @Autowired + private ActorSystemContext actorContext; + + private TbActorSystem system; + + private TbActorRef appActor; + + @Value("${actors.system.throughput:5}") + private int actorThroughput; + + @Value("${actors.system.max_actor_init_attempts:10}") + private int maxActorInitAttempts; + + @Value("${actors.system.scheduler_pool_size:1}") + private int schedulerPoolSize; + + @Value("${actors.system.app_dispatcher_pool_size:1}") + private int appDispatcherSize; + + @Value("${actors.system.tenant_dispatcher_pool_size:2}") + private int tenantDispatcherSize; + + @Value("${actors.system.device_dispatcher_pool_size:4}") + private int deviceDispatcherSize; + + @Value("${actors.system.rule_dispatcher_pool_size:4}") + private int ruleDispatcherSize; + + @PostConstruct + public void initActorSystem() { + log.info("Initializing actor system."); + actorContext.setActorService(this); + TbActorSystemSettings settings = new TbActorSystemSettings(actorThroughput, schedulerPoolSize, maxActorInitAttempts); + system = new DefaultTbActorSystem(settings); + + system.createDispatcher(APP_DISPATCHER_NAME, initDispatcherExecutor(APP_DISPATCHER_NAME, appDispatcherSize)); + system.createDispatcher(TENANT_DISPATCHER_NAME, initDispatcherExecutor(TENANT_DISPATCHER_NAME, tenantDispatcherSize)); + system.createDispatcher(DEVICE_DISPATCHER_NAME, initDispatcherExecutor(DEVICE_DISPATCHER_NAME, deviceDispatcherSize)); + system.createDispatcher(RULE_DISPATCHER_NAME, initDispatcherExecutor(RULE_DISPATCHER_NAME, ruleDispatcherSize)); + + actorContext.setActorSystem(system); + + appActor = system.createRootActor(APP_DISPATCHER_NAME, new AppActor.ActorCreator(actorContext)); + actorContext.setAppActor(appActor); + + TbActorRef statsActor = system.createRootActor(TENANT_DISPATCHER_NAME, new StatsActor.ActorCreator(actorContext, "StatsActor")); + actorContext.setStatsActor(statsActor); + + log.info("Actor system initialized."); + } + + private ExecutorService initDispatcherExecutor(String dispatcherName, int poolSize) { + if (poolSize == 0) { + int cores = Runtime.getRuntime().availableProcessors(); + poolSize = Math.max(1, cores / 2); + } + if (poolSize == 1) { + return Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(dispatcherName)); + } else { + return ThingsBoardExecutors.newWorkStealingPool(poolSize, dispatcherName); + } + } + + @AfterStartUp(order = AfterStartUp.ACTOR_SYSTEM) + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + log.info("Received application ready event. Sending application init message to actor system"); + appActor.tellWithHighPriority(new AppInitMsg()); + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + log.info("Received partition change event."); + this.appActor.tellWithHighPriority(new PartitionChangeMsg(event.getQueueKey().getType(), event.getPartitions())); + } + + @PreDestroy + public void stopActorSystem() { + if (system != null) { + log.info("Stopping actor system."); + system.stop(); + log.info("Actor system stopped."); + } + } + +} 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 new file mode 100644 index 0000000..2cc8766 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.shared; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.common.msg.TbActorMsg; + +import java.util.concurrent.ScheduledExecutorService; + +@Slf4j +public abstract class AbstractContextAwareMsgProcessor { + + protected final static ObjectMapper mapper = new ObjectMapper(); + + protected final ActorSystemContext systemContext; + + protected AbstractContextAwareMsgProcessor(ActorSystemContext systemContext) { + super(); + this.systemContext = systemContext; + } + + private ScheduledExecutorService getScheduler() { + return systemContext.getScheduler(); + } + + protected void schedulePeriodicMsgWithDelay(TbActorCtx ctx, TbActorMsg msg, long delayInMs, long periodInMs) { + systemContext.schedulePeriodicMsgWithDelay(ctx, msg, delayInMs, periodInMs); + } + + protected void scheduleMsgWithDelay(TbActorCtx ctx, TbActorMsg msg, long delayInMs) { + systemContext.scheduleMsgWithDelay(ctx, msg, delayInMs); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ActorTerminationMsg.java b/application/src/main/java/org/thingsboard/server/actors/shared/ActorTerminationMsg.java new file mode 100644 index 0000000..21b1cd9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ActorTerminationMsg.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.shared; + +public abstract class ActorTerminationMsg { + + private final T id; + + public ActorTerminationMsg(T id) { + super(); + this.id = id; + } + + public T getId() { + return id; + } + +} 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 new file mode 100644 index 0000000..06407d6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -0,0 +1,108 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.shared; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.stats.StatsPersistTick; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.RuleNodeException; + +@Slf4j +public abstract class ComponentMsgProcessor extends AbstractContextAwareMsgProcessor { + + protected final TenantId tenantId; + protected final T entityId; + protected ComponentLifecycleState state; + + protected ComponentMsgProcessor(ActorSystemContext systemContext, TenantId tenantId, T id) { + super(systemContext); + this.tenantId = tenantId; + this.entityId = id; + } + + protected TenantProfileConfiguration getTenantProfileConfiguration() { + return systemContext.getTenantProfileCache().get(tenantId).getProfileData().getConfiguration(); + } + + public abstract String getComponentName(); + + public abstract void start(TbActorCtx context) throws Exception; + + public abstract void stop(TbActorCtx context) throws Exception; + + public abstract void onPartitionChangeMsg(PartitionChangeMsg msg) throws Exception; + + public void onCreated(TbActorCtx context) throws Exception { + start(context); + } + + public void onUpdate(TbActorCtx context) throws Exception { + restart(context); + } + + public void onActivate(TbActorCtx context) throws Exception { + restart(context); + } + + public void onSuspend(TbActorCtx context) throws Exception { + stop(context); + } + + public void onStop(TbActorCtx context) throws Exception { + stop(context); + } + + private void restart(TbActorCtx context) throws Exception { + stop(context); + start(context); + } + + public void scheduleStatsPersistTick(TbActorCtx context, long statsPersistFrequency) { + schedulePeriodicMsgWithDelay(context, new StatsPersistTick(), statsPersistFrequency, statsPersistFrequency); + } + + protected boolean checkMsgValid(TbMsg tbMsg) { + var valid = tbMsg.isValid(); + if (!valid) { + if (log.isTraceEnabled()) { + log.trace("Skip processing of message: {} because it is no longer valid!", tbMsg); + } + } + return valid; + } + + protected void checkComponentStateActive(TbMsg tbMsg) throws RuleNodeException { + if (state != ComponentLifecycleState.ACTIVE) { + log.debug("Component is not active. Current state [{}] for processor [{}][{}] tenant [{}]", state, entityId.getEntityType(), entityId, tenantId); + RuleNodeException ruleNodeException = getInactiveException(); + if (tbMsg != null) { + tbMsg.getCallback().onFailure(ruleNodeException); + } + throw ruleNodeException; + } + } + + abstract protected RuleNodeException getInactiveException(); + +} 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 new file mode 100644 index 0000000..247a6c6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/stats/StatsActor.java @@ -0,0 +1,89 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.stats; + +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.TbActor; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbStringActorId; +import org.thingsboard.server.actors.service.ContextAwareActor; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.event.StatisticsEvent; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +@Slf4j +public class StatsActor extends ContextAwareActor { + + private final ObjectMapper mapper = new ObjectMapper(); + + public StatsActor(ActorSystemContext context) { + super(context); + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + log.debug("Received message: {}", msg); + if (msg.getMsgType().equals(MsgType.STATS_PERSIST_MSG)) { + onStatsPersistMsg((StatsPersistMsg) msg); + return true; + } else { + return false; + } + } + + public void onStatsPersistMsg(StatsPersistMsg msg) { + if (msg.isEmpty()) { + return; + } + systemContext.getEventService().saveAsync(StatisticsEvent.builder() + .tenantId(msg.getTenantId()) + .entityId(msg.getEntityId().getId()) + .serviceId(systemContext.getServiceInfoProvider().getServiceId()) + .messagesProcessed(msg.getMessagesProcessed()) + .errorsOccurred(msg.getErrorsOccurred()) + .build() + ); + } + + private JsonNode toBodyJson(String serviceId, long messagesProcessed, long errorsOccurred) { + return mapper.createObjectNode().put("server", serviceId).put("messagesProcessed", messagesProcessed).put("errorsOccurred", errorsOccurred); + } + + public static class ActorCreator extends ContextBasedCreator { + private final String actorId; + + public ActorCreator(ActorSystemContext context, String actorId) { + super(context); + this.actorId = actorId; + } + + @Override + public TbActorId createActorId() { + return new TbStringActorId(actorId); + } + + @Override + public TbActor createActor() { + return new StatsActor(context); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistMsg.java b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistMsg.java new file mode 100644 index 0000000..f2dc74e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistMsg.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.stats; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +@AllArgsConstructor +@Getter +@ToString +public final class StatsPersistMsg implements TbActorMsg { + + private final long messagesProcessed; + private final long errorsOccurred; + private final TenantId tenantId; + private final EntityId entityId; + + @Override + public MsgType getMsgType() { + return MsgType.STATS_PERSIST_MSG; + } + + public boolean isEmpty() { + return messagesProcessed == 0 && errorsOccurred == 0; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java new file mode 100644 index 0000000..671fd0a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/stats/StatsPersistTick.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.stats; + +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; + +public final class StatsPersistTick implements TbActorMsg { + @Override + public MsgType getMsgType() { + return MsgType.STATS_PERSIST_TICK_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/DebugTbRateLimits.java b/application/src/main/java/org/thingsboard/server/actors/tenant/DebugTbRateLimits.java new file mode 100644 index 0000000..575e814 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/DebugTbRateLimits.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.tenant; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.msg.tools.TbRateLimits; + +@Data +@AllArgsConstructor +public class DebugTbRateLimits { + + private TbRateLimits tbRateLimits; + private boolean ruleChainEventSaved; + +} 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 new file mode 100644 index 0000000..9c40d40 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -0,0 +1,305 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.tenant; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActor; +import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.TbActorException; +import org.thingsboard.server.actors.TbActorId; +import org.thingsboard.server.actors.TbActorNotRegisteredException; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.TbEntityTypeActorIdPredicate; +import org.thingsboard.server.actors.device.DeviceActorCreator; +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.common.data.ApiUsageState; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; +import org.thingsboard.server.common.msg.aware.RuleChainAwareMsg; +import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.PartitionChangeMsg; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.service.edge.rpc.EdgeRpcService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import java.util.List; + +@Slf4j +public class TenantActor extends RuleChainManagerActor { + + private boolean isRuleEngine; + private boolean isCore; + private ApiUsageState apiUsageState; + + private TenantActor(ActorSystemContext systemContext, TenantId tenantId) { + super(systemContext, tenantId); + } + + boolean cantFindTenant = false; + + @Override + public void init(TbActorCtx ctx) throws TbActorException { + super.init(ctx); + log.debug("[{}] Starting tenant actor.", tenantId); + try { + Tenant tenant = systemContext.getTenantService().findTenantById(tenantId); + if (tenant == null) { + cantFindTenant = true; + log.info("[{}] Started tenant actor for missing tenant.", tenantId); + } else { + TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenant.getTenantProfileId()); + + isCore = systemContext.getServiceInfoProvider().isService(ServiceType.TB_CORE); + isRuleEngine = systemContext.getServiceInfoProvider().isService(ServiceType.TB_RULE_ENGINE); + if (isRuleEngine) { + try { + if (getApiUsageState().isReExecEnabled()) { + log.debug("[{}] Going to init rule chains", tenantId); + initRuleChains(); + } else { + log.info("[{}] Skip init of the rule chains due to API limits", tenantId); + } + } catch (Exception e) { + cantFindTenant = true; + } + } + log.debug("[{}] Tenant actor started.", tenantId); + } + } catch (Exception e) { + log.warn("[{}] Unknown failure", tenantId, e); + } + } + + @Override + public void destroy() { + log.info("[{}] Stopping tenant actor.", tenantId); + } + + @Override + protected boolean doProcess(TbActorMsg msg) { + if (cantFindTenant) { + log.info("[{}] Processing missing Tenant msg: {}", tenantId, msg); + if (msg.getMsgType().equals(MsgType.QUEUE_TO_RULE_ENGINE_MSG)) { + QueueToRuleEngineMsg queueMsg = (QueueToRuleEngineMsg) msg; + queueMsg.getMsg().getCallback().onSuccess(); + } else if (msg.getMsgType().equals(MsgType.TRANSPORT_TO_DEVICE_ACTOR_MSG)) { + TransportToDeviceActorMsgWrapper transportMsg = (TransportToDeviceActorMsgWrapper) msg; + transportMsg.getCallback().onSuccess(); + } + return true; + } + switch (msg.getMsgType()) { + case PARTITION_CHANGE_MSG: + PartitionChangeMsg partitionChangeMsg = (PartitionChangeMsg) msg; + ServiceType serviceType = partitionChangeMsg.getServiceType(); + if (ServiceType.TB_RULE_ENGINE.equals(serviceType)) { + //To Rule Chain Actors + broadcast(msg); + } else if (ServiceType.TB_CORE.equals(serviceType)) { + List deviceActorIds = ctx.filterChildren(new TbEntityTypeActorIdPredicate(EntityType.DEVICE) { + @Override + protected boolean testEntityId(EntityId entityId) { + return super.testEntityId(entityId) && !isMyPartition(entityId); + } + }); + deviceActorIds.forEach(id -> ctx.stop(id)); + } + break; + case COMPONENT_LIFE_CYCLE_MSG: + onComponentLifecycleMsg((ComponentLifecycleMsg) msg); + break; + case QUEUE_TO_RULE_ENGINE_MSG: + onQueueToRuleEngineMsg((QueueToRuleEngineMsg) msg); + break; + case TRANSPORT_TO_DEVICE_ACTOR_MSG: + onToDeviceActorMsg((DeviceAwareMsg) msg, false); + break; + 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: + case DEVICE_EDGE_UPDATE_TO_DEVICE_ACTOR_MSG: + case DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG: + case DEVICE_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: + case SERVER_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: + case REMOVE_RPC_TO_DEVICE_ACTOR_MSG: + onToDeviceActorMsg((DeviceAwareMsg) msg, true); + break; + case SESSION_TIMEOUT_MSG: + ctx.broadcastToChildrenByType(msg, EntityType.DEVICE); + break; + case RULE_CHAIN_INPUT_MSG: + case RULE_CHAIN_OUTPUT_MSG: + case RULE_CHAIN_TO_RULE_CHAIN_MSG: + onRuleChainMsg((RuleChainAwareMsg) msg); + break; + case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG: + case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG: + case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG: + onToEdgeSessionMsg((EdgeSessionMsg) msg); + break; + default: + return false; + } + return true; + } + + private boolean isMyPartition(EntityId entityId) { + return systemContext.resolve(ServiceType.TB_CORE, tenantId, entityId).isMyPartition(); + } + + private void onQueueToRuleEngineMsg(QueueToRuleEngineMsg msg) { + if (!isRuleEngine) { + log.warn("RECEIVED INVALID MESSAGE: {}", msg); + return; + } + TbMsg tbMsg = msg.getMsg(); + if (getApiUsageState().isReExecEnabled()) { + if (tbMsg.getRuleChainId() == null) { + if (getRootChainActor() != null) { + getRootChainActor().tell(msg); + } else { + tbMsg.getCallback().onFailure(new RuleEngineException("No Root Rule Chain available!")); + log.info("[{}] No Root Chain: {}", tenantId, msg); + } + } else { + try { + ctx.tell(new TbEntityActorId(tbMsg.getRuleChainId()), msg); + } catch (TbActorNotRegisteredException ex) { + log.trace("Received message for non-existing rule chain: [{}]", tbMsg.getRuleChainId()); + //TODO: 3.1 Log it to dead letters queue; + tbMsg.getCallback().onSuccess(); + } + } + } else { + log.trace("[{}] Ack message because Rule Engine is disabled", tenantId); + tbMsg.getCallback().onSuccess(); + } + } + + private void onRuleChainMsg(RuleChainAwareMsg msg) { + if (getApiUsageState().isReExecEnabled()) { + getOrCreateActor(msg.getRuleChainId()).tell(msg); + } + } + + private void onToDeviceActorMsg(DeviceAwareMsg msg, boolean priority) { + if (!isCore) { + log.warn("RECEIVED INVALID MESSAGE: {}", msg); + } + TbActorRef deviceActor = getOrCreateDeviceActor(msg.getDeviceId()); + if (priority) { + deviceActor.tellWithHighPriority(msg); + } else { + deviceActor.tell(msg); + } + } + + private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { + if (msg.getEntityId().getEntityType().equals(EntityType.API_USAGE_STATE)) { + ApiUsageState old = getApiUsageState(); + apiUsageState = new ApiUsageState(systemContext.getApiUsageStateService().getApiUsageState(tenantId)); + if (old.isReExecEnabled() && !apiUsageState.isReExecEnabled()) { + log.info("[{}] Received API state update. Going to DISABLE Rule Engine execution.", tenantId); + destroyRuleChains(); + } else if (!old.isReExecEnabled() && apiUsageState.isReExecEnabled()) { + log.info("[{}] Received API state update. Going to ENABLE Rule Engine execution.", tenantId); + initRuleChains(); + } + } else if (msg.getEntityId().getEntityType() == EntityType.EDGE) { + EdgeId edgeId = new EdgeId(msg.getEntityId().getId()); + EdgeRpcService edgeRpcService = systemContext.getEdgeRpcService(); + if (msg.getEvent() == ComponentLifecycleEvent.DELETED) { + edgeRpcService.deleteEdge(tenantId, edgeId); + } else if (msg.getEvent() == ComponentLifecycleEvent.UPDATED) { + Edge edge = systemContext.getEdgeService().findEdgeById(tenantId, edgeId); + edgeRpcService.updateEdge(tenantId, edge); + } + } else if (isRuleEngine) { + TbActorRef target = getEntityActorRef(msg.getEntityId()); + if (target != null) { + if (msg.getEntityId().getEntityType() == EntityType.RULE_CHAIN) { + RuleChain ruleChain = systemContext.getRuleChainService(). + findRuleChainById(tenantId, new RuleChainId(msg.getEntityId().getId())); + if (ruleChain != null && RuleChainType.CORE.equals(ruleChain.getType())) { + visit(ruleChain, target); + } + } + target.tellWithHighPriority(msg); + } else { + log.debug("[{}] Invalid component lifecycle msg: {}", tenantId, msg); + } + } + } + + private TbActorRef getOrCreateDeviceActor(DeviceId deviceId) { + return ctx.getOrCreateChildActor(new TbEntityActorId(deviceId), + () -> DefaultActorService.DEVICE_DISPATCHER_NAME, + () -> new DeviceActorCreator(systemContext, tenantId, deviceId)); + } + + private void onToEdgeSessionMsg(EdgeSessionMsg msg) { + systemContext.getEdgeRpcService().onToEdgeSessionMsg(tenantId, msg); + } + + private ApiUsageState getApiUsageState() { + if (apiUsageState == null) { + apiUsageState = new ApiUsageState(systemContext.getApiUsageStateService().getApiUsageState(tenantId)); + } + return apiUsageState; + } + + public static class ActorCreator extends ContextBasedCreator { + + private final TenantId tenantId; + + public ActorCreator(ActorSystemContext context, TenantId tenantId) { + super(context); + this.tenantId = tenantId; + } + + @Override + public TbActorId createActorId() { + return new TbEntityActorId(tenantId); + } + + @Override + public TbActor createActor() { + return new TenantActor(context, tenantId); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/CustomOAuth2AuthorizationRequestResolver.java b/application/src/main/java/org/thingsboard/server/config/CustomOAuth2AuthorizationRequestResolver.java new file mode 100644 index 0000000..360d5e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/CustomOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,302 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; +import org.springframework.security.web.util.UrlUtils; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.dao.oauth2.OAuth2Configuration; +import org.thingsboard.server.dao.oauth2.OAuth2Service; +import org.thingsboard.server.service.security.auth.oauth2.TbOAuth2ParameterNames; +import org.thingsboard.server.service.security.model.token.OAuth2AppTokenFactory; +import org.thingsboard.server.utils.MiscUtils; + +import javax.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@Slf4j +public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + private static final String DEFAULT_AUTHORIZATION_REQUEST_BASE_URI = "/oauth2/authorization"; + private static final String DEFAULT_LOGIN_PROCESSING_URI = "/login/oauth2/code/"; + private static final String REGISTRATION_ID_URI_VARIABLE_NAME = "registrationId"; + private static final char PATH_DELIMITER = '/'; + + private final AntPathRequestMatcher authorizationRequestMatcher = new AntPathRequestMatcher( + DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{" + REGISTRATION_ID_URI_VARIABLE_NAME + "}"); + private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); + private final StringKeyGenerator secureKeyGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); + + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + + @Autowired + private OAuth2Service oAuth2Service; + + @Autowired + private OAuth2AppTokenFactory oAuth2AppTokenFactory; + + @Autowired(required = false) + private OAuth2Configuration oauth2Configuration; + + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + String registrationId = this.resolveRegistrationId(request); + String redirectUriAction = getAction(request, "login"); + String appPackage = getAppPackage(request); + String appToken = getAppToken(request); + return resolve(request, registrationId, redirectUriAction, appPackage, appToken); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId) { + if (registrationId == null) { + return null; + } + String redirectUriAction = getAction(request, "authorize"); + String appPackage = getAppPackage(request); + String appToken = getAppToken(request); + return resolve(request, registrationId, redirectUriAction, appPackage, appToken); + } + + private String getAction(HttpServletRequest request, String defaultAction) { + String action = request.getParameter("action"); + if (action == null) { + return defaultAction; + } + return action; + } + + private String getAppPackage(HttpServletRequest request) { + return request.getParameter("pkg"); + } + + private String getAppToken(HttpServletRequest request) { + return request.getParameter("appToken"); + } + + @SuppressWarnings("deprecation") + private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction, String appPackage, String appToken) { + if (registrationId == null) { + return null; + } + + ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId); + if (clientRegistration == null) { + throw new IllegalArgumentException("Invalid Client Registration with Id: " + registrationId); + } + + Map attributes = new HashMap<>(); + attributes.put(OAuth2ParameterNames.REGISTRATION_ID, clientRegistration.getRegistrationId()); + if (!StringUtils.isEmpty(appPackage)) { + if (StringUtils.isEmpty(appToken)) { + throw new IllegalArgumentException("Invalid application token."); + } else { + String appSecret = this.oAuth2Service.findAppSecret(UUID.fromString(registrationId), appPackage); + if (StringUtils.isEmpty(appSecret)) { + throw new IllegalArgumentException("Invalid package: " + appPackage + ". No application secret found for Client Registration with given application package."); + } + String callbackUrlScheme = this.oAuth2AppTokenFactory.validateTokenAndGetCallbackUrlScheme(appPackage, appToken, appSecret); + attributes.put(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME, callbackUrlScheme); + } + } + + OAuth2AuthorizationRequest.Builder builder; + if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(clientRegistration.getAuthorizationGrantType())) { + builder = OAuth2AuthorizationRequest.authorizationCode(); + Map additionalParameters = new HashMap<>(); + if (!CollectionUtils.isEmpty(clientRegistration.getScopes()) && + clientRegistration.getScopes().contains(OidcScopes.OPENID)) { + // Section 3.1.2.1 Authentication Request - https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // scope + // REQUIRED. OpenID Connect requests MUST contain the "openid" scope value. + addNonceParameters(attributes, additionalParameters); + } + if (ClientAuthenticationMethod.NONE.equals(clientRegistration.getClientAuthenticationMethod())) { + addPkceParameters(attributes, additionalParameters); + } + builder.additionalParameters(additionalParameters); + } else if (AuthorizationGrantType.IMPLICIT.equals(clientRegistration.getAuthorizationGrantType())) { + builder = OAuth2AuthorizationRequest.implicit(); + } else { + throw new IllegalArgumentException("Invalid Authorization Grant Type (" + + clientRegistration.getAuthorizationGrantType().getValue() + + ") for Client Registration with Id: " + clientRegistration.getRegistrationId()); + } + + String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction); + + return builder + .clientId(clientRegistration.getClientId()) + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .redirectUri(redirectUriStr) + .scopes(clientRegistration.getScopes()) + .state(this.stateGenerator.generateKey()) + .attributes(attributes) + .build(); + } + + private String resolveRegistrationId(HttpServletRequest request) { + if (this.authorizationRequestMatcher.matches(request)) { + return this.authorizationRequestMatcher + .matcher(request).getVariables().get(REGISTRATION_ID_URI_VARIABLE_NAME); + } + return null; + } + + /** + * Expands the {@link ClientRegistration#getRedirectUriTemplate()} with following provided variables:
+ * - baseUrl (e.g. https://localhost/app)
+ * - baseScheme (e.g. https)
+ * - baseHost (e.g. localhost)
+ * - basePort (e.g. :8080)
+ * - basePath (e.g. /app)
+ * - registrationId (e.g. google)
+ * - action (e.g. login)
+ *

+ * Null variables are provided as empty strings. + *

+ * Default redirectUriTemplate is: {@link org.springframework.security.config.oauth2.client}.CommonOAuth2Provider#DEFAULT_REDIRECT_URL + * + * @return expanded URI + */ + private String expandRedirectUri(HttpServletRequest request, ClientRegistration clientRegistration, String action) { + Map uriVariables = new HashMap<>(); + uriVariables.put("registrationId", clientRegistration.getRegistrationId()); + + UriComponents uriComponents = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) + .replacePath(request.getContextPath()) + .replaceQuery(null) + .fragment(null) + .build(); + String scheme = uriComponents.getScheme(); + uriVariables.put("baseScheme", scheme == null ? "" : scheme); + String host = uriComponents.getHost(); + uriVariables.put("baseHost", host == null ? "" : host); + // following logic is based on HierarchicalUriComponents#toUriString() + int port = uriComponents.getPort(); + uriVariables.put("basePort", port == -1 ? "" : ":" + port); + String path = uriComponents.getPath(); + if (StringUtils.hasLength(path)) { + if (path.charAt(0) != PATH_DELIMITER) { + path = PATH_DELIMITER + path; + } + } + uriVariables.put("basePath", path == null ? "" : path); + uriVariables.put("baseUrl", uriComponents.toUriString()); + + uriVariables.put("action", action == null ? "" : action); + + String redirectUri = getRedirectUri(request); + log.trace("Redirect URI - {}.", redirectUri); + + return UriComponentsBuilder.fromUriString(redirectUri) + .buildAndExpand(uriVariables) + .toUriString(); + } + + private String getRedirectUri(HttpServletRequest request) { + String loginProcessingUri = oauth2Configuration != null ? oauth2Configuration.getLoginProcessingUrl() : DEFAULT_LOGIN_PROCESSING_URI; + + String scheme = MiscUtils.getScheme(request); + String domainName = MiscUtils.getDomainName(request); + int port = MiscUtils.getPort(request); + String baseUrl = scheme + "://" + domainName; + if (needsPort(scheme, port)){ + baseUrl += ":" + port; + } + return baseUrl + loginProcessingUri; + } + + private boolean needsPort(String scheme, int port) { + boolean isHttpDefault = "http".equals(scheme.toLowerCase()) && port == 80; + boolean isHttpsDefault = "https".equals(scheme.toLowerCase()) && port == 443; + return !isHttpDefault && !isHttpsDefault; + } + + /** + * Creates nonce and its hash for use in OpenID Connect 1.0 Authentication Requests. + * + * @param attributes where the {@link OidcParameterNames#NONCE} is stored for the authentication request + * @param additionalParameters where the {@link OidcParameterNames#NONCE} hash is added for the authentication request + * + * @since 5.2 + * @see 3.1.2.1. Authentication Request + */ + private void addNonceParameters(Map attributes, Map additionalParameters) { + try { + String nonce = this.secureKeyGenerator.generateKey(); + String nonceHash = createHash(nonce); + attributes.put(OidcParameterNames.NONCE, nonce); + additionalParameters.put(OidcParameterNames.NONCE, nonceHash); + } catch (NoSuchAlgorithmException e) { } + } + + /** + * Creates and adds additional PKCE parameters for use in the OAuth 2.0 Authorization and Access Token Requests + * + * @param attributes where {@link PkceParameterNames#CODE_VERIFIER} is stored for the token request + * @param additionalParameters where {@link PkceParameterNames#CODE_CHALLENGE} and, usually, + * {@link PkceParameterNames#CODE_CHALLENGE_METHOD} are added to be used in the authorization request. + * + * @since 5.2 + * @see 1.1. Protocol Flow + * @see 4.1. Client Creates a Code Verifier + * @see 4.2. Client Creates the Code Challenge + */ + private void addPkceParameters(Map attributes, Map additionalParameters) { + String codeVerifier = this.secureKeyGenerator.generateKey(); + attributes.put(PkceParameterNames.CODE_VERIFIER, codeVerifier); + try { + String codeChallenge = createHash(codeVerifier); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeChallenge); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + } catch (NoSuchAlgorithmException e) { + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeVerifier); + } + } + + private static String createHash(String value) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java b/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java new file mode 100644 index 0000000..2205cdc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; + +import java.util.HashMap; +import java.util.Map; + +/** + * Created by yyh on 2017/5/2. + * CORS configuration + */ +@Configuration +@ConfigurationProperties(prefix = "spring.mvc.cors") +public class MvcCorsProperties { + + private Map mappings = new HashMap<>(); + + public MvcCorsProperties() { + super(); + } + + public Map getMappings() { + return mappings; + } + + public void setMappings(Map mappings) { + this.mappings = mappings; + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java new file mode 100644 index 0000000..085b05a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.tools.TbRateLimits; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Slf4j +@Component +public class RateLimitProcessingFilter extends OncePerRequestFilter { + + @Autowired + private ThingsboardErrorResponseHandler errorResponseHandler; + + @Autowired + @Lazy + private TbTenantProfileCache tenantProfileCache; + + private final ConcurrentMap perTenantLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap perCustomerLimits = new ConcurrentHashMap<>(); + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + SecurityUser user = getCurrentUser(); + if (user != null && !user.isSystemAdmin()) { + var profile = tenantProfileCache.get(user.getTenantId()); + if (profile == null) { + log.debug("[{}] Failed to lookup tenant profile", user.getTenantId()); + errorResponseHandler.handle(new BadCredentialsException("Failed to lookup tenant profile"), response); + return; + } + var profileConfiguration = profile.getDefaultProfileConfiguration(); + if (!checkRateLimits(user.getTenantId(), profileConfiguration.getTenantServerRestLimitsConfiguration(), perTenantLimits, response)) { + return; + } + if (user.isCustomerUser()) { + if (!checkRateLimits(user.getCustomerId(), profileConfiguration.getCustomerServerRestLimitsConfiguration(), perCustomerLimits, response)) { + return; + } + } + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } + + private boolean checkRateLimits(I ownerId, String rateLimitConfig, Map rateLimitsMap, ServletResponse response) { + if (StringUtils.isNotEmpty(rateLimitConfig)) { + TbRateLimits rateLimits = rateLimitsMap.get(ownerId); + if (rateLimits == null || !rateLimits.getConfiguration().equals(rateLimitConfig)) { + rateLimits = new TbRateLimits(rateLimitConfig); + rateLimitsMap.put(ownerId, rateLimits); + } + + if (!rateLimits.tryConsume()) { + errorResponseHandler.handle(new TbRateLimitsException(ownerId.getEntityType()), (HttpServletResponse) response); + return false; + } + } else { + rateLimitsMap.remove(ownerId); + } + + return true; + } + + protected SecurityUser getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof SecurityUser) { + return (SecurityUser) authentication.getPrincipal(); + } else { + return null; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java new file mode 100644 index 0000000..e23b629 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@Configuration +@EnableScheduling +public class SchedulingConfiguration implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(taskScheduler()); + } + + @Bean(destroyMethod="shutdown") + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler threadPoolScheduler = new ThreadPoolTaskScheduler(); + threadPoolScheduler.setThreadNamePrefix("TB-Scheduling-"); + threadPoolScheduler.setPoolSize(Runtime.getRuntime().availableProcessors()); + threadPoolScheduler.setRemoveOnCancelPolicy(true); + return threadPoolScheduler; + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java new file mode 100644 index 0000000..c53cf1b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -0,0 +1,397 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import com.fasterxml.classmate.TypeResolver; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse; +import org.thingsboard.server.exception.ThingsboardErrorResponse; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.rest.LoginRequest; +import org.thingsboard.server.service.security.auth.rest.LoginResponse; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.ExampleBuilder; +import springfox.documentation.builders.OperationBuilder; +import springfox.documentation.builders.RepresentationBuilder; +import springfox.documentation.builders.RequestParameterBuilder; +import springfox.documentation.builders.ResponseBuilder; +import springfox.documentation.schema.Example; +import springfox.documentation.service.ApiDescription; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.service.ApiListing; +import springfox.documentation.service.AuthorizationScope; +import springfox.documentation.service.Contact; +import springfox.documentation.service.HttpLoginPasswordScheme; +import springfox.documentation.service.ParameterType; +import springfox.documentation.service.Response; +import springfox.documentation.service.SecurityReference; +import springfox.documentation.service.SecurityScheme; +import springfox.documentation.service.Tag; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spi.service.ApiListingBuilderPlugin; +import springfox.documentation.spi.service.ApiListingScannerPlugin; +import springfox.documentation.spi.service.contexts.ApiListingContext; +import springfox.documentation.spi.service.contexts.DocumentationContext; +import springfox.documentation.spi.service.contexts.OperationContext; +import springfox.documentation.spi.service.contexts.SecurityContext; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator; +import springfox.documentation.swagger.common.SwaggerPluginSupport; +import springfox.documentation.swagger.web.DocExpansion; +import springfox.documentation.swagger.web.ModelRendering; +import springfox.documentation.swagger.web.OperationsSorter; +import springfox.documentation.swagger.web.UiConfiguration; +import springfox.documentation.swagger.web.UiConfigurationBuilder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static com.google.common.collect.Lists.newArrayList; +import static java.util.function.Predicate.not; +import static springfox.documentation.builders.PathSelectors.any; +import static springfox.documentation.builders.PathSelectors.regex; + +@Slf4j +@Configuration +@TbCoreComponent +@Profile("!test") +public class SwaggerConfiguration { + + @Value("${swagger.api_path_regex}") + private String apiPathRegex; + @Value("${swagger.security_path_regex}") + private String securityPathRegex; + @Value("${swagger.non_security_path_regex}") + private String nonSecurityPathRegex; + @Value("${swagger.title}") + private String title; + @Value("${swagger.description}") + private String description; + @Value("${swagger.contact.name}") + private String contactName; + @Value("${swagger.contact.url}") + private String contactUrl; + @Value("${swagger.contact.email}") + private String contactEmail; + @Value("${swagger.license.title}") + private String licenseTitle; + @Value("${swagger.license.url}") + private String licenseUrl; + @Value("${swagger.version}") + private String version; + @Value("${app.version:unknown}") + private String appVersion; + + @Bean + public Docket thingsboardApi() { + TypeResolver typeResolver = new TypeResolver(); + return new Docket(DocumentationType.OAS_30) + .groupName("thingsboard") + .apiInfo(apiInfo()) + .additionalModels( + typeResolver.resolve(ThingsboardErrorResponse.class), + typeResolver.resolve(ThingsboardCredentialsExpiredResponse.class), + typeResolver.resolve(LoginRequest.class), + typeResolver.resolve(LoginResponse.class) + ) + .select() + .paths(apiPaths()) + .paths(any()) + .build() + .globalResponses(HttpMethod.GET, + defaultErrorResponses(false) + ) + .globalResponses(HttpMethod.POST, + defaultErrorResponses(true) + ) + .globalResponses(HttpMethod.DELETE, + defaultErrorResponses(false) + ) + .securitySchemes(newArrayList(httpLogin())) + .securityContexts(newArrayList(securityContext())) + .enableUrlTemplating(true); + } + + @Bean + @Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER) + ApiListingScannerPlugin loginEndpointListingScanner(final CachingOperationNameGenerator operationNames) { + return new ApiListingScannerPlugin() { + @Override + public List apply(DocumentationContext context) { + return List.of(loginEndpointApiDescription(operationNames)); + } + + @Override + public boolean supports(DocumentationType delimiter) { + return DocumentationType.SWAGGER_2.equals(delimiter) || DocumentationType.OAS_30.equals(delimiter); + } + }; + } + + @Bean + @Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER) + ApiListingBuilderPlugin loginEndpointListingBuilder() { + return new ApiListingBuilderPlugin() { + @Override + public void apply(ApiListingContext apiListingContext) { + if (apiListingContext.getResourceGroup().getGroupName().equals("default")) { + ApiListing apiListing = apiListingContext.apiListingBuilder().build(); + if (apiListing.getResourcePath().equals("/api/auth/login")) { + apiListingContext.apiListingBuilder().tags(Set.of(new Tag("login-endpoint", "Login Endpoint"))); + apiListingContext.apiListingBuilder().description("Login Endpoint"); + } + } + } + + @Override + public boolean supports(DocumentationType delimiter) { + return DocumentationType.SWAGGER_2.equals(delimiter) || DocumentationType.OAS_30.equals(delimiter); + } + }; + } + + @Bean + UiConfiguration uiConfig() { + return UiConfigurationBuilder.builder() + .deepLinking(true) + .displayOperationId(false) + .defaultModelsExpandDepth(1) + .defaultModelExpandDepth(1) + .defaultModelRendering(ModelRendering.EXAMPLE) + .displayRequestDuration(false) + .docExpansion(DocExpansion.NONE) + .filter(false) + .maxDisplayedTags(null) + .operationsSorter(OperationsSorter.ALPHA) + .showExtensions(false) + .showCommonExtensions(false) + .supportedSubmitMethods(UiConfiguration.Constants.DEFAULT_SUBMIT_METHODS) + .validatorUrl(null) + .persistAuthorization(true) + .syntaxHighlightActivate(true) + .syntaxHighlightTheme("agate") + .build(); + } + + private SecurityScheme httpLogin() { + return HttpLoginPasswordScheme + .X_AUTHORIZATION_BUILDER + .loginEndpoint("/api/auth/login") + .name("HTTP login form") + .description("Enter Username / Password") + .build(); + } + + private SecurityContext securityContext() { + return SecurityContext.builder() + .securityReferences(defaultAuth()) + .operationSelector(securityPathOperationSelector()) + .build(); + } + + private Predicate apiPaths() { + return regex(apiPathRegex); + } + + private Predicate securityPathOperationSelector() { + return new SecurityPathOperationSelector(securityPathRegex, nonSecurityPathRegex); + } + + List defaultAuth() { + AuthorizationScope[] authorizationScopes = new AuthorizationScope[3]; + authorizationScopes[0] = new AuthorizationScope(Authority.SYS_ADMIN.name(), "System administrator"); + authorizationScopes[1] = new AuthorizationScope(Authority.TENANT_ADMIN.name(), "Tenant administrator"); + authorizationScopes[2] = new AuthorizationScope(Authority.CUSTOMER_USER.name(), "Customer"); + return newArrayList( + new SecurityReference("HTTP login form", authorizationScopes)); + } + + private ApiInfo apiInfo() { + String apiVersion = version; + if (StringUtils.isEmpty(apiVersion)) { + apiVersion = appVersion; + } + return new ApiInfoBuilder() + .title(title) + .description(description) + .contact(new Contact(contactName, contactUrl, contactEmail)) + .license(licenseTitle) + .licenseUrl(licenseUrl) + .version(apiVersion) + .build(); + } + + private ApiDescription loginEndpointApiDescription(final CachingOperationNameGenerator operationNames) { + return new ApiDescription(null, "/api/auth/login", "Login method to get user JWT token data", "Login endpoint", Collections.singletonList( + new OperationBuilder(operationNames) + .summary("Login method to get user JWT token data") + .tags(Set.of("login-endpoint")) + .authorizations(new ArrayList<>()) + .position(0) + .codegenMethodNameStem("loginPost") + .method(HttpMethod.POST) + .notes("Login method used to authenticate user and get JWT token data.\n\nValue of the response **token** " + + "field can be used as **X-Authorization** header value:\n\n`X-Authorization: Bearer $JWT_TOKEN_VALUE`.") + .requestParameters( + List.of( + new RequestParameterBuilder() + .in(ParameterType.BODY) + .required(true) + .description("Login request") + .content(c -> + c.requestBody(true) + .representation(MediaType.APPLICATION_JSON) + .apply(classRepresentation(LoginRequest.class, false)) + ) + .build() + ) + ) + .responses(loginResponses()) + .build() + ), false); + } + + private Collection loginResponses() { + List responses = new ArrayList<>(); + responses.add( + new ResponseBuilder() + .code("200") + .description("OK") + .representation(MediaType.APPLICATION_JSON) + .apply(classRepresentation(LoginResponse.class, true)). + build() + ); + responses.addAll(loginErrorResponses()); + return responses; + } + + /** Helper methods **/ + + private List defaultErrorResponses(boolean isPost) { + return List.of( + errorResponse("400", "Bad Request", + ThingsboardErrorResponse.of(isPost ? "Invalid request body" : "Invalid UUID string: 123", ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST)), + errorResponse("401", "Unauthorized", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorResponse("403", "Forbidden", + ThingsboardErrorResponse.of("You don't have permission to perform this operation!", + ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN)), + errorResponse("404", "Not Found", + ThingsboardErrorResponse.of("Requested item wasn't found!", ThingsboardErrorCode.ITEM_NOT_FOUND, HttpStatus.NOT_FOUND)), + errorResponse("429", "Too Many Requests", + ThingsboardErrorResponse.of("Too many requests for current tenant!", + ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS)) + ); + } + + private List loginErrorResponses() { + return List.of( + errorResponse("401", "Unauthorized", + List.of( + errorExample("bad-credentials", "Bad credentials", + ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("token-expired", "JWT token expired", + ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)), + errorExample("account-disabled", "Disabled account", + ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("account-locked", "Locked account", + ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("authentication-failed", "General authentication error", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)) + ) + ), + errorResponse("401 ", "Unauthorized (**Expired credentials**)", + List.of( + errorExample("credentials-expired", "Expired credentials", + ThingsboardCredentialsExpiredResponse.of("User password expired!", StringUtils.randomAlphanumeric(30))) + ), ThingsboardCredentialsExpiredResponse.class + ) + ); + } + + private Response errorResponse(String code, String description, ThingsboardErrorResponse example) { + return errorResponse(code, description, List.of(errorExample("error-code-" + code, description, example))); + } + + private Response errorResponse(String code, String description, List examples) { + return errorResponse(code, description, examples, ThingsboardErrorResponse.class); + } + + private Response errorResponse(String code, String description, List examples, + Class errorResponseClass) { + return new ResponseBuilder() + .code(code) + .description(description) + .examples(examples) + .representation(MediaType.APPLICATION_JSON) + .apply(classRepresentation(errorResponseClass, true)) + .build(); + } + + private Example errorExample(String id, String summary, ThingsboardErrorResponse example) { + return new ExampleBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .summary(summary) + .id(id) + .value(example).build(); + } + + private Consumer classRepresentation(Class clazz, boolean isResponse) { + return r -> r.model( + m -> + m.referenceModel(ref -> + ref.key(k -> + k.qualifiedModelName(q -> + q.namespace(clazz.getPackageName()) + .name(clazz.getSimpleName())).isResponse(isResponse))) + ); + } + + private static class SecurityPathOperationSelector implements Predicate { + + private final Predicate securityPathSelector; + + SecurityPathOperationSelector(String securityPathRegex, String nonSecurityPathRegex) { + this.securityPathSelector = regex(securityPathRegex).and( + not( + regex(nonSecurityPathRegex) + )); + } + + @Override + public boolean test(OperationContext operationContext) { + return this.securityPathSelector.test(operationContext.requestMappingPattern()); + } + } + + +} diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java new file mode 100644 index 0000000..112fa5c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.support.ResourceBundleMessageSource; + +@Configuration +public class ThingsboardMessageConfiguration { + + @Bean + @Primary + public MessageSource messageSource() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + messageSource.setBasename("i18n/messages"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java new file mode 100644 index 0000000..a6540f0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -0,0 +1,252 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.thingsboard.server.dao.oauth2.OAuth2Configuration; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.jwt.JwtTokenAuthenticationProcessingFilter; +import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; +import org.thingsboard.server.service.security.auth.jwt.RefreshTokenProcessingFilter; +import org.thingsboard.server.service.security.auth.jwt.SkipPathRequestMatcher; +import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor; +import org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationProvider; +import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter; +import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled=true) +@Order(SecurityProperties.BASIC_AUTH_ORDER) +@TbCoreComponent +public class ThingsboardSecurityConfiguration { + + public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; + public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization"; + public static final String JWT_TOKEN_QUERY_PARAM = "token"; + + public static final String WEBJARS_ENTRY_POINT = "/webjars/**"; + public static final String DEVICE_API_ENTRY_POINT = "/api/v1/**"; + public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login"; + public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public"; + public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token"; + protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**"}; + public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**"; + public static final String WS_TOKEN_BASED_AUTH_ENTRY_POINT = "/api/ws/**"; + + @Autowired private ThingsboardErrorResponseHandler restAccessDeniedHandler; + + @Autowired(required = false) + @Qualifier("oauth2AuthenticationSuccessHandler") + private AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler; + + @Autowired(required = false) + @Qualifier("oauth2AuthenticationFailureHandler") + private AuthenticationFailureHandler oauth2AuthenticationFailureHandler; + + @Autowired(required = false) + private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Autowired + @Qualifier("defaultAuthenticationSuccessHandler") + private AuthenticationSuccessHandler successHandler; + + @Autowired + @Qualifier("defaultAuthenticationFailureHandler") + private AuthenticationFailureHandler failureHandler; + + @Autowired private RestAuthenticationProvider restAuthenticationProvider; + @Autowired private JwtAuthenticationProvider jwtAuthenticationProvider; + @Autowired private RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider; + + @Autowired(required = false) OAuth2Configuration oauth2Configuration; + + @Autowired + @Qualifier("jwtHeaderTokenExtractor") + private TokenExtractor jwtHeaderTokenExtractor; + + @Autowired + @Qualifier("jwtQueryTokenExtractor") + private TokenExtractor jwtQueryTokenExtractor; + + @Autowired private AuthenticationManager authenticationManager; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private RateLimitProcessingFilter rateLimitProcessingFilter; + + @Bean + protected RestLoginProcessingFilter buildRestLoginProcessingFilter() throws Exception { + RestLoginProcessingFilter filter = new RestLoginProcessingFilter(FORM_BASED_LOGIN_ENTRY_POINT, successHandler, failureHandler, objectMapper); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + @Bean + protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() throws Exception { + RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler, objectMapper); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception { + List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS)); + pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, + PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT)); + SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT); + JwtTokenAuthenticationProcessingFilter filter + = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + @Bean + protected RefreshTokenProcessingFilter buildRefreshTokenProcessingFilter() throws Exception { + RefreshTokenProcessingFilter filter = new RefreshTokenProcessingFilter(TOKEN_REFRESH_ENTRY_POINT, successHandler, failureHandler, objectMapper); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + @Bean + protected JwtTokenAuthenticationProcessingFilter buildWsJwtTokenAuthenticationProcessingFilter() throws Exception { + AntPathRequestMatcher matcher = new AntPathRequestMatcher(WS_TOKEN_BASED_AUTH_ENTRY_POINT); + JwtTokenAuthenticationProcessingFilter filter + = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtQueryTokenExtractor, matcher); + filter.setAuthenticationManager(this.authenticationManager); + return filter; + } + + @Bean + public AuthenticationManager authenticationManager(ObjectPostProcessor objectPostProcessor) throws Exception { + DefaultAuthenticationEventPublisher eventPublisher = objectPostProcessor + .postProcess(new DefaultAuthenticationEventPublisher()); + var auth = new AuthenticationManagerBuilder(objectPostProcessor); + auth.authenticationEventPublisher(eventPublisher); + auth.authenticationProvider(restAuthenticationProvider); + auth.authenticationProvider(jwtAuthenticationProvider); + auth.authenticationProvider(refreshTokenAuthenticationProvider); + return auth.build(); + } + + @Bean + protected BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Autowired + private OAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver; + + @Bean + @Order(0) + SecurityFilterChain resources(HttpSecurity http) throws Exception { + http + .requestMatchers((matchers) -> matchers.antMatchers("/*.js","/*.css","/*.ico","/assets/**","/static/**")) + .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()) + .requestCache().disable() + .securityContext().disable() + .sessionManagement().disable(); + return http.build(); + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.headers().cacheControl().and().frameOptions().disable() + .and() + .cors() + .and() + .csrf().disable() + .exceptionHandling() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers(WEBJARS_ENTRY_POINT).permitAll() // Webjars + .antMatchers(DEVICE_API_ENTRY_POINT).permitAll() // Device HTTP Transport API + .antMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point + .antMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point + .antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point + .antMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points + .and() + .authorizeRequests() + .antMatchers(WS_TOKEN_BASED_AUTH_ENTRY_POINT).authenticated() // Protected WebSocket API End-points + .antMatchers(TOKEN_BASED_AUTH_ENTRY_POINT).authenticated() // Protected API End-points + .and() + .exceptionHandling().accessDeniedHandler(restAccessDeniedHandler) + .and() + .addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(buildWsJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitProcessingFilter, UsernamePasswordAuthenticationFilter.class); + if (oauth2Configuration != null) { + http.oauth2Login() + .authorizationEndpoint() + .authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository) + .authorizationRequestResolver(oAuth2AuthorizationRequestResolver) + .and() + .loginPage("/oauth2Login") + .loginProcessingUrl(oauth2Configuration.getLoginProcessingUrl()) + .successHandler(oauth2AuthenticationSuccessHandler) + .failureHandler(oauth2AuthenticationFailureHandler); + } + return http.build(); + } + + @Bean + @ConditionalOnMissingBean(CorsFilter.class) + public CorsFilter corsFilter(@Autowired MvcCorsProperties mvcCorsProperties) { + if (mvcCorsProperties.getMappings().size() == 0) { + return new CorsFilter(new UrlBasedCorsConfigurationSource()); + } else { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.setCorsConfigurations(mvcCorsProperties.getMappings()); + return new CorsFilter(source); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/WebConfig.java b/application/src/main/java/org/thingsboard/server/config/WebConfig.java new file mode 100644 index 0000000..012d57f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/WebConfig.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.thingsboard.server.utils.MiscUtils; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Controller +public class WebConfig { + + @RequestMapping(value = {"/assets", "/assets/", "/{path:^(?!api$)(?!assets$)(?!static$)(?!webjars$)(?!swagger-ui$)[^\\.]*}/**"}) + public String redirect() { + return "forward:/index.html"; + } + + @RequestMapping("/swagger-ui.html") + public void redirectSwagger(HttpServletRequest request, HttpServletResponse response) throws IOException { + String baseUrl = MiscUtils.constructBaseUrl(request); + response.sendRedirect(baseUrl + "/swagger-ui/"); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java new file mode 100644 index 0000000..13b1312 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.controller.plugin.TbWebSocketHandler; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Map; + +@Configuration +@TbCoreComponent +@EnableWebSocket +public class WebSocketConfiguration implements WebSocketConfigurer { + + public static final String WS_PLUGIN_PREFIX = "/api/ws/plugins/"; + private static final String WS_PLUGIN_MAPPING = WS_PLUGIN_PREFIX + "**"; + + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(32768); + container.setMaxBinaryMessageBufferSize(32768); + return container; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(wsHandler(), WS_PLUGIN_MAPPING).setAllowedOriginPatterns("*") + .addInterceptors(new HttpSessionHandshakeInterceptor(), new HandshakeInterceptor() { + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, + Map attributes) throws Exception { + SecurityUser user = null; + try { + user = getCurrentUser(); + } catch (ThingsboardException ex) { + } + if (user == null) { + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } else { + return true; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, + Exception exception) { + //Do nothing + } + }); + } + + @Bean + public WebSocketHandler wsHandler() { + return new TbWebSocketHandler(); + } + + protected SecurityUser getCurrentUser() throws ThingsboardException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof SecurityUser) { + return (SecurityUser) authentication.getPrincipal(); + } else { + throw new ThingsboardException("You aren't authorized to perform this operation!", ThingsboardErrorCode.AUTHENTICATION); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java b/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java new file mode 100644 index 0000000..e5a2178 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AbstractRpcController.java @@ -0,0 +1,183 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FutureCallback; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +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.id.UUIDBased; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.rpc.LocalRequestMetaData; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.telemetry.exception.ToErrorResponseEntity; + +import javax.annotation.Nullable; +import java.util.Optional; +import java.util.UUID; + +/** + * Created by ashvayka on 22.03.18. + */ +@TbCoreComponent +@Slf4j +public abstract class AbstractRpcController extends BaseController { + + @Autowired + protected TbCoreDeviceRpcService deviceRpcService; + + @Autowired + protected AccessValidator accessValidator; + + @Value("${server.rest.server_side_rpc.min_timeout:5000}") + protected long minTimeout; + + @Value("${server.rest.server_side_rpc.default_timeout:10000}") + protected long defaultTimeout; + + protected DeferredResult handleDeviceRPCRequest(boolean oneWay, DeviceId deviceId, String requestBody, HttpStatus timeoutStatus, HttpStatus noActiveConnectionStatus) throws ThingsboardException { + try { + JsonNode rpcRequestBody = JacksonUtil.toJsonNode(requestBody); + ToDeviceRpcRequestBody body = new ToDeviceRpcRequestBody(rpcRequestBody.get("method").asText(), JacksonUtil.toString(rpcRequestBody.get("params"))); + SecurityUser currentUser = getCurrentUser(); + TenantId tenantId = currentUser.getTenantId(); + final DeferredResult response = new DeferredResult<>(); + long timeout = rpcRequestBody.has(DataConstants.TIMEOUT) ? rpcRequestBody.get(DataConstants.TIMEOUT).asLong() : defaultTimeout; + long expTime = rpcRequestBody.has(DataConstants.EXPIRATION_TIME) ? rpcRequestBody.get(DataConstants.EXPIRATION_TIME).asLong() : System.currentTimeMillis() + Math.max(minTimeout, timeout); + UUID rpcRequestUUID = rpcRequestBody.has("requestUUID") ? UUID.fromString(rpcRequestBody.get("requestUUID").asText()) : UUID.randomUUID(); + boolean persisted = rpcRequestBody.has(DataConstants.PERSISTENT) && rpcRequestBody.get(DataConstants.PERSISTENT).asBoolean(); + String additionalInfo = JacksonUtil.toString(rpcRequestBody.get(DataConstants.ADDITIONAL_INFO)); + Integer retries = rpcRequestBody.has(DataConstants.RETRIES) ? rpcRequestBody.get(DataConstants.RETRIES).asInt() : null; + accessValidator.validate(currentUser, Operation.RPC_CALL, deviceId, new HttpValidationCallback(response, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable DeferredResult result) { + ToDeviceRpcRequest rpcRequest = new ToDeviceRpcRequest(rpcRequestUUID, + tenantId, + deviceId, + oneWay, + expTime, + body, + persisted, + retries, + additionalInfo + ); + deviceRpcService.processRestApiRpcRequest(rpcRequest, fromDeviceRpcResponse -> reply(new LocalRequestMetaData(rpcRequest, currentUser, result), fromDeviceRpcResponse, timeoutStatus, noActiveConnectionStatus), currentUser); + } + + @Override + public void onFailure(Throwable e) { + ResponseEntity entity; + if (e instanceof ToErrorResponseEntity) { + entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); + } else { + entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); + } + logRpcCall(currentUser, deviceId, body, oneWay, Optional.empty(), e); + response.setResult(entity); + } + })); + return response; + } catch (IllegalArgumentException ioe) { + throw new ThingsboardException("Invalid request body", ioe, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + + public void reply(LocalRequestMetaData rpcRequest, FromDeviceRpcResponse response, HttpStatus timeoutStatus, HttpStatus noActiveConnectionStatus) { + Optional rpcError = response.getError(); + DeferredResult responseWriter = rpcRequest.getResponseWriter(); + if (rpcError.isPresent()) { + logRpcCall(rpcRequest, rpcError, null); + RpcError error = rpcError.get(); + switch (error) { + case TIMEOUT: + responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); + break; + case NO_ACTIVE_CONNECTION: + responseWriter.setResult(new ResponseEntity<>(noActiveConnectionStatus)); + break; + default: + responseWriter.setResult(new ResponseEntity<>(timeoutStatus)); + break; + } + } else { + Optional responseData = response.getResponse(); + if (responseData.isPresent() && !StringUtils.isEmpty(responseData.get())) { + String data = responseData.get(); + try { + logRpcCall(rpcRequest, rpcError, null); + responseWriter.setResult(new ResponseEntity<>(JacksonUtil.toJsonNode(data), HttpStatus.OK)); + } catch (IllegalArgumentException e) { + log.debug("Failed to decode device response: {}", data, e); + logRpcCall(rpcRequest, rpcError, e); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_ACCEPTABLE)); + } + } else { + logRpcCall(rpcRequest, rpcError, null); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + } + } + + private void logRpcCall(LocalRequestMetaData rpcRequest, Optional rpcError, Throwable e) { + logRpcCall(rpcRequest.getUser(), rpcRequest.getRequest().getDeviceId(), rpcRequest.getRequest().getBody(), rpcRequest.getRequest().isOneway(), rpcError, null); + } + + + private void logRpcCall(SecurityUser user, EntityId entityId, ToDeviceRpcRequestBody body, boolean oneWay, Optional rpcError, Throwable e) { + String rpcErrorStr = ""; + if (rpcError.isPresent()) { + rpcErrorStr = "RPC Error: " + rpcError.get().name(); + } + String method = body.getMethod(); + String params = body.getParams(); + + auditLogService.logEntityAction( + user.getTenantId(), + user.getCustomerId(), + user.getId(), + user.getName(), + (UUIDBased & EntityId) entityId, + null, + ActionType.RPC_CALL, + BaseController.toException(e), + rpcErrorStr, + oneWay, + method, + params); + } + + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java new file mode 100644 index 0000000..29f32b8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -0,0 +1,402 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.UpdateMessage; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; +import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.RepositorySettingsInfo; +import org.thingsboard.server.common.data.security.model.JwtSettings; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; +import org.thingsboard.server.service.sync.vc.autocommit.TbAutoCommitSettingsService; +import org.thingsboard.server.service.update.UpdateService; + +import static org.thingsboard.server.controller.ControllerConstants.*; + +@RestController +@TbCoreComponent +@RequestMapping("/api/admin") +public class AdminController extends BaseController { + + @Autowired + private MailService mailService; + + @Autowired + private SmsService smsService; + + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private SystemSecurityService systemSecurityService; + + @Lazy + @Autowired + private JwtSettingsService jwtSettingsService; + + @Lazy + @Autowired + private JwtTokenFactory tokenFactory; + + @Autowired + private EntitiesVersionControlService versionControlService; + + @Autowired + private TbAutoCommitSettingsService autoCommitSettingsService; + + @Autowired + private UpdateService updateService; + + @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", + notes = "Get the Administration Settings object using specified string key. Referencing non-existing key will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/settings/{key}", method = RequestMethod.GET) + @ResponseBody + public AdminSettings getAdminSettings( + @ApiParam(value = "A string value of the key (e.g. 'general' or 'mail').") + @PathVariable("key") String key) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key), "No Administration settings found for key: " + key); + if (adminSettings.getKey().equals("mail")) { + ((ObjectNode) adminSettings.getJsonValue()).remove("password"); + } + return adminSettings; + } catch (Exception e) { + throw handleException(e); + } + } + + + @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", + notes = "Creates or Updates the Administration Settings. Platform generates random Administration Settings Id during settings creation. " + + "The Administration Settings Id will be present in the response. Specify the Administration Settings Id when you would like to update the Administration Settings. " + + "Referencing non-existing Administration Settings Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/settings", method = RequestMethod.POST) + @ResponseBody + public AdminSettings saveAdminSettings( + @ApiParam(value = "A JSON value representing the Administration Settings.") + @RequestBody AdminSettings adminSettings) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); + adminSettings.setTenantId(getTenantId()); + adminSettings = checkNotNull(adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings)); + if (adminSettings.getKey().equals("mail")) { + mailService.updateMailConfiguration(); + ((ObjectNode) adminSettings.getJsonValue()).remove("password"); + } else if (adminSettings.getKey().equals("sms")) { + smsService.updateSmsConfiguration(); + } + return adminSettings; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get the Security Settings object", + notes = "Get the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/securitySettings", method = RequestMethod.GET) + @ResponseBody + public SecuritySettings getSecuritySettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + return checkNotNull(systemSecurityService.getSecuritySettings(TenantId.SYS_TENANT_ID)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", + notes = "Updates the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/securitySettings", method = RequestMethod.POST) + @ResponseBody + public SecuritySettings saveSecuritySettings( + @ApiParam(value = "A JSON value representing the Security Settings.") + @RequestBody SecuritySettings securitySettings) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); + securitySettings = checkNotNull(systemSecurityService.saveSecuritySettings(TenantId.SYS_TENANT_ID, securitySettings)); + return securitySettings; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get the JWT Settings object (getJwtSettings)", + notes = "Get the JWT Settings object that contains JWT token policy, etc. " + SYSTEM_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/jwtSettings", method = RequestMethod.GET) + @ResponseBody + public JwtSettings getJwtSettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + return checkNotNull(jwtSettingsService.getJwtSettings()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Update JWT Settings (saveJwtSettings)", + notes = "Updates the JWT Settings object that contains JWT token policy, etc. The tokenSigningKey field is a Base64 encoded string." + SYSTEM_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/jwtSettings", method = RequestMethod.POST) + @ResponseBody + public JwtPair saveJwtSettings( + @ApiParam(value = "A JSON value representing the JWT Settings.") + @RequestBody JwtSettings jwtSettings) throws ThingsboardException { + try { + SecurityUser securityUser = getCurrentUser(); + accessControlService.checkPermission(securityUser, Resource.ADMIN_SETTINGS, Operation.WRITE); + checkNotNull(jwtSettingsService.saveJwtSettings(jwtSettings)); + return tokenFactory.createTokenPair(securityUser); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Send test email (sendTestMail)", + notes = "Attempts to send test email to the System Administrator User using Mail Settings provided as a parameter. " + + "You may change the 'To' email in the user profile of the System Administrator. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/settings/testMail", method = RequestMethod.POST) + public void sendTestMail( + @ApiParam(value = "A JSON value representing the Mail Settings.") + @RequestBody AdminSettings adminSettings) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + adminSettings = checkNotNull(adminSettings); + if (adminSettings.getKey().equals("mail")) { + if (!adminSettings.getJsonValue().has("password")) { + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); + } + String email = getCurrentUser().getEmail(); + mailService.sendTestMail(adminSettings.getJsonValue(), email); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Send test sms (sendTestMail)", + notes = "Attempts to send test sms to the System Administrator User using SMS Settings and phone number provided as a parameters of the request. " + + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/settings/testSms", method = RequestMethod.POST) + public void sendTestSms( + @ApiParam(value = "A JSON value representing the Test SMS request.") + @RequestBody TestSmsRequest testSmsRequest) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); + smsService.sendTestSms(testSmsRequest); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get repository settings (getRepositorySettings)", + notes = "Get the repository settings object. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/repositorySettings") + public RepositorySettings getRepositorySettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + RepositorySettings versionControlSettings = checkNotNull(versionControlService.getVersionControlSettings(getTenantId())); + versionControlSettings.setPassword(null); + versionControlSettings.setPrivateKey(null); + versionControlSettings.setPrivateKeyPassword(null); + return versionControlSettings; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Check repository settings exists (repositorySettingsExists)", + notes = "Check whether the repository settings exists. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/repositorySettings/exists") + public Boolean repositorySettingsExists() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + return versionControlService.getVersionControlSettings(getTenantId()) != null; + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/repositorySettings/info") + public RepositorySettingsInfo getRepositorySettingsInfo() throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + RepositorySettings repositorySettings = versionControlService.getVersionControlSettings(getTenantId()); + if (repositorySettings != null) { + return RepositorySettingsInfo.builder() + .configured(true) + .readOnly(repositorySettings.isReadOnly()) + .build(); + } else { + return RepositorySettingsInfo.builder() + .configured(false) + .build(); + } + } + + @ApiOperation(value = "Creates or Updates the repository settings (saveRepositorySettings)", + notes = "Creates or Updates the repository settings object. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/repositorySettings") + public DeferredResult saveRepositorySettings(@RequestBody RepositorySettings settings) throws ThingsboardException { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + ListenableFuture future = versionControlService.saveVersionControlSettings(getTenantId(), settings); + return wrapFuture(Futures.transform(future, savedSettings -> { + savedSettings.setPassword(null); + savedSettings.setPrivateKey(null); + savedSettings.setPrivateKeyPassword(null); + return savedSettings; + }, MoreExecutors.directExecutor())); + } + + @ApiOperation(value = "Delete repository settings (deleteRepositorySettings)", + notes = "Deletes the repository settings." + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/repositorySettings", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public DeferredResult deleteRepositorySettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); + return wrapFuture(versionControlService.deleteVersionControlSettings(getTenantId())); + } catch (Exception e) { + throw handleException(e); + } + } + + + @ApiOperation(value = "Check repository access (checkRepositoryAccess)", + notes = "Attempts to check repository access. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/repositorySettings/checkAccess", method = RequestMethod.POST) + public DeferredResult checkRepositoryAccess( + @ApiParam(value = "A JSON value representing the Repository Settings.") + @RequestBody RepositorySettings settings) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + settings = checkNotNull(settings); + return wrapFuture(versionControlService.checkVersionControlAccess(getTenantId(), settings)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get auto commit settings (getAutoCommitSettings)", + notes = "Get the auto commit settings object. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/autoCommitSettings") + public AutoCommitSettings getAutoCommitSettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + return checkNotNull(autoCommitSettingsService.get(getTenantId())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Check auto commit settings exists (autoCommitSettingsExists)", + notes = "Check whether the auto commit settings exists. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/autoCommitSettings/exists") + public Boolean autoCommitSettingsExists() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + return autoCommitSettingsService.get(getTenantId()) != null; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Creates or Updates the auto commit settings (saveAutoCommitSettings)", + notes = "Creates or Updates the auto commit settings object. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/autoCommitSettings") + public AutoCommitSettings saveAutoCommitSettings(@RequestBody AutoCommitSettings settings) throws ThingsboardException { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + return autoCommitSettingsService.save(getTenantId(), settings); + } + + @ApiOperation(value = "Delete auto commit settings (deleteAutoCommitSettings)", + notes = "Deletes the auto commit settings." + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/autoCommitSettings", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteAutoCommitSettings() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); + autoCommitSettingsService.delete(getTenantId()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Check for new Platform Releases (checkUpdates)", + notes = "Check notifications about new platform releases. " + + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/updates", method = RequestMethod.GET) + @ResponseBody + public UpdateMessage checkUpdates() throws ThingsboardException { + try { + return updateService.checkUpdates(); + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java new file mode 100644 index 0000000..858a7ee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -0,0 +1,317 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.StringUtils; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.alarm.TbAlarmService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import static org.thingsboard.server.controller.ControllerConstants.ALARM_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ALARM_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ALARM_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api") +public class AlarmController extends BaseController { + + private final TbAlarmService tbAlarmService; + + public static final String ALARM_ID = "alarmId"; + private static final String ALARM_SECURITY_CHECK = "If the user has the authority of 'Tenant Administrator', the server checks that the originator of alarm is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the originator of alarm belongs to the customer. "; + private static final String ALARM_QUERY_SEARCH_STATUS_DESCRIPTION = "A string value representing one of the AlarmSearchStatus enumeration value"; + private static final String ALARM_QUERY_SEARCH_STATUS_ALLOWABLE_VALUES = "ANY, ACTIVE, CLEARED, ACK, UNACK"; + private static final String ALARM_QUERY_STATUS_DESCRIPTION = "A string value representing one of the AlarmStatus enumeration value"; + private static final String ALARM_QUERY_STATUS_ALLOWABLE_VALUES = "ACTIVE_UNACK, ACTIVE_ACK, CLEARED_UNACK, CLEARED_ACK"; + private static final String ALARM_QUERY_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on of next alarm fields: type, severity or status"; + private static final String ALARM_QUERY_START_TIME_DESCRIPTION = "The start timestamp in milliseconds of the search time range over the Alarm class field: 'createdTime'."; + private static final String ALARM_QUERY_END_TIME_DESCRIPTION = "The end timestamp in milliseconds of the search time range over the Alarm class field: 'createdTime'."; + private static final String ALARM_QUERY_FETCH_ORIGINATOR_DESCRIPTION = "A boolean value to specify if the alarm originator name will be " + + "filled in the AlarmInfo object field: 'originatorName' or will returns as null."; + + @ApiOperation(value = "Get Alarm (getAlarmById)", + notes = "Fetch the Alarm object based on the provided Alarm Id. " + ALARM_SECURITY_CHECK, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/{alarmId}", method = RequestMethod.GET) + @ResponseBody + public Alarm getAlarmById(@ApiParam(value = ALARM_ID_PARAM_DESCRIPTION) + @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { + checkParameter(ALARM_ID, strAlarmId); + try { + AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); + return checkAlarmId(alarmId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Alarm Info (getAlarmInfoById)", + notes = "Fetch the Alarm Info object based on the provided Alarm Id. " + + ALARM_SECURITY_CHECK + ALARM_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/info/{alarmId}", method = RequestMethod.GET) + @ResponseBody + public AlarmInfo getAlarmInfoById(@ApiParam(value = ALARM_ID_PARAM_DESCRIPTION) + @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { + checkParameter(ALARM_ID, strAlarmId); + try { + AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); + return checkAlarmInfoId(alarmId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create or update Alarm (saveAlarm)", + notes = "Creates or Updates the Alarm. " + + "When creating alarm, platform generates Alarm Id as " + UUID_WIKI_LINK + + "The newly created Alarm id will be present in the response. Specify existing Alarm id to update the alarm. " + + "Referencing non-existing Alarm Id will cause 'Not Found' error. " + + "\n\nPlatform also deduplicate the alarms based on the entity id of originator and alarm 'type'. " + + "For example, if the user or system component create the alarm with the type 'HighTemperature' for device 'Device A' the new active alarm is created. " + + "If the user tries to create 'HighTemperature' alarm for the same device again, the previous alarm will be updated (the 'end_ts' will be set to current timestamp). " + + "If the user clears the alarm (see 'Clear Alarm(clearAlarm)'), than new alarm with the same type and same device may be created. " + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Alarm entity. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + , produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm", method = RequestMethod.POST) + @ResponseBody + public Alarm saveAlarm(@ApiParam(value = "A JSON value representing the alarm.") @RequestBody Alarm alarm) throws ThingsboardException { + alarm.setTenantId(getTenantId()); + checkEntity(alarm.getId(), alarm, Resource.ALARM); + return tbAlarmService.save(alarm, getCurrentUser()); + } + + @ApiOperation(value = "Delete Alarm (deleteAlarm)", + notes = "Deletes the Alarm. Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/{alarmId}", method = RequestMethod.DELETE) + @ResponseBody + public Boolean deleteAlarm(@ApiParam(value = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { + checkParameter(ALARM_ID, strAlarmId); + AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); + Alarm alarm = checkAlarmId(alarmId, Operation.DELETE); + return tbAlarmService.delete(alarm, getCurrentUser()); + } + + @ApiOperation(value = "Acknowledge Alarm (ackAlarm)", + notes = "Acknowledge the Alarm. " + + "Once acknowledged, the 'ack_ts' field will be set to current timestamp and special rule chain event 'ALARM_ACK' will be generated. " + + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/{alarmId}/ack", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void ackAlarm(@ApiParam(value = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws Exception { + checkParameter(ALARM_ID, strAlarmId); + AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); + Alarm alarm = checkAlarmId(alarmId, Operation.WRITE); + tbAlarmService.ack(alarm, getCurrentUser()).get(); + } + + @ApiOperation(value = "Clear Alarm (clearAlarm)", + notes = "Clear the Alarm. " + + "Once cleared, the 'clear_ts' field will be set to current timestamp and special rule chain event 'ALARM_CLEAR' will be generated. " + + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/{alarmId}/clear", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void clearAlarm(@ApiParam(value = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws Exception { + checkParameter(ALARM_ID, strAlarmId); + AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); + Alarm alarm = checkAlarmId(alarmId, Operation.WRITE); + tbAlarmService.clear(alarm, getCurrentUser()).get(); + } + + @ApiOperation(value = "Get Alarms (getAlarms)", + notes = "Returns a page of alarms for the selected entity. Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/{entityType}/{entityId}", method = RequestMethod.GET) + @ResponseBody + public PageData getAlarms( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = ALARM_QUERY_SEARCH_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_SEARCH_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String searchStatus, + @ApiParam(value = ALARM_QUERY_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String status, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ALARM_QUERY_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ALARM_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = ALARM_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = ALARM_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = ALARM_QUERY_FETCH_ORIGINATOR_DESCRIPTION) + @RequestParam(required = false) Boolean fetchOriginator + ) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + AlarmSearchStatus alarmSearchStatus = StringUtils.isEmpty(searchStatus) ? null : AlarmSearchStatus.valueOf(searchStatus); + AlarmStatus alarmStatus = StringUtils.isEmpty(status) ? null : AlarmStatus.valueOf(status); + if (alarmSearchStatus != null && alarmStatus != null) { + throw new ThingsboardException("Invalid alarms search query: Both parameters 'searchStatus' " + + "and 'status' can't be specified at the same time!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + checkEntityId(entityId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + + try { + return checkNotNull(alarmService.findAlarms(getCurrentUser().getTenantId(), new AlarmQuery(entityId, pageLink, alarmSearchStatus, alarmStatus, fetchOriginator)).get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get All Alarms (getAllAlarms)", + notes = "Returns a page of alarms that belongs to the current user owner. " + + "If the user has the authority of 'Tenant Administrator', the server returns alarms that belongs to the tenant of current user. " + + "If the user has the authority of 'Customer User', the server returns alarms that belongs to the customer of current user. " + + "Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarms", method = RequestMethod.GET) + @ResponseBody + public PageData getAllAlarms( + @ApiParam(value = ALARM_QUERY_SEARCH_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_SEARCH_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String searchStatus, + @ApiParam(value = ALARM_QUERY_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String status, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ALARM_QUERY_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ALARM_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = ALARM_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = ALARM_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = ALARM_QUERY_FETCH_ORIGINATOR_DESCRIPTION) + @RequestParam(required = false) Boolean fetchOriginator + ) throws ThingsboardException { + AlarmSearchStatus alarmSearchStatus = StringUtils.isEmpty(searchStatus) ? null : AlarmSearchStatus.valueOf(searchStatus); + AlarmStatus alarmStatus = StringUtils.isEmpty(status) ? null : AlarmStatus.valueOf(status); + if (alarmSearchStatus != null && alarmStatus != null) { + throw new ThingsboardException("Invalid alarms search query: Both parameters 'searchStatus' " + + "and 'status' can't be specified at the same time!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + + try { + if (getCurrentUser().isCustomerUser()) { + return checkNotNull(alarmService.findCustomerAlarms(getCurrentUser().getTenantId(), getCurrentUser().getCustomerId(), new AlarmQuery(null, pageLink, alarmSearchStatus, alarmStatus, fetchOriginator)).get()); + } else { + return checkNotNull(alarmService.findAlarms(getCurrentUser().getTenantId(), new AlarmQuery(null, pageLink, alarmSearchStatus, alarmStatus, fetchOriginator)).get()); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Highest Alarm Severity (getHighestAlarmSeverity)", + notes = "Search the alarms by originator ('entityType' and entityId') and optional 'status' or 'searchStatus' filters and returns the highest AlarmSeverity(CRITICAL, MAJOR, MINOR, WARNING or INDETERMINATE). " + + "Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + , produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarm/highestSeverity/{entityType}/{entityId}", method = RequestMethod.GET) + @ResponseBody + public AlarmSeverity getHighestAlarmSeverity( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = ALARM_QUERY_SEARCH_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_SEARCH_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String searchStatus, + @ApiParam(value = ALARM_QUERY_STATUS_DESCRIPTION, allowableValues = ALARM_QUERY_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) String status + ) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + AlarmSearchStatus alarmSearchStatus = StringUtils.isEmpty(searchStatus) ? null : AlarmSearchStatus.valueOf(searchStatus); + AlarmStatus alarmStatus = StringUtils.isEmpty(status) ? null : AlarmStatus.valueOf(status); + if (alarmSearchStatus != null && alarmStatus != null) { + throw new ThingsboardException("Invalid alarms search query: Both parameters 'searchStatus' " + + "and 'status' can't be specified at the same time!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + checkEntityId(entityId, Operation.READ); + try { + return alarmService.findHighestAlarmSeverity(getCurrentUser().getTenantId(), entityId, alarmSearchStatus, alarmStatus); + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java new file mode 100644 index 0000000..968829b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -0,0 +1,561 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +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.EntitySubtype; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetInfo; +import org.thingsboard.server.common.data.asset.AssetSearchQuery; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.asset.AssetBulkImportService; +import org.thingsboard.server.service.entitiy.asset.TbAssetService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.ASSET_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_NAME_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; +import static org.thingsboard.server.controller.EdgeController.EDGE_ID; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class AssetController extends BaseController { + private final AssetBulkImportService assetBulkImportService; + private final TbAssetService tbAssetService; + + public static final String ASSET_ID = "assetId"; + + @ApiOperation(value = "Get Asset (getAssetById)", + notes = "Fetch the Asset object based on the provided Asset Id. " + + "If the user has the authority of 'Tenant Administrator', the server checks that the asset is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the asset is assigned to the same customer." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + , produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/asset/{assetId}", method = RequestMethod.GET) + @ResponseBody + public Asset getAssetById(@ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(ASSET_ID, strAssetId); + try { + AssetId assetId = new AssetId(toUUID(strAssetId)); + return checkAssetId(assetId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Asset Info (getAssetInfoById)", + notes = "Fetch the Asset Info object based on the provided Asset Id. " + + "If the user has the authority of 'Tenant Administrator', the server checks that the asset is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the asset is assigned to the same customer. " + + ASSET_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/asset/info/{assetId}", method = RequestMethod.GET) + @ResponseBody + public AssetInfo getAssetInfoById(@ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(ASSET_ID, strAssetId); + try { + AssetId assetId = new AssetId(toUUID(strAssetId)); + return checkAssetInfoId(assetId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Asset (saveAsset)", + notes = "Creates or Updates the Asset. When creating asset, platform generates Asset Id as " + UUID_WIKI_LINK + + "The newly created Asset id will be present in the response. " + + "Specify existing Asset id to update the asset. " + + "Referencing non-existing Asset Id will cause 'Not Found' error. " + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Asset entity. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/asset", method = RequestMethod.POST) + @ResponseBody + public Asset saveAsset(@ApiParam(value = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception { + asset.setTenantId(getTenantId()); + checkEntity(asset.getId(), asset, Resource.ASSET); + return tbAssetService.save(asset, getCurrentUser()); + } + + @ApiOperation(value = "Delete asset (deleteAsset)", + notes = "Deletes the asset and all the relations (from and to the asset). Referencing non-existing asset Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/asset/{assetId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteAsset(@ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws Exception { + checkParameter(ASSET_ID, strAssetId); + AssetId assetId = new AssetId(toUUID(strAssetId)); + Asset asset = checkAssetId(assetId, Operation.DELETE); + tbAssetService.delete(asset, getCurrentUser()).get(); + } + + @ApiOperation(value = "Assign asset to customer (assignAssetToCustomer)", + notes = "Creates assignment of the asset to customer. Customer will be able to query asset afterwards." + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/asset/{assetId}", method = RequestMethod.POST) + @ResponseBody + public Asset assignAssetToCustomer(@ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, + @ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + checkParameter(ASSET_ID, strAssetId); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + AssetId assetId = new AssetId(toUUID(strAssetId)); + checkAssetId(assetId, Operation.ASSIGN_TO_CUSTOMER); + return tbAssetService.assignAssetToCustomer(getTenantId(), assetId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Unassign asset from customer (unassignAssetFromCustomer)", + notes = "Clears assignment of the asset to customer. Customer will not be able to query asset afterwards." + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/asset/{assetId}", method = RequestMethod.DELETE) + @ResponseBody + public Asset unassignAssetFromCustomer(@ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(ASSET_ID, strAssetId); + AssetId assetId = new AssetId(toUUID(strAssetId)); + Asset asset = checkAssetId(assetId, Operation.UNASSIGN_FROM_CUSTOMER); + if (asset.getCustomerId() == null || asset.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + throw new IncorrectParameterException("Asset isn't assigned to any customer!"); + } + Customer customer = checkCustomerId(asset.getCustomerId(), Operation.READ); + return tbAssetService.unassignAssetToCustomer(getTenantId(), assetId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Make asset publicly available (assignAssetToPublicCustomer)", + notes = "Asset will be available for non-authorized (not logged-in) users. " + + "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + + "However, users that are logged-in and belong to different tenant will not be able to access the asset." + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/asset/{assetId}", method = RequestMethod.POST) + @ResponseBody + public Asset assignAssetToPublicCustomer(@ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(ASSET_ID, strAssetId); + AssetId assetId = new AssetId(toUUID(strAssetId)); + checkAssetId(assetId, Operation.ASSIGN_TO_CUSTOMER); + return tbAssetService.assignAssetToPublicCustomer(getTenantId(), assetId, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Assets (getTenantAssets)", + notes = "Returns a page of assets owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantAssets( + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = ASSET_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = ASSET_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(assetService.findAssetsByTenantIdAndType(tenantId, type, pageLink)); + } else { + return checkNotNull(assetService.findAssetsByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Asset Infos (getTenantAssetInfos)", + notes = "Returns a page of assets info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantAssetInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = ASSET_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(required = false) String assetProfileId, + @ApiParam(value = ASSET_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(assetService.findAssetInfosByTenantIdAndType(tenantId, type, pageLink)); + } else if (assetProfileId != null && assetProfileId.length() > 0) { + AssetProfileId profileId = new AssetProfileId(toUUID(assetProfileId)); + return checkNotNull(assetService.findAssetInfosByTenantIdAndAssetProfileId(tenantId, profileId, pageLink)); + } else { + return checkNotNull(assetService.findAssetInfosByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Asset (getTenantAsset)", + notes = "Requested asset must be owned by tenant that the user belongs to. " + + "Asset name is an unique property of asset. So it can be used to identify the asset." + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/assets", params = {"assetName"}, method = RequestMethod.GET) + @ResponseBody + public Asset getTenantAsset( + @ApiParam(value = ASSET_NAME_DESCRIPTION) + @RequestParam String assetName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(assetService.findAssetByTenantIdAndName(tenantId, assetName)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Assets (getCustomerAssets)", + notes = "Returns a page of assets objects assigned to customer. " + + PAGE_DATA_PARAMETERS, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerAssets( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = ASSET_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = ASSET_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(assetService.findAssetsByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); + } else { + return checkNotNull(assetService.findAssetsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Asset Infos (getCustomerAssetInfos)", + notes = "Returns a page of assets info objects assigned to customer. " + + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerAssetInfos( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = ASSET_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(required = false) String assetProfileId, + @ApiParam(value = ASSET_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(assetService.findAssetInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); + } else if (assetProfileId != null && assetProfileId.length() > 0) { + AssetProfileId profileId = new AssetProfileId(toUUID(assetProfileId)); + return checkNotNull(assetService.findAssetInfosByTenantIdAndCustomerIdAndAssetProfileId(tenantId, customerId, profileId, pageLink)); + } else { + return checkNotNull(assetService.findAssetInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Assets By Ids (getAssetsByIds)", + notes = "Requested assets must be owned by tenant or assigned to customer which user is performing the request. ", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/assets", params = {"assetIds"}, method = RequestMethod.GET) + @ResponseBody + public List getAssetsByIds( + @ApiParam(value = "A list of assets ids, separated by comma ','") + @RequestParam("assetIds") String[] strAssetIds) throws ThingsboardException { + checkArrayParameter("assetIds", strAssetIds); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = user.getCustomerId(); + List assetIds = new ArrayList<>(); + for (String strAssetId : strAssetIds) { + assetIds.add(new AssetId(toUUID(strAssetId))); + } + ListenableFuture> assets; + if (customerId == null || customerId.isNullUid()) { + assets = assetService.findAssetsByTenantIdAndIdsAsync(tenantId, assetIds); + } else { + assets = assetService.findAssetsByTenantIdCustomerIdAndIdsAsync(tenantId, customerId, assetIds); + } + return checkNotNull(assets.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related assets (findByQuery)", + notes = "Returns all assets that are related to the specific entity. " + + "The entity id, relation type, asset types, depth of the search, and other query parameters defined using complex 'AssetSearchQuery' object. " + + "See 'Model' tab of the Parameters for more info.", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/assets", method = RequestMethod.POST) + @ResponseBody + public List findByQuery(@RequestBody AssetSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getAssetTypes()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + List assets = checkNotNull(assetService.findAssetsByQuery(getTenantId(), query).get()); + assets = assets.stream().filter(asset -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ASSET, Operation.READ, asset.getId(), asset); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return assets; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Asset Types (getAssetTypes)", + notes = "Returns a set of unique asset types based on assets that are either owned by the tenant or assigned to the customer which user is performing the request.", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/asset/types", method = RequestMethod.GET) + @ResponseBody + public List getAssetTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> assetTypes = assetService.findAssetTypesByTenantId(tenantId); + return checkNotNull(assetTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Assign asset to edge (assignAssetToEdge)", + notes = "Creates assignment of an existing asset to an instance of The Edge. " + + EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive a copy of assignment asset " + + EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once asset will be delivered to edge service, it's going to be available for usage on remote edge instance.", + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/asset/{assetId}", method = RequestMethod.POST) + @ResponseBody + public Asset assignAssetToEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION) @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(ASSET_ID, strAssetId); + + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + AssetId assetId = new AssetId(toUUID(strAssetId)); + checkAssetId(assetId, Operation.READ); + + return tbAssetService.assignAssetToEdge(getTenantId(), assetId, edge, getCurrentUser()); + } + + @ApiOperation(value = "Unassign asset from edge (unassignAssetFromEdge)", + notes = "Clears assignment of the asset to the edge. " + + EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive an 'unassign' command to remove asset " + + EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once 'unassign' command will be delivered to edge service, it's going to remove asset locally.", + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/asset/{assetId}", method = RequestMethod.DELETE) + @ResponseBody + public Asset unassignAssetFromEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION) @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = ASSET_ID_PARAM_DESCRIPTION) @PathVariable(ASSET_ID) String strAssetId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(ASSET_ID, strAssetId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + AssetId assetId = new AssetId(toUUID(strAssetId)); + Asset asset = checkAssetId(assetId, Operation.READ); + + return tbAssetService.unassignAssetFromEdge(getTenantId(), asset, edge, getCurrentUser()); + } + + @ApiOperation(value = "Get assets assigned to edge (getEdgeAssets)", + notes = "Returns a page of assets assigned to edge. " + + PAGE_DATA_PARAMETERS, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/{edgeId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeAssets( + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = ASSET_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = ASSET_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = "Timestamp. Assets with creation time before it won't be queried") + @RequestParam(required = false) Long startTime, + @ApiParam(value = "Timestamp. Assets with creation time after it won't be queried") + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + PageData nonFilteredResult; + if (type != null && type.trim().length() > 0) { + nonFilteredResult = assetService.findAssetsByTenantIdAndEdgeIdAndType(tenantId, edgeId, type, pageLink); + } else { + nonFilteredResult = assetService.findAssetsByTenantIdAndEdgeId(tenantId, edgeId, pageLink); + } + List filteredAssets = nonFilteredResult.getData().stream().filter(asset -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ASSET, Operation.READ, asset.getId(), asset); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + PageData filteredResult = new PageData<>(filteredAssets, + nonFilteredResult.getTotalPages(), + nonFilteredResult.getTotalElements(), + nonFilteredResult.hasNext()); + return checkNotNull(filteredResult); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Import the bulk of assets (processAssetsBulkImport)", + notes = "There's an ability to import the bulk of assets using the only .csv file.", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @PostMapping("/asset/bulk_import") + public BulkImportResult processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception { + SecurityUser user = getCurrentUser(); + return assetBulkImportService.processBulkImport(request, user); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java new file mode 100644 index 0000000..2e84dd6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java @@ -0,0 +1,227 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.asset.AssetProfile; +import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.asset.profile.TbAssetProfileService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_ID; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class AssetProfileController extends BaseController { + + private final TbAssetProfileService tbAssetProfileService; + + @ApiOperation(value = "Get Asset Profile (getAssetProfileById)", + notes = "Fetch the Asset Profile object based on the provided Asset Profile Id. " + + "The server checks that the asset profile is owned by the same tenant. " + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/assetProfile/{assetProfileId}", method = RequestMethod.GET) + @ResponseBody + public AssetProfile getAssetProfileById( + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_PROFILE_ID) String strAssetProfileId) throws ThingsboardException { + checkParameter(ASSET_PROFILE_ID, strAssetProfileId); + try { + AssetProfileId assetProfileId = new AssetProfileId(toUUID(strAssetProfileId)); + return checkAssetProfileId(assetProfileId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Asset Profile Info (getAssetProfileInfoById)", + notes = "Fetch the Asset Profile Info object based on the provided Asset Profile Id. " + + ASSET_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/assetProfileInfo/{assetProfileId}", method = RequestMethod.GET) + @ResponseBody + public AssetProfileInfo getAssetProfileInfoById( + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_PROFILE_ID) String strAssetProfileId) throws ThingsboardException { + checkParameter(ASSET_PROFILE_ID, strAssetProfileId); + try { + AssetProfileId assetProfileId = new AssetProfileId(toUUID(strAssetProfileId)); + return new AssetProfileInfo(checkAssetProfileId(assetProfileId, Operation.READ)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Default Asset Profile (getDefaultAssetProfileInfo)", + notes = "Fetch the Default Asset Profile Info object. " + + ASSET_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/assetProfileInfo/default", method = RequestMethod.GET) + @ResponseBody + public AssetProfileInfo getDefaultAssetProfileInfo() throws ThingsboardException { + try { + return checkNotNull(assetProfileService.findDefaultAssetProfileInfo(getTenantId())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Asset Profile (saveAssetProfile)", + notes = "Create or update the Asset Profile. When creating asset profile, platform generates asset profile id as " + UUID_WIKI_LINK + + "The newly created asset profile id will be present in the response. " + + "Specify existing asset profile id to update the asset profile. " + + "Referencing non-existing asset profile Id will cause 'Not Found' error. " + NEW_LINE + + "Asset profile name is unique in the scope of tenant. Only one 'default' asset profile may exist in scope of tenant. " + + "Remove 'id', 'tenantId' from the request body example (below) to create new Asset Profile entity. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json", + consumes = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/assetProfile", method = RequestMethod.POST) + @ResponseBody + public AssetProfile saveAssetProfile( + @ApiParam(value = "A JSON value representing the asset profile.") + @RequestBody AssetProfile assetProfile) throws Exception { + assetProfile.setTenantId(getTenantId()); + checkEntity(assetProfile.getId(), assetProfile, Resource.ASSET_PROFILE); + return tbAssetProfileService.save(assetProfile, getCurrentUser()); + } + + @ApiOperation(value = "Delete asset profile (deleteAssetProfile)", + notes = "Deletes the asset profile. Referencing non-existing asset profile Id will cause an error. " + + "Can't delete the asset profile if it is referenced by existing assets." + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/assetProfile/{assetProfileId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteAssetProfile( + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_PROFILE_ID) String strAssetProfileId) throws ThingsboardException { + checkParameter(ASSET_PROFILE_ID, strAssetProfileId); + AssetProfileId assetProfileId = new AssetProfileId(toUUID(strAssetProfileId)); + AssetProfile assetProfile = checkAssetProfileId(assetProfileId, Operation.DELETE); + tbAssetProfileService.delete(assetProfile, getCurrentUser()); + } + + @ApiOperation(value = "Make Asset Profile Default (setDefaultAssetProfile)", + notes = "Marks asset profile as default within a tenant scope." + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/assetProfile/{assetProfileId}/default", method = RequestMethod.POST) + @ResponseBody + public AssetProfile setDefaultAssetProfile( + @ApiParam(value = ASSET_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(ASSET_PROFILE_ID) String strAssetProfileId) throws ThingsboardException { + checkParameter(ASSET_PROFILE_ID, strAssetProfileId); + AssetProfileId assetProfileId = new AssetProfileId(toUUID(strAssetProfileId)); + AssetProfile assetProfile = checkAssetProfileId(assetProfileId, Operation.WRITE); + AssetProfile previousDefaultAssetProfile = assetProfileService.findDefaultAssetProfile(getTenantId()); + return tbAssetProfileService.setDefaultAssetProfile(assetProfile, previousDefaultAssetProfile, getCurrentUser()); + } + + @ApiOperation(value = "Get Asset Profiles (getAssetProfiles)", + notes = "Returns a page of asset profile objects owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/assetProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAssetProfiles( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(assetProfileService.findAssetProfiles(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Asset Profile infos (getAssetProfileInfos)", + notes = "Returns a page of asset profile info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + ASSET_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/assetProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAssetProfileInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ASSET_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(assetProfileService.findAssetProfileInfos(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java new file mode 100644 index 0000000..33792a7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java @@ -0,0 +1,233 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.AUDIT_LOG_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class AuditLogController extends BaseController { + + private static final String AUDIT_LOG_QUERY_START_TIME_DESCRIPTION = "The start timestamp in milliseconds of the search time range over the AuditLog class field: 'createdTime'."; + private static final String AUDIT_LOG_QUERY_END_TIME_DESCRIPTION = "The end timestamp in milliseconds of the search time range over the AuditLog class field: 'createdTime'."; + private static final String AUDIT_LOG_QUERY_ACTION_TYPES_DESCRIPTION = "A String value representing comma-separated list of action types. " + + "This parameter is optional, but it can be used to filter results to fetch only audit logs of specific action types. " + + "For example, 'LOGIN', 'LOGOUT'. See the 'Model' tab of the Response Class for more details."; + private static final String AUDIT_LOG_SORT_PROPERTY_DESCRIPTION = "Property of audit log to sort by. " + + "See the 'Model' tab of the Response Class for more details. " + + "Note: entityType sort property is not defined in the AuditLog class, however, it can be used to sort audit logs by types of entities that were logged."; + + + @ApiOperation(value = "Get audit logs by customer id (getAuditLogsByCustomerId)", + notes = "Returns a page of audit logs related to the targeted customer entities (devices, assets, etc.), " + + "and users actions (login, logout, etc.) that belong to this customer. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/audit/logs/customer/{customerId}", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAuditLogsByCustomerId( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = AUDIT_LOG_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = AUDIT_LOG_SORT_PROPERTY_DESCRIPTION, allowableValues = AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = AUDIT_LOG_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = AUDIT_LOG_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = AUDIT_LOG_QUERY_ACTION_TYPES_DESCRIPTION) + @RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException { + try { + checkParameter("CustomerId", strCustomerId); + TenantId tenantId = getCurrentUser().getTenantId(); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + List actionTypes = parseActionTypesStr(actionTypesStr); + return checkNotNull(auditLogService.findAuditLogsByTenantIdAndCustomerId(tenantId, new CustomerId(UUID.fromString(strCustomerId)), actionTypes, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get audit logs by user id (getAuditLogsByUserId)", + notes = "Returns a page of audit logs related to the actions of targeted user. " + + "For example, RPC call to a particular device, or alarm acknowledgment for a specific device, etc. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/audit/logs/user/{userId}", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAuditLogsByUserId( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable("userId") String strUserId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = AUDIT_LOG_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = AUDIT_LOG_SORT_PROPERTY_DESCRIPTION, allowableValues = AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = AUDIT_LOG_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = AUDIT_LOG_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = AUDIT_LOG_QUERY_ACTION_TYPES_DESCRIPTION) + @RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException { + try { + checkParameter("UserId", strUserId); + TenantId tenantId = getCurrentUser().getTenantId(); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + List actionTypes = parseActionTypesStr(actionTypesStr); + return checkNotNull(auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, new UserId(UUID.fromString(strUserId)), actionTypes, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get audit logs by entity id (getAuditLogsByEntityId)", + notes = "Returns a page of audit logs related to the actions on the targeted entity. " + + "Basically, this API call is used to get the full lifecycle of some specific entity. " + + "For example to see when a device was created, updated, assigned to some customer, or even deleted from the system. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/audit/logs/entity/{entityType}/{entityId}", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAuditLogsByEntityId( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") + @PathVariable("entityType") String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String strEntityId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = AUDIT_LOG_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = AUDIT_LOG_SORT_PROPERTY_DESCRIPTION, allowableValues = AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = AUDIT_LOG_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = AUDIT_LOG_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = AUDIT_LOG_QUERY_ACTION_TYPES_DESCRIPTION) + @RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException { + try { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + TenantId tenantId = getCurrentUser().getTenantId(); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + List actionTypes = parseActionTypesStr(actionTypesStr); + return checkNotNull(auditLogService.findAuditLogsByTenantIdAndEntityId(tenantId, EntityIdFactory.getByTypeAndId(strEntityType, strEntityId), actionTypes, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get all audit logs (getAuditLogs)", + notes = "Returns a page of audit logs related to all entities in the scope of the current user's Tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/audit/logs", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getAuditLogs( + @ApiParam(value = PAGE_SIZE_DESCRIPTION) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION) + @RequestParam int page, + @ApiParam(value = AUDIT_LOG_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = AUDIT_LOG_SORT_PROPERTY_DESCRIPTION, allowableValues = AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = AUDIT_LOG_QUERY_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = AUDIT_LOG_QUERY_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = AUDIT_LOG_QUERY_ACTION_TYPES_DESCRIPTION) + @RequestParam(name = "actionTypes", required = false) String actionTypesStr) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + List actionTypes = parseActionTypesStr(actionTypesStr); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + return checkNotNull(auditLogService.findAuditLogsByTenantId(tenantId, actionTypes, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + private List parseActionTypesStr(String actionTypesStr) { + List result = null; + if (StringUtils.isNoneBlank(actionTypesStr)) { + String[] tmp = actionTypesStr.split(","); + result = Arrays.stream(tmp).map(at -> ActionType.valueOf(at.toUpperCase())).collect(Collectors.toList()); + } + return result; + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java new file mode 100644 index 0000000..0cb3a3f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -0,0 +1,326 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; +import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.model.ActivateUserRequest; +import org.thingsboard.server.service.security.model.ChangePasswordRequest; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.ResetPasswordEmailRequest; +import org.thingsboard.server.service.security.model.ResetPasswordRequest; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import javax.servlet.http.HttpServletRequest; +import java.net.URI; +import java.net.URISyntaxException; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@Slf4j +@RequiredArgsConstructor +public class AuthController extends BaseController { + private final BCryptPasswordEncoder passwordEncoder; + private final JwtTokenFactory tokenFactory; + private final MailService mailService; + private final SystemSecurityService systemSecurityService; + private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; + + + @ApiOperation(value = "Get current User (getUser)", + notes = "Get the information about the User which credentials are used to perform this REST API call.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/auth/user", method = RequestMethod.GET) + public @ResponseBody + User getUser() throws ThingsboardException { + try { + SecurityUser securityUser = getCurrentUser(); + return userService.findUserById(securityUser.getTenantId(), securityUser.getId()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Logout (logout)", + notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/auth/logout", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void logout(HttpServletRequest request) throws ThingsboardException { + logLogoutAction(request); + } + + @ApiOperation(value = "Change password for current User (changePassword)", + notes = "Change the password for the User which credentials are used to perform this REST API call. Be aware that previously generated [JWT](https://jwt.io/) tokens will be still valid until they expire.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public ObjectNode changePassword( + @ApiParam(value = "Change Password Request") + @RequestBody ChangePasswordRequest changePasswordRequest) throws ThingsboardException { + try { + String currentPassword = changePasswordRequest.getCurrentPassword(); + String newPassword = changePasswordRequest.getNewPassword(); + SecurityUser securityUser = getCurrentUser(); + UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, securityUser.getId()); + if (!passwordEncoder.matches(currentPassword, userCredentials.getPassword())) { + throw new ThingsboardException("Current password doesn't match!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + systemSecurityService.validatePassword(securityUser.getTenantId(), newPassword, userCredentials); + if (passwordEncoder.matches(newPassword, userCredentials.getPassword())) { + throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + userCredentials.setPassword(passwordEncoder.encode(newPassword)); + userService.replaceUserCredentials(securityUser.getTenantId(), userCredentials); + + sendEntityNotificationMsg(getTenantId(), userCredentials.getUserId(), EdgeEventActionType.CREDENTIALS_UPDATED); + + eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId())); + ObjectNode response = JacksonUtil.newObjectNode(); + response.put("token", tokenFactory.createAccessJwtToken(securityUser).getToken()); + response.put("refreshToken", tokenFactory.createRefreshToken(securityUser).getToken()); + return response; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get the current User password policy (getUserPasswordPolicy)", + notes = "API call to get the password policy for the password validation form(s).") + @RequestMapping(value = "/noauth/userPasswordPolicy", method = RequestMethod.GET) + @ResponseBody + public UserPasswordPolicy getUserPasswordPolicy() throws ThingsboardException { + try { + SecuritySettings securitySettings = + checkNotNull(systemSecurityService.getSecuritySettings(TenantId.SYS_TENANT_ID)); + return securitySettings.getPasswordPolicy(); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Check Activate User Token (checkActivateToken)", + notes = "Checks the activation token and forwards user to 'Create Password' page. " + + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + + "If token is not valid, returns '409 Conflict'.") + @RequestMapping(value = "/noauth/activate", params = {"activateToken"}, method = RequestMethod.GET) + public ResponseEntity checkActivateToken( + @ApiParam(value = "The activate token string.") + @RequestParam(value = "activateToken") String activateToken) { + HttpHeaders headers = new HttpHeaders(); + HttpStatus responseStatus; + UserCredentials userCredentials = userService.findUserCredentialsByActivateToken(TenantId.SYS_TENANT_ID, activateToken); + if (userCredentials != null) { + String createURI = "/login/createPassword"; + try { + URI location = new URI(createURI + "?activateToken=" + activateToken); + headers.setLocation(location); + responseStatus = HttpStatus.SEE_OTHER; + } catch (URISyntaxException e) { + log.error("Unable to create URI with address [{}]", createURI); + responseStatus = HttpStatus.BAD_REQUEST; + } + } else { + responseStatus = HttpStatus.CONFLICT; + } + return new ResponseEntity<>(headers, responseStatus); + } + + @ApiOperation(value = "Request reset password email (requestResetPasswordByEmail)", + notes = "Request to send the reset password email if the user with specified email address is present in the database. " + + "Always return '200 OK' status for security purposes.") + @RequestMapping(value = "/noauth/resetPasswordByEmail", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void requestResetPasswordByEmail( + @ApiParam(value = "The JSON object representing the reset password email request.") + @RequestBody ResetPasswordEmailRequest resetPasswordByEmailRequest, + HttpServletRequest request) throws ThingsboardException { + try { + String email = resetPasswordByEmailRequest.getEmail(); + UserCredentials userCredentials = userService.requestPasswordReset(TenantId.SYS_TENANT_ID, email); + User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId()); + String baseUrl = systemSecurityService.getBaseUrl(user.getTenantId(), user.getCustomerId(), request); + String resetUrl = String.format("%s/api/noauth/resetPassword?resetToken=%s", baseUrl, + userCredentials.getResetToken()); + + mailService.sendResetPasswordEmailAsync(resetUrl, email); + } catch (Exception e) { + log.warn("Error occurred: {}", e.getMessage()); + } + } + + @ApiOperation(value = "Check password reset token (checkResetToken)", + notes = "Checks the password reset token and forwards user to 'Reset Password' page. " + + "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + + "If token is not valid, returns '409 Conflict'.") + @RequestMapping(value = "/noauth/resetPassword", params = {"resetToken"}, method = RequestMethod.GET) + public ResponseEntity checkResetToken( + @ApiParam(value = "The reset token string.") + @RequestParam(value = "resetToken") String resetToken) { + HttpHeaders headers = new HttpHeaders(); + HttpStatus responseStatus; + String resetURI = "/login/resetPassword"; + UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); + if (userCredentials != null) { + try { + URI location = new URI(resetURI + "?resetToken=" + resetToken); + headers.setLocation(location); + responseStatus = HttpStatus.SEE_OTHER; + } catch (URISyntaxException e) { + log.error("Unable to create URI with address [{}]", resetURI); + responseStatus = HttpStatus.BAD_REQUEST; + } + } else { + responseStatus = HttpStatus.CONFLICT; + } + return new ResponseEntity<>(headers, responseStatus); + } + + @ApiOperation(value = "Activate User", + notes = "Checks the activation token and updates corresponding user password in the database. " + + "Now the user may start using his password to login. " + + "The response already contains the [JWT](https://jwt.io) activation and refresh tokens, " + + "to simplify the user activation flow and avoid asking user to input password again after activation. " + + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + + "If token is not valid, returns '404 Bad Request'.") + @RequestMapping(value = "/noauth/activate", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + @ResponseBody + public JwtPair activateUser( + @ApiParam(value = "Activate user request.") + @RequestBody ActivateUserRequest activateRequest, + @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, + HttpServletRequest request) throws ThingsboardException { + try { + String activateToken = activateRequest.getActivateToken(); + String password = activateRequest.getPassword(); + systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password, null); + String encodedPassword = passwordEncoder.encode(password); + UserCredentials credentials = userService.activateUserCredentials(TenantId.SYS_TENANT_ID, activateToken, encodedPassword); + User user = userService.findUserById(TenantId.SYS_TENANT_ID, credentials.getUserId()); + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + SecurityUser securityUser = new SecurityUser(user, credentials.isEnabled(), principal); + userService.setUserCredentialsEnabled(user.getTenantId(), user.getId(), true); + String baseUrl = systemSecurityService.getBaseUrl(user.getTenantId(), user.getCustomerId(), request); + String loginUrl = String.format("%s/login", baseUrl); + String email = user.getEmail(); + + if (sendActivationMail) { + try { + mailService.sendAccountActivatedEmail(loginUrl, email); + } catch (Exception e) { + log.info("Unable to send account activation email [{}]", e.getMessage()); + } + } + + sendEntityNotificationMsg(user.getTenantId(), user.getId(), EdgeEventActionType.CREDENTIALS_UPDATED); + + return tokenFactory.createTokenPair(securityUser); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Reset password (resetPassword)", + notes = "Checks the password reset token and updates the password. " + + "If token is valid, returns the object that contains [JWT](https://jwt.io/) access and refresh tokens. " + + "If token is not valid, returns '404 Bad Request'.") + @RequestMapping(value = "/noauth/resetPassword", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + @ResponseBody + public JwtPair resetPassword( + @ApiParam(value = "Reset password request.") + @RequestBody ResetPasswordRequest resetPasswordRequest, + HttpServletRequest request) throws ThingsboardException { + try { + String resetToken = resetPasswordRequest.getResetToken(); + String password = resetPasswordRequest.getPassword(); + UserCredentials userCredentials = userService.findUserCredentialsByResetToken(TenantId.SYS_TENANT_ID, resetToken); + if (userCredentials != null) { + systemSecurityService.validatePassword(TenantId.SYS_TENANT_ID, password, userCredentials); + if (passwordEncoder.matches(password, userCredentials.getPassword())) { + throw new ThingsboardException("New password should be different from existing!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + String encodedPassword = passwordEncoder.encode(password); + userCredentials.setPassword(encodedPassword); + userCredentials.setResetToken(null); + userCredentials = userService.replaceUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); + User user = userService.findUserById(TenantId.SYS_TENANT_ID, userCredentials.getUserId()); + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), principal); + String baseUrl = systemSecurityService.getBaseUrl(user.getTenantId(), user.getCustomerId(), request); + String loginUrl = String.format("%s/login", baseUrl); + String email = user.getEmail(); + mailService.sendPasswordWasResetEmail(loginUrl, email); + + eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId())); + + return tokenFactory.createTokenPair(securityUser); + } else { + throw new ThingsboardException("Invalid reset token!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } catch (Exception e) { + throw handleException(e); + } + } + + private void logLogoutAction(HttpServletRequest request) throws ThingsboardException { + try { + var user = getCurrentUser(); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGOUT, null); + eventPublisher.publishEvent(new UserSessionInvalidationEvent(user.getSessionId())); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AutoCommitController.java b/application/src/main/java/org/thingsboard/server/controller/AutoCommitController.java new file mode 100644 index 0000000..1a097e6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AutoCommitController.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.UUID; + +public class AutoCommitController extends BaseController { + + @Autowired + private EntitiesVersionControlService vcService; + + protected ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + if (vcService != null) { + return vcService.autoCommit(user, entityId); + } else { + // We do not support auto-commit for rule engine + return Futures.immediateFailedFuture(new RuntimeException("Operation not supported!")); + } + } + + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java new file mode 100644 index 0000000..43d0499 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -0,0 +1,962 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceInfo; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantInfo; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetInfo; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.edge.EdgeInfo; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.ClaimDevicesService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; +import org.thingsboard.server.dao.oauth2.OAuth2Service; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.rpc.RpcService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.service.Validator; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.component.ComponentDiscoveryService; +import org.thingsboard.server.service.edge.rpc.EdgeRpcService; +import org.thingsboard.server.service.entitiy.TbNotificationEntityService; +import org.thingsboard.server.service.ota.OtaPackageStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.AccessControlService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; +import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import javax.mail.MessagingException; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.INCORRECT_TENANT_ID; +import static org.thingsboard.server.controller.UserController.YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION; +import static org.thingsboard.server.dao.service.Validator.validateId; + +@Slf4j +@TbCoreComponent +public abstract class BaseController { + + /*Swagger UI description*/ + + private static final ObjectMapper json = new ObjectMapper(); + + @Autowired + private ThingsboardErrorResponseHandler errorResponseHandler; + + @Autowired + protected AccessControlService accessControlService; + + @Autowired + protected TenantService tenantService; + + @Autowired + protected TenantProfileService tenantProfileService; + + @Autowired + protected CustomerService customerService; + + @Autowired + protected UserService userService; + + @Autowired + protected DeviceService deviceService; + + @Autowired + protected DeviceProfileService deviceProfileService; + + @Autowired + protected AssetService assetService; + + @Autowired + protected AssetProfileService assetProfileService; + + @Autowired + protected AlarmSubscriptionService alarmService; + + @Autowired + protected DeviceCredentialsService deviceCredentialsService; + + @Autowired + protected WidgetsBundleService widgetsBundleService; + + @Autowired + protected WidgetTypeService widgetTypeService; + + @Autowired + protected DashboardService dashboardService; + + @Autowired + protected OAuth2Service oAuth2Service; + + @Autowired + protected OAuth2ConfigTemplateService oAuth2ConfigTemplateService; + + @Autowired + protected ComponentDiscoveryService componentDescriptorService; + + @Autowired + protected RuleChainService ruleChainService; + + @Autowired + protected TbClusterService tbClusterService; + + @Autowired + protected RelationService relationService; + + @Autowired + protected AuditLogService auditLogService; + + @Autowired + protected DeviceStateService deviceStateService; + + @Autowired + protected EntityViewService entityViewService; + + @Autowired + protected TelemetrySubscriptionService tsSubService; + + @Autowired + protected AttributesService attributesService; + + @Autowired + protected ClaimDevicesService claimDevicesService; + + @Autowired + protected PartitionService partitionService; + + @Autowired + protected TbResourceService resourceService; + + @Autowired + protected OtaPackageService otaPackageService; + + @Autowired + protected OtaPackageStateService otaPackageStateService; + + @Autowired + protected RpcService rpcService; + + @Autowired + protected TbQueueProducerProvider producerProvider; + + @Autowired + protected TbTenantProfileCache tenantProfileCache; + + @Autowired + protected TbDeviceProfileCache deviceProfileCache; + + @Autowired + protected TbAssetProfileCache assetProfileCache; + + @Autowired(required = false) + protected EdgeService edgeService; + + @Autowired(required = false) + protected EdgeRpcService edgeRpcService; + + @Autowired + protected TbNotificationEntityService notificationEntityService; + + @Autowired + protected QueueService queueService; + + @Autowired + protected EntitiesVersionControlService vcService; + + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + + @Value("${edges.enabled}") + @Getter + protected boolean edgesEnabled; + + @ExceptionHandler(Exception.class) + public void handleControllerException(Exception e, HttpServletResponse response) { + ThingsboardException thingsboardException = handleException(e); + if (thingsboardException.getErrorCode() == ThingsboardErrorCode.GENERAL && thingsboardException.getCause() instanceof Exception + && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { + e = (Exception) thingsboardException.getCause(); + } else { + e = thingsboardException; + } + errorResponseHandler.handle(e, response); + } + + @ExceptionHandler(ThingsboardException.class) + public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { + errorResponseHandler.handle(ex, response); + } + + /** + * @deprecated Exceptions that are not of {@link ThingsboardException} type + * are now caught and mapped to {@link ThingsboardException} by + * {@link ExceptionHandler} {@link BaseController#handleControllerException(Exception, HttpServletResponse)} + * which basically acts like the following boilerplate: + * {@code + * try { + * someExceptionThrowingMethod(); + * } catch (Exception e) { + * throw handleException(e); + * } + * } + * */ + @Deprecated + ThingsboardException handleException(Exception exception) { + return handleException(exception, true); + } + + private ThingsboardException handleException(Exception exception, boolean logException) { + if (logException && logControllerErrorStackTrace) { + log.error("Error [{}]", exception.getMessage(), exception); + } + + String cause = ""; + if (exception.getCause() != null) { + cause = exception.getCause().getClass().getCanonicalName(); + } + + if (exception instanceof ThingsboardException) { + return (ThingsboardException) exception; + } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException + || exception instanceof DataValidationException || cause.contains("IncorrectParameterException")) { + return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else if (exception instanceof MessagingException) { + return new ThingsboardException("Unable to send mail: " + exception.getMessage(), ThingsboardErrorCode.GENERAL); + } else if (exception instanceof AsyncRequestTimeoutException) { + return new ThingsboardException("Request timeout", ThingsboardErrorCode.GENERAL); + } else { + return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.GENERAL); + } + } + + /** + * Handles validation error for controller method arguments annotated with @{@link javax.validation.Valid} + * */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public void handleValidationError(MethodArgumentNotValidException e, HttpServletResponse response) { + String errorMessage = "Validation error: " + e.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + ThingsboardException thingsboardException = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + handleThingsboardException(thingsboardException, response); + } + + T checkNotNull(T reference) throws ThingsboardException { + return checkNotNull(reference, "Requested item wasn't found!"); + } + + T checkNotNull(T reference, String notFoundMessage) throws ThingsboardException { + if (reference == null) { + throw new ThingsboardException(notFoundMessage, ThingsboardErrorCode.ITEM_NOT_FOUND); + } + return reference; + } + + T checkNotNull(Optional reference) throws ThingsboardException { + return checkNotNull(reference, "Requested item wasn't found!"); + } + + T checkNotNull(Optional reference, String notFoundMessage) throws ThingsboardException { + if (reference.isPresent()) { + return reference.get(); + } else { + throw new ThingsboardException(notFoundMessage, ThingsboardErrorCode.ITEM_NOT_FOUND); + } + } + + void checkParameter(String name, String param) throws ThingsboardException { + if (StringUtils.isEmpty(param)) { + throw new ThingsboardException("Parameter '" + name + "' can't be empty!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + + void checkArrayParameter(String name, String[] params) throws ThingsboardException { + if (params == null || params.length == 0) { + throw new ThingsboardException("Parameter '" + name + "' can't be empty!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else { + for (String param : params) { + checkParameter(name, param); + } + } + } + + UUID toUUID(String id) throws ThingsboardException { + try { + return UUID.fromString(id); + } catch (IllegalArgumentException e) { + throw handleException(e, false); + } + } + + PageLink createPageLink(int pageSize, int page, String textSearch, String sortProperty, String sortOrder) throws ThingsboardException { + if (StringUtils.isNotEmpty(sortProperty)) { + if (!Validator.isValidProperty(sortProperty)) { + throw new IllegalArgumentException("Invalid sort property"); + } + SortOrder.Direction direction = SortOrder.Direction.ASC; + if (StringUtils.isNotEmpty(sortOrder)) { + try { + direction = SortOrder.Direction.valueOf(sortOrder.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ThingsboardException("Unsupported sort order '" + sortOrder + "'! Only 'ASC' or 'DESC' types are allowed.", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + SortOrder sort = new SortOrder(sortProperty, direction); + return new PageLink(pageSize, page, textSearch, sort); + } else { + return new PageLink(pageSize, page, textSearch); + } + } + + TimePageLink createTimePageLink(int pageSize, int page, String textSearch, + String sortProperty, String sortOrder, Long startTime, Long endTime) throws ThingsboardException { + PageLink pageLink = this.createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return new TimePageLink(pageLink, startTime, endTime); + } + + protected SecurityUser getCurrentUser() throws ThingsboardException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof SecurityUser) { + return (SecurityUser) authentication.getPrincipal(); + } else { + throw new ThingsboardException("You aren't authorized to perform this operation!", ThingsboardErrorCode.AUTHENTICATION); + } + } + + Tenant checkTenantId(TenantId tenantId, Operation operation) throws ThingsboardException { + try { + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + Tenant tenant = tenantService.findTenantById(tenantId); + checkNotNull(tenant, "Tenant with id [" + tenantId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.TENANT, operation, tenantId, tenant); + return tenant; + } catch (Exception e) { + throw handleException(e, false); + } + } + + TenantInfo checkTenantInfoId(TenantId tenantId, Operation operation) throws ThingsboardException { + try { + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + TenantInfo tenant = tenantService.findTenantInfoById(tenantId); + checkNotNull(tenant, "Tenant with id [" + tenantId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.TENANT, operation, tenantId, tenant); + return tenant; + } catch (Exception e) { + throw handleException(e, false); + } + } + + TenantProfile checkTenantProfileId(TenantProfileId tenantProfileId, Operation operation) throws ThingsboardException { + try { + validateId(tenantProfileId, "Incorrect tenantProfileId " + tenantProfileId); + TenantProfile tenantProfile = tenantProfileService.findTenantProfileById(getTenantId(), tenantProfileId); + checkNotNull(tenantProfile, "Tenant profile with id [" + tenantProfileId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.TENANT_PROFILE, operation); + return tenantProfile; + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected TenantId getTenantId() throws ThingsboardException { + return getCurrentUser().getTenantId(); + } + + Customer checkCustomerId(CustomerId customerId, Operation operation) throws ThingsboardException { + try { + validateId(customerId, "Incorrect customerId " + customerId); + Customer customer = customerService.findCustomerById(getTenantId(), customerId); + checkNotNull(customer, "Customer with id [" + customerId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.CUSTOMER, operation, customerId, customer); + return customer; + } catch (Exception e) { + throw handleException(e, false); + } + } + + User checkUserId(UserId userId, Operation operation) throws ThingsboardException { + try { + validateId(userId, "Incorrect userId " + userId); + User user = userService.findUserById(getCurrentUser().getTenantId(), userId); + checkNotNull(user, "User with id [" + userId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.USER, operation, userId, user); + return user; + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected void checkEntity(I entityId, T entity, Resource resource) throws ThingsboardException { + if (entityId == null) { + accessControlService + .checkPermission(getCurrentUser(), resource, Operation.CREATE, null, entity); + } else { + checkEntityId(entityId, Operation.WRITE); + } + } + + protected void checkEntityId(EntityId entityId, Operation operation) throws ThingsboardException { + try { + if (entityId == null) { + throw new ThingsboardException("Parameter entityId can't be empty!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + validateId(entityId.getId(), "Incorrect entityId " + entityId); + switch (entityId.getEntityType()) { + case ALARM: + checkAlarmId(new AlarmId(entityId.getId()), operation); + return; + case DEVICE: + checkDeviceId(new DeviceId(entityId.getId()), operation); + return; + case DEVICE_PROFILE: + checkDeviceProfileId(new DeviceProfileId(entityId.getId()), operation); + return; + case CUSTOMER: + checkCustomerId(new CustomerId(entityId.getId()), operation); + return; + case TENANT: + checkTenantId(TenantId.fromUUID(entityId.getId()), operation); + return; + case TENANT_PROFILE: + checkTenantProfileId(new TenantProfileId(entityId.getId()), operation); + return; + case RULE_CHAIN: + checkRuleChain(new RuleChainId(entityId.getId()), operation); + return; + case RULE_NODE: + checkRuleNode(new RuleNodeId(entityId.getId()), operation); + return; + case ASSET: + checkAssetId(new AssetId(entityId.getId()), operation); + return; + case ASSET_PROFILE: + checkAssetProfileId(new AssetProfileId(entityId.getId()), operation); + return; + case DASHBOARD: + checkDashboardId(new DashboardId(entityId.getId()), operation); + return; + case USER: + checkUserId(new UserId(entityId.getId()), operation); + return; + case ENTITY_VIEW: + checkEntityViewId(new EntityViewId(entityId.getId()), operation); + return; + case EDGE: + checkEdgeId(new EdgeId(entityId.getId()), operation); + return; + case WIDGETS_BUNDLE: + checkWidgetsBundleId(new WidgetsBundleId(entityId.getId()), operation); + return; + case WIDGET_TYPE: + checkWidgetTypeId(new WidgetTypeId(entityId.getId()), operation); + return; + case TB_RESOURCE: + checkResourceId(new TbResourceId(entityId.getId()), operation); + return; + case OTA_PACKAGE: + checkOtaPackageId(new OtaPackageId(entityId.getId()), operation); + return; + case QUEUE: + checkQueueId(new QueueId(entityId.getId()), operation); + return; + default: + throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); + } + } catch (Exception e) { + throw handleException(e, false); + } + } + + Device checkDeviceId(DeviceId deviceId, Operation operation) throws ThingsboardException { + try { + validateId(deviceId, "Incorrect deviceId " + deviceId); + Device device = deviceService.findDeviceById(getCurrentUser().getTenantId(), deviceId); + checkNotNull(device, "Device with id [" + deviceId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.DEVICE, operation, deviceId, device); + return device; + } catch (Exception e) { + throw handleException(e, false); + } + } + + DeviceInfo checkDeviceInfoId(DeviceId deviceId, Operation operation) throws ThingsboardException { + try { + validateId(deviceId, "Incorrect deviceId " + deviceId); + DeviceInfo device = deviceService.findDeviceInfoById(getCurrentUser().getTenantId(), deviceId); + checkNotNull(device, "Device with id [" + deviceId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.DEVICE, operation, deviceId, device); + return device; + } catch (Exception e) { + throw handleException(e, false); + } + } + + DeviceProfile checkDeviceProfileId(DeviceProfileId deviceProfileId, Operation operation) throws ThingsboardException { + try { + validateId(deviceProfileId, "Incorrect deviceProfileId " + deviceProfileId); + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(getCurrentUser().getTenantId(), deviceProfileId); + checkNotNull(deviceProfile, "Device profile with id [" + deviceProfileId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.DEVICE_PROFILE, operation, deviceProfileId, deviceProfile); + return deviceProfile; + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected EntityView checkEntityViewId(EntityViewId entityViewId, Operation operation) throws ThingsboardException { + try { + validateId(entityViewId, "Incorrect entityViewId " + entityViewId); + EntityView entityView = entityViewService.findEntityViewById(getCurrentUser().getTenantId(), entityViewId); + checkNotNull(entityView, "Entity view with id [" + entityViewId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ENTITY_VIEW, operation, entityViewId, entityView); + return entityView; + } catch (Exception e) { + throw handleException(e, false); + } + } + + EntityViewInfo checkEntityViewInfoId(EntityViewId entityViewId, Operation operation) throws ThingsboardException { + try { + validateId(entityViewId, "Incorrect entityViewId " + entityViewId); + EntityViewInfo entityView = entityViewService.findEntityViewInfoById(getCurrentUser().getTenantId(), entityViewId); + checkNotNull(entityView, "Entity view with id [" + entityViewId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ENTITY_VIEW, operation, entityViewId, entityView); + return entityView; + } catch (Exception e) { + throw handleException(e, false); + } + } + + Asset checkAssetId(AssetId assetId, Operation operation) throws ThingsboardException { + try { + validateId(assetId, "Incorrect assetId " + assetId); + Asset asset = assetService.findAssetById(getCurrentUser().getTenantId(), assetId); + checkNotNull(asset, "Asset with id [" + assetId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ASSET, operation, assetId, asset); + return asset; + } catch (Exception e) { + throw handleException(e, false); + } + } + + AssetInfo checkAssetInfoId(AssetId assetId, Operation operation) throws ThingsboardException { + try { + validateId(assetId, "Incorrect assetId " + assetId); + AssetInfo asset = assetService.findAssetInfoById(getCurrentUser().getTenantId(), assetId); + checkNotNull(asset, "Asset with id [" + assetId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ASSET, operation, assetId, asset); + return asset; + } catch (Exception e) { + throw handleException(e, false); + } + } + + AssetProfile checkAssetProfileId(AssetProfileId assetProfileId, Operation operation) throws ThingsboardException { + try { + validateId(assetProfileId, "Incorrect assetProfileId " + assetProfileId); + AssetProfile assetProfile = assetProfileService.findAssetProfileById(getCurrentUser().getTenantId(), assetProfileId); + checkNotNull(assetProfile, "Asset profile with id [" + assetProfileId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ASSET_PROFILE, operation, assetProfileId, assetProfile); + return assetProfile; + } catch (Exception e) { + throw handleException(e, false); + } + } + + Alarm checkAlarmId(AlarmId alarmId, Operation operation) throws ThingsboardException { + try { + validateId(alarmId, "Incorrect alarmId " + alarmId); + Alarm alarm = alarmService.findAlarmByIdAsync(getCurrentUser().getTenantId(), alarmId).get(); + checkNotNull(alarm, "Alarm with id [" + alarmId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ALARM, operation, alarmId, alarm); + return alarm; + } catch (Exception e) { + throw handleException(e, false); + } + } + + AlarmInfo checkAlarmInfoId(AlarmId alarmId, Operation operation) throws ThingsboardException { + try { + validateId(alarmId, "Incorrect alarmId " + alarmId); + AlarmInfo alarmInfo = alarmService.findAlarmInfoByIdAsync(getCurrentUser().getTenantId(), alarmId).get(); + checkNotNull(alarmInfo, "Alarm with id [" + alarmId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.ALARM, operation, alarmId, alarmInfo); + return alarmInfo; + } catch (Exception e) { + throw handleException(e, false); + } + } + + WidgetsBundle checkWidgetsBundleId(WidgetsBundleId widgetsBundleId, Operation operation) throws ThingsboardException { + try { + validateId(widgetsBundleId, "Incorrect widgetsBundleId " + widgetsBundleId); + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleById(getCurrentUser().getTenantId(), widgetsBundleId); + checkNotNull(widgetsBundle, "Widgets bundle with id [" + widgetsBundleId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.WIDGETS_BUNDLE, operation, widgetsBundleId, widgetsBundle); + return widgetsBundle; + } catch (Exception e) { + throw handleException(e, false); + } + } + + WidgetTypeDetails checkWidgetTypeId(WidgetTypeId widgetTypeId, Operation operation) throws ThingsboardException { + try { + validateId(widgetTypeId, "Incorrect widgetTypeId " + widgetTypeId); + WidgetTypeDetails widgetTypeDetails = widgetTypeService.findWidgetTypeDetailsById(getCurrentUser().getTenantId(), widgetTypeId); + checkNotNull(widgetTypeDetails, "Widget type with id [" + widgetTypeId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.WIDGET_TYPE, operation, widgetTypeId, widgetTypeDetails); + return widgetTypeDetails; + } catch (Exception e) { + throw handleException(e, false); + } + } + + Dashboard checkDashboardId(DashboardId dashboardId, Operation operation) throws ThingsboardException { + try { + validateId(dashboardId, "Incorrect dashboardId " + dashboardId); + Dashboard dashboard = dashboardService.findDashboardById(getCurrentUser().getTenantId(), dashboardId); + checkNotNull(dashboard, "Dashboard with id [" + dashboardId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.DASHBOARD, operation, dashboardId, dashboard); + return dashboard; + } catch (Exception e) { + throw handleException(e, false); + } + } + + Edge checkEdgeId(EdgeId edgeId, Operation operation) throws ThingsboardException { + try { + validateId(edgeId, "Incorrect edgeId " + edgeId); + Edge edge = edgeService.findEdgeById(getTenantId(), edgeId); + checkNotNull(edge, "Edge with id [" + edgeId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.EDGE, operation, edgeId, edge); + return edge; + } catch (Exception e) { + throw handleException(e, false); + } + } + + EdgeInfo checkEdgeInfoId(EdgeId edgeId, Operation operation) throws ThingsboardException { + try { + validateId(edgeId, "Incorrect edgeId " + edgeId); + EdgeInfo edge = edgeService.findEdgeInfoById(getCurrentUser().getTenantId(), edgeId); + checkNotNull(edge, "Edge with id [" + edgeId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.EDGE, operation, edgeId, edge); + return edge; + } catch (Exception e) { + throw handleException(e, false); + } + } + + DashboardInfo checkDashboardInfoId(DashboardId dashboardId, Operation operation) throws ThingsboardException { + try { + validateId(dashboardId, "Incorrect dashboardId " + dashboardId); + DashboardInfo dashboardInfo = dashboardService.findDashboardInfoById(getCurrentUser().getTenantId(), dashboardId); + checkNotNull(dashboardInfo, "Dashboard with id [" + dashboardId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.DASHBOARD, operation, dashboardId, dashboardInfo); + return dashboardInfo; + } catch (Exception e) { + throw handleException(e, false); + } + } + + ComponentDescriptor checkComponentDescriptorByClazz(String clazz) throws ThingsboardException { + try { + log.debug("[{}] Lookup component descriptor", clazz); + return checkNotNull(componentDescriptorService.getComponent(clazz)); + } catch (Exception e) { + throw handleException(e, false); + } + } + + List checkComponentDescriptorsByType(ComponentType type, RuleChainType ruleChainType) throws ThingsboardException { + try { + log.debug("[{}] Lookup component descriptors", type); + return componentDescriptorService.getComponents(type, ruleChainType); + } catch (Exception e) { + throw handleException(e, false); + } + } + + List checkComponentDescriptorsByTypes(Set types, RuleChainType ruleChainType) throws ThingsboardException { + try { + log.debug("[{}] Lookup component descriptors", types); + return componentDescriptorService.getComponents(types, ruleChainType); + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected RuleChain checkRuleChain(RuleChainId ruleChainId, Operation operation) throws ThingsboardException { + validateId(ruleChainId, "Incorrect ruleChainId " + ruleChainId); + RuleChain ruleChain = ruleChainService.findRuleChainById(getCurrentUser().getTenantId(), ruleChainId); + checkNotNull(ruleChain, "Rule chain with id [" + ruleChainId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.RULE_CHAIN, operation, ruleChainId, ruleChain); + return ruleChain; + } + + protected RuleNode checkRuleNode(RuleNodeId ruleNodeId, Operation operation) throws ThingsboardException { + validateId(ruleNodeId, "Incorrect ruleNodeId " + ruleNodeId); + RuleNode ruleNode = ruleChainService.findRuleNodeById(getTenantId(), ruleNodeId); + checkNotNull(ruleNode, "Rule node with id [" + ruleNodeId + "] is not found"); + checkRuleChain(ruleNode.getRuleChainId(), operation); + return ruleNode; + } + + TbResource checkResourceId(TbResourceId resourceId, Operation operation) throws ThingsboardException { + try { + validateId(resourceId, "Incorrect resourceId " + resourceId); + TbResource resource = resourceService.findResourceById(getCurrentUser().getTenantId(), resourceId); + checkNotNull(resource, "Resource with id [" + resourceId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.TB_RESOURCE, operation, resourceId, resource); + return resource; + } catch (Exception e) { + throw handleException(e, false); + } + } + + TbResourceInfo checkResourceInfoId(TbResourceId resourceId, Operation operation) throws ThingsboardException { + try { + validateId(resourceId, "Incorrect resourceId " + resourceId); + TbResourceInfo resourceInfo = resourceService.findResourceInfoById(getCurrentUser().getTenantId(), resourceId); + checkNotNull(resourceInfo, "Resource with id [" + resourceId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.TB_RESOURCE, operation, resourceId, resourceInfo); + return resourceInfo; + } catch (Exception e) { + throw handleException(e, false); + } + } + + OtaPackage checkOtaPackageId(OtaPackageId otaPackageId, Operation operation) throws ThingsboardException { + try { + validateId(otaPackageId, "Incorrect otaPackageId " + otaPackageId); + OtaPackage otaPackage = otaPackageService.findOtaPackageById(getCurrentUser().getTenantId(), otaPackageId); + checkNotNull(otaPackage, "OTA package with id [" + otaPackageId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.OTA_PACKAGE, operation, otaPackageId, otaPackage); + return otaPackage; + } catch (Exception e) { + throw handleException(e, false); + } + } + + OtaPackageInfo checkOtaPackageInfoId(OtaPackageId otaPackageId, Operation operation) throws ThingsboardException { + try { + validateId(otaPackageId, "Incorrect otaPackageId " + otaPackageId); + OtaPackageInfo otaPackageIn = otaPackageService.findOtaPackageInfoById(getCurrentUser().getTenantId(), otaPackageId); + checkNotNull(otaPackageIn, "OTA package with id [" + otaPackageId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.OTA_PACKAGE, operation, otaPackageId, otaPackageIn); + return otaPackageIn; + } catch (Exception e) { + throw handleException(e, false); + } + } + + Rpc checkRpcId(RpcId rpcId, Operation operation) throws ThingsboardException { + try { + validateId(rpcId, "Incorrect rpcId " + rpcId); + Rpc rpc = rpcService.findById(getCurrentUser().getTenantId(), rpcId); + checkNotNull(rpc, "RPC with id [" + rpcId + "] is not found"); + accessControlService.checkPermission(getCurrentUser(), Resource.RPC, operation, rpcId, rpc); + return rpc; + } catch (Exception e) { + throw handleException(e, false); + } + } + + protected Queue checkQueueId(QueueId queueId, Operation operation) throws ThingsboardException { + validateId(queueId, "Incorrect queueId " + queueId); + Queue queue = queueService.findQueueById(getCurrentUser().getTenantId(), queueId); + checkNotNull(queue); + accessControlService.checkPermission(getCurrentUser(), Resource.QUEUE, operation, queueId, queue); + TenantId tenantId = getTenantId(); + if (queue.getTenantId().isNullUid() && !tenantId.isNullUid()) { + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + if (tenantProfile.isIsolatedTbRuleEngine()) { + throw new ThingsboardException(YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION, + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + return queue; + } + + protected I emptyId(EntityType entityType) { + return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); + } + + public static Exception toException(Throwable error) { + return error != null ? (Exception.class.isInstance(error) ? (Exception) error : new Exception(error)) : null; + } + + protected void sendEntityNotificationMsg(TenantId tenantId, EntityId entityId, EdgeEventActionType action) { + sendNotificationMsgToEdge(tenantId, null, entityId, null, null, action); + } + + protected void sendEntityAssignToEdgeNotificationMsg(TenantId tenantId, EdgeId edgeId, EntityId entityId, EdgeEventActionType action) { + sendNotificationMsgToEdge(tenantId, edgeId, entityId, null, null, action); + } + + private void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action) { + tbClusterService.sendNotificationMsgToEdge(tenantId, edgeId, entityId, body, type, action); + } + + protected void processDashboardIdFromAdditionalInfo(ObjectNode additionalInfo, String requiredFields) throws ThingsboardException { + String dashboardId = additionalInfo.has(requiredFields) ? additionalInfo.get(requiredFields).asText() : null; + if (dashboardId != null && !dashboardId.equals("null")) { + if (dashboardService.findDashboardById(getTenantId(), new DashboardId(UUID.fromString(dashboardId))) == null) { + additionalInfo.remove(requiredFields); + } + } + } + + protected MediaType parseMediaType(String contentType) { + try { + return MediaType.parseMediaType(contentType); + } catch (Exception e) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + protected DeferredResult wrapFuture(ListenableFuture future) { + final DeferredResult deferredResult = new DeferredResult<>(); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(T result) { + deferredResult.setResult(result); + } + + @Override + public void onFailure(Throwable t) { + deferredResult.setErrorResult(t); + } + }, MoreExecutors.directExecutor()); + return deferredResult; + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java new file mode 100644 index 0000000..31f6425 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class ComponentDescriptorController extends BaseController { + + private static final String COMPONENT_DESCRIPTOR_DEFINITION = "Each Component Descriptor represents configuration of specific rule node (e.g. 'Save Timeseries' or 'Send Email'.). " + + "The Component Descriptors are used by the rule chain Web UI to build the configuration forms for the rule nodes. " + + "The Component Descriptors are discovered at runtime by scanning the class path and searching for @RuleNode annotation. " + + "Once discovered, the up to date list of descriptors is persisted to the database."; + + @ApiOperation(value = "Get Component Descriptor (getComponentDescriptorByClazz)", + notes = "Gets the Component Descriptor object using class name from the path parameters. " + + COMPONENT_DESCRIPTOR_DEFINITION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + @RequestMapping(value = "/component/{componentDescriptorClazz:.+}", method = RequestMethod.GET) + @ResponseBody + public ComponentDescriptor getComponentDescriptorByClazz( + @ApiParam(value = "Component Descriptor class name", required = true) + @PathVariable("componentDescriptorClazz") String strComponentDescriptorClazz) throws ThingsboardException { + checkParameter("strComponentDescriptorClazz", strComponentDescriptorClazz); + try { + return checkComponentDescriptorByClazz(strComponentDescriptorClazz); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Component Descriptors (getComponentDescriptorsByType)", + notes = "Gets the Component Descriptors using rule node type and optional rule chain type request parameters. " + + COMPONENT_DESCRIPTOR_DEFINITION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + @RequestMapping(value = "/components/{componentType}", method = RequestMethod.GET) + @ResponseBody + public List getComponentDescriptorsByType( + @ApiParam(value = "Type of the Rule Node", allowableValues = "ENRICHMENT,FILTER,TRANSFORMATION,ACTION,EXTERNAL", required = true) + @PathVariable("componentType") String strComponentType, + @ApiParam(value = "Type of the Rule Chain", allowableValues = "CORE,EDGE") + @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { + checkParameter("componentType", strComponentType); + try { + return checkComponentDescriptorsByType(ComponentType.valueOf(strComponentType), getRuleChainType(strRuleChainType)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Component Descriptors (getComponentDescriptorsByTypes)", + notes = "Gets the Component Descriptors using coma separated list of rule node types and optional rule chain type request parameters. " + + COMPONENT_DESCRIPTOR_DEFINITION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") + @RequestMapping(value = "/components", params = {"componentTypes"}, method = RequestMethod.GET) + @ResponseBody + public List getComponentDescriptorsByTypes( + @ApiParam(value = "List of types of the Rule Nodes, (ENRICHMENT, FILTER, TRANSFORMATION, ACTION or EXTERNAL)", required = true) + @RequestParam("componentTypes") String[] strComponentTypes, + @ApiParam(value = "Type of the Rule Chain", allowableValues = "CORE,EDGE") + @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { + checkArrayParameter("componentTypes", strComponentTypes); + try { + Set componentTypes = new HashSet<>(); + for (String strComponentType : strComponentTypes) { + componentTypes.add(ComponentType.valueOf(strComponentType)); + } + return checkComponentDescriptorsByTypes(componentTypes, getRuleChainType(strRuleChainType)); + } catch (Exception e) { + throw handleException(e); + } + } + + private RuleChainType getRuleChainType(String strRuleChainType) { + RuleChainType ruleChainType; + if (StringUtils.isEmpty(strRuleChainType)) { + ruleChainType = RuleChainType.CORE; + } else { + ruleChainType = RuleChainType.valueOf(strRuleChainType); + } + return ruleChainType; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java new file mode 100644 index 0000000..e00ccaa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -0,0 +1,1546 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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; + +public class ControllerConstants { + + protected static final String NEW_LINE = "\n\n"; + protected static final String UUID_WIKI_LINK = "[time-based UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_1_(date-time_and_MAC_address)). "; + protected static final int DEFAULT_PAGE_SIZE = 1000; + protected static final String ENTITY_TYPE = "entityType"; + protected static final String CUSTOMER_ID = "customerId"; + protected static final String TENANT_ID = "tenantId"; + protected static final String DEVICE_ID = "deviceId"; + protected static final String EDGE_ID = "edgeId"; + protected static final String RPC_ID = "rpcId"; + protected static final String ENTITY_ID = "entityId"; + protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " + + "The result is wrapped with PageData object that allows you to iterate over result set using pagination. " + + "See the 'Model' tab of the Response Class for more details. "; + protected static final String DASHBOARD_ID_PARAM_DESCRIPTION = "A string value representing the dashboard id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String RPC_ID_PARAM_DESCRIPTION = "A string value representing the rpc id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String DEVICE_ID_PARAM_DESCRIPTION = "A string value representing the device id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String ENTITY_VIEW_ID_PARAM_DESCRIPTION = "A string value representing the entity view id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String DEVICE_PROFILE_ID_PARAM_DESCRIPTION = "A string value representing the device profile id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + + protected static final String ASSET_PROFILE_ID_PARAM_DESCRIPTION = "A string value representing the asset profile id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String TENANT_PROFILE_ID_PARAM_DESCRIPTION = "A string value representing the tenant profile id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String TENANT_ID_PARAM_DESCRIPTION = "A string value representing the tenant id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String EDGE_ID_PARAM_DESCRIPTION = "A string value representing the edge id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String CUSTOMER_ID_PARAM_DESCRIPTION = "A string value representing the customer id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String USER_ID_PARAM_DESCRIPTION = "A string value representing the user id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String ASSET_ID_PARAM_DESCRIPTION = "A string value representing the asset id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String ALARM_ID_PARAM_DESCRIPTION = "A string value representing the alarm id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String ENTITY_ID_PARAM_DESCRIPTION = "A string value representing the entity id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String OTA_PACKAGE_ID_PARAM_DESCRIPTION = "A string value representing the ota package id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String ENTITY_TYPE_PARAM_DESCRIPTION = "A string value representing the entity type. For example, 'DEVICE'"; + protected static final String RULE_CHAIN_ID_PARAM_DESCRIPTION = "A string value representing the rule chain id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String RULE_NODE_ID_PARAM_DESCRIPTION = "A string value representing the rule node id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String WIDGET_BUNDLE_ID_PARAM_DESCRIPTION = "A string value representing the widget bundle id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String WIDGET_TYPE_ID_PARAM_DESCRIPTION = "A string value representing the widget type id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String VC_REQUEST_ID_PARAM_DESCRIPTION = "A string value representing the version control request id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String RESOURCE_ID_PARAM_DESCRIPTION = "A string value representing the resource id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String SYSTEM_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' authority."; + protected static final String SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'SYS_ADMIN' or 'TENANT_ADMIN' authority."; + protected static final String TENANT_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'TENANT_ADMIN' authority."; + protected static final String TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'TENANT_ADMIN' or 'CUSTOMER_USER' authority."; + protected static final String CUSTOMER_AUTHORITY_PARAGRAPH = "\n\nAvailable for users with 'CUSTOMER_USER' authority."; + protected static final String AVAILABLE_FOR_ANY_AUTHORIZED_USER = "\n\nAvailable for any authorized user. "; + protected static final String PAGE_SIZE_DESCRIPTION = "Maximum amount of entities in a one page"; + protected static final String PAGE_NUMBER_DESCRIPTION = "Sequence number of page starting from 0"; + protected static final String DEVICE_TYPE_DESCRIPTION = "Device type as the name of the device profile"; + protected static final String ENTITY_VIEW_TYPE_DESCRIPTION = "Entity View type"; + protected static final String ASSET_TYPE_DESCRIPTION = "Asset type"; + protected static final String EDGE_TYPE_DESCRIPTION = "A string value representing the edge type. For example, 'default'"; + protected static final String RULE_CHAIN_TYPE_DESCRIPTION = "Rule chain type (CORE or EDGE)"; + protected static final String ASSET_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset name."; + protected static final String DASHBOARD_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the dashboard title."; + protected static final String WIDGET_BUNDLE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the widget bundle title."; + protected static final String RPC_TEXT_SEARCH_DESCRIPTION = "Not implemented. Leave empty."; + protected static final String DEVICE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device name."; + protected static final String ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the entity view name."; + protected static final String USER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the user email."; + protected static final String TENANT_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant name."; + protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; + protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; + protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; + + protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; + protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; + protected static final String EDGE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the edge name."; + protected static final String EVENT_TEXT_SEARCH_DESCRIPTION = "The value is not used in searching."; + protected static final String AUDIT_LOG_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on one of the next properties: entityType, entityName, userName, actionType, actionStatus."; + protected static final String SORT_PROPERTY_DESCRIPTION = "Property of entity to sort by"; + protected static final String DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, title"; + protected static final String CUSTOMER_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, title, email, country, city"; + protected static final String RPC_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, expirationTime, request, response"; + protected static final String DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, deviceProfileName, label, customerTitle"; + protected static final String ENTITY_VIEW_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, type"; + protected static final String ENTITY_VIEW_INFO_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, type, customerTitle"; + protected static final String USER_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, firstName, lastName, email"; + protected static final String TENANT_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, title, email, country, state, city, address, address2, zip, phone, email"; + protected static final String TENANT_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, description, isDefault"; + protected static final String TENANT_PROFILE_INFO_SORT_PROPERTY_ALLOWABLE_VALUES = "id, name"; + protected static final String TENANT_INFO_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, tenantProfileName, title, email, country, state, city, address, address2, zip, phone, email"; + protected static final String DEVICE_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, type, transportType, description, isDefault"; + + protected static final String ASSET_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, description, isDefault"; + protected static final String ASSET_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, type, label, customerTitle"; + protected static final String ALARM_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, startTs, endTs, type, ackTs, clearTs, severity, status"; + protected static final String EVENT_SORT_PROPERTY_ALLOWABLE_VALUES = "ts, id"; + protected static final String EDGE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, type, label, customerTitle"; + protected static final String RULE_CHAIN_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, root"; + protected static final String WIDGET_BUNDLE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, title, tenantId"; + protected static final String AUDIT_LOG_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, entityType, entityName, userName, actionType, actionStatus"; + protected static final String SORT_ORDER_DESCRIPTION = "Sort order. ASC (ASCENDING) or DESC (DESCENDING)"; + protected static final String SORT_ORDER_ALLOWABLE_VALUES = "ASC, DESC"; + protected static final String RPC_STATUS_ALLOWABLE_VALUES = "QUEUED, SENT, DELIVERED, SUCCESSFUL, TIMEOUT, EXPIRED, FAILED"; + protected static final String RULE_CHAIN_TYPES_ALLOWABLE_VALUES = "CORE, EDGE"; + protected static final String TRANSPORT_TYPE_ALLOWABLE_VALUES = "DEFAULT, MQTT, COAP, LWM2M, SNMP"; + protected static final String DEVICE_INFO_DESCRIPTION = "Device Info is an extension of the default Device object that contains information about the assigned customer name and device profile name. "; + protected static final String ASSET_INFO_DESCRIPTION = "Asset Info is an extension of the default Asset object that contains information about the assigned customer name. "; + protected static final String ALARM_INFO_DESCRIPTION = "Alarm Info is an extension of the default Alarm object that also contains name of the alarm originator."; + protected static final String RELATION_INFO_DESCRIPTION = "Relation Info is an extension of the default Relation object that contains information about the 'from' and 'to' entity names. "; + protected static final String EDGE_INFO_DESCRIPTION = "Edge Info is an extension of the default Edge object that contains information about the assigned customer name. "; + protected static final String DEVICE_PROFILE_INFO_DESCRIPTION = "Device Profile Info is a lightweight object that includes main information about Device Profile excluding the heavyweight configuration object. "; + + protected static final String ASSET_PROFILE_INFO_DESCRIPTION = "Asset Profile Info is a lightweight object that includes main information about Asset Profile. "; + protected static final String QUEUE_SERVICE_TYPE_DESCRIPTION = "Service type (implemented only for the TB-RULE-ENGINE)"; + protected static final String QUEUE_SERVICE_TYPE_ALLOWABLE_VALUES = "TB-RULE-ENGINE, TB-CORE, TB-TRANSPORT, JS-EXECUTOR"; + protected static final String QUEUE_QUEUE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the queue name."; + protected static final String QUEUE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, name, topic"; + protected static final String QUEUE_ID_PARAM_DESCRIPTION = "A string value representing the queue id. For example, '784f394c-42b6-435a-983c-b7beff2784f9'"; + protected static final String QUEUE_NAME_PARAM_DESCRIPTION = "A string value representing the queue id. For example, 'Main'"; + protected static final String OTA_PACKAGE_INFO_DESCRIPTION = "OTA Package Info is a lightweight object that includes main information about the OTA Package excluding the heavyweight data. "; + protected static final String OTA_PACKAGE_DESCRIPTION = "OTA Package is a heavyweight object that includes main information about the OTA Package and also data. "; + protected static final String OTA_PACKAGE_CHECKSUM_ALGORITHM_ALLOWABLE_VALUES = "MD5, SHA256, SHA384, SHA512, CRC32, MURMUR3_32, MURMUR3_128"; + protected static final String OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the ota package title."; + protected static final String OTA_PACKAGE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, type, title, version, tag, url, fileName, dataSize, checksum"; + protected static final String RESOURCE_INFO_DESCRIPTION = "Resource Info is a lightweight object that includes main information about the Resource excluding the heavyweight data. "; + protected static final String RESOURCE_DESCRIPTION = "Resource is a heavyweight object that includes main information about the Resource and also data. "; + + protected static final String RESOURCE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the resource title."; + protected static final String RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES = "createdTime, title, resourceType, tenantId"; + protected static final String LWM2M_OBJECT_DESCRIPTION = "LwM2M Object is a object that includes information about the LwM2M model which can be used in transport configuration for the LwM2M device profile. "; + protected static final String LWM2M_OBJECT_SORT_PROPERTY_ALLOWABLE_VALUES = "id, name"; + + protected static final String DEVICE_NAME_DESCRIPTION = "A string value representing the Device name."; + protected static final String ASSET_NAME_DESCRIPTION = "A string value representing the Asset name."; + + protected static final String EVENT_START_TIME_DESCRIPTION = "Timestamp. Events with creation time before it won't be queried."; + protected static final String EVENT_END_TIME_DESCRIPTION = "Timestamp. Events with creation time after it won't be queried."; + + protected static final String EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION = "Unassignment works in async way - first, 'unassign' notification event pushed to edge queue on platform. "; + protected static final String EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION = "(Edge will receive this instantly, if it's currently connected, or once it's going to be connected to platform). "; + protected static final String EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION = "Assignment works in async way - first, notification event pushed to edge service queue on platform. "; + protected static final String EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION = "(Edge will receive this instantly, if it's currently connected, or once it's going to be connected to platform). "; + + protected static final String ENTITY_VERSION_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the entity version name."; + protected static final String VERSION_ID_PARAM_DESCRIPTION = "Version id, for example fd82625bdd7d6131cf8027b44ee967012ecaf990. Represents commit hash."; + protected static final String BRANCH_PARAM_DESCRIPTION = "The name of the working branch, for example 'master'"; + + protected static final String MARKDOWN_CODE_BLOCK_START = "```json\n"; + protected static final String MARKDOWN_CODE_BLOCK_END = "\n```"; + protected static final String EVENT_ERROR_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"eventType\":\"ERROR\",\n" + + " \"server\":\"ip-172-31-24-152\",\n" + + " \"method\":\"onClusterEventMsg\",\n" + + " \"errorStr\":\"Error Message\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String EVENT_LC_EVENT_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"eventType\":\"LC_EVENT\",\n" + + " \"server\":\"ip-172-31-24-152\",\n" + + " \"event\":\"STARTED\",\n" + + " \"status\":\"Success\",\n" + + " \"errorStr\":\"Error Message\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String EVENT_STATS_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"eventType\":\"STATS\",\n" + + " \"server\":\"ip-172-31-24-152\",\n" + + " \"messagesProcessed\":10,\n" + + " \"errorsOccurred\":5\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String DEBUG_FILTER_OBJ = + " \"msgDirectionType\":\"IN\",\n" + + " \"server\":\"ip-172-31-24-152\",\n" + + " \"dataSearch\":\"humidity\",\n" + + " \"metadataSearch\":\"deviceName\",\n" + + " \"entityName\":\"DEVICE\",\n" + + " \"relationType\":\"Success\",\n" + + " \"entityId\":\"de9d54a0-2b7a-11ec-a3cc-23386423d98f\",\n" + + " \"msgType\":\"POST_TELEMETRY_REQUEST\",\n" + + " \"isError\":\"false\",\n" + + " \"errorStr\":\"Error Message\"\n" + + "}"; + protected static final String EVENT_DEBUG_RULE_NODE_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + "{\n" + + " \"eventType\":\"DEBUG_RULE_NODE\",\n" + DEBUG_FILTER_OBJ + MARKDOWN_CODE_BLOCK_END; + protected static final String EVENT_DEBUG_RULE_CHAIN_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + "{\n" + + " \"eventType\":\"DEBUG_RULE_CHAIN\",\n" + DEBUG_FILTER_OBJ + MARKDOWN_CODE_BLOCK_END; + + protected static final String IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION = "A Boolean value representing the Server SecurityInfo for future Bootstrap client mode settings. Values: 'true' for Bootstrap Server; 'false' for Lwm2m Server. "; + + protected static final String DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_DESCRIPTION = + "{\n" + + " \"device\": {\n" + + " \"name\": \"LwRpk00000000\",\n" + + " \"type\": \"lwm2mProfileRpk\"\n" + + " },\n" + + " \"credentials\": {\n" + + " \"id\": \"null\",\n" + + " \"createdTime\": 0,\n" + + " \"deviceId\": \"null\",\n" + + " \"credentialsType\": \"LWM2M_CREDENTIALS\",\n" + + " \"credentialsId\": \"LwRpk00000000\",\n" + + " \"credentialsValue\": {\n" + + " \"client\": {\n" + + " \"endpoint\": \"LwRpk00000000\",\n" + + " \"securityConfigClientMode\": \"RPK\",\n" + + " \"key\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUEBxNl/RcYJNm8mk91CyVXoIJiROYDlXcSSqK6e5bDHwOW4ZiN2lNnXalyF0Jxw8MbAytnDMERXyAja5VEMeVQ==\"\n" + + " },\n" + + " \"bootstrap\": {\n" + + " \"bootstrapServer\": {\n" + + " \"securityMode\": \"RPK\",\n" + + " \"clientPublicKeyOrId\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUEBxNl/RcYJNm8mk91CyVXoIJiROYDlXcSSqK6e5bDHwOW4ZiN2lNnXalyF0Jxw8MbAytnDMERXyAja5VEMeVQ==\",\n" + + " \"clientSecretKey\": \"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgd9GAx7yZW37autew5KZykn4IgRpge/tZSjnudnZJnMahRANCAARQQHE2X9Fxgk2byaT3ULJVeggmJE5gOVdxJKorp7lsMfA5bhmI3aU2ddqXIXQnHDwxsDK2cMwRFfICNrlUQx5V\"\n" + + " },\n" + + " \"lwm2mServer\": {\n" + + " \"securityMode\": \"RPK\",\n" + + " \"clientPublicKeyOrId\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUEBxNl/RcYJNm8mk91CyVXoIJiROYDlXcSSqK6e5bDHwOW4ZiN2lNnXalyF0Jxw8MbAytnDMERXyAja5VEMeVQ==\",\n" + + " \"clientSecretKey\": \"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgd9GAx7yZW37autew5KZykn4IgRpge/tZSjnudnZJnMahRANCAARQQHE2X9Fxgk2byaT3ULJVeggmJE5gOVdxJKorp7lsMfA5bhmI3aU2ddqXIXQnHDwxsDK2cMwRFfICNrlUQx5V\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + protected static final String DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_DESCRIPTION_MARKDOWN = + MARKDOWN_CODE_BLOCK_START + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_DESCRIPTION + MARKDOWN_CODE_BLOCK_END; + + + protected static final String FILTER_VALUE_TYPE = NEW_LINE + "## Value Type and Operations" + NEW_LINE + + "Provides a hint about the data type of the entity field that is defined in the filter key. " + + "The value type impacts the list of possible operations that you may use in the corresponding predicate. For example, you may use 'STARTS_WITH' or 'END_WITH', but you can't use 'GREATER_OR_EQUAL' for string values." + + "The following filter value types and corresponding predicate operations are supported: " + NEW_LINE + + " * 'STRING' - used to filter any 'String' or 'JSON' values. Operations: EQUAL, NOT_EQUAL, STARTS_WITH, ENDS_WITH, CONTAINS, NOT_CONTAINS; \n" + + " * 'NUMERIC' - used for 'Long' and 'Double' values. Operations: EQUAL, NOT_EQUAL, GREATER, LESS, GREATER_OR_EQUAL, LESS_OR_EQUAL; \n" + + " * 'BOOLEAN' - used for boolean values. Operations: EQUAL, NOT_EQUAL;\n" + + " * 'DATE_TIME' - similar to numeric, transforms value to milliseconds since epoch. Operations: EQUAL, NOT_EQUAL, GREATER, LESS, GREATER_OR_EQUAL, LESS_OR_EQUAL; \n"; + + protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"schedule\":{\n" + + " \"type\":\"SPECIFIC_TIME\",\n" + + " \"endsOn\":64800000,\n" + + " \"startsOn\":43200000,\n" + + " \"timezone\":\"Europe/Kiev\",\n" + + " \"daysOfWeek\":[\n" + + " 1,\n" + + " 3,\n" + + " 5\n" + + " ]\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"schedule\":{\n" + + " \"type\":\"CUSTOM\",\n" + + " \"items\":[\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":1\n" + + " },\n" + + " {\n" + + " \"endsOn\":64800000,\n" + + " \"enabled\":true,\n" + + " \"startsOn\":43200000,\n" + + " \"dayOfWeek\":2\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":3\n" + + " },\n" + + " {\n" + + " \"endsOn\":57600000,\n" + + " \"enabled\":true,\n" + + " \"startsOn\":36000000,\n" + + " \"dayOfWeek\":4\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":5\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":6\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":7\n" + + " }\n" + + " ],\n" + + " \"timezone\":\"Europe/Kiev\"\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "\"schedule\": null" + MARKDOWN_CODE_BLOCK_END; + + protected static final String DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"spec\":{\n" + + " \"type\":\"REPEATING\",\n" + + " \"predicate\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":5,\n" + + " \"dynamicValue\":{\n" + + " \"inherit\":true,\n" + + " \"sourceType\":\"CURRENT_DEVICE\",\n" + + " \"sourceAttribute\":\"tempAttr\"\n" + + " }\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + protected static final String DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"spec\":{\n" + + " \"type\":\"DURATION\",\n" + + " \"unit\":\"MINUTES\",\n" + + " \"predicate\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":30,\n" + + " \"dynamicValue\":null\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + + protected static final String RELATION_TYPE_PARAM_DESCRIPTION = "A string value representing relation type between entities. For example, 'Contains', 'Manages'. It can be any string value."; + protected static final String RELATION_TYPE_GROUP_PARAM_DESCRIPTION = "A string value representing relation type group. For example, 'COMMON'"; + + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + protected static final String DEFAULT_DASHBOARD = "defaultDashboardId"; + protected static final String HOME_DASHBOARD = "homeDashboardId"; + + protected static final String SINGLE_ENTITY = "\n\n## Single Entity\n\n" + + "Allows to filter only one entity based on the id. For example, this entity filter selects certain device:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"singleEntity\",\n" + + " \"singleEntity\": {\n" + + " \"id\": \"d521edb0-2a7a-11ec-94eb-213c95f54092\",\n" + + " \"entityType\": \"DEVICE\"\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ENTITY_LIST = "\n\n## Entity List Filter\n\n" + + "Allows to filter entities of the same type using their ids. For example, this entity filter selects two devices:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"entityList\",\n" + + " \"entityType\": \"DEVICE\",\n" + + " \"entityList\": [\n" + + " \"e6501f30-2a7a-11ec-94eb-213c95f54092\",\n" + + " \"e6657bf0-2a7a-11ec-94eb-213c95f54092\"\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ENTITY_NAME = "\n\n## Entity Name Filter\n\n" + + "Allows to filter entities of the same type using the **'starts with'** expression over entity name. " + + "For example, this entity filter selects all devices which name starts with 'Air Quality':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"entityName\",\n" + + " \"entityType\": \"DEVICE\",\n" + + " \"entityNameFilter\": \"Air Quality\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ENTITY_TYPE_FILTER = "\n\n## Entity Type Filter\n\n" + + "Allows to filter entities based on their type (CUSTOMER, USER, DASHBOARD, ASSET, DEVICE, etc)" + + "For example, this entity filter selects all tenant customers:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"entityType\",\n" + + " \"entityType\": \"CUSTOMER\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ASSET_TYPE = "\n\n## Asset Type Filter\n\n" + + "Allows to filter assets based on their type and the **'starts with'** expression over their name. " + + "For example, this entity filter selects all 'charging station' assets which name starts with 'Tesla':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"assetType\",\n" + + " \"assetType\": \"charging station\",\n" + + " \"assetNameFilter\": \"Tesla\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String DEVICE_TYPE = "\n\n## Device Type Filter\n\n" + + "Allows to filter devices based on their type and the **'starts with'** expression over their name. " + + "For example, this entity filter selects all 'Temperature Sensor' devices which name starts with 'ABC':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"deviceType\",\n" + + " \"deviceType\": \"Temperature Sensor\",\n" + + " \"deviceNameFilter\": \"ABC\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String EDGE_TYPE = "\n\n## Edge Type Filter\n\n" + + "Allows to filter edge instances based on their type and the **'starts with'** expression over their name. " + + "For example, this entity filter selects all 'Factory' edge instances which name starts with 'Nevada':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"edgeType\",\n" + + " \"edgeType\": \"Factory\",\n" + + " \"edgeNameFilter\": \"Nevada\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ENTITY_VIEW_TYPE = "\n\n## Entity View Filter\n\n" + + "Allows to filter entity views based on their type and the **'starts with'** expression over their name. " + + "For example, this entity filter selects all 'Concrete Mixer' entity views which name starts with 'CAT':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"entityViewType\",\n" + + " \"entityViewType\": \"Concrete Mixer\",\n" + + " \"entityViewNameFilter\": \"CAT\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String API_USAGE = "\n\n## Api Usage Filter\n\n" + + "Allows to query for Api Usage based on optional customer id. If the customer id is not set, returns current tenant API usage." + + "For example, this entity filter selects the 'Api Usage' entity for customer with id 'e6501f30-2a7a-11ec-94eb-213c95f54092':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"apiUsageState\",\n" + + " \"customerId\": {\n" + + " \"id\": \"d521edb0-2a7a-11ec-94eb-213c95f54092\",\n" + + " \"entityType\": \"CUSTOMER\"\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String MAX_LEVEL_DESCRIPTION = "Possible direction values are 'TO' and 'FROM'. The 'maxLevel' defines how many relation levels should the query search 'recursively'. "; + protected static final String FETCH_LAST_LEVEL_ONLY_DESCRIPTION = "Assuming the 'maxLevel' is > 1, the 'fetchLastLevelOnly' defines either to return all related entities or only entities that are on the last level of relations. "; + + protected static final String RELATIONS_QUERY_FILTER = "\n\n## Relations Query Filter\n\n" + + "Allows to filter entities that are related to the provided root entity. " + + MAX_LEVEL_DESCRIPTION + + FETCH_LAST_LEVEL_ONLY_DESCRIPTION + + "The 'filter' object allows you to define the relation type and set of acceptable entity types to search for. " + + "The relation query calculates all related entities, even if they are filtered using different relation types, and then extracts only those who match the 'filters'.\n\n" + + "For example, this entity filter selects all devices and assets which are related to the asset with id 'e51de0c0-2a7a-11ec-94eb-213c95f54092':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"relationsQuery\",\n" + + " \"rootEntity\": {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"id\": \"e51de0c0-2a7a-11ec-94eb-213c95f54092\"\n" + + " },\n" + + " \"direction\": \"FROM\",\n" + + " \"maxLevel\": 1,\n" + + " \"fetchLastLevelOnly\": false,\n" + + " \"filters\": [\n" + + " {\n" + + " \"relationType\": \"Contains\",\n" + + " \"entityTypes\": [\n" + + " \"DEVICE\",\n" + + " \"ASSET\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + + protected static final String ASSET_QUERY_FILTER = "\n\n## Asset Search Query\n\n" + + "Allows to filter assets that are related to the provided root entity. Filters related assets based on the relation type and set of asset types. " + + MAX_LEVEL_DESCRIPTION + + FETCH_LAST_LEVEL_ONLY_DESCRIPTION + + "The 'relationType' defines the type of the relation to search for. " + + "The 'assetTypes' defines the type of the asset to search for. " + + "The relation query calculates all related entities, even if they are filtered using different relation types, and then extracts only assets that match 'relationType' and 'assetTypes' conditions.\n\n" + + "For example, this entity filter selects 'charging station' assets which are related to the asset with id 'e51de0c0-2a7a-11ec-94eb-213c95f54092' using 'Contains' relation:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"assetSearchQuery\",\n" + + " \"rootEntity\": {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"id\": \"e51de0c0-2a7a-11ec-94eb-213c95f54092\"\n" + + " },\n" + + " \"direction\": \"FROM\",\n" + + " \"maxLevel\": 1,\n" + + " \"fetchLastLevelOnly\": false,\n" + + " \"relationType\": \"Contains\",\n" + + " \"assetTypes\": [\n" + + " \"charging station\"\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String DEVICE_QUERY_FILTER = "\n\n## Device Search Query\n\n" + + "Allows to filter devices that are related to the provided root entity. Filters related devices based on the relation type and set of device types. " + + MAX_LEVEL_DESCRIPTION + + FETCH_LAST_LEVEL_ONLY_DESCRIPTION + + "The 'relationType' defines the type of the relation to search for. " + + "The 'deviceTypes' defines the type of the device to search for. " + + "The relation query calculates all related entities, even if they are filtered using different relation types, and then extracts only devices that match 'relationType' and 'deviceTypes' conditions.\n\n" + + "For example, this entity filter selects 'Charging port' and 'Air Quality Sensor' devices which are related to the asset with id 'e52b0020-2a7a-11ec-94eb-213c95f54092' using 'Contains' relation:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"deviceSearchQuery\",\n" + + " \"rootEntity\": {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"id\": \"e52b0020-2a7a-11ec-94eb-213c95f54092\"\n" + + " },\n" + + " \"direction\": \"FROM\",\n" + + " \"maxLevel\": 2,\n" + + " \"fetchLastLevelOnly\": true,\n" + + " \"relationType\": \"Contains\",\n" + + " \"deviceTypes\": [\n" + + " \"Air Quality Sensor\",\n" + + " \"Charging port\"\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String EV_QUERY_FILTER = "\n\n## Entity View Query\n\n" + + "Allows to filter entity views that are related to the provided root entity. Filters related entity views based on the relation type and set of entity view types. " + + MAX_LEVEL_DESCRIPTION + + FETCH_LAST_LEVEL_ONLY_DESCRIPTION + + "The 'relationType' defines the type of the relation to search for. " + + "The 'entityViewTypes' defines the type of the entity view to search for. " + + "The relation query calculates all related entities, even if they are filtered using different relation types, and then extracts only devices that match 'relationType' and 'deviceTypes' conditions.\n\n" + + "For example, this entity filter selects 'Concrete mixer' entity views which are related to the asset with id 'e52b0020-2a7a-11ec-94eb-213c95f54092' using 'Contains' relation:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"entityViewSearchQuery\",\n" + + " \"rootEntity\": {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"id\": \"e52b0020-2a7a-11ec-94eb-213c95f54092\"\n" + + " },\n" + + " \"direction\": \"FROM\",\n" + + " \"maxLevel\": 1,\n" + + " \"fetchLastLevelOnly\": false,\n" + + " \"relationType\": \"Contains\",\n" + + " \"entityViewTypes\": [\n" + + " \"Concrete mixer\"\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String EDGE_QUERY_FILTER = "\n\n## Edge Search Query\n\n" + + "Allows to filter edge instances that are related to the provided root entity. Filters related edge instances based on the relation type and set of edge types. " + + MAX_LEVEL_DESCRIPTION + + FETCH_LAST_LEVEL_ONLY_DESCRIPTION + + "The 'relationType' defines the type of the relation to search for. " + + "The 'deviceTypes' defines the type of the device to search for. " + + "The relation query calculates all related entities, even if they are filtered using different relation types, and then extracts only devices that match 'relationType' and 'deviceTypes' conditions.\n\n" + + "For example, this entity filter selects 'Factory' edge instances which are related to the asset with id 'e52b0020-2a7a-11ec-94eb-213c95f54092' using 'Contains' relation:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"deviceSearchQuery\",\n" + + " \"rootEntity\": {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"id\": \"e52b0020-2a7a-11ec-94eb-213c95f54092\"\n" + + " },\n" + + " \"direction\": \"FROM\",\n" + + " \"maxLevel\": 2,\n" + + " \"fetchLastLevelOnly\": true,\n" + + " \"relationType\": \"Contains\",\n" + + " \"edgeTypes\": [\n" + + " \"Factory\"\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String EMPTY = "\n\n## Entity Type Filter\n\n" + + "Allows to filter multiple entities of the same type using the **'starts with'** expression over entity name. " + + "For example, this entity filter selects all devices which name starts with 'Air Quality':\n\n" + + MARKDOWN_CODE_BLOCK_START + + "" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String ENTITY_FILTERS = + "\n\n # Entity Filters" + + "\nEntity Filter body depends on the 'type' parameter. Let's review available entity filter types. In fact, they do correspond to available dashboard aliases." + + SINGLE_ENTITY + ENTITY_LIST + ENTITY_NAME + ENTITY_TYPE_FILTER + ASSET_TYPE + DEVICE_TYPE + EDGE_TYPE + ENTITY_VIEW_TYPE + API_USAGE + RELATIONS_QUERY_FILTER + + ASSET_QUERY_FILTER + DEVICE_QUERY_FILTER + EV_QUERY_FILTER + EDGE_QUERY_FILTER; + + protected static final String FILTER_KEY = "\n\n## Filter Key\n\n" + + "Filter Key defines either entity field, attribute or telemetry. It is a JSON object that consists the key name and type. " + + "The following filter key types are supported: \n\n" + + " * 'CLIENT_ATTRIBUTE' - used for client attributes; \n" + + " * 'SHARED_ATTRIBUTE' - used for shared attributes; \n" + + " * 'SERVER_ATTRIBUTE' - used for server attributes; \n" + + " * 'ATTRIBUTE' - used for any of the above; \n" + + " * 'TIME_SERIES' - used for time-series values; \n" + + " * 'ENTITY_FIELD' - used for accessing entity fields like 'name', 'label', etc. The list of available fields depends on the entity type; \n" + + " * 'ALARM_FIELD' - similar to entity field, but is used in alarm queries only; \n" + + "\n\n Let's review the example:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String FILTER_PREDICATE = "\n\n## Filter Predicate\n\n" + + "Filter Predicate defines the logical expression to evaluate. The list of available operations depends on the filter value type, see above. " + + "Platform supports 4 predicate types: 'STRING', 'NUMERIC', 'BOOLEAN' and 'COMPLEX'. The last one allows to combine multiple operations over one filter key." + + "\n\nSimple predicate example to check 'value < 100': \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 100,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\nComplex predicate example, to check 'value < 10 or value > 20': \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"OR\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 10,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 20,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\nMore complex predicate example, to check 'value < 10 or (value > 50 && value < 60)': \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"OR\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 10,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"AND\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 50,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 60,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n You may also want to replace hardcoded values (for example, temperature > 20) with the more dynamic " + + "expression (for example, temperature > 'value of the tenant attribute with key 'temperatureThreshold'). " + + "It is possible to use 'dynamicValue' to define attribute of the tenant, customer or user that is performing the API call. " + + "See example below: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 0,\n" + + " \"dynamicValue\": {\n" + + " \"sourceType\": \"CURRENT_USER\",\n" + + " \"sourceAttribute\": \"temperatureThreshold\"\n" + + " }\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Note that you may use 'CURRENT_USER', 'CURRENT_CUSTOMER' and 'CURRENT_TENANT' as a 'sourceType'. The 'defaultValue' is used when the attribute with such a name is not defined for the chosen source."; + + protected static final String KEY_FILTERS = + "\n\n # Key Filters" + + "\nKey Filter allows you to define complex logical expressions over entity field, attribute or latest time-series value. The filter is defined using 'key', 'valueType' and 'predicate' objects. " + + "Single Entity Query may have zero, one or multiple predicates. If multiple filters are defined, they are evaluated using logical 'AND'. " + + "The example below checks that temperature of the entity is above 20 degrees:" + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"key\": {\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + " },\n" + + " \"valueType\": \"NUMERIC\",\n" + + " \"predicate\": {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 20,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Now let's review 'key', 'valueType' and 'predicate' objects in detail." + + FILTER_KEY + FILTER_VALUE_TYPE + FILTER_PREDICATE; + + protected static final String ENTITY_COUNT_QUERY_DESCRIPTION = + "Allows to run complex queries to search the count of platform entities (devices, assets, customers, etc) " + + "based on the combination of main entity filter and multiple key filters. Returns the number of entities that match the query definition.\n\n" + + "# Query Definition\n\n" + + "\n\nMain **entity filter** is mandatory and defines generic search criteria. " + + "For example, \"find all devices with profile 'Moisture Sensor'\" or \"Find all devices related to asset 'Building A'\"" + + "\n\nOptional **key filters** allow to filter results of the entity filter by complex criteria against " + + "main entity fields (name, label, type, etc), attributes and telemetry. " + + "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and timeseries field 'batteryLevel' > 40\"." + + "\n\nLet's review the example:" + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"entityFilter\": {\n" + + " \"type\": \"entityType\",\n" + + " \"entityType\": \"DEVICE\"\n" + + " },\n" + + " \"keyFilters\": [\n" + + " {\n" + + " \"key\": {\n" + + " \"type\": \"ATTRIBUTE\",\n" + + " \"key\": \"active\"\n" + + " },\n" + + " \"valueType\": \"BOOLEAN\",\n" + + " \"predicate\": {\n" + + " \"operation\": \"EQUAL\",\n" + + " \"value\": {\n" + + " \"defaultValue\": true,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"BOOLEAN\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Example mentioned above search all devices which have attribute 'active' set to 'true'. Now let's review available entity filters and key filters syntax:" + + ENTITY_FILTERS + + KEY_FILTERS + + ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + + protected static final String ENTITY_DATA_QUERY_DESCRIPTION = + "Allows to run complex queries over platform entities (devices, assets, customers, etc) " + + "based on the combination of main entity filter and multiple key filters. " + + "Returns the paginated result of the query that contains requested entity fields and latest values of requested attributes and time-series data.\n\n" + + "# Query Definition\n\n" + + "\n\nMain **entity filter** is mandatory and defines generic search criteria. " + + "For example, \"find all devices with profile 'Moisture Sensor'\" or \"Find all devices related to asset 'Building A'\"" + + "\n\nOptional **key filters** allow to filter results of the **entity filter** by complex criteria against " + + "main entity fields (name, label, type, etc), attributes and telemetry. " + + "For example, \"temperature > 20 or temperature< 10\" or \"name starts with 'T', and attribute 'model' is 'T1000', and timeseries field 'batteryLevel' > 40\"." + + "\n\nThe **entity fields** and **latest values** contains list of entity fields and latest attribute/telemetry fields to fetch for each entity." + + "\n\nThe **page link** contains information about the page to fetch and the sort ordering." + + "\n\nLet's review the example:" + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"entityFilter\": {\n" + + " \"type\": \"entityType\",\n" + + " \"resolveMultiple\": true,\n" + + " \"entityType\": \"DEVICE\"\n" + + " },\n" + + " \"keyFilters\": [\n" + + " {\n" + + " \"key\": {\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + " },\n" + + " \"valueType\": \"NUMERIC\",\n" + + " \"predicate\": {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 0,\n" + + " \"dynamicValue\": {\n" + + " \"sourceType\": \"CURRENT_USER\",\n" + + " \"sourceAttribute\": \"temperatureThreshold\",\n" + + " \"inherit\": false\n" + + " }\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"entityFields\": [\n" + + " {\n" + + " \"type\": \"ENTITY_FIELD\",\n" + + " \"key\": \"name\"\n" + + " },\n" + + " {\n" + + " \"type\": \"ENTITY_FIELD\",\n" + + " \"key\": \"label\"\n" + + " },\n" + + " {\n" + + " \"type\": \"ENTITY_FIELD\",\n" + + " \"key\": \"additionalInfo\"\n" + + " }\n" + + " ],\n" + + " \"latestValues\": [\n" + + " {\n" + + " \"type\": \"ATTRIBUTE\",\n" + + " \"key\": \"model\"\n" + + " },\n" + + " {\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + " }\n" + + " ],\n" + + " \"pageLink\": {\n" + + " \"page\": 0,\n" + + " \"pageSize\": 10,\n" + + " \"sortOrder\": {\n" + + " \"key\": {\n" + + " \"key\": \"name\",\n" + + " \"type\": \"ENTITY_FIELD\"\n" + + " },\n" + + " \"direction\": \"ASC\"\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Example mentioned above search all devices which have attribute 'active' set to 'true'. Now let's review available entity filters and key filters syntax:" + + ENTITY_FILTERS + + KEY_FILTERS + + ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + + + protected static final String ALARM_DATA_QUERY_DESCRIPTION = "This method description defines how Alarm Data Query extends the Entity Data Query. " + + "See method 'Find Entity Data by Query' first to get the info about 'Entity Data Query'." + + "\n\n The platform will first search the entities that match the entity and key filters. Then, the platform will use 'Alarm Page Link' to filter the alarms related to those entities. " + + "Finally, platform fetch the properties of alarm that are defined in the **'alarmFields'** and combine them with the other entity, attribute and latest time-series fields to return the result. " + + "\n\n See example of the alarm query below. The query will search first 100 active alarms with type 'Temperature Alarm' or 'Fire Alarm' for any device with current temperature > 0. " + + "The query will return combination of the entity fields: name of the device, device model and latest temperature reading and alarms fields: createdTime, type, severity and status: " + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"entityFilter\": {\n" + + " \"type\": \"entityType\",\n" + + " \"resolveMultiple\": true,\n" + + " \"entityType\": \"DEVICE\"\n" + + " },\n" + + " \"pageLink\": {\n" + + " \"page\": 0,\n" + + " \"pageSize\": 100,\n" + + " \"textSearch\": null,\n" + + " \"searchPropagatedAlarms\": false,\n" + + " \"statusList\": [\n" + + " \"ACTIVE\"\n" + + " ],\n" + + " \"severityList\": [\n" + + " \"CRITICAL\",\n" + + " \"MAJOR\"\n" + + " ],\n" + + " \"typeList\": [\n" + + " \"Temperature Alarm\",\n" + + " \"Fire Alarm\"\n" + + " ],\n" + + " \"sortOrder\": {\n" + + " \"key\": {\n" + + " \"key\": \"createdTime\",\n" + + " \"type\": \"ALARM_FIELD\"\n" + + " },\n" + + " \"direction\": \"DESC\"\n" + + " },\n" + + " \"timeWindow\": 86400000\n" + + " },\n" + + " \"keyFilters\": [\n" + + " {\n" + + " \"key\": {\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + " },\n" + + " \"valueType\": \"NUMERIC\",\n" + + " \"predicate\": {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"defaultValue\": 0,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"alarmFields\": [\n" + + " {\n" + + " \"type\": \"ALARM_FIELD\",\n" + + " \"key\": \"createdTime\"\n" + + " },\n" + + " {\n" + + " \"type\": \"ALARM_FIELD\",\n" + + " \"key\": \"type\"\n" + + " },\n" + + " {\n" + + " \"type\": \"ALARM_FIELD\",\n" + + " \"key\": \"severity\"\n" + + " },\n" + + " {\n" + + " \"type\": \"ALARM_FIELD\",\n" + + " \"key\": \"status\"\n" + + " }\n" + + " ],\n" + + " \"entityFields\": [\n" + + " {\n" + + " \"type\": \"ENTITY_FIELD\",\n" + + " \"key\": \"name\"\n" + + " }\n" + + " ],\n" + + " \"latestValues\": [\n" + + " {\n" + + " \"type\": \"ATTRIBUTE\",\n" + + " \"key\": \"model\"\n" + + " },\n" + + " {\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + ""; + + protected static final String COAP_TRANSPORT_CONFIGURATION_EXAMPLE = MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\":\"COAP\",\n" + + " \"clientSettings\":{\n" + + " \"edrxCycle\":null,\n" + + " \"powerMode\":\"DRX\",\n" + + " \"psmActivityTimer\":null,\n" + + " \"pagingTransmissionWindow\":null\n" + + " },\n" + + " \"coapDeviceTypeConfiguration\":{\n" + + " \"coapDeviceType\":\"DEFAULT\",\n" + + " \"transportPayloadTypeConfiguration\":{\n" + + " \"transportPayloadType\":\"JSON\"\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + + protected static final String TRANSPORT_CONFIGURATION = "# Transport Configuration" + NEW_LINE + + "5 transport configuration types are available:\n" + + " * 'DEFAULT';\n" + + " * 'MQTT';\n" + + " * 'LWM2M';\n" + + " * 'COAP';\n" + + " * 'SNMP'." + NEW_LINE + "Default type supports basic MQTT, HTTP, CoAP and LwM2M transports. " + + "Please refer to the [docs](https://thingsboard.io/docs/user-guide/device-profiles/#transport-configuration) for more details about other types.\n" + + "\nSee another example of COAP transport configuration below:" + NEW_LINE + COAP_TRANSPORT_CONFIGURATION_EXAMPLE; + + protected static final String ALARM_FILTER_KEY = "## Alarm Filter Key" + NEW_LINE + + "Filter Key defines either entity field, attribute, telemetry or constant. It is a JSON object that consists the key name and type. The following filter key types are supported:\n" + + " * 'ATTRIBUTE' - used for attributes values;\n" + + " * 'TIME_SERIES' - used for time-series values;\n" + + " * 'ENTITY_FIELD' - used for accessing entity fields like 'name', 'label', etc. The list of available fields depends on the entity type;\n" + + " * 'CONSTANT' - constant value specified." + NEW_LINE + "Let's review the example:" + NEW_LINE + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"TIME_SERIES\",\n" + + " \"key\": \"temperature\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END; + + protected static final String DEVICE_PROFILE_FILTER_PREDICATE = NEW_LINE + "## Filter Predicate" + NEW_LINE + + "Filter Predicate defines the logical expression to evaluate. The list of available operations depends on the filter value type, see above. " + + "Platform supports 4 predicate types: 'STRING', 'NUMERIC', 'BOOLEAN' and 'COMPLEX'. The last one allows to combine multiple operations over one filter key." + NEW_LINE + + "Simple predicate example to check 'value < 100': " + NEW_LINE + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 100,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "Complex predicate example, to check 'value < 10 or value > 20': " + NEW_LINE + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"OR\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 10,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 20,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "More complex predicate example, to check 'value < 10 or (value > 50 && value < 60)': " + NEW_LINE + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"OR\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 10,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"type\": \"COMPLEX\",\n" + + " \"operation\": \"AND\",\n" + + " \"predicates\": [\n" + + " {\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 50,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " },\n" + + " {\n" + + " \"operation\": \"LESS\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 60,\n" + + " \"dynamicValue\": null\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "You may also want to replace hardcoded values (for example, temperature > 20) with the more dynamic " + + "expression (for example, temperature > value of the tenant attribute with key 'temperatureThreshold'). " + + "It is possible to use 'dynamicValue' to define attribute of the tenant, customer or device. " + + "See example below:" + NEW_LINE + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"operation\": \"GREATER\",\n" + + " \"value\": {\n" + + " \"userValue\": null,\n" + + " \"defaultValue\": 0,\n" + + " \"dynamicValue\": {\n" + + " \"inherit\": false,\n" + + " \"sourceType\": \"CURRENT_TENANT\",\n" + + " \"sourceAttribute\": \"temperatureThreshold\"\n" + + " }\n" + + " },\n" + + " \"type\": \"NUMERIC\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "Note that you may use 'CURRENT_DEVICE', 'CURRENT_CUSTOMER' and 'CURRENT_TENANT' as a 'sourceType'. The 'defaultValue' is used when the attribute with such a name is not defined for the chosen source. " + + "The 'sourceAttribute' can be inherited from the owner of the specified 'sourceType' if 'inherit' is set to true."; + + protected static final String KEY_FILTERS_DESCRIPTION = "# Key Filters" + NEW_LINE + + "Key filter objects are created under the **'condition'** array. They allow you to define complex logical expressions over entity field, " + + "attribute, latest time-series value or constant. The filter is defined using 'key', 'valueType', " + + "'value' (refers to the value of the 'CONSTANT' alarm filter key type) and 'predicate' objects. Let's review each object:" + NEW_LINE + + ALARM_FILTER_KEY + FILTER_VALUE_TYPE + NEW_LINE + DEVICE_PROFILE_FILTER_PREDICATE + NEW_LINE; + + protected static final String DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + + " \"alarms\":[\n" + + " ],\n" + + " \"configuration\":{\n" + + " \"type\":\"DEFAULT\"\n" + + " },\n" + + " \"provisionConfiguration\":{\n" + + " \"type\":\"DISABLED\",\n" + + " \"provisionDeviceSecret\":null\n" + + " },\n" + + " \"transportConfiguration\":{\n" + + " \"type\":\"DEFAULT\"\n" + + " }\n" + + "}" + MARKDOWN_CODE_BLOCK_END; + + protected static final String CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + + " \"alarms\":[\n" + + " {\n" + + " \"id\":\"2492b935-1226-59e9-8615-17d8978a4f93\",\n" + + " \"alarmType\":\"Temperature Alarm\",\n" + + " \"clearRule\":{\n" + + " \"schedule\":null,\n" + + " \"condition\":{\n" + + " \"spec\":{\n" + + " \"type\":\"SIMPLE\"\n" + + " },\n" + + " \"condition\":[\n" + + " {\n" + + " \"key\":{\n" + + " \"key\":\"temperature\",\n" + + " \"type\":\"TIME_SERIES\"\n" + + " },\n" + + " \"value\":null,\n" + + " \"predicate\":{\n" + + " \"type\":\"NUMERIC\",\n" + + " \"value\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":30.0,\n" + + " \"dynamicValue\":null\n" + + " },\n" + + " \"operation\":\"LESS\"\n" + + " },\n" + + " \"valueType\":\"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"dashboardId\":null,\n" + + " \"alarmDetails\":null\n" + + " },\n" + + " \"propagate\":false,\n" + + " \"createRules\":{\n" + + " \"MAJOR\":{\n" + + " \"schedule\":{\n" + + " \"type\":\"SPECIFIC_TIME\",\n" + + " \"endsOn\":64800000,\n" + + " \"startsOn\":43200000,\n" + + " \"timezone\":\"Europe/Kiev\",\n" + + " \"daysOfWeek\":[\n" + + " 1,\n" + + " 3,\n" + + " 5\n" + + " ]\n" + + " },\n" + + " \"condition\":{\n" + + " \"spec\":{\n" + + " \"type\":\"DURATION\",\n" + + " \"unit\":\"MINUTES\",\n" + + " \"predicate\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":30,\n" + + " \"dynamicValue\":null\n" + + " }\n" + + " },\n" + + " \"condition\":[\n" + + " {\n" + + " \"key\":{\n" + + " \"key\":\"temperature\",\n" + + " \"type\":\"TIME_SERIES\"\n" + + " },\n" + + " \"value\":null,\n" + + " \"predicate\":{\n" + + " \"type\":\"COMPLEX\",\n" + + " \"operation\":\"OR\",\n" + + " \"predicates\":[\n" + + " {\n" + + " \"type\":\"NUMERIC\",\n" + + " \"value\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":50.0,\n" + + " \"dynamicValue\":null\n" + + " },\n" + + " \"operation\":\"LESS_OR_EQUAL\"\n" + + " },\n" + + " {\n" + + " \"type\":\"NUMERIC\",\n" + + " \"value\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":30.0,\n" + + " \"dynamicValue\":null\n" + + " },\n" + + " \"operation\":\"GREATER\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"valueType\":\"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"dashboardId\":null,\n" + + " \"alarmDetails\":null\n" + + " },\n" + + " \"WARNING\":{\n" + + " \"schedule\":{\n" + + " \"type\":\"CUSTOM\",\n" + + " \"items\":[\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":1\n" + + " },\n" + + " {\n" + + " \"endsOn\":64800000,\n" + + " \"enabled\":true,\n" + + " \"startsOn\":43200000,\n" + + " \"dayOfWeek\":2\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":3\n" + + " },\n" + + " {\n" + + " \"endsOn\":57600000,\n" + + " \"enabled\":true,\n" + + " \"startsOn\":36000000,\n" + + " \"dayOfWeek\":4\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":5\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":6\n" + + " },\n" + + " {\n" + + " \"endsOn\":0,\n" + + " \"enabled\":false,\n" + + " \"startsOn\":0,\n" + + " \"dayOfWeek\":7\n" + + " }\n" + + " ],\n" + + " \"timezone\":\"Europe/Kiev\"\n" + + " },\n" + + " \"condition\":{\n" + + " \"spec\":{\n" + + " \"type\":\"REPEATING\",\n" + + " \"predicate\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":5,\n" + + " \"dynamicValue\":null\n" + + " }\n" + + " },\n" + + " \"condition\":[\n" + + " {\n" + + " \"key\":{\n" + + " \"key\":\"tempConstant\",\n" + + " \"type\":\"CONSTANT\"\n" + + " },\n" + + " \"value\":30,\n" + + " \"predicate\":{\n" + + " \"type\":\"NUMERIC\",\n" + + " \"value\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":0.0,\n" + + " \"dynamicValue\":{\n" + + " \"inherit\":false,\n" + + " \"sourceType\":\"CURRENT_DEVICE\",\n" + + " \"sourceAttribute\":\"tempThreshold\"\n" + + " }\n" + + " },\n" + + " \"operation\":\"EQUAL\"\n" + + " },\n" + + " \"valueType\":\"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"dashboardId\":null,\n" + + " \"alarmDetails\":null\n" + + " },\n" + + " \"CRITICAL\":{\n" + + " \"schedule\":null,\n" + + " \"condition\":{\n" + + " \"spec\":{\n" + + " \"type\":\"SIMPLE\"\n" + + " },\n" + + " \"condition\":[\n" + + " {\n" + + " \"key\":{\n" + + " \"key\":\"temperature\",\n" + + " \"type\":\"TIME_SERIES\"\n" + + " },\n" + + " \"value\":null,\n" + + " \"predicate\":{\n" + + " \"type\":\"NUMERIC\",\n" + + " \"value\":{\n" + + " \"userValue\":null,\n" + + " \"defaultValue\":50.0,\n" + + " \"dynamicValue\":null\n" + + " },\n" + + " \"operation\":\"GREATER\"\n" + + " },\n" + + " \"valueType\":\"NUMERIC\"\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"dashboardId\":null,\n" + + " \"alarmDetails\":null\n" + + " }\n" + + " },\n" + + " \"propagateRelationTypes\":null\n" + + " }\n" + + " ],\n" + + " \"configuration\":{\n" + + " \"type\":\"DEFAULT\"\n" + + " },\n" + + " \"provisionConfiguration\":{\n" + + " \"type\":\"ALLOW_CREATE_NEW_DEVICES\",\n" + + " \"provisionDeviceSecret\":\"vaxb9hzqdbz3oqukvomg\"\n" + + " },\n" + + " \"transportConfiguration\":{\n" + + " \"type\":\"MQTT\",\n" + + " \"deviceTelemetryTopic\":\"v1/devices/me/telemetry\",\n" + + " \"deviceAttributesTopic\":\"v1/devices/me/attributes\",\n" + + " \"transportPayloadTypeConfiguration\":{\n" + + " \"transportPayloadType\":\"PROTOBUF\",\n" + + " \"deviceTelemetryProtoSchema\":\"syntax =\\\"proto3\\\";\\npackage telemetry;\\n\\nmessage SensorDataReading {\\n\\n optional double temperature = 1;\\n optional double humidity = 2;\\n InnerObject innerObject = 3;\\n\\n message InnerObject {\\n optional string key1 = 1;\\n optional bool key2 = 2;\\n optional double key3 = 3;\\n optional int32 key4 = 4;\\n optional string key5 = 5;\\n }\\n}\",\n" + + " \"deviceAttributesProtoSchema\":\"syntax =\\\"proto3\\\";\\npackage attributes;\\n\\nmessage SensorConfiguration {\\n optional string firmwareVersion = 1;\\n optional string serialNumber = 2;\\n}\",\n" + + " \"deviceRpcRequestProtoSchema\":\"syntax =\\\"proto3\\\";\\npackage rpc;\\n\\nmessage RpcRequestMsg {\\n optional string method = 1;\\n optional int32 requestId = 2;\\n optional string params = 3;\\n}\",\n" + + " \"deviceRpcResponseProtoSchema\":\"syntax =\\\"proto3\\\";\\npackage rpc;\\n\\nmessage RpcResponseMsg {\\n optional string payload = 1;\\n}\"\n" + + " }\n" + + " }\n" + + "}" + MARKDOWN_CODE_BLOCK_END; + protected static final String DEVICE_PROFILE_DATA_DEFINITION = NEW_LINE + "# Device profile data definition" + NEW_LINE + + "Device profile data object contains alarm rules configuration, device provision strategy and transport type configuration for device connectivity. Let's review some examples. " + + "First one is the default device profile data configuration and second one - the custom one. " + + NEW_LINE + DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE + + NEW_LINE + "Let's review some specific objects examples related to the device profile configuration:"; + + protected static final String ALARM_SCHEDULE = NEW_LINE + "# Alarm Schedule" + NEW_LINE + + "Alarm Schedule JSON object represents the time interval during which the alarm rule is active. Note, " + + NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE + NEW_LINE + "means alarm rule is active all the time. " + + "**'daysOfWeek'** field represents Monday as 1, Tuesday as 2 and so on. **'startsOn'** and **'endsOn'** fields represent hours in millis (e.g. 64800000 = 18:00 or 6pm). " + + "**'enabled'** flag specifies if item in a custom rule is active for specific day of the week:" + NEW_LINE + + "## Specific Time Schedule" + NEW_LINE + + DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE + NEW_LINE + + "## Custom Schedule" + + NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE + NEW_LINE; + + protected static final String ALARM_CONDITION_TYPE = "# Alarm condition type (**'spec'**)" + NEW_LINE + + "Alarm condition type can be either simple, duration, or repeating. For example, 5 times in a row or during 5 minutes." + NEW_LINE + + "Note, **'userValue'** field is not used and reserved for future usage, **'dynamicValue'** is used for condition appliance by using the value of the **'sourceAttribute'** " + + "or else **'defaultValue'** is used (if **'sourceAttribute'** is absent).\n" + + "\n**'sourceType'** of the **'sourceAttribute'** can be: \n" + + " * 'CURRENT_DEVICE';\n" + + " * 'CURRENT_CUSTOMER';\n" + + " * 'CURRENT_TENANT'." + NEW_LINE + + "**'sourceAttribute'** can be inherited from the owner if **'inherit'** is set to true (for CURRENT_DEVICE and CURRENT_CUSTOMER)." + NEW_LINE + + "## Repeating alarm condition" + NEW_LINE + + DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE + NEW_LINE + + "## Duration alarm condition" + NEW_LINE + + DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE + NEW_LINE + + "**'unit'** can be: \n" + + " * 'SECONDS';\n" + + " * 'MINUTES';\n" + + " * 'HOURS';\n" + + " * 'DAYS'." + NEW_LINE; + + protected static final String PROVISION_CONFIGURATION = "# Provision Configuration" + NEW_LINE + + "There are 3 types of device provision configuration for the device profile: \n" + + " * 'DISABLED';\n" + + " * 'ALLOW_CREATE_NEW_DEVICES';\n" + + " * 'CHECK_PRE_PROVISIONED_DEVICES'." + NEW_LINE + + "Please refer to the [docs](https://thingsboard.io/docs/user-guide/device-provisioning/) for more details." + NEW_LINE; + + protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + ALARM_SCHEDULE + ALARM_CONDITION_TYPE + + KEY_FILTERS_DESCRIPTION + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION; + + protected static final String DEVICE_PROFILE_ID = "deviceProfileId"; + + protected static final String ASSET_PROFILE_ID = "assetProfileId"; + + protected static final String MODEL_DESCRIPTION = "See the 'Model' tab for more details."; + protected static final String ENTITY_VIEW_DESCRIPTION = "Entity Views limit the degree of exposure of the Device or Asset telemetry and attributes to the Customers. " + + "Every Entity View references exactly one entity (device or asset) and defines telemetry and attribute keys that will be visible to the assigned Customer. " + + "As a Tenant Administrator you are able to create multiple EVs per Device or Asset and assign them to different Customers. "; + protected static final String ENTITY_VIEW_INFO_DESCRIPTION = "Entity Views Info extends the Entity View with customer title and 'is public' flag. " + ENTITY_VIEW_DESCRIPTION; + + protected static final String ATTRIBUTES_SCOPE_DESCRIPTION = "A string value representing the attributes scope. For example, 'SERVER_SCOPE'."; + protected static final String ATTRIBUTES_KEYS_DESCRIPTION = "A string value representing the comma-separated list of attributes keys. For example, 'active,inactivityAlarmTime'."; + protected static final String ATTRIBUTES_SCOPE_ALLOWED_VALUES = "SERVER_SCOPE, CLIENT_SCOPE, SHARED_SCOPE"; + protected static final String ATTRIBUTES_JSON_REQUEST_DESCRIPTION = "A string value representing the json object. For example, '{\"key\":\"value\"}'. See API call description for more details."; + + protected static final String TELEMETRY_KEYS_BASE_DESCRIPTION = "A string value representing the comma-separated list of telemetry keys."; + protected static final String TELEMETRY_KEYS_DESCRIPTION = TELEMETRY_KEYS_BASE_DESCRIPTION + " If keys are not selected, the result will return all latest timeseries. For example, 'temperature,humidity'."; + protected static final String TELEMETRY_SCOPE_DESCRIPTION = "Value is deprecated, reserved for backward compatibility and not used in the API call implementation. Specify any scope for compatibility"; + protected static final String TELEMETRY_JSON_REQUEST_DESCRIPTION = "A JSON with the telemetry values. See API call description for more details."; + + + protected static final String STRICT_DATA_TYPES_DESCRIPTION = "Enables/disables conversion of telemetry values to strings. Conversion is enabled by default. Set parameter to 'true' in order to disable the conversion."; + protected static final String INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION = "Referencing a non-existing entity Id or invalid entity type will cause an error. "; + + protected static final String SAVE_ATTIRIBUTES_STATUS_OK = "Attribute from the request was created or updated. "; + protected static final String INVALID_STRUCTURE_OF_THE_REQUEST = "Invalid structure of the request"; + protected static final String SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST = INVALID_STRUCTURE_OF_THE_REQUEST + " or invalid attributes scope provided."; + protected static final String SAVE_ENTITY_ATTRIBUTES_STATUS_OK = "Platform creates an audit log event about entity attributes updates with action type 'ATTRIBUTES_UPDATED', " + + "and also sends event msg to the rule engine with msg type 'ATTRIBUTES_UPDATED'."; + protected static final String SAVE_ENTITY_ATTRIBUTES_STATUS_UNAUTHORIZED = "User is not authorized to save entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."; + protected static final String SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about entity attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."; + protected static final String SAVE_ENTITY_TIMESERIES_STATUS_OK = "Timeseries from the request was created or updated. " + + "Platform creates an audit log event about entity timeseries updates with action type 'TIMESERIES_UPDATED'."; + protected static final String SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED = "User is not authorized to save entity timeseries for selected entity. Most likely, User belongs to different Customer or Tenant."; + protected static final String SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about entity timeseries updates with action type 'TIMESERIES_UPDATED' that includes an error stacktrace."; + + protected static final String ENTITY_ATTRIBUTE_SCOPES = " List of possible attribute scopes depends on the entity type: " + + "\n\n * SERVER_SCOPE - supported for all entity types;" + + "\n * CLIENT_SCOPE - supported for devices;" + + "\n * SHARED_SCOPE - supported for devices. "+ "\n\n"; + + protected static final String ATTRIBUTE_DATA_EXAMPLE = "[\n" + + " {\"key\": \"stringAttributeKey\", \"value\": \"value\", \"lastUpdateTs\": 1609459200000},\n" + + " {\"key\": \"booleanAttributeKey\", \"value\": false, \"lastUpdateTs\": 1609459200001},\n" + + " {\"key\": \"doubleAttributeKey\", \"value\": 42.2, \"lastUpdateTs\": 1609459200002},\n" + + " {\"key\": \"longKeyExample\", \"value\": 73, \"lastUpdateTs\": 1609459200003},\n" + + " {\"key\": \"jsonKeyExample\",\n" + + " \"value\": {\n" + + " \"someNumber\": 42,\n" + + " \"someArray\": [1,2,3],\n" + + " \"someNestedObject\": {\"key\": \"value\"}\n" + + " },\n" + + " \"lastUpdateTs\": 1609459200004\n" + + " }\n" + + "]"; + + protected static final String LATEST_TS_STRICT_DATA_EXAMPLE = "{\n" + + " \"stringTsKey\": [{ \"value\": \"value\", \"ts\": 1609459200000}],\n" + + " \"booleanTsKey\": [{ \"value\": false, \"ts\": 1609459200000}],\n" + + " \"doubleTsKey\": [{ \"value\": 42.2, \"ts\": 1609459200000}],\n" + + " \"longTsKey\": [{ \"value\": 73, \"ts\": 1609459200000}],\n" + + " \"jsonTsKey\": [{ \n" + + " \"value\": {\n" + + " \"someNumber\": 42,\n" + + " \"someArray\": [1,2,3],\n" + + " \"someNestedObject\": {\"key\": \"value\"}\n" + + " }, \n" + + " \"ts\": 1609459200000}]\n" + + "}\n"; + + protected static final String LATEST_TS_NON_STRICT_DATA_EXAMPLE = "{\n" + + " \"stringTsKey\": [{ \"value\": \"value\", \"ts\": 1609459200000}],\n" + + " \"booleanTsKey\": [{ \"value\": \"false\", \"ts\": 1609459200000}],\n" + + " \"doubleTsKey\": [{ \"value\": \"42.2\", \"ts\": 1609459200000}],\n" + + " \"longTsKey\": [{ \"value\": \"73\", \"ts\": 1609459200000}],\n" + + " \"jsonTsKey\": [{ \"value\": \"{\\\"someNumber\\\": 42,\\\"someArray\\\": [1,2,3],\\\"someNestedObject\\\": {\\\"key\\\": \\\"value\\\"}}\", \"ts\": 1609459200000}]\n" + + "}\n"; + + protected static final String TS_STRICT_DATA_EXAMPLE = "{\n" + + " \"temperature\": [\n" + + " {\n" + + " \"value\": 36.7,\n" + + " \"ts\": 1609459200000\n" + + " },\n" + + " {\n" + + " \"value\": 36.6,\n" + + " \"ts\": 1609459201000\n" + + " }\n" + + " ]\n" + + "}"; + + protected static final String SAVE_ATTRIBUTES_REQUEST_PAYLOAD = "The request payload is a JSON object with key-value format of attributes to create or update. " + + "For example:\n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"stringKey\":\"value1\", \n" + + " \"booleanKey\":true, \n" + + " \"doubleKey\":42.0, \n" + + " \"longKey\":73, \n" + + " \"jsonKey\": {\n" + + " \"someNumber\": 42,\n" + + " \"someArray\": [1,2,3],\n" + + " \"someNestedObject\": {\"key\": \"value\"}\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + "\n"; + + protected static final String SAVE_TIMESERIES_REQUEST_PAYLOAD = "The request payload is a JSON document with three possible formats:\n\n" + + "Simple format without timestamp. In such a case, current server time will be used: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\"temperature\": 26}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Single JSON object with timestamp: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\"ts\":1634712287000,\"values\":{\"temperature\":26, \"humidity\":87}}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n JSON array with timestamps: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "[{\"ts\":1634712287000,\"values\":{\"temperature\":26, \"humidity\":87}}, {\"ts\":1634712588000,\"values\":{\"temperature\":25, \"humidity\":88}}]" + + MARKDOWN_CODE_BLOCK_END ; +} diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java new file mode 100644 index 0000000..591eaa1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -0,0 +1,209 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +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.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.customer.TbCustomerService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api") +public class CustomerController extends BaseController { + + private final TbCustomerService tbCustomerService; + + public static final String IS_PUBLIC = "isPublic"; + public static final String CUSTOMER_SECURITY_CHECK = "If the user has the authority of 'Tenant Administrator', the server checks that the customer is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the user belongs to the customer."; + + @ApiOperation(value = "Get Customer (getCustomerById)", + notes = "Get the Customer object based on the provided Customer Id. " + + CUSTOMER_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}", method = RequestMethod.GET) + @ResponseBody + public Customer getCustomerById( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + if (!customer.getAdditionalInfo().isNull()) { + processDashboardIdFromAdditionalInfo((ObjectNode) customer.getAdditionalInfo(), HOME_DASHBOARD); + } + return customer; + } catch (Exception e) { + throw handleException(e); + } + } + + + @ApiOperation(value = "Get short Customer info (getShortCustomerInfoById)", + notes = "Get the short customer object that contains only the title and 'isPublic' flag. " + + CUSTOMER_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/shortInfo", method = RequestMethod.GET) + @ResponseBody + public JsonNode getShortCustomerInfoById( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + ObjectMapper objectMapper = new ObjectMapper(); + ObjectNode infoObject = objectMapper.createObjectNode(); + infoObject.put("title", customer.getTitle()); + infoObject.put(IS_PUBLIC, customer.isPublic()); + return infoObject; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Title (getCustomerTitleById)", + notes = "Get the title of the customer. " + + CUSTOMER_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/title", method = RequestMethod.GET, produces = "application/text") + @ResponseBody + public String getCustomerTitleById( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + return customer.getTitle(); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create or update Customer (saveCustomer)", + notes = "Creates or Updates the Customer. When creating customer, platform generates Customer Id as " + UUID_WIKI_LINK + + "The newly created Customer Id will be present in the response. " + + "Specify existing Customer Id to update the Customer. " + + "Referencing non-existing Customer Id will cause 'Not Found' error." + + "Remove 'id', 'tenantId' from the request body example (below) to create new Customer entity. " + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer", method = RequestMethod.POST) + @ResponseBody + public Customer saveCustomer(@ApiParam(value = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception { + customer.setTenantId(getTenantId()); + checkEntity(customer.getId(), customer, Resource.CUSTOMER); + return tbCustomerService.save(customer, getCurrentUser()); + } + + @ApiOperation(value = "Delete Customer (deleteCustomer)", + notes = "Deletes the Customer and all customer Users. " + + "All assigned Dashboards, Assets, Devices, etc. will be unassigned but not deleted. " + + "Referencing non-existing Customer Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteCustomer(@ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.DELETE); + tbCustomerService.delete(customer, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Customers (getCustomers)", + notes = "Returns a page of customers owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customers", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomers( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = CUSTOMER_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = CUSTOMER_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(customerService.findCustomersByTenantId(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Customer by Customer title (getTenantCustomer)", + notes = "Get the Customer using Customer Title. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/customers", params = {"customerTitle"}, method = RequestMethod.GET) + @ResponseBody + public Customer getTenantCustomer( + @ApiParam(value = "A string value representing the Customer title.") + @RequestParam String customerTitle) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(customerService.findCustomerByTenantIdAndTitle(tenantId, customerTitle), "Customer with title [" + customerTitle + "] is not found"); + } catch (Exception e) { + throw handleException(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 new file mode 100644 index 0000000..9ceeb0d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -0,0 +1,717 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.Example; +import io.swagger.annotations.ExampleProperty; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.HomeDashboard; +import org.thingsboard.server.common.data.HomeDashboardInfo; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DASHBOARD_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.DASHBOARD_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api") +public class DashboardController extends BaseController { + + private final TbDashboardService tbDashboardService; + public static final String DASHBOARD_ID = "dashboardId"; + + private static final String HOME_DASHBOARD_ID = "homeDashboardId"; + private static final String HOME_DASHBOARD_HIDE_TOOLBAR = "homeDashboardHideToolbar"; + public static final String DASHBOARD_INFO_DEFINITION = "The Dashboard Info object contains lightweight information about the dashboard (e.g. title, image, assigned customers) but does not contain the heavyweight configuration JSON."; + public static final String DASHBOARD_DEFINITION = "The Dashboard object is a heavyweight object that contains information about the dashboard (e.g. title, image, assigned customers) and also configuration JSON (e.g. layouts, widgets, entity aliases)."; + public static final String HIDDEN_FOR_MOBILE = "Exclude dashboards that are hidden for mobile"; + + @Value("${ui.dashboard.max_datapoints_limit}") + private long maxDatapointsLimit; + + @ApiOperation(value = "Get server time (getServerTime)", + notes = "Get the server time (milliseconds since January 1, 1970 UTC). " + + "Used to adjust view of the dashboards according to the difference between browser and server time.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET) + @ResponseBody + @ApiResponse(code = 200, message = "OK", examples = @Example(value = @ExampleProperty(value = "1636023857137", mediaType = "application/json"))) + public long getServerTime() throws ThingsboardException { + return System.currentTimeMillis(); + } + + @ApiOperation(value = "Get max data points limit (getMaxDatapointsLimit)", + notes = "Get the maximum number of data points that dashboard may request from the server per in a single subscription command. " + + "This value impacts the time window behavior. It impacts 'Max values' parameter in case user selects 'None' as 'Data aggregation function'. " + + "It also impacts the 'Grouping interval' in case of any other 'Data aggregation function' is selected. " + + "The actual value of the limit is configurable in the system configuration file.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/maxDatapointsLimit", method = RequestMethod.GET) + @ResponseBody + @ApiResponse(code = 200, message = "OK", examples = @Example(value = @ExampleProperty(value = "5000", mediaType = "application/json"))) + public long getMaxDatapointsLimit() throws ThingsboardException { + return maxDatapointsLimit; + } + + @ApiOperation(value = "Get Dashboard Info (getDashboardInfoById)", + notes = "Get the information about the dashboard based on 'dashboardId' parameter. " + DASHBOARD_INFO_DEFINITION, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/info/{dashboardId}", method = RequestMethod.GET) + @ResponseBody + public DashboardInfo getDashboardInfoById( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + try { + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + return checkDashboardInfoId(dashboardId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Dashboard (getDashboardById)", + notes = "Get the dashboard based on 'dashboardId' parameter. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/{dashboardId}", method = RequestMethod.GET) + @ResponseBody + public Dashboard getDashboardById( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + try { + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + return checkDashboardId(dashboardId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Dashboard (saveDashboard)", + notes = "Create or update the Dashboard. When creating dashboard, platform generates Dashboard Id as " + UUID_WIKI_LINK + + "The newly created Dashboard id will be present in the response. " + + "Specify existing Dashboard id to update the dashboard. " + + "Referencing non-existing dashboard Id will cause 'Not Found' error. " + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Dashboard entity. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/dashboard", method = RequestMethod.POST) + @ResponseBody + public Dashboard saveDashboard( + @ApiParam(value = "A JSON value representing the dashboard.") + @RequestBody Dashboard dashboard) throws Exception { + dashboard.setTenantId(getTenantId()); + checkEntity(dashboard.getId(), dashboard, Resource.DASHBOARD); + return tbDashboardService.save(dashboard, getCurrentUser()); + } + + @ApiOperation(value = "Delete the Dashboard (deleteDashboard)", + notes = "Delete the Dashboard." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/dashboard/{dashboardId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteDashboard( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.DELETE); + tbDashboardService.delete(dashboard, getCurrentUser()); + } + + @ApiOperation(value = "Assign the Dashboard (assignDashboardToCustomer)", + notes = "Assign the Dashboard to specified Customer or do nothing if the Dashboard is already assigned to that Customer. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/dashboard/{dashboardId}", method = RequestMethod.POST) + @ResponseBody + public Dashboard assignDashboardToCustomer( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + checkParameter(DASHBOARD_ID, strDashboardId); + + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.ASSIGN_TO_CUSTOMER); + return tbDashboardService.assignDashboardToCustomer(dashboard, customer, getCurrentUser()); + } + + @ApiOperation(value = "Unassign the Dashboard (unassignDashboardFromCustomer)", + notes = "Unassign the Dashboard from specified Customer or do nothing if the Dashboard is already assigned to that Customer. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/dashboard/{dashboardId}", method = RequestMethod.DELETE) + @ResponseBody + public Dashboard unassignDashboardFromCustomer( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + checkParameter(DASHBOARD_ID, strDashboardId); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.UNASSIGN_FROM_CUSTOMER); + return tbDashboardService.unassignDashboardFromCustomer(dashboard, customer, getCurrentUser()); + } + + @ApiOperation(value = "Update the Dashboard Customers (updateDashboardCustomers)", + notes = "Updates the list of Customers that this Dashboard is assigned to. Removes previous assignments to customers that are not in the provided list. " + + "Returns the Dashboard object. " + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/dashboard/{dashboardId}/customers", method = RequestMethod.POST) + @ResponseBody + public Dashboard updateDashboardCustomers( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId, + @ApiParam(value = "JSON array with the list of customer ids, or empty to remove all customers") + @RequestBody(required = false) String[] strCustomerIds) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.ASSIGN_TO_CUSTOMER); + Set customerIds = customerIdFromStr(strCustomerIds); + return tbDashboardService.updateDashboardCustomers(dashboard, customerIds, getCurrentUser()); + } + + @ApiOperation(value = "Adds the Dashboard Customers (addDashboardCustomers)", + notes = "Adds the list of Customers to the existing list of assignments for the Dashboard. Keeps previous assignments to customers that are not in the provided list. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/dashboard/{dashboardId}/customers/add", method = RequestMethod.POST) + @ResponseBody + public Dashboard addDashboardCustomers( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId, + @ApiParam(value = "JSON array with the list of customer ids") + @RequestBody String[] strCustomerIds) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.ASSIGN_TO_CUSTOMER); + Set customerIds = customerIdFromStr(strCustomerIds); + return tbDashboardService.addDashboardCustomers(dashboard, customerIds, getCurrentUser()); + } + + @ApiOperation(value = "Remove the Dashboard Customers (removeDashboardCustomers)", + notes = "Removes the list of Customers from the existing list of assignments for the Dashboard. Keeps other assignments to customers that are not in the provided list. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/dashboard/{dashboardId}/customers/remove", method = RequestMethod.POST) + @ResponseBody + public Dashboard removeDashboardCustomers( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId, + @ApiParam(value = "JSON array with the list of customer ids") + @RequestBody String[] strCustomerIds) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.UNASSIGN_FROM_CUSTOMER); + Set customerIds = customerIdFromStr(strCustomerIds); + return tbDashboardService.removeDashboardCustomers(dashboard, customerIds, getCurrentUser()); + } + + @ApiOperation(value = "Assign the Dashboard to Public Customer (assignDashboardToPublicCustomer)", + notes = "Assigns the dashboard to a special, auto-generated 'Public' Customer. Once assigned, unauthenticated users may browse the dashboard. " + + "This method is useful if you like to embed the dashboard on public web pages to be available for users that are not logged in. " + + "Be aware that making the dashboard public does not mean that it automatically makes all devices and assets you use in the dashboard to be public." + + "Use [assign Asset to Public Customer](#!/asset-controller/assignAssetToPublicCustomerUsingPOST) and " + + "[assign Device to Public Customer](#!/device-controller/assignDeviceToPublicCustomerUsingPOST) for this purpose. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/dashboard/{dashboardId}", method = RequestMethod.POST) + @ResponseBody + public Dashboard assignDashboardToPublicCustomer( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.ASSIGN_TO_CUSTOMER); + return tbDashboardService.assignDashboardToPublicCustomer(dashboard, getCurrentUser()); + } + + @ApiOperation(value = "Unassign the Dashboard from Public Customer (unassignDashboardFromPublicCustomer)", + notes = "Unassigns the dashboard from a special, auto-generated 'Public' Customer. Once unassigned, unauthenticated users may no longer browse the dashboard. " + + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/dashboard/{dashboardId}", method = RequestMethod.DELETE) + @ResponseBody + public Dashboard unassignDashboardFromPublicCustomer( + @ApiParam(value = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(DASHBOARD_ID, strDashboardId); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.UNASSIGN_FROM_CUSTOMER); + return tbDashboardService.unassignDashboardFromPublicCustomer(dashboard, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Dashboards by System Administrator (getTenantDashboards)", + notes = "Returns a page of dashboard info objects owned by tenant. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + + SYSTEM_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenant/{tenantId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantDashboards( + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(TENANT_ID) String strTenantId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DASHBOARD_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + checkTenantId(tenantId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(dashboardService.findDashboardsByTenantId(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Dashboards (getTenantDashboards)", + notes = "Returns a page of dashboard info objects owned by the tenant of a current user. " + + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantDashboards( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = HIDDEN_FOR_MOBILE) + @RequestParam(required = false) Boolean mobile, + @ApiParam(value = DASHBOARD_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (mobile != null && mobile) { + return checkNotNull(dashboardService.findMobileDashboardsByTenantId(tenantId, pageLink)); + } else { + return checkNotNull(dashboardService.findDashboardsByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Dashboards (getCustomerDashboards)", + notes = "Returns a page of dashboard info objects owned by the specified customer. " + + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerDashboards( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = HIDDEN_FOR_MOBILE) + @RequestParam(required = false) Boolean mobile, + @ApiParam(value = DASHBOARD_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (mobile != null && mobile) { + return checkNotNull(dashboardService.findMobileDashboardsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } else { + return checkNotNull(dashboardService.findDashboardsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Home Dashboard (getHomeDashboard)", + notes = "Returns the home dashboard object that is configured as 'homeDashboardId' parameter in the 'additionalInfo' of the User. " + + "If 'homeDashboardId' parameter is not set on the User level and the User has authority 'CUSTOMER_USER', check the same parameter for the corresponding Customer. " + + "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/home", method = RequestMethod.GET) + @ResponseBody + public HomeDashboard getHomeDashboard() throws ThingsboardException { + try { + SecurityUser securityUser = getCurrentUser(); + if (securityUser.isSystemAdmin()) { + return null; + } + User user = userService.findUserById(securityUser.getTenantId(), securityUser.getId()); + JsonNode additionalInfo = user.getAdditionalInfo(); + HomeDashboard homeDashboard; + homeDashboard = extractHomeDashboardFromAdditionalInfo(additionalInfo); + if (homeDashboard == null) { + if (securityUser.isCustomerUser()) { + Customer customer = customerService.findCustomerById(securityUser.getTenantId(), securityUser.getCustomerId()); + additionalInfo = customer.getAdditionalInfo(); + homeDashboard = extractHomeDashboardFromAdditionalInfo(additionalInfo); + } + if (homeDashboard == null) { + Tenant tenant = tenantService.findTenantById(securityUser.getTenantId()); + additionalInfo = tenant.getAdditionalInfo(); + homeDashboard = extractHomeDashboardFromAdditionalInfo(additionalInfo); + } + } + return homeDashboard; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Home Dashboard Info (getHomeDashboardInfo)", + notes = "Returns the home dashboard info object that is configured as 'homeDashboardId' parameter in the 'additionalInfo' of the User. " + + "If 'homeDashboardId' parameter is not set on the User level and the User has authority 'CUSTOMER_USER', check the same parameter for the corresponding Customer. " + + "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/home/info", method = RequestMethod.GET) + @ResponseBody + public HomeDashboardInfo getHomeDashboardInfo() throws ThingsboardException { + try { + SecurityUser securityUser = getCurrentUser(); + if (securityUser.isSystemAdmin()) { + return null; + } + User user = userService.findUserById(securityUser.getTenantId(), securityUser.getId()); + JsonNode additionalInfo = user.getAdditionalInfo(); + HomeDashboardInfo homeDashboardInfo; + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + if (homeDashboardInfo == null) { + if (securityUser.isCustomerUser()) { + Customer customer = customerService.findCustomerById(securityUser.getTenantId(), securityUser.getCustomerId()); + additionalInfo = customer.getAdditionalInfo(); + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + } + if (homeDashboardInfo == null) { + Tenant tenant = tenantService.findTenantById(securityUser.getTenantId()); + additionalInfo = tenant.getAdditionalInfo(); + homeDashboardInfo = extractHomeDashboardInfoFromAdditionalInfo(additionalInfo); + } + } + return homeDashboardInfo; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Home Dashboard Info (getTenantHomeDashboardInfo)", + notes = "Returns the home dashboard info object that is configured as 'homeDashboardId' parameter in the 'additionalInfo' of the corresponding tenant. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.GET) + @ResponseBody + public HomeDashboardInfo getTenantHomeDashboardInfo() throws ThingsboardException { + try { + Tenant tenant = tenantService.findTenantById(getTenantId()); + JsonNode additionalInfo = tenant.getAdditionalInfo(); + DashboardId dashboardId = null; + boolean hideDashboardToolbar = true; + if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { + String strDashboardId = additionalInfo.get(HOME_DASHBOARD_ID).asText(); + dashboardId = new DashboardId(toUUID(strDashboardId)); + if (additionalInfo.has(HOME_DASHBOARD_HIDE_TOOLBAR)) { + hideDashboardToolbar = additionalInfo.get(HOME_DASHBOARD_HIDE_TOOLBAR).asBoolean(); + } + } + return new HomeDashboardInfo(dashboardId, hideDashboardToolbar); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Update Tenant Home Dashboard Info (getTenantHomeDashboardInfo)", + notes = "Update the home dashboard assignment for the current tenant. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void setTenantHomeDashboardInfo( + @ApiParam(value = "A JSON object that represents home dashboard id and other parameters", required = true) + @RequestBody HomeDashboardInfo homeDashboardInfo) throws ThingsboardException { + + try { + if (homeDashboardInfo.getDashboardId() != null) { + checkDashboardId(homeDashboardInfo.getDashboardId(), Operation.READ); + } + Tenant tenant = tenantService.findTenantById(getTenantId()); + JsonNode additionalInfo = tenant.getAdditionalInfo(); + if (additionalInfo == null || !(additionalInfo instanceof ObjectNode)) { + additionalInfo = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + } + if (homeDashboardInfo.getDashboardId() != null) { + ((ObjectNode) additionalInfo).put(HOME_DASHBOARD_ID, homeDashboardInfo.getDashboardId().getId().toString()); + ((ObjectNode) additionalInfo).put(HOME_DASHBOARD_HIDE_TOOLBAR, homeDashboardInfo.isHideDashboardToolbar()); + } else { + ((ObjectNode) additionalInfo).remove(HOME_DASHBOARD_ID); + ((ObjectNode) additionalInfo).remove(HOME_DASHBOARD_HIDE_TOOLBAR); + } + tenant.setAdditionalInfo(additionalInfo); + tenantService.saveTenant(tenant); + } catch (Exception e) { + throw handleException(e); + } + } + + private HomeDashboardInfo extractHomeDashboardInfoFromAdditionalInfo(JsonNode additionalInfo) { + try { + if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { + String strDashboardId = additionalInfo.get(HOME_DASHBOARD_ID).asText(); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + checkDashboardId(dashboardId, Operation.READ); + boolean hideDashboardToolbar = true; + if (additionalInfo.has(HOME_DASHBOARD_HIDE_TOOLBAR)) { + hideDashboardToolbar = additionalInfo.get(HOME_DASHBOARD_HIDE_TOOLBAR).asBoolean(); + } + return new HomeDashboardInfo(dashboardId, hideDashboardToolbar); + } + } catch (Exception e) { + } + return null; + } + + private HomeDashboard extractHomeDashboardFromAdditionalInfo(JsonNode additionalInfo) { + try { + if (additionalInfo != null && additionalInfo.has(HOME_DASHBOARD_ID) && !additionalInfo.get(HOME_DASHBOARD_ID).isNull()) { + String strDashboardId = additionalInfo.get(HOME_DASHBOARD_ID).asText(); + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.READ); + boolean hideDashboardToolbar = true; + if (additionalInfo.has(HOME_DASHBOARD_HIDE_TOOLBAR)) { + hideDashboardToolbar = additionalInfo.get(HOME_DASHBOARD_HIDE_TOOLBAR).asBoolean(); + } + return new HomeDashboard(dashboard, hideDashboardToolbar); + } + } catch (Exception e) { + } + return null; + } + + @ApiOperation(value = "Assign dashboard to edge (assignDashboardToEdge)", + notes = "Creates assignment of an existing dashboard to an instance of The Edge. " + + EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive a copy of assignment dashboard " + + EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once dashboard will be delivered to edge service, it's going to be available for usage on remote edge instance." + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}", method = RequestMethod.POST) + @ResponseBody + public Dashboard assignDashboardToEdge(@PathVariable("edgeId") String strEdgeId, + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter("edgeId", strEdgeId); + checkParameter(DASHBOARD_ID, strDashboardId); + + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + checkDashboardId(dashboardId, Operation.READ); + return tbDashboardService.asignDashboardToEdge(getTenantId(), dashboardId, edge, getCurrentUser()); + } + + @ApiOperation(value = "Unassign dashboard from edge (unassignDashboardFromEdge)", + notes = "Clears assignment of the dashboard to the edge. " + + EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive an 'unassign' command to remove dashboard " + + EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once 'unassign' command will be delivered to edge service, it's going to remove dashboard locally." + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}", method = RequestMethod.DELETE) + @ResponseBody + public Dashboard unassignDashboardFromEdge(@PathVariable("edgeId") String strEdgeId, + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(DASHBOARD_ID, strDashboardId); + + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.READ); + + return tbDashboardService.unassignDashboardFromEdge(dashboard, edge, getCurrentUser()); + } + + @ApiOperation(value = "Get Edge Dashboards (getEdgeDashboards)", + notes = "Returns a page of dashboard info objects assigned to the specified edge. " + + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/{edgeId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeDashboards( + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DASHBOARD_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DASHBOARD_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("edgeId", strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + PageData nonFilteredResult = dashboardService.findDashboardsByTenantIdAndEdgeId(tenantId, edgeId, pageLink); + List filteredDashboards = nonFilteredResult.getData().stream().filter(dashboardInfo -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.DASHBOARD, Operation.READ, dashboardInfo.getId(), dashboardInfo); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + PageData filteredResult = new PageData<>(filteredDashboards, + nonFilteredResult.getTotalPages(), + nonFilteredResult.getTotalElements(), + nonFilteredResult.hasNext()); + return checkNotNull(filteredResult); + } catch (Exception e) { + throw handleException(e); + } + } + + private Set customerIdFromStr(String[] strCustomerIds) { + Set customerIds = new HashSet<>(); + if (strCustomerIds != null) { + for (String strCustomerId : strCustomerIds) { + customerIds.add(new CustomerId(UUID.fromString(strCustomerId))); + } + } + return customerIds; + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java new file mode 100644 index 0000000..ae15583 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -0,0 +1,789 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.common.util.concurrent.MoreExecutors; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.ClaimRequest; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceInfo; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.device.DeviceSearchQuery; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ota.OtaPackageType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.dao.device.claim.ClaimResponse; +import org.thingsboard.server.dao.device.claim.ClaimResult; +import org.thingsboard.server.dao.device.claim.ReclaimResult; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.device.DeviceBulkImportService; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_NAME_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_DESCRIPTION_MARKDOWN; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; +import static org.thingsboard.server.controller.EdgeController.EDGE_ID; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class DeviceController extends BaseController { + + protected static final String DEVICE_NAME = "deviceName"; + + private final DeviceBulkImportService deviceBulkImportService; + + private final TbDeviceService tbDeviceService; + + @ApiOperation(value = "Get Device (getDeviceById)", + notes = "Fetch the Device object based on the provided Device Id. " + + "If the user has the authority of 'TENANT_ADMIN', the server checks that the device is owned by the same tenant. " + + "If the user has the authority of 'CUSTOMER_USER', the server checks that the device is assigned to the same customer." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device/{deviceId}", method = RequestMethod.GET) + @ResponseBody + public Device getDeviceById(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + return checkDeviceId(deviceId, Operation.READ); + } + + @ApiOperation(value = "Get Device Info (getDeviceInfoById)", + notes = "Fetch the Device Info object based on the provided Device Id. " + + "If the user has the authority of 'Tenant Administrator', the server checks that the device is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the device is assigned to the same customer. " + + DEVICE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device/info/{deviceId}", method = RequestMethod.GET) + @ResponseBody + public DeviceInfo getDeviceInfoById(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + return checkDeviceInfoId(deviceId, Operation.READ); + } + + @ApiOperation(value = "Create Or Update Device (saveDevice)", + notes = "Create or update the Device. When creating device, platform generates Device Id as " + UUID_WIKI_LINK + + "Device credentials are also generated if not provided in the 'accessToken' request parameter. " + + "The newly created device id will be present in the response. " + + "Specify existing Device id to update the device. " + + "Referencing non-existing device Id will cause 'Not Found' error." + + "\n\nDevice name is unique in the scope of tenant. Use unique identifiers like MAC or IMEI for the device names and non-unique 'label' field for user-friendly visualization purposes." + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Device entity. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device", method = RequestMethod.POST) + @ResponseBody + public Device saveDevice(@ApiParam(value = "A JSON value representing the device.") @RequestBody Device device, + @ApiParam(value = "Optional value of the device credentials to be used during device creation. " + + "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception { + device.setTenantId(getCurrentUser().getTenantId()); + Device oldDevice = null; + if (device.getId() != null) { + oldDevice = checkDeviceId(device.getId(), Operation.WRITE); + } else { + checkEntity(null, device, Resource.DEVICE); + } + return tbDeviceService.save(device, oldDevice, accessToken, getCurrentUser()); + } + + @ApiOperation(value = "Create Device (saveDevice) with credentials ", + notes = "Create or update the Device. When creating device, platform generates Device Id as " + UUID_WIKI_LINK + + "Requires to provide the Device Credentials object as well. Useful to create device and credentials in one request. " + + "You may find the example of LwM2M device and RPK credentials below: \n\n" + + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_DESCRIPTION_MARKDOWN + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Device entity. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST) + @ResponseBody + public Device saveDeviceWithCredentials(@ApiParam(value = "The JSON object with device and credentials. See method description above for example.") + @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException { + Device device = checkNotNull(deviceAndCredentials.getDevice()); + DeviceCredentials credentials = checkNotNull(deviceAndCredentials.getCredentials()); + device.setTenantId(getCurrentUser().getTenantId()); + checkEntity(device.getId(), device, Resource.DEVICE); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser()); + } + + @ApiOperation(value = "Delete device (deleteDevice)", + notes = "Deletes the device, it's credentials and all the relations (from and to the device). Referencing non-existing device Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/device/{deviceId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteDevice(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws Exception { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + Device device = checkDeviceId(deviceId, Operation.DELETE); + tbDeviceService.delete(device, getCurrentUser()).get(); + } + + @ApiOperation(value = "Assign device to customer (assignDeviceToCustomer)", + notes = "Creates assignment of the device to customer. Customer will be able to query device afterwards." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/device/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public Device assignDeviceToCustomer(@ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + checkParameter(DEVICE_ID, strDeviceId); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + checkDeviceId(deviceId, Operation.ASSIGN_TO_CUSTOMER); + return tbDeviceService.assignDeviceToCustomer(getTenantId(), deviceId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Unassign device from customer (unassignDeviceFromCustomer)", + notes = "Clears assignment of the device to customer. Customer will not be able to query device afterwards." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/device/{deviceId}", method = RequestMethod.DELETE) + @ResponseBody + public Device unassignDeviceFromCustomer(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + Device device = checkDeviceId(deviceId, Operation.UNASSIGN_FROM_CUSTOMER); + if (device.getCustomerId() == null || device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + throw new IncorrectParameterException("Device isn't assigned to any customer!"); + } + + Customer customer = checkCustomerId(device.getCustomerId(), Operation.READ); + + return tbDeviceService.unassignDeviceFromCustomer(device, customer, getCurrentUser()); + } + + @ApiOperation(value = "Make device publicly available (assignDeviceToPublicCustomer)", + notes = "Device will be available for non-authorized (not logged-in) users. " + + "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + + "However, users that are logged-in and belong to different tenant will not be able to access the device." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/device/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public Device assignDeviceToPublicCustomer(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + checkDeviceId(deviceId, Operation.ASSIGN_TO_CUSTOMER); + return tbDeviceService.assignDeviceToPublicCustomer(getTenantId(), deviceId, getCurrentUser()); + } + + @ApiOperation(value = "Get Device Credentials (getDeviceCredentialsByDeviceId)", + notes = "If during device creation there wasn't specified any credentials, platform generates random 'ACCESS_TOKEN' credentials." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device/{deviceId}/credentials", method = RequestMethod.GET) + @ResponseBody + public DeviceCredentials getDeviceCredentialsByDeviceId(@ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + Device device = checkDeviceId(deviceId, Operation.READ_CREDENTIALS); + return tbDeviceService.getDeviceCredentialsByDeviceId(device, getCurrentUser()); + } + + @ApiOperation(value = "Update device credentials (updateDeviceCredentials)", notes = "During device creation, platform generates random 'ACCESS_TOKEN' credentials. " + + "Use this method to update the device credentials. First use 'getDeviceCredentialsByDeviceId' to get the credentials id and value. " + + "Then use current method to update the credentials type and value. It is not possible to create multiple device credentials for the same device. " + + "The structure of device credentials id and value is simple for the 'ACCESS_TOKEN' but is much more complex for the 'MQTT_BASIC' or 'LWM2M_CREDENTIALS'." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/device/credentials", method = RequestMethod.POST) + @ResponseBody + public DeviceCredentials updateDeviceCredentials( + @ApiParam(value = "A JSON value representing the device credentials.") + @RequestBody DeviceCredentials deviceCredentials) throws ThingsboardException { + checkNotNull(deviceCredentials); + Device device = checkDeviceId(deviceCredentials.getDeviceId(), Operation.WRITE_CREDENTIALS); + return tbDeviceService.updateDeviceCredentials(device, deviceCredentials, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Devices (getTenantDevices)", + notes = "Returns a page of devices owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantDevices( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = DEVICE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(deviceService.findDevicesByTenantIdAndType(tenantId, type, pageLink)); + } else { + return checkNotNull(deviceService.findDevicesByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Device Infos (getTenantDeviceInfos)", + notes = "Returns a page of devices info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantDeviceInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(required = false) String deviceProfileId, + @ApiParam(value = DEVICE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(deviceService.findDeviceInfosByTenantIdAndType(tenantId, type, pageLink)); + } else if (deviceProfileId != null && deviceProfileId.length() > 0) { + DeviceProfileId profileId = new DeviceProfileId(toUUID(deviceProfileId)); + return checkNotNull(deviceService.findDeviceInfosByTenantIdAndDeviceProfileId(tenantId, profileId, pageLink)); + } else { + return checkNotNull(deviceService.findDeviceInfosByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Device (getTenantDevice)", + notes = "Requested device must be owned by tenant that the user belongs to. " + + "Device name is an unique property of device. So it can be used to identify the device." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/devices", params = {"deviceName"}, method = RequestMethod.GET) + @ResponseBody + public Device getTenantDevice( + @ApiParam(value = DEVICE_NAME_DESCRIPTION) + @RequestParam String deviceName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(deviceService.findDeviceByTenantIdAndName(tenantId, deviceName)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Devices (getCustomerDevices)", + notes = "Returns a page of devices objects assigned to customer. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerDevices( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = DEVICE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(deviceService.findDevicesByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); + } else { + return checkNotNull(deviceService.findDevicesByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Device Infos (getCustomerDeviceInfos)", + notes = "Returns a page of devices info objects assigned to customer. " + + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerDeviceInfos( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(required = false) String deviceProfileId, + @ApiParam(value = DEVICE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(deviceService.findDeviceInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); + } else if (deviceProfileId != null && deviceProfileId.length() > 0) { + DeviceProfileId profileId = new DeviceProfileId(toUUID(deviceProfileId)); + return checkNotNull(deviceService.findDeviceInfosByTenantIdAndCustomerIdAndDeviceProfileId(tenantId, customerId, profileId, pageLink)); + } else { + return checkNotNull(deviceService.findDeviceInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Devices By Ids (getDevicesByIds)", + notes = "Requested devices must be owned by tenant or assigned to customer which user is performing the request. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/devices", params = {"deviceIds"}, method = RequestMethod.GET) + @ResponseBody + public List getDevicesByIds( + @ApiParam(value = "A list of devices ids, separated by comma ','") + @RequestParam("deviceIds") String[] strDeviceIds) throws ThingsboardException { + checkArrayParameter("deviceIds", strDeviceIds); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = user.getCustomerId(); + List deviceIds = new ArrayList<>(); + for (String strDeviceId : strDeviceIds) { + deviceIds.add(new DeviceId(toUUID(strDeviceId))); + } + ListenableFuture> devices; + if (customerId == null || customerId.isNullUid()) { + devices = deviceService.findDevicesByTenantIdAndIdsAsync(tenantId, deviceIds); + } else { + devices = deviceService.findDevicesByTenantIdCustomerIdAndIdsAsync(tenantId, customerId, deviceIds); + } + return checkNotNull(devices.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related devices (findByQuery)", + notes = "Returns all devices that are related to the specific entity. " + + "The entity id, relation type, device types, depth of the search, and other query parameters defined using complex 'DeviceSearchQuery' object. " + + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/devices", method = RequestMethod.POST) + @ResponseBody + public List findByQuery( + @ApiParam(value = "The device search query JSON") + @RequestBody DeviceSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getDeviceTypes()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + List devices = checkNotNull(deviceService.findDevicesByQuery(getCurrentUser().getTenantId(), query).get()); + devices = devices.stream().filter(device -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.DEVICE, Operation.READ, device.getId(), device); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return devices; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Device Types (getDeviceTypes)", + notes = "Returns a set of unique device profile names based on devices that are either owned by the tenant or assigned to the customer which user is performing the request." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/device/types", method = RequestMethod.GET) + @ResponseBody + public List getDeviceTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> deviceTypes = deviceService.findDeviceTypesByTenantId(tenantId); + return checkNotNull(deviceTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Claim device (claimDevice)", + notes = "Claiming makes it possible to assign a device to the specific customer using device/server side claiming data (in the form of secret key)." + + "To make this happen you have to provide unique device name and optional claiming data (it is needed only for device-side claiming)." + + "Once device is claimed, the customer becomes its owner and customer users may access device data as well as control the device. \n" + + "In order to enable claiming devices feature a system parameter security.claim.allowClaimingByDefault should be set to true, " + + "otherwise a server-side claimingAllowed attribute with the value true is obligatory for provisioned devices. \n" + + "See official documentation for more details regarding claiming." + CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('CUSTOMER_USER')") + @RequestMapping(value = "/customer/device/{deviceName}/claim", method = RequestMethod.POST) + @ResponseBody + public DeferredResult claimDevice(@ApiParam(value = "Unique name of the device which is going to be claimed") + @PathVariable(DEVICE_NAME) String deviceName, + @ApiParam(value = "Claiming request which can optionally contain secret key") + @RequestBody(required = false) ClaimRequest claimRequest) throws ThingsboardException { + checkParameter(DEVICE_NAME, deviceName); + final DeferredResult deferredResult = new DeferredResult<>(); + + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = user.getCustomerId(); + + Device device = checkNotNull(deviceService.findDeviceByTenantIdAndName(tenantId, deviceName)); + accessControlService.checkPermission(user, Resource.DEVICE, Operation.CLAIM_DEVICES, + device.getId(), device); + String secretKey = getSecretKey(claimRequest); + + ListenableFuture future = tbDeviceService.claimDevice(tenantId, device, customerId, secretKey, user); + + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable ClaimResult result) { + HttpStatus status; + if (result != null) { + if (result.getResponse().equals(ClaimResponse.SUCCESS)) { + status = HttpStatus.OK; + deferredResult.setResult(new ResponseEntity<>(result, status)); + } else { + status = HttpStatus.BAD_REQUEST; + deferredResult.setResult(new ResponseEntity<>(result.getResponse(), status)); + } + } else { + deferredResult.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); + } + } + + @Override + public void onFailure(Throwable t) { + deferredResult.setErrorResult(t); + } + }, MoreExecutors.directExecutor()); + return deferredResult; + } + + @ApiOperation(value = "Reclaim device (reClaimDevice)", + notes = "Reclaiming means the device will be unassigned from the customer and the device will be available for claiming again." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/device/{deviceName}/claim", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public DeferredResult reClaimDevice(@ApiParam(value = "Unique name of the device which is going to be reclaimed") + @PathVariable(DEVICE_NAME) String deviceName) throws ThingsboardException { + checkParameter(DEVICE_NAME, deviceName); + final DeferredResult deferredResult = new DeferredResult<>(); + + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + + Device device = checkNotNull(deviceService.findDeviceByTenantIdAndName(tenantId, deviceName)); + accessControlService.checkPermission(user, Resource.DEVICE, Operation.CLAIM_DEVICES, + device.getId(), device); + + ListenableFuture result = tbDeviceService.reclaimDevice(tenantId, device, user); + Futures.addCallback(result, new FutureCallback<>() { + @Override + public void onSuccess(ReclaimResult reclaimResult) { + deferredResult.setResult(new ResponseEntity(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + deferredResult.setErrorResult(t); + } + }, MoreExecutors.directExecutor()); + return deferredResult; + } + + private String getSecretKey(ClaimRequest claimRequest) { + String secretKey = claimRequest.getSecretKey(); + if (secretKey != null) { + return secretKey; + } + return DataConstants.DEFAULT_SECRET_KEY; + } + + @ApiOperation(value = "Assign device to tenant (assignDeviceToTenant)", + notes = "Creates assignment of the device to tenant. Thereafter tenant will be able to reassign the device to a customer." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/{tenantId}/device/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public Device assignDeviceToTenant(@ApiParam(value = TENANT_ID_PARAM_DESCRIPTION) + @PathVariable(TENANT_ID) String strTenantId, + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(TENANT_ID, strTenantId); + checkParameter(DEVICE_ID, strDeviceId); + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + Device device = checkDeviceId(deviceId, Operation.ASSIGN_TO_TENANT); + + TenantId newTenantId = TenantId.fromUUID(toUUID(strTenantId)); + Tenant newTenant = tenantService.findTenantById(newTenantId); + if (newTenant == null) { + throw new ThingsboardException("Could not find the specified Tenant!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + return tbDeviceService.assignDeviceToTenant(device, newTenant, getCurrentUser()); + } + + @ApiOperation(value = "Assign device to edge (assignDeviceToEdge)", + notes = "Creates assignment of an existing device to an instance of The Edge. " + + EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive a copy of assignment device " + + EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once device will be delivered to edge service, it's going to be available for usage on remote edge instance." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/device/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public Device assignDeviceToEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(DEVICE_ID, strDeviceId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + checkDeviceId(deviceId, Operation.READ); + + return tbDeviceService.assignDeviceToEdge(getTenantId(), deviceId, edge, getCurrentUser()); + } + + @ApiOperation(value = "Unassign device from edge (unassignDeviceFromEdge)", + notes = "Clears assignment of the device to the edge. " + + EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive an 'unassign' command to remove device " + + EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once 'unassign' command will be delivered to edge service, it's going to remove device locally." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/device/{deviceId}", method = RequestMethod.DELETE) + @ResponseBody + public Device unassignDeviceFromEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String strDeviceId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(DEVICE_ID, strDeviceId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + DeviceId deviceId = new DeviceId(toUUID(strDeviceId)); + Device device = checkDeviceId(deviceId, Operation.READ); + return tbDeviceService.unassignDeviceFromEdge(device, edge, getCurrentUser()); + } + + @ApiOperation(value = "Get devices assigned to edge (getEdgeDevices)", + notes = "Returns a page of devices assigned to edge. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/{edgeId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeDevices( + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = DEVICE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = "Timestamp. Devices with creation time before it won't be queried") + @RequestParam(required = false) Long startTime, + @ApiParam(value = "Timestamp. Devices with creation time after it won't be queried") + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + PageData nonFilteredResult; + if (type != null && type.trim().length() > 0) { + nonFilteredResult = deviceService.findDevicesByTenantIdAndEdgeIdAndType(tenantId, edgeId, type, pageLink); + } else { + nonFilteredResult = deviceService.findDevicesByTenantIdAndEdgeId(tenantId, edgeId, pageLink); + } + List filteredDevices = nonFilteredResult.getData().stream().filter(device -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.DEVICE, Operation.READ, device.getId(), device); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + PageData filteredResult = new PageData<>(filteredDevices, + nonFilteredResult.getTotalPages(), + nonFilteredResult.getTotalElements(), + nonFilteredResult.hasNext()); + return checkNotNull(filteredResult); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Count devices by device profile (countByDeviceProfileAndEmptyOtaPackage)", + notes = "The platform gives an ability to load OTA (over-the-air) packages to devices. " + + "It can be done in two different ways: device scope or device profile scope." + + "In the response you will find the number of devices with specified device profile, but without previously defined device scope OTA package. " + + "It can be useful when you want to define number of devices that will be affected with future OTA package" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/devices/count/{otaPackageType}/{deviceProfileId}", method = RequestMethod.GET) + @ResponseBody + public Long countByDeviceProfileAndEmptyOtaPackage + (@ApiParam(value = "OTA package type", allowableValues = "FIRMWARE, SOFTWARE") + @PathVariable("otaPackageType") String otaPackageType, + @ApiParam(value = "Device Profile Id. I.g. '784f394c-42b6-435a-983c-b7beff2784f9'") + @PathVariable("deviceProfileId") String deviceProfileId) throws ThingsboardException { + checkParameter("OtaPackageType", otaPackageType); + checkParameter("DeviceProfileId", deviceProfileId); + try { + return deviceService.countDevicesByTenantIdAndDeviceProfileIdAndEmptyOtaPackage( + getTenantId(), + new DeviceProfileId(UUID.fromString(deviceProfileId)), + OtaPackageType.valueOf(otaPackageType)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Import the bulk of devices (processDevicesBulkImport)", + notes = "There's an ability to import the bulk of devices using the only .csv file." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @PostMapping("/device/bulk_import") + public BulkImportResult processDevicesBulkImport(@RequestBody BulkImportRequest request) throws + Exception { + SecurityUser user = getCurrentUser(); + return deviceBulkImportService.processBulkImport(request, user); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java new file mode 100644 index 0000000..bd2a5a2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java @@ -0,0 +1,294 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.List; +import java.util.UUID; + +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_DATA; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TRANSPORT_TYPE_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class DeviceProfileController extends BaseController { + + private final TbDeviceProfileService tbDeviceProfileService; + + @Autowired + private TimeseriesService timeseriesService; + + @ApiOperation(value = "Get Device Profile (getDeviceProfileById)", + notes = "Fetch the Device Profile object based on the provided Device Profile Id. " + + "The server checks that the device profile is owned by the same tenant. " + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile/{deviceProfileId}", method = RequestMethod.GET) + @ResponseBody + public DeviceProfile getDeviceProfileById( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_PROFILE_ID) String strDeviceProfileId) throws ThingsboardException { + checkParameter(DEVICE_PROFILE_ID, strDeviceProfileId); + try { + DeviceProfileId deviceProfileId = new DeviceProfileId(toUUID(strDeviceProfileId)); + return checkDeviceProfileId(deviceProfileId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Device Profile Info (getDeviceProfileInfoById)", + notes = "Fetch the Device Profile Info object based on the provided Device Profile Id. " + + DEVICE_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/deviceProfileInfo/{deviceProfileId}", method = RequestMethod.GET) + @ResponseBody + public DeviceProfileInfo getDeviceProfileInfoById( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_PROFILE_ID) String strDeviceProfileId) throws ThingsboardException { + checkParameter(DEVICE_PROFILE_ID, strDeviceProfileId); + try { + DeviceProfileId deviceProfileId = new DeviceProfileId(toUUID(strDeviceProfileId)); + return new DeviceProfileInfo(checkDeviceProfileId(deviceProfileId, Operation.READ)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Default Device Profile (getDefaultDeviceProfileInfo)", + notes = "Fetch the Default Device Profile Info object. " + + DEVICE_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/deviceProfileInfo/default", method = RequestMethod.GET) + @ResponseBody + public DeviceProfileInfo getDefaultDeviceProfileInfo() throws ThingsboardException { + try { + return checkNotNull(deviceProfileService.findDefaultDeviceProfileInfo(getTenantId())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get time-series keys (getTimeseriesKeys)", + notes = "Get a set of unique time-series keys used by devices that belong to specified profile. " + + "If profile is not set returns a list of unique keys among all profiles. " + + "The call is used for auto-complete in the UI forms. " + + "The implementation limits the number of devices that participate in search to 100 as a trade of between accurate results and time-consuming queries. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile/devices/keys/timeseries", method = RequestMethod.GET) + @ResponseBody + public List getTimeseriesKeys( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(name = DEVICE_PROFILE_ID, required = false) String deviceProfileIdStr) throws ThingsboardException { + DeviceProfileId deviceProfileId; + if (StringUtils.isNotEmpty(deviceProfileIdStr)) { + deviceProfileId = new DeviceProfileId(UUID.fromString(deviceProfileIdStr)); + checkDeviceProfileId(deviceProfileId, Operation.READ); + } else { + deviceProfileId = null; + } + + try { + return timeseriesService.findAllKeysByDeviceProfileId(getTenantId(), deviceProfileId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get attribute keys (getAttributesKeys)", + notes = "Get a set of unique attribute keys used by devices that belong to specified profile. " + + "If profile is not set returns a list of unique keys among all profiles. " + + "The call is used for auto-complete in the UI forms. " + + "The implementation limits the number of devices that participate in search to 100 as a trade of between accurate results and time-consuming queries. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile/devices/keys/attributes", method = RequestMethod.GET) + @ResponseBody + public List getAttributesKeys( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @RequestParam(name = DEVICE_PROFILE_ID, required = false) String deviceProfileIdStr) throws ThingsboardException { + DeviceProfileId deviceProfileId; + if (StringUtils.isNotEmpty(deviceProfileIdStr)) { + deviceProfileId = new DeviceProfileId(UUID.fromString(deviceProfileIdStr)); + checkDeviceProfileId(deviceProfileId, Operation.READ); + } else { + deviceProfileId = null; + } + + try { + return attributesService.findAllKeysByDeviceProfileId(getTenantId(), deviceProfileId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Device Profile (saveDeviceProfile)", + notes = "Create or update the Device Profile. When creating device profile, platform generates device profile id as " + UUID_WIKI_LINK + + "The newly created device profile id will be present in the response. " + + "Specify existing device profile id to update the device profile. " + + "Referencing non-existing device profile Id will cause 'Not Found' error. " + NEW_LINE + + "Device profile name is unique in the scope of tenant. Only one 'default' device profile may exist in scope of tenant." + DEVICE_PROFILE_DATA + + "Remove 'id', 'tenantId' from the request body example (below) to create new Device Profile entity. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json", + consumes = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile", method = RequestMethod.POST) + @ResponseBody + public DeviceProfile saveDeviceProfile( + @ApiParam(value = "A JSON value representing the device profile.") + @RequestBody DeviceProfile deviceProfile) throws Exception { + deviceProfile.setTenantId(getTenantId()); + checkEntity(deviceProfile.getId(), deviceProfile, Resource.DEVICE_PROFILE); + return tbDeviceProfileService.save(deviceProfile, getCurrentUser()); + } + + @ApiOperation(value = "Delete device profile (deleteDeviceProfile)", + notes = "Deletes the device profile. Referencing non-existing device profile Id will cause an error. " + + "Can't delete the device profile if it is referenced by existing devices." + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile/{deviceProfileId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteDeviceProfile( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_PROFILE_ID) String strDeviceProfileId) throws ThingsboardException { + checkParameter(DEVICE_PROFILE_ID, strDeviceProfileId); + DeviceProfileId deviceProfileId = new DeviceProfileId(toUUID(strDeviceProfileId)); + DeviceProfile deviceProfile = checkDeviceProfileId(deviceProfileId, Operation.DELETE); + tbDeviceProfileService.delete(deviceProfile, getCurrentUser()); + } + + @ApiOperation(value = "Make Device Profile Default (setDefaultDeviceProfile)", + notes = "Marks device profile as default within a tenant scope." + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfile/{deviceProfileId}/default", method = RequestMethod.POST) + @ResponseBody + public DeviceProfile setDefaultDeviceProfile( + @ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_PROFILE_ID) String strDeviceProfileId) throws ThingsboardException { + checkParameter(DEVICE_PROFILE_ID, strDeviceProfileId); + DeviceProfileId deviceProfileId = new DeviceProfileId(toUUID(strDeviceProfileId)); + DeviceProfile deviceProfile = checkDeviceProfileId(deviceProfileId, Operation.WRITE); + DeviceProfile previousDefaultDeviceProfile = deviceProfileService.findDefaultDeviceProfile(getTenantId()); + return tbDeviceProfileService.setDefaultDeviceProfile(deviceProfile, previousDefaultDeviceProfile, getCurrentUser()); + } + + @ApiOperation(value = "Get Device Profiles (getDeviceProfiles)", + notes = "Returns a page of devices profile objects owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/deviceProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getDeviceProfiles( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(deviceProfileService.findDeviceProfiles(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Device Profiles for transport type (getDeviceProfileInfos)", + notes = "Returns a page of devices profile info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + DEVICE_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/deviceProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getDeviceProfileInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = DEVICE_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = "Type of the transport", allowableValues = TRANSPORT_TYPE_ALLOWABLE_VALUES) + @RequestParam(required = false) String transportType) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(deviceProfileService.findDeviceProfileInfos(getTenantId(), pageLink, transportType)); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java new file mode 100644 index 0000000..6a5dd44 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java @@ -0,0 +1,598 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNode; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeInfo; +import org.thingsboard.server.common.data.edge.EdgeSearchQuery; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeBulkImportService; +import org.thingsboard.server.service.entitiy.edge.TbEdgeService; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@Slf4j +@RequestMapping("/api") +@RequiredArgsConstructor +public class EdgeController extends BaseController { + private final EdgeBulkImportService edgeBulkImportService; + private final TbEdgeService tbEdgeService; + + public static final String EDGE_ID = "edgeId"; + public static final String EDGE_SECURITY_CHECK = "If the user has the authority of 'Tenant Administrator', the server checks that the edge is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the edge is assigned to the same customer."; + + @ApiOperation(value = "Is edges support enabled (isEdgesSupportEnabled)", + notes = "Returns 'true' if edges support enabled on server, 'false' - otherwise.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edges/enabled", method = RequestMethod.GET) + @ResponseBody + public boolean isEdgesSupportEnabled() { + return edgesEnabled; + } + + @ApiOperation(value = "Get Edge (getEdgeById)", + notes = "Get the Edge object based on the provided Edge Id. " + EDGE_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/{edgeId}", method = RequestMethod.GET) + @ResponseBody + public Edge getEdgeById(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + return checkEdgeId(edgeId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Edge Info (getEdgeInfoById)", + notes = "Get the Edge Info object based on the provided Edge Id. " + EDGE_SECURITY_CHECK + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/info/{edgeId}", method = RequestMethod.GET) + @ResponseBody + public EdgeInfo getEdgeInfoById(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + return checkEdgeInfoId(edgeId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Edge (saveEdge)", + notes = "Create or update the Edge. When creating edge, platform generates Edge Id as " + UUID_WIKI_LINK + + "The newly created edge id will be present in the response. " + + "Specify existing Edge id to update the edge. " + + "Referencing non-existing Edge Id will cause 'Not Found' error." + + "\n\nEdge name is unique in the scope of tenant. Use unique identifiers like MAC or IMEI for the edge names and non-unique 'label' field for user-friendly visualization purposes." + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Edge entity. " + + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge", method = RequestMethod.POST) + @ResponseBody + public Edge saveEdge(@ApiParam(value = "A JSON value representing the edge.", required = true) + @RequestBody Edge edge) throws Exception { + TenantId tenantId = getTenantId(); + edge.setTenantId(tenantId); + boolean created = edge.getId() == null; + + RuleChain edgeTemplateRootRuleChain = null; + if (created) { + edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(tenantId); + if (edgeTemplateRootRuleChain == null) { + throw new DataValidationException("Root edge rule chain is not available!"); + } + } + + Operation operation = created ? Operation.CREATE : Operation.WRITE; + + accessControlService.checkPermission(getCurrentUser(), Resource.EDGE, operation, edge.getId(), edge); + + return tbEdgeService.save(edge, edgeTemplateRootRuleChain, getCurrentUser()); + } + + @ApiOperation(value = "Delete edge (deleteEdge)", + notes = "Deletes the edge. Referencing non-existing edge Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.DELETE); + tbEdgeService.delete(edge, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Edges (getEdges)", + notes = "Returns a page of edges owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdges(@ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EDGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(edgeService.findEdgesByTenantId(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Assign edge to customer (assignEdgeToCustomer)", + notes = "Creates assignment of the edge to customer. Customer will be able to query edge afterwards." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/edge/{edgeId}", method = RequestMethod.POST) + @ResponseBody + public Edge assignEdgeToCustomer(@ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + checkParameter(EDGE_ID, strEdgeId); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.ASSIGN_TO_CUSTOMER); + return tbEdgeService.assignEdgeToCustomer(getTenantId(), edgeId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Unassign edge from customer (unassignEdgeFromCustomer)", + notes = "Clears assignment of the edge to customer. Customer will not be able to query edge afterwards." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/edge/{edgeId}", method = RequestMethod.DELETE) + @ResponseBody + public Edge unassignEdgeFromCustomer(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.UNASSIGN_FROM_CUSTOMER); + if (edge.getCustomerId() == null || edge.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + throw new IncorrectParameterException("Edge isn't assigned to any customer!"); + } + Customer customer = checkCustomerId(edge.getCustomerId(), Operation.READ); + + return tbEdgeService.unassignEdgeFromCustomer(edge, customer, getCurrentUser()); + } + + @ApiOperation(value = "Make edge publicly available (assignEdgeToPublicCustomer)", + notes = "Edge will be available for non-authorized (not logged-in) users. " + + "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + + "However, users that are logged-in and belong to different tenant will not be able to access the edge." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/edge/{edgeId}", method = RequestMethod.POST) + @ResponseBody + public Edge assignEdgeToPublicCustomer(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.ASSIGN_TO_CUSTOMER); + return tbEdgeService.assignEdgeToPublicCustomer(getTenantId(), edgeId, getCurrentUser()); + } + + @ApiOperation(value = "Get Tenant Edges (getTenantEdges)", + notes = "Returns a page of edges owned by tenant. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantEdges( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EDGE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = EDGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(edgeService.findEdgesByTenantIdAndType(tenantId, type, pageLink)); + } else { + return checkNotNull(edgeService.findEdgesByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Edge Infos (getTenantEdgeInfos)", + notes = "Returns a page of edges info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/edgeInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantEdgeInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EDGE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = EDGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(edgeService.findEdgeInfosByTenantIdAndType(tenantId, type, pageLink)); + } else { + return checkNotNull(edgeService.findEdgeInfosByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Edge (getTenantEdge)", + notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + + "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/edges", params = {"edgeName"}, method = RequestMethod.GET) + @ResponseBody + public Edge getTenantEdge(@ApiParam(value = "Unique name of the edge", required = true) + @RequestParam String edgeName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(edgeService.findEdgeByTenantIdAndName(tenantId, edgeName)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Set root rule chain for provided edge (setEdgeRootRuleChain)", + notes = "Change root rule chain of the edge to the new provided rule chain. \n" + + "This operation will send a notification to update root rule chain on remote edge service." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/{ruleChainId}/root", method = RequestMethod.POST) + @ResponseBody + public Edge setEdgeRootRuleChain(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("ruleChainId") String strRuleChainId) throws Exception { + checkParameter(EDGE_ID, strEdgeId); + checkParameter("ruleChainId", strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + checkRuleChain(ruleChainId, Operation.WRITE); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.WRITE); + accessControlService.checkPermission(getCurrentUser(), Resource.EDGE, Operation.WRITE, edge.getId(), edge); + return tbEdgeService.setEdgeRootRuleChain(edge, ruleChainId, getCurrentUser()); + } + + @ApiOperation(value = "Get Customer Edges (getCustomerEdges)", + notes = "Returns a page of edges objects assigned to customer. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/edges", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerEdges( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EDGE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = EDGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + PageData result; + if (type != null && type.trim().length() > 0) { + result = edgeService.findEdgesByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink); + } else { + result = edgeService.findEdgesByTenantIdAndCustomerId(tenantId, customerId, pageLink); + } + return checkNotNull(result); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Edge Infos (getCustomerEdgeInfos)", + notes = "Returns a page of edges info objects assigned to customer. " + + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/edgeInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerEdgeInfos( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable("customerId") String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EDGE_TYPE_DESCRIPTION) + @RequestParam(required = false) String type, + @ApiParam(value = EDGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + PageData result; + if (type != null && type.trim().length() > 0) { + result = edgeService.findEdgeInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink); + } else { + result = edgeService.findEdgeInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink); + } + return checkNotNull(result); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Edges By Ids (getEdgesByIds)", + notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edges", params = {"edgeIds"}, method = RequestMethod.GET) + @ResponseBody + public List getEdgesByIds( + @ApiParam(value = "A list of edges ids, separated by comma ','", required = true) + @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException { + checkArrayParameter("edgeIds", strEdgeIds); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + CustomerId customerId = user.getCustomerId(); + List edgeIds = new ArrayList<>(); + for (String strEdgeId : strEdgeIds) { + edgeIds.add(new EdgeId(toUUID(strEdgeId))); + } + ListenableFuture> edgesFuture; + if (customerId == null || customerId.isNullUid()) { + edgesFuture = edgeService.findEdgesByTenantIdAndIdsAsync(tenantId, edgeIds); + } else { + edgesFuture = edgeService.findEdgesByTenantIdCustomerIdAndIdsAsync(tenantId, customerId, edgeIds); + } + List edges = edgesFuture.get(); + return checkNotNull(edges); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related edges (findByQuery)", + notes = "Returns all edges that are related to the specific entity. " + + "The entity id, relation type, edge types, depth of the search, and other query parameters defined using complex 'EdgeSearchQuery' object. " + + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edges", method = RequestMethod.POST) + @ResponseBody + public List findByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getEdgeTypes()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + List edges = checkNotNull(edgeService.findEdgesByQuery(tenantId, query).get()); + edges = edges.stream().filter(edge -> { + try { + accessControlService.checkPermission(user, Resource.EDGE, Operation.READ, edge.getId(), edge); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return edges; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Edge Types (getEdgeTypes)", + notes = "Returns a set of unique edge types based on edges that are either owned by the tenant or assigned to the customer which user is performing the request." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/types", method = RequestMethod.GET) + @ResponseBody + public List getEdgeTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> edgeTypes = edgeService.findEdgeTypesByTenantId(tenantId); + return checkNotNull(edgeTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Sync edge (syncEdge)", + notes = "Starts synchronization process between edge and cloud. \n" + + "All entities that are assigned to particular edge are going to be send to remote edge service." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/sync/{edgeId}", method = RequestMethod.POST) + public DeferredResult syncEdge(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { + checkParameter("edgeId", strEdgeId); + try { + final DeferredResult response = new DeferredResult<>(); + if (isEdgesEnabled()) { + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + edgeId = checkNotNull(edgeId); + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId); + edgeRpcService.processSyncRequest(request, fromEdgeSyncResponse -> reply(response, fromEdgeSyncResponse)); + } else { + throw new ThingsboardException("Edges support disabled", ThingsboardErrorCode.GENERAL); + } + return response; + } catch (Exception e) { + throw handleException(e); + } + } + + private void reply(DeferredResult response, FromEdgeSyncResponse fromEdgeSyncResponse) { + if (fromEdgeSyncResponse.isSuccess()) { + response.setResult(new ResponseEntity<>(HttpStatus.OK)); + } else { + response.setErrorResult(new ThingsboardException("Edge is not connected", ThingsboardErrorCode.GENERAL)); + } + } + + @ApiOperation(value = "Find missing rule chains (findMissingToRelatedRuleChains)", + notes = "Returns list of rule chains ids that are not assigned to particular edge, but these rule chains are present in the already assigned rule chains to edge." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/missingToRelatedRuleChains/{edgeId}", method = RequestMethod.GET) + @ResponseBody + public String findMissingToRelatedRuleChains(@ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { + try { + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + edgeId = checkNotNull(edgeId); + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + return edgeService.findMissingToRelatedRuleChains(tenantId, edgeId, TbRuleChainInputNode.class.getName()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Import the bulk of edges (processEdgesBulkImport)", + notes = "There's an ability to import the bulk of edges using the only .csv file." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @PostMapping("/edge/bulk_import") + public BulkImportResult processEdgesBulkImport(@RequestBody BulkImportRequest request) throws Exception { + SecurityUser user = getCurrentUser(); + RuleChain edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(user.getTenantId()); + if (edgeTemplateRootRuleChain == null) { + throw new DataValidationException("Root edge rule chain is not available!"); + } + + return edgeBulkImportService.processBulkImport(request, user); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java new file mode 100644 index 0000000..5dd4373 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeEventController.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; + +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; + +@Slf4j +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class EdgeEventController extends BaseController { + + @Autowired + private EdgeEventService edgeEventService; + + public static final String EDGE_ID = "edgeId"; + + @ApiOperation(value = "Get Edge Events (getEdgeEvents)", + notes = "Returns a page of edge events for the requested edge. " + + PAGE_DATA_PARAMETERS, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/events", method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeEvents( + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = "The case insensitive 'substring' filter based on the edge event type name.") + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EDGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = "Timestamp. Edge events with creation time before it won't be queried") + @RequestParam(required = false) Long startTime, + @ApiParam(value = "Timestamp. Edge events with creation time after it won't be queried") + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + return checkNotNull(edgeEventService.findEdgeEvents(tenantId, edgeId, pageLink, false)); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java new file mode 100644 index 0000000..f2bfbc9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java @@ -0,0 +1,519 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; +import org.thingsboard.server.common.data.sync.vc.EntityDataDiff; +import org.thingsboard.server.common.data.sync.vc.EntityDataInfo; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionLoadResult; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadRequest; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.BRANCH_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VERSION_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.VC_REQUEST_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.VERSION_ID_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api/entities/vc") +@PreAuthorize("hasAuthority('TENANT_ADMIN')") +@RequiredArgsConstructor +public class EntitiesVersionControlController extends BaseController { + + private final EntitiesVersionControlService versionControlService; + + + @ApiOperation(value = "Save entities version (saveEntitiesVersion)", notes = "" + + "Creates a new version of entities (or a single entity) by request.\n" + + "Supported entity types: CUSTOMER, ASSET, RULE_CHAIN, DASHBOARD, DEVICE_PROFILE, DEVICE, ENTITY_VIEW, WIDGETS_BUNDLE." + NEW_LINE + + "There are two available types of request: `SINGLE_ENTITY` and `COMPLEX`. " + + "Each of them contains version name (`versionName`) and name of a branch (`branch`) to create version (commit) in. " + + "If specified branch does not exists in a remote repo, then new empty branch will be created. " + + "Request of the `SINGLE_ENTITY` type has id of an entity (`entityId`) and additional configuration (`config`) " + + "which has following options: \n" + + "- `saveRelations` - whether to add inbound and outbound relations of type COMMON to created entity version;\n" + + "- `saveAttributes` - to save attributes of server scope (and also shared scope for devices);\n" + + "- `saveCredentials` - when saving a version of a device, to add its credentials to the version." + NEW_LINE + + "An example of a `SINGLE_ENTITY` version create request:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"SINGLE_ENTITY\",\n" + + "\n" + + " \"versionName\": \"Version 1.0\",\n" + + " \"branch\": \"dev\",\n" + + "\n" + + " \"entityId\": {\n" + + " \"entityType\": \"DEVICE\",\n" + + " \"id\": \"b79448e0-d4f4-11ec-847b-0f432358ab48\"\n" + + " },\n" + + " \"config\": {\n" + + " \"saveRelations\": true,\n" + + " \"saveAttributes\": true,\n" + + " \"saveCredentials\": false\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "Second request type (`COMPLEX`), additionally to `branch` and `versionName`, contains following properties:\n" + + "- `entityTypes` - a structure with entity types to export and configuration for each entity type; " + + " this configuration has all the options available for `SINGLE_ENTITY` and additionally has these ones: \n" + + " - `allEntities` and `entityIds` - if you want to save the version of all entities of the entity type " + + " then set `allEntities` param to true, otherwise set it to false and specify the list of specific entities (`entityIds`);\n" + + " - `syncStrategy` - synchronization strategy to use for this entity type: when set to `OVERWRITE` " + + " then the list of remote entities of this type will be overwritten by newly added entities. If set to " + + " `MERGE` - existing remote entities of this entity type will not be removed, new entities will just " + + " be added on top (or existing remote entities will be updated).\n" + + "- `syncStrategy` - default synchronization strategy to use when it is not specified for an entity type." + NEW_LINE + + "Example for this type of request:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"COMPLEX\",\n" + + "\n" + + " \"versionName\": \"Devices and profiles: release 2\",\n" + + " \"branch\": \"master\",\n" + + "\n" + + " \"syncStrategy\": \"OVERWRITE\",\n" + + " \"entityTypes\": {\n" + + " \"DEVICE\": {\n" + + " \"syncStrategy\": null,\n" + + " \"allEntities\": true,\n" + + " \"saveRelations\": true,\n" + + " \"saveAttributes\": true,\n" + + " \"saveCredentials\": true\n" + + " },\n" + + " \"DEVICE_PROFILE\": {\n" + + " \"syncStrategy\": \"MERGE\",\n" + + " \"allEntities\": false,\n" + + " \"entityIds\": [\n" + + " \"b79448e0-d4f4-11ec-847b-0f432358ab48\"\n" + + " ],\n" + + " \"saveRelations\": true\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "Response wil contain generated request UUID, that can be then used to retrieve " + + "status of operation via `getVersionCreateRequestStatus`.\n" + + TENANT_AUTHORITY_PARAGRAPH) + @PostMapping("/version") + public DeferredResult saveEntitiesVersion(@RequestBody VersionCreateRequest request) throws Exception { + SecurityUser user = getCurrentUser(); + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + return wrapFuture(versionControlService.saveEntitiesVersion(user, request)); + } + + @ApiOperation(value = "Get version create request status (getVersionCreateRequestStatus)", notes = "" + + "Returns the status of previously made version create request. " + NEW_LINE + + "This status contains following properties:\n" + + "- `done` - whether request processing is finished;\n" + + "- `version` - created version info: timestamp, version id (commit hash), commit name and commit author;\n" + + "- `added` - count of items that were created in the remote repo;\n" + + "- `modified` - modified items count;\n" + + "- `removed` - removed items count;\n" + + "- `error` - error message, if an error occurred while handling the request." + NEW_LINE + + "An example of successful status:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"done\": true,\n" + + " \"added\": 10,\n" + + " \"modified\": 2,\n" + + " \"removed\": 5,\n" + + " \"version\": {\n" + + " \"timestamp\": 1655198528000,\n" + + " \"id\":\"8a834dd389ed80e0759ba8ee338b3f1fd160a114\",\n" + + " \"name\": \"My devices v2.0\",\n" + + " \"author\": \"John Doe\"\n" + + " },\n" + + " \"error\": null\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/version/{requestId}/status") + public VersionCreationResult getVersionCreateRequestStatus(@ApiParam(value = VC_REQUEST_ID_PARAM_DESCRIPTION, required = true) + @PathVariable UUID requestId) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + return versionControlService.getVersionCreateStatus(getCurrentUser(), requestId); + } + + @ApiOperation(value = "List entity versions (listEntityVersions)", notes = "" + + "Returns list of versions for a specific entity in a concrete branch. \n" + + "You need to specify external id of an entity to list versions for. This is `externalId` property of an entity, " + + "or otherwise if not set - simply id of this entity. \n" + + "If specified branch does not exist - empty page data will be returned. " + NEW_LINE + + "Each version info item has timestamp, id, name and author. Version id can then be used to restore the version. " + + PAGE_DATA_PARAMETERS + NEW_LINE + + "Response example: \n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"data\": [\n" + + " {\n" + + " \"timestamp\": 1655198593000,\n" + + " \"id\": \"fd82625bdd7d6131cf8027b44ee967012ecaf990\",\n" + + " \"name\": \"Devices and assets - v2.0\",\n" + + " \"author\": \"John Doe \"\n" + + " },\n" + + " {\n" + + " \"timestamp\": 1655198528000,\n" + + " \"id\": \"682adcffa9c8a2f863af6f00c4850323acbd4219\",\n" + + " \"name\": \"Update my device\",\n" + + " \"author\": \"John Doe \"\n" + + " },\n" + + " {\n" + + " \"timestamp\": 1655198280000,\n" + + " \"id\": \"d2a6087c2b30e18cc55e7cdda345a8d0dfb959a4\",\n" + + " \"name\": \"Devices and assets - v1.0\",\n" + + " \"author\": \"John Doe \"\n" + + " }\n" + + " ],\n" + + " \"totalPages\": 1,\n" + + " \"totalElements\": 3,\n" + + " \"hasNext\": false\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/version/{entityType}/{externalEntityUuid}", params = {"branch", "pageSize", "page"}) + public DeferredResult> listEntityVersions(@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable EntityType entityType, + @ApiParam(value = "A string value representing external entity id. This is `externalId` property of an entity, or otherwise if not set - simply id of this entity.") + @PathVariable UUID externalEntityUuid, + @ApiParam(value = BRANCH_PARAM_DESCRIPTION) + @RequestParam String branch, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VERSION_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = "timestamp") + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + EntityId externalEntityId = EntityIdFactory.getByTypeAndUuid(entityType, externalEntityUuid); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return wrapFuture(versionControlService.listEntityVersions(getTenantId(), branch, externalEntityId, pageLink)); + } + + @ApiOperation(value = "List entity type versions (listEntityTypeVersions)", notes = "" + + "Returns list of versions of an entity type in a branch. This is a collected list of versions that were created " + + "for entities of this type in a remote branch. \n" + + "If specified branch does not exist - empty page data will be returned. " + + "The response structure is the same as for `listEntityVersions` API method." + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/version/{entityType}", params = {"branch", "pageSize", "page"}) + public DeferredResult> listEntityTypeVersions(@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable EntityType entityType, + @ApiParam(value = BRANCH_PARAM_DESCRIPTION, required = true) + @RequestParam String branch, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VERSION_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = "timestamp") + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return wrapFuture(versionControlService.listEntityTypeVersions(getTenantId(), branch, entityType, pageLink)); + } + + @ApiOperation(value = "List all versions (listVersions)", notes = "" + + "Lists all available versions in a branch for all entity types. \n" + + "If specified branch does not exist - empty page data will be returned. " + + "The response format is the same as for `listEntityVersions` API method." + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/version", params = {"branch", "pageSize", "page"}) + public DeferredResult> listVersions(@ApiParam(value = BRANCH_PARAM_DESCRIPTION, required = true) + @RequestParam String branch, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VERSION_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = "timestamp") + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return wrapFuture(versionControlService.listVersions(getTenantId(), branch, pageLink)); + } + + + @ApiOperation(value = "List entities at version (listEntitiesAtVersion)", notes = "" + + "Returns a list of remote entities of a specific entity type that are available at a concrete version. \n" + + "Each entity item in the result has `externalId` property. " + + "Entities order will be the same as in the repository." + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/entity/{entityType}/{versionId}") + public DeferredResult> listEntitiesAtVersion(@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable EntityType entityType, + @ApiParam(value = VERSION_ID_PARAM_DESCRIPTION, required = true) + @PathVariable String versionId) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + return wrapFuture(versionControlService.listEntitiesAtVersion(getTenantId(), versionId, entityType)); + } + + @ApiOperation(value = "List all entities at version (listAllEntitiesAtVersion)", notes = "" + + "Returns a list of all remote entities available in a specific version. " + + "Response type is the same as for listAllEntitiesAtVersion API method. \n" + + "Returned entities order will be the same as in the repository." + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/entity/{versionId}") + public DeferredResult> listAllEntitiesAtVersion(@ApiParam(value = VERSION_ID_PARAM_DESCRIPTION, required = true) + @PathVariable String versionId) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + return wrapFuture(versionControlService.listAllEntitiesAtVersion(getTenantId(), versionId)); + } + + @ApiOperation(value = "Get entity data info (getEntityDataInfo)", notes = "" + + "Retrieves short info about the remote entity by external id at a concrete version. \n" + + "Returned entity data info contains following properties: " + + "`hasRelations` (whether stored entity data contains relations), `hasAttributes` (contains attributes) and " + + "`hasCredentials` (whether stored device data has credentials)." + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping("/info/{versionId}/{entityType}/{externalEntityUuid}") + public DeferredResult getEntityDataInfo(@ApiParam(value = VERSION_ID_PARAM_DESCRIPTION, required = true) + @PathVariable String versionId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable EntityType entityType, + @ApiParam(value = "A string value representing external entity id", required = true) + @PathVariable UUID externalEntityUuid) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, externalEntityUuid); + return wrapFuture(versionControlService.getEntityDataInfo(getCurrentUser(), entityId, versionId)); + } + + @ApiOperation(value = "Compare entity data to version (compareEntityDataToVersion)", notes = "" + + "Returns an object with current entity data and the one at a specific version. " + + "Entity data structure is the same as stored in a repository. " + + TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/diff/{entityType}/{internalEntityUuid}", params = {"versionId"}) + public DeferredResult compareEntityDataToVersion(@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable EntityType entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable UUID internalEntityUuid, + @ApiParam(value = VERSION_ID_PARAM_DESCRIPTION, required = true) + @RequestParam String versionId) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, internalEntityUuid); + return wrapFuture(versionControlService.compareEntityDataToVersion(getCurrentUser(), entityId, versionId)); + } + + @ApiOperation(value = "Load entities version (loadEntitiesVersion)", notes = "" + + "Loads specific version of remote entities (or single entity) by request. " + + "Supported entity types: CUSTOMER, ASSET, RULE_CHAIN, DASHBOARD, DEVICE_PROFILE, DEVICE, ENTITY_VIEW, WIDGETS_BUNDLE." + NEW_LINE + + "There are multiple types of request. Each of them requires branch name (`branch`) and version id (`versionId`). " + + "Request of type `SINGLE_ENTITY` is needed to restore a concrete version of a specific entity. It contains " + + "id of a remote entity (`externalEntityId`) and additional configuration (`config`):\n" + + "- `loadRelations` - to update relations list (in case `saveRelations` option was enabled during version creation);\n" + + "- `loadAttributes` - to load entity attributes (if `saveAttributes` config option was enabled);\n" + + "- `loadCredentials` - to update device credentials (if `saveCredentials` option was enabled during version creation)." + NEW_LINE + + "An example of such request:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"SINGLE_ENTITY\",\n" + + " \n" + + " \"branch\": \"dev\",\n" + + " \"versionId\": \"b3c28d722d328324c7c15b0b30047b0c40011cf7\",\n" + + " \n" + + " \"externalEntityId\": {\n" + + " \"entityType\": \"DEVICE\",\n" + + " \"id\": \"b7944123-d4f4-11ec-847b-0f432358ab48\"\n" + + " },\n" + + " \"config\": {\n" + + " \"loadRelations\": false,\n" + + " \"loadAttributes\": true,\n" + + " \"loadCredentials\": true\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "Another request type (`ENTITY_TYPE`) is needed to load specific version of the whole entity types. " + + "It contains a structure with entity types to load and configs for each entity type (`entityTypes`). " + + "For each specified entity type, the method will load all remote entities of this type that are present " + + "at the version. A config for each entity type contains the same options as in `SINGLE_ENTITY` request type, and " + + "additionally contains following options:\n" + + "- `removeOtherEntities` - to remove local entities that are not present on the remote - basically to " + + " overwrite local entity type with the remote one;\n" + + "- `findExistingEntityByName` - when you are loading some remote entities that are not yet present at this tenant, " + + " try to find existing entity by name and update it rather than create new." + NEW_LINE + + "Here is an example of the request to completely restore version of the whole device entity type:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"type\": \"ENTITY_TYPE\",\n" + + "\n" + + " \"branch\": \"dev\",\n" + + " \"versionId\": \"b3c28d722d328324c7c15b0b30047b0c40011cf7\",\n" + + "\n" + + " \"entityTypes\": {\n" + + " \"DEVICE\": {\n" + + " \"removeOtherEntities\": true,\n" + + " \"findExistingEntityByName\": false,\n" + + " \"loadRelations\": true,\n" + + " \"loadAttributes\": true,\n" + + " \"loadCredentials\": true\n" + + " }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + NEW_LINE + + "The response will contain generated request UUID that is to be used to check the status of operation " + + "via `getVersionLoadRequestStatus`." + + TENANT_AUTHORITY_PARAGRAPH) + @PostMapping("/entity") + public UUID loadEntitiesVersion(@RequestBody VersionLoadRequest request) throws Exception { + SecurityUser user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.VERSION_CONTROL, Operation.WRITE); + return versionControlService.loadEntitiesVersion(user, request); + } + + @ApiOperation(value = "Get version load request status (getVersionLoadRequestStatus)", notes = "" + + "Returns the status of previously made version load request. " + + "The structure contains following parameters:\n" + + "- `done` - if the request was successfully processed;\n" + + "- `result` - a list of load results for each entity type:\n" + + " - `created` - created entities count;\n" + + " - `updated` - updated entities count;\n" + + " - `deleted` - removed entities count.\n" + + "- `error` - if an error occurred during processing, error info:\n" + + " - `type` - error type;\n" + + " - `source` - an external id of remote entity;\n" + + " - `target` - if failed to find referenced entity by external id - this external id;\n" + + " - `message` - error message." + NEW_LINE + + "An example of successfully processed request status:\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"done\": true,\n" + + " \"result\": [\n" + + " {\n" + + " \"entityType\": \"DEVICE\",\n" + + " \"created\": 10,\n" + + " \"updated\": 5,\n" + + " \"deleted\": 5\n" + + " },\n" + + " {\n" + + " \"entityType\": \"ASSET\",\n" + + " \"created\": 4,\n" + + " \"updated\": 0,\n" + + " \"deleted\": 8\n" + + " }\n" + + " ]\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + TENANT_AUTHORITY_PARAGRAPH + ) + @GetMapping(value = "/entity/{requestId}/status") + public VersionLoadResult getVersionLoadRequestStatus(@ApiParam(value = VC_REQUEST_ID_PARAM_DESCRIPTION, required = true) + @PathVariable UUID requestId) throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.WRITE); + return versionControlService.getVersionLoadStatus(getCurrentUser(), requestId); + } + + + @ApiOperation(value = "List branches (listBranches)", notes = "" + + "Lists branches available in the remote repository. \n\n" + + "Response example: \n" + + MARKDOWN_CODE_BLOCK_START + + "[\n" + + " {\n" + + " \"name\": \"master\",\n" + + " \"default\": true\n" + + " },\n" + + " {\n" + + " \"name\": \"dev\",\n" + + " \"default\": false\n" + + " },\n" + + " {\n" + + " \"name\": \"dev-2\",\n" + + " \"default\": false\n" + + " }\n" + + "]" + + MARKDOWN_CODE_BLOCK_END) + @GetMapping("/branches") + public DeferredResult> listBranches() throws Exception { + accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.READ); + final TenantId tenantId = getTenantId(); + ListenableFuture> branches = versionControlService.listBranches(tenantId); + return wrapFuture(Futures.transform(branches, remoteBranches -> { + List infos = new ArrayList<>(); + BranchInfo defaultBranch; + String defaultBranchName = versionControlService.getVersionControlSettings(tenantId).getDefaultBranch(); + if (StringUtils.isNotEmpty(defaultBranchName)) { + defaultBranch = new BranchInfo(defaultBranchName, true); + } else { + defaultBranch = remoteBranches.stream().filter(BranchInfo::isDefault).findFirst().orElse(null); + } + if (defaultBranch != null) { + infos.add(defaultBranch); + } + infos.addAll(remoteBranches.stream().filter(b -> !b.equals(defaultBranch)) + .map(b -> new BranchInfo(b.getName(), false)).collect(Collectors.toList())); + return infos; + }, MoreExecutors.directExecutor())); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java new file mode 100644 index 0000000..bc6c628 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -0,0 +1,126 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.query.EntityQueryService; + +import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class EntityQueryController extends BaseController { + + @Autowired + private EntityQueryService entityQueryService; + + private static final int MAX_PAGE_SIZE = 100; + + @ApiOperation(value = "Count Entities by Query", notes = ENTITY_COUNT_QUERY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entitiesQuery/count", method = RequestMethod.POST) + @ResponseBody + public long countEntitiesByQuery( + @ApiParam(value = "A JSON value representing the entity count query. See API call notes above for more details.") + @RequestBody EntityCountQuery query) throws ThingsboardException { + checkNotNull(query); + try { + return this.entityQueryService.countEntitiesByQuery(getCurrentUser(), query); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find Entity Data by Query", notes = ENTITY_DATA_QUERY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entitiesQuery/find", method = RequestMethod.POST) + @ResponseBody + public PageData findEntityDataByQuery( + @ApiParam(value = "A JSON value representing the entity data query. See API call notes above for more details.") + @RequestBody EntityDataQuery query) throws ThingsboardException { + checkNotNull(query); + try { + return this.entityQueryService.findEntityDataByQuery(getCurrentUser(), query); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find Alarms by Query", notes = ALARM_DATA_QUERY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/alarmsQuery/find", method = RequestMethod.POST) + @ResponseBody + public PageData findAlarmDataByQuery( + @ApiParam(value = "A JSON value representing the alarm data query. See API call notes above for more details.") + @RequestBody AlarmDataQuery query) throws ThingsboardException { + checkNotNull(query); + try { + return this.entityQueryService.findAlarmDataByQuery(getCurrentUser(), query); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find Entity Keys by Query", + notes = "Uses entity data query (see 'Find Entity Data by Query') to find first 100 entities. Then fetch and return all unique time-series and/or attribute keys. Used mostly for UI hints.") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entitiesQuery/find/keys", method = RequestMethod.POST) + @ResponseBody + public DeferredResult findEntityTimeseriesAndAttributesKeysByQuery( + @ApiParam(value = "A JSON value representing the entity data query. See API call notes above for more details.") + @RequestBody EntityDataQuery query, + @ApiParam(value = "Include all unique time-series keys to the result.") + @RequestParam("timeseries") boolean isTimeseries, + @ApiParam(value = "Include all unique attribute keys to the result.") + @RequestParam("attributes") boolean isAttributes) throws ThingsboardException { + TenantId tenantId = getTenantId(); + checkNotNull(query); + try { + EntityDataPageLink pageLink = query.getPageLink(); + if (pageLink.getPageSize() > MAX_PAGE_SIZE) { + pageLink.setPageSize(MAX_PAGE_SIZE); + } + return entityQueryService.getKeysByQuery(getCurrentUser(), tenantId, query, isTimeseries, isAttributes); + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java new file mode 100644 index 0000000..efc4237 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java @@ -0,0 +1,380 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +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.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationsQuery; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.entity.relation.TbEntityRelationService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RELATION_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RELATION_TYPE_GROUP_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RELATION_TYPE_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class EntityRelationController extends BaseController { + + private final TbEntityRelationService tbEntityRelationService; + + public static final String TO_TYPE = "toType"; + public static final String FROM_ID = "fromId"; + public static final String FROM_TYPE = "fromType"; + public static final String RELATION_TYPE = "relationType"; + public static final String TO_ID = "toId"; + + private static final String SECURITY_CHECKS_ENTITIES_DESCRIPTION = "\n\nIf the user has the authority of 'System Administrator', the server checks that 'from' and 'to' entities are owned by the sysadmin. " + + "If the user has the authority of 'Tenant Administrator', the server checks that 'from' and 'to' entities are owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the 'from' and 'to' entities are assigned to the same customer."; + + private static final String SECURITY_CHECKS_ENTITY_DESCRIPTION = "\n\nIf the user has the authority of 'System Administrator', the server checks that the entity is owned by the sysadmin. " + + "If the user has the authority of 'Tenant Administrator', the server checks that the entity is owned by the same tenant. " + + "If the user has the authority of 'Customer User', the server checks that the entity is assigned to the same customer."; + + @ApiOperation(value = "Create Relation (saveRelation)", + notes = "Creates or updates a relation between two entities in the platform. " + + "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relation", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void saveRelation(@ApiParam(value = "A JSON value representing the relation.", required = true) + @RequestBody EntityRelation relation) throws ThingsboardException { + checkNotNull(relation); + checkCanCreateRelation(relation.getFrom()); + checkCanCreateRelation(relation.getTo()); + if (relation.getTypeGroup() == null) { + relation.setTypeGroup(RelationTypeGroup.COMMON); + } + + tbEntityRelationService.save(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); + } + + @ApiOperation(value = "Delete Relation (deleteRelation)", + notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relation", method = RequestMethod.DELETE, params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) + @ResponseStatus(value = HttpStatus.OK) + public void deleteRelation(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @ApiParam(value = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + checkParameter(FROM_ID, strFromId); + checkParameter(FROM_TYPE, strFromType); + checkParameter(RELATION_TYPE, strRelationType); + checkParameter(TO_ID, strToId); + checkParameter(TO_TYPE, strToType); + EntityId fromId = EntityIdFactory.getByTypeAndId(strFromType, strFromId); + EntityId toId = EntityIdFactory.getByTypeAndId(strToType, strToId); + checkCanCreateRelation(fromId); + checkCanCreateRelation(toId); + + RelationTypeGroup relationTypeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + EntityRelation relation = new EntityRelation(fromId, toId, strRelationType, relationTypeGroup); + tbEntityRelationService.delete(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); + } + + @ApiOperation(value = "Delete Relations (deleteRelations)", + notes = "Deletes all the relation (both 'from' and 'to' direction) for the specified entity. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.DELETE, params = {"entityId", "entityType"}) + @ResponseStatus(value = HttpStatus.OK) + public void deleteRelations(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam("entityId") String strId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam("entityType") String strType) throws ThingsboardException { + checkParameter("entityId", strId); + checkParameter("entityType", strType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strType, strId); + checkEntityId(entityId, Operation.WRITE); + tbEntityRelationService.deleteRelations(getTenantId(), getCurrentUser().getCustomerId(), entityId, getCurrentUser()); + } + + @ApiOperation(value = "Get Relation (getRelation)", + notes = "Returns relation object between two specified entities if present. Otherwise throws exception. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relation", method = RequestMethod.GET, params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) + @ResponseBody + public EntityRelation getRelation(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @ApiParam(value = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + try { + checkParameter(FROM_ID, strFromId); + checkParameter(FROM_TYPE, strFromType); + checkParameter(RELATION_TYPE, strRelationType); + checkParameter(TO_ID, strToId); + checkParameter(TO_TYPE, strToType); + EntityId fromId = EntityIdFactory.getByTypeAndId(strFromType, strFromId); + EntityId toId = EntityIdFactory.getByTypeAndId(strToType, strToId); + checkEntityId(fromId, Operation.READ); + checkEntityId(toId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + return checkNotNull(relationService.getRelation(getTenantId(), fromId, toId, strRelationType, typeGroup)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relations (findByFrom)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.GET, params = {FROM_ID, FROM_TYPE}) + @ResponseBody + public List findByFrom(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(FROM_ID, strFromId); + checkParameter(FROM_TYPE, strFromType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strFromType, strFromId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findByFrom(getTenantId(), entityId, typeGroup))); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relation Infos (findInfoByFrom)", + notes = "Returns list of relation info objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations/info", method = RequestMethod.GET, params = {FROM_ID, FROM_TYPE}) + @ResponseBody + public List findInfoByFrom(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(FROM_ID, strFromId); + checkParameter(FROM_TYPE, strFromType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strFromType, strFromId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByFrom(getTenantId(), entityId, typeGroup).get())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relations (findByFrom)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction and relation type. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.GET, params = {FROM_ID, FROM_TYPE, RELATION_TYPE}) + @ResponseBody + public List findByFrom(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @ApiParam(value = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(FROM_ID, strFromId); + checkParameter(FROM_TYPE, strFromType); + checkParameter(RELATION_TYPE, strRelationType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strFromType, strFromId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findByFromAndType(getTenantId(), entityId, strRelationType, typeGroup))); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relations (findByTo)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.GET, params = {TO_ID, TO_TYPE}) + @ResponseBody + public List findByTo(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(TO_ID, strToId); + checkParameter(TO_TYPE, strToType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strToType, strToId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findByTo(getTenantId(), entityId, typeGroup))); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relation Infos (findInfoByTo)", + notes = "Returns list of relation info objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations/info", method = RequestMethod.GET, params = {TO_ID, TO_TYPE}) + @ResponseBody + public List findInfoByTo(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(TO_ID, strToId); + checkParameter(TO_TYPE, strToType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strToType, strToId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByTo(getTenantId(), entityId, typeGroup).get())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get List of Relations (findByTo)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction and relation type. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.GET, params = {TO_ID, TO_TYPE, RELATION_TYPE}) + @ResponseBody + public List findByTo(@ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType, + @ApiParam(value = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @ApiParam(value = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + checkParameter(TO_ID, strToId); + checkParameter(TO_TYPE, strToType); + checkParameter(RELATION_TYPE, strRelationType); + EntityId entityId = EntityIdFactory.getByTypeAndId(strToType, strToId); + checkEntityId(entityId, Operation.READ); + RelationTypeGroup typeGroup = parseRelationTypeGroup(strRelationTypeGroup, RelationTypeGroup.COMMON); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findByToAndType(getTenantId(), entityId, strRelationType, typeGroup))); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related entities (findByQuery)", + notes = "Returns all entities that are related to the specific entity. " + + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + + "See 'Model' tab of the Parameters for more info.", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations", method = RequestMethod.POST) + @ResponseBody + public List findByQuery(@ApiParam(value = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getFilters()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findByQuery(getTenantId(), query).get())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related entity infos (findInfoByQuery)", + notes = "Returns all entity infos that are related to the specific entity. " + + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + + "See 'Model' tab of the Parameters for more info. " + RELATION_INFO_DESCRIPTION, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/relations/info", method = RequestMethod.POST) + @ResponseBody + public List findInfoByQuery(@ApiParam(value = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getFilters()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByQuery(getTenantId(), query).get())); + } catch (Exception e) { + throw handleException(e); + } + } + + private void checkCanCreateRelation(EntityId entityId) throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + var isTenantAdminAndRelateToSelf = currentUser.isTenantAdmin() && currentUser.getTenantId().equals(entityId); + if (!isTenantAdminAndRelateToSelf) { + checkEntityId(entityId, Operation.WRITE); + } + } + + private List filterRelationsByReadPermission(List relationsByQuery) { + return relationsByQuery.stream().filter(relationByQuery -> { + try { + checkEntityId(relationByQuery.getTo(), Operation.READ); + } catch (ThingsboardException e) { + return false; + } + try { + checkEntityId(relationByQuery.getFrom(), Operation.READ); + } catch (ThingsboardException e) { + return false; + } + return true; + }).collect(Collectors.toList()); + } + + private RelationTypeGroup parseRelationTypeGroup(String strRelationTypeGroup, RelationTypeGroup defaultValue) { + RelationTypeGroup result = defaultValue; + if (strRelationTypeGroup != null && strRelationTypeGroup.trim().length() > 0) { + try { + result = RelationTypeGroup.valueOf(strRelationTypeGroup); + } catch (IllegalArgumentException e) { + } + } + return result; + } + +} 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..0f299fe --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -0,0 +1,528 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.EntitySubtype; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.edge.Edge; +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.EdgeId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_INFO_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; +import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.EdgeController.EDGE_ID; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api") +@Slf4j +public class EntityViewController extends BaseController { + + public final TbEntityViewService tbEntityViewService; + + public static final String ENTITY_VIEW_ID = "entityViewId"; + + @ApiOperation(value = "Get entity view (getEntityViewById)", + notes = "Fetch the EntityView object based on the provided entity view id. " + + ENTITY_VIEW_DESCRIPTION + MODEL_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) + @ResponseBody + public EntityView getEntityViewById( + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + return checkEntityViewId(new EntityViewId(toUUID(strEntityViewId)), Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Entity View info (getEntityViewInfoById)", + notes = "Fetch the Entity View info object based on the provided Entity View Id. " + + ENTITY_VIEW_INFO_DESCRIPTION + MODEL_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/info/{entityViewId}", method = RequestMethod.GET) + @ResponseBody + public EntityViewInfo getEntityViewInfoById( + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + return checkEntityViewInfoId(entityViewId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save or update entity view (saveEntityView)", + notes = ENTITY_VIEW_DESCRIPTION + MODEL_DESCRIPTION + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Entity View entity." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView", method = RequestMethod.POST) + @ResponseBody + public EntityView saveEntityView( + @ApiParam(value = "A JSON object representing the entity view.") + @RequestBody EntityView entityView) throws Exception { + entityView.setTenantId(getCurrentUser().getTenantId()); + EntityView existingEntityView = null; + if (entityView.getId() == null) { + accessControlService + .checkPermission(getCurrentUser(), Resource.ENTITY_VIEW, Operation.CREATE, null, entityView); + } else { + existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); + } + return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser()); + } + + @ApiOperation(value = "Delete entity view (deleteEntityView)", + notes = "Delete the EntityView object based on the provided entity view id. " + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteEntityView( + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId, Operation.DELETE); + tbEntityViewService.delete(entityView, getCurrentUser()); + } + + @ApiOperation(value = "Get Entity View by name (getTenantEntityView)", + notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"entityViewName"}, method = RequestMethod.GET) + @ResponseBody + public EntityView getTenantEntityView( + @ApiParam(value = "Entity View name") + @RequestParam String entityViewName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Assign Entity View to customer (assignEntityViewToCustomer)", + notes = "Creates assignment of the Entity View to customer. Customer will be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) + @ResponseBody + public EntityView assignEntityViewToCustomer( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId, Operation.READ); + + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + checkEntityViewId(entityViewId, Operation.ASSIGN_TO_CUSTOMER); + + return tbEntityViewService.assignEntityViewToCustomer(getTenantId(), entityViewId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Unassign Entity View from customer (unassignEntityViewFromCustomer)", + notes = "Clears assignment of the Entity View to customer. Customer will not be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseBody + public EntityView unassignEntityViewFromCustomer( + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId, Operation.UNASSIGN_FROM_CUSTOMER); + 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(), Operation.READ); + + return tbEntityViewService.unassignEntityViewFromCustomer(getTenantId(), entityViewId, customer, getCurrentUser()); + } + + @ApiOperation(value = "Get Customer Entity Views (getCustomerEntityViews)", + notes = "Returns a page of Entity View objects assigned to customer. " + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerEntityViews( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VIEW_TYPE) + @RequestParam(required = false) String type, + @ApiParam(value = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ENTITY_VIEW_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + 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); + } + } + + @ApiOperation(value = "Get Customer Entity View info (getCustomerEntityViewInfos)", + notes = "Returns a page of Entity View info objects assigned to customer. " + ENTITY_VIEW_DESCRIPTION + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/entityViewInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerEntityViewInfos( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VIEW_TYPE) + @RequestParam(required = false) String type, + @ApiParam(value = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ENTITY_VIEW_INFO_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); + } else { + return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Entity Views (getTenantEntityViews)", + notes = "Returns a page of entity views owned by tenant. " + ENTITY_VIEW_DESCRIPTION + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantEntityViews( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VIEW_TYPE) + @RequestParam(required = false) String type, + @ApiParam(value = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ENTITY_VIEW_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + + 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); + } + } + + @ApiOperation(value = "Get Tenant Entity Views (getTenantEntityViews)", + notes = "Returns a page of entity views info owned by tenant. " + ENTITY_VIEW_DESCRIPTION + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViewInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantEntityViewInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = ENTITY_VIEW_TYPE) + @RequestParam(required = false) String type, + @ApiParam(value = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = ENTITY_VIEW_INFO_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndType(tenantId, type, pageLink)); + } else { + return checkNotNull(entityViewService.findEntityViewInfosByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Find related entity views (findByQuery)", + notes = "Returns all entity views that are related to the specific entity. " + + "The entity id, relation type, entity view types, depth of the search, and other query parameters defined using complex 'EntityViewSearchQuery' object. " + + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityViews", method = RequestMethod.POST) + @ResponseBody + public List findByQuery( + @ApiParam(value = "The entity view search query JSON") + @RequestBody EntityViewSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getEntityViewTypes()); + checkEntityId(query.getParameters().getEntityId(), Operation.READ); + try { + List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(getTenantId(), query).get()); + entityViews = entityViews.stream().filter(entityView -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ENTITY_VIEW, Operation.READ, entityView.getId(), entityView); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return entityViews; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Entity View Types (getEntityViewTypes)", + notes = "Returns a set of unique entity view types based on entity views that are either owned by the tenant or assigned to the customer which user is performing the request." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/types", method = RequestMethod.GET) + @ResponseBody + public List getEntityViewTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> entityViewTypes = entityViewService.findEntityViewTypesByTenantId(tenantId); + return checkNotNull(entityViewTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Make entity view publicly available (assignEntityViewToPublicCustomer)", + notes = "Entity View will be available for non-authorized (not logged-in) users. " + + "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + + "However, users that are logged-in and belong to different tenant will not be able to access the entity view." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/public/entityView/{entityViewId}", method = RequestMethod.POST) + @ResponseBody + public EntityView assignEntityViewToPublicCustomer( + @ApiParam(value = ENTITY_VIEW_ID_PARAM_DESCRIPTION) + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + checkEntityViewId(entityViewId, Operation.ASSIGN_TO_CUSTOMER); + + Customer publicCustomer = customerService.findOrCreatePublicCustomer(getTenantId()); + + return tbEntityViewService.assignEntityViewToPublicCustomer(getTenantId(), getCurrentUser().getCustomerId(), + publicCustomer, entityViewId, getCurrentUser()); + } + + @ApiOperation(value = "Assign entity view to edge (assignEntityViewToEdge)", + notes = "Creates assignment of an existing entity view to an instance of The Edge. " + + EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive a copy of assignment entity view " + + EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once entity view will be delivered to edge service, it's going to be available for usage on remote edge instance.", + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/entityView/{entityViewId}", method = RequestMethod.POST) + @ResponseBody + public EntityView assignEntityViewToEdge(@PathVariable(EDGE_ID) String strEdgeId, + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + checkEntityViewId(entityViewId, Operation.READ); + + return tbEntityViewService.assignEntityViewToEdge(getTenantId(), getCurrentUser().getCustomerId(), + entityViewId, edge, getCurrentUser()); + } + + @ApiOperation(value = "Unassign entity view from edge (unassignEntityViewFromEdge)", + notes = "Clears assignment of the entity view to the edge. " + + EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive an 'unassign' command to remove entity view " + + EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once 'unassign' command will be delivered to edge service, it's going to remove entity view locally.", + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseBody + public EntityView unassignEntityViewFromEdge(@PathVariable(EDGE_ID) String strEdgeId, + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.READ); + + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId, Operation.READ); + + return tbEntityViewService.unassignEntityViewFromEdge(getTenantId(), entityView.getCustomerId(), entityView, + edge, getCurrentUser()); + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/edge/{edgeId}/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeEntityViews( + @PathVariable(EDGE_ID) String strEdgeId, + @RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) String type, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder, + @RequestParam(required = false) Long startTime, + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + PageData nonFilteredResult; + if (type != null && type.trim().length() > 0) { + nonFilteredResult = entityViewService.findEntityViewsByTenantIdAndEdgeIdAndType(tenantId, edgeId, type, pageLink); + } else { + nonFilteredResult = entityViewService.findEntityViewsByTenantIdAndEdgeId(tenantId, edgeId, pageLink); + } + List filteredEntityViews = nonFilteredResult.getData().stream().filter(entityView -> { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.ENTITY_VIEW, Operation.READ, entityView.getId(), entityView); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + PageData filteredResult = new PageData<>(filteredEntityViews, + nonFilteredResult.getTotalPages(), + nonFilteredResult.getTotalElements(), + nonFilteredResult.hasNext()); + return checkNotNull(filteredResult); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java new file mode 100644 index 0000000..db04059 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -0,0 +1,270 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.DataConstants; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.event.EventFilter; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.Locale; + +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_DEBUG_RULE_CHAIN_FILTER_OBJ; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_DEBUG_RULE_NODE_FILTER_OBJ; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_END_TIME_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_ERROR_FILTER_OBJ; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_LC_EVENT_FILTER_OBJ; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_START_TIME_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_STATS_FILTER_OBJ; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class EventController extends BaseController { + + private static final String EVENT_FILTER_DEFINITION = "# Event Filter Definition" + NEW_LINE + + "5 different eventFilter objects could be set for different event types. " + + "The eventType field is required. Others are optional. If some of them are set, the filtering will be applied according to them. " + + "See the examples below for all the fields used for each event type filtering. " + NEW_LINE + + "Note," + NEW_LINE + + " * 'server' - string value representing the server name, identifier or ip address where the platform is running;\n" + + " * 'errorStr' - the case insensitive 'contains' filter based on error message." + NEW_LINE + + "## Error Event Filter" + NEW_LINE + + EVENT_ERROR_FILTER_OBJ + NEW_LINE + + " * 'method' - string value representing the method name when the error happened." + NEW_LINE + + "## Lifecycle Event Filter" + NEW_LINE + + EVENT_LC_EVENT_FILTER_OBJ + NEW_LINE + + " * 'event' - string value representing the lifecycle event type;\n" + + " * 'status' - string value representing status of the lifecycle event." + NEW_LINE + + "## Statistics Event Filter" + NEW_LINE + + EVENT_STATS_FILTER_OBJ + NEW_LINE + + " * 'messagesProcessed' - the minimum number of successfully processed messages;\n" + + " * 'errorsOccurred' - the minimum number of errors occurred during messages processing." + NEW_LINE + + "## Debug Rule Node Event Filter" + NEW_LINE + + EVENT_DEBUG_RULE_NODE_FILTER_OBJ + NEW_LINE + + "## Debug Rule Chain Event Filter" + NEW_LINE + + EVENT_DEBUG_RULE_CHAIN_FILTER_OBJ + NEW_LINE + + " * 'msgDirectionType' - string value representing msg direction type (incoming to entity or outcoming from entity);\n" + + " * 'dataSearch' - the case insensitive 'contains' filter based on data (key and value) for the message;\n" + + " * 'metadataSearch' - the case insensitive 'contains' filter based on metadata (key and value) for the message;\n" + + " * 'entityName' - string value representing the entity type;\n" + + " * 'relationType' - string value representing the type of message routing;\n" + + " * 'entityId' - string value representing the entity id in the event body (originator of the message);\n" + + " * 'msgType' - string value representing the message type;\n" + + " * 'isError' - boolean value to filter the errors." + NEW_LINE; + + @Autowired + private EventService eventService; + + @ApiOperation(value = "Get Events by type (getEvents)", + notes = "Returns a page of events for specified entity by specifying event type. " + + PAGE_DATA_PARAMETERS, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/events/{entityType}/{entityId}/{eventType}", method = RequestMethod.GET) + @ResponseBody + public PageData getEvents( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = "A string value representing event type", example = "STATS", required = true) + @PathVariable("eventType") String eventType, + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION, required = true) + @RequestParam(TENANT_ID) String strTenantId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EVENT_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EVENT_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = EVENT_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = EVENT_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + checkEntityId(entityId, Operation.READ); + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + return checkNotNull(eventService.findEvents(tenantId, entityId, resolveEventType(eventType), pageLink)); + } + + @ApiOperation(value = "Get Events (Deprecated)", + notes = "Returns a page of events for specified entity. Deprecated and will be removed in next minor release. " + + "The call was deprecated to improve the performance of the system. " + + "Current implementation will return 'Lifecycle' events only. " + + "Use 'Get events by type' or 'Get events by filter' instead. " + + PAGE_DATA_PARAMETERS, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.GET) + @ResponseBody + public PageData getEvents( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION, required = true) + @RequestParam("tenantId") String strTenantId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = EVENT_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EVENT_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = EVENT_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = EVENT_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + checkEntityId(entityId, Operation.READ); + + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + + return checkNotNull(eventService.findEvents(tenantId, entityId, EventType.LC_EVENT, pageLink)); + } + + @ApiOperation(value = "Get Events by event filter (getEvents)", + notes = "Returns a page of events for the chosen entity by specifying the event filter. " + + PAGE_DATA_PARAMETERS + NEW_LINE + + EVENT_FILTER_DEFINITION, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.POST) + @ResponseBody + public PageData getEvents( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION, required = true) + @RequestParam(TENANT_ID) String strTenantId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = "A JSON value representing the event filter.", required = true) + @RequestBody EventFilter eventFilter, + @ApiParam(value = EVENT_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = EVENT_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder, + @ApiParam(value = EVENT_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = EVENT_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + checkEntityId(entityId, Operation.READ); + + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + return checkNotNull(eventService.findEventsByFilter(tenantId, entityId, eventFilter, pageLink)); + } + + @ApiOperation(value = "Clear Events (clearEvents)", notes = "Clears events by filter for specified entity.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/events/{entityType}/{entityId}/clear", method = RequestMethod.POST) + @ResponseStatus(HttpStatus.OK) + public void clearEvents(@ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_TYPE) String strEntityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(ENTITY_ID) String strEntityId, + @ApiParam(value = EVENT_START_TIME_DESCRIPTION) + @RequestParam(required = false) Long startTime, + @ApiParam(value = EVENT_END_TIME_DESCRIPTION) + @RequestParam(required = false) Long endTime, + @ApiParam(value = EVENT_FILTER_DEFINITION) + @RequestBody EventFilter eventFilter) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + checkEntityId(entityId, Operation.WRITE); + + eventService.removeEvents(getTenantId(), entityId, eventFilter, startTime, endTime); + } catch (Exception e) { + throw handleException(e); + } + } + + private static EventType resolveEventType(String eventType) throws ThingsboardException { + for (var et : EventType.values()) { + if (et.name().equalsIgnoreCase(eventType) || et.getOldName().equalsIgnoreCase(eventType)) { + return et; + } + } + throw new ThingsboardException("Event type: '" + eventType + "' is not supported!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/HttpValidationCallback.java b/application/src/main/java/org/thingsboard/server/controller/HttpValidationCallback.java new file mode 100644 index 0000000..ca2101f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/HttpValidationCallback.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.service.security.ValidationCallback; + +/** + * Created by ashvayka on 21.02.17. + */ +public class HttpValidationCallback extends ValidationCallback> { + + public HttpValidationCallback(DeferredResult response, FutureCallback> action) { + super(response, action); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java new file mode 100644 index 0000000..8a47ed2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.service.lwm2m.LwM2MService; + +import java.util.Map; + +import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + +@Slf4j +@RestController +@ConditionalOnExpression("('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && '${transport.lwm2m.enabled:false}'=='true'") +@RequestMapping("/api") +public class Lwm2mController extends BaseController { + + @Autowired + private DeviceController deviceController; + + @Autowired + private LwM2MService lwM2MService; + + public static final String IS_BOOTSTRAP_SERVER = "isBootstrapServer"; + + @ApiOperation(value = "Get Lwm2m Bootstrap SecurityInfo (getLwm2mBootstrapSecurityInfo)", + notes = "Get the Lwm2m Bootstrap SecurityInfo object (of the current server) based on the provided isBootstrapServer parameter. If isBootstrapServer == true, get the parameters of the current Bootstrap Server. If isBootstrapServer == false, get the parameters of the current Lwm2m Server. Used for client settings when starting the client in Bootstrap mode. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/lwm2m/deviceProfile/bootstrap/{isBootstrapServer}", method = RequestMethod.GET) + @ResponseBody + public LwM2MServerSecurityConfigDefault getLwm2mBootstrapSecurityInfo( + @ApiParam(value = IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION) + @PathVariable(IS_BOOTSTRAP_SERVER) boolean bootstrapServer) throws ThingsboardException { + try { + return lwM2MService.getServerSecurityInfo(bootstrapServer); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(hidden = true, value = "Save device with credentials (Deprecated)") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/lwm2m/device-credentials", method = RequestMethod.POST) + @ResponseBody + public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { + ObjectMapper mapper = new ObjectMapper(); + Device device = checkNotNull(mapper.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); + DeviceCredentials credentials = checkNotNull(mapper.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java new file mode 100644 index 0000000..f08f8f3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +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.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.OAuth2ClientRegistrationTemplateId; +import org.thingsboard.server.common.data.oauth2.OAuth2ClientRegistrationTemplate; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.List; + +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequestMapping("/api/oauth2/config/template") +@Slf4j +public class OAuth2ConfigTemplateController extends BaseController { + private static final String CLIENT_REGISTRATION_TEMPLATE_ID = "clientRegistrationTemplateId"; + + private static final String OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION = "Client registration template is OAuth2 provider configuration template with default settings for registering new OAuth2 clients"; + + @ApiOperation(value = "Create or update OAuth2 client registration template (saveClientRegistrationTemplate)" + SYSTEM_AUTHORITY_PARAGRAPH, + notes = OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public OAuth2ClientRegistrationTemplate saveClientRegistrationTemplate(@RequestBody OAuth2ClientRegistrationTemplate clientRegistrationTemplate) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_TEMPLATE, Operation.WRITE); + return oAuth2ConfigTemplateService.saveClientRegistrationTemplate(clientRegistrationTemplate); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete OAuth2 client registration template by id (deleteClientRegistrationTemplate)" + SYSTEM_AUTHORITY_PARAGRAPH, + notes = OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/{clientRegistrationTemplateId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteClientRegistrationTemplate(@ApiParam(value = "String representation of client registration template id to delete", example = "139b1f81-2f5d-11ec-9dbe-9b627e1a88f4") + @PathVariable(CLIENT_REGISTRATION_TEMPLATE_ID) String strClientRegistrationTemplateId) throws ThingsboardException { + checkParameter(CLIENT_REGISTRATION_TEMPLATE_ID, strClientRegistrationTemplateId); + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_TEMPLATE, Operation.DELETE); + OAuth2ClientRegistrationTemplateId clientRegistrationTemplateId = new OAuth2ClientRegistrationTemplateId(toUUID(strClientRegistrationTemplateId)); + oAuth2ConfigTemplateService.deleteClientRegistrationTemplateById(clientRegistrationTemplateId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + notes = OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(method = RequestMethod.GET, produces = "application/json") + @ResponseBody + public List getClientRegistrationTemplates() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_TEMPLATE, Operation.READ); + return oAuth2ConfigTemplateService.findAllClientRegistrationTemplates(); + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java new file mode 100644 index 0000000..5904fab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -0,0 +1,136 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo; +import org.thingsboard.server.common.data.oauth2.OAuth2Info; +import org.thingsboard.server.common.data.oauth2.PlatformType; +import org.thingsboard.server.dao.oauth2.OAuth2Configuration; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.utils.MiscUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.Enumeration; +import java.util.List; + +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@Slf4j +public class OAuth2Controller extends BaseController { + + @Autowired + private OAuth2Configuration oAuth2Configuration; + + + @ApiOperation(value = "Get OAuth2 clients (getOAuth2Clients)", notes = "Get the list of OAuth2 clients " + + "to log in with, available for such domain scheme (HTTP or HTTPS) (if x-forwarded-proto request header is present - " + + "the scheme is known from it) and domain name and port (port may be known from x-forwarded-port header)") + @RequestMapping(value = "/noauth/oauth2Clients", method = RequestMethod.POST) + @ResponseBody + public List getOAuth2Clients(HttpServletRequest request, + @ApiParam(value = "Mobile application package name, to find OAuth2 clients " + + "where there is configured mobile application with such package name") + @RequestParam(required = false) String pkgName, + @ApiParam(value = "Platform type to search OAuth2 clients for which " + + "the usage with this platform type is allowed in the settings. " + + "If platform type is not one of allowable values - it will just be ignored", + allowableValues = "WEB, ANDROID, IOS") + @RequestParam(required = false) String platform) throws ThingsboardException { + try { + if (log.isDebugEnabled()) { + log.debug("Executing getOAuth2Clients: [{}][{}][{}]", request.getScheme(), request.getServerName(), request.getServerPort()); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String header = headerNames.nextElement(); + log.debug("Header: {} {}", header, request.getHeader(header)); + } + } + PlatformType platformType = null; + if (StringUtils.isNotEmpty(platform)) { + try { + platformType = PlatformType.valueOf(platform); + } catch (Exception e) {} + } + return oAuth2Service.getOAuth2Clients(MiscUtils.getScheme(request), MiscUtils.getDomainNameAndPort(request), pkgName, platformType); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get current OAuth2 settings (getCurrentOAuth2Info)", notes = SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/oauth2/config", method = RequestMethod.GET, produces = "application/json") + @ResponseBody + public OAuth2Info getCurrentOAuth2Info() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_INFO, Operation.READ); + return oAuth2Service.findOAuth2Info(); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save OAuth2 settings (saveOAuth2Info)", notes = SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/oauth2/config", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public OAuth2Info saveOAuth2Info(@RequestBody OAuth2Info oauth2Info) throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_INFO, Operation.WRITE); + oAuth2Service.saveOAuth2Info(oauth2Info); + return oAuth2Service.findOAuth2Info(); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get OAuth2 log in processing URL (getLoginProcessingUrl)", notes = "Returns the URL enclosed in " + + "double quotes. After successful authentication with OAuth2 provider, it makes a redirect to this path so that the platform can do " + + "further log in processing. This URL may be configured as 'security.oauth2.loginProcessingUrl' property in yml configuration file, or " + + "as 'SECURITY_OAUTH2_LOGIN_PROCESSING_URL' env variable. By default it is '/login/oauth2/code/'" + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/oauth2/loginProcessingUrl", method = RequestMethod.GET) + @ResponseBody + public String getLoginProcessingUrl() throws ThingsboardException { + try { + accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_INFO, Operation.READ); + return "\"" + oAuth2Configuration.getLoginProcessingUrl() + "\""; + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java new file mode 100644 index 0000000..0040187 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -0,0 +1,262 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.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.RequestPart; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; +import org.thingsboard.server.common.data.ota.OtaPackageType; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.ota.TbOtaPackageService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.io.IOException; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_CHECKSUM_ALGORITHM_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@Slf4j +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class OtaPackageController extends BaseController { + + private final TbOtaPackageService tbOtaPackageService; + + public static final String OTA_PACKAGE_ID = "otaPackageId"; + public static final String CHECKSUM_ALGORITHM = "checksumAlgorithm"; + + @ApiOperation(value = "Download OTA Package (downloadOtaPackage)", notes = "Download OTA Package based on the provided OTA Package Id." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority( 'TENANT_ADMIN')") + @RequestMapping(value = "/otaPackage/{otaPackageId}/download", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity downloadOtaPackage(@ApiParam(value = OTA_PACKAGE_ID_PARAM_DESCRIPTION) + @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { + checkParameter(OTA_PACKAGE_ID, strOtaPackageId); + try { + OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); + OtaPackage otaPackage = checkOtaPackageId(otaPackageId, Operation.READ); + + if (otaPackage.hasUrl()) { + return ResponseEntity.badRequest().build(); + } + + ByteArrayResource resource = new ByteArrayResource(otaPackage.getData().array()); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + otaPackage.getFileName()) + .header("x-filename", otaPackage.getFileName()) + .contentLength(resource.contentLength()) + .contentType(parseMediaType(otaPackage.getContentType())) + .body(resource); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get OTA Package Info (getOtaPackageInfoById)", + notes = "Fetch the OTA Package Info object based on the provided OTA Package Id. " + + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/otaPackage/info/{otaPackageId}", method = RequestMethod.GET) + @ResponseBody + public OtaPackageInfo getOtaPackageInfoById(@ApiParam(value = OTA_PACKAGE_ID_PARAM_DESCRIPTION) + @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { + checkParameter(OTA_PACKAGE_ID, strOtaPackageId); + try { + OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); + return checkNotNull(otaPackageService.findOtaPackageInfoById(getTenantId(), otaPackageId)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get OTA Package (getOtaPackageById)", + notes = "Fetch the OTA Package object based on the provided OTA Package Id. " + + "The server checks that the OTA Package is owned by the same tenant. " + OTA_PACKAGE_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.GET) + @ResponseBody + public OtaPackage getOtaPackageById(@ApiParam(value = OTA_PACKAGE_ID_PARAM_DESCRIPTION) + @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { + checkParameter(OTA_PACKAGE_ID, strOtaPackageId); + try { + OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); + return checkOtaPackageId(otaPackageId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update OTA Package Info (saveOtaPackageInfo)", + notes = "Create or update the OTA Package Info. When creating OTA Package Info, platform generates OTA Package id as " + UUID_WIKI_LINK + + "The newly created OTA Package id will be present in the response. " + + "Specify existing OTA Package id to update the OTA Package Info. " + + "Referencing non-existing OTA Package Id will cause 'Not Found' error. " + + "\n\nOTA Package combination of the title with the version is unique in the scope of tenant. " + TENANT_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE, + consumes = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/otaPackage", method = RequestMethod.POST) + @ResponseBody + public OtaPackageInfo saveOtaPackageInfo(@ApiParam(value = "A JSON value representing the OTA Package.") + @RequestBody SaveOtaPackageInfoRequest otaPackageInfo) throws ThingsboardException { + otaPackageInfo.setTenantId(getTenantId()); + checkEntity(otaPackageInfo.getId(), otaPackageInfo, Resource.OTA_PACKAGE); + + return tbOtaPackageService.save(otaPackageInfo, getCurrentUser()); + } + + @ApiOperation(value = "Save OTA Package data (saveOtaPackageData)", + notes = "Update the OTA Package. Adds the date to the existing OTA Package Info" + TENANT_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE, + consumes = MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.POST, consumes = MULTIPART_FORM_DATA_VALUE) + @ResponseBody + public OtaPackageInfo saveOtaPackageData(@ApiParam(value = OTA_PACKAGE_ID_PARAM_DESCRIPTION) + @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId, + @ApiParam(value = "OTA Package checksum. For example, '0xd87f7e0c'") + @RequestParam(required = false) String checksum, + @ApiParam(value = "OTA Package checksum algorithm.", allowableValues = OTA_PACKAGE_CHECKSUM_ALGORITHM_ALLOWABLE_VALUES) + @RequestParam(CHECKSUM_ALGORITHM) String checksumAlgorithmStr, + @ApiParam(value = "OTA Package data.") + @RequestPart MultipartFile file) throws ThingsboardException, IOException { + checkParameter(OTA_PACKAGE_ID, strOtaPackageId); + checkParameter(CHECKSUM_ALGORITHM, checksumAlgorithmStr); + OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); + OtaPackageInfo otaPackageInfo = checkOtaPackageInfoId(otaPackageId, Operation.READ); + ChecksumAlgorithm checksumAlgorithm = ChecksumAlgorithm.valueOf(checksumAlgorithmStr.toUpperCase()); + byte[] data = file.getBytes(); + return tbOtaPackageService.saveOtaPackageData(otaPackageInfo, checksum, checksumAlgorithm, + data, file.getOriginalFilename(), file.getContentType(), getCurrentUser()); + } + + @ApiOperation(value = "Get OTA Package Infos (getOtaPackages)", + notes = "Returns a page of OTA Package Info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/otaPackages", method = RequestMethod.GET) + @ResponseBody + public PageData getOtaPackages(@ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = OTA_PACKAGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(otaPackageService.findTenantOtaPackagesByTenantId(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get OTA Package Infos (getOtaPackages)", + notes = "Returns a page of OTA Package Info objects owned by tenant. " + + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/otaPackages/{deviceProfileId}/{type}", method = RequestMethod.GET) + @ResponseBody + public PageData getOtaPackages(@ApiParam(value = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceProfileId") String strDeviceProfileId, + @ApiParam(value = "OTA Package type.", allowableValues = "FIRMWARE, SOFTWARE") + @PathVariable("type") String strType, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = OTA_PACKAGE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("deviceProfileId", strDeviceProfileId); + checkParameter("type", strType); + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(otaPackageService.findTenantOtaPackagesByTenantIdAndDeviceProfileIdAndTypeAndHasData(getTenantId(), + new DeviceProfileId(toUUID(strDeviceProfileId)), OtaPackageType.valueOf(strType), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete OTA Package (deleteOtaPackage)", + notes = "Deletes the OTA Package. Referencing non-existing OTA Package Id will cause an error. " + + "Can't delete the OTA Package if it is referenced by existing devices or device profile." + TENANT_AUTHORITY_PARAGRAPH, + produces = APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.DELETE) + @ResponseBody + public void deleteOtaPackage(@ApiParam(value = OTA_PACKAGE_ID_PARAM_DESCRIPTION) + @PathVariable("otaPackageId") String strOtaPackageId) throws ThingsboardException { + checkParameter(OTA_PACKAGE_ID, strOtaPackageId); + OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); + OtaPackageInfo otaPackageInfo = checkOtaPackageInfoId(otaPackageId, Operation.DELETE); + tbOtaPackageService.delete(otaPackageInfo, getCurrentUser()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueController.java b/application/src/main/java/org/thingsboard/server/controller/QueueController.java new file mode 100644 index 0000000..3b37e67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/QueueController.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +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.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.queue.TbQueueService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.UUID; + +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_NAME_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_QUEUE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_SERVICE_TYPE_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_SERVICE_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.QUEUE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class QueueController extends BaseController { + + private final TbQueueService tbQueueService; + + @ApiOperation(value = "Get Queues (getTenantQueuesByServiceType)", + notes = "Returns a page of queues registered in the platform. " + + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/queues", params = {"serviceType", "pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantQueuesByServiceType(@ApiParam(value = QUEUE_SERVICE_TYPE_DESCRIPTION, allowableValues = QUEUE_SERVICE_TYPE_ALLOWABLE_VALUES, required = true) + @RequestParam String serviceType, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = QUEUE_QUEUE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = QUEUE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("serviceType", serviceType); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + ServiceType type = ServiceType.valueOf(serviceType); + switch (type) { + case TB_RULE_ENGINE: + return queueService.findQueuesByTenantId(getTenantId(), pageLink); + default: + return new PageData<>(); + } + } + + @ApiOperation(value = "Get Queue (getQueueById)", + notes = "Fetch the Queue object based on the provided Queue Id. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/queues/{queueId}", method = RequestMethod.GET) + @ResponseBody + public Queue getQueueById(@ApiParam(value = QUEUE_ID_PARAM_DESCRIPTION) + @PathVariable("queueId") String queueIdStr) throws ThingsboardException { + checkParameter("queueId", queueIdStr); + QueueId queueId = new QueueId(UUID.fromString(queueIdStr)); + checkQueueId(queueId, Operation.READ); + return checkNotNull(queueService.findQueueById(getTenantId(), queueId)); + } + + @ApiOperation(value = "Get Queue (getQueueByName)", + notes = "Fetch the Queue object based on the provided Queue name. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/queues/name/{queueName}", method = RequestMethod.GET) + @ResponseBody + public Queue getQueueByName(@ApiParam(value = QUEUE_NAME_PARAM_DESCRIPTION) + @PathVariable("queueName") String queueName) throws ThingsboardException { + checkParameter("queueName", queueName); + return checkNotNull(queueService.findQueueByTenantIdAndName(getTenantId(), queueName)); + } + + @ApiOperation(value = "Create Or Update Queue (saveQueue)", + notes = "Create or update the Queue. When creating queue, platform generates Queue Id as " + UUID_WIKI_LINK + + "Specify existing Queue id to update the queue. " + + "Referencing non-existing Queue Id will cause 'Not Found' error." + + "\n\nQueue name is unique in the scope of sysadmin. " + + "Remove 'id', 'tenantId' from the request body example (below) to create new Queue entity. " + + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/queues", params = {"serviceType"}, method = RequestMethod.POST) + @ResponseBody + + public Queue saveQueue(@ApiParam(value = "A JSON value representing the queue.") + @RequestBody Queue queue, + @ApiParam(value = QUEUE_SERVICE_TYPE_DESCRIPTION, allowableValues = QUEUE_SERVICE_TYPE_ALLOWABLE_VALUES, required = true) + @RequestParam String serviceType) throws ThingsboardException { + checkParameter("serviceType", serviceType); + queue.setTenantId(getCurrentUser().getTenantId()); + + checkEntity(queue.getId(), queue, Resource.QUEUE); + + ServiceType type = ServiceType.valueOf(serviceType); + switch (type) { + case TB_RULE_ENGINE: + queue.setTenantId(getTenantId()); + Queue savedQueue = tbQueueService.saveQueue(queue); + checkNotNull(savedQueue); + return savedQueue; + default: + return null; + } + } + + @ApiOperation(value = "Delete Queue (deleteQueue)", notes = "Deletes the Queue. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/queues/{queueId}", method = RequestMethod.DELETE) + @ResponseBody + public void deleteQueue(@ApiParam(value = QUEUE_ID_PARAM_DESCRIPTION) + @PathVariable("queueId") String queueIdStr) throws ThingsboardException { + checkParameter("queueId", queueIdStr); + QueueId queueId = new QueueId(toUUID(queueIdStr)); + checkQueueId(queueId, Operation.DELETE); + tbQueueService.deleteQueue(getTenantId(), queueId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java new file mode 100644 index 0000000..05cdf61 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.UUID; + +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequestMapping(TbUrlConstants.RPC_V1_URL_PREFIX) +@Slf4j +public class RpcV1Controller extends AbstractRpcController { + + @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleOneWayDeviceRPCRequest( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceId") String deviceIdStr, + @ApiParam(value = "A JSON value representing the RPC request.") + @RequestBody String requestBody) throws ThingsboardException { + return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); + } + + @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleTwoWayDeviceRPCRequest( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceId") String deviceIdStr, + @ApiParam(value = "A JSON value representing the RPC request.") + @RequestBody String requestBody) throws ThingsboardException { + return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java new file mode 100644 index 0000000..300d522 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java @@ -0,0 +1,258 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.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.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.server.ResponseStatusException; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rpc.RpcStatus; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.rpc.RemoveRpcActorMsg; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.telemetry.exception.ToErrorResponseEntity; + +import javax.annotation.Nullable; +import java.util.UUID; + +import static org.thingsboard.server.common.data.DataConstants.RPC_DELETED; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RPC_ID; +import static org.thingsboard.server.controller.ControllerConstants.RPC_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RPC_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.RPC_STATUS_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.RPC_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + +@RestController +@TbCoreComponent +@RequestMapping(TbUrlConstants.RPC_V2_URL_PREFIX) +@Slf4j +public class RpcV2Controller extends AbstractRpcController { + + private static final String RPC_REQUEST_DESCRIPTION = "Sends the one-way remote-procedure call (RPC) request to device. " + + "The RPC call is A JSON that contains the method name ('method'), parameters ('params') and multiple optional fields. " + + "See example below. We will review the properties of the RPC call one-by-one below. " + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"method\": \"setGpio\",\n" + + " \"params\": {\n" + + " \"pin\": 7,\n" + + " \"value\": 1\n" + + " },\n" + + " \"persistent\": false,\n" + + " \"timeout\": 5000\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n### Server-side RPC structure\n" + + "\n" + + "The body of server-side RPC request consists of multiple fields:\n" + + "\n" + + "* **method** - mandatory, name of the method to distinct the RPC calls.\n" + + " For example, \"getCurrentTime\" or \"getWeatherForecast\". The value of the parameter is a string.\n" + + "* **params** - mandatory, parameters used for processing of the request. The value is a JSON. Leave empty JSON \"{}\" if no parameters needed.\n" + + "* **timeout** - optional, value of the processing timeout in milliseconds. The default value is 10000 (10 seconds). The minimum value is 5000 (5 seconds).\n" + + "* **expirationTime** - optional, value of the epoch time (in milliseconds, UTC timezone). Overrides **timeout** if present.\n" + + "* **persistent** - optional, indicates persistent RPC. The default value is \"false\".\n" + + "* **retries** - optional, defines how many times persistent RPC will be re-sent in case of failures on the network and/or device side.\n" + + "* **additionalInfo** - optional, defines metadata for the persistent RPC that will be added to the persistent RPC events."; + + private static final String ONE_WAY_RPC_RESULT = "\n\n### RPC Result\n" + + "In case of persistent RPC, the result of this call is 'rpcId' UUID. In case of lightweight RPC, " + + "the result of this call is either 200 OK if the message was sent to device, or 504 Gateway Timeout if device is offline."; + + private static final String TWO_WAY_RPC_RESULT = "\n\n### RPC Result\n" + + "In case of persistent RPC, the result of this call is 'rpcId' UUID. In case of lightweight RPC, " + + "the result of this call is the response from device, or 504 Gateway Timeout if device is offline."; + + private static final String ONE_WAY_RPC_REQUEST_DESCRIPTION = "Sends the one-way remote-procedure call (RPC) request to device. " + RPC_REQUEST_DESCRIPTION + ONE_WAY_RPC_RESULT + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + + private static final String TWO_WAY_RPC_REQUEST_DESCRIPTION = "Sends the two-way remote-procedure call (RPC) request to device. " + RPC_REQUEST_DESCRIPTION + TWO_WAY_RPC_RESULT + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; + + @ApiOperation(value = "Send one-way RPC request", notes = ONE_WAY_RPC_REQUEST_DESCRIPTION) + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Persistent RPC request was saved to the database or lightweight RPC request was sent to the device."), + @ApiResponse(code = 400, message = "Invalid structure of the request."), + @ApiResponse(code = 401, message = "User is not authorized to send the RPC request. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 504, message = "Timeout to process the RPC call. Most likely, device is offline."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleOneWayDeviceRPCRequest( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceId") String deviceIdStr, + @ApiParam(value = "A JSON value representing the RPC request.") + @RequestBody String requestBody) throws ThingsboardException { + return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + } + + @ApiOperation(value = "Send two-way RPC request", notes = TWO_WAY_RPC_REQUEST_DESCRIPTION) + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Persistent RPC request was saved to the database or lightweight RPC response received."), + @ApiResponse(code = 400, message = "Invalid structure of the request."), + @ApiResponse(code = 401, message = "User is not authorized to send the RPC request. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 504, message = "Timeout to process the RPC call. Most likely, device is offline."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult handleTwoWayDeviceRPCRequest( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION) + @PathVariable(DEVICE_ID) String deviceIdStr, + @ApiParam(value = "A JSON value representing the RPC request.") + @RequestBody String requestBody) throws ThingsboardException { + return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + } + + @ApiOperation(value = "Get persistent RPC request", notes = "Get information about the status of the RPC call." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/persistent/{rpcId}", method = RequestMethod.GET) + @ResponseBody + public Rpc getPersistedRpc( + @ApiParam(value = RPC_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(RPC_ID) String strRpc) throws ThingsboardException { + checkParameter("RpcId", strRpc); + try { + RpcId rpcId = new RpcId(UUID.fromString(strRpc)); + return checkRpcId(rpcId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get persistent RPC requests", notes = "Allows to query RPC calls for specific device using pagination." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/persistent/device/{deviceId}", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getPersistedRpcByDevice( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(DEVICE_ID) String strDeviceId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = "Status of the RPC", allowableValues = RPC_STATUS_ALLOWABLE_VALUES) + @RequestParam(required = false) RpcStatus rpcStatus, + @ApiParam(value = RPC_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RPC_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("DeviceId", strDeviceId); + try { + if (rpcStatus != null && rpcStatus.equals(RpcStatus.DELETED)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "RpcStatus: DELETED"); + } + + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + DeviceId deviceId = new DeviceId(UUID.fromString(strDeviceId)); + final DeferredResult response = new DeferredResult<>(); + + accessValidator.validate(getCurrentUser(), Operation.RPC_CALL, deviceId, new HttpValidationCallback(response, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable DeferredResult result) { + PageData rpcCalls; + if (rpcStatus != null) { + rpcCalls = rpcService.findAllByDeviceIdAndStatus(tenantId, deviceId, rpcStatus, pageLink); + } else { + rpcCalls = rpcService.findAllByDeviceId(tenantId, deviceId, pageLink); + } + response.setResult(new ResponseEntity<>(rpcCalls, HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable e) { + ResponseEntity entity; + if (e instanceof ToErrorResponseEntity) { + entity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); + } else { + entity = new ResponseEntity(HttpStatus.UNAUTHORIZED); + } + response.setResult(entity); + } + })); + return response; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete persistent RPC", notes = "Deletes the persistent RPC request." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/persistent/{rpcId}", method = RequestMethod.DELETE) + @ResponseBody + public void deleteRpc( + @ApiParam(value = RPC_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(RPC_ID) String strRpc) throws ThingsboardException { + checkParameter("RpcId", strRpc); + try { + RpcId rpcId = new RpcId(UUID.fromString(strRpc)); + Rpc rpc = checkRpcId(rpcId, Operation.DELETE); + + if (rpc != null) { + if (rpc.getStatus().equals(RpcStatus.QUEUED)) { + RemoveRpcActorMsg removeMsg = new RemoveRpcActorMsg(getTenantId(), rpc.getDeviceId(), rpc.getUuidId()); + log.trace("[{}] Forwarding msg {} to queue actor!", rpc.getDeviceId(), rpc); + tbClusterService.pushMsgToCore(removeMsg, null); + } + + rpcService.deleteRpc(getTenantId(), rpcId); + rpc.setStatus(RpcStatus.DELETED); + + TbMsg msg = TbMsg.newMsg(RPC_DELETED, rpc.getDeviceId(), TbMsgMetaData.EMPTY, JacksonUtil.toString(rpc)); + tbClusterService.pushMsgToRuleEngine(getTenantId(), rpc.getDeviceId(), msg, null); + } + } 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 new file mode 100644 index 0000000..dc74f36 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -0,0 +1,677 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.rule.engine.api.ScriptEngine; +import org.thingsboard.script.api.js.JsInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.tenant.DebugTbRateLimits; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageDataIterableByTenant; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.rule.DefaultRuleChainCreateRequest; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainData; +import org.thingsboard.server.common.data.rule.RuleChainImportResult; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainOutputLabelsUsage; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.script.ScriptLanguage; +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.dao.event.EventService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.rule.TbRuleChainService; +import org.thingsboard.server.service.script.RuleNodeJsScriptEngine; +import org.thingsboard.server.service.script.RuleNodeTbelScriptEngine; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_TYPES_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.RULE_CHAIN_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RULE_NODE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@Slf4j +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class RuleChainController extends BaseController { + + public static final String RULE_CHAIN_ID = "ruleChainId"; + public static final String RULE_NODE_ID = "ruleNodeId"; + + private static final int DEFAULT_PAGE_SIZE = 1000; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + public static final int TIMEOUT = 20; + + private static final String RULE_CHAIN_DESCRIPTION = "The rule chain object is lightweight and contains general information about the rule chain. " + + "List of rule nodes and their connection is stored in a separate 'metadata' object."; + private static final String RULE_CHAIN_METADATA_DESCRIPTION = "The metadata object contains information about the rule nodes and their connections."; + private static final String TEST_SCRIPT_FUNCTION = "Execute the Script function and return the result. The format of request: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"script\": \"Your Function as String\",\n" + + " \"scriptType\": \"One of: update, generate, filter, switch, json, string\",\n" + + " \"argNames\": [\"msg\", \"metadata\", \"type\"],\n" + + " \"msg\": \"{\\\"temperature\\\": 42}\", \n" + + " \"metadata\": {\n" + + " \"deviceName\": \"Device A\",\n" + + " \"deviceType\": \"Thermometer\"\n" + + " },\n" + + " \"msgType\": \"POST_TELEMETRY_REQUEST\"\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; + + @Autowired + protected TbRuleChainService tbRuleChainService; + + @Autowired + private EventService eventService; + + @Autowired + private JsInvokeService jsInvokeService; + + @Autowired(required = false) + private TbelInvokeService tbelInvokeService; + + @Autowired(required = false) + private ActorSystemContext actorContext; + + @Value("${actors.rule.chain.debug_mode_rate_limits_per_tenant.enabled}") + private boolean debugPerTenantEnabled; + + @Value("${tbel.enabled:true}") + private boolean tbelEnabled; + + @ApiOperation(value = "Get Rule Chain (getRuleChainById)", + notes = "Fetch the Rule Chain object based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.GET) + @ResponseBody + public RuleChain getRuleChainById( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + try { + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + return checkRuleChain(ruleChainId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Rule Chain output labels (getRuleChainOutputLabels)", + notes = "Fetch the unique labels for the \"output\" Rule Nodes that belong to the Rule Chain based on the provided Rule Chain Id. " + + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels", method = RequestMethod.GET) + @ResponseBody + public Set getRuleChainOutputLabels( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + try { + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + checkRuleChain(ruleChainId, Operation.READ); + return tbRuleChainService.getRuleChainOutputLabels(getTenantId(), ruleChainId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get output labels usage (getRuleChainOutputLabelsUsage)", + notes = "Fetch the list of rule chains and the relation types (labels) they use to process output of the current rule chain based on the provided Rule Chain Id. " + + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels/usage", method = RequestMethod.GET) + @ResponseBody + public List getRuleChainOutputLabelsUsage( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + try { + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + checkRuleChain(ruleChainId, Operation.READ); + return tbRuleChainService.getOutputLabelUsage(getCurrentUser().getTenantId(), ruleChainId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Rule Chain (getRuleChainById)", + notes = "Fetch the Rule Chain Metadata object based on the provided Rule Chain Id. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/metadata", method = RequestMethod.GET) + @ResponseBody + public RuleChainMetaData getRuleChainMetaData( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + try { + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + checkRuleChain(ruleChainId, Operation.READ); + return ruleChainService.loadRuleChainMetaData(getTenantId(), ruleChainId); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Rule Chain (saveRuleChain)", + notes = "Create or update the Rule Chain. When creating Rule Chain, platform generates Rule Chain Id as " + UUID_WIKI_LINK + + "The newly created Rule Chain Id will be present in the response. " + + "Specify existing Rule Chain id to update the rule chain. " + + "Referencing non-existing rule chain Id will cause 'Not Found' error." + + "\n\n" + RULE_CHAIN_DESCRIPTION + + "Remove 'id', 'tenantId' from the request body example (below) to create new Rule Chain entity." + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain", method = RequestMethod.POST) + @ResponseBody + public RuleChain saveRuleChain( + @ApiParam(value = "A JSON value representing the rule chain.") + @RequestBody RuleChain ruleChain) throws Exception { + ruleChain.setTenantId(getCurrentUser().getTenantId()); + checkEntity(ruleChain.getId(), ruleChain, Resource.RULE_CHAIN); + return tbRuleChainService.save(ruleChain, getCurrentUser()); + } + + @ApiOperation(value = "Create Default Rule Chain", + notes = "Create rule chain from template, based on the specified name in the request. " + + "Creates the rule chain based on the template that is used to create root rule chain. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/device/default", method = RequestMethod.POST) + @ResponseBody + public RuleChain saveRuleChain( + @ApiParam(value = "A JSON value representing the request.") + @RequestBody DefaultRuleChainCreateRequest request) throws Exception { + checkNotNull(request); + checkParameter(request.getName(), "name"); + return tbRuleChainService.saveDefaultByName(getTenantId(), request, getCurrentUser()); + } + + @ApiOperation(value = "Set Root Rule Chain (setRootRuleChain)", + notes = "Makes the rule chain to be root rule chain. Updates previous root rule chain as well. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/root", method = RequestMethod.POST) + @ResponseBody + public RuleChain setRootRuleChain( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.WRITE); + return tbRuleChainService.setRootRuleChain(getTenantId(), ruleChain, getCurrentUser()); + } + + @ApiOperation(value = "Update Rule Chain Metadata", + notes = "Updates the rule chain metadata. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/metadata", method = RequestMethod.POST) + @ResponseBody + public RuleChainMetaData saveRuleChainMetaData( + @ApiParam(value = "A JSON value representing the rule chain metadata.") + @RequestBody RuleChainMetaData ruleChainMetaData, + @ApiParam(value = "Update related rule nodes.") + @RequestParam(value = "updateRelated", required = false, defaultValue = "true") boolean updateRelated + ) throws Exception { + TenantId tenantId = getTenantId(); + if (debugPerTenantEnabled) { + ConcurrentMap debugPerTenantLimits = actorContext.getDebugPerTenantLimits(); + DebugTbRateLimits debugTbRateLimits = debugPerTenantLimits.getOrDefault(tenantId, null); + if (debugTbRateLimits != null) { + debugPerTenantLimits.remove(tenantId, debugTbRateLimits); + } + } + RuleChain ruleChain = checkRuleChain(ruleChainMetaData.getRuleChainId(), Operation.WRITE); + + return tbRuleChainService.saveRuleChainMetaData(tenantId, ruleChain, ruleChainMetaData, updateRelated, getCurrentUser()); + } + + @ApiOperation(value = "Get Rule Chains (getRuleChains)", + notes = "Returns a page of Rule Chains owned by tenant. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getRuleChains( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = RULE_CHAIN_TYPE_DESCRIPTION, allowableValues = RULE_CHAIN_TYPES_ALLOWABLE_VALUES) + @RequestParam(value = "type", required = false) String typeStr, + @ApiParam(value = RULE_CHAIN_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RULE_CHAIN_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + RuleChainType type = RuleChainType.CORE; + if (typeStr != null && typeStr.trim().length() > 0) { + type = RuleChainType.valueOf(typeStr); + } + return checkNotNull(ruleChainService.findTenantRuleChainsByType(tenantId, type, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete rule chain (deleteRuleChain)", + notes = "Deletes the rule chain. Referencing non-existing rule chain Id will cause an error. " + + "Referencing rule chain that is used in the device profiles will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteRuleChain( + @ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.DELETE); + tbRuleChainService.delete(ruleChain, getCurrentUser()); + } + + @ApiOperation(value = "Get latest input message (getLatestRuleNodeDebugInput)", + notes = "Gets the input message from the debug events for specified Rule Chain Id. " + + "Referencing non-existing rule chain Id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleNode/{ruleNodeId}/debugIn", method = RequestMethod.GET) + @ResponseBody + public JsonNode getLatestRuleNodeDebugInput( + @ApiParam(value = RULE_NODE_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_NODE_ID) String strRuleNodeId) throws ThingsboardException { + checkParameter(RULE_NODE_ID, strRuleNodeId); + try { + RuleNodeId ruleNodeId = new RuleNodeId(toUUID(strRuleNodeId)); + checkRuleNode(ruleNodeId, Operation.READ); + TenantId tenantId = getCurrentUser().getTenantId(); + List events = eventService.findLatestEvents(tenantId, ruleNodeId, EventType.DEBUG_RULE_NODE, 2); + JsonNode result = null; + if (events != null) { + for (EventInfo event : events) { + JsonNode body = event.getBody(); + if (body.has("type") && body.get("type").asText().equals("IN")) { + result = body; + break; + } + } + } + return result; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Is TBEL script executor enabled", + notes = "Returns 'True' if the TBEL script execution is enabled" + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/tbelEnabled", method = RequestMethod.GET) + @ResponseBody + public Boolean isTbelEnabled() { + return tbelEnabled; + } + + @ApiOperation(value = "Test Script function", + notes = TEST_SCRIPT_FUNCTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/testScript", method = RequestMethod.POST) + @ResponseBody + public JsonNode testScript( + @ApiParam(value = "Script language: JS or TBEL") + @RequestParam(required = false) ScriptLanguage scriptLang, + @ApiParam(value = "Test JS request. See API call description above.") + @RequestBody JsonNode inputParams) throws ThingsboardException { + try { + String script = inputParams.get("script").asText(); + String scriptType = inputParams.get("scriptType").asText(); + JsonNode argNamesJson = inputParams.get("argNames"); + String[] argNames = objectMapper.treeToValue(argNamesJson, String[].class); + + String data = inputParams.get("msg").asText(); + JsonNode metadataJson = inputParams.get("metadata"); + Map metadata = objectMapper.convertValue(metadataJson, new TypeReference>() { + }); + String msgType = inputParams.get("msgType").asText(); + String output = ""; + String errorText = ""; + ScriptEngine engine = null; + try { + if (scriptLang == null) { + scriptLang = ScriptLanguage.JS; + } + if (ScriptLanguage.JS.equals(scriptLang)) { + engine = new RuleNodeJsScriptEngine(getTenantId(), jsInvokeService, script, argNames); + } else { + if (tbelInvokeService == null) { + throw new IllegalArgumentException("TBEL script engine is disabled!"); + } + engine = new RuleNodeTbelScriptEngine(getTenantId(), tbelInvokeService, script, argNames); + } + TbMsg inMsg = TbMsg.newMsg(msgType, null, new TbMsgMetaData(metadata), TbMsgDataType.JSON, data); + switch (scriptType) { + case "update": + output = msgToOutput(engine.executeUpdateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + break; + case "generate": + output = msgToOutput(engine.executeGenerateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + break; + case "filter": + boolean result = engine.executeFilterAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + output = Boolean.toString(result); + break; + case "switch": + Set states = engine.executeSwitchAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + output = objectMapper.writeValueAsString(states); + break; + case "json": + JsonNode json = engine.executeJsonAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + output = objectMapper.writeValueAsString(json); + break; + case "string": + output = engine.executeToStringAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + break; + default: + throw new IllegalArgumentException("Unsupported script type: " + scriptType); + } + } catch (Exception e) { + log.error("Error evaluating JS function", e); + errorText = e.getMessage(); + } finally { + if (engine != null) { + engine.destroy(); + } + } + ObjectNode result = objectMapper.createObjectNode(); + result.put("output", output); + result.put("error", errorText); + return result; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Export Rule Chains", notes = "Exports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChains/export", params = {"limit"}, method = RequestMethod.GET) + @ResponseBody + public RuleChainData exportRuleChains( + @ApiParam(value = "A limit of rule chains to export.", required = true) + @RequestParam("limit") int limit) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + PageLink pageLink = new PageLink(limit); + return checkNotNull(ruleChainService.exportTenantRuleChains(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Import Rule Chains", notes = "Imports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChains/import", method = RequestMethod.POST) + @ResponseBody + public List importRuleChains( + @ApiParam(value = "A JSON value representing the rule chains.") + @RequestBody RuleChainData ruleChainData, + @ApiParam(value = "Enables overwrite for existing rule chains with the same name.") + @RequestParam(required = false, defaultValue = "false") boolean overwrite) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + List importResults = ruleChainService.importTenantRuleChains(tenantId, ruleChainData, overwrite); + for (RuleChainImportResult importResult : importResults) { + if (importResult.getError() == null) { + tbClusterService.broadcastEntityStateChangeEvent(importResult.getTenantId(), importResult.getRuleChainId(), + importResult.isUpdated() ? ComponentLifecycleEvent.UPDATED : ComponentLifecycleEvent.CREATED); + } + } + return importResults; + } catch (Exception e) { + throw handleException(e); + } + } + + private String msgToOutput(TbMsg msg) throws Exception { + JsonNode resultNode = convertMsgToOut(msg); + return objectMapper.writeValueAsString(resultNode); + } + + private String msgToOutput(List msgs) throws Exception { + JsonNode resultNode; + if (msgs.size() > 1) { + resultNode = objectMapper.createArrayNode(); + for (TbMsg msg : msgs) { + JsonNode convertedData = convertMsgToOut(msg); + ((ArrayNode) resultNode).add(convertedData); + } + } else { + resultNode = convertMsgToOut(msgs.get(0)); + } + return objectMapper.writeValueAsString(resultNode); + } + + private JsonNode convertMsgToOut(TbMsg msg) throws Exception { + ObjectNode msgData = objectMapper.createObjectNode(); + if (!StringUtils.isEmpty(msg.getData())) { + msgData.set("msg", objectMapper.readTree(msg.getData())); + } + Map metadata = msg.getMetaData().getData(); + msgData.set("metadata", objectMapper.valueToTree(metadata)); + msgData.put("msgType", msg.getType()); + return msgData; + } + + @ApiOperation(value = "Assign rule chain to edge (assignRuleChainToEdge)", + notes = "Creates assignment of an existing rule chain to an instance of The Edge. " + + EDGE_ASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive a copy of assignment rule chain " + + EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once rule chain will be delivered to edge service, it's going to start processing messages locally. " + + "\n\nOnly rule chain with type 'EDGE' can be assigned to edge." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.POST) + @ResponseBody + public RuleChain assignRuleChainToEdge(@PathVariable("edgeId") String strEdgeId, + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter("edgeId", strEdgeId); + checkParameter(RULE_CHAIN_ID, strRuleChainId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.WRITE); + + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.READ); + + return tbRuleChainService.assignRuleChainToEdge(getTenantId(), ruleChain, edge, getCurrentUser()); + } + + @ApiOperation(value = "Unassign rule chain from edge (unassignRuleChainFromEdge)", + notes = "Clears assignment of the rule chain to the edge. " + + EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION + + "Second, remote edge service will receive an 'unassign' command to remove rule chain " + + EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + + "Third, once 'unassign' command will be delivered to edge service, it's going to remove rule chain locally." + TENANT_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) + @ResponseBody + public RuleChain unassignRuleChainFromEdge(@PathVariable("edgeId") String strEdgeId, + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter("edgeId", strEdgeId); + checkParameter(RULE_CHAIN_ID, strRuleChainId); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + Edge edge = checkEdgeId(edgeId, Operation.WRITE); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.READ); + + return tbRuleChainService.unassignRuleChainFromEdge(getTenantId(), ruleChain, edge, getCurrentUser()); + } + + @ApiOperation(value = "Get Edge Rule Chains (getEdgeRuleChains)", + notes = "Returns a page of Rule Chains assigned to the specified edge. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getEdgeRuleChains( + @ApiParam(value = EDGE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(EDGE_ID) String strEdgeId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = RULE_CHAIN_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RULE_CHAIN_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter(EDGE_ID, strEdgeId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + EdgeId edgeId = new EdgeId(toUUID(strEdgeId)); + checkEdgeId(edgeId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(ruleChainService.findRuleChainsByTenantIdAndEdgeId(tenantId, edgeId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Set Edge Template Root Rule Chain (setEdgeTemplateRootRuleChain)", + notes = "Makes the rule chain to be root rule chain for any new edge that will be created. " + + "Does not update root rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/edgeTemplateRoot", method = RequestMethod.POST) + @ResponseBody + public RuleChain setEdgeTemplateRootRuleChain(@ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.WRITE); + return tbRuleChainService.setEdgeTemplateRootRuleChain(getTenantId(), ruleChain, getCurrentUser()); + } + + @ApiOperation(value = "Set Auto Assign To Edge Rule Chain (setAutoAssignToEdgeRuleChain)", + notes = "Makes the rule chain to be automatically assigned for any new edge that will be created. " + + "Does not assign this rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.POST) + @ResponseBody + public RuleChain setAutoAssignToEdgeRuleChain(@ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.WRITE); + return tbRuleChainService.setAutoAssignToEdgeRuleChain(getTenantId(), ruleChain, getCurrentUser()); + } + + @ApiOperation(value = "Unset Auto Assign To Edge Rule Chain (unsetAutoAssignToEdgeRuleChain)", + notes = "Removes the rule chain from the list of rule chains that are going to be automatically assigned for any new edge that will be created. " + + "Does not unassign this rule chain for already assigned edges. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.DELETE) + @ResponseBody + public RuleChain unsetAutoAssignToEdgeRuleChain(@ApiParam(value = RULE_CHAIN_ID_PARAM_DESCRIPTION) + @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { + checkParameter(RULE_CHAIN_ID, strRuleChainId); + RuleChainId ruleChainId = new RuleChainId(toUUID(strRuleChainId)); + RuleChain ruleChain = checkRuleChain(ruleChainId, Operation.WRITE); + return tbRuleChainService.unsetAutoAssignToEdgeRuleChain(getTenantId(), ruleChain, getCurrentUser()); + } + + // TODO: @voba refactor this - add new config to edge rule chain to set it as auto-assign + @ApiOperation(value = "Get Auto Assign To Edge Rule Chains (getAutoAssignToEdgeRuleChains)", + notes = "Returns a list of Rule Chains that will be assigned to a newly created edge. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/ruleChain/autoAssignToEdgeRuleChains", method = RequestMethod.GET) + @ResponseBody + public List getAutoAssignToEdgeRuleChains() throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + List result = new ArrayList<>(); + PageDataIterableByTenant autoAssignRuleChainsIterator = + new PageDataIterableByTenant<>(ruleChainService::findAutoAssignToEdgeRuleChainsByTenantId, tenantId, DEFAULT_PAGE_SIZE); + for (RuleChain ruleChain : autoAssignRuleChainsIterator) { + result.add(ruleChain); + } + return checkNotNull(result); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java new file mode 100644 index 0000000..8cb7bce --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.queue.util.TbCoreComponent; +import springfox.documentation.annotations.ApiIgnore; + +import javax.annotation.PostConstruct; + +@ApiIgnore +@RestController +@TbCoreComponent +@RequestMapping("/api") +@Slf4j +public class SystemInfoController { + + @Autowired(required = false) + private BuildProperties buildProperties; + + @PostConstruct + public void init() { + JsonNode info = buildInfoObject(); + log.info("System build info: {}", info); + } + + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/system/info", method = RequestMethod.GET) + @ResponseBody + public JsonNode getSystemVersionInfo() { + return buildInfoObject(); + } + + private JsonNode buildInfoObject() { + ObjectMapper objectMapper = new ObjectMapper(); + ObjectNode infoObject = objectMapper.createObjectNode(); + if (buildProperties != null) { + infoObject.put("version", buildProperties.getVersion()); + infoObject.put("artifact", buildProperties.getArtifact()); + infoObject.put("name", buildProperties.getName()); + } else { + infoObject.put("version", "unknown"); + } + return infoObject; + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java new file mode 100644 index 0000000..ec0b530 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -0,0 +1,243 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.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.RestController; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.lwm2m.LwM2mObject; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.resource.TbResourceService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.Base64; +import java.util.List; + +import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INFO_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@Slf4j +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class TbResourceController extends BaseController { + + private final TbResourceService tbResourceService; + + public static final String RESOURCE_ID = "resourceId"; + + @ApiOperation(value = "Download Resource (downloadResource)", notes = "Download Resource based on the provided Resource Id." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource/{resourceId}/download", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity downloadResource(@ApiParam(value = RESOURCE_ID_PARAM_DESCRIPTION) + @PathVariable(RESOURCE_ID) String strResourceId) throws ThingsboardException { + checkParameter(RESOURCE_ID, strResourceId); + try { + TbResourceId resourceId = new TbResourceId(toUUID(strResourceId)); + TbResource tbResource = checkResourceId(resourceId, Operation.READ); + + ByteArrayResource resource = new ByteArrayResource(Base64.getDecoder().decode(tbResource.getData().getBytes())); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + tbResource.getFileName()) + .header("x-filename", tbResource.getFileName()) + .contentLength(resource.contentLength()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Resource Info (getResourceInfoById)", + notes = "Fetch the Resource Info object based on the provided Resource Id. " + + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource/info/{resourceId}", method = RequestMethod.GET) + @ResponseBody + public TbResourceInfo getResourceInfoById(@ApiParam(value = RESOURCE_ID_PARAM_DESCRIPTION) + @PathVariable(RESOURCE_ID) String strResourceId) throws ThingsboardException { + checkParameter(RESOURCE_ID, strResourceId); + try { + TbResourceId resourceId = new TbResourceId(toUUID(strResourceId)); + return checkResourceInfoId(resourceId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Resource (getResourceById)", + notes = "Fetch the Resource object based on the provided Resource Id. " + + RESOURCE_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource/{resourceId}", method = RequestMethod.GET) + @ResponseBody + public TbResource getResourceById(@ApiParam(value = RESOURCE_ID_PARAM_DESCRIPTION) + @PathVariable(RESOURCE_ID) String strResourceId) throws ThingsboardException { + checkParameter(RESOURCE_ID, strResourceId); + try { + TbResourceId resourceId = new TbResourceId(toUUID(strResourceId)); + return checkResourceId(resourceId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Resource (saveResource)", + notes = "Create or update the Resource. When creating the Resource, platform generates Resource id as " + UUID_WIKI_LINK + + "The newly created Resource id will be present in the response. " + + "Specify existing Resource id to update the Resource. " + + "Referencing non-existing Resource Id will cause 'Not Found' error. " + + "\n\nResource combination of the title with the key is unique in the scope of tenant. " + + "Remove 'id', 'tenantId' from the request body example (below) to create new Resource entity." + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json", + consumes = "application/json") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource", method = RequestMethod.POST) + @ResponseBody + public TbResource saveResource(@ApiParam(value = "A JSON value representing the Resource.") + @RequestBody TbResource resource) throws Exception { + resource.setTenantId(getTenantId()); + checkEntity(resource.getId(), resource, Resource.TB_RESOURCE); + return tbResourceService.save(resource, getCurrentUser()); + } + + @ApiOperation(value = "Get Resource Infos (getResources)", + notes = "Returns a page of Resource Info objects owned by tenant or sysadmin. " + + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource", method = RequestMethod.GET) + @ResponseBody + public PageData getResources(@ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = RESOURCE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { + return checkNotNull(resourceService.findTenantResourcesByTenantId(getTenantId(), pageLink)); + } else { + return checkNotNull(resourceService.findAllTenantResourcesByTenantId(getTenantId(), pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get LwM2M Objects (getLwm2mListObjectsPage)", + notes = "Returns a page of LwM2M objects parsed from Resources with type 'LWM2M_MODEL' owned by tenant or sysadmin. " + + PAGE_DATA_PARAMETERS + LWM2M_OBJECT_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/resource/lwm2m/page", method = RequestMethod.GET) + @ResponseBody + public List getLwm2mListObjectsPage(@ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = RESOURCE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = LWM2M_OBJECT_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = new PageLink(pageSize, page, textSearch); + return checkNotNull(resourceService.findLwM2mObjectPage(getTenantId(), sortProperty, sortOrder, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get LwM2M Objects (getLwm2mListObjects)", + notes = "Returns a page of LwM2M objects parsed from Resources with type 'LWM2M_MODEL' owned by tenant or sysadmin. " + + "You can specify parameters to filter the results. " + LWM2M_OBJECT_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH, + produces = "application/json") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/resource/lwm2m", method = RequestMethod.GET) + @ResponseBody + public List getLwm2mListObjects(@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES, required = true) + @RequestParam String sortOrder, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = LWM2M_OBJECT_SORT_PROPERTY_ALLOWABLE_VALUES, required = true) + @RequestParam String sortProperty, + @ApiParam(value = "LwM2M Object ids.", required = true) + @RequestParam(required = false) String[] objectIds) throws ThingsboardException { + try { + return checkNotNull(resourceService.findLwM2mObject(getTenantId(), sortOrder, sortProperty, objectIds)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete Resource (deleteResource)", + notes = "Deletes the Resource. Referencing non-existing Resource Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/resource/{resourceId}", method = RequestMethod.DELETE) + @ResponseBody + public void deleteResource(@ApiParam(value = RESOURCE_ID_PARAM_DESCRIPTION) + @PathVariable("resourceId") String strResourceId) throws ThingsboardException { + checkParameter(RESOURCE_ID, strResourceId); + TbResourceId resourceId = new TbResourceId(toUUID(strResourceId)); + TbResource tbResource = checkResourceId(resourceId, Operation.DELETE); + tbResourceService.delete(tbResource, getCurrentUser()); + } +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java new file mode 100644 index 0000000..afc6f38 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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; + +/** + * Created by ashvayka on 17.05.18. + */ +public class TbUrlConstants { + public static final String TELEMETRY_URL_PREFIX = "/api/plugins/telemetry"; + public static final String RPC_V1_URL_PREFIX = "/api/plugins/rpc"; + public static final String RPC_V2_URL_PREFIX = "/api/rpc"; +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java new file mode 100644 index 0000000..6f6c4dd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -0,0 +1,990 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +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 com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.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.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +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.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; +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.DataType; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.dao.service.ConstraintValidator; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.telemetry.AttributeData; +import org.thingsboard.server.service.telemetry.TsData; +import org.thingsboard.server.service.telemetry.exception.InvalidParametersException; +import org.thingsboard.server.service.telemetry.exception.UncheckedApiException; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_JSON_REQUEST_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_KEYS_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_ALLOWED_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTES_SCOPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ATTRIBUTE_DATA_EXAMPLE; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID; +import static org.thingsboard.server.controller.ControllerConstants.DEVICE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ATTRIBUTE_SCOPES; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.INVALID_STRUCTURE_OF_THE_REQUEST; +import static org.thingsboard.server.controller.ControllerConstants.LATEST_TS_NON_STRICT_DATA_EXAMPLE; +import static org.thingsboard.server.controller.ControllerConstants.LATEST_TS_STRICT_DATA_EXAMPLE; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ATTIRIBUTES_STATUS_OK; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ATTRIBUTES_REQUEST_PAYLOAD; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_ATTRIBUTES_STATUS_OK; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_ATTRIBUTES_STATUS_UNAUTHORIZED; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_TIMESERIES_STATUS_OK; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED; +import static org.thingsboard.server.controller.ControllerConstants.SAVE_TIMESERIES_REQUEST_PAYLOAD; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.STRICT_DATA_TYPES_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TELEMETRY_JSON_REQUEST_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TELEMETRY_KEYS_BASE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TELEMETRY_KEYS_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TELEMETRY_SCOPE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TS_STRICT_DATA_EXAMPLE; + + +/** + * Created by ashvayka on 22.03.18. + */ +@RestController +@TbCoreComponent +@RequestMapping(TbUrlConstants.TELEMETRY_URL_PREFIX) +@Slf4j +public class TelemetryController extends BaseController { + + @Autowired + private TimeseriesService tsService; + + @Autowired + private AccessValidator accessValidator; + + @Value("${transport.json.max_string_value_length:0}") + private int maxStringValueLength; + + private ExecutorService executor; + + @PostConstruct + public void initExecutor() { + executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("telemetry-controller")); + } + + @PreDestroy + public void shutdownExecutor() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @ApiOperation(value = "Get all attribute keys (getAttributeKeys)", + notes = "Returns a set of unique attribute key names for the selected entity. " + + "The response will include merged key names set for all attribute scopes:" + + "\n\n * SERVER_SCOPE - supported for all entity types;" + + "\n * CLIENT_SCOPE - supported for devices;" + + "\n * SHARED_SCOPE - supported for devices. " + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/keys/attributes", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getAttributeKeys( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr) throws ThingsboardException { + try { + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, this::getAttributeKeysCallback); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get all attribute keys by scope (getAttributeKeysByScope)", + notes = "Returns a set of unique attribute key names for the selected entity and attributes scope: " + + "\n\n * SERVER_SCOPE - supported for all entity types;" + + "\n * CLIENT_SCOPE - supported for devices;" + + "\n * SHARED_SCOPE - supported for devices. " + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/keys/attributes/{scope}", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getAttributeKeysByScope( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES) @PathVariable("scope") String scope) throws ThingsboardException { + try { + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, + (result, tenantId, entityId) -> getAttributeKeysCallback(result, tenantId, entityId, scope)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get attributes (getAttributes)", + notes = "Returns all attributes that belong to specified entity. Use optional 'keys' parameter to return specific attributes." + + "\n Example of the result: \n\n" + + MARKDOWN_CODE_BLOCK_START + + ATTRIBUTE_DATA_EXAMPLE + + MARKDOWN_CODE_BLOCK_END + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/values/attributes", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getAttributes( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, null, keysStr)); + } catch (Exception e) { + throw handleException(e); + } + } + + + @ApiOperation(value = "Get attributes by scope (getAttributesByScope)", + notes = "Returns all attributes of a specified scope that belong to specified entity." + + ENTITY_ATTRIBUTE_SCOPES + + "Use optional 'keys' parameter to return specific attributes." + + "\n Example of the result: \n\n" + + MARKDOWN_CODE_BLOCK_START + + ATTRIBUTE_DATA_EXAMPLE + + MARKDOWN_CODE_BLOCK_END + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/values/attributes/{scope}", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getAttributesByScope( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES, required = true) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keysStr)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get time-series keys (getTimeseriesKeys)", + notes = "Returns a set of unique time-series key names for the selected entity. " + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/keys/timeseries", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getTimeseriesKeys( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr) throws ThingsboardException { + try { + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, + (result, tenantId, entityId) -> Futures.addCallback(tsService.findAllLatest(tenantId, entityId), getTsKeysToResponseCallback(result), MoreExecutors.directExecutor())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get latest time-series value (getLatestTimeseries)", + notes = "Returns all time-series that belong to specified entity. Use optional 'keys' parameter to return specific time-series." + + " The result is a JSON object. The format of the values depends on the 'useStrictDataTypes' parameter." + + " By default, all time-series values are converted to strings: \n\n" + + MARKDOWN_CODE_BLOCK_START + + LATEST_TS_NON_STRICT_DATA_EXAMPLE + + MARKDOWN_CODE_BLOCK_END + + "\n\n However, it is possible to request the values without conversion ('useStrictDataTypes'=true): \n\n" + + MARKDOWN_CODE_BLOCK_START + + LATEST_TS_STRICT_DATA_EXAMPLE + + MARKDOWN_CODE_BLOCK_END + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/values/timeseries", method = RequestMethod.GET) + @ResponseBody + public DeferredResult getLatestTimeseries( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = TELEMETRY_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @ApiParam(value = STRICT_DATA_TYPES_DESCRIPTION) + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, + (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keysStr, useStrictDataTypes)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get time-series data (getTimeseries)", + notes = "Returns a range of time-series values for specified entity. " + + "Returns not aggregated data by default. " + + "Use aggregation function ('agg') and aggregation interval ('interval') to enable aggregation of the results on the database / server side. " + + "The aggregation is generally more efficient then fetching all records. \n\n" + + MARKDOWN_CODE_BLOCK_START + + TS_STRICT_DATA_EXAMPLE + + MARKDOWN_CODE_BLOCK_END + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/values/timeseries", method = RequestMethod.GET, params = {"keys", "startTs", "endTs"}) + @ResponseBody + public DeferredResult getTimeseries( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = TELEMETRY_KEYS_BASE_DESCRIPTION, required = true) @RequestParam(name = "keys") String keys, + @ApiParam(value = "A long value representing the start timestamp of the time range in milliseconds, UTC.") + @RequestParam(name = "startTs") Long startTs, + @ApiParam(value = "A long value representing the end timestamp of the time range in milliseconds, UTC.") + @RequestParam(name = "endTs") Long endTs, + @ApiParam(value = "A long value representing the aggregation interval range in milliseconds.") + @RequestParam(name = "interval", defaultValue = "0") Long interval, + @ApiParam(value = "An integer value that represents a max number of timeseries data points to fetch." + + " This parameter is used only in the case if 'agg' parameter is set to 'NONE'.", defaultValue = "100") + @RequestParam(name = "limit", defaultValue = "100") Integer limit, + @ApiParam(value = "A string value representing the aggregation function. " + + "If the interval is not specified, 'agg' parameter will use 'NONE' value.", + allowableValues = "MIN, MAX, AVG, SUM, COUNT, NONE") + @RequestParam(name = "agg", defaultValue = "NONE") String aggStr, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, + @ApiParam(value = STRICT_DATA_TYPES_DESCRIPTION) + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException { + try { + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, + (result, tenantId, entityId) -> { + // If interval is 0, convert this to a NONE aggregation, which is probably what the user really wanted + Aggregation agg = interval == 0L ? Aggregation.valueOf(Aggregation.NONE.name()) : Aggregation.valueOf(aggStr); + List queries = toKeysList(keys).stream().map(key -> new BaseReadTsKvQuery(key, startTs, endTs, interval, limit, agg, orderBy)) + .collect(Collectors.toList()); + + Futures.addCallback(tsService.findAll(tenantId, entityId, queries), getTsKvListCallback(result, useStrictDataTypes), MoreExecutors.directExecutor()); + }); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save device attributes (saveDeviceAttributes)", + notes = "Creates or updates the device attributes based on device id and specified attribute scope. " + + SAVE_ATTRIBUTES_REQUEST_PAYLOAD + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = SAVE_ATTIRIBUTES_STATUS_OK + + "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED', " + + "and also sends event msg to the rule engine with msg type 'ATTRIBUTES_UPDATED'."), + @ApiResponse(code = 400, message = SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST), + @ApiResponse(code = 401, message = "User is not authorized to save device attributes for selected device. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 500, message = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult saveDeviceAttributes( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("deviceId") String deviceIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES, required = true) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); + return saveAttributes(getTenantId(), entityId, scope, request); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save entity attributes (saveEntityAttributesV1)", + notes = "Creates or updates the entity attributes based on Entity Id and the specified attribute scope. " + + ENTITY_ATTRIBUTE_SCOPES + + SAVE_ATTRIBUTES_REQUEST_PAYLOAD + + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = SAVE_ATTIRIBUTES_STATUS_OK + SAVE_ENTITY_ATTRIBUTES_STATUS_OK), + @ApiResponse(code = 400, message = SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST), + @ApiResponse(code = 401, message = SAVE_ENTITY_ATTRIBUTES_STATUS_UNAUTHORIZED), + @ApiResponse(code = 500, message = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult saveEntityAttributesV1( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return saveAttributes(getTenantId(), entityId, scope, request); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save entity attributes (saveEntityAttributesV2)", + notes = "Creates or updates the entity attributes based on Entity Id and the specified attribute scope. " + + ENTITY_ATTRIBUTE_SCOPES + + SAVE_ATTRIBUTES_REQUEST_PAYLOAD + + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = SAVE_ATTIRIBUTES_STATUS_OK + SAVE_ENTITY_ATTRIBUTES_STATUS_OK), + @ApiResponse(code = 400, message = SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST), + @ApiResponse(code = 401, message = SAVE_ENTITY_ATTRIBUTES_STATUS_UNAUTHORIZED), + @ApiResponse(code = 500, message = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/attributes/{scope}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult saveEntityAttributesV2( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES, required = true) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return saveAttributes(getTenantId(), entityId, scope, request); + } catch (Exception e) { + throw handleException(e); + } + } + + + @ApiOperation(value = "Save or update time-series data (saveEntityTelemetry)", + notes = "Creates or updates the entity time-series data based on the Entity Id and request payload." + + SAVE_TIMESERIES_REQUEST_PAYLOAD + + "\n\n The scope parameter is not used in the API call implementation but should be specified whatever value because it is used as a path variable. " + + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = SAVE_ENTITY_TIMESERIES_STATUS_OK), + @ApiResponse(code = 400, message = INVALID_STRUCTURE_OF_THE_REQUEST), + @ApiResponse(code = 401, message = SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED), + @ApiResponse(code = 500, message = SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/timeseries/{scope}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult saveEntityTelemetry( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = TELEMETRY_SCOPE_DESCRIPTION, required = true, allowableValues = "ANY") @PathVariable("scope") String scope, + @ApiParam(value = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return saveTelemetry(getTenantId(), entityId, requestBody, 0L); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save or update time-series data with TTL (saveEntityTelemetryWithTTL)", + notes = "Creates or updates the entity time-series data based on the Entity Id and request payload." + + SAVE_TIMESERIES_REQUEST_PAYLOAD + + "\n\n The scope parameter is not used in the API call implementation but should be specified whatever value because it is used as a path variable. " + + "\n\nThe ttl parameter takes affect only in case of Cassandra DB." + + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = SAVE_ENTITY_TIMESERIES_STATUS_OK), + @ApiResponse(code = 400, message = INVALID_STRUCTURE_OF_THE_REQUEST), + @ApiResponse(code = 401, message = SAVE_ENTITY_TIMESERIES_STATUS_UNAUTHORIZED), + @ApiResponse(code = 500, message = SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/timeseries/{scope}/{ttl}", method = RequestMethod.POST) + @ResponseBody + public DeferredResult saveEntityTelemetryWithTTL( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = TELEMETRY_SCOPE_DESCRIPTION, required = true, allowableValues = "ANY") @PathVariable("scope") String scope, + @ApiParam(value = "A long value representing TTL (Time to Live) parameter.", required = true) @PathVariable("ttl") Long ttl, + @ApiParam(value = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return saveTelemetry(getTenantId(), entityId, requestBody, ttl); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete entity time-series data (deleteEntityTimeseries)", + notes = "Delete time-series for selected entity based on entity id, entity type and keys." + + " Use 'deleteAllDataForKeys' to delete all time-series data." + + " Use 'startTs' and 'endTs' to specify time-range instead. " + + " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) after deletion of the time range. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Timeseries for the selected keys in the request was removed. " + + "Platform creates an audit log event about entity timeseries removal with action type 'TIMESERIES_DELETED'."), + @ApiResponse(code = 400, message = "Platform returns a bad request in case if keys list is empty or start and end timestamp values is empty when deleteAllDataForKeys is set to false."), + @ApiResponse(code = 401, message = "User is not authorized to delete entity timeseries for selected entity. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 500, message = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about entity timeseries removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) + @ResponseBody + public DeferredResult deleteEntityTimeseries( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = TELEMETRY_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr, + @ApiParam(value = "A boolean value to specify if should be deleted all data for selected keys or only data that are in the selected time range.") + @RequestParam(name = "deleteAllDataForKeys", defaultValue = "false") boolean deleteAllDataForKeys, + @ApiParam(value = "A long value representing the start timestamp of removal time range in milliseconds.") + @RequestParam(name = "startTs", required = false) Long startTs, + @ApiParam(value = "A long value representing the end timestamp of removal time range in milliseconds.") + @RequestParam(name = "endTs", required = false) Long endTs, + @ApiParam(value = "If the parameter is set to true, the latest telemetry will be rewritten in case that current latest value was removed, otherwise, in case that parameter is set to false the new latest value will not set.") + @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted); + } catch (Exception e) { + throw handleException(e); + } + } + + private DeferredResult deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys, + Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException { + List keys = toKeysList(keysStr); + if (keys.isEmpty()) { + return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + } + SecurityUser user = getCurrentUser(); + + long deleteFromTs; + long deleteToTs; + if (deleteAllDataForKeys) { + deleteFromTs = 0L; + deleteToTs = System.currentTimeMillis(); + } else { + if (startTs == null || endTs == null) { + return getImmediateDeferredResult("When deleteAllDataForKeys is false, start and end timestamp values shouldn't be empty", HttpStatus.BAD_REQUEST); + } else { + deleteFromTs = startTs; + deleteToTs = endTs; + } + } + + return accessValidator.validateEntityAndCallback(user, Operation.WRITE_TELEMETRY, entityIdStr, (result, tenantId, entityId) -> { + List deleteTsKvQueries = new ArrayList<>(); + for (String key : keys) { + deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted)); + } + tsSubService.deleteTimeseriesAndNotify(tenantId, entityId, keys, deleteTsKvQueries, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + logTimeseriesDeleted(user, entityId, keys, deleteFromTs, deleteToTs, null); + result.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logTimeseriesDeleted(user, entityId, keys, deleteFromTs, deleteToTs, t); + result.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }); + }); + } + + @ApiOperation(value = "Delete device attributes (deleteDeviceAttributes)", + notes = "Delete device attributes using provided Device Id, scope and a list of keys. " + + "Referencing a non-existing Device Id will cause an error" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Device attributes was removed for the selected keys in the request. " + + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED'."), + @ApiResponse(code = 400, message = "Platform returns a bad request in case if keys or scope are not specified."), + @ApiResponse(code = 401, message = "User is not authorized to delete device attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 500, message = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE) + @ResponseBody + public DeferredResult deleteDeviceAttributes( + @ApiParam(value = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES, required = true) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); + return deleteAttributes(entityId, scope, keysStr); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete entity attributes (deleteEntityAttributes)", + notes = "Delete entity attributes using provided Entity Id, scope and a list of keys. " + + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + produces = MediaType.APPLICATION_JSON_VALUE) + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Entity attributes was removed for the selected keys in the request. " + + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED'."), + @ApiResponse(code = 400, message = "Platform returns a bad request in case if keys or scope are not specified."), + @ApiResponse(code = 401, message = "User is not authorized to delete entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), + @ApiResponse(code = 500, message = "The exception was thrown during processing the request. " + + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), + }) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.DELETE) + @ResponseBody + public DeferredResult deleteEntityAttributes( + @ApiParam(value = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, defaultValue = "DEVICE") @PathVariable("entityType") String entityType, + @ApiParam(value = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @ApiParam(value = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, allowableValues = ATTRIBUTES_SCOPE_ALLOWED_VALUES) @PathVariable("scope") String scope, + @ApiParam(value = ATTRIBUTES_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr) throws ThingsboardException { + try { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return deleteAttributes(entityId, scope, keysStr); + } catch (Exception e) { + throw handleException(e); + } + } + + private DeferredResult deleteAttributes(EntityId entityIdSrc, String scope, String keysStr) throws ThingsboardException { + List keys = toKeysList(keysStr); + if (keys.isEmpty()) { + return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + } + SecurityUser user = getCurrentUser(); + + if (DataConstants.SERVER_SCOPE.equals(scope) || + DataConstants.SHARED_SCOPE.equals(scope) || + DataConstants.CLIENT_SCOPE.equals(scope)) { + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.WRITE_ATTRIBUTES, entityIdSrc, (result, tenantId, entityId) -> { + tsSubService.deleteAndNotify(tenantId, entityId, scope, keys, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + logAttributesDeleted(user, entityId, scope, keys, null); + if (entityIdSrc.getEntityType().equals(EntityType.DEVICE)) { + DeviceId deviceId = new DeviceId(entityId.getId()); + tbClusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete( + user.getTenantId(), deviceId, scope, keys), null); + } + result.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logAttributesDeleted(user, entityId, scope, keys, t); + result.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }); + }); + } else { + return getImmediateDeferredResult("Invalid attribute scope: " + scope, HttpStatus.BAD_REQUEST); + } + } + + private DeferredResult saveAttributes(TenantId srcTenantId, EntityId entityIdSrc, String scope, JsonNode json) throws ThingsboardException { + if (!DataConstants.SERVER_SCOPE.equals(scope) && !DataConstants.SHARED_SCOPE.equals(scope)) { + return getImmediateDeferredResult("Invalid scope: " + scope, HttpStatus.BAD_REQUEST); + } + if (json.isObject()) { + List attributes = extractRequestAttributes(json); + attributes.forEach(ConstraintValidator::validateFields); + if (attributes.isEmpty()) { + return getImmediateDeferredResult("No attributes data found in request body!", HttpStatus.BAD_REQUEST); + } + for (AttributeKvEntry attributeKvEntry : attributes) { + if (attributeKvEntry.getKey().isEmpty() || attributeKvEntry.getKey().trim().length() == 0) { + return getImmediateDeferredResult("Key cannot be empty or contains only spaces", HttpStatus.BAD_REQUEST); + } + } + SecurityUser user = getCurrentUser(); + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.WRITE_ATTRIBUTES, entityIdSrc, (result, tenantId, entityId) -> { + tsSubService.saveAndNotify(tenantId, entityId, scope, attributes, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + logAttributesUpdated(user, entityId, scope, attributes, null); + result.setResult(new ResponseEntity(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logAttributesUpdated(user, entityId, scope, attributes, t); + AccessValidator.handleError(t, result, HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + }); + } else { + return getImmediateDeferredResult("Request is not a JSON object", HttpStatus.BAD_REQUEST); + } + } + + private DeferredResult saveTelemetry(TenantId curTenantId, EntityId entityIdSrc, String requestBody, long ttl) throws ThingsboardException { + Map> telemetryRequest; + JsonElement telemetryJson; + try { + telemetryJson = new JsonParser().parse(requestBody); + } catch (Exception e) { + return getImmediateDeferredResult("Unable to parse timeseries payload: Invalid JSON body!", HttpStatus.BAD_REQUEST); + } + try { + telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson, System.currentTimeMillis()); + } catch (Exception e) { + return getImmediateDeferredResult("Unable to parse timeseries payload. Invalid JSON body: " + e.getMessage(), HttpStatus.BAD_REQUEST); + } + List entries = new ArrayList<>(); + for (Map.Entry> entry : telemetryRequest.entrySet()) { + for (KvEntry kv : entry.getValue()) { + entries.add(new BasicTsKvEntry(entry.getKey(), kv)); + } + } + if (entries.isEmpty()) { + return getImmediateDeferredResult("No timeseries data found in request body!", HttpStatus.BAD_REQUEST); + } + SecurityUser user = getCurrentUser(); + return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.WRITE_TELEMETRY, entityIdSrc, (result, tenantId, entityId) -> { + long tenantTtl = ttl; + if (!TenantId.SYS_TENANT_ID.equals(tenantId) && tenantTtl == 0) { + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + tenantTtl = TimeUnit.DAYS.toSeconds(((DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration()).getDefaultStorageTtlDays()); + } + tsSubService.saveAndNotify(tenantId, user.getCustomerId(), entityId, entries, tenantTtl, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + logTelemetryUpdated(user, entityId, entries, null); + result.setResult(new ResponseEntity(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logTelemetryUpdated(user, entityId, entries, t); + AccessValidator.handleError(t, result, HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + }); + } + + private void getLatestTimeseriesValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, String keys, Boolean useStrictDataTypes) { + ListenableFuture> future; + if (StringUtils.isEmpty(keys)) { + future = tsService.findAllLatest(user.getTenantId(), entityId); + } else { + future = tsService.findLatest(user.getTenantId(), entityId, toKeysList(keys)); + } + Futures.addCallback(future, getTsKvListCallback(result, useStrictDataTypes), MoreExecutors.directExecutor()); + } + + private void getAttributeValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, String scope, String keys) { + List keyList = toKeysList(keys); + FutureCallback> callback = getAttributeValuesToResponseCallback(result, user, scope, entityId, keyList); + if (!StringUtils.isEmpty(scope)) { + if (keyList != null && !keyList.isEmpty()) { + Futures.addCallback(attributesService.find(user.getTenantId(), entityId, scope, keyList), callback, MoreExecutors.directExecutor()); + } else { + Futures.addCallback(attributesService.findAll(user.getTenantId(), entityId, scope), callback, MoreExecutors.directExecutor()); + } + } else { + List>> futures = new ArrayList<>(); + for (String tmpScope : DataConstants.allScopes()) { + if (keyList != null && !keyList.isEmpty()) { + futures.add(attributesService.find(user.getTenantId(), entityId, tmpScope, keyList)); + } else { + futures.add(attributesService.findAll(user.getTenantId(), entityId, tmpScope)); + } + } + + ListenableFuture> future = mergeAllAttributesFutures(futures); + + Futures.addCallback(future, callback, MoreExecutors.directExecutor()); + } + } + + private void getAttributeKeysCallback(@Nullable DeferredResult result, TenantId tenantId, EntityId entityId, String scope) { + Futures.addCallback(attributesService.findAll(tenantId, entityId, scope), getAttributeKeysToResponseCallback(result), MoreExecutors.directExecutor()); + } + + private void getAttributeKeysCallback(@Nullable DeferredResult result, TenantId tenantId, EntityId entityId) { + List>> futures = new ArrayList<>(); + for (String scope : DataConstants.allScopes()) { + futures.add(attributesService.findAll(tenantId, entityId, scope)); + } + + ListenableFuture> future = mergeAllAttributesFutures(futures); + + Futures.addCallback(future, getAttributeKeysToResponseCallback(result), MoreExecutors.directExecutor()); + } + + private FutureCallback> getTsKeysToResponseCallback(final DeferredResult response) { + return new FutureCallback<>() { + @Override + public void onSuccess(List values) { + List keys = values.stream().map(KvEntry::getKey).collect(Collectors.toList()); + response.setResult(new ResponseEntity<>(keys, HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable e) { + log.error("Failed to fetch attributes", e); + AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + }; + } + + private FutureCallback> getAttributeKeysToResponseCallback(final DeferredResult response) { + return new FutureCallback>() { + + @Override + public void onSuccess(List attributes) { + List keys = attributes.stream().map(KvEntry::getKey).collect(Collectors.toList()); + response.setResult(new ResponseEntity<>(keys, HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable e) { + log.error("Failed to fetch attributes", e); + AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + }; + } + + private FutureCallback> getAttributeValuesToResponseCallback(final DeferredResult response, + final SecurityUser user, final String scope, + final EntityId entityId, final List keyList) { + return new FutureCallback<>() { + @Override + public void onSuccess(List attributes) { + List values = attributes.stream().map(attribute -> + new AttributeData(attribute.getLastUpdateTs(), attribute.getKey(), getKvValue(attribute)) + ).collect(Collectors.toList()); + logAttributesRead(user, entityId, scope, keyList, null); + response.setResult(new ResponseEntity<>(values, HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable e) { + log.error("Failed to fetch attributes", e); + logAttributesRead(user, entityId, scope, keyList, e); + AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + }; + } + + private FutureCallback> getTsKvListCallback(final DeferredResult response, Boolean useStrictDataTypes) { + return new FutureCallback<>() { + @Override + public void onSuccess(List data) { + Map> result = new LinkedHashMap<>(); + for (TsKvEntry entry : data) { + Object value = useStrictDataTypes ? getKvValue(entry) : entry.getValueAsString(); + result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(new TsData(entry.getTs(), value)); + } + response.setResult(new ResponseEntity<>(result, HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable e) { + log.error("Failed to fetch historical data", e); + AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + }; + } + + private void logTimeseriesDeleted(SecurityUser user, EntityId entityId, List keys, long startTs, long endTs, Throwable e) { + notificationEntityService.logEntityAction(user.getTenantId(), entityId, ActionType.TIMESERIES_DELETED, user, + toException(e), keys, startTs, endTs); + } + + private void logTelemetryUpdated(SecurityUser user, EntityId entityId, List telemetry, Throwable e) { + notificationEntityService.logEntityAction(user.getTenantId(), entityId, ActionType.TIMESERIES_UPDATED, user, + toException(e), telemetry); + } + + private void logAttributesDeleted(SecurityUser user, EntityId entityId, String scope, List keys, Throwable e) { + notificationEntityService.logEntityAction(user.getTenantId(), (UUIDBased & EntityId) entityId, + ActionType.ATTRIBUTES_DELETED, user, toException(e), scope, keys); + } + + private void logAttributesUpdated(SecurityUser user, EntityId entityId, String scope, List attributes, Throwable e) { + notificationEntityService.logEntityAction(user.getTenantId(), entityId, ActionType.ATTRIBUTES_UPDATED, user, + toException(e), scope, attributes); + } + + + private void logAttributesRead(SecurityUser user, EntityId entityId, String scope, List keys, Throwable e) { + notificationEntityService.logEntityAction(user.getTenantId(), entityId, ActionType.ATTRIBUTES_READ, user, + toException(e), scope, keys); + } + + private ListenableFuture> mergeAllAttributesFutures(List>> futures) { + return Futures.transform(Futures.successfulAsList(futures), + (Function>, ? extends List>) input -> { + List tmp = new ArrayList<>(); + if (input != null) { + input.forEach(tmp::addAll); + } + return tmp; + }, executor); + } + + private List toKeysList(String keys) { + List keyList = null; + if (!StringUtils.isEmpty(keys)) { + keyList = Arrays.asList(keys.split(",")); + } + return keyList; + } + + private DeferredResult getImmediateDeferredResult(String message, HttpStatus status) { + DeferredResult result = new DeferredResult<>(); + result.setResult(new ResponseEntity<>(message, status)); + return result; + } + + private List extractRequestAttributes(JsonNode jsonNode) { + long ts = System.currentTimeMillis(); + List attributes = new ArrayList<>(); + jsonNode.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode value = entry.getValue(); + if (entry.getValue().isObject() || entry.getValue().isArray()) { + attributes.add(new BaseAttributeKvEntry(new JsonDataEntry(key, toJsonStr(value)), ts)); + } else if (entry.getValue().isTextual()) { + if (maxStringValueLength > 0 && entry.getValue().textValue().length() > maxStringValueLength) { + String message = String.format("String value length [%d] for key [%s] is greater than maximum allowed [%d]", entry.getValue().textValue().length(), key, maxStringValueLength); + throw new UncheckedApiException(new InvalidParametersException(message)); + } + attributes.add(new BaseAttributeKvEntry(new StringDataEntry(key, value.textValue()), ts)); + } else if (entry.getValue().isBoolean()) { + attributes.add(new BaseAttributeKvEntry(new BooleanDataEntry(key, value.booleanValue()), ts)); + } else if (entry.getValue().isDouble()) { + attributes.add(new BaseAttributeKvEntry(new DoubleDataEntry(key, value.doubleValue()), ts)); + } else if (entry.getValue().isNumber()) { + if (entry.getValue().isBigInteger()) { + throw new UncheckedApiException(new InvalidParametersException("Big integer values are not supported!")); + } else { + attributes.add(new BaseAttributeKvEntry(new LongDataEntry(key, value.longValue()), ts)); + } + } + }); + return attributes; + } + + private String toJsonStr(JsonNode value) { + try { + return JacksonUtil.toString(value); + } catch (IllegalArgumentException e) { + throw new JsonParseException("Can't parse jsonValue: " + value, e); + } + } + + private JsonNode toJsonNode(String value) { + try { + return JacksonUtil.toJsonNode(value); + } catch (IllegalArgumentException e) { + throw new JsonParseException("Can't parse jsonValue: " + value, e); + } + } + + private Object getKvValue(KvEntry entry) { + if (entry.getDataType() == DataType.JSON) { + return toJsonNode(entry.getJsonValue().get()); + } + return entry.getValue(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java new file mode 100644 index 0000000..b44c532 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -0,0 +1,190 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +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.Tenant; +import org.thingsboard.server.common.data.TenantInfo; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.tenant.TbTenantService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_INFO_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@Slf4j +@RequiredArgsConstructor +public class TenantController extends BaseController { + + private static final String TENANT_INFO_DESCRIPTION = "The Tenant Info object extends regular Tenant object and includes Tenant Profile name. "; + + private final TenantService tenantService; + private final TbTenantService tbTenantService; + + @ApiOperation(value = "Get Tenant (getTenantById)", + notes = "Fetch the Tenant object based on the provided Tenant Id. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.GET) + @ResponseBody + public Tenant getTenantById( + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION) + @PathVariable(TENANT_ID) String strTenantId) throws ThingsboardException { + checkParameter(TENANT_ID, strTenantId); + try { + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + Tenant tenant = checkTenantId(tenantId, Operation.READ); + if (!tenant.getAdditionalInfo().isNull()) { + processDashboardIdFromAdditionalInfo((ObjectNode) tenant.getAdditionalInfo(), HOME_DASHBOARD); + } + return tenant; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Info (getTenantInfoById)", + notes = "Fetch the Tenant Info object based on the provided Tenant Id. " + + TENANT_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/tenant/info/{tenantId}", method = RequestMethod.GET) + @ResponseBody + public TenantInfo getTenantInfoById( + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION) + @PathVariable(TENANT_ID) String strTenantId) throws ThingsboardException { + checkParameter(TENANT_ID, strTenantId); + try { + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + return checkTenantInfoId(tenantId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or update Tenant (saveTenant)", + notes = "Create or update the Tenant. When creating tenant, platform generates Tenant Id as " + UUID_WIKI_LINK + + "Default Rule Chain and Device profile are also generated for the new tenants automatically. " + + "The newly created Tenant Id will be present in the response. " + + "Specify existing Tenant Id id to update the Tenant. " + + "Referencing non-existing Tenant Id will cause 'Not Found' error." + + "Remove 'id', 'tenantId' from the request body example (below) to create new Tenant entity." + + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenant", method = RequestMethod.POST) + @ResponseBody + public Tenant saveTenant(@ApiParam(value = "A JSON value representing the tenant.") + @RequestBody Tenant tenant) throws Exception { + checkEntity(tenant.getId(), tenant, Resource.TENANT); + return tbTenantService.save(tenant); + } + + @ApiOperation(value = "Delete Tenant (deleteTenant)", + notes = "Deletes the tenant, it's customers, rule chains, devices and all other related entities. Referencing non-existing tenant Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteTenant(@ApiParam(value = TENANT_ID_PARAM_DESCRIPTION) + @PathVariable(TENANT_ID) String strTenantId) throws Exception { + checkParameter(TENANT_ID, strTenantId); + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + Tenant tenant = checkTenantId(tenantId, Operation.DELETE); + tbTenantService.delete(tenant); + } + + @ApiOperation(value = "Get Tenants (getTenants)", notes = "Returns a page of tenants registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenants", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenants( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = TENANT_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = TENANT_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(tenantService.findTenants(pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenants Info (getTenants)", notes = "Returns a page of tenant info objects registered in the platform. " + + TENANT_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = TENANT_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = TENANT_INFO_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(tenantService.findTenantInfos(pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java new file mode 100644 index 0000000..e5a33da --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -0,0 +1,271 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +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.EntityInfo; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_PROFILE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_PROFILE_INFO_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@Slf4j +@RequiredArgsConstructor +public class TenantProfileController extends BaseController { + + private static final String TENANT_PROFILE_INFO_DESCRIPTION = "Tenant Profile Info is a lightweight object that contains only id and name of the profile. "; + + private final TbTenantProfileService tbTenantProfileService; + + @ApiOperation(value = "Get Tenant Profile (getTenantProfileById)", + notes = "Fetch the Tenant Profile object based on the provided Tenant Profile Id. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfile/{tenantProfileId}", method = RequestMethod.GET) + @ResponseBody + public TenantProfile getTenantProfileById( + @ApiParam(value = TENANT_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { + checkParameter("tenantProfileId", strTenantProfileId); + try { + TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); + return checkTenantProfileId(tenantProfileId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Profile Info (getTenantProfileInfoById)", + notes = "Fetch the Tenant Profile Info object based on the provided Tenant Profile Id. " + TENANT_PROFILE_INFO_DESCRIPTION + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfileInfo/{tenantProfileId}", method = RequestMethod.GET) + @ResponseBody + public EntityInfo getTenantProfileInfoById( + @ApiParam(value = TENANT_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { + checkParameter("tenantProfileId", strTenantProfileId); + try { + TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); + return checkNotNull(tenantProfileService.findTenantProfileInfoById(getTenantId(), tenantProfileId)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get default Tenant Profile Info (getDefaultTenantProfileInfo)", + notes = "Fetch the default Tenant Profile Info object based. " + TENANT_PROFILE_INFO_DESCRIPTION + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfileInfo/default", method = RequestMethod.GET) + @ResponseBody + public EntityInfo getDefaultTenantProfileInfo() throws ThingsboardException { + try { + return checkNotNull(tenantProfileService.findDefaultTenantProfileInfo(getTenantId())); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or update Tenant Profile (saveTenantProfile)", + notes = "Create or update the Tenant Profile. When creating tenant profile, platform generates Tenant Profile Id as " + UUID_WIKI_LINK + + "The newly created Tenant Profile Id will be present in the response. " + + "Specify existing Tenant Profile Id id to update the Tenant Profile. " + + "Referencing non-existing Tenant Profile Id will cause 'Not Found' error. " + + "\n\nUpdate of the tenant profile configuration will cause immediate recalculation of API limits for all affected Tenants. " + + "\n\nThe **'profileData'** object is the part of Tenant Profile that defines API limits and Rate limits. " + + "\n\nYou have an ability to define maximum number of devices ('maxDevice'), assets ('maxAssets') and other entities. " + + "You may also define maximum number of messages to be processed per month ('maxTransportMessages', 'maxREExecutions', etc). " + + "The '*RateLimit' defines the rate limits using simple syntax. For example, '1000:1,20000:60' means up to 1000 events per second but no more than 20000 event per minute. " + + "Let's review the example of tenant profile data below: " + + "\n\n" + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"name\": \"Default\",\n" + + " \"description\": \"Default tenant profile\",\n" + + " \"isolatedTbRuleEngine\": false,\n" + + " \"profileData\": {\n" + + " \"configuration\": {\n" + + " \"type\": \"DEFAULT\",\n" + + " \"maxDevices\": 0,\n" + + " \"maxAssets\": 0,\n" + + " \"maxCustomers\": 0,\n" + + " \"maxUsers\": 0,\n" + + " \"maxDashboards\": 0,\n" + + " \"maxRuleChains\": 0,\n" + + " \"maxResourcesInBytes\": 0,\n" + + " \"maxOtaPackagesInBytes\": 0,\n" + + " \"transportTenantMsgRateLimit\": \"1000:1,20000:60\",\n" + + " \"transportTenantTelemetryMsgRateLimit\": \"1000:1,20000:60\",\n" + + " \"transportTenantTelemetryDataPointsRateLimit\": \"1000:1,20000:60\",\n" + + " \"transportDeviceMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportDeviceTelemetryMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportDeviceTelemetryDataPointsRateLimit\": \"20:1,600:60\",\n" + + " \"maxTransportMessages\": 10000000,\n" + + " \"maxTransportDataPoints\": 10000000,\n" + + " \"maxREExecutions\": 4000000,\n" + + " \"maxJSExecutions\": 5000000,\n" + + " \"maxDPStorageDays\": 0,\n" + + " \"maxRuleNodeExecutionsPerMessage\": 50,\n" + + " \"maxEmails\": 0,\n" + + " \"maxSms\": 0,\n" + + " \"maxCreatedAlarms\": 1000,\n" + + " \"defaultStorageTtlDays\": 0,\n" + + " \"alarmsTtlDays\": 0,\n" + + " \"rpcTtlDays\": 0,\n" + + " \"warnThreshold\": 0\n" + + " }\n" + + " },\n" + + " \"default\": true\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "Remove 'id', from the request body example (below) to create new Tenant Profile entity." + + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfile", method = RequestMethod.POST) + @ResponseBody + public TenantProfile saveTenantProfile(@ApiParam(value = "A JSON value representing the tenant profile.") + @RequestBody TenantProfile tenantProfile) throws ThingsboardException { + try { + TenantProfile oldProfile; + if (tenantProfile.getId() == null) { + accessControlService.checkPermission(getCurrentUser(), Resource.TENANT_PROFILE, Operation.CREATE); + oldProfile = null; + } else { + oldProfile = checkTenantProfileId(tenantProfile.getId(), Operation.WRITE); + } + + return tbTenantProfileService.save(getTenantId(), tenantProfile, oldProfile); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete Tenant Profile (deleteTenantProfile)", + notes = "Deletes the tenant profile. Referencing non-existing tenant profile Id will cause an error. Referencing profile that is used by the tenants will cause an error. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfile/{tenantProfileId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteTenantProfile(@ApiParam(value = TENANT_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { + try { + checkParameter("tenantProfileId", strTenantProfileId); + TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); + TenantProfile profile = checkTenantProfileId(tenantProfileId, Operation.DELETE); + tbTenantProfileService.delete(getTenantId(), profile); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Make tenant profile default (setDefaultTenantProfile)", + notes = "Makes specified tenant profile to be default. Referencing non-existing tenant profile Id will cause an error. " + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfile/{tenantProfileId}/default", method = RequestMethod.POST) + @ResponseBody + public TenantProfile setDefaultTenantProfile( + @ApiParam(value = TENANT_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { + checkParameter("tenantProfileId", strTenantProfileId); + try { + TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); + TenantProfile tenantProfile = checkTenantProfileId(tenantProfileId, Operation.WRITE); + tenantProfileService.setDefaultTenantProfile(getTenantId(), tenantProfileId); + return tenantProfile; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Profiles (getTenantProfiles)", notes = "Returns a page of tenant profiles registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantProfiles( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = TENANT_PROFILE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(tenantProfileService.findTenantProfiles(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Profiles Info (getTenantProfileInfos)", notes = "Returns a page of tenant profile info objects registered in the platform. " + + TENANT_PROFILE_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenantProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantProfileInfos( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = TENANT_PROFILE_INFO_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(tenantProfileService.findTenantProfileInfos(getTenantId(), pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java new file mode 100644 index 0000000..5aad5c9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -0,0 +1,272 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.validation.Valid; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; + +@RestController +@RequestMapping("/api/2fa") +@TbCoreComponent +@RequiredArgsConstructor +public class TwoFactorAuthConfigController extends BaseController { + + private final TwoFaConfigManager twoFaConfigManager; + private final TwoFactorAuthService twoFactorAuthService; + + + @ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)", + notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE + + "Example:\n" + + "```\n{\n \"configs\": {\n" + + " \"EMAIL\": {\n \"providerType\": \"EMAIL\",\n \"useByDefault\": true,\n \"email\": \"tenant@thingsboard.org\"\n },\n" + + " \"TOTP\": {\n \"providerType\": \"TOTP\",\n \"useByDefault\": false,\n \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=P6Z2TLYTASOGP6LCJZAD24ETT5DACNNX\"\n },\n" + + " \"SMS\": {\n \"providerType\": \"SMS\",\n \"useByDefault\": false,\n \"phoneNumber\": \"+380501253652\"\n }\n" + + " }\n}\n```" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @GetMapping("/account/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null); + } + + + @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", + notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE + + "For TOTP, this will return a corresponding account config template " + + "with a generated OTP auth URL (with new random secret key for each API call) that can be then " + + "converted to a QR code to scan with an authenticator app. Example:\n" + + "```\n{\n" + + " \"providerType\": \"TOTP\",\n" + + " \"useByDefault\": false,\n" + + " \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=PNJDNWJVAK4ZTUYT7RFGPQLXA7XGU7PX\"\n" + + "}\n```" + NEW_LINE + + "For EMAIL, the generated config will contain email from user's account:\n" + + "```\n{\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"useByDefault\": false,\n" + + " \"email\": \"tenant@thingsboard.org\"\n" + + "}\n```" + NEW_LINE + + "For SMS 2FA this method will just return a config with empty/default values as there is nothing to generate/preset:\n" + + "```\n{\n" + + " \"providerType\": \"SMS\",\n" + + " \"useByDefault\": false,\n" + + " \"phoneNumber\": null\n" + + "}\n```" + NEW_LINE + + "Will throw an error (Bad Request) if the provider is not configured for usage. " + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PostMapping("/account/config/generate") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public TwoFaAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) + @RequestParam TwoFaProviderType providerType) throws Exception { + SecurityUser user = getCurrentUser(); + return twoFactorAuthService.generateNewAccountConfig(user, providerType); + } + + @ApiOperation(value = "Submit 2FA account config (submitTwoFaAccountConfig)", + notes = "Submit 2FA account config to prepare for a future verification. " + + "Basically, this method will send a verification code for a given account config, if this has " + + "sense for a chosen 2FA provider. This code is needed to then verify and save the account config." + NEW_LINE + + "Example of EMAIL 2FA account config:\n" + + "```\n{\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"useByDefault\": true,\n" + + " \"email\": \"separate-email-for-2fa@thingsboard.org\"\n" + + "}\n```" + NEW_LINE + + "Example of SMS 2FA account config:\n" + + "```\n{\n" + + " \"providerType\": \"SMS\",\n" + + " \"useByDefault\": false,\n" + + " \"phoneNumber\": \"+38012312321\"\n" + + "}\n```" + NEW_LINE + + "For TOTP this method does nothing." + NEW_LINE + + "Will throw an error (Bad Request) if submitted account config is not valid, " + + "or if the provider is not configured for usage. " + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PostMapping("/account/config/submit") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { + SecurityUser user = getCurrentUser(); + twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); + } + + @ApiOperation(value = "Verify and save 2FA account config (verifyAndSaveTwoFaAccountConfig)", + notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " + NEW_LINE + + "Returns whole account's 2FA settings object.\n" + + "Will throw an error (Bad Request) if the provider is not configured for usage. " + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PostMapping("/account/config") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, + @RequestParam(required = false) String verificationCode) throws Exception { + SecurityUser user = getCurrentUser(); + if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) { + throw new IllegalArgumentException("2FA provider is already configured"); + } + + boolean verificationSuccess; + if (accountConfig.getProviderType() != TwoFaProviderType.BACKUP_CODE) { + verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false); + } else { + verificationSuccess = true; + } + if (verificationSuccess) { + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + } else { + throw new IllegalArgumentException("Verification code is incorrect"); + } + } + + @ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes = + "Update config for a given provider type. \n" + + "Update request example:\n" + + "```\n{\n \"useByDefault\": true\n}\n```\n" + + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PutMapping("/account/config") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType, + @RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + .orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found")); + accountConfig.setUseByDefault(updateRequest.isUseByDefault()); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + } + + @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes = + "Delete 2FA config for a given 2FA provider type. \n" + + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @DeleteMapping("/account/config") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType); + } + + + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + + "Example of response:\n" + + "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + ) + @GetMapping("/providers") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public List getAvailableTwoFaProviders() throws ThingsboardException { + return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) + .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() + .map(TwoFaProviderConfig::getProviderType) + .collect(Collectors.toList()); + } + + + @ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)", + notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " + + "If 2FA is not configured, then an empty response will be returned." + + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @GetMapping("/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + public PlatformTwoFaSettings getPlatformTwoFaSettings() throws ThingsboardException { + return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), false).orElse(null); + } + + @ApiOperation(value = "Save platform 2FA settings (savePlatformTwoFaSettings)", + notes = "Save 2FA settings for platform. The settings have following properties:\n" + + "- `providers` - the list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list. \n\n" + + "- `minVerificationCodeSendPeriod` - minimal period in seconds to wait after verification code send request to send next request. \n" + + "- `verificationCodeCheckRateLimit` - rate limit configuration for verification code checking.\n" + + "The format is standard: 'amountOfRequests:periodInSeconds'. The value of '1:60' would limit verification " + + "code checking requests to one per minute.\n" + + "- `maxVerificationFailuresBeforeUserLockout` - maximum number of verification failures before a user gets disabled.\n" + + "- `totalAllowedTimeForVerification` - total amount of time in seconds allotted for verification. " + + "Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.\n" + NEW_LINE + + "TOTP 2FA provider config has following settings:\n" + + "- `issuerName` - issuer name that will be displayed in an authenticator app near a username. Must not be blank.\n\n" + + "For SMS 2FA provider:\n" + + "- `smsVerificationMessageTemplate` - verification message template. Available template variables " + + "are ${code} and ${userEmail}. It must not be blank and must contain verification code variable.\n" + + "- `verificationCodeLifetime` - verification code lifetime in seconds. Required to be positive.\n\n" + + "For EMAIL provider type:\n" + + "- `verificationCodeLifetime` - the same as for SMS." + NEW_LINE + + "Example of the settings:\n" + + "```\n{\n" + + " \"providers\": [\n" + + " {\n" + + " \"providerType\": \"TOTP\",\n" + + " \"issuerName\": \"TB\"\n" + + " },\n" + + " {\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"verificationCodeLifetime\": 60\n" + + " },\n" + + " {\n" + + " \"providerType\": \"SMS\",\n" + + " \"verificationCodeLifetime\": 60,\n" + + " \"smsVerificationMessageTemplate\": \"Here is your verification code: ${code}\"\n" + + " }\n" + + " ],\n" + + " \"minVerificationCodeSendPeriod\": 60,\n" + + " \"verificationCodeCheckRateLimit\": \"3:900\",\n" + + " \"maxVerificationFailuresBeforeUserLockout\": 10,\n" + + " \"totalAllowedTimeForVerification\": 600\n" + + "}\n```" + + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PostMapping("/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + public PlatformTwoFaSettings savePlatformTwoFaSettings(@ApiParam(value = "Settings value", required = true) + @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { + return twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings); + } + + + @Data + public static class TwoFaAccountConfigUpdateRequest { + private boolean useByDefault; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java new file mode 100644 index 0000000..5ce46e3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -0,0 +1,152 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.EmailTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; + +@RestController +@RequestMapping("/api/auth/2fa") +@TbCoreComponent +@RequiredArgsConstructor +public class TwoFactorAuthController extends BaseController { + + private final TwoFactorAuthService twoFactorAuthService; + private final TwoFaConfigManager twoFaConfigManager; + private final JwtTokenFactory tokenFactory; + private final SystemSecurityService systemSecurityService; + private final UserService userService; + + + @ApiOperation(value = "Request 2FA verification code (requestTwoFaVerificationCode)", + notes = "Request 2FA verification code." + NEW_LINE + + "To make a request to this endpoint, you need an access token with the scope of PRE_VERIFICATION_TOKEN, " + + "which is issued on username/password auth if 2FA is enabled." + NEW_LINE + + "The API method is rate limited (using rate limit config from TwoFactorAuthSettings). " + + "Will return a Bad Request error if provider is not configured for usage, " + + "and Too Many Requests error if rate limits are exceeded.") + @PostMapping("/verification/send") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public void requestTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType) throws Exception { + SecurityUser user = getCurrentUser(); + twoFactorAuthService.prepareVerificationCode(user, providerType, true); + } + + @ApiOperation(value = "Check 2FA verification code (checkTwoFaVerificationCode)", + notes = "Checks 2FA verification code, and if it is correct the method returns a regular access and refresh token pair." + NEW_LINE + + "The API method is rate limited (using rate limit config from TwoFactorAuthSettings), and also will block a user " + + "after X unsuccessful verification attempts if such behavior is configured (in TwoFactorAuthSettings)." + NEW_LINE + + "Will return a Bad Request error if provider is not configured for usage, " + + "and Too Many Requests error if rate limits are exceeded.") + @PostMapping("/verification/check") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public JwtPair checkTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType, + @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { + SecurityUser user = getCurrentUser(); + boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true); + if (verificationSuccess) { + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); + user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); + return tokenFactory.createTokenPair(user); + } else { + ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + throw error; + } + } + + + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + "Get the list of 2FA provider infos available for user to use. Example:\n" + + "```\n[\n" + + " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + + " {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" + + " {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" + + "]\n```") + @GetMapping("/providers") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public List getAvailableTwoFaProviders() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + Optional platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) + .map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList()) + .stream().map(config -> { + String contact = null; + switch (config.getProviderType()) { + case SMS: + String phoneNumber = ((SmsTwoFaAccountConfig) config).getPhoneNumber(); + contact = StringUtils.obfuscate(phoneNumber, 2, '*', phoneNumber.indexOf('+') + 1, phoneNumber.length()); + break; + case EMAIL: + String email = ((EmailTwoFaAccountConfig) config).getEmail(); + contact = StringUtils.obfuscate(email, 2, '*', 0, email.indexOf('@')); + break; + } + return TwoFaProviderInfo.builder() + .type(config.getProviderType()) + .isDefault(config.isUseByDefault()) + .contact(contact) + .minVerificationCodeSendPeriod(platformTwoFaSettings.get().getMinVerificationCodeSendPeriod()) + .build(); + }) + .collect(Collectors.toList()); + } + + @Data + @AllArgsConstructor + @Builder + public static class TwoFaProviderInfo { + private TwoFaProviderType type; + private boolean isDefault; + private String contact; + private Integer minVerificationCodeSendPeriod; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java new file mode 100644 index 0000000..321ff8d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class UiSettingsController extends BaseController { + + @Value("${ui.help.base-url}") + private String helpBaseUrl; + + @ApiOperation(value = "Get UI help base url (getHelpBaseUrl)", + notes = "Get UI help base url used to fetch help assets. " + + "The actual value of the base url is configurable in the system configuration file.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/uiSettings/helpBaseUrl", method = RequestMethod.GET) + @ResponseBody + public String getHelpBaseUrl() throws ThingsboardException { + return helpBaseUrl; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java new file mode 100644 index 0000000..86e4456 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -0,0 +1,381 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +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.rule.engine.api.MailService; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.user.TbUserService; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import javax.servlet.http.HttpServletRequest; + +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; +import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.DEFAULT_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.USER_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.USER_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RequiredArgsConstructor +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class UserController extends BaseController { + + public static final String USER_ID = "userId"; + public static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; + public static final String ACTIVATE_URL_PATTERN = "%s/api/noauth/activate?activateToken=%s"; + + @Value("${security.user_token_access_enabled}") + @Getter + private boolean userTokenAccessEnabled; + + private final MailService mailService; + private final JwtTokenFactory tokenFactory; + private final SystemSecurityService systemSecurityService; + private final ApplicationEventPublisher eventPublisher; + private final TbUserService tbUserService; + + @ApiOperation(value = "Get User (getUserById)", + notes = "Fetch the User object based on the provided User Id. " + + "If the user has the authority of 'SYS_ADMIN', the server does not perform additional checks. " + + "If the user has the authority of 'TENANT_ADMIN', the server checks that the requested user is owned by the same tenant. " + + "If the user has the authority of 'CUSTOMER_USER', the server checks that the requested user is owned by the same customer.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/user/{userId}", method = RequestMethod.GET) + @ResponseBody + public User getUserById( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + try { + UserId userId = new UserId(toUUID(strUserId)); + User user = checkUserId(userId, Operation.READ); + if (user.getAdditionalInfo().isObject()) { + ObjectNode additionalInfo = (ObjectNode) user.getAdditionalInfo(); + processDashboardIdFromAdditionalInfo(additionalInfo, DEFAULT_DASHBOARD); + processDashboardIdFromAdditionalInfo(additionalInfo, HOME_DASHBOARD); + UserCredentials userCredentials = userService.findUserCredentialsByUserId(user.getTenantId(), user.getId()); + if (userCredentials.isEnabled() && !additionalInfo.has("userCredentialsEnabled")) { + additionalInfo.put("userCredentialsEnabled", true); + } + } + return user; + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Check Token Access Enabled (isUserTokenAccessEnabled)", + notes = "Checks that the system is configured to allow administrators to impersonate themself as other users. " + + "If the user who performs the request has the authority of 'SYS_ADMIN', it is possible to login as any tenant administrator. " + + "If the user who performs the request has the authority of 'TENANT_ADMIN', it is possible to login as any customer user. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/tokenAccessEnabled", method = RequestMethod.GET) + @ResponseBody + public boolean isUserTokenAccessEnabled() { + return userTokenAccessEnabled; + } + + @ApiOperation(value = "Get User Token (getUserToken)", + notes = "Returns the token of the User based on the provided User Id. " + + "If the user who performs the request has the authority of 'SYS_ADMIN', it is possible to get the token of any tenant administrator. " + + "If the user who performs the request has the authority of 'TENANT_ADMIN', it is possible to get the token of any customer user that belongs to the same tenant. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/{userId}/token", method = RequestMethod.GET) + @ResponseBody + public JwtPair getUserToken( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + try { + if (!userTokenAccessEnabled) { + throw new ThingsboardException(YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION, + ThingsboardErrorCode.PERMISSION_DENIED); + } + UserId userId = new UserId(toUUID(strUserId)); + SecurityUser authUser = getCurrentUser(); + User user = checkUserId(userId, Operation.READ); + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + UserCredentials credentials = userService.findUserCredentialsByUserId(authUser.getTenantId(), userId); + SecurityUser securityUser = new SecurityUser(user, credentials.isEnabled(), principal); + return tokenFactory.createTokenPair(securityUser); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Save Or update User (saveUser)", + notes = "Create or update the User. When creating user, platform generates User Id as " + UUID_WIKI_LINK + + "The newly created User Id will be present in the response. " + + "Specify existing User Id to update the device. " + + "Referencing non-existing User Id will cause 'Not Found' error." + + "\n\nDevice email is unique for entire platform setup." + + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new User entity." + + "\n\nAvailable for users with 'SYS_ADMIN', 'TENANT_ADMIN' or 'CUSTOMER_USER' authority.") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/user", method = RequestMethod.POST) + @ResponseBody + public User saveUser( + @ApiParam(value = "A JSON value representing the User.", required = true) + @RequestBody User user, + @ApiParam(value = "Send activation email (or use activation link)", defaultValue = "true") + @RequestParam(required = false, defaultValue = "true") boolean sendActivationMail, HttpServletRequest request) throws ThingsboardException { + if (!Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { + user.setTenantId(getCurrentUser().getTenantId()); + } + checkEntity(user.getId(), user, Resource.USER); + return tbUserService.save(getTenantId(), getCurrentUser().getCustomerId(), user, sendActivationMail, request, getCurrentUser()); + } + + @ApiOperation(value = "Send or re-send the activation email", + notes = "Force send the activation email to the user. Useful to resend the email if user has accidentally deleted it. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/sendActivationMail", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void sendActivationEmail( + @ApiParam(value = "Email of the user", required = true) + @RequestParam(value = "email") String email, + HttpServletRequest request) throws ThingsboardException { + try { + User user = checkNotNull(userService.findUserByEmail(getCurrentUser().getTenantId(), email)); + + accessControlService.checkPermission(getCurrentUser(), Resource.USER, Operation.READ, + user.getId(), user); + + UserCredentials userCredentials = userService.findUserCredentialsByUserId(getCurrentUser().getTenantId(), user.getId()); + if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { + String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request); + String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, + userCredentials.getActivateToken()); + mailService.sendActivationEmail(activateUrl, email); + } else { + throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get the activation link (getActivationLink)", + notes = "Get the activation link for the user. " + + "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/{userId}/activationLink", method = RequestMethod.GET, produces = "text/plain") + @ResponseBody + public String getActivationLink( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + HttpServletRequest request) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + try { + UserId userId = new UserId(toUUID(strUserId)); + User user = checkUserId(userId, Operation.READ); + SecurityUser authUser = getCurrentUser(); + UserCredentials userCredentials = userService.findUserCredentialsByUserId(authUser.getTenantId(), user.getId()); + if (!userCredentials.isEnabled() && userCredentials.getActivateToken() != null) { + String baseUrl = systemSecurityService.getBaseUrl(getTenantId(), getCurrentUser().getCustomerId(), request); + String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, + userCredentials.getActivateToken()); + return activateUrl; + } else { + throw new ThingsboardException("User is already activated!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete User (deleteUser)", + notes = "Deletes the User, it's credentials and all the relations (from and to the User). " + + "Referencing non-existing User Id will cause an error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/{userId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteUser( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + UserId userId = new UserId(toUUID(strUserId)); + User user = checkUserId(userId, Operation.DELETE); + if (user.getAuthority() == Authority.SYS_ADMIN && getCurrentUser().getId().equals(userId)) { + throw new ThingsboardException("Sysadmin is not allowed to delete himself", ThingsboardErrorCode.PERMISSION_DENIED); + } + tbUserService.delete(getTenantId(), getCurrentUser().getCustomerId(), user, getCurrentUser()); + } + + @ApiOperation(value = "Get Users (getUsers)", + notes = "Returns a page of users owned by tenant or customer. The scope depends on authority of the user that performs the request." + + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/users", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getUsers( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = USER_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = USER_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + SecurityUser currentUser = getCurrentUser(); + if (Authority.TENANT_ADMIN.equals(currentUser.getAuthority())) { + return checkNotNull(userService.findUsersByTenantId(currentUser.getTenantId(), pageLink)); + } else { + return checkNotNull(userService.findCustomerUsers(currentUser.getTenantId(), currentUser.getCustomerId(), pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Tenant Users (getTenantAdmins)", + notes = "Returns a page of users owned by tenant. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('SYS_ADMIN')") + @RequestMapping(value = "/tenant/{tenantId}/users", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getTenantAdmins( + @ApiParam(value = TENANT_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(TENANT_ID) String strTenantId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = USER_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = USER_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("tenantId", strTenantId); + try { + TenantId tenantId = TenantId.fromUUID(toUUID(strTenantId)); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return checkNotNull(userService.findTenantAdmins(tenantId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Customer Users (getCustomerUsers)", + notes = "Returns a page of users owned by customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/users", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getCustomerUsers( + @ApiParam(value = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) + @PathVariable(CUSTOMER_ID) String strCustomerId, + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = USER_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = USER_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId, Operation.READ); + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(userService.findCustomerUsers(tenantId, customerId, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Enable/Disable User credentials (setUserCredentialsEnabled)", + notes = "Enables or Disables user credentials. Useful when you would like to block user account without deleting it. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/user/{userId}/userCredentialsEnabled", method = RequestMethod.POST) + @ResponseBody + public void setUserCredentialsEnabled( + @ApiParam(value = USER_ID_PARAM_DESCRIPTION) + @PathVariable(USER_ID) String strUserId, + @ApiParam(value = "Disable (\"true\") or enable (\"false\") the credentials.", defaultValue = "true") + @RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException { + checkParameter(USER_ID, strUserId); + try { + UserId userId = new UserId(toUUID(strUserId)); + User user = checkUserId(userId, Operation.WRITE); + TenantId tenantId = getCurrentUser().getTenantId(); + userService.setUserCredentialsEnabled(tenantId, userId, userCredentialsEnabled); + + if (!userCredentialsEnabled) { + eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(userId)); + } + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java new file mode 100644 index 0000000..725d8ca --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -0,0 +1,250 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +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.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.widget.WidgetType; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeInfo; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.List; + +import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; +import static org.thingsboard.server.controller.ControllerConstants.WIDGET_TYPE_ID_PARAM_DESCRIPTION; + +@Slf4j +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class WidgetTypeController extends AutoCommitController { + + private static final String WIDGET_TYPE_DESCRIPTION = "Widget Type represents the template for widget creation. Widget Type and Widget are similar to class and object in OOP theory."; + private static final String WIDGET_TYPE_DETAILS_DESCRIPTION = "Widget Type Details extend Widget Type and add image and description properties. " + + "Those properties are useful to edit the Widget Type but they are not required for Dashboard rendering. "; + private static final String WIDGET_TYPE_INFO_DESCRIPTION = "Widget Type Info is a lightweight object that represents Widget Type but does not contain the heavyweight widget descriptor JSON"; + + + @ApiOperation(value = "Get Widget Type Details (getWidgetTypeById)", + notes = "Get the Widget Type Details based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetType/{widgetTypeId}", method = RequestMethod.GET) + @ResponseBody + public WidgetTypeDetails getWidgetTypeById( + @ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetTypeId") String strWidgetTypeId) throws ThingsboardException { + checkParameter("widgetTypeId", strWidgetTypeId); + try { + WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId)); + return checkWidgetTypeId(widgetTypeId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Widget Type (saveWidgetType)", + notes = "Create or update the Widget Type. " + WIDGET_TYPE_DESCRIPTION + " " + + "When creating the Widget Type, platform generates Widget Type Id as " + UUID_WIKI_LINK + + "The newly created Widget Type Id will be present in the response. " + + "Specify existing Widget Type id to update the Widget Type. " + + "Referencing non-existing Widget Type Id will cause 'Not Found' error." + + "\n\nWidget Type alias is unique in the scope of Widget Bundle. " + + "Special Tenant Id '13814000-1dd2-11b2-8080-808080808080' is automatically used if the create request is sent by user with 'SYS_ADMIN' authority." + + "Remove 'id', 'tenantId' rom the request body example (below) to create new Widget Type entity." + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetType", method = RequestMethod.POST) + @ResponseBody + public WidgetTypeDetails saveWidgetType( + @ApiParam(value = "A JSON value representing the Widget Type Details.", required = true) + @RequestBody WidgetTypeDetails widgetTypeDetails) throws ThingsboardException { + try { + var currentUser = getCurrentUser(); + if (Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + widgetTypeDetails.setTenantId(TenantId.SYS_TENANT_ID); + } else { + widgetTypeDetails.setTenantId(currentUser.getTenantId()); + } + + checkEntity(widgetTypeDetails.getId(), widgetTypeDetails, Resource.WIDGET_TYPE); + WidgetTypeDetails savedWidgetTypeDetails = widgetTypeService.saveWidgetType(widgetTypeDetails); + + if (!Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(widgetTypeDetails.getTenantId(), widgetTypeDetails.getBundleAlias()); + if (widgetsBundle != null) { + autoCommit(currentUser, widgetsBundle.getId()); + } + } + + sendEntityNotificationMsg(getTenantId(), savedWidgetTypeDetails.getId(), + widgetTypeDetails.getId() == null ? EdgeEventActionType.ADDED : EdgeEventActionType.UPDATED); + + return checkNotNull(savedWidgetTypeDetails); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Delete widget type (deleteWidgetType)", + notes = "Deletes the Widget Type. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetType/{widgetTypeId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteWidgetType( + @ApiParam(value = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetTypeId") String strWidgetTypeId) throws ThingsboardException { + checkParameter("widgetTypeId", strWidgetTypeId); + try { + var currentUser = getCurrentUser(); + WidgetTypeId widgetTypeId = new WidgetTypeId(toUUID(strWidgetTypeId)); + WidgetTypeDetails wtd = checkWidgetTypeId(widgetTypeId, Operation.DELETE); + widgetTypeService.deleteWidgetType(currentUser.getTenantId(), widgetTypeId); + + if (wtd != null && !Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(wtd.getTenantId(), wtd.getBundleAlias()); + if (widgetsBundle != null) { + autoCommit(currentUser, widgetsBundle.getId()); + } + } + + sendEntityNotificationMsg(getTenantId(), widgetTypeId, EdgeEventActionType.DELETED); + + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)", + notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetTypes", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) + @ResponseBody + public List getBundleWidgetTypes( + @ApiParam(value = "System or Tenant", required = true) + @RequestParam boolean isSystem, + @ApiParam(value = "Widget Bundle alias", required = true) + @RequestParam String bundleAlias) throws ThingsboardException { + try { + TenantId tenantId; + if (isSystem) { + tenantId = TenantId.SYS_TENANT_ID; + } else { + tenantId = getCurrentUser().getTenantId(); + } + return checkNotNull(widgetTypeService.findWidgetTypesByTenantIdAndBundleAlias(tenantId, bundleAlias)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get all Widget types details for specified Bundle (getBundleWidgetTypes)", + notes = "Returns an array of Widget Type Details objects that belong to specified Widget Bundle." + WIDGET_TYPE_DETAILS_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetTypesDetails", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) + @ResponseBody + public List getBundleWidgetTypesDetails( + @ApiParam(value = "System or Tenant", required = true) + @RequestParam boolean isSystem, + @ApiParam(value = "Widget Bundle alias", required = true) + @RequestParam String bundleAlias) throws ThingsboardException { + try { + TenantId tenantId; + if (isSystem) { + tenantId = TenantId.SYS_TENANT_ID; + } else { + tenantId = getCurrentUser().getTenantId(); + } + return checkNotNull(widgetTypeService.findWidgetTypesDetailsByTenantIdAndBundleAlias(tenantId, bundleAlias)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfos)", + notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetTypesInfos", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) + @ResponseBody + public List getBundleWidgetTypesInfos( + @ApiParam(value = "System or Tenant", required = true) + @RequestParam boolean isSystem, + @ApiParam(value = "Widget Bundle alias", required = true) + @RequestParam String bundleAlias) throws ThingsboardException { + try { + TenantId tenantId; + if (isSystem) { + tenantId = TenantId.SYS_TENANT_ID; + } else { + tenantId = getCurrentUser().getTenantId(); + } + return checkNotNull(widgetTypeService.findWidgetTypesInfosByTenantIdAndBundleAlias(tenantId, bundleAlias)); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get Widget Type (getWidgetType)", + notes = "Get the Widget Type based on the provided parameters. " + WIDGET_TYPE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/widgetType", params = {"isSystem", "bundleAlias", "alias"}, method = RequestMethod.GET) + @ResponseBody + public WidgetType getWidgetType( + @ApiParam(value = "System or Tenant", required = true) + @RequestParam boolean isSystem, + @ApiParam(value = "Widget Bundle alias", required = true) + @RequestParam String bundleAlias, + @ApiParam(value = "Widget Type alias", required = true) + @RequestParam String alias) throws ThingsboardException { + try { + TenantId tenantId; + if (isSystem) { + tenantId = TenantId.fromUUID(ModelConstants.NULL_UUID); + } else { + tenantId = getCurrentUser().getTenantId(); + } + WidgetType widgetType = widgetTypeService.findWidgetTypeByTenantIdBundleAliasAndAlias(tenantId, bundleAlias, alias); + checkNotNull(widgetType); + accessControlService.checkPermission(getCurrentUser(), Resource.WIDGET_TYPE, Operation.READ, widgetType.getId(), widgetType); + return widgetType; + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java new file mode 100644 index 0000000..23ec498 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -0,0 +1,175 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +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.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.widgets.bundle.TbWidgetsBundleService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.util.List; + +import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; +import static org.thingsboard.server.controller.ControllerConstants.WIDGET_BUNDLE_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.WIDGET_BUNDLE_SORT_PROPERTY_ALLOWABLE_VALUES; +import static org.thingsboard.server.controller.ControllerConstants.WIDGET_BUNDLE_TEXT_SEARCH_DESCRIPTION; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class WidgetsBundleController extends BaseController { + + private final TbWidgetsBundleService tbWidgetsBundleService; + + private static final String WIDGET_BUNDLE_DESCRIPTION = "Widget Bundle represents a group(bundle) of widgets. Widgets are grouped into bundle by type or use case. "; + + @ApiOperation(value = "Get Widget Bundle (getWidgetsBundleById)", + notes = "Get the Widget Bundle based on the provided Widget Bundle Id. " + WIDGET_BUNDLE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}", method = RequestMethod.GET) + @ResponseBody + public WidgetsBundle getWidgetsBundleById( + @ApiParam(value = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { + checkParameter("widgetsBundleId", strWidgetsBundleId); + try { + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); + return checkWidgetsBundleId(widgetsBundleId, Operation.READ); + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Create Or Update Widget Bundle (saveWidgetsBundle)", + notes = "Create or update the Widget Bundle. " + WIDGET_BUNDLE_DESCRIPTION + " " + + "When creating the bundle, platform generates Widget Bundle Id as " + UUID_WIKI_LINK + + "The newly created Widget Bundle Id will be present in the response. " + + "Specify existing Widget Bundle id to update the Widget Bundle. " + + "Referencing non-existing Widget Bundle Id will cause 'Not Found' error." + + "\n\nWidget Bundle alias is unique in the scope of tenant. " + + "Special Tenant Id '13814000-1dd2-11b2-8080-808080808080' is automatically used if the create bundle request is sent by user with 'SYS_ADMIN' authority." + + "Remove 'id', 'tenantId' from the request body example (below) to create new Widgets Bundle entity." + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetsBundle", method = RequestMethod.POST) + @ResponseBody + public WidgetsBundle saveWidgetsBundle( + @ApiParam(value = "A JSON value representing the Widget Bundle.", required = true) + @RequestBody WidgetsBundle widgetsBundle) throws Exception { + var currentUser = getCurrentUser(); + if (Authority.SYS_ADMIN.equals(currentUser.getAuthority())) { + widgetsBundle.setTenantId(TenantId.SYS_TENANT_ID); + } else { + widgetsBundle.setTenantId(currentUser.getTenantId()); + } + + checkEntity(widgetsBundle.getId(), widgetsBundle, Resource.WIDGETS_BUNDLE); + + return tbWidgetsBundleService.save(widgetsBundle, currentUser); + } + + @ApiOperation(value = "Delete widgets bundle (deleteWidgetsBundle)", + notes = "Deletes the widget bundle. Referencing non-existing Widget Bundle Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteWidgetsBundle( + @ApiParam(value = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { + checkParameter("widgetsBundleId", strWidgetsBundleId); + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); + WidgetsBundle widgetsBundle = checkWidgetsBundleId(widgetsBundleId, Operation.DELETE); + tbWidgetsBundleService.delete(widgetsBundle); + } + + @ApiOperation(value = "Get Widget Bundles (getWidgetsBundles)", + notes = "Returns a page of Widget Bundle objects available for current user. " + WIDGET_BUNDLE_DESCRIPTION + " " + + PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/widgetsBundles", params = {"pageSize", "page"}, method = RequestMethod.GET) + @ResponseBody + public PageData getWidgetsBundles( + @ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @ApiParam(value = WIDGET_BUNDLE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = WIDGET_BUNDLE_SORT_PROPERTY_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortProperty, + @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + try { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { + return checkNotNull(widgetsBundleService.findSystemWidgetsBundlesByPageLink(getTenantId(), pageLink)); + } else { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(widgetsBundleService.findAllTenantWidgetsBundlesByTenantIdAndPageLink(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @ApiOperation(value = "Get all Widget Bundles (getWidgetsBundles)", + notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/widgetsBundles", method = RequestMethod.GET) + @ResponseBody + public List getWidgetsBundles() throws ThingsboardException { + try { + if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { + return checkNotNull(widgetsBundleService.findSystemWidgetsBundles(getTenantId())); + } else { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(widgetsBundleService.findAllTenantWidgetsBundlesByTenantId(tenantId)); + } + } catch (Exception e) { + throw handleException(e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java new file mode 100644 index 0000000..3a429ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -0,0 +1,491 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.BeanCreationNotAllowedException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PongMessage; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.NativeWebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.tools.TbRateLimits; +import org.thingsboard.server.config.WebSocketConfiguration; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.telemetry.SessionEvent; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketMsgEndpoint; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; + +import javax.websocket.RemoteEndpoint; +import javax.websocket.SendHandler; +import javax.websocket.SendResult; +import javax.websocket.Session; +import java.io.IOException; +import java.net.URI; +import java.security.InvalidParameterException; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.LinkedBlockingQueue; + +import static org.thingsboard.server.service.telemetry.DefaultTelemetryWebSocketService.NUMBER_OF_PING_ATTEMPTS; + +@Service +@TbCoreComponent +@Slf4j +public class TbWebSocketHandler extends TextWebSocketHandler implements TelemetryWebSocketMsgEndpoint { + + private static final ConcurrentMap internalSessionMap = new ConcurrentHashMap<>(); + private static final ConcurrentMap externalSessionMap = new ConcurrentHashMap<>(); + + + @Autowired + private TelemetryWebSocketService webSocketService; + + @Autowired + private TbTenantProfileCache tenantProfileCache; + + @Value("${server.ws.send_timeout:5000}") + private long sendTimeout; + @Value("${server.ws.ping_timeout:30000}") + private long pingTimeout; + + private ConcurrentMap blacklistedSessions = new ConcurrentHashMap<>(); + private ConcurrentMap perSessionUpdateLimits = new ConcurrentHashMap<>(); + + private ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); + + @Override + public void handleTextMessage(WebSocketSession session, TextMessage message) { + try { + SessionMetaData sessionMd = internalSessionMap.get(session.getId()); + if (sessionMd != null) { + log.trace("[{}][{}] Processing {}", sessionMd.sessionRef.getSecurityCtx().getTenantId(), session.getId(), message.getPayload()); + webSocketService.handleWebSocketMsg(sessionMd.sessionRef, message.getPayload()); + } else { + log.trace("[{}] Failed to find session", session.getId()); + session.close(CloseStatus.SERVER_ERROR.withReason("Session not found!")); + } + } catch (IOException e) { + log.warn("IO error", e); + } + } + + @Override + protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception { + try { + SessionMetaData sessionMd = internalSessionMap.get(session.getId()); + if (sessionMd != null) { + log.trace("[{}][{}] Processing pong response {}", sessionMd.sessionRef.getSecurityCtx().getTenantId(), session.getId(), message.getPayload()); + sessionMd.processPongMessage(System.currentTimeMillis()); + } else { + log.trace("[{}] Failed to find session", session.getId()); + session.close(CloseStatus.SERVER_ERROR.withReason("Session not found!")); + } + } catch (IOException e) { + log.warn("IO error", e); + } + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + super.afterConnectionEstablished(session); + try { + if (session instanceof NativeWebSocketSession) { + Session nativeSession = ((NativeWebSocketSession) session).getNativeSession(Session.class); + if (nativeSession != null) { + nativeSession.getAsyncRemote().setSendTimeout(sendTimeout); + } + } + String internalSessionId = session.getId(); + TelemetryWebSocketSessionRef sessionRef = toRef(session); + String externalSessionId = sessionRef.getSessionId(); + + if (!checkLimits(session, sessionRef)) { + return; + } + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + internalSessionMap.put(internalSessionId, new SessionMetaData(session, sessionRef, + tenantProfileConfiguration != null && tenantProfileConfiguration.getWsMsgQueueLimitPerSession() > 0 ? + tenantProfileConfiguration.getWsMsgQueueLimitPerSession() : 500)); + + externalSessionMap.put(externalSessionId, internalSessionId); + processInWebSocketService(sessionRef, SessionEvent.onEstablished()); + log.info("[{}][{}][{}] Session is opened from address: {}", sessionRef.getSecurityCtx().getTenantId(), externalSessionId, session.getId(), session.getRemoteAddress()); + } catch (InvalidParameterException e) { + log.warn("[{}] Failed to start session", session.getId(), e); + session.close(CloseStatus.BAD_DATA.withReason(e.getMessage())); + } catch (Exception e) { + log.warn("[{}] Failed to start session", session.getId(), e); + session.close(CloseStatus.SERVER_ERROR.withReason(e.getMessage())); + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable tError) throws Exception { + super.handleTransportError(session, tError); + SessionMetaData sessionMd = internalSessionMap.get(session.getId()); + if (sessionMd != null) { + processInWebSocketService(sessionMd.sessionRef, SessionEvent.onError(tError)); + } else { + log.trace("[{}] Failed to find session", session.getId()); + } + log.trace("[{}] Session transport error", session.getId(), tError); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + super.afterConnectionClosed(session, closeStatus); + SessionMetaData sessionMd = internalSessionMap.remove(session.getId()); + if (sessionMd != null) { + cleanupLimits(session, sessionMd.sessionRef); + externalSessionMap.remove(sessionMd.sessionRef.getSessionId()); + processInWebSocketService(sessionMd.sessionRef, SessionEvent.onClosed()); + log.info("[{}][{}][{}] Session is closed", sessionMd.sessionRef.getSecurityCtx().getTenantId(), sessionMd.sessionRef.getSessionId(), session.getId()); + } else { + log.info("[{}] Session is closed", session.getId()); + } + } + + private void processInWebSocketService(TelemetryWebSocketSessionRef sessionRef, SessionEvent event) { + try { + webSocketService.handleWebSocketSessionEvent(sessionRef, event); + } catch (BeanCreationNotAllowedException e) { + log.warn("[{}] Failed to close session due to possible shutdown state", sessionRef.getSessionId()); + } + } + + private TelemetryWebSocketSessionRef toRef(WebSocketSession session) throws IOException { + URI sessionUri = session.getUri(); + String path = sessionUri.getPath(); + path = path.substring(WebSocketConfiguration.WS_PLUGIN_PREFIX.length()); + if (path.length() == 0) { + throw new IllegalArgumentException("URL should contain plugin token!"); + } + String[] pathElements = path.split("/"); + String serviceToken = pathElements[0]; + if (!"telemetry".equalsIgnoreCase(serviceToken)) { + throw new InvalidParameterException("Can't find plugin with specified token!"); + } else { + SecurityUser currentUser = (SecurityUser) ((Authentication) session.getPrincipal()).getPrincipal(); + return new TelemetryWebSocketSessionRef(UUID.randomUUID().toString(), currentUser, session.getLocalAddress(), session.getRemoteAddress()); + } + } + + private class SessionMetaData implements SendHandler { + private final WebSocketSession session; + private final RemoteEndpoint.Async asyncRemote; + private final TelemetryWebSocketSessionRef sessionRef; + + private volatile boolean isSending = false; + private final Queue> msgQueue; + + private volatile long lastActivityTime; + + SessionMetaData(WebSocketSession session, TelemetryWebSocketSessionRef sessionRef, int maxMsgQueuePerSession) { + super(); + this.session = session; + Session nativeSession = ((NativeWebSocketSession) session).getNativeSession(Session.class); + this.asyncRemote = nativeSession.getAsyncRemote(); + this.sessionRef = sessionRef; + this.msgQueue = new LinkedBlockingQueue<>(maxMsgQueuePerSession); + this.lastActivityTime = System.currentTimeMillis(); + } + + synchronized void sendPing(long currentTime) { + try { + long timeSinceLastActivity = currentTime - lastActivityTime; + if (timeSinceLastActivity >= pingTimeout) { + log.warn("[{}] Closing session due to ping timeout", session.getId()); + closeSession(CloseStatus.SESSION_NOT_RELIABLE); + } else if (timeSinceLastActivity >= pingTimeout / NUMBER_OF_PING_ATTEMPTS) { + sendMsg(TbWebSocketPingMsg.INSTANCE); + } + } catch (Exception e) { + log.trace("[{}] Failed to send ping msg", session.getId(), e); + closeSession(CloseStatus.SESSION_NOT_RELIABLE); + } + } + + private void closeSession(CloseStatus reason) { + try { + close(this.sessionRef, reason); + } catch (IOException ioe) { + log.trace("[{}] Session transport error", session.getId(), ioe); + } + } + + synchronized void processPongMessage(long currentTime) { + lastActivityTime = currentTime; + } + + synchronized void sendMsg(String msg) { + sendMsg(new TbWebSocketTextMsg(msg)); + } + + synchronized void sendMsg(TbWebSocketMsg msg) { + if (isSending) { + try { + msgQueue.add(msg); + } catch (RuntimeException e) { + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId(), e); + } else { + log.info("[{}][{}] Session closed due to queue error", sessionRef.getSecurityCtx().getTenantId(), session.getId()); + } + closeSession(CloseStatus.POLICY_VIOLATION.withReason("Max pending updates limit reached!")); + } + } else { + isSending = true; + sendMsgInternal(msg); + } + } + + private void sendMsgInternal(TbWebSocketMsg msg) { + try { + if (TbWebSocketMsgType.TEXT.equals(msg.getType())) { + TbWebSocketTextMsg textMsg = (TbWebSocketTextMsg) msg; + this.asyncRemote.sendText(textMsg.getMsg(), this); + } else { + TbWebSocketPingMsg pingMsg = (TbWebSocketPingMsg) msg; + this.asyncRemote.sendPing(pingMsg.getMsg()); + processNextMsg(); + } + } catch (Exception e) { + log.trace("[{}] Failed to send msg", session.getId(), e); + closeSession(CloseStatus.SESSION_NOT_RELIABLE); + } + } + + @Override + public void onResult(SendResult result) { + if (!result.isOK()) { + log.trace("[{}] Failed to send msg", session.getId(), result.getException()); + closeSession(CloseStatus.SESSION_NOT_RELIABLE); + } else { + processNextMsg(); + } + } + + private void processNextMsg() { + TbWebSocketMsg msg = msgQueue.poll(); + if (msg != null) { + sendMsgInternal(msg); + } else { + isSending = false; + } + } + } + + @Override + public void send(TelemetryWebSocketSessionRef sessionRef, int subscriptionId, String msg) throws IOException { + String externalId = sessionRef.getSessionId(); + log.debug("[{}] Processing {}", externalId, msg); + String internalId = externalSessionMap.get(externalId); + if (internalId != null) { + SessionMetaData sessionMd = internalSessionMap.get(internalId); + if (sessionMd != null) { + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + if (tenantProfileConfiguration != null) { + if (StringUtils.isNotEmpty(tenantProfileConfiguration.getWsUpdatesPerSessionRateLimit())) { + TbRateLimits rateLimits = perSessionUpdateLimits.computeIfAbsent(sessionRef.getSessionId(), sid -> new TbRateLimits(tenantProfileConfiguration.getWsUpdatesPerSessionRateLimit())); + if (!rateLimits.tryConsume()) { + if (blacklistedSessions.putIfAbsent(externalId, sessionRef) == null) { + log.info("[{}][{}][{}] Failed to process session update. Max session updates limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), externalId); + sessionMd.sendMsg("{\"subscriptionId\":" + subscriptionId + ", \"errorCode\":" + ThingsboardErrorCode.TOO_MANY_UPDATES.getErrorCode() + ", \"errorMsg\":\"Too many updates!\"}"); + } + return; + } else { + log.debug("[{}][{}][{}] Session is no longer blacklisted.", sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), externalId); + blacklistedSessions.remove(externalId); + } + } else { + perSessionUpdateLimits.remove(sessionRef.getSessionId()); + } + } + sessionMd.sendMsg(msg); + } else { + log.warn("[{}][{}] Failed to find session by internal id", externalId, internalId); + } + } else { + log.warn("[{}] Failed to find session by external id", externalId); + } + } + + @Override + public void sendPing(TelemetryWebSocketSessionRef sessionRef, long currentTime) throws IOException { + String externalId = sessionRef.getSessionId(); + String internalId = externalSessionMap.get(externalId); + if (internalId != null) { + SessionMetaData sessionMd = internalSessionMap.get(internalId); + if (sessionMd != null) { + sessionMd.sendPing(currentTime); + } else { + log.warn("[{}][{}] Failed to find session by internal id", externalId, internalId); + } + } else { + log.warn("[{}] Failed to find session by external id", externalId); + } + } + + @Override + public void close(TelemetryWebSocketSessionRef sessionRef, CloseStatus reason) throws IOException { + String externalId = sessionRef.getSessionId(); + log.debug("[{}] Processing close request", externalId); + String internalId = externalSessionMap.get(externalId); + if (internalId != null) { + SessionMetaData sessionMd = internalSessionMap.get(internalId); + if (sessionMd != null) { + sessionMd.session.close(reason); + } else { + log.warn("[{}][{}] Failed to find session by internal id", externalId, internalId); + } + } else { + log.warn("[{}] Failed to find session by external id", externalId); + } + } + + private boolean checkLimits(WebSocketSession session, TelemetryWebSocketSessionRef sessionRef) throws Exception { + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + if (tenantProfileConfiguration == null) { + return true; + } + + String sessionId = session.getId(); + if (tenantProfileConfiguration.getMaxWsSessionsPerTenant() > 0) { + Set tenantSessions = tenantSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (tenantSessions) { + if (tenantSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerTenant()) { + tenantSessions.add(sessionId); + } else { + log.info("[{}][{}][{}] Failed to start session. Max tenant sessions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId); + session.close(CloseStatus.POLICY_VIOLATION.withReason("Max tenant sessions limit reached!")); + return false; + } + } + } + + if (sessionRef.getSecurityCtx().isCustomerUser()) { + if (tenantProfileConfiguration.getMaxWsSessionsPerCustomer() > 0) { + Set customerSessions = customerSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getCustomerId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (customerSessions) { + if (customerSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerCustomer()) { + customerSessions.add(sessionId); + } else { + log.info("[{}][{}][{}] Failed to start session. Max customer sessions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId); + session.close(CloseStatus.POLICY_VIOLATION.withReason("Max customer sessions limit reached")); + return false; + } + } + } + if (tenantProfileConfiguration.getMaxWsSessionsPerRegularUser() > 0 + && UserPrincipal.Type.USER_NAME.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set regularUserSessions = regularUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (regularUserSessions) { + if (regularUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerRegularUser()) { + regularUserSessions.add(sessionId); + } else { + log.info("[{}][{}][{}] Failed to start session. Max regular user sessions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId); + session.close(CloseStatus.POLICY_VIOLATION.withReason("Max regular user sessions limit reached")); + return false; + } + } + } + if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 + && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (publicUserSessions) { + if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser()) { + publicUserSessions.add(sessionId); + } else { + log.info("[{}][{}][{}] Failed to start session. Max public user sessions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), sessionId); + session.close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached")); + return false; + } + } + } + } + return true; + } + + private void cleanupLimits(WebSocketSession session, TelemetryWebSocketSessionRef sessionRef) { + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + if (tenantProfileConfiguration == null) return; + + String sessionId = session.getId(); + perSessionUpdateLimits.remove(sessionRef.getSessionId()); + blacklistedSessions.remove(sessionRef.getSessionId()); + if (tenantProfileConfiguration.getMaxWsSessionsPerTenant() > 0) { + Set tenantSessions = tenantSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (tenantSessions) { + tenantSessions.remove(sessionId); + } + } + if (sessionRef.getSecurityCtx().isCustomerUser()) { + if (tenantProfileConfiguration.getMaxWsSessionsPerCustomer() > 0) { + Set customerSessions = customerSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getCustomerId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (customerSessions) { + customerSessions.remove(sessionId); + } + } + if (tenantProfileConfiguration.getMaxWsSessionsPerRegularUser() > 0 && UserPrincipal.Type.USER_NAME.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set regularUserSessions = regularUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (regularUserSessions) { + regularUserSessions.remove(sessionId); + } + } + if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (publicUserSessions) { + publicUserSessions.remove(sessionId); + } + } + } + } + + private DefaultTenantProfileConfiguration getTenantProfileConfiguration(TelemetryWebSocketSessionRef sessionRef) { + return Optional.ofNullable(tenantProfileCache.get(sessionRef.getSecurityCtx().getTenantId())) + .map(TenantProfile::getDefaultProfileConfiguration).orElse(null); + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsg.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsg.java new file mode 100644 index 0000000..a48b9c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsg.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.plugin; + +public interface TbWebSocketMsg { + + TbWebSocketMsgType getType(); + + T getMsg(); + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsgType.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsgType.java new file mode 100644 index 0000000..de2500d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketMsgType.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.plugin; + +public enum TbWebSocketMsgType { + + PING, TEXT +} diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketPingMsg.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketPingMsg.java new file mode 100644 index 0000000..2fbc6c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketPingMsg.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.plugin; + +import lombok.RequiredArgsConstructor; + +import java.nio.ByteBuffer; + +@RequiredArgsConstructor +public class TbWebSocketPingMsg implements TbWebSocketMsg { + + public static TbWebSocketPingMsg INSTANCE = new TbWebSocketPingMsg(); + + private static final ByteBuffer PING_MSG = ByteBuffer.wrap(new byte[]{}); + + @Override + public TbWebSocketMsgType getType() { + return TbWebSocketMsgType.PING; + } + + @Override + public ByteBuffer getMsg() { + return PING_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketTextMsg.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketTextMsg.java new file mode 100644 index 0000000..54efca4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketTextMsg.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.plugin; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class TbWebSocketTextMsg implements TbWebSocketMsg { + + private final String value; + + @Override + public TbWebSocketMsgType getType() { + return TbWebSocketMsgType.TEXT; + } + + @Override + public String getMsg() { + return value; + } +} diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java new file mode 100644 index 0000000..208fdea --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.exception; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import org.springframework.http.HttpStatus; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; + +@ApiModel +public class ThingsboardCredentialsExpiredResponse extends ThingsboardErrorResponse { + + private final String resetToken; + + protected ThingsboardCredentialsExpiredResponse(String message, String resetToken) { + super(message, ThingsboardErrorCode.CREDENTIALS_EXPIRED, HttpStatus.UNAUTHORIZED); + this.resetToken = resetToken; + } + + public static ThingsboardCredentialsExpiredResponse of(final String message, final String resetToken) { + return new ThingsboardCredentialsExpiredResponse(message, resetToken); + } + + @ApiModelProperty(position = 5, value = "Password reset token", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public String getResetToken() { + return resetToken; + } +} diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java new file mode 100644 index 0000000..7cf2ca8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.exception; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import org.springframework.http.HttpStatus; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; + +import java.util.Date; + +@ApiModel +public class ThingsboardErrorResponse { + // HTTP Response Status Code + private final HttpStatus status; + + // General Error message + private final String message; + + // Error code + private final ThingsboardErrorCode errorCode; + + private final Date timestamp; + + protected ThingsboardErrorResponse(final String message, final ThingsboardErrorCode errorCode, HttpStatus status) { + this.message = message; + this.errorCode = errorCode; + this.status = status; + this.timestamp = new java.util.Date(); + } + + public static ThingsboardErrorResponse of(final String message, final ThingsboardErrorCode errorCode, HttpStatus status) { + return new ThingsboardErrorResponse(message, errorCode, status); + } + + @ApiModelProperty(position = 1, value = "HTTP Response Status Code", example = "401", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public Integer getStatus() { + return status.value(); + } + + @ApiModelProperty(position = 2, value = "Error message", example = "Authentication failed", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public String getMessage() { + return message; + } + + @ApiModelProperty(position = 3, value = "Platform error code:" + + "\n* `2` - General error (HTTP: 500 - Internal Server Error)" + + "\n\n* `10` - Authentication failed (HTTP: 401 - Unauthorized)" + + "\n\n* `11` - JWT token expired (HTTP: 401 - Unauthorized)" + + "\n\n* `15` - Credentials expired (HTTP: 401 - Unauthorized)" + + "\n\n* `20` - Permission denied (HTTP: 403 - Forbidden)" + + "\n\n* `30` - Invalid arguments (HTTP: 400 - Bad Request)" + + "\n\n* `31` - Bad request params (HTTP: 400 - Bad Request)" + + "\n\n* `32` - Item not found (HTTP: 404 - Not Found)" + + "\n\n* `33` - Too many requests (HTTP: 429 - Too Many Requests)" + + "\n\n* `34` - Too many updates (Too many updates over Websocket session)" + + "\n\n* `40` - Subscription violation (HTTP: 403 - Forbidden)", + example = "10", dataType = "integer", + accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public ThingsboardErrorCode getErrorCode() { + return errorCode; + } + + @ApiModelProperty(position = 4, value = "Timestamp", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public Date getTimestamp() { + return timestamp; + } +} diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java new file mode 100644 index 0000000..30e934f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -0,0 +1,203 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.util.WebUtils; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.msg.tools.TbRateLimitsException; +import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHandler implements AccessDeniedHandler { + + private static final Map statusToErrorCodeMap = new HashMap<>(); + static { + statusToErrorCodeMap.put(HttpStatus.BAD_REQUEST, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + statusToErrorCodeMap.put(HttpStatus.UNAUTHORIZED, ThingsboardErrorCode.AUTHENTICATION); + statusToErrorCodeMap.put(HttpStatus.FORBIDDEN, ThingsboardErrorCode.PERMISSION_DENIED); + statusToErrorCodeMap.put(HttpStatus.NOT_FOUND, ThingsboardErrorCode.ITEM_NOT_FOUND); + statusToErrorCodeMap.put(HttpStatus.METHOD_NOT_ALLOWED, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + statusToErrorCodeMap.put(HttpStatus.NOT_ACCEPTABLE, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + statusToErrorCodeMap.put(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + statusToErrorCodeMap.put(HttpStatus.TOO_MANY_REQUESTS, ThingsboardErrorCode.TOO_MANY_REQUESTS); + statusToErrorCodeMap.put(HttpStatus.INTERNAL_SERVER_ERROR, ThingsboardErrorCode.GENERAL); + statusToErrorCodeMap.put(HttpStatus.SERVICE_UNAVAILABLE, ThingsboardErrorCode.GENERAL); + } + private static final Map errorCodeToStatusMap = new HashMap<>(); + static { + errorCodeToStatusMap.put(ThingsboardErrorCode.GENERAL, HttpStatus.INTERNAL_SERVER_ERROR); + errorCodeToStatusMap.put(ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED); + errorCodeToStatusMap.put(ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED); + errorCodeToStatusMap.put(ThingsboardErrorCode.CREDENTIALS_EXPIRED, HttpStatus.UNAUTHORIZED); + errorCodeToStatusMap.put(ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); + errorCodeToStatusMap.put(ThingsboardErrorCode.INVALID_ARGUMENTS, HttpStatus.BAD_REQUEST); + errorCodeToStatusMap.put(ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST); + errorCodeToStatusMap.put(ThingsboardErrorCode.ITEM_NOT_FOUND, HttpStatus.NOT_FOUND); + errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS); + errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_UPDATES, HttpStatus.TOO_MANY_REQUESTS); + errorCodeToStatusMap.put(ThingsboardErrorCode.SUBSCRIPTION_VIOLATION, HttpStatus.FORBIDDEN); + } + + private static ThingsboardErrorCode statusToErrorCode(HttpStatus status) { + return statusToErrorCodeMap.getOrDefault(status, ThingsboardErrorCode.GENERAL); + } + + private static HttpStatus errorCodeToStatus(ThingsboardErrorCode errorCode) { + return errorCodeToStatusMap.getOrDefault(errorCode, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Autowired + private ObjectMapper mapper; + + @Override + @ExceptionHandler(AccessDeniedException.class) + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, + ServletException { + if (!response.isCommitted()) { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.FORBIDDEN.value()); + mapper.writeValue(response.getWriter(), + ThingsboardErrorResponse.of("You don't have permission to perform this operation!", + ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN)); + } + } + + @ExceptionHandler(Exception.class) + public void handle(Exception exception, HttpServletResponse response) { + log.debug("Processing exception {}", exception.getMessage(), exception); + if (!response.isCommitted()) { + try { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + if (exception instanceof ThingsboardException) { + ThingsboardException thingsboardException = (ThingsboardException) exception; + if (thingsboardException.getErrorCode() == ThingsboardErrorCode.SUBSCRIPTION_VIOLATION) { + handleSubscriptionException((ThingsboardException) exception, response); + } else { + handleThingsboardException((ThingsboardException) exception, response); + } + } else if (exception instanceof TbRateLimitsException) { + handleRateLimitException(response, (TbRateLimitsException) exception); + } else if (exception instanceof AccessDeniedException) { + handleAccessDeniedException(response); + } else if (exception instanceof AuthenticationException) { + handleAuthenticationException((AuthenticationException) exception, response); + } else { + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(), + ThingsboardErrorCode.GENERAL, HttpStatus.INTERNAL_SERVER_ERROR)); + } + } catch (IOException e) { + log.error("Can't handle exception", e); + } + } + } + + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, @Nullable Object body, + HttpHeaders headers, HttpStatus status, + WebRequest request) { + if (HttpStatus.INTERNAL_SERVER_ERROR.equals(status)) { + request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST); + } + ThingsboardErrorCode errorCode = statusToErrorCode(status); + return new ResponseEntity<>(ThingsboardErrorResponse.of(ex.getMessage(), errorCode, status), headers, status); + } + + private void handleThingsboardException(ThingsboardException thingsboardException, HttpServletResponse response) throws IOException { + ThingsboardErrorCode errorCode = thingsboardException.getErrorCode(); + HttpStatus status = errorCodeToStatus(errorCode); + response.setStatus(status.value()); + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of(thingsboardException.getMessage(), errorCode, status)); + } + + private void handleRateLimitException(HttpServletResponse response, TbRateLimitsException exception) throws IOException { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + String message = "Too many requests for current " + exception.getEntityType().name().toLowerCase() + "!"; + mapper.writeValue(response.getWriter(), + ThingsboardErrorResponse.of(message, + ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS)); + } + + private void handleSubscriptionException(ThingsboardException subscriptionException, HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.FORBIDDEN.value()); + mapper.writeValue(response.getWriter(), + (new ObjectMapper()).readValue(((HttpClientErrorException) subscriptionException.getCause()).getResponseBodyAsByteArray(), Object.class)); + } + + private void handleAccessDeniedException(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.FORBIDDEN.value()); + mapper.writeValue(response.getWriter(), + ThingsboardErrorResponse.of("You don't have permission to perform this operation!", + ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN)); + + } + + private void handleAuthenticationException(AuthenticationException authenticationException, HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + if (authenticationException instanceof BadCredentialsException || authenticationException instanceof UsernameNotFoundException) { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof DisabledException) { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof LockedException) { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof JwtExpiredTokenException) { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof AuthMethodNotSupportedException) { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of(authenticationException.getMessage(), ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } else if (authenticationException instanceof UserPasswordExpiredException) { + UserPasswordExpiredException expiredException = (UserPasswordExpiredException) authenticationException; + String resetToken = expiredException.getResetToken(); + mapper.writeValue(response.getWriter(), ThingsboardCredentialsExpiredResponse.of(expiredException.getMessage(), resetToken)); + } else { + mapper.writeValue(response.getWriter(), ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallConfiguration.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallConfiguration.java new file mode 100644 index 0000000..4338f4a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallConfiguration.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.install; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.thingsboard.server.dao.audit.AuditLogLevelFilter; +import org.thingsboard.server.dao.audit.AuditLogLevelProperties; + +import java.util.HashMap; + +@Configuration +@Profile("install") +public class ThingsboardInstallConfiguration { + + @Bean + public AuditLogLevelFilter emptyAuditLogLevelFilter() { + var props = new AuditLogLevelProperties(); + props.setMask(new HashMap<>()); + return new AuditLogLevelFilter(props); + } +} diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallException.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallException.java new file mode 100644 index 0000000..81d9a7d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallException.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.install; + +import org.springframework.boot.ExitCodeGenerator; + +public class ThingsboardInstallException extends RuntimeException implements ExitCodeGenerator { + + public ThingsboardInstallException(String message, Throwable cause) { + super(message, cause); + } + + public int getExitCode() { + return 1; + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java new file mode 100644 index 0000000..9facea6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -0,0 +1,299 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.service.component.ComponentDiscoveryService; +import org.thingsboard.server.service.install.DatabaseEntitiesUpgradeService; +import org.thingsboard.server.service.install.DatabaseTsUpgradeService; +import org.thingsboard.server.service.install.EntityDatabaseSchemaService; +import org.thingsboard.server.service.install.NoSqlKeyspaceService; +import org.thingsboard.server.service.install.SystemDataLoaderService; +import org.thingsboard.server.service.install.TsDatabaseSchemaService; +import org.thingsboard.server.service.install.TsLatestDatabaseSchemaService; +import org.thingsboard.server.service.install.migrate.EntitiesMigrateService; +import org.thingsboard.server.service.install.migrate.TsLatestMigrateService; +import org.thingsboard.server.service.install.update.CacheCleanupService; +import org.thingsboard.server.service.install.update.DataUpdateService; + +@Service +@Profile("install") +@Slf4j +public class ThingsboardInstallService { + + @Value("${install.upgrade:false}") + private Boolean isUpgrade; + + @Value("${install.upgrade.from_version:1.2.3}") + private String upgradeFromVersion; + + @Value("${install.load_demo:false}") + private Boolean loadDemo; + + @Autowired + private EntityDatabaseSchemaService entityDatabaseSchemaService; + + @Autowired(required = false) + private NoSqlKeyspaceService noSqlKeyspaceService; + + @Autowired + private TsDatabaseSchemaService tsDatabaseSchemaService; + + @Autowired(required = false) + private TsLatestDatabaseSchemaService tsLatestDatabaseSchemaService; + + @Autowired + private DatabaseEntitiesUpgradeService databaseEntitiesUpgradeService; + + @Autowired(required = false) + private DatabaseTsUpgradeService databaseTsUpgradeService; + + @Autowired + private ComponentDiscoveryService componentDiscoveryService; + + @Autowired + private ApplicationContext context; + + @Autowired + private SystemDataLoaderService systemDataLoaderService; + + @Autowired + private DataUpdateService dataUpdateService; + + @Autowired + private CacheCleanupService cacheCleanupService; + + @Autowired(required = false) + private EntitiesMigrateService entitiesMigrateService; + + @Autowired(required = false) + private TsLatestMigrateService latestMigrateService; + + public void performInstall() { + try { + if (isUpgrade) { + log.info("Starting ThingsBoard Upgrade from version {} ...", upgradeFromVersion); + + cacheCleanupService.clearCache(upgradeFromVersion); + + if ("2.5.0-cassandra".equals(upgradeFromVersion)) { + log.info("Migrating ThingsBoard entities data from cassandra to SQL database ..."); + entitiesMigrateService.migrate(); + log.info("Updating system data..."); + systemDataLoaderService.updateSystemWidgets(); + } else if ("3.0.1-cassandra".equals(upgradeFromVersion)) { + log.info("Migrating ThingsBoard latest timeseries data from cassandra to SQL database ..."); + latestMigrateService.migrate(); + } else { + switch (upgradeFromVersion) { + case "1.2.3": //NOSONAR, Need to execute gradual upgrade starting from upgradeFromVersion + log.info("Upgrading ThingsBoard from version 1.2.3 to 1.3.0 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("1.2.3"); + + case "1.3.0": //NOSONAR, Need to execute gradual upgrade starting from upgradeFromVersion + log.info("Upgrading ThingsBoard from version 1.3.0 to 1.3.1 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("1.3.0"); + + case "1.3.1": //NOSONAR, Need to execute gradual upgrade starting from upgradeFromVersion + log.info("Upgrading ThingsBoard from version 1.3.1 to 1.4.0 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("1.3.1"); + + case "1.4.0": + log.info("Upgrading ThingsBoard from version 1.4.0 to 2.0.0 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("1.4.0"); + + dataUpdateService.updateData("1.4.0"); + + case "2.0.0": + log.info("Upgrading ThingsBoard from version 2.0.0 to 2.1.1 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.0.0"); + + case "2.1.1": + log.info("Upgrading ThingsBoard from version 2.1.1 to 2.1.2 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.1.1"); + case "2.1.3": + log.info("Upgrading ThingsBoard from version 2.1.3 to 2.2.0 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.1.3"); + + case "2.3.0": + log.info("Upgrading ThingsBoard from version 2.3.0 to 2.3.1 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.3.0"); + + case "2.3.1": + log.info("Upgrading ThingsBoard from version 2.3.1 to 2.4.0 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.3.1"); + + case "2.4.0": + log.info("Upgrading ThingsBoard from version 2.4.0 to 2.4.1 ..."); + + case "2.4.1": + log.info("Upgrading ThingsBoard from version 2.4.1 to 2.4.2 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.4.1"); + case "2.4.2": + log.info("Upgrading ThingsBoard from version 2.4.2 to 2.4.3 ..."); + + databaseEntitiesUpgradeService.upgradeDatabase("2.4.2"); + + case "2.4.3": + log.info("Upgrading ThingsBoard from version 2.4.3 to 2.5 ..."); + + if (databaseTsUpgradeService != null) { + databaseTsUpgradeService.upgradeDatabase("2.4.3"); + } + databaseEntitiesUpgradeService.upgradeDatabase("2.4.3"); + case "2.5.0": + log.info("Upgrading ThingsBoard from version 2.5.0 to 2.5.1 ..."); + if (databaseTsUpgradeService != null) { + databaseTsUpgradeService.upgradeDatabase("2.5.0"); + } + case "2.5.1": + log.info("Upgrading ThingsBoard from version 2.5.1 to 3.0.0 ..."); + case "3.0.1": + log.info("Upgrading ThingsBoard from version 3.0.1 to 3.1.0 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.0.1"); + dataUpdateService.updateData("3.0.1"); + case "3.1.0": + log.info("Upgrading ThingsBoard from version 3.1.0 to 3.1.1 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.1.0"); + case "3.1.1": + log.info("Upgrading ThingsBoard from version 3.1.1 to 3.2.0 ..."); + if (databaseTsUpgradeService != null) { + databaseTsUpgradeService.upgradeDatabase("3.1.1"); + } + databaseEntitiesUpgradeService.upgradeDatabase("3.1.1"); + dataUpdateService.updateData("3.1.1"); + systemDataLoaderService.createOAuth2Templates(); + case "3.2.0": + log.info("Upgrading ThingsBoard from version 3.2.0 to 3.2.1 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.2.0"); + case "3.2.1": + log.info("Upgrading ThingsBoard from version 3.2.1 to 3.2.2 ..."); + if (databaseTsUpgradeService != null) { + databaseTsUpgradeService.upgradeDatabase("3.2.1"); + } + databaseEntitiesUpgradeService.upgradeDatabase("3.2.1"); + case "3.2.2": + log.info("Upgrading ThingsBoard from version 3.2.2 to 3.3.0 ..."); + if (databaseTsUpgradeService != null) { + databaseTsUpgradeService.upgradeDatabase("3.2.2"); + } + databaseEntitiesUpgradeService.upgradeDatabase("3.2.2"); + + dataUpdateService.updateData("3.2.2"); + systemDataLoaderService.createOAuth2Templates(); + case "3.3.0": + log.info("Upgrading ThingsBoard from version 3.3.0 to 3.3.1 ..."); + case "3.3.1": + log.info("Upgrading ThingsBoard from version 3.3.1 to 3.3.2 ..."); + case "3.3.2": + log.info("Upgrading ThingsBoard from version 3.3.2 to 3.3.3 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.3.2"); + dataUpdateService.updateData("3.3.2"); + case "3.3.3": + log.info("Upgrading ThingsBoard from version 3.3.3 to 3.3.4 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.3.3"); + case "3.3.4": + log.info("Upgrading ThingsBoard from version 3.3.4 to 3.4.0 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.3.4"); + dataUpdateService.updateData("3.3.4"); + case "3.4.0": + log.info("Upgrading ThingsBoard from version 3.4.0 to 3.4.1 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.4.0"); + dataUpdateService.updateData("3.4.0"); + case "3.4.1": + log.info("Upgrading ThingsBoard from version 3.4.1 to 3.4.2 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.4.1"); + dataUpdateService.updateData("3.4.1"); + log.info("Updating system data..."); + systemDataLoaderService.updateSystemWidgets(); + break; + + //TODO update CacheCleanupService on the next version upgrade + + default: + throw new RuntimeException("Unable to upgrade ThingsBoard, unsupported fromVersion: " + upgradeFromVersion); + + } + } + log.info("Upgrade finished successfully!"); + + } else { + + log.info("Starting ThingsBoard Installation..."); + + log.info("Installing DataBase schema for entities..."); + + entityDatabaseSchemaService.createDatabaseSchema(); + + log.info("Installing DataBase schema for timeseries..."); + + if (noSqlKeyspaceService != null) { + noSqlKeyspaceService.createDatabaseSchema(); + } + + tsDatabaseSchemaService.createDatabaseSchema(); + + if (tsLatestDatabaseSchemaService != null) { + tsLatestDatabaseSchemaService.createDatabaseSchema(); + } + + log.info("Loading system data..."); + + componentDiscoveryService.discoverComponents(); + + systemDataLoaderService.createSysAdmin(); + systemDataLoaderService.createDefaultTenantProfiles(); + systemDataLoaderService.createAdminSettings(); + systemDataLoaderService.createRandomJwtSettings(); + systemDataLoaderService.loadSystemWidgets(); + systemDataLoaderService.createOAuth2Templates(); + systemDataLoaderService.createQueues(); +// systemDataLoaderService.loadSystemPlugins(); +// systemDataLoaderService.loadSystemRules(); + + if (loadDemo) { + log.info("Loading demo data..."); + systemDataLoaderService.loadDemoData(); + } + log.info("Installation finished successfully!"); + } + + + } catch (Exception e) { + log.error("Unexpected error during ThingsBoard installation!", e); + throw new ThingsboardInstallException("Unexpected error during ThingsBoard installation!", e); + } finally { + SpringApplication.exit(context); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java new file mode 100644 index 0000000..7057f90 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java @@ -0,0 +1,267 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.action; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +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.dao.audit.AuditLogService; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EntityActionService { + private final TbClusterService tbClusterService; + private final AuditLogService auditLogService; + + private static final ObjectMapper json = new ObjectMapper(); + + public void pushEntityActionToRuleEngine(EntityId entityId, HasName entity, TenantId tenantId, CustomerId customerId, + ActionType actionType, User user, Object... additionalInfo) { + String msgType = null; + switch (actionType) { + case ADDED: + msgType = DataConstants.ENTITY_CREATED; + break; + case DELETED: + msgType = DataConstants.ENTITY_DELETED; + break; + case UPDATED: + msgType = DataConstants.ENTITY_UPDATED; + break; + case ASSIGNED_TO_CUSTOMER: + msgType = DataConstants.ENTITY_ASSIGNED; + break; + case UNASSIGNED_FROM_CUSTOMER: + msgType = DataConstants.ENTITY_UNASSIGNED; + break; + case ATTRIBUTES_UPDATED: + msgType = DataConstants.ATTRIBUTES_UPDATED; + break; + case ATTRIBUTES_DELETED: + msgType = DataConstants.ATTRIBUTES_DELETED; + break; + case ALARM_ACK: + msgType = DataConstants.ALARM_ACK; + break; + case ALARM_CLEAR: + msgType = DataConstants.ALARM_CLEAR; + break; + case ALARM_DELETE: + msgType = DataConstants.ALARM_DELETE; + break; + case ASSIGNED_FROM_TENANT: + msgType = DataConstants.ENTITY_ASSIGNED_FROM_TENANT; + break; + case ASSIGNED_TO_TENANT: + msgType = DataConstants.ENTITY_ASSIGNED_TO_TENANT; + break; + case PROVISION_SUCCESS: + msgType = DataConstants.PROVISION_SUCCESS; + break; + case PROVISION_FAILURE: + msgType = DataConstants.PROVISION_FAILURE; + break; + case TIMESERIES_UPDATED: + msgType = DataConstants.TIMESERIES_UPDATED; + break; + case TIMESERIES_DELETED: + msgType = DataConstants.TIMESERIES_DELETED; + break; + case ASSIGNED_TO_EDGE: + msgType = DataConstants.ENTITY_ASSIGNED_TO_EDGE; + break; + case UNASSIGNED_FROM_EDGE: + msgType = DataConstants.ENTITY_UNASSIGNED_FROM_EDGE; + break; + case RELATION_ADD_OR_UPDATE: + msgType = DataConstants.RELATION_ADD_OR_UPDATE; + break; + case RELATION_DELETED: + msgType = DataConstants.RELATION_DELETED; + break; + case RELATIONS_DELETED: + msgType = DataConstants.RELATIONS_DELETED; + break; + } + if (!StringUtils.isEmpty(msgType)) { + try { + TbMsgMetaData metaData = new TbMsgMetaData(); + if (user != null) { + metaData.putValue("userId", user.getId().toString()); + metaData.putValue("userName", user.getName()); + } + if (customerId != null && !customerId.isNullUid()) { + metaData.putValue("customerId", customerId.toString()); + } + if (actionType == ActionType.ASSIGNED_TO_CUSTOMER) { + String strCustomerId = extractParameter(String.class, 1, additionalInfo); + String strCustomerName = extractParameter(String.class, 2, additionalInfo); + metaData.putValue("assignedCustomerId", strCustomerId); + metaData.putValue("assignedCustomerName", strCustomerName); + } else if (actionType == ActionType.UNASSIGNED_FROM_CUSTOMER) { + String strCustomerId = extractParameter(String.class, 1, additionalInfo); + String strCustomerName = extractParameter(String.class, 2, additionalInfo); + metaData.putValue("unassignedCustomerId", strCustomerId); + metaData.putValue("unassignedCustomerName", strCustomerName); + } else if (actionType == ActionType.ASSIGNED_FROM_TENANT) { + String strTenantId = extractParameter(String.class, 0, additionalInfo); + String strTenantName = extractParameter(String.class, 1, additionalInfo); + metaData.putValue("assignedFromTenantId", strTenantId); + metaData.putValue("assignedFromTenantName", strTenantName); + } else if (actionType == ActionType.ASSIGNED_TO_TENANT) { + String strTenantId = extractParameter(String.class, 0, additionalInfo); + String strTenantName = extractParameter(String.class, 1, additionalInfo); + metaData.putValue("assignedToTenantId", strTenantId); + metaData.putValue("assignedToTenantName", strTenantName); + } else if (actionType == ActionType.ASSIGNED_TO_EDGE) { + String strEdgeId = extractParameter(String.class, 1, additionalInfo); + String strEdgeName = extractParameter(String.class, 2, additionalInfo); + metaData.putValue("assignedEdgeId", strEdgeId); + metaData.putValue("assignedEdgeName", strEdgeName); + } else if (actionType == ActionType.UNASSIGNED_FROM_EDGE) { + String strEdgeId = extractParameter(String.class, 1, additionalInfo); + String strEdgeName = extractParameter(String.class, 2, additionalInfo); + metaData.putValue("unassignedEdgeId", strEdgeId); + metaData.putValue("unassignedEdgeName", strEdgeName); + } + ObjectNode entityNode; + if (entity != null) { + entityNode = json.valueToTree(entity); + if (entityId.getEntityType() == EntityType.DASHBOARD) { + entityNode.put("configuration", ""); + } + } else { + entityNode = json.createObjectNode(); + if (actionType == ActionType.ATTRIBUTES_UPDATED) { + String scope = extractParameter(String.class, 0, additionalInfo); + @SuppressWarnings("unchecked") + List attributes = extractParameter(List.class, 1, additionalInfo); + metaData.putValue(DataConstants.SCOPE, scope); + if (attributes != null) { + for (AttributeKvEntry attr : attributes) { + JacksonUtil.addKvEntry(entityNode, attr); + } + } + } else if (actionType == ActionType.ATTRIBUTES_DELETED) { + String scope = extractParameter(String.class, 0, additionalInfo); + @SuppressWarnings("unchecked") + List keys = extractParameter(List.class, 1, additionalInfo); + metaData.putValue(DataConstants.SCOPE, scope); + ArrayNode attrsArrayNode = entityNode.putArray("attributes"); + if (keys != null) { + keys.forEach(attrsArrayNode::add); + } + } else if (actionType == ActionType.TIMESERIES_UPDATED) { + @SuppressWarnings("unchecked") + List timeseries = extractParameter(List.class, 0, additionalInfo); + addTimeseries(entityNode, timeseries); + } else if (actionType == ActionType.TIMESERIES_DELETED) { + @SuppressWarnings("unchecked") + List keys = extractParameter(List.class, 0, additionalInfo); + if (keys != null) { + ArrayNode timeseriesArrayNode = entityNode.putArray("timeseries"); + keys.forEach(timeseriesArrayNode::add); + } + entityNode.put("startTs", extractParameter(Long.class, 1, additionalInfo)); + entityNode.put("endTs", extractParameter(Long.class, 2, additionalInfo)); + } else if (ActionType.RELATION_ADD_OR_UPDATE.equals(actionType) || ActionType.RELATION_DELETED.equals(actionType)) { + entityNode = json.valueToTree(extractParameter(EntityRelation.class, 0, additionalInfo)); + } + } + TbMsg tbMsg = TbMsg.newMsg(msgType, entityId, customerId, metaData, TbMsgDataType.JSON, json.writeValueAsString(entityNode)); + if (tenantId == null || tenantId.isNullUid()) { + if (entity instanceof HasTenantId) { + tenantId = ((HasTenantId) entity).getTenantId(); + } + } + tbClusterService.pushMsgToRuleEngine(tenantId, entityId, tbMsg, null); + } catch (Exception e) { + log.warn("[{}] Failed to push entity action to rule engine: {}", entityId, actionType, e); + } + } + } + + public void logEntityAction(User user, I entityId, E entity, CustomerId customerId, + ActionType actionType, Exception e, Object... additionalInfo) { + if (customerId == null || customerId.isNullUid()) { + customerId = user.getCustomerId(); + } + if (e == null) { + pushEntityActionToRuleEngine(entityId, entity, user.getTenantId(), customerId, actionType, user, additionalInfo); + } + auditLogService.logEntityAction(user.getTenantId(), customerId, user.getId(), user.getName(), entityId, entity, actionType, e, additionalInfo); + } + + public void sendEntityNotificationMsgToEdge(TenantId tenantId, EntityId entityId, EdgeEventActionType action) { + tbClusterService.sendNotificationMsgToEdge(tenantId, null, entityId, null, null, action); + } + + private T extractParameter(Class clazz, int index, Object... additionalInfo) { + T result = null; + if (additionalInfo != null && additionalInfo.length > index) { + Object paramObject = additionalInfo[index]; + if (clazz.isInstance(paramObject)) { + result = clazz.cast(paramObject); + } + } + return result; + } + + private void addTimeseries(ObjectNode entityNode, List timeseries) throws Exception { + if (timeseries != null && !timeseries.isEmpty()) { + ArrayNode result = entityNode.putArray("timeseries"); + Map> groupedTelemetry = timeseries.stream() + .collect(Collectors.groupingBy(TsKvEntry::getTs)); + for (Map.Entry> entry : groupedTelemetry.entrySet()) { + ObjectNode element = json.createObjectNode(); + element.put("ts", entry.getKey()); + ObjectNode values = element.putObject("values"); + for (TsKvEntry tsKvEntry : entry.getValue()) { + JacksonUtil.addKvEntry(values, tsKvEntry); + } + result.add(element); + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java new file mode 100644 index 0000000..44e9c32 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java @@ -0,0 +1,153 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import lombok.Getter; +import org.springframework.data.util.Pair; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.tools.SchedulerUtils; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class BaseApiUsageState { + private final Map currentCycleValues = new ConcurrentHashMap<>(); + private final Map currentHourValues = new ConcurrentHashMap<>(); + + @Getter + private final ApiUsageState apiUsageState; + @Getter + private volatile long currentCycleTs; + @Getter + private volatile long nextCycleTs; + @Getter + private volatile long currentHourTs; + + public BaseApiUsageState(ApiUsageState apiUsageState) { + this.apiUsageState = apiUsageState; + this.currentCycleTs = SchedulerUtils.getStartOfCurrentMonth(); + this.nextCycleTs = SchedulerUtils.getStartOfNextMonth(); + this.currentHourTs = SchedulerUtils.getStartOfCurrentHour(); + } + + public void put(ApiUsageRecordKey key, Long value) { + currentCycleValues.put(key, value); + } + + public void putHourly(ApiUsageRecordKey key, Long value) { + currentHourValues.put(key, value); + } + + public long add(ApiUsageRecordKey key, long value) { + long result = currentCycleValues.getOrDefault(key, 0L) + value; + currentCycleValues.put(key, result); + return result; + } + + public long get(ApiUsageRecordKey key) { + return currentCycleValues.getOrDefault(key, 0L); + } + + public long addToHourly(ApiUsageRecordKey key, long value) { + long result = currentHourValues.getOrDefault(key, 0L) + value; + currentHourValues.put(key, result); + return result; + } + + public void setHour(long currentHourTs) { + this.currentHourTs = currentHourTs; + for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + currentHourValues.put(key, 0L); + } + } + + public void setCycles(long currentCycleTs, long nextCycleTs) { + this.currentCycleTs = currentCycleTs; + this.nextCycleTs = nextCycleTs; + for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + currentCycleValues.put(key, 0L); + } + } + + public ApiUsageStateValue getFeatureValue(ApiFeature feature) { + switch (feature) { + case TRANSPORT: + return apiUsageState.getTransportState(); + case RE: + return apiUsageState.getReExecState(); + case DB: + return apiUsageState.getDbStorageState(); + case JS: + return apiUsageState.getJsExecState(); + case EMAIL: + return apiUsageState.getEmailExecState(); + case SMS: + return apiUsageState.getSmsExecState(); + case ALARM: + return apiUsageState.getAlarmExecState(); + default: + return ApiUsageStateValue.ENABLED; + } + } + + public boolean setFeatureValue(ApiFeature feature, ApiUsageStateValue value) { + ApiUsageStateValue currentValue = getFeatureValue(feature); + switch (feature) { + case TRANSPORT: + apiUsageState.setTransportState(value); + break; + case RE: + apiUsageState.setReExecState(value); + break; + case DB: + apiUsageState.setDbStorageState(value); + break; + case JS: + apiUsageState.setJsExecState(value); + break; + case EMAIL: + apiUsageState.setEmailExecState(value); + break; + case SMS: + apiUsageState.setSmsExecState(value); + break; + case ALARM: + apiUsageState.setAlarmExecState(value); + break; + } + return !currentValue.equals(value); + } + + public abstract EntityType getEntityType(); + + public TenantId getTenantId() { + return getApiUsageState().getTenantId(); + } + + public EntityId getEntityId() { + return getApiUsageState().getEntityId(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/CustomerApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/CustomerApiUsageState.java new file mode 100644 index 0000000..0072242 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/CustomerApiUsageState.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.EntityType; + +public class CustomerApiUsageState extends BaseApiUsageState { + public CustomerApiUsageState(ApiUsageState apiUsageState) { + super(apiUsageState); + } + + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultRateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultRateLimitService.java new file mode 100644 index 0000000..525386d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultRateLimitService.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.tools.TbRateLimits; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +public class DefaultRateLimitService implements RateLimitService { + + private final TbTenantProfileCache tenantProfileCache; + + private final Map> rateLimits = new ConcurrentHashMap<>(); + + @Override + public boolean checkEntityExportLimit(TenantId tenantId) { + return checkLimit(tenantId, "entityExport", DefaultTenantProfileConfiguration::getTenantEntityExportRateLimit); + } + + @Override + public boolean checkEntityImportLimit(TenantId tenantId) { + return checkLimit(tenantId, "entityImport", DefaultTenantProfileConfiguration::getTenantEntityImportRateLimit); + } + + private boolean checkLimit(TenantId tenantId, String rateLimitsKey, Function rateLimitConfigExtractor) { + String rateLimitConfig = tenantProfileCache.get(tenantId).getProfileConfiguration() + .map(rateLimitConfigExtractor).orElse(null); + + Map rateLimits = this.rateLimits.get(rateLimitsKey); + if (StringUtils.isEmpty(rateLimitConfig)) { + if (rateLimits != null) { + rateLimits.remove(tenantId); + if (rateLimits.isEmpty()) { + this.rateLimits.remove(rateLimitsKey); + } + } + return true; + } + + if (rateLimits == null) { + rateLimits = new ConcurrentHashMap<>(); + this.rateLimits.put(rateLimitsKey, rateLimits); + } + TbRateLimits rateLimit = rateLimits.get(tenantId); + if (rateLimit == null || !rateLimit.getConfiguration().equals(rateLimitConfig)) { + rateLimit = new TbRateLimits(rateLimitConfig); + rateLimits.put(tenantId, rateLimit); + } + + return rateLimit.tryConsume(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java new file mode 100644 index 0000000..b8ca8b3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -0,0 +1,550 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateMailMessage; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +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.page.PageDataIterable; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.tools.SchedulerUtils; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; +import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.UsageStatsKVProto; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.partition.AbstractPartitionBasedService; +import org.thingsboard.server.service.telemetry.InternalTelemetryService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService implements TbApiUsageStateService { + + public static final String HOURLY = "Hourly"; + public static final FutureCallback VOID_CALLBACK = new FutureCallback() { + @Override + public void onSuccess(@Nullable Integer result) { + } + + @Override + public void onFailure(Throwable t) { + } + }; + private final TbClusterService clusterService; + private final PartitionService partitionService; + private final TenantService tenantService; + private final TimeseriesService tsService; + private final ApiUsageStateService apiUsageStateService; + private final TbTenantProfileCache tenantProfileCache; + private final MailService mailService; + private final DbCallbackExecutorService dbExecutor; + + @Lazy + @Autowired + private InternalTelemetryService tsWsService; + + // Entities that should be processed on this server + final Map myUsageStates = new ConcurrentHashMap<>(); + // Entities that should be processed on other servers + final Map otherUsageStates = new ConcurrentHashMap<>(); + + final Set deletedEntities = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + @Value("${usage.stats.report.enabled:true}") + private boolean enabled; + + @Value("${usage.stats.check.cycle:60000}") + private long nextCycleCheckInterval; + + private final Lock updateLock = new ReentrantLock(); + + private final ExecutorService mailExecutor; + + public DefaultTbApiUsageStateService(TbClusterService clusterService, + PartitionService partitionService, + TenantService tenantService, + TimeseriesService tsService, + ApiUsageStateService apiUsageStateService, + TbTenantProfileCache tenantProfileCache, + MailService mailService, + DbCallbackExecutorService dbExecutor) { + this.clusterService = clusterService; + this.partitionService = partitionService; + this.tenantService = tenantService; + this.tsService = tsService; + this.apiUsageStateService = apiUsageStateService; + this.tenantProfileCache = tenantProfileCache; + this.mailService = mailService; + this.mailExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("api-usage-svc-mail")); + this.dbExecutor = dbExecutor; + } + + @PostConstruct + public void init() { + super.init(); + if (enabled) { + log.info("Starting api usage service."); + scheduledExecutor.scheduleAtFixedRate(this::checkStartOfNextCycle, nextCycleCheckInterval, nextCycleCheckInterval, TimeUnit.MILLISECONDS); + log.info("Started api usage service."); + } + } + + @Override + protected String getServiceName() { + return "API Usage"; + } + + @Override + protected String getSchedulerExecutorName() { + return "api-usage-scheduled"; + } + + @Override + public void process(TbProtoQueueMsg msg, TbCallback callback) { + ToUsageStatsServiceMsg statsMsg = msg.getValue(); + + TenantId tenantId = TenantId.fromUUID(new UUID(statsMsg.getTenantIdMSB(), statsMsg.getTenantIdLSB())); + EntityId entityId; + if (statsMsg.getCustomerIdMSB() != 0 && statsMsg.getCustomerIdLSB() != 0) { + entityId = new CustomerId(new UUID(statsMsg.getCustomerIdMSB(), statsMsg.getCustomerIdLSB())); + } else { + entityId = tenantId; + } + + processEntityUsageStats(tenantId, entityId, statsMsg.getValuesList()); + callback.onSuccess(); + } + + private void processEntityUsageStats(TenantId tenantId, EntityId entityId, List values) { + if (deletedEntities.contains(entityId)) return; + + BaseApiUsageState usageState; + List updatedEntries; + Map result; + + updateLock.lock(); + try { + usageState = getOrFetchState(tenantId, entityId); + long ts = usageState.getCurrentCycleTs(); + long hourTs = usageState.getCurrentHourTs(); + long newHourTs = SchedulerUtils.getStartOfCurrentHour(); + if (newHourTs != hourTs) { + usageState.setHour(newHourTs); + } + updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); + Set apiFeatures = new HashSet<>(); + for (UsageStatsKVProto kvProto : values) { + ApiUsageRecordKey recordKey = ApiUsageRecordKey.valueOf(kvProto.getKey()); + long newValue = usageState.add(recordKey, kvProto.getValue()); + updatedEntries.add(new BasicTsKvEntry(ts, new LongDataEntry(recordKey.getApiCountKey(), newValue))); + long newHourlyValue = usageState.addToHourly(recordKey, kvProto.getValue()); + updatedEntries.add(new BasicTsKvEntry(newHourTs, new LongDataEntry(recordKey.getApiCountKey() + HOURLY, newHourlyValue))); + apiFeatures.add(recordKey.getApiFeature()); + } + if (usageState.getEntityType() == EntityType.TENANT && !usageState.getEntityId().equals(TenantId.SYS_TENANT_ID)) { + result = ((TenantApiUsageState) usageState).checkStateUpdatedDueToThreshold(apiFeatures); + } else { + result = Collections.emptyMap(); + } + } finally { + updateLock.unlock(); + } + tsWsService.saveAndNotifyInternal(tenantId, usageState.getApiUsageState().getId(), updatedEntries, VOID_CALLBACK); + if (!result.isEmpty()) { + persistAndNotify(usageState, result); + } + } + + @Override + public ApiUsageState getApiUsageState(TenantId tenantId) { + TenantApiUsageState tenantState = (TenantApiUsageState) myUsageStates.get(tenantId); + if (tenantState != null) { + return tenantState.getApiUsageState(); + } else { + ApiUsageState state = otherUsageStates.get(tenantId); + if (state != null) { + return state; + } else { + if (partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition()) { + return getOrFetchState(tenantId, tenantId).getApiUsageState(); + } else { + state = otherUsageStates.get(tenantId); + if (state == null) { + state = apiUsageStateService.findTenantApiUsageState(tenantId); + if (state != null) { + otherUsageStates.put(tenantId, state); + } + } + return state; + } + } + } + } + + @Override + public void onApiUsageStateUpdate(TenantId tenantId) { + otherUsageStates.remove(tenantId); + } + + @Override + public void onTenantProfileUpdate(TenantProfileId tenantProfileId) { + log.info("[{}] On Tenant Profile Update", tenantProfileId); + TenantProfile tenantProfile = tenantProfileCache.get(tenantProfileId); + updateLock.lock(); + try { + myUsageStates.values().stream() + .filter(state -> state.getEntityType() == EntityType.TENANT) + .map(state -> (TenantApiUsageState) state) + .forEach(state -> { + if (tenantProfile.getId().equals(state.getTenantProfileId())) { + updateTenantState(state, tenantProfile); + } + }); + } finally { + updateLock.unlock(); + } + } + + @Override + public void onTenantUpdate(TenantId tenantId) { + log.info("[{}] On Tenant Update.", tenantId); + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + updateLock.lock(); + try { + TenantApiUsageState state = (TenantApiUsageState) myUsageStates.get(tenantId); + if (state != null && !state.getTenantProfileId().equals(tenantProfile.getId())) { + updateTenantState(state, tenantProfile); + } + } finally { + updateLock.unlock(); + } + } + + private void updateTenantState(TenantApiUsageState state, TenantProfile profile) { + TenantProfileData oldProfileData = state.getTenantProfileData(); + state.setTenantProfileId(profile.getId()); + state.setTenantProfileData(profile.getProfileData()); + Map result = state.checkStateUpdatedDueToThresholds(); + if (!result.isEmpty()) { + persistAndNotify(state, result); + } + updateProfileThresholds(state.getTenantId(), state.getApiUsageState().getId(), + oldProfileData.getConfiguration(), profile.getProfileData().getConfiguration()); + } + + private void addEntityState(TopicPartitionInfo tpi, BaseApiUsageState state) { + EntityId entityId = state.getEntityId(); + Set entityIds = partitionedEntities.get(tpi); + if (entityIds != null) { + entityIds.add(entityId); + myUsageStates.put(entityId, state); + } else { + log.debug("[{}] belongs to external partition {}", entityId, tpi.getFullTopicName()); + throw new RuntimeException(entityId.getEntityType() + " belongs to external partition " + tpi.getFullTopicName() + "!"); + } + } + + private void updateProfileThresholds(TenantId tenantId, ApiUsageStateId id, + TenantProfileConfiguration oldData, TenantProfileConfiguration newData) { + long ts = System.currentTimeMillis(); + List profileThresholds = new ArrayList<>(); + for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + long newProfileThreshold = newData.getProfileThreshold(key); + if (oldData == null || oldData.getProfileThreshold(key) != newProfileThreshold) { + log.info("[{}] Updating profile threshold [{}]:[{}]", tenantId, key, newProfileThreshold); + profileThresholds.add(new BasicTsKvEntry(ts, new LongDataEntry(key.getApiLimitKey(), newProfileThreshold))); + } + } + if (!profileThresholds.isEmpty()) { + tsWsService.saveAndNotifyInternal(tenantId, id, profileThresholds, VOID_CALLBACK); + } + } + + public void onTenantDelete(TenantId tenantId) { + deletedEntities.add(tenantId); + myUsageStates.remove(tenantId); + otherUsageStates.remove(tenantId); + } + + @Override + public void onCustomerDelete(CustomerId customerId) { + deletedEntities.add(customerId); + myUsageStates.remove(customerId); + } + + @Override + protected void cleanupEntityOnPartitionRemoval(EntityId entityId) { + myUsageStates.remove(entityId); + } + + private void persistAndNotify(BaseApiUsageState state, Map result) { + log.info("[{}] Detected update of the API state for {}: {}", state.getEntityId(), state.getEntityType(), result); + apiUsageStateService.update(state.getApiUsageState()); + clusterService.onApiStateChange(state.getApiUsageState(), null); + long ts = System.currentTimeMillis(); + List stateTelemetry = new ArrayList<>(); + result.forEach((apiFeature, aState) -> stateTelemetry.add(new BasicTsKvEntry(ts, new StringDataEntry(apiFeature.getApiStateKey(), aState.name())))); + tsWsService.saveAndNotifyInternal(state.getTenantId(), state.getApiUsageState().getId(), stateTelemetry, VOID_CALLBACK); + + if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) { + String email = tenantService.findTenantById(state.getTenantId()).getEmail(); + if (StringUtils.isNotEmpty(email)) { + result.forEach((apiFeature, stateValue) -> { + mailExecutor.submit(() -> { + try { + mailService.sendApiFeatureStateEmail(apiFeature, stateValue, email, createStateMailMessage((TenantApiUsageState) state, apiFeature, stateValue)); + } catch (ThingsboardException e) { + log.warn("[{}] Can't send update of the API state to tenant with provided email [{}]", state.getTenantId(), email, e); + } + }); + }); + } else { + log.warn("[{}] Can't send update of the API state to tenant with empty email!", state.getTenantId()); + } + } + } + + private ApiUsageStateMailMessage createStateMailMessage(TenantApiUsageState state, ApiFeature apiFeature, ApiUsageStateValue stateValue) { + StateChecker checker = getStateChecker(stateValue); + for (ApiUsageRecordKey apiUsageRecordKey : ApiUsageRecordKey.getKeys(apiFeature)) { + long threshold = state.getProfileThreshold(apiUsageRecordKey); + long warnThreshold = state.getProfileWarnThreshold(apiUsageRecordKey); + long value = state.get(apiUsageRecordKey); + if (checker.check(threshold, warnThreshold, value)) { + return new ApiUsageStateMailMessage(apiUsageRecordKey, threshold, value); + } + } + return null; + } + + private StateChecker getStateChecker(ApiUsageStateValue stateValue) { + if (ApiUsageStateValue.ENABLED.equals(stateValue)) { + return (t, wt, v) -> true; + } else if (ApiUsageStateValue.WARNING.equals(stateValue)) { + return (t, wt, v) -> v < t && v >= wt; + } else { + return (t, wt, v) -> v >= t; + } + } + + @Override + public ApiUsageState findApiUsageStateById(TenantId tenantId, ApiUsageStateId id) { + return apiUsageStateService.findApiUsageStateById(tenantId, id); + } + + private interface StateChecker { + boolean check(long threshold, long warnThreshold, long value); + } + + private void checkStartOfNextCycle() { + updateLock.lock(); + try { + long now = System.currentTimeMillis(); + myUsageStates.values().forEach(state -> { + if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) { + state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth()); + saveNewCounts(state, Arrays.asList(ApiUsageRecordKey.values())); + if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) { + TenantId tenantId = state.getTenantId(); + updateTenantState((TenantApiUsageState) state, tenantProfileCache.get(tenantId)); + } + } + }); + } finally { + updateLock.unlock(); + } + } + + private void saveNewCounts(BaseApiUsageState state, List keys) { + List counts = keys.stream() + .map(key -> new BasicTsKvEntry(state.getCurrentCycleTs(), new LongDataEntry(key.getApiCountKey(), 0L))) + .collect(Collectors.toList()); + + tsWsService.saveAndNotifyInternal(state.getTenantId(), state.getApiUsageState().getId(), counts, VOID_CALLBACK); + } + + BaseApiUsageState getOrFetchState(TenantId tenantId, EntityId entityId) { + if (entityId == null || entityId.isNullUid()) { + entityId = tenantId; + } + BaseApiUsageState state = myUsageStates.get(entityId); + if (state != null) { + return state; + } + + ApiUsageState storedState = apiUsageStateService.findApiUsageStateByEntityId(entityId); + if (storedState == null) { + try { + storedState = apiUsageStateService.createDefaultApiUsageState(tenantId, entityId); + } catch (Exception e) { + storedState = apiUsageStateService.findApiUsageStateByEntityId(entityId); + } + } + if (entityId.getEntityType() == EntityType.TENANT) { + if (!entityId.equals(TenantId.SYS_TENANT_ID)) { + state = new TenantApiUsageState(tenantProfileCache.get((TenantId) entityId), storedState); + } else { + state = new TenantApiUsageState(storedState); + } + } else { + state = new CustomerApiUsageState(storedState); + } + + List newCounts = new ArrayList<>(); + try { + List dbValues = tsService.findAllLatest(tenantId, storedState.getId()).get(); + for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + boolean cycleEntryFound = false; + boolean hourlyEntryFound = false; + for (TsKvEntry tsKvEntry : dbValues) { + if (tsKvEntry.getKey().equals(key.getApiCountKey())) { + cycleEntryFound = true; + + boolean oldCount = tsKvEntry.getTs() == state.getCurrentCycleTs(); + state.put(key, oldCount ? tsKvEntry.getLongValue().get() : 0L); + + if (!oldCount) { + newCounts.add(key); + } + } else if (tsKvEntry.getKey().equals(key.getApiCountKey() + HOURLY)) { + hourlyEntryFound = true; + state.putHourly(key, tsKvEntry.getTs() == state.getCurrentHourTs() ? tsKvEntry.getLongValue().get() : 0L); + } + if (cycleEntryFound && hourlyEntryFound) { + break; + } + } + } + log.debug("[{}] Initialized state: {}", entityId, storedState); + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (tpi.isMyPartition()) { + addEntityState(tpi, state); + } else { + otherUsageStates.put(entityId, state.getApiUsageState()); + } + saveNewCounts(state, newCounts); + } catch (InterruptedException | ExecutionException e) { + log.warn("[{}] Failed to fetch api usage state from db.", tenantId, e); + } + + return state; + } + + @Override + protected void onRepartitionEvent() { + otherUsageStates.entrySet().removeIf(entry -> + partitionService.resolve(ServiceType.TB_CORE, entry.getValue().getTenantId(), entry.getKey()).isMyPartition()); + } + + @Override + protected Map>> onAddedPartitions(Set addedPartitions) { + var result = new HashMap>>(); + try { + log.info("Initializing tenant states."); + updateLock.lock(); + try { + PageDataIterable tenantIterator = new PageDataIterable<>(tenantService::findTenants, 1024); + for (Tenant tenant : tenantIterator) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenant.getId(), tenant.getId()); + if (addedPartitions.contains(tpi)) { + if (!myUsageStates.containsKey(tenant.getId()) && tpi.isMyPartition()) { + log.debug("[{}] Initializing tenant state.", tenant.getId()); + result.computeIfAbsent(tpi, tmp -> new ArrayList<>()).add(dbExecutor.submit(() -> { + try { + updateTenantState((TenantApiUsageState) getOrFetchState(tenant.getId(), tenant.getId()), tenantProfileCache.get(tenant.getTenantProfileId())); + log.debug("[{}] Initialized tenant state.", tenant.getId()); + } catch (Exception e) { + log.warn("[{}] Failed to initialize tenant API state", tenant.getId(), e); + } + return null; + })); + } + } else { + log.debug("[{}][{}] Tenant doesn't belong to current partition. tpi [{}]", tenant.getName(), tenant.getId(), tpi); + } + } + } finally { + updateLock.unlock(); + } + } catch (Exception e) { + log.warn("Unknown failure", e); + } + return result; + } + + @PreDestroy + private void destroy() { + super.stop(); + if (mailExecutor != null) { + mailExecutor.shutdownNow(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/RateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/RateLimitService.java new file mode 100644 index 0000000..d3d4244 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/RateLimitService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import org.thingsboard.server.common.data.id.TenantId; + +public interface RateLimitService { + + boolean checkEntityExportLimit(TenantId tenantId); + + boolean checkEntityImportLimit(TenantId tenantId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/TbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/TbApiUsageStateService.java new file mode 100644 index 0000000..5f01d34 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/TbApiUsageStateService.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.stats.TbApiUsageStateClient; +import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +public interface TbApiUsageStateService extends TbApiUsageStateClient, RuleEngineApiUsageStateService, ApplicationListener { + + void process(TbProtoQueueMsg msg, TbCallback callback); + + void onTenantProfileUpdate(TenantProfileId tenantProfileId); + + void onTenantUpdate(TenantId tenantId); + + void onTenantDelete(TenantId tenantId); + + void onCustomerDelete(CustomerId customerId); + + void onApiUsageStateUpdate(TenantId tenantId); +} diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java new file mode 100644 index 0000000..0229d95 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/TenantApiUsageState.java @@ -0,0 +1,102 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.apiusage; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.util.Pair; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class TenantApiUsageState extends BaseApiUsageState { + @Getter + @Setter + private TenantProfileId tenantProfileId; + @Getter + @Setter + private TenantProfileData tenantProfileData; + + public TenantApiUsageState(TenantProfile tenantProfile, ApiUsageState apiUsageState) { + super(apiUsageState); + this.tenantProfileId = tenantProfile.getId(); + this.tenantProfileData = tenantProfile.getProfileData(); + } + + public TenantApiUsageState(ApiUsageState apiUsageState) { + super(apiUsageState); + } + + public long getProfileThreshold(ApiUsageRecordKey key) { + return tenantProfileData.getConfiguration().getProfileThreshold(key); + } + + public long getProfileWarnThreshold(ApiUsageRecordKey key) { + return tenantProfileData.getConfiguration().getWarnThreshold(key); + } + + private Pair checkStateUpdatedDueToThreshold(ApiFeature feature) { + ApiUsageStateValue featureValue = ApiUsageStateValue.ENABLED; + for (ApiUsageRecordKey recordKey : ApiUsageRecordKey.getKeys(feature)) { + long value = get(recordKey); + long threshold = getProfileThreshold(recordKey); + long warnThreshold = getProfileWarnThreshold(recordKey); + ApiUsageStateValue tmpValue; + if (threshold == 0 || value == 0 || value < warnThreshold) { + tmpValue = ApiUsageStateValue.ENABLED; + } else if (value < threshold) { + tmpValue = ApiUsageStateValue.WARNING; + } else { + tmpValue = ApiUsageStateValue.DISABLED; + } + featureValue = ApiUsageStateValue.toMoreRestricted(featureValue, tmpValue); + } + return setFeatureValue(feature, featureValue) ? Pair.of(feature, featureValue) : null; + } + + + public Map checkStateUpdatedDueToThresholds() { + return checkStateUpdatedDueToThreshold(new HashSet<>(Arrays.asList(ApiFeature.values()))); + } + + public Map checkStateUpdatedDueToThreshold(Set features) { + Map result = new HashMap<>(); + for (ApiFeature feature : features) { + Pair tmp = checkStateUpdatedDueToThreshold(feature); + if (tmp != null) { + result.put(tmp.getFirst(), tmp.getSecond()); + } + } + return result; + } + + @Override + public EntityType getEntityType() { + return EntityType.TENANT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/asset/AssetBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/asset/AssetBulkImportService.java new file mode 100644 index 0000000..5142311 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/asset/AssetBulkImportService.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.asset; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumnType; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.asset.TbAssetService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.sync.ie.importing.csv.AbstractBulkImportService; + +import java.util.Map; +import java.util.Optional; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class AssetBulkImportService extends AbstractBulkImportService { + private final AssetService assetService; + private final TbAssetService tbAssetService; + private final AssetProfileService assetProfileService; + + @Override + protected void setEntityFields(Asset entity, Map fields) { + ObjectNode additionalInfo = getOrCreateAdditionalInfoObj(entity); + fields.forEach((columnType, value) -> { + switch (columnType) { + case NAME: + entity.setName(value); + break; + case TYPE: + entity.setType(value); + break; + case LABEL: + entity.setLabel(value); + break; + case DESCRIPTION: + additionalInfo.set("description", new TextNode(value)); + break; + } + }); + entity.setAdditionalInfo(additionalInfo); + } + + @Override + @SneakyThrows + protected Asset saveEntity(SecurityUser user, Asset entity, Map fields) { + AssetProfile assetProfile; + if (StringUtils.isNotEmpty(entity.getType())) { + assetProfile = assetProfileService.findOrCreateAssetProfile(entity.getTenantId(), entity.getType()); + } else { + assetProfile = assetProfileService.findDefaultAssetProfile(entity.getTenantId()); + } + entity.setAssetProfileId(assetProfile.getId()); + return tbAssetService.save(entity, user); + } + + @Override + protected Asset findOrCreateEntity(TenantId tenantId, String name) { + return Optional.ofNullable(assetService.findAssetByTenantIdAndName(tenantId, name)) + .orElseGet(Asset::new); + } + + @Override + protected void setOwners(Asset entity, SecurityUser user) { + entity.setTenantId(user.getTenantId()); + entity.setCustomerId(user.getCustomerId()); + } + + @Override + protected EntityType getEntityType() { + return EntityType.ASSET; + } + +} 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 new file mode 100644 index 0000000..7a95bf7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java @@ -0,0 +1,274 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.stereotype.Service; +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.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.dao.component.ComponentDescriptorService; + +import javax.annotation.PostConstruct; +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Service +@Slf4j +public class AnnotationComponentDiscoveryService implements ComponentDiscoveryService { + + public static final int MAX_OPTIMISITC_RETRIES = 3; + @Value("${plugins.scan_packages}") + private String[] scanPackages; + + @Autowired + private Environment environment; + + @Autowired + private ComponentDescriptorService componentDescriptorService; + + private Map components = new HashMap<>(); + + private Map> coreComponentsMap = new HashMap<>(); + + private Map> edgeComponentsMap = new HashMap<>(); + + private ObjectMapper mapper = new ObjectMapper(); + + private boolean isInstall() { + return environment.acceptsProfiles(Profiles.of("install")); + } + + @PostConstruct + public void init() { + if (!isInstall()) { + discoverComponents(); + } + } + + private void registerRuleNodeComponents() { + Set ruleNodeBeanDefinitions = getBeanDefinitions(RuleNode.class); + for (BeanDefinition def : ruleNodeBeanDefinitions) { + 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); + putComponentIntoMaps(type, ruleNodeAnnotation, 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); + } + } + } + + private void putComponentIntoMaps(ComponentType type, RuleNode ruleNodeAnnotation, ComponentDescriptor component) { + boolean ruleChainTypesMethodAvailable; + try { + ruleNodeAnnotation.getClass().getMethod("ruleChainTypes"); + ruleChainTypesMethodAvailable = true; + } catch (NoSuchMethodException exception) { + log.warn("[{}] does not have ruleChainTypes. Probably extension class compiled before 3.3 release. " + + "Please update your extensions and compile using latest 3.3 release dependency", ruleNodeAnnotation.name()); + ruleChainTypesMethodAvailable = false; + } + if (ruleChainTypesMethodAvailable) { + if (ruleChainTypeContainsArray(RuleChainType.CORE, ruleNodeAnnotation.ruleChainTypes())) { + coreComponentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); + } + if (ruleChainTypeContainsArray(RuleChainType.EDGE, ruleNodeAnnotation.ruleChainTypes())) { + edgeComponentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); + } + } else { + coreComponentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); + } + } + + private boolean ruleChainTypeContainsArray(RuleChainType ruleChainType, RuleChainType[] array) { + for (RuleChainType tmp : array) { + if (ruleChainType.equals(tmp)) { + return true; + } + } + return false; + } + + private ComponentDescriptor scanAndPersistComponent(BeanDefinition def, ComponentType type) { + ComponentDescriptor scannedComponent = new ComponentDescriptor(); + String clazzName = def.getBeanClassName(); + try { + scannedComponent.setType(type); + Class clazz = Class.forName(clazzName); + RuleNode ruleNodeAnnotation = clazz.getAnnotation(RuleNode.class); + scannedComponent.setName(ruleNodeAnnotation.name()); + scannedComponent.setScope(ruleNodeAnnotation.scope()); + NodeDefinition nodeDefinition = prepareNodeDefinition(ruleNodeAnnotation); + ObjectNode configurationDescriptor = mapper.createObjectNode(); + JsonNode node = mapper.valueToTree(nodeDefinition); + configurationDescriptor.set("nodeDefinition", node); + scannedComponent.setConfigurationDescriptor(configurationDescriptor); + scannedComponent.setClazz(clazzName); + log.debug("Processing scanned component: {}", scannedComponent); + } catch (Exception e) { + log.error("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); + throw new RuntimeException(e); + } + ComponentDescriptor persistedComponent = componentDescriptorService.findByClazz(TenantId.SYS_TENANT_ID, clazzName); + if (persistedComponent == null) { + log.debug("Persisting new component: {}", scannedComponent); + scannedComponent = componentDescriptorService.saveComponent(TenantId.SYS_TENANT_ID, scannedComponent); + } else if (scannedComponent.equals(persistedComponent)) { + log.debug("Component is already persisted: {}", persistedComponent); + scannedComponent = persistedComponent; + } else { + log.debug("Component {} will be updated to {}", persistedComponent, scannedComponent); + componentDescriptorService.deleteByClazz(TenantId.SYS_TENANT_ID, persistedComponent.getClazz()); + scannedComponent.setId(persistedComponent.getId()); + scannedComponent = componentDescriptorService.saveComponent(TenantId.SYS_TENANT_ID, scannedComponent); + } + return scannedComponent; + } + + private NodeDefinition prepareNodeDefinition(RuleNode nodeAnnotation) throws Exception { + NodeDefinition nodeDefinition = new NodeDefinition(); + nodeDefinition.setDetails(nodeAnnotation.nodeDetails()); + nodeDefinition.setDescription(nodeAnnotation.nodeDescription()); + nodeDefinition.setInEnabled(nodeAnnotation.inEnabled()); + nodeDefinition.setOutEnabled(nodeAnnotation.outEnabled()); + nodeDefinition.setRelationTypes(getRelationTypesWithFailureRelation(nodeAnnotation)); + nodeDefinition.setCustomRelations(nodeAnnotation.customRelations()); + nodeDefinition.setRuleChainNode(nodeAnnotation.ruleChainNode()); + Class configClazz = nodeAnnotation.configClazz(); + NodeConfiguration config = configClazz.getDeclaredConstructor().newInstance(); + NodeConfiguration defaultConfiguration = config.defaultConfiguration(); + nodeDefinition.setDefaultConfiguration(mapper.valueToTree(defaultConfiguration)); + nodeDefinition.setUiResources(nodeAnnotation.uiResources()); + nodeDefinition.setConfigDirective(nodeAnnotation.configDirective()); + nodeDefinition.setIcon(nodeAnnotation.icon()); + nodeDefinition.setIconUrl(nodeAnnotation.iconUrl()); + nodeDefinition.setDocUrl(nodeAnnotation.docUrl()); + return nodeDefinition; + } + + private String[] getRelationTypesWithFailureRelation(RuleNode nodeAnnotation) { + List relationTypes = new ArrayList<>(Arrays.asList(nodeAnnotation.relationTypes())); + if (!relationTypes.contains(TbRelationTypes.FAILURE)) { + relationTypes.add(TbRelationTypes.FAILURE); + } + return relationTypes.toArray(new String[relationTypes.size()]); + } + + private Set getBeanDefinitions(Class componentType) { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(componentType)); + Set defs = new HashSet<>(); + for (String scanPackage : scanPackages) { + defs.addAll(scanner.findCandidateComponents(scanPackage)); + } + return defs; + } + + @Override + public void discoverComponents() { + registerRuleNodeComponents(); + log.debug("Found following definitions: {}", components.values()); + } + + @Override + public List getComponents(ComponentType type, RuleChainType ruleChainType) { + if (RuleChainType.CORE.equals(ruleChainType)) { + if (coreComponentsMap.containsKey(type)) { + return Collections.unmodifiableList(coreComponentsMap.get(type)); + } else { + return Collections.emptyList(); + } + } else if (RuleChainType.EDGE.equals(ruleChainType)) { + if (edgeComponentsMap.containsKey(type)) { + return Collections.unmodifiableList(edgeComponentsMap.get(type)); + } else { + return Collections.emptyList(); + } + } else { + log.error("Unsupported rule chain type {}", ruleChainType); + throw new RuntimeException("Unsupported rule chain type " + ruleChainType); + } + } + + @Override + public List getComponents(Set types, RuleChainType ruleChainType) { + if (RuleChainType.CORE.equals(ruleChainType)) { + return getComponents(types, coreComponentsMap); + } else if (RuleChainType.EDGE.equals(ruleChainType)) { + return getComponents(types, edgeComponentsMap); + } else { + log.error("Unsupported rule chain type {}", ruleChainType); + throw new RuntimeException("Unsupported rule chain type " + ruleChainType); + } + } + + @Override + public Optional getComponent(String clazz) { + return Optional.ofNullable(components.get(clazz)); + } + + private List getComponents(Set types, Map> componentsMap) { + List result = new ArrayList<>(); + types.stream().filter(componentsMap::containsKey).forEach(type -> { + result.addAll(componentsMap.get(type)); + }); + return Collections.unmodifiableList(result); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java new file mode 100644 index 0000000..05dd8aa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/component/ComponentDiscoveryService.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.component; + +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * @author Andrew Shvayka + */ +public interface ComponentDiscoveryService { + + void discoverComponents(); + + List getComponents(ComponentType type, RuleChainType ruleChainType); + + List getComponents(Set types, RuleChainType ruleChainType); + + Optional getComponent(String clazz); +} diff --git a/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java new file mode 100644 index 0000000..a397a4d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/device/ClaimDevicesServiceImpl.java @@ -0,0 +1,256 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.ClaimDataInfo; +import org.thingsboard.server.dao.device.ClaimDevicesService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.claim.ClaimData; +import org.thingsboard.server.dao.device.claim.ClaimResponse; +import org.thingsboard.server.dao.device.claim.ClaimResult; +import org.thingsboard.server.dao.device.claim.ReclaimResult; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE; + +@Service +@Slf4j +@TbCoreComponent +public class ClaimDevicesServiceImpl implements ClaimDevicesService { + + private static final String CLAIM_ATTRIBUTE_NAME = "claimingAllowed"; + private static final String CLAIM_DATA_ATTRIBUTE_NAME = "claimingData"; + private static final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private TbClusterService clusterService; + @Autowired + private DeviceService deviceService; + @Autowired + private AttributesService attributesService; + @Autowired + private RuleEngineTelemetryService telemetryService; + @Autowired + private CustomerService customerService; + @Autowired + private CacheManager cacheManager; + + @Value("${security.claim.allowClaimingByDefault}") + private boolean isAllowedClaimingByDefault; + + @Value("${security.claim.duration}") + private long systemDurationMs; + + @Override + public ListenableFuture registerClaimingInfo(TenantId tenantId, DeviceId deviceId, String secretKey, long durationMs) { + ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(tenantId, deviceId); + return Futures.transformAsync(deviceFuture, device -> { + Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); + List key = constructCacheKey(device.getId()); + + if (isAllowedClaimingByDefault) { + if (device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + persistInCache(secretKey, durationMs, cache, key); + return Futures.immediateFuture(null); + } + log.warn("The device [{}] has been already claimed!", device.getName()); + throw new IllegalArgumentException(); + } else { + ListenableFuture> claimingAllowedFuture = attributesService.find(tenantId, device.getId(), + DataConstants.SERVER_SCOPE, Collections.singletonList(CLAIM_ATTRIBUTE_NAME)); + return Futures.transform(claimingAllowedFuture, list -> { + if (list != null && !list.isEmpty()) { + Optional claimingAllowedOptional = list.get(0).getBooleanValue(); + if (claimingAllowedOptional.isPresent() && claimingAllowedOptional.get() + && device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + persistInCache(secretKey, durationMs, cache, key); + return null; + } + } + log.warn("Failed to find claimingAllowed attribute for device or it is already claimed![{}]", device.getName()); + throw new IllegalArgumentException(); + }, MoreExecutors.directExecutor()); + } + }, MoreExecutors.directExecutor()); + } + + private ListenableFuture getClaimData(Cache cache, Device device) { + List key = constructCacheKey(device.getId()); + ClaimData claimDataFromCache = cache.get(key, ClaimData.class); + if (claimDataFromCache != null) { + return Futures.immediateFuture(new ClaimDataInfo(true, key, claimDataFromCache)); + } else { + ListenableFuture> claimDataAttrFuture = attributesService.find(device.getTenantId(), device.getId(), + DataConstants.SERVER_SCOPE, CLAIM_DATA_ATTRIBUTE_NAME); + + return Futures.transform(claimDataAttrFuture, claimDataAttr -> { + if (claimDataAttr.isPresent()) { + ClaimData claimDataFromAttribute = JacksonUtil.fromString(claimDataAttr.get().getValueAsString(), ClaimData.class); + return new ClaimDataInfo(false, key, claimDataFromAttribute); + } + return null; + }, MoreExecutors.directExecutor()); + } + } + + @Override + public ListenableFuture claimDevice(Device device, CustomerId customerId, String secretKey) { + Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); + ListenableFuture claimDataFuture = getClaimData(cache, device); + + return Futures.transformAsync(claimDataFuture, claimData -> { + if (claimData != null) { + long currTs = System.currentTimeMillis(); + if (currTs > claimData.getData().getExpirationTime() || !secretKeyIsEmptyOrEqual(secretKey, claimData.getData().getSecretKey())) { + log.warn("The claiming timeout occurred or wrong 'secretKey' provided for the device [{}]", device.getName()); + if (claimData.isFromCache()) { + cache.evict(claimData.getKey()); + } + return Futures.immediateFuture(new ClaimResult(null, ClaimResponse.FAILURE)); + } else { + if (device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + device.setCustomerId(customerId); + Device savedDevice = deviceService.saveDevice(device); + clusterService.onDeviceUpdated(savedDevice, device); + return Futures.transform(removeClaimingSavedData(cache, claimData, device), result -> new ClaimResult(savedDevice, ClaimResponse.SUCCESS), MoreExecutors.directExecutor()); + } + return Futures.transform(removeClaimingSavedData(cache, claimData, device), result -> new ClaimResult(null, ClaimResponse.CLAIMED), MoreExecutors.directExecutor()); + } + } else { + log.warn("Failed to find the device's claiming message![{}]", device.getName()); + if (device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + return Futures.immediateFuture(new ClaimResult(null, ClaimResponse.FAILURE)); + } else { + return Futures.immediateFuture(new ClaimResult(null, ClaimResponse.CLAIMED)); + } + } + }, MoreExecutors.directExecutor()); + } + + private boolean secretKeyIsEmptyOrEqual(String secretKeyA, String secretKeyB) { + return (StringUtils.isEmpty(secretKeyA) && StringUtils.isEmpty(secretKeyB)) || secretKeyA.equals(secretKeyB); + } + + @Override + public ListenableFuture reClaimDevice(TenantId tenantId, Device device) { + if (!device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + cacheEviction(device.getId()); + Customer unassignedCustomer = customerService.findCustomerById(tenantId, device.getCustomerId()); + device.setCustomerId(null); + Device savedDevice = deviceService.saveDevice(device); + clusterService.onDeviceUpdated(savedDevice, device); + if (isAllowedClaimingByDefault) { + return Futures.immediateFuture(new ReclaimResult(unassignedCustomer)); + } + SettableFuture result = SettableFuture.create(); + telemetryService.saveAndNotify( + tenantId, savedDevice.getId(), DataConstants.SERVER_SCOPE, Collections.singletonList( + new BaseAttributeKvEntry(new BooleanDataEntry(CLAIM_ATTRIBUTE_NAME, true), System.currentTimeMillis()) + ), + new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + result.set(new ReclaimResult(unassignedCustomer)); + } + + @Override + public void onFailure(Throwable t) { + result.setException(t); + } + }); + return result; + } + cacheEviction(device.getId()); + return Futures.immediateFuture(new ReclaimResult(null)); + } + + private List constructCacheKey(DeviceId deviceId) { + return Collections.singletonList(deviceId); + } + + private void persistInCache(String secretKey, long durationMs, Cache cache, List key) { + ClaimData claimData = new ClaimData(secretKey, + System.currentTimeMillis() + validateDurationMs(durationMs)); + cache.putIfAbsent(key, claimData); + } + + private long validateDurationMs(long durationMs) { + if (durationMs > 0L) { + return durationMs; + } + return systemDurationMs; + } + + private ListenableFuture removeClaimingSavedData(Cache cache, ClaimDataInfo data, Device device) { + if (data.isFromCache()) { + cache.evict(data.getKey()); + } + SettableFuture result = SettableFuture.create(); + telemetryService.deleteAndNotify(device.getTenantId(), + device.getId(), DataConstants.SERVER_SCOPE, Arrays.asList(CLAIM_ATTRIBUTE_NAME, CLAIM_DATA_ATTRIBUTE_NAME), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + result.set(tmp); + } + + @Override + public void onFailure(Throwable t) { + result.setException(t); + } + }); + return result; + } + + private void cacheEviction(DeviceId deviceId) { + Cache cache = cacheManager.getCache(CLAIM_DEVICES_CACHE); + cache.evict(constructCacheKey(deviceId)); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java new file mode 100644 index 0000000..9f8d58b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -0,0 +1,272 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device; + +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode; +import org.thingsboard.server.common.data.device.data.PowerMode; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.Lwm2mDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.lwm2m.OtherConfiguration; +import org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryMappingConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumnType; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.device.TbDeviceService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.sync.ie.importing.csv.AbstractBulkImportService; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DeviceBulkImportService extends AbstractBulkImportService { + protected final DeviceService deviceService; + protected final TbDeviceService tbDeviceService; + protected final DeviceCredentialsService deviceCredentialsService; + protected final DeviceProfileService deviceProfileService; + + private final Lock findOrCreateDeviceProfileLock = new ReentrantLock(); + + @Override + protected void setEntityFields(Device entity, Map fields) { + ObjectNode additionalInfo = getOrCreateAdditionalInfoObj(entity); + fields.forEach((columnType, value) -> { + switch (columnType) { + case NAME: + entity.setName(value); + break; + case TYPE: + entity.setType(value); + break; + case LABEL: + entity.setLabel(value); + break; + case DESCRIPTION: + additionalInfo.set("description", new TextNode(value)); + break; + case IS_GATEWAY: + additionalInfo.set("gateway", BooleanNode.valueOf(Boolean.parseBoolean(value))); + break; + } + entity.setAdditionalInfo(additionalInfo); + }); + } + + @Override + @SneakyThrows + protected Device saveEntity(SecurityUser user, Device entity, Map fields) { + DeviceCredentials deviceCredentials; + try { + deviceCredentials = createDeviceCredentials(entity.getTenantId(), entity.getId(), fields); + deviceCredentialsService.formatCredentials(deviceCredentials); + } catch (Exception e) { + throw new DeviceCredentialsValidationException("Invalid device credentials: " + e.getMessage()); + } + + DeviceProfile deviceProfile; + if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.LWM2M_CREDENTIALS) { + deviceProfile = setUpLwM2mDeviceProfile(entity.getTenantId(), entity); + } else if (StringUtils.isNotEmpty(entity.getType())) { + deviceProfile = deviceProfileService.findOrCreateDeviceProfile(entity.getTenantId(), entity.getType()); + } else { + deviceProfile = deviceProfileService.findDefaultDeviceProfile(entity.getTenantId()); + } + entity.setDeviceProfileId(deviceProfile.getId()); + + return tbDeviceService.saveDeviceWithCredentials(entity, deviceCredentials, user); + } + + @Override + protected Device findOrCreateEntity(TenantId tenantId, String name) { + return Optional.ofNullable(deviceService.findDeviceByTenantIdAndName(tenantId, name)) + .orElseGet(Device::new); + } + + @Override + protected void setOwners(Device entity, SecurityUser user) { + entity.setTenantId(user.getTenantId()); + entity.setCustomerId(user.getCustomerId()); + } + + @SneakyThrows + private DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceId deviceId, Map fields) { + DeviceCredentials credentials = new DeviceCredentials(); + if (fields.containsKey(BulkImportColumnType.LWM2M_CLIENT_ENDPOINT)) { + credentials.setCredentialsType(DeviceCredentialsType.LWM2M_CREDENTIALS); + setUpLwm2mCredentials(fields, credentials); + } else if (fields.containsKey(BulkImportColumnType.X509)) { + credentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + setUpX509CertificateCredentials(fields, credentials); + } else if (CollectionUtils.containsAny(fields.keySet(), EnumSet.of(BulkImportColumnType.MQTT_CLIENT_ID, BulkImportColumnType.MQTT_USER_NAME, BulkImportColumnType.MQTT_PASSWORD))) { + credentials.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + setUpBasicMqttCredentials(fields, credentials); + } else if (deviceId != null && !fields.containsKey(BulkImportColumnType.ACCESS_TOKEN)) { + credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, deviceId); + } else { + credentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + setUpAccessTokenCredentials(fields, credentials); + } + return credentials; + } + + private void setUpAccessTokenCredentials(Map fields, DeviceCredentials credentials) { + credentials.setCredentialsId(Optional.ofNullable(fields.get(BulkImportColumnType.ACCESS_TOKEN)) + .orElseGet(() -> StringUtils.randomAlphanumeric(20))); + } + + private void setUpBasicMqttCredentials(Map fields, DeviceCredentials credentials) { + BasicMqttCredentials basicMqttCredentials = new BasicMqttCredentials(); + basicMqttCredentials.setClientId(fields.get(BulkImportColumnType.MQTT_CLIENT_ID)); + basicMqttCredentials.setUserName(fields.get(BulkImportColumnType.MQTT_USER_NAME)); + basicMqttCredentials.setPassword(fields.get(BulkImportColumnType.MQTT_PASSWORD)); + credentials.setCredentialsValue(JacksonUtil.toString(basicMqttCredentials)); + } + + private void setUpX509CertificateCredentials(Map fields, DeviceCredentials credentials) { + credentials.setCredentialsValue(fields.get(BulkImportColumnType.X509)); + } + + private void setUpLwm2mCredentials(Map fields, DeviceCredentials credentials) throws com.fasterxml.jackson.core.JsonProcessingException { + ObjectNode lwm2mCredentials = JacksonUtil.newObjectNode(); + + Set.of(BulkImportColumnType.LWM2M_CLIENT_SECURITY_CONFIG_MODE, BulkImportColumnType.LWM2M_BOOTSTRAP_SERVER_SECURITY_MODE, + BulkImportColumnType.LWM2M_SERVER_SECURITY_MODE).stream() + .map(fields::get) + .filter(Objects::nonNull) + .forEach(securityMode -> { + try { + LwM2MSecurityMode.valueOf(securityMode.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new DeviceCredentialsValidationException("Unknown LwM2M security mode: " + securityMode + ", (the mode should be: NO_SEC, PSK, RPK, X509)!"); + } + }); + + ObjectNode client = JacksonUtil.newObjectNode(); + setValues(client, fields, Set.of(BulkImportColumnType.LWM2M_CLIENT_SECURITY_CONFIG_MODE, + BulkImportColumnType.LWM2M_CLIENT_ENDPOINT, BulkImportColumnType.LWM2M_CLIENT_IDENTITY, + BulkImportColumnType.LWM2M_CLIENT_KEY, BulkImportColumnType.LWM2M_CLIENT_CERT)); + LwM2MClientCredential lwM2MClientCredential = JacksonUtil.treeToValue(client, LwM2MClientCredential.class); + // so that only fields needed for specific type of lwM2MClientCredentials were saved in json + lwm2mCredentials.set("client", JacksonUtil.valueToTree(lwM2MClientCredential)); + + ObjectNode bootstrapServer = JacksonUtil.newObjectNode(); + setValues(bootstrapServer, fields, Set.of(BulkImportColumnType.LWM2M_BOOTSTRAP_SERVER_SECURITY_MODE, + BulkImportColumnType.LWM2M_BOOTSTRAP_SERVER_PUBLIC_KEY_OR_ID, BulkImportColumnType.LWM2M_BOOTSTRAP_SERVER_SECRET_KEY)); + + ObjectNode lwm2mServer = JacksonUtil.newObjectNode(); + setValues(lwm2mServer, fields, Set.of(BulkImportColumnType.LWM2M_SERVER_SECURITY_MODE, + BulkImportColumnType.LWM2M_SERVER_CLIENT_PUBLIC_KEY_OR_ID, BulkImportColumnType.LWM2M_SERVER_CLIENT_SECRET_KEY)); + + ObjectNode bootstrap = JacksonUtil.newObjectNode(); + bootstrap.set("bootstrapServer", bootstrapServer); + bootstrap.set("lwm2mServer", lwm2mServer); + lwm2mCredentials.set("bootstrap", bootstrap); + + credentials.setCredentialsValue(lwm2mCredentials.toString()); + } + + private DeviceProfile setUpLwM2mDeviceProfile(TenantId tenantId, Device device) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByName(tenantId, device.getType()); + if (deviceProfile != null) { + if (deviceProfile.getTransportType() != DeviceTransportType.LWM2M) { + deviceProfile.setTransportType(DeviceTransportType.LWM2M); + deviceProfile.getProfileData().setTransportConfiguration(new Lwm2mDeviceProfileTransportConfiguration()); + deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile); + } + } else { + findOrCreateDeviceProfileLock.lock(); + try { + deviceProfile = deviceProfileService.findDeviceProfileByName(tenantId, device.getType()); + if (deviceProfile == null) { + deviceProfile = new DeviceProfile(); + deviceProfile.setTenantId(tenantId); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setName(device.getType()); + deviceProfile.setTransportType(DeviceTransportType.LWM2M); + deviceProfile.setProvisionType(DeviceProfileProvisionType.DISABLED); + + Lwm2mDeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration(); + transportConfiguration.setBootstrap(Collections.emptyList()); + transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(1, 1, 1, PowerMode.DRX, null, null, null, null, null)); + transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap())); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); + DisabledDeviceProfileProvisionConfiguration provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null); + deviceProfileData.setConfiguration(configuration); + deviceProfileData.setTransportConfiguration(transportConfiguration); + deviceProfileData.setProvisionConfiguration(provisionConfiguration); + deviceProfile.setProfileData(deviceProfileData); + + deviceProfile = deviceProfileService.saveDeviceProfile(deviceProfile); + } + } finally { + findOrCreateDeviceProfileLock.unlock(); + } + } + return deviceProfile; + } + + private void setValues(ObjectNode objectNode, Map data, Collection columns) { + for (BulkImportColumnType column : columns) { + String value = StringUtils.defaultString(data.get(column), column.getDefaultValue()); + if (value != null && column.getKey() != null) { + objectNode.set(column.getKey(), new TextNode(value)); + } + } + } + + @Override + protected EntityType getEntityType() { + return EntityType.DEVICE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java new file mode 100644 index 0000000..c88cbd8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -0,0 +1,260 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceDao; +import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionFailedException; +import org.thingsboard.server.dao.device.provision.ProvisionRequest; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.state.DeviceStateService; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + + +@Service +@Slf4j +@TbCoreComponent +public class DeviceProvisionServiceImpl implements DeviceProvisionService { + + protected TbQueueProducer> ruleEngineMsgProducer; + + private static final String DEVICE_PROVISION_STATE = "provisionState"; + private static final String PROVISIONED_STATE = "provisioned"; + + @Autowired + TbClusterService clusterService; + + @Autowired + DeviceDao deviceDao; + + @Autowired + DeviceProfileDao deviceProfileDao; + + @Autowired + DeviceService deviceService; + + @Autowired + DeviceCredentialsService deviceCredentialsService; + + @Autowired + AttributesService attributesService; + + @Autowired + DeviceStateService deviceStateService; + + @Autowired + AuditLogService auditLogService; + + @Autowired + PartitionService partitionService; + + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider) { + ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); + } + + @Override + public ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) { + String provisionRequestKey = provisionRequest.getCredentials().getProvisionDeviceKey(); + String provisionRequestSecret = provisionRequest.getCredentials().getProvisionDeviceSecret(); + if (!StringUtils.isEmpty(provisionRequest.getDeviceName())) { + provisionRequest.setDeviceName(provisionRequest.getDeviceName().trim()); + if (StringUtils.isEmpty(provisionRequest.getDeviceName())) { + log.warn("Provision request contains empty device name!"); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } + + if (StringUtils.isEmpty(provisionRequestKey) || StringUtils.isEmpty(provisionRequestSecret)) { + throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); + } + + DeviceProfile targetProfile = deviceProfileDao.findByProvisionDeviceKey(provisionRequestKey); + + if (targetProfile == null || targetProfile.getProfileData().getProvisionConfiguration() == null || + targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() == null) { + throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); + } + + Device targetDevice = deviceDao.findDeviceByTenantIdAndName(targetProfile.getTenantId().getId(), provisionRequest.getDeviceName()).orElse(null); + + switch (targetProfile.getProvisionType()) { + case ALLOW_CREATE_NEW_DEVICES: + if (targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret().equals(provisionRequestSecret)) { + if (targetDevice != null) { + log.warn("[{}] The device is present and could not be provisioned once more!", targetDevice.getName()); + notify(targetDevice, provisionRequest, DataConstants.PROVISION_FAILURE, false); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } else { + return createDevice(provisionRequest, targetProfile); + } + } + break; + case CHECK_PRE_PROVISIONED_DEVICES: + if (targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret().equals(provisionRequestSecret)) { + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + return processProvision(targetDevice, provisionRequest); + } else { + log.warn("[{}] Failed to find pre provisioned device!", provisionRequest.getDeviceName()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } + break; + } + throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); + } + + private ProvisionResponse processProvision(Device device, ProvisionRequest provisionRequest) { + try { + Optional provisionState = attributesService.find(device.getTenantId(), device.getId(), + DataConstants.SERVER_SCOPE, DEVICE_PROVISION_STATE).get(); + if (provisionState != null && provisionState.isPresent() && !provisionState.get().getValueAsString().equals(PROVISIONED_STATE)) { + notify(device, provisionRequest, DataConstants.PROVISION_FAILURE, false); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } else { + saveProvisionStateAttribute(device).get(); + notify(device, provisionRequest, DataConstants.PROVISION_SUCCESS, true); + } + } catch (InterruptedException | ExecutionException e) { + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + return new ProvisionResponse(deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()), ProvisionResponseStatus.SUCCESS); + } + + private ProvisionResponse createDevice(ProvisionRequest provisionRequest, DeviceProfile profile) { + return processCreateDevice(provisionRequest, profile); + } + + private void notify(Device device, ProvisionRequest provisionRequest, String type, boolean success) { + pushProvisionEventToRuleEngine(provisionRequest, device, type); + logAction(device.getTenantId(), device.getCustomerId(), device, success, provisionRequest); + } + + private ProvisionResponse processCreateDevice(ProvisionRequest provisionRequest, DeviceProfile profile) { + try { + if (StringUtils.isEmpty(provisionRequest.getDeviceName())) { + String newDeviceName = StringUtils.randomAlphanumeric(20); + log.info("Device name not found in provision request. Generated name is: {}", newDeviceName); + provisionRequest.setDeviceName(newDeviceName); + } + Device savedDevice = deviceService.saveDevice(provisionRequest, profile); + clusterService.onDeviceUpdated(savedDevice, null); + saveProvisionStateAttribute(savedDevice).get(); + pushDeviceCreatedEventToRuleEngine(savedDevice); + notify(savedDevice, provisionRequest, DataConstants.PROVISION_SUCCESS, true); + + return new ProvisionResponse(getDeviceCredentials(savedDevice), ProvisionResponseStatus.SUCCESS); + } catch (Exception e) { + log.warn("[{}] Error during device creation from provision request: [{}]", provisionRequest.getDeviceName(), provisionRequest, e); + Device device = deviceService.findDeviceByTenantIdAndName(profile.getTenantId(), provisionRequest.getDeviceName()); + if (device != null) { + notify(device, provisionRequest, DataConstants.PROVISION_FAILURE, false); + } + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } + + private ListenableFuture> saveProvisionStateAttribute(Device device) { + return attributesService.save(device.getTenantId(), device.getId(), DataConstants.SERVER_SCOPE, + Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), + System.currentTimeMillis()))); + } + + private DeviceCredentials getDeviceCredentials(Device device) { + return deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); + } + + private void pushProvisionEventToRuleEngine(ProvisionRequest request, Device device, String type) { + try { + JsonNode entityNode = JacksonUtil.valueToTree(request); + TbMsg msg = TbMsg.newMsg(type, device.getId(), device.getCustomerId(), createTbMsgMetaData(device), JacksonUtil.toString(entityNode)); + sendToRuleEngine(device.getTenantId(), msg, null); + } catch (IllegalArgumentException e) { + log.warn("[{}] Failed to push device action to rule engine: {}", device.getId(), type, e); + } + } + + private void pushDeviceCreatedEventToRuleEngine(Device device) { + try { + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(device); + TbMsg msg = TbMsg.newMsg(DataConstants.ENTITY_CREATED, device.getId(), device.getCustomerId(), createTbMsgMetaData(device), JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + sendToRuleEngine(device.getTenantId(), msg, null); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push device action to rule engine: {}", device.getId(), DataConstants.ENTITY_CREATED, e); + } + } + + protected void sendToRuleEngine(TenantId tenantId, TbMsg tbMsg, TbQueueCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, tenantId, tbMsg.getOriginator()); + TransportProtos.ToRuleEngineMsg msg = TransportProtos.ToRuleEngineMsg.newBuilder().setTbMsg(TbMsg.toByteString(tbMsg)) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()).build(); + ruleEngineMsgProducer.send(tpi, new TbProtoQueueMsg<>(tbMsg.getId(), msg), callback); + } + + private TbMsgMetaData createTbMsgMetaData(Device device) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("tenantId", device.getTenantId().toString()); + return metaData; + } + + private void logAction(TenantId tenantId, CustomerId customerId, Device device, boolean success, ProvisionRequest provisionRequest) { + ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE; + auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/DefaultEdgeNotificationService.java b/application/src/main/java/org/thingsboard/server/service/edge/DefaultEdgeNotificationService.java new file mode 100644 index 0000000..6d368fb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/DefaultEdgeNotificationService.java @@ -0,0 +1,255 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.rpc.processor.AlarmEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.AssetEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.AssetProfileEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.CustomerEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DashboardEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DeviceEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DeviceProfileEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.EntityViewEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.OtaPackageEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.QueueEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.RelationEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.RuleChainEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.UserEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.WidgetBundleEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.WidgetTypeEdgeProcessor; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Service +@TbCoreComponent +@Slf4j +public class DefaultEdgeNotificationService implements EdgeNotificationService { + + public static final String EDGE_IS_ROOT_BODY_KEY = "isRoot"; + + @Autowired + private EdgeService edgeService; + + @Autowired + private EdgeEventService edgeEventService; + + @Autowired + private TbClusterService clusterService; + + @Autowired + private EdgeProcessor edgeProcessor; + + @Autowired + private AssetEdgeProcessor assetProcessor; + + @Autowired + private DeviceEdgeProcessor deviceProcessor; + + @Autowired + private EntityViewEdgeProcessor entityViewProcessor; + + @Autowired + private DashboardEdgeProcessor dashboardProcessor; + + @Autowired + private RuleChainEdgeProcessor ruleChainProcessor; + + @Autowired + private UserEdgeProcessor userProcessor; + + @Autowired + private CustomerEdgeProcessor customerProcessor; + + @Autowired + private DeviceProfileEdgeProcessor deviceProfileProcessor; + + @Autowired + private AssetProfileEdgeProcessor assetProfileProcessor; + + @Autowired + private OtaPackageEdgeProcessor otaPackageProcessor; + + @Autowired + private WidgetBundleEdgeProcessor widgetBundleProcessor; + + @Autowired + private WidgetTypeEdgeProcessor widgetTypeProcessor; + + @Autowired + private QueueEdgeProcessor queueProcessor; + + @Autowired + private AlarmEdgeProcessor alarmProcessor; + + @Autowired + private RelationEdgeProcessor relationProcessor; + + private ExecutorService dbCallBackExecutor; + + @PostConstruct + public void initExecutor() { + dbCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-notifications")); + } + + @PreDestroy + public void shutdownExecutor() { + if (dbCallBackExecutor != null) { + dbCallBackExecutor.shutdownNow(); + } + } + + @Override + public Edge setEdgeRootRuleChain(TenantId tenantId, Edge edge, RuleChainId ruleChainId) throws Exception { + edge.setRootRuleChainId(ruleChainId); + Edge savedEdge = edgeService.saveEdge(edge); + ObjectNode isRootBody = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + isRootBody.put(EDGE_IS_ROOT_BODY_KEY, Boolean.TRUE); + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.RULE_CHAIN, EdgeEventActionType.UPDATED, ruleChainId, isRootBody).get(); + return savedEdge; + } + + private ListenableFuture saveEdgeEvent(TenantId tenantId, + EdgeId edgeId, + EdgeEventType type, + EdgeEventActionType action, + EntityId entityId, + JsonNode body) { + log.debug("Pushing edge event to edge queue. tenantId [{}], edgeId [{}], type [{}], action[{}], entityId [{}], body [{}]", + tenantId, edgeId, type, action, entityId, body); + + EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(tenantId, edgeId, type, action, entityId, body); + + return Futures.transform(edgeEventService.saveAsync(edgeEvent), unused -> { + clusterService.onEdgeEventUpdate(tenantId, edgeId); + return null; + }, dbCallBackExecutor); + } + + @Override + public void pushNotificationToEdge(TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg, TbCallback callback) { + log.debug("Pushing notification to edge {}", edgeNotificationMsg); + try { + TenantId tenantId = TenantId.fromUUID(new UUID(edgeNotificationMsg.getTenantIdMSB(), edgeNotificationMsg.getTenantIdLSB())); + EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); + ListenableFuture future; + switch (type) { + case EDGE: + future = edgeProcessor.processEdgeNotification(tenantId, edgeNotificationMsg); + break; + case ASSET: + future = assetProcessor.processAssetNotification(tenantId, edgeNotificationMsg); + break; + case DEVICE: + future = deviceProcessor.processDeviceNotification(tenantId, edgeNotificationMsg); + break; + case ENTITY_VIEW: + future = entityViewProcessor.processEntityViewNotification(tenantId, edgeNotificationMsg); + break; + case DASHBOARD: + future = dashboardProcessor.processDashboardNotification(tenantId, edgeNotificationMsg); + break; + case RULE_CHAIN: + future = ruleChainProcessor.processRuleChainNotification(tenantId, edgeNotificationMsg); + break; + case USER: + future = userProcessor.processUserNotification(tenantId, edgeNotificationMsg); + break; + case CUSTOMER: + future = customerProcessor.processCustomerNotification(tenantId, edgeNotificationMsg); + break; + case DEVICE_PROFILE: + future = deviceProfileProcessor.processDeviceProfileNotification(tenantId, edgeNotificationMsg); + break; + case ASSET_PROFILE: + future = assetProfileProcessor.processAssetProfileNotification(tenantId, edgeNotificationMsg); + break; + case OTA_PACKAGE: + future = otaPackageProcessor.processOtaPackageNotification(tenantId, edgeNotificationMsg); + break; + case WIDGETS_BUNDLE: + future = widgetBundleProcessor.processWidgetsBundleNotification(tenantId, edgeNotificationMsg); + break; + case WIDGET_TYPE: + future = widgetTypeProcessor.processWidgetTypeNotification(tenantId, edgeNotificationMsg); + break; + case QUEUE: + future = queueProcessor.processQueueNotification(tenantId, edgeNotificationMsg); + break; + case ALARM: + future = alarmProcessor.processAlarmNotification(tenantId, edgeNotificationMsg); + break; + case RELATION: + future = relationProcessor.processRelationNotification(tenantId, edgeNotificationMsg); + break; + default: + log.warn("Edge event type [{}] is not designed to be pushed to edge", type); + future = Futures.immediateFuture(null); + } + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void unused) { + callback.onSuccess(); + } + + @Override + public void onFailure(Throwable throwable) { + callBackFailure(edgeNotificationMsg, callback, throwable); + } + }, dbCallBackExecutor); + } catch (Exception e) { + callBackFailure(edgeNotificationMsg, callback, e); + } + } + + private void callBackFailure(TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg, TbCallback callback, Throwable throwable) { + log.error("Can't push to edge updates, edgeNotificationMsg [{}]", edgeNotificationMsg, throwable); + callback.onFailure(throwable); + } + +} + + diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeBulkImportService.java new file mode 100644 index 0000000..f8c23ec --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeBulkImportService.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumnType; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.edge.TbEdgeService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.sync.ie.importing.csv.AbstractBulkImportService; + +import java.util.Map; +import java.util.Optional; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class EdgeBulkImportService extends AbstractBulkImportService { + private final EdgeService edgeService; + private final TbEdgeService tbEdgeService; + private final RuleChainService ruleChainService; + + @Override + protected void setEntityFields(Edge entity, Map fields) { + ObjectNode additionalInfo = getOrCreateAdditionalInfoObj(entity); + fields.forEach((columnType, value) -> { + switch (columnType) { + case NAME: + entity.setName(value); + break; + case TYPE: + entity.setType(value); + break; + case LABEL: + entity.setLabel(value); + break; + case DESCRIPTION: + additionalInfo.set("description", new TextNode(value)); + break; + case ROUTING_KEY: + entity.setRoutingKey(value); + break; + case SECRET: + entity.setSecret(value); + break; + } + }); + entity.setAdditionalInfo(additionalInfo); + } + + @SneakyThrows + @Override + protected Edge saveEntity(SecurityUser user, Edge entity, Map fields) { + RuleChain edgeTemplateRootRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(user.getTenantId()); + return tbEdgeService.save(entity, edgeTemplateRootRuleChain, user); + } + + @Override + protected Edge findOrCreateEntity(TenantId tenantId, String name) { + return Optional.ofNullable(edgeService.findEdgeByTenantIdAndName(tenantId, name)) + .orElseGet(Edge::new); + } + + @Override + protected void setOwners(Edge entity, SecurityUser user) { + entity.setTenantId(user.getTenantId()); + entity.setCustomerId(user.getCustomerId()); + } + + @Override + protected EntityType getEntityType() { + return EntityType.EDGE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java new file mode 100644 index 0000000..4adb73e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -0,0 +1,193 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge; + +import freemarker.template.Configuration; +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; +import org.thingsboard.server.service.edge.rpc.constructor.EdgeMsgConstructor; +import org.thingsboard.server.service.edge.rpc.processor.AdminSettingsEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.AlarmEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.AssetEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.AssetProfileEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.CustomerEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DashboardEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DeviceEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.DeviceProfileEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.EntityViewEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.OtaPackageEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.QueueEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.RelationEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.RuleChainEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.TelemetryEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.UserEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.WidgetBundleEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.WidgetTypeEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.sync.EdgeRequestsService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.executors.GrpcCallbackExecutorService; + +@Component +@TbCoreComponent +@Data +@Lazy +public class EdgeContextComponent { + + @Autowired + private TbClusterService clusterService; + + @Autowired + private EdgeService edgeService; + + @Autowired + private EdgeEventService edgeEventService; + + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private Configuration freemarkerConfig; + + @Autowired + private DeviceService deviceService; + + @Autowired + private AssetService assetService; + + @Autowired + private EntityViewService entityViewService; + + @Autowired + private DeviceProfileService deviceProfileService; + + @Autowired + private AssetProfileService assetProfileService; + + @Autowired + private AttributesService attributesService; + + @Autowired + private DashboardService dashboardService; + + @Autowired + private RuleChainService ruleChainService; + + @Autowired + private UserService userService; + + @Autowired + private CustomerService customerService; + + @Autowired + private WidgetsBundleService widgetsBundleService; + + @Autowired + private EdgeRequestsService edgeRequestsService; + + @Autowired + private OtaPackageService otaPackageService; + + @Autowired + private QueueService queueService; + + @Autowired + private AlarmEdgeProcessor alarmProcessor; + + @Autowired + private DeviceProfileEdgeProcessor deviceProfileProcessor; + + @Autowired + private AssetProfileEdgeProcessor assetProfileProcessor; + + @Autowired + private EdgeProcessor edgeProcessor; + + @Autowired + private DeviceEdgeProcessor deviceProcessor; + + @Autowired + private AssetEdgeProcessor assetProcessor; + + @Autowired + private EntityViewEdgeProcessor entityViewProcessor; + + @Autowired + private UserEdgeProcessor userProcessor; + + @Autowired + private RelationEdgeProcessor relationProcessor; + + @Autowired + private TelemetryEdgeProcessor telemetryProcessor; + + @Autowired + private DashboardEdgeProcessor dashboardProcessor; + + @Autowired + private RuleChainEdgeProcessor ruleChainProcessor; + + @Autowired + private CustomerEdgeProcessor customerProcessor; + + @Autowired + private WidgetBundleEdgeProcessor widgetBundleProcessor; + + @Autowired + private WidgetTypeEdgeProcessor widgetTypeProcessor; + + @Autowired + private AdminSettingsEdgeProcessor adminSettingsProcessor; + + @Autowired + private OtaPackageEdgeProcessor otaPackageEdgeProcessor; + + @Autowired + private QueueEdgeProcessor queueEdgeProcessor; + + @Autowired + private EdgeMsgConstructor edgeMsgConstructor; + + @Autowired + private EdgeEventStorageSettings edgeEventStorageSettings; + + @Autowired + private DbCallbackExecutorService dbCallbackExecutor; + + @Autowired + private GrpcCallbackExecutorService grpcCallbackExecutorService; +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeNotificationService.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeNotificationService.java new file mode 100644 index 0000000..ee36c80 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeNotificationService.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge; + +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.gen.transport.TransportProtos; + +public interface EdgeNotificationService { + + Edge setEdgeRootRuleChain(TenantId tenantId, Edge edge, RuleChainId ruleChainId) throws Exception; + + void pushNotificationToEdge(TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg, TbCallback callback); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java new file mode 100644 index 0000000..81c3ca6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Data +public class EdgeEventStorageSettings { + @Value("${edges.storage.max_read_records_count}") + private int maxReadRecordsCount; + @Value("${edges.storage.no_read_records_sleep}") + private long noRecordsSleepInterval; + @Value("${edges.storage.sleep_between_batches}") + private long sleepIntervalBetweenBatches; +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java new file mode 100644 index 0000000..fea89d6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -0,0 +1,426 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; +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.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.ResourceUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.state.DefaultDeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +@Service +@Slf4j +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); + private final Map sessionNewEvents = new HashMap<>(); + private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); + + private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); + + @Value("${edges.rpc.port}") + private int rpcPort; + @Value("${edges.rpc.ssl.enabled}") + private boolean sslEnabled; + @Value("${edges.rpc.ssl.cert}") + private String certFileResource; + @Value("${edges.rpc.ssl.private_key}") + private String privateKeyResource; + @Value("${edges.state.persistToTelemetry:false}") + private boolean persistToTelemetry; + @Value("${edges.rpc.client_max_keep_alive_time_sec}") + private int clientMaxKeepAliveTimeSec; + @Value("${edges.rpc.max_inbound_message_size:4194304}") + private int maxInboundMessageSize; + + @Value("${edges.scheduler_pool_size}") + private int schedulerPoolSize; + + @Value("${edges.send_scheduler_pool_size}") + private int sendSchedulerPoolSize; + + @Autowired + private EdgeContextComponent ctx; + + @Autowired + private TelemetrySubscriptionService tsSubService; + + @Autowired + private TbClusterService clusterService; + + private Server server; + + private ScheduledExecutorService edgeEventProcessingExecutorService; + + private ScheduledExecutorService sendDownlinkExecutorService; + + private ScheduledExecutorService executorService; + + @PostConstruct + public void init() { + log.info("Initializing Edge RPC service!"); + NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort) + .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) + .maxInboundMessageSize(maxInboundMessageSize) + .addService(this); + if (sslEnabled) { + try { + InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource); + InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource); + builder.useTransportSecurity(certFileIs, privateKeyFileIs); + } catch (Exception e) { + log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + throw new RuntimeException("Unable to set up SSL context!", e); + } + } + server = builder.build(); + log.info("Going to start Edge RPC server using port: {}", rpcPort); + try { + server.start(); + } catch (IOException e) { + log.error("Failed to start Edge RPC server!", e); + throw new RuntimeException("Failed to start Edge RPC server!"); + } + this.edgeEventProcessingExecutorService = Executors.newScheduledThreadPool(schedulerPoolSize, ThingsBoardThreadFactory.forName("edge-event-check-scheduler")); + this.sendDownlinkExecutorService = Executors.newScheduledThreadPool(sendSchedulerPoolSize, ThingsBoardThreadFactory.forName("edge-send-scheduler")); + this.executorService = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("edge-service")); + log.info("Edge RPC service initialized!"); + } + + @PreDestroy + public void destroy() { + if (server != null) { + server.shutdownNow(); + } + for (Map.Entry> entry : sessionEdgeEventChecks.entrySet()) { + EdgeId edgeId = entry.getKey(); + ScheduledFuture sessionEdgeEventCheck = entry.getValue(); + if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { + sessionEdgeEventCheck.cancel(true); + sessionEdgeEventChecks.remove(edgeId); + } + } + if (edgeEventProcessingExecutorService != null) { + edgeEventProcessingExecutorService.shutdownNow(); + } + if (sendDownlinkExecutorService != null) { + sendDownlinkExecutorService.shutdownNow(); + } + if (executorService != null) { + executorService.shutdownNow(); + } + } + + @Override + public StreamObserver handleMsgs(StreamObserver outputStream) { + return new EdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService).getInputStream(); + } + + @Override + public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) { + executorService.execute(() -> { + switch (msg.getMsgType()) { + case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG: + EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg; + log.trace("[{}] onToEdgeSessionMsg [{}]", edgeEventUpdateMsg.getTenantId(), msg); + onEdgeEvent(tenantId, edgeEventUpdateMsg.getEdgeId()); + break; + case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG: + ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg; + log.trace("[{}] toEdgeSyncRequest [{}]", toEdgeSyncRequest.getTenantId(), msg); + startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId()); + break; + case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG: + FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg; + log.trace("[{}] fromEdgeSyncResponse [{}]", fromEdgeSyncResponse.getTenantId(), msg); + processSyncResponse(fromEdgeSyncResponse); + break; + } + }); + } + + @Override + public void updateEdge(TenantId tenantId, Edge edge) { + executorService.execute(() -> { + EdgeGrpcSession session = sessions.get(edge.getId()); + if (session != null && session.isConnected()) { + log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); + session.onConfigurationUpdate(edge); + } else { + log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); + } + }); + } + + @Override + public void deleteEdge(TenantId tenantId, EdgeId edgeId) { + executorService.execute(() -> { + EdgeGrpcSession session = sessions.get(edgeId); + if (session != null && session.isConnected()) { + log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId); + session.close(); + sessions.remove(edgeId); + final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); + newEventLock.lock(); + try { + sessionNewEvents.remove(edgeId); + } finally { + newEventLock.unlock(); + } + cancelScheduleEdgeEventsCheck(edgeId); + } + }); + } + + private void onEdgeEvent(TenantId tenantId, EdgeId edgeId) { + EdgeGrpcSession session = sessions.get(edgeId); + if (session != null && session.isConnected()) { + log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId.getId()); + final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); + newEventLock.lock(); + try { + if (Boolean.FALSE.equals(sessionNewEvents.get(edgeId))) { + log.trace("[{}] set session new events flag to true [{}]", tenantId, edgeId.getId()); + sessionNewEvents.put(edgeId, true); + } + } finally { + newEventLock.unlock(); + } + } + } + + private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSession edgeGrpcSession) { + log.info("[{}] edge [{}] connected successfully.", edgeGrpcSession.getSessionId(), edgeId); + sessions.put(edgeId, edgeGrpcSession); + final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); + newEventLock.lock(); + try { + sessionNewEvents.put(edgeId, true); + } finally { + newEventLock.unlock(); + } + save(edgeId, DefaultDeviceStateService.ACTIVITY_STATE, true); + save(edgeId, DefaultDeviceStateService.LAST_CONNECT_TIME, System.currentTimeMillis()); + cancelScheduleEdgeEventsCheck(edgeId); + scheduleEdgeEventsCheck(edgeGrpcSession); + } + + private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId) { + EdgeGrpcSession session = sessions.get(edgeId); + if (session != null) { + boolean success = false; + if (session.isConnected()) { + session.startSyncProcess(tenantId, edgeId, true); + success = true; + } + clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success)); + } + } + + @Override + public void processSyncRequest(ToEdgeSyncRequest request, Consumer responseConsumer) { + log.trace("[{}][{}] Processing sync edge request [{}]", request.getTenantId(), request.getId(), request.getEdgeId()); + UUID requestId = request.getId(); + localSyncEdgeRequests.put(requestId, responseConsumer); + clusterService.pushEdgeSyncRequestToCore(request); + scheduleSyncRequestTimeout(request, requestId); + } + + private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) { + log.trace("[{}] scheduling sync edge request", requestId); + executorService.schedule(() -> { + log.trace("[{}] checking if sync edge request is not processed...", requestId); + Consumer consumer = localSyncEdgeRequests.remove(requestId); + if (consumer != null) { + log.trace("[{}] timeout for processing sync edge request.", requestId); + consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false)); + } + }, 20, TimeUnit.SECONDS); + } + + private void processSyncResponse(FromEdgeSyncResponse response) { + log.trace("[{}] Received response from sync service: [{}]", response.getId(), response); + UUID requestId = response.getId(); + Consumer consumer = localSyncEdgeRequests.remove(requestId); + if (consumer != null) { + consumer.accept(response); + } else { + log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response); + } + } + + private void scheduleEdgeEventsCheck(EdgeGrpcSession session) { + EdgeId edgeId = session.getEdge().getId(); + UUID tenantId = session.getEdge().getTenantId().getId(); + if (sessions.containsKey(edgeId)) { + ScheduledFuture edgeEventCheckTask = edgeEventProcessingExecutorService.schedule(() -> { + try { + final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); + newEventLock.lock(); + try { + if (Boolean.TRUE.equals(sessionNewEvents.get(edgeId))) { + log.trace("[{}] Set session new events flag to false", edgeId.getId()); + sessionNewEvents.put(edgeId, false); + Futures.addCallback(session.processEdgeEvents(), new FutureCallback<>() { + @Override + public void onSuccess(Void result) { + scheduleEdgeEventsCheck(session); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), t); + scheduleEdgeEventsCheck(session); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + scheduleEdgeEventsCheck(session); + } + } finally { + newEventLock.unlock(); + } + } catch (Exception e) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), e); + } + }, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS); + sessionEdgeEventChecks.put(edgeId, edgeEventCheckTask); + log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId()); + } else { + log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]", + tenantId, edgeId.getId()); + } + } + + private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) { + log.trace("[{}] cancelling edge event check for edge", edgeId); + if (sessionEdgeEventChecks.containsKey(edgeId)) { + ScheduledFuture sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId); + if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { + sessionEdgeEventCheck.cancel(true); + sessionEdgeEventChecks.remove(edgeId); + } + } + } + + private void onEdgeDisconnect(EdgeId edgeId) { + log.info("[{}] edge disconnected!", edgeId); + sessions.remove(edgeId); + final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); + newEventLock.lock(); + try { + sessionNewEvents.remove(edgeId); + } finally { + newEventLock.unlock(); + } + save(edgeId, DefaultDeviceStateService.ACTIVITY_STATE, false); + save(edgeId, DefaultDeviceStateService.LAST_DISCONNECT_TIME, System.currentTimeMillis()); + cancelScheduleEdgeEventsCheck(edgeId); + } + + private void save(EdgeId edgeId, String key, long value) { + log.debug("[{}] Updating long edge telemetry [{}] [{}]", edgeId, key, value); + if (persistToTelemetry) { + tsSubService.saveAndNotify( + TenantId.SYS_TENANT_ID, edgeId, + Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(key, value))), + new AttributeSaveCallback(edgeId, key, value)); + } else { + tsSubService.saveAttrAndNotify(TenantId.SYS_TENANT_ID, edgeId, DataConstants.SERVER_SCOPE, key, value, new AttributeSaveCallback(edgeId, key, value)); + } + } + + private void save(EdgeId edgeId, String key, boolean value) { + log.debug("[{}] Updating boolean edge telemetry [{}] [{}]", edgeId, key, value); + if (persistToTelemetry) { + tsSubService.saveAndNotify( + TenantId.SYS_TENANT_ID, edgeId, + Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(key, value))), + new AttributeSaveCallback(edgeId, key, value)); + } else { + tsSubService.saveAttrAndNotify(TenantId.SYS_TENANT_ID, edgeId, DataConstants.SERVER_SCOPE, key, value, new AttributeSaveCallback(edgeId, key, value)); + } + } + + private static class AttributeSaveCallback implements FutureCallback { + private final EdgeId edgeId; + private final String key; + private final Object value; + + AttributeSaveCallback(EdgeId edgeId, String key, Object value) { + this.edgeId = edgeId; + this.key = key; + this.value = value; + } + + @Override + public void onSuccess(@Nullable Void result) { + log.trace("[{}] Successfully updated attribute [{}] with value [{}]", edgeId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to update attribute [{}] with value [{}]", edgeId, key, value, t); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java new file mode 100644 index 0000000..28cc32e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -0,0 +1,694 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +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 io.grpc.stub.StreamObserver; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg; +import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; +import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; +import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg; +import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; +import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.EntityDataProto; +import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsgType; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; +import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher; + +import java.io.Closeable; +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 java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Slf4j +@Data +public final class EdgeGrpcSession implements Closeable { + + private static final ReentrantLock downlinkMsgLock = new ReentrantLock(); + + private static final int MAX_DOWNLINK_ATTEMPTS = 10; // max number of attemps to send downlink message if edge connected + + private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs"; + + private final UUID sessionId; + private final BiConsumer sessionOpenListener; + private final Consumer sessionCloseListener; + + private final EdgeSessionState sessionState = new EdgeSessionState(); + + private EdgeContextComponent ctx; + private Edge edge; + private StreamObserver inputStream; + private StreamObserver outputStream; + private boolean connected; + private boolean syncCompleted; + + private EdgeVersion edgeVersion; + + private ScheduledExecutorService sendDownlinkExecutorService; + + EdgeGrpcSession(EdgeContextComponent ctx, StreamObserver outputStream, BiConsumer sessionOpenListener, + Consumer sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService) { + this.sessionId = UUID.randomUUID(); + this.ctx = ctx; + this.outputStream = outputStream; + this.sessionOpenListener = sessionOpenListener; + this.sessionCloseListener = sessionCloseListener; + this.sendDownlinkExecutorService = sendDownlinkExecutorService; + initInputStream(); + } + + private void initInputStream() { + this.inputStream = new StreamObserver<>() { + @Override + public void onNext(RequestMsg requestMsg) { + if (!connected && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) { + ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg()); + outputStream.onNext(ResponseMsg.newBuilder() + .setConnectResponseMsg(responseMsg) + .build()); + if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) { + outputStream.onError(new RuntimeException(responseMsg.getErrorMsg())); + } else { + connected = true; + } + } + if (connected) { + if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) { + if (requestMsg.hasSyncRequestMsg() && requestMsg.getSyncRequestMsg().getSyncRequired()) { + boolean fullSync = true; + if (requestMsg.getSyncRequestMsg().hasFullSync()) { + fullSync = requestMsg.getSyncRequestMsg().getFullSync(); + } + startSyncProcess(edge.getTenantId(), edge.getId(), fullSync); + } else { + syncCompleted = true; + } + } + if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) { + if (requestMsg.hasUplinkMsg()) { + onUplinkMsg(requestMsg.getUplinkMsg()); + } + if (requestMsg.hasDownlinkResponseMsg()) { + onDownlinkResponse(requestMsg.getDownlinkResponseMsg()); + } + } + } + } + + @Override + public void onError(Throwable t) { + log.error("[{}] Stream was terminated due to error:", sessionId, t); + closeSession(); + } + + @Override + public void onCompleted() { + log.info("[{}] Stream was closed and completed successfully!", sessionId); + closeSession(); + } + + private void closeSession() { + connected = false; + if (edge != null) { + try { + sessionCloseListener.accept(edge.getId()); + } catch (Exception ignored) { + } + } + try { + outputStream.onCompleted(); + } catch (Exception ignored) { + } + } + }; + } + + public void startSyncProcess(TenantId tenantId, EdgeId edgeId, boolean fullSync) { + log.trace("[{}][{}] Staring edge sync process", tenantId, edgeId); + syncCompleted = false; + interruptGeneralProcessingOnSync(tenantId, edgeId); + doSync(new EdgeSyncCursor(ctx, edge, fullSync)); + } + + private void doSync(EdgeSyncCursor cursor) { + if (cursor.hasNext()) { + log.info("[{}][{}] starting sync process, cursor current idx = {}", edge.getTenantId(), edge.getId(), cursor.getCurrentIdx()); + ListenableFuture uuidListenableFuture = startProcessingEdgeEvents(cursor.getNext()); + Futures.addCallback(uuidListenableFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable UUID result) { + doSync(cursor); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Exception during sync process", edge.getTenantId(), edge.getId(), t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build()) + .build(); + Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback() { + @Override + public void onSuccess(Void result) { + syncCompleted = true; + ctx.getClusterService().onEdgeEventUpdate(edge.getTenantId(), edge.getId()); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Exception during sending sync complete", edge.getTenantId(), edge.getId(), t); + } + }, ctx.getGrpcCallbackExecutorService()); + } + } + + private void onUplinkMsg(UplinkMsg uplinkMsg) { + ListenableFuture> future = processUplinkMsg(uplinkMsg); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List result) { + UplinkResponseMsg uplinkResponseMsg = UplinkResponseMsg.newBuilder() + .setUplinkMsgId(uplinkMsg.getUplinkMsgId()) + .setSuccess(true).build(); + sendDownlinkMsg(ResponseMsg.newBuilder() + .setUplinkResponseMsg(uplinkResponseMsg) + .build()); + } + + @Override + public void onFailure(Throwable t) { + String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t); + UplinkResponseMsg uplinkResponseMsg = UplinkResponseMsg.newBuilder() + .setUplinkMsgId(uplinkMsg.getUplinkMsgId()) + .setSuccess(false).setErrorMsg(errorMsg).build(); + sendDownlinkMsg(ResponseMsg.newBuilder() + .setUplinkResponseMsg(uplinkResponseMsg) + .build()); + } + }, ctx.getGrpcCallbackExecutorService()); + } + + private void onDownlinkResponse(DownlinkResponseMsg msg) { + try { + if (msg.getSuccess()) { + sessionState.getPendingMsgsMap().remove(msg.getDownlinkMsgId()); + log.debug("[{}] Msg has been processed successfully!Msd Id: [{}], Msg: {}", edge.getRoutingKey(), msg.getDownlinkMsgId(), msg); + } else { + log.error("[{}] Msg processing failed! Msd Id: [{}], Error msg: {}", edge.getRoutingKey(), msg.getDownlinkMsgId(), msg.getErrorMsg()); + } + if (sessionState.getPendingMsgsMap().isEmpty()) { + log.debug("[{}] Pending msgs map is empty. Stopping current iteration", edge.getRoutingKey()); + stopCurrentSendDownlinkMsgsTask(null); + } + } catch (Exception e) { + log.error("[{}] Can't process downlink response message [{}]", this.sessionId, msg, e); + } + } + + private void sendDownlinkMsg(ResponseMsg downlinkMsg) { + log.trace("[{}] Sending downlink msg [{}]", this.sessionId, downlinkMsg); + if (isConnected()) { + downlinkMsgLock.lock(); + try { + outputStream.onNext(downlinkMsg); + } catch (Exception e) { + log.error("[{}] Failed to send downlink message [{}]", this.sessionId, downlinkMsg, e); + connected = false; + sessionCloseListener.accept(edge.getId()); + } finally { + downlinkMsgLock.unlock(); + } + log.trace("[{}] Response msg successfully sent [{}]", this.sessionId, downlinkMsg); + } + } + + void onConfigurationUpdate(Edge edge) { + log.debug("[{}] onConfigurationUpdate [{}]", this.sessionId, edge); + this.edge = edge; + EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder() + .setConfiguration(ctx.getEdgeMsgConstructor().constructEdgeConfiguration(edge)).build(); + ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder() + .setEdgeUpdateMsg(edgeConfig) + .build(); + sendDownlinkMsg(edgeConfigMsg); + } + + ListenableFuture processEdgeEvents() throws Exception { + SettableFuture result = SettableFuture.create(); + log.trace("[{}] starting processing edge events", this.sessionId); + if (isConnected() && isSyncCompleted()) { + Long queueStartTs = getQueueStartTs().get(); + GeneralEdgeEventFetcher fetcher = new GeneralEdgeEventFetcher( + queueStartTs, + ctx.getEdgeEventService()); + ListenableFuture ifOffsetFuture = startProcessingEdgeEvents(fetcher); + Futures.addCallback(ifOffsetFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable UUID ifOffset) { + if (ifOffset != null) { + Long newStartTs = Uuids.unixTimestamp(ifOffset); + ListenableFuture> updateFuture = updateQueueStartTs(newStartTs); + Futures.addCallback(updateFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List list) { + log.debug("[{}] queue offset was updated [{}][{}]", sessionId, ifOffset, newStartTs); + result.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to update queue offset [{}]", sessionId, ifOffset, t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.trace("[{}] ifOffset is null. Skipping iteration without db update", sessionId); + result.set(null); + } + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to process events", sessionId, t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.trace("[{}] edge is not connected or sync is not completed. Skipping iteration", sessionId); + result.set(null); + } + return result; + } + + private ListenableFuture startProcessingEdgeEvents(EdgeEventFetcher fetcher) { + SettableFuture result = SettableFuture.create(); + PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()); + processEdgeEvents(fetcher, pageLink, result); + return result; + } + + private void processEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture result) { + try { + PageData pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink); + if (isConnected() && !pageData.getData().isEmpty()) { + log.trace("[{}] [{}] event(s) are going to be processed.", this.sessionId, pageData.getData().size()); + List downlinkMsgsPack = convertToDownlinkMsgsPack(pageData.getData()); + Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + if (isConnected() && pageData.hasNext()) { + processEdgeEvents(fetcher, pageLink.nextPageLink(), result); + } else { + UUID ifOffset = pageData.getData().get(pageData.getData().size() - 1).getUuidId(); + result.set(ifOffset); + } + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to send downlink msgs pack", sessionId, t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.trace("[{}] no event(s) found. Stop processing edge events", this.sessionId); + result.set(null); + } + } catch (Exception e) { + log.error("[{}] Failed to fetch edge events", this.sessionId, e); + result.setException(e); + } + } + + private ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) { + interruptPreviousSendDownlinkMsgsTask(); + + sessionState.setSendDownlinkMsgsFuture(SettableFuture.create()); + sessionState.getPendingMsgsMap().clear(); + + downlinkMsgsPack.forEach(msg -> sessionState.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg)); + scheduleDownlinkMsgsPackSend(1); + + return sessionState.getSendDownlinkMsgsFuture(); + } + + private void scheduleDownlinkMsgsPackSend(int attempt) { + Runnable sendDownlinkMsgsTask = () -> { + try { + if (isConnected() && sessionState.getPendingMsgsMap().values().size() > 0) { + List copy = new ArrayList<>(sessionState.getPendingMsgsMap().values()); + if (attempt > 1) { + log.warn("[{}] Failed to deliver the batch: {}, attempt: {}", this.sessionId, copy, attempt); + } + log.trace("[{}] [{}] downlink msg(s) are going to be send.", this.sessionId, copy.size()); + for (DownlinkMsg downlinkMsg : copy) { + sendDownlinkMsg(ResponseMsg.newBuilder() + .setDownlinkMsg(downlinkMsg) + .build()); + } + if (attempt < MAX_DOWNLINK_ATTEMPTS) { + scheduleDownlinkMsgsPackSend(attempt + 1); + } else { + log.warn("[{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}", + this.sessionId, MAX_DOWNLINK_ATTEMPTS, copy); + stopCurrentSendDownlinkMsgsTask(null); + } + } else { + stopCurrentSendDownlinkMsgsTask(null); + } + } catch (Exception e) { + stopCurrentSendDownlinkMsgsTask(e); + } + }; + + if (attempt == 1) { + sendDownlinkExecutorService.submit(sendDownlinkMsgsTask); + } else { + sessionState.setScheduledSendDownlinkTask( + sendDownlinkExecutorService.schedule( + sendDownlinkMsgsTask, + ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(), + TimeUnit.MILLISECONDS) + ); + } + } + + private DownlinkMsg convertToDownlinkMsg(EdgeEvent edgeEvent) { + log.trace("[{}][{}] converting edge event to downlink msg [{}]", edge.getTenantId(), this.sessionId, edgeEvent); + DownlinkMsg downlinkMsg = null; + try { + switch (edgeEvent.getAction()) { + case UPDATED: + case ADDED: + case DELETED: + case ASSIGNED_TO_EDGE: + case UNASSIGNED_FROM_EDGE: + case ALARM_ACK: + case ALARM_CLEAR: + case CREDENTIALS_UPDATED: + case RELATION_ADD_OR_UPDATE: + case RELATION_DELETED: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + case CREDENTIALS_REQUEST: + case ENTITY_MERGE_REQUEST: + case RPC_CALL: + downlinkMsg = convertEntityEventToDownlink(edgeEvent); + log.trace("[{}][{}] entity message processed [{}]", edgeEvent.getTenantId(), this.sessionId, downlinkMsg); + break; + case ATTRIBUTES_UPDATED: + case POST_ATTRIBUTES: + case ATTRIBUTES_DELETED: + case TIMESERIES_UPDATED: + downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edgeEvent); + break; + default: + log.warn("[{}][{}] Unsupported action type [{}]", edge.getTenantId(), this.sessionId, edgeEvent.getAction()); + } + } catch (Exception e) { + log.error("[{}][{}] Exception during converting edge event to downlink msg", edge.getTenantId(), this.sessionId, e); + } + return downlinkMsg; + } + + private List convertToDownlinkMsgsPack(List edgeEvents) { + return edgeEvents + .stream() + .map(this::convertToDownlinkMsg) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private ListenableFuture getQueueStartTs() { + ListenableFuture> future = + ctx.getAttributesService().find(edge.getTenantId(), edge.getId(), DataConstants.SERVER_SCOPE, QUEUE_START_TS_ATTR_KEY); + return Futures.transform(future, attributeKvEntryOpt -> { + if (attributeKvEntryOpt != null && attributeKvEntryOpt.isPresent()) { + AttributeKvEntry attributeKvEntry = attributeKvEntryOpt.get(); + return attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L; + } else { + return 0L; + } + }, ctx.getGrpcCallbackExecutorService()); + } + + private ListenableFuture> updateQueueStartTs(Long newStartTs) { + log.trace("[{}] updating QueueStartTs [{}][{}]", this.sessionId, edge.getId(), newStartTs); + List attributes = Collections.singletonList( + new BaseAttributeKvEntry( + new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis())); + return ctx.getAttributesService().save(edge.getTenantId(), edge.getId(), DataConstants.SERVER_SCOPE, attributes); + } + + private DownlinkMsg convertEntityEventToDownlink(EdgeEvent edgeEvent) { + log.trace("Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent, edgeEvent.getAction()); + switch (edgeEvent.getType()) { + case EDGE: + return ctx.getEdgeProcessor().convertEdgeEventToDownlink(edgeEvent); + case DEVICE: + return ctx.getDeviceProcessor().convertDeviceEventToDownlink(edgeEvent); + case DEVICE_PROFILE: + return ctx.getDeviceProfileProcessor().convertDeviceProfileEventToDownlink(edgeEvent); + case ASSET_PROFILE: + return ctx.getAssetProfileProcessor().convertAssetProfileEventToDownlink(edgeEvent); + case ASSET: + return ctx.getAssetProcessor().convertAssetEventToDownlink(edgeEvent); + case ENTITY_VIEW: + return ctx.getEntityViewProcessor().convertEntityViewEventToDownlink(edgeEvent); + case DASHBOARD: + return ctx.getDashboardProcessor().convertDashboardEventToDownlink(edgeEvent); + case CUSTOMER: + return ctx.getCustomerProcessor().convertCustomerEventToDownlink(edgeEvent); + case RULE_CHAIN: + return ctx.getRuleChainProcessor().convertRuleChainEventToDownlink(edgeEvent); + case RULE_CHAIN_METADATA: + return ctx.getRuleChainProcessor().convertRuleChainMetadataEventToDownlink(edgeEvent, this.edgeVersion); + case ALARM: + return ctx.getAlarmProcessor().convertAlarmEventToDownlink(edgeEvent); + case USER: + return ctx.getUserProcessor().convertUserEventToDownlink(edgeEvent); + case RELATION: + return ctx.getRelationProcessor().convertRelationEventToDownlink(edgeEvent); + case WIDGETS_BUNDLE: + return ctx.getWidgetBundleProcessor().convertWidgetsBundleEventToDownlink(edgeEvent); + case WIDGET_TYPE: + return ctx.getWidgetTypeProcessor().convertWidgetTypeEventToDownlink(edgeEvent); + case ADMIN_SETTINGS: + return ctx.getAdminSettingsProcessor().convertAdminSettingsEventToDownlink(edgeEvent); + case OTA_PACKAGE: + return ctx.getOtaPackageEdgeProcessor().convertOtaPackageEventToDownlink(edgeEvent); + case QUEUE: + return ctx.getQueueEdgeProcessor().convertQueueEventToDownlink(edgeEvent); + default: + log.warn("Unsupported edge event type [{}]", edgeEvent); + return null; + } + } + + private ListenableFuture> processUplinkMsg(UplinkMsg uplinkMsg) { + List> result = new ArrayList<>(); + try { + if (uplinkMsg.getEntityDataCount() > 0) { + for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) { + result.addAll(ctx.getTelemetryProcessor().processTelemetryFromEdge(edge.getTenantId(), entityData)); + } + } + if (uplinkMsg.getDeviceUpdateMsgCount() > 0) { + for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceFromEdge(edge.getTenantId(), edge, deviceUpdateMsg)); + } + } + if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) { + for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceCredentialsFromEdge(edge.getTenantId(), deviceCredentialsUpdateMsg)); + } + } + if (uplinkMsg.getAlarmUpdateMsgCount() > 0) { + for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) { + result.add(ctx.getAlarmProcessor().processAlarmFromEdge(edge.getTenantId(), alarmUpdateMsg)); + } + } + if (uplinkMsg.getRelationUpdateMsgCount() > 0) { + for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) { + result.add(ctx.getRelationProcessor().processRelationFromEdge(edge.getTenantId(), relationUpdateMsg)); + } + } + if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) { + for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg)); + } + } + if (uplinkMsg.getAttributesRequestMsgCount() > 0) { + for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg)); + } + } + if (uplinkMsg.getRelationRequestMsgCount() > 0) { + for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg)); + } + } + if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) { + for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg)); + } + } + if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) { + for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg)); + } + } + if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) { + for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg)); + } + } + if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) { + for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg)); + } + } + if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) { + for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg)); + } + } + } catch (Exception e) { + log.error("[{}] Can't process uplink msg [{}]", this.sessionId, uplinkMsg, e); + return Futures.immediateFailedFuture(e); + } + return Futures.allAsList(result); + } + + private ConnectResponseMsg processConnect(ConnectRequestMsg request) { + log.trace("[{}] processConnect [{}]", this.sessionId, request); + Optional optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey()); + if (optional.isPresent()) { + edge = optional.get(); + try { + if (edge.getSecret().equals(request.getEdgeSecret())) { + sessionOpenListener.accept(edge.getId(), this); + this.edgeVersion = request.getEdgeVersion(); + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.ACCEPTED) + .setErrorMsg("") + .setConfiguration(ctx.getEdgeMsgConstructor().constructEdgeConfiguration(edge)).build(); + } + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) + .setErrorMsg("Failed to validate the edge!") + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } catch (Exception e) { + log.error("[{}] Failed to process edge connection!", request.getEdgeRoutingKey(), e); + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE) + .setErrorMsg("Failed to process edge connection!") + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } + } + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) + .setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey()) + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } + + @Override + public void close() { + log.debug("[{}] Closing session", sessionId); + connected = false; + try { + outputStream.onCompleted(); + } catch (Exception e) { + log.debug("[{}] Failed to close output stream: {}", sessionId, e.getMessage()); + } + } + + private void interruptPreviousSendDownlinkMsgsTask() { + String msg = String.format("[%s] Previous send downlink future was not properly completed, stopping it now!", this.sessionId); + stopCurrentSendDownlinkMsgsTask(new RuntimeException(msg)); + } + + private void interruptGeneralProcessingOnSync(TenantId tenantId, EdgeId edgeId) { + String msg = String.format("[%s][%s] Sync process started. General processing interrupted!", tenantId, edgeId); + stopCurrentSendDownlinkMsgsTask(new RuntimeException(msg)); + } + + public void stopCurrentSendDownlinkMsgsTask(Exception e) { + if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone()) { + if (e != null) { + log.warn(e.getMessage(), e); + sessionState.getSendDownlinkMsgsFuture().setException(e); + } else { + sessionState.getSendDownlinkMsgsFuture().set(null); + } + } + if (sessionState.getScheduledSendDownlinkTask() != null) { + sessionState.getScheduledSendDownlinkTask().cancel(true); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java new file mode 100644 index 0000000..6c55153 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; + +import java.util.function.Consumer; + +public interface EdgeRpcService { + + void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg); + + void updateEdge(TenantId tenantId, Edge edge); + + void deleteEdge(TenantId tenantId, EdgeId edgeId); + + void processSyncRequest(ToEdgeSyncRequest request, Consumer responseConsumer); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java new file mode 100644 index 0000000..5a3d8dc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import com.google.common.util.concurrent.SettableFuture; +import lombok.Data; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; + +@Data +public class EdgeSessionState { + + private final Map pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>()); + private SettableFuture sendDownlinkMsgsFuture; + private ScheduledFuture scheduledSendDownlinkTask; +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java new file mode 100644 index 0000000..e923258 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.rpc.fetch.AdminSettingsEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.AssetProfilesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.AssetsEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.CustomerEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.CustomerUsersEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.DashboardsEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.DeviceProfilesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.DevicesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.EntityViewsEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.OtaPackagesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.QueuesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.RuleChainsEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.SystemWidgetsBundlesEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.TenantAdminUsersEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.TenantWidgetsBundlesEdgeEventFetcher; + +import java.util.LinkedList; +import java.util.List; +import java.util.NoSuchElementException; + +public class EdgeSyncCursor { + + List fetchers = new LinkedList<>(); + + int currentIdx = 0; + + public EdgeSyncCursor(EdgeContextComponent ctx, Edge edge, boolean fullSync) { + if (fullSync) { + fetchers.add(new QueuesEdgeEventFetcher(ctx.getQueueService())); + fetchers.add(new RuleChainsEdgeEventFetcher(ctx.getRuleChainService())); + fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), ctx.getFreemarkerConfig())); + fetchers.add(new DeviceProfilesEdgeEventFetcher(ctx.getDeviceProfileService())); + fetchers.add(new AssetProfilesEdgeEventFetcher(ctx.getAssetProfileService())); + fetchers.add(new TenantAdminUsersEdgeEventFetcher(ctx.getUserService())); + if (edge.getCustomerId() != null && !EntityId.NULL_UUID.equals(edge.getCustomerId().getId())) { + fetchers.add(new CustomerEdgeEventFetcher()); + fetchers.add(new CustomerUsersEdgeEventFetcher(ctx.getUserService(), edge.getCustomerId())); + } + } + fetchers.add(new DevicesEdgeEventFetcher(ctx.getDeviceService())); + fetchers.add(new AssetsEdgeEventFetcher(ctx.getAssetService())); + fetchers.add(new EntityViewsEdgeEventFetcher(ctx.getEntityViewService())); + fetchers.add(new DashboardsEdgeEventFetcher(ctx.getDashboardService())); + if (fullSync) { + fetchers.add(new SystemWidgetsBundlesEdgeEventFetcher(ctx.getWidgetsBundleService())); + fetchers.add(new TenantWidgetsBundlesEdgeEventFetcher(ctx.getWidgetsBundleService())); + fetchers.add(new OtaPackagesEdgeEventFetcher(ctx.getOtaPackageService())); + } + } + + public boolean hasNext() { + return fetchers.size() > currentIdx; + } + + public EdgeEventFetcher getNext() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + EdgeEventFetcher edgeEventFetcher = fetchers.get(currentIdx); + currentIdx++; + return edgeEventFetcher; + } + + public int getCurrentIdx() { + return currentIdx; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AdminSettingsMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AdminSettingsMsgConstructor.java new file mode 100644 index 0000000..ee33dd6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AdminSettingsMsgConstructor.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class AdminSettingsMsgConstructor { + + public AdminSettingsUpdateMsg constructAdminSettingsUpdateMsg(AdminSettings adminSettings) { + AdminSettingsUpdateMsg.Builder builder = AdminSettingsUpdateMsg.newBuilder() + .setKey(adminSettings.getKey()) + .setJsonValue(JacksonUtil.toString(adminSettings.getJsonValue())); + if (adminSettings.getId() != null) { + builder.setIsSystem(true); + } + return builder.build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AlarmMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AlarmMsgConstructor.java new file mode 100644 index 0000000..e8f70e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AlarmMsgConstructor.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class AlarmMsgConstructor { + + @Autowired + private DeviceService deviceService; + + @Autowired + private AssetService assetService; + + @Autowired + private EntityViewService entityViewService; + + public AlarmUpdateMsg constructAlarmUpdatedMsg(TenantId tenantId, UpdateMsgType msgType, Alarm alarm) { + String entityName = null; + switch (alarm.getOriginator().getEntityType()) { + case DEVICE: + entityName = deviceService.findDeviceById(tenantId, new DeviceId(alarm.getOriginator().getId())).getName(); + break; + case ASSET: + entityName = assetService.findAssetById(tenantId, new AssetId(alarm.getOriginator().getId())).getName(); + break; + case ENTITY_VIEW: + entityName = entityViewService.findEntityViewById(tenantId, new EntityViewId(alarm.getOriginator().getId())).getName(); + break; + } + AlarmUpdateMsg.Builder builder = AlarmUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(alarm.getId().getId().getMostSignificantBits()) + .setIdLSB(alarm.getId().getId().getLeastSignificantBits()) + .setName(alarm.getName()) + .setType(alarm.getType()) + .setOriginatorName(entityName) + .setOriginatorType(alarm.getOriginator().getEntityType().name()) + .setSeverity(alarm.getSeverity().name()) + .setStatus(alarm.getStatus().name()) + .setStartTs(alarm.getStartTs()) + .setEndTs(alarm.getEndTs()) + .setAckTs(alarm.getAckTs()) + .setClearTs(alarm.getClearTs()) + .setDetails(JacksonUtil.toString(alarm.getDetails())) + .setPropagate(alarm.isPropagate()) + .setPropagateToOwner(alarm.isPropagateToOwner()) + .setPropagateToTenant(alarm.isPropagateToTenant()); + return builder.build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetMsgConstructor.java new file mode 100644 index 0000000..f8cff82 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetMsgConstructor.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class AssetMsgConstructor { + + public AssetUpdateMsg constructAssetUpdatedMsg(UpdateMsgType msgType, Asset asset) { + AssetUpdateMsg.Builder builder = AssetUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(asset.getUuidId().getMostSignificantBits()) + .setIdLSB(asset.getUuidId().getLeastSignificantBits()) + .setName(asset.getName()) + .setType(asset.getType()); + if (asset.getLabel() != null) { + builder.setLabel(asset.getLabel()); + } + if (asset.getCustomerId() != null) { + builder.setCustomerIdMSB(asset.getCustomerId().getId().getMostSignificantBits()); + builder.setCustomerIdLSB(asset.getCustomerId().getId().getLeastSignificantBits()); + } + if (asset.getAssetProfileId() != null) { + builder.setAssetProfileIdMSB(asset.getAssetProfileId().getId().getMostSignificantBits()); + builder.setAssetProfileIdLSB(asset.getAssetProfileId().getId().getLeastSignificantBits()); + } + if (asset.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(asset.getAdditionalInfo())); + } + return builder.build(); + } + + public AssetUpdateMsg constructAssetDeleteMsg(AssetId assetId) { + return AssetUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(assetId.getId().getMostSignificantBits()) + .setIdLSB(assetId.getId().getLeastSignificantBits()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetProfileMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetProfileMsgConstructor.java new file mode 100644 index 0000000..ec71217 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/AssetProfileMsgConstructor.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.google.protobuf.ByteString; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.nio.charset.StandardCharsets; + +@Component +@TbCoreComponent +public class AssetProfileMsgConstructor { + + public AssetProfileUpdateMsg constructAssetProfileUpdatedMsg(UpdateMsgType msgType, AssetProfile assetProfile) { + AssetProfileUpdateMsg.Builder builder = AssetProfileUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(assetProfile.getId().getId().getMostSignificantBits()) + .setIdLSB(assetProfile.getId().getId().getLeastSignificantBits()) + .setName(assetProfile.getName()) + .setDefault(assetProfile.isDefault()); + if (assetProfile.getDefaultDashboardId() != null) { + builder.setDefaultDashboardIdMSB(assetProfile.getDefaultDashboardId().getId().getMostSignificantBits()) + .setDefaultDashboardIdLSB(assetProfile.getDefaultDashboardId().getId().getLeastSignificantBits()); + } + if (assetProfile.getDefaultQueueName() != null) { + builder.setDefaultQueueName(assetProfile.getDefaultQueueName()); + } + if (assetProfile.getDescription() != null) { + builder.setDescription(assetProfile.getDescription()); + } + if (assetProfile.getImage() != null) { + builder.setImage(ByteString.copyFrom(assetProfile.getImage().getBytes(StandardCharsets.UTF_8))); + } + return builder.build(); + } + + public AssetProfileUpdateMsg constructAssetProfileDeleteMsg(AssetProfileId assetProfileId) { + return AssetProfileUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(assetProfileId.getId().getMostSignificantBits()) + .setIdLSB(assetProfileId.getId().getLeastSignificantBits()).build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/CustomerMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/CustomerMsgConstructor.java new file mode 100644 index 0000000..38eb357 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/CustomerMsgConstructor.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class CustomerMsgConstructor { + + public CustomerUpdateMsg constructCustomerUpdatedMsg(UpdateMsgType msgType, Customer customer) { + CustomerUpdateMsg.Builder builder = CustomerUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(customer.getId().getId().getMostSignificantBits()) + .setIdLSB(customer.getId().getId().getLeastSignificantBits()) + .setTitle(customer.getTitle()); + if (customer.getCountry() != null) { + builder.setCountry(customer.getCountry()); + } + if (customer.getState() != null) { + builder.setState(customer.getState()); + } + if (customer.getCity() != null) { + builder.setCity(customer.getCity()); + } + if (customer.getAddress() != null) { + builder.setAddress(customer.getAddress()); + } + if (customer.getAddress2() != null) { + builder.setAddress2(customer.getAddress2()); + } + if (customer.getZip() != null) { + builder.setZip(customer.getZip()); + } + if (customer.getPhone() != null) { + builder.setPhone(customer.getPhone()); + } + if (customer.getEmail() != null) { + builder.setEmail(customer.getEmail()); + } + if (customer.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(customer.getAdditionalInfo())); + } + return builder.build(); + } + + public CustomerUpdateMsg constructCustomerDeleteMsg(CustomerId customerId) { + return CustomerUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(customerId.getId().getMostSignificantBits()) + .setIdLSB(customerId.getId().getLeastSignificantBits()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DashboardMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DashboardMsgConstructor.java new file mode 100644 index 0000000..c61c5eb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DashboardMsgConstructor.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class DashboardMsgConstructor { + + public DashboardUpdateMsg constructDashboardUpdatedMsg(UpdateMsgType msgType, Dashboard dashboard) { + DashboardUpdateMsg.Builder builder = DashboardUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(dashboard.getId().getId().getMostSignificantBits()) + .setIdLSB(dashboard.getId().getId().getLeastSignificantBits()) + .setTitle(dashboard.getTitle()) + .setConfiguration(JacksonUtil.toString(dashboard.getConfiguration())); + if (dashboard.getAssignedCustomers() != null) { + builder.setAssignedCustomers(JacksonUtil.toString(dashboard.getAssignedCustomers())); + } + return builder.build(); + } + + public DashboardUpdateMsg constructDashboardDeleteMsg(DashboardId dashboardId) { + return DashboardUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(dashboardId.getId().getMostSignificantBits()) + .setIdLSB(dashboardId.getId().getLeastSignificantBits()).build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceMsgConstructor.java new file mode 100644 index 0000000..522bcd3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceMsgConstructor.java @@ -0,0 +1,151 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.protobuf.ByteString; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +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.gen.edge.v1.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; +import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RpcRequestMsg; +import org.thingsboard.server.gen.edge.v1.RpcResponseMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.UUID; + +@Component +@TbCoreComponent +public class DeviceMsgConstructor { + + @Autowired + private DataDecodingEncodingService dataDecodingEncodingService; + + public DeviceUpdateMsg constructDeviceUpdatedMsg(UpdateMsgType msgType, Device device, String conflictName) { + DeviceUpdateMsg.Builder builder = DeviceUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(device.getId().getId().getMostSignificantBits()) + .setIdLSB(device.getId().getId().getLeastSignificantBits()) + .setName(device.getName()) + .setType(device.getType()); + if (device.getLabel() != null) { + builder.setLabel(device.getLabel()); + } + if (device.getCustomerId() != null) { + builder.setCustomerIdMSB(device.getCustomerId().getId().getMostSignificantBits()); + builder.setCustomerIdLSB(device.getCustomerId().getId().getLeastSignificantBits()); + } + if (device.getDeviceProfileId() != null) { + builder.setDeviceProfileIdMSB(device.getDeviceProfileId().getId().getMostSignificantBits()); + builder.setDeviceProfileIdLSB(device.getDeviceProfileId().getId().getLeastSignificantBits()); + } + if (device.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(device.getAdditionalInfo())); + } + if (device.getFirmwareId() != null) { + builder.setFirmwareIdMSB(device.getFirmwareId().getId().getMostSignificantBits()) + .setFirmwareIdLSB(device.getFirmwareId().getId().getLeastSignificantBits()); + } + if (conflictName != null) { + builder.setConflictName(conflictName); + } + if (device.getDeviceData() != null) { + builder.setDeviceDataBytes(ByteString.copyFrom(dataDecodingEncodingService.encode(device.getDeviceData()))); + } + return builder.build(); + } + + public DeviceCredentialsUpdateMsg constructDeviceCredentialsUpdatedMsg(DeviceCredentials deviceCredentials) { + DeviceCredentialsUpdateMsg.Builder builder = DeviceCredentialsUpdateMsg.newBuilder() + .setDeviceIdMSB(deviceCredentials.getDeviceId().getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceCredentials.getDeviceId().getId().getLeastSignificantBits()); + if (deviceCredentials.getCredentialsType() != null) { + builder.setCredentialsType(deviceCredentials.getCredentialsType().name()) + .setCredentialsId(deviceCredentials.getCredentialsId()); + } + if (deviceCredentials.getCredentialsValue() != null) { + builder.setCredentialsValue(deviceCredentials.getCredentialsValue()); + } + return builder.build(); + } + + public DeviceUpdateMsg constructDeviceDeleteMsg(DeviceId deviceId) { + return DeviceUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(deviceId.getId().getMostSignificantBits()) + .setIdLSB(deviceId.getId().getLeastSignificantBits()).build(); + } + + public DeviceRpcCallMsg constructDeviceRpcCallMsg(UUID deviceId, JsonNode body) { + DeviceRpcCallMsg.Builder builder = constructDeviceRpcMsg(deviceId, body); + if (body.has("error") || body.has("response")) { + RpcResponseMsg.Builder responseBuilder = RpcResponseMsg.newBuilder(); + if (body.has("error")) { + responseBuilder.setError(body.get("error").asText()); + } else { + responseBuilder.setResponse(body.get("response").asText()); + } + builder.setResponseMsg(responseBuilder.build()); + } else { + RpcRequestMsg.Builder requestBuilder = RpcRequestMsg.newBuilder(); + requestBuilder.setMethod(body.get("method").asText()); + requestBuilder.setParams(body.get("params").asText()); + builder.setRequestMsg(requestBuilder.build()); + } + return builder.build(); + } + + private DeviceRpcCallMsg.Builder constructDeviceRpcMsg(UUID deviceId, JsonNode body) { + DeviceRpcCallMsg.Builder builder = DeviceRpcCallMsg.newBuilder() + .setDeviceIdMSB(deviceId.getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getLeastSignificantBits()) + .setRequestId(body.get("requestId").asInt()); + if (body.get("oneway") != null) { + builder.setOneway(body.get("oneway").asBoolean()); + } + if (body.get("requestUUID") != null) { + UUID requestUUID = UUID.fromString(body.get("requestUUID").asText()); + builder.setRequestUuidMSB(requestUUID.getMostSignificantBits()) + .setRequestUuidLSB(requestUUID.getLeastSignificantBits()); + } + if (body.get("expirationTime") != null) { + builder.setExpirationTime(body.get("expirationTime").asLong()); + } + if (body.get("persisted") != null) { + builder.setPersisted(body.get("persisted").asBoolean()); + } + if (body.get("retries") != null) { + builder.setRetries(body.get("retries").asInt()); + } + if (body.get("additionalInfo") != null) { + builder.setAdditionalInfo(JacksonUtil.toString(body.get("additionalInfo"))); + } + if (body.get("serviceId") != null) { + builder.setServiceId(body.get("serviceId").asText()); + } + if (body.get("sessionId") != null) { + builder.setSessionId(body.get("sessionId").asText()); + } + return builder; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java new file mode 100644 index 0000000..7865adb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.google.protobuf.ByteString; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.nio.charset.StandardCharsets; + +@Component +@TbCoreComponent +public class DeviceProfileMsgConstructor { + + @Autowired + private DataDecodingEncodingService dataDecodingEncodingService; + + public DeviceProfileUpdateMsg constructDeviceProfileUpdatedMsg(UpdateMsgType msgType, DeviceProfile deviceProfile) { + DeviceProfileUpdateMsg.Builder builder = DeviceProfileUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(deviceProfile.getId().getId().getMostSignificantBits()) + .setIdLSB(deviceProfile.getId().getId().getLeastSignificantBits()) + .setName(deviceProfile.getName()) + .setDefault(deviceProfile.isDefault()) + .setType(deviceProfile.getType().name()) + .setProfileDataBytes(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile.getProfileData()))); + if (deviceProfile.getDefaultQueueName() != null) { + builder.setDefaultQueueName(deviceProfile.getDefaultQueueName()); + } + if (deviceProfile.getDescription() != null) { + builder.setDescription(deviceProfile.getDescription()); + } + if (deviceProfile.getTransportType() != null) { + builder.setTransportType(deviceProfile.getTransportType().name()); + } + if (deviceProfile.getProvisionType() != null) { + builder.setProvisionType(deviceProfile.getProvisionType().name()); + } + if (deviceProfile.getProvisionDeviceKey() != null) { + builder.setProvisionDeviceKey(deviceProfile.getProvisionDeviceKey()); + } + if (deviceProfile.getImage() != null) { + builder.setImage(ByteString.copyFrom(deviceProfile.getImage().getBytes(StandardCharsets.UTF_8))); + } + if (deviceProfile.getFirmwareId() != null) { + builder.setFirmwareIdMSB(deviceProfile.getFirmwareId().getId().getMostSignificantBits()) + .setFirmwareIdLSB(deviceProfile.getFirmwareId().getId().getLeastSignificantBits()); + } + return builder.build(); + } + + public DeviceProfileUpdateMsg constructDeviceProfileDeleteMsg(DeviceProfileId deviceProfileId) { + return DeviceProfileUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(deviceProfileId.getId().getMostSignificantBits()) + .setIdLSB(deviceProfileId.getId().getLeastSignificantBits()).build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EdgeMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EdgeMsgConstructor.java new file mode 100644 index 0000000..5501217 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EdgeMsgConstructor.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class EdgeMsgConstructor { + + public EdgeConfiguration constructEdgeConfiguration(Edge edge) { + EdgeConfiguration.Builder builder = EdgeConfiguration.newBuilder() + .setEdgeIdMSB(edge.getId().getId().getMostSignificantBits()) + .setEdgeIdLSB(edge.getId().getId().getLeastSignificantBits()) + .setTenantIdMSB(edge.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(edge.getTenantId().getId().getLeastSignificantBits()) + .setName(edge.getName()) + .setType(edge.getType()) + .setRoutingKey(edge.getRoutingKey()) + .setSecret(edge.getSecret()) + .setAdditionalInfo(JacksonUtil.toString(edge.getAdditionalInfo())) + .setCloudType("CE"); + if (edge.getCustomerId() != null) { + builder.setCustomerIdMSB(edge.getCustomerId().getId().getMostSignificantBits()) + .setCustomerIdLSB(edge.getCustomerId().getId().getLeastSignificantBits()); + } + return builder.build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java new file mode 100644 index 0000000..e2d3735 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.reflect.TypeToken; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.gen.edge.v1.AttributeDeleteMsg; +import org.thingsboard.server.gen.edge.v1.EntityDataProto; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.List; + +@Component +@Slf4j +@TbCoreComponent +public class EntityDataMsgConstructor { + + public EntityDataProto constructEntityDataMsg(EntityId entityId, EdgeEventActionType actionType, JsonElement entityData) { + EntityDataProto.Builder builder = EntityDataProto.newBuilder() + .setEntityIdMSB(entityId.getId().getMostSignificantBits()) + .setEntityIdLSB(entityId.getId().getLeastSignificantBits()) + .setEntityType(entityId.getEntityType().name()); + switch (actionType) { + case TIMESERIES_UPDATED: + try { + JsonObject data = entityData.getAsJsonObject(); + long ts; + if (data.get("ts") != null && !data.get("ts").isJsonNull()) { + ts = data.getAsJsonPrimitive("ts").getAsLong(); + } else { + ts = System.currentTimeMillis(); + } + builder.setPostTelemetryMsg(JsonConverter.convertToTelemetryProto(data.getAsJsonObject("data"), ts)); + } catch (Exception e) { + log.warn("[{}] Can't convert to telemetry proto, entityData [{}]", entityId, entityData, e); + } + break; + case ATTRIBUTES_UPDATED: + try { + JsonObject data = entityData.getAsJsonObject(); + TransportProtos.PostAttributeMsg attributesUpdatedMsg = JsonConverter.convertToAttributesProto(data.getAsJsonObject("kv")); + builder.setAttributesUpdatedMsg(attributesUpdatedMsg); + builder.setPostAttributeScope(getScopeOfDefault(data)); + } catch (Exception e) { + log.warn("[{}] Can't convert to AttributesUpdatedMsg proto, entityData [{}]", entityId, entityData, e); + } + break; + case POST_ATTRIBUTES: + try { + JsonObject data = entityData.getAsJsonObject(); + TransportProtos.PostAttributeMsg postAttributesMsg = JsonConverter.convertToAttributesProto(data.getAsJsonObject("kv")); + builder.setPostAttributesMsg(postAttributesMsg); + builder.setPostAttributeScope(getScopeOfDefault(data)); + } catch (Exception e) { + log.warn("[{}] Can't convert to PostAttributesMsg, entityData [{}]", entityId, entityData, e); + } + break; + case ATTRIBUTES_DELETED: + try { + AttributeDeleteMsg.Builder attributeDeleteMsg = AttributeDeleteMsg.newBuilder(); + attributeDeleteMsg.setScope(entityData.getAsJsonObject().getAsJsonPrimitive("scope").getAsString()); + JsonArray jsonArray = entityData.getAsJsonObject().getAsJsonArray("keys"); + List keys = new Gson().fromJson(jsonArray.toString(), new TypeToken<>(){}.getType()); + attributeDeleteMsg.addAllAttributeNames(keys); + attributeDeleteMsg.build(); + builder.setAttributeDeleteMsg(attributeDeleteMsg); + } catch (Exception e) { + log.warn("[{}] Can't convert to AttributeDeleteMsg proto, entityData [{}]", entityId, entityData, e); + } + break; + } + return builder.build(); + } + + private String getScopeOfDefault(JsonObject data) { + JsonPrimitive scope = data.getAsJsonPrimitive("scope"); + String result = DataConstants.SERVER_SCOPE; + if (scope != null && StringUtils.isNotBlank(scope.getAsString())) { + result = scope.getAsString(); + } + return result; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityViewMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityViewMsgConstructor.java new file mode 100644 index 0000000..d5aa797 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityViewMsgConstructor.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.gen.edge.v1.EdgeEntityType; +import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class EntityViewMsgConstructor { + + public EntityViewUpdateMsg constructEntityViewUpdatedMsg(UpdateMsgType msgType, EntityView entityView) { + EdgeEntityType entityType; + switch (entityView.getEntityId().getEntityType()) { + case DEVICE: + entityType = EdgeEntityType.DEVICE; + break; + case ASSET: + entityType = EdgeEntityType.ASSET; + break; + default: + throw new RuntimeException("Unsupported entity type [" + entityView.getEntityId().getEntityType() + "]"); + } + EntityViewUpdateMsg.Builder builder = EntityViewUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(entityView.getId().getId().getMostSignificantBits()) + .setIdLSB(entityView.getId().getId().getLeastSignificantBits()) + .setName(entityView.getName()) + .setType(entityView.getType()) + .setEntityIdMSB(entityView.getEntityId().getId().getMostSignificantBits()) + .setEntityIdLSB(entityView.getEntityId().getId().getLeastSignificantBits()) + .setEntityType(entityType); + if (entityView.getCustomerId() != null) { + builder.setCustomerIdMSB(entityView.getCustomerId().getId().getMostSignificantBits()); + builder.setCustomerIdLSB(entityView.getCustomerId().getId().getLeastSignificantBits()); + } + if (entityView.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(entityView.getAdditionalInfo())); + } + return builder.build(); + } + + public EntityViewUpdateMsg constructEntityViewDeleteMsg(EntityViewId entityViewId) { + return EntityViewUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(entityViewId.getId().getMostSignificantBits()) + .setIdLSB(entityViewId.getId().getLeastSignificantBits()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/OtaPackageMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/OtaPackageMsgConstructor.java new file mode 100644 index 0000000..35a1a6e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/OtaPackageMsgConstructor.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.google.protobuf.ByteString; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.gen.edge.v1.OtaPackageUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class OtaPackageMsgConstructor { + + public OtaPackageUpdateMsg constructOtaPackageUpdatedMsg(UpdateMsgType msgType, OtaPackage otaPackage) { + OtaPackageUpdateMsg.Builder builder = OtaPackageUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(otaPackage.getId().getId().getMostSignificantBits()) + .setIdLSB(otaPackage.getId().getId().getLeastSignificantBits()) + .setType(otaPackage.getType().name()) + .setTitle(otaPackage.getTitle()) + .setVersion(otaPackage.getVersion()) + .setTag(otaPackage.getTag()); + + if (otaPackage.getDeviceProfileId() != null) { + builder.setDeviceProfileIdMSB(otaPackage.getDeviceProfileId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(otaPackage.getDeviceProfileId().getId().getLeastSignificantBits()); + } + + if (otaPackage.getUrl() != null) { + builder.setUrl(otaPackage.getUrl()); + } + if (otaPackage.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(otaPackage.getAdditionalInfo())); + } + if (otaPackage.getFileName() != null) { + builder.setFileName(otaPackage.getFileName()); + } + if (otaPackage.getContentType() != null) { + builder.setContentType(otaPackage.getContentType()); + } + if (otaPackage.getChecksumAlgorithm() != null) { + builder.setChecksumAlgorithm(otaPackage.getChecksumAlgorithm().name()); + } + if (otaPackage.getChecksum() != null) { + builder.setChecksum(otaPackage.getChecksum()); + } + if (otaPackage.getDataSize() != null) { + builder.setDataSize(otaPackage.getDataSize()); + } + if (otaPackage.getData() != null) { + builder.setData(ByteString.copyFrom(otaPackage.getData().array())); + } + return builder.build(); + } + + public OtaPackageUpdateMsg constructOtaPackageDeleteMsg(OtaPackageId otaPackageId) { + return OtaPackageUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(otaPackageId.getId().getMostSignificantBits()) + .setIdLSB(otaPackageId.getId().getLeastSignificantBits()).build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/QueueMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/QueueMsgConstructor.java new file mode 100644 index 0000000..85362ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/QueueMsgConstructor.java @@ -0,0 +1,75 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.gen.edge.v1.ProcessingStrategyProto; +import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; +import org.thingsboard.server.gen.edge.v1.SubmitStrategyProto; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class QueueMsgConstructor { + + public QueueUpdateMsg constructQueueUpdatedMsg(UpdateMsgType msgType, Queue queue) { + QueueUpdateMsg.Builder builder = QueueUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(queue.getId().getId().getMostSignificantBits()) + .setIdLSB(queue.getId().getId().getLeastSignificantBits()) + .setTenantIdMSB(queue.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(queue.getTenantId().getId().getLeastSignificantBits()) + .setName(queue.getName()) + .setTopic(queue.getTopic()) + .setPollInterval(queue.getPollInterval()) + .setPartitions(queue.getPartitions()) + .setConsumerPerPartition(queue.isConsumerPerPartition()) + .setPackProcessingTimeout(queue.getPackProcessingTimeout()) + .setSubmitStrategy(createSubmitStrategyProto(queue.getSubmitStrategy())) + .setProcessingStrategy(createProcessingStrategyProto(queue.getProcessingStrategy())); + return builder.build(); + } + + private ProcessingStrategyProto createProcessingStrategyProto(ProcessingStrategy processingStrategy) { + return ProcessingStrategyProto.newBuilder() + .setType(processingStrategy.getType().name()) + .setRetries(processingStrategy.getRetries()) + .setFailurePercentage(processingStrategy.getFailurePercentage()) + .setPauseBetweenRetries(processingStrategy.getPauseBetweenRetries()) + .setMaxPauseBetweenRetries(processingStrategy.getMaxPauseBetweenRetries()) + .build(); + } + + private SubmitStrategyProto createSubmitStrategyProto(SubmitStrategy submitStrategy) { + return SubmitStrategyProto.newBuilder() + .setType(submitStrategy.getType().name()) + .setBatchSize(submitStrategy.getBatchSize()) + .build(); + } + + public QueueUpdateMsg constructQueueDeleteMsg(QueueId queueId) { + return QueueUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(queueId.getId().getMostSignificantBits()) + .setIdLSB(queueId.getId().getLeastSignificantBits()).build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RelationMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RelationMsgConstructor.java new file mode 100644 index 0000000..c445dea --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RelationMsgConstructor.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class RelationMsgConstructor { + + public RelationUpdateMsg constructRelationUpdatedMsg(UpdateMsgType msgType, EntityRelation entityRelation) { + RelationUpdateMsg.Builder builder = RelationUpdateMsg.newBuilder() + .setMsgType(msgType) + .setFromIdMSB(entityRelation.getFrom().getId().getMostSignificantBits()) + .setFromIdLSB(entityRelation.getFrom().getId().getLeastSignificantBits()) + .setFromEntityType(entityRelation.getFrom().getEntityType().name()) + .setToIdMSB(entityRelation.getTo().getId().getMostSignificantBits()) + .setToIdLSB(entityRelation.getTo().getId().getLeastSignificantBits()) + .setToEntityType(entityRelation.getTo().getEntityType().name()) + .setType(entityRelation.getType()); + if (entityRelation.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(entityRelation.getAdditionalInfo())); + } + if (entityRelation.getTypeGroup() != null) { + builder.setTypeGroup(entityRelation.getTypeGroup().name()); + } + return builder.build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructor.java new file mode 100644 index 0000000..7f5ae72 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.rpc.constructor.rule.RuleChainMetadataConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.rule.RuleChainMetadataConstructorFactory; + +@Component +@Slf4j +@TbCoreComponent +public class RuleChainMsgConstructor { + + public RuleChainUpdateMsg constructRuleChainUpdatedMsg(UpdateMsgType msgType, RuleChain ruleChain, boolean isRoot) { + RuleChainUpdateMsg.Builder builder = RuleChainUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(ruleChain.getId().getId().getMostSignificantBits()) + .setIdLSB(ruleChain.getId().getId().getLeastSignificantBits()) + .setName(ruleChain.getName()) + .setRoot(isRoot) + .setDebugMode(ruleChain.isDebugMode()) + .setConfiguration(JacksonUtil.toString(ruleChain.getConfiguration())); + if (ruleChain.getFirstRuleNodeId() != null) { + builder.setFirstRuleNodeIdMSB(ruleChain.getFirstRuleNodeId().getId().getMostSignificantBits()) + .setFirstRuleNodeIdLSB(ruleChain.getFirstRuleNodeId().getId().getLeastSignificantBits()); + } + return builder.build(); + } + + public RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + UpdateMsgType msgType, + RuleChainMetaData ruleChainMetaData, + EdgeVersion edgeVersion) { + RuleChainMetadataConstructor ruleChainMetadataConstructor + = RuleChainMetadataConstructorFactory.getByEdgeVersion(edgeVersion); + return ruleChainMetadataConstructor.constructRuleChainMetadataUpdatedMsg(tenantId, msgType, ruleChainMetaData); + } + + public RuleChainUpdateMsg constructRuleChainDeleteMsg(RuleChainId ruleChainId) { + return RuleChainUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(ruleChainId.getId().getMostSignificantBits()) + .setIdLSB(ruleChainId.getId().getLeastSignificantBits()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/UserMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/UserMsgConstructor.java new file mode 100644 index 0000000..625b555 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/UserMsgConstructor.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class UserMsgConstructor { + + public UserUpdateMsg constructUserUpdatedMsg(UpdateMsgType msgType, User user) { + UserUpdateMsg.Builder builder = UserUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(user.getId().getId().getMostSignificantBits()) + .setIdLSB(user.getId().getId().getLeastSignificantBits()) + .setEmail(user.getEmail()) + .setAuthority(user.getAuthority().name()); + if (user.getCustomerId() != null) { + builder.setCustomerIdMSB(user.getCustomerId().getId().getMostSignificantBits()); + builder.setCustomerIdLSB(user.getCustomerId().getId().getLeastSignificantBits()); + } + if (user.getFirstName() != null) { + builder.setFirstName(user.getFirstName()); + } + if (user.getLastName() != null) { + builder.setLastName(user.getLastName()); + } + if (user.getAdditionalInfo() != null) { + builder.setAdditionalInfo(JacksonUtil.toString(user.getAdditionalInfo())); + } + return builder.build(); + } + + public UserUpdateMsg constructUserDeleteMsg(UserId userId) { + return UserUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(userId.getId().getMostSignificantBits()) + .setIdLSB(userId.getId().getLeastSignificantBits()).build(); + } + + public UserCredentialsUpdateMsg constructUserCredentialsUpdatedMsg(UserCredentials userCredentials) { + UserCredentialsUpdateMsg.Builder builder = UserCredentialsUpdateMsg.newBuilder() + .setUserIdMSB(userCredentials.getUserId().getId().getMostSignificantBits()) + .setUserIdLSB(userCredentials.getUserId().getId().getLeastSignificantBits()) + .setEnabled(userCredentials.isEnabled()) + .setPassword(userCredentials.getPassword()); + return builder.build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetTypeMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetTypeMsgConstructor.java new file mode 100644 index 0000000..ef95577 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetTypeMsgConstructor.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.WidgetTypeUpdateMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@TbCoreComponent +public class WidgetTypeMsgConstructor { + + public WidgetTypeUpdateMsg constructWidgetTypeUpdateMsg(UpdateMsgType msgType, WidgetTypeDetails widgetTypeDetails) { + WidgetTypeUpdateMsg.Builder builder = WidgetTypeUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(widgetTypeDetails.getId().getId().getMostSignificantBits()) + .setIdLSB(widgetTypeDetails.getId().getId().getLeastSignificantBits()); + if (widgetTypeDetails.getBundleAlias() != null) { + builder.setBundleAlias(widgetTypeDetails.getBundleAlias()); + } + if (widgetTypeDetails.getAlias() != null) { + builder.setAlias(widgetTypeDetails.getAlias()); + } + if (widgetTypeDetails.getName() != null) { + builder.setName(widgetTypeDetails.getName()); + } + if (widgetTypeDetails.getDescriptor() != null) { + builder.setDescriptorJson(JacksonUtil.toString(widgetTypeDetails.getDescriptor())); + } + if (widgetTypeDetails.getTenantId().equals(TenantId.SYS_TENANT_ID)) { + builder.setIsSystem(true); + } + if (widgetTypeDetails.getImage() != null) { + builder.setImage(widgetTypeDetails.getImage()); + } + if (widgetTypeDetails.getDescription() != null) { + builder.setDescription(widgetTypeDetails.getDescription()); + } + return builder.build(); + } + + public WidgetTypeUpdateMsg constructWidgetTypeDeleteMsg(WidgetTypeId widgetTypeId) { + return WidgetTypeUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(widgetTypeId.getId().getMostSignificantBits()) + .setIdLSB(widgetTypeId.getId().getLeastSignificantBits()) + .build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetsBundleMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetsBundleMsgConstructor.java new file mode 100644 index 0000000..33ce6e6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/WidgetsBundleMsgConstructor.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor; + +import com.google.protobuf.ByteString; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.WidgetsBundleUpdateMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.nio.charset.StandardCharsets; + +@Component +@TbCoreComponent +public class WidgetsBundleMsgConstructor { + + public WidgetsBundleUpdateMsg constructWidgetsBundleUpdateMsg(UpdateMsgType msgType, WidgetsBundle widgetsBundle) { + WidgetsBundleUpdateMsg.Builder builder = WidgetsBundleUpdateMsg.newBuilder() + .setMsgType(msgType) + .setIdMSB(widgetsBundle.getId().getId().getMostSignificantBits()) + .setIdLSB(widgetsBundle.getId().getId().getLeastSignificantBits()) + .setTitle(widgetsBundle.getTitle()) + .setAlias(widgetsBundle.getAlias()); + if (widgetsBundle.getImage() != null) { + builder.setImage(ByteString.copyFrom(widgetsBundle.getImage().getBytes(StandardCharsets.UTF_8))); + } + if (widgetsBundle.getDescription() != null) { + builder.setDescription(widgetsBundle.getDescription()); + } + if (widgetsBundle.getTenantId().equals(TenantId.SYS_TENANT_ID)) { + builder.setIsSystem(true); + } + return builder.build(); + } + + public WidgetsBundleUpdateMsg constructWidgetsBundleDeleteMsg(WidgetsBundleId widgetsBundleId) { + return WidgetsBundleUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(widgetsBundleId.getId().getMostSignificantBits()) + .setIdLSB(widgetsBundleId.getId().getLeastSignificantBits()) + .build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/AbstractRuleChainMetadataConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/AbstractRuleChainMetadataConstructor.java new file mode 100644 index 0000000..8fb3292 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/AbstractRuleChainMetadataConstructor.java @@ -0,0 +1,137 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor.rule; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.gen.edge.v1.NodeConnectionInfoProto; +import org.thingsboard.server.gen.edge.v1.RuleChainConnectionInfoProto; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleNodeProto; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableSet; + +@Slf4j +@AllArgsConstructor +public abstract class AbstractRuleChainMetadataConstructor implements RuleChainMetadataConstructor { + + @Override + public RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + UpdateMsgType msgType, + RuleChainMetaData ruleChainMetaData) { + try { + RuleChainMetadataUpdateMsg.Builder builder = RuleChainMetadataUpdateMsg.newBuilder(); + builder.setRuleChainIdMSB(ruleChainMetaData.getRuleChainId().getId().getMostSignificantBits()) + .setRuleChainIdLSB(ruleChainMetaData.getRuleChainId().getId().getLeastSignificantBits()); + constructRuleChainMetadataUpdatedMsg(tenantId, builder, ruleChainMetaData); + builder.setMsgType(msgType); + return builder.build(); + } catch (JsonProcessingException ex) { + log.error("Can't construct RuleChainMetadataUpdateMsg", ex); + } + return null; + } + + protected abstract void constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + RuleChainMetadataUpdateMsg.Builder builder, + RuleChainMetaData ruleChainMetaData) throws JsonProcessingException; + + protected List constructConnections(List connections) { + List result = new ArrayList<>(); + if (connections != null && !connections.isEmpty()) { + for (NodeConnectionInfo connection : connections) { + result.add(constructConnection(connection)); + } + } + return result; + } + + private NodeConnectionInfoProto constructConnection(NodeConnectionInfo connection) { + return NodeConnectionInfoProto.newBuilder() + .setFromIndex(connection.getFromIndex()) + .setToIndex(connection.getToIndex()) + .setType(connection.getType()) + .build(); + } + + protected List constructNodes(List nodes) throws JsonProcessingException { + List result = new ArrayList<>(); + if (nodes != null && !nodes.isEmpty()) { + for (RuleNode node : nodes) { + result.add(constructNode(node)); + } + } + return result; + } + + private RuleNodeProto constructNode(RuleNode node) throws JsonProcessingException { + return RuleNodeProto.newBuilder() + .setIdMSB(node.getId().getId().getMostSignificantBits()) + .setIdLSB(node.getId().getId().getLeastSignificantBits()) + .setType(node.getType()) + .setName(node.getName()) + .setDebugMode(node.isDebugMode()) + .setConfiguration(JacksonUtil.OBJECT_MAPPER.writeValueAsString(node.getConfiguration())) + .setAdditionalInfo(JacksonUtil.OBJECT_MAPPER.writeValueAsString(node.getAdditionalInfo())) + .build(); + } + + protected List constructRuleChainConnections(List ruleChainConnections, + NavigableSet removedNodeIndexes) throws JsonProcessingException { + List result = new ArrayList<>(); + if (ruleChainConnections != null && !ruleChainConnections.isEmpty()) { + for (RuleChainConnectionInfo ruleChainConnectionInfo : ruleChainConnections) { + if (!removedNodeIndexes.isEmpty()) { // 3_3_0 only + int fromIndex = ruleChainConnectionInfo.getFromIndex(); + // decrease index because of removed nodes + for (Integer removedIndex : removedNodeIndexes) { + if (fromIndex > removedIndex) { + fromIndex = fromIndex - 1; + } + } + ruleChainConnectionInfo.setFromIndex(fromIndex); + ObjectNode additionalInfo = (ObjectNode) ruleChainConnectionInfo.getAdditionalInfo(); + if (additionalInfo.get("ruleChainNodeId") == null) { + additionalInfo.put("ruleChainNodeId", "rule-chain-node-UNDEFINED"); + } + } + result.add(constructRuleChainConnection(ruleChainConnectionInfo)); + } + } + return result; + } + + private RuleChainConnectionInfoProto constructRuleChainConnection(RuleChainConnectionInfo ruleChainConnectionInfo) throws JsonProcessingException { + return RuleChainConnectionInfoProto.newBuilder() + .setFromIndex(ruleChainConnectionInfo.getFromIndex()) + .setTargetRuleChainIdMSB(ruleChainConnectionInfo.getTargetRuleChainId().getId().getMostSignificantBits()) + .setTargetRuleChainIdLSB(ruleChainConnectionInfo.getTargetRuleChainId().getId().getLeastSignificantBits()) + .setType(ruleChainConnectionInfo.getType()) + .setAdditionalInfo(JacksonUtil.OBJECT_MAPPER.writeValueAsString(ruleChainConnectionInfo.getAdditionalInfo())) + .build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructor.java new file mode 100644 index 0000000..014023c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructor.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor.rule; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; + +public interface RuleChainMetadataConstructor { + + RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + UpdateMsgType msgType, + RuleChainMetaData ruleChainMetaData); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorFactory.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorFactory.java new file mode 100644 index 0000000..5fc686f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorFactory.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor.rule; + +import org.thingsboard.server.gen.edge.v1.EdgeVersion; + +public final class RuleChainMetadataConstructorFactory { + + public static RuleChainMetadataConstructor getByEdgeVersion(EdgeVersion edgeVersion) { + switch (edgeVersion) { + case V_3_3_0: + return new RuleChainMetadataConstructorV330(); + case V_3_3_3: + case V_3_4_0: + default: + return new RuleChainMetadataConstructorV340(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV330.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV330.java new file mode 100644 index 0000000..3172ee9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV330.java @@ -0,0 +1,165 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor.rule; + +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNode; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNodeConfiguration; +import org.thingsboard.rule.engine.flow.TbRuleChainOutputNode; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableSet; +import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +public class RuleChainMetadataConstructorV330 extends AbstractRuleChainMetadataConstructor { + + private static final String RULE_CHAIN_INPUT_NODE = TbRuleChainInputNode.class.getName(); + private static final String TB_RULE_CHAIN_OUTPUT_NODE = TbRuleChainOutputNode.class.getName(); + + @Override + protected void constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + RuleChainMetadataUpdateMsg.Builder builder, + RuleChainMetaData ruleChainMetaData) throws JsonProcessingException { + List supportedNodes = filterNodes(ruleChainMetaData.getNodes()); + + NavigableSet removedNodeIndexes = getRemovedNodeIndexes(ruleChainMetaData.getNodes(), ruleChainMetaData.getConnections()); + List connections = filterConnections(ruleChainMetaData.getNodes(), ruleChainMetaData.getConnections(), removedNodeIndexes); + + List ruleChainConnections = new ArrayList<>(); + if (ruleChainMetaData.getRuleChainConnections() != null) { + ruleChainConnections.addAll(ruleChainMetaData.getRuleChainConnections()); + } + ruleChainConnections.addAll(addRuleChainConnections(ruleChainMetaData.getNodes(), ruleChainMetaData.getConnections())); + builder.addAllNodes(constructNodes(supportedNodes)) + .addAllConnections(constructConnections(connections)) + .addAllRuleChainConnections(constructRuleChainConnections(ruleChainConnections, removedNodeIndexes)); + if (ruleChainMetaData.getFirstNodeIndex() != null) { + Integer firstNodeIndex = ruleChainMetaData.getFirstNodeIndex(); + // decrease index because of removed nodes + for (Integer removedIndex : removedNodeIndexes) { + if (firstNodeIndex > removedIndex) { + firstNodeIndex = firstNodeIndex - 1; + } + } + builder.setFirstNodeIndex(firstNodeIndex); + } else { + builder.setFirstNodeIndex(-1); + } + } + + private NavigableSet getRemovedNodeIndexes(List nodes, List connections) { + TreeSet removedIndexes = new TreeSet<>(); + for (NodeConnectionInfo connection : connections) { + for (int i = 0; i < nodes.size(); i++) { + RuleNode node = nodes.get(i); + if (node.getType().equalsIgnoreCase(RULE_CHAIN_INPUT_NODE) + || node.getType().equalsIgnoreCase(TB_RULE_CHAIN_OUTPUT_NODE)) { + if (connection.getFromIndex() == i || connection.getToIndex() == i) { + removedIndexes.add(i); + } + } + } + } + return removedIndexes.descendingSet(); + } + + private List filterConnections(List nodes, + List connections, + NavigableSet removedNodeIndexes) { + List result = new ArrayList<>(); + if (connections != null) { + result = connections.stream().filter(conn -> { + for (int i = 0; i < nodes.size(); i++) { + RuleNode node = nodes.get(i); + if (node.getType().equalsIgnoreCase(RULE_CHAIN_INPUT_NODE) + || node.getType().equalsIgnoreCase(TB_RULE_CHAIN_OUTPUT_NODE)) { + if (conn.getFromIndex() == i || conn.getToIndex() == i) { + return false; + } + } + } + return true; + }).map(conn -> { + NodeConnectionInfo newConn = new NodeConnectionInfo(); + newConn.setFromIndex(conn.getFromIndex()); + newConn.setToIndex(conn.getToIndex()); + newConn.setType(conn.getType()); + return newConn; + }).collect(Collectors.toList()); + } + + // decrease index because of removed nodes + for (Integer removedIndex : removedNodeIndexes) { + for (NodeConnectionInfo newConn : result) { + if (newConn.getToIndex() > removedIndex) { + newConn.setToIndex(newConn.getToIndex() - 1); + } + if (newConn.getFromIndex() > removedIndex) { + newConn.setFromIndex(newConn.getFromIndex() - 1); + } + } + } + + return result; + } + + private List filterNodes(List nodes) { + List result = new ArrayList<>(); + for (RuleNode node : nodes) { + if (RULE_CHAIN_INPUT_NODE.equals(node.getType()) + || TB_RULE_CHAIN_OUTPUT_NODE.equals(node.getType())) { + log.trace("Skipping not supported rule node {}", node); + } else { + result.add(node); + } + } + return result; + } + + private List addRuleChainConnections(List nodes, List connections) { + List result = new ArrayList<>(); + for (int i = 0; i < nodes.size(); i++) { + RuleNode node = nodes.get(i); + if (node.getType().equalsIgnoreCase(RULE_CHAIN_INPUT_NODE)) { + for (NodeConnectionInfo connection : connections) { + if (connection.getToIndex() == i) { + RuleChainConnectionInfo e = new RuleChainConnectionInfo(); + e.setFromIndex(connection.getFromIndex()); + TbRuleChainInputNodeConfiguration configuration = JacksonUtil.treeToValue(node.getConfiguration(), TbRuleChainInputNodeConfiguration.class); + e.setTargetRuleChainId(new RuleChainId(UUID.fromString(configuration.getRuleChainId()))); + e.setAdditionalInfo(node.getAdditionalInfo()); + e.setType(connection.getType()); + result.add(e); + } + } + } + } + return result; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV340.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV340.java new file mode 100644 index 0000000..9acb2ae --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/rule/RuleChainMetadataConstructorV340.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.constructor.rule; + +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; + +import java.util.TreeSet; + +@Slf4j +public class RuleChainMetadataConstructorV340 extends AbstractRuleChainMetadataConstructor { + + @Override + protected void constructRuleChainMetadataUpdatedMsg(TenantId tenantId, + RuleChainMetadataUpdateMsg.Builder builder, + RuleChainMetaData ruleChainMetaData) throws JsonProcessingException { + builder.addAllNodes(constructNodes(ruleChainMetaData.getNodes())) + .addAllConnections(constructConnections(ruleChainMetaData.getConnections())) + .addAllRuleChainConnections(constructRuleChainConnections(ruleChainMetaData.getRuleChainConnections(), new TreeSet<>())); + if (ruleChainMetaData.getFirstNodeIndex() != null) { + builder.setFirstNodeIndex(ruleChainMetaData.getFirstNodeIndex()); + } else { + builder.setFirstNodeIndex(-1); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java new file mode 100644 index 0000000..b7c30fd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import freemarker.template.Configuration; +import freemarker.template.Template; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.text.WordUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AdminSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.settings.AdminSettingsService; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@AllArgsConstructor +@Slf4j +public class AdminSettingsEdgeEventFetcher implements EdgeEventFetcher { + + private final AdminSettingsService adminSettingsService; + private final Configuration freemarkerConfig; + + private static final Pattern startPattern = Pattern.compile("
"); + private static final Pattern endPattern = Pattern.compile("
"); + + private static final List templatesNames = Arrays.asList( + "account.activated.ftl", + "account.lockout.ftl", + "activation.ftl", + "password.was.reset.ftl", + "reset.password.ftl", + "test.ftl"); + + // TODO: @voba fix format of next templates + // "state.disabled.ftl", + // "state.enabled.ftl", + // "state.warning.ftl", + + @Override + public PageLink getPageLink(int pageSize) { + return null; + } + + @Override + public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) throws Exception { + List result = new ArrayList<>(); + + AdminSettings systemMailSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); + result.add(EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS, + EdgeEventActionType.UPDATED, null, JacksonUtil.OBJECT_MAPPER.valueToTree(systemMailSettings))); + + AdminSettings tenantMailSettings = convertToTenantAdminSettings(tenantId, systemMailSettings.getKey(), (ObjectNode) systemMailSettings.getJsonValue()); + result.add(EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS, + EdgeEventActionType.UPDATED, null, JacksonUtil.OBJECT_MAPPER.valueToTree(tenantMailSettings))); + + AdminSettings systemMailTemplates = loadMailTemplates(); + result.add(EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS, + EdgeEventActionType.UPDATED, null, JacksonUtil.OBJECT_MAPPER.valueToTree(systemMailTemplates))); + + AdminSettings tenantMailTemplates = convertToTenantAdminSettings(tenantId, systemMailTemplates.getKey(), (ObjectNode) systemMailTemplates.getJsonValue()); + result.add(EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS, + EdgeEventActionType.UPDATED, null, JacksonUtil.OBJECT_MAPPER.valueToTree(tenantMailTemplates))); + + // return PageData object to be in sync with other fetchers + return new PageData<>(result, 1, result.size(), false); + } + + private AdminSettings loadMailTemplates() throws Exception { + Map mailTemplates = new HashMap<>(); + for (String templatesName : templatesNames) { + Template template = freemarkerConfig.getTemplate(templatesName); + if (template != null) { + String name = validateName(template.getName()); + Map mailTemplate = getMailTemplateFromFile(template.toString()); + if (mailTemplate != null) { + mailTemplates.put(name, mailTemplate); + } else { + log.error("Can't load mail template from file {}", template.getName()); + } + } + } + AdminSettings adminSettings = new AdminSettings(); + adminSettings.setId(new AdminSettingsId(Uuids.timeBased())); + adminSettings.setKey("mailTemplates"); + adminSettings.setJsonValue(JacksonUtil.OBJECT_MAPPER.convertValue(mailTemplates, JsonNode.class)); + return adminSettings; + } + + private Map getMailTemplateFromFile(String stringTemplate) { + Map mailTemplate = new HashMap<>(); + Matcher start = startPattern.matcher(stringTemplate); + Matcher end = endPattern.matcher(stringTemplate); + if (start.find() && end.find()) { + String body = StringUtils.substringBetween(stringTemplate, start.group(), end.group()).replaceAll("\t", ""); + String subject = StringUtils.substringBetween(body, "

", "

"); + mailTemplate.put("subject", subject); + mailTemplate.put("body", body); + } else { + return null; + } + return mailTemplate; + } + + private String validateName(String name) throws Exception { + StringBuilder nameBuilder = new StringBuilder(); + name = name.replace(".ftl", ""); + String[] nameParts = name.split("\\."); + if (nameParts.length >= 1) { + nameBuilder.append(nameParts[0]); + for (int i = 1; i < nameParts.length; i++) { + String word = WordUtils.capitalize(nameParts[i]); + nameBuilder.append(word); + } + return nameBuilder.toString(); + } else { + throw new Exception("Error during filename validation"); + } + } + + private AdminSettings convertToTenantAdminSettings(TenantId tenantId, String key, ObjectNode jsonValue) { + AdminSettings tenantMailSettings = new AdminSettings(); + tenantMailSettings.setTenantId(tenantId); + jsonValue.put("useSystemMailSettings", true); + tenantMailSettings.setJsonValue(jsonValue); + tenantMailSettings.setKey(key); + return tenantMailSettings; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetProfilesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetProfilesEdgeEventFetcher.java new file mode 100644 index 0000000..efade1b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetProfilesEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.asset.AssetProfileService; + +@AllArgsConstructor +@Slf4j +public class AssetProfilesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final AssetProfileService assetProfileService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return assetProfileService.findAssetProfiles(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, AssetProfile assetProfile) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ASSET_PROFILE, + EdgeEventActionType.ADDED, assetProfile.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetsEdgeEventFetcher.java new file mode 100644 index 0000000..ead8763 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AssetsEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.asset.AssetService; + +@AllArgsConstructor +@Slf4j +public class AssetsEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final AssetService assetService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return assetService.findAssetsByTenantIdAndEdgeId(tenantId, edge.getId(), pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, Asset asset) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ASSET, + EdgeEventActionType.ADDED, asset.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BasePageableEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BasePageableEdgeEventFetcher.java new file mode 100644 index 0000000..5d7a5cb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BasePageableEdgeEventFetcher.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public abstract class BasePageableEdgeEventFetcher implements EdgeEventFetcher { + + @Override + public PageLink getPageLink(int pageSize) { + return new PageLink(pageSize); + } + + @Override + public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) { + log.trace("[{}] start fetching edge events [{}]", tenantId, edge.getId()); + PageData pageData = fetchPageData(tenantId, edge, pageLink); + List result = new ArrayList<>(); + if (!pageData.getData().isEmpty()) { + for (T entity : pageData.getData()) { + result.add(constructEdgeEvent(tenantId, edge, entity)); + } + } + return new PageData<>(result, pageData.getTotalPages(), pageData.getTotalElements(), pageData.hasNext()); + } + + abstract PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink); + + abstract EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, T entity); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseUsersEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseUsersEdgeEventFetcher.java new file mode 100644 index 0000000..6791ba6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseUsersEdgeEventFetcher.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.user.UserService; + +@Slf4j +@AllArgsConstructor +public abstract class BaseUsersEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + protected final UserService userService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return findUsers(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, User user) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.USER, + EdgeEventActionType.ADDED, user.getId(), null); + } + + protected abstract PageData findUsers(TenantId tenantId, PageLink pageLink); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseWidgetsBundlesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseWidgetsBundlesEdgeEventFetcher.java new file mode 100644 index 0000000..709c438 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/BaseWidgetsBundlesEdgeEventFetcher.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +@Slf4j +@AllArgsConstructor +public abstract class BaseWidgetsBundlesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + protected final WidgetsBundleService widgetsBundleService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return findWidgetsBundles(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, WidgetsBundle widgetsBundle) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.WIDGETS_BUNDLE, + EdgeEventActionType.ADDED, widgetsBundle.getId(), null); + } + + protected abstract PageData findWidgetsBundles(TenantId tenantId, PageLink pageLink); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerEdgeEventFetcher.java new file mode 100644 index 0000000..de88c2f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerEdgeEventFetcher.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@AllArgsConstructor +public class CustomerEdgeEventFetcher implements EdgeEventFetcher { + + @Override + public PageLink getPageLink(int pageSize) { + return null; + } + + @Override + public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) { + List result = new ArrayList<>(); + result.add(EdgeUtils.constructEdgeEvent(edge.getTenantId(), edge.getId(), + EdgeEventType.CUSTOMER, EdgeEventActionType.ADDED, edge.getCustomerId(), null)); + // @voba - returns PageData object to be in sync with other fetchers + return new PageData<>(result, 1, result.size(), false); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerUsersEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerUsersEdgeEventFetcher.java new file mode 100644 index 0000000..2cc923d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/CustomerUsersEdgeEventFetcher.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.user.UserService; + +public class CustomerUsersEdgeEventFetcher extends BaseUsersEdgeEventFetcher { + + private final CustomerId customerId; + + public CustomerUsersEdgeEventFetcher(UserService userService, CustomerId customerId) { + super(userService); + this.customerId = customerId; + } + + @Override + protected PageData findUsers(TenantId tenantId, PageLink pageLink) { + return userService.findCustomerUsers(tenantId, customerId, pageLink); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DashboardsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DashboardsEdgeEventFetcher.java new file mode 100644 index 0000000..a6e7b33 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DashboardsEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.dashboard.DashboardService; + +@AllArgsConstructor +@Slf4j +public class DashboardsEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final DashboardService dashboardService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return dashboardService.findDashboardsByTenantIdAndEdgeId(tenantId, edge.getId(), pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, DashboardInfo dashboardInfo) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.DASHBOARD, + EdgeEventActionType.ADDED, dashboardInfo.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DeviceProfilesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DeviceProfilesEdgeEventFetcher.java new file mode 100644 index 0000000..ef4a5ac --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DeviceProfilesEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.device.DeviceProfileService; + +@AllArgsConstructor +@Slf4j +public class DeviceProfilesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final DeviceProfileService deviceProfileService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return deviceProfileService.findDeviceProfiles(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, DeviceProfile deviceProfile) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE_PROFILE, + EdgeEventActionType.ADDED, deviceProfile.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DevicesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DevicesEdgeEventFetcher.java new file mode 100644 index 0000000..fe896db --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/DevicesEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.device.DeviceService; + +@AllArgsConstructor +@Slf4j +public class DevicesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final DeviceService deviceService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return deviceService.findDevicesByTenantIdAndEdgeId(tenantId, edge.getId(), pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, Device device) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, + EdgeEventActionType.ADDED, device.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EdgeEventFetcher.java new file mode 100644 index 0000000..f2a026f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EdgeEventFetcher.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +public interface EdgeEventFetcher { + + PageLink getPageLink(int pageSize); + + PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) throws Exception; +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EntityViewsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EntityViewsEdgeEventFetcher.java new file mode 100644 index 0000000..3bc4bef --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/EntityViewsEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entityview.EntityViewService; + +@AllArgsConstructor +@Slf4j +public class EntityViewsEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final EntityViewService entityViewService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return entityViewService.findEntityViewsByTenantIdAndEdgeId(tenantId, edge.getId(), pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, EntityView entityView) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ENTITY_VIEW, + EdgeEventActionType.ADDED, entityView.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java new file mode 100644 index 0000000..ed5e039 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.edge.EdgeEventService; + +@AllArgsConstructor +public class GeneralEdgeEventFetcher implements EdgeEventFetcher { + + private final Long queueStartTs; + private final EdgeEventService edgeEventService; + + @Override + public PageLink getPageLink(int pageSize) { + return new TimePageLink( + pageSize, + 0, + null, + new SortOrder("createdTime", SortOrder.Direction.ASC), + queueStartTs, + null); + } + + @Override + public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) { + return edgeEventService.findEdgeEvents(tenantId, edge.getId(), (TimePageLink) pageLink, true); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OtaPackagesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OtaPackagesEdgeEventFetcher.java new file mode 100644 index 0000000..14b8bac --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OtaPackagesEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.ota.OtaPackageService; + +@AllArgsConstructor +@Slf4j +public class OtaPackagesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final OtaPackageService otaPackageService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return otaPackageService.findTenantOtaPackagesByTenantId(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, OtaPackageInfo otaPackageInfo) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.OTA_PACKAGE, + EdgeEventActionType.ADDED, otaPackageInfo.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/QueuesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/QueuesEdgeEventFetcher.java new file mode 100644 index 0000000..a47dfa5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/QueuesEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; + +@AllArgsConstructor +@Slf4j +public class QueuesEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final QueueService queueService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return queueService.findQueuesByTenantId(tenantId, pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, Queue queue) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.QUEUE, + EdgeEventActionType.ADDED, queue.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/RuleChainsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/RuleChainsEdgeEventFetcher.java new file mode 100644 index 0000000..8d2a8ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/RuleChainsEdgeEventFetcher.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.dao.rule.RuleChainService; + +@Slf4j +@AllArgsConstructor +public class RuleChainsEdgeEventFetcher extends BasePageableEdgeEventFetcher { + + private final RuleChainService ruleChainService; + + @Override + PageData fetchPageData(TenantId tenantId, Edge edge, PageLink pageLink) { + return ruleChainService.findRuleChainsByTenantIdAndEdgeId(tenantId, edge.getId(), pageLink); + } + + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, RuleChain ruleChain) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.RULE_CHAIN, + EdgeEventActionType.ADDED, ruleChain.getId(), null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java new file mode 100644 index 0000000..1aca380 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/SystemWidgetsBundlesEdgeEventFetcher.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +@Slf4j +public class SystemWidgetsBundlesEdgeEventFetcher extends BaseWidgetsBundlesEdgeEventFetcher { + + public SystemWidgetsBundlesEdgeEventFetcher(WidgetsBundleService widgetsBundleService) { + super(widgetsBundleService); + } + + @Override + protected PageData findWidgetsBundles(TenantId tenantId, PageLink pageLink) { + return widgetsBundleService.findSystemWidgetsBundlesByPageLink(tenantId, pageLink); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantAdminUsersEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantAdminUsersEdgeEventFetcher.java new file mode 100644 index 0000000..a496231 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantAdminUsersEdgeEventFetcher.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.user.UserService; + +public class TenantAdminUsersEdgeEventFetcher extends BaseUsersEdgeEventFetcher { + + public TenantAdminUsersEdgeEventFetcher(UserService userService) { + super(userService); + } + + @Override + protected PageData findUsers(TenantId tenantId, PageLink pageLink) { + return userService.findTenantAdmins(tenantId, pageLink); + } +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetsBundlesEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetsBundlesEdgeEventFetcher.java new file mode 100644 index 0000000..9983e4b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/TenantWidgetsBundlesEdgeEventFetcher.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.fetch; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +@Slf4j +public class TenantWidgetsBundlesEdgeEventFetcher extends BaseWidgetsBundlesEdgeEventFetcher { + + public TenantWidgetsBundlesEdgeEventFetcher(WidgetsBundleService widgetsBundleService) { + super(widgetsBundleService); + } + @Override + protected PageData findWidgetsBundles(TenantId tenantId, PageLink pageLink) { + return widgetsBundleService.findTenantWidgetsBundlesByTenantId(tenantId, pageLink); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AdminSettingsEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AdminSettingsEdgeProcessor.java new file mode 100644 index 0000000..c3d3df4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AdminSettingsEdgeProcessor.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class AdminSettingsEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertAdminSettingsEventToDownlink(EdgeEvent edgeEvent) { + AdminSettings adminSettings = JacksonUtil.OBJECT_MAPPER.convertValue(edgeEvent.getBody(), AdminSettings.class); + AdminSettingsUpdateMsg adminSettingsUpdateMsg = adminSettingsMsgConstructor.constructAdminSettingsUpdateMsg(adminSettings); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAdminSettingsUpdateMsg(adminSettingsUpdateMsg) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AlarmEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AlarmEdgeProcessor.java new file mode 100644 index 0000000..b5d256b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AlarmEdgeProcessor.java @@ -0,0 +1,195 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Component +@Slf4j +@TbCoreComponent +public class AlarmEdgeProcessor extends BaseEdgeProcessor { + + public ListenableFuture processAlarmFromEdge(TenantId tenantId, AlarmUpdateMsg alarmUpdateMsg) { + log.trace("[{}] onAlarmUpdate [{}]", tenantId, alarmUpdateMsg); + EntityId originatorId = getAlarmOriginator(tenantId, alarmUpdateMsg.getOriginatorName(), + EntityType.valueOf(alarmUpdateMsg.getOriginatorType())); + if (originatorId == null) { + log.warn("Originator not found for the alarm msg {}", alarmUpdateMsg); + return Futures.immediateFuture(null); + } + try { + Alarm existentAlarm = alarmService.findLatestByOriginatorAndType(tenantId, originatorId, alarmUpdateMsg.getType()).get(); + switch (alarmUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + if (existentAlarm == null || existentAlarm.getStatus().isCleared()) { + existentAlarm = new Alarm(); + existentAlarm.setTenantId(tenantId); + existentAlarm.setType(alarmUpdateMsg.getName()); + existentAlarm.setOriginator(originatorId); + existentAlarm.setSeverity(AlarmSeverity.valueOf(alarmUpdateMsg.getSeverity())); + existentAlarm.setStartTs(alarmUpdateMsg.getStartTs()); + existentAlarm.setClearTs(alarmUpdateMsg.getClearTs()); + existentAlarm.setPropagate(alarmUpdateMsg.getPropagate()); + } + existentAlarm.setStatus(AlarmStatus.valueOf(alarmUpdateMsg.getStatus())); + existentAlarm.setAckTs(alarmUpdateMsg.getAckTs()); + existentAlarm.setEndTs(alarmUpdateMsg.getEndTs()); + existentAlarm.setDetails(JacksonUtil.OBJECT_MAPPER.readTree(alarmUpdateMsg.getDetails())); + alarmService.createOrUpdateAlarm(existentAlarm); + break; + case ALARM_ACK_RPC_MESSAGE: + if (existentAlarm != null) { + alarmService.ackAlarm(tenantId, existentAlarm.getId(), alarmUpdateMsg.getAckTs()); + } + break; + case ALARM_CLEAR_RPC_MESSAGE: + if (existentAlarm != null) { + alarmService.clearAlarm(tenantId, existentAlarm.getId(), + JacksonUtil.OBJECT_MAPPER.readTree(alarmUpdateMsg.getDetails()), alarmUpdateMsg.getAckTs()); + } + break; + case ENTITY_DELETED_RPC_MESSAGE: + if (existentAlarm != null) { + alarmService.deleteAlarm(tenantId, existentAlarm.getId()); + } + break; + } + return Futures.immediateFuture(null); + } catch (Exception e) { + log.error("Failed to process alarm update msg [{}]", alarmUpdateMsg, e); + return Futures.immediateFailedFuture(new RuntimeException("Failed to process alarm update msg", e)); + } + } + + private EntityId getAlarmOriginator(TenantId tenantId, String entityName, EntityType entityType) { + switch (entityType) { + case DEVICE: + return deviceService.findDeviceByTenantIdAndName(tenantId, entityName).getId(); + case ASSET: + return assetService.findAssetByTenantIdAndName(tenantId, entityName).getId(); + case ENTITY_VIEW: + return entityViewService.findEntityViewByTenantIdAndName(tenantId, entityName).getId(); + default: + return null; + } + } + + public DownlinkMsg convertAlarmEventToDownlink(EdgeEvent edgeEvent) { + AlarmId alarmId = new AlarmId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ALARM_ACK: + case ALARM_CLEAR: + try { + Alarm alarm = alarmService.findAlarmByIdAsync(edgeEvent.getTenantId(), alarmId).get(); + if (alarm != null) { + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAlarmUpdateMsg(alarmMsgConstructor.constructAlarmUpdatedMsg(edgeEvent.getTenantId(), msgType, alarm)) + .build(); + } + } catch (Exception e) { + log.error("Can't process alarm msg [{}] [{}]", edgeEvent, msgType, e); + } + break; + case DELETED: + Alarm alarm = JacksonUtil.OBJECT_MAPPER.convertValue(edgeEvent.getBody(), Alarm.class); + AlarmUpdateMsg alarmUpdateMsg = + alarmMsgConstructor.constructAlarmUpdatedMsg(edgeEvent.getTenantId(), msgType, alarm); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAlarmUpdateMsg(alarmUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processAlarmNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) throws JsonProcessingException { + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + AlarmId alarmId = new AlarmId(new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + switch (actionType) { + case DELETED: + EdgeId edgeId = new EdgeId(new UUID(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB())); + Alarm deletedAlarm = JacksonUtil.OBJECT_MAPPER.readValue(edgeNotificationMsg.getBody(), Alarm.class); + return saveEdgeEvent(tenantId, edgeId, EdgeEventType.ALARM, actionType, alarmId, JacksonUtil.OBJECT_MAPPER.valueToTree(deletedAlarm)); + default: + ListenableFuture alarmFuture = alarmService.findAlarmByIdAsync(tenantId, alarmId); + return Futures.transformAsync(alarmFuture, alarm -> { + if (alarm == null) { + return Futures.immediateFuture(null); + } + EdgeEventType type = EdgeUtils.getEdgeEventTypeByEntityType(alarm.getOriginator().getEntityType()); + if (type == null) { + return Futures.immediateFuture(null); + } + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + List> futures = new ArrayList<>(); + do { + pageData = edgeService.findRelatedEdgeIdsByEntityId(tenantId, alarm.getOriginator(), pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + for (EdgeId relatedEdgeId : pageData.getData()) { + futures.add(saveEdgeEvent(tenantId, + relatedEdgeId, + EdgeEventType.ALARM, + EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()), + alarmId, + null)); + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + }, dbCallbackExecutorService); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetEdgeProcessor.java new file mode 100644 index 0000000..8f03523 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetEdgeProcessor.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class AssetEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertAssetEventToDownlink(EdgeEvent edgeEvent) { + AssetId assetId = new AssetId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ASSIGNED_TO_EDGE: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + Asset asset = assetService.findAssetById(edgeEvent.getTenantId(), assetId); + if (asset != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + AssetUpdateMsg assetUpdateMsg = + assetMsgConstructor.constructAssetUpdatedMsg(msgType, asset); + DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAssetUpdateMsg(assetUpdateMsg); + if (UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(msgType)) { + AssetProfile assetProfile = assetProfileService.findAssetProfileById(edgeEvent.getTenantId(), asset.getAssetProfileId()); + builder.addAssetProfileUpdateMsg(assetProfileMsgConstructor.constructAssetProfileUpdatedMsg(msgType, assetProfile)); + } + downlinkMsg = builder.build(); + } + break; + case DELETED: + case UNASSIGNED_FROM_EDGE: + AssetUpdateMsg assetUpdateMsg = + assetMsgConstructor.constructAssetDeleteMsg(assetId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAssetUpdateMsg(assetUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processAssetNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotification(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetProfileEdgeProcessor.java new file mode 100644 index 0000000..ce17c05 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/AssetProfileEdgeProcessor.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class AssetProfileEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertAssetProfileEventToDownlink(EdgeEvent edgeEvent) { + AssetProfileId assetProfileId = new AssetProfileId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + AssetProfile assetProfile = assetProfileService.findAssetProfileById(edgeEvent.getTenantId(), assetProfileId); + if (assetProfile != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + AssetProfileUpdateMsg assetProfileUpdateMsg = + assetProfileMsgConstructor.constructAssetProfileUpdatedMsg(msgType, assetProfile); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAssetProfileUpdateMsg(assetProfileUpdateMsg) + .build(); + } + break; + case DELETED: + AssetProfileUpdateMsg assetProfileUpdateMsg = + assetProfileMsgConstructor.constructAssetProfileDeleteMsg(assetProfileId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAssetProfileUpdateMsg(assetProfileUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processAssetProfileNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java new file mode 100644 index 0000000..3b39520 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -0,0 +1,465 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.edge.rpc.constructor.AdminSettingsMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.AlarmMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.AssetMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.AssetProfileMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.CustomerMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.DashboardMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.DeviceMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.DeviceProfileMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.EdgeMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.EntityDataMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.EntityViewMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.OtaPackageMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.QueueMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.RelationMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.RuleChainMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.UserMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.WidgetTypeMsgConstructor; +import org.thingsboard.server.service.edge.rpc.constructor.WidgetsBundleMsgConstructor; +import org.thingsboard.server.service.entitiy.TbNotificationEntityService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +public abstract class BaseEdgeProcessor { + + protected static final int DEFAULT_PAGE_SIZE = 100; + + @Autowired + protected TelemetrySubscriptionService tsSubService; + + @Autowired + protected TbNotificationEntityService notificationEntityService; + + @Autowired + protected RuleChainService ruleChainService; + + @Autowired + protected AlarmService alarmService; + + @Autowired + protected DeviceService deviceService; + + @Autowired + protected TbDeviceProfileCache deviceProfileCache; + + @Autowired + protected TbAssetProfileCache assetProfileCache; + + @Autowired + protected DashboardService dashboardService; + + @Autowired + protected AssetService assetService; + + @Autowired + protected EntityViewService entityViewService; + + @Autowired + protected TenantService tenantService; + + @Autowired + protected EdgeService edgeService; + + @Autowired + protected CustomerService customerService; + + @Autowired + protected UserService userService; + + @Autowired + protected DeviceProfileService deviceProfileService; + + @Autowired + protected AssetProfileService assetProfileService; + + @Autowired + protected RelationService relationService; + + @Autowired + protected DeviceCredentialsService deviceCredentialsService; + + @Autowired + protected AttributesService attributesService; + + @Autowired + protected TbClusterService tbClusterService; + + @Autowired + protected DeviceStateService deviceStateService; + + @Autowired + protected EdgeEventService edgeEventService; + + @Autowired + protected WidgetsBundleService widgetsBundleService; + + @Autowired + protected WidgetTypeService widgetTypeService; + + @Autowired + protected OtaPackageService otaPackageService; + + @Autowired + protected QueueService queueService; + + @Autowired + protected PartitionService partitionService; + + @Autowired + @Lazy + protected TbQueueProducerProvider producerProvider; + + @Autowired + protected DataValidator deviceValidator; + + @Autowired + protected EdgeMsgConstructor edgeMsgConstructor; + + @Autowired + protected EntityDataMsgConstructor entityDataMsgConstructor; + + @Autowired + protected RuleChainMsgConstructor ruleChainMsgConstructor; + + @Autowired + protected AlarmMsgConstructor alarmMsgConstructor; + + @Autowired + protected DeviceMsgConstructor deviceMsgConstructor; + + @Autowired + protected AssetMsgConstructor assetMsgConstructor; + + @Autowired + protected EntityViewMsgConstructor entityViewMsgConstructor; + + @Autowired + protected DashboardMsgConstructor dashboardMsgConstructor; + + @Autowired + protected RelationMsgConstructor relationMsgConstructor; + + @Autowired + protected UserMsgConstructor userMsgConstructor; + + @Autowired + protected CustomerMsgConstructor customerMsgConstructor; + + @Autowired + protected DeviceProfileMsgConstructor deviceProfileMsgConstructor; + + @Autowired + protected AssetProfileMsgConstructor assetProfileMsgConstructor; + + @Autowired + protected WidgetsBundleMsgConstructor widgetsBundleMsgConstructor; + + @Autowired + protected WidgetTypeMsgConstructor widgetTypeMsgConstructor; + + @Autowired + protected AdminSettingsMsgConstructor adminSettingsMsgConstructor; + + @Autowired + protected OtaPackageMsgConstructor otaPackageMsgConstructor; + + @Autowired + protected QueueMsgConstructor queueMsgConstructor; + + @Autowired + protected DbCallbackExecutorService dbCallbackExecutorService; + + protected ListenableFuture saveEdgeEvent(TenantId tenantId, + EdgeId edgeId, + EdgeEventType type, + EdgeEventActionType action, + EntityId entityId, + JsonNode body) { + log.debug("Pushing event to edge queue. tenantId [{}], edgeId [{}], type[{}], " + + "action [{}], entityId [{}], body [{}]", + tenantId, edgeId, type, action, entityId, body); + + EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(tenantId, edgeId, type, action, entityId, body); + + return Futures.transform(edgeEventService.saveAsync(edgeEvent), unused -> { + tbClusterService.onEdgeEventUpdate(tenantId, edgeId); + return null; + }, dbCallbackExecutorService); + } + + protected ListenableFuture processActionForAllEdges(TenantId tenantId, EdgeEventType type, EdgeEventActionType actionType, EntityId entityId) { + List> futures = new ArrayList<>(); + if (TenantId.SYS_TENANT_ID.equals(tenantId)) { + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData tenantsIds; + do { + tenantsIds = tenantService.findTenantsIds(pageLink); + for (TenantId tenantId1 : tenantsIds.getData()) { + futures.addAll(processActionForAllEdgesByTenantId(tenantId1, type, actionType, entityId, null)); + } + pageLink = pageLink.nextPageLink(); + } while (tenantsIds.hasNext()); + } else { + futures = processActionForAllEdgesByTenantId(tenantId, type, actionType, entityId, null); + } + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + } + + protected List> processActionForAllEdgesByTenantId(TenantId tenantId, + EdgeEventType type, + EdgeEventActionType actionType, + EntityId entityId, + JsonNode body) { + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + List> futures = new ArrayList<>(); + do { + pageData = edgeService.findEdgesByTenantId(tenantId, pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + for (Edge edge : pageData.getData()) { + futures.add(saveEdgeEvent(tenantId, edge.getId(), type, actionType, entityId, body)); + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return futures; + } + + protected UpdateMsgType getUpdateMsgType(EdgeEventActionType actionType) { + switch (actionType) { + case UPDATED: + case CREDENTIALS_UPDATED: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + return UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE; + case ADDED: + case ASSIGNED_TO_EDGE: + case RELATION_ADD_OR_UPDATE: + return UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE; + case DELETED: + case UNASSIGNED_FROM_EDGE: + case RELATION_DELETED: + return UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; + case ALARM_ACK: + return UpdateMsgType.ALARM_ACK_RPC_MESSAGE; + case ALARM_CLEAR: + return UpdateMsgType.ALARM_CLEAR_RPC_MESSAGE; + default: + throw new RuntimeException("Unsupported actionType [" + actionType + "]"); + } + } + + protected ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); + EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, + new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg); + switch (actionType) { + case ADDED: + case UPDATED: + case CREDENTIALS_UPDATED: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + case DELETED: + if (edgeId != null) { + return saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, null); + } else { + return pushNotificationToAllRelatedEdges(tenantId, entityId, type, actionType); + } + case ASSIGNED_TO_EDGE: + case UNASSIGNED_FROM_EDGE: + ListenableFuture future = saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, null); + return Futures.transformAsync(future, unused -> { + if (type.equals(EdgeEventType.RULE_CHAIN)) { + return updateDependentRuleChains(tenantId, new RuleChainId(entityId.getId()), edgeId); + } else { + return Futures.immediateFuture(null); + } + }, dbCallbackExecutorService); + default: + return Futures.immediateFuture(null); + } + } + + private EdgeId safeGetEdgeId(TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + if (edgeNotificationMsg.getEdgeIdMSB() != 0 && edgeNotificationMsg.getEdgeIdLSB() != 0) { + return new EdgeId(new UUID(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB())); + } else { + return null; + } + } + + private ListenableFuture pushNotificationToAllRelatedEdges(TenantId tenantId, EntityId entityId, EdgeEventType type, EdgeEventActionType actionType) { + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + List> futures = new ArrayList<>(); + do { + pageData = edgeService.findRelatedEdgeIdsByEntityId(tenantId, entityId, pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + for (EdgeId relatedEdgeId : pageData.getData()) { + futures.add(saveEdgeEvent(tenantId, relatedEdgeId, type, actionType, entityId, null)); + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + } + + private ListenableFuture updateDependentRuleChains(TenantId tenantId, RuleChainId processingRuleChainId, EdgeId edgeId) { + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + List> futures = new ArrayList<>(); + do { + pageData = ruleChainService.findRuleChainsByTenantIdAndEdgeId(tenantId, edgeId, pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + for (RuleChain ruleChain : pageData.getData()) { + if (!ruleChain.getId().equals(processingRuleChainId)) { + List connectionInfos = + ruleChainService.loadRuleChainMetaData(ruleChain.getTenantId(), ruleChain.getId()).getRuleChainConnections(); + if (connectionInfos != null && !connectionInfos.isEmpty()) { + for (RuleChainConnectionInfo connectionInfo : connectionInfos) { + if (connectionInfo.getTargetRuleChainId().equals(processingRuleChainId)) { + futures.add(saveEdgeEvent(tenantId, + edgeId, + EdgeEventType.RULE_CHAIN_METADATA, + EdgeEventActionType.UPDATED, + ruleChain.getId(), + null)); + } + } + } + } + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + } + + protected ListenableFuture processEntityNotificationForAllEdges(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); + EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + switch (actionType) { + case ADDED: + case UPDATED: + case DELETED: + case CREDENTIALS_UPDATED: // used by USER entity + return processActionForAllEdges(tenantId, type, actionType, entityId); + default: + return Futures.immediateFuture(null); + } + } + + protected EntityId constructEntityId(String entityTypeStr, long entityIdMSB, long entityIdLSB) { + EntityType entityType = EntityType.valueOf(entityTypeStr); + switch (entityType) { + case DEVICE: + return new DeviceId(new UUID(entityIdMSB, entityIdLSB)); + case ASSET: + return new AssetId(new UUID(entityIdMSB, entityIdLSB)); + case ENTITY_VIEW: + return new EntityViewId(new UUID(entityIdMSB, entityIdLSB)); + case DASHBOARD: + return new DashboardId(new UUID(entityIdMSB, entityIdLSB)); + case TENANT: + return TenantId.fromUUID(new UUID(entityIdMSB, entityIdLSB)); + case CUSTOMER: + return new CustomerId(new UUID(entityIdMSB, entityIdLSB)); + case USER: + return new UserId(new UUID(entityIdMSB, entityIdLSB)); + case EDGE: + return new EdgeId(new UUID(entityIdMSB, entityIdLSB)); + default: + log.warn("Unsupported entity type [{}] during construct of entity id. entityIdMSB [{}], entityIdLSB [{}]", + entityTypeStr, entityIdMSB, entityIdLSB); + return null; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/CustomerEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/CustomerEdgeProcessor.java new file mode 100644 index 0000000..7d720fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/CustomerEdgeProcessor.java @@ -0,0 +1,107 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +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.Customer; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Component +@Slf4j +@TbCoreComponent +public class CustomerEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertCustomerEventToDownlink(EdgeEvent edgeEvent) { + CustomerId customerId = new CustomerId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + Customer customer = customerService.findCustomerById(edgeEvent.getTenantId(), customerId); + if (customer != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + CustomerUpdateMsg customerUpdateMsg = + customerMsgConstructor.constructCustomerUpdatedMsg(msgType, customer); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addCustomerUpdateMsg(customerUpdateMsg) + .build(); + } + break; + case DELETED: + CustomerUpdateMsg customerUpdateMsg = + customerMsgConstructor.constructCustomerDeleteMsg(customerId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addCustomerUpdateMsg(customerUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processCustomerNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); + UUID uuid = new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB()); + CustomerId customerId = new CustomerId(EntityIdFactory.getByEdgeEventTypeAndUuid(type, uuid).getId()); + switch (actionType) { + case UPDATED: + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + List> futures = new ArrayList<>(); + do { + pageData = edgeService.findEdgesByTenantIdAndCustomerId(tenantId, customerId, pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + for (Edge edge : pageData.getData()) { + futures.add(saveEdgeEvent(tenantId, edge.getId(), type, actionType, customerId, null)); + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + case DELETED: + EdgeId edgeId = new EdgeId(new UUID(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB())); + return saveEdgeEvent(tenantId, edgeId, type, actionType, customerId, null); + default: + return Futures.immediateFuture(null); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DashboardEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DashboardEdgeProcessor.java new file mode 100644 index 0000000..283df69 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DashboardEdgeProcessor.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class DashboardEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertDashboardEventToDownlink(EdgeEvent edgeEvent) { + DashboardId dashboardId = new DashboardId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ASSIGNED_TO_EDGE: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + Dashboard dashboard = dashboardService.findDashboardById(edgeEvent.getTenantId(), dashboardId); + if (dashboard != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + DashboardUpdateMsg dashboardUpdateMsg = + dashboardMsgConstructor.constructDashboardUpdatedMsg(msgType, dashboard); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDashboardUpdateMsg(dashboardUpdateMsg) + .build(); + } + break; + case DELETED: + case UNASSIGNED_FROM_EDGE: + DashboardUpdateMsg dashboardUpdateMsg = + dashboardMsgConstructor.constructDashboardDeleteMsg(dashboardId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDashboardUpdateMsg(dashboardUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processDashboardNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotification(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceEdgeProcessor.java new file mode 100644 index 0000000..81e7970 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceEdgeProcessor.java @@ -0,0 +1,502 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +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.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; +import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.rpc.FromDeviceRpcResponseActorMsg; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; + +@Component +@Slf4j +@TbCoreComponent +public class DeviceEdgeProcessor extends BaseEdgeProcessor { + + @Autowired + private DataDecodingEncodingService dataDecodingEncodingService; + + private static final ReentrantLock deviceCreationLock = new ReentrantLock(); + + public ListenableFuture processDeviceFromEdge(TenantId tenantId, Edge edge, DeviceUpdateMsg deviceUpdateMsg) { + log.trace("[{}] onDeviceUpdate [{}] from edge [{}]", tenantId, deviceUpdateMsg, edge.getName()); + switch (deviceUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + String deviceName = deviceUpdateMsg.getName(); + Device device = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName); + if (device != null) { + boolean deviceAlreadyExistsForThisEdge = isDeviceAlreadyExistsOnCloudForThisEdge(tenantId, edge, device); + if (deviceAlreadyExistsForThisEdge) { + log.info("[{}] Device with name '{}' already exists on the cloud, and related to this edge [{}]. " + + "deviceUpdateMsg [{}], Updating device", tenantId, deviceName, edge.getId(), deviceUpdateMsg); + return updateDevice(tenantId, edge, deviceUpdateMsg); + } else { + log.info("[{}] Device with name '{}' already exists on the cloud, but not related to this edge [{}]. deviceUpdateMsg [{}]." + + "Creating a new device with random prefix and relate to this edge", tenantId, deviceName, edge.getId(), deviceUpdateMsg); + String newDeviceName = deviceUpdateMsg.getName() + "_" + StringUtils.randomAlphabetic(15); + Device newDevice; + try { + newDevice = createDevice(tenantId, edge, deviceUpdateMsg, newDeviceName); + } catch (DataValidationException e) { + log.error("[{}] Device update msg can't be processed due to data validation [{}]", tenantId, deviceUpdateMsg, e); + return Futures.immediateFuture(null); + } + ObjectNode body = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + body.put("conflictName", deviceName); + ListenableFuture input = saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, EdgeEventActionType.ENTITY_MERGE_REQUEST, newDevice.getId(), body); + return Futures.transformAsync(input, unused -> + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, EdgeEventActionType.CREDENTIALS_REQUEST, newDevice.getId(), null), + dbCallbackExecutorService); + } + } else { + log.info("[{}] Creating new device on the cloud [{}]", tenantId, deviceUpdateMsg); + try { + device = createDevice(tenantId, edge, deviceUpdateMsg, deviceUpdateMsg.getName()); + } catch (DataValidationException e) { + log.error("[{}] Device update msg can't be processed due to data validation [{}]", tenantId, deviceUpdateMsg, e); + return Futures.immediateFuture(null); + } + return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, EdgeEventActionType.CREDENTIALS_REQUEST, device.getId(), null); + } + case ENTITY_UPDATED_RPC_MESSAGE: + return updateDevice(tenantId, edge, deviceUpdateMsg); + case ENTITY_DELETED_RPC_MESSAGE: + DeviceId deviceId = new DeviceId(new UUID(deviceUpdateMsg.getIdMSB(), deviceUpdateMsg.getIdLSB())); + Device deviceToDelete = deviceService.findDeviceById(tenantId, deviceId); + if (deviceToDelete != null) { + deviceService.unassignDeviceFromEdge(tenantId, deviceId, edge.getId()); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + log.error("Unsupported msg type {}", deviceUpdateMsg.getMsgType()); + return Futures.immediateFailedFuture(new RuntimeException("Unsupported msg type " + deviceUpdateMsg.getMsgType())); + } + } + + private boolean isDeviceAlreadyExistsOnCloudForThisEdge(TenantId tenantId, Edge edge, Device device) { + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + do { + pageData = edgeService.findRelatedEdgeIdsByEntityId(tenantId, device.getId(), pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + if (pageData.getData().contains(edge.getId())) { + return true; + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return false; + } + + public ListenableFuture processDeviceCredentialsFromEdge(TenantId tenantId, DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg) { + log.debug("Executing onDeviceCredentialsUpdate, deviceCredentialsUpdateMsg [{}]", deviceCredentialsUpdateMsg); + DeviceId deviceId = new DeviceId(new UUID(deviceCredentialsUpdateMsg.getDeviceIdMSB(), deviceCredentialsUpdateMsg.getDeviceIdLSB())); + ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(tenantId, deviceId); + return Futures.transform(deviceFuture, device -> { + if (device != null) { + log.debug("Updating device credentials for device [{}]. New device credentials Id [{}], value [{}]", + device.getName(), deviceCredentialsUpdateMsg.getCredentialsId(), deviceCredentialsUpdateMsg.getCredentialsValue()); + try { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, device.getId()); + deviceCredentials.setCredentialsType(DeviceCredentialsType.valueOf(deviceCredentialsUpdateMsg.getCredentialsType())); + deviceCredentials.setCredentialsId(deviceCredentialsUpdateMsg.getCredentialsId()); + if (deviceCredentialsUpdateMsg.hasCredentialsValue()) { + deviceCredentials.setCredentialsValue(deviceCredentialsUpdateMsg.getCredentialsValue()); + } + deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } catch (Exception e) { + log.error("Can't update device credentials for device [{}], deviceCredentialsUpdateMsg [{}]", device.getName(), deviceCredentialsUpdateMsg, e); + throw new RuntimeException(e); + } + } + return null; + }, dbCallbackExecutorService); + } + + + private ListenableFuture updateDevice(TenantId tenantId, Edge edge, DeviceUpdateMsg deviceUpdateMsg) { + DeviceId deviceId = new DeviceId(new UUID(deviceUpdateMsg.getIdMSB(), deviceUpdateMsg.getIdLSB())); + Device device = deviceService.findDeviceById(tenantId, deviceId); + if (device != null) { + device.setName(deviceUpdateMsg.getName()); + device.setType(deviceUpdateMsg.getType()); + if (deviceUpdateMsg.hasLabel()) { + device.setLabel(deviceUpdateMsg.getLabel()); + } + if (deviceUpdateMsg.hasAdditionalInfo()) { + device.setAdditionalInfo(JacksonUtil.toJsonNode(deviceUpdateMsg.getAdditionalInfo())); + } + if (deviceUpdateMsg.hasDeviceProfileIdMSB() && deviceUpdateMsg.hasDeviceProfileIdLSB()) { + DeviceProfileId deviceProfileId = new DeviceProfileId( + new UUID(deviceUpdateMsg.getDeviceProfileIdMSB(), + deviceUpdateMsg.getDeviceProfileIdLSB())); + device.setDeviceProfileId(deviceProfileId); + } + device.setCustomerId(getCustomerId(deviceUpdateMsg)); + Optional deviceDataOpt = + dataDecodingEncodingService.decode(deviceUpdateMsg.getDeviceDataBytes().toByteArray()); + if (deviceDataOpt.isPresent()) { + device.setDeviceData(deviceDataOpt.get()); + } + Device savedDevice = deviceService.saveDevice(device); + tbClusterService.onDeviceUpdated(savedDevice, device, false); + return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, EdgeEventActionType.CREDENTIALS_REQUEST, deviceId, null); + } else { + String errMsg = String.format("[%s] can't find device [%s], edge [%s]", tenantId, deviceUpdateMsg, edge.getId()); + log.warn(errMsg); + return Futures.immediateFailedFuture(new RuntimeException(errMsg)); + } + } + + private Device createDevice(TenantId tenantId, Edge edge, DeviceUpdateMsg deviceUpdateMsg, String deviceName) { + Device device; + deviceCreationLock.lock(); + try { + log.debug("[{}] Creating device entity [{}] from edge [{}]", tenantId, deviceUpdateMsg, edge.getName()); + DeviceId deviceId = new DeviceId(new UUID(deviceUpdateMsg.getIdMSB(), deviceUpdateMsg.getIdLSB())); + device = deviceService.findDeviceById(tenantId, deviceId); + boolean created = false; + if (device == null) { + device = new Device(); + device.setTenantId(tenantId); + device.setCreatedTime(Uuids.unixTimestamp(deviceId.getId())); + created = true; + } + device.setName(deviceName); + device.setType(deviceUpdateMsg.getType()); + if (deviceUpdateMsg.hasLabel()) { + device.setLabel(deviceUpdateMsg.getLabel()); + } + if (deviceUpdateMsg.hasAdditionalInfo()) { + device.setAdditionalInfo(JacksonUtil.toJsonNode(deviceUpdateMsg.getAdditionalInfo())); + } + if (deviceUpdateMsg.hasDeviceProfileIdMSB() && deviceUpdateMsg.hasDeviceProfileIdLSB()) { + DeviceProfileId deviceProfileId = new DeviceProfileId( + new UUID(deviceUpdateMsg.getDeviceProfileIdMSB(), + deviceUpdateMsg.getDeviceProfileIdLSB())); + device.setDeviceProfileId(deviceProfileId); + } + device.setCustomerId(getCustomerId(deviceUpdateMsg)); + Optional deviceDataOpt = + dataDecodingEncodingService.decode(deviceUpdateMsg.getDeviceDataBytes().toByteArray()); + if (deviceDataOpt.isPresent()) { + device.setDeviceData(deviceDataOpt.get()); + } + if (created) { + deviceValidator.validate(device, Device::getTenantId); + device.setId(deviceId); + } else { + deviceValidator.validate(device, Device::getTenantId); + } + Device savedDevice = deviceService.saveDevice(device, false); + tbClusterService.onDeviceUpdated(savedDevice, created ? null : device, false); + if (created) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(new DeviceId(savedDevice.getUuidId())); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId(StringUtils.randomAlphanumeric(20)); + deviceCredentialsService.createDeviceCredentials(device.getTenantId(), deviceCredentials); + } + createRelationFromEdge(tenantId, edge.getId(), device.getId()); + pushDeviceCreatedEventToRuleEngine(tenantId, edge, device); + deviceService.assignDeviceToEdge(edge.getTenantId(), device.getId(), edge.getId()); + } finally { + deviceCreationLock.unlock(); + } + return device; + } + + private CustomerId getCustomerId(DeviceUpdateMsg deviceUpdateMsg) { + if (deviceUpdateMsg.hasCustomerIdMSB() && deviceUpdateMsg.hasCustomerIdLSB()) { + return new CustomerId(new UUID(deviceUpdateMsg.getCustomerIdMSB(), deviceUpdateMsg.getCustomerIdLSB())); + } else { + return null; + } + } + + private void createRelationFromEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId) { + EntityRelation relation = new EntityRelation(); + relation.setFrom(edgeId); + relation.setTo(entityId); + relation.setTypeGroup(RelationTypeGroup.COMMON); + relation.setType(EntityRelation.EDGE_TYPE); + relationService.saveRelation(tenantId, relation); + } + + private void pushDeviceCreatedEventToRuleEngine(TenantId tenantId, Edge edge, Device device) { + try { + DeviceId deviceId = device.getId(); + ObjectNode entityNode = JacksonUtil.OBJECT_MAPPER.valueToTree(device); + TbMsg tbMsg = TbMsg.newMsg(DataConstants.ENTITY_CREATED, deviceId, device.getCustomerId(), + getActionTbMsgMetaData(edge, device.getCustomerId()), TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send ENTITY_CREATED EVENT to rule engine [{}]", device); + } + + @Override + public void onFailure(Throwable t) { + log.debug("Failed to send ENTITY_CREATED EVENT to rule engine [{}]", device, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push device action to rule engine: {}", device.getId(), DataConstants.ENTITY_CREATED, e); + } + } + + private TbMsgMetaData getActionTbMsgMetaData(Edge edge, CustomerId customerId) { + TbMsgMetaData metaData = getTbMsgMetaData(edge); + if (customerId != null && !customerId.isNullUid()) { + metaData.putValue("customerId", customerId.toString()); + } + return metaData; + } + + private TbMsgMetaData getTbMsgMetaData(Edge edge) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("edgeId", edge.getId().toString()); + metaData.putValue("edgeName", edge.getName()); + return metaData; + } + + public ListenableFuture processDeviceRpcCallFromEdge(TenantId tenantId, Edge edge, DeviceRpcCallMsg deviceRpcCallMsg) { + log.trace("[{}] processDeviceRpcCallFromEdge [{}]", tenantId, deviceRpcCallMsg); + if (deviceRpcCallMsg.hasResponseMsg()) { + return processDeviceRpcResponseFromEdge(tenantId, deviceRpcCallMsg); + } else if (deviceRpcCallMsg.hasRequestMsg()) { + return processDeviceRpcRequestFromEdge(tenantId, edge, deviceRpcCallMsg); + } + return Futures.immediateFuture(null); + } + + private ListenableFuture processDeviceRpcResponseFromEdge(TenantId tenantId, DeviceRpcCallMsg deviceRpcCallMsg) { + SettableFuture futureToSet = SettableFuture.create(); + UUID requestUuid = new UUID(deviceRpcCallMsg.getRequestUuidMSB(), deviceRpcCallMsg.getRequestUuidLSB()); + DeviceId deviceId = new DeviceId(new UUID(deviceRpcCallMsg.getDeviceIdMSB(), deviceRpcCallMsg.getDeviceIdLSB())); + + FromDeviceRpcResponse response; + if (!StringUtils.isEmpty(deviceRpcCallMsg.getResponseMsg().getError())) { + response = new FromDeviceRpcResponse(requestUuid, null, RpcError.valueOf(deviceRpcCallMsg.getResponseMsg().getError())); + } else { + response = new FromDeviceRpcResponse(requestUuid, deviceRpcCallMsg.getResponseMsg().getResponse(), null); + } + TbQueueCallback callback = new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process push notification to core [{}]", deviceRpcCallMsg, t); + futureToSet.setException(t); + } + }; + FromDeviceRpcResponseActorMsg msg = + new FromDeviceRpcResponseActorMsg(deviceRpcCallMsg.getRequestId(), + tenantId, + deviceId, response); + tbClusterService.pushMsgToCore(msg, callback); + return futureToSet; + } + + private ListenableFuture processDeviceRpcRequestFromEdge(TenantId tenantId, Edge edge, DeviceRpcCallMsg deviceRpcCallMsg) { + DeviceId deviceId = new DeviceId(new UUID(deviceRpcCallMsg.getDeviceIdMSB(), deviceRpcCallMsg.getDeviceIdLSB())); + try { + TbMsgMetaData metaData = new TbMsgMetaData(); + String requestId = Integer.toString(deviceRpcCallMsg.getRequestId()); + metaData.putValue("requestId", requestId); + metaData.putValue("serviceId", deviceRpcCallMsg.getServiceId()); + metaData.putValue("sessionId", deviceRpcCallMsg.getSessionId()); + metaData.putValue(DataConstants.EDGE_ID, edge.getId().toString()); + Device device = deviceService.findDeviceById(tenantId, deviceId); + if (device != null) { + metaData.putValue("deviceName", device.getName()); + metaData.putValue("deviceType", device.getType()); + metaData.putValue(DataConstants.DEVICE_ID, deviceId.getId().toString()); + } + ObjectNode data = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + data.put("method", deviceRpcCallMsg.getRequestMsg().getMethod()); + data.put("params", deviceRpcCallMsg.getRequestMsg().getParams()); + TbMsg tbMsg = TbMsg.newMsg(SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, null, metaData, + TbMsgDataType.JSON, JacksonUtil.OBJECT_MAPPER.writeValueAsString(data)); + tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + log.debug("Successfully send TO_SERVER_RPC_REQUEST to rule engine [{}], deviceRpcCallMsg {}", + device, deviceRpcCallMsg); + } + + @Override + public void onFailure(Throwable t) { + log.debug("Failed to send TO_SERVER_RPC_REQUEST to rule engine [{}], deviceRpcCallMsg {}", + device, deviceRpcCallMsg, t); + } + }); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.warn("[{}] Failed to push TO_SERVER_RPC_REQUEST to rule engine. deviceRpcCallMsg {}", deviceId, deviceRpcCallMsg, e); + } + + return Futures.immediateFuture(null); + } + + public DownlinkMsg convertDeviceEventToDownlink(EdgeEvent edgeEvent) { + DeviceId deviceId = new DeviceId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ASSIGNED_TO_EDGE: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + Device device = deviceService.findDeviceById(edgeEvent.getTenantId(), deviceId); + if (device != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + DeviceUpdateMsg deviceUpdateMsg = + deviceMsgConstructor.constructDeviceUpdatedMsg(msgType, device, null); + DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceUpdateMsg(deviceUpdateMsg); + if (UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(msgType)) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(edgeEvent.getTenantId(), device.getDeviceProfileId()); + builder.addDeviceProfileUpdateMsg(deviceProfileMsgConstructor.constructDeviceProfileUpdatedMsg(msgType, deviceProfile)); + } + downlinkMsg = builder.build(); + } + break; + case DELETED: + case UNASSIGNED_FROM_EDGE: + DeviceUpdateMsg deviceUpdateMsg = + deviceMsgConstructor.constructDeviceDeleteMsg(deviceId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceUpdateMsg(deviceUpdateMsg) + .build(); + break; + case CREDENTIALS_UPDATED: + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(edgeEvent.getTenantId(), deviceId); + if (deviceCredentials != null) { + DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = + deviceMsgConstructor.constructDeviceCredentialsUpdatedMsg(deviceCredentials); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceCredentialsUpdateMsg(deviceCredentialsUpdateMsg) + .build(); + } + break; + case RPC_CALL: + return convertRpcCallEventToDownlink(edgeEvent); + case CREDENTIALS_REQUEST: + return convertCredentialsRequestEventToDownlink(edgeEvent); + case ENTITY_MERGE_REQUEST: + return convertEntityMergeRequestEventToDownlink(edgeEvent); + } + return downlinkMsg; + } + + private DownlinkMsg convertRpcCallEventToDownlink(EdgeEvent edgeEvent) { + log.trace("Executing convertRpcCallEventToDownlink, edgeEvent [{}]", edgeEvent); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceRpcCallMsg(deviceMsgConstructor.constructDeviceRpcCallMsg(edgeEvent.getEntityId(), edgeEvent.getBody())) + .build(); + } + + private DownlinkMsg convertCredentialsRequestEventToDownlink(EdgeEvent edgeEvent) { + DeviceId deviceId = new DeviceId(edgeEvent.getEntityId()); + DeviceCredentialsRequestMsg deviceCredentialsRequestMsg = DeviceCredentialsRequestMsg.newBuilder() + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .build(); + DownlinkMsg.Builder builder = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceCredentialsRequestMsg(deviceCredentialsRequestMsg); + return builder.build(); + } + + public DownlinkMsg convertEntityMergeRequestEventToDownlink(EdgeEvent edgeEvent) { + DeviceId deviceId = new DeviceId(edgeEvent.getEntityId()); + Device device = deviceService.findDeviceById(edgeEvent.getTenantId(), deviceId); + String conflictName = null; + if(edgeEvent.getBody() != null) { + conflictName = edgeEvent.getBody().get("conflictName").asText(); + } + DeviceUpdateMsg deviceUpdateMsg = deviceMsgConstructor + .constructDeviceUpdatedMsg(UpdateMsgType.ENTITY_MERGE_RPC_MESSAGE, device, conflictName); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceUpdateMsg(deviceUpdateMsg) + .build(); + } + + public ListenableFuture processDeviceNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotification(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceProfileEdgeProcessor.java new file mode 100644 index 0000000..d5d36ee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/DeviceProfileEdgeProcessor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class DeviceProfileEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertDeviceProfileEventToDownlink(EdgeEvent edgeEvent) { + DeviceProfileId deviceProfileId = new DeviceProfileId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(edgeEvent.getTenantId(), deviceProfileId); + if (deviceProfile != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + DeviceProfileUpdateMsg deviceProfileUpdateMsg = + deviceProfileMsgConstructor.constructDeviceProfileUpdatedMsg(msgType, deviceProfile); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceProfileUpdateMsg(deviceProfileUpdateMsg) + .build(); + } + break; + case DELETED: + DeviceProfileUpdateMsg deviceProfileUpdateMsg = + deviceProfileMsgConstructor.constructDeviceProfileDeleteMsg(deviceProfileId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addDeviceProfileUpdateMsg(deviceProfileUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processDeviceProfileNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java new file mode 100644 index 0000000..d4f6998 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java @@ -0,0 +1,114 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +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.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Component +@Slf4j +@TbCoreComponent +public class EdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + EdgeId edgeId = new EdgeId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + Edge edge = edgeService.findEdgeById(edgeEvent.getTenantId(), edgeId); + if (edge != null) { + EdgeConfiguration edgeConfigMsg = + edgeMsgConstructor.constructEdgeConfiguration(edge); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .setEdgeConfiguration(edgeConfigMsg) + .build(); + } + break; + } + return downlinkMsg; + } + + public ListenableFuture processEdgeNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + try { + EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); + EdgeId edgeId = new EdgeId(new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); + switch (actionType) { + case ASSIGNED_TO_CUSTOMER: + CustomerId customerId = JacksonUtil.OBJECT_MAPPER.readValue(edgeNotificationMsg.getBody(), CustomerId.class); + Edge edge = edgeService.findEdgeById(tenantId, edgeId); + if (edge == null || customerId.isNullUid()) { + return Futures.immediateFuture(null); + } + List> futures = new ArrayList<>(); + futures.add(saveEdgeEvent(edge.getTenantId(), edge.getId(), EdgeEventType.CUSTOMER, EdgeEventActionType.ADDED, customerId, null)); + futures.add(saveEdgeEvent(edge.getTenantId(), edge.getId(), EdgeEventType.EDGE, EdgeEventActionType.ASSIGNED_TO_CUSTOMER, edgeId, null)); + PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE); + PageData pageData; + do { + pageData = userService.findCustomerUsers(tenantId, customerId, pageLink); + if (pageData != null && pageData.getData() != null && !pageData.getData().isEmpty()) { + log.trace("[{}] [{}] user(s) are going to be added to edge.", edge.getId(), pageData.getData().size()); + for (User user : pageData.getData()) { + futures.add(saveEdgeEvent(edge.getTenantId(), edge.getId(), EdgeEventType.USER, EdgeEventActionType.ADDED, user.getId(), null)); + } + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } + } while (pageData != null && pageData.hasNext()); + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + case UNASSIGNED_FROM_CUSTOMER: + CustomerId customerIdToDelete = JacksonUtil.OBJECT_MAPPER.readValue(edgeNotificationMsg.getBody(), CustomerId.class); + edge = edgeService.findEdgeById(tenantId, edgeId); + if (edge == null || customerIdToDelete.isNullUid()) { + return Futures.immediateFuture(null); + } + return Futures.transformAsync(saveEdgeEvent(edge.getTenantId(), edge.getId(), EdgeEventType.EDGE, EdgeEventActionType.UNASSIGNED_FROM_CUSTOMER, edgeId, null), + voids -> saveEdgeEvent(edge.getTenantId(), edge.getId(), EdgeEventType.CUSTOMER, EdgeEventActionType.DELETED, customerIdToDelete, null), + dbCallbackExecutorService); + default: + return Futures.immediateFuture(null); + } + } catch (Exception e) { + log.error("Exception during processing edge event", e); + return Futures.immediateFailedFuture(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EntityViewEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EntityViewEdgeProcessor.java new file mode 100644 index 0000000..b89ce15 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EntityViewEdgeProcessor.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class EntityViewEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertEntityViewEventToDownlink(EdgeEvent edgeEvent) { + EntityViewId entityViewId = new EntityViewId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ASSIGNED_TO_EDGE: + case ASSIGNED_TO_CUSTOMER: + case UNASSIGNED_FROM_CUSTOMER: + EntityView entityView = entityViewService.findEntityViewById(edgeEvent.getTenantId(), entityViewId); + if (entityView != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + EntityViewUpdateMsg entityViewUpdateMsg = + entityViewMsgConstructor.constructEntityViewUpdatedMsg(msgType, entityView); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addEntityViewUpdateMsg(entityViewUpdateMsg) + .build(); + } + break; + case DELETED: + case UNASSIGNED_FROM_EDGE: + EntityViewUpdateMsg entityViewUpdateMsg = + entityViewMsgConstructor.constructEntityViewDeleteMsg(entityViewId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addEntityViewUpdateMsg(entityViewUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processEntityViewNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotification(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/OtaPackageEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/OtaPackageEdgeProcessor.java new file mode 100644 index 0000000..37ae514 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/OtaPackageEdgeProcessor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.OtaPackageUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class OtaPackageEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertOtaPackageEventToDownlink(EdgeEvent edgeEvent) { + OtaPackageId otaPackageId = new OtaPackageId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + OtaPackage otaPackage = otaPackageService.findOtaPackageById(edgeEvent.getTenantId(), otaPackageId); + if (otaPackage != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + OtaPackageUpdateMsg otaPackageUpdateMsg = + otaPackageMsgConstructor.constructOtaPackageUpdatedMsg(msgType, otaPackage); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addOtaPackageUpdateMsg(otaPackageUpdateMsg) + .build(); + } + break; + case DELETED: + OtaPackageUpdateMsg otaPackageUpdateMsg = + otaPackageMsgConstructor.constructOtaPackageDeleteMsg(otaPackageId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addOtaPackageUpdateMsg(otaPackageUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processOtaPackageNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/QueueEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/QueueEdgeProcessor.java new file mode 100644 index 0000000..6a7e66a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/QueueEdgeProcessor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class QueueEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertQueueEventToDownlink(EdgeEvent edgeEvent) { + QueueId queueId = new QueueId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + Queue queue = queueService.findQueueById(edgeEvent.getTenantId(), queueId); + if (queue != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + QueueUpdateMsg queueUpdateMsg = + queueMsgConstructor.constructQueueUpdatedMsg(msgType, queue); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addQueueUpdateMsg(queueUpdateMsg) + .build(); + } + break; + case DELETED: + QueueUpdateMsg queueDeleteMsg = + queueMsgConstructor.constructQueueDeleteMsg(queueId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addQueueUpdateMsg(queueDeleteMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processQueueNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RelationEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RelationEdgeProcessor.java new file mode 100644 index 0000000..2998001 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RelationEdgeProcessor.java @@ -0,0 +1,153 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +@TbCoreComponent +public class RelationEdgeProcessor extends BaseEdgeProcessor { + + public ListenableFuture processRelationFromEdge(TenantId tenantId, RelationUpdateMsg relationUpdateMsg) { + log.trace("[{}] onRelationUpdate [{}]", tenantId, relationUpdateMsg); + try { + EntityRelation entityRelation = new EntityRelation(); + + UUID fromUUID = new UUID(relationUpdateMsg.getFromIdMSB(), relationUpdateMsg.getFromIdLSB()); + EntityId fromId = EntityIdFactory.getByTypeAndUuid(EntityType.valueOf(relationUpdateMsg.getFromEntityType()), fromUUID); + entityRelation.setFrom(fromId); + + UUID toUUID = new UUID(relationUpdateMsg.getToIdMSB(), relationUpdateMsg.getToIdLSB()); + EntityId toId = EntityIdFactory.getByTypeAndUuid(EntityType.valueOf(relationUpdateMsg.getToEntityType()), toUUID); + entityRelation.setTo(toId); + + entityRelation.setType(relationUpdateMsg.getType()); + if (relationUpdateMsg.hasTypeGroup()) { + entityRelation.setTypeGroup(RelationTypeGroup.valueOf(relationUpdateMsg.getTypeGroup())); + } + entityRelation.setAdditionalInfo(JacksonUtil.OBJECT_MAPPER.readTree(relationUpdateMsg.getAdditionalInfo())); + switch (relationUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + if (isEntityExists(tenantId, entityRelation.getTo()) + && isEntityExists(tenantId, entityRelation.getFrom())) { + relationService.saveRelationAsync(tenantId, entityRelation); + } + break; + case ENTITY_DELETED_RPC_MESSAGE: + relationService.deleteRelation(tenantId, entityRelation); + break; + case UNRECOGNIZED: + log.error("Unsupported msg type"); + } + return Futures.immediateFuture(null); + } catch (Exception e) { + log.error("Failed to process relation update msg [{}]", relationUpdateMsg, e); + return Futures.immediateFailedFuture(new RuntimeException("Failed to process relation update msg", e)); + } + } + + + private boolean isEntityExists(TenantId tenantId, EntityId entityId) throws ThingsboardException { + switch (entityId.getEntityType()) { + case DEVICE: + return deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId())) != null; + case ASSET: + return assetService.findAssetById(tenantId, new AssetId(entityId.getId())) != null; + case ENTITY_VIEW: + return entityViewService.findEntityViewById(tenantId, new EntityViewId(entityId.getId())) != null; + case CUSTOMER: + return customerService.findCustomerById(tenantId, new CustomerId(entityId.getId())) != null; + case USER: + return userService.findUserById(tenantId, new UserId(entityId.getId())) != null; + case DASHBOARD: + return dashboardService.findDashboardById(tenantId, new DashboardId(entityId.getId())) != null; + default: + throw new ThingsboardException("Unsupported entity type " + entityId.getEntityType(), ThingsboardErrorCode.INVALID_ARGUMENTS); + } + } + + public DownlinkMsg convertRelationEventToDownlink(EdgeEvent edgeEvent) { + EntityRelation entityRelation = JacksonUtil.OBJECT_MAPPER.convertValue(edgeEvent.getBody(), EntityRelation.class); + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + RelationUpdateMsg relationUpdateMsg = relationMsgConstructor.constructRelationUpdatedMsg(msgType, entityRelation); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addRelationUpdateMsg(relationUpdateMsg) + .build(); + } + + public ListenableFuture processRelationNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) throws JsonProcessingException { + EntityRelation relation = JacksonUtil.OBJECT_MAPPER.readValue(edgeNotificationMsg.getBody(), EntityRelation.class); + if (relation.getFrom().getEntityType().equals(EntityType.EDGE) || + relation.getTo().getEntityType().equals(EntityType.EDGE)) { + return Futures.immediateFuture(null); + } + + Set uniqueEdgeIds = new HashSet<>(); + uniqueEdgeIds.addAll(edgeService.findAllRelatedEdgeIds(tenantId, relation.getTo())); + uniqueEdgeIds.addAll(edgeService.findAllRelatedEdgeIds(tenantId, relation.getFrom())); + if (uniqueEdgeIds.isEmpty()) { + return Futures.immediateFuture(null); + } + List> futures = new ArrayList<>(); + for (EdgeId edgeId : uniqueEdgeIds) { + futures.add(saveEdgeEvent(tenantId, + edgeId, + EdgeEventType.RELATION, + EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()), + null, + JacksonUtil.OBJECT_MAPPER.valueToTree(relation))); + } + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RuleChainEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RuleChainEdgeProcessor.java new file mode 100644 index 0000000..22d8005 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/RuleChainEdgeProcessor.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import static org.thingsboard.server.service.edge.DefaultEdgeNotificationService.EDGE_IS_ROOT_BODY_KEY; + +@Component +@Slf4j +@TbCoreComponent +public class RuleChainEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertRuleChainEventToDownlink(EdgeEvent edgeEvent) { + RuleChainId ruleChainId = new RuleChainId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + case ASSIGNED_TO_EDGE: + RuleChain ruleChain = ruleChainService.findRuleChainById(edgeEvent.getTenantId(), ruleChainId); + if (ruleChain != null) { + boolean isRoot = false; + if (edgeEvent.getBody() != null && edgeEvent.getBody().get(EDGE_IS_ROOT_BODY_KEY) != null) { + try { + isRoot = Boolean.parseBoolean(edgeEvent.getBody().get(EDGE_IS_ROOT_BODY_KEY).asText()); + } catch (Exception ignored) {} + } + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + RuleChainUpdateMsg ruleChainUpdateMsg = + ruleChainMsgConstructor.constructRuleChainUpdatedMsg(msgType, ruleChain, isRoot); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addRuleChainUpdateMsg(ruleChainUpdateMsg) + .build(); + } + break; + case DELETED: + case UNASSIGNED_FROM_EDGE: + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addRuleChainUpdateMsg(ruleChainMsgConstructor.constructRuleChainDeleteMsg(ruleChainId)) + .build(); + break; + } + return downlinkMsg; + } + + public DownlinkMsg convertRuleChainMetadataEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + RuleChainId ruleChainId = new RuleChainId(edgeEvent.getEntityId()); + RuleChain ruleChain = ruleChainService.findRuleChainById(edgeEvent.getTenantId(), ruleChainId); + DownlinkMsg downlinkMsg = null; + if (ruleChain != null) { + RuleChainMetaData ruleChainMetaData = ruleChainService.loadRuleChainMetaData(edgeEvent.getTenantId(), ruleChainId); + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = + ruleChainMsgConstructor.constructRuleChainMetadataUpdatedMsg(edgeEvent.getTenantId(), msgType, ruleChainMetaData, edgeVersion); + if (ruleChainMetadataUpdateMsg != null) { + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addRuleChainMetadataUpdateMsg(ruleChainMetadataUpdateMsg) + .build(); + } + } + return downlinkMsg; + } + + public ListenableFuture processRuleChainNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotification(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/TelemetryEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/TelemetryEdgeProcessor.java new file mode 100644 index 0000000..481ee77 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/TelemetryEdgeProcessor.java @@ -0,0 +1,348 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.common.transport.util.JsonUtils; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.gen.edge.v1.AttributeDeleteMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EntityDataProto; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +@TbCoreComponent +public class TelemetryEdgeProcessor extends BaseEdgeProcessor { + + private final Gson gson = new Gson(); + + private TbQueueProducer> tbCoreMsgProducer; + + @PostConstruct + public void init() { + tbCoreMsgProducer = producerProvider.getTbCoreMsgProducer(); + } + + public List> processTelemetryFromEdge(TenantId tenantId, EntityDataProto entityData) { + log.trace("[{}] processTelemetryFromEdge [{}]", tenantId, entityData); + List> result = new ArrayList<>(); + EntityId entityId = constructEntityId(entityData.getEntityType(), entityData.getEntityIdMSB(), entityData.getEntityIdLSB()); + if ((entityData.hasPostAttributesMsg() || entityData.hasPostTelemetryMsg() || entityData.hasAttributesUpdatedMsg()) && entityId != null) { + Pair pair = getBaseMsgMetadataAndCustomerId(tenantId, entityId); + TbMsgMetaData metaData = pair.getKey(); + CustomerId customerId = pair.getValue(); + metaData.putValue(DataConstants.MSG_SOURCE_KEY, DataConstants.EDGE_MSG_SOURCE); + if (entityData.hasPostAttributesMsg()) { + result.add(processPostAttributes(tenantId, customerId, entityId, entityData.getPostAttributesMsg(), metaData)); + } + if (entityData.hasAttributesUpdatedMsg()) { + metaData.putValue("scope", entityData.getPostAttributeScope()); + result.add(processAttributesUpdate(tenantId, customerId, entityId, entityData.getAttributesUpdatedMsg(), metaData)); + } + if (entityData.hasPostTelemetryMsg()) { + result.add(processPostTelemetry(tenantId, customerId, entityId, entityData.getPostTelemetryMsg(), metaData)); + } + if (EntityType.DEVICE.equals(entityId.getEntityType())) { + DeviceId deviceId = new DeviceId(entityId.getId()); + + long currentTs = System.currentTimeMillis(); + + TransportProtos.DeviceActivityProto deviceActivityMsg = TransportProtos.DeviceActivityProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setLastActivityTime(currentTs).build(); + + log.trace("[{}][{}] device activity time is going to be updated, ts {}", tenantId, deviceId, currentTs); + + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId); + tbCoreMsgProducer.send(tpi, new TbProtoQueueMsg<>(deviceId.getId(), + TransportProtos.ToCoreMsg.newBuilder().setDeviceActivityMsg(deviceActivityMsg).build()), null); + } + } + if (entityData.hasAttributeDeleteMsg()) { + result.add(processAttributeDeleteMsg(tenantId, entityId, entityData.getAttributeDeleteMsg(), entityData.getEntityType())); + } + return result; + } + + private Pair getBaseMsgMetadataAndCustomerId(TenantId tenantId, EntityId entityId) { + TbMsgMetaData metaData = new TbMsgMetaData(); + CustomerId customerId = null; + switch (entityId.getEntityType()) { + case DEVICE: + Device device = deviceService.findDeviceById(tenantId, new DeviceId(entityId.getId())); + if (device != null) { + customerId = device.getCustomerId(); + metaData.putValue("deviceName", device.getName()); + metaData.putValue("deviceType", device.getType()); + } + break; + case ASSET: + Asset asset = assetService.findAssetById(tenantId, new AssetId(entityId.getId())); + if (asset != null) { + customerId = asset.getCustomerId(); + metaData.putValue("assetName", asset.getName()); + metaData.putValue("assetType", asset.getType()); + } + break; + case ENTITY_VIEW: + EntityView entityView = entityViewService.findEntityViewById(tenantId, new EntityViewId(entityId.getId())); + if (entityView != null) { + customerId = entityView.getCustomerId(); + metaData.putValue("entityViewName", entityView.getName()); + metaData.putValue("entityViewType", entityView.getType()); + } + break; + case EDGE: + Edge edge = edgeService.findEdgeById(tenantId, new EdgeId(entityId.getId())); + if (edge != null) { + customerId = edge.getCustomerId(); + metaData.putValue("edgeName", edge.getName()); + metaData.putValue("edgeType", edge.getType()); + } + break; + default: + log.debug("Using empty metadata for entityId [{}]", entityId); + break; + } + return new ImmutablePair<>(metaData, customerId != null ? customerId : new CustomerId(ModelConstants.NULL_UUID)); + } + + private ListenableFuture processPostTelemetry(TenantId tenantId, CustomerId customerId, EntityId entityId, TransportProtos.PostTelemetryMsg msg, TbMsgMetaData metaData) { + SettableFuture futureToSet = SettableFuture.create(); + for (TransportProtos.TsKvListProto tsKv : msg.getTsKvListList()) { + JsonObject json = JsonUtils.getJsonObject(tsKv.getKvList()); + metaData.putValue("ts", tsKv.getTs() + ""); + var defaultQueueAndRuleChain = getDefaultQueueNameAndRuleChainId(tenantId, entityId); + TbMsg tbMsg = TbMsg.newMsg(defaultQueueAndRuleChain.getKey(), SessionMsgType.POST_TELEMETRY_REQUEST.name(), entityId, customerId, metaData, gson.toJson(json), defaultQueueAndRuleChain.getValue(), null); + tbClusterService.pushMsgToRuleEngine(tenantId, tbMsg.getOriginator(), tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process post telemetry [{}]", msg, t); + futureToSet.setException(t); + } + }); + } + return futureToSet; + } + + private Pair getDefaultQueueNameAndRuleChainId(TenantId tenantId, EntityId entityId) { + RuleChainId ruleChainId = null; + String queueName = null; + if (EntityType.DEVICE.equals(entityId.getEntityType())) { + DeviceProfile deviceProfile = deviceProfileCache.get(tenantId, new DeviceId(entityId.getId())); + if (deviceProfile == null) { + log.warn("[{}] Device profile is null!", entityId); + } else { + ruleChainId = deviceProfile.getDefaultRuleChainId(); + queueName = deviceProfile.getDefaultQueueName(); + } + } else if (EntityType.ASSET.equals(entityId.getEntityType())) { + AssetProfile assetProfile = assetProfileCache.get(tenantId, new AssetId(entityId.getId())); + if (assetProfile == null) { + log.warn("[{}] Asset profile is null!", entityId); + } else { + ruleChainId = assetProfile.getDefaultRuleChainId(); + queueName = assetProfile.getDefaultQueueName(); + } + } + return new ImmutablePair<>(queueName, ruleChainId); + } + + private ListenableFuture processPostAttributes(TenantId tenantId, CustomerId customerId, EntityId entityId, TransportProtos.PostAttributeMsg msg, TbMsgMetaData metaData) { + SettableFuture futureToSet = SettableFuture.create(); + JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); + var defaultQueueAndRuleChain = getDefaultQueueNameAndRuleChainId(tenantId, entityId); + TbMsg tbMsg = TbMsg.newMsg(defaultQueueAndRuleChain.getKey(), SessionMsgType.POST_ATTRIBUTES_REQUEST.name(), entityId, customerId, metaData, gson.toJson(json), defaultQueueAndRuleChain.getValue(), null); + tbClusterService.pushMsgToRuleEngine(tenantId, tbMsg.getOriginator(), tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process post attributes [{}]", msg, t); + futureToSet.setException(t); + } + }); + return futureToSet; + } + + private ListenableFuture processAttributesUpdate(TenantId tenantId, + CustomerId customerId, + EntityId entityId, + TransportProtos.PostAttributeMsg msg, + TbMsgMetaData metaData) { + SettableFuture futureToSet = SettableFuture.create(); + JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); + List attributes = new ArrayList<>(JsonConverter.convertToAttributes(json)); + String scope = metaData.getValue("scope"); + tsSubService.saveAndNotify(tenantId, entityId, scope, attributes, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + var defaultQueueAndRuleChain = getDefaultQueueNameAndRuleChainId(tenantId, entityId); + TbMsg tbMsg = TbMsg.newMsg(defaultQueueAndRuleChain.getKey(), DataConstants.ATTRIBUTES_UPDATED, entityId, + customerId, metaData, gson.toJson(json), defaultQueueAndRuleChain.getValue(), null); + tbClusterService.pushMsgToRuleEngine(tenantId, tbMsg.getOriginator(), tbMsg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process attributes update [{}]", msg, t); + futureToSet.setException(t); + } + }); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process attributes update [{}]", msg, t); + futureToSet.setException(t); + } + }); + return futureToSet; + } + + private ListenableFuture processAttributeDeleteMsg(TenantId tenantId, EntityId entityId, AttributeDeleteMsg attributeDeleteMsg, + String entityType) { + SettableFuture futureToSet = SettableFuture.create(); + String scope = attributeDeleteMsg.getScope(); + List attributeNames = attributeDeleteMsg.getAttributeNamesList(); + attributesService.removeAll(tenantId, entityId, scope, attributeNames); + if (EntityType.DEVICE.name().equals(entityType)) { + tbClusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete( + tenantId, (DeviceId) entityId, scope, attributeNames), new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Can't process attribute delete msg [{}]", attributeDeleteMsg, t); + futureToSet.setException(t); + } + }); + } + return futureToSet; + } + + public DownlinkMsg convertTelemetryEventToDownlink(EdgeEvent edgeEvent) throws JsonProcessingException { + EntityId entityId; + switch (edgeEvent.getType()) { + case DEVICE: + entityId = new DeviceId(edgeEvent.getEntityId()); + break; + case ASSET: + entityId = new AssetId(edgeEvent.getEntityId()); + break; + case ENTITY_VIEW: + entityId = new EntityViewId(edgeEvent.getEntityId()); + break; + case DASHBOARD: + entityId = new DashboardId(edgeEvent.getEntityId()); + break; + case TENANT: + entityId = TenantId.fromUUID(edgeEvent.getEntityId()); + break; + case CUSTOMER: + entityId = new CustomerId(edgeEvent.getEntityId()); + break; + case USER: + entityId = new UserId(edgeEvent.getEntityId()); + break; + case EDGE: + entityId = new EdgeId(edgeEvent.getEntityId()); + break; + default: + log.warn("Unsupported edge event type [{}]", edgeEvent); + return null; + } + return constructEntityDataProtoMsg(entityId, edgeEvent.getAction(), + JsonUtils.parse(JacksonUtil.OBJECT_MAPPER.writeValueAsString(edgeEvent.getBody()))); + } + + private DownlinkMsg constructEntityDataProtoMsg(EntityId entityId, EdgeEventActionType actionType, JsonElement entityData) { + EntityDataProto entityDataProto = entityDataMsgConstructor.constructEntityDataMsg(entityId, actionType, entityData); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addEntityData(entityDataProto) + .build(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/UserEdgeProcessor.java new file mode 100644 index 0000000..390fafe --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/UserEdgeProcessor.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class UserEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertUserEventToDownlink(EdgeEvent edgeEvent) { + UserId userId = new UserId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + User user = userService.findUserById(edgeEvent.getTenantId(), userId); + if (user != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addUserUpdateMsg(userMsgConstructor.constructUserUpdatedMsg(msgType, user)) + .build(); + } + break; + case DELETED: + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addUserUpdateMsg(userMsgConstructor.constructUserDeleteMsg(userId)) + .build(); + break; + case CREDENTIALS_UPDATED: + UserCredentials userCredentialsByUserId = userService.findUserCredentialsByUserId(edgeEvent.getTenantId(), userId); + if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) { + UserCredentialsUpdateMsg userCredentialsUpdateMsg = + userMsgConstructor.constructUserCredentialsUpdatedMsg(userCredentialsByUserId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addUserCredentialsUpdateMsg(userCredentialsUpdateMsg) + .build(); + } + } + return downlinkMsg; + } + + public ListenableFuture processUserNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetBundleEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetBundleEdgeProcessor.java new file mode 100644 index 0000000..b74e3c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetBundleEdgeProcessor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.WidgetsBundleUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class WidgetBundleEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertWidgetsBundleEventToDownlink(EdgeEvent edgeEvent) { + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleById(edgeEvent.getTenantId(), widgetsBundleId); + if (widgetsBundle != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + WidgetsBundleUpdateMsg widgetsBundleUpdateMsg = + widgetsBundleMsgConstructor.constructWidgetsBundleUpdateMsg(msgType, widgetsBundle); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addWidgetsBundleUpdateMsg(widgetsBundleUpdateMsg) + .build(); + } + break; + case DELETED: + WidgetsBundleUpdateMsg widgetsBundleUpdateMsg = + widgetsBundleMsgConstructor.constructWidgetsBundleDeleteMsg(widgetsBundleId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addWidgetsBundleUpdateMsg(widgetsBundleUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processWidgetsBundleNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetTypeEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetTypeEdgeProcessor.java new file mode 100644 index 0000000..10807b7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/WidgetTypeEdgeProcessor.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.WidgetTypeUpdateMsg; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class WidgetTypeEdgeProcessor extends BaseEdgeProcessor { + + public DownlinkMsg convertWidgetTypeEventToDownlink(EdgeEvent edgeEvent) { + WidgetTypeId widgetTypeId = new WidgetTypeId(edgeEvent.getEntityId()); + DownlinkMsg downlinkMsg = null; + switch (edgeEvent.getAction()) { + case ADDED: + case UPDATED: + WidgetTypeDetails widgetTypeDetails = widgetTypeService.findWidgetTypeDetailsById(edgeEvent.getTenantId(), widgetTypeId); + if (widgetTypeDetails != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + WidgetTypeUpdateMsg widgetTypeUpdateMsg = + widgetTypeMsgConstructor.constructWidgetTypeUpdateMsg(msgType, widgetTypeDetails); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addWidgetTypeUpdateMsg(widgetTypeUpdateMsg) + .build(); + } + break; + case DELETED: + WidgetTypeUpdateMsg widgetTypeUpdateMsg = + widgetTypeMsgConstructor.constructWidgetTypeDeleteMsg(widgetTypeId); + downlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addWidgetTypeUpdateMsg(widgetTypeUpdateMsg) + .build(); + break; + } + return downlinkMsg; + } + + public ListenableFuture processWidgetTypeNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { + return processEntityNotificationForAllEdges(tenantId, edgeNotificationMsg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java new file mode 100644 index 0000000..4be7ddb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/DefaultEdgeRequestsService.java @@ -0,0 +1,403 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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 org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationsQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.relation.RelationsSearchParameters; +import org.thingsboard.server.common.data.widget.WidgetType; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.state.DefaultDeviceStateService; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@TbCoreComponent +@Slf4j +public class DefaultEdgeRequestsService implements EdgeRequestsService { + + private static final int DEFAULT_PAGE_SIZE = 1000; + + @Autowired + private EdgeEventService edgeEventService; + + @Autowired + private AttributesService attributesService; + + @Autowired + private RelationService relationService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private AssetService assetService; + + @Lazy + @Autowired + private TbEntityViewService entityViewService; + + @Autowired + private DeviceProfileService deviceProfileService; + + @Autowired + private AssetProfileService assetProfileService; + + @Autowired + private WidgetsBundleService widgetsBundleService; + + @Autowired + private WidgetTypeService widgetTypeService; + + @Autowired + private DbCallbackExecutorService dbCallbackExecutorService; + + @Autowired + private TbClusterService tbClusterService; + + @Override + public ListenableFuture processRuleChainMetadataRequestMsg(TenantId tenantId, Edge edge, RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg) { + log.trace("[{}] processRuleChainMetadataRequestMsg [{}][{}]", tenantId, edge.getName(), ruleChainMetadataRequestMsg); + if (ruleChainMetadataRequestMsg.getRuleChainIdMSB() == 0 || ruleChainMetadataRequestMsg.getRuleChainIdLSB() == 0) { + return Futures.immediateFuture(null); + } + RuleChainId ruleChainId = + new RuleChainId(new UUID(ruleChainMetadataRequestMsg.getRuleChainIdMSB(), ruleChainMetadataRequestMsg.getRuleChainIdLSB())); + return saveEdgeEvent(tenantId, edge.getId(), + EdgeEventType.RULE_CHAIN_METADATA, EdgeEventActionType.ADDED, ruleChainId, null); + } + + @Override + public ListenableFuture processAttributesRequestMsg(TenantId tenantId, Edge edge, AttributesRequestMsg attributesRequestMsg) { + log.trace("[{}] processAttributesRequestMsg [{}][{}]", tenantId, edge.getName(), attributesRequestMsg); + EntityId entityId = EntityIdFactory.getByTypeAndUuid( + EntityType.valueOf(attributesRequestMsg.getEntityType()), + new UUID(attributesRequestMsg.getEntityIdMSB(), attributesRequestMsg.getEntityIdLSB())); + final EdgeEventType type = EdgeUtils.getEdgeEventTypeByEntityType(entityId.getEntityType()); + if (type == null) { + log.warn("[{}] Type doesn't supported {}", tenantId, entityId.getEntityType()); + return Futures.immediateFuture(null); + } + SettableFuture futureToSet = SettableFuture.create(); + String scope = attributesRequestMsg.getScope(); + ListenableFuture> findAttrFuture = attributesService.findAll(tenantId, entityId, scope); + Futures.addCallback(findAttrFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List ssAttributes) { + if (ssAttributes == null || ssAttributes.isEmpty()) { + log.trace("[{}][{}] No attributes found for entity {} [{}]", tenantId, + edge.getName(), + entityId.getEntityType(), + entityId.getId()); + futureToSet.set(null); + return; + } + + try { + Map entityData = new HashMap<>(); + ObjectNode attributes = JacksonUtil.OBJECT_MAPPER.createObjectNode(); + for (AttributeKvEntry attr : ssAttributes) { + if (DefaultDeviceStateService.PERSISTENT_ATTRIBUTES.contains(attr.getKey()) + && !DefaultDeviceStateService.INACTIVITY_TIMEOUT.equals(attr.getKey())) { + continue; + } + if (attr.getDataType() == DataType.BOOLEAN && attr.getBooleanValue().isPresent()) { + attributes.put(attr.getKey(), attr.getBooleanValue().get()); + } else if (attr.getDataType() == DataType.DOUBLE && attr.getDoubleValue().isPresent()) { + attributes.put(attr.getKey(), attr.getDoubleValue().get()); + } else if (attr.getDataType() == DataType.LONG && attr.getLongValue().isPresent()) { + attributes.put(attr.getKey(), attr.getLongValue().get()); + } else { + attributes.put(attr.getKey(), attr.getValueAsString()); + } + } + entityData.put("kv", attributes); + entityData.put("scope", scope); + JsonNode body = JacksonUtil.OBJECT_MAPPER.valueToTree(entityData); + log.debug("Sending attributes data msg, entityId [{}], attributes [{}]", entityId, body); + ListenableFuture future = saveEdgeEvent(tenantId, edge.getId(), type, EdgeEventActionType.ATTRIBUTES_UPDATED, entityId, body); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void unused) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable throwable) { + String errMsg = String.format("[%s] Failed to save edge event [%s]", edge.getId(), attributesRequestMsg); + log.error(errMsg, throwable); + futureToSet.setException(new RuntimeException(errMsg, throwable)); + } + }, dbCallbackExecutorService); + } catch (Exception e) { + String errMsg = String.format("[%s] Failed to save attribute updates to the edge [%s]", edge.getId(), attributesRequestMsg); + log.error(errMsg, e); + futureToSet.setException(new RuntimeException(errMsg, e)); + } + } + + @Override + public void onFailure(Throwable t) { + String errMsg = String.format("[%s] Can't find attributes [%s]", edge.getId(), attributesRequestMsg); + log.error(errMsg, t); + futureToSet.setException(new RuntimeException(errMsg, t)); + } + }, dbCallbackExecutorService); + return futureToSet; + } + + @Override + public ListenableFuture processRelationRequestMsg(TenantId tenantId, Edge edge, RelationRequestMsg relationRequestMsg) { + log.trace("[{}] processRelationRequestMsg [{}][{}]", tenantId, edge.getName(), relationRequestMsg); + EntityId entityId = EntityIdFactory.getByTypeAndUuid( + EntityType.valueOf(relationRequestMsg.getEntityType()), + new UUID(relationRequestMsg.getEntityIdMSB(), relationRequestMsg.getEntityIdLSB())); + + List>> futures = new ArrayList<>(); + futures.add(findRelationByQuery(tenantId, edge, entityId, EntitySearchDirection.FROM)); + futures.add(findRelationByQuery(tenantId, edge, entityId, EntitySearchDirection.TO)); + ListenableFuture>> relationsListFuture = Futures.allAsList(futures); + SettableFuture futureToSet = SettableFuture.create(); + Futures.addCallback(relationsListFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List> relationsList) { + try { + if (relationsList != null && !relationsList.isEmpty()) { + List> futures = new ArrayList<>(); + for (List entityRelations : relationsList) { + log.trace("[{}] [{}] [{}] relation(s) are going to be pushed to edge.", edge.getId(), entityId, entityRelations.size()); + for (EntityRelation relation : entityRelations) { + try { + if (!relation.getFrom().getEntityType().equals(EntityType.EDGE) && + !relation.getTo().getEntityType().equals(EntityType.EDGE)) { + futures.add(saveEdgeEvent(tenantId, + edge.getId(), + EdgeEventType.RELATION, + EdgeEventActionType.ADDED, + null, + JacksonUtil.OBJECT_MAPPER.valueToTree(relation))); + } + } catch (Exception e) { + String errMsg = String.format("[%s] Exception during loading relation [%s] to edge on sync!", edge.getId(), relation); + log.error(errMsg, e); + futureToSet.setException(new RuntimeException(errMsg, e)); + return; + } + } + } + Futures.addCallback(Futures.allAsList(futures), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List voids) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable throwable) { + String errMsg = String.format("[%s] Exception during saving edge events [%s]!", edge.getId(), relationRequestMsg); + log.error(errMsg, throwable); + futureToSet.setException(new RuntimeException(errMsg, throwable)); + } + }, dbCallbackExecutorService); + } else { + futureToSet.set(null); + } + } catch (Exception e) { + log.error("Exception during loading relation(s) to edge on sync!", e); + futureToSet.setException(e); + } + } + + @Override + public void onFailure(Throwable t) { + String errMsg = String.format("[%s] Can't find relation by query. Entity id [%s]!", tenantId, entityId); + log.error(errMsg, t); + futureToSet.setException(new RuntimeException(errMsg, t)); + } + }, dbCallbackExecutorService); + return futureToSet; + } + + private ListenableFuture> findRelationByQuery(TenantId tenantId, Edge edge, + EntityId entityId, EntitySearchDirection direction) { + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(new RelationsSearchParameters(entityId, direction, -1, false)); + return relationService.findByQuery(tenantId, query); + } + + @Override + public ListenableFuture processDeviceCredentialsRequestMsg(TenantId tenantId, Edge edge, DeviceCredentialsRequestMsg deviceCredentialsRequestMsg) { + log.trace("[{}] processDeviceCredentialsRequestMsg [{}][{}]", tenantId, edge.getName(), deviceCredentialsRequestMsg); + if (deviceCredentialsRequestMsg.getDeviceIdMSB() == 0 || deviceCredentialsRequestMsg.getDeviceIdLSB() == 0) { + return Futures.immediateFuture(null); + } + DeviceId deviceId = new DeviceId(new UUID(deviceCredentialsRequestMsg.getDeviceIdMSB(), deviceCredentialsRequestMsg.getDeviceIdLSB())); + return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.DEVICE, + EdgeEventActionType.CREDENTIALS_UPDATED, deviceId, null); + } + + @Override + public ListenableFuture processUserCredentialsRequestMsg(TenantId tenantId, Edge edge, UserCredentialsRequestMsg userCredentialsRequestMsg) { + log.trace("[{}] processUserCredentialsRequestMsg [{}][{}]", tenantId, edge.getName(), userCredentialsRequestMsg); + if (userCredentialsRequestMsg.getUserIdMSB() == 0 || userCredentialsRequestMsg.getUserIdLSB() == 0) { + return Futures.immediateFuture(null); + } + UserId userId = new UserId(new UUID(userCredentialsRequestMsg.getUserIdMSB(), userCredentialsRequestMsg.getUserIdLSB())); + return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.USER, + EdgeEventActionType.CREDENTIALS_UPDATED, userId, null); + } + + @Override + public ListenableFuture processWidgetBundleTypesRequestMsg(TenantId tenantId, Edge edge, + WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg) { + log.trace("[{}] processWidgetBundleTypesRequestMsg [{}][{}]", tenantId, edge.getName(), widgetBundleTypesRequestMsg); + List> futures = new ArrayList<>(); + if (widgetBundleTypesRequestMsg.getWidgetBundleIdMSB() != 0 && widgetBundleTypesRequestMsg.getWidgetBundleIdLSB() != 0) { + WidgetsBundleId widgetsBundleId = new WidgetsBundleId(new UUID(widgetBundleTypesRequestMsg.getWidgetBundleIdMSB(), widgetBundleTypesRequestMsg.getWidgetBundleIdLSB())); + WidgetsBundle widgetsBundleById = widgetsBundleService.findWidgetsBundleById(tenantId, widgetsBundleId); + if (widgetsBundleById != null) { + List widgetTypesToPush = + widgetTypeService.findWidgetTypesByTenantIdAndBundleAlias(widgetsBundleById.getTenantId(), widgetsBundleById.getAlias()); + for (WidgetType widgetType : widgetTypesToPush) { + futures.add(saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.WIDGET_TYPE, EdgeEventActionType.ADDED, widgetType.getId(), null)); + } + } + } + return Futures.transform(Futures.allAsList(futures), voids -> null, dbCallbackExecutorService); + } + + @Override + public ListenableFuture processEntityViewsRequestMsg(TenantId tenantId, Edge edge, EntityViewsRequestMsg entityViewsRequestMsg) { + log.trace("[{}] processEntityViewsRequestMsg [{}][{}]", tenantId, edge.getName(), entityViewsRequestMsg); + EntityId entityId = EntityIdFactory.getByTypeAndUuid( + EntityType.valueOf(entityViewsRequestMsg.getEntityType()), + new UUID(entityViewsRequestMsg.getEntityIdMSB(), entityViewsRequestMsg.getEntityIdLSB())); + SettableFuture futureToSet = SettableFuture.create(); + Futures.addCallback(entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List entityViews) { + if (entityViews == null || entityViews.isEmpty()) { + futureToSet.set(null); + return; + } + List> futures = new ArrayList<>(); + for (EntityView entityView : entityViews) { + ListenableFuture future = relationService.checkRelationAsync(tenantId, edge.getId(), entityView.getId(), + EntityRelation.CONTAINS_TYPE, RelationTypeGroup.EDGE); + futures.add(Futures.transformAsync(future, result -> { + if (Boolean.TRUE.equals(result)) { + return saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.ENTITY_VIEW, + EdgeEventActionType.ADDED, entityView.getId(), null); + } else { + return Futures.immediateFuture(null); + } + }, dbCallbackExecutorService)); + } + Futures.addCallback(Futures.allAsList(futures), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List result) { + futureToSet.set(null); + } + + @Override + public void onFailure(Throwable t) { + log.error("Exception during loading relation to edge on sync!", t); + futureToSet.setException(t); + } + }, dbCallbackExecutorService); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Can't find entity views by entity id [{}]", tenantId, entityId, t); + futureToSet.setException(t); + } + }, dbCallbackExecutorService); + return futureToSet; + } + + private ListenableFuture saveEdgeEvent(TenantId tenantId, + EdgeId edgeId, + EdgeEventType type, + EdgeEventActionType action, + EntityId entityId, + JsonNode body) { + log.trace("Pushing edge event to edge queue. tenantId [{}], edgeId [{}], type [{}], action[{}], entityId [{}], body [{}]", + tenantId, edgeId, type, action, entityId, body); + + EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(tenantId, edgeId, type, action, entityId, body); + + return Futures.transform(edgeEventService.saveAsync(edgeEvent), unused -> { + tbClusterService.onEdgeEventUpdate(tenantId, edgeId); + return null; + }, dbCallbackExecutorService); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java new file mode 100644 index 0000000..4d51223 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/sync/EdgeRequestsService.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.sync; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; + +public interface EdgeRequestsService { + + ListenableFuture processRuleChainMetadataRequestMsg(TenantId tenantId, Edge edge, RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg); + + ListenableFuture processAttributesRequestMsg(TenantId tenantId, Edge edge, AttributesRequestMsg attributesRequestMsg); + + ListenableFuture processRelationRequestMsg(TenantId tenantId, Edge edge, RelationRequestMsg relationRequestMsg); + + ListenableFuture processDeviceCredentialsRequestMsg(TenantId tenantId, Edge edge, DeviceCredentialsRequestMsg deviceCredentialsRequestMsg); + + ListenableFuture processUserCredentialsRequestMsg(TenantId tenantId, Edge edge, UserCredentialsRequestMsg userCredentialsRequestMsg); + + ListenableFuture processWidgetBundleTypesRequestMsg(TenantId tenantId, Edge edge, WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg); + + ListenableFuture processEntityViewsRequestMsg(TenantId tenantId, Edge edge, EntityViewsRequestMsg entityViewsRequestMsg); +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java new file mode 100644 index 0000000..9f34c06 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -0,0 +1,131 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy; + +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.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; +import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +public abstract class AbstractTbEntityService { + + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + + @Autowired + protected DbCallbackExecutorService dbExecutor; + @Autowired(required = false) + protected TbNotificationEntityService notificationEntityService; + @Autowired(required = false) + protected EdgeService edgeService; + @Autowired + protected AlarmService alarmService; + @Autowired + protected AlarmSubscriptionService alarmSubscriptionService; + @Autowired + protected CustomerService customerService; + @Autowired + protected TbClusterService tbClusterService; + @Autowired(required = false) + private EntitiesVersionControlService vcService; + + protected ListenableFuture removeAlarmsByEntityId(TenantId tenantId, EntityId entityId) { + ListenableFuture> alarmsFuture = + alarmService.findAlarms(tenantId, new AlarmQuery(entityId, new TimePageLink(Integer.MAX_VALUE), null, null, false)); + + ListenableFuture> alarmIdsFuture = Futures.transform(alarmsFuture, page -> + page.getData().stream().map(AlarmInfo::getId).collect(Collectors.toList()), dbExecutor); + + return Futures.transform(alarmIdsFuture, ids -> { + ids.stream().map(alarmId -> alarmService.deleteAlarm(tenantId, alarmId)).collect(Collectors.toList()); + return null; + }, dbExecutor); + } + + protected T checkNotNull(T reference) throws ThingsboardException { + return checkNotNull(reference, "Requested item wasn't found!"); + } + + protected T checkNotNull(T reference, String notFoundMessage) throws ThingsboardException { + if (reference == null) { + throw new ThingsboardException(notFoundMessage, ThingsboardErrorCode.ITEM_NOT_FOUND); + } + return reference; + } + + protected T checkNotNull(Optional reference) throws ThingsboardException { + return checkNotNull(reference, "Requested item wasn't found!"); + } + + protected T checkNotNull(Optional reference, String notFoundMessage) throws ThingsboardException { + if (reference.isPresent()) { + return reference.get(); + } else { + throw new ThingsboardException(notFoundMessage, ThingsboardErrorCode.ITEM_NOT_FOUND); + } + } + + protected I emptyId(EntityType entityType) { + return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); + } + + protected ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + if (vcService != null) { + return vcService.autoCommit(user, entityId); + } else { + // We do not support auto-commit for rule engine + return Futures.immediateFailedFuture(new RuntimeException("Operation not supported!")); + } + } + + protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + if (vcService != null) { + return vcService.autoCommit(user, entityType, entityIds); + } else { + // We do not support auto-commit for rule engine + return Futures.immediateFailedFuture(new RuntimeException("Operation not supported!")); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbNotificationEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbNotificationEntityService.java new file mode 100644 index 0000000..8f8531a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbNotificationEntityService.java @@ -0,0 +1,327 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.msg.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.security.DeviceCredentials; +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.service.action.EntityActionService; +import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultTbNotificationEntityService implements TbNotificationEntityService { + + private final EntityActionService entityActionService; + private final TbClusterService tbClusterService; + private final GatewayNotificationsService gatewayNotificationsService; + + @Override + public void logEntityAction(TenantId tenantId, I entityId, ActionType actionType, + User user, Exception e, Object... additionalInfo) { + logEntityAction(tenantId, entityId, null, null, actionType, user, e, additionalInfo); + } + + @Override + public void logEntityAction(TenantId tenantId, I entityId, E entity, + ActionType actionType, User user, Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, null, actionType, user, null, additionalInfo); + } + + @Override + public void logEntityAction(TenantId tenantId, I entityId, E entity, + ActionType actionType, User user, Exception e, + Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, null, actionType, user, e, additionalInfo); + } + + @Override + public void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, + ActionType actionType, User user, Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, null, additionalInfo); + } + + @Override + public void logEntityAction(TenantId tenantId, I entityId, E entity, + CustomerId customerId, ActionType actionType, + User user, Exception e, Object... additionalInfo) { + if (user != null) { + entityActionService.logEntityAction(user, entityId, entity, customerId, actionType, e, additionalInfo); + } else if (e == null) { + entityActionService.pushEntityActionToRuleEngine(entityId, entity, tenantId, customerId, actionType, null, additionalInfo); + } + } + + @Override + public void notifyDeleteEntity(TenantId tenantId, I entityId, E entity, + CustomerId customerId, ActionType actionType, + List relatedEdgeIds, + User user, Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, additionalInfo); + sendDeleteNotificationMsg(tenantId, entityId, relatedEdgeIds, null); + } + + @Override + public void notifyDeleteAlarm(TenantId tenantId, Alarm alarm, EntityId originatorId, CustomerId customerId, + List relatedEdgeIds, User user, String body, Object... additionalInfo) { + logEntityAction(tenantId, originatorId, alarm, customerId, ActionType.DELETED, user, additionalInfo); + sendAlarmDeleteNotificationMsg(tenantId, alarm, relatedEdgeIds, body); + } + + @Override + public void notifyDeleteRuleChain(TenantId tenantId, RuleChain ruleChain, List relatedEdgeIds, User user) { + RuleChainId ruleChainId = ruleChain.getId(); + logEntityAction(tenantId, ruleChainId, ruleChain, null, ActionType.DELETED, user, null, ruleChainId.toString()); + if (RuleChainType.EDGE.equals(ruleChain.getType())) { + sendDeleteNotificationMsg(tenantId, ruleChainId, relatedEdgeIds, null); + } + } + + @Override + public void notifySendMsgToEdgeService(TenantId tenantId, I entityId, EdgeEventActionType edgeEventActionType) { + sendEntityNotificationMsg(tenantId, entityId, edgeEventActionType); + } + + @Override + public void notifyAssignOrUnassignEntityToCustomer(TenantId tenantId, I entityId, + CustomerId customerId, E entity, + ActionType actionType, + User user, boolean sendToEdge, + Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, additionalInfo); + + if (sendToEdge) { + sendEntityNotificationMsg(tenantId, entityId, edgeTypeByActionType(actionType), JacksonUtil.toString(customerId)); + } + } + + @Override + public void notifyAssignOrUnassignEntityToEdge(TenantId tenantId, I entityId, + CustomerId customerId, EdgeId edgeId, + E entity, ActionType actionType, + User user, Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, additionalInfo); + sendEntityAssignToEdgeNotificationMsg(tenantId, edgeId, entityId, edgeTypeByActionType(actionType)); + } + + @Override + public void notifyCreateOrUpdateTenant(Tenant tenant, ComponentLifecycleEvent event) { + tbClusterService.onTenantChange(tenant, null); + tbClusterService.broadcastEntityStateChangeEvent(tenant.getId(), tenant.getId(), event); + } + + @Override + public void notifyDeleteTenant(Tenant tenant) { + tbClusterService.onTenantDelete(tenant, null); + tbClusterService.broadcastEntityStateChangeEvent(tenant.getId(), tenant.getId(), ComponentLifecycleEvent.DELETED); + } + + @Override + public void notifyCreateOrUpdateDevice(TenantId tenantId, DeviceId deviceId, CustomerId customerId, + Device device, Device oldDevice, ActionType actionType, + User user, Object... additionalInfo) { + tbClusterService.onDeviceUpdated(device, oldDevice); + logEntityAction(tenantId, deviceId, device, customerId, actionType, user, additionalInfo); + } + + @Override + public void notifyDeleteDevice(TenantId tenantId, DeviceId deviceId, CustomerId customerId, Device device, + List relatedEdgeIds, User user, Object... additionalInfo) { + gatewayNotificationsService.onDeviceDeleted(device); + tbClusterService.onDeviceDeleted(device, null); + + notifyDeleteEntity(tenantId, deviceId, device, customerId, ActionType.DELETED, relatedEdgeIds, user, additionalInfo); + } + + @Override + public void notifyUpdateDeviceCredentials(TenantId tenantId, DeviceId deviceId, CustomerId customerId, Device device, + DeviceCredentials deviceCredentials, User user) { + tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(tenantId, deviceCredentials.getDeviceId(), deviceCredentials), null); + sendEntityNotificationMsg(tenantId, deviceId, EdgeEventActionType.CREDENTIALS_UPDATED); + logEntityAction(tenantId, deviceId, device, customerId, ActionType.CREDENTIALS_UPDATED, user, deviceCredentials); + } + + @Override + public void notifyAssignDeviceToTenant(TenantId tenantId, TenantId newTenantId, DeviceId deviceId, CustomerId customerId, + Device device, Tenant tenant, User user, Object... additionalInfo) { + logEntityAction(tenantId, deviceId, device, customerId, ActionType.ASSIGNED_TO_TENANT, user, additionalInfo); + pushAssignedFromNotification(tenant, newTenantId, device); + } + + @Override + public void notifyCreateOrUpdateEntity(TenantId tenantId, I entityId, E entity, + CustomerId customerId, ActionType actionType, + User user, Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, additionalInfo); + if (actionType == ActionType.UPDATED) { + sendEntityNotificationMsg(tenantId, entityId, EdgeEventActionType.UPDATED); + } + } + + @Override + public void notifyCreateOrUpdateOrDeleteEdge(TenantId tenantId, EdgeId edgeId, CustomerId customerId, Edge edge, + ActionType actionType, User user, Object... additionalInfo) { + ComponentLifecycleEvent lifecycleEvent; + switch (actionType) { + case ADDED: + lifecycleEvent = ComponentLifecycleEvent.CREATED; + break; + case UPDATED: + lifecycleEvent = ComponentLifecycleEvent.UPDATED; + break; + case DELETED: + lifecycleEvent = ComponentLifecycleEvent.DELETED; + break; + default: + throw new IllegalArgumentException("Unknown actionType: " + actionType); + } + tbClusterService.broadcastEntityStateChangeEvent(tenantId, edgeId, lifecycleEvent); + logEntityAction(tenantId, edgeId, edge, customerId, actionType, user, additionalInfo); + } + + @Override + public void notifyCreateOrUpdateAlarm(Alarm alarm, ActionType actionType, User user, Object... additionalInfo) { + logEntityAction(alarm.getTenantId(), alarm.getOriginator(), alarm, alarm.getCustomerId(), actionType, user, additionalInfo); + sendEntityNotificationMsg(alarm.getTenantId(), alarm.getId(), edgeTypeByActionType(actionType)); + } + + @Override + public void notifyCreateOrUpdateOrDelete(TenantId tenantId, CustomerId customerId, + I entityId, E entity, User user, + ActionType actionType, boolean sendNotifyMsgToEdge, Exception e, + Object... additionalInfo) { + logEntityAction(tenantId, entityId, entity, customerId, actionType, user, e, additionalInfo); + if (sendNotifyMsgToEdge) { + sendEntityNotificationMsg(tenantId, entityId, edgeTypeByActionType(actionType)); + } + } + + @Override + public void notifyRelation(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user, + ActionType actionType, Object... additionalInfo) { + logEntityAction(tenantId, relation.getFrom(), null, customerId, actionType, user, additionalInfo); + logEntityAction(tenantId, relation.getTo(), null, customerId, actionType, user, additionalInfo); + if (!EntityType.EDGE.equals(relation.getFrom().getEntityType()) && !EntityType.EDGE.equals(relation.getTo().getEntityType())) { + sendNotificationMsgToEdge(tenantId, null, null, JacksonUtil.toString(relation), + EdgeEventType.RELATION, edgeTypeByActionType(actionType)); + } + } + + private void sendEntityNotificationMsg(TenantId tenantId, EntityId entityId, EdgeEventActionType action) { + sendEntityNotificationMsg(tenantId, entityId, action, null); + } + + private void sendEntityNotificationMsg(TenantId tenantId, EntityId entityId, EdgeEventActionType action, String body) { + sendNotificationMsgToEdge(tenantId, null, entityId, body, null, action); + } + + private void sendAlarmDeleteNotificationMsg(TenantId tenantId, Alarm alarm, List edgeIds, String body) { + sendDeleteNotificationMsg(tenantId, alarm.getId(), edgeIds, body); + } + + private void sendDeleteNotificationMsg(TenantId tenantId, EntityId entityId, List edgeIds, String body) { + if (edgeIds != null && !edgeIds.isEmpty()) { + for (EdgeId edgeId : edgeIds) { + sendNotificationMsgToEdge(tenantId, edgeId, entityId, body, null, EdgeEventActionType.DELETED); + } + } + } + + private void sendEntityAssignToEdgeNotificationMsg(TenantId tenantId, EdgeId edgeId, EntityId entityId, EdgeEventActionType action) { + sendNotificationMsgToEdge(tenantId, edgeId, entityId, null, null, action); + } + + private void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, + EdgeEventType type, EdgeEventActionType action) { + tbClusterService.sendNotificationMsgToEdge(tenantId, edgeId, entityId, body, type, action); + } + + private void pushAssignedFromNotification(Tenant currentTenant, TenantId newTenantId, Device assignedDevice) { + String data = JacksonUtil.toString(JacksonUtil.valueToTree(assignedDevice)); + if (data != null) { + TbMsg tbMsg = TbMsg.newMsg(DataConstants.ENTITY_ASSIGNED_FROM_TENANT, assignedDevice.getId(), + assignedDevice.getCustomerId(), getMetaDataForAssignedFrom(currentTenant), TbMsgDataType.JSON, data); + tbClusterService.pushMsgToRuleEngine(newTenantId, assignedDevice.getId(), tbMsg, null); + } + } + + private TbMsgMetaData getMetaDataForAssignedFrom(Tenant tenant) { + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("assignedFromTenantId", tenant.getId().getId().toString()); + metaData.putValue("assignedFromTenantName", tenant.getName()); + return metaData; + } + + public static EdgeEventActionType edgeTypeByActionType(ActionType actionType) { + switch (actionType) { + case ADDED: + return EdgeEventActionType.ADDED; + case UPDATED: + return EdgeEventActionType.UPDATED; + case ALARM_ACK: + return EdgeEventActionType.ALARM_ACK; + case ALARM_CLEAR: + return EdgeEventActionType.ALARM_CLEAR; + case DELETED: + return EdgeEventActionType.DELETED; + case RELATION_ADD_OR_UPDATE: + return EdgeEventActionType.RELATION_ADD_OR_UPDATE; + case RELATION_DELETED: + return EdgeEventActionType.RELATION_DELETED; + case ASSIGNED_TO_CUSTOMER: + return EdgeEventActionType.ASSIGNED_TO_CUSTOMER; + case UNASSIGNED_FROM_CUSTOMER: + return EdgeEventActionType.UNASSIGNED_FROM_CUSTOMER; + case ASSIGNED_TO_EDGE: + return EdgeEventActionType.ASSIGNED_TO_EDGE; + case UNASSIGNED_FROM_EDGE: + return EdgeEventActionType.UNASSIGNED_FROM_EDGE; + default: + return null; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java new file mode 100644 index 0000000..7c265a4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy; + +import org.thingsboard.server.common.data.User; + +public interface SimpleTbEntityService { + + default T save(T entity) throws Exception { + return save(entity, null); + } + + T save(T entity, User user) throws Exception; + + void delete(T entity, User user); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/TbNotificationEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/TbNotificationEntityService.java new file mode 100644 index 0000000..dc6e148 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/TbNotificationEntityService.java @@ -0,0 +1,112 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy; + +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +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.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.security.DeviceCredentials; + +import java.util.List; + +public interface TbNotificationEntityService { + + void logEntityAction(TenantId tenantId, I entityId, ActionType actionType, User user, + Exception e, Object... additionalInfo); + + void logEntityAction(TenantId tenantId, I entityId, E entity, ActionType actionType, + User user, Object... additionalInfo); + + void logEntityAction(TenantId tenantId, I entityId, E entity, ActionType actionType, + User user, Exception e, Object... additionalInfo); + + void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, + ActionType actionType, User user, Object... additionalInfo); + + void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, + ActionType actionType, User user, Exception e, + Object... additionalInfo); + + void notifyCreateOrUpdateEntity(TenantId tenantId, I entityId, E entity, + CustomerId customerId, ActionType actionType, + User user, Object... additionalInfo); + + void notifyDeleteEntity(TenantId tenantId, I entityId, E entity, + CustomerId customerId, ActionType actionType, + List relatedEdgeIds, + User user, Object... additionalInfo); + + void notifyDeleteAlarm(TenantId tenantId, Alarm alarm, EntityId originatorId, CustomerId customerId, + List relatedEdgeIds, User user, String body, Object... additionalInfo); + + void notifyDeleteRuleChain(TenantId tenantId, RuleChain ruleChain, + List relatedEdgeIds, User user); + + void notifySendMsgToEdgeService(TenantId tenantId, I entityId, EdgeEventActionType edgeEventActionType); + + void notifyAssignOrUnassignEntityToCustomer(TenantId tenantId, I entityId, + CustomerId customerId, E entity, + ActionType actionType, + User user, boolean sendToEdge, + Object... additionalInfo); + + void notifyAssignOrUnassignEntityToEdge(TenantId tenantId, I entityId, + CustomerId customerId, EdgeId edgeId, + E entity, ActionType actionType, + User user, Object... additionalInfo); + + void notifyCreateOrUpdateTenant(Tenant tenant, ComponentLifecycleEvent event); + + void notifyDeleteTenant(Tenant tenant); + + void notifyCreateOrUpdateDevice(TenantId tenantId, DeviceId deviceId, CustomerId customerId, Device device, + Device oldDevice, ActionType actionType, User user, Object... additionalInfo); + + void notifyDeleteDevice(TenantId tenantId, DeviceId deviceId, CustomerId customerId, Device device, + List relatedEdgeIds, User user, Object... additionalInfo); + + void notifyUpdateDeviceCredentials(TenantId tenantId, DeviceId deviceId, CustomerId customerId, Device device, + DeviceCredentials deviceCredentials, User user); + + void notifyAssignDeviceToTenant(TenantId tenantId, TenantId newTenantId, DeviceId deviceId, CustomerId customerId, + Device device, Tenant tenant, User user, Object... additionalInfo); + + void notifyCreateOrUpdateOrDeleteEdge(TenantId tenantId, EdgeId edgeId, CustomerId customerId, Edge edge, ActionType actionType, + User user, Object... additionalInfo); + + void notifyCreateOrUpdateAlarm(Alarm alarm, ActionType actionType, User user, Object... additionalInfo); + + void notifyCreateOrUpdateOrDelete(TenantId tenantId, CustomerId customerId, + I entityId, E entity, User user, + ActionType actionType, boolean sendNotifyMsgToEdge, + Exception e, Object... additionalInfo); + + void notifyRelation(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user, + ActionType actionType, Object... additionalInfo); +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java new file mode 100644 index 0000000..8157eff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmService.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.alarm; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.util.List; + +@Service +@AllArgsConstructor +public class DefaultTbAlarmService extends AbstractTbEntityService implements TbAlarmService { + + @Override + public Alarm save(Alarm alarm, User user) throws ThingsboardException { + ActionType actionType = alarm.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = alarm.getTenantId(); + try { + Alarm savedAlarm = checkNotNull(alarmSubscriptionService.createOrUpdateAlarm(alarm)); + notificationEntityService.notifyCreateOrUpdateAlarm(savedAlarm, actionType, user); + return savedAlarm; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ALARM), alarm, actionType, user, e); + throw e; + } + } + + @Override + public ListenableFuture ack(Alarm alarm, User user) { + long ackTs = System.currentTimeMillis(); + ListenableFuture future = alarmSubscriptionService.ackAlarm(alarm.getTenantId(), alarm.getId(), ackTs); + return Futures.transform(future, result -> { + alarm.setAckTs(ackTs); + alarm.setStatus(alarm.getStatus().isCleared() ? AlarmStatus.CLEARED_ACK : AlarmStatus.ACTIVE_ACK); + notificationEntityService.notifyCreateOrUpdateAlarm(alarm, ActionType.ALARM_ACK, user); + return null; + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture clear(Alarm alarm, User user) { + long clearTs = System.currentTimeMillis(); + ListenableFuture future = alarmSubscriptionService.clearAlarm(alarm.getTenantId(), alarm.getId(), null, clearTs); + return Futures.transform(future, result -> { + alarm.setClearTs(clearTs); + alarm.setStatus(alarm.getStatus().isAck() ? AlarmStatus.CLEARED_ACK : AlarmStatus.CLEARED_UNACK); + notificationEntityService.notifyCreateOrUpdateAlarm(alarm, ActionType.ALARM_CLEAR, user); + return null; + }, MoreExecutors.directExecutor()); + } + + @Override + public Boolean delete(Alarm alarm, User user) { + TenantId tenantId = alarm.getTenantId(); + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, alarm.getOriginator()); + notificationEntityService.notifyDeleteAlarm(tenantId, alarm, alarm.getOriginator(), alarm.getCustomerId(), + relatedEdgeIds, user, JacksonUtil.toString(alarm)); + return alarmSubscriptionService.deleteAlarm(tenantId, alarm.getId()); + } +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/TbAlarmService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/TbAlarmService.java new file mode 100644 index 0000000..8cd8d0b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/TbAlarmService.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.alarm; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.exception.ThingsboardException; + +public interface TbAlarmService { + + Alarm save(Alarm entity, User user) throws ThingsboardException; + + ListenableFuture ack(Alarm alarm, User user); + + ListenableFuture clear(Alarm alarm, User user); + + Boolean delete(Alarm alarm, User user); +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java new file mode 100644 index 0000000..22edb2a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -0,0 +1,178 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.asset; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; + +import java.util.List; + +import static org.thingsboard.server.dao.asset.BaseAssetService.TB_SERVICE_QUEUE; + +@Service +@AllArgsConstructor +public class DefaultTbAssetService extends AbstractTbEntityService implements TbAssetService { + + private final AssetService assetService; + private final TbAssetProfileCache assetProfileCache; + + @Override + public Asset save(Asset asset, User user) throws Exception { + ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = asset.getTenantId(); + try { + if (TB_SERVICE_QUEUE.equals(asset.getType())) { + throw new ThingsboardException("Unable to save asset with type " + TB_SERVICE_QUEUE, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else if (asset.getAssetProfileId() != null) { + AssetProfile assetProfile = assetProfileCache.get(tenantId, asset.getAssetProfileId()); + if (assetProfile != null && TB_SERVICE_QUEUE.equals(assetProfile.getName())) { + throw new ThingsboardException("Unable to save asset with profile " + TB_SERVICE_QUEUE, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + Asset savedAsset = checkNotNull(assetService.saveAsset(asset)); + autoCommit(user, savedAsset.getId()); + notificationEntityService.notifyCreateOrUpdateEntity(tenantId, savedAsset.getId(), savedAsset, + asset.getCustomerId(), actionType, user); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, savedAsset.getId(), + asset.getId() == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), asset, actionType, user, e); + throw e; + } + } + + @Override + public ListenableFuture delete(Asset asset, User user) { + TenantId tenantId = asset.getTenantId(); + AssetId assetId = asset.getId(); + try { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, assetId); + assetService.deleteAsset(tenantId, assetId); + notificationEntityService.notifyDeleteEntity(tenantId, assetId, asset, asset.getCustomerId(), + ActionType.DELETED, relatedEdgeIds, user, assetId.toString()); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, assetId, ComponentLifecycleEvent.DELETED); + return removeAlarmsByEntityId(tenantId, assetId); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), ActionType.DELETED, user, e, + assetId.toString()); + throw e; + } + } + + @Override + public Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + CustomerId customerId = customer.getId(); + try { + Asset savedAsset = checkNotNull(assetService.assignAssetToCustomer(tenantId, assetId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, assetId, customerId, savedAsset, + actionType, user, true, assetId.toString(), customerId.toString(), customer.getName()); + + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), actionType, user, e, + assetId.toString(), customerId.toString()); + throw e; + } + } + + @Override + public Asset unassignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + try { + Asset savedAsset = checkNotNull(assetService.unassignAssetFromCustomer(tenantId, assetId)); + CustomerId customerId = customer.getId(); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, assetId, customerId, savedAsset, + actionType, user, true, assetId.toString(), customerId.toString(), customer.getName()); + + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), actionType, user, e, assetId.toString()); + throw e; + } + } + + @Override + public Asset assignAssetToPublicCustomer(TenantId tenantId, AssetId assetId, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + try { + Customer publicCustomer = customerService.findOrCreatePublicCustomer(tenantId); + Asset savedAsset = checkNotNull(assetService.assignAssetToCustomer(tenantId, assetId, publicCustomer.getId())); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, assetId, savedAsset.getCustomerId(), savedAsset, + actionType, user, false, actionType.toString(), publicCustomer.getId().toString(), publicCustomer.getName()); + + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), actionType, user, e, assetId.toString()); + throw e; + } + } + + @Override + public Asset assignAssetToEdge(TenantId tenantId, AssetId assetId, Edge edge, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_EDGE; + EdgeId edgeId = edge.getId(); + try { + Asset savedAsset = checkNotNull(assetService.assignAssetToEdge(tenantId, assetId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, assetId, savedAsset.getCustomerId(), + edgeId, savedAsset, actionType, user, assetId.toString(), edgeId.toString(), edge.getName()); + + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), actionType, + user, e, assetId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public Asset unassignAssetFromEdge(TenantId tenantId, Asset asset, Edge edge, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_EDGE; + AssetId assetId = asset.getId(); + EdgeId edgeId = edge.getId(); + try { + Asset savedAsset = checkNotNull(assetService.unassignAssetFromEdge(tenantId, assetId, edgeId)); + + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, assetId, asset.getCustomerId(), + edgeId, asset, actionType, user, assetId.toString(), edgeId.toString(), edge.getName()); + + return savedAsset; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET), actionType, + user, e, assetId.toString(), edgeId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java new file mode 100644 index 0000000..303aabc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.asset; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbAssetService { + + Asset save(Asset asset, User user) throws Exception; + + ListenableFuture delete(Asset asset, User user); + + Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException; + + Asset unassignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException; + + Asset assignAssetToPublicCustomer(TenantId tenantId, AssetId assetId, User user) throws ThingsboardException; + + Asset assignAssetToEdge(TenantId tenantId, AssetId assetId, Edge edge, User user) throws ThingsboardException; + + Asset unassignAssetFromEdge(TenantId tenantId, Asset asset, Edge edge, User user) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/DefaultTbAssetProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/DefaultTbAssetProfileService.java new file mode 100644 index 0000000..d053b99 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/DefaultTbAssetProfileService.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.asset.profile; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import static org.thingsboard.server.dao.asset.BaseAssetService.TB_SERVICE_QUEUE; + +@Service +@TbCoreComponent +@AllArgsConstructor +@Slf4j +public class DefaultTbAssetProfileService extends AbstractTbEntityService implements TbAssetProfileService { + + private final AssetProfileService assetProfileService; + + @Override + public AssetProfile save(AssetProfile assetProfile, User user) throws Exception { + ActionType actionType = assetProfile.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = assetProfile.getTenantId(); + try { + if (TB_SERVICE_QUEUE.equals(assetProfile.getName())) { + throw new ThingsboardException("Unable to save asset profile with name " + TB_SERVICE_QUEUE, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else if (assetProfile.getId() != null) { + AssetProfile foundAssetProfile = assetProfileService.findAssetProfileById(tenantId, assetProfile.getId()); + if (foundAssetProfile != null && TB_SERVICE_QUEUE.equals(foundAssetProfile.getName())) { + throw new ThingsboardException("Updating asset profile with name " + TB_SERVICE_QUEUE + " is prohibited!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + AssetProfile savedAssetProfile = checkNotNull(assetProfileService.saveAssetProfile(assetProfile)); + autoCommit(user, savedAssetProfile.getId()); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, savedAssetProfile.getId(), + actionType.equals(ActionType.ADDED) ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, savedAssetProfile.getId(), + savedAssetProfile, user, actionType, true, null); + return savedAssetProfile; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET_PROFILE), assetProfile, actionType, user, e); + throw e; + } + } + + @Override + public void delete(AssetProfile assetProfile, User user) { + AssetProfileId assetProfileId = assetProfile.getId(); + TenantId tenantId = assetProfile.getTenantId(); + try { + assetProfileService.deleteAssetProfile(tenantId, assetProfileId); + + tbClusterService.broadcastEntityStateChangeEvent(tenantId, assetProfileId, ComponentLifecycleEvent.DELETED); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, assetProfileId, assetProfile, + user, ActionType.DELETED, true, null, assetProfileId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET_PROFILE), ActionType.DELETED, + user, e, assetProfileId.toString()); + throw e; + } + } + + @Override + public AssetProfile setDefaultAssetProfile(AssetProfile assetProfile, AssetProfile previousDefaultAssetProfile, User user) throws ThingsboardException { + TenantId tenantId = assetProfile.getTenantId(); + AssetProfileId assetProfileId = assetProfile.getId(); + try { + if (assetProfileService.setDefaultAssetProfile(tenantId, assetProfileId)) { + if (previousDefaultAssetProfile != null) { + previousDefaultAssetProfile = assetProfileService.findAssetProfileById(tenantId, previousDefaultAssetProfile.getId()); + notificationEntityService.logEntityAction(tenantId, previousDefaultAssetProfile.getId(), previousDefaultAssetProfile, + ActionType.UPDATED, user); + } + assetProfile = assetProfileService.findAssetProfileById(tenantId, assetProfileId); + + notificationEntityService.logEntityAction(tenantId, assetProfileId, assetProfile, ActionType.UPDATED, user); + } + return assetProfile; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ASSET_PROFILE), ActionType.UPDATED, + user, e, assetProfileId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/TbAssetProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/TbAssetProfileService.java new file mode 100644 index 0000000..e451b32 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/profile/TbAssetProfileService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.asset.profile; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +public interface TbAssetProfileService extends SimpleTbEntityService { + + AssetProfile setDefaultAssetProfile(AssetProfile assetProfile, AssetProfile previousDefaultAssetProfile, User user) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java new file mode 100644 index 0000000..986d67d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.customer; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.util.List; + +@Service +@AllArgsConstructor +public class DefaultTbCustomerService extends AbstractTbEntityService implements TbCustomerService { + + @Override + public Customer save(Customer customer, User user) throws Exception { + ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = customer.getTenantId(); + try { + Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer)); + autoCommit(user, savedCustomer.getId()); + notificationEntityService.notifyCreateOrUpdateEntity(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user); + return savedCustomer; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.CUSTOMER), customer, actionType, user, e); + throw e; + } + } + + @Override + public void delete(Customer customer, User user) { + TenantId tenantId = customer.getTenantId(); + CustomerId customerId = customer.getId(); + try { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, customer.getId()); + customerService.deleteCustomer(tenantId, customerId); + notificationEntityService.notifyDeleteEntity(tenantId, customer.getId(), customer, customerId, + ActionType.DELETED, relatedEdgeIds, user, customerId.toString()); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, customer.getId(), ComponentLifecycleEvent.DELETED); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.CUSTOMER), ActionType.DELETED, + user, e, customerId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java new file mode 100644 index 0000000..870de10 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.customer; + +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +public interface TbCustomerService extends SimpleTbEntityService { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java new file mode 100644 index 0000000..7fadab1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java @@ -0,0 +1,293 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.dashboard; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ShortCustomerInfo; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@TbCoreComponent +@AllArgsConstructor +public class DefaultTbDashboardService extends AbstractTbEntityService implements TbDashboardService { + + private final DashboardService dashboardService; + + @Override + public Dashboard save(Dashboard dashboard, User user) throws Exception { + ActionType actionType = dashboard.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = dashboard.getTenantId(); + try { + Dashboard savedDashboard = checkNotNull(dashboardService.saveDashboard(dashboard)); + autoCommit(user, savedDashboard.getId()); + notificationEntityService.notifyCreateOrUpdateEntity(tenantId, savedDashboard.getId(), savedDashboard, + null, actionType, user); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), dashboard, actionType, user, e); + throw e; + } + } + + @Override + public void delete(Dashboard dashboard, User user) { + DashboardId dashboardId = dashboard.getId(); + TenantId tenantId = dashboard.getTenantId(); + try { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, dashboardId); + dashboardService.deleteDashboard(tenantId, dashboardId); + notificationEntityService.notifyDeleteEntity(tenantId, dashboardId, dashboard, null, + ActionType.DELETED, relatedEdgeIds, user, dashboardId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), ActionType.DELETED, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard assignDashboardToCustomer(Dashboard dashboard, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + CustomerId customerId = customer.getId(); + DashboardId dashboardId = dashboard.getId(); + try { + Dashboard savedDashboard = checkNotNull(dashboardService.assignDashboardToCustomer(tenantId, dashboardId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, customerId, savedDashboard, + actionType, user, true, dashboardId.toString(), customerId.toString(), customer.getName()); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, + user, e, dashboardId.toString(), customerId.toString()); + throw e; + } + } + + @Override + public Dashboard assignDashboardToPublicCustomer(Dashboard dashboard, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Customer publicCustomer = customerService.findOrCreatePublicCustomer(tenantId); + Dashboard savedDashboard = checkNotNull(dashboardService.assignDashboardToCustomer(tenantId, dashboardId, publicCustomer.getId())); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, publicCustomer.getId(), savedDashboard, + actionType, user, false, dashboardId.toString(), + publicCustomer.getId().toString(), publicCustomer.getName()); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard unassignDashboardFromPublicCustomer(Dashboard dashboard, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Customer publicCustomer = customerService.findOrCreatePublicCustomer(tenantId); + Dashboard savedDashboard = checkNotNull(dashboardService.unassignDashboardFromCustomer(tenantId, dashboardId, publicCustomer.getId())); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, publicCustomer.getId(), dashboard, + actionType, user, false, dashboardId.toString(), + publicCustomer.getId().toString(), publicCustomer.getName()); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard updateDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Set addedCustomerIds = new HashSet<>(); + Set removedCustomerIds = new HashSet<>(); + for (CustomerId customerId : customerIds) { + if (!dashboard.isAssignedToCustomer(customerId)) { + addedCustomerIds.add(customerId); + } + } + + Set assignedCustomers = dashboard.getAssignedCustomers(); + if (assignedCustomers != null) { + for (ShortCustomerInfo customerInfo : assignedCustomers) { + if (!customerIds.contains(customerInfo.getCustomerId())) { + removedCustomerIds.add(customerInfo.getCustomerId()); + } + } + } + + if (addedCustomerIds.isEmpty() && removedCustomerIds.isEmpty()) { + return dashboard; + } else { + Dashboard savedDashboard = null; + for (CustomerId customerId : addedCustomerIds) { + savedDashboard = checkNotNull(dashboardService.assignDashboardToCustomer(tenantId, dashboardId, customerId)); + ShortCustomerInfo customerInfo = savedDashboard.getAssignedCustomerInfo(customerId); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, savedDashboard.getId(), customerId, savedDashboard, + actionType, user, true, dashboardId.toString(), customerId.toString(), customerInfo.getTitle()); + } + actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + for (CustomerId customerId : removedCustomerIds) { + ShortCustomerInfo customerInfo = dashboard.getAssignedCustomerInfo(customerId); + savedDashboard = checkNotNull(dashboardService.unassignDashboardFromCustomer(tenantId, dashboardId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, savedDashboard.getId(), customerId, savedDashboard, + ActionType.UNASSIGNED_FROM_CUSTOMER, user, true, dashboardId.toString(), customerId.toString(), customerInfo.getTitle()); + } + return savedDashboard; + } + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard addDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Set addedCustomerIds = new HashSet<>(); + for (CustomerId customerId : customerIds) { + if (!dashboard.isAssignedToCustomer(customerId)) { + addedCustomerIds.add(customerId); + } + } + if (addedCustomerIds.isEmpty()) { + return dashboard; + } else { + Dashboard savedDashboard = null; + for (CustomerId customerId : addedCustomerIds) { + savedDashboard = checkNotNull(dashboardService.assignDashboardToCustomer(tenantId, dashboardId, customerId)); + ShortCustomerInfo customerInfo = savedDashboard.getAssignedCustomerInfo(customerId); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, customerId, savedDashboard, + actionType, user, true, dashboardId.toString(), customerId.toString(), customerInfo.getTitle()); + } + return savedDashboard; + } + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard removeDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Set removedCustomerIds = new HashSet<>(); + for (CustomerId customerId : customerIds) { + if (dashboard.isAssignedToCustomer(customerId)) { + removedCustomerIds.add(customerId); + } + } + if (removedCustomerIds.isEmpty()) { + return dashboard; + } else { + Dashboard savedDashboard = null; + for (CustomerId customerId : removedCustomerIds) { + ShortCustomerInfo customerInfo = dashboard.getAssignedCustomerInfo(customerId); + savedDashboard = checkNotNull(dashboardService.unassignDashboardFromCustomer(tenantId, dashboardId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, customerId, savedDashboard, + actionType, user, true, dashboardId.toString(), customerId.toString(), customerInfo.getTitle()); + } + return savedDashboard; + } + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + + @Override + public Dashboard asignDashboardToEdge(TenantId tenantId, DashboardId dashboardId, Edge edge, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_EDGE; + EdgeId edgeId = edge.getId(); + try { + Dashboard savedDashboard = checkNotNull(dashboardService.assignDashboardToEdge(tenantId, dashboardId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, dashboardId, null, + edgeId, savedDashboard, actionType, user, dashboardId.toString(), + edgeId.toString(), edge.getName()); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + actionType, user, e, dashboardId.toString(), edgeId); + throw e; + } + } + + @Override + public Dashboard unassignDashboardFromEdge(Dashboard dashboard, Edge edge, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_EDGE; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + EdgeId edgeId = edge.getId(); + try { + Dashboard savedDevice = checkNotNull(dashboardService.unassignDashboardFromEdge(tenantId, dashboardId, edgeId)); + + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, dashboardId, null, + edgeId, dashboard, actionType, user, dashboardId.toString(), + edgeId.toString(), edge.getName()); + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, + dashboardId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public Dashboard unassignDashboardFromCustomer(Dashboard dashboard, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + TenantId tenantId = dashboard.getTenantId(); + DashboardId dashboardId = dashboard.getId(); + try { + Dashboard savedDashboard = checkNotNull(dashboardService.unassignDashboardFromCustomer(tenantId, dashboardId, customer.getId())); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, dashboardId, customer.getId(), savedDashboard, + actionType, user, true, dashboardId.toString(), customer.getId().toString(), customer.getName()); + return savedDashboard; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DASHBOARD), actionType, user, e, dashboardId.toString()); + throw e; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java new file mode 100644 index 0000000..73d7ea5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.dashboard; + +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +import java.util.Set; + +public interface TbDashboardService extends SimpleTbEntityService { + + Dashboard assignDashboardToCustomer(Dashboard dashboard, Customer customer, User user) throws ThingsboardException; + + Dashboard assignDashboardToPublicCustomer(Dashboard dashboard, User user) throws ThingsboardException; + + Dashboard unassignDashboardFromPublicCustomer(Dashboard dashboard, User user) throws ThingsboardException; + + Dashboard updateDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException; + + Dashboard addDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException; + + Dashboard removeDashboardCustomers(Dashboard dashboard, Set customerIds, User user) throws ThingsboardException; + + Dashboard asignDashboardToEdge(TenantId tenantId, DashboardId dashboardId, Edge edge, User user) throws ThingsboardException; + + Dashboard unassignDashboardFromEdge(Dashboard dashboard, Edge edge, User user) throws ThingsboardException; + + Dashboard unassignDashboardFromCustomer(Dashboard dashboard, Customer customer, User user) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java new file mode 100644 index 0000000..2505367 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -0,0 +1,283 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.device; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.dao.device.ClaimDevicesService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.claim.ClaimResponse; +import org.thingsboard.server.dao.device.claim.ClaimResult; +import org.thingsboard.server.dao.device.claim.ReclaimResult; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.util.List; + +@AllArgsConstructor +@TbCoreComponent +@Service +@Slf4j +public class DefaultTbDeviceService extends AbstractTbEntityService implements TbDeviceService { + + private final DeviceService deviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final ClaimDevicesService claimDevicesService; + private final TenantService tenantService; + + @Override + public Device save(Device device, Device oldDevice, String accessToken, User user) throws Exception { + ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = device.getTenantId(); + try { + Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken)); + autoCommit(user, savedDevice.getId()); + notificationEntityService.notifyCreateOrUpdateDevice(tenantId, savedDevice.getId(), savedDevice.getCustomerId(), + savedDevice, oldDevice, actionType, user); + + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), device, actionType, user, e); + throw e; + } + } + + @Override + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + boolean isCreate = device.getId() == null; + ActionType actionType = isCreate ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = device.getTenantId(); + try { + Device oldDevice = isCreate ? null : deviceService.findDeviceById(tenantId, device.getId()); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials)); + notificationEntityService.notifyCreateOrUpdateDevice(tenantId, savedDevice.getId(), savedDevice.getCustomerId(), + savedDevice, oldDevice, actionType, user); + + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), device, actionType, user, e); + throw e; + } + } + + @Override + public ListenableFuture delete(Device device, User user) { + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + try { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, deviceId); + deviceService.deleteDevice(tenantId, deviceId); + notificationEntityService.notifyDeleteDevice(tenantId, deviceId, device.getCustomerId(), device, + relatedEdgeIds, user, deviceId.toString()); + + return removeAlarmsByEntityId(tenantId, deviceId); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), ActionType.DELETED, + user, e, deviceId.toString()); + throw e; + } + } + + @Override + public Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + CustomerId customerId = customer.getId(); + try { + Device savedDevice = checkNotNull(deviceService.assignDeviceToCustomer(tenantId, deviceId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, deviceId, customerId, savedDevice, + actionType, user, true, deviceId.toString(), customerId.toString(), customer.getName()); + + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), actionType, user, + e, deviceId.toString(), customerId.toString()); + throw e; + } + } + + @Override + public Device unassignDeviceFromCustomer(Device device, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + try { + Device savedDevice = checkNotNull(deviceService.unassignDeviceFromCustomer(tenantId, deviceId)); + CustomerId customerId = customer.getId(); + + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, deviceId, customerId, savedDevice, + actionType, user, true, deviceId.toString(), customerId.toString(), customer.getName()); + + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), actionType, + user, e, deviceId.toString()); + throw e; + } + } + + @Override + public Device assignDeviceToPublicCustomer(TenantId tenantId, DeviceId deviceId, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + Customer publicCustomer = customerService.findOrCreatePublicCustomer(tenantId); + try { + Device savedDevice = checkNotNull(deviceService.assignDeviceToCustomer(tenantId, deviceId, publicCustomer.getId())); + + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, deviceId, savedDevice.getCustomerId(), savedDevice, + actionType, user, false, deviceId.toString(), + publicCustomer.getId().toString(), publicCustomer.getName()); + + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), actionType, + user, e, deviceId.toString()); + throw e; + } + } + + @Override + public DeviceCredentials getDeviceCredentialsByDeviceId(Device device, User user) throws ThingsboardException { + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + try { + DeviceCredentials deviceCredentials = checkNotNull(deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, deviceId)); + notificationEntityService.logEntityAction(tenantId, deviceId, device, device.getCustomerId(), + ActionType.CREDENTIALS_READ, user, deviceId.toString()); + return deviceCredentials; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + ActionType.CREDENTIALS_READ, user, e, deviceId.toString()); + throw e; + } + } + + @Override + public DeviceCredentials updateDeviceCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException { + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + try { + DeviceCredentials result = checkNotNull(deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials)); + notificationEntityService.notifyUpdateDeviceCredentials(tenantId, deviceId, device.getCustomerId(), device, result, user); + return result; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + ActionType.CREDENTIALS_UPDATED, user, e, deviceCredentials); + throw e; + } + } + + @Override + public ListenableFuture claimDevice(TenantId tenantId, Device device, CustomerId customerId, String secretKey, User user) { + ListenableFuture future = claimDevicesService.claimDevice(device, customerId, secretKey); + + return Futures.transform(future, result -> { + if (result != null && result.getResponse().equals(ClaimResponse.SUCCESS)) { + notificationEntityService.logEntityAction(tenantId, device.getId(), result.getDevice(), customerId, + ActionType.ASSIGNED_TO_CUSTOMER, user, device.getId().toString(), customerId.toString(), + customerService.findCustomerById(tenantId, customerId).getName()); + } + return result; + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture reclaimDevice(TenantId tenantId, Device device, User user) { + ListenableFuture future = claimDevicesService.reClaimDevice(tenantId, device); + + return Futures.transform(future, result -> { + Customer unassignedCustomer = result.getUnassignedCustomer(); + if (unassignedCustomer != null) { + notificationEntityService.logEntityAction(tenantId, device.getId(), device, device.getCustomerId(), + ActionType.UNASSIGNED_FROM_CUSTOMER, user, device.getId().toString(), + unassignedCustomer.getId().toString(), unassignedCustomer.getName()); + } + return result; + }, MoreExecutors.directExecutor()); + } + + @Override + public Device assignDeviceToTenant(Device device, Tenant newTenant, User user) { + TenantId tenantId = device.getTenantId(); + TenantId newTenantId = newTenant.getId(); + DeviceId deviceId = device.getId(); + try { + Tenant tenant = tenantService.findTenantById(tenantId); + Device assignedDevice = deviceService.assignDeviceToTenant(newTenantId, device); + + notificationEntityService.notifyAssignDeviceToTenant(tenantId, newTenantId, deviceId, + assignedDevice.getCustomerId(), assignedDevice, tenant, user, newTenantId.toString(), newTenant.getName()); + + return assignedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + ActionType.ASSIGNED_TO_TENANT, user, e, deviceId.toString()); + throw e; + } + } + + @Override + public Device assignDeviceToEdge(TenantId tenantId, DeviceId deviceId, Edge edge, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_EDGE; + EdgeId edgeId = edge.getId(); + try { + Device savedDevice = checkNotNull(deviceService.assignDeviceToEdge(tenantId, deviceId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, deviceId, savedDevice.getCustomerId(), + edgeId, savedDevice, actionType, user, deviceId.toString(), edgeId.toString(), edge.getName()); + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + ActionType.ASSIGNED_TO_EDGE, user, e, deviceId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public Device unassignDeviceFromEdge(Device device, Edge edge, User user) throws ThingsboardException { + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + EdgeId edgeId = edge.getId(); + try { + Device savedDevice = checkNotNull(deviceService.unassignDeviceFromEdge(tenantId, deviceId, edgeId)); + + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, deviceId, device.getCustomerId(), + edgeId, device, ActionType.UNASSIGNED_FROM_EDGE, user, deviceId.toString(), edgeId.toString(), edge.getName()); + return savedDevice; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE), + ActionType.UNASSIGNED_FROM_EDGE, user, e, deviceId.toString(), edgeId.toString()); + throw e; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java new file mode 100644 index 0000000..3ffc120 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.device; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Customer; +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.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.dao.device.claim.ClaimResult; +import org.thingsboard.server.dao.device.claim.ReclaimResult; + +public interface TbDeviceService { + + Device save(Device device, Device oldDevice, String accessToken, User user) throws Exception; + + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + + ListenableFuture delete(Device device, User user); + + Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, Customer customer, User user) throws ThingsboardException; + + Device unassignDeviceFromCustomer(Device device, Customer customer, User user) throws ThingsboardException; + + Device assignDeviceToPublicCustomer(TenantId tenantId, DeviceId deviceId, User user) throws ThingsboardException; + + DeviceCredentials getDeviceCredentialsByDeviceId(Device device, User user) throws ThingsboardException; + + DeviceCredentials updateDeviceCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + + ListenableFuture claimDevice(TenantId tenantId, Device device, CustomerId customerId, String secretKey, User user); + + ListenableFuture reclaimDevice(TenantId tenantId, Device device, User user); + + Device assignDeviceToTenant(Device device, Tenant newTenant, User user); + + Device assignDeviceToEdge(TenantId tenantId, DeviceId deviceId, Edge edge, User user) throws ThingsboardException; + + Device unassignDeviceFromEdge(Device device, Edge edge, User user) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/DefaultTbDeviceProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/DefaultTbDeviceProfileService.java new file mode 100644 index 0000000..650b35d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/DefaultTbDeviceProfileService.java @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.device.profile; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.ota.OtaPackageStateService; + +import java.util.Objects; + +@Service +@TbCoreComponent +@AllArgsConstructor +@Slf4j +public class DefaultTbDeviceProfileService extends AbstractTbEntityService implements TbDeviceProfileService { + + private final DeviceProfileService deviceProfileService; + private final OtaPackageStateService otaPackageStateService; + + @Override + public DeviceProfile save(DeviceProfile deviceProfile, User user) throws Exception { + ActionType actionType = deviceProfile.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = deviceProfile.getTenantId(); + try { + boolean isFirmwareChanged = false; + boolean isSoftwareChanged = false; + + if (actionType.equals(ActionType.UPDATED)) { + DeviceProfile oldDeviceProfile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfile.getId()); + if (!Objects.equals(deviceProfile.getFirmwareId(), oldDeviceProfile.getFirmwareId())) { + isFirmwareChanged = true; + } + if (!Objects.equals(deviceProfile.getSoftwareId(), oldDeviceProfile.getSoftwareId())) { + isSoftwareChanged = true; + } + } + DeviceProfile savedDeviceProfile = checkNotNull(deviceProfileService.saveDeviceProfile(deviceProfile)); + autoCommit(user, savedDeviceProfile.getId()); + tbClusterService.onDeviceProfileChange(savedDeviceProfile, null); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, savedDeviceProfile.getId(), + actionType.equals(ActionType.ADDED) ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + + otaPackageStateService.update(savedDeviceProfile, isFirmwareChanged, isSoftwareChanged); + + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, savedDeviceProfile.getId(), + savedDeviceProfile, user, actionType, true, null); + return savedDeviceProfile; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE_PROFILE), deviceProfile, actionType, user, e); + throw e; + } + } + + @Override + public void delete(DeviceProfile deviceProfile, User user) { + DeviceProfileId deviceProfileId = deviceProfile.getId(); + TenantId tenantId = deviceProfile.getTenantId(); + try { + deviceProfileService.deleteDeviceProfile(tenantId, deviceProfileId); + + tbClusterService.onDeviceProfileDelete(deviceProfile, null); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, deviceProfileId, ComponentLifecycleEvent.DELETED); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, deviceProfileId, deviceProfile, + user, ActionType.DELETED, true, null, deviceProfileId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE_PROFILE), ActionType.DELETED, + user, e, deviceProfileId.toString()); + throw e; + } + } + + @Override + public DeviceProfile setDefaultDeviceProfile(DeviceProfile deviceProfile, DeviceProfile previousDefaultDeviceProfile, User user) throws ThingsboardException { + TenantId tenantId = deviceProfile.getTenantId(); + DeviceProfileId deviceProfileId = deviceProfile.getId(); + try { + if (deviceProfileService.setDefaultDeviceProfile(tenantId, deviceProfileId)) { + if (previousDefaultDeviceProfile != null) { + previousDefaultDeviceProfile = deviceProfileService.findDeviceProfileById(tenantId, previousDefaultDeviceProfile.getId()); + notificationEntityService.logEntityAction(tenantId, previousDefaultDeviceProfile.getId(), previousDefaultDeviceProfile, + ActionType.UPDATED, user); + } + deviceProfile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfileId); + + notificationEntityService.logEntityAction(tenantId, deviceProfileId, deviceProfile, ActionType.UPDATED, user); + } + return deviceProfile; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.DEVICE_PROFILE), ActionType.UPDATED, + user, e, deviceProfileId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/TbDeviceProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/TbDeviceProfileService.java new file mode 100644 index 0000000..03ad8f7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/profile/TbDeviceProfileService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.device.profile; + +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +public interface TbDeviceProfileService extends SimpleTbEntityService { + + DeviceProfile setDefaultDeviceProfile(DeviceProfile deviceProfile, DeviceProfile previousDefaultDeviceProfile, User user) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java new file mode 100644 index 0000000..697fe35 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/DefaultTbEdgeService.java @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.edge; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeNotificationService; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +@AllArgsConstructor +@TbCoreComponent +@Service +@Slf4j +public class DefaultTbEdgeService extends AbstractTbEntityService implements TbEdgeService { + + private final EdgeNotificationService edgeNotificationService; + private final RuleChainService ruleChainService; + + @Override + public Edge save(Edge edge, RuleChain edgeTemplateRootRuleChain, User user) throws Exception { + ActionType actionType = edge.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = edge.getTenantId(); + try { + Edge savedEdge = checkNotNull(edgeService.saveEdge(edge)); + EdgeId edgeId = savedEdge.getId(); + + if (actionType == ActionType.ADDED) { + ruleChainService.assignRuleChainToEdge(tenantId, edgeTemplateRootRuleChain.getId(), edgeId); + edgeNotificationService.setEdgeRootRuleChain(tenantId, savedEdge, edgeTemplateRootRuleChain.getId()); + edgeService.assignDefaultRuleChainsToEdge(tenantId, edgeId); + } + + notificationEntityService.notifyCreateOrUpdateOrDeleteEdge(tenantId, edgeId, savedEdge.getCustomerId(), savedEdge, actionType, user); + + return savedEdge; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), edge, actionType, user, e); + throw e; + } + } + + @Override + public void delete(Edge edge, User user) { + EdgeId edgeId = edge.getId(); + TenantId tenantId = edge.getTenantId(); + try { + edgeService.deleteEdge(tenantId, edgeId); + notificationEntityService.notifyCreateOrUpdateOrDeleteEdge(tenantId, edgeId, edge.getCustomerId(), edge, ActionType.DELETED, user, edgeId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), ActionType.DELETED, + user, e, edgeId.toString()); + throw e; + } + } + + @Override + public Edge assignEdgeToCustomer(TenantId tenantId, EdgeId edgeId, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.ASSIGNED_TO_CUSTOMER; + CustomerId customerId = customer.getId(); + try { + Edge savedEdge = checkNotNull(edgeService.assignEdgeToCustomer(tenantId, edgeId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, edgeId, customerId, savedEdge, + actionType, user, true, edgeId.toString(), customerId.toString(), customer.getName()); + + return savedEdge; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), + ActionType.ASSIGNED_TO_CUSTOMER, user, e, edgeId.toString(), customerId.toString()); + throw e; + } + } + + @Override + public Edge unassignEdgeFromCustomer(Edge edge, Customer customer, User user) throws ThingsboardException { + ActionType actionType = ActionType.UNASSIGNED_FROM_CUSTOMER; + TenantId tenantId = edge.getTenantId(); + EdgeId edgeId = edge.getId(); + CustomerId customerId = customer.getId(); + try { + Edge savedEdge = checkNotNull(edgeService.unassignEdgeFromCustomer(tenantId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, edgeId, customerId, savedEdge, + actionType, user, true, edgeId.toString(), customerId.toString(), customer.getName()); + return savedEdge; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), + ActionType.UNASSIGNED_FROM_CUSTOMER, user, e, edgeId.toString()); + throw e; + } + } + + @Override + public Edge assignEdgeToPublicCustomer(TenantId tenantId, EdgeId edgeId, User user) throws ThingsboardException { + Customer publicCustomer = customerService.findOrCreatePublicCustomer(tenantId); + CustomerId customerId = publicCustomer.getId(); + try { + Edge savedEdge = checkNotNull(edgeService.assignEdgeToCustomer(tenantId, edgeId, customerId)); + + notificationEntityService.notifyCreateOrUpdateOrDeleteEdge(tenantId, edgeId, customerId, savedEdge, ActionType.ASSIGNED_TO_CUSTOMER, user, + edgeId.toString(), customerId.toString(), publicCustomer.getName()); + + return savedEdge; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), + ActionType.ASSIGNED_TO_CUSTOMER, user, e, edgeId.toString()); + throw e; + } + } + + @Override + public Edge setEdgeRootRuleChain(Edge edge, RuleChainId ruleChainId, User user) throws Exception { + TenantId tenantId = edge.getTenantId(); + EdgeId edgeId = edge.getId(); + try { + Edge updatedEdge = edgeNotificationService.setEdgeRootRuleChain(tenantId, edge, ruleChainId); + notificationEntityService.logEntityAction(tenantId, edgeId, edge, null, ActionType.UPDATED, user); + return updatedEdge; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.EDGE), + ActionType.UPDATED, user, e, edgeId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/edge/TbEdgeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/TbEdgeService.java new file mode 100644 index 0000000..b7cc0a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/edge/TbEdgeService.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.edge; + +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; + +public interface TbEdgeService { + Edge save(Edge edge, RuleChain edgeTemplateRootRuleChain, User user) throws Exception; + + void delete(Edge edge, User user); + + Edge assignEdgeToCustomer(TenantId tenantId, EdgeId edgeId, Customer customer, User user) throws ThingsboardException; + + Edge unassignEdgeFromCustomer(Edge edge, Customer customer, User user) throws ThingsboardException; + + Edge assignEdgeToPublicCustomer(TenantId tenantId, EdgeId edgeId, User user) throws ThingsboardException; + + Edge setEdgeRootRuleChain(Edge edge, RuleChainId ruleChainId, User user) throws Exception; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java new file mode 100644 index 0000000..8a32392 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/DefaultTbEntityRelationService.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.entity.relation; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +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.TenantId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +@Service +@TbCoreComponent +@AllArgsConstructor +@Slf4j +public class DefaultTbEntityRelationService extends AbstractTbEntityService implements TbEntityRelationService { + + private final RelationService relationService; + + @Override + public void save(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { + try { + relationService.saveRelation(tenantId, relation); + notificationEntityService.notifyRelation(tenantId, customerId, + relation, user, ActionType.RELATION_ADD_OR_UPDATE, relation); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, relation.getFrom(), null, customerId, + ActionType.RELATION_ADD_OR_UPDATE, user, e, relation); + notificationEntityService.logEntityAction(tenantId, relation.getTo(), null, customerId, + ActionType.RELATION_ADD_OR_UPDATE, user, e, relation); + throw e; + } + } + + @Override + public void delete(TenantId tenantId, CustomerId customerId, EntityRelation relation, User user) throws ThingsboardException { + try { + boolean found = relationService.deleteRelation(tenantId, relation.getFrom(), relation.getTo(), relation.getType(), relation.getTypeGroup()); + if (!found) { + throw new ThingsboardException("Requested item wasn't found!", ThingsboardErrorCode.ITEM_NOT_FOUND); + } + notificationEntityService.notifyRelation(tenantId, customerId, + relation, user, ActionType.RELATION_DELETED, relation); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, relation.getFrom(), null, customerId, + ActionType.RELATION_DELETED, user, e, relation); + notificationEntityService.logEntityAction(tenantId, relation.getTo(), null, customerId, + ActionType.RELATION_DELETED, user, e, relation); + throw e; + } + } + + @Override + public void deleteRelations(TenantId tenantId, CustomerId customerId, EntityId entityId, User user) throws ThingsboardException { + try { + relationService.deleteEntityRelations(tenantId, entityId); + notificationEntityService.logEntityAction(tenantId, entityId, null, customerId, ActionType.RELATIONS_DELETED, user); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, entityId, null, customerId, + ActionType.RELATIONS_DELETED, user, e); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java new file mode 100644 index 0000000..09dfd35 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entity/relation/TbEntityRelationService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.entity.relation; + +import org.thingsboard.server.common.data.User; +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.TenantId; +import org.thingsboard.server.common.data.relation.EntityRelation; + +public interface TbEntityRelationService { + + void save(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; + + void delete(TenantId tenantId, CustomerId customerId, EntityRelation entity, User user) throws ThingsboardException; + + void deleteRelations(TenantId tenantId, CustomerId customerId, EntityId entityId, User user) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java new file mode 100644 index 0000000..3ae5fe6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -0,0 +1,450 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.entityview; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.thingsboard.server.common.data.Customer; +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.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.StringUtils.isBlank; + +@Service +@AllArgsConstructor +@Slf4j +public class DefaultTbEntityViewService extends AbstractTbEntityService implements TbEntityViewService { + + private final EntityViewService entityViewService; + private final AttributesService attributesService; + private final TelemetrySubscriptionService tsSubService; + private final TimeseriesService tsService; + + final Map>> localCache = new ConcurrentHashMap<>(); + + @Override + public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = entityView.getTenantId(); + try { + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user); + autoCommit(user, savedEntityView.getId()); + notificationEntityService.notifyCreateOrUpdateEntity(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView, + null, actionType, user); + localCache.computeIfAbsent(savedEntityView.getTenantId(), (k) -> new ConcurrentReferenceHashMap<>()).clear(); + tbClusterService.broadcastEntityStateChangeEvent(savedEntityView.getTenantId(), savedEntityView.getId(), + entityView.getId() == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(user.getTenantId(), emptyId(EntityType.ENTITY_VIEW), entityView, actionType, user, e); + throw e; + } + } + + @Override + public void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException { + List> futures = new ArrayList<>(); + + if (oldEntityView != null) { + if (oldEntityView.getKeys() != null && oldEntityView.getKeys().getAttributes() != null) { + futures.add(deleteAttributesFromEntityView(oldEntityView, DataConstants.CLIENT_SCOPE, oldEntityView.getKeys().getAttributes().getCs(), user)); + futures.add(deleteAttributesFromEntityView(oldEntityView, DataConstants.SERVER_SCOPE, oldEntityView.getKeys().getAttributes().getSs(), user)); + futures.add(deleteAttributesFromEntityView(oldEntityView, DataConstants.SHARED_SCOPE, oldEntityView.getKeys().getAttributes().getSh(), user)); + } + List tsKeys = oldEntityView.getKeys() != null && oldEntityView.getKeys().getTimeseries() != null ? + oldEntityView.getKeys().getTimeseries() : Collections.emptyList(); + futures.add(deleteLatestFromEntityView(oldEntityView, tsKeys, user)); + } + if (savedEntityView.getKeys() != null) { + if (savedEntityView.getKeys().getAttributes() != null) { + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs(), user)); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs(), user)); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh(), user)); + } + futures.add(copyLatestFromEntityToEntityView(tenantId, savedEntityView)); + } + for (ListenableFuture future : futures) { + try { + future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException("Failed to copy attributes to entity view", e); + } + } + } + + @Override + public void delete(EntityView entityView, User user) throws ThingsboardException { + TenantId tenantId = entityView.getTenantId(); + EntityViewId entityViewId = entityView.getId(); + try { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, entityViewId); + entityViewService.deleteEntityView(tenantId, entityViewId); + notificationEntityService.notifyDeleteEntity(tenantId, entityViewId, entityView, entityView.getCustomerId(), ActionType.DELETED, + relatedEdgeIds, user, entityViewId.toString()); + + localCache.computeIfAbsent(tenantId, (k) -> new ConcurrentReferenceHashMap<>()).clear(); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityViewId, ComponentLifecycleEvent.DELETED); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.DELETED, user, e, entityViewId.toString()); + throw e; + } + } + + @Override + public EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, Customer customer, User user) throws ThingsboardException { + CustomerId customerId = customer.getId(); + try { + EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToCustomer(tenantId, entityViewId, customerId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, entityViewId, customerId, savedEntityView, + ActionType.ASSIGNED_TO_CUSTOMER, user, true, entityViewId.toString(), customerId.toString(), customer.getName()); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.ASSIGNED_TO_CUSTOMER, user, e, entityViewId.toString(), customerId.toString()); + throw e; + } + } + + @Override + public EntityView assignEntityViewToPublicCustomer(TenantId tenantId, CustomerId customerId, Customer publicCustomer, + EntityViewId entityViewId, User user) throws ThingsboardException { + try { + EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToCustomer(tenantId, + entityViewId, publicCustomer.getId())); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, entityViewId, customerId, savedEntityView, + ActionType.ASSIGNED_TO_CUSTOMER, user, false, savedEntityView.getEntityId().toString(), + publicCustomer.getId().toString(), publicCustomer.getName()); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.ASSIGNED_TO_CUSTOMER, user, e, entityViewId.toString()); + throw e; + } + } + + @Override + public EntityView assignEntityViewToEdge(TenantId tenantId, CustomerId customerId, EntityViewId entityViewId, Edge edge, User user) throws ThingsboardException { + EdgeId edgeId = edge.getId(); + try { + EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToEdge(tenantId, entityViewId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, entityViewId, customerId, + edgeId, savedEntityView, ActionType.ASSIGNED_TO_EDGE, user, savedEntityView.getEntityId().toString(), + edgeId.toString(), edge.getName()); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.ASSIGNED_TO_EDGE, user, e, entityViewId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public EntityView unassignEntityViewFromEdge(TenantId tenantId, CustomerId customerId, EntityView entityView, + Edge edge, User user) throws ThingsboardException { + EntityViewId entityViewId = entityView.getId(); + EdgeId edgeId = edge.getId(); + try { + EntityView savedEntityView = checkNotNull(entityViewService.unassignEntityViewFromEdge(tenantId, entityViewId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, entityViewId, customerId, + edgeId, entityView, ActionType.UNASSIGNED_FROM_EDGE, user, entityViewId.toString(), + edgeId.toString(), edge.getName()); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.UNASSIGNED_FROM_EDGE, user, e, entityViewId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public EntityView unassignEntityViewFromCustomer(TenantId tenantId, EntityViewId entityViewId, Customer customer, User user) throws ThingsboardException { + try { + EntityView savedEntityView = checkNotNull(entityViewService.unassignEntityViewFromCustomer(tenantId, entityViewId)); + notificationEntityService.notifyAssignOrUnassignEntityToCustomer(tenantId, entityViewId, customer.getId(), savedEntityView, + ActionType.UNASSIGNED_FROM_CUSTOMER, user, true, customer.getId().toString(), customer.getName()); + return savedEntityView; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.ENTITY_VIEW), + ActionType.UNASSIGNED_FROM_CUSTOMER, user, e, entityViewId.toString()); + throw e; + } + } + + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { + Map> localCacheByTenant = localCache.computeIfAbsent(tenantId, (k) -> new ConcurrentReferenceHashMap<>()); + List fromLocalCache = localCacheByTenant.get(entityId); + if (fromLocalCache != null) { + return Futures.immediateFuture(fromLocalCache); + } + + ListenableFuture> future = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId); + + return Futures.transform(future, (entityViewList) -> { + localCacheByTenant.put(entityId, entityViewList); + return entityViewList; + }, MoreExecutors.directExecutor()); + } + + @Override + public void onComponentLifecycleMsg(ComponentLifecycleMsg componentLifecycleMsg) { + Map> localCacheByTenant = localCache.computeIfAbsent(componentLifecycleMsg.getTenantId(), (k) -> new ConcurrentReferenceHashMap<>()); + EntityViewId entityViewId = new EntityViewId(componentLifecycleMsg.getEntityId().getId()); + deleteOldCacheValue(localCacheByTenant, entityViewId); + if (componentLifecycleMsg.getEvent() != ComponentLifecycleEvent.DELETED) { + EntityView entityView = entityViewService.findEntityViewById(componentLifecycleMsg.getTenantId(), entityViewId); + if (entityView != null) { + localCacheByTenant.remove(entityView.getEntityId()); + } + } + } + + private void deleteOldCacheValue(Map> localCacheByTenant, EntityViewId entityViewId) { + for (var entry : localCacheByTenant.entrySet()) { + EntityView toDelete = null; + for (EntityView view : entry.getValue()) { + if (entityViewId.equals(view.getId())) { + toDelete = view; + break; + } + } + if (toDelete != null) { + entry.getValue().remove(toDelete); + break; + } + } + } + + private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys, User user) throws ThingsboardException { + EntityViewId entityId = entityView.getId(); + if (keys != null && !keys.isEmpty()) { + ListenableFuture> getAttrFuture = attributesService.find(entityView.getTenantId(), entityView.getEntityId(), scope, keys); + return Futures.transform(getAttrFuture, attributeKvEntries -> { + List attributes; + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + attributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); + return startTime == 0 && endTime == 0 || + (endTime == 0 && startTime < lastUpdateTs) || + (startTime == 0 && endTime > lastUpdateTs) || + (startTime < lastUpdateTs && endTime > lastUpdateTs); + }).collect(Collectors.toList()); + tsSubService.saveAndNotify(entityView.getTenantId(), entityId, scope, attributes, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + try { + logAttributesUpdated(entityView.getTenantId(), user, entityId, scope, attributes, null); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + } + + @Override + public void onFailure(Throwable t) { + try { + logAttributesUpdated(entityView.getTenantId(), user, entityId, scope, attributes, t); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + } + }); + } + return null; + }, MoreExecutors.directExecutor()); + } else { + return Futures.immediateFuture(null); + } + } + + private ListenableFuture> copyLatestFromEntityToEntityView(TenantId tenantId, EntityView entityView) { + EntityViewId entityId = entityView.getId(); + List keys = entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null ? + entityView.getKeys().getTimeseries() : Collections.emptyList(); + long startTs = entityView.getStartTimeMs(); + long endTs = entityView.getEndTimeMs() == 0 ? Long.MAX_VALUE : entityView.getEndTimeMs(); + ListenableFuture> keysFuture; + if (keys.isEmpty()) { + keysFuture = Futures.transform(tsService.findAllLatest(tenantId, + entityView.getEntityId()), latest -> latest.stream().map(TsKvEntry::getKey).collect(Collectors.toList()), MoreExecutors.directExecutor()); + } else { + keysFuture = Futures.immediateFuture(keys); + } + ListenableFuture> latestFuture = Futures.transformAsync(keysFuture, fetchKeys -> { + List queries = fetchKeys.stream().filter(key -> !isBlank(key)).map(key -> new BaseReadTsKvQuery(key, startTs, endTs, 1, "DESC")).collect(Collectors.toList()); + if (!queries.isEmpty()) { + return tsService.findAll(tenantId, entityView.getEntityId(), queries); + } else { + return Futures.immediateFuture(null); + } + }, MoreExecutors.directExecutor()); + return Futures.transform(latestFuture, latestValues -> { + if (latestValues != null && !latestValues.isEmpty()) { + tsSubService.saveLatestAndNotify(entityView.getTenantId(), entityId, latestValues, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + } + + @Override + public void onFailure(Throwable t) { + } + }); + } + return null; + }, MoreExecutors.directExecutor()); + } + + private ListenableFuture deleteAttributesFromEntityView(EntityView entityView, String scope, List keys, User user) { + EntityViewId entityId = entityView.getId(); + SettableFuture resultFuture = SettableFuture.create(); + if (keys != null && !keys.isEmpty()) { + tsSubService.deleteAndNotify(entityView.getTenantId(), entityId, scope, keys, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + try { + logAttributesDeleted(entityView.getTenantId(), user, entityId, scope, keys, null); + } catch (ThingsboardException e) { + log.error("Failed to log attribute delete", e); + } + resultFuture.set(tmp); + } + + @Override + public void onFailure(Throwable t) { + try { + logAttributesDeleted(entityView.getTenantId(), user, entityId, scope, keys, t); + } catch (ThingsboardException e) { + log.error("Failed to log attribute delete", e); + } + resultFuture.setException(t); + } + }); + } else { + resultFuture.set(null); + } + return resultFuture; + } + + private ListenableFuture deleteLatestFromEntityView(EntityView entityView, List keys, User user) { + EntityViewId entityId = entityView.getId(); + SettableFuture resultFuture = SettableFuture.create(); + if (keys != null && !keys.isEmpty()) { + tsSubService.deleteLatest(entityView.getTenantId(), entityId, keys, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + try { + logTimeseriesDeleted(entityView.getTenantId(), user, entityId, keys, null); + } catch (ThingsboardException e) { + log.error("Failed to log timeseries delete", e); + } + resultFuture.set(tmp); + } + + @Override + public void onFailure(Throwable t) { + try { + logTimeseriesDeleted(entityView.getTenantId(), user, entityId, keys, t); + } catch (ThingsboardException e) { + log.error("Failed to log timeseries delete", e); + } + resultFuture.setException(t); + } + }); + } else { + tsSubService.deleteAllLatest(entityView.getTenantId(), entityId, new FutureCallback>() { + @Override + public void onSuccess(@Nullable Collection keys) { + try { + logTimeseriesDeleted(entityView.getTenantId(), user, entityId, new ArrayList<>(keys), null); + } catch (ThingsboardException e) { + log.error("Failed to log timeseries delete", e); + } + resultFuture.set(null); + } + + @Override + public void onFailure(Throwable t) { + try { + logTimeseriesDeleted(entityView.getTenantId(), user, entityId, Collections.emptyList(), t); + } catch (ThingsboardException e) { + log.error("Failed to log timeseries delete", e); + } + resultFuture.setException(t); + } + }); + } + return resultFuture; + } + + private void logAttributesUpdated(TenantId tenantId, User user, EntityId entityId, String scope, List attributes, Throwable e) throws ThingsboardException { + notificationEntityService.logEntityAction(tenantId, entityId, ActionType.ATTRIBUTES_UPDATED, user, toException(e), scope, attributes); + } + + private void logAttributesDeleted(TenantId tenantId, User user, EntityId entityId, String scope, List keys, Throwable e) throws ThingsboardException { + notificationEntityService.logEntityAction(tenantId, entityId, ActionType.ATTRIBUTES_DELETED, user, toException(e), scope, keys); + } + + private void logTimeseriesDeleted(TenantId tenantId, User user, EntityId entityId, List keys, Throwable e) throws ThingsboardException { + notificationEntityService.logEntityAction(tenantId, entityId, ActionType.TIMESERIES_DELETED, user, toException(e), keys); + } + + public static Exception toException(Throwable error) { + return error != null ? (Exception.class.isInstance(error) ? (Exception) error : new Exception(error)) : null; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java new file mode 100644 index 0000000..c4dbedd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.entityview; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +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.msg.plugin.ComponentLifecycleListener; + +import java.util.List; + +public interface TbEntityViewService extends ComponentLifecycleListener { + + EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + + void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; + + void delete(EntityView entity, User user) throws ThingsboardException; + + EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, Customer customer, User user) throws ThingsboardException; + + EntityView assignEntityViewToPublicCustomer(TenantId tenantId, CustomerId customerId, Customer publicCustomer, + EntityViewId entityViewId, User user) throws ThingsboardException; + + EntityView assignEntityViewToEdge(TenantId tenantId, CustomerId customerId, EntityViewId entityViewId, Edge edge, User user) throws ThingsboardException; + + EntityView unassignEntityViewFromEdge(TenantId tenantId, CustomerId customerId, EntityView entityView, Edge edge, User user) throws ThingsboardException; + + EntityView unassignEntityViewFromCustomer(TenantId tenantId, EntityViewId entityViewId, Customer customer, User user) throws ThingsboardException; + + ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId); +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java new file mode 100644 index 0000000..ac825c2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java @@ -0,0 +1,114 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.ota; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.nio.ByteBuffer; + +@Service +@TbCoreComponent +@AllArgsConstructor +@Slf4j +public class DefaultTbOtaPackageService extends AbstractTbEntityService implements TbOtaPackageService { + + private final OtaPackageService otaPackageService; + + @Override + public OtaPackageInfo save(SaveOtaPackageInfoRequest saveOtaPackageInfoRequest, User user) throws ThingsboardException { + ActionType actionType = saveOtaPackageInfoRequest.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = saveOtaPackageInfoRequest.getTenantId(); + try { + OtaPackageInfo savedOtaPackageInfo = otaPackageService.saveOtaPackageInfo(new OtaPackageInfo(saveOtaPackageInfoRequest), saveOtaPackageInfoRequest.isUsesUrl()); + + boolean sendMsgToEdge = savedOtaPackageInfo.hasUrl() || savedOtaPackageInfo.isHasData(); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, savedOtaPackageInfo.getId(), + savedOtaPackageInfo, user, actionType, sendMsgToEdge, null); + + return savedOtaPackageInfo; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.OTA_PACKAGE), saveOtaPackageInfoRequest, + actionType, user, e); + throw e; + } + } + + @Override + public OtaPackageInfo saveOtaPackageData(OtaPackageInfo otaPackageInfo, String checksum, ChecksumAlgorithm checksumAlgorithm, + byte[] data, String filename, String contentType, User user) throws ThingsboardException { + TenantId tenantId = otaPackageInfo.getTenantId(); + OtaPackageId otaPackageId = otaPackageInfo.getId(); + try { + if (StringUtils.isEmpty(checksum)) { + checksum = otaPackageService.generateChecksum(checksumAlgorithm, ByteBuffer.wrap(data)); + } + OtaPackage otaPackage = new OtaPackage(otaPackageId); + otaPackage.setCreatedTime(otaPackageInfo.getCreatedTime()); + otaPackage.setTenantId(tenantId); + otaPackage.setDeviceProfileId(otaPackageInfo.getDeviceProfileId()); + otaPackage.setType(otaPackageInfo.getType()); + otaPackage.setTitle(otaPackageInfo.getTitle()); + otaPackage.setVersion(otaPackageInfo.getVersion()); + otaPackage.setTag(otaPackageInfo.getTag()); + otaPackage.setAdditionalInfo(otaPackageInfo.getAdditionalInfo()); + otaPackage.setChecksumAlgorithm(checksumAlgorithm); + otaPackage.setChecksum(checksum); + otaPackage.setFileName(filename); + otaPackage.setContentType(contentType); + otaPackage.setData(ByteBuffer.wrap(data)); + otaPackage.setDataSize((long) data.length); + OtaPackageInfo savedOtaPackage = otaPackageService.saveOtaPackage(otaPackage); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, savedOtaPackage.getId(), + savedOtaPackage, user, ActionType.UPDATED, true, null); + return savedOtaPackage; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.OTA_PACKAGE), ActionType.UPDATED, + user, e, otaPackageId.toString()); + throw e; + } + } + + @Override + public void delete(OtaPackageInfo otaPackageInfo, User user) throws ThingsboardException { + TenantId tenantId = otaPackageInfo.getTenantId(); + OtaPackageId otaPackageId = otaPackageInfo.getId(); + try { + otaPackageService.deleteOtaPackage(tenantId, otaPackageId); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, otaPackageId, otaPackageInfo, + user, ActionType.DELETED, true, null, otaPackageInfo.getId().toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.OTA_PACKAGE), + ActionType.DELETED, user, e, otaPackageId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/TbOtaPackageService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/TbOtaPackageService.java new file mode 100644 index 0000000..aec2f20 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/TbOtaPackageService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.ota; + +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; + +public interface TbOtaPackageService { + + OtaPackageInfo save(SaveOtaPackageInfoRequest saveOtaPackageInfoRequest, User user) throws ThingsboardException; + + OtaPackageInfo saveOtaPackageData(OtaPackageInfo otaPackageInfo, String checksum, ChecksumAlgorithm checksumAlgorithm, + byte[] data, String filename, String contentType, User user) throws ThingsboardException; + + void delete(OtaPackageInfo otaPackageInfo, User user) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java new file mode 100644 index 0000000..4355d99 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -0,0 +1,227 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.queue; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.scheduler.SchedulerComponent; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +@TbCoreComponent +@AllArgsConstructor +public class DefaultTbQueueService extends AbstractTbEntityService implements TbQueueService { + private static final long DELETE_DELAY = 30; + + private final QueueService queueService; + private final TbClusterService tbClusterService; + private final TbQueueAdmin tbQueueAdmin; + private final SchedulerComponent scheduler; + + @Override + public Queue saveQueue(Queue queue) { + boolean create = queue.getId() == null; + Queue oldQueue; + + if (create) { + oldQueue = null; + } else { + oldQueue = queueService.findQueueById(queue.getTenantId(), queue.getId()); + } + + //TODO: add checkNotNull + Queue savedQueue = queueService.saveQueue(queue); + + if (create) { + onQueueCreated(savedQueue); + } else { + onQueueUpdated(savedQueue, oldQueue); + } + + notificationEntityService.notifySendMsgToEdgeService(queue.getTenantId(), savedQueue.getId(), create ? EdgeEventActionType.ADDED : EdgeEventActionType.UPDATED); + + return savedQueue; + } + + @Override + public void deleteQueue(TenantId tenantId, QueueId queueId) { + Queue queue = queueService.findQueueById(tenantId, queueId); + queueService.deleteQueue(tenantId, queueId); + onQueueDeleted(queue); + } + + @Override + public void deleteQueueByQueueName(TenantId tenantId, String queueName) { + Queue queue = queueService.findQueueByTenantIdAndNameInternal(tenantId, queueName); + queueService.deleteQueue(tenantId, queue.getId()); + onQueueDeleted(queue); + } + + private void onQueueCreated(Queue queue) { + for (int i = 0; i < queue.getPartitions(); i++) { + tbQueueAdmin.createTopicIfNotExists( + new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName()); + } + + tbClusterService.onQueueChange(queue); + } + + private void onQueueUpdated(Queue queue, Queue oldQueue) { + int oldPartitions = oldQueue.getPartitions(); + int currentPartitions = queue.getPartitions(); + + if (currentPartitions != oldPartitions) { + if (currentPartitions > oldPartitions) { + log.info("Added [{}] new partitions to [{}] queue", currentPartitions - oldPartitions, queue.getName()); + for (int i = oldPartitions; i < currentPartitions; i++) { + tbQueueAdmin.createTopicIfNotExists( + new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName()); + } + tbClusterService.onQueueChange(queue); + } else { + log.info("Removed [{}] partitions from [{}] queue", oldPartitions - currentPartitions, queue.getName()); + tbClusterService.onQueueChange(queue); + + scheduler.schedule(() -> { + for (int i = currentPartitions; i < oldPartitions; i++) { + String fullTopicName = new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(); + log.info("Removed partition [{}]", fullTopicName); + tbQueueAdmin.deleteTopic( + fullTopicName); + } + }, DELETE_DELAY, TimeUnit.SECONDS); + } + } else if (!oldQueue.equals(queue)) { + tbClusterService.onQueueChange(queue); + } + } + + private void onQueueDeleted(Queue queue) { + tbClusterService.onQueueDelete(queue); + +// queueStatsService.deleteQueueStatsByQueueId(tenantId, queueId); + + scheduler.schedule(() -> { + for (int i = 0; i < queue.getPartitions(); i++) { + String fullTopicName = new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(); + log.info("Deleting queue [{}]", fullTopicName); + try { + tbQueueAdmin.deleteTopic(fullTopicName); + } catch (Exception e) { + log.error("Failed to delete queue [{}]", fullTopicName); + } + } + }, DELETE_DELAY, TimeUnit.SECONDS); + + notificationEntityService.notifySendMsgToEdgeService(queue.getTenantId(), queue.getId(), EdgeEventActionType.DELETED); + } + + @Override + public void updateQueuesByTenants(List tenantIds, TenantProfile newTenantProfile, TenantProfile + oldTenantProfile) { + boolean oldIsolated = oldTenantProfile != null && oldTenantProfile.isIsolatedTbRuleEngine(); + boolean newIsolated = newTenantProfile.isIsolatedTbRuleEngine(); + + if (!oldIsolated && !newIsolated) { + return; + } + + if (newTenantProfile.equals(oldTenantProfile)) { + return; + } + + Map oldQueues; + Map newQueues; + + if (oldIsolated) { + oldQueues = oldTenantProfile.getProfileData().getQueueConfiguration().stream() + .collect(Collectors.toMap(TenantProfileQueueConfiguration::getName, q -> q)); + } else { + oldQueues = Collections.emptyMap(); + } + + if (newIsolated) { + newQueues = newTenantProfile.getProfileData().getQueueConfiguration().stream() + .collect(Collectors.toMap(TenantProfileQueueConfiguration::getName, q -> q)); + } else { + newQueues = Collections.emptyMap(); + } + + List toRemove = new ArrayList<>(); + List toCreate = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + + for (String oldQueue : oldQueues.keySet()) { + if (!newQueues.containsKey(oldQueue)) { + toRemove.add(oldQueue); + } + } + + for (String newQueue : newQueues.keySet()) { + if (oldQueues.containsKey(newQueue)) { + toUpdate.add(newQueue); + } else { + toCreate.add(newQueue); + } + } + + tenantIds.forEach(tenantId -> { + toCreate.forEach(key -> saveQueue(new Queue(tenantId, newQueues.get(key)))); + + toUpdate.forEach(key -> { + Queue queueToUpdate = new Queue(tenantId, newQueues.get(key)); + Queue foundQueue = queueService.findQueueByTenantIdAndName(tenantId, key); + queueToUpdate.setId(foundQueue.getId()); + queueToUpdate.setCreatedTime(foundQueue.getCreatedTime()); + + if (!queueToUpdate.equals(foundQueue)) { + saveQueue(queueToUpdate); + } + }); + + toRemove.forEach(q -> { + Queue queue = queueService.findQueueByTenantIdAndNameInternal(tenantId, q); + QueueId queueIdForRemove = queue.getId(); + deleteQueue(tenantId, queueIdForRemove); + }); + }); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/TbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/TbQueueService.java new file mode 100644 index 0000000..86f82b1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/TbQueueService.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.queue; + +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; + +import java.util.List; + +public interface TbQueueService { + + Queue saveQueue(Queue queue); + + void deleteQueue(TenantId tenantId, QueueId queueId); + + void deleteQueueByQueueName(TenantId tenantId, String queueName); + + void updateQueuesByTenants(List tenantIds, TenantProfile newTenantProfile, TenantProfile oldTenantProfile); +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java new file mode 100644 index 0000000..01a2eee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.tenant; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.entitiy.queue.TbQueueService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DefaultTbTenantService extends AbstractTbEntityService implements TbTenantService { + + private final TenantService tenantService; + private final TbTenantProfileCache tenantProfileCache; + private final InstallScripts installScripts; + private final TbQueueService tbQueueService; + private final TenantProfileService tenantProfileService; + private final EntitiesVersionControlService versionControlService; + + @Override + public Tenant save(Tenant tenant) throws Exception { + boolean created = tenant.getId() == null; + Tenant oldTenant = !created ? tenantService.findTenantById(tenant.getId()) : null; + + Tenant savedTenant = checkNotNull(tenantService.saveTenant(tenant)); + if (created) { + installScripts.createDefaultRuleChains(savedTenant.getId()); + installScripts.createDefaultEdgeRuleChains(savedTenant.getId()); + } + tenantProfileCache.evict(savedTenant.getId()); + notificationEntityService.notifyCreateOrUpdateTenant(savedTenant, created ? + ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + + TenantProfile oldTenantProfile = oldTenant != null ? tenantProfileService.findTenantProfileById(TenantId.SYS_TENANT_ID, oldTenant.getTenantProfileId()) : null; + TenantProfile newTenantProfile = tenantProfileService.findTenantProfileById(TenantId.SYS_TENANT_ID, savedTenant.getTenantProfileId()); + tbQueueService.updateQueuesByTenants(Collections.singletonList(savedTenant.getTenantId()), newTenantProfile, oldTenantProfile); + return savedTenant; + } + + @Override + public void delete(Tenant tenant) throws Exception { + TenantId tenantId = tenant.getId(); + tenantService.deleteTenant(tenantId); + tenantProfileCache.evict(tenantId); + notificationEntityService.notifyDeleteTenant(tenant); + versionControlService.deleteVersionControlSettings(tenantId).get(1, TimeUnit.MINUTES); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/TbTenantService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/TbTenantService.java new file mode 100644 index 0000000..2dc8fa4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/TbTenantService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.tenant; + +import org.thingsboard.server.common.data.Tenant; + +public interface TbTenantService { + + Tenant save(Tenant tenant) throws Exception; + + void delete(Tenant tenant) throws Exception; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java new file mode 100644 index 0000000..a25c4ac --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.tenant.profile; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.entitiy.queue.TbQueueService; + +import java.util.List; + +@Slf4j +@Service +@TbCoreComponent +@AllArgsConstructor +public class DefaultTbTenantProfileService extends AbstractTbEntityService implements TbTenantProfileService { + private final TbQueueService tbQueueService; + private final TenantProfileService tenantProfileService; + private final TenantService tenantService; + private final TbTenantProfileCache tenantProfileCache; + + @Override + public TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException { + TenantProfile savedTenantProfile = checkNotNull(tenantProfileService.saveTenantProfile(tenantId, tenantProfile)); + if (oldTenantProfile != null && savedTenantProfile.isIsolatedTbRuleEngine()) { + List tenantIds = tenantService.findTenantIdsByTenantProfileId(savedTenantProfile.getId()); + tbQueueService.updateQueuesByTenants(tenantIds, savedTenantProfile, oldTenantProfile); + } + + tenantProfileCache.put(savedTenantProfile); + tbClusterService.onTenantProfileChange(savedTenantProfile, null); + tbClusterService.broadcastEntityStateChangeEvent(TenantId.SYS_TENANT_ID, savedTenantProfile.getId(), + tenantProfile.getId() == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + + return savedTenantProfile; + } + + @Override + public void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException { + tenantProfileService.deleteTenantProfile(tenantId, tenantProfile.getId()); + tbClusterService.onTenantProfileDelete(tenantProfile, null); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java new file mode 100644 index 0000000..96d18e3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.tenant.profile; + +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbTenantProfileService { + TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException; + + void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java new file mode 100644 index 0000000..a3df4b8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/DefaultUserService.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.user; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import javax.servlet.http.HttpServletRequest; + +import static org.thingsboard.server.controller.UserController.ACTIVATE_URL_PATTERN; + +@Service +@TbCoreComponent +@AllArgsConstructor +@Slf4j +public class DefaultUserService extends AbstractTbEntityService implements TbUserService { + + private final UserService userService; + private final MailService mailService; + private final SystemSecurityService systemSecurityService; + + @Override + public User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, + HttpServletRequest request, User user) throws ThingsboardException { + ActionType actionType = tbUser.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + try { + boolean sendEmail = tbUser.getId() == null && sendActivationMail; + User savedUser = checkNotNull(userService.saveUser(tbUser)); + if (sendEmail) { + UserCredentials userCredentials = userService.findUserCredentialsByUserId(tenantId, savedUser.getId()); + String baseUrl = systemSecurityService.getBaseUrl(tenantId, customerId, request); + String activateUrl = String.format(ACTIVATE_URL_PATTERN, baseUrl, + userCredentials.getActivateToken()); + String email = savedUser.getEmail(); + try { + mailService.sendActivationEmail(activateUrl, email); + } catch (ThingsboardException e) { + userService.deleteUser(tenantId, savedUser.getId()); + throw e; + } + } + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, customerId, savedUser.getId(), + savedUser, user, actionType, true, null); + return savedUser; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.USER), tbUser, actionType, user, e); + throw e; + } + } + + @Override + public void delete(TenantId tenantId, CustomerId customerId, User tbUser, User user) throws ThingsboardException { + UserId userId = tbUser.getId(); + + try { + userService.deleteUser(tenantId, userId); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, customerId, userId, tbUser, + user, ActionType.DELETED, true, null, customerId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.USER), + ActionType.DELETED, user, e, userId.toString()); + throw e; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java new file mode 100644 index 0000000..765733b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/user/TbUserService.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.user; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; + +import javax.servlet.http.HttpServletRequest; + +public interface TbUserService { + User save(TenantId tenantId, CustomerId customerId, User tbUser, boolean sendActivationMail, HttpServletRequest request, User user) throws ThingsboardException; + + void delete(TenantId tenantId, CustomerId customerId, User tbUser, User user) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java new file mode 100644 index 0000000..1706598 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/DefaultWidgetsBundleService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.widgets.bundle; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +@Service +@TbCoreComponent +@AllArgsConstructor +public class DefaultWidgetsBundleService extends AbstractTbEntityService implements TbWidgetsBundleService { + + private final WidgetsBundleService widgetsBundleService; + + @Override + public WidgetsBundle save(WidgetsBundle widgetsBundle, User user) throws Exception { + WidgetsBundle savedWidgetsBundle = checkNotNull(widgetsBundleService.saveWidgetsBundle(widgetsBundle)); + autoCommit(user, savedWidgetsBundle.getId()); + notificationEntityService.notifySendMsgToEdgeService(widgetsBundle.getTenantId(), savedWidgetsBundle.getId(), + widgetsBundle.getId() == null ? EdgeEventActionType.ADDED : EdgeEventActionType.UPDATED); + return savedWidgetsBundle; + } + + @Override + public void delete(WidgetsBundle widgetsBundle) throws ThingsboardException { + widgetsBundleService.deleteWidgetsBundle(widgetsBundle.getTenantId(), widgetsBundle.getId()); + notificationEntityService.notifySendMsgToEdgeService(widgetsBundle.getTenantId(), widgetsBundle.getId(), + EdgeEventActionType.DELETED); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java new file mode 100644 index 0000000..2820934 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/bundle/TbWidgetsBundleService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.widgets.bundle; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.widget.WidgetsBundle; + +public interface TbWidgetsBundleService { + + WidgetsBundle save(WidgetsBundle entity, User currentUser) throws Exception; + + void delete(WidgetsBundle entity) throws ThingsboardException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/executors/DbCallbackExecutorService.java b/application/src/main/java/org/thingsboard/server/service/executors/DbCallbackExecutorService.java new file mode 100644 index 0000000..84ac8c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/executors/DbCallbackExecutorService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.executors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class DbCallbackExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.db_callback_thread_pool_size}") + private int dbCallbackExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return dbCallbackExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/executors/ExternalCallExecutorService.java b/application/src/main/java/org/thingsboard/server/service/executors/ExternalCallExecutorService.java new file mode 100644 index 0000000..8329517 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/executors/ExternalCallExecutorService.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.executors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class ExternalCallExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.external_call_thread_pool_size}") + private int externalCallExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return externalCallExecutorThreadPoolSize; + } + +} + diff --git a/application/src/main/java/org/thingsboard/server/service/executors/GrpcCallbackExecutorService.java b/application/src/main/java/org/thingsboard/server/service/executors/GrpcCallbackExecutorService.java new file mode 100644 index 0000000..b249ad0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/executors/GrpcCallbackExecutorService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.executors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class GrpcCallbackExecutorService extends AbstractListeningExecutor { + + @Value("${edges.grpc_callback_thread_pool_size}") + private int grpcCallbackExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return grpcCallbackExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java b/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java new file mode 100644 index 0000000..e812ec4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/executors/SharedEventLoopGroupService.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.executors; + +import com.google.common.util.concurrent.MoreExecutors; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import lombok.Getter; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Component +public class SharedEventLoopGroupService { + + @Getter + private EventLoopGroup sharedEventLoopGroup; + + @PostConstruct + public void init() { + this.sharedEventLoopGroup = new NioEventLoopGroup(); + } + + @PreDestroy + public void destroy() { + if (this.sharedEventLoopGroup != null) { + this.sharedEventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/gateway_device/DefaultGatewayNotificationsService.java b/application/src/main/java/org/thingsboard/server/service/gateway_device/DefaultGatewayNotificationsService.java new file mode 100644 index 0000000..8eea83c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/gateway_device/DefaultGatewayNotificationsService.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.gateway_device; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +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.TenantId; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DefaultGatewayNotificationsService implements GatewayNotificationsService { + + private final static String DEVICE_RENAMED_METHOD_NAME = "gateway_device_renamed"; + private final static String DEVICE_DELETED_METHOD_NAME = "gateway_device_deleted"; + private final static Long rpcTimeout = TimeUnit.DAYS.toMillis(1); + @Lazy + @Autowired + private TbCoreDeviceRpcService deviceRpcService; + + @Override + public void onDeviceUpdated(Device device, Device oldDevice) { + Optional gatewayDeviceId = getGatewayDeviceIdFromAdditionalInfoInDevice(device); + if (gatewayDeviceId.isPresent()) { + ObjectNode renamedDeviceNode = JacksonUtil.newObjectNode(); + renamedDeviceNode.put(oldDevice.getName(), device.getName()); + ToDeviceRpcRequest rpcRequest = formDeviceToGatewayRPCRequest(device.getTenantId(), gatewayDeviceId.get(), renamedDeviceNode, DEVICE_RENAMED_METHOD_NAME); + deviceRpcService.processRestApiRpcRequest(rpcRequest, fromDeviceRpcResponse -> { + log.trace("Device renamed RPC with id: [{}] processed to gateway device with id: [{}], old device name: [{}], new device name: [{}]", + rpcRequest.getId(), gatewayDeviceId, oldDevice.getName(), device.getName()); + }, null); + } + } + + @Override + public void onDeviceDeleted(Device device) { + Optional gatewayDeviceId = getGatewayDeviceIdFromAdditionalInfoInDevice(device); + if (gatewayDeviceId.isPresent()) { + TextNode deletedDeviceNode = new TextNode(device.getName()); + ToDeviceRpcRequest rpcRequest = formDeviceToGatewayRPCRequest(device.getTenantId(), gatewayDeviceId.get(), deletedDeviceNode, DEVICE_DELETED_METHOD_NAME); + deviceRpcService.processRestApiRpcRequest(rpcRequest, fromDeviceRpcResponse -> { + log.trace("Device deleted RPC with id: [{}] processed to gateway device with id: [{}], deleted device name: [{}]", + rpcRequest.getId(), gatewayDeviceId, device.getName()); + }, null); + } + } + + private ToDeviceRpcRequest formDeviceToGatewayRPCRequest(TenantId tenantId, DeviceId gatewayDeviceId, JsonNode deviceDataNode, String method) { + ToDeviceRpcRequestBody body = new ToDeviceRpcRequestBody(method, JacksonUtil.toString(deviceDataNode)); + long expTime = System.currentTimeMillis() + rpcTimeout; + UUID rpcRequestUUID = UUID.randomUUID(); + return new ToDeviceRpcRequest(rpcRequestUUID, + tenantId, + gatewayDeviceId, + true, + expTime, + body, + true, + 3, + null + ); + } + + private Optional getGatewayDeviceIdFromAdditionalInfoInDevice(Device device) { + JsonNode deviceAdditionalInfo = device.getAdditionalInfo(); + if (deviceAdditionalInfo != null && deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY)) { + try { + JsonNode lastConnectedGatewayIdNode = deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY); + return Optional.of(new DeviceId(UUID.fromString(lastConnectedGatewayIdNode.asText()))); + } catch (RuntimeException e) { + log.debug("[{}] Failed to decode connected gateway: {}", device.getId(), deviceAdditionalInfo); + } + } + return Optional.empty(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/gateway_device/GatewayNotificationsService.java b/application/src/main/java/org/thingsboard/server/service/gateway_device/GatewayNotificationsService.java new file mode 100644 index 0000000..d6b20c8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/gateway_device/GatewayNotificationsService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.gateway_device; + +import org.thingsboard.server.common.data.Device; + +public interface GatewayNotificationsService { + + void onDeviceUpdated(Device device, Device oldDevice); + + void onDeviceDeleted(Device device); +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/AbstractCassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/AbstractCassandraDatabaseUpgradeService.java new file mode 100644 index 0000000..c149413 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/AbstractCassandraDatabaseUpgradeService.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; +import org.thingsboard.server.service.install.cql.CQLStatementsParser; + +import java.nio.file.Path; +import java.util.List; + +@Slf4j +public abstract class AbstractCassandraDatabaseUpgradeService { + @Autowired + protected CassandraCluster cluster; + + @Autowired + @Qualifier("CassandraInstallCluster") + private CassandraInstallCluster installCluster; + + protected void loadCql(Path cql) throws Exception { + List statements = new CQLStatementsParser(cql).getStatements(); + statements.forEach(statement -> { + installCluster.getSession().execute(statement); + try { + Thread.sleep(2500); + } catch (InterruptedException e) { + } + }); + Thread.sleep(5000); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/AbstractSqlTsDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/AbstractSqlTsDatabaseUpgradeService.java new file mode 100644 index 0000000..b223a20 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/AbstractSqlTsDatabaseUpgradeService.java @@ -0,0 +1,117 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.Statement; + +@Slf4j +public abstract class AbstractSqlTsDatabaseUpgradeService { + + protected static final String CALL_REGEX = "call "; + protected static final String DROP_TABLE = "DROP TABLE "; + protected static final String DROP_PROCEDURE_IF_EXISTS = "DROP PROCEDURE IF EXISTS "; + protected static final String TS_KV_SQL = "ts_kv.sql"; + protected static final String PATH_TO_USERS_PUBLIC_FOLDER = "C:\\Users\\Public"; + protected static final String THINGSBOARD_WINDOWS_UPGRADE_DIR = "THINGSBOARD_WINDOWS_UPGRADE_DIR"; + + @Value("${spring.datasource.url}") + protected String dbUrl; + + @Value("${spring.datasource.username}") + protected String dbUserName; + + @Value("${spring.datasource.password}") + protected String dbPassword; + + @Autowired + protected InstallScripts installScripts; + + protected abstract void loadSql(Connection conn, String fileName, String version); + + protected void loadFunctions(Path sqlFile, Connection conn) throws Exception { + String sql = new String(Files.readAllBytes(sqlFile), StandardCharsets.UTF_8); + conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } + + protected boolean checkVersion(Connection conn) { + boolean versionValid = false; + try { + Statement statement = conn.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT current_setting('server_version_num')"); + resultSet.next(); + if(resultSet.getLong(1) > 110000) { + versionValid = true; + } + statement.close(); + } catch (Exception e) { + log.info("Failed to check current PostgreSQL version due to: {}", e.getMessage()); + } + return versionValid; + } + + protected boolean isOldSchema(Connection conn, long fromVersion) { + boolean isOldSchema = true; + try { + Statement statement = conn.createStatement(); + statement.execute("CREATE TABLE IF NOT EXISTS tb_schema_settings ( schema_version bigint NOT NULL, CONSTRAINT tb_schema_settings_pkey PRIMARY KEY (schema_version));"); + Thread.sleep(1000); + ResultSet resultSet = statement.executeQuery("SELECT schema_version FROM tb_schema_settings;"); + if (resultSet.next()) { + isOldSchema = resultSet.getLong(1) <= fromVersion; + } else { + resultSet.close(); + statement.execute("INSERT INTO tb_schema_settings (schema_version) VALUES (" + fromVersion + ")"); + } + statement.close(); + } catch (InterruptedException | SQLException e) { + log.info("Failed to check current PostgreSQL schema due to: {}", e.getMessage()); + } + return isOldSchema; + } + + protected void executeQuery(Connection conn, String query) { + try { + Statement statement = conn.createStatement(); + statement.execute(query); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + SQLWarning warnings = statement.getWarnings(); + if (warnings != null) { + log.info("{}", warnings.getMessage()); + SQLWarning nextWarning = warnings.getNextWarning(); + while (nextWarning != null) { + log.info("{}", nextWarning.getMessage()); + nextWarning = nextWarning.getNextWarning(); + } + } + Thread.sleep(2000); + log.info("Successfully executed query: {}", query); + } catch (InterruptedException | SQLException e) { + log.error("Failed to execute query: {} due to: {}", query, e.getMessage()); + throw new RuntimeException("Failed to execute query:" + query + " due to: ", e); + } + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java new file mode 100644 index 0000000..1d843e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java @@ -0,0 +1,75 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; +import org.thingsboard.server.service.install.cql.CQLStatementsParser; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +@Slf4j +public abstract class CassandraAbstractDatabaseSchemaService implements DatabaseSchemaService { + + private static final String CASSANDRA_DIR = "cassandra"; + private static final String CASSANDRA_STANDARD_KEYSPACE = "thingsboard"; + + @Autowired + @Qualifier("CassandraInstallCluster") + private CassandraInstallCluster cluster; + + @Autowired + private InstallScripts installScripts; + + @Value("${cassandra.keyspace_name}") + private String keyspaceName; + + private final String schemaCql; + + protected CassandraAbstractDatabaseSchemaService(String schemaCql) { + this.schemaCql = schemaCql; + } + + @Override + public void createDatabaseSchema() throws Exception { + this.createDatabaseSchema(true); + } + + @Override + public void createDatabaseSchema(boolean createIndexes) throws Exception { + log.info("Installing Cassandra DataBase schema part: " + schemaCql); + Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, schemaCql); + loadCql(schemaFile); + } + + @Override + public void createDatabaseIndexes() throws Exception { + } + + private void loadCql(Path cql) throws Exception { + List statements = new CQLStatementsParser(cql).getStatements(); + statements.forEach(statement -> cluster.getSession().execute(getCassandraKeyspaceName(statement))); + } + + private String getCassandraKeyspaceName(String statement) { + return statement.replaceFirst(CASSANDRA_STANDARD_KEYSPACE, keyspaceName); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraKeyspaceService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraKeyspaceService.java new file mode 100644 index 0000000..dfdd05e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraKeyspaceService.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlAnyDaoNonCloud; + +/* +* Create keyspace for Cassandra NoSQL database for non-cloud deployment. +* For cloud service like Astra DBaas admin have to create keyspace manually on cloud UI. +* Then create tokens with database admin role and put it on Thingsboard parameters. +* Without this service cloud DB will end up with exception like +* UnauthorizedException: Missing correct permission on thingsboard +* */ +@Service +@NoSqlAnyDaoNonCloud +@Profile("install") +public class CassandraKeyspaceService extends CassandraAbstractDatabaseSchemaService + implements NoSqlKeyspaceService { + public CassandraKeyspaceService() { + super("schema-keyspace.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java new file mode 100644 index 0000000..e77a034 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlTsDao; + +@Service +@NoSqlTsDao +@Profile("install") +public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements TsDatabaseSchemaService { + public CassandraTsDatabaseSchemaService() { + super("schema-ts.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseUpgradeService.java new file mode 100644 index 0000000..25218fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseUpgradeService.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import com.datastax.oss.driver.api.core.servererrors.InvalidQueryException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlTsDao; + +@Service +@NoSqlTsDao +@Profile("install") +@Slf4j +public class CassandraTsDatabaseUpgradeService extends AbstractCassandraDatabaseUpgradeService implements DatabaseTsUpgradeService { + + @Override + public void upgradeDatabase(String fromVersion) throws Exception { + switch (fromVersion) { + case "2.4.3": + log.info("Updating schema ..."); + String updateTsKvTableStmt = "alter table ts_kv_cf add json_v text"; + String updateTsKvLatestTableStmt = "alter table ts_kv_latest_cf add json_v text"; + + try { + log.info("Updating ts ..."); + cluster.getSession().execute(updateTsKvTableStmt); + Thread.sleep(2500); + log.info("Ts updated."); + log.info("Updating ts latest ..."); + cluster.getSession().execute(updateTsKvLatestTableStmt); + Thread.sleep(2500); + log.info("Ts latest updated."); + } catch (InvalidQueryException e) { + } + log.info("Schema updated."); + break; + case "2.5.0": + case "3.1.1": + case "3.2.1": + case "3.2.2": + 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/CassandraTsLatestDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsLatestDatabaseSchemaService.java new file mode 100644 index 0000000..181fb41 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsLatestDatabaseSchemaService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlTsLatestDao; + +@Service +@NoSqlTsLatestDao +@Profile("install") +public class CassandraTsLatestDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements TsLatestDatabaseSchemaService { + public CassandraTsLatestDatabaseSchemaService() { + super("schema-ts-latest.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseEntitiesUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseEntitiesUpgradeService.java new file mode 100644 index 0000000..beb4135 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseEntitiesUpgradeService.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface DatabaseEntitiesUpgradeService { + + void upgradeDatabase(String fromVersion) throws Exception; + +} 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 new file mode 100644 index 0000000..2d90b8a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.thingsboard.server.common.data.ShortCustomerInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.UUIDConverter; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.dashboard.DashboardService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Created by igor on 2/27/18. + */ +@Slf4j +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 RULE_CHAIN = "rule_chain"; + 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"; + + public static final ObjectMapper objectMapper = new ObjectMapper(); + + public static void upgradeTo40_assignDashboards(Path dashboardsDump, DashboardService dashboardService, boolean sql) throws Exception { + JavaType assignedCustomersType = + objectMapper.getTypeFactory().constructCollectionType(HashSet.class, ShortCustomerInfo.class); + try (CSVParser csvParser = new CSVParser(Files.newBufferedReader(dashboardsDump), CSV_DUMP_FORMAT.withFirstRecordAsHeader())) { + csvParser.forEach(record -> { + String customerIdString = record.get(CUSTOMER_ID); + String assignedCustomersString = record.get(ASSIGNED_CUSTOMERS); + DashboardId dashboardId = new DashboardId(toUUID(record.get(ID), sql)); + List customerIds = new ArrayList<>(); + if (!StringUtils.isEmpty(assignedCustomersString)) { + try { + Set assignedCustomers = objectMapper.readValue(assignedCustomersString, assignedCustomersType); + assignedCustomers.forEach((customerInfo) -> { + CustomerId customerId = customerInfo.getCustomerId(); + if (!customerId.isNullUid()) { + customerIds.add(customerId); + } + }); + } catch (IOException e) { + log.error("Unable to parse assigned customers field", e); + } + } + if (!StringUtils.isEmpty(customerIdString)) { + CustomerId customerId = new CustomerId(toUUID(customerIdString, sql)); + if (!customerId.isNullUid()) { + customerIds.add(customerId); + } + } + for (CustomerId customerId : customerIds) { + dashboardService.assignDashboardToCustomer(TenantId.SYS_TENANT_ID, dashboardId, customerId); + } + }); + } + } + + private static UUID toUUID(String src, boolean sql) { + if (sql) { + return UUIDConverter.fromString(src); + } else { + return UUID.fromString(src); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaService.java new file mode 100644 index 0000000..8dde168 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface DatabaseSchemaService { + + void createDatabaseSchema() throws Exception; + + void createDatabaseSchema(boolean createIndexes) throws Exception; + + void createDatabaseIndexes() throws Exception; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseTsUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseTsUpgradeService.java new file mode 100644 index 0000000..16c5bf9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseTsUpgradeService.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface DatabaseTsUpgradeService { + + void upgradeDatabase(String fromVersion) throws Exception; + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/DbUpgradeExecutorService.java b/application/src/main/java/org/thingsboard/server/service/install/DbUpgradeExecutorService.java new file mode 100644 index 0000000..b3ce77a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DbUpgradeExecutorService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; + +@Component +@Profile("install") +public class DbUpgradeExecutorService extends DbCallbackExecutorService { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java new file mode 100644 index 0000000..9507912 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -0,0 +1,674 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FutureCallback; +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.context.annotation.Bean; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.device.profile.AlarmCondition; +import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; +import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; +import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; +import org.thingsboard.server.common.data.device.profile.AlarmRule; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.DynamicValueSourceType; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Service +@Profile("install") +@Slf4j +public class DefaultSystemDataLoaderService implements SystemDataLoaderService { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + public static final String CUSTOMER_CRED = "customer"; + public static final String DEFAULT_DEVICE_TYPE = "default"; + public static final String ACTIVITY_STATE = "active"; + + @Autowired + private InstallScripts installScripts; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private UserService userService; + + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private WidgetsBundleService widgetsBundleService; + + @Autowired + private TenantService tenantService; + + @Autowired + private TenantProfileService tenantProfileService; + + @Autowired + private CustomerService customerService; + + @Autowired + private DeviceService deviceService; + + @Autowired + private DeviceProfileService deviceProfileService; + + @Autowired + private AttributesService attributesService; + + @Autowired + private DeviceCredentialsService deviceCredentialsService; + + @Autowired + private RuleChainService ruleChainService; + + @Autowired + private TimeseriesService tsService; + + @Value("${state.persistToTelemetry:false}") + @Getter + private boolean persistActivityToTelemetry; + + @Lazy + @Autowired + private QueueService queueService; + + @Autowired + private JwtSettingsService jwtSettingsService; + + @Bean + protected BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + private ExecutorService tsCallBackExecutor; + + @PostConstruct + public void initExecutor() { + tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("sys-loader-ts-callback")); + } + + @PreDestroy + public void shutdownExecutor() { + if (tsCallBackExecutor != null) { + tsCallBackExecutor.shutdownNow(); + } + } + + @Override + public void createSysAdmin() { + createUser(Authority.SYS_ADMIN, null, null, "sysadmin@thingsboard.org", "sysadmin"); + } + + @Override + public void createDefaultTenantProfiles() throws Exception { + tenantProfileService.findOrCreateDefaultTenantProfile(TenantId.SYS_TENANT_ID); + + TenantProfileData isolatedRuleEngineTenantProfileData = new TenantProfileData(); + isolatedRuleEngineTenantProfileData.setConfiguration(new DefaultTenantProfileConfiguration()); + + TenantProfileQueueConfiguration mainQueueConfiguration = new TenantProfileQueueConfiguration(); + mainQueueConfiguration.setName("Main"); + mainQueueConfiguration.setTopic("tb_rule_engine.main"); + mainQueueConfiguration.setPollInterval(25); + mainQueueConfiguration.setPartitions(10); + mainQueueConfiguration.setConsumerPerPartition(true); + mainQueueConfiguration.setPackProcessingTimeout(2000); + SubmitStrategy mainQueueSubmitStrategy = new SubmitStrategy(); + mainQueueSubmitStrategy.setType(SubmitStrategyType.BURST); + mainQueueSubmitStrategy.setBatchSize(1000); + mainQueueConfiguration.setSubmitStrategy(mainQueueSubmitStrategy); + ProcessingStrategy mainQueueProcessingStrategy = new ProcessingStrategy(); + mainQueueProcessingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + mainQueueProcessingStrategy.setRetries(3); + mainQueueProcessingStrategy.setFailurePercentage(0); + mainQueueProcessingStrategy.setPauseBetweenRetries(3); + mainQueueProcessingStrategy.setMaxPauseBetweenRetries(3); + mainQueueConfiguration.setProcessingStrategy(mainQueueProcessingStrategy); + + isolatedRuleEngineTenantProfileData.setQueueConfiguration(Collections.singletonList(mainQueueConfiguration)); + + TenantProfile isolatedTbRuleEngineProfile = new TenantProfile(); + isolatedTbRuleEngineProfile.setDefault(false); + isolatedTbRuleEngineProfile.setName("Isolated TB Rule Engine"); + isolatedTbRuleEngineProfile.setDescription("Isolated TB Rule Engine tenant profile"); + isolatedTbRuleEngineProfile.setIsolatedTbRuleEngine(true); + isolatedTbRuleEngineProfile.setProfileData(isolatedRuleEngineTenantProfileData); + + try { + tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, isolatedTbRuleEngineProfile); + } catch (DataValidationException e) { + log.warn(e.getMessage()); + } + } + + @Override + public void createAdminSettings() throws Exception { + AdminSettings generalSettings = new AdminSettings(); + generalSettings.setTenantId(TenantId.SYS_TENANT_ID); + generalSettings.setKey("general"); + ObjectNode node = objectMapper.createObjectNode(); + node.put("baseUrl", "http://localhost:8080"); + node.put("prohibitDifferentUrl", false); + generalSettings.setJsonValue(node); + adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, generalSettings); + + AdminSettings mailSettings = new AdminSettings(); + mailSettings.setTenantId(TenantId.SYS_TENANT_ID); + mailSettings.setKey("mail"); + node = objectMapper.createObjectNode(); + node.put("mailFrom", "ThingsBoard "); + node.put("smtpProtocol", "smtp"); + node.put("smtpHost", "localhost"); + node.put("smtpPort", "25"); + node.put("timeout", "10000"); + node.put("enableTls", false); + node.put("username", ""); + node.put("password", ""); + node.put("tlsVersion", "TLSv1.2");//NOSONAR, key used to identify password field (not password value itself) + node.put("enableProxy", false); + node.put("showChangePassword", false); + mailSettings.setJsonValue(node); + adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, mailSettings); + } + + @Override + public void createRandomJwtSettings() throws Exception { + jwtSettingsService.createRandomJwtSettings(); + } + + @Override + public void saveLegacyYmlSettings() throws Exception { + jwtSettingsService.saveLegacyYmlSettings(); + } + + @Override + public void createOAuth2Templates() throws Exception { + installScripts.createOAuth2Templates(); + } + + @Override + public void loadDemoData() throws Exception { + Tenant demoTenant = new Tenant(); + demoTenant.setRegion("Global"); + demoTenant.setTitle("Tenant"); + demoTenant = tenantService.saveTenant(demoTenant); + installScripts.loadDemoRuleChains(demoTenant.getId()); + createUser(Authority.TENANT_ADMIN, demoTenant.getId(), null, "tenant@thingsboard.org", "tenant"); + + Customer customerA = new Customer(); + customerA.setTenantId(demoTenant.getId()); + customerA.setTitle("Customer A"); + customerA = customerService.saveCustomer(customerA); + Customer customerB = new Customer(); + customerB.setTenantId(demoTenant.getId()); + customerB.setTitle("Customer B"); + customerB = customerService.saveCustomer(customerB); + Customer customerC = new Customer(); + customerC.setTenantId(demoTenant.getId()); + customerC.setTitle("Customer C"); + customerC = customerService.saveCustomer(customerC); + createUser(Authority.CUSTOMER_USER, demoTenant.getId(), customerA.getId(), "customer@thingsboard.org", CUSTOMER_CRED); + createUser(Authority.CUSTOMER_USER, demoTenant.getId(), customerA.getId(), "customerA@thingsboard.org", CUSTOMER_CRED); + createUser(Authority.CUSTOMER_USER, demoTenant.getId(), customerB.getId(), "customerB@thingsboard.org", CUSTOMER_CRED); + createUser(Authority.CUSTOMER_USER, demoTenant.getId(), customerC.getId(), "customerC@thingsboard.org", CUSTOMER_CRED); + + DeviceProfile defaultDeviceProfile = this.deviceProfileService.findOrCreateDeviceProfile(demoTenant.getId(), DEFAULT_DEVICE_TYPE); + + createDevice(demoTenant.getId(), customerA.getId(), defaultDeviceProfile.getId(), "Test Device A1", "A1_TEST_TOKEN", null); + createDevice(demoTenant.getId(), customerA.getId(), defaultDeviceProfile.getId(), "Test Device A2", "A2_TEST_TOKEN", null); + createDevice(demoTenant.getId(), customerA.getId(), defaultDeviceProfile.getId(), "Test Device A3", "A3_TEST_TOKEN", null); + createDevice(demoTenant.getId(), customerB.getId(), defaultDeviceProfile.getId(), "Test Device B1", "B1_TEST_TOKEN", null); + createDevice(demoTenant.getId(), customerC.getId(), defaultDeviceProfile.getId(), "Test Device C1", "C1_TEST_TOKEN", null); + + createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "DHT11 Demo Device", "DHT11_DEMO_TOKEN", "Demo device that is used in sample " + + "applications that upload data from DHT11 temperature and humidity sensor"); + + createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "Raspberry Pi Demo Device", "RASPBERRY_PI_DEMO_TOKEN", "Demo device that is used in " + + "Raspberry Pi GPIO control sample application"); + + DeviceProfile thermostatDeviceProfile = new DeviceProfile(); + thermostatDeviceProfile.setTenantId(demoTenant.getId()); + thermostatDeviceProfile.setDefault(false); + thermostatDeviceProfile.setName("thermostat"); + thermostatDeviceProfile.setType(DeviceProfileType.DEFAULT); + thermostatDeviceProfile.setTransportType(DeviceTransportType.DEFAULT); + thermostatDeviceProfile.setProvisionType(DeviceProfileProvisionType.DISABLED); + thermostatDeviceProfile.setDescription("Thermostat device profile"); + thermostatDeviceProfile.setDefaultRuleChainId(ruleChainService.findTenantRuleChainsByType( + demoTenant.getId(), RuleChainType.CORE, new PageLink(1, 0, "Thermostat")).getData().get(0).getId()); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); + DefaultDeviceProfileTransportConfiguration transportConfiguration = new DefaultDeviceProfileTransportConfiguration(); + DisabledDeviceProfileProvisionConfiguration provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null); + deviceProfileData.setConfiguration(configuration); + deviceProfileData.setTransportConfiguration(transportConfiguration); + deviceProfileData.setProvisionConfiguration(provisionConfiguration); + thermostatDeviceProfile.setProfileData(deviceProfileData); + + DeviceProfileAlarm highTemperature = new DeviceProfileAlarm(); + highTemperature.setId("highTemperatureAlarmID"); + highTemperature.setAlarmType("High Temperature"); + AlarmRule temperatureRule = new AlarmRule(); + AlarmCondition temperatureCondition = new AlarmCondition(); + temperatureCondition.setSpec(new SimpleAlarmConditionSpec()); + + AlarmConditionFilter temperatureAlarmFlagAttributeFilter = new AlarmConditionFilter(); + temperatureAlarmFlagAttributeFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "temperatureAlarmFlag")); + temperatureAlarmFlagAttributeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate temperatureAlarmFlagAttributePredicate = new BooleanFilterPredicate(); + temperatureAlarmFlagAttributePredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + temperatureAlarmFlagAttributePredicate.setValue(new FilterPredicateValue<>(Boolean.TRUE)); + temperatureAlarmFlagAttributeFilter.setPredicate(temperatureAlarmFlagAttributePredicate); + + AlarmConditionFilter temperatureTimeseriesFilter = new AlarmConditionFilter(); + temperatureTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + temperatureTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate temperatureTimeseriesFilterPredicate = new NumericFilterPredicate(); + temperatureTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + FilterPredicateValue temperatureTimeseriesPredicateValue = + new FilterPredicateValue<>(25.0, null, + new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "temperatureAlarmThreshold")); + temperatureTimeseriesFilterPredicate.setValue(temperatureTimeseriesPredicateValue); + temperatureTimeseriesFilter.setPredicate(temperatureTimeseriesFilterPredicate); + temperatureCondition.setCondition(Arrays.asList(temperatureAlarmFlagAttributeFilter, temperatureTimeseriesFilter)); + temperatureRule.setAlarmDetails("Current temperature = ${temperature}"); + temperatureRule.setCondition(temperatureCondition); + highTemperature.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.MAJOR, temperatureRule))); + + AlarmRule clearTemperatureRule = new AlarmRule(); + AlarmCondition clearTemperatureCondition = new AlarmCondition(); + clearTemperatureCondition.setSpec(new SimpleAlarmConditionSpec()); + + AlarmConditionFilter clearTemperatureTimeseriesFilter = new AlarmConditionFilter(); + clearTemperatureTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + clearTemperatureTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate clearTemperatureTimeseriesFilterPredicate = new NumericFilterPredicate(); + clearTemperatureTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS_OR_EQUAL); + FilterPredicateValue clearTemperatureTimeseriesPredicateValue = + new FilterPredicateValue<>(25.0, null, + new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "temperatureAlarmThreshold")); + + clearTemperatureTimeseriesFilterPredicate.setValue(clearTemperatureTimeseriesPredicateValue); + clearTemperatureTimeseriesFilter.setPredicate(clearTemperatureTimeseriesFilterPredicate); + clearTemperatureCondition.setCondition(Collections.singletonList(clearTemperatureTimeseriesFilter)); + clearTemperatureRule.setCondition(clearTemperatureCondition); + clearTemperatureRule.setAlarmDetails("Current temperature = ${temperature}"); + highTemperature.setClearRule(clearTemperatureRule); + + DeviceProfileAlarm lowHumidity = new DeviceProfileAlarm(); + lowHumidity.setId("lowHumidityAlarmID"); + lowHumidity.setAlarmType("Low Humidity"); + AlarmRule humidityRule = new AlarmRule(); + AlarmCondition humidityCondition = new AlarmCondition(); + humidityCondition.setSpec(new SimpleAlarmConditionSpec()); + + AlarmConditionFilter humidityAlarmFlagAttributeFilter = new AlarmConditionFilter(); + humidityAlarmFlagAttributeFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "humidityAlarmFlag")); + humidityAlarmFlagAttributeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate humidityAlarmFlagAttributePredicate = new BooleanFilterPredicate(); + humidityAlarmFlagAttributePredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + humidityAlarmFlagAttributePredicate.setValue(new FilterPredicateValue<>(Boolean.TRUE)); + humidityAlarmFlagAttributeFilter.setPredicate(humidityAlarmFlagAttributePredicate); + + AlarmConditionFilter humidityTimeseriesFilter = new AlarmConditionFilter(); + humidityTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "humidity")); + humidityTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate humidityTimeseriesFilterPredicate = new NumericFilterPredicate(); + humidityTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS); + FilterPredicateValue humidityTimeseriesPredicateValue = + new FilterPredicateValue<>(60.0, null, + new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "humidityAlarmThreshold")); + humidityTimeseriesFilterPredicate.setValue(humidityTimeseriesPredicateValue); + humidityTimeseriesFilter.setPredicate(humidityTimeseriesFilterPredicate); + humidityCondition.setCondition(Arrays.asList(humidityAlarmFlagAttributeFilter, humidityTimeseriesFilter)); + + humidityRule.setCondition(humidityCondition); + humidityRule.setAlarmDetails("Current humidity = ${humidity}"); + lowHumidity.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.MINOR, humidityRule))); + + AlarmRule clearHumidityRule = new AlarmRule(); + AlarmCondition clearHumidityCondition = new AlarmCondition(); + clearHumidityCondition.setSpec(new SimpleAlarmConditionSpec()); + + AlarmConditionFilter clearHumidityTimeseriesFilter = new AlarmConditionFilter(); + clearHumidityTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "humidity")); + clearHumidityTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate clearHumidityTimeseriesFilterPredicate = new NumericFilterPredicate(); + clearHumidityTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER_OR_EQUAL); + FilterPredicateValue clearHumidityTimeseriesPredicateValue = + new FilterPredicateValue<>(60.0, null, + new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "humidityAlarmThreshold")); + + clearHumidityTimeseriesFilterPredicate.setValue(clearHumidityTimeseriesPredicateValue); + clearHumidityTimeseriesFilter.setPredicate(clearHumidityTimeseriesFilterPredicate); + clearHumidityCondition.setCondition(Collections.singletonList(clearHumidityTimeseriesFilter)); + clearHumidityRule.setCondition(clearHumidityCondition); + clearHumidityRule.setAlarmDetails("Current humidity = ${humidity}"); + lowHumidity.setClearRule(clearHumidityRule); + + deviceProfileData.setAlarms(Arrays.asList(highTemperature, lowHumidity)); + + DeviceProfile savedThermostatDeviceProfile = deviceProfileService.saveDeviceProfile(thermostatDeviceProfile); + + DeviceId t1Id = createDevice(demoTenant.getId(), null, savedThermostatDeviceProfile.getId(), "Thermostat T1", "T1_TEST_TOKEN", "Demo device for Thermostats dashboard").getId(); + DeviceId t2Id = createDevice(demoTenant.getId(), null, savedThermostatDeviceProfile.getId(), "Thermostat T2", "T2_TEST_TOKEN", "Demo device for Thermostats dashboard").getId(); + + attributesService.save(demoTenant.getId(), t1Id, DataConstants.SERVER_SCOPE, + Arrays.asList(new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 37.3948)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", -122.1503)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new BooleanDataEntry("temperatureAlarmFlag", true)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new BooleanDataEntry("humidityAlarmFlag", true)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new LongDataEntry("temperatureAlarmThreshold", (long) 20)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new LongDataEntry("humidityAlarmThreshold", (long) 50)))); + + attributesService.save(demoTenant.getId(), t2Id, DataConstants.SERVER_SCOPE, + Arrays.asList(new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 37.493801)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", -121.948769)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new BooleanDataEntry("temperatureAlarmFlag", true)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new BooleanDataEntry("humidityAlarmFlag", true)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new LongDataEntry("temperatureAlarmThreshold", (long) 25)), + new BaseAttributeKvEntry(System.currentTimeMillis(), new LongDataEntry("humidityAlarmThreshold", (long) 30)))); + + installScripts.loadDashboards(demoTenant.getId(), null); + } + + @Override + public void deleteSystemWidgetBundle(String bundleAlias) throws Exception { + WidgetsBundle widgetsBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, bundleAlias); + if (widgetsBundle != null) { + widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, widgetsBundle.getId()); + } + } + + @Override + public void loadSystemWidgets() throws Exception { + installScripts.loadSystemWidgets(); + } + + @Override + public void updateSystemWidgets() throws Exception { + this.deleteSystemWidgetBundle("charts"); + this.deleteSystemWidgetBundle("cards"); + this.deleteSystemWidgetBundle("maps"); + this.deleteSystemWidgetBundle("analogue_gauges"); + this.deleteSystemWidgetBundle("digital_gauges"); + this.deleteSystemWidgetBundle("gpio_widgets"); + this.deleteSystemWidgetBundle("alarm_widgets"); + this.deleteSystemWidgetBundle("control_widgets"); + this.deleteSystemWidgetBundle("maps_v2"); + this.deleteSystemWidgetBundle("gateway_widgets"); + this.deleteSystemWidgetBundle("input_widgets"); + this.deleteSystemWidgetBundle("date"); + this.deleteSystemWidgetBundle("entity_admin_widgets"); + this.deleteSystemWidgetBundle("navigation_widgets"); + this.deleteSystemWidgetBundle("edge_widgets"); + installScripts.loadSystemWidgets(); + } + + private User createUser(Authority authority, + TenantId tenantId, + CustomerId customerId, + String email, + String password) { + User user = new User(); + user.setAuthority(authority); + user.setEmail(email); + user.setTenantId(tenantId); + user.setCustomerId(customerId); + user = userService.saveUser(user); + UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, user.getId()); + userCredentials.setPassword(passwordEncoder.encode(password)); + userCredentials.setEnabled(true); + userCredentials.setActivateToken(null); + userService.saveUserCredentials(TenantId.SYS_TENANT_ID, userCredentials); + return user; + } + + private Device createDevice(TenantId tenantId, + CustomerId customerId, + DeviceProfileId deviceProfileId, + String name, + String accessToken, + String description) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setCustomerId(customerId); + device.setDeviceProfileId(deviceProfileId); + device.setName(name); + if (description != null) { + ObjectNode additionalInfo = objectMapper.createObjectNode(); + additionalInfo.put("description", description); + device.setAdditionalInfo(additionalInfo); + } + device = deviceService.saveDevice(device); + save(device.getId(), ACTIVITY_STATE, false); + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(TenantId.SYS_TENANT_ID, device.getId()); + deviceCredentials.setCredentialsId(accessToken); + deviceCredentialsService.updateDeviceCredentials(TenantId.SYS_TENANT_ID, deviceCredentials); + return device; + } + + private void save(DeviceId deviceId, String key, boolean value) { + if (persistActivityToTelemetry) { + ListenableFuture saveFuture = tsService.save( + TenantId.SYS_TENANT_ID, + deviceId, + Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(key, value))), 0L); + addTsCallback(saveFuture, new TelemetrySaveCallback<>(deviceId, key, value)); + } else { + ListenableFuture> saveFuture = attributesService.save(TenantId.SYS_TENANT_ID, deviceId, DataConstants.SERVER_SCOPE, + Collections.singletonList(new BaseAttributeKvEntry(new BooleanDataEntry(key, value) + , System.currentTimeMillis()))); + addTsCallback(saveFuture, new TelemetrySaveCallback<>(deviceId, key, value)); + } + } + + private static class TelemetrySaveCallback implements FutureCallback { + private final DeviceId deviceId; + private final String key; + private final Object value; + + TelemetrySaveCallback(DeviceId deviceId, String key, Object value) { + this.deviceId = deviceId; + this.key = key; + this.value = value; + } + + @Override + public void onSuccess(@Nullable T result) { + log.trace("[{}] Successfully updated attribute [{}] with value [{}]", deviceId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to update attribute [{}] with value [{}]", deviceId, key, value, t); + } + } + + private void addTsCallback(ListenableFuture saveFuture, final FutureCallback callback) { + Futures.addCallback(saveFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable S result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }, tsCallBackExecutor); + } + + @Override + public void createQueues() { + Queue mainQueue = queueService.findQueueByTenantIdAndName(TenantId.SYS_TENANT_ID, "Main"); + if (mainQueue == null) { + mainQueue = new Queue(); + mainQueue.setTenantId(TenantId.SYS_TENANT_ID); + mainQueue.setName("Main"); + mainQueue.setTopic("tb_rule_engine.main"); + mainQueue.setPollInterval(25); + mainQueue.setPartitions(10); + mainQueue.setConsumerPerPartition(true); + mainQueue.setPackProcessingTimeout(2000); + SubmitStrategy mainQueueSubmitStrategy = new SubmitStrategy(); + mainQueueSubmitStrategy.setType(SubmitStrategyType.BURST); + mainQueueSubmitStrategy.setBatchSize(1000); + mainQueue.setSubmitStrategy(mainQueueSubmitStrategy); + ProcessingStrategy mainQueueProcessingStrategy = new ProcessingStrategy(); + mainQueueProcessingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + mainQueueProcessingStrategy.setRetries(3); + mainQueueProcessingStrategy.setFailurePercentage(0); + mainQueueProcessingStrategy.setPauseBetweenRetries(3); + mainQueueProcessingStrategy.setMaxPauseBetweenRetries(3); + mainQueue.setProcessingStrategy(mainQueueProcessingStrategy); + queueService.saveQueue(mainQueue); + } + + Queue highPriorityQueue = queueService.findQueueByTenantIdAndName(TenantId.SYS_TENANT_ID, "HighPriority"); + if (highPriorityQueue == null) { + highPriorityQueue = new Queue(); + highPriorityQueue.setTenantId(TenantId.SYS_TENANT_ID); + highPriorityQueue.setName("HighPriority"); + highPriorityQueue.setTopic("tb_rule_engine.hp"); + highPriorityQueue.setPollInterval(25); + highPriorityQueue.setPartitions(10); + highPriorityQueue.setConsumerPerPartition(true); + highPriorityQueue.setPackProcessingTimeout(2000); + SubmitStrategy highPriorityQueueSubmitStrategy = new SubmitStrategy(); + highPriorityQueueSubmitStrategy.setType(SubmitStrategyType.BURST); + highPriorityQueueSubmitStrategy.setBatchSize(100); + highPriorityQueue.setSubmitStrategy(highPriorityQueueSubmitStrategy); + ProcessingStrategy highPriorityQueueProcessingStrategy = new ProcessingStrategy(); + highPriorityQueueProcessingStrategy.setType(ProcessingStrategyType.RETRY_FAILED_AND_TIMED_OUT); + highPriorityQueueProcessingStrategy.setRetries(0); + highPriorityQueueProcessingStrategy.setFailurePercentage(0); + highPriorityQueueProcessingStrategy.setPauseBetweenRetries(5); + highPriorityQueueProcessingStrategy.setMaxPauseBetweenRetries(5); + highPriorityQueue.setProcessingStrategy(highPriorityQueueProcessingStrategy); + queueService.saveQueue(highPriorityQueue); + } + + Queue sequentialByOriginatorQueue = queueService.findQueueByTenantIdAndName(TenantId.SYS_TENANT_ID, "SequentialByOriginator"); + if (sequentialByOriginatorQueue == null) { + sequentialByOriginatorQueue = new Queue(); + sequentialByOriginatorQueue.setTenantId(TenantId.SYS_TENANT_ID); + sequentialByOriginatorQueue.setName("SequentialByOriginator"); + sequentialByOriginatorQueue.setTopic("tb_rule_engine.sq"); + sequentialByOriginatorQueue.setPollInterval(25); + sequentialByOriginatorQueue.setPartitions(10); + sequentialByOriginatorQueue.setPackProcessingTimeout(2000); + sequentialByOriginatorQueue.setConsumerPerPartition(true); + SubmitStrategy sequentialByOriginatorQueueSubmitStrategy = new SubmitStrategy(); + sequentialByOriginatorQueueSubmitStrategy.setType(SubmitStrategyType.SEQUENTIAL_BY_ORIGINATOR); + sequentialByOriginatorQueueSubmitStrategy.setBatchSize(100); + sequentialByOriginatorQueue.setSubmitStrategy(sequentialByOriginatorQueueSubmitStrategy); + ProcessingStrategy sequentialByOriginatorQueueProcessingStrategy = new ProcessingStrategy(); + sequentialByOriginatorQueueProcessingStrategy.setType(ProcessingStrategyType.RETRY_FAILED_AND_TIMED_OUT); + sequentialByOriginatorQueueProcessingStrategy.setRetries(3); + sequentialByOriginatorQueueProcessingStrategy.setFailurePercentage(0); + sequentialByOriginatorQueueProcessingStrategy.setPauseBetweenRetries(5); + sequentialByOriginatorQueueProcessingStrategy.setMaxPauseBetweenRetries(5); + sequentialByOriginatorQueue.setProcessingStrategy(sequentialByOriginatorQueueProcessingStrategy); + queueService.saveQueue(sequentialByOriginatorQueue); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java new file mode 100644 index 0000000..ff779d4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface EntityDatabaseSchemaService extends DatabaseSchemaService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java new file mode 100644 index 0000000..38ff5aa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -0,0 +1,263 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.oauth2.OAuth2ClientRegistrationTemplate; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +import static org.thingsboard.server.service.install.DatabaseHelper.objectMapper; + +/** + * Created by ashvayka on 18.04.18. + */ +@Component +@Slf4j +public class InstallScripts { + + public static final String APP_DIR = "application"; + public static final String SRC_DIR = "src"; + public static final String MAIN_DIR = "main"; + public static final String DATA_DIR = "data"; + public static final String JSON_DIR = "json"; + public static final String SYSTEM_DIR = "system"; + public static final String TENANT_DIR = "tenant"; + public static final String DEVICE_PROFILE_DIR = "device_profile"; + public static final String DEMO_DIR = "demo"; + public static final String RULE_CHAINS_DIR = "rule_chains"; + public static final String WIDGET_BUNDLES_DIR = "widget_bundles"; + public static final String OAUTH2_CONFIG_TEMPLATES_DIR = "oauth2_config_templates"; + public static final String DASHBOARDS_DIR = "dashboards"; + public static final String MODELS_DIR = "models"; + public static final String CREDENTIALS_DIR = "credentials"; + + public static final String EDGE_MANAGEMENT = "edge_management"; + + public static final String JSON_EXT = ".json"; + public static final String XML_EXT = ".xml"; + + @Value("${install.data_dir:}") + private String dataDir; + + @Autowired + private RuleChainService ruleChainService; + + @Autowired + private DashboardService dashboardService; + + @Autowired + private WidgetTypeService widgetTypeService; + + @Autowired + private WidgetsBundleService widgetsBundleService; + + @Autowired + private OAuth2ConfigTemplateService oAuth2TemplateService; + + @Autowired + private ResourceService resourceService; + + private Path getTenantRuleChainsDir() { + return Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, RULE_CHAINS_DIR); + } + + private Path getDeviceProfileDefaultRuleChainTemplateFilePath() { + return Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, DEVICE_PROFILE_DIR, "rule_chain_template.json"); + } + + private Path getEdgeRuleChainsDir() { + return Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, EDGE_MANAGEMENT, RULE_CHAINS_DIR); + } + + public String getDataDir() { + if (!StringUtils.isEmpty(dataDir)) { + if (!Paths.get(this.dataDir).toFile().isDirectory()) { + throw new RuntimeException("'install.data_dir' property value is not a valid directory!"); + } + return dataDir; + } else { + String workDir = System.getProperty("user.dir"); + if (workDir.endsWith("application")) { + return Paths.get(workDir, SRC_DIR, MAIN_DIR, DATA_DIR).toString(); + } else { + Path dataDirPath = Paths.get(workDir, APP_DIR, SRC_DIR, MAIN_DIR, DATA_DIR); + if (Files.exists(dataDirPath)) { + return dataDirPath.toString(); + } else { + throw new RuntimeException("Not valid working directory: " + workDir + ". Please use either root project directory, application module directory or specify valid \"install.data_dir\" ENV variable to avoid automatic data directory lookup!"); + } + } + } + } + + public void createDefaultRuleChains(TenantId tenantId) throws IOException { + Path tenantChainsDir = getTenantRuleChainsDir(); + loadRuleChainsFromPath(tenantId, tenantChainsDir); + } + + public void createDefaultEdgeRuleChains(TenantId tenantId) throws IOException { + Path edgeChainsDir = getEdgeRuleChainsDir(); + loadRuleChainsFromPath(tenantId, edgeChainsDir); + } + + private void loadRuleChainsFromPath(TenantId tenantId, Path ruleChainsPath) throws IOException { + try (DirectoryStream dirStream = Files.newDirectoryStream(ruleChainsPath, path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach( + path -> { + try { + createRuleChainFromFile(tenantId, path, null); + } catch (Exception e) { + log.error("Unable to load rule chain from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load rule chain from json", e); + } + } + ); + } + } + + public RuleChain createDefaultRuleChain(TenantId tenantId, String ruleChainName) throws IOException { + return createRuleChainFromFile(tenantId, getDeviceProfileDefaultRuleChainTemplateFilePath(), ruleChainName); + } + + public RuleChain createRuleChainFromFile(TenantId tenantId, Path templateFilePath, String newRuleChainName) throws IOException { + JsonNode ruleChainJson = objectMapper.readTree(templateFilePath.toFile()); + RuleChain ruleChain = objectMapper.treeToValue(ruleChainJson.get("ruleChain"), RuleChain.class); + RuleChainMetaData ruleChainMetaData = objectMapper.treeToValue(ruleChainJson.get("metadata"), RuleChainMetaData.class); + + ruleChain.setTenantId(tenantId); + if (!StringUtils.isEmpty(newRuleChainName)) { + ruleChain.setName(newRuleChainName); + } + ruleChain = ruleChainService.saveRuleChain(ruleChain); + + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainService.saveRuleChainMetaData(TenantId.SYS_TENANT_ID, ruleChainMetaData); + + return ruleChain; + } + + public void loadSystemWidgets() throws Exception { + Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + try (DirectoryStream dirStream = Files.newDirectoryStream(widgetBundlesDir, path -> path.toString().endsWith(JSON_EXT))) { + dirStream.forEach( + path -> { + try { + JsonNode widgetsBundleDescriptorJson = objectMapper.readTree(path.toFile()); + JsonNode widgetsBundleJson = widgetsBundleDescriptorJson.get("widgetsBundle"); + WidgetsBundle widgetsBundle = objectMapper.treeToValue(widgetsBundleJson, WidgetsBundle.class); + WidgetsBundle savedWidgetsBundle = widgetsBundleService.saveWidgetsBundle(widgetsBundle); + JsonNode widgetTypesArrayJson = widgetsBundleDescriptorJson.get("widgetTypes"); + widgetTypesArrayJson.forEach( + widgetTypeJson -> { + try { + WidgetTypeDetails widgetTypeDetails = objectMapper.treeToValue(widgetTypeJson, WidgetTypeDetails.class); + widgetTypeDetails.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetTypeService.saveWidgetType(widgetTypeDetails); + } catch (Exception e) { + log.error("Unable to load widget type from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load widget type from json", e); + } + } + ); + } catch (Exception e) { + log.error("Unable to load widgets bundle from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load widgets bundle from json", e); + } + } + ); + } + } + + public void loadDashboards(TenantId tenantId, CustomerId customerId) throws Exception { + Path dashboardsDir = Paths.get(getDataDir(), JSON_DIR, DEMO_DIR, DASHBOARDS_DIR); + try (DirectoryStream dirStream = Files.newDirectoryStream(dashboardsDir, path -> path.toString().endsWith(JSON_EXT))) { + dirStream.forEach( + path -> { + try { + JsonNode dashboardJson = objectMapper.readTree(path.toFile()); + Dashboard dashboard = objectMapper.treeToValue(dashboardJson, Dashboard.class); + dashboard.setTenantId(tenantId); + Dashboard savedDashboard = dashboardService.saveDashboard(dashboard); + if (customerId != null && !customerId.isNullUid()) { + dashboardService.assignDashboardToCustomer(TenantId.SYS_TENANT_ID, savedDashboard.getId(), customerId); + } + } catch (Exception e) { + log.error("Unable to load dashboard from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load dashboard from json", e); + } + } + ); + } + } + + public void loadDemoRuleChains(TenantId tenantId) { + try { + createDefaultRuleChains(tenantId); + createDefaultRuleChain(tenantId, "Thermostat"); + createDefaultEdgeRuleChains(tenantId); + } catch (Exception e) { + log.error("Unable to load rule chain from json", e); + throw new RuntimeException("Unable to load rule chain from json", e); + } + } + + public void createOAuth2Templates() throws Exception { + Path oauth2ConfigTemplatesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, OAUTH2_CONFIG_TEMPLATES_DIR); + try (DirectoryStream dirStream = Files.newDirectoryStream(oauth2ConfigTemplatesDir, path -> path.toString().endsWith(JSON_EXT))) { + dirStream.forEach( + path -> { + try { + JsonNode oauth2ConfigTemplateJson = objectMapper.readTree(path.toFile()); + OAuth2ClientRegistrationTemplate clientRegistrationTemplate = objectMapper.treeToValue(oauth2ConfigTemplateJson, OAuth2ClientRegistrationTemplate.class); + Optional existingClientRegistrationTemplate = + oAuth2TemplateService.findClientRegistrationTemplateByProviderId(clientRegistrationTemplate.getProviderId()); + if (existingClientRegistrationTemplate.isPresent()) { + clientRegistrationTemplate.setId(existingClientRegistrationTemplate.get().getId()); + } + oAuth2TemplateService.saveClientRegistrationTemplate(clientRegistrationTemplate); + } catch (Exception e) { + log.error("Unable to load oauth2 config templates from json: [{}]", path.toString()); + throw new RuntimeException("Unable to load oauth2 config templates from json", e); + } + } + ); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/NoSqlKeyspaceService.java b/application/src/main/java/org/thingsboard/server/service/install/NoSqlKeyspaceService.java new file mode 100644 index 0000000..5b8d772 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/NoSqlKeyspaceService.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface NoSqlKeyspaceService extends DatabaseSchemaService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java new file mode 100644 index 0000000..28e233c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java @@ -0,0 +1,97 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +@Slf4j +public abstract class SqlAbstractDatabaseSchemaService implements DatabaseSchemaService { + + protected static final String SQL_DIR = "sql"; + + @Value("${spring.datasource.url}") + protected String dbUrl; + + @Value("${spring.datasource.username}") + protected String dbUserName; + + @Value("${spring.datasource.password}") + protected String dbPassword; + + @Autowired + protected InstallScripts installScripts; + + private final String schemaSql; + private final String schemaIdxSql; + + protected SqlAbstractDatabaseSchemaService(String schemaSql, String schemaIdxSql) { + this.schemaSql = schemaSql; + this.schemaIdxSql = schemaIdxSql; + } + + @Override + public void createDatabaseSchema() throws Exception { + this.createDatabaseSchema(true); + } + + @Override + public void createDatabaseSchema(boolean createIndexes) throws Exception { + log.info("Installing SQL DataBase schema part: " + schemaSql); + executeQueryFromFile(schemaSql); + + if (createIndexes) { + this.createDatabaseIndexes(); + } + } + + @Override + public void createDatabaseIndexes() throws Exception { + if (schemaIdxSql != null) { + log.info("Installing SQL DataBase schema indexes part: " + schemaIdxSql); + executeQueryFromFile(schemaIdxSql); + } + } + + void executeQueryFromFile(String schemaIdxSql) throws SQLException, IOException { + Path schemaIdxFile = Paths.get(installScripts.getDataDir(), SQL_DIR, schemaIdxSql); + String sql = Files.readString(schemaIdxFile); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to load initial thingsboard database schema + } + } + + protected void executeQuery(String query) { + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + conn.createStatement().execute(query); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + log.info("Successfully executed query: {}", query); + Thread.sleep(5000); + } catch (InterruptedException | SQLException e) { + log.error("Failed to execute query: {} due to: {}", query, e.getMessage()); + throw new RuntimeException("Failed to execute query: " + query, e); + } + } + +} 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 new file mode 100644 index 0000000..80665d5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -0,0 +1,754 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.dao.asset.AssetDao; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.common.data.util.TbPair; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.sql.tenant.TenantRepository; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; +import org.thingsboard.server.queue.settings.TbRuleEngineQueueConfiguration; +import org.thingsboard.server.service.install.sql.SqlDbHelper; + +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLSyntaxErrorException; +import java.sql.SQLWarning; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +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") +@Slf4j +public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService { + + private static final String SCHEMA_UPDATE_SQL = "schema_update.sql"; + + @Value("${spring.datasource.url}") + private String dbUrl; + + @Value("${spring.datasource.username}") + private String dbUserName; + + @Value("${spring.datasource.password}") + private String dbPassword; + + @Autowired + private DashboardService dashboardService; + + @Autowired + private InstallScripts installScripts; + + @Autowired + private SystemDataLoaderService systemDataLoaderService; + + @Autowired + private TenantService tenantService; + + @Autowired + private TenantRepository tenantRepository; + + @Autowired + private DeviceService deviceService; + + @Autowired + private AssetDao assetDao; + + @Autowired + private DeviceProfileService deviceProfileService; + + @Autowired + private AssetProfileService assetProfileService; + + @Autowired + private ApiUsageStateService apiUsageStateService; + + @Lazy + @Autowired + private QueueService queueService; + + @Autowired + private TbRuleEngineQueueConfigService queueConfig; + + @Autowired + private DbUpgradeExecutorService dbUpgradeExecutor; + + @Override + public void upgradeDatabase(String fromVersion) throws Exception { + switch (fromVersion) { + case "1.3.0": + log.info("Updating schema ..."); + Path schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "1.3.1", SCHEMA_UPDATE_SQL); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + loadSql(schemaUpdateFile, conn); + } + log.info("Schema updated."); + break; + case "1.3.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + + log.info("Dumping dashboards ..."); + Path dashboardsDump = SqlDbHelper.dumpTableIfExists(conn, DASHBOARD, + new String[]{ID, TENANT_ID, CUSTOMER_ID, TITLE, SEARCH_TEXT, ASSIGNED_CUSTOMERS, CONFIGURATION}, + new String[]{"", "", "", "", "", "", ""}, + "tb-dashboards", true); + log.info("Dashboards dumped."); + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "1.4.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + + log.info("Restoring dashboards ..."); + if (dashboardsDump != null) { + SqlDbHelper.loadTable(conn, DASHBOARD, + new String[]{ID, TENANT_ID, TITLE, SEARCH_TEXT, CONFIGURATION}, dashboardsDump, true); + DatabaseHelper.upgradeTo40_assignDashboards(dashboardsDump, dashboardService, true); + Files.deleteIfExists(dashboardsDump); + } + log.info("Dashboards restored."); + } + break; + case "1.4.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.0.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + 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; + case "2.3.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.3.1", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; + case "2.3.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.4.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + try { + conn.createStatement().execute("ALTER TABLE device ADD COLUMN label varchar(255)"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + log.info("Schema updated."); + } + break; + case "2.4.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("ALTER TABLE asset ADD COLUMN label varchar(255)"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.4.2", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + try { + conn.createStatement().execute("ALTER TABLE device ADD CONSTRAINT device_name_unq_key UNIQUE (tenant_id, name)"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + try { + conn.createStatement().execute("ALTER TABLE device_credentials ADD CONSTRAINT device_credentials_id_unq_key UNIQUE (credentials_id)"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + try { + conn.createStatement().execute("ALTER TABLE asset ADD CONSTRAINT asset_name_unq_key UNIQUE (tenant_id, name)"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + log.info("Schema updated."); + } + break; + case "2.4.2": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("ALTER TABLE alarm ADD COLUMN propagate_relation_types varchar"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + log.info("Schema updated."); + } + break; + case "2.4.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("ALTER TABLE attribute_kv ADD COLUMN json_v json;"); + } catch (Exception e) { + if (e instanceof SQLSyntaxErrorException) { + try { + conn.createStatement().execute("ALTER TABLE attribute_kv ADD COLUMN json_v varchar(10000000);"); + } catch (Exception e1) { + } + } + } + try { + conn.createStatement().execute("ALTER TABLE tenant ADD COLUMN isolated_tb_core boolean DEFAULT (false), ADD COLUMN isolated_tb_rule_engine boolean DEFAULT (false)"); + } catch (Exception e) { + } + try { + long ts = System.currentTimeMillis(); + conn.createStatement().execute("ALTER TABLE event ADD COLUMN ts bigint DEFAULT " + ts + ";"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception e) { + } + log.info("Schema updated."); + } + break; + case "3.0.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + if (isOldSchema(conn, 3000001)) { + String[] tables = new String[]{"admin_settings", "alarm", "asset", "audit_log", "attribute_kv", + "component_descriptor", "customer", "dashboard", "device", "device_credentials", "event", + "relation", "tb_user", "tenant", "user_credentials", "widget_type", "widgets_bundle", + "rule_chain", "rule_node", "entity_view"}; + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.0.1", "schema_update_to_uuid.sql"); + loadSql(schemaUpdateFile, conn); + + conn.createStatement().execute("call drop_all_idx()"); + + log.info("Optimizing alarm relations..."); + conn.createStatement().execute("DELETE from relation WHERE relation_type_group = 'ALARM' AND relation_type <> 'ALARM_ANY';"); + conn.createStatement().execute("DELETE from relation WHERE relation_type_group = 'ALARM' AND relation_type = 'ALARM_ANY' " + + "AND exists(SELECT * FROM alarm WHERE alarm.id = relation.to_id AND alarm.originator_id = relation.from_id)"); + log.info("Alarm relations optimized."); + + for (String table : tables) { + log.info("Updating table {}.", table); + Statement statement = conn.createStatement(); + statement.execute("call update_" + table + "();"); + + SQLWarning warnings = statement.getWarnings(); + if (warnings != null) { + log.info("{}", warnings.getMessage()); + SQLWarning nextWarning = warnings.getNextWarning(); + while (nextWarning != null) { + log.info("{}", nextWarning.getMessage()); + nextWarning = nextWarning.getNextWarning(); + } + } + + conn.createStatement().execute("DROP PROCEDURE update_" + table); + log.info("Table {} updated.", table); + } + conn.createStatement().execute("call create_all_idx()"); + + conn.createStatement().execute("DROP PROCEDURE drop_all_idx"); + conn.createStatement().execute("DROP PROCEDURE create_all_idx"); + conn.createStatement().execute("DROP FUNCTION column_type_to_uuid"); + + log.info("Updating alarm relations..."); + conn.createStatement().execute("UPDATE relation SET relation_type = 'ANY' WHERE relation_type_group = 'ALARM' AND relation_type = 'ALARM_ANY';"); + log.info("Alarm relations updated."); + + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3001000;"); + + conn.createStatement().execute("VACUUM FULL"); + } + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.1.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.1.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; + case "3.1.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + if (isOldSchema(conn, 3001000)) { + + try { + conn.createStatement().execute("ALTER TABLE device ADD COLUMN device_profile_id uuid, ADD COLUMN device_data jsonb"); + } catch (Exception e) { + } + + try { + conn.createStatement().execute("ALTER TABLE tenant ADD COLUMN tenant_profile_id uuid"); + } catch (Exception e) { + } + + try { + conn.createStatement().execute("CREATE TABLE IF NOT EXISTS rule_node_state (" + + " id uuid NOT NULL CONSTRAINT rule_node_state_pkey PRIMARY KEY," + + " created_time bigint NOT NULL," + + " rule_node_id uuid NOT NULL," + + " entity_type varchar(32) NOT NULL," + + " entity_id uuid NOT NULL," + + " state_data varchar(16384) NOT NULL," + + " CONSTRAINT rule_node_state_unq_key UNIQUE (rule_node_id, entity_id)," + + " CONSTRAINT fk_rule_node_state_node_id FOREIGN KEY (rule_node_id) REFERENCES rule_node(id) ON DELETE CASCADE)"); + } catch (Exception e) { + } + + try { + conn.createStatement().execute("CREATE TABLE IF NOT EXISTS api_usage_state (" + + " id uuid NOT NULL CONSTRAINT usage_record_pkey PRIMARY KEY," + + " created_time bigint NOT NULL," + + " tenant_id uuid," + + " entity_type varchar(32)," + + " entity_id uuid," + + " transport varchar(32)," + + " db_storage varchar(32)," + + " re_exec varchar(32)," + + " js_exec varchar(32)," + + " email_exec varchar(32)," + + " sms_exec varchar(32)," + + " CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id)\n" + + ");"); + } catch (Exception e) { + } + + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.1.1", "schema_update_before.sql"); + loadSql(schemaUpdateFile, conn); + + log.info("Creating default tenant profiles..."); + systemDataLoaderService.createDefaultTenantProfiles(); + + log.info("Updating tenant profiles..."); + conn.createStatement().execute("call update_tenant_profiles()"); + + log.info("Creating default device profiles..."); + PageLink pageLink = new PageLink(100); + PageData pageData; + do { + pageData = tenantService.findTenants(pageLink); + for (Tenant tenant : pageData.getData()) { + try { + apiUsageStateService.createDefaultApiUsageState(tenant.getId(), null); + } catch (Exception e) { + } + List deviceTypes = deviceService.findDeviceTypesByTenantId(tenant.getId()).get(); + try { + deviceProfileService.createDefaultDeviceProfile(tenant.getId()); + } catch (Exception e) { + } + for (EntitySubtype deviceType : deviceTypes) { + try { + deviceProfileService.findOrCreateDeviceProfile(tenant.getId(), deviceType.getType()); + } catch (Exception e) { + } + } + } + pageLink = pageLink.nextPageLink(); + } while (pageData.hasNext()); + + log.info("Updating device profiles..."); + conn.createStatement().execute("call update_device_profiles()"); + + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.1.1", "schema_update_after.sql"); + loadSql(schemaUpdateFile, conn); + + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3002000;"); + } + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.2.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_device_device_profile_id ON device(tenant_id, device_profile_id);"); + conn.createStatement().execute("ALTER TABLE dashboard ALTER COLUMN configuration TYPE varchar;"); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3002001;"); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + log.info("Schema updated."); + } + break; + case "3.2.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + conn.createStatement().execute("CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_id_and_created_time ON audit_log(tenant_id, created_time);"); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.2.1", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3002002;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.2.2": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("ALTER TABLE rule_chain ADD COLUMN type varchar(255) DEFAULT 'CORE'"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception ignored) { + } + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.2.2", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Load Edge TTL functions ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.2.2", "schema_update_ttl.sql"); + loadSql(schemaUpdateFile, conn); + log.info("Edge TTL functions successfully loaded!"); + log.info("Updating indexes and TTL procedure for event table..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.2.2", "schema_update_event.sql"); + loadSql(schemaUpdateFile, conn); + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3003000;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.3.2": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.2", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + try { + conn.createStatement().execute("ALTER TABLE alarm ADD COLUMN propagate_to_owner boolean DEFAULT false;"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + conn.createStatement().execute("ALTER TABLE alarm ADD COLUMN propagate_to_tenant boolean DEFAULT false;"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception ignored) { + } + + try { + conn.createStatement().execute("insert into entity_alarm(tenant_id, entity_id, created_time, alarm_type, customer_id, alarm_id)" + + " select tenant_id, originator_id, created_time, type, customer_id, id from alarm ON CONFLICT DO NOTHING;"); + conn.createStatement().execute("insert into entity_alarm(tenant_id, entity_id, created_time, alarm_type, customer_id, alarm_id)" + + " select a.tenant_id, r.from_id, created_time, type, customer_id, id" + + " from alarm a inner join relation r on r.relation_type_group = 'ALARM' and r.relation_type = 'ANY' and a.id = r.to_id ON CONFLICT DO NOTHING;"); + conn.createStatement().execute("delete from relation r where r.relation_type_group = 'ALARM';"); + } catch (Exception e) { + log.error("Failed to update alarm relations!!!", e); + } + + log.info("Updating lwm2m device profiles ..."); + try { + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.2", "schema_update_lwm2m_bootstrap.sql"); + loadSql(schemaUpdateFile, conn); + log.info("Updating server`s public key from HexDec to Base64 in profile for LWM2M..."); + conn.createStatement().execute("call update_profile_bootstrap();"); + log.info("Server`s public key from HexDec to Base64 in profile for LWM2M updated."); + log.info("Updating client`s public key and secret key from HexDec to Base64 for LWM2M..."); + conn.createStatement().execute("call update_device_credentials_to_base64_and_bootstrap();"); + log.info("Client`s public key and secret key from HexDec to Base64 for LWM2M updated."); + } catch (Exception e) { + log.error("Failed to update lwm2m profiles!!!", e); + } + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3003003;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.3.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + try { + conn.createStatement().execute("ALTER TABLE edge DROP COLUMN edge_license_key;"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + conn.createStatement().execute("ALTER TABLE edge DROP COLUMN cloud_endpoint;"); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + } catch (Exception ignored) { + } + + log.info("Updating TTL cleanup procedure for the event table..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.3", "schema_event_ttl_procedure.sql"); + loadSql(schemaUpdateFile, conn); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.3", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3003004;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.3.4": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.4", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + + log.info("Loading queues..."); + try { + if (!CollectionUtils.isEmpty(queueConfig.getQueues())) { + queueConfig.getQueues().forEach(queueSettings -> { + Queue queue = queueConfigToQueue(queueSettings); + Queue existing = queueService.findQueueByTenantIdAndName(queue.getTenantId(), queue.getName()); + if (existing == null) { + queueService.saveQueue(queue); + } + }); + } else { + systemDataLoaderService.createQueues(); + } + } catch (Exception e) { + } + + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3004000;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.4.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.4.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3004001;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + case "3.4.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + runSchemaUpdateScript(conn, "3.4.1"); + if (isOldSchema(conn, 3004001)) { + try { + conn.createStatement().execute("ALTER TABLE asset ADD COLUMN asset_profile_id uuid"); + } catch (Exception e) { + } + + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.4.1", "schema_update_before.sql"); + loadSql(schemaUpdateFile, conn); + + conn.createStatement().execute("DELETE FROM asset a WHERE NOT exists(SELECT id FROM tenant WHERE id = a.tenant_id);"); + + log.info("Creating default asset profiles..."); + + PageLink pageLink = new PageLink(1000); + PageData tenantIds; + do { + List> futures = new ArrayList<>(); + tenantIds = tenantService.findTenantsIds(pageLink); + for (TenantId tenantId : tenantIds.getData()) { + futures.add(dbUpgradeExecutor.submit(() -> { + try { + assetProfileService.createDefaultAssetProfile(tenantId); + } catch (Exception e) {} + })); + } + Futures.allAsList(futures).get(); + pageLink = pageLink.nextPageLink(); + } while (tenantIds.hasNext()); + + pageLink = new PageLink(1000); + PageData> pairs; + do { + List> futures = new ArrayList<>(); + pairs = assetDao.getAllAssetTypes(pageLink); + for (TbPair pair : pairs.getData()) { + TenantId tenantId = new TenantId(pair.getFirst()); + String assetType = pair.getSecond(); + if (!"default".equals(assetType)) { + futures.add(dbUpgradeExecutor.submit(() -> { + try { + assetProfileService.findOrCreateAssetProfile(tenantId, assetType); + } catch (Exception e) {} + })); + } + } + Futures.allAsList(futures).get(); + pageLink = pageLink.nextPageLink(); + } while (pairs.hasNext()); + + log.info("Updating asset profiles..."); + conn.createStatement().execute("call update_asset_profiles()"); + + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.4.1", "schema_update_after.sql"); + loadSql(schemaUpdateFile, conn); + + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3004002;"); + } + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; + default: + throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); + } + } + + private void runSchemaUpdateScript(Connection connection, String version) throws Exception { + Path schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", version, SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, connection); + } + + private void loadSql(Path sqlFile, Connection conn) throws Exception { + String sql = new String(Files.readAllBytes(sqlFile), Charset.forName("UTF-8")); + Statement st = conn.createStatement(); + st.setQueryTimeout((int) TimeUnit.HOURS.toSeconds(3)); + st.execute(sql);//NOSONAR, ignoring because method used to execute thingsboard database upgrade script + printWarnings(st); + Thread.sleep(5000); + } + + protected void printWarnings(Statement statement) throws SQLException { + SQLWarning warnings = statement.getWarnings(); + if (warnings != null) { + log.info("{}", warnings.getMessage()); + SQLWarning nextWarning = warnings.getNextWarning(); + while (nextWarning != null) { + log.info("{}", nextWarning.getMessage()); + nextWarning = nextWarning.getNextWarning(); + } + } + } + + protected boolean isOldSchema(Connection conn, long fromVersion) { + boolean isOldSchema = true; + try { + Statement statement = conn.createStatement(); + statement.execute("CREATE TABLE IF NOT EXISTS tb_schema_settings ( schema_version bigint NOT NULL, CONSTRAINT tb_schema_settings_pkey PRIMARY KEY (schema_version));"); + Thread.sleep(1000); + ResultSet resultSet = statement.executeQuery("SELECT schema_version FROM tb_schema_settings;"); + if (resultSet.next()) { + isOldSchema = resultSet.getLong(1) <= fromVersion; + } else { + resultSet.close(); + statement.execute("INSERT INTO tb_schema_settings (schema_version) VALUES (" + fromVersion + ")"); + } + statement.close(); + } catch (InterruptedException | SQLException e) { + log.info("Failed to check current PostgreSQL schema due to: {}", e.getMessage()); + } + return isOldSchema; + } + + private Queue queueConfigToQueue(TbRuleEngineQueueConfiguration queueSettings) { + Queue queue = new Queue(); + queue.setTenantId(TenantId.SYS_TENANT_ID); + queue.setName(queueSettings.getName()); + queue.setTopic(queueSettings.getTopic()); + queue.setPollInterval(queueSettings.getPollInterval()); + queue.setPartitions(queueSettings.getPartitions()); + queue.setPackProcessingTimeout(queueSettings.getPackProcessingTimeout()); + SubmitStrategy submitStrategy = new SubmitStrategy(); + submitStrategy.setBatchSize(queueSettings.getSubmitStrategy().getBatchSize()); + submitStrategy.setType(SubmitStrategyType.valueOf(queueSettings.getSubmitStrategy().getType())); + queue.setSubmitStrategy(submitStrategy); + ProcessingStrategy processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.valueOf(queueSettings.getProcessingStrategy().getType())); + processingStrategy.setRetries(queueSettings.getProcessingStrategy().getRetries()); + processingStrategy.setFailurePercentage(queueSettings.getProcessingStrategy().getFailurePercentage()); + processingStrategy.setPauseBetweenRetries(queueSettings.getProcessingStrategy().getPauseBetweenRetries()); + processingStrategy.setMaxPauseBetweenRetries(queueSettings.getProcessingStrategy().getMaxPauseBetweenRetries()); + queue.setProcessingStrategy(processingStrategy); + queue.setConsumerPerPartition(queueSettings.isConsumerPerPartition()); + return queue; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java new file mode 100644 index 0000000..7ae1554 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile("install") +@Slf4j +public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService + implements EntityDatabaseSchemaService { + public static final String SCHEMA_ENTITIES_SQL = "schema-entities.sql"; + public static final String SCHEMA_ENTITIES_IDX_SQL = "schema-entities-idx.sql"; + public static final String SCHEMA_ENTITIES_IDX_PSQL_ADDON_SQL = "schema-entities-idx-psql-addon.sql"; + + public SqlEntityDatabaseSchemaService() { + super(SCHEMA_ENTITIES_SQL, SCHEMA_ENTITIES_IDX_SQL); + } + + @Override + public void createDatabaseIndexes() throws Exception { + super.createDatabaseIndexes(); + log.info("Installing SQL DataBase schema PostgreSQL specific indexes part: " + SCHEMA_ENTITIES_IDX_PSQL_ADDON_SQL); + executeQueryFromFile(SCHEMA_ENTITIES_IDX_PSQL_ADDON_SQL); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java new file mode 100644 index 0000000..f3ede62 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.SqlTsDao; + +@Service +@SqlTsDao +@Profile("install") +public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService implements TsDatabaseSchemaService { + + @Value("${sql.postgres.ts_key_value_partitioning:MONTHS}") + private String partitionType; + + public SqlTsDatabaseSchemaService() { + super("schema-ts-psql.sql", null); + } + + @Override + public void createDatabaseSchema() throws Exception { + super.createDatabaseSchema(); + executeQuery("CREATE TABLE IF NOT EXISTS ts_kv_indefinite PARTITION OF ts_kv DEFAULT;"); + } +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseUpgradeService.java new file mode 100644 index 0000000..003f544 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseUpgradeService.java @@ -0,0 +1,260 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.dao.util.SqlTsDao; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; + +@Service +@Profile("install") +@Slf4j +@SqlTsDao +public class SqlTsDatabaseUpgradeService extends AbstractSqlTsDatabaseUpgradeService implements DatabaseTsUpgradeService { + + @Value("${sql.postgres.ts_key_value_partitioning:MONTHS}") + private String partitionType; + + private static final String TS_KV_LATEST_SQL = "ts_kv_latest.sql"; + private static final String LOAD_FUNCTIONS_SQL = "schema_update_psql_ts.sql"; + private static final String LOAD_TTL_FUNCTIONS_SQL = "schema_update_ttl.sql"; + private static final String LOAD_DROP_PARTITIONS_FUNCTIONS_SQL = "schema_update_psql_drop_partitions.sql"; + + private static final String TS_KV_OLD = "ts_kv_old;"; + private static final String TS_KV_LATEST_OLD = "ts_kv_latest_old;"; + + private static final String CREATE_PARTITION_TS_KV_TABLE = "create_partition_ts_kv_table()"; + private static final String CREATE_NEW_TS_KV_LATEST_TABLE = "create_new_ts_kv_latest_table()"; + private static final String CREATE_PARTITIONS = "create_partitions(IN partition_type varchar)"; + private static final String CREATE_TS_KV_DICTIONARY_TABLE = "create_ts_kv_dictionary_table()"; + private static final String INSERT_INTO_DICTIONARY = "insert_into_dictionary()"; + private static final String INSERT_INTO_TS_KV = "insert_into_ts_kv(IN path_to_file varchar)"; + private static final String INSERT_INTO_TS_KV_LATEST = "insert_into_ts_kv_latest(IN path_to_file varchar)"; + private static final String INSERT_INTO_TS_KV_CURSOR = "insert_into_ts_kv_cursor()"; + private static final String INSERT_INTO_TS_KV_LATEST_CURSOR = "insert_into_ts_kv_latest_cursor()"; + + private static final String CALL_CREATE_PARTITION_TS_KV_TABLE = CALL_REGEX + CREATE_PARTITION_TS_KV_TABLE; + private static final String CALL_CREATE_NEW_TS_KV_LATEST_TABLE = CALL_REGEX + CREATE_NEW_TS_KV_LATEST_TABLE; + private static final String CALL_CREATE_TS_KV_DICTIONARY_TABLE = CALL_REGEX + CREATE_TS_KV_DICTIONARY_TABLE; + private static final String CALL_INSERT_INTO_DICTIONARY = CALL_REGEX + INSERT_INTO_DICTIONARY; + private static final String CALL_INSERT_INTO_TS_KV_CURSOR = CALL_REGEX + INSERT_INTO_TS_KV_CURSOR; + private static final String CALL_INSERT_INTO_TS_KV_LATEST_CURSOR = CALL_REGEX + INSERT_INTO_TS_KV_LATEST_CURSOR; + + private static final String DROP_TABLE_TS_KV_OLD = DROP_TABLE + TS_KV_OLD; + private static final String DROP_TABLE_TS_KV_LATEST_OLD = DROP_TABLE + TS_KV_LATEST_OLD; + + private static final String DROP_PROCEDURE_CREATE_PARTITION_TS_KV_TABLE = DROP_PROCEDURE_IF_EXISTS + CREATE_PARTITION_TS_KV_TABLE; + private static final String DROP_PROCEDURE_CREATE_NEW_TS_KV_LATEST_TABLE = DROP_PROCEDURE_IF_EXISTS + CREATE_NEW_TS_KV_LATEST_TABLE; + private static final String DROP_PROCEDURE_CREATE_PARTITIONS = DROP_PROCEDURE_IF_EXISTS + CREATE_PARTITIONS; + private static final String DROP_PROCEDURE_CREATE_TS_KV_DICTIONARY_TABLE = DROP_PROCEDURE_IF_EXISTS + CREATE_TS_KV_DICTIONARY_TABLE; + private static final String DROP_PROCEDURE_INSERT_INTO_DICTIONARY = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_DICTIONARY; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV_LATEST; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV_CURSOR = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV_CURSOR; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST_CURSOR = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV_LATEST_CURSOR; + private static final String DROP_FUNCTION_GET_PARTITION_DATA = "DROP FUNCTION IF EXISTS get_partitions_data;"; + + @Override + public void upgradeDatabase(String fromVersion) throws Exception { + switch (fromVersion) { + case "2.4.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Check the current PostgreSQL version..."); + boolean versionValid = checkVersion(conn); + if (!versionValid) { + throw new RuntimeException("PostgreSQL version should be at least more than 11, please upgrade your PostgreSQL and restart the script!"); + } else { + log.info("PostgreSQL version is valid!"); + if (isOldSchema(conn, 2004003)) { + log.info("Load upgrade functions ..."); + loadSql(conn, LOAD_FUNCTIONS_SQL, "2.4.3"); + log.info("Updating timeseries schema ..."); + executeQuery(conn, CALL_CREATE_PARTITION_TS_KV_TABLE); + if (!partitionType.equals("INDEFINITE")) { + executeQuery(conn, "call create_partitions('" + partitionType + "')"); + } + executeQuery(conn, CALL_CREATE_TS_KV_DICTIONARY_TABLE); + executeQuery(conn, CALL_INSERT_INTO_DICTIONARY); + + Path pathToTempTsKvFile = null; + Path pathToTempTsKvLatestFile = null; + if (SystemUtils.IS_OS_WINDOWS) { + log.info("Lookup for environment variable: {} ...", THINGSBOARD_WINDOWS_UPGRADE_DIR); + Path pathToDir; + String thingsboardWindowsUpgradeDir = System.getenv("THINGSBOARD_WINDOWS_UPGRADE_DIR"); + if (StringUtils.isNotEmpty(thingsboardWindowsUpgradeDir)) { + log.info("Environment variable: {} was found!", THINGSBOARD_WINDOWS_UPGRADE_DIR); + pathToDir = Paths.get(thingsboardWindowsUpgradeDir); + } else { + log.info("Failed to lookup environment variable: {}", THINGSBOARD_WINDOWS_UPGRADE_DIR); + pathToDir = Paths.get(PATH_TO_USERS_PUBLIC_FOLDER); + } + log.info("Directory: {} will be used for creation temporary upgrade files!", pathToDir); + try { + Path tsKvFile = Files.createTempFile(pathToDir, "ts_kv", ".sql"); + Path tsKvLatestFile = Files.createTempFile(pathToDir, "ts_kv_latest", ".sql"); + pathToTempTsKvFile = tsKvFile.toAbsolutePath(); + pathToTempTsKvLatestFile = tsKvLatestFile.toAbsolutePath(); + try { + copyTimeseries(conn, pathToTempTsKvFile, pathToTempTsKvLatestFile); + } catch (Exception e) { + insertTimeseries(conn); + } + } catch (IOException | SecurityException e) { + log.warn("Failed to create time-series upgrade files due to: {}", e.getMessage()); + insertTimeseries(conn); + } + } else { + try { + Path tempDirPath = Files.createTempDirectory("ts_kv"); + File tempDirAsFile = tempDirPath.toFile(); + boolean writable = tempDirAsFile.setWritable(true, false); + boolean readable = tempDirAsFile.setReadable(true, false); + boolean executable = tempDirAsFile.setExecutable(true, false); + pathToTempTsKvFile = tempDirPath.resolve(TS_KV_SQL).toAbsolutePath(); + pathToTempTsKvLatestFile = tempDirPath.resolve(TS_KV_LATEST_SQL).toAbsolutePath(); + try { + if (writable && readable && executable) { + copyTimeseries(conn, pathToTempTsKvFile, pathToTempTsKvLatestFile); + } else { + throw new RuntimeException("Failed to grant write permissions for the: " + tempDirPath + "folder!"); + } + } catch (Exception e) { + insertTimeseries(conn); + } + } catch (IOException | SecurityException e) { + log.warn("Failed to create time-series upgrade files due to: {}", e.getMessage()); + insertTimeseries(conn); + } + } + + removeUpgradeFiles(pathToTempTsKvFile, pathToTempTsKvLatestFile); + + executeQuery(conn, DROP_TABLE_TS_KV_OLD); + executeQuery(conn, DROP_TABLE_TS_KV_LATEST_OLD); + + executeQuery(conn, DROP_PROCEDURE_CREATE_PARTITION_TS_KV_TABLE); + executeQuery(conn, DROP_PROCEDURE_CREATE_PARTITIONS); + executeQuery(conn, DROP_PROCEDURE_CREATE_TS_KV_DICTIONARY_TABLE); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_DICTIONARY); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV); + executeQuery(conn, DROP_PROCEDURE_CREATE_NEW_TS_KV_LATEST_TABLE); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV_CURSOR); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST_CURSOR); + executeQuery(conn, DROP_FUNCTION_GET_PARTITION_DATA); + + executeQuery(conn, "ALTER TABLE ts_kv ADD COLUMN IF NOT EXISTS json_v json;"); + executeQuery(conn, "ALTER TABLE ts_kv_latest ADD COLUMN IF NOT EXISTS json_v json;"); + } else { + executeQuery(conn, "ALTER TABLE ts_kv DROP CONSTRAINT IF EXISTS ts_kv_pkey;"); + executeQuery(conn, "ALTER TABLE ts_kv ADD CONSTRAINT ts_kv_pkey PRIMARY KEY (entity_id, key, ts);"); + } + + log.info("Load TTL functions ..."); + loadSql(conn, LOAD_TTL_FUNCTIONS_SQL, "2.4.3"); + log.info("Load Drop Partitions functions ..."); + loadSql(conn, LOAD_DROP_PARTITIONS_FUNCTIONS_SQL, "2.4.3"); + + executeQuery(conn, "UPDATE tb_schema_settings SET schema_version = 2005000"); + + log.info("schema timeseries updated!"); + } + } + break; + case "2.5.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + executeQuery(conn, "CREATE TABLE IF NOT EXISTS ts_kv_indefinite PARTITION OF ts_kv DEFAULT;"); + executeQuery(conn, "UPDATE tb_schema_settings SET schema_version = 2005001"); + } + break; + case "3.1.1": + case "3.2.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Load TTL functions ..."); + loadSql(conn, LOAD_TTL_FUNCTIONS_SQL, "2.4.3"); + log.info("Load Drop Partitions functions ..."); + loadSql(conn, LOAD_DROP_PARTITIONS_FUNCTIONS_SQL, "2.4.3"); + + executeQuery(conn, "DROP PROCEDURE IF EXISTS cleanup_timeseries_by_ttl(character varying, bigint, bigint);"); + executeQuery(conn, "DROP FUNCTION IF EXISTS delete_asset_records_from_ts_kv(character varying, character varying, bigint);"); + executeQuery(conn, "DROP FUNCTION IF EXISTS delete_device_records_from_ts_kv(character varying, character varying, bigint);"); + executeQuery(conn, "DROP FUNCTION IF EXISTS delete_customer_records_from_ts_kv(character varying, character varying, bigint);"); + } + break; + case "3.2.2": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Load Drop Partitions functions ..."); + loadSql(conn, LOAD_DROP_PARTITIONS_FUNCTIONS_SQL, "2.4.3"); + } + break; + default: + throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); + } + } + + private void removeUpgradeFiles(Path pathToTempTsKvFile, Path pathToTempTsKvLatestFile) { + if (pathToTempTsKvFile != null && pathToTempTsKvFile.toFile().exists()) { + boolean deleteTsKvFile = pathToTempTsKvFile.toFile().delete(); + if (deleteTsKvFile) { + log.info("Successfully deleted the temp file for ts_kv table upgrade!"); + } + } + if (pathToTempTsKvLatestFile != null && pathToTempTsKvLatestFile.toFile().exists()) { + boolean deleteTsKvLatestFile = pathToTempTsKvLatestFile.toFile().delete(); + if (deleteTsKvLatestFile) { + log.info("Successfully deleted the temp file for ts_kv_latest table upgrade!"); + } + } + } + + private void copyTimeseries(Connection conn, Path pathToTempTsKvFile, Path pathToTempTsKvLatestFile) { + executeQuery(conn, "call insert_into_ts_kv('" + pathToTempTsKvFile + "')"); + executeQuery(conn, CALL_CREATE_NEW_TS_KV_LATEST_TABLE); + executeQuery(conn, "call insert_into_ts_kv_latest('" + pathToTempTsKvLatestFile + "')"); + } + + private void insertTimeseries(Connection conn) { + log.warn("Upgrade script failed using the copy to/from files strategy!" + + " Trying to perfrom the upgrade using Inserts strategy ..."); + executeQuery(conn, CALL_INSERT_INTO_TS_KV_CURSOR); + executeQuery(conn, CALL_CREATE_NEW_TS_KV_LATEST_TABLE); + executeQuery(conn, CALL_INSERT_INTO_TS_KV_LATEST_CURSOR); + } + + @Override + protected void loadSql(Connection conn, String fileName, String version) { + Path schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", version, fileName); + try { + loadFunctions(schemaUpdateFile, conn); + log.info("Functions successfully loaded!"); + } catch (Exception e) { + log.info("Failed to load PostgreSQL upgrade functions due to: {}", e.getMessage()); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java new file mode 100644 index 0000000..041351f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface SystemDataLoaderService { + + void createSysAdmin() throws Exception; + + void createDefaultTenantProfiles() throws Exception; + + void createAdminSettings() throws Exception; + + void createRandomJwtSettings() throws Exception; + + void saveLegacyYmlSettings() throws Exception; + + void createOAuth2Templates() throws Exception; + + void loadSystemWidgets() throws Exception; + + void updateSystemWidgets() throws Exception; + + void loadDemoData() throws Exception; + + void deleteSystemWidgetBundle(String bundleAlias) throws Exception; + + void createQueues(); +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/TbRuleEngineQueueConfigService.java b/application/src/main/java/org/thingsboard/server/service/install/TbRuleEngineQueueConfigService.java new file mode 100644 index 0000000..1125467 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TbRuleEngineQueueConfigService.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.thingsboard.server.queue.settings.TbRuleEngineQueueConfiguration; + +import javax.annotation.PostConstruct; +import java.util.List; + +@Slf4j +@Data +@EnableAutoConfiguration +@Configuration +@ConfigurationProperties(prefix = "queue.rule-engine") +@Profile("install") +public class TbRuleEngineQueueConfigService { + + private String topic; + private List queues; + + @PostConstruct + public void validate() { + queues.stream().filter(queue -> queue.getName().equals("Main")).findFirst().orElseThrow(() -> { + log.error("Main queue is not configured in thingsboard.yml"); + return new RuntimeException("No \"Main\" queue configured!"); + }); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseSchemaService.java new file mode 100644 index 0000000..409498c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseSchemaService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.TimescaleDBTsDao; + +@Service +@TimescaleDBTsDao +@Profile("install") +@Slf4j +public class TimescaleTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService implements TsDatabaseSchemaService { + + @Value("${sql.timescale.chunk_time_interval:86400000}") + private long chunkTimeInterval; + + public TimescaleTsDatabaseSchemaService() { + super("schema-timescale.sql", null); + } + + @Override + public void createDatabaseSchema() throws Exception { + super.createDatabaseSchema(); + executeQuery("SELECT create_hypertable('ts_kv', 'ts', chunk_time_interval => " + chunkTimeInterval + ", if_not_exists => true);"); + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseUpgradeService.java new file mode 100644 index 0000000..16c054b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TimescaleTsDatabaseUpgradeService.java @@ -0,0 +1,217 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.dao.util.TimescaleDBTsDao; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; + +@Service +@Profile("install") +@Slf4j +@TimescaleDBTsDao +public class TimescaleTsDatabaseUpgradeService extends AbstractSqlTsDatabaseUpgradeService implements DatabaseTsUpgradeService { + + @Value("${sql.timescale.chunk_time_interval:86400000}") + private long chunkTimeInterval; + + private static final String LOAD_FUNCTIONS_SQL = "schema_update_timescale_ts.sql"; + private static final String LOAD_TTL_FUNCTIONS_SQL = "schema_update_ttl.sql"; + + private static final String TENANT_TS_KV_OLD_TABLE = "tenant_ts_kv_old;"; + + private static final String CREATE_TS_KV_LATEST_TABLE = "create_ts_kv_latest_table()"; + private static final String CREATE_NEW_TS_KV_TABLE = "create_new_ts_kv_table()"; + private static final String CREATE_TS_KV_DICTIONARY_TABLE = "create_ts_kv_dictionary_table()"; + private static final String INSERT_INTO_DICTIONARY = "insert_into_dictionary()"; + private static final String INSERT_INTO_TS_KV = "insert_into_ts_kv(IN path_to_file varchar)"; + private static final String INSERT_INTO_TS_KV_CURSOR = "insert_into_ts_kv_cursor()"; + private static final String INSERT_INTO_TS_KV_LATEST = "insert_into_ts_kv_latest()"; + + private static final String CALL_CREATE_TS_KV_LATEST_TABLE = CALL_REGEX + CREATE_TS_KV_LATEST_TABLE; + private static final String CALL_CREATE_NEW_TENANT_TS_KV_TABLE = CALL_REGEX + CREATE_NEW_TS_KV_TABLE; + private static final String CALL_CREATE_TS_KV_DICTIONARY_TABLE = CALL_REGEX + CREATE_TS_KV_DICTIONARY_TABLE; + private static final String CALL_INSERT_INTO_DICTIONARY = CALL_REGEX + INSERT_INTO_DICTIONARY; + private static final String CALL_INSERT_INTO_TS_KV_LATEST = CALL_REGEX + INSERT_INTO_TS_KV_LATEST; + private static final String CALL_INSERT_INTO_TS_KV_CURSOR = CALL_REGEX + INSERT_INTO_TS_KV_CURSOR; + + private static final String DROP_OLD_TENANT_TS_KV_TABLE = DROP_TABLE + TENANT_TS_KV_OLD_TABLE; + + private static final String DROP_PROCEDURE_CREATE_TS_KV_LATEST_TABLE = DROP_PROCEDURE_IF_EXISTS + CREATE_TS_KV_LATEST_TABLE; + private static final String DROP_PROCEDURE_CREATE_TENANT_TS_KV_TABLE_COPY = DROP_PROCEDURE_IF_EXISTS + CREATE_NEW_TS_KV_TABLE; + private static final String DROP_PROCEDURE_CREATE_TS_KV_DICTIONARY_TABLE = DROP_PROCEDURE_IF_EXISTS + CREATE_TS_KV_DICTIONARY_TABLE; + private static final String DROP_PROCEDURE_INSERT_INTO_DICTIONARY = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_DICTIONARY; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV_CURSOR = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV_CURSOR; + private static final String DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST = DROP_PROCEDURE_IF_EXISTS + INSERT_INTO_TS_KV_LATEST; + + @Autowired + private InstallScripts installScripts; + + @Override + public void upgradeDatabase(String fromVersion) throws Exception { + switch (fromVersion) { + case "2.4.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Check the current PostgreSQL version..."); + boolean versionValid = checkVersion(conn); + if (!versionValid) { + throw new RuntimeException("PostgreSQL version should be at least more than 11, please upgrade your PostgreSQL and restart the script!"); + } else { + log.info("PostgreSQL version is valid!"); + if (isOldSchema(conn, 2004003)) { + log.info("Load upgrade functions ..."); + loadSql(conn, LOAD_FUNCTIONS_SQL, "2.4.3"); + log.info("Updating timescale schema ..."); + executeQuery(conn, CALL_CREATE_TS_KV_LATEST_TABLE); + executeQuery(conn, CALL_CREATE_NEW_TENANT_TS_KV_TABLE); + + executeQuery(conn, "SELECT create_hypertable('ts_kv', 'ts', chunk_time_interval => " + chunkTimeInterval + ", if_not_exists => true);"); + + executeQuery(conn, CALL_CREATE_TS_KV_DICTIONARY_TABLE); + executeQuery(conn, CALL_INSERT_INTO_DICTIONARY); + + Path pathToTempTsKvFile = null; + if (SystemUtils.IS_OS_WINDOWS) { + Path pathToDir; + log.info("Lookup for environment variable: {} ...", THINGSBOARD_WINDOWS_UPGRADE_DIR); + String thingsboardWindowsUpgradeDir = System.getenv(THINGSBOARD_WINDOWS_UPGRADE_DIR); + if (StringUtils.isNotEmpty(thingsboardWindowsUpgradeDir)) { + log.info("Environment variable: {} was found!", THINGSBOARD_WINDOWS_UPGRADE_DIR); + pathToDir = Paths.get(thingsboardWindowsUpgradeDir); + } else { + log.info("Failed to lookup environment variable: {}", THINGSBOARD_WINDOWS_UPGRADE_DIR); + pathToDir = Paths.get(PATH_TO_USERS_PUBLIC_FOLDER); + } + log.info("Directory: {} will be used for creation temporary upgrade file!", pathToDir); + try { + Path tsKvFile = Files.createTempFile(pathToDir, "ts_kv", ".sql"); + pathToTempTsKvFile = tsKvFile.toAbsolutePath(); + try { + executeQuery(conn, "call insert_into_ts_kv('" + pathToTempTsKvFile + "')"); + } catch (Exception e) { + insertTimeseries(conn); + } + } catch (IOException | SecurityException e) { + log.warn("Failed to create time-series upgrade files due to: {}", e.getMessage()); + insertTimeseries(conn); + } + } else { + try { + Path tempDirPath = Files.createTempDirectory("ts_kv"); + File tempDirAsFile = tempDirPath.toFile(); + boolean writable = tempDirAsFile.setWritable(true, false); + boolean readable = tempDirAsFile.setReadable(true, false); + boolean executable = tempDirAsFile.setExecutable(true, false); + pathToTempTsKvFile = tempDirPath.resolve(TS_KV_SQL).toAbsolutePath(); + try { + if (writable && readable && executable) { + executeQuery(conn, "call insert_into_ts_kv('" + pathToTempTsKvFile + "')"); + } else { + throw new RuntimeException("Failed to grant write permissions for the: " + tempDirPath + "folder!"); + } + } catch (Exception e) { + insertTimeseries(conn); + } + } catch (IOException | SecurityException e) { + log.warn("Failed to create time-series upgrade files due to: {}", e.getMessage()); + insertTimeseries(conn); + } + } + removeUpgradeFile(pathToTempTsKvFile); + + executeQuery(conn, CALL_INSERT_INTO_TS_KV_LATEST); + + executeQuery(conn, DROP_OLD_TENANT_TS_KV_TABLE); + + executeQuery(conn, DROP_PROCEDURE_CREATE_TS_KV_LATEST_TABLE); + executeQuery(conn, DROP_PROCEDURE_CREATE_TENANT_TS_KV_TABLE_COPY); + executeQuery(conn, DROP_PROCEDURE_CREATE_TS_KV_DICTIONARY_TABLE); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_DICTIONARY); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV_CURSOR); + executeQuery(conn, DROP_PROCEDURE_INSERT_INTO_TS_KV_LATEST); + + executeQuery(conn, "ALTER TABLE ts_kv ADD COLUMN IF NOT EXISTS json_v json;"); + executeQuery(conn, "ALTER TABLE ts_kv_latest ADD COLUMN IF NOT EXISTS json_v json;"); + } + + log.info("Load TTL functions ..."); + loadSql(conn, LOAD_TTL_FUNCTIONS_SQL, "2.4.3"); + + executeQuery(conn, "UPDATE tb_schema_settings SET schema_version = 2005000"); + log.info("schema timescale updated!"); + } + } + break; + case "2.5.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + executeQuery(conn, "UPDATE tb_schema_settings SET schema_version = 2005001"); + } + break; + case "3.1.1": + break; + case "3.2.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + loadSql(conn, LOAD_TTL_FUNCTIONS_SQL, "3.2.1"); + } + break; + case "3.2.2": + break; + default: + throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); + } + } + + private void insertTimeseries(Connection conn) { + log.warn("Upgrade script failed using the copy to/from files strategy!" + + " Trying to perfrom the upgrade using Inserts strategy ..."); + executeQuery(conn, CALL_INSERT_INTO_TS_KV_CURSOR); + } + + private void removeUpgradeFile(Path pathToTempTsKvFile) { + if (pathToTempTsKvFile != null && pathToTempTsKvFile.toFile().exists()) { + boolean deleteTsKvFile = pathToTempTsKvFile.toFile().delete(); + if (deleteTsKvFile) { + log.info("Successfully deleted the temp file for ts_kv table upgrade!"); + } + } + } + + @Override + protected void loadSql(Connection conn, String fileName, String version) { + Path schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", version, fileName); + try { + loadFunctions(schemaUpdateFile, conn); + log.info("Functions successfully loaded!"); + } catch (Exception e) { + log.info("Failed to load PostgreSQL upgrade functions due to: {}", e.getMessage()); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java new file mode 100644 index 0000000..4274f1d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface TsDatabaseSchemaService extends DatabaseSchemaService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/TsLatestDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/TsLatestDatabaseSchemaService.java new file mode 100644 index 0000000..d74afa8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/TsLatestDatabaseSchemaService.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +public interface TsLatestDatabaseSchemaService extends DatabaseSchemaService { +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/cql/CQLStatementsParser.java b/application/src/main/java/org/thingsboard/server/service/install/cql/CQLStatementsParser.java new file mode 100644 index 0000000..9883009 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/cql/CQLStatementsParser.java @@ -0,0 +1,168 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.cql; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class CQLStatementsParser { + + enum State { + DEFAULT, + INSINGLELINECOMMENT, + INMULTILINECOMMENT, + INQUOTESTRING, + INSQUOTESTRING, + + } + + private String text; + private State state; + private int pos; + private List statements; + + public CQLStatementsParser(Path cql) throws IOException { + try { + List lines = Files.readAllLines(cql); + StringBuilder t = new StringBuilder(); + for (String l : lines) { + t.append(l.trim()); + t.append('\n'); + } + + text = t.toString(); + pos = 0; + state = State.DEFAULT; + parseStatements(); + } + catch (IOException e) { + log.error("Unable to parse CQL file [{}]!", cql); + log.error("Exception", e); + throw e; + } + } + + public List getStatements() { + return this.statements; + } + + private void parseStatements() { + this.statements = new ArrayList<>(); + StringBuilder statementUnderConstruction = new StringBuilder(); + + char c; + while ((c = getChar()) != 0) { + switch (state) { + case DEFAULT: + processDefaultState(c, statementUnderConstruction); + break; + case INSINGLELINECOMMENT: + if (c == '\n') { + state = State.DEFAULT; + } + break; + + case INMULTILINECOMMENT: + if (c == '*' && peekAhead() == '/') { + state = State.DEFAULT; + advance(); + } + break; + + case INQUOTESTRING: + processInQuoteStringState(c, statementUnderConstruction); + break; + case INSQUOTESTRING: + processInSQuoteStringState(c, statementUnderConstruction); + break; + } + + } + String tmp = statementUnderConstruction.toString().trim(); + if (tmp.length() > 0) { + this.statements.add(tmp); + } + } + + private void processDefaultState(char c, StringBuilder statementUnderConstruction) { + if ((c == '/' && peekAhead() == '/') || (c == '-' && peekAhead() == '-')) { + state = State.INSINGLELINECOMMENT; + advance(); + } else if (c == '/' && peekAhead() == '*') { + state = State.INMULTILINECOMMENT; + advance(); + } else if (c == '\n') { + statementUnderConstruction.append(' '); + } else { + statementUnderConstruction.append(c); + if (c == '\"') { + state = State.INQUOTESTRING; + } else if (c == '\'') { + state = State.INSQUOTESTRING; + } else if (c == ';') { + statements.add(statementUnderConstruction.toString().trim()); + statementUnderConstruction.setLength(0); + } + } + } + + private void processInQuoteStringState(char c, StringBuilder statementUnderConstruction) { + statementUnderConstruction.append(c); + if (c == '"') { + if (peekAhead() == '"') { + statementUnderConstruction.append(getChar()); + } else { + state = State.DEFAULT; + } + } + } + + private void processInSQuoteStringState(char c, StringBuilder statementUnderConstruction) { + statementUnderConstruction.append(c); + if (c == '\'') { + if (peekAhead() == '\'') { + statementUnderConstruction.append(getChar()); + } else { + state = State.DEFAULT; + } + } + } + + private char getChar() { + if (pos < text.length()) + return text.charAt(pos++); + else + return 0; + } + + private char peekAhead() { + if (pos < text.length()) + return text.charAt(pos); // don't advance + else + return 0; + } + + private void advance() { + pos++; + } + +} 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 new file mode 100644 index 0000000..add2845 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java @@ -0,0 +1,218 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.cql; + +import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.cql.Statement; +import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata; +import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; +import com.datastax.oss.driver.api.core.type.DataType; +import com.datastax.oss.protocol.internal.ProtocolConstants; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.csv.CSVRecord; +import org.thingsboard.server.dao.cassandra.guava.GuavaSession; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.UUID; + +import static org.thingsboard.server.service.install.DatabaseHelper.CSV_DUMP_FORMAT; + +public class CassandraDbHelper { + + public static Path dumpCfIfExists(KeyspaceMetadata ks, GuavaSession session, String cfName, + String[] columns, String[] defaultValues, String dumpPrefix) throws Exception { + return dumpCfIfExists(ks, session, cfName, columns, defaultValues, dumpPrefix, false); + } + + public static Path dumpCfIfExists(KeyspaceMetadata ks, GuavaSession session, String cfName, + String[] columns, String[] defaultValues, String dumpPrefix, boolean printHeader) throws Exception { + if (ks.getTable(cfName) != null) { + Path dumpFile = Files.createTempFile(dumpPrefix, null); + Files.deleteIfExists(dumpFile); + CSVFormat csvFormat = CSV_DUMP_FORMAT; + if (printHeader) { + csvFormat = csvFormat.withHeader(columns); + } + try (CSVPrinter csvPrinter = new CSVPrinter(Files.newBufferedWriter(dumpFile), csvFormat)) { + Statement stmt = SimpleStatement.newInstance("SELECT * FROM " + cfName); + stmt.setPageSize(1000); + ResultSet rs = session.execute(stmt); + Iterator iter = rs.iterator(); + while (iter.hasNext()) { + Row row = iter.next(); + if (row != null) { + dumpRow(row, columns, defaultValues, csvPrinter); + } + } + } + return dumpFile; + } else { + return null; + } + } + + public static void appendToEndOfLine(Path targetDumpFile, String toAppend) throws Exception { + Path tmp = Files.createTempFile(null, null); + try (CSVParser csvParser = new CSVParser(Files.newBufferedReader(targetDumpFile), CSV_DUMP_FORMAT)) { + try (CSVPrinter csvPrinter = new CSVPrinter(Files.newBufferedWriter(tmp), CSV_DUMP_FORMAT)) { + csvParser.forEach(record -> { + List newRecord = new ArrayList<>(); + record.forEach(val -> newRecord.add(val)); + newRecord.add(toAppend); + try { + csvPrinter.printRecord(newRecord); + } catch (IOException e) { + throw new RuntimeException("Error appending to EOL", e); + } + }); + } + } + Files.move(tmp, targetDumpFile, StandardCopyOption.REPLACE_EXISTING); + } + + public static void loadCf(KeyspaceMetadata ks, GuavaSession session, String cfName, String[] columns, Path sourceFile) throws Exception { + loadCf(ks, session, cfName, columns, sourceFile, false); + } + + public static void loadCf(KeyspaceMetadata ks, GuavaSession session, String cfName, String[] columns, Path sourceFile, boolean parseHeader) throws Exception { + TableMetadata tableMetadata = ks.getTable(cfName).get(); + PreparedStatement prepared = session.prepare(createInsertStatement(cfName, columns)); + CSVFormat csvFormat = CSV_DUMP_FORMAT; + if (parseHeader) { + csvFormat = csvFormat.withFirstRecordAsHeader(); + } else { + csvFormat = CSV_DUMP_FORMAT.withHeader(columns); + } + try (CSVParser csvParser = new CSVParser(Files.newBufferedReader(sourceFile), csvFormat)) { + csvParser.forEach(record -> { + BoundStatementBuilder boundStatementBuilder = new BoundStatementBuilder(prepared.bind()); + for (String column : columns) { + setColumnValue(tableMetadata, column, record, boundStatementBuilder); + } + session.execute(boundStatementBuilder.build()); + }); + } + } + + + private static void dumpRow(Row row, String[] columns, String[] defaultValues, CSVPrinter csvPrinter) throws Exception { + List record = new ArrayList<>(); + for (int i=0;i -1) { + String str; + DataType type = row.getColumnDefinitions().get(index).getType(); + try { + if (row.isNull(index)) { + return null; + } else if (type.getProtocolCode() == ProtocolConstants.DataType.DOUBLE) { + str = Double.valueOf(row.getDouble(index)).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.INT) { + str = Integer.valueOf(row.getInt(index)).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.BIGINT) { + str = Long.valueOf(row.getLong(index)).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.UUID) { + str = row.getUuid(index).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.TIMEUUID) { + str = row.getUuid(index).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.FLOAT) { + str = Float.valueOf(row.getFloat(index)).toString(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.TIMESTAMP) { + str = ""+row.getInstant(index).toEpochMilli(); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.BOOLEAN) { + str = Boolean.valueOf(row.getBoolean(index)).toString(); + } else { + str = row.getString(index); + } + } catch (Exception e) { + str = ""; + } + return str; + } else { + return defaultValue; + } + } + + private static String createInsertStatement(String cfName, String[] columns) { + StringBuilder insertStatementBuilder = new StringBuilder(); + insertStatementBuilder.append("INSERT INTO ").append(cfName).append(" ("); + for (String column : columns) { + insertStatementBuilder.append(column).append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(") VALUES ("); + for (String column : columns) { + insertStatementBuilder.append("?").append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(")"); + return insertStatementBuilder.toString(); + } + + private static void setColumnValue(TableMetadata tableMetadata, String column, + CSVRecord record, BoundStatementBuilder boundStatementBuilder) { + String value = record.get(column); + DataType type = tableMetadata.getColumn(column).get().getType(); + if (value == null) { + boundStatementBuilder.setToNull(column); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.DOUBLE) { + boundStatementBuilder.setDouble(column, Double.valueOf(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.INT) { + boundStatementBuilder.setInt(column, Integer.valueOf(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.BIGINT) { + boundStatementBuilder.setLong(column, Long.valueOf(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.UUID) { + boundStatementBuilder.setUuid(column, UUID.fromString(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.TIMEUUID) { + boundStatementBuilder.setUuid(column, UUID.fromString(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.FLOAT) { + boundStatementBuilder.setFloat(column, Float.valueOf(value)); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.TIMESTAMP) { + boundStatementBuilder.setInstant(column, Instant.ofEpochMilli(Long.valueOf(value))); + } else if (type.getProtocolCode() == ProtocolConstants.DataType.BOOLEAN) { + boundStatementBuilder.setBoolean(column, Boolean.valueOf(value)); + } else { + boundStatementBuilder.setString(column, value); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraEntitiesToSqlMigrateService.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraEntitiesToSqlMigrateService.java new file mode 100644 index 0000000..e9f72a6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraEntitiesToSqlMigrateService.java @@ -0,0 +1,327 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.UUIDConverter; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.util.NoSqlAnyDao; +import org.thingsboard.server.service.install.EntityDatabaseSchemaService; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.Arrays; +import java.util.List; + +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.bigintColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.booleanColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.doubleColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.enumToIntColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.idColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.jsonColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.stringColumn; + +@Service +@Profile("install") +@NoSqlAnyDao +@Slf4j +public class CassandraEntitiesToSqlMigrateService implements EntitiesMigrateService { + + @Autowired + private EntityDatabaseSchemaService entityDatabaseSchemaService; + + @Autowired + protected CassandraCluster cluster; + + @Value("${spring.datasource.url}") + protected String dbUrl; + + @Value("${spring.datasource.username}") + protected String dbUserName; + + @Value("${spring.datasource.password}") + protected String dbPassword; + + @Override + public void migrate() throws Exception { + log.info("Performing migration of entities data from cassandra to SQL database ..."); + entityDatabaseSchemaService.createDatabaseSchema(false); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + conn.setAutoCommit(false); + for (CassandraToSqlTable table: tables) { + table.migrateToSql(cluster.getSession(), conn); + } + } catch (Exception e) { + log.error("Unexpected error during ThingsBoard entities data migration!", e); + throw e; + } + entityDatabaseSchemaService.createDatabaseIndexes(); + } + + private static List tables = Arrays.asList( + new CassandraToSqlTable("admin_settings", + idColumn("id"), + stringColumn("key"), + stringColumn("json_value")), + new CassandraToSqlTable("alarm", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("type"), + idColumn("originator_id"), + enumToIntColumn("originator_type", EntityType.class), + stringColumn("severity"), + stringColumn("status"), + bigintColumn("start_ts"), + bigintColumn("end_ts"), + bigintColumn("ack_ts"), + bigintColumn("clear_ts"), + stringColumn("details", "additional_info"), + booleanColumn("propagate"), + stringColumn("propagate_relation_types")), + new CassandraToSqlTable("asset", + idColumn("id"), + idColumn("tenant_id"), + idColumn("customer_id"), + stringColumn("name"), + stringColumn("type"), + stringColumn("label"), + stringColumn("search_text"), + stringColumn("additional_info")) { + @Override + protected boolean onConstraintViolation(List batchData, + CassandraToSqlColumnData[] data, String constraint) { + if (constraint.equalsIgnoreCase("asset_name_unq_key")) { + this.handleUniqueNameViolation(data, "asset"); + return true; + } + return super.onConstraintViolation(batchData, data, constraint); + } + }, + new CassandraToSqlTable("audit_log_by_tenant_id", "audit_log", + idColumn("id"), + idColumn("tenant_id"), + idColumn("customer_id"), + idColumn("entity_id"), + stringColumn("entity_type"), + stringColumn("entity_name"), + idColumn("user_id"), + stringColumn("user_name"), + stringColumn("action_type"), + stringColumn("action_data"), + stringColumn("action_status"), + stringColumn("action_failure_details")), + new CassandraToSqlTable("attributes_kv_cf", "attribute_kv", + idColumn("entity_id"), + stringColumn("entity_type"), + stringColumn("attribute_type"), + stringColumn("attribute_key"), + booleanColumn("bool_v", true), + stringColumn("str_v"), + bigintColumn("long_v"), + doubleColumn("dbl_v"), + jsonColumn("json_v"), + bigintColumn("last_update_ts")), + new CassandraToSqlTable("component_descriptor", + idColumn("id"), + stringColumn("type"), + stringColumn("scope"), + stringColumn("name"), + stringColumn("search_text"), + stringColumn("clazz"), + stringColumn("configuration_descriptor"), + stringColumn("actions")) { + @Override + protected boolean onConstraintViolation(List batchData, + CassandraToSqlColumnData[] data, String constraint) { + if (constraint.equalsIgnoreCase("component_descriptor_clazz_key")) { + String clazz = this.getColumnData(data, "clazz").getValue(); + log.warn("Found component_descriptor record with duplicate clazz [{}]. Record will be ignored!", clazz); + this.ignoreRecord(batchData, data); + return true; + } + return super.onConstraintViolation(batchData, data, constraint); + } + }, + new CassandraToSqlTable("customer", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("title"), + stringColumn("search_text"), + stringColumn("country"), + stringColumn("state"), + stringColumn("city"), + stringColumn("address"), + stringColumn("address2"), + stringColumn("zip"), + stringColumn("phone"), + stringColumn("email"), + stringColumn("additional_info")), + new CassandraToSqlTable("dashboard", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("title"), + stringColumn("search_text"), + stringColumn("assigned_customers"), + stringColumn("configuration")), + new CassandraToSqlTable("device", + idColumn("id"), + idColumn("tenant_id"), + idColumn("customer_id"), + stringColumn("name"), + stringColumn("type"), + stringColumn("label"), + stringColumn("search_text"), + stringColumn("additional_info")) { + @Override + protected boolean onConstraintViolation(List batchData, + CassandraToSqlColumnData[] data, String constraint) { + if (constraint.equalsIgnoreCase("device_name_unq_key")) { + this.handleUniqueNameViolation(data, "device"); + return true; + } + return super.onConstraintViolation(batchData, data, constraint); + } + }, + new CassandraToSqlTable("device_credentials", + idColumn("id"), + idColumn("device_id"), + stringColumn("credentials_type"), + stringColumn("credentials_id"), + stringColumn("credentials_value")), + new CassandraToSqlTable("event", + idColumn("id"), + idColumn("tenant_id"), + idColumn("entity_id"), + stringColumn("entity_type"), + stringColumn("event_type"), + stringColumn("event_uid"), + stringColumn("body"), + new CassandraToSqlEventTsColumn()), + new CassandraToSqlTable("relation", + idColumn("from_id"), + stringColumn("from_type"), + idColumn("to_id"), + stringColumn("to_type"), + stringColumn("relation_type_group"), + stringColumn("relation_type"), + stringColumn("additional_info")), + new CassandraToSqlTable("user", "tb_user", + idColumn("id"), + idColumn("tenant_id"), + idColumn("customer_id"), + stringColumn("email"), + stringColumn("search_text"), + stringColumn("authority"), + stringColumn("first_name"), + stringColumn("last_name"), + stringColumn("additional_info")) { + @Override + protected boolean onConstraintViolation(List batchData, + CassandraToSqlColumnData[] data, String constraint) { + if (constraint.equalsIgnoreCase("tb_user_email_key")) { + this.handleUniqueEmailViolation(data); + return true; + } + return super.onConstraintViolation(batchData, data, constraint); + } + }, + new CassandraToSqlTable("tenant", + idColumn("id"), + stringColumn("title"), + stringColumn("search_text"), + stringColumn("region"), + stringColumn("country"), + stringColumn("state"), + stringColumn("city"), + stringColumn("address"), + stringColumn("address2"), + stringColumn("zip"), + stringColumn("phone"), + stringColumn("email"), + stringColumn("additional_info"), + booleanColumn("isolated_tb_core"), + booleanColumn("isolated_tb_rule_engine")), + new CassandraToSqlTable("user_credentials", + idColumn("id"), + idColumn("user_id"), + booleanColumn("enabled"), + stringColumn("password"), + stringColumn("activate_token"), + stringColumn("reset_token")) { + @Override + protected boolean onConstraintViolation(List batchData, + CassandraToSqlColumnData[] data, String constraint) { + if (constraint.equalsIgnoreCase("user_credentials_user_id_key")) { + String id = UUIDConverter.fromString(this.getColumnData(data, "id").getValue()).toString(); + log.warn("Found user credentials record with duplicate user_id [id:[{}]]. Record will be ignored!", id); + this.ignoreRecord(batchData, data); + return true; + } + return super.onConstraintViolation(batchData, data, constraint); + } + }, + new CassandraToSqlTable("widget_type", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("bundle_alias"), + stringColumn("alias"), + stringColumn("name"), + stringColumn("descriptor")), + new CassandraToSqlTable("widgets_bundle", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("alias"), + stringColumn("title"), + stringColumn("search_text")), + new CassandraToSqlTable("rule_chain", + idColumn("id"), + idColumn("tenant_id"), + stringColumn("name"), + stringColumn("search_text"), + idColumn("first_rule_node_id"), + booleanColumn("root"), + booleanColumn("debug_mode"), + stringColumn("configuration"), + stringColumn("additional_info")), + new CassandraToSqlTable("rule_node", + idColumn("id"), + idColumn("rule_chain_id"), + stringColumn("type"), + stringColumn("name"), + booleanColumn("debug_mode"), + stringColumn("search_text"), + stringColumn("configuration"), + stringColumn("additional_info")), + new CassandraToSqlTable("entity_view", + idColumn("id"), + idColumn("tenant_id"), + idColumn("customer_id"), + idColumn("entity_id"), + stringColumn("entity_type"), + stringColumn("name"), + stringColumn("type"), + stringColumn("keys"), + bigintColumn("start_ts"), + bigintColumn("end_ts"), + stringColumn("search_text"), + stringColumn("additional_info")) + ); +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumn.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumn.java new file mode 100644 index 0000000..a003c4c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumn.java @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import com.datastax.oss.driver.api.core.cql.Row; +import lombok.Data; +import org.thingsboard.server.common.data.UUIDConverter; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.util.regex.Pattern; + +@Data +public class CassandraToSqlColumn { + + private static final ThreadLocal PATTERN_THREAD_LOCAL = ThreadLocal.withInitial(() -> Pattern.compile(String.valueOf(Character.MIN_VALUE))); + private static final String EMPTY_STR = ""; + + private int index; + private int sqlIndex; + private String cassandraColumnName; + private String sqlColumnName; + private CassandraToSqlColumnType type; + private int sqlType; + private int size; + private Class enumClass; + private boolean allowNullBoolean = false; + + public static CassandraToSqlColumn idColumn(String name) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.ID); + } + + public static CassandraToSqlColumn stringColumn(String name) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.STRING); + } + + public static CassandraToSqlColumn stringColumn(String cassandraColumnName, String sqlColumnName) { + return new CassandraToSqlColumn(cassandraColumnName, sqlColumnName); + } + + public static CassandraToSqlColumn bigintColumn(String name) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.BIGINT); + } + + public static CassandraToSqlColumn doubleColumn(String name) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.DOUBLE); + } + + public static CassandraToSqlColumn booleanColumn(String name) { + return booleanColumn(name, false); + } + + public static CassandraToSqlColumn booleanColumn(String name, boolean allowNullBoolean) { + return new CassandraToSqlColumn(name, name, CassandraToSqlColumnType.BOOLEAN, null, allowNullBoolean); + } + + public static CassandraToSqlColumn jsonColumn(String name) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.JSON); + } + + public static CassandraToSqlColumn enumToIntColumn(String name, Class enumClass) { + return new CassandraToSqlColumn(name, CassandraToSqlColumnType.ENUM_TO_INT, enumClass); + } + + public CassandraToSqlColumn(String columnName) { + this(columnName, columnName, CassandraToSqlColumnType.STRING, null, false); + } + + public CassandraToSqlColumn(String columnName, CassandraToSqlColumnType type) { + this(columnName, columnName, type, null, false); + } + + public CassandraToSqlColumn(String columnName, CassandraToSqlColumnType type, Class enumClass) { + this(columnName, columnName, type, enumClass, false); + } + + public CassandraToSqlColumn(String cassandraColumnName, String sqlColumnName) { + this(cassandraColumnName, sqlColumnName, CassandraToSqlColumnType.STRING, null, false); + } + + public CassandraToSqlColumn(String cassandraColumnName, String sqlColumnName, CassandraToSqlColumnType type, + Class enumClass, boolean allowNullBoolean) { + this.cassandraColumnName = cassandraColumnName; + this.sqlColumnName = sqlColumnName; + this.type = type; + this.enumClass = enumClass; + this.allowNullBoolean = allowNullBoolean; + } + + public String getColumnValue(Row row) { + if (row.isNull(index)) { + if (this.type == CassandraToSqlColumnType.BOOLEAN && !this.allowNullBoolean) { + return Boolean.toString(false); + } else { + return null; + } + } else { + switch (this.type) { + case ID: + return UUIDConverter.fromTimeUUID(row.getUuid(index)); + case DOUBLE: + return Double.toString(row.getDouble(index)); + case INTEGER: + return Integer.toString(row.getInt(index)); + case FLOAT: + return Float.toString(row.getFloat(index)); + case BIGINT: + return Long.toString(row.getLong(index)); + case BOOLEAN: + return Boolean.toString(row.getBoolean(index)); + case STRING: + case JSON: + case ENUM_TO_INT: + default: + String value = row.getString(index); + return this.replaceNullChars(value); + } + } + } + + public void setColumnValue(PreparedStatement sqlInsertStatement, String value) throws SQLException { + if (value == null) { + sqlInsertStatement.setNull(this.sqlIndex, this.sqlType); + } else { + switch (this.type) { + case DOUBLE: + sqlInsertStatement.setDouble(this.sqlIndex, Double.parseDouble(value)); + break; + case INTEGER: + sqlInsertStatement.setInt(this.sqlIndex, Integer.parseInt(value)); + break; + case FLOAT: + sqlInsertStatement.setFloat(this.sqlIndex, Float.parseFloat(value)); + break; + case BIGINT: + sqlInsertStatement.setLong(this.sqlIndex, Long.parseLong(value)); + break; + case BOOLEAN: + sqlInsertStatement.setBoolean(this.sqlIndex, Boolean.parseBoolean(value)); + break; + case ENUM_TO_INT: + @SuppressWarnings("unchecked") + Enum enumVal = Enum.valueOf(this.enumClass, value); + int intValue = enumVal.ordinal(); + sqlInsertStatement.setInt(this.sqlIndex, intValue); + break; + case JSON: + case STRING: + case ID: + default: + sqlInsertStatement.setString(this.sqlIndex, value); + break; + } + } + } + + private String replaceNullChars(String strValue) { + if (strValue != null) { + return PATTERN_THREAD_LOCAL.get().matcher(strValue).replaceAll(EMPTY_STR); + } + return strValue; + } + +} + diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnData.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnData.java new file mode 100644 index 0000000..7a6a014 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnData.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import lombok.Data; + +@Data +public class CassandraToSqlColumnData { + + private String value; + private String originalValue; + private int constraintCounter = 0; + + public CassandraToSqlColumnData(String value) { + this.value = value; + this.originalValue = value; + } + + public int nextContraintCounter() { + return ++constraintCounter; + } + + public String getNextConstraintStringValue(CassandraToSqlColumn column) { + int counter = this.nextContraintCounter(); + String newValue = this.originalValue + counter; + int overflow = newValue.length() - column.getSize(); + if (overflow > 0) { + newValue = this.originalValue.substring(0, this.originalValue.length()-overflow) + counter; + } + return newValue; + } + + public String getNextConstraintEmailValue(CassandraToSqlColumn column) { + int counter = this.nextContraintCounter(); + String[] emailValues = this.originalValue.split("@"); + String newValue = emailValues[0] + "+" + counter + "@" + emailValues[1]; + int overflow = newValue.length() - column.getSize(); + if (overflow > 0) { + newValue = emailValues[0].substring(0, emailValues[0].length()-overflow) + "+" + counter + "@" + emailValues[1]; + } + return newValue; + } + + public String getLogValue() { + if (this.value != null && this.value.length() > 255) { + return this.value.substring(0, 255) + "...[truncated " + (this.value.length() - 255) + " symbols]"; + } + return this.value; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnType.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnType.java new file mode 100644 index 0000000..206fb2d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlColumnType.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +public enum CassandraToSqlColumnType { + ID, + DOUBLE, + INTEGER, + FLOAT, + BIGINT, + BOOLEAN, + STRING, + JSON, + ENUM_TO_INT +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlEventTsColumn.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlEventTsColumn.java new file mode 100644 index 0000000..38a3225 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlEventTsColumn.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import com.datastax.oss.driver.api.core.cql.Row; + +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.EPOCH_DIFF; + +public class CassandraToSqlEventTsColumn extends CassandraToSqlColumn { + + CassandraToSqlEventTsColumn() { + super("id", "ts", CassandraToSqlColumnType.BIGINT, null, false); + } + + @Override + public String getColumnValue(Row row) { + UUID id = row.getUuid(getIndex()); + long ts = getTs(id); + return ts + ""; + } + + private long getTs(UUID uuid) { + return (uuid.timestamp() - EPOCH_DIFF) / 10000; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlTable.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlTable.java new file mode 100644 index 0000000..7f3669f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraToSqlTable.java @@ -0,0 +1,304 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.cql.Statement; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.internal.util.JdbcExceptionHelper; +import org.postgresql.util.PSQLException; +import org.thingsboard.server.common.data.UUIDConverter; +import org.thingsboard.server.dao.cassandra.guava.GuavaSession; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +@Data +@Slf4j +public class CassandraToSqlTable { + + private static final int DEFAULT_BATCH_SIZE = 10000; + + private String cassandraCf; + private String sqlTableName; + + private List columns; + + private int batchSize = DEFAULT_BATCH_SIZE; + + private PreparedStatement sqlInsertStatement; + + public CassandraToSqlTable(String tableName, CassandraToSqlColumn... columns) { + this(tableName, tableName, DEFAULT_BATCH_SIZE, columns); + } + + public CassandraToSqlTable(String tableName, String sqlTableName, CassandraToSqlColumn... columns) { + this(tableName, sqlTableName, DEFAULT_BATCH_SIZE, columns); + } + + public CassandraToSqlTable(String tableName, int batchSize, CassandraToSqlColumn... columns) { + this(tableName, tableName, batchSize, columns); + } + + public CassandraToSqlTable(String cassandraCf, String sqlTableName, int batchSize, CassandraToSqlColumn... columns) { + this.cassandraCf = cassandraCf; + this.sqlTableName = sqlTableName; + this.batchSize = batchSize; + this.columns = Arrays.asList(columns); + for (int i=0;i iter = rs.iterator(); + int rowCounter = 0; + List batchData; + boolean hasNext; + do { + batchData = this.extractBatchData(iter); + hasNext = batchData.size() == this.batchSize; + this.batchInsert(batchData, conn); + rowCounter += batchData.size(); + log.info("[{}] {} records migrated so far...", this.sqlTableName, rowCounter); + } while (hasNext); + this.sqlInsertStatement.close(); + log.info("[{}] {} total records migrated.", this.sqlTableName, rowCounter); + log.info("[{}] Finished migration data from cassandra '{}' Column Family to '{}' SQL table.", + this.sqlTableName, this.cassandraCf, this.sqlTableName); + } + + private List extractBatchData(Iterator iter) { + List batchData = new ArrayList<>(); + while (iter.hasNext() && batchData.size() < this.batchSize) { + Row row = iter.next(); + if (row != null) { + CassandraToSqlColumnData[] data = this.extractRowData(row); + batchData.add(data); + } + } + return batchData; + } + + private CassandraToSqlColumnData[] extractRowData(Row row) { + CassandraToSqlColumnData[] data = new CassandraToSqlColumnData[this.columns.size()]; + for (CassandraToSqlColumn column: this.columns) { + String value = column.getColumnValue(row); + data[column.getIndex()] = new CassandraToSqlColumnData(value); + } + return this.validateColumnData(data); + } + + protected CassandraToSqlColumnData[] validateColumnData(CassandraToSqlColumnData[] data) { + for (int i=0;i column.getSize()) { + log.warn("[{}] Value size [{}] exceeds maximum size [{}] of column [{}] and will be truncated!", + this.sqlTableName, + value.length(), column.getSize(), column.getSqlColumnName()); + log.warn("[{}] Affected data:\n{}", this.sqlTableName, this.dataToString(data)); + value = value.substring(0, column.getSize()); + columnData.setOriginalValue(value); + columnData.setValue(value); + } + } + } + return data; + } + + protected void batchInsert(List batchData, Connection conn) throws SQLException { + boolean retry = false; + for (CassandraToSqlColumnData[] data : batchData) { + for (CassandraToSqlColumn column: this.columns) { + column.setColumnValue(this.sqlInsertStatement, data[column.getIndex()].getValue()); + } + try { + this.sqlInsertStatement.executeUpdate(); + } catch (SQLException e) { + if (this.handleInsertException(batchData, data, conn, e)) { + retry = true; + break; + } else { + throw e; + } + } + } + if (retry) { + this.batchInsert(batchData, conn); + } else { + conn.commit(); + } + } + + private boolean handleInsertException(List batchData, + CassandraToSqlColumnData[] data, + Connection conn, SQLException ex) throws SQLException { + conn.commit(); + String constraint = extractConstraintName(ex).orElse(null); + if (constraint != null) { + if (this.onConstraintViolation(batchData, data, constraint)) { + return true; + } else { + log.error("[{}] Unhandled constraint violation [{}] during insert!", this.sqlTableName, constraint); + log.error("[{}] Affected data:\n{}", this.sqlTableName, this.dataToString(data)); + } + } else { + log.error("[{}] Unhandled exception during insert!", this.sqlTableName); + log.error("[{}] Affected data:\n{}", this.sqlTableName, this.dataToString(data)); + } + return false; + } + + private String dataToString(CassandraToSqlColumnData[] data) { + StringBuffer stringData = new StringBuffer("{\n"); + for (int i=0;i batchData, + CassandraToSqlColumnData[] data, String constraint) { + return false; + } + + protected void handleUniqueNameViolation(CassandraToSqlColumnData[] data, String entityType) { + CassandraToSqlColumn nameColumn = this.getColumn("name"); + CassandraToSqlColumn searchTextColumn = this.getColumn("search_text"); + CassandraToSqlColumnData nameColumnData = data[nameColumn.getIndex()]; + CassandraToSqlColumnData searchTextColumnData = data[searchTextColumn.getIndex()]; + String prevName = nameColumnData.getValue(); + String newName = nameColumnData.getNextConstraintStringValue(nameColumn); + nameColumnData.setValue(newName); + searchTextColumnData.setValue(searchTextColumnData.getNextConstraintStringValue(searchTextColumn)); + String id = UUIDConverter.fromString(this.getColumnData(data, "id").getValue()).toString(); + log.warn("Found {} with duplicate name [id:[{}]]. Attempting to rename {} from '{}' to '{}'...", entityType, id, entityType, prevName, newName); + } + + protected void handleUniqueEmailViolation(CassandraToSqlColumnData[] data) { + CassandraToSqlColumn emailColumn = this.getColumn("email"); + CassandraToSqlColumn searchTextColumn = this.getColumn("search_text"); + CassandraToSqlColumnData emailColumnData = data[emailColumn.getIndex()]; + CassandraToSqlColumnData searchTextColumnData = data[searchTextColumn.getIndex()]; + String prevEmail = emailColumnData.getValue(); + String newEmail = emailColumnData.getNextConstraintEmailValue(emailColumn); + emailColumnData.setValue(newEmail); + searchTextColumnData.setValue(searchTextColumnData.getNextConstraintEmailValue(searchTextColumn)); + String id = UUIDConverter.fromString(this.getColumnData(data, "id").getValue()).toString(); + log.warn("Found user with duplicate email [id:[{}]]. Attempting to rename email from '{}' to '{}'...", id, prevEmail, newEmail); + } + + protected void ignoreRecord(List batchData, CassandraToSqlColumnData[] data) { + log.warn("[{}] Affected data:\n{}", this.sqlTableName, this.dataToString(data)); + int index = batchData.indexOf(data); + if (index > 0) { + batchData.remove(index); + } + } + + protected CassandraToSqlColumn getColumn(String sqlColumnName) { + return this.columns.stream().filter(col -> col.getSqlColumnName().equals(sqlColumnName)).findFirst().get(); + } + + protected CassandraToSqlColumnData getColumnData(CassandraToSqlColumnData[] data, String sqlColumnName) { + CassandraToSqlColumn column = this.getColumn(sqlColumnName); + return data[column.getIndex()]; + } + + private Optional extractConstraintName(SQLException ex) { + final String sqlState = JdbcExceptionHelper.extractSqlState( ex ); + if (sqlState != null) { + String sqlStateClassCode = JdbcExceptionHelper.determineSqlStateClassCode( sqlState ); + if ( sqlStateClassCode != null ) { + if (Arrays.asList( + "23", // "integrity constraint violation" + "27", // "triggered data change violation" + "44" // "with check option violation" + ).contains(sqlStateClassCode)) { + if (ex instanceof PSQLException) { + return Optional.of(((PSQLException)ex).getServerErrorMessage().getConstraint()); + } + } + } + } + return Optional.empty(); + } + + protected Statement createCassandraSelectStatement() { + StringBuilder selectStatementBuilder = new StringBuilder(); + selectStatementBuilder.append("SELECT "); + for (CassandraToSqlColumn column : columns) { + selectStatementBuilder.append(column.getCassandraColumnName()).append(","); + } + selectStatementBuilder.deleteCharAt(selectStatementBuilder.length() - 1); + selectStatementBuilder.append(" FROM ").append(cassandraCf); + return SimpleStatement.newInstance(selectStatementBuilder.toString()); + } + + private PreparedStatement createSqlInsertStatement(Connection conn) throws SQLException { + StringBuilder insertStatementBuilder = new StringBuilder(); + insertStatementBuilder.append("INSERT INTO ").append(this.sqlTableName).append(" ("); + for (CassandraToSqlColumn column : columns) { + insertStatementBuilder.append(column.getSqlColumnName()).append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(") VALUES ("); + for (CassandraToSqlColumn column : columns) { + if (column.getType() == CassandraToSqlColumnType.JSON) { + insertStatementBuilder.append("cast(? AS json)"); + } else { + insertStatementBuilder.append("?"); + } + insertStatementBuilder.append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(")"); + return conn.prepareStatement(insertStatementBuilder.toString()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java new file mode 100644 index 0000000..1aa7602 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/CassandraTsLatestToSqlMigrateService.java @@ -0,0 +1,233 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.UUIDConverter; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.model.sqlts.dictionary.TsKvDictionary; +import org.thingsboard.server.dao.model.sqlts.dictionary.TsKvDictionaryCompositeKey; +import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; +import org.thingsboard.server.dao.sqlts.dictionary.TsKvDictionaryRepository; +import org.thingsboard.server.dao.sqlts.insert.latest.InsertLatestTsRepository; +import org.thingsboard.server.dao.util.NoSqlTsDao; +import org.thingsboard.server.dao.util.SqlTsLatestDao; +import org.thingsboard.server.service.install.InstallScripts; + +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.bigintColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.booleanColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.doubleColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.idColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.jsonColumn; +import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.stringColumn; + +@Service +@Profile("install") +@NoSqlTsDao +@SqlTsLatestDao +@Slf4j +public class CassandraTsLatestToSqlMigrateService implements TsLatestMigrateService { + + private static final int MAX_KEY_LENGTH = 255; + private static final int MAX_STR_V_LENGTH = 10000000; + + @Autowired + private InsertLatestTsRepository insertLatestTsRepository; + + @Autowired + protected CassandraCluster cluster; + + @Autowired + protected TsKvDictionaryRepository dictionaryRepository; + + @Autowired + private InstallScripts installScripts; + + @Value("${spring.datasource.url}") + protected String dbUrl; + + @Value("${spring.datasource.username}") + protected String dbUserName; + + @Value("${spring.datasource.password}") + protected String dbPassword; + + private final ConcurrentMap tsKvDictionaryMap = new ConcurrentHashMap<>(); + + protected static final ReentrantLock tsCreationLock = new ReentrantLock(); + + @Override + public void migrate() throws Exception { + log.info("Performing migration of latest timeseries data from cassandra to SQL database ..."); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + Path schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.0.1", "schema_ts_latest.sql"); + loadSql(schemaUpdateFile, conn); + conn.setAutoCommit(false); + for (CassandraToSqlTable table : tables) { + table.migrateToSql(cluster.getSession(), conn); + } + } catch (Exception e) { + log.error("Unexpected error during ThingsBoard entities data migration!", e); + throw e; + } + } + + private List tables = Arrays.asList( + new CassandraToSqlTable("ts_kv_latest_cf", "ts_kv_latest", + idColumn("entity_id"), + stringColumn("key"), + bigintColumn("ts"), + booleanColumn("bool_v"), + stringColumn("str_v"), + bigintColumn("long_v"), + doubleColumn("dbl_v"), + jsonColumn("json_v")) { + + @Override + protected void batchInsert(List batchData, Connection conn) { + insertLatestTsRepository + .saveOrUpdate(batchData.stream().map(data -> getTsKvLatestEntity(data)).collect(Collectors.toList())); + } + + @Override + protected CassandraToSqlColumnData[] validateColumnData(CassandraToSqlColumnData[] data) { + return data; + } + }); + + private TsKvLatestEntity getTsKvLatestEntity(CassandraToSqlColumnData[] data) { + TsKvLatestEntity latestEntity = new TsKvLatestEntity(); + latestEntity.setEntityId(UUIDConverter.fromString(data[0].getValue())); + latestEntity.setKey(getOrSaveKeyId(data[1].getValue())); + latestEntity.setTs(Long.parseLong(data[2].getValue())); + + String strV = data[4].getValue(); + if (strV != null) { + if (strV.length() > MAX_STR_V_LENGTH) { + log.warn("[ts_kv_latest] Value size [{}] exceeds maximum size [{}] of column [str_v] and will be truncated!", + strV.length(), MAX_STR_V_LENGTH); + log.warn("Affected data:\n{}", strV); + strV = strV.substring(0, MAX_STR_V_LENGTH); + } + latestEntity.setStrValue(strV); + } else { + Long longV = null; + try { + longV = Long.parseLong(data[5].getValue()); + } catch (Exception e) { + } + if (longV != null) { + latestEntity.setLongValue(longV); + } else { + Double doubleV = null; + try { + doubleV = Double.parseDouble(data[6].getValue()); + } catch (Exception e) { + } + if (doubleV != null) { + latestEntity.setDoubleValue(doubleV); + } else { + + String jsonV = data[7].getValue(); + if (StringUtils.isNoneEmpty(jsonV)) { + latestEntity.setJsonValue(jsonV); + } else { + Boolean boolV = null; + try { + boolV = Boolean.parseBoolean(data[3].getValue()); + } catch (Exception e) { + } + if (boolV != null) { + latestEntity.setBooleanValue(boolV); + } else { + log.warn("All values in key-value row are nullable "); + } + } + } + } + } + return latestEntity; + } + + protected Integer getOrSaveKeyId(String strKey) { + if (strKey.length() > MAX_KEY_LENGTH) { + log.warn("[ts_kv_latest] Value size [{}] exceeds maximum size [{}] of column [key] and will be truncated!", + strKey.length(), MAX_KEY_LENGTH); + log.warn("Affected data:\n{}", strKey); + strKey = strKey.substring(0, MAX_KEY_LENGTH); + } + + Integer keyId = tsKvDictionaryMap.get(strKey); + if (keyId == null) { + Optional tsKvDictionaryOptional; + tsKvDictionaryOptional = dictionaryRepository.findById(new TsKvDictionaryCompositeKey(strKey)); + if (!tsKvDictionaryOptional.isPresent()) { + tsCreationLock.lock(); + try { + tsKvDictionaryOptional = dictionaryRepository.findById(new TsKvDictionaryCompositeKey(strKey)); + if (!tsKvDictionaryOptional.isPresent()) { + TsKvDictionary tsKvDictionary = new TsKvDictionary(); + tsKvDictionary.setKey(strKey); + try { + TsKvDictionary saved = dictionaryRepository.save(tsKvDictionary); + tsKvDictionaryMap.put(saved.getKey(), saved.getKeyId()); + keyId = saved.getKeyId(); + } catch (ConstraintViolationException e) { + tsKvDictionaryOptional = dictionaryRepository.findById(new TsKvDictionaryCompositeKey(strKey)); + TsKvDictionary dictionary = tsKvDictionaryOptional.orElseThrow(() -> new RuntimeException("Failed to get TsKvDictionary entity from DB!")); + tsKvDictionaryMap.put(dictionary.getKey(), dictionary.getKeyId()); + keyId = dictionary.getKeyId(); + } + } else { + keyId = tsKvDictionaryOptional.get().getKeyId(); + } + } finally { + tsCreationLock.unlock(); + } + } else { + keyId = tsKvDictionaryOptional.get().getKeyId(); + tsKvDictionaryMap.put(strKey, keyId); + } + } + return keyId; + } + + private void loadSql(Path sqlFile, Connection conn) throws Exception { + String sql = new String(Files.readAllBytes(sqlFile), Charset.forName("UTF-8")); + conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script + Thread.sleep(5000); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/EntitiesMigrateService.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/EntitiesMigrateService.java new file mode 100644 index 0000000..42cdd2e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/EntitiesMigrateService.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +public interface EntitiesMigrateService { + + void migrate() throws Exception; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/migrate/TsLatestMigrateService.java b/application/src/main/java/org/thingsboard/server/service/install/migrate/TsLatestMigrateService.java new file mode 100644 index 0000000..c32f08b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/migrate/TsLatestMigrateService.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.migrate; + +public interface TsLatestMigrateService { + + void migrate() throws Exception; +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/sql/SqlDbHelper.java b/application/src/main/java/org/thingsboard/server/service/install/sql/SqlDbHelper.java new file mode 100644 index 0000000..83f53d9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/sql/SqlDbHelper.java @@ -0,0 +1,176 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.sql; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.csv.CSVRecord; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.thingsboard.server.service.install.DatabaseHelper.CSV_DUMP_FORMAT; + +/** + * Created by igor on 2/27/18. + */ +@Slf4j +public class SqlDbHelper { + + public static Path dumpTableIfExists(Connection conn, String tableName, + String[] columns, String[] defaultValues, String dumpPrefix) throws Exception { + return dumpTableIfExists(conn, tableName, columns, defaultValues, dumpPrefix, false); + } + + public static Path dumpTableIfExists(Connection conn, String tableName, + String[] columns, String[] defaultValues, String dumpPrefix, boolean printHeader) throws Exception { + + if (tableExists(conn, tableName)) { + Path dumpFile = Files.createTempFile(dumpPrefix, null); + Files.deleteIfExists(dumpFile); + CSVFormat csvFormat = CSV_DUMP_FORMAT; + if (printHeader) { + csvFormat = csvFormat.withHeader(columns); + } + try (CSVPrinter csvPrinter = new CSVPrinter(Files.newBufferedWriter(dumpFile), csvFormat)) { + try (PreparedStatement stmt = conn.prepareStatement("SELECT * FROM " + tableName)) { + try (ResultSet tableRes = stmt.executeQuery()) { + ResultSetMetaData resMetaData = tableRes.getMetaData(); + Map columnIndexMap = new HashMap<>(); + for (int i = 1; i <= resMetaData.getColumnCount(); i++) { + String columnName = resMetaData.getColumnName(i); + columnIndexMap.put(columnName.toUpperCase(), i); + } + while(tableRes.next()) { + dumpRow(tableRes, columnIndexMap, columns, defaultValues, csvPrinter); + } + } + } + } + return dumpFile; + } else { + return null; + } + } + + private static boolean tableExists(Connection conn, String tableName) { + try (Statement stmt = conn.createStatement()) { + stmt.executeQuery("select * from " + tableName + " where 1=0"); + return true; + } catch (Exception e) { + return false; + } + } + + public static void loadTable(Connection conn, String tableName, String[] columns, Path sourceFile) throws Exception { + loadTable(conn, tableName, columns, sourceFile, false); + } + + public static void loadTable(Connection conn, String tableName, String[] columns, Path sourceFile, boolean parseHeader) throws Exception { + CSVFormat csvFormat = CSV_DUMP_FORMAT; + if (parseHeader) { + csvFormat = csvFormat.withFirstRecordAsHeader(); + } else { + csvFormat = CSV_DUMP_FORMAT.withHeader(columns); + } + try (PreparedStatement prepared = conn.prepareStatement(createInsertStatement(tableName, columns))) { + try (CSVParser csvParser = new CSVParser(Files.newBufferedReader(sourceFile), csvFormat)) { + csvParser.forEach(record -> { + try { + for (int i = 0; i < columns.length; i++) { + setColumnValue(i, columns[i], record, prepared); + } + prepared.execute(); + } catch (SQLException e) { + log.error("Unable to load table record!", e); + } + }); + } + } + } + + private static void dumpRow(ResultSet res, Map columnIndexMap, String[] columns, + String[] defaultValues, CSVPrinter csvPrinter) throws Exception { + List record = new ArrayList<>(); + for (int i=0;i columnIndexMap, ResultSet res) { + int index = columnIndexMap.containsKey(column.toUpperCase()) ? columnIndexMap.get(column.toUpperCase()) : -1; + if (index > -1) { + String str; + try { + Object obj = res.getObject(index); + if (obj == null) { + return null; + } else { + str = obj.toString(); + } + } catch (Exception e) { + str = ""; + } + return str; + } else { + return defaultValue; + } + } + + private static void setColumnValue(int index, String column, + CSVRecord record, PreparedStatement preparedStatement) throws SQLException { + String value = record.get(column); + int type = preparedStatement.getParameterMetaData().getParameterType(index + 1); + preparedStatement.setObject(index + 1, value, type); + } + + private static String createInsertStatement(String tableName, String[] columns) { + StringBuilder insertStatementBuilder = new StringBuilder(); + insertStatementBuilder.append("INSERT INTO ").append(tableName).append(" ("); + for (String column : columns) { + insertStatementBuilder.append(column).append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(") VALUES ("); + for (String column : columns) { + insertStatementBuilder.append("?").append(","); + } + insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); + insertStatementBuilder.append(")"); + return insertStatementBuilder.toString(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/CacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/CacheCleanupService.java new file mode 100644 index 0000000..083b1d3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/CacheCleanupService.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +public interface CacheCleanupService { + + void clearCache(String fromVersion) throws Exception; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DataUpdateService.java new file mode 100644 index 0000000..909e162 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DataUpdateService.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +public interface DataUpdateService { + + void updateData(String fromVersion) throws Exception; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java new file mode 100644 index 0000000..367bb04 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -0,0 +1,112 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Objects; +import java.util.Optional; + +@RequiredArgsConstructor +@Service +@Profile("install") +@Slf4j +public class DefaultCacheCleanupService implements CacheCleanupService { + + private final CacheManager cacheManager; + private final Optional> redisTemplate; + + + /** + * Cleanup caches that can not deserialize anymore due to schema upgrade or data update using sql scripts. + * Refer to SqlDatabaseUpgradeService and /data/upgrage/*.sql + * to discover which tables were changed + * */ + @Override + public void clearCache(String fromVersion) throws Exception { + switch (fromVersion) { + case "3.0.1": + log.info("Clear cache to upgrade from version 3.0.1 to 3.1.0 ..."); + clearAllCaches(); + //do not break to show explicit calls for next versions + case "3.1.1": + log.info("Clear cache to upgrade from version 3.1.1 to 3.2.0 ..."); + clearCacheByName("devices"); + clearCacheByName("deviceProfiles"); + clearCacheByName("tenantProfiles"); + case "3.2.2": + log.info("Clear cache to upgrade from version 3.2.2 to 3.3.0 ..."); + clearCacheByName("devices"); + clearCacheByName("deviceProfiles"); + clearCacheByName("tenantProfiles"); + clearCacheByName("relations"); + break; + case "3.3.2": + log.info("Clear cache to upgrade from version 3.3.2 to 3.3.3 ..."); + clearAll(); + break; + case "3.3.3": + log.info("Clear cache to upgrade from version 3.3.3 to 3.3.4 ..."); + clearAll(); + break; + case "3.3.4": + log.info("Clear cache to upgrade from version 3.3.4 to 3.4.0 ..."); + clearAll(); + break; + case "3.4.1": + log.info("Clear cache to upgrade from version 3.4.1 to 3.4.2 ..."); + clearCacheByName("assets"); + clearCacheByName("repositorySettings"); + break; + case "3.4.2": + log.info("Clearing cache to upgrade from version 3.4.2 to 3.4.3 ..."); + clearCacheByName("repositorySettings"); + break; + default: + //Do nothing, since cache cleanup is optional. + } + } + + void clearAllCaches() { + cacheManager.getCacheNames().forEach(this::clearCacheByName); + } + + void clearCacheByName(final String cacheName) { + log.info("Clearing cache [{}]", cacheName); + Cache cache = cacheManager.getCache(cacheName); + Objects.requireNonNull(cache, "Cache does not exist for name " + cacheName); + cache.clear(); + } + + void clearAll() { + if (redisTemplate.isPresent()) { + log.info("Flushing all caches"); + redisTemplate.get().execute((RedisCallback) connection -> { + connection.flushAll(); + return null; + }); + return; + } + cacheManager.getCacheNames().forEach(this::clearCacheByName); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java new file mode 100644 index 0000000..ee34bd7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -0,0 +1,683 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNode; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNodeConfiguration; +import org.thingsboard.rule.engine.profile.TbDeviceProfileNode; +import org.thingsboard.rule.engine.profile.TbDeviceProfileNodeConfiguration; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.alarm.AlarmDao; +import org.thingsboard.server.dao.audit.AuditLogDao; +import org.thingsboard.server.dao.edge.EdgeEventDao; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.model.sql.DeviceProfileEntity; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.sql.device.DeviceProfileRepository; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.install.SystemDataLoaderService; +import org.thingsboard.server.service.install.TbRuleEngineQueueConfigService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.StringUtils.isBlank; + +@Service +@Profile("install") +@Slf4j +public class DefaultDataUpdateService implements DataUpdateService { + + @Autowired + private TenantService tenantService; + + @Autowired + private RelationService relationService; + + @Autowired + private RuleChainService ruleChainService; + + @Autowired + private InstallScripts installScripts; + + @Autowired + private EntityViewService entityViewService; + + @Autowired + private TimeseriesService tsService; + + @Autowired + private EntityService entityService; + + @Autowired + private AlarmDao alarmDao; + + @Autowired + private DeviceProfileRepository deviceProfileRepository; + + @Autowired + private RateLimitsUpdater rateLimitsUpdater; + + @Autowired + private TenantProfileService tenantProfileService; + + @Lazy + @Autowired + private QueueService queueService; + + @Autowired + private TbRuleEngineQueueConfigService queueConfig; + + @Autowired + private SystemDataLoaderService systemDataLoaderService; + + @Autowired + private EventService eventService; + + @Autowired + private AuditLogDao auditLogDao; + + @Autowired + private EdgeEventDao edgeEventDao; + + @Override + public void updateData(String fromVersion) throws Exception { + switch (fromVersion) { + case "1.4.0": + log.info("Updating data from version 1.4.0 to 2.0.0 ..."); + tenantsDefaultRuleChainUpdater.updateEntities(null); + break; + case "3.0.1": + log.info("Updating data from version 3.0.1 to 3.1.0 ..."); + tenantsEntityViewsUpdater.updateEntities(null); + break; + case "3.1.1": + log.info("Updating data from version 3.1.1 to 3.2.0 ..."); + tenantsRootRuleChainUpdater.updateEntities(null); + break; + case "3.2.2": + log.info("Updating data from version 3.2.2 to 3.3.0 ..."); + tenantsDefaultEdgeRuleChainUpdater.updateEntities(null); + tenantsAlarmsCustomerUpdater.updateEntities(null); + deviceProfileEntityDynamicConditionsUpdater.updateEntities(null); + updateOAuth2Params(); + break; + case "3.3.2": + log.info("Updating data from version 3.3.2 to 3.3.3 ..."); + updateNestedRuleChains(); + break; + case "3.3.4": + log.info("Updating data from version 3.3.4 to 3.4.0 ..."); + tenantsProfileQueueConfigurationUpdater.updateEntities(); + rateLimitsUpdater.updateEntities(); + break; + case "3.4.0": + boolean skipEventsMigration = getEnv("TB_SKIP_EVENTS_MIGRATION", false); + if (!skipEventsMigration) { + log.info("Updating data from version 3.4.0 to 3.4.1 ..."); + eventService.migrateEvents(); + } + break; + case "3.4.1": + log.info("Updating data from version 3.4.1 to 3.4.2 ..."); + systemDataLoaderService.saveLegacyYmlSettings(); + boolean skipAuditLogsMigration = getEnv("TB_SKIP_AUDIT_LOGS_MIGRATION", false); + if (!skipAuditLogsMigration) { + log.info("Starting audit logs migration. Can be skipped with TB_SKIP_AUDIT_LOGS_MIGRATION env variable set to true"); + auditLogDao.migrateAuditLogs(); + } else { + log.info("Skipping audit logs migration"); + } + boolean skipEdgeEventsMigration = getEnv("TB_SKIP_EDGE_EVENTS_MIGRATION", false); + if (!skipEdgeEventsMigration) { + log.info("Starting edge events migration. Can be skipped with TB_SKIP_EDGE_EVENTS_MIGRATION env variable set to true"); + edgeEventDao.migrateEdgeEvents(); + } else { + log.info("Skipping edge events migration"); + } + break; + default: + throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion); + } + } + + private final PaginatedUpdater deviceProfileEntityDynamicConditionsUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Device Profile Entity Dynamic Conditions Updater"; + } + + @Override + protected PageData findEntities(String id, PageLink pageLink) { + return DaoUtil.pageToPageData(deviceProfileRepository.findAll(DaoUtil.toPageable(pageLink))); + } + + @Override + protected void updateEntity(DeviceProfileEntity deviceProfile) { + if (convertDeviceProfileForVersion330(deviceProfile.getProfileData())) { + deviceProfileRepository.save(deviceProfile); + } + } + }; + + boolean convertDeviceProfileForVersion330(JsonNode profileData) { + boolean isUpdated = false; + if (profileData.has("alarms") && !profileData.get("alarms").isNull()) { + JsonNode alarms = profileData.get("alarms"); + for (JsonNode alarm : alarms) { + if (alarm.has("createRules")) { + JsonNode createRules = alarm.get("createRules"); + for (AlarmSeverity severity : AlarmSeverity.values()) { + if (createRules.has(severity.name())) { + JsonNode spec = createRules.get(severity.name()).get("condition").get("spec"); + if (convertDeviceProfileAlarmRulesForVersion330(spec)) { + isUpdated = true; + } + } + } + } + if (alarm.has("clearRule") && !alarm.get("clearRule").isNull()) { + JsonNode spec = alarm.get("clearRule").get("condition").get("spec"); + if (convertDeviceProfileAlarmRulesForVersion330(spec)) { + isUpdated = true; + } + } + } + } + return isUpdated; + } + + private final PaginatedUpdater tenantsDefaultRuleChainUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Tenants default rule chain updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String region, PageLink pageLink) { + return tenantService.findTenants(pageLink); + } + + @Override + protected void updateEntity(Tenant tenant) { + try { + RuleChain ruleChain = ruleChainService.getRootTenantRuleChain(tenant.getId()); + if (ruleChain == null) { + installScripts.createDefaultRuleChains(tenant.getId()); + } + } catch (Exception e) { + log.error("Unable to update Tenant", e); + } + } + }; + + private void updateNestedRuleChains() { + try { + var packSize = 1024; + var updated = 0; + boolean hasNext = true; + while (hasNext) { + List relations = relationService.findRuleNodeToRuleChainRelations(TenantId.SYS_TENANT_ID, RuleChainType.CORE, packSize); + hasNext = relations.size() == packSize; + for (EntityRelation relation : relations) { + try { + RuleNodeId sourceNodeId = new RuleNodeId(relation.getFrom().getId()); + RuleNode sourceNode = ruleChainService.findRuleNodeById(TenantId.SYS_TENANT_ID, sourceNodeId); + if (sourceNode == null) { + log.info("Skip processing of relation for non existing source rule node: [{}]", sourceNodeId); + relationService.deleteRelation(TenantId.SYS_TENANT_ID, relation); + continue; + } + RuleChainId sourceRuleChainId = sourceNode.getRuleChainId(); + RuleChainId targetRuleChainId = new RuleChainId(relation.getTo().getId()); + RuleChain targetRuleChain = ruleChainService.findRuleChainById(TenantId.SYS_TENANT_ID, targetRuleChainId); + if (targetRuleChain == null) { + log.info("Skip processing of relation for non existing target rule chain: [{}]", targetRuleChainId); + relationService.deleteRelation(TenantId.SYS_TENANT_ID, relation); + continue; + } + TenantId tenantId = targetRuleChain.getTenantId(); + RuleNode targetNode = new RuleNode(); + targetNode.setName(targetRuleChain.getName()); + targetNode.setRuleChainId(sourceRuleChainId); + targetNode.setType(TbRuleChainInputNode.class.getName()); + TbRuleChainInputNodeConfiguration configuration = new TbRuleChainInputNodeConfiguration(); + configuration.setRuleChainId(targetRuleChain.getId().toString()); + targetNode.setConfiguration(JacksonUtil.valueToTree(configuration)); + targetNode.setAdditionalInfo(relation.getAdditionalInfo()); + targetNode.setDebugMode(false); + targetNode = ruleChainService.saveRuleNode(tenantId, targetNode); + + EntityRelation sourceRuleChainToRuleNode = new EntityRelation(); + sourceRuleChainToRuleNode.setFrom(sourceRuleChainId); + sourceRuleChainToRuleNode.setTo(targetNode.getId()); + sourceRuleChainToRuleNode.setType(EntityRelation.CONTAINS_TYPE); + sourceRuleChainToRuleNode.setTypeGroup(RelationTypeGroup.RULE_CHAIN); + relationService.saveRelation(tenantId, sourceRuleChainToRuleNode); + + EntityRelation sourceRuleNodeToTargetRuleNode = new EntityRelation(); + sourceRuleNodeToTargetRuleNode.setFrom(sourceNode.getId()); + sourceRuleNodeToTargetRuleNode.setTo(targetNode.getId()); + sourceRuleNodeToTargetRuleNode.setType(relation.getType()); + sourceRuleNodeToTargetRuleNode.setTypeGroup(RelationTypeGroup.RULE_NODE); + sourceRuleNodeToTargetRuleNode.setAdditionalInfo(relation.getAdditionalInfo()); + relationService.saveRelation(tenantId, sourceRuleNodeToTargetRuleNode); + + //Delete old relation + relationService.deleteRelation(tenantId, relation); + updated++; + } catch (Exception e) { + log.info("Failed to update RuleNodeToRuleChainRelation: {}", relation, e); + } + } + if (updated > 0) { + log.info("RuleNodeToRuleChainRelations: {} entities updated so far...", updated); + } + } + } catch (Exception e) { + log.error("Unable to update Tenant", e); + } + } + + private final PaginatedUpdater tenantsDefaultEdgeRuleChainUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Tenants default edge rule chain updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String region, PageLink pageLink) { + return tenantService.findTenants(pageLink); + } + + @Override + protected void updateEntity(Tenant tenant) { + try { + RuleChain defaultEdgeRuleChain = ruleChainService.getEdgeTemplateRootRuleChain(tenant.getId()); + if (defaultEdgeRuleChain == null) { + installScripts.createDefaultEdgeRuleChains(tenant.getId()); + } + } catch (Exception e) { + log.error("Unable to update Tenant", e); + } + } + }; + + private final PaginatedUpdater tenantsRootRuleChainUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Tenants root rule chain updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String region, PageLink pageLink) { + return tenantService.findTenants(pageLink); + } + + @Override + protected void updateEntity(Tenant tenant) { + try { + RuleChain ruleChain = ruleChainService.getRootTenantRuleChain(tenant.getId()); + if (ruleChain == null) { + installScripts.createDefaultRuleChains(tenant.getId()); + } else { + RuleChainMetaData md = ruleChainService.loadRuleChainMetaData(tenant.getId(), ruleChain.getId()); + int oldIdx = md.getFirstNodeIndex(); + int newIdx = md.getNodes().size(); + + if (md.getNodes().size() < oldIdx) { + // Skip invalid rule chains + return; + } + + RuleNode oldFirstNode = md.getNodes().get(oldIdx); + if (oldFirstNode.getType().equals(TbDeviceProfileNode.class.getName())) { + // No need to update the rule node twice. + return; + } + + RuleNode ruleNode = new RuleNode(); + ruleNode.setRuleChainId(ruleChain.getId()); + ruleNode.setName("Device Profile Node"); + ruleNode.setType(TbDeviceProfileNode.class.getName()); + ruleNode.setDebugMode(false); + TbDeviceProfileNodeConfiguration ruleNodeConfiguration = new TbDeviceProfileNodeConfiguration().defaultConfiguration(); + ruleNode.setConfiguration(JacksonUtil.valueToTree(ruleNodeConfiguration)); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put("description", "Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \"Success\" relation type."); + additionalInfo.put("layoutX", 204); + additionalInfo.put("layoutY", 240); + ruleNode.setAdditionalInfo(additionalInfo); + + md.getNodes().add(ruleNode); + md.setFirstNodeIndex(newIdx); + md.addConnectionInfo(newIdx, oldIdx, "Success"); + ruleChainService.saveRuleChainMetaData(tenant.getId(), md); + } + } catch (Exception e) { + log.error("[{}] Unable to update Tenant: {}", tenant.getId(), tenant.getName(), e); + } + } + }; + + private final PaginatedUpdater tenantsEntityViewsUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Tenants entity views updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String region, PageLink pageLink) { + return tenantService.findTenants(pageLink); + } + + @Override + protected void updateEntity(Tenant tenant) { + updateTenantEntityViews(tenant.getId()); + } + }; + + private void updateTenantEntityViews(TenantId tenantId) { + PageLink pageLink = new PageLink(100); + PageData pageData = entityViewService.findEntityViewByTenantId(tenantId, pageLink); + boolean hasNext = true; + while (hasNext) { + List>> updateFutures = new ArrayList<>(); + for (EntityView entityView : pageData.getData()) { + updateFutures.add(updateEntityViewLatestTelemetry(entityView)); + } + + try { + Futures.allAsList(updateFutures).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to copy latest telemetry to entity view", e); + } + + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + pageData = entityViewService.findEntityViewByTenantId(tenantId, pageLink); + } else { + hasNext = false; + } + } + } + + private ListenableFuture> updateEntityViewLatestTelemetry(EntityView entityView) { + EntityViewId entityId = entityView.getId(); + List keys = entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null ? + entityView.getKeys().getTimeseries() : Collections.emptyList(); + long startTs = entityView.getStartTimeMs(); + long endTs = entityView.getEndTimeMs() == 0 ? Long.MAX_VALUE : entityView.getEndTimeMs(); + ListenableFuture> keysFuture; + if (keys.isEmpty()) { + keysFuture = Futures.transform(tsService.findAllLatest(TenantId.SYS_TENANT_ID, + entityView.getEntityId()), latest -> latest.stream().map(TsKvEntry::getKey).collect(Collectors.toList()), MoreExecutors.directExecutor()); + } else { + keysFuture = Futures.immediateFuture(keys); + } + ListenableFuture> latestFuture = Futures.transformAsync(keysFuture, fetchKeys -> { + List queries = fetchKeys.stream().filter(key -> !isBlank(key)).map(key -> new BaseReadTsKvQuery(key, startTs, endTs, 1, "DESC")).collect(Collectors.toList()); + if (!queries.isEmpty()) { + return tsService.findAll(TenantId.SYS_TENANT_ID, entityView.getEntityId(), queries); + } else { + return Futures.immediateFuture(null); + } + }, MoreExecutors.directExecutor()); + return Futures.transformAsync(latestFuture, latestValues -> { + if (latestValues != null && !latestValues.isEmpty()) { + ListenableFuture> saveFuture = tsService.saveLatest(TenantId.SYS_TENANT_ID, entityId, latestValues); + return saveFuture; + } + return Futures.immediateFuture(null); + }, MoreExecutors.directExecutor()); + } + + private final PaginatedUpdater tenantsAlarmsCustomerUpdater = + new PaginatedUpdater<>() { + + final AtomicLong processed = new AtomicLong(); + + @Override + protected String getName() { + return "Tenants alarms customer updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String region, PageLink pageLink) { + return tenantService.findTenants(pageLink); + } + + @Override + protected void updateEntity(Tenant tenant) { + updateTenantAlarmsCustomer(tenant.getId(), getName(), processed); + } + }; + + private void updateTenantAlarmsCustomer(TenantId tenantId, String name, AtomicLong processed) { + AlarmQuery alarmQuery = new AlarmQuery(null, new TimePageLink(1000), null, null, false); + PageData alarms = alarmDao.findAlarms(tenantId, alarmQuery); + boolean hasNext = true; + while (hasNext) { + for (Alarm alarm : alarms.getData()) { + if (alarm.getCustomerId() == null && alarm.getOriginator() != null) { + alarm.setCustomerId(entityService.fetchEntityCustomerId(tenantId, alarm.getOriginator())); + alarmDao.save(tenantId, alarm); + } + if (processed.incrementAndGet() % 1000 == 0) { + log.info("{}: {} alarms processed so far...", name, processed); + } + } + if (alarms.hasNext()) { + alarmQuery.setPageLink(alarmQuery.getPageLink().nextPageLink()); + alarms = alarmDao.findAlarms(tenantId, alarmQuery); + } else { + hasNext = false; + } + } + } + + boolean convertDeviceProfileAlarmRulesForVersion330(JsonNode spec) { + if (spec != null) { + if (spec.has("type") && spec.get("type").asText().equals("DURATION")) { + if (spec.has("value")) { + long value = spec.get("value").asLong(); + var predicate = new FilterPredicateValue<>( + value, null, new DynamicValue<>(null, null, false) + ); + ((ObjectNode) spec).remove("value"); + ((ObjectNode) spec).putPOJO("predicate", predicate); + return true; + } + } else if (spec.has("type") && spec.get("type").asText().equals("REPEATING")) { + if (spec.has("count")) { + int count = spec.get("count").asInt(); + var predicate = new FilterPredicateValue<>( + count, null, new DynamicValue<>(null, null, false) + ); + ((ObjectNode) spec).remove("count"); + ((ObjectNode) spec).putPOJO("predicate", predicate); + return true; + } + } + } + return false; + } + + private void updateOAuth2Params() { + log.warn("CAUTION: Update of Oauth2 parameters from 3.2.2 to 3.3.0 available only in ThingsBoard versions 3.3.0/3.3.1"); + } + + private final PaginatedUpdater tenantsProfileQueueConfigurationUpdater = + new PaginatedUpdater<>() { + + @Override + protected String getName() { + return "Tenant profiles queue configuration updater"; + } + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected PageData findEntities(String id, PageLink pageLink) { + return tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, pageLink); + } + + @Override + protected void updateEntity(TenantProfile tenantProfile) { + updateTenantProfileQueueConfiguration(tenantProfile); + } + }; + + private void updateTenantProfileQueueConfiguration(TenantProfile profile) { + try { + List queueConfiguration = profile.getProfileData().getQueueConfiguration(); + if (profile.isIsolatedTbRuleEngine() && (queueConfiguration == null || queueConfiguration.isEmpty())) { + TenantProfileQueueConfiguration mainQueueConfig = getMainQueueConfiguration(); + profile.getProfileData().setQueueConfiguration(Collections.singletonList((mainQueueConfig))); + tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, profile); + List isolatedTenants = tenantService.findTenantIdsByTenantProfileId(profile.getId()); + isolatedTenants.forEach(tenantId -> { + queueService.saveQueue(new Queue(tenantId, mainQueueConfig)); + }); + } + } catch (Exception e) { + log.error("Failed to update tenant profile queue configuration name=[" + profile.getName() + "], id=[" + profile.getId().getId() + "]", e); + } + } + + private TenantProfileQueueConfiguration getMainQueueConfiguration() { + TenantProfileQueueConfiguration mainQueueConfiguration = new TenantProfileQueueConfiguration(); + mainQueueConfiguration.setName("Main"); + mainQueueConfiguration.setTopic("tb_rule_engine.main"); + mainQueueConfiguration.setPollInterval(25); + mainQueueConfiguration.setPartitions(10); + mainQueueConfiguration.setConsumerPerPartition(true); + mainQueueConfiguration.setPackProcessingTimeout(2000); + SubmitStrategy mainQueueSubmitStrategy = new SubmitStrategy(); + mainQueueSubmitStrategy.setType(SubmitStrategyType.BURST); + mainQueueSubmitStrategy.setBatchSize(1000); + mainQueueConfiguration.setSubmitStrategy(mainQueueSubmitStrategy); + ProcessingStrategy mainQueueProcessingStrategy = new ProcessingStrategy(); + mainQueueProcessingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + mainQueueProcessingStrategy.setRetries(3); + mainQueueProcessingStrategy.setFailurePercentage(0); + mainQueueProcessingStrategy.setPauseBetweenRetries(3); + mainQueueProcessingStrategy.setMaxPauseBetweenRetries(3); + mainQueueConfiguration.setProcessingStrategy(mainQueueProcessingStrategy); + return mainQueueConfiguration; + } + + private boolean getEnv(String name, boolean defaultValue) { + String env = System.getenv(name); + if (env == null) { + return defaultValue; + } else { + return Boolean.parseBoolean(env); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/PaginatedUpdater.java b/application/src/main/java/org/thingsboard/server/service/install/update/PaginatedUpdater.java new file mode 100644 index 0000000..b2fb707 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/PaginatedUpdater.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.SearchTextBased; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +@Slf4j +public abstract class PaginatedUpdater { + + private static final int DEFAULT_LIMIT = 100; + private int updated = 0; + + public void updateEntities(I id) { + updated = 0; + PageLink pageLink = new PageLink(DEFAULT_LIMIT); + boolean hasNext = true; + while (hasNext) { + PageData entities = findEntities(id, pageLink); + for (D entity : entities.getData()) { + updateEntity(entity); + } + updated += entities.getData().size(); + hasNext = entities.hasNext(); + if (hasNext) { + log.info("{}: {} entities updated so far...", getName(), updated); + pageLink = pageLink.nextPageLink(); + } else { + if (updated > DEFAULT_LIMIT || forceReportTotal()) { + log.info("{}: {} total entities updated.", getName(), updated); + } + } + } + } + + public void updateEntities() { + updateEntities(null); + } + + protected boolean forceReportTotal() { + return false; + } + + protected abstract String getName(); + + protected abstract PageData findEntities(I id, PageLink pageLink); + + protected abstract void updateEntity(D entity); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/RateLimitsUpdater.java b/application/src/main/java/org/thingsboard/server/service/install/update/RateLimitsUpdater.java new file mode 100644 index 0000000..87179b2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/update/RateLimitsUpdater.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install.update; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.tenant.TenantProfileService; + +@Component +class RateLimitsUpdater extends PaginatedUpdater { + + @Value("#{ environment.getProperty('TB_SERVER_REST_LIMITS_TENANT_ENABLED') ?: environment.getProperty('server.rest.limits.tenant.enabled') ?: 'false' }") + boolean tenantServerRestLimitsEnabled; + @Value("#{ environment.getProperty('TB_SERVER_REST_LIMITS_TENANT_CONFIGURATION') ?: environment.getProperty('server.rest.limits.tenant.configuration') ?: '100:1,2000:60' }") + String tenantServerRestLimitsConfiguration; + @Value("#{ environment.getProperty('TB_SERVER_REST_LIMITS_CUSTOMER_ENABLED') ?: environment.getProperty('server.rest.limits.customer.enabled') ?: 'false' }") + boolean customerServerRestLimitsEnabled; + @Value("#{ environment.getProperty('TB_SERVER_REST_LIMITS_CUSTOMER_CONFIGURATION') ?: environment.getProperty('server.rest.limits.customer.configuration') ?: '50:1,1000:60' }") + String customerServerRestLimitsConfiguration; + + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SESSIONS_PER_TENANT') ?: environment.getProperty('server.ws.limits.max_sessions_per_tenant') ?: '0' }") + private int maxWsSessionsPerTenant; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SESSIONS_PER_CUSTOMER') ?: environment.getProperty('server.ws.limits.max_sessions_per_customer') ?: '0' }") + private int maxWsSessionsPerCustomer; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SESSIONS_PER_REGULAR_USER') ?: environment.getProperty('server.ws.limits.max_sessions_per_regular_user') ?: '0' }") + private int maxWsSessionsPerRegularUser; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SESSIONS_PER_PUBLIC_USER') ?: environment.getProperty('server.ws.limits.max_sessions_per_public_user') ?: '0' }") + private int maxWsSessionsPerPublicUser; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_QUEUE_PER_WS_SESSION') ?: environment.getProperty('server.ws.limits.max_queue_per_ws_session') ?: '500' }") + private int wsMsgQueueLimitPerSession; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SUBSCRIPTIONS_PER_TENANT') ?: environment.getProperty('server.ws.limits.max_subscriptions_per_tenant') ?: '0' }") + private long maxWsSubscriptionsPerTenant; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SUBSCRIPTIONS_PER_CUSTOMER') ?: environment.getProperty('server.ws.limits.max_subscriptions_per_customer') ?: '0' }") + private long maxWsSubscriptionsPerCustomer; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SUBSCRIPTIONS_PER_REGULAR_USER') ?: environment.getProperty('server.ws.limits.max_subscriptions_per_regular_user') ?: '0' }") + private long maxWsSubscriptionsPerRegularUser; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_SUBSCRIPTIONS_PER_PUBLIC_USER') ?: environment.getProperty('server.ws.limits.max_subscriptions_per_public_user') ?: '0' }") + private long maxWsSubscriptionsPerPublicUser; + @Value("#{ environment.getProperty('TB_SERVER_WS_TENANT_RATE_LIMITS_MAX_UPDATES_PER_SESSION') ?: environment.getProperty('server.ws.limits.max_updates_per_session') ?: '300:1,3000:60' }") + private String wsUpdatesPerSessionRateLimit; + + @Value("#{ environment.getProperty('CASSANDRA_QUERY_TENANT_RATE_LIMITS_ENABLED') ?: environment.getProperty('cassandra.query.tenant_rate_limits.enabled') ?: 'false' }") + private boolean cassandraQueryTenantRateLimitsEnabled; + @Value("#{ environment.getProperty('CASSANDRA_QUERY_TENANT_RATE_LIMITS_CONFIGURATION') ?: environment.getProperty('cassandra.query.tenant_rate_limits.configuration') ?: '1000:1,30000:60' }") + private String cassandraQueryTenantRateLimitsConfiguration; + + @Autowired + private TenantProfileService tenantProfileService; + + @Override + protected boolean forceReportTotal() { + return true; + } + + @Override + protected String getName() { + return "Rate limits updater"; + } + + @Override + protected PageData findEntities(String id, PageLink pageLink) { + return tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, pageLink); + } + + @Override + protected void updateEntity(TenantProfile tenantProfile) { + var profileConfiguration = tenantProfile.getDefaultProfileConfiguration(); + + if (tenantServerRestLimitsEnabled && StringUtils.isNotEmpty(tenantServerRestLimitsConfiguration)) { + profileConfiguration.setTenantServerRestLimitsConfiguration(tenantServerRestLimitsConfiguration); + } + if (customerServerRestLimitsEnabled && StringUtils.isNotEmpty(customerServerRestLimitsConfiguration)) { + profileConfiguration.setCustomerServerRestLimitsConfiguration(customerServerRestLimitsConfiguration); + } + + profileConfiguration.setMaxWsSessionsPerTenant(maxWsSessionsPerTenant); + profileConfiguration.setMaxWsSessionsPerCustomer(maxWsSessionsPerCustomer); + profileConfiguration.setMaxWsSessionsPerPublicUser(maxWsSessionsPerPublicUser); + profileConfiguration.setMaxWsSessionsPerRegularUser(maxWsSessionsPerRegularUser); + profileConfiguration.setMaxWsSubscriptionsPerTenant(maxWsSubscriptionsPerTenant); + profileConfiguration.setMaxWsSubscriptionsPerCustomer(maxWsSubscriptionsPerCustomer); + profileConfiguration.setMaxWsSubscriptionsPerPublicUser(maxWsSubscriptionsPerPublicUser); + profileConfiguration.setMaxWsSubscriptionsPerRegularUser(maxWsSubscriptionsPerRegularUser); + profileConfiguration.setWsMsgQueueLimitPerSession(wsMsgQueueLimitPerSession); + if (StringUtils.isNotEmpty(wsUpdatesPerSessionRateLimit)) { + profileConfiguration.setWsUpdatesPerSessionRateLimit(wsUpdatesPerSessionRateLimit); + } + + if (cassandraQueryTenantRateLimitsEnabled && StringUtils.isNotEmpty(cassandraQueryTenantRateLimitsConfiguration)) { + profileConfiguration.setCassandraQueryTenantRateLimitsConfiguration(cassandraQueryTenantRateLimitsConfiguration); + } + + tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MService.java b/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MService.java new file mode 100644 index 0000000..eee6408 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MService.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.lwm2m; + +import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; + +public interface LwM2MService { + + LwM2MServerSecurityConfigDefault getServerSecurityInfo(boolean bootstrapServer); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MServiceImpl.java new file mode 100644 index 0000000..31e60b3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/lwm2m/LwM2MServiceImpl.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.lwm2m; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.config.LwM2MSecureServerConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnExpression("('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && '${transport.lwm2m.enabled:false}'=='true'") +public class LwM2MServiceImpl implements LwM2MService { + + private final LwM2MTransportServerConfig serverConfig; + private final Optional bootstrapConfig; + + @Override + public LwM2MServerSecurityConfigDefault getServerSecurityInfo(boolean bootstrapServer) { + LwM2MSecureServerConfig bsServerConfig = bootstrapServer ? bootstrapConfig.orElse(null) : serverConfig; + if (bsServerConfig!= null) { + LwM2MServerSecurityConfigDefault result = getServerSecurityConfig(bsServerConfig); + result.setBootstrapServerIs(bootstrapServer); + return result; + } + else { + return null; + } + } + + private LwM2MServerSecurityConfigDefault getServerSecurityConfig(LwM2MSecureServerConfig bsServerConfig) { + LwM2MServerSecurityConfigDefault bsServ = new LwM2MServerSecurityConfigDefault(); + bsServ.setShortServerId(bsServerConfig.getId()); + bsServ.setHost(bsServerConfig.getHost()); + bsServ.setPort(bsServerConfig.getPort()); + bsServ.setSecurityHost(bsServerConfig.getSecureHost()); + bsServ.setSecurityPort(bsServerConfig.getSecurePort()); + byte[] publicKeyBase64 = getPublicKey(bsServerConfig); + if (publicKeyBase64 == null) { + bsServ.setServerPublicKey(""); + } else { + bsServ.setServerPublicKey(Base64.encodeBase64String(publicKeyBase64)); + } + byte[] certificateBase64 = getCertificate(bsServerConfig); + if (certificateBase64 == null) { + bsServ.setServerCertificate(""); + } else { + bsServ.setServerCertificate(Base64.encodeBase64String(certificateBase64)); + } + return bsServ; + } + + private byte[] getPublicKey(LwM2MSecureServerConfig config) { + try { + SslCredentials sslCredentials = config.getSslCredentials(); + if (sslCredentials != null) { + return sslCredentials.getPublicKey().getEncoded(); + } + } catch (Exception e) { + log.trace("Failed to fetch public key from key store!", e); + } + return null; + } + + private byte[] getCertificate(LwM2MSecureServerConfig config) { + try { + SslCredentials sslCredentials = config.getSslCredentials(); + if (sslCredentials != null) { + return sslCredentials.getCertificateChain()[0].getEncoded(); + } + } catch (Exception e) { + log.trace("Failed to fetch certificate from key store!", e); + } + return null; + } +} + diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java new file mode 100644 index 0000000..7b26af9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -0,0 +1,510 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mail; + +import com.fasterxml.jackson.databind.JsonNode; +import freemarker.template.Configuration; +import freemarker.template.Template; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.NestedRuntimeException; +import org.springframework.core.io.InputStreamSource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.rule.engine.api.TbEmail; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.ApiFeature; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageStateMailMessage; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; + +import javax.annotation.PostConstruct; +import javax.mail.internet.MimeMessage; +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Service +@Slf4j +public class DefaultMailService implements MailService { + + public static final String MAIL_PROP = "mail."; + public static final String TARGET_EMAIL = "targetEmail"; + public static final String UTF_8 = "UTF-8"; + public static final int _10K = 10000; + public static final int _1M = 1000000; + + private final MessageSource messages; + private final Configuration freemarkerConfig; + private final AdminSettingsService adminSettingsService; + private final TbApiUsageReportClient apiUsageClient; + + private static final long DEFAULT_TIMEOUT = 10_000; + + @Lazy + @Autowired + private TbApiUsageStateService apiUsageStateService; + + @Autowired + private MailExecutorService mailExecutorService; + + @Autowired + private PasswordResetExecutorService passwordResetExecutorService; + + private JavaMailSenderImpl mailSender; + + private String mailFrom; + + private long timeout; + + public DefaultMailService(MessageSource messages, Configuration freemarkerConfig, AdminSettingsService adminSettingsService, TbApiUsageReportClient apiUsageClient) { + this.messages = messages; + this.freemarkerConfig = freemarkerConfig; + this.adminSettingsService = adminSettingsService; + this.apiUsageClient = apiUsageClient; + } + + @PostConstruct + private void init() { + updateMailConfiguration(); + } + + @Override + public void updateMailConfiguration() { + AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail"); + if (settings != null) { + JsonNode jsonConfig = settings.getJsonValue(); + mailSender = createMailSender(jsonConfig); + mailFrom = jsonConfig.get("mailFrom").asText(); + timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); + } else { + throw new IncorrectParameterException("Failed to update mail configuration. Settings not found!"); + } + } + + private JavaMailSenderImpl createMailSender(JsonNode jsonConfig) { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(jsonConfig.get("smtpHost").asText()); + mailSender.setPort(parsePort(jsonConfig.get("smtpPort").asText())); + mailSender.setUsername(jsonConfig.get("username").asText()); + mailSender.setPassword(jsonConfig.get("password").asText()); + mailSender.setJavaMailProperties(createJavaMailProperties(jsonConfig)); + return mailSender; + } + + private Properties createJavaMailProperties(JsonNode jsonConfig) { + Properties javaMailProperties = new Properties(); + String protocol = jsonConfig.get("smtpProtocol").asText(); + javaMailProperties.put("mail.transport.protocol", protocol); + javaMailProperties.put(MAIL_PROP + protocol + ".host", jsonConfig.get("smtpHost").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".port", jsonConfig.get("smtpPort").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".timeout", jsonConfig.get("timeout").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".auth", String.valueOf(StringUtils.isNotEmpty(jsonConfig.get("username").asText()))); + boolean enableTls = false; + if (jsonConfig.has("enableTls")) { + if (jsonConfig.get("enableTls").isBoolean() && jsonConfig.get("enableTls").booleanValue()) { + enableTls = true; + } else if (jsonConfig.get("enableTls").isTextual()) { + enableTls = "true".equalsIgnoreCase(jsonConfig.get("enableTls").asText()); + } + } + javaMailProperties.put(MAIL_PROP + protocol + ".starttls.enable", enableTls); + if (enableTls && jsonConfig.has("tlsVersion") && !jsonConfig.get("tlsVersion").isNull()) { + String tlsVersion = jsonConfig.get("tlsVersion").asText(); + if (StringUtils.isNoneEmpty(tlsVersion)) { + javaMailProperties.put(MAIL_PROP + protocol + ".ssl.protocols", tlsVersion); + } + } + + boolean enableProxy = jsonConfig.has("enableProxy") && jsonConfig.get("enableProxy").asBoolean(); + + if (enableProxy) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.host", jsonConfig.get("proxyHost").asText()); + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.port", jsonConfig.get("proxyPort").asText()); + String proxyUser = jsonConfig.get("proxyUser").asText(); + if (StringUtils.isNoneEmpty(proxyUser)) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.user", proxyUser); + } + String proxyPassword = jsonConfig.get("proxyPassword").asText(); + if (StringUtils.isNoneEmpty(proxyPassword)) { + javaMailProperties.put(MAIL_PROP + protocol + ".proxy.password", proxyPassword); + } + } + return javaMailProperties; + } + + private int parsePort(String strPort) { + try { + return Integer.valueOf(strPort); + } catch (NumberFormatException e) { + throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); + } + } + + @Override + public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException { + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException { + JavaMailSenderImpl testMailSender = createMailSender(jsonConfig); + String mailFrom = jsonConfig.get("mailFrom").asText(); + String subject = messages.getMessage("test.message.subject", null, Locale.US); + long timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT); + + Map model = new HashMap<>(); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("test.ftl", model); + + sendMail(testMailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendActivationEmail(String activationLink, String email) throws ThingsboardException { + + String subject = messages.getMessage("activation.subject", null, Locale.US); + + Map model = new HashMap<>(); + model.put("activationLink", activationLink); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("activation.ftl", model); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendAccountActivatedEmail(String loginLink, String email) throws ThingsboardException { + + String subject = messages.getMessage("account.activated.subject", null, Locale.US); + + Map model = new HashMap<>(); + model.put("loginLink", loginLink); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("account.activated.ftl", model); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendResetPasswordEmail(String passwordResetLink, String email) throws ThingsboardException { + + String subject = messages.getMessage("reset.password.subject", null, Locale.US); + + Map model = new HashMap<>(); + model.put("passwordResetLink", passwordResetLink); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("reset.password.ftl", model); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendResetPasswordEmailAsync(String passwordResetLink, String email) { + passwordResetExecutorService.execute(() -> { + try { + this.sendResetPasswordEmail(passwordResetLink, email); + } catch (ThingsboardException e) { + log.error("Error occurred: {} ", e.getMessage()); + } + }); + } + + @Override + public void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException { + + String subject = messages.getMessage("password.was.reset.subject", null, Locale.US); + + Map model = new HashMap<>(); + model.put("loginLink", loginLink); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("password.was.reset.ftl", model); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException { + sendMail(tenantId, customerId, tbEmail, this.mailSender, timeout); + } + + @Override + public void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { + sendMail(tenantId, customerId, tbEmail, javaMailSender, timeout); + } + + private void sendMail(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException { + if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) { + try { + MimeMessage mailMsg = javaMailSender.createMimeMessage(); + boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty()); + MimeMessageHelper helper = new MimeMessageHelper(mailMsg, multipart, "UTF-8"); + helper.setFrom(StringUtils.isBlank(tbEmail.getFrom()) ? mailFrom : tbEmail.getFrom()); + helper.setTo(tbEmail.getTo().split("\\s*,\\s*")); + if (!StringUtils.isBlank(tbEmail.getCc())) { + helper.setCc(tbEmail.getCc().split("\\s*,\\s*")); + } + if (!StringUtils.isBlank(tbEmail.getBcc())) { + helper.setBcc(tbEmail.getBcc().split("\\s*,\\s*")); + } + helper.setSubject(tbEmail.getSubject()); + helper.setText(tbEmail.getBody(), tbEmail.isHtml()); + + if (multipart) { + for (String imgId : tbEmail.getImages().keySet()) { + String imgValue = tbEmail.getImages().get(imgId); + String value = imgValue.replaceFirst("^data:image/[^;]*;base64,?", ""); + byte[] bytes = javax.xml.bind.DatatypeConverter.parseBase64Binary(value); + String contentType = helper.getFileTypeMap().getContentType(imgId); + InputStreamSource iss = () -> new ByteArrayInputStream(bytes); + helper.addInline(imgId, iss, contentType); + } + } + sendMailWithTimeout(javaMailSender, helper.getMimeMessage(), timeout); + apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.EMAIL_EXEC_COUNT, 1); + } catch (Exception e) { + throw handleException(e); + } + } else { + throw new RuntimeException("Email sending is disabled due to API limits!"); + } + } + + @Override + public void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException { + String subject = messages.getMessage("account.lockout.subject", null, Locale.US); + + Map model = new HashMap<>(); + model.put("lockoutAccount", lockoutEmail); + model.put("maxFailedLoginAttempts", maxFailedLoginAttempts); + model.put(TARGET_EMAIL, email); + + String message = mergeTemplateIntoString("account.lockout.ftl", model); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendTwoFaVerificationEmail(String email, String verificationCode, int expirationTimeSeconds) throws ThingsboardException { + String subject = messages.getMessage("2fa.verification.code.subject", null, Locale.US); + String message = mergeTemplateIntoString("2fa.verification.code.ftl", Map.of( + TARGET_EMAIL, email, + "code", verificationCode, + "expirationTimeSeconds", expirationTimeSeconds + )); + + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageStateMailMessage msg) throws ThingsboardException { + String subject = messages.getMessage("api.usage.state", null, Locale.US); + + Map model = new HashMap<>(); + model.put("apiFeature", apiFeature.getLabel()); + model.put(TARGET_EMAIL, email); + + String message = null; + + switch (stateValue) { + case ENABLED: + model.put("apiLabel", toEnabledValueLabel(apiFeature)); + message = mergeTemplateIntoString("state.enabled.ftl", model); + break; + case WARNING: + model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(msg.getKey(), msg.getValue(), msg.getThreshold())); + message = mergeTemplateIntoString("state.warning.ftl", model); + break; + case DISABLED: + model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(msg.getKey(), msg.getThreshold())); + message = mergeTemplateIntoString("state.disabled.ftl", model); + break; + } + sendMail(mailSender, mailFrom, email, subject, message, timeout); + } + + @Override + public void testConnection(TenantId tenantId) throws Exception { + mailSender.testConnection(); + } + + private String toEnabledValueLabel(ApiFeature apiFeature) { + switch (apiFeature) { + case DB: + return "save"; + case TRANSPORT: + return "receive"; + case JS: + return "invoke"; + case RE: + return "process"; + case EMAIL: + case SMS: + return "send"; + case ALARM: + return "create"; + default: + throw new RuntimeException("Not implemented!"); + } + } + + private String toDisabledValueLabel(ApiFeature apiFeature) { + switch (apiFeature) { + case DB: + return "saved"; + case TRANSPORT: + return "received"; + case JS: + return "invoked"; + case RE: + return "processed"; + case EMAIL: + case SMS: + return "sent"; + case ALARM: + return "created"; + default: + throw new RuntimeException("Not implemented!"); + } + } + + private String toWarningValueLabel(ApiUsageRecordKey key, long value, long threshold) { + String valueInM = getValueAsString(value); + String thresholdInM = getValueAsString(threshold); + switch (key) { + case STORAGE_DP_COUNT: + case TRANSPORT_DP_COUNT: + return valueInM + " out of " + thresholdInM + " allowed data points"; + case TRANSPORT_MSG_COUNT: + return valueInM + " out of " + thresholdInM + " allowed messages"; + case JS_EXEC_COUNT: + return valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; + case RE_EXEC_COUNT: + return valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; + case EMAIL_EXEC_COUNT: + return valueInM + " out of " + thresholdInM + " allowed Email messages"; + case SMS_EXEC_COUNT: + return valueInM + " out of " + thresholdInM + " allowed SMS messages"; + default: + throw new RuntimeException("Not implemented!"); + } + } + + private String toDisabledValueLabel(ApiUsageRecordKey key, long value) { + switch (key) { + case STORAGE_DP_COUNT: + case TRANSPORT_DP_COUNT: + return getValueAsString(value) + " data points"; + case TRANSPORT_MSG_COUNT: + return getValueAsString(value) + " messages"; + case JS_EXEC_COUNT: + return "JavaScript functions " + getValueAsString(value) + " times"; + case RE_EXEC_COUNT: + return getValueAsString(value) + " Rule Engine messages"; + case EMAIL_EXEC_COUNT: + return getValueAsString(value) + " Email messages"; + case SMS_EXEC_COUNT: + return getValueAsString(value) + " SMS messages"; + default: + throw new RuntimeException("Not implemented!"); + } + } + + private String getValueAsString(long value) { + if (value > _1M && value % _1M < _10K) { + return value / _1M + "M"; + } else if (value > _10K) { + return String.format("%.2fM", ((double) value) / 1000000); + } else { + return value + ""; + } + } + + private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, + String subject, String message, long timeout) throws ThingsboardException { + try { + MimeMessage mimeMsg = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMsg, UTF_8); + helper.setFrom(mailFrom); + helper.setTo(email); + helper.setSubject(subject); + helper.setText(message, true); + + sendMailWithTimeout(mailSender, helper.getMimeMessage(), timeout); + } catch (Exception e) { + throw handleException(e); + } + } + + private void sendMailWithTimeout(JavaMailSender mailSender, MimeMessage msg, long timeout) { + try { + mailExecutorService.submit(() -> mailSender.send(msg)).get(timeout, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + log.debug("Error during mail submission", e); + throw new RuntimeException("Timeout!"); + } catch (Exception e) { + throw new RuntimeException(ExceptionUtils.getRootCause(e)); + } + } + + private String mergeTemplateIntoString(String templateLocation, + Map model) throws ThingsboardException { + try { + Template template = freemarkerConfig.getTemplate(templateLocation); + return FreeMarkerTemplateUtils.processTemplateIntoString(template, model); + } catch (Exception e) { + throw handleException(e); + } + } + + protected ThingsboardException handleException(Exception exception) { + String message; + if (exception instanceof NestedRuntimeException) { + message = ((NestedRuntimeException) exception).getMostSpecificCause().getMessage(); + } else { + message = exception.getMessage(); + } + log.warn("Unable to send mail: {}", message); + return new ThingsboardException(String.format("Unable to send mail: %s", message), + ThingsboardErrorCode.GENERAL); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/MailExecutorService.java b/application/src/main/java/org/thingsboard/server/service/mail/MailExecutorService.java new file mode 100644 index 0000000..8a8c79b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/MailExecutorService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class MailExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.mail_thread_pool_size}") + private int mailExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return mailExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mail/PasswordResetExecutorService.java b/application/src/main/java/org/thingsboard/server/service/mail/PasswordResetExecutorService.java new file mode 100644 index 0000000..ea761c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mail/PasswordResetExecutorService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class PasswordResetExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.mail_password_reset_thread_pool_size:10}") + private int mailExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return mailExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java new file mode 100644 index 0000000..17ea56d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java @@ -0,0 +1,373 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ota; + +import com.google.common.util.concurrent.FutureCallback; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.TenantId; +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.BasicTsKvEntry; +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.ota.OtaPackageType; +import org.thingsboard.server.common.data.ota.OtaPackageUpdateStatus; +import org.thingsboard.server.common.data.ota.OtaPackageUtil; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.thingsboard.server.common.data.ota.OtaPackageKey.CHECKSUM; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.CHECKSUM_ALGORITHM; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.SIZE; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.STATE; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.TAG; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.TITLE; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.TS; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.URL; +import static org.thingsboard.server.common.data.ota.OtaPackageKey.VERSION; +import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; +import static org.thingsboard.server.common.data.ota.OtaPackageType.SOFTWARE; +import static org.thingsboard.server.common.data.ota.OtaPackageUtil.getAttributeKey; +import static org.thingsboard.server.common.data.ota.OtaPackageUtil.getTargetTelemetryKey; +import static org.thingsboard.server.common.data.ota.OtaPackageUtil.getTelemetryKey; + +@Slf4j +@Service +public class DefaultOtaPackageStateService implements OtaPackageStateService { + + private final TbClusterService tbClusterService; + private final OtaPackageService otaPackageService; + private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; + private final RuleEngineTelemetryService telemetryService; + private final TbQueueProducer> otaPackageStateMsgProducer; + + public DefaultOtaPackageStateService(@Lazy TbClusterService tbClusterService, + OtaPackageService otaPackageService, + DeviceService deviceService, + DeviceProfileService deviceProfileService, + @Lazy RuleEngineTelemetryService telemetryService, + Optional coreQueueFactory, + Optional reQueueFactory) { + this.tbClusterService = tbClusterService; + this.otaPackageService = otaPackageService; + this.deviceService = deviceService; + this.deviceProfileService = deviceProfileService; + this.telemetryService = telemetryService; + if (coreQueueFactory.isPresent()) { + this.otaPackageStateMsgProducer = coreQueueFactory.get().createToOtaPackageStateServiceMsgProducer(); + } else { + this.otaPackageStateMsgProducer = reQueueFactory.get().createToOtaPackageStateServiceMsgProducer(); + } + } + + @Override + public void update(Device device, Device oldDevice) { + updateFirmware(device, oldDevice); + updateSoftware(device, oldDevice); + } + + private void updateFirmware(Device device, Device oldDevice) { + OtaPackageId newFirmwareId = device.getFirmwareId(); + if (newFirmwareId == null) { + DeviceProfile newDeviceProfile = deviceProfileService.findDeviceProfileById(device.getTenantId(), device.getDeviceProfileId()); + newFirmwareId = newDeviceProfile.getFirmwareId(); + } + if (oldDevice != null) { + OtaPackageId oldFirmwareId = oldDevice.getFirmwareId(); + if (oldFirmwareId == null) { + DeviceProfile oldDeviceProfile = deviceProfileService.findDeviceProfileById(oldDevice.getTenantId(), oldDevice.getDeviceProfileId()); + oldFirmwareId = oldDeviceProfile.getFirmwareId(); + } + if (newFirmwareId != null) { + if (!newFirmwareId.equals(oldFirmwareId)) { + // Device was updated and new firmware is different from previous firmware. + send(device.getTenantId(), device.getId(), newFirmwareId, System.currentTimeMillis(), FIRMWARE); + } + } else if (oldFirmwareId != null){ + // Device was updated and new firmware is not set. + remove(device, FIRMWARE); + } + } else if (newFirmwareId != null) { + // Device was created and firmware is defined. + send(device.getTenantId(), device.getId(), newFirmwareId, System.currentTimeMillis(), FIRMWARE); + } + } + + private void updateSoftware(Device device, Device oldDevice) { + OtaPackageId newSoftwareId = device.getSoftwareId(); + if (newSoftwareId == null) { + DeviceProfile newDeviceProfile = deviceProfileService.findDeviceProfileById(device.getTenantId(), device.getDeviceProfileId()); + newSoftwareId = newDeviceProfile.getSoftwareId(); + } + if (oldDevice != null) { + OtaPackageId oldSoftwareId = oldDevice.getSoftwareId(); + if (oldSoftwareId == null) { + DeviceProfile oldDeviceProfile = deviceProfileService.findDeviceProfileById(oldDevice.getTenantId(), oldDevice.getDeviceProfileId()); + oldSoftwareId = oldDeviceProfile.getSoftwareId(); + } + if (newSoftwareId != null) { + if (!newSoftwareId.equals(oldSoftwareId)) { + // Device was updated and new firmware is different from previous firmware. + send(device.getTenantId(), device.getId(), newSoftwareId, System.currentTimeMillis(), SOFTWARE); + } + } else if (oldSoftwareId != null){ + // Device was updated and new firmware is not set. + remove(device, SOFTWARE); + } + } else if (newSoftwareId != null) { + // Device was created and firmware is defined. + send(device.getTenantId(), device.getId(), newSoftwareId, System.currentTimeMillis(), SOFTWARE); + } + } + + @Override + public void update(DeviceProfile deviceProfile, boolean isFirmwareChanged, boolean isSoftwareChanged) { + TenantId tenantId = deviceProfile.getTenantId(); + + if (isFirmwareChanged) { + update(tenantId, deviceProfile, FIRMWARE); + } + if (isSoftwareChanged) { + update(tenantId, deviceProfile, SOFTWARE); + } + } + + private void update(TenantId tenantId, DeviceProfile deviceProfile, OtaPackageType otaPackageType) { + Consumer updateConsumer; + OtaPackageId packageId = OtaPackageUtil.getOtaPackageId(deviceProfile, otaPackageType); + + if (packageId != null) { + long ts = System.currentTimeMillis(); + updateConsumer = d -> send(d.getTenantId(), d.getId(), packageId, ts, otaPackageType); + } else { + updateConsumer = d -> remove(d, otaPackageType); + } + + PageLink pageLink = new PageLink(100); + PageData pageData; + do { + pageData = deviceService.findDevicesByTenantIdAndTypeAndEmptyOtaPackage(tenantId, deviceProfile.getId(), otaPackageType, pageLink); + pageData.getData().forEach(updateConsumer); + + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + } + + @Override + public boolean process(ToOtaPackageStateServiceMsg msg) { + boolean isSuccess = false; + OtaPackageId targetOtaPackageId = new OtaPackageId(new UUID(msg.getOtaPackageIdMSB(), msg.getOtaPackageIdLSB())); + DeviceId deviceId = new DeviceId(new UUID(msg.getDeviceIdMSB(), msg.getDeviceIdLSB())); + TenantId tenantId = TenantId.fromUUID(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB())); + OtaPackageType firmwareType = OtaPackageType.valueOf(msg.getType()); + long ts = msg.getTs(); + + Device device = deviceService.findDeviceById(tenantId, deviceId); + if (device == null) { + log.warn("[{}] [{}] Device was removed during firmware update msg was queued!", tenantId, deviceId); + } else { + OtaPackageId currentOtaPackageId = OtaPackageUtil.getOtaPackageId(device, firmwareType); + if (currentOtaPackageId == null) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(tenantId, device.getDeviceProfileId()); + currentOtaPackageId = OtaPackageUtil.getOtaPackageId(deviceProfile, firmwareType); + } + + if (targetOtaPackageId.equals(currentOtaPackageId)) { + update(device, otaPackageService.findOtaPackageInfoById(device.getTenantId(), targetOtaPackageId), ts); + isSuccess = true; + } else { + log.warn("[{}] [{}] Can`t update firmware for the device, target firmwareId: [{}], current firmwareId: [{}]!", tenantId, deviceId, targetOtaPackageId, currentOtaPackageId); + } + } + return isSuccess; + } + + private void send(TenantId tenantId, DeviceId deviceId, OtaPackageId firmwareId, long ts, OtaPackageType firmwareType) { + ToOtaPackageStateServiceMsg msg = ToOtaPackageStateServiceMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setOtaPackageIdMSB(firmwareId.getId().getMostSignificantBits()) + .setOtaPackageIdLSB(firmwareId.getId().getLeastSignificantBits()) + .setType(firmwareType.name()) + .setTs(ts) + .build(); + + OtaPackageInfo firmware = otaPackageService.findOtaPackageInfoById(tenantId, firmwareId); + if (firmware == null) { + log.warn("[{}] Failed to send firmware update because firmware was already deleted", firmwareId); + return; + } + + TopicPartitionInfo tpi = new TopicPartitionInfo(otaPackageStateMsgProducer.getDefaultTopic(), null, null, false); + otaPackageStateMsgProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), msg), null); + + List telemetry = new ArrayList<>(); + telemetry.add(new BasicTsKvEntry(ts, new StringDataEntry(getTargetTelemetryKey(firmware.getType(), TITLE), firmware.getTitle()))); + telemetry.add(new BasicTsKvEntry(ts, new StringDataEntry(getTargetTelemetryKey(firmware.getType(), VERSION), firmware.getVersion()))); + + if (StringUtils.isNotEmpty(firmware.getTag())) { + telemetry.add(new BasicTsKvEntry(ts, new StringDataEntry(getTargetTelemetryKey(firmware.getType(), TAG), firmware.getTag()))); + } + + telemetry.add(new BasicTsKvEntry(ts, new LongDataEntry(getTargetTelemetryKey(firmware.getType(), TS), ts))); + telemetry.add(new BasicTsKvEntry(ts, new StringDataEntry(getTelemetryKey(firmware.getType(), STATE), OtaPackageUpdateStatus.QUEUED.name()))); + + telemetryService.saveAndNotify(tenantId, deviceId, telemetry, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + log.trace("[{}] Success save firmware status!", deviceId); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to save firmware status!", deviceId, t); + } + }); + } + + + private void update(Device device, OtaPackageInfo otaPackage, long ts) { + TenantId tenantId = device.getTenantId(); + DeviceId deviceId = device.getId(); + OtaPackageType otaPackageType = otaPackage.getType(); + + BasicTsKvEntry status = new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(getTelemetryKey(otaPackageType, STATE), OtaPackageUpdateStatus.INITIATED.name())); + + telemetryService.saveAndNotify(tenantId, deviceId, Collections.singletonList(status), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + log.trace("[{}] Success save telemetry with target {} for device!", deviceId, otaPackage); + updateAttributes(device, otaPackage, ts, tenantId, deviceId, otaPackageType); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to save telemetry with target {} for device!", deviceId, otaPackage, t); + updateAttributes(device, otaPackage, ts, tenantId, deviceId, otaPackageType); + } + }); + } + + private void updateAttributes(Device device, OtaPackageInfo otaPackage, long ts, TenantId tenantId, DeviceId deviceId, OtaPackageType otaPackageType) { + List attributes = new ArrayList<>(); + List attrToRemove = new ArrayList<>(); + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, TITLE), otaPackage.getTitle()))); + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, VERSION), otaPackage.getVersion()))); + if (StringUtils.isNotEmpty(otaPackage.getTag())) { + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, TAG), otaPackage.getTag()))); + } else { + attrToRemove.add(getAttributeKey(otaPackageType, TAG)); + } + if (otaPackage.hasUrl()) { + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, URL), otaPackage.getUrl()))); + + if (otaPackage.getDataSize() == null) { + attrToRemove.add(getAttributeKey(otaPackageType, SIZE)); + } else { + attributes.add(new BaseAttributeKvEntry(ts, new LongDataEntry(getAttributeKey(otaPackageType, SIZE), otaPackage.getDataSize()))); + } + + if (otaPackage.getChecksumAlgorithm() != null) { + attrToRemove.add(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM)); + } else { + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM), otaPackage.getChecksumAlgorithm().name()))); + } + + if (StringUtils.isEmpty(otaPackage.getChecksum())) { + attrToRemove.add(getAttributeKey(otaPackageType, CHECKSUM)); + } else { + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM), otaPackage.getChecksum()))); + } + } else { + attributes.add(new BaseAttributeKvEntry(ts, new LongDataEntry(getAttributeKey(otaPackageType, SIZE), otaPackage.getDataSize()))); + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM), otaPackage.getChecksumAlgorithm().name()))); + attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM), otaPackage.getChecksum()))); + attrToRemove.add(getAttributeKey(otaPackageType, URL)); + } + + remove(device, otaPackageType, attrToRemove); + + telemetryService.saveAndNotify(tenantId, deviceId, DataConstants.SHARED_SCOPE, attributes, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + log.trace("[{}] Success save attributes with target firmware!", deviceId); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to save attributes with target firmware!", deviceId, t); + } + }); + } + + private void remove(Device device, OtaPackageType otaPackageType) { + remove(device, otaPackageType, OtaPackageUtil.getAttributeKeys(otaPackageType)); + } + + private void remove(Device device, OtaPackageType otaPackageType, List attributesKeys) { + telemetryService.deleteAndNotify(device.getTenantId(), device.getId(), DataConstants.SHARED_SCOPE, attributesKeys, + new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Void tmp) { + log.trace("[{}] Success remove target {} attributes!", device.getId(), otaPackageType); + tbClusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(device.getTenantId(), device.getId(), DataConstants.SHARED_SCOPE, attributesKeys), null); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to remove target {} attributes!", device.getId(), otaPackageType, t); + } + }); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ota/OtaPackageStateService.java b/application/src/main/java/org/thingsboard/server/service/ota/OtaPackageStateService.java new file mode 100644 index 0000000..d6915b3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ota/OtaPackageStateService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ota; + +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; + +public interface OtaPackageStateService { + + void update(Device device, Device oldDevice); + + void update(DeviceProfile deviceProfile, boolean isFirmwareChanged, boolean isSoftwareChanged); + + boolean process(ToOtaPackageStateServiceMsg msg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/partition/AbstractPartitionBasedService.java b/application/src/main/java/org/thingsboard/server/service/partition/AbstractPartitionBasedService.java new file mode 100644 index 0000000..3056966 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/partition/AbstractPartitionBasedService.java @@ -0,0 +1,187 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.partition; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningScheduledExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; + +@Slf4j +public abstract class AbstractPartitionBasedService extends TbApplicationEventListener { + + protected final ConcurrentMap> partitionedEntities = new ConcurrentHashMap<>(); + protected final ConcurrentMap>> partitionedFetchTasks = new ConcurrentHashMap<>(); + final Queue> subscribeQueue = new ConcurrentLinkedQueue<>(); + + protected ListeningScheduledExecutorService scheduledExecutor; + + abstract protected String getServiceName(); + + abstract protected String getSchedulerExecutorName(); + + abstract protected Map>> onAddedPartitions(Set addedPartitions); + + abstract protected void cleanupEntityOnPartitionRemoval(T entityId); + + public Set getPartitionedEntities(TopicPartitionInfo tpi) { + return partitionedEntities.get(tpi); + } + + protected void init() { + // Should be always single threaded due to absence of locks. + scheduledExecutor = MoreExecutors.listeningDecorator(Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName(getSchedulerExecutorName()))); + } + + protected ServiceType getServiceType() { + return ServiceType.TB_CORE; + } + + protected void stop() { + if (scheduledExecutor != null) { + scheduledExecutor.shutdownNow(); + } + } + + /** + * DiscoveryService will call this event from the single thread (one-by-one). + * Events order is guaranteed by DiscoveryService. + * The only concurrency is expected from the [main] thread on Application started. + * Async implementation. Locks is not allowed by design. + * Any locks or delays in this module will affect DiscoveryService and entire system + */ + @Override + protected void onTbApplicationEvent(PartitionChangeEvent partitionChangeEvent) { + if (getServiceType().equals(partitionChangeEvent.getServiceType())) { + log.debug("onTbApplicationEvent, processing event: {}", partitionChangeEvent); + subscribeQueue.add(partitionChangeEvent.getPartitions()); + scheduledExecutor.submit(this::pollInitStateFromDB); + } + } + + protected void pollInitStateFromDB() { + final Set partitions = getLatestPartitions(); + if (partitions == null) { + log.debug("Nothing to do. Partitions are empty."); + return; + } + initStateFromDB(partitions); + } + + private void initStateFromDB(Set partitions) { + try { + log.info("[{}] CURRENT PARTITIONS: {}", getServiceName(), partitionedEntities.keySet()); + log.info("[{}] NEW PARTITIONS: {}", getServiceName(), partitions); + + Set addedPartitions = new HashSet<>(partitions); + addedPartitions.removeAll(partitionedEntities.keySet()); + + log.info("[{}] ADDED PARTITIONS: {}", getServiceName(), addedPartitions); + + Set removedPartitions = new HashSet<>(partitionedEntities.keySet()); + removedPartitions.removeAll(partitions); + + log.info("[{}] REMOVED PARTITIONS: {}", getServiceName(), removedPartitions); + + boolean partitionListChanged = false; + // We no longer manage current partition of entities; + for (var partition : removedPartitions) { + Set entities = partitionedEntities.remove(partition); + if (entities != null) { + entities.forEach(this::cleanupEntityOnPartitionRemoval); + } + List> fetchTasks = partitionedFetchTasks.remove(partition); + if (fetchTasks != null) { + fetchTasks.forEach(f -> f.cancel(false)); + } + partitionListChanged = true; + } + + onRepartitionEvent(); + + addedPartitions.forEach(tpi -> partitionedEntities.computeIfAbsent(tpi, key -> ConcurrentHashMap.newKeySet())); + + if (!addedPartitions.isEmpty()) { + var fetchTasks = onAddedPartitions(addedPartitions); + if (fetchTasks != null && !fetchTasks.isEmpty()) { + partitionedFetchTasks.putAll(fetchTasks); + } + partitionListChanged = true; + } + + if (partitionListChanged) { + List> partitionFetchFutures = new ArrayList<>(); + partitionedFetchTasks.values().forEach(partitionFetchFutures::addAll); + DonAsynchron.withCallback(Futures.allAsList(partitionFetchFutures), t -> logPartitions(), this::logFailure); + } + } catch (Throwable t) { + log.warn("[{}] Failed to init entities state from DB", getServiceName(), t); + } + } + + private void logFailure(Throwable e) { + if (e instanceof CancellationException) { + //Probably this is fine and happens due to re-balancing. + log.trace("Partition fetch task error", e); + } else { + log.error("Partition fetch task error", e); + } + + } + + private void logPartitions() { + log.info("[{}] Managing following partitions:", getServiceName()); + partitionedEntities.forEach((tpi, entities) -> { + log.info("[{}][{}]: {} entities", getServiceName(), tpi.getFullTopicName(), entities.size()); + }); + } + + protected void onRepartitionEvent() { + } + + private Set getLatestPartitions() { + log.debug("getLatestPartitionsFromQueue, queue size {}", subscribeQueue.size()); + Set partitions = null; + while (!subscribeQueue.isEmpty()) { + partitions = subscribeQueue.poll(); + log.debug("polled from the queue partitions {}", partitions); + } + log.debug("getLatestPartitionsFromQueue, partitions {}", partitions); + return partitions; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java new file mode 100644 index 0000000..23d0e1b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.profile; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +@Service +@Slf4j +public class DefaultTbAssetProfileCache implements TbAssetProfileCache { + + private final Lock assetProfileFetchLock = new ReentrantLock(); + private final AssetProfileService assetProfileService; + private final AssetService assetService; + + private final ConcurrentMap assetProfilesMap = new ConcurrentHashMap<>(); + private final ConcurrentMap assetsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap>> profileListeners = new ConcurrentHashMap<>(); + private final ConcurrentMap>> assetProfileListeners = new ConcurrentHashMap<>(); + + public DefaultTbAssetProfileCache(AssetProfileService assetProfileService, AssetService assetService) { + this.assetProfileService = assetProfileService; + this.assetService = assetService; + } + + @Override + public AssetProfile get(TenantId tenantId, AssetProfileId assetProfileId) { + AssetProfile profile = assetProfilesMap.get(assetProfileId); + if (profile == null) { + assetProfileFetchLock.lock(); + try { + profile = assetProfilesMap.get(assetProfileId); + if (profile == null) { + profile = assetProfileService.findAssetProfileById(tenantId, assetProfileId); + if (profile != null) { + assetProfilesMap.put(assetProfileId, profile); + log.debug("[{}] Fetch asset profile into cache: {}", profile.getId(), profile); + } + } + } finally { + assetProfileFetchLock.unlock(); + } + } + log.trace("[{}] Found asset profile in cache: {}", assetProfileId, profile); + return profile; + } + + @Override + public AssetProfile get(TenantId tenantId, AssetId assetId) { + AssetProfileId profileId = assetsMap.get(assetId); + if (profileId == null) { + Asset asset = assetService.findAssetById(tenantId, assetId); + if (asset != null) { + profileId = asset.getAssetProfileId(); + assetsMap.put(assetId, profileId); + } else { + return null; + } + } + return get(tenantId, profileId); + } + + @Override + public void evict(TenantId tenantId, AssetProfileId profileId) { + AssetProfile oldProfile = assetProfilesMap.remove(profileId); + log.debug("[{}] evict asset profile from cache: {}", profileId, oldProfile); + AssetProfile newProfile = get(tenantId, profileId); + if (newProfile != null) { + notifyProfileListeners(newProfile); + } + } + + @Override + public void evict(TenantId tenantId, AssetId assetId) { + AssetProfileId old = assetsMap.remove(assetId); + if (old != null) { + AssetProfile newProfile = get(tenantId, assetId); + if (newProfile == null || !old.equals(newProfile.getId())) { + notifyAssetListeners(tenantId, assetId, newProfile); + } + } + } + + @Override + public void addListener(TenantId tenantId, EntityId listenerId, + Consumer profileListener, + BiConsumer assetListener) { + if (profileListener != null) { + profileListeners.computeIfAbsent(tenantId, id -> new ConcurrentHashMap<>()).put(listenerId, profileListener); + } + if (assetListener != null) { + assetProfileListeners.computeIfAbsent(tenantId, id -> new ConcurrentHashMap<>()).put(listenerId, assetListener); + } + } + + @Override + public AssetProfile find(AssetProfileId assetProfileId) { + return assetProfileService.findAssetProfileById(TenantId.SYS_TENANT_ID, assetProfileId); + } + + @Override + public AssetProfile findOrCreateAssetProfile(TenantId tenantId, String profileName) { + return assetProfileService.findOrCreateAssetProfile(tenantId, profileName); + } + + @Override + public void removeListener(TenantId tenantId, EntityId listenerId) { + ConcurrentMap> tenantListeners = profileListeners.get(tenantId); + if (tenantListeners != null) { + tenantListeners.remove(listenerId); + } + ConcurrentMap> assetListeners = assetProfileListeners.get(tenantId); + if (assetListeners != null) { + assetListeners.remove(listenerId); + } + } + + private void notifyProfileListeners(AssetProfile profile) { + ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); + if (tenantListeners != null) { + tenantListeners.forEach((id, listener) -> listener.accept(profile)); + } + } + + private void notifyAssetListeners(TenantId tenantId, AssetId assetId, AssetProfile profile) { + if (profile != null) { + ConcurrentMap> tenantListeners = assetProfileListeners.get(tenantId); + if (tenantListeners != null) { + tenantListeners.forEach((id, listener) -> listener.accept(assetId, profile)); + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java new file mode 100644 index 0000000..8ec29ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.profile; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +@Service +@Slf4j +public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { + + private final Lock deviceProfileFetchLock = new ReentrantLock(); + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + + private final ConcurrentMap deviceProfilesMap = new ConcurrentHashMap<>(); + private final ConcurrentMap devicesMap = new ConcurrentHashMap<>(); + private final ConcurrentMap>> profileListeners = new ConcurrentHashMap<>(); + private final ConcurrentMap>> deviceProfileListeners = new ConcurrentHashMap<>(); + + public DefaultTbDeviceProfileCache(DeviceProfileService deviceProfileService, DeviceService deviceService) { + this.deviceProfileService = deviceProfileService; + this.deviceService = deviceService; + } + + @Override + public DeviceProfile get(TenantId tenantId, DeviceProfileId deviceProfileId) { + DeviceProfile profile = deviceProfilesMap.get(deviceProfileId); + if (profile == null) { + deviceProfileFetchLock.lock(); + try { + profile = deviceProfilesMap.get(deviceProfileId); + if (profile == null) { + profile = deviceProfileService.findDeviceProfileById(tenantId, deviceProfileId); + if (profile != null) { + deviceProfilesMap.put(deviceProfileId, profile); + log.debug("[{}] Fetch device profile into cache: {}", profile.getId(), profile); + } + } + } finally { + deviceProfileFetchLock.unlock(); + } + } + log.trace("[{}] Found device profile in cache: {}", deviceProfileId, profile); + return profile; + } + + @Override + public DeviceProfile get(TenantId tenantId, DeviceId deviceId) { + DeviceProfileId profileId = devicesMap.get(deviceId); + if (profileId == null) { + Device device = deviceService.findDeviceById(tenantId, deviceId); + if (device != null) { + profileId = device.getDeviceProfileId(); + devicesMap.put(deviceId, profileId); + } else { + return null; + } + } + return get(tenantId, profileId); + } + + @Override + public void evict(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile oldProfile = deviceProfilesMap.remove(profileId); + log.debug("[{}] evict device profile from cache: {}", profileId, oldProfile); + DeviceProfile newProfile = get(tenantId, profileId); + if (newProfile != null) { + notifyProfileListeners(newProfile); + } + } + + @Override + public void evict(TenantId tenantId, DeviceId deviceId) { + DeviceProfileId old = devicesMap.remove(deviceId); + if (old != null) { + DeviceProfile newProfile = get(tenantId, deviceId); + if (newProfile == null || !old.equals(newProfile.getId())) { + notifyDeviceListeners(tenantId, deviceId, newProfile); + } + } + } + + @Override + public void addListener(TenantId tenantId, EntityId listenerId, + Consumer profileListener, + BiConsumer deviceListener) { + if (profileListener != null) { + profileListeners.computeIfAbsent(tenantId, id -> new ConcurrentHashMap<>()).put(listenerId, profileListener); + } + if (deviceListener != null) { + deviceProfileListeners.computeIfAbsent(tenantId, id -> new ConcurrentHashMap<>()).put(listenerId, deviceListener); + } + } + + @Override + public DeviceProfile find(DeviceProfileId deviceProfileId) { + return deviceProfileService.findDeviceProfileById(TenantId.SYS_TENANT_ID, deviceProfileId); + } + + @Override + public DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName) { + return deviceProfileService.findOrCreateDeviceProfile(tenantId, profileName); + } + + @Override + public void removeListener(TenantId tenantId, EntityId listenerId) { + ConcurrentMap> tenantListeners = profileListeners.get(tenantId); + if (tenantListeners != null) { + tenantListeners.remove(listenerId); + } + ConcurrentMap> deviceListeners = deviceProfileListeners.get(tenantId); + if (deviceListeners != null) { + deviceListeners.remove(listenerId); + } + } + + private void notifyProfileListeners(DeviceProfile profile) { + ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); + if (tenantListeners != null) { + tenantListeners.forEach((id, listener) -> listener.accept(profile)); + } + } + + private void notifyDeviceListeners(TenantId tenantId, DeviceId deviceId, DeviceProfile profile) { + if (profile != null) { + ConcurrentMap> tenantListeners = deviceProfileListeners.get(tenantId); + if (tenantListeners != null) { + tenantListeners.forEach((id, listener) -> listener.accept(deviceId, profile)); + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/profile/TbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/TbAssetProfileCache.java new file mode 100644 index 0000000..6c332e6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/profile/TbAssetProfileCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.profile; + +import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbAssetProfileCache extends RuleEngineAssetProfileCache { + + void evict(TenantId tenantId, AssetProfileId id); + + void evict(TenantId tenantId, AssetId id); + + AssetProfile find(AssetProfileId assetProfileId); + + AssetProfile findOrCreateAssetProfile(TenantId tenantId, String assetType); +} diff --git a/application/src/main/java/org/thingsboard/server/service/profile/TbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/TbDeviceProfileCache.java new file mode 100644 index 0000000..03fc3e3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/profile/TbDeviceProfileCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.profile; + +import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbDeviceProfileCache extends RuleEngineDeviceProfileCache { + + void evict(TenantId tenantId, DeviceProfileId id); + + void evict(TenantId tenantId, DeviceId id); + + DeviceProfile find(DeviceProfileId deviceProfileId); + + DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String deviceType); +} diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java new file mode 100644 index 0000000..d2a6369 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -0,0 +1,318 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.KvUtil; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.FilterPredicateType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.SimpleKeyFilterPredicate; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +@Slf4j +@TbCoreComponent +public class DefaultEntityQueryService implements EntityQueryService { + + @Autowired + private EntityService entityService; + + @Autowired + private AlarmService alarmService; + + @Value("${server.ws.max_entities_per_alarm_subscription:1000}") + private int maxEntitiesPerAlarmSubscription; + + @Autowired + private DbCallbackExecutorService dbCallbackExecutor; + + @Autowired + private TimeseriesService timeseriesService; + + @Autowired + private AttributesService attributesService; + + @Override + public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) { + return entityService.countEntitiesByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); + } + + @Override + public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) { + if (query.getKeyFilters() != null) { + resolveDynamicValuesInPredicates( + query.getKeyFilters().stream() + .map(KeyFilter::getPredicate) + .collect(Collectors.toList()), + securityUser + ); + } + return entityService.findEntityDataByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); + } + + private void resolveDynamicValuesInPredicates(List predicates, SecurityUser user) { + predicates.forEach(predicate -> { + if (predicate.getType() == FilterPredicateType.COMPLEX) { + resolveDynamicValuesInPredicates( + ((ComplexFilterPredicate) predicate).getPredicates(), + user + ); + } else { + setResolvedValue(user, (SimpleKeyFilterPredicate) predicate); + } + }); + } + + private void setResolvedValue(SecurityUser user, SimpleKeyFilterPredicate predicate) { + DynamicValue dynamicValue = predicate.getValue().getDynamicValue(); + if (dynamicValue != null && dynamicValue.getResolvedValue() == null) { + resolveDynamicValue(dynamicValue, user, predicate.getType()); + } + } + + private void resolveDynamicValue(DynamicValue dynamicValue, SecurityUser user, FilterPredicateType predicateType) { + EntityId entityId; + switch (dynamicValue.getSourceType()) { + case CURRENT_TENANT: + entityId = user.getTenantId(); + break; + case CURRENT_CUSTOMER: + entityId = user.getCustomerId(); + break; + case CURRENT_USER: + entityId = user.getId(); + break; + default: + throw new RuntimeException("Not supported operation for source type: {" + dynamicValue.getSourceType() + "}"); + } + + try { + Optional valueOpt = attributesService.find(user.getTenantId(), entityId, + TbAttributeSubscriptionScope.SERVER_SCOPE.name(), dynamicValue.getSourceAttribute()).get(); + + if (valueOpt.isPresent()) { + AttributeKvEntry entry = valueOpt.get(); + Object resolved = null; + switch (predicateType) { + case STRING: + resolved = KvUtil.getStringValue(entry); + break; + case NUMERIC: + resolved = KvUtil.getDoubleValue(entry); + break; + case BOOLEAN: + resolved = KvUtil.getBoolValue(entry); + break; + case COMPLEX: + break; + } + + dynamicValue.setResolvedValue((T) resolved); + } + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + + @Override + public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) { + EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); + PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), + securityUser.getCustomerId(), entityDataQuery); + if (entities.getTotalElements() > 0) { + LinkedHashMap entitiesMap = new LinkedHashMap<>(); + for (EntityData entityData : entities.getData()) { + entitiesMap.put(entityData.getEntityId(), entityData); + } + PageData alarms = alarmService.findAlarmDataByQueryForEntities(securityUser.getTenantId(), query, entitiesMap.keySet()); + for (AlarmData alarmData : alarms.getData()) { + EntityId entityId = alarmData.getEntityId(); + if (entityId != null) { + EntityData entityData = entitiesMap.get(entityId); + if (entityData != null) { + alarmData.getLatest().putAll(entityData.getLatest()); + } + } + } + return alarms; + } else { + return new PageData<>(); + } + } + + private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { + EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); + EntityDataSortOrder entitiesSortOrder; + if (sortOrder == null || sortOrder.getKey().getType().equals(EntityKeyType.ALARM_FIELD)) { + entitiesSortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)); + } else { + entitiesSortOrder = sortOrder; + } + EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + } + + @Override + public DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, + boolean isTimeseries, boolean isAttributes) { + final DeferredResult response = new DeferredResult<>(); + if (!isAttributes && !isTimeseries) { + replyWithEmptyResponse(response); + return response; + } + + List ids = this.findEntityDataByQuery(securityUser, query).getData().stream() + .map(EntityData::getEntityId) + .collect(Collectors.toList()); + if (ids.isEmpty()) { + replyWithEmptyResponse(response); + return response; + } + + Set types = ids.stream().map(EntityId::getEntityType).collect(Collectors.toSet()); + final ListenableFuture> timeseriesKeysFuture; + final ListenableFuture> attributesKeysFuture; + + if (isTimeseries) { + timeseriesKeysFuture = dbCallbackExecutor.submit(() -> timeseriesService.findAllKeysByEntityIds(tenantId, ids)); + } else { + timeseriesKeysFuture = null; + } + + if (isAttributes) { + Map> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); + List>> futures = new ArrayList<>(typesMap.size()); + typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, type, entityIds)))); + attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { + if (CollectionUtils.isEmpty(lists)) { + return Collections.emptyList(); + } + return lists.stream().flatMap(List::stream).distinct().sorted().collect(Collectors.toList()); + }, dbCallbackExecutor); + } else { + attributesKeysFuture = null; + } + + if (isTimeseries && isAttributes) { + Futures.whenAllComplete(timeseriesKeysFuture, attributesKeysFuture).run(() -> { + try { + replyWithResponse(response, types, timeseriesKeysFuture.get(), attributesKeysFuture.get()); + } catch (Exception e) { + log.error("Failed to fetch timeseries and attributes keys!", e); + AccessValidator.handleError(e, response, HttpStatus.INTERNAL_SERVER_ERROR); + } + }, dbCallbackExecutor); + } else if (isTimeseries) { + addCallback(timeseriesKeysFuture, keys -> replyWithResponse(response, types, keys, null), + error -> { + log.error("Failed to fetch timeseries keys!", error); + AccessValidator.handleError(error, response, HttpStatus.INTERNAL_SERVER_ERROR); + }); + } else { + addCallback(attributesKeysFuture, keys -> replyWithResponse(response, types, null, keys), + error -> { + log.error("Failed to fetch attributes keys!", error); + AccessValidator.handleError(error, response, HttpStatus.INTERNAL_SERVER_ERROR); + }); + } + return response; + } + + private void replyWithResponse(DeferredResult response, Set types, List timeseriesKeys, List attributesKeys) { + ObjectNode json = JacksonUtil.newObjectNode(); + addItemsToArrayNode(json.putArray("entityTypes"), types); + addItemsToArrayNode(json.putArray("timeseries"), timeseriesKeys); + addItemsToArrayNode(json.putArray("attribute"), attributesKeys); + response.setResult(new ResponseEntity<>(json, HttpStatus.OK)); + } + + private void replyWithEmptyResponse(DeferredResult response) { + replyWithResponse(response, Collections.emptySet(), Collections.emptyList(), Collections.emptyList()); + } + + private void addItemsToArrayNode(ArrayNode arrayNode, Collection collection) { + if (!CollectionUtils.isEmpty(collection)) { + collection.forEach(item -> arrayNode.add(item.toString())); + } + } + + private void addCallback(ListenableFuture> future, Consumer> success, Consumer error) { + Futures.addCallback(future, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List keys) { + success.accept(keys); + } + + @Override + public void onFailure(Throwable t) { + error.accept(t); + } + }, dbCallbackExecutor); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java new file mode 100644 index 0000000..a2f1efb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface EntityQueryService { + + long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); + + PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query); + + PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query); + + DeferredResult getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, + boolean isTimeseries, boolean isAttributes); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultQueueRoutingInfoService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultQueueRoutingInfoService.java new file mode 100644 index 0000000..d7a333a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultQueueRoutingInfoService.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.discovery.QueueRoutingInfo; +import org.thingsboard.server.queue.discovery.QueueRoutingInfoService; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core' || '${service.type:null}'=='tb-rule-engine'") +public class DefaultQueueRoutingInfoService implements QueueRoutingInfoService { + + private final QueueService queueService; + + public DefaultQueueRoutingInfoService(QueueService queueService) { + this.queueService = queueService; + } + + @Override + public List getAllQueuesRoutingInfo() { + return queueService.findAllQueues().stream().map(QueueRoutingInfo::new).collect(Collectors.toList()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java new file mode 100644 index 0000000..e2779ba --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -0,0 +1,608 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import com.google.protobuf.ByteString; +import lombok.RequiredArgsConstructor; +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.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.msg.DeviceEdgeUpdateMsg; +import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.FromDeviceRPCResponseProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.MultipleTbQueueCallbackWrapper; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.NotificationsTopicService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; +import org.thingsboard.server.service.ota.OtaPackageStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DefaultTbClusterService implements TbClusterService { + + @Value("${cluster.stats.enabled:false}") + private boolean statsEnabled; + @Value("${edges.enabled:true}") + protected boolean edgesEnabled; + + private final AtomicInteger toCoreMsgs = new AtomicInteger(0); + private final AtomicInteger toCoreNfs = new AtomicInteger(0); + private final AtomicInteger toRuleEngineMsgs = new AtomicInteger(0); + private final AtomicInteger toRuleEngineNfs = new AtomicInteger(0); + private final AtomicInteger toTransportNfs = new AtomicInteger(0); + + @Autowired + @Lazy + private PartitionService partitionService; + + @Autowired + @Lazy + private TbQueueProducerProvider producerProvider; + + @Autowired + @Lazy + private OtaPackageStateService otaPackageStateService; + + private final NotificationsTopicService notificationsTopicService; + private final DataDecodingEncodingService encodingService; + private final TbDeviceProfileCache deviceProfileCache; + private final TbAssetProfileCache assetProfileCache; + private final GatewayNotificationsService gatewayNotificationsService; + + @Override + public void pushMsgToCore(TenantId tenantId, EntityId entityId, ToCoreMsg msg, TbQueueCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + producerProvider.getTbCoreMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), msg), callback); + toCoreMsgs.incrementAndGet(); + } + + @Override + public void pushMsgToCore(TopicPartitionInfo tpi, UUID msgId, ToCoreMsg msg, TbQueueCallback callback) { + producerProvider.getTbCoreMsgProducer().send(tpi, new TbProtoQueueMsg<>(msgId, msg), callback); + toCoreMsgs.incrementAndGet(); + } + + @Override + public void pushMsgToCore(ToDeviceActorNotificationMsg msg, TbQueueCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, msg.getTenantId(), msg.getDeviceId()); + log.trace("PUSHING msg: {} to:{}", msg, tpi); + byte[] msgBytes = encodingService.encode(msg); + ToCoreMsg toCoreMsg = ToCoreMsg.newBuilder().setToDeviceActorNotificationMsg(ByteString.copyFrom(msgBytes)).build(); + producerProvider.getTbCoreMsgProducer().send(tpi, new TbProtoQueueMsg<>(msg.getDeviceId().getId(), toCoreMsg), callback); + toCoreMsgs.incrementAndGet(); + } + + @Override + public void pushMsgToVersionControl(TenantId tenantId, TransportProtos.ToVersionControlServiceMsg msg, TbQueueCallback callback) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_VC_EXECUTOR, tenantId, tenantId); + log.trace("PUSHING msg: {} to:{}", msg, tpi); + producerProvider.getTbVersionControlMsgProducer().send(tpi, new TbProtoQueueMsg<>(tenantId.getId(), msg), callback); + //TODO: ashvayka + toCoreMsgs.incrementAndGet(); + } + + @Override + public void pushNotificationToCore(String serviceId, FromDeviceRpcResponse response, TbQueueCallback callback) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, serviceId); + log.trace("PUSHING msg: {} to:{}", response, tpi); + FromDeviceRPCResponseProto.Builder builder = FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(response.getId().getMostSignificantBits()) + .setRequestIdLSB(response.getId().getLeastSignificantBits()) + .setError(response.getError().isPresent() ? response.getError().get().ordinal() : -1); + response.getResponse().ifPresent(builder::setResponse); + ToCoreNotificationMsg msg = ToCoreNotificationMsg.newBuilder().setFromDeviceRpcResponse(builder).build(); + producerProvider.getTbCoreNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(response.getId(), msg), callback); + toCoreNfs.incrementAndGet(); + } + + @Override + public void pushMsgToRuleEngine(TopicPartitionInfo tpi, UUID msgId, ToRuleEngineMsg msg, TbQueueCallback callback) { + log.trace("PUSHING msg: {} to:{}", msg, tpi); + producerProvider.getRuleEngineMsgProducer().send(tpi, new TbProtoQueueMsg<>(msgId, msg), callback); + toRuleEngineMsgs.incrementAndGet(); + } + + @Override + public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbMsg tbMsg, TbQueueCallback callback) { + if (tenantId == null || tenantId.isNullUid()) { + if (entityId.getEntityType().equals(EntityType.TENANT)) { + tenantId = TenantId.fromUUID(entityId.getId()); + } else { + log.warn("[{}][{}] Received invalid message: {}", tenantId, entityId, tbMsg); + return; + } + } else { + if (entityId.getEntityType().equals(EntityType.DEVICE)) { + tbMsg = transformMsg(tbMsg, deviceProfileCache.get(tenantId, new DeviceId(entityId.getId()))); + } else if (entityId.getEntityType().equals(EntityType.DEVICE_PROFILE)) { + tbMsg = transformMsg(tbMsg, deviceProfileCache.get(tenantId, new DeviceProfileId(entityId.getId()))); + } else if (entityId.getEntityType().equals(EntityType.ASSET)) { + tbMsg = transformMsg(tbMsg, assetProfileCache.get(tenantId, new AssetId(entityId.getId()))); + } else if (entityId.getEntityType().equals(EntityType.ASSET_PROFILE)) { + tbMsg = transformMsg(tbMsg, assetProfileCache.get(tenantId, new AssetProfileId(entityId.getId()))); + } + } + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, tbMsg.getQueueName(), tenantId, entityId); + log.trace("PUSHING msg: {} to:{}", tbMsg, tpi); + ToRuleEngineMsg msg = ToRuleEngineMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTbMsg(TbMsg.toByteString(tbMsg)).build(); + producerProvider.getRuleEngineMsgProducer().send(tpi, new TbProtoQueueMsg<>(tbMsg.getId(), msg), callback); + toRuleEngineMsgs.incrementAndGet(); + } + + private TbMsg transformMsg(TbMsg tbMsg, DeviceProfile deviceProfile) { + if (deviceProfile != null) { + RuleChainId targetRuleChainId = deviceProfile.getDefaultRuleChainId(); + String targetQueueName = deviceProfile.getDefaultQueueName(); + tbMsg = transformMsg(tbMsg, targetRuleChainId, targetQueueName); + } + return tbMsg; + } + + private TbMsg transformMsg(TbMsg tbMsg, AssetProfile assetProfile) { + if (assetProfile != null) { + RuleChainId targetRuleChainId = assetProfile.getDefaultRuleChainId(); + String targetQueueName = assetProfile.getDefaultQueueName(); + tbMsg = transformMsg(tbMsg, targetRuleChainId, targetQueueName); + } + return tbMsg; + } + + private TbMsg transformMsg(TbMsg tbMsg, RuleChainId targetRuleChainId, String targetQueueName) { + boolean isRuleChainTransform = targetRuleChainId != null && !targetRuleChainId.equals(tbMsg.getRuleChainId()); + boolean isQueueTransform = targetQueueName != null && !targetQueueName.equals(tbMsg.getQueueName()); + + if (isRuleChainTransform && isQueueTransform) { + tbMsg = TbMsg.transformMsg(tbMsg, targetRuleChainId, targetQueueName); + } else if (isRuleChainTransform) { + tbMsg = TbMsg.transformMsg(tbMsg, targetRuleChainId); + } else if (isQueueTransform) { + tbMsg = TbMsg.transformMsg(tbMsg, targetQueueName); + } + return tbMsg; + } + + @Override + public void pushNotificationToRuleEngine(String serviceId, FromDeviceRpcResponse response, TbQueueCallback callback) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); + log.trace("PUSHING msg: {} to:{}", response, tpi); + FromDeviceRPCResponseProto.Builder builder = FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(response.getId().getMostSignificantBits()) + .setRequestIdLSB(response.getId().getLeastSignificantBits()) + .setError(response.getError().isPresent() ? response.getError().get().ordinal() : -1); + response.getResponse().ifPresent(builder::setResponse); + ToRuleEngineNotificationMsg msg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(builder).build(); + producerProvider.getRuleEngineNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(response.getId(), msg), callback); + toRuleEngineNfs.incrementAndGet(); + } + + @Override + public void pushNotificationToTransport(String serviceId, ToTransportMsg response, TbQueueCallback callback) { + if (serviceId == null || serviceId.isEmpty()) { + log.trace("pushNotificationToTransport: skipping message without serviceId [{}], (ToTransportMsg) response [{}]", serviceId, response); + if (callback != null) { + callback.onSuccess(null); //callback that message already sent, no useful payload expected + } + return; + } + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, serviceId); + log.trace("PUSHING msg: {} to:{}", response, tpi); + producerProvider.getTransportNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), response), callback); + toTransportNfs.incrementAndGet(); + } + + @Override + public void broadcastEntityStateChangeEvent(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent state) { + log.trace("[{}] Processing {} state change event: {}", tenantId, entityId.getEntityType(), state); + broadcast(new ComponentLifecycleMsg(tenantId, entityId, state)); + } + + @Override + public void onDeviceProfileChange(DeviceProfile deviceProfile, TbQueueCallback callback) { + broadcastEntityChangeToTransport(deviceProfile.getTenantId(), deviceProfile.getId(), deviceProfile, callback); + } + + @Override + public void onTenantProfileChange(TenantProfile tenantProfile, TbQueueCallback callback) { + broadcastEntityChangeToTransport(TenantId.SYS_TENANT_ID, tenantProfile.getId(), tenantProfile, callback); + } + + @Override + public void onTenantChange(Tenant tenant, TbQueueCallback callback) { + broadcastEntityChangeToTransport(TenantId.SYS_TENANT_ID, tenant.getId(), tenant, callback); + } + + @Override + public void onApiStateChange(ApiUsageState apiUsageState, TbQueueCallback callback) { + broadcastEntityChangeToTransport(apiUsageState.getTenantId(), apiUsageState.getId(), apiUsageState, callback); + broadcast(new ComponentLifecycleMsg(apiUsageState.getTenantId(), apiUsageState.getId(), ComponentLifecycleEvent.UPDATED)); + } + + @Override + public void onDeviceProfileDelete(DeviceProfile entity, TbQueueCallback callback) { + broadcastEntityDeleteToTransport(entity.getTenantId(), entity.getId(), entity.getName(), callback); + } + + @Override + public void onTenantProfileDelete(TenantProfile entity, TbQueueCallback callback) { + broadcastEntityDeleteToTransport(TenantId.SYS_TENANT_ID, entity.getId(), entity.getName(), callback); + } + + @Override + public void onTenantDelete(Tenant entity, TbQueueCallback callback) { + broadcastEntityDeleteToTransport(TenantId.SYS_TENANT_ID, entity.getId(), entity.getName(), callback); + } + + @Override + public void onDeviceDeleted(Device device, TbQueueCallback callback) { + broadcastEntityDeleteToTransport(device.getTenantId(), device.getId(), device.getName(), callback); + sendDeviceStateServiceEvent(device.getTenantId(), device.getId(), false, false, true); + broadcastEntityStateChangeEvent(device.getTenantId(), device.getId(), ComponentLifecycleEvent.DELETED); + } + + @Override + public void onResourceChange(TbResource resource, TbQueueCallback callback) { + TenantId tenantId = resource.getTenantId(); + log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); + TransportProtos.ResourceUpdateMsg resourceUpdateMsg = TransportProtos.ResourceUpdateMsg.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setResourceType(resource.getResourceType().name()) + .setResourceKey(resource.getResourceKey()) + .build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); + broadcast(transportMsg, callback); + } + + @Override + public void onResourceDeleted(TbResource resource, TbQueueCallback callback) { + log.trace("[{}] Processing delete resource", resource); + TransportProtos.ResourceDeleteMsg resourceUpdateMsg = TransportProtos.ResourceDeleteMsg.newBuilder() + .setTenantIdMSB(resource.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(resource.getTenantId().getId().getLeastSignificantBits()) + .setResourceType(resource.getResourceType().name()) + .setResourceKey(resource.getResourceKey()) + .build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceUpdateMsg).build(); + broadcast(transportMsg, callback); + } + + public void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) { + String entityName = (entity instanceof HasName) ? ((HasName) entity).getName() : entity.getClass().getName(); + log.trace("[{}][{}][{}] Processing [{}] change event", tenantId, entityid.getEntityType(), entityid.getId(), entityName); + TransportProtos.EntityUpdateMsg entityUpdateMsg = TransportProtos.EntityUpdateMsg.newBuilder() + .setEntityType(entityid.getEntityType().name()) + .setData(ByteString.copyFrom(encodingService.encode(entity))).build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setEntityUpdateMsg(entityUpdateMsg).build(); + broadcast(transportMsg, callback); + } + + private void broadcastEntityDeleteToTransport(TenantId tenantId, EntityId entityId, String name, TbQueueCallback callback) { + log.trace("[{}][{}][{}] Processing [{}] delete event", tenantId, entityId.getEntityType(), entityId.getId(), name); + TransportProtos.EntityDeleteMsg entityDeleteMsg = TransportProtos.EntityDeleteMsg.newBuilder() + .setEntityType(entityId.getEntityType().name()) + .setEntityIdMSB(entityId.getId().getMostSignificantBits()) + .setEntityIdLSB(entityId.getId().getLeastSignificantBits()) + .build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setEntityDeleteMsg(entityDeleteMsg).build(); + broadcast(transportMsg, callback); + } + + private void broadcast(ToTransportMsg transportMsg, TbQueueCallback callback) { + TbQueueProducer> toTransportNfProducer = producerProvider.getTransportNotificationsMsgProducer(); + Set tbTransportServices = partitionService.getAllServiceIds(ServiceType.TB_TRANSPORT); + TbQueueCallback proxyCallback = callback != null ? new MultipleTbQueueCallbackWrapper(tbTransportServices.size(), callback) : null; + for (String transportServiceId : tbTransportServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, transportServiceId); + toTransportNfProducer.send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), transportMsg), proxyCallback); + toTransportNfs.incrementAndGet(); + } + } + + @Override + public void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) { + log.trace("[{}] Processing edge {} event update ", tenantId, edgeId); + EdgeEventUpdateMsg msg = new EdgeEventUpdateMsg(tenantId, edgeId); + byte[] msgBytes = encodingService.encode(msg); + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setEdgeEventUpdateMsg(ByteString.copyFrom(msgBytes)).build(); + pushEdgeSyncMsgToCore(edgeId, toCoreMsg); + } + + @Override + public void pushEdgeSyncRequestToCore(ToEdgeSyncRequest toEdgeSyncRequest) { + log.trace("[{}] Processing edge sync request {} ", toEdgeSyncRequest.getTenantId(), toEdgeSyncRequest); + byte[] msgBytes = encodingService.encode(toEdgeSyncRequest); + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setToEdgeSyncRequestMsg(ByteString.copyFrom(msgBytes)).build(); + pushEdgeSyncMsgToCore(toEdgeSyncRequest.getEdgeId(), toCoreMsg); + } + + @Override + public void pushEdgeSyncResponseToCore(FromEdgeSyncResponse fromEdgeSyncResponse) { + log.trace("[{}] Processing edge sync response {}", fromEdgeSyncResponse.getTenantId(), fromEdgeSyncResponse); + byte[] msgBytes = encodingService.encode(fromEdgeSyncResponse); + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setFromEdgeSyncResponseMsg(ByteString.copyFrom(msgBytes)).build(); + pushEdgeSyncMsgToCore(fromEdgeSyncResponse.getEdgeId(), toCoreMsg); + } + + private void pushEdgeSyncMsgToCore(EdgeId edgeId, ToCoreNotificationMsg toCoreMsg) { + TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); + Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); + for (String serviceId : tbCoreServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, serviceId); + toCoreNfProducer.send(tpi, new TbProtoQueueMsg<>(edgeId.getId(), toCoreMsg), null); + toCoreNfs.incrementAndGet(); + } + } + + private void broadcast(ComponentLifecycleMsg msg) { + byte[] msgBytes = encodingService.encode(msg); + TbQueueProducer> toRuleEngineProducer = producerProvider.getRuleEngineNotificationsMsgProducer(); + Set tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); + EntityType entityType = msg.getEntityId().getEntityType(); + if (entityType.equals(EntityType.TENANT) + || entityType.equals(EntityType.TENANT_PROFILE) + || entityType.equals(EntityType.DEVICE_PROFILE) + || entityType.equals(EntityType.ASSET_PROFILE) + || entityType.equals(EntityType.API_USAGE_STATE) + || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) + || entityType.equals(EntityType.ENTITY_VIEW) + || entityType.equals(EntityType.EDGE)) { + TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); + Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); + for (String serviceId : tbCoreServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, serviceId); + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setComponentLifecycleMsg(ByteString.copyFrom(msgBytes)).build(); + toCoreNfProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toCoreMsg), null); + toCoreNfs.incrementAndGet(); + } + // No need to push notifications twice + tbRuleEngineServices.removeAll(tbCoreServices); + } + for (String serviceId : tbRuleEngineServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, serviceId); + ToRuleEngineNotificationMsg toRuleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setComponentLifecycleMsg(ByteString.copyFrom(msgBytes)).build(); + toRuleEngineProducer.send(tpi, new TbProtoQueueMsg<>(msg.getEntityId().getId(), toRuleEngineMsg), null); + toRuleEngineNfs.incrementAndGet(); + } + } + + @Scheduled(fixedDelayString = "${cluster.stats.print_interval_ms}") + public void printStats() { + if (statsEnabled) { + int toCoreMsgCnt = toCoreMsgs.getAndSet(0); + int toCoreNfsCnt = toCoreNfs.getAndSet(0); + int toRuleEngineMsgsCnt = toRuleEngineMsgs.getAndSet(0); + int toRuleEngineNfsCnt = toRuleEngineNfs.getAndSet(0); + int toTransportNfsCnt = toTransportNfs.getAndSet(0); + if (toCoreMsgCnt > 0 || toCoreNfsCnt > 0 || toRuleEngineMsgsCnt > 0 || toRuleEngineNfsCnt > 0 || toTransportNfsCnt > 0) { + log.info("To TbCore: [{}] messages [{}] notifications; To TbRuleEngine: [{}] messages [{}] notifications; To Transport: [{}] notifications", + toCoreMsgCnt, toCoreNfsCnt, toRuleEngineMsgsCnt, toRuleEngineNfsCnt, toTransportNfsCnt); + } + } + } + + private void sendDeviceStateServiceEvent(TenantId tenantId, DeviceId deviceId, boolean added, boolean updated, boolean deleted) { + TransportProtos.DeviceStateServiceMsgProto.Builder builder = TransportProtos.DeviceStateServiceMsgProto.newBuilder(); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setDeviceIdMSB(deviceId.getId().getMostSignificantBits()); + builder.setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()); + builder.setAdded(added); + builder.setUpdated(updated); + builder.setDeleted(deleted); + TransportProtos.DeviceStateServiceMsgProto msg = builder.build(); + pushMsgToCore(tenantId, deviceId, TransportProtos.ToCoreMsg.newBuilder().setDeviceStateServiceMsg(msg).build(), null); + } + + @Override + public void onDeviceUpdated(Device device, Device old) { + onDeviceUpdated(device, old, true); + } + + @Override + public void onDeviceUpdated(Device device, Device old, boolean notifyEdge) { + var created = old == null; + broadcastEntityChangeToTransport(device.getTenantId(), device.getId(), device, null); + if (old != null) { + boolean deviceNameChanged = !device.getName().equals(old.getName()); + if (deviceNameChanged) { + gatewayNotificationsService.onDeviceUpdated(device, old); + } + if (deviceNameChanged || !device.getType().equals(old.getType())) { + pushMsgToCore(new DeviceNameOrTypeUpdateMsg(device.getTenantId(), device.getId(), device.getName(), device.getType()), null); + } + } + broadcastEntityStateChangeEvent(device.getTenantId(), device.getId(), created ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + sendDeviceStateServiceEvent(device.getTenantId(), device.getId(), created, !created, false); + otaPackageStateService.update(device, old); + if (!created && notifyEdge) { + sendNotificationMsgToEdge(device.getTenantId(), null, device.getId(), null, null, EdgeEventActionType.UPDATED); + } + } + + @Override + public void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action) { + if (!edgesEnabled) { + return; + } + if (type == null) { + if (entityId != null) { + type = EdgeUtils.getEdgeEventTypeByEntityType(entityId.getEntityType()); + } else { + log.trace("[{}] entity id and type are null. Ignoring this notification", tenantId); + return; + } + if (type == null) { + log.trace("[{}] edge event type is null. Ignoring this notification [{}]", tenantId, entityId); + return; + } + } + TransportProtos.EdgeNotificationMsgProto.Builder builder = TransportProtos.EdgeNotificationMsgProto.newBuilder(); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setType(type.name()); + builder.setAction(action.name()); + if (entityId != null) { + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setEntityType(entityId.getEntityType().name()); + } + if (edgeId != null) { + builder.setEdgeIdMSB(edgeId.getId().getMostSignificantBits()); + builder.setEdgeIdLSB(edgeId.getId().getLeastSignificantBits()); + } + if (body != null) { + builder.setBody(body); + } + TransportProtos.EdgeNotificationMsgProto msg = builder.build(); + log.trace("[{}] sending notification to edge service {}", tenantId.getId(), msg); + pushMsgToCore(tenantId, entityId != null ? entityId : tenantId, TransportProtos.ToCoreMsg.newBuilder().setEdgeNotificationMsg(msg).build(), null); + + if (entityId != null && EntityType.DEVICE.equals(entityId.getEntityType())) { + pushDeviceUpdateMessage(tenantId, edgeId, entityId, action); + } + } + + private void pushDeviceUpdateMessage(TenantId tenantId, EdgeId edgeId, EntityId entityId, EdgeEventActionType action) { + log.trace("{} Going to send edge update notification for device actor, device id {}, edge id {}", tenantId, entityId, edgeId); + switch (action) { + case ASSIGNED_TO_EDGE: + pushMsgToCore(new DeviceEdgeUpdateMsg(tenantId, new DeviceId(entityId.getId()), edgeId), null); + break; + case UNASSIGNED_FROM_EDGE: + pushMsgToCore(new DeviceEdgeUpdateMsg(tenantId, new DeviceId(entityId.getId()), null), null); + break; + } + } + + @Override + public void onQueueChange(Queue queue) { + log.trace("[{}][{}] Processing queue change [{}] event", queue.getTenantId(), queue.getId(), queue.getName()); + + TransportProtos.QueueUpdateMsg queueUpdateMsg = TransportProtos.QueueUpdateMsg.newBuilder() + .setTenantIdMSB(queue.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(queue.getTenantId().getId().getLeastSignificantBits()) + .setQueueIdMSB(queue.getId().getId().getMostSignificantBits()) + .setQueueIdLSB(queue.getId().getId().getLeastSignificantBits()) + .setQueueName(queue.getName()) + .setQueueTopic(queue.getTopic()) + .setPartitions(queue.getPartitions()) + .build(); + + ToRuleEngineNotificationMsg ruleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setQueueUpdateMsg(queueUpdateMsg).build(); + ToCoreNotificationMsg coreMsg = ToCoreNotificationMsg.newBuilder().setQueueUpdateMsg(queueUpdateMsg).build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setQueueUpdateMsg(queueUpdateMsg).build(); + doSendQueueNotifications(ruleEngineMsg, coreMsg, transportMsg); + } + + @Override + public void onQueueDelete(Queue queue) { + log.trace("[{}][{}] Processing queue delete [{}] event", queue.getTenantId(), queue.getId(), queue.getName()); + + TransportProtos.QueueDeleteMsg queueDeleteMsg = TransportProtos.QueueDeleteMsg.newBuilder() + .setTenantIdMSB(queue.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(queue.getTenantId().getId().getLeastSignificantBits()) + .setQueueIdMSB(queue.getId().getId().getMostSignificantBits()) + .setQueueIdLSB(queue.getId().getId().getLeastSignificantBits()) + .setQueueName(queue.getName()) + .build(); + + ToRuleEngineNotificationMsg ruleEngineMsg = ToRuleEngineNotificationMsg.newBuilder().setQueueDeleteMsg(queueDeleteMsg).build(); + ToCoreNotificationMsg coreMsg = ToCoreNotificationMsg.newBuilder().setQueueDeleteMsg(queueDeleteMsg).build(); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setQueueDeleteMsg(queueDeleteMsg).build(); + doSendQueueNotifications(ruleEngineMsg, coreMsg, transportMsg); + } + + private void doSendQueueNotifications(ToRuleEngineNotificationMsg ruleEngineMsg, ToCoreNotificationMsg coreMsg, ToTransportMsg transportMsg) { + Set tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); + Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); + Set tbTransportServices = partitionService.getAllServiceIds(ServiceType.TB_TRANSPORT); + // No need to push notifications twice + tbTransportServices.removeAll(tbCoreServices); + tbCoreServices.removeAll(tbRuleEngineServices); + + for (String ruleEngineServiceId : tbRuleEngineServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_RULE_ENGINE, ruleEngineServiceId); + producerProvider.getRuleEngineNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), ruleEngineMsg), null); + toRuleEngineNfs.incrementAndGet(); + } + for (String coreServiceId : tbCoreServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, coreServiceId); + producerProvider.getTbCoreNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), coreMsg), null); + toCoreNfs.incrementAndGet(); + } + for (String transportServiceId : tbTransportServices) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, transportServiceId); + producerProvider.getTransportNotificationsMsgProducer().send(tpi, new TbProtoQueueMsg<>(UUID.randomUUID(), transportMsg), null); + toTransportNfs.incrementAndGet(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java new file mode 100644 index 0000000..931acf9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -0,0 +1,581 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.EdgeNotificationMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.FromDeviceRPCResponseProto; +import org.thingsboard.server.gen.transport.TransportProtos.LocalSubscriptionServiceMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscriptionMgrMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionCloseProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.edge.EdgeNotificationService; +import org.thingsboard.server.service.ota.OtaPackageStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.queue.processing.AbstractConsumerService; +import org.thingsboard.server.service.queue.processing.IdMsgPair; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; +import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; +import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.subscription.SubscriptionManagerService; +import org.thingsboard.server.service.subscription.TbLocalSubscriptionService; +import org.thingsboard.server.service.subscription.TbSubscriptionUtils; +import org.thingsboard.server.service.sync.vc.GitVersionControlQueueService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +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.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@TbCoreComponent +@Slf4j +public class DefaultTbCoreConsumerService extends AbstractConsumerService implements TbCoreConsumerService { + + @Value("${queue.core.poll-interval}") + private long pollDuration; + @Value("${queue.core.pack-processing-timeout}") + private long packProcessingTimeout; + @Value("${queue.core.stats.enabled:false}") + private boolean statsEnabled; + + @Value("${queue.core.ota.pack-interval-ms:60000}") + private long firmwarePackInterval; + @Value("${queue.core.ota.pack-size:100}") + private int firmwarePackSize; + + private final TbQueueConsumer> mainConsumer; + private final DeviceStateService stateService; + private final TbApiUsageStateService statsService; + private final TbLocalSubscriptionService localSubscriptionService; + private final SubscriptionManagerService subscriptionManagerService; + private final TbCoreDeviceRpcService tbCoreDeviceRpcService; + private final EdgeNotificationService edgeNotificationService; + private final OtaPackageStateService firmwareStateService; + private final GitVersionControlQueueService vcQueueService; + private final TbCoreConsumerStats stats; + protected final TbQueueConsumer> usageStatsConsumer; + private final TbQueueConsumer> firmwareStatesConsumer; + + protected volatile ExecutorService usageStatsExecutor; + + private volatile ExecutorService firmwareStatesExecutor; + + public DefaultTbCoreConsumerService(TbCoreQueueFactory tbCoreQueueFactory, + ActorSystemContext actorContext, + DeviceStateService stateService, + TbLocalSubscriptionService localSubscriptionService, + SubscriptionManagerService subscriptionManagerService, + DataDecodingEncodingService encodingService, + TbCoreDeviceRpcService tbCoreDeviceRpcService, + StatsFactory statsFactory, + TbDeviceProfileCache deviceProfileCache, + TbAssetProfileCache assetProfileCache, + TbApiUsageStateService statsService, + TbTenantProfileCache tenantProfileCache, + TbApiUsageStateService apiUsageStateService, + EdgeNotificationService edgeNotificationService, + OtaPackageStateService firmwareStateService, + GitVersionControlQueueService vcQueueService, + PartitionService partitionService, + Optional jwtSettingsService) { + super(actorContext, encodingService, tenantProfileCache, deviceProfileCache, assetProfileCache, apiUsageStateService, partitionService, tbCoreQueueFactory.createToCoreNotificationsMsgConsumer(), jwtSettingsService); + this.mainConsumer = tbCoreQueueFactory.createToCoreMsgConsumer(); + this.usageStatsConsumer = tbCoreQueueFactory.createToUsageStatsServiceMsgConsumer(); + this.firmwareStatesConsumer = tbCoreQueueFactory.createToOtaPackageStateServiceMsgConsumer(); + this.stateService = stateService; + this.localSubscriptionService = localSubscriptionService; + this.subscriptionManagerService = subscriptionManagerService; + this.tbCoreDeviceRpcService = tbCoreDeviceRpcService; + this.edgeNotificationService = edgeNotificationService; + this.stats = new TbCoreConsumerStats(statsFactory); + this.statsService = statsService; + this.firmwareStateService = firmwareStateService; + this.vcQueueService = vcQueueService; + } + + @PostConstruct + public void init() { + super.init("tb-core-consumer", "tb-core-notifications-consumer"); + this.usageStatsExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("tb-core-usage-stats-consumer")); + this.firmwareStatesExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("tb-core-firmware-notifications-consumer")); + } + + @PreDestroy + public void destroy() { + super.destroy(); + if (usageStatsExecutor != null) { + usageStatsExecutor.shutdownNow(); + } + if (firmwareStatesExecutor != null) { + firmwareStatesExecutor.shutdownNow(); + } + } + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onApplicationEvent(ApplicationReadyEvent event) { + super.onApplicationEvent(event); + launchUsageStatsConsumer(); + launchOtaPackageUpdateNotificationConsumer(); + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + if (event.getServiceType().equals(getServiceType())) { + log.info("Subscribing to partitions: {}", event.getPartitions()); + this.mainConsumer.subscribe(event.getPartitions()); + this.usageStatsConsumer.subscribe( + event + .getPartitions() + .stream() + .map(tpi -> tpi.newByTopic(usageStatsConsumer.getTopic())) + .collect(Collectors.toSet())); + } + this.firmwareStatesConsumer.subscribe(); + } + + @Override + protected void launchMainConsumers() { + consumersExecutor.submit(() -> { + while (!stopped) { + try { + List> msgs = mainConsumer.poll(pollDuration); + if (msgs.isEmpty()) { + continue; + } + List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList()); + ConcurrentMap> pendingMap = orderedMsgList.stream().collect( + Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + CountDownLatch processingTimeoutLatch = new CountDownLatch(1); + TbPackProcessingContext> ctx = new TbPackProcessingContext<>( + processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); + PendingMsgHolder pendingMsgHolder = new PendingMsgHolder(); + Future packSubmitFuture = consumersExecutor.submit(() -> { + orderedMsgList.forEach((element) -> { + UUID id = element.getUuid(); + TbProtoQueueMsg msg = element.getMsg(); + log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); + TbCallback callback = new TbPackCallback<>(id, ctx); + try { + ToCoreMsg toCoreMsg = msg.getValue(); + pendingMsgHolder.setToCoreMsg(toCoreMsg); + if (toCoreMsg.hasToSubscriptionMgrMsg()) { + log.trace("[{}] Forwarding message to subscription manager service {}", id, toCoreMsg.getToSubscriptionMgrMsg()); + forwardToSubMgrService(toCoreMsg.getToSubscriptionMgrMsg(), callback); + } else if (toCoreMsg.hasToDeviceActorMsg()) { + log.trace("[{}] Forwarding message to device actor {}", id, toCoreMsg.getToDeviceActorMsg()); + forwardToDeviceActor(toCoreMsg.getToDeviceActorMsg(), callback); + } else if (toCoreMsg.hasDeviceStateServiceMsg()) { + log.trace("[{}] Forwarding message to state service {}", id, toCoreMsg.getDeviceStateServiceMsg()); + forwardToStateService(toCoreMsg.getDeviceStateServiceMsg(), callback); + } else if (toCoreMsg.hasEdgeNotificationMsg()) { + log.trace("[{}] Forwarding message to edge service {}", id, toCoreMsg.getEdgeNotificationMsg()); + forwardToEdgeNotificationService(toCoreMsg.getEdgeNotificationMsg(), callback); + } else if (toCoreMsg.hasDeviceActivityMsg()) { + log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceActivityMsg()); + forwardToStateService(toCoreMsg.getDeviceActivityMsg(), callback); + } else if (!toCoreMsg.getToDeviceActorNotificationMsg().isEmpty()) { + Optional actorMsg = encodingService.decode(toCoreMsg.getToDeviceActorNotificationMsg().toByteArray()); + if (actorMsg.isPresent()) { + TbActorMsg tbActorMsg = actorMsg.get(); + if (tbActorMsg.getMsgType().equals(MsgType.DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG)) { + tbCoreDeviceRpcService.forwardRpcRequestToDeviceActor((ToDeviceRpcRequestActorMsg) tbActorMsg); + } else { + log.trace("[{}] Forwarding message to App Actor {}", id, actorMsg.get()); + actorContext.tell(actorMsg.get()); + } + } + callback.onSuccess(); + } + } catch (Throwable e) { + log.warn("[{}] Failed to process message: {}", id, msg, e); + callback.onFailure(e); + } + }); + }); + if (!processingTimeoutLatch.await(packProcessingTimeout, TimeUnit.MILLISECONDS)) { + if (!packSubmitFuture.isDone()) { + packSubmitFuture.cancel(true); + ToCoreMsg lastSubmitMsg = pendingMsgHolder.getToCoreMsg(); + log.info("Timeout to process message: {}", lastSubmitMsg); + } + ctx.getAckMap().forEach((id, msg) -> log.debug("[{}] Timeout to process message: {}", id, msg.getValue())); + ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process message: {}", id, msg.getValue())); + } + mainConsumer.commit(); + } catch (Exception e) { + if (!stopped) { + 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); + } + } + } + } + log.info("TB Core Consumer stopped."); + }); + } + + private static class PendingMsgHolder { + @Getter + @Setter + private volatile ToCoreMsg toCoreMsg; + } + + @Override + protected ServiceType getServiceType() { + return ServiceType.TB_CORE; + } + + @Override + protected long getNotificationPollDuration() { + return pollDuration; + } + + @Override + protected long getNotificationPackProcessingTimeout() { + return packProcessingTimeout; + } + + @Override + protected void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) { + ToCoreNotificationMsg toCoreNotification = msg.getValue(); + if (toCoreNotification.hasToLocalSubscriptionServiceMsg()) { + log.trace("[{}] Forwarding message to local subscription service {}", id, toCoreNotification.getToLocalSubscriptionServiceMsg()); + forwardToLocalSubMgrService(toCoreNotification.getToLocalSubscriptionServiceMsg(), callback); + } else if (toCoreNotification.hasFromDeviceRpcResponse()) { + log.trace("[{}] Forwarding message to RPC service {}", id, toCoreNotification.getFromDeviceRpcResponse()); + forwardToCoreRpcService(toCoreNotification.getFromDeviceRpcResponse(), callback); + } else if (toCoreNotification.getComponentLifecycleMsg() != null && !toCoreNotification.getComponentLifecycleMsg().isEmpty()) { + handleComponentLifecycleMsg(id, toCoreNotification.getComponentLifecycleMsg()); + callback.onSuccess(); + } else if (!toCoreNotification.getEdgeEventUpdateMsg().isEmpty()) { + forwardToAppActor(id, encodingService.decode(toCoreNotification.getEdgeEventUpdateMsg().toByteArray()), callback); + } else if (!toCoreNotification.getToEdgeSyncRequestMsg().isEmpty()) { + forwardToAppActor(id, encodingService.decode(toCoreNotification.getToEdgeSyncRequestMsg().toByteArray()), callback); + } else if (!toCoreNotification.getFromEdgeSyncResponseMsg().isEmpty()) { + forwardToAppActor(id, encodingService.decode(toCoreNotification.getFromEdgeSyncResponseMsg().toByteArray()), callback); + } else if (toCoreNotification.hasQueueUpdateMsg()) { + TransportProtos.QueueUpdateMsg queue = toCoreNotification.getQueueUpdateMsg(); + partitionService.updateQueue(queue); + callback.onSuccess(); + } else if (toCoreNotification.hasQueueDeleteMsg()) { + TransportProtos.QueueDeleteMsg queue = toCoreNotification.getQueueDeleteMsg(); + partitionService.removeQueue(queue); + callback.onSuccess(); + } else if (toCoreNotification.hasVcResponseMsg()) { + vcQueueService.processResponse(toCoreNotification.getVcResponseMsg()); + callback.onSuccess(); + } + if (statsEnabled) { + stats.log(toCoreNotification); + } + } + + private void launchUsageStatsConsumer() { + usageStatsExecutor.submit(() -> { + while (!stopped) { + try { + List> msgs = usageStatsConsumer.poll(getNotificationPollDuration()); + if (msgs.isEmpty()) { + continue; + } + ConcurrentMap> pendingMap = msgs.stream().collect( + Collectors.toConcurrentMap(s -> UUID.randomUUID(), Function.identity())); + CountDownLatch processingTimeoutLatch = new CountDownLatch(1); + TbPackProcessingContext> ctx = new TbPackProcessingContext<>( + processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); + pendingMap.forEach((id, msg) -> { + log.trace("[{}] Creating usage stats callback for message: {}", id, msg.getValue()); + TbCallback callback = new TbPackCallback<>(id, ctx); + try { + handleUsageStats(msg, callback); + } catch (Throwable e) { + log.warn("[{}] Failed to process usage stats: {}", id, msg, e); + callback.onFailure(e); + } + }); + if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) { + ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process usage stats: {}", id, msg.getValue())); + ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process usage stats: {}", id, msg.getValue())); + } + usageStatsConsumer.commit(); + } catch (Exception e) { + if (!stopped) { + log.warn("Failed to obtain usage stats from queue.", e); + try { + Thread.sleep(getNotificationPollDuration()); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new usage stats", e2); + } + } + } + } + log.info("TB Usage Stats Consumer stopped."); + }); + } + + private void launchOtaPackageUpdateNotificationConsumer() { + long maxProcessingTimeoutPerRecord = firmwarePackInterval / firmwarePackSize; + firmwareStatesExecutor.submit(() -> { + while (!stopped) { + try { + List> msgs = firmwareStatesConsumer.poll(getNotificationPollDuration()); + if (msgs.isEmpty()) { + continue; + } + long timeToSleep = maxProcessingTimeoutPerRecord; + for (TbProtoQueueMsg msg : msgs) { + try { + long startTime = System.currentTimeMillis(); + boolean isSuccessUpdate = handleOtaPackageUpdates(msg); + long endTime = System.currentTimeMillis(); + long spentTime = endTime - startTime; + timeToSleep = timeToSleep - spentTime; + if (isSuccessUpdate) { + if (timeToSleep > 0) { + log.debug("Spent time per record is: [{}]!", spentTime); + Thread.sleep(timeToSleep); + timeToSleep = 0; + } + timeToSleep += maxProcessingTimeoutPerRecord; + } + } catch (Throwable e) { + log.warn("Failed to process firmware update msg: {}", msg, e); + } + } + firmwareStatesConsumer.commit(); + } catch (Exception e) { + if (!stopped) { + log.warn("Failed to obtain usage stats from queue.", e); + try { + Thread.sleep(getNotificationPollDuration()); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new firmware updates", e2); + } + } + } + } + log.info("TB Ota Package States Consumer stopped."); + }); + } + + private void handleUsageStats(TbProtoQueueMsg msg, TbCallback callback) { + statsService.process(msg, callback); + } + + private boolean handleOtaPackageUpdates(TbProtoQueueMsg msg) { + return firmwareStateService.process(msg.getValue()); + } + + private void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { + RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) + , proto.getResponse(), error); + tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); + callback.onSuccess(); + } + + @Scheduled(fixedDelayString = "${queue.core.stats.print-interval-ms}") + public void printStats() { + if (statsEnabled) { + stats.printStats(); + stats.reset(); + } + } + + private void forwardToLocalSubMgrService(LocalSubscriptionServiceMsgProto msg, TbCallback callback) { + if (msg.hasSubUpdate()) { + localSubscriptionService.onSubscriptionUpdate(msg.getSubUpdate().getSessionId(), TbSubscriptionUtils.fromProto(msg.getSubUpdate()), callback); + } else if (msg.hasAlarmSubUpdate()) { + localSubscriptionService.onSubscriptionUpdate(msg.getAlarmSubUpdate().getSessionId(), TbSubscriptionUtils.fromProto(msg.getAlarmSubUpdate()), callback); + } else { + throwNotHandled(msg, callback); + } + } + + private void forwardToSubMgrService(SubscriptionMgrMsgProto msg, TbCallback callback) { + if (msg.hasAttributeSub()) { + subscriptionManagerService.addSubscription(TbSubscriptionUtils.fromProto(msg.getAttributeSub()), callback); + } else if (msg.hasTelemetrySub()) { + subscriptionManagerService.addSubscription(TbSubscriptionUtils.fromProto(msg.getTelemetrySub()), callback); + } else if (msg.hasAlarmSub()) { + subscriptionManagerService.addSubscription(TbSubscriptionUtils.fromProto(msg.getAlarmSub()), callback); + } else if (msg.hasSubClose()) { + TbSubscriptionCloseProto closeProto = msg.getSubClose(); + subscriptionManagerService.cancelSubscription(closeProto.getSessionId(), closeProto.getSubscriptionId(), callback); + } else if (msg.hasTsUpdate()) { + TbTimeSeriesUpdateProto proto = msg.getTsUpdate(); + subscriptionManagerService.onTimeSeriesUpdate( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + TbSubscriptionUtils.toTsKvEntityList(proto.getDataList()), callback); + } else if (msg.hasAttrUpdate()) { + TbAttributeUpdateProto proto = msg.getAttrUpdate(); + subscriptionManagerService.onAttributesUpdate( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + proto.getScope(), TbSubscriptionUtils.toAttributeKvList(proto.getDataList()), callback); + } else if (msg.hasAttrDelete()) { + TbAttributeDeleteProto proto = msg.getAttrDelete(); + subscriptionManagerService.onAttributesDelete( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + proto.getScope(), proto.getKeysList(), proto.getNotifyDevice(), callback); + } else if (msg.hasTsDelete()) { + TbTimeSeriesDeleteProto proto = msg.getTsDelete(); + subscriptionManagerService.onTimeSeriesDelete( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + proto.getKeysList(), callback); + } else if (msg.hasAlarmUpdate()) { + TbAlarmUpdateProto proto = msg.getAlarmUpdate(); + subscriptionManagerService.onAlarmUpdate( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + JacksonUtil.fromString(proto.getAlarm(), Alarm.class), callback); + } else if (msg.hasAlarmDelete()) { + TbAlarmDeleteProto proto = msg.getAlarmDelete(); + subscriptionManagerService.onAlarmDeleted( + TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), + TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), + JacksonUtil.fromString(proto.getAlarm(), Alarm.class), callback); + } else { + throwNotHandled(msg, callback); + } + if (statsEnabled) { + stats.log(msg); + } + } + + private void forwardToStateService(DeviceStateServiceMsgProto deviceStateServiceMsg, TbCallback callback) { + if (statsEnabled) { + stats.log(deviceStateServiceMsg); + } + stateService.onQueueMsg(deviceStateServiceMsg, callback); + } + + private void forwardToStateService(TransportProtos.DeviceActivityProto deviceActivityMsg, TbCallback callback) { + if (statsEnabled) { + stats.log(deviceActivityMsg); + } + TenantId tenantId = TenantId.fromUUID(new UUID(deviceActivityMsg.getTenantIdMSB(), deviceActivityMsg.getTenantIdLSB())); + DeviceId deviceId = new DeviceId(new UUID(deviceActivityMsg.getDeviceIdMSB(), deviceActivityMsg.getDeviceIdLSB())); + try { + stateService.onDeviceActivity(tenantId, deviceId, deviceActivityMsg.getLastActivityTime()); + callback.onSuccess(); + } catch (Exception e) { + callback.onFailure(new RuntimeException("Failed update device activity for device [" + deviceId.getId() + "]!", e)); + } + } + + private void forwardToEdgeNotificationService(EdgeNotificationMsgProto edgeNotificationMsg, TbCallback callback) { + if (statsEnabled) { + stats.log(edgeNotificationMsg); + } + edgeNotificationService.pushNotificationToEdge(edgeNotificationMsg, callback); + } + + private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg, TbCallback callback) { + if (statsEnabled) { + stats.log(toDeviceActorMsg); + } + actorContext.tell(new TransportToDeviceActorMsgWrapper(toDeviceActorMsg, callback)); + } + + private void forwardToAppActor(UUID id, Optional actorMsg, TbCallback callback) { + if (actorMsg.isPresent()) { + log.trace("[{}] Forwarding message to App Actor {}", id, actorMsg.get()); + actorContext.tell(actorMsg.get()); + } + callback.onSuccess(); + } + + private void throwNotHandled(Object msg, TbCallback callback) { + log.warn("Message not handled: {}", msg); + callback.onFailure(new RuntimeException("Message not handled!")); + } + + @Override + protected void stopMainConsumers() { + if (mainConsumer != null) { + mainConsumer.unsubscribe(); + } + if (usageStatsConsumer != null) { + usageStatsConsumer.unsubscribe(); + } + if (firmwareStatesConsumer != null) { + firmwareStatesConsumer.unsubscribe(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java new file mode 100644 index 0000000..dd97db3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -0,0 +1,495 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import com.google.protobuf.ProtocolStringList; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.queue.processing.AbstractConsumerService; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategy; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; +import org.thingsboard.server.service.stats.RuleEngineStatisticsService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +@Service +@TbRuleEngineComponent +@Slf4j +public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService implements TbRuleEngineConsumerService { + + public static final String SUCCESSFUL_STATUS = "successful"; + public static final String FAILED_STATUS = "failed"; + public static final String THREAD_TOPIC_SEPARATOR = " | "; + @Value("${queue.rule-engine.poll-interval}") + private long pollDuration; + @Value("${queue.rule-engine.pack-processing-timeout}") + private long packProcessingTimeout; + @Value("${queue.rule-engine.stats.enabled:true}") + private boolean statsEnabled; + @Value("${queue.rule-engine.prometheus-stats.enabled:false}") + boolean prometheusStatsEnabled; + + private final StatsFactory statsFactory; + private final TbRuleEngineSubmitStrategyFactory submitStrategyFactory; + private final TbRuleEngineProcessingStrategyFactory processingStrategyFactory; + private final TbRuleEngineQueueFactory tbRuleEngineQueueFactory; + private final RuleEngineStatisticsService statisticsService; + private final TbRuleEngineDeviceRpcService tbDeviceRpcService; + private final TbServiceInfoProvider serviceInfoProvider; + private final QueueService queueService; + // private final TenantId tenantId; + private final ConcurrentMap>> consumers = new ConcurrentHashMap<>(); + private final ConcurrentMap consumerConfigurations = new ConcurrentHashMap<>(); + private final ConcurrentMap consumerStats = new ConcurrentHashMap<>(); + private final ConcurrentMap topicsConsumerPerPartition = new ConcurrentHashMap<>(); + final ExecutorService submitExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer-submit")); + final ScheduledExecutorService repartitionExecutor = Executors.newScheduledThreadPool(1, ThingsBoardThreadFactory.forName("tb-rule-engine-consumer-repartition")); + + public DefaultTbRuleEngineConsumerService(TbRuleEngineProcessingStrategyFactory processingStrategyFactory, + TbRuleEngineSubmitStrategyFactory submitStrategyFactory, + TbRuleEngineQueueFactory tbRuleEngineQueueFactory, + RuleEngineStatisticsService statisticsService, + ActorSystemContext actorContext, + DataDecodingEncodingService encodingService, + TbRuleEngineDeviceRpcService tbDeviceRpcService, + StatsFactory statsFactory, + TbDeviceProfileCache deviceProfileCache, + TbAssetProfileCache assetProfileCache, + TbTenantProfileCache tenantProfileCache, + TbApiUsageStateService apiUsageStateService, + PartitionService partitionService, TbServiceInfoProvider serviceInfoProvider, QueueService queueService) { + super(actorContext, encodingService, tenantProfileCache, deviceProfileCache, assetProfileCache, apiUsageStateService, partitionService, tbRuleEngineQueueFactory.createToRuleEngineNotificationsMsgConsumer(), Optional.empty()); + this.statisticsService = statisticsService; + this.tbRuleEngineQueueFactory = tbRuleEngineQueueFactory; + this.submitStrategyFactory = submitStrategyFactory; + this.processingStrategyFactory = processingStrategyFactory; + this.tbDeviceRpcService = tbDeviceRpcService; + this.statsFactory = statsFactory; + this.serviceInfoProvider = serviceInfoProvider; + this.queueService = queueService; + } + + @PostConstruct + public void init() { + super.init("tb-rule-engine-consumer", "tb-rule-engine-notifications-consumer"); + List queues = queueService.findAllQueues(); + for (Queue configuration : queues) { + initConsumer(configuration); + } + } + + private void initConsumer(Queue configuration) { + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, configuration); + consumerConfigurations.putIfAbsent(queueKey, configuration); + consumerStats.putIfAbsent(queueKey, new TbRuleEngineConsumerStats(configuration.getName(), statsFactory)); + if (!configuration.isConsumerPerPartition()) { + consumers.computeIfAbsent(queueKey, queueName -> tbRuleEngineQueueFactory.createToRuleEngineMsgConsumer(configuration)); + } else { + topicsConsumerPerPartition.computeIfAbsent(queueKey, k -> new TbTopicWithConsumerPerPartition(k.getQueueName())); + } + } + + @PreDestroy + public void stop() { + super.destroy(); + submitExecutor.shutdownNow(); + repartitionExecutor.shutdownNow(); + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + if (event.getServiceType().equals(getServiceType())) { + String serviceQueue = event.getQueueKey().getQueueName(); + log.info("[{}] Subscribing to partitions: {}", serviceQueue, event.getPartitions()); + if (!consumerConfigurations.get(event.getQueueKey()).isConsumerPerPartition()) { + consumers.get(event.getQueueKey()).subscribe(event.getPartitions()); + } else { + log.info("[{}] Subscribing consumer per partition: {}", serviceQueue, event.getPartitions()); + subscribeConsumerPerPartition(event.getQueueKey(), event.getPartitions()); + } + } + } + + void subscribeConsumerPerPartition(QueueKey queue, Set partitions) { + topicsConsumerPerPartition.get(queue).getSubscribeQueue().add(partitions); + scheduleTopicRepartition(queue); + } + + private void scheduleTopicRepartition(QueueKey queue) { + repartitionExecutor.schedule(() -> repartitionTopicWithConsumerPerPartition(queue), 1, TimeUnit.SECONDS); + } + + void repartitionTopicWithConsumerPerPartition(final QueueKey queueKey) { + if (stopped) { + return; + } + TbTopicWithConsumerPerPartition tbTopicWithConsumerPerPartition = topicsConsumerPerPartition.get(queueKey); + java.util.Queue> subscribeQueue = tbTopicWithConsumerPerPartition.getSubscribeQueue(); + if (subscribeQueue.isEmpty()) { + return; + } + if (tbTopicWithConsumerPerPartition.getLock().tryLock()) { + try { + Set partitions = null; + while (!subscribeQueue.isEmpty()) { + partitions = subscribeQueue.poll(); + } + if (partitions == null) { + return; + } + + Set addedPartitions = new HashSet<>(partitions); + ConcurrentMap>> consumers = tbTopicWithConsumerPerPartition.getConsumers(); + addedPartitions.removeAll(consumers.keySet()); + log.info("calculated addedPartitions {}", addedPartitions); + + Set removedPartitions = new HashSet<>(consumers.keySet()); + removedPartitions.removeAll(partitions); + log.info("calculated removedPartitions {}", removedPartitions); + + removedPartitions.forEach((tpi) -> { + removeConsumerForTopicByTpi(queueKey.getQueueName(), consumers, tpi); + }); + + addedPartitions.forEach((tpi) -> { + log.info("[{}] Adding consumer for topic: {}", queueKey, tpi); + Queue configuration = consumerConfigurations.get(queueKey); + TbQueueConsumer> consumer = tbRuleEngineQueueFactory.createToRuleEngineMsgConsumer(configuration); + consumers.put(tpi, consumer); + launchConsumer(consumer, consumerConfigurations.get(queueKey), consumerStats.get(queueKey), "" + queueKey + "-" + tpi.getPartition().orElse(-999999)); + consumer.subscribe(Collections.singleton(tpi)); + }); + + } finally { + tbTopicWithConsumerPerPartition.getLock().unlock(); + } + } else { + scheduleTopicRepartition(queueKey); //reschedule later + } + + } + + void removeConsumerForTopicByTpi(String queue, ConcurrentMap>> consumers, TopicPartitionInfo tpi) { + log.info("[{}] Removing consumer for topic: {}", queue, tpi); + consumers.get(tpi).unsubscribe(); + consumers.remove(tpi); + } + + @Override + protected void launchMainConsumers() { + consumers.forEach((queue, consumer) -> launchConsumer(consumer, consumerConfigurations.get(queue), consumerStats.get(queue), queue.getQueueName())); + } + + @Override + protected void stopMainConsumers() { + consumers.values().forEach(TbQueueConsumer::unsubscribe); + topicsConsumerPerPartition.values().forEach(tbTopicWithConsumerPerPartition -> tbTopicWithConsumerPerPartition.getConsumers().keySet() + .forEach((tpi) -> removeConsumerForTopicByTpi(tbTopicWithConsumerPerPartition.getTopic(), tbTopicWithConsumerPerPartition.getConsumers(), tpi))); + } + + void launchConsumer(TbQueueConsumer> consumer, Queue configuration, TbRuleEngineConsumerStats stats, String threadSuffix) { + consumersExecutor.execute(() -> consumerLoop(consumer, configuration, stats, threadSuffix)); + } + + void consumerLoop(TbQueueConsumer> consumer, org.thingsboard.server.common.data.queue.Queue configuration, TbRuleEngineConsumerStats stats, String threadSuffix) { + updateCurrentThreadName(threadSuffix); + while (!stopped && !consumer.isStopped()) { + try { + List> msgs = consumer.poll(pollDuration); + if (msgs.isEmpty()) { + continue; + } + final TbRuleEngineSubmitStrategy submitStrategy = getSubmitStrategy(configuration); + final TbRuleEngineProcessingStrategy ackStrategy = getAckStrategy(configuration); + submitStrategy.init(msgs); + while (!stopped && !consumer.isStopped()) { + TbMsgPackProcessingContext ctx = new TbMsgPackProcessingContext(configuration.getName(), submitStrategy, ackStrategy.isSkipTimeoutMsgs()); + submitStrategy.submitAttempt((id, msg) -> submitExecutor.submit(() -> submitMessage(configuration, stats, ctx, id, msg))); + + final boolean timeout = !ctx.await(configuration.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); + + TbRuleEngineProcessingResult result = new TbRuleEngineProcessingResult(configuration.getName(), timeout, ctx); + if (timeout) { + printFirstOrAll(configuration, ctx, ctx.getPendingMap(), "Timeout"); + } + if (!ctx.getFailedMap().isEmpty()) { + printFirstOrAll(configuration, ctx, ctx.getFailedMap(), "Failed"); + } + ctx.printProfilerStats(); + + TbRuleEngineProcessingDecision decision = ackStrategy.analyze(result); + if (statsEnabled) { + stats.log(result, decision.isCommit()); + } + + ctx.cleanup(); + + if (decision.isCommit()) { + submitStrategy.stop(); + break; + } else { + submitStrategy.update(decision.getReprocessMap()); + } + } + consumer.commit(); + } catch (Exception e) { + if (!stopped) { + log.warn("Failed to process 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); + } + } + } + } + log.info("TB Rule Engine Consumer stopped."); + } + + void updateCurrentThreadName(String threadSuffix) { + String name = Thread.currentThread().getName(); + int spliteratorIndex = name.indexOf(THREAD_TOPIC_SEPARATOR); + if (spliteratorIndex > 0) { + name = name.substring(0, spliteratorIndex); + } + name = name + THREAD_TOPIC_SEPARATOR + threadSuffix; + Thread.currentThread().setName(name); + } + + TbRuleEngineProcessingStrategy getAckStrategy(Queue configuration) { + return processingStrategyFactory.newInstance(configuration.getName(), configuration.getProcessingStrategy()); + } + + TbRuleEngineSubmitStrategy getSubmitStrategy(Queue configuration) { + return submitStrategyFactory.newInstance(configuration.getName(), configuration.getSubmitStrategy()); + } + + void submitMessage(Queue configuration, TbRuleEngineConsumerStats stats, TbMsgPackProcessingContext ctx, UUID id, TbProtoQueueMsg msg) { + log.trace("[{}] Creating callback for topic {} message: {}", id, configuration.getName(), msg.getValue()); + ToRuleEngineMsg toRuleEngineMsg = msg.getValue(); + TenantId tenantId = TenantId.fromUUID(new UUID(toRuleEngineMsg.getTenantIdMSB(), toRuleEngineMsg.getTenantIdLSB())); + TbMsgCallback callback = prometheusStatsEnabled ? + new TbMsgPackCallback(id, tenantId, ctx, stats.getTimer(tenantId, SUCCESSFUL_STATUS), stats.getTimer(tenantId, FAILED_STATUS)) : + new TbMsgPackCallback(id, tenantId, ctx); + try { + if (toRuleEngineMsg.getTbMsg() != null && !toRuleEngineMsg.getTbMsg().isEmpty()) { + forwardToRuleEngineActor(configuration.getName(), tenantId, toRuleEngineMsg, callback); + } else { + callback.onSuccess(); + } + } catch (Exception e) { + callback.onFailure(new RuleEngineException(e.getMessage())); + } + } + + private void printFirstOrAll(Queue configuration, TbMsgPackProcessingContext ctx, Map> map, String prefix) { + boolean printAll = log.isTraceEnabled(); + log.info("{} to process [{}] messages", prefix, map.size()); + for (Map.Entry> pending : map.entrySet()) { + ToRuleEngineMsg tmp = pending.getValue().getValue(); + TbMsg tmpMsg = TbMsg.fromBytes(configuration.getName(), tmp.getTbMsg().toByteArray(), TbMsgCallback.EMPTY); + RuleNodeInfo ruleNodeInfo = ctx.getLastVisitedRuleNode(pending.getKey()); + if (printAll) { + log.trace("[{}] {} to process message: {}, Last Rule Node: {}", TenantId.fromUUID(new UUID(tmp.getTenantIdMSB(), tmp.getTenantIdLSB())), prefix, tmpMsg, ruleNodeInfo); + } else { + log.info("[{}] {} to process message: {}, Last Rule Node: {}", TenantId.fromUUID(new UUID(tmp.getTenantIdMSB(), tmp.getTenantIdLSB())), prefix, tmpMsg, ruleNodeInfo); + break; + } + } + } + + @Override + protected ServiceType getServiceType() { + return ServiceType.TB_RULE_ENGINE; + } + + @Override + protected long getNotificationPollDuration() { + return pollDuration; + } + + @Override + protected long getNotificationPackProcessingTimeout() { + return packProcessingTimeout; + } + + @Override + protected void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) throws Exception { + ToRuleEngineNotificationMsg nfMsg = msg.getValue(); + if (nfMsg.getComponentLifecycleMsg() != null && !nfMsg.getComponentLifecycleMsg().isEmpty()) { + handleComponentLifecycleMsg(id, nfMsg.getComponentLifecycleMsg()); + callback.onSuccess(); + } else if (nfMsg.hasFromDeviceRpcResponse()) { + TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); + RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) + , proto.getResponse(), error); + tbDeviceRpcService.processRpcResponseFromDevice(response); + callback.onSuccess(); + } else if (nfMsg.hasQueueUpdateMsg()) { + repartitionExecutor.execute(() -> updateQueue(nfMsg.getQueueUpdateMsg())); + callback.onSuccess(); + } else if (nfMsg.hasQueueDeleteMsg()) { + repartitionExecutor.execute(() -> deleteQueue(nfMsg.getQueueDeleteMsg())); + callback.onSuccess(); + } else { + log.trace("Received notification with missing handler"); + callback.onSuccess(); + } + } + + private void updateQueue(TransportProtos.QueueUpdateMsg queueUpdateMsg) { + log.info("Received queue update msg: [{}]", queueUpdateMsg); + String queueName = queueUpdateMsg.getQueueName(); + TenantId tenantId = new TenantId(new UUID(queueUpdateMsg.getTenantIdMSB(), queueUpdateMsg.getTenantIdLSB())); + QueueId queueId = new QueueId(new UUID(queueUpdateMsg.getQueueIdMSB(), queueUpdateMsg.getQueueIdLSB())); + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueUpdateMsg.getQueueName(), tenantId); + Queue queue = queueService.findQueueById(tenantId, queueId); + Queue oldQueue = consumerConfigurations.remove(queueKey); + if (oldQueue != null) { + if (oldQueue.isConsumerPerPartition()) { + TbTopicWithConsumerPerPartition consumerPerPartition = topicsConsumerPerPartition.remove(queueKey); + ReentrantLock lock = consumerPerPartition.getLock(); + try { + lock.lock(); + consumerPerPartition.getConsumers().values().forEach(TbQueueConsumer::unsubscribe); + } finally { + lock.unlock(); + } + } else { + TbQueueConsumer> consumer = consumers.remove(queueKey); + consumer.unsubscribe(); + } + } + + initConsumer(queue); + + if (!queue.isConsumerPerPartition()) { + launchConsumer(consumers.get(queueKey), consumerConfigurations.get(queueKey), consumerStats.get(queueKey), queueName); + } + + partitionService.updateQueue(queueUpdateMsg); + partitionService.recalculatePartitions(serviceInfoProvider.getServiceInfo(), new ArrayList<>(partitionService.getOtherServices(ServiceType.TB_RULE_ENGINE))); + } + + private void deleteQueue(TransportProtos.QueueDeleteMsg queueDeleteMsg) { + log.info("Received queue delete msg: [{}]", queueDeleteMsg); + TenantId tenantId = new TenantId(new UUID(queueDeleteMsg.getTenantIdMSB(), queueDeleteMsg.getTenantIdLSB())); + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueDeleteMsg.getQueueName(), tenantId); + + Queue queue = consumerConfigurations.remove(queueKey); + if (queue != null) { + if (queue.isConsumerPerPartition()) { + TbTopicWithConsumerPerPartition tbTopicWithConsumerPerPartition = topicsConsumerPerPartition.remove(queueKey); + if (tbTopicWithConsumerPerPartition != null) { + tbTopicWithConsumerPerPartition.getConsumers().values().forEach(TbQueueConsumer::unsubscribe); + tbTopicWithConsumerPerPartition.getConsumers().clear(); + } + } else { + TbQueueConsumer> consumer = consumers.remove(queueKey); + if (consumer != null) { + consumer.unsubscribe(); + } + } + } + partitionService.removeQueue(queueDeleteMsg); + } + + private void forwardToRuleEngineActor(String queueName, TenantId tenantId, ToRuleEngineMsg toRuleEngineMsg, TbMsgCallback callback) { + TbMsg tbMsg = TbMsg.fromBytes(queueName, toRuleEngineMsg.getTbMsg().toByteArray(), callback); + QueueToRuleEngineMsg msg; + ProtocolStringList relationTypesList = toRuleEngineMsg.getRelationTypesList(); + Set relationTypes = null; + if (relationTypesList != null) { + if (relationTypesList.size() == 1) { + relationTypes = Collections.singleton(relationTypesList.get(0)); + } else { + relationTypes = new HashSet<>(relationTypesList); + } + } + msg = new QueueToRuleEngineMsg(tenantId, tbMsg, relationTypes, toRuleEngineMsg.getFailureMessage()); + actorContext.tell(msg); + } + + @Scheduled(fixedDelayString = "${queue.rule-engine.stats.print-interval-ms}") + public void printStats() { + if (statsEnabled) { + long ts = System.currentTimeMillis(); + consumerStats.forEach((queue, stats) -> { + stats.printStats(); + statisticsService.reportQueueStats(ts, stats); + stats.reset(); + }); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java new file mode 100644 index 0000000..be9dadf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTenantRoutingInfoService.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.discovery.TenantRoutingInfo; +import org.thingsboard.server.queue.discovery.TenantRoutingInfoService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; + +@Slf4j +@Service +@ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core' || '${service.type:null}'=='tb-rule-engine'") +public class DefaultTenantRoutingInfoService implements TenantRoutingInfoService { + + private final TenantService tenantService; + + private final TbTenantProfileCache tenantProfileCache; + + public DefaultTenantRoutingInfoService(TenantService tenantService, TbTenantProfileCache tenantProfileCache) { + this.tenantService = tenantService; + this.tenantProfileCache = tenantProfileCache; + } + + @Override + public TenantRoutingInfo getRoutingInfo(TenantId tenantId) { + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + if (tenantProfile != null) { + return new TenantRoutingInfo(tenantId, tenantProfile.isIsolatedTbRuleEngine()); + } else { + throw new RuntimeException("Tenant not found!"); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerService.java new file mode 100644 index 0000000..6a7b6a0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerService.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +public interface TbCoreConsumerService extends ApplicationListener { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java new file mode 100644 index 0000000..11740ae --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbCoreConsumerStats.java @@ -0,0 +1,147 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class TbCoreConsumerStats { + public static final String TOTAL_MSGS = "totalMsgs"; + public static final String SESSION_EVENTS = "sessionEvents"; + public static final String GET_ATTRIBUTE = "getAttr"; + public static final String ATTRIBUTE_SUBSCRIBES = "subToAttr"; + public static final String RPC_SUBSCRIBES = "subToRpc"; + public static final String TO_DEVICE_RPC_CALL_RESPONSES = "toDevRpc"; + public static final String SUBSCRIPTION_INFO = "subInfo"; + public static final String DEVICE_CLAIMS = "claimDevice"; + public static final String DEVICE_STATES = "deviceState"; + public static final String SUBSCRIPTION_MSGS = "subMsgs"; + public static final String TO_CORE_NOTIFICATIONS = "coreNfs"; + public static final String EDGE_NOTIFICATIONS = "edgeNfs"; + public static final String DEVICE_ACTIVITIES = "deviceActivity"; + + private final StatsCounter totalCounter; + private final StatsCounter sessionEventCounter; + private final StatsCounter getAttributesCounter; + private final StatsCounter subscribeToAttributesCounter; + private final StatsCounter subscribeToRPCCounter; + private final StatsCounter toDeviceRPCCallResponseCounter; + private final StatsCounter subscriptionInfoCounter; + private final StatsCounter claimDeviceCounter; + + private final StatsCounter deviceStateCounter; + private final StatsCounter subscriptionMsgCounter; + private final StatsCounter toCoreNotificationsCounter; + private final StatsCounter edgeNotificationsCounter; + private final StatsCounter deviceActivitiesCounter; + + private final List counters = new ArrayList<>(); + + public TbCoreConsumerStats(StatsFactory statsFactory) { + String statsKey = StatsType.CORE.getName(); + + this.totalCounter = register(statsFactory.createStatsCounter(statsKey, TOTAL_MSGS)); + this.sessionEventCounter = register(statsFactory.createStatsCounter(statsKey, SESSION_EVENTS)); + this.getAttributesCounter = register(statsFactory.createStatsCounter(statsKey, GET_ATTRIBUTE)); + this.subscribeToAttributesCounter = register(statsFactory.createStatsCounter(statsKey, ATTRIBUTE_SUBSCRIBES)); + this.subscribeToRPCCounter = register(statsFactory.createStatsCounter(statsKey, RPC_SUBSCRIBES)); + this.toDeviceRPCCallResponseCounter = register(statsFactory.createStatsCounter(statsKey, TO_DEVICE_RPC_CALL_RESPONSES)); + this.subscriptionInfoCounter = register(statsFactory.createStatsCounter(statsKey, SUBSCRIPTION_INFO)); + this.claimDeviceCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_CLAIMS)); + this.deviceStateCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_STATES)); + this.subscriptionMsgCounter = register(statsFactory.createStatsCounter(statsKey, SUBSCRIPTION_MSGS)); + this.toCoreNotificationsCounter = register(statsFactory.createStatsCounter(statsKey, TO_CORE_NOTIFICATIONS)); + this.edgeNotificationsCounter = register(statsFactory.createStatsCounter(statsKey, EDGE_NOTIFICATIONS)); + this.deviceActivitiesCounter = register(statsFactory.createStatsCounter(statsKey, DEVICE_ACTIVITIES)); + } + + private StatsCounter register(StatsCounter counter){ + counters.add(counter); + return counter; + } + + public void log(TransportProtos.TransportToDeviceActorMsg msg) { + totalCounter.increment(); + if (msg.hasSessionEvent()) { + sessionEventCounter.increment(); + } + if (msg.hasGetAttributes()) { + getAttributesCounter.increment(); + } + if (msg.hasSubscribeToAttributes()) { + subscribeToAttributesCounter.increment(); + } + if (msg.hasSubscribeToRPC()) { + subscribeToRPCCounter.increment(); + } + if (msg.hasToDeviceRPCCallResponse()) { + toDeviceRPCCallResponseCounter.increment(); + } + if (msg.hasSubscriptionInfo()) { + subscriptionInfoCounter.increment(); + } + if (msg.hasClaimDevice()) { + claimDeviceCounter.increment(); + } + } + + public void log(TransportProtos.DeviceStateServiceMsgProto msg) { + totalCounter.increment(); + deviceStateCounter.increment(); + } + + public void log(TransportProtos.EdgeNotificationMsgProto msg) { + totalCounter.increment(); + edgeNotificationsCounter.increment(); + } + + public void log(TransportProtos.DeviceActivityProto msg) { + totalCounter.increment(); + deviceActivitiesCounter.increment(); + } + + public void log(TransportProtos.SubscriptionMgrMsgProto msg) { + totalCounter.increment(); + subscriptionMsgCounter.increment(); + } + + public void log(TransportProtos.ToCoreNotificationMsg msg) { + totalCounter.increment(); + toCoreNotificationsCounter.increment(); + } + + public void printStats() { + int total = totalCounter.get(); + if (total > 0) { + StringBuilder stats = new StringBuilder(); + counters.forEach(counter -> { + stats.append(counter.getName()).append(" = [").append(counter.get()).append("] "); + }); + log.info("Core Stats: {}", stats); + } + } + + public void reset() { + counters.forEach(StatsCounter::clear); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java new file mode 100644 index 0000000..a4f1b19 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class TbMsgPackCallback implements TbMsgCallback { + private final UUID id; + private final TenantId tenantId; + private final TbMsgPackProcessingContext ctx; + private final long startMsgProcessing; + private final Timer successfulMsgTimer; + private final Timer failedMsgTimer; + + public TbMsgPackCallback(UUID id, TenantId tenantId, TbMsgPackProcessingContext ctx) { + this(id, tenantId, ctx, null, null); + } + + public TbMsgPackCallback(UUID id, TenantId tenantId, TbMsgPackProcessingContext ctx, Timer successfulMsgTimer, Timer failedMsgTimer) { + this.id = id; + this.tenantId = tenantId; + this.ctx = ctx; + this.successfulMsgTimer = successfulMsgTimer; + this.failedMsgTimer = failedMsgTimer; + startMsgProcessing = System.currentTimeMillis(); + } + + @Override + public void onSuccess() { + log.trace("[{}] ON SUCCESS", id); + if (successfulMsgTimer != null) { + successfulMsgTimer.record(System.currentTimeMillis() - startMsgProcessing, TimeUnit.MILLISECONDS); + } + ctx.onSuccess(id); + } + + @Override + public void onFailure(RuleEngineException e) { + log.trace("[{}] ON FAILURE", id, e); + if (failedMsgTimer != null) { + failedMsgTimer.record(System.currentTimeMillis() - startMsgProcessing, TimeUnit.MILLISECONDS); + } + ctx.onFailure(tenantId, id, e); + } + + @Override + public boolean isMsgValid() { + return !ctx.isCanceled(); + } + + @Override + public void onProcessingStart(RuleNodeInfo ruleNodeInfo) { + log.trace("[{}] ON PROCESSING START: {}", id, ruleNodeInfo); + ctx.onProcessingStart(id, ruleNodeInfo); + } + + @Override + public void onProcessingEnd(RuleNodeId ruleNodeId) { + log.trace("[{}] ON PROCESSING END: {}", id, ruleNodeId); + ctx.onProcessingEnd(id, ruleNodeId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContext.java new file mode 100644 index 0000000..754c85b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContext.java @@ -0,0 +1,165 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; + +import java.util.Comparator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +public class TbMsgPackProcessingContext { + + private final String queueName; + private final TbRuleEngineSubmitStrategy submitStrategy; + private final boolean skipTimeoutMsgsPossible; + @Getter + private final boolean profilerEnabled; + private final AtomicInteger pendingCount; + private final CountDownLatch processingTimeoutLatch = new CountDownLatch(1); + @Getter + private final ConcurrentMap> pendingMap; + @Getter + private final ConcurrentMap> successMap = new ConcurrentHashMap<>(); + @Getter + private final ConcurrentMap> failedMap = new ConcurrentHashMap<>(); + @Getter + private final ConcurrentMap exceptionsMap = new ConcurrentHashMap<>(); + + private final ConcurrentMap lastRuleNodeMap = new ConcurrentHashMap<>(); + + private volatile boolean canceled = false; + + public TbMsgPackProcessingContext(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeoutMsgsPossible) { + this.queueName = queueName; + this.submitStrategy = submitStrategy; + this.skipTimeoutMsgsPossible = skipTimeoutMsgsPossible; + this.profilerEnabled = log.isDebugEnabled(); + this.pendingMap = submitStrategy.getPendingMap(); + this.pendingCount = new AtomicInteger(pendingMap.size()); + } + + public boolean await(long packProcessingTimeout, TimeUnit milliseconds) throws InterruptedException { + boolean success = processingTimeoutLatch.await(packProcessingTimeout, milliseconds); + if (!success && profilerEnabled) { + msgProfilerMap.values().forEach(this::onTimeout); + } + return success; + } + + public void onSuccess(UUID id) { + TbProtoQueueMsg msg; + boolean empty = false; + msg = pendingMap.remove(id); + if (msg != null) { + empty = pendingCount.decrementAndGet() == 0; + successMap.put(id, msg); + submitStrategy.onSuccess(id); + } + if (empty) { + processingTimeoutLatch.countDown(); + } + } + + public void onFailure(TenantId tenantId, UUID id, RuleEngineException e) { + TbProtoQueueMsg msg; + boolean empty = false; + msg = pendingMap.remove(id); + if (msg != null) { + empty = pendingCount.decrementAndGet() == 0; + failedMap.put(id, msg); + exceptionsMap.putIfAbsent(tenantId, e); + } + if (empty) { + processingTimeoutLatch.countDown(); + } + } + + private final ConcurrentHashMap msgProfilerMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap ruleNodeProfilerMap = new ConcurrentHashMap<>(); + + public void onProcessingStart(UUID id, RuleNodeInfo ruleNodeInfo) { + lastRuleNodeMap.put(id, ruleNodeInfo); + if (profilerEnabled) { + msgProfilerMap.computeIfAbsent(id, TbMsgProfilerInfo::new).onStart(ruleNodeInfo.getRuleNodeId()); + ruleNodeProfilerMap.putIfAbsent(ruleNodeInfo.getRuleNodeId().getId(), new TbRuleNodeProfilerInfo(ruleNodeInfo)); + } + } + + public void onProcessingEnd(UUID id, RuleNodeId ruleNodeId) { + if (profilerEnabled) { + long processingTime = msgProfilerMap.computeIfAbsent(id, TbMsgProfilerInfo::new).onEnd(ruleNodeId); + if (processingTime > 0) { + ruleNodeProfilerMap.computeIfAbsent(ruleNodeId.getId(), TbRuleNodeProfilerInfo::new).record(processingTime); + } + } + } + + public void onTimeout(TbMsgProfilerInfo profilerInfo) { + Map.Entry ruleNodeInfo = profilerInfo.onTimeout(); + if (ruleNodeInfo != null) { + ruleNodeProfilerMap.computeIfAbsent(ruleNodeInfo.getKey(), TbRuleNodeProfilerInfo::new).record(ruleNodeInfo.getValue()); + } + } + + public RuleNodeInfo getLastVisitedRuleNode(UUID id) { + return lastRuleNodeMap.get(id); + } + + public void printProfilerStats() { + if (profilerEnabled) { + log.debug("Top Rule Nodes by max execution time:"); + ruleNodeProfilerMap.values().stream() + .sorted(Comparator.comparingLong(TbRuleNodeProfilerInfo::getMaxExecutionTime).reversed()).limit(5) + .forEach(info -> log.debug("[{}][{}] max execution time: {}. {}", queueName, info.getRuleNodeId(), info.getMaxExecutionTime(), info.getLabel())); + + log.info("Top Rule Nodes by avg execution time:"); + ruleNodeProfilerMap.values().stream() + .sorted(Comparator.comparingDouble(TbRuleNodeProfilerInfo::getAvgExecutionTime).reversed()).limit(5) + .forEach(info -> log.info("[{}][{}] avg execution time: {}. {}", queueName, info.getRuleNodeId(), info.getAvgExecutionTime(), info.getLabel())); + + log.info("Top Rule Nodes by execution count:"); + ruleNodeProfilerMap.values().stream() + .sorted(Comparator.comparingInt(TbRuleNodeProfilerInfo::getExecutionCount).reversed()).limit(5) + .forEach(info -> log.info("[{}][{}] execution count: {}. {}", queueName, info.getRuleNodeId(), info.getExecutionCount(), info.getLabel())); + } + } + + public void cleanup() { + canceled = true; + pendingMap.clear(); + successMap.clear(); + failedMap.clear(); + } + + public boolean isCanceled() { + return skipTimeoutMsgsPossible && canceled; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgProfilerInfo.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgProfilerInfo.java new file mode 100644 index 0000000..3a82d27 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgProfilerInfo.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; + +import java.util.AbstractMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +public class TbMsgProfilerInfo { + private final UUID msgId; + private AtomicLong totalProcessingTime = new AtomicLong(); + private Lock stateLock = new ReentrantLock(); + private RuleNodeId currentRuleNodeId; + private long stateChangeTime; + + public TbMsgProfilerInfo(UUID msgId) { + this.msgId = msgId; + } + + public void onStart(RuleNodeId ruleNodeId) { + long currentTime = System.currentTimeMillis(); + stateLock.lock(); + try { + currentRuleNodeId = ruleNodeId; + stateChangeTime = currentTime; + } finally { + stateLock.unlock(); + } + } + + public long onEnd(RuleNodeId ruleNodeId) { + long currentTime = System.currentTimeMillis(); + stateLock.lock(); + try { + if (ruleNodeId.equals(currentRuleNodeId)) { + long processingTime = currentTime - stateChangeTime; + stateChangeTime = currentTime; + totalProcessingTime.addAndGet(processingTime); + currentRuleNodeId = null; + return processingTime; + } else { + log.trace("[{}] Invalid sequence of rule node processing detected. Expected [{}] but was [{}]", msgId, currentRuleNodeId, ruleNodeId); + return 0; + } + } finally { + stateLock.unlock(); + } + } + + public Map.Entry onTimeout() { + long currentTime = System.currentTimeMillis(); + stateLock.lock(); + try { + if (currentRuleNodeId != null && stateChangeTime > 0) { + long timeoutTime = currentTime - stateChangeTime; + totalProcessingTime.addAndGet(timeoutTime); + return new AbstractMap.SimpleEntry<>(currentRuleNodeId.getId(), timeoutTime); + } + } finally { + stateLock.unlock(); + } + return null; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java b/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java new file mode 100644 index 0000000..cc19d3d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbPackCallback.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.msg.queue.TbCallback; + +import java.util.UUID; + +@Slf4j +public class TbPackCallback implements TbCallback { + private final TbPackProcessingContext ctx; + private final UUID id; + + public TbPackCallback(UUID id, TbPackProcessingContext ctx) { + this.id = id; + this.ctx = ctx; + } + + @Override + public void onSuccess() { + log.trace("[{}] ON SUCCESS", id); + ctx.onSuccess(id); + } + + @Override + public void onFailure(Throwable t) { + log.trace("[{}] ON FAILURE", id, t); + ctx.onFailure(id, t); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbPackProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/queue/TbPackProcessingContext.java new file mode 100644 index 0000000..c02095e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbPackProcessingContext.java @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +public class TbPackProcessingContext { + + private final AtomicInteger pendingCount; + private final CountDownLatch processingTimeoutLatch; + private final ConcurrentMap ackMap; + private final ConcurrentMap failedMap; + + public TbPackProcessingContext(CountDownLatch processingTimeoutLatch, + ConcurrentMap ackMap, + ConcurrentMap failedMap) { + this.processingTimeoutLatch = processingTimeoutLatch; + this.pendingCount = new AtomicInteger(ackMap.size()); + this.ackMap = ackMap; + this.failedMap = failedMap; + } + + public boolean await(long packProcessingTimeout, TimeUnit milliseconds) throws InterruptedException { + return processingTimeoutLatch.await(packProcessingTimeout, milliseconds); + } + + public void onSuccess(UUID id) { + boolean empty = false; + T msg = ackMap.remove(id); + if (msg != null) { + empty = pendingCount.decrementAndGet() == 0; + } + if (empty) { + processingTimeoutLatch.countDown(); + } else { + if (log.isTraceEnabled()) { + log.trace("Items left: {}", ackMap.size()); + for (T t : ackMap.values()) { + log.trace("left item: {}", t); + } + } + } + } + + public void onFailure(UUID id, Throwable t) { + boolean empty = false; + T msg = ackMap.remove(id); + if (msg != null) { + empty = pendingCount.decrementAndGet() == 0; + failedMap.put(id, msg); + if (log.isTraceEnabled()) { + log.trace("Items left: {}", ackMap.size()); + for (T v : ackMap.values()) { + log.trace("left item: {}", v); + } + } + } + if (empty) { + processingTimeoutLatch.countDown(); + } + } + + public ConcurrentMap getAckMap() { + return ackMap; + } + + public ConcurrentMap getFailedMap() { + return failedMap; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerService.java new file mode 100644 index 0000000..5a2a91a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerService.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +public interface TbRuleEngineConsumerService extends ApplicationListener { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerStats.java b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerStats.java new file mode 100644 index 0000000..96c754c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleEngineConsumerStats.java @@ -0,0 +1,167 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsType; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Slf4j +public class TbRuleEngineConsumerStats { + + public static final String TOTAL_MSGS = "totalMsgs"; + public static final String SUCCESSFUL_MSGS = "successfulMsgs"; + public static final String TMP_TIMEOUT = "tmpTimeout"; + public static final String TMP_FAILED = "tmpFailed"; + public static final String TIMEOUT_MSGS = "timeoutMsgs"; + public static final String FAILED_MSGS = "failedMsgs"; + public static final String SUCCESSFUL_ITERATIONS = "successfulIterations"; + public static final String FAILED_ITERATIONS = "failedIterations"; + + private final StatsFactory statsFactory; + + private final StatsCounter totalMsgCounter; + private final StatsCounter successMsgCounter; + private final StatsCounter tmpTimeoutMsgCounter; + private final StatsCounter tmpFailedMsgCounter; + + private final StatsCounter timeoutMsgCounter; + private final StatsCounter failedMsgCounter; + + private final StatsCounter successIterationsCounter; + private final StatsCounter failedIterationsCounter; + + private final List counters = new ArrayList<>(); + private final ConcurrentMap tenantStats = new ConcurrentHashMap<>(); + private final ConcurrentMap tenantMsgProcessTimers = new ConcurrentHashMap<>(); + private final ConcurrentMap tenantExceptions = new ConcurrentHashMap<>(); + + private final String queueName; + + public TbRuleEngineConsumerStats(String queueName, StatsFactory statsFactory) { + this.queueName = queueName; + this.statsFactory = statsFactory; + + String statsKey = StatsType.RULE_ENGINE.getName() + "." + queueName; + this.totalMsgCounter = statsFactory.createStatsCounter(statsKey, TOTAL_MSGS); + this.successMsgCounter = statsFactory.createStatsCounter(statsKey, SUCCESSFUL_MSGS); + this.timeoutMsgCounter = statsFactory.createStatsCounter(statsKey, TIMEOUT_MSGS); + this.failedMsgCounter = statsFactory.createStatsCounter(statsKey, FAILED_MSGS); + this.tmpTimeoutMsgCounter = statsFactory.createStatsCounter(statsKey, TMP_TIMEOUT); + this.tmpFailedMsgCounter = statsFactory.createStatsCounter(statsKey, TMP_FAILED); + this.successIterationsCounter = statsFactory.createStatsCounter(statsKey, SUCCESSFUL_ITERATIONS); + this.failedIterationsCounter = statsFactory.createStatsCounter(statsKey, FAILED_ITERATIONS); + + counters.add(totalMsgCounter); + counters.add(successMsgCounter); + counters.add(timeoutMsgCounter); + counters.add(failedMsgCounter); + + counters.add(tmpTimeoutMsgCounter); + counters.add(tmpFailedMsgCounter); + counters.add(successIterationsCounter); + counters.add(failedIterationsCounter); + } + + public Timer getTimer(TenantId tenantId, String status){ + return tenantMsgProcessTimers.computeIfAbsent(tenantId, + id -> statsFactory.createTimer(StatsType.RULE_ENGINE.getName() + "." + queueName, + "tenantId", tenantId.getId().toString(), + "status", status + )); + } + + public void log(TbRuleEngineProcessingResult msg, boolean finalIterationForPack) { + int success = msg.getSuccessMap().size(); + int pending = msg.getPendingMap().size(); + int failed = msg.getFailedMap().size(); + totalMsgCounter.add(success + pending + failed); + successMsgCounter.add(success); + msg.getSuccessMap().values().forEach(m -> getTenantStats(m).logSuccess()); + if (finalIterationForPack) { + if (pending > 0 || failed > 0) { + timeoutMsgCounter.add(pending); + failedMsgCounter.add(failed); + if (pending > 0) { + msg.getPendingMap().values().forEach(m -> getTenantStats(m).logTimeout()); + } + if (failed > 0) { + msg.getFailedMap().values().forEach(m -> getTenantStats(m).logFailed()); + } + failedIterationsCounter.increment(); + } else { + successIterationsCounter.increment(); + } + } else { + failedIterationsCounter.increment(); + tmpTimeoutMsgCounter.add(pending); + tmpFailedMsgCounter.add(failed); + if (pending > 0) { + msg.getPendingMap().values().forEach(m -> getTenantStats(m).logTmpTimeout()); + } + if (failed > 0) { + msg.getFailedMap().values().forEach(m -> getTenantStats(m).logTmpFailed()); + } + } + msg.getExceptionsMap().forEach(tenantExceptions::putIfAbsent); + } + + private TbTenantRuleEngineStats getTenantStats(TbProtoQueueMsg m) { + ToRuleEngineMsg reMsg = m.getValue(); + return tenantStats.computeIfAbsent(new UUID(reMsg.getTenantIdMSB(), reMsg.getTenantIdLSB()), TbTenantRuleEngineStats::new); + } + + public ConcurrentMap getTenantStats() { + return tenantStats; + } + + public String getQueueName() { + return queueName; + } + + public ConcurrentMap getTenantExceptions() { + return tenantExceptions; + } + + public void printStats() { + int total = totalMsgCounter.get(); + if (total > 0) { + StringBuilder stats = new StringBuilder(); + counters.forEach(counter -> { + stats.append(counter.getName()).append(" = [").append(counter.get()).append("] "); + }); + log.info("[{}] Stats: {}", queueName, stats); + } + } + + public void reset() { + counters.forEach(StatsCounter::clear); + tenantStats.clear(); + tenantExceptions.clear(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbRuleNodeProfilerInfo.java b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleNodeProfilerInfo.java new file mode 100644 index 0000000..243fde4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbRuleNodeProfilerInfo.java @@ -0,0 +1,75 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.Getter; +import org.thingsboard.server.common.msg.queue.RuleNodeInfo; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class TbRuleNodeProfilerInfo { + @Getter + private final UUID ruleNodeId; + @Getter + private final String label; + private AtomicInteger executionCount = new AtomicInteger(0); + private AtomicLong executionTime = new AtomicLong(0); + private AtomicLong maxExecutionTime = new AtomicLong(0); + + public TbRuleNodeProfilerInfo(RuleNodeInfo ruleNodeInfo) { + this.ruleNodeId = ruleNodeInfo.getRuleNodeId().getId(); + this.label = ruleNodeInfo.toString(); + } + + public TbRuleNodeProfilerInfo(UUID ruleNodeId) { + this.ruleNodeId = ruleNodeId; + this.label = ""; + } + + public void record(long processingTime) { + executionCount.incrementAndGet(); + executionTime.addAndGet(processingTime); + while (true) { + long value = maxExecutionTime.get(); + if (value >= processingTime) { + break; + } + if (maxExecutionTime.compareAndSet(value, processingTime)) { + break; + } + } + } + + int getExecutionCount() { + return executionCount.get(); + } + + long getMaxExecutionTime() { + return maxExecutionTime.get(); + } + + double getAvgExecutionTime() { + double executionCnt = (double) executionCount.get(); + if (executionCnt > 0) { + return executionTime.get() / executionCnt; + } else { + return 0.0; + } + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbTenantRuleEngineStats.java b/application/src/main/java/org/thingsboard/server/service/queue/TbTenantRuleEngineStats.java new file mode 100644 index 0000000..8ea8a09 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbTenantRuleEngineStats.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Data +public class TbTenantRuleEngineStats { + + private final UUID tenantId; + + private final AtomicInteger totalMsgCounter = new AtomicInteger(0); + private final AtomicInteger successMsgCounter = new AtomicInteger(0); + private final AtomicInteger tmpTimeoutMsgCounter = new AtomicInteger(0); + private final AtomicInteger tmpFailedMsgCounter = new AtomicInteger(0); + + private final AtomicInteger timeoutMsgCounter = new AtomicInteger(0); + private final AtomicInteger failedMsgCounter = new AtomicInteger(0); + + private final Map counters = new HashMap<>(); + + public TbTenantRuleEngineStats(UUID tenantId) { + this.tenantId = tenantId; + counters.put(TbRuleEngineConsumerStats.TOTAL_MSGS, totalMsgCounter); + counters.put(TbRuleEngineConsumerStats.SUCCESSFUL_MSGS, successMsgCounter); + counters.put(TbRuleEngineConsumerStats.TIMEOUT_MSGS, timeoutMsgCounter); + counters.put(TbRuleEngineConsumerStats.FAILED_MSGS, failedMsgCounter); + + counters.put(TbRuleEngineConsumerStats.TMP_TIMEOUT, tmpTimeoutMsgCounter); + counters.put(TbRuleEngineConsumerStats.TMP_FAILED, tmpFailedMsgCounter); + } + + public void logSuccess() { + totalMsgCounter.incrementAndGet(); + successMsgCounter.incrementAndGet(); + } + + public void logFailed() { + totalMsgCounter.incrementAndGet(); + failedMsgCounter.incrementAndGet(); + } + + public void logTimeout() { + totalMsgCounter.incrementAndGet(); + timeoutMsgCounter.incrementAndGet(); + } + + public void logTmpFailed() { + totalMsgCounter.incrementAndGet(); + tmpFailedMsgCounter.incrementAndGet(); + } + + public void logTmpTimeout() { + totalMsgCounter.incrementAndGet(); + tmpTimeoutMsgCounter.incrementAndGet(); + } + + public void printStats() { + int total = totalMsgCounter.get(); + if (total > 0) { + StringBuilder stats = new StringBuilder(); + counters.forEach((label, value) -> { + stats.append(label).append(" = [").append(value.get()).append("]"); + }); + log.info("[{}] Stats: {}", tenantId, stats); + } + } + + public void reset() { + counters.values().forEach(counter -> counter.set(0)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbTopicWithConsumerPerPartition.java b/application/src/main/java/org/thingsboard/server/service/queue/TbTopicWithConsumerPerPartition.java new file mode 100644 index 0000000..e51b345 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbTopicWithConsumerPerPartition.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.Collections; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +@RequiredArgsConstructor +@Data +public class TbTopicWithConsumerPerPartition { + private final String topic; + @Getter + private final ReentrantLock lock = new ReentrantLock(); //NonfairSync + private volatile Set partitions = Collections.emptySet(); + private final ConcurrentMap>> consumers = new ConcurrentHashMap<>(); + private final Queue> subscribeQueue = new ConcurrentLinkedQueue<>(); +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java new file mode 100644 index 0000000..c814ab1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -0,0 +1,230 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import com.google.protobuf.ByteString; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.queue.TbPackCallback; +import org.thingsboard.server.service.queue.TbPackProcessingContext; + +import javax.annotation.PreDestroy; +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.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +public abstract class AbstractConsumerService extends TbApplicationEventListener { + + protected volatile ExecutorService consumersExecutor; + protected volatile ExecutorService notificationsConsumerExecutor; + protected volatile boolean stopped = false; + + protected final ActorSystemContext actorContext; + protected final DataDecodingEncodingService encodingService; + protected final TbTenantProfileCache tenantProfileCache; + protected final TbDeviceProfileCache deviceProfileCache; + protected final TbAssetProfileCache assetProfileCache; + protected final TbApiUsageStateService apiUsageStateService; + protected final PartitionService partitionService; + + protected final TbQueueConsumer> nfConsumer; + protected final Optional jwtSettingsService; + + + public AbstractConsumerService(ActorSystemContext actorContext, DataDecodingEncodingService encodingService, + TbTenantProfileCache tenantProfileCache, TbDeviceProfileCache deviceProfileCache, + TbAssetProfileCache assetProfileCache, TbApiUsageStateService apiUsageStateService, + PartitionService partitionService, TbQueueConsumer> nfConsumer, Optional jwtSettingsService) { + this.actorContext = actorContext; + this.encodingService = encodingService; + this.tenantProfileCache = tenantProfileCache; + this.deviceProfileCache = deviceProfileCache; + this.assetProfileCache = assetProfileCache; + this.apiUsageStateService = apiUsageStateService; + this.partitionService = partitionService; + this.nfConsumer = nfConsumer; + this.jwtSettingsService = jwtSettingsService; + } + + public void init(String mainConsumerThreadName, String nfConsumerThreadName) { + this.consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName(mainConsumerThreadName)); + this.notificationsConsumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(nfConsumerThreadName)); + } + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onApplicationEvent(ApplicationReadyEvent event) { + log.info("Subscribing to notifications: {}", nfConsumer.getTopic()); + this.nfConsumer.subscribe(); + launchNotificationsConsumer(); + launchMainConsumers(); + } + + protected abstract ServiceType getServiceType(); + + protected abstract void launchMainConsumers(); + + protected abstract void stopMainConsumers(); + + protected abstract long getNotificationPollDuration(); + + protected abstract long getNotificationPackProcessingTimeout(); + + protected void launchNotificationsConsumer() { + notificationsConsumerExecutor.submit(() -> { + while (!stopped) { + try { + List> msgs = nfConsumer.poll(getNotificationPollDuration()); + if (msgs.isEmpty()) { + continue; + } + ConcurrentMap> pendingMap = msgs.stream().collect( + Collectors.toConcurrentMap(s -> UUID.randomUUID(), Function.identity())); + CountDownLatch processingTimeoutLatch = new CountDownLatch(1); + TbPackProcessingContext> ctx = new TbPackProcessingContext<>( + processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); + pendingMap.forEach((id, msg) -> { + log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue()); + TbCallback callback = new TbPackCallback<>(id, ctx); + try { + handleNotification(id, msg, callback); + } catch (Throwable e) { + log.warn("[{}] Failed to process notification: {}", id, msg, e); + callback.onFailure(e); + } + }); + if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) { + ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process notification: {}", id, msg.getValue())); + ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process notification: {}", id, msg.getValue())); + } + nfConsumer.commit(); + } catch (Exception e) { + if (!stopped) { + log.warn("Failed to obtain notifications from queue.", e); + try { + Thread.sleep(getNotificationPollDuration()); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new notifications", e2); + } + } + } + } + log.info("TB Notifications Consumer stopped."); + }); + } + + protected void handleComponentLifecycleMsg(UUID id, ByteString nfMsg) { + Optional actorMsgOpt = encodingService.decode(nfMsg.toByteArray()); + if (actorMsgOpt.isPresent()) { + TbActorMsg actorMsg = actorMsgOpt.get(); + if (actorMsg instanceof ComponentLifecycleMsg) { + ComponentLifecycleMsg componentLifecycleMsg = (ComponentLifecycleMsg) actorMsg; + log.debug("[{}][{}][{}] Received Lifecycle event: {}", componentLifecycleMsg.getTenantId(), componentLifecycleMsg.getEntityId().getEntityType(), + componentLifecycleMsg.getEntityId(), componentLifecycleMsg.getEvent()); + if (EntityType.TENANT_PROFILE.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + TenantProfileId tenantProfileId = new TenantProfileId(componentLifecycleMsg.getEntityId().getId()); + tenantProfileCache.evict(tenantProfileId); + if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED)) { + apiUsageStateService.onTenantProfileUpdate(tenantProfileId); + } + } else if (EntityType.TENANT.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + if (TenantId.SYS_TENANT_ID.equals(componentLifecycleMsg.getTenantId())) { + jwtSettingsService.ifPresent(JwtSettingsService::reloadJwtSettings); + return; + } else { + tenantProfileCache.evict(componentLifecycleMsg.getTenantId()); + partitionService.removeTenant(componentLifecycleMsg.getTenantId()); + if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED)) { + apiUsageStateService.onTenantUpdate(componentLifecycleMsg.getTenantId()); + } else if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.DELETED)) { + apiUsageStateService.onTenantDelete((TenantId) componentLifecycleMsg.getEntityId()); + } + } + } else if (EntityType.DEVICE_PROFILE.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + deviceProfileCache.evict(componentLifecycleMsg.getTenantId(), new DeviceProfileId(componentLifecycleMsg.getEntityId().getId())); + } else if (EntityType.DEVICE.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + deviceProfileCache.evict(componentLifecycleMsg.getTenantId(), new DeviceId(componentLifecycleMsg.getEntityId().getId())); + } else if (EntityType.ASSET_PROFILE.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + assetProfileCache.evict(componentLifecycleMsg.getTenantId(), new AssetProfileId(componentLifecycleMsg.getEntityId().getId())); + } else if (EntityType.ASSET.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + assetProfileCache.evict(componentLifecycleMsg.getTenantId(), new AssetId(componentLifecycleMsg.getEntityId().getId())); + } else if (EntityType.ENTITY_VIEW.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + actorContext.getTbEntityViewService().onComponentLifecycleMsg(componentLifecycleMsg); + } else if (EntityType.API_USAGE_STATE.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + apiUsageStateService.onApiUsageStateUpdate(componentLifecycleMsg.getTenantId()); + } else if (EntityType.CUSTOMER.equals(componentLifecycleMsg.getEntityId().getEntityType())) { + if (componentLifecycleMsg.getEvent() == ComponentLifecycleEvent.DELETED) { + apiUsageStateService.onCustomerDelete((CustomerId) componentLifecycleMsg.getEntityId()); + } + } + } + log.trace("[{}] Forwarding message to App Actor {}", id, actorMsg); + actorContext.tellWithHighPriority(actorMsg); + } + } + + protected abstract void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) throws Exception; + + @PreDestroy + public void destroy() { + stopped = true; + stopMainConsumers(); + if (nfConsumer != null) { + nfConsumer.unsubscribe(); + } + if (consumersExecutor != null) { + consumersExecutor.shutdownNow(); + } + if (notificationsConsumerExecutor != null) { + notificationsConsumerExecutor.shutdownNow(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..3f21ae1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +public abstract class AbstractTbRuleEngineSubmitStrategy implements TbRuleEngineSubmitStrategy { + + protected final String queueName; + protected List> orderedMsgList; + private volatile boolean stopped; + + public AbstractTbRuleEngineSubmitStrategy(String queueName) { + this.queueName = queueName; + } + + protected abstract void doOnSuccess(UUID id); + + @Override + public void init(List> msgs) { + orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList()); + } + + @Override + public ConcurrentMap> getPendingMap() { + return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid, pair -> pair.msg)); + } + + @Override + public void update(ConcurrentMap> reprocessMap) { + List> newOrderedMsgList = new ArrayList<>(reprocessMap.size()); + for (IdMsgPair pair : orderedMsgList) { + if (reprocessMap.containsKey(pair.uuid)) { + newOrderedMsgList.add(pair); + } + } + orderedMsgList = newOrderedMsgList; + } + + @Override + public void onSuccess(UUID id) { + if (!stopped) { + doOnSuccess(id); + } + } + + @Override + public void stop() { + stopped = true; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..652d781 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; + +@Slf4j +public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitStrategy { + + private final int batchSize; + private final AtomicInteger packIdx = new AtomicInteger(0); + private final Map> pendingPack = new LinkedHashMap<>(); + private volatile BiConsumer> msgConsumer; + + public BatchTbRuleEngineSubmitStrategy(String queueName, int batchSize) { + super(queueName); + this.batchSize = batchSize; + } + + @Override + public void submitAttempt(BiConsumer> msgConsumer) { + this.msgConsumer = msgConsumer; + submitNext(); + } + + @Override + public void update(ConcurrentMap> reprocessMap) { + super.update(reprocessMap); + packIdx.set(0); + } + + @Override + protected void doOnSuccess(UUID id) { + boolean endOfPendingPack; + synchronized (pendingPack) { + TbProtoQueueMsg msg = pendingPack.remove(id); + endOfPendingPack = msg != null && pendingPack.isEmpty(); + } + if (endOfPendingPack) { + packIdx.incrementAndGet(); + submitNext(); + } + } + + private void submitNext() { + int listSize = orderedMsgList.size(); + int startIdx = Math.min(packIdx.get() * batchSize, listSize); + int endIdx = Math.min(startIdx + batchSize, listSize); + Map> tmpPack; + synchronized (pendingPack) { + pendingPack.clear(); + for (int i = startIdx; i < endIdx; i++) { + IdMsgPair pair = orderedMsgList.get(i); + pendingPack.put(pair.uuid, pair.msg); + } + tmpPack = new LinkedHashMap<>(pendingPack); + } + int submitSize = pendingPack.size(); + if (log.isDebugEnabled() && submitSize > 0) { + log.debug("[{}] submitting [{}] messages to rule engine", queueName, submitSize); + } + tmpPack.forEach(msgConsumer); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..5d4696b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.UUID; +import java.util.function.BiConsumer; + +@Slf4j +public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitStrategy { + + public BurstTbRuleEngineSubmitStrategy(String queueName) { + super(queueName); + } + + @Override + public void submitAttempt(BiConsumer> msgConsumer) { + if (log.isDebugEnabled()) { + log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size()); + } + orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid, pair.msg)); + } + + @Override + protected void doOnSuccess(UUID id) { + + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java new file mode 100644 index 0000000..9b15e67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.Getter; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.UUID; + +public class IdMsgPair { + @Getter + final UUID uuid; + @Getter + final TbProtoQueueMsg msg; + + public IdMsgPair(UUID uuid, TbProtoQueueMsg msg) { + this.uuid = uuid; + this.msg = msg; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..ef9c665 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java @@ -0,0 +1,100 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiConsumer; + +@Slf4j +public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitStrategy { + + private volatile BiConsumer> msgConsumer; + private volatile ConcurrentMap msgToEntityIdMap = new ConcurrentHashMap<>(); + private volatile ConcurrentMap>> entityIdToListMap = new ConcurrentHashMap<>(); + + public SequentialByEntityIdTbRuleEngineSubmitStrategy(String queueName) { + super(queueName); + } + + @Override + public void init(List> msgs) { + super.init(msgs); + initMaps(); + } + + @Override + public void submitAttempt(BiConsumer> msgConsumer) { + this.msgConsumer = msgConsumer; + entityIdToListMap.forEach((entityId, queue) -> { + IdMsgPair msg = queue.peek(); + if (msg != null) { + msgConsumer.accept(msg.uuid, msg.msg); + } + }); + } + + @Override + public void update(ConcurrentMap> reprocessMap) { + super.update(reprocessMap); + initMaps(); + } + + @Override + protected void doOnSuccess(UUID id) { + EntityId entityId = msgToEntityIdMap.get(id); + if (entityId != null) { + Queue> queue = entityIdToListMap.get(entityId); + if (queue != null) { + IdMsgPair next = null; + synchronized (queue) { + IdMsgPair expected = queue.peek(); + if (expected != null && expected.uuid.equals(id)) { + queue.poll(); + next = queue.peek(); + } + } + if (next != null) { + msgConsumer.accept(next.uuid, next.msg); + } + } + } + } + + private void initMaps() { + msgToEntityIdMap.clear(); + entityIdToListMap.clear(); + for (IdMsgPair pair : orderedMsgList) { + EntityId entityId = getEntityId(pair.msg.getValue()); + if (entityId != null) { + msgToEntityIdMap.put(pair.uuid, entityId); + entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair); + } + } + } + + protected abstract EntityId getEntityId(TransportProtos.ToRuleEngineMsg msg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByOriginatorIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByOriginatorIdTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..872da21 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByOriginatorIdTbRuleEngineSubmitStrategy.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import com.google.protobuf.InvalidProtocolBufferException; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.msg.gen.MsgProtos; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; + +@Slf4j +public class SequentialByOriginatorIdTbRuleEngineSubmitStrategy extends SequentialByEntityIdTbRuleEngineSubmitStrategy { + + public SequentialByOriginatorIdTbRuleEngineSubmitStrategy(String queueName) { + super(queueName); + } + + @Override + protected EntityId getEntityId(TransportProtos.ToRuleEngineMsg msg) { + try { + MsgProtos.TbMsgProto proto = MsgProtos.TbMsgProto.parseFrom(msg.getTbMsg()); + return EntityIdFactory.getByTypeAndUuid(proto.getEntityType(), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); + } catch (InvalidProtocolBufferException e) { + log.warn("[{}] Failed to parse TbMsg: {}", queueName, msg); + return null; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByTenantIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByTenantIdTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..8b18613 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByTenantIdTbRuleEngineSubmitStrategy.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; + +public class SequentialByTenantIdTbRuleEngineSubmitStrategy extends SequentialByEntityIdTbRuleEngineSubmitStrategy { + + public SequentialByTenantIdTbRuleEngineSubmitStrategy(String queueName) { + super(queueName); + } + + @Override + protected EntityId getEntityId(TransportProtos.ToRuleEngineMsg msg) { + return TenantId.fromUUID(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB())); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..dd4cf21 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; + +@Slf4j +public class SequentialTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitStrategy { + + private final AtomicInteger msgIdx = new AtomicInteger(0); + private volatile BiConsumer> msgConsumer; + private volatile UUID expectedMsgId; + + public SequentialTbRuleEngineSubmitStrategy(String queueName) { + super(queueName); + } + + @Override + public void submitAttempt(BiConsumer> msgConsumer) { + this.msgConsumer = msgConsumer; + msgIdx.set(0); + submitNext(); + } + + @Override + public void update(ConcurrentMap> reprocessMap) { + super.update(reprocessMap); + } + + @Override + protected void doOnSuccess(UUID id) { + if (expectedMsgId.equals(id)) { + msgIdx.incrementAndGet(); + submitNext(); + } + } + + private void submitNext() { + int listSize = orderedMsgList.size(); + int idx = msgIdx.get(); + if (idx < listSize) { + IdMsgPair pair = orderedMsgList.get(idx); + expectedMsgId = pair.uuid; + if (log.isDebugEnabled()) { + log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg); + } + msgConsumer.accept(pair.uuid, pair.msg); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingDecision.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingDecision.java new file mode 100644 index 0000000..f68864a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingDecision.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +@Data +public class TbRuleEngineProcessingDecision { + + private final boolean commit; + private final ConcurrentMap> reprocessMap; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingResult.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingResult.java new file mode 100644 index 0000000..001f4a4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingResult.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +public class TbRuleEngineProcessingResult { + + @Getter + private final String queueName; + @Getter + private final boolean success; + @Getter + private final boolean timeout; + @Getter + private final TbMsgPackProcessingContext ctx; + + public TbRuleEngineProcessingResult(String queueName, boolean timeout, TbMsgPackProcessingContext ctx) { + this.queueName = queueName; + this.timeout = timeout; + this.ctx = ctx; + this.success = !timeout && ctx.getPendingMap().isEmpty() && ctx.getFailedMap().isEmpty(); + } + + public ConcurrentMap> getPendingMap() { + return ctx.getPendingMap(); + } + + public ConcurrentMap> getSuccessMap() { + return ctx.getSuccessMap(); + } + + public ConcurrentMap> getFailedMap() { + return ctx.getFailedMap(); + } + + public ConcurrentMap getExceptionsMap() { + return ctx.getExceptionsMap(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategy.java new file mode 100644 index 0000000..b0a26c2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategy.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +public interface TbRuleEngineProcessingStrategy { + + boolean isSkipTimeoutMsgs(); + + TbRuleEngineProcessingDecision analyze(TbRuleEngineProcessingResult result); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategyFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategyFactory.java new file mode 100644 index 0000000..e50d560 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineProcessingStrategyFactory.java @@ -0,0 +1,175 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +public class TbRuleEngineProcessingStrategyFactory { + + public TbRuleEngineProcessingStrategy newInstance(String name, ProcessingStrategy processingStrategy) { + switch (processingStrategy.getType()) { + case SKIP_ALL_FAILURES: + return new SkipStrategy(name, false); + case SKIP_ALL_FAILURES_AND_TIMED_OUT: + return new SkipStrategy(name, true); + case RETRY_ALL: + return new RetryStrategy(name, true, true, true, processingStrategy); + case RETRY_FAILED: + return new RetryStrategy(name, false, true, false, processingStrategy); + case RETRY_TIMED_OUT: + return new RetryStrategy(name, false, false, true, processingStrategy); + case RETRY_FAILED_AND_TIMED_OUT: + return new RetryStrategy(name, false, true, true, processingStrategy); + default: + throw new RuntimeException("TbRuleEngineProcessingStrategy with type " + processingStrategy.getType() + " is not supported!"); + } + } + + private static class RetryStrategy implements TbRuleEngineProcessingStrategy { + private final String queueName; + private final boolean retrySuccessful; + private final boolean retryFailed; + private final boolean retryTimeout; + private final int maxRetries; + private final double maxAllowedFailurePercentage; + private final long maxPauseBetweenRetries; + + private long pauseBetweenRetries; + + private int initialTotalCount; + private int retryCount; + + public RetryStrategy(String queueName, boolean retrySuccessful, boolean retryFailed, boolean retryTimeout, ProcessingStrategy processingStrategy) { + this.queueName = queueName; + this.retrySuccessful = retrySuccessful; + this.retryFailed = retryFailed; + this.retryTimeout = retryTimeout; + this.maxRetries = processingStrategy.getRetries(); + this.maxAllowedFailurePercentage = processingStrategy.getFailurePercentage(); + this.pauseBetweenRetries = processingStrategy.getPauseBetweenRetries(); + this.maxPauseBetweenRetries = processingStrategy.getMaxPauseBetweenRetries(); + } + + @Override + public boolean isSkipTimeoutMsgs() { + return true; + } + + @Override + public TbRuleEngineProcessingDecision analyze(TbRuleEngineProcessingResult result) { + if (result.isSuccess()) { + log.trace("[{}] The result of the msg pack processing is successful, going to proceed with processing of the following msgs", queueName); + return new TbRuleEngineProcessingDecision(true, null); + } else { + if (retryCount == 0) { + initialTotalCount = result.getPendingMap().size() + result.getFailedMap().size() + result.getSuccessMap().size(); + } + retryCount++; + double failedCount = result.getFailedMap().size() + result.getPendingMap().size(); + if (maxRetries > 0 && retryCount > maxRetries) { + log.debug("[{}] Skip reprocess of the rule engine pack due to max retries", queueName); + return new TbRuleEngineProcessingDecision(true, null); + } else if (maxAllowedFailurePercentage > 0 && (failedCount / initialTotalCount) > maxAllowedFailurePercentage) { + log.debug("[{}] Skip reprocess of the rule engine pack due to max allowed failure percentage", queueName); + return new TbRuleEngineProcessingDecision(true, null); + } else { + log.debug("[{}] The result of msg pack processing is unsuccessful, checking unprocessed msgs and going to reprocess them", queueName); + ConcurrentMap> toReprocess = new ConcurrentHashMap<>(initialTotalCount); + if (retryFailed) { + result.getFailedMap().forEach(toReprocess::put); + } else if (log.isDebugEnabled() && !result.getFailedMap().isEmpty()) { + log.debug("[{}] Skipped {} failed messages due to the processing strategy configuration", queueName, result.getFailedMap().size()); + } + if (retryTimeout) { + result.getPendingMap().forEach(toReprocess::put); + } else if (log.isDebugEnabled() && !result.getPendingMap().isEmpty()) { + log.debug("[{}] Skipped {} timedOut messages due to the processing strategy configuration", queueName, result.getPendingMap().size()); + } + if (retrySuccessful) { + result.getSuccessMap().forEach(toReprocess::put); + } else if (log.isTraceEnabled() && !result.getSuccessMap().isEmpty()) { + log.trace("[{}] Skipped {} successful messages due to the processing strategy configuration", queueName, result.getSuccessMap().size()); + } + if (CollectionUtils.isEmpty(toReprocess)) { + if (log.isDebugEnabled()) { + log.debug("[{}] Stopping the reprocessing logic due to reprocessing map is empty", queueName); + } + return new TbRuleEngineProcessingDecision(true, null); + } + log.debug("[{}] Going to reprocess {} messages", queueName, toReprocess.size()); + if (log.isTraceEnabled()) { + toReprocess.forEach((id, msg) -> log.trace("Going to reprocess [{}]: {}", id, TbMsg.fromBytes(result.getQueueName(), msg.getValue().getTbMsg().toByteArray(), TbMsgCallback.EMPTY))); + } + if (pauseBetweenRetries > 0) { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(pauseBetweenRetries)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + if (maxPauseBetweenRetries > pauseBetweenRetries) { + pauseBetweenRetries = Math.min(maxPauseBetweenRetries, pauseBetweenRetries * 2); + } + } + return new TbRuleEngineProcessingDecision(false, toReprocess); + } + } + } + } + + private static class SkipStrategy implements TbRuleEngineProcessingStrategy { + + private final String queueName; + private final boolean skipTimeoutMsgs; + + public SkipStrategy(String name, boolean skipTimeoutMsgs) { + this.queueName = name; + this.skipTimeoutMsgs = skipTimeoutMsgs; + } + + @Override + public boolean isSkipTimeoutMsgs() { + return skipTimeoutMsgs; + } + + @Override + public TbRuleEngineProcessingDecision analyze(TbRuleEngineProcessingResult result) { + if (!result.isSuccess()) { + log.debug("[{}] Reprocessing skipped for {} failed and {} timeout messages", queueName, result.getFailedMap().size(), result.getPendingMap().size()); + } + if (log.isTraceEnabled()) { + result.getFailedMap().forEach((id, msg) -> log.trace("Failed messages [{}]: {}", id, TbMsg.fromBytes(result.getQueueName(), msg.getValue().getTbMsg().toByteArray(), TbMsgCallback.EMPTY))); + } + if (log.isTraceEnabled()) { + result.getPendingMap().forEach((id, msg) -> log.trace("Timeout messages [{}]: {}", id, TbMsg.fromBytes(result.getQueueName(), msg.getValue().getTbMsg().toByteArray(), TbMsgCallback.EMPTY))); + } + return new TbRuleEngineProcessingDecision(true, null); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategy.java new file mode 100644 index 0000000..fed7170 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategy.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiConsumer; + +public interface TbRuleEngineSubmitStrategy { + + void init(List> msgs); + + ConcurrentMap> getPendingMap(); + + void submitAttempt(BiConsumer> msgConsumer); + + void update(ConcurrentMap> reprocessMap); + + void onSuccess(UUID id); + + void stop(); +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategyFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategyFactory.java new file mode 100644 index 0000000..5c4d3b1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/TbRuleEngineSubmitStrategyFactory.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.processing; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.queue.settings.TbRuleEngineQueueSubmitStrategyConfiguration; + +@Component +@Slf4j +public class TbRuleEngineSubmitStrategyFactory { + + public TbRuleEngineSubmitStrategy newInstance(String name, SubmitStrategy submitStrategy) { + switch (submitStrategy.getType()) { + case BURST: + return new BurstTbRuleEngineSubmitStrategy(name); + case BATCH: + return new BatchTbRuleEngineSubmitStrategy(name, submitStrategy.getBatchSize()); + case SEQUENTIAL_BY_ORIGINATOR: + return new SequentialByOriginatorIdTbRuleEngineSubmitStrategy(name); + case SEQUENTIAL_BY_TENANT: + return new SequentialByTenantIdTbRuleEngineSubmitStrategy(name); + case SEQUENTIAL: + return new SequentialTbRuleEngineSubmitStrategy(name); + default: + throw new RuntimeException("TbRuleEngineProcessingStrategy with type " + submitStrategy.getType() + " is not supported!"); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java new file mode 100644 index 0000000..5853516 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java @@ -0,0 +1,249 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.resource; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.leshan.core.model.DDFFileParser; +import org.eclipse.leshan.core.model.DefaultDDFFileValidator; +import org.eclipse.leshan.core.model.InvalidDDFFileException; +import org.eclipse.leshan.core.model.ObjectModel; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.lwm2m.LwM2mInstance; +import org.thingsboard.server.common.data.lwm2m.LwM2mObject; +import org.thingsboard.server.common.data.lwm2m.LwM2mResourceObserve; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_KEY; +import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_SEARCH_TEXT; +import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID; +import static org.thingsboard.server.dao.service.Validator.validateId; + +@Slf4j +@Service +@TbCoreComponent +public class DefaultTbResourceService extends AbstractTbEntityService implements TbResourceService { + + private final ResourceService resourceService; + private final DDFFileParser ddfFileParser; + + public DefaultTbResourceService(ResourceService resourceService) { + this.resourceService = resourceService; + this.ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); + } + + @Override + public TbResource getResource(TenantId tenantId, ResourceType resourceType, String resourceId) { + return resourceService.getResource(tenantId, resourceType, resourceId); + } + + @Override + public TbResource findResourceById(TenantId tenantId, TbResourceId resourceId) { + return resourceService.findResourceById(tenantId, resourceId); + } + + @Override + public TbResourceInfo findResourceInfoById(TenantId tenantId, TbResourceId resourceId) { + return resourceService.findResourceInfoById(tenantId, resourceId); + } + + @Override + public PageData findAllTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink) { + return resourceService.findAllTenantResourcesByTenantId(tenantId, pageLink); + } + + @Override + public PageData findTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink) { + return resourceService.findTenantResourcesByTenantId(tenantId, pageLink); + } + + @Override + public List findLwM2mObject(TenantId tenantId, String sortOrder, String sortProperty, String[] objectIds) { + log.trace("Executing findByTenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + List resources = resourceService.findTenantResourcesByResourceTypeAndObjectIds(tenantId, ResourceType.LWM2M_MODEL, + objectIds); + return resources.stream() + .flatMap(s -> Stream.ofNullable(toLwM2mObject(s, false))) + .sorted(getComparator(sortProperty, sortOrder)) + .collect(Collectors.toList()); + } + + @Override + public List findLwM2mObjectPage(TenantId tenantId, String sortProperty, String sortOrder, PageLink pageLink) { + log.trace("Executing findByTenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + PageData resourcePageData = resourceService.findTenantResourcesByResourceTypeAndPageLink(tenantId, ResourceType.LWM2M_MODEL, pageLink); + return resourcePageData.getData().stream() + .flatMap(s -> Stream.ofNullable(toLwM2mObject(s, false))) + .sorted(getComparator(sortProperty, sortOrder)) + .collect(Collectors.toList()); + } + + @Override + public void deleteResourcesByTenantId(TenantId tenantId) { + resourceService.deleteResourcesByTenantId(tenantId); + } + + @Override + public long sumDataSizeByTenantId(TenantId tenantId) { + return resourceService.sumDataSizeByTenantId(tenantId); + } + + private Comparator getComparator(String sortProperty, String sortOrder) { + Comparator comparator; + if ("name".equals(sortProperty)) { + comparator = Comparator.comparing(LwM2mObject::getName); + } else { + comparator = Comparator.comparingLong(LwM2mObject::getId); + } + return "DESC".equals(sortOrder) ? comparator.reversed() : comparator; + } + + private LwM2mObject toLwM2mObject(TbResource resource, boolean isSave) { + try { + DDFFileParser ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); + List objectModels = + ddfFileParser.parse(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); + if (objectModels.size() == 0) { + return null; + } else { + ObjectModel obj = objectModels.get(0); + LwM2mObject lwM2mObject = new LwM2mObject(); + lwM2mObject.setId(obj.id); + lwM2mObject.setKeyId(resource.getResourceKey()); + lwM2mObject.setName(obj.name); + lwM2mObject.setMultiple(obj.multiple); + lwM2mObject.setMandatory(obj.mandatory); + LwM2mInstance instance = new LwM2mInstance(); + instance.setId(0); + List resources = new ArrayList<>(); + obj.resources.forEach((k, v) -> { + if (isSave) { + LwM2mResourceObserve lwM2MResourceObserve = new LwM2mResourceObserve(k, v.name, false, false, false); + resources.add(lwM2MResourceObserve); + } else if (v.operations.isReadable()) { + LwM2mResourceObserve lwM2MResourceObserve = new LwM2mResourceObserve(k, v.name, false, false, false); + resources.add(lwM2MResourceObserve); + } + }); + if (isSave || resources.size() > 0) { + instance.setResources(resources.toArray(LwM2mResourceObserve[]::new)); + lwM2mObject.setInstances(new LwM2mInstance[]{instance}); + return lwM2mObject; + } else { + return null; + } + } + } catch (IOException | InvalidDDFFileException e) { + log.error("Could not parse the XML of objectModel with name [{}]", resource.getSearchText(), e); + return null; + } + } + + @Override + public TbResource save(TbResource tbResource, User user) throws ThingsboardException { + ActionType actionType = tbResource.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = tbResource.getTenantId(); + try { + TbResource savedResource = checkNotNull(doSave(tbResource)); + tbClusterService.onResourceChange(savedResource, null); + notificationEntityService.logEntityAction(tenantId, savedResource.getId(), savedResource, actionType, user); + return savedResource; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), + tbResource, actionType, user, e); + throw e; + } + } + + @Override + public void delete(TbResource tbResource, User user) { + TbResourceId resourceId = tbResource.getId(); + TenantId tenantId = tbResource.getTenantId(); + try { + resourceService.deleteResource(tenantId, resourceId); + tbClusterService.onResourceDeleted(tbResource, null); + notificationEntityService.logEntityAction(tenantId, resourceId, tbResource, ActionType.DELETED, user, resourceId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), + ActionType.DELETED, user, e, resourceId.toString()); + throw e; + } + } + + private TbResource doSave(TbResource resource) throws ThingsboardException { + log.trace("Executing saveResource [{}]", resource); + if (StringUtils.isEmpty(resource.getData())) { + throw new DataValidationException("Resource data should be specified!"); + } + if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { + try { + List objectModels = + ddfFileParser.parse(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); + if (!objectModels.isEmpty()) { + ObjectModel objectModel = objectModels.get(0); + + String resourceKey = objectModel.id + LWM2M_SEPARATOR_KEY + objectModel.version; + String name = objectModel.name; + resource.setResourceKey(resourceKey); + if (resource.getId() == null) { + resource.setTitle(name + " id=" + objectModel.id + " v" + objectModel.version); + } + resource.setSearchText(resourceKey + LWM2M_SEPARATOR_SEARCH_TEXT + name); + } else { + throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); + } + } catch (InvalidDDFFileException e) { + log.error("Failed to parse file {}", resource.getFileName(), e); + throw new DataValidationException("Failed to parse file " + resource.getFileName()); + } catch (IOException e) { + throw new ThingsboardException(e, ThingsboardErrorCode.GENERAL); + } + if (resource.getResourceType().equals(ResourceType.LWM2M_MODEL) && toLwM2mObject(resource, true) == null) { + throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); + } + } else { + resource.setResourceKey(resource.getFileName()); + } + + return resourceService.saveResource(resource); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java new file mode 100644 index 0000000..6fd9566 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.resource; + +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.lwm2m.LwM2mObject; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +import java.util.List; + +public interface TbResourceService extends SimpleTbEntityService { + + TbResource getResource(TenantId tenantId, ResourceType resourceType, String resourceKey); + + TbResource findResourceById(TenantId tenantId, TbResourceId resourceId); + + TbResourceInfo findResourceInfoById(TenantId tenantId, TbResourceId resourceId); + + PageData findAllTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink); + + PageData findTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink); + + List findLwM2mObject(TenantId tenantId, + String sortOrder, + String sortProperty, + String[] objectIds); + + List findLwM2mObjectPage(TenantId tenantId, + String sortProperty, + String sortOrder, + PageLink pageLink); + + void deleteResourcesByTenantId(TenantId tenantId); + + long sumDataSizeByTenantId(TenantId tenantId); +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbCoreDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbCoreDeviceRpcService.java new file mode 100644 index 0000000..fe70f44 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbCoreDeviceRpcService.java @@ -0,0 +1,215 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.rpc.RpcError; +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.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 27.03.18. + */ +@Service +@Slf4j +@TbCoreComponent +public class DefaultTbCoreDeviceRpcService implements TbCoreDeviceRpcService { + + private static final ObjectMapper json = new ObjectMapper(); + + private final DeviceService deviceService; + private final TbClusterService clusterService; + private final TbServiceInfoProvider serviceInfoProvider; + private final ActorSystemContext actorContext; + + private final ConcurrentMap> localToRuleEngineRpcRequests = new ConcurrentHashMap<>(); + private final ConcurrentMap localToDeviceRpcRequests = new ConcurrentHashMap<>(); + + private Optional tbRuleEngineRpcService; + private ScheduledExecutorService scheduler; + private String serviceId; + + public DefaultTbCoreDeviceRpcService(DeviceService deviceService, TbClusterService clusterService, TbServiceInfoProvider serviceInfoProvider, + ActorSystemContext actorContext) { + this.deviceService = deviceService; + this.clusterService = clusterService; + this.serviceInfoProvider = serviceInfoProvider; + this.actorContext = actorContext; + } + + @Autowired(required = false) + public void setTbRuleEngineRpcService(Optional tbRuleEngineRpcService) { + this.tbRuleEngineRpcService = tbRuleEngineRpcService; + } + + @PostConstruct + public void initExecutor() { + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("tb-core-rpc-scheduler")); + serviceId = serviceInfoProvider.getServiceId(); + } + + @PreDestroy + public void shutdownExecutor() { + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + @Override + public void processRestApiRpcRequest(ToDeviceRpcRequest request, Consumer responseConsumer, SecurityUser currentUser) { + 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, currentUser); + scheduleToRuleEngineTimeout(request, requestId); + } + + @Override + public void processRpcResponseFromRuleEngine(FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from rule engine: [{}]", response.getId(), response); + UUID requestId = response.getId(); + Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); + if (consumer != null) { + consumer.accept(response); + } else { + log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); + } + } + + @Override + public void forwardRpcRequestToDeviceActor(ToDeviceRpcRequestActorMsg rpcMsg) { + ToDeviceRpcRequest request = rpcMsg.getMsg(); + log.trace("[{}][{}] Processing local rpc call to device actor [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); + UUID requestId = request.getId(); + localToDeviceRpcRequests.put(requestId, rpcMsg); + actorContext.tellWithHighPriority(rpcMsg); + scheduleToDeviceTimeout(request, requestId); + } + + @Override + public void processRpcResponseFromDeviceActor(FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from device actor.", response.getId()); + UUID requestId = response.getId(); + ToDeviceRpcRequestActorMsg request = localToDeviceRpcRequests.remove(requestId); + if (request != null) { + sendRpcResponseToTbRuleEngine(request.getServiceId(), response); + } else { + log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); + } + } + + @Override + public void processRemoveRpc(RemoveRpcActorMsg removeRpcMsg) { + log.trace("[{}][{}] Processing remove RPC [{}]", removeRpcMsg.getTenantId(), removeRpcMsg.getRequestId(), removeRpcMsg.getDeviceId()); + actorContext.tellWithHighPriority(removeRpcMsg); + } + + private void sendRpcResponseToTbRuleEngine(String originServiceId, FromDeviceRpcResponse response) { + if (serviceId.equals(originServiceId)) { + if (tbRuleEngineRpcService.isPresent()) { + tbRuleEngineRpcService.get().processRpcResponseFromDevice(response); + } else { + log.warn("Failed to find tbCoreRpcService for local service. Possible duplication of serviceIds."); + } + } else { + clusterService.pushNotificationToRuleEngine(originServiceId, response, null); + } + } + + private void sendRpcRequestToRuleEngine(ToDeviceRpcRequest msg, SecurityUser currentUser) { + ObjectNode entityNode = json.createObjectNode(); + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("requestUUID", msg.getId().toString()); + metaData.putValue("originServiceId", serviceId); + metaData.putValue("expirationTime", Long.toString(msg.getExpirationTime())); + metaData.putValue("oneway", Boolean.toString(msg.isOneway())); + metaData.putValue(DataConstants.PERSISTENT, Boolean.toString(msg.isPersisted())); + + if (msg.getRetries() != null) { + metaData.putValue(DataConstants.RETRIES, msg.getRetries().toString()); + } + + + Device device = deviceService.findDeviceById(msg.getTenantId(), msg.getDeviceId()); + if (device != null) { + metaData.putValue("deviceName", device.getName()); + metaData.putValue("deviceType", device.getType()); + } + + entityNode.put("method", msg.getBody().getMethod()); + entityNode.put("params", msg.getBody().getParams()); + + entityNode.put(DataConstants.ADDITIONAL_INFO, msg.getAdditionalInfo()); + + try { + TbMsg tbMsg = TbMsg.newMsg(DataConstants.RPC_CALL_FROM_SERVER_TO_DEVICE, msg.getDeviceId(), Optional.ofNullable(currentUser).map(User::getCustomerId).orElse(null), metaData, TbMsgDataType.JSON, json.writeValueAsString(entityNode)); + clusterService.pushMsgToRuleEngine(msg.getTenantId(), msg.getDeviceId(), tbMsg, null); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private void scheduleToRuleEngineTimeout(ToDeviceRpcRequest request, UUID requestId) { + long timeout = Math.max(0, request.getExpirationTime() - System.currentTimeMillis()) + TimeUnit.SECONDS.toMillis(1); + log.trace("[{}] processing to rule engine request.", requestId); + scheduler.schedule(() -> { + log.trace("[{}] timeout for processing to rule engine request.", requestId); + Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); + if (consumer != null) { + consumer.accept(new FromDeviceRpcResponse(requestId, null, RpcError.TIMEOUT)); + } + }, timeout, TimeUnit.MILLISECONDS); + } + + private void scheduleToDeviceTimeout(ToDeviceRpcRequest request, UUID requestId) { + long timeout = Math.max(0, request.getExpirationTime() - System.currentTimeMillis()) + TimeUnit.SECONDS.toMillis(1); + log.trace("[{}] processing to device request.", requestId); + scheduler.schedule(() -> { + log.trace("[{}] timeout for to device request.", requestId); + localToDeviceRpcRequests.remove(requestId); + }, timeout, TimeUnit.MILLISECONDS); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcService.java new file mode 100644 index 0000000..c0f6cc7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcService.java @@ -0,0 +1,193 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; +import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcResponse; +import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.dao.rpc.RpcService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.cluster.TbClusterService; + +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.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +@Service +@TbRuleEngineComponent +@Slf4j +public class DefaultTbRuleEngineRpcService implements TbRuleEngineDeviceRpcService { + + private final PartitionService partitionService; + private final TbClusterService clusterService; + private final TbServiceInfoProvider serviceInfoProvider; + private final RpcService rpcService; + + private final ConcurrentMap> toDeviceRpcRequests = new ConcurrentHashMap<>(); + + private Optional tbCoreRpcService; + private ScheduledExecutorService scheduler; + private String serviceId; + + public DefaultTbRuleEngineRpcService(PartitionService partitionService, + TbClusterService clusterService, + TbServiceInfoProvider serviceInfoProvider, + RpcService rpcService) { + this.partitionService = partitionService; + this.clusterService = clusterService; + this.serviceInfoProvider = serviceInfoProvider; + this.rpcService = rpcService; + } + + @Autowired(required = false) + public void setTbCoreRpcService(Optional tbCoreRpcService) { + this.tbCoreRpcService = tbCoreRpcService; + } + + @PostConstruct + public void initExecutor() { + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("rule-engine-rpc-scheduler")); + serviceId = serviceInfoProvider.getServiceId(); + } + + @PreDestroy + public void shutdownExecutor() { + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + @Override + public void sendRpcReplyToDevice(String serviceId, UUID sessionId, int requestId, String body) { + if (serviceId == null || serviceId.isEmpty()){ + log.trace("sendRpcReplyToDevice: skipping message without serviceId [{}], sessionId[{}], requestId[{}], body[{}]", serviceId, sessionId, requestId, body); + return; + } + TransportProtos.ToServerRpcResponseMsg responseMsg = TransportProtos.ToServerRpcResponseMsg.newBuilder() + .setRequestId(requestId) + .setPayload(body).build(); + TransportProtos.ToTransportMsg msg = TransportProtos.ToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToServerResponse(responseMsg) + .build(); + clusterService.pushNotificationToTransport(serviceId, msg, null); + } + + @Override + public void sendRpcRequestToDevice(RuleEngineDeviceRpcRequest src, Consumer consumer) { + ToDeviceRpcRequest request = new ToDeviceRpcRequest(src.getRequestUUID(), src.getTenantId(), src.getDeviceId(), + src.isOneway(), src.getExpirationTime(), new ToDeviceRpcRequestBody(src.getMethod(), src.getBody()), src.isPersisted(), src.getRetries(), src.getAdditionalInfo()); + forwardRpcRequestToDeviceActor(request, response -> { + if (src.isRestApiCall()) { + sendRpcResponseToTbCore(src.getOriginServiceId(), response); + } + consumer.accept(RuleEngineDeviceRpcResponse.builder() + .deviceId(src.getDeviceId()) + .requestId(src.getRequestId()) + .error(response.getError()) + .response(response.getResponse()) + .build()); + }); + } + + @Override + public Rpc findRpcById(TenantId tenantId, RpcId id) { + return rpcService.findById(tenantId, id); + } + + @Override + public void processRpcResponseFromDevice(FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from Core RPC Service", response.getId()); + UUID requestId = response.getId(); + Consumer consumer = toDeviceRpcRequests.remove(requestId); + if (consumer != null) { + scheduler.submit(() -> consumer.accept(response)); + } else { + log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); + } + } + + private void forwardRpcRequestToDeviceActor(ToDeviceRpcRequest request, Consumer responseConsumer) { + log.trace("[{}][{}] Processing local rpc call to device actor [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); + UUID requestId = request.getId(); + toDeviceRpcRequests.put(requestId, responseConsumer); + sendRpcRequestToDevice(request); + scheduleTimeout(request, requestId); + } + + private void sendRpcRequestToDevice(ToDeviceRpcRequest msg) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, msg.getTenantId(), msg.getDeviceId()); + ToDeviceRpcRequestActorMsg rpcMsg = new ToDeviceRpcRequestActorMsg(serviceId, msg); + if (tpi.isMyPartition()) { + log.trace("[{}] Forwarding msg {} to device actor!", msg.getDeviceId(), msg); + if (tbCoreRpcService.isPresent()) { + tbCoreRpcService.get().forwardRpcRequestToDeviceActor(rpcMsg); + } else { + log.warn("Failed to find tbCoreRpcService for local service. Possible duplication of serviceIds."); + } + } else { + log.trace("[{}] Forwarding msg {} to queue actor!", msg.getDeviceId(), msg); + clusterService.pushMsgToCore(rpcMsg, null); + } + } + + private void sendRpcResponseToTbCore(String originServiceId, FromDeviceRpcResponse response) { + if (serviceId.equals(originServiceId)) { + if (tbCoreRpcService.isPresent()) { + tbCoreRpcService.get().processRpcResponseFromRuleEngine(response); + } else { + log.warn("Failed to find tbCoreRpcService for local service. Possible duplication of serviceIds."); + } + } else { + clusterService.pushNotificationToCore(originServiceId, response, null); + } + } + + private void scheduleTimeout(ToDeviceRpcRequest request, UUID requestId) { + long timeout = Math.max(0, request.getExpirationTime() - System.currentTimeMillis()) + TimeUnit.SECONDS.toMillis(1); + log.trace("[{}] processing the request: [{}]", this.hashCode(), requestId); + scheduler.schedule(() -> { + log.trace("[{}] timeout the request: [{}]", this.hashCode(), requestId); + Consumer consumer = toDeviceRpcRequests.remove(requestId); + if (consumer != null) { + scheduler.submit(() -> consumer.accept(new FromDeviceRpcResponse(requestId, null, RpcError.TIMEOUT))); + } + }, timeout, TimeUnit.MILLISECONDS); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponseActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponseActorMsg.java new file mode 100644 index 0000000..128a1a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponseActorMsg.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.thingsboard.server.common.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.rpc.FromDeviceRpcResponse; + +@ToString +@RequiredArgsConstructor +public class FromDeviceRpcResponseActorMsg implements ToDeviceActorNotificationMsg { + + @Getter + private final Integer requestId; + @Getter + private final TenantId tenantId; + @Getter + private final DeviceId deviceId; + + @Getter + private final FromDeviceRpcResponse msg; + + @Override + public MsgType getMsgType() { + return MsgType.DEVICE_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/LocalRequestMetaData.java b/application/src/main/java/org/thingsboard/server/service/rpc/LocalRequestMetaData.java new file mode 100644 index 0000000..5f3f017 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/LocalRequestMetaData.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import lombok.Data; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.service.security.model.SecurityUser; + +/** + * Created by ashvayka on 16.04.18. + */ +@Data +public class LocalRequestMetaData { + private final ToDeviceRpcRequest request; + private final SecurityUser user; + private final DeferredResult responseWriter; +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/RemoveRpcActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/RemoveRpcActorMsg.java new file mode 100644 index 0000000..9173d44 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/RemoveRpcActorMsg.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +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.ToDeviceActorNotificationMsg; + +import java.util.UUID; + +@ToString +@RequiredArgsConstructor +public class RemoveRpcActorMsg implements ToDeviceActorNotificationMsg { + + @Getter + private final TenantId tenantId; + @Getter + private final DeviceId deviceId; + + @Getter + private final UUID requestId; + + @Override + public MsgType getMsgType() { + return MsgType.REMOVE_RPC_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/TbCoreDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/TbCoreDeviceRpcService.java new file mode 100644 index 0000000..5443de7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/TbCoreDeviceRpcService.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.function.Consumer; + +/** + * Handles REST API calls that contain RPC requests to Device. + */ +public interface TbCoreDeviceRpcService { + + /** + * Handles REST API calls that contain RPC requests to Device and pushes them to Rule Engine. + * Schedules the timeout for the RPC call based on the {@link ToDeviceRpcRequest} + * @param request the RPC request + * @param responseConsumer the consumer of the RPC response + * @param currentUser + */ + void processRestApiRpcRequest(ToDeviceRpcRequest request, Consumer responseConsumer, SecurityUser currentUser); + + /** + * Handles the RPC response from the Rule Engine. + * + * @param response the RPC response + */ + void processRpcResponseFromRuleEngine(FromDeviceRpcResponse response); + + /** + * Forwards the RPC request from Rule Engine to Device Actor + * + * @param request the RPC request message + */ + void forwardRpcRequestToDeviceActor(ToDeviceRpcRequestActorMsg request); + + /** + * Handles the RPC response from the Device Actor (Transport). + * + * @param response the RPC response + */ + void processRpcResponseFromDeviceActor(FromDeviceRpcResponse response); + + void processRemoveRpc(RemoveRpcActorMsg removeRpcMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/TbRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/TbRpcService.java new file mode 100644 index 0000000..47fdab1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/TbRpcService.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rpc.RpcStatus; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.rpc.RpcService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.cluster.TbClusterService; + +@TbCoreComponent +@Service +@RequiredArgsConstructor +@Slf4j +public class TbRpcService { + private final RpcService rpcService; + private final TbClusterService tbClusterService; + + public Rpc save(TenantId tenantId, Rpc rpc) { + Rpc saved = rpcService.save(rpc); + pushRpcMsgToRuleEngine(tenantId, saved); + return saved; + } + + public void save(TenantId tenantId, RpcId rpcId, RpcStatus newStatus, JsonNode response) { + Rpc foundRpc = rpcService.findById(tenantId, rpcId); + if (foundRpc != null) { + foundRpc.setStatus(newStatus); + if (response != null) { + foundRpc.setResponse(response); + } + Rpc saved = rpcService.save(foundRpc); + pushRpcMsgToRuleEngine(tenantId, saved); + } else { + log.warn("[{}] Failed to update RPC status because RPC was already deleted", rpcId); + } + } + + private void pushRpcMsgToRuleEngine(TenantId tenantId, Rpc rpc) { + TbMsg msg = TbMsg.newMsg("RPC_" + rpc.getStatus().name(), rpc.getDeviceId(), TbMsgMetaData.EMPTY, JacksonUtil.toString(rpc)); + tbClusterService.pushMsgToRuleEngine(tenantId, rpc.getDeviceId(), msg, null); + } + + public Rpc findRpcById(TenantId tenantId, RpcId rpcId) { + return rpcService.findById(tenantId, rpcId); + } + + public PageData findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink) { + return rpcService.findAllByDeviceIdAndStatus(tenantId, deviceId, rpcStatus, pageLink); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/TbRuleEngineDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/TbRuleEngineDeviceRpcService.java new file mode 100644 index 0000000..7464de6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/TbRuleEngineDeviceRpcService.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import org.thingsboard.rule.engine.api.RuleEngineRpcService; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; + +/** + * Created by ashvayka on 16.04.18. + */ +public interface TbRuleEngineDeviceRpcService extends RuleEngineRpcService { + + /** + * Handles the RPC response from the Device Actor (Transport). + * + * @param response the RPC response + */ + void processRpcResponseFromDevice(FromDeviceRpcResponse response); +} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/ToDeviceRpcRequestActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/ToDeviceRpcRequestActorMsg.java new file mode 100644 index 0000000..5c86292 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rpc/ToDeviceRpcRequestActorMsg.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rpc; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.thingsboard.server.common.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.rpc.ToDeviceRpcRequest; + +/** + * Created by ashvayka on 16.04.18. + */ +@ToString +@RequiredArgsConstructor +public class ToDeviceRpcRequestActorMsg implements ToDeviceActorNotificationMsg { + + private static final long serialVersionUID = -8592877558138716589L; + + @Getter + private final String serviceId; + @Getter + private final ToDeviceRpcRequest msg; + + @Override + public DeviceId getDeviceId() { + return msg.getDeviceId(); + } + + @Override + public TenantId getTenantId() { + return msg.getTenantId(); + } + + @Override + public MsgType getMsgType() { + return MsgType.DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/rule/DefaultTbRuleChainService.java b/application/src/main/java/org/thingsboard/server/service/rule/DefaultTbRuleChainService.java new file mode 100644 index 0000000..28a4e1e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rule/DefaultTbRuleChainService.java @@ -0,0 +1,444 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rule; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNode; +import org.thingsboard.rule.engine.flow.TbRuleChainInputNodeConfiguration; +import org.thingsboard.rule.engine.flow.TbRuleChainOutputNode; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.DefaultRuleChainCreateRequest; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainOutputLabelsUsage; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleChainUpdateResult; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.rule.RuleNodeUpdateResult; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Service +@TbCoreComponent +@Slf4j +public class DefaultTbRuleChainService extends AbstractTbEntityService implements TbRuleChainService { + + private final RuleChainService ruleChainService; + private final RelationService relationService; + private final InstallScripts installScripts; + + private final EntitiesVersionControlService vcService; + + @Override + public Set getRuleChainOutputLabels(TenantId tenantId, RuleChainId ruleChainId) { + RuleChainMetaData metaData = ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId); + Set outputLabels = new TreeSet<>(); + for (RuleNode ruleNode : metaData.getNodes()) { + if (isOutputRuleNode(ruleNode)) { + outputLabels.add(ruleNode.getName()); + } + } + return outputLabels; + } + + @Override + public List getOutputLabelUsage(TenantId tenantId, RuleChainId ruleChainId) { + List ruleNodes = ruleChainService.findRuleNodesByTenantIdAndType(tenantId, TbRuleChainInputNode.class.getName(), ruleChainId.getId().toString()); + Map ruleChainNamesCache = new HashMap<>(); + // Additional filter, "just in case" the structure of the JSON configuration will change. + var filteredRuleNodes = ruleNodes.stream().filter(node -> { + try { + TbRuleChainInputNodeConfiguration configuration = JacksonUtil.treeToValue(node.getConfiguration(), TbRuleChainInputNodeConfiguration.class); + return ruleChainId.getId().toString().equals(configuration.getRuleChainId()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to decode rule node configuration", tenantId, ruleChainId, e); + return false; + } + }).collect(Collectors.toList()); + + + return filteredRuleNodes.stream() + .map(ruleNode -> { + RuleChainOutputLabelsUsage usage = new RuleChainOutputLabelsUsage(); + usage.setRuleNodeId(ruleNode.getId()); + usage.setRuleNodeName(ruleNode.getName()); + usage.setRuleChainId(ruleNode.getRuleChainId()); + List relations = ruleChainService.getRuleNodeRelations(tenantId, ruleNode.getId()); + if (relations != null && !relations.isEmpty()) { + usage.setLabels(relations.stream().map(EntityRelation::getType).collect(Collectors.toSet())); + } + return usage; + }) + .filter(usage -> usage.getLabels() != null) + .peek(usage -> { + String ruleChainName = ruleChainNamesCache.computeIfAbsent(usage.getRuleChainId(), + id -> ruleChainService.findRuleChainById(tenantId, id).getName()); + usage.setRuleChainName(ruleChainName); + }) + .sorted(Comparator + .comparing(RuleChainOutputLabelsUsage::getRuleChainName) + .thenComparing(RuleChainOutputLabelsUsage::getRuleNodeName)) + .collect(Collectors.toList()); + } + + @Override + public List updateRelatedRuleChains(TenantId tenantId, RuleChainId ruleChainId, RuleChainUpdateResult result) { + Set ruleChainIds = new HashSet<>(); + log.debug("[{}][{}] Going to update links in related rule chains", tenantId, ruleChainId); + if (result.getUpdatedRuleNodes() == null || result.getUpdatedRuleNodes().isEmpty()) { + return Collections.emptyList(); + } + + Set oldLabels = new HashSet<>(); + Set newLabels = new HashSet<>(); + Set confusedLabels = new HashSet<>(); + Map updatedLabels = new HashMap<>(); + for (RuleNodeUpdateResult update : result.getUpdatedRuleNodes()) { + var oldNode = update.getOldRuleNode(); + var newNode = update.getNewRuleNode(); + if (isOutputRuleNode(newNode)) { + try { + oldLabels.add(oldNode.getName()); + newLabels.add(newNode.getName()); + if (!oldNode.getName().equals(newNode.getName())) { + String oldLabel = oldNode.getName(); + String newLabel = newNode.getName(); + if (updatedLabels.containsKey(oldLabel) && !updatedLabels.get(oldLabel).equals(newLabel)) { + confusedLabels.add(oldLabel); + log.warn("[{}][{}] Can't automatically rename the label from [{}] to [{}] due to conflict [{}]", tenantId, ruleChainId, oldLabel, newLabel, updatedLabels.get(oldLabel)); + } else { + updatedLabels.put(oldLabel, newLabel); + } + + } + } catch (Exception e) { + log.warn("[{}][{}][{}] Failed to decode rule node configuration", tenantId, ruleChainId, newNode.getId(), e); + } + } + } + // Remove all output labels that are renamed to two or more different labels, since we don't which new label to use; + confusedLabels.forEach(updatedLabels::remove); + // Remove all output labels that are renamed but still present in the rule chain; + newLabels.forEach(updatedLabels::remove); + if (!oldLabels.equals(newLabels)) { + ruleChainIds.addAll(updateRelatedRuleChains(tenantId, ruleChainId, updatedLabels)); + } + return ruleChainIds.stream().map(id -> ruleChainService.findRuleChainById(tenantId, id)).collect(Collectors.toList()); + } + + @Override + public RuleChain save(RuleChain ruleChain, User user) throws Exception { + TenantId tenantId = ruleChain.getTenantId(); + ActionType actionType = ruleChain.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + try { + RuleChain savedRuleChain = checkNotNull(ruleChainService.saveRuleChain(ruleChain)); + autoCommit(user, savedRuleChain.getId()); + + if (RuleChainType.CORE.equals(savedRuleChain.getType())) { + tbClusterService.broadcastEntityStateChangeEvent(tenantId, savedRuleChain.getId(), + actionType.equals(ActionType.ADDED) ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + } + boolean sendMsgToEdge = RuleChainType.EDGE.equals(savedRuleChain.getType()) && actionType.equals(ActionType.UPDATED); + notificationEntityService.notifyCreateOrUpdateOrDelete(tenantId, null, savedRuleChain.getId(), + savedRuleChain, user, actionType, sendMsgToEdge, null); + return savedRuleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ruleChain, actionType, user, e); + throw e; + } + } + + @Override + public void delete(RuleChain ruleChain, User user) { + TenantId tenantId = ruleChain.getTenantId(); + RuleChainId ruleChainId = ruleChain.getId(); + try { + List referencingRuleNodes = ruleChainService.getReferencingRuleChainNodes(tenantId, ruleChainId); + + Set referencingRuleChainIds = referencingRuleNodes.stream().map(RuleNode::getRuleChainId).collect(Collectors.toSet()); + + List relatedEdgeIds = null; + if (RuleChainType.EDGE.equals(ruleChain.getType())) { + relatedEdgeIds = edgeService.findAllRelatedEdgeIds(tenantId, ruleChainId); + } + + ruleChainService.deleteRuleChainById(tenantId, ruleChainId); + + referencingRuleChainIds.remove(ruleChain.getId()); + + if (RuleChainType.CORE.equals(ruleChain.getType())) { + referencingRuleChainIds.forEach(referencingRuleChainId -> + tbClusterService.broadcastEntityStateChangeEvent(tenantId, referencingRuleChainId, ComponentLifecycleEvent.UPDATED)); + + tbClusterService.broadcastEntityStateChangeEvent(tenantId, ruleChain.getId(), ComponentLifecycleEvent.DELETED); + } + + notificationEntityService.notifyDeleteRuleChain(tenantId, ruleChain, relatedEdgeIds, user); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.DELETED, + user, e, ruleChainId.toString()); + throw e; + } + } + + @Override + public RuleChain saveDefaultByName(TenantId tenantId, DefaultRuleChainCreateRequest request, User user) throws Exception { + try { + RuleChain savedRuleChain = installScripts.createDefaultRuleChain(tenantId, request.getName()); + autoCommit(user, savedRuleChain.getId()); + tbClusterService.broadcastEntityStateChangeEvent(tenantId, savedRuleChain.getId(), ComponentLifecycleEvent.CREATED); + notificationEntityService.logEntityAction(tenantId, savedRuleChain.getId(), savedRuleChain, ActionType.ADDED, user); + return savedRuleChain; + } catch (Exception e) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(request.getName()); + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ruleChain, + ActionType.ADDED, user, e); + throw e; + } + } + + @Override + public RuleChain setRootRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + try { + RuleChain previousRootRuleChain = ruleChainService.getRootTenantRuleChain(tenantId); + if (ruleChainService.setRootRuleChain(tenantId, ruleChainId)) { + if (previousRootRuleChain != null) { + RuleChainId previousRootRuleChainId = previousRootRuleChain.getId(); + previousRootRuleChain = ruleChainService.findRuleChainById(tenantId, previousRootRuleChainId); + + tbClusterService.broadcastEntityStateChangeEvent(tenantId, previousRootRuleChainId, + ComponentLifecycleEvent.UPDATED); + notificationEntityService.logEntityAction(tenantId, previousRootRuleChainId, previousRootRuleChain, + ActionType.UPDATED, user); + } + ruleChain = ruleChainService.findRuleChainById(tenantId, ruleChainId); + + tbClusterService.broadcastEntityStateChangeEvent(tenantId, ruleChainId, + ComponentLifecycleEvent.UPDATED); + notificationEntityService.logEntityAction(tenantId, ruleChainId, ruleChain, ActionType.UPDATED, user); + } + return ruleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.UPDATED, + user, e, ruleChainId.toString()); + throw e; + } + } + + @Override + public RuleChainMetaData saveRuleChainMetaData(TenantId tenantId, RuleChain ruleChain, RuleChainMetaData ruleChainMetaData, + boolean updateRelated, User user) throws Exception { + RuleChainId ruleChainId = ruleChain.getId(); + RuleChainId ruleChainMetaDataId = ruleChainMetaData.getRuleChainId(); + try { + RuleChainUpdateResult result = ruleChainService.saveRuleChainMetaData(tenantId, ruleChainMetaData); + checkNotNull(result.isSuccess() ? true : null); + + List updatedRuleChains; + if (updateRelated && result.isSuccess()) { + updatedRuleChains = updateRelatedRuleChains(tenantId, ruleChainMetaDataId, result); + } else { + updatedRuleChains = Collections.emptyList(); + } + + if (updatedRuleChains.isEmpty()) { + autoCommit(user, ruleChainMetaData.getRuleChainId()); + } else { + List uuids = new ArrayList<>(updatedRuleChains.size() + 1); + uuids.add(ruleChainMetaData.getRuleChainId().getId()); + updatedRuleChains.forEach(rc -> uuids.add(rc.getId().getId())); + autoCommit(user, EntityType.RULE_CHAIN, uuids); + } + + RuleChainMetaData savedRuleChainMetaData = checkNotNull(ruleChainService.loadRuleChainMetaData(tenantId, ruleChainMetaDataId)); + + if (RuleChainType.CORE.equals(ruleChain.getType())) { + tbClusterService.broadcastEntityStateChangeEvent(tenantId, ruleChainId, ComponentLifecycleEvent.UPDATED); + updatedRuleChains.forEach(updatedRuleChain -> { + tbClusterService.broadcastEntityStateChangeEvent(tenantId, updatedRuleChain.getId(), ComponentLifecycleEvent.UPDATED); + }); + } + + notificationEntityService.logEntityAction(tenantId, ruleChainId, ruleChain, ActionType.UPDATED, user, ruleChainMetaData); + + if (RuleChainType.EDGE.equals(ruleChain.getType())) { + notificationEntityService.notifySendMsgToEdgeService(tenantId, ruleChain.getId(), EdgeEventActionType.UPDATED); + } + + for (RuleChain updatedRuleChain : updatedRuleChains) { + if (RuleChainType.EDGE.equals(ruleChain.getType())) { + notificationEntityService.notifySendMsgToEdgeService(tenantId, updatedRuleChain.getId(), EdgeEventActionType.UPDATED); + } else { + RuleChainMetaData updatedRuleChainMetaData = checkNotNull(ruleChainService.loadRuleChainMetaData(tenantId, updatedRuleChain.getId())); + notificationEntityService.logEntityAction(tenantId, updatedRuleChain.getId(), updatedRuleChain, + ActionType.UPDATED, user, updatedRuleChainMetaData); + } + } + return savedRuleChainMetaData; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.ADDED, + user, e, ruleChainMetaData); + throw e; + } + } + + @Override + public RuleChain assignRuleChainToEdge(TenantId tenantId, RuleChain ruleChain, Edge edge, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + EdgeId edgeId = edge.getId(); + try { + RuleChain savedRuleChain = checkNotNull(ruleChainService.assignRuleChainToEdge(tenantId, ruleChainId, edgeId)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, ruleChainId, + null, edgeId, savedRuleChain, ActionType.ASSIGNED_TO_EDGE, + user, ruleChainId.toString(), edgeId.toString(), edge.getName()); + return savedRuleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), + ActionType.ASSIGNED_TO_EDGE, user, e, ruleChainId.toString(), edgeId.toString()); + throw e; + } + } + + @Override + public RuleChain unassignRuleChainFromEdge(TenantId tenantId, RuleChain ruleChain, Edge edge, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + EdgeId edgeId = edge.getId(); + try { + RuleChain savedRuleChain = checkNotNull(ruleChainService.unassignRuleChainFromEdge(tenantId, ruleChainId, edgeId, false)); + notificationEntityService.notifyAssignOrUnassignEntityToEdge(tenantId, ruleChainId, + null, edgeId, savedRuleChain, ActionType.UNASSIGNED_FROM_EDGE, + user, ruleChainId.toString(), edgeId.toString(), edge.getName()); + return savedRuleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), + ActionType.UNASSIGNED_FROM_EDGE, user, e, ruleChainId, edgeId); + throw e; + } + } + + @Override + public RuleChain setEdgeTemplateRootRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + try { + ruleChainService.setEdgeTemplateRootRuleChain(tenantId, ruleChainId); + notificationEntityService.logEntityAction(tenantId, ruleChainId, ruleChain, ActionType.UPDATED, user); + return ruleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.UPDATED, + user, e, ruleChainId.toString()); + throw e; + } + } + + @Override + public RuleChain setAutoAssignToEdgeRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + try { + ruleChainService.setAutoAssignToEdgeRuleChain(tenantId, ruleChainId); + notificationEntityService.logEntityAction(tenantId, ruleChainId, ruleChain, ActionType.UPDATED, user); + return ruleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.UPDATED, + user, e, ruleChainId.toString()); + throw e; + } + } + + @Override + public RuleChain unsetAutoAssignToEdgeRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException { + RuleChainId ruleChainId = ruleChain.getId(); + try { + ruleChainService.unsetAutoAssignToEdgeRuleChain(tenantId, ruleChainId); + notificationEntityService.logEntityAction(tenantId, ruleChainId, ruleChain, ActionType.UPDATED, user); + return ruleChain; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.RULE_CHAIN), ActionType.UPDATED, + user, e, ruleChainId.toString()); + throw e; + } + } + + private Set updateRelatedRuleChains(TenantId tenantId, RuleChainId ruleChainId, Map labelsMap) { + Set updatedRuleChains = new HashSet<>(); + List usageList = getOutputLabelUsage(tenantId, ruleChainId); + for (RuleChainOutputLabelsUsage usage : usageList) { + labelsMap.forEach((oldLabel, newLabel) -> { + if (usage.getLabels().contains(oldLabel)) { + updatedRuleChains.add(usage.getRuleChainId()); + renameOutgoingLinks(tenantId, usage.getRuleNodeId(), oldLabel, newLabel); + } + }); + } + return updatedRuleChains; + } + + private void renameOutgoingLinks(TenantId tenantId, RuleNodeId ruleNodeId, String oldLabel, String newLabel) { + List relations = ruleChainService.getRuleNodeRelations(tenantId, ruleNodeId); + for (EntityRelation relation : relations) { + if (relation.getType().equals(oldLabel)) { + relationService.deleteRelation(tenantId, relation); + relation.setType(newLabel); + relationService.saveRelation(tenantId, relation); + } + } + } + + private boolean isOutputRuleNode(RuleNode ruleNode) { + return isRuleNode(ruleNode, TbRuleChainOutputNode.class); + } + + private boolean isInputRuleNode(RuleNode ruleNode) { + return isRuleNode(ruleNode, TbRuleChainInputNode.class); + } + + private boolean isRuleNode(RuleNode ruleNode, Class clazz) { + return ruleNode != null && ruleNode.getType().equals(clazz.getName()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/rule/TbRuleChainService.java b/application/src/main/java/org/thingsboard/server/service/rule/TbRuleChainService.java new file mode 100644 index 0000000..a8c3fed --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/rule/TbRuleChainService.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.rule; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.DefaultRuleChainCreateRequest; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainOutputLabelsUsage; +import org.thingsboard.server.common.data.rule.RuleChainUpdateResult; +import org.thingsboard.server.service.entitiy.SimpleTbEntityService; + +import java.util.List; +import java.util.Set; + +public interface TbRuleChainService extends SimpleTbEntityService { + + Set getRuleChainOutputLabels(TenantId tenantId, RuleChainId ruleChainId); + + List getOutputLabelUsage(TenantId tenantId, RuleChainId ruleChainId); + + List updateRelatedRuleChains(TenantId tenantId, RuleChainId ruleChainId, RuleChainUpdateResult result); + + RuleChain saveDefaultByName(TenantId tenantId, DefaultRuleChainCreateRequest request, User user) throws Exception; + + RuleChain setRootRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException; + + RuleChainMetaData saveRuleChainMetaData(TenantId tenantId, RuleChain ruleChain, RuleChainMetaData ruleChainMetaData, + boolean updateRelated, User user) throws Exception; + + RuleChain assignRuleChainToEdge(TenantId tenantId, RuleChain ruleChain, Edge edge, User user) throws ThingsboardException; + + RuleChain unassignRuleChainFromEdge(TenantId tenantId, RuleChain ruleChain, Edge edge, User user) throws ThingsboardException; + + RuleChain setEdgeTemplateRootRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException; + + RuleChain setAutoAssignToEdgeRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException; + + RuleChain unsetAutoAssignToEdgeRuleChain(TenantId tenantId, RuleChain ruleChain, User user) throws ThingsboardException; +} 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 new file mode 100644 index 0000000..9b5a678 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -0,0 +1,152 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.js.JsInvokeService; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import javax.script.ScriptException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + + +@Slf4j +public class RuleNodeJsScriptEngine extends RuleNodeScriptEngine { + + public RuleNodeJsScriptEngine(TenantId tenantId, JsInvokeService scriptInvokeService, String script, String... argNames) { + super(tenantId, scriptInvokeService, script, argNames); + } + + @Override + public ListenableFuture executeJsonAsync(TbMsg msg) { + return executeScriptAsync(msg); + } + + @Override + protected ListenableFuture> executeUpdateTransform(TbMsg msg, JsonNode json) { + if (json.isObject()) { + return Futures.immediateFuture(Collections.singletonList(unbindMsg(json, msg))); + } else if (json.isArray()) { + List res = new ArrayList<>(json.size()); + json.forEach(jsonObject -> res.add(unbindMsg(jsonObject, msg))); + return Futures.immediateFuture(res); + } + log.warn("Wrong result type: {}", json.getNodeType()); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + } + + @Override + protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, JsonNode result) { + if (!result.isObject()) { + log.warn("Wrong result type: {}", result.getNodeType()); + Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + } + return Futures.immediateFuture(unbindMsg(result, prevMsg)); + } + + @Override + protected JsonNode convertResult(Object result) { + return JacksonUtil.toJsonNode(result != null ? result.toString() : null); + } + + @Override + protected ListenableFuture executeToStringTransform(JsonNode result) { + if (result.isTextual()) { + return Futures.immediateFuture(result.asText()); + } + log.warn("Wrong result type: {}", result.getNodeType()); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + } + + @Override + protected ListenableFuture executeFilterTransform(JsonNode json) { + if (json.isBoolean()) { + return Futures.immediateFuture(json.asBoolean()); + } + log.warn("Wrong result type: {}", json.getNodeType()); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + json.getNodeType())); + } + + @Override + protected ListenableFuture> executeSwitchTransform(JsonNode result) { + if (result.isTextual()) { + return Futures.immediateFuture(Collections.singleton(result.asText())); + } + if (result.isArray()) { + Set nextStates = new HashSet<>(); + for (JsonNode val : result) { + if (!val.isTextual()) { + log.warn("Wrong result type: {}", val.getNodeType()); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + val.getNodeType())); + } else { + nextStates.add(val.asText()); + } + } + return Futures.immediateFuture(nextStates); + } + log.warn("Wrong result type: {}", result.getNodeType()); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + result.getNodeType())); + } + + @Override + protected Object[] prepareArgs(TbMsg msg) { + String[] args = new String[3]; + if (msg.getData() != null) { + args[0] = msg.getData(); + } else { + args[0] = ""; + } + args[1] = JacksonUtil.toString(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; + } + + private static TbMsg unbindMsg(JsonNode msgData, TbMsg msg) { + String data = null; + Map metadata = null; + String messageType = null; + if (msgData.has(RuleNodeScriptFactory.MSG)) { + JsonNode msgPayload = msgData.get(RuleNodeScriptFactory.MSG); + data = JacksonUtil.toString(msgPayload); + } + if (msgData.has(RuleNodeScriptFactory.METADATA)) { + JsonNode msgMetadata = msgData.get(RuleNodeScriptFactory.METADATA); + metadata = JacksonUtil.convertValue(msgMetadata, new TypeReference<>() { + }); + } + if (msgData.has(RuleNodeScriptFactory.MSG_TYPE)) { + messageType = msgData.get(RuleNodeScriptFactory.MSG_TYPE).asText(); + } + String newData = data != null ? data : msg.getData(); + TbMsgMetaData newMetadata = metadata != null ? new TbMsgMetaData(metadata) : msg.getMetaData().copy(); + String newMessageType = !StringUtils.isEmpty(messageType) ? messageType : msg.getType(); + return TbMsg.transformMsg(msg, newMessageType, msg.getOriginator(), newMetadata, newData); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java new file mode 100644 index 0000000..3a88bb6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptEngine.java @@ -0,0 +1,133 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.ScriptEngine; +import org.thingsboard.script.api.ScriptInvokeService; +import org.thingsboard.script.api.ScriptType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsg; + +import javax.script.ScriptException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + + +@Slf4j +public abstract class RuleNodeScriptEngine implements ScriptEngine { + + private final T scriptInvokeService; + + private final UUID scriptId; + private final TenantId tenantId; + + public RuleNodeScriptEngine(TenantId tenantId, T scriptInvokeService, String script, String... argNames) { + this.tenantId = tenantId; + this.scriptInvokeService = scriptInvokeService; + try { + this.scriptId = this.scriptInvokeService.eval(tenantId, ScriptType.RULE_NODE_SCRIPT, script, argNames).get(); + } catch (Exception e) { + Throwable t = e; + if (e instanceof ExecutionException) { + t = e.getCause(); + } + throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); + } + } + + protected abstract Object[] prepareArgs(TbMsg msg); + + @Override + public ListenableFuture> executeUpdateAsync(TbMsg msg) { + ListenableFuture result = executeScriptAsync(msg); + return Futures.transformAsync(result, + json -> executeUpdateTransform(msg, json), + MoreExecutors.directExecutor()); + } + + protected abstract ListenableFuture> executeUpdateTransform(TbMsg msg, R result); + + @Override + public ListenableFuture executeGenerateAsync(TbMsg prevMsg) { + return Futures.transformAsync(executeScriptAsync(prevMsg), + result -> executeGenerateTransform(prevMsg, result), + MoreExecutors.directExecutor()); + } + + protected abstract ListenableFuture executeGenerateTransform(TbMsg prevMsg, R result); + + @Override + public ListenableFuture executeToStringAsync(TbMsg msg) { + return Futures.transformAsync(executeScriptAsync(msg), this::executeToStringTransform, MoreExecutors.directExecutor()); + } + + + @Override + public ListenableFuture executeFilterAsync(TbMsg msg) { + return Futures.transformAsync(executeScriptAsync(msg), + this::executeFilterTransform, + MoreExecutors.directExecutor()); + } + + protected abstract ListenableFuture executeToStringTransform(R result); + + protected abstract ListenableFuture executeFilterTransform(R result); + + protected abstract ListenableFuture> executeSwitchTransform(R result); + + @Override + public ListenableFuture> executeSwitchAsync(TbMsg msg) { + return Futures.transformAsync(executeScriptAsync(msg), + this::executeSwitchTransform, + MoreExecutors.directExecutor()); //usually runs in a callbackExecutor + } + + ListenableFuture executeScriptAsync(TbMsg msg) { + log.trace("execute script async, msg {}", msg); + Object[] inArgs = prepareArgs(msg); + return executeScriptAsync(msg.getCustomerId(), inArgs[0], inArgs[1], inArgs[2]); + } + + ListenableFuture executeScriptAsync(CustomerId customerId, Object... args) { + return Futures.transformAsync(scriptInvokeService.invokeScript(tenantId, customerId, this.scriptId, args), + o -> { + try { + return Futures.immediateFuture(convertResult(o)); + } catch (Exception e) { + if (e.getCause() instanceof ScriptException) { + return Futures.immediateFailedFuture(e.getCause()); + } else if (e.getCause() instanceof RuntimeException) { + return Futures.immediateFailedFuture(new ScriptException(e.getCause().getMessage())); + } else { + return Futures.immediateFailedFuture(new ScriptException(e)); + } + } + }, MoreExecutors.directExecutor()); + } + + public void destroy() { + scriptInvokeService.release(this.scriptId); + } + + protected abstract R convertResult(Object result); +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java new file mode 100644 index 0000000..08b2d5e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeTbelScriptEngine.java @@ -0,0 +1,171 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.RuleNodeScriptFactory; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import javax.script.ScriptException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + + +@Slf4j +public class RuleNodeTbelScriptEngine extends RuleNodeScriptEngine { + + public RuleNodeTbelScriptEngine(TenantId tenantId, TbelInvokeService scriptInvokeService, String script, String... argNames) { + super(tenantId, scriptInvokeService, script, argNames); + } + + @Override + protected ListenableFuture executeFilterTransform(Object result) { + if (result instanceof Boolean) { + return Futures.immediateFuture((Boolean) result); + } + return wrongResultType(result); + } + + @Override + protected ListenableFuture> executeUpdateTransform(TbMsg msg, Object result) { + if (result instanceof Map) { + return Futures.immediateFuture(Collections.singletonList(unbindMsg((Map) result, msg))); + } else if (result instanceof Collection) { + List res = new ArrayList<>(); + for (Object resObject : (Collection) result) { + if (resObject instanceof Map) { + res.add(unbindMsg((Map) resObject, msg)); + } else { + return wrongResultType(resObject); + } + } + return Futures.immediateFuture(res); + } + return wrongResultType(result); + } + + @Override + protected ListenableFuture executeGenerateTransform(TbMsg prevMsg, Object result) { + if (result instanceof Map) { + return Futures.immediateFuture(unbindMsg((Map) result, prevMsg)); + } + return wrongResultType(result); + } + + @Override + protected ListenableFuture executeToStringTransform(Object result) { + if (result instanceof String) { + return Futures.immediateFuture((String) result); + } else { + return Futures.immediateFuture(JacksonUtil.toString(result)); + } + } + + @Override + protected ListenableFuture> executeSwitchTransform(Object result) { + if (result instanceof String) { + return Futures.immediateFuture(Collections.singleton((String) result)); + } else if (result instanceof Collection) { + Set res = new HashSet<>(); + for (Object resObject : (Collection) result) { + if (resObject instanceof String) { + res.add((String) resObject); + } else { + return wrongResultType(resObject); + } + } + return Futures.immediateFuture(res); + } + return wrongResultType(result); + } + + @Override + public ListenableFuture executeJsonAsync(TbMsg msg) { + return Futures.transform(executeScriptAsync(msg), JacksonUtil::valueToTree, MoreExecutors.directExecutor()); + + } + + @Override + protected Object convertResult(Object result) { + return result; + } + + @Override + protected Object[] prepareArgs(TbMsg msg) { + Object[] args = new Object[3]; + if (msg.getData() != null) { + args[0] = JacksonUtil.fromString(msg.getData(), Map.class); + } else { + args[0] = new HashMap<>(); + } + args[1] = new HashMap<>(msg.getMetaData().getData()); + args[2] = msg.getType(); + return args; + } + + private static TbMsg unbindMsg(Map msgData, TbMsg msg) { + String data = null; + Map metadata = null; + String messageType = null; + if (msgData.containsKey(RuleNodeScriptFactory.MSG)) { + data = JacksonUtil.toString(msgData.get(RuleNodeScriptFactory.MSG)); + } + if (msgData.containsKey(RuleNodeScriptFactory.METADATA)) { + Object msgMetadataObj = msgData.get(RuleNodeScriptFactory.METADATA); + if (msgMetadataObj instanceof Map) { + metadata = ((Map) msgMetadataObj).entrySet().stream().filter(e -> e.getValue() != null) + .collect(Collectors.toMap(e -> e.getKey().toString(), e -> e.getValue().toString())); + } else { + metadata = JacksonUtil.convertValue(msgMetadataObj, new TypeReference<>() { + }); + } + } + if (msgData.containsKey(RuleNodeScriptFactory.MSG_TYPE)) { + messageType = msgData.get(RuleNodeScriptFactory.MSG_TYPE).toString(); + } + String newData = data != null ? data : msg.getData(); + TbMsgMetaData newMetadata = metadata != null ? new TbMsgMetaData(metadata) : msg.getMetaData().copy(); + String newMessageType = !StringUtils.isEmpty(messageType) ? messageType : msg.getType(); + return TbMsg.transformMsg(msg, newMessageType, msg.getOriginator(), newMetadata, newData); + } + + private static ListenableFuture wrongResultType(Object result) { + String className = toClassName(result); + log.warn("Wrong result type: {}", className); + return Futures.immediateFailedFuture(new ScriptException("Wrong result type: " + className)); + } + + private static String toClassName(Object result) { + return result != null ? result.getClass().getSimpleName() : "null"; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java new file mode 100644 index 0000000..0f32e31 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -0,0 +1,589 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security; + +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EdgeId; +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.OtaPackageId; +import org.thingsboard.server.common.data.id.RpcId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.controller.HttpValidationCallback; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rpc.RpcService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.AccessControlService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.telemetry.exception.ToErrorResponseEntity; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.BiConsumer; + +/** + * Created by ashvayka on 27.03.18. + */ +@Component +public class AccessValidator { + + public static final String ONLY_SYSTEM_ADMINISTRATOR_IS_ALLOWED_TO_PERFORM_THIS_OPERATION = "Only system administrator is allowed to perform this operation!"; + 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 EDGE_WITH_REQUESTED_ID_NOT_FOUND = "Edge 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; + + @Autowired + protected CustomerService customerService; + + @Autowired + protected UserService userService; + + @Autowired + protected DeviceService deviceService; + + @Autowired + protected DeviceProfileService deviceProfileService; + + @Autowired + protected AssetProfileService assetProfileService; + + @Autowired + protected AssetService assetService; + + @Autowired + protected AlarmService alarmService; + + @Autowired + protected RuleChainService ruleChainService; + + @Autowired + protected EntityViewService entityViewService; + + @Autowired(required = false) + protected EdgeService edgeService; + + @Autowired + protected AccessControlService accessControlService; + + @Autowired + protected ApiUsageStateService apiUsageStateService; + + @Autowired + protected ResourceService resourceService; + + @Autowired + protected OtaPackageService otaPackageService; + + @Autowired + protected RpcService rpcService; + + private ExecutorService executor; + + @PostConstruct + public void initExecutor() { + executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("access-validator")); + } + + @PreDestroy + public void shutdownExecutor() { + if (executor != null) { + executor.shutdownNow(); + } + } + + public DeferredResult validateEntityAndCallback(SecurityUser currentUser, Operation operation, String entityType, String entityIdStr, + ThreeConsumer, TenantId, EntityId> onSuccess) throws ThingsboardException { + return validateEntityAndCallback(currentUser, operation, entityType, entityIdStr, onSuccess, (result, t) -> handleError(t, result, HttpStatus.INTERNAL_SERVER_ERROR)); + } + + public DeferredResult validateEntityAndCallback(SecurityUser currentUser, Operation operation, String entityType, String entityIdStr, + ThreeConsumer, TenantId, EntityId> onSuccess, + BiConsumer, Throwable> onFailure) throws ThingsboardException { + return validateEntityAndCallback(currentUser, operation, EntityIdFactory.getByTypeAndId(entityType, entityIdStr), + onSuccess, onFailure); + } + + public DeferredResult validateEntityAndCallback(SecurityUser currentUser, Operation operation, EntityId entityId, + ThreeConsumer, TenantId, EntityId> onSuccess) throws ThingsboardException { + return validateEntityAndCallback(currentUser, operation, entityId, onSuccess, (result, t) -> handleError(t, result, HttpStatus.INTERNAL_SERVER_ERROR)); + } + + public DeferredResult validateEntityAndCallback(SecurityUser currentUser, Operation operation, EntityId entityId, + ThreeConsumer, TenantId, EntityId> onSuccess, + BiConsumer, Throwable> onFailure) throws ThingsboardException { + + final DeferredResult response = new DeferredResult<>(); + + validate(currentUser, operation, entityId, new HttpValidationCallback(response, + new FutureCallback>() { + @Override + public void onSuccess(@Nullable DeferredResult result) { + try { + onSuccess.accept(response, currentUser.getTenantId(), entityId); + } catch (Exception e) { + onFailure(e); + } + } + + @Override + public void onFailure(Throwable t) { + onFailure.accept(response, t); + } + })); + + return response; + } + + public void validate(SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + switch (entityId.getEntityType()) { + case DEVICE: + validateDevice(currentUser, operation, entityId, callback); + return; + case DEVICE_PROFILE: + validateDeviceProfile(currentUser, operation, entityId, callback); + return; + case ASSET: + validateAsset(currentUser, operation, entityId, callback); + return; + case ASSET_PROFILE: + validateAssetProfile(currentUser, operation, entityId, callback); + return; + case RULE_CHAIN: + validateRuleChain(currentUser, operation, entityId, callback); + return; + case CUSTOMER: + validateCustomer(currentUser, operation, entityId, callback); + return; + case TENANT: + validateTenant(currentUser, operation, entityId, callback); + return; + case TENANT_PROFILE: + validateTenantProfile(currentUser, operation, entityId, callback); + return; + case USER: + validateUser(currentUser, operation, entityId, callback); + return; + case ENTITY_VIEW: + validateEntityView(currentUser, operation, entityId, callback); + return; + case EDGE: + validateEdge(currentUser, operation, entityId, callback); + return; + case API_USAGE_STATE: + validateApiUsageState(currentUser, operation, entityId, callback); + return; + case TB_RESOURCE: + validateResource(currentUser, operation, entityId, callback); + return; + case OTA_PACKAGE: + validateOtaPackage(currentUser, operation, entityId, callback); + return; + case RPC: + validateRpc(currentUser, operation, entityId, callback); + return; + default: + //TODO: add support of other entities + throw new IllegalStateException("Not Implemented!"); + } + } + + private void validateDevice(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture deviceFuture = deviceService.findDeviceByIdAsync(currentUser.getTenantId(), new DeviceId(entityId.getId())); + Futures.addCallback(deviceFuture, getCallback(callback, device -> { + if (device == null) { + return ValidationResult.entityNotFound(DEVICE_WITH_REQUESTED_ID_NOT_FOUND); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.DEVICE, operation, entityId, device); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(device); + } + }), executor); + } + } + + private void validateRpc(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + ListenableFuture rpcFurure = rpcService.findRpcByIdAsync(currentUser.getTenantId(), new RpcId(entityId.getId())); + Futures.addCallback(rpcFurure, getCallback(callback, rpc -> { + if (rpc == null) { + return ValidationResult.entityNotFound("Rpc with requested id wasn't found!"); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.RPC, operation, entityId, rpc); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(rpc); + } + }), executor); + } + + private void validateDeviceProfile(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileById(currentUser.getTenantId(), new DeviceProfileId(entityId.getId())); + if (deviceProfile == null) { + callback.onSuccess(ValidationResult.entityNotFound("Device profile with requested id wasn't found!")); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.DEVICE_PROFILE, operation, entityId, deviceProfile); + } catch (ThingsboardException e) { + callback.onSuccess(ValidationResult.accessDenied(e.getMessage())); + } + callback.onSuccess(ValidationResult.ok(deviceProfile)); + } + } + } + + private void validateAssetProfile(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + AssetProfile assetProfile = assetProfileService.findAssetProfileById(currentUser.getTenantId(), new AssetProfileId(entityId.getId())); + if (assetProfile == null) { + callback.onSuccess(ValidationResult.entityNotFound("Asset profile with requested id wasn't found!")); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.ASSET_PROFILE, operation, entityId, assetProfile); + } catch (ThingsboardException e) { + callback.onSuccess(ValidationResult.accessDenied(e.getMessage())); + } + callback.onSuccess(ValidationResult.ok(assetProfile)); + } + } + } + + private void validateApiUsageState(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + if (!operation.equals(Operation.READ_TELEMETRY)) { + callback.onSuccess(ValidationResult.accessDenied("Allowed only READ_TELEMETRY operation!")); + } + ApiUsageState apiUsageState = apiUsageStateService.findApiUsageStateById(currentUser.getTenantId(), new ApiUsageStateId(entityId.getId())); + if (apiUsageState == null) { + callback.onSuccess(ValidationResult.entityNotFound("Api Usage State with requested id wasn't found!")); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.API_USAGE_STATE, operation, entityId, apiUsageState); + } catch (ThingsboardException e) { + callback.onSuccess(ValidationResult.accessDenied(e.getMessage())); + } + callback.onSuccess(ValidationResult.ok(apiUsageState)); + } + } + } + + private void validateOtaPackage(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + OtaPackageInfo otaPackage = otaPackageService.findOtaPackageInfoById(currentUser.getTenantId(), new OtaPackageId(entityId.getId())); + if (otaPackage == null) { + callback.onSuccess(ValidationResult.entityNotFound("OtaPackage with requested id wasn't found!")); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.OTA_PACKAGE, operation, entityId, otaPackage); + } catch (ThingsboardException e) { + callback.onSuccess(ValidationResult.accessDenied(e.getMessage())); + } + callback.onSuccess(ValidationResult.ok(otaPackage)); + } + } + } + + private void validateResource(SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + ListenableFuture resourceFuture = resourceService.findResourceInfoByIdAsync(currentUser.getTenantId(), new TbResourceId(entityId.getId())); + Futures.addCallback(resourceFuture, getCallback(callback, resource -> { + if (resource == null) { + return ValidationResult.entityNotFound("Resource with requested id wasn't found!"); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.TB_RESOURCE, operation, entityId, resource); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(resource); + } + }), executor); + } + + private void validateAsset(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture assetFuture = assetService.findAssetByIdAsync(currentUser.getTenantId(), new AssetId(entityId.getId())); + Futures.addCallback(assetFuture, getCallback(callback, asset -> { + if (asset == null) { + return ValidationResult.entityNotFound("Asset with requested id wasn't found!"); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.ASSET, operation, entityId, asset); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(asset); + } + }), executor); + } + } + + private void validateRuleChain(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isCustomerUser()) { + callback.onSuccess(ValidationResult.accessDenied(CUSTOMER_USER_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture ruleChainFuture = ruleChainService.findRuleChainByIdAsync(currentUser.getTenantId(), new RuleChainId(entityId.getId())); + Futures.addCallback(ruleChainFuture, getCallback(callback, ruleChain -> { + if (ruleChain == null) { + return ValidationResult.entityNotFound("Rule chain with requested id wasn't found!"); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.RULE_CHAIN, operation, entityId, ruleChain); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(ruleChain); + } + }), executor); + } + } + + private void validateRule(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isCustomerUser()) { + callback.onSuccess(ValidationResult.accessDenied(CUSTOMER_USER_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture ruleNodeFuture = ruleChainService.findRuleNodeByIdAsync(currentUser.getTenantId(), new RuleNodeId(entityId.getId())); + Futures.addCallback(ruleNodeFuture, getCallback(callback, ruleNodeTmp -> { + RuleNode ruleNode = ruleNodeTmp; + if (ruleNode == null) { + return ValidationResult.entityNotFound("Rule node with requested id wasn't found!"); + } else if (ruleNode.getRuleChainId() == null) { + return ValidationResult.entityNotFound("Rule chain with requested node id wasn't found!"); + } else { + //TODO: make async + RuleChain ruleChain = ruleChainService.findRuleChainById(currentUser.getTenantId(), ruleNode.getRuleChainId()); + try { + accessControlService.checkPermission(currentUser, Resource.RULE_CHAIN, operation, ruleNode.getRuleChainId(), ruleChain); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(ruleNode); + } + }), executor); + } + } + + private void validateCustomer(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture customerFuture = customerService.findCustomerByIdAsync(currentUser.getTenantId(), new CustomerId(entityId.getId())); + Futures.addCallback(customerFuture, getCallback(callback, customer -> { + if (customer == null) { + return ValidationResult.entityNotFound("Customer with requested id wasn't found!"); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.CUSTOMER, operation, entityId, customer); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(customer); + } + }), executor); + } + } + + private void validateTenant(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isCustomerUser()) { + callback.onSuccess(ValidationResult.accessDenied(CUSTOMER_USER_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.ok(null)); + } else { + ListenableFuture tenantFuture = tenantService.findTenantByIdAsync(currentUser.getTenantId(), TenantId.fromUUID(entityId.getId())); + Futures.addCallback(tenantFuture, getCallback(callback, tenant -> { + if (tenant == null) { + return ValidationResult.entityNotFound("Tenant with requested id wasn't found!"); + } + try { + accessControlService.checkPermission(currentUser, Resource.TENANT, operation, entityId, tenant); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(tenant); + + }), executor); + } + } + + private void validateTenantProfile(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.ok(null)); + } else { + callback.onSuccess(ValidationResult.accessDenied(ONLY_SYSTEM_ADMINISTRATOR_IS_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } + } + + private void validateUser(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + ListenableFuture userFuture = userService.findUserByIdAsync(currentUser.getTenantId(), new UserId(entityId.getId())); + Futures.addCallback(userFuture, getCallback(callback, user -> { + if (user == null) { + return ValidationResult.entityNotFound("User with requested id wasn't found!"); + } + try { + accessControlService.checkPermission(currentUser, Resource.USER, operation, entityId, user); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(user); + + }), executor); + } + + private void validateEntityView(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture entityViewFuture = entityViewService.findEntityViewByIdAsync(currentUser.getTenantId(), new EntityViewId(entityId.getId())); + Futures.addCallback(entityViewFuture, getCallback(callback, entityView -> { + if (entityView == null) { + return ValidationResult.entityNotFound(ENTITY_VIEW_WITH_REQUESTED_ID_NOT_FOUND); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.ENTITY_VIEW, operation, entityId, entityView); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(entityView); + } + }), executor); + } + } + + private void validateEdge(final SecurityUser currentUser, Operation operation, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture edgeFuture = edgeService.findEdgeByIdAsync(currentUser.getTenantId(), new EdgeId(entityId.getId())); + Futures.addCallback(edgeFuture, getCallback(callback, edge -> { + if (edge == null) { + return ValidationResult.entityNotFound(EDGE_WITH_REQUESTED_ID_NOT_FOUND); + } else { + try { + accessControlService.checkPermission(currentUser, Resource.EDGE, operation, entityId, edge); + } catch (ThingsboardException e) { + return ValidationResult.accessDenied(e.getMessage()); + } + return ValidationResult.ok(edge); + } + }), executor); + } + } + + private FutureCallback getCallback(FutureCallback callback, Function> transformer) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable T result) { + callback.onSuccess(transformer.apply(result)); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + public static void handleError(Throwable e, final DeferredResult response, HttpStatus defaultErrorStatus) { + ResponseEntity responseEntity; + if (e instanceof ToErrorResponseEntity) { + responseEntity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); + } else if (e instanceof IllegalArgumentException || e instanceof IncorrectParameterException) { + responseEntity = new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + } else { + responseEntity = new ResponseEntity<>(defaultErrorStatus); + } + response.setResult(responseEntity); + } + + public interface ThreeConsumer { + void accept(A a, B b, C c); + } +} 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 new file mode 100644 index 0000000..ea90a7a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security; + +import com.google.common.util.concurrent.FutureCallback; +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; + +/** + * Created by ashvayka on 31.03.18. + */ +public class ValidationCallback implements FutureCallback { + + private final T response; + private final FutureCallback action; + + public ValidationCallback(T response, FutureCallback action) { + this.response = response; + this.action = action; + } + + @Override + public void onSuccess(ValidationResult result) { + if (result.getResultCode() == ValidationResultCode.OK) { + action.onSuccess(response); + } else { + onFailure(getException(result)); + } + } + + @Override + 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/security/ValidationResult.java b/application/src/main/java/org/thingsboard/server/service/security/ValidationResult.java new file mode 100644 index 0000000..7b91c76 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/ValidationResult.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ValidationResult { + + private final ValidationResultCode resultCode; + private final String message; + private final V v; + + public static ValidationResult ok(V v) { + return new ValidationResult<>(ValidationResultCode.OK, "Ok", v); + } + + public static ValidationResult accessDenied(String message) { + return new ValidationResult<>(ValidationResultCode.ACCESS_DENIED, message, null); + } + + public static ValidationResult entityNotFound(String message) { + return new ValidationResult<>(ValidationResultCode.ENTITY_NOT_FOUND, message, null); + } + + public static ValidationResult unauthorized(String message) { + return new ValidationResult<>(ValidationResultCode.UNAUTHORIZED, message, null); + } + + public static ValidationResult internalError(String message) { + return new ValidationResult<>(ValidationResultCode.INTERNAL_ERROR, message, null); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/ValidationResultCode.java b/application/src/main/java/org/thingsboard/server/service/security/ValidationResultCode.java new file mode 100644 index 0000000..cf2bc3d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/ValidationResultCode.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security; + +/** + * Created by ashvayka on 17.05.18. + */ +public enum ValidationResultCode { + OK, + UNAUTHORIZED, + ACCESS_DENIED, + ENTITY_NOT_FOUND, + INTERNAL_ERROR +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractJwtAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractJwtAuthenticationToken.java new file mode 100644 index 0000000..563bc40 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractJwtAuthenticationToken.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +public abstract class AbstractJwtAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = -6212297506742428406L; + + private RawAccessJwtToken rawAccessToken; + private SecurityUser securityUser; + + public AbstractJwtAuthenticationToken(RawAccessJwtToken unsafeToken) { + super(null); + this.rawAccessToken = unsafeToken; + this.setAuthenticated(false); + } + + public AbstractJwtAuthenticationToken(SecurityUser securityUser) { + super(securityUser.getAuthorities()); + this.eraseCredentials(); + this.securityUser = securityUser; + super.setAuthenticated(true); + } + + @Override + public void setAuthenticated(boolean authenticated) { + if (authenticated) { + throw new IllegalArgumentException( + "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); + } + super.setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return rawAccessToken; + } + + @Override + public Object getPrincipal() { + return this.securityUser; + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + this.rawAccessToken = null; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java new file mode 100644 index 0000000..74230d9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/DefaultTokenOutdatingService.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.event.UserAuthDataChangedEvent; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; + +import java.util.Optional; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +@Service +public class DefaultTokenOutdatingService implements TokenOutdatingService { + + private final TbTransactionalCache cache; + private final JwtTokenFactory tokenFactory; + + public DefaultTokenOutdatingService(@Qualifier("UsersSessionInvalidation") TbTransactionalCache cache, JwtTokenFactory tokenFactory) { + this.cache = cache; + this.tokenFactory = tokenFactory; + } + + @EventListener(classes = UserAuthDataChangedEvent.class) + public void onUserAuthDataChanged(UserAuthDataChangedEvent event) { + if (StringUtils.hasText(event.getId())) { + cache.put(event.getId(), event.getTs()); + } + } + + @Override + public boolean isOutdated(JwtToken token, UserId userId) { + Claims claims = tokenFactory.parseTokenClaims(token).getBody(); + long issueTime = claims.getIssuedAt().getTime(); + String sessionId = claims.get("sessionId", String.class); + if (isTokenOutdated(issueTime, userId.toString())){ + return true; + } else { + return sessionId != null && isTokenOutdated(issueTime, sessionId); + } + } + + private Boolean isTokenOutdated(long issueTime, String sessionId) { + return Optional.ofNullable(cache.get(sessionId)).map(outdatageTime -> isTokenOutdated(issueTime, outdatageTime.get())).orElse(false); + } + + private boolean isTokenOutdated(long issueTime, Long outdatageTime) { + return MILLISECONDS.toSeconds(issueTime) < MILLISECONDS.toSeconds(outdatageTime); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/JwtAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/JwtAuthenticationToken.java new file mode 100644 index 0000000..1b85431 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/JwtAuthenticationToken.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +public class JwtAuthenticationToken extends AbstractJwtAuthenticationToken { + + private static final long serialVersionUID = -8487219769037942225L; + + public JwtAuthenticationToken(RawAccessJwtToken unsafeToken) { + super(unsafeToken); + } + + public JwtAuthenticationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java new file mode 100644 index 0000000..8c70e69 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import org.thingsboard.server.service.security.model.SecurityUser; + +public class MfaAuthenticationToken extends AbstractJwtAuthenticationToken { + public MfaAuthenticationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/RefreshAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/RefreshAuthenticationToken.java new file mode 100644 index 0000000..ca76984 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/RefreshAuthenticationToken.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +public class RefreshAuthenticationToken extends AbstractJwtAuthenticationToken { + + private static final long serialVersionUID = -1311042791508924523L; + + public RefreshAuthenticationToken(RawAccessJwtToken unsafeToken) { + super(unsafeToken); + } + + public RefreshAuthenticationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java new file mode 100644 index 0000000..02c1bf6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/TokenOutdatingService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth; + +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.JwtToken; + +public interface TokenOutdatingService { + + boolean isOutdated(JwtToken token, UserId userId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java new file mode 100644 index 0000000..9ce2ae7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtAuthenticationProvider.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final JwtTokenFactory tokenFactory; + private final TokenOutdatingService tokenOutdatingService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); + SecurityUser securityUser = tokenFactory.parseAccessJwtToken(rawAccessToken); + + if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { + throw new JwtExpiredTokenException("Token is outdated"); + } + + return new JwtAuthenticationToken(securityUser); + } + + @Override + public boolean supports(Class authentication) { + return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java new file mode 100644 index 0000000..164eb6d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/JwtTokenAuthenticationProcessingFilter.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.thingsboard.server.service.security.auth.JwtAuthenticationToken; +import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class JwtTokenAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { + private final AuthenticationFailureHandler failureHandler; + private final TokenExtractor tokenExtractor; + + @Autowired + public JwtTokenAuthenticationProcessingFilter(AuthenticationFailureHandler failureHandler, + TokenExtractor tokenExtractor, RequestMatcher matcher) { + super(matcher); + this.failureHandler = failureHandler; + this.tokenExtractor = tokenExtractor; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + RawAccessJwtToken token = new RawAccessJwtToken(tokenExtractor.extract(request)); + return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token)); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authResult); + SecurityContextHolder.setContext(context); + chain.doFilter(request, response); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + failureHandler.onAuthenticationFailure(request, response, failed); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java new file mode 100644 index 0000000..2754975 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -0,0 +1,137 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { + private final JwtTokenFactory tokenFactory; + private final UserService userService; + private final CustomerService customerService; + private final TokenOutdatingService tokenOutdatingService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + Assert.notNull(authentication, "No authentication data provided"); + RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials(); + SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken); + UserPrincipal principal = unsafeUser.getUserPrincipal(); + + SecurityUser securityUser; + if (principal.getType() == UserPrincipal.Type.USER_NAME) { + securityUser = authenticateByUserId(unsafeUser.getId()); + } else { + securityUser = authenticateByPublicId(principal.getValue()); + } + securityUser.setSessionId(unsafeUser.getSessionId()); + if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { + throw new CredentialsExpiredException("Token is outdated"); + } + + return new RefreshAuthenticationToken(securityUser); + } + + private SecurityUser authenticateByUserId(UserId userId) { + TenantId systemId = TenantId.SYS_TENANT_ID; + User user = userService.findUserById(systemId, userId); + if (user == null) { + throw new UsernameNotFoundException("User not found by refresh token"); + } + + UserCredentials userCredentials = userService.findUserCredentialsByUserId(systemId, user.getId()); + if (userCredentials == null) { + throw new UsernameNotFoundException("User credentials not found"); + } + + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + + if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned"); + + UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + + return securityUser; + } + + private SecurityUser authenticateByPublicId(String publicId) { + TenantId systemId = TenantId.SYS_TENANT_ID; + CustomerId customerId; + try { + customerId = new CustomerId(UUID.fromString(publicId)); + } catch (Exception e) { + throw new BadCredentialsException("Refresh token is not valid"); + } + Customer publicCustomer = customerService.findCustomerById(systemId, customerId); + if (publicCustomer == null) { + throw new UsernameNotFoundException("Public entity not found by refresh token"); + } + + if (!publicCustomer.isPublic()) { + throw new BadCredentialsException("Refresh token is not valid"); + } + + User user = new User(new UserId(EntityId.NULL_UUID)); + user.setTenantId(publicCustomer.getTenantId()); + user.setCustomerId(publicCustomer.getId()); + user.setEmail(publicId); + user.setAuthority(Authority.CUSTOMER_USER); + user.setFirstName("Public"); + user.setLastName("Public"); + + UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId); + + SecurityUser securityUser = new SecurityUser(user, true, userPrincipal); + + return securityUser; + } + + @Override + public boolean supports(Class authentication) { + return (RefreshAuthenticationToken.class.isAssignableFrom(authentication)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java new file mode 100644 index 0000000..7937641 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenProcessingFilter.java @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; +import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class RefreshTokenProcessingFilter extends AbstractAuthenticationProcessingFilter { + + private final AuthenticationSuccessHandler successHandler; + private final AuthenticationFailureHandler failureHandler; + + private final ObjectMapper objectMapper; + + public RefreshTokenProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler, + AuthenticationFailureHandler failureHandler, ObjectMapper mapper) { + super(defaultProcessUrl); + this.successHandler = successHandler; + this.failureHandler = failureHandler; + this.objectMapper = mapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + if (!HttpMethod.POST.name().equals(request.getMethod())) { + if(log.isDebugEnabled()) { + log.debug("Authentication method not supported. Request method: " + request.getMethod()); + } + throw new AuthMethodNotSupportedException("Authentication method not supported"); + } + + RefreshTokenRequest refreshTokenRequest; + try { + refreshTokenRequest = objectMapper.readValue(request.getReader(), RefreshTokenRequest.class); + } catch (Exception e) { + throw new AuthenticationServiceException("Invalid refresh token request payload"); + } + + if (StringUtils.isBlank(refreshTokenRequest.getRefreshToken())) { + throw new AuthenticationServiceException("Refresh token is not provided"); + } + + RawAccessJwtToken token = new RawAccessJwtToken(refreshTokenRequest.getRefreshToken()); + + return this.getAuthenticationManager().authenticate(new RefreshAuthenticationToken(token)); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + successHandler.onAuthenticationSuccess(request, response, authResult); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + failureHandler.onAuthenticationFailure(request, response, failed); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java new file mode 100644 index 0000000..bdc5d40 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenRequest.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RefreshTokenRequest { + private String refreshToken; + + @JsonCreator + public RefreshTokenRequest(@JsonProperty("refreshToken") String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getRefreshToken() { + return refreshToken; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java new file mode 100644 index 0000000..7c58227 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/SkipPathRequestMatcher.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt; + +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; + +public class SkipPathRequestMatcher implements RequestMatcher { + private OrRequestMatcher matchers; + private RequestMatcher processingMatcher; + + public SkipPathRequestMatcher(List pathsToSkip, String processingPath) { + Assert.notNull(pathsToSkip, "List of paths to skip is required."); + List m = pathsToSkip.stream().map(path -> new AntPathRequestMatcher(path)).collect(Collectors.toList()); + matchers = new OrRequestMatcher(m); + processingMatcher = new AntPathRequestMatcher(processingPath); + } + + @Override + public boolean matches(HttpServletRequest request) { + if (matchers.matches(request)) { + return false; + } + return processingMatcher.matches(request) ? true : false; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java new file mode 100644 index 0000000..0b90cd8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtHeaderTokenExtractor.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.extractor; + +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.config.ThingsboardSecurityConfiguration; + +import javax.servlet.http.HttpServletRequest; + +@Component(value="jwtHeaderTokenExtractor") +public class JwtHeaderTokenExtractor implements TokenExtractor { + public static final String HEADER_PREFIX = "Bearer "; + + @Override + public String extract(HttpServletRequest request) { + String header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM); + if (StringUtils.isBlank(header)) { + header = request.getHeader(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM_V2); + if (StringUtils.isBlank(header)) { + throw new AuthenticationServiceException("Authorization header cannot be blank!"); + } + } + + if (header.length() < HEADER_PREFIX.length()) { + throw new AuthenticationServiceException("Invalid authorization header size."); + } + + return header.substring(HEADER_PREFIX.length(), header.length()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java new file mode 100644 index 0000000..cb40379 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/JwtQueryTokenExtractor.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.extractor; + +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.config.ThingsboardSecurityConfiguration; + +import javax.servlet.http.HttpServletRequest; + +@Component(value="jwtQueryTokenExtractor") +public class JwtQueryTokenExtractor implements TokenExtractor { + + @Override + public String extract(HttpServletRequest request) { + String token = null; + if (request.getParameterMap() != null && !request.getParameterMap().isEmpty()) { + String[] tokenParamValue = request.getParameterMap().get(ThingsboardSecurityConfiguration.JWT_TOKEN_QUERY_PARAM); + if (tokenParamValue != null && tokenParamValue.length == 1) { + token = tokenParamValue[0]; + } + } + if (StringUtils.isBlank(token)) { + throw new AuthenticationServiceException("Authorization query parameter cannot be blank!"); + } + + return token; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java new file mode 100644 index 0000000..26cbc97 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/extractor/TokenExtractor.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.extractor; + +import javax.servlet.http.HttpServletRequest; + +public interface TokenExtractor { + public String extract(HttpServletRequest request); +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java new file mode 100644 index 0000000..36beaec --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java @@ -0,0 +1,162 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.settings; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.security.model.JwtSettings; +import org.thingsboard.server.dao.settings.AdminSettingsService; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultJwtSettingsService implements JwtSettingsService { + + @Lazy + private final AdminSettingsService adminSettingsService; + @Lazy + private final Optional tbClusterService; + private final JwtSettingsValidator jwtSettingsValidator; + + @Value("${security.jwt.tokenExpirationTime:9000}") + private Integer tokenExpirationTime; + @Value("${security.jwt.refreshTokenExpTime:604800}") + private Integer refreshTokenExpTime; + @Value("${security.jwt.tokenIssuer:thingsboard.io}") + private String tokenIssuer; + @Value("${security.jwt.tokenSigningKey:thingsboardDefaultSigningKey}") + private String tokenSigningKey; + + private volatile JwtSettings jwtSettings = null; //lazy init + + /** + * Create JWT admin settings is intended to be called from Install scripts only + */ + @Override + public void createRandomJwtSettings() { + if (getJwtSettingsFromDb() == null) { + log.info("Creating JWT admin settings..."); + this.jwtSettings = getJwtSettingsFromYml(); + if (isSigningKeyDefault(jwtSettings)) { + this.jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( + RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); + } + saveJwtSettings(jwtSettings); + } else { + log.info("Skip creating JWT admin settings because they already exist."); + } + } + + /** + * Create JWT admin settings is intended to be called from Upgrade scripts only + */ + @Override + public void saveLegacyYmlSettings() { + log.info("Saving legacy JWT admin settings from YML..."); + if (getJwtSettingsFromDb() == null) { + saveJwtSettings(getJwtSettingsFromYml()); + } + } + + @Override + public JwtSettings saveJwtSettings(JwtSettings jwtSettings) { + jwtSettingsValidator.validate(jwtSettings); + final AdminSettings adminJwtSettings = mapJwtToAdminSettings(jwtSettings); + final AdminSettings existedSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, ADMIN_SETTINGS_JWT_KEY); + if (existedSettings != null) { + adminJwtSettings.setId(existedSettings.getId()); + } + + log.info("Saving new JWT admin settings. From this moment, the JWT parameters from YAML and ENV will be ignored"); + adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminJwtSettings); + + tbClusterService.ifPresent(cs -> cs.broadcastEntityStateChangeEvent(TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID, ComponentLifecycleEvent.UPDATED)); + return reloadJwtSettings(); + } + + @Override + public JwtSettings reloadJwtSettings() { + return getJwtSettings(true); + } + + @Override + public JwtSettings getJwtSettings() { + return getJwtSettings(false); + } + + public JwtSettings getJwtSettings(boolean forceReload) { + if (this.jwtSettings == null || forceReload) { + synchronized (this) { + if (this.jwtSettings == null || forceReload) { + JwtSettings result = getJwtSettingsFromDb(); + if (result == null) { + result = getJwtSettingsFromYml(); + log.warn("Loading the JWT settings from YML since there are no settings in DB. Looks like the upgrade script was not applied."); + } + if (isSigningKeyDefault(result)) { + log.warn("WARNING: The platform is configured to use default JWT Signing Key. " + + "This is a security issue that needs to be resolved. Please change the JWT Signing Key using the Web UI. " + + "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator."); + } + this.jwtSettings = result; + } + } + } + return this.jwtSettings; + } + + private JwtSettings getJwtSettingsFromYml() { + return new JwtSettings(this.tokenExpirationTime, this.refreshTokenExpTime, this.tokenIssuer, this.tokenSigningKey); + } + + private JwtSettings getJwtSettingsFromDb() { + AdminSettings adminJwtSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, ADMIN_SETTINGS_JWT_KEY); + return adminJwtSettings != null ? mapAdminToJwtSettings(adminJwtSettings) : null; + } + + private JwtSettings mapAdminToJwtSettings(AdminSettings adminSettings) { + Objects.requireNonNull(adminSettings, "adminSettings for JWT is null"); + return JacksonUtil.treeToValue(adminSettings.getJsonValue(), JwtSettings.class); + } + + private AdminSettings mapJwtToAdminSettings(JwtSettings jwtSettings) { + Objects.requireNonNull(jwtSettings, "jwtSettings is null"); + AdminSettings adminJwtSettings = new AdminSettings(); + adminJwtSettings.setTenantId(TenantId.SYS_TENANT_ID); + adminJwtSettings.setKey(ADMIN_SETTINGS_JWT_KEY); + adminJwtSettings.setJsonValue(JacksonUtil.valueToTree(jwtSettings)); + return adminJwtSettings; + } + + private boolean isSigningKeyDefault(JwtSettings settings) { + return TOKEN_SIGNING_KEY_DEFAULT.equals(settings.getTokenSigningKey()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java new file mode 100644 index 0000000..bf83179 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.settings; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.RandomUtils; +import org.apache.commons.lang3.StringUtils; +import org.bouncycastle.util.Arrays; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.model.JwtSettings; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.Base64; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class DefaultJwtSettingsValidator implements JwtSettingsValidator { + + @Override + public void validate(JwtSettings jwtSettings) { + if (StringUtils.isEmpty(jwtSettings.getTokenIssuer())) { + throw new DataValidationException("JWT token issuer should be specified!"); + } + if (Optional.ofNullable(jwtSettings.getRefreshTokenExpTime()).orElse(0) <= TimeUnit.MINUTES.toSeconds(15)) { + throw new DataValidationException("JWT refresh token expiration time should be at least 15 minutes!"); + } + if (Optional.ofNullable(jwtSettings.getTokenExpirationTime()).orElse(0) <= TimeUnit.MINUTES.toSeconds(1)) { + throw new DataValidationException("JWT token expiration time should be at least 1 minute!"); + } + if (jwtSettings.getTokenExpirationTime() >= jwtSettings.getRefreshTokenExpTime()) { + throw new DataValidationException("JWT token expiration time should greater than JWT refresh token expiration time!"); + } + if (StringUtils.isEmpty(jwtSettings.getTokenSigningKey())) { + throw new DataValidationException("JWT token signing key should be specified!"); + } + + byte[] decodedKey; + try { + decodedKey = Base64.getDecoder().decode(jwtSettings.getTokenSigningKey()); + } catch (Exception e) { + throw new DataValidationException("JWT token signing key should be a valid Base64 encoded string! " + e.getMessage()); + } + + if (Arrays.isNullOrEmpty(decodedKey)) { + throw new DataValidationException("JWT token signing key should be non-empty after Base64 decoding!"); + } + if (decodedKey.length * Byte.SIZE < 256 && !JwtSettingsService.TOKEN_SIGNING_KEY_DEFAULT.equals(jwtSettings.getTokenSigningKey())) { + throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 256 bits of data!"); + } + + System.arraycopy(decodedKey, 0, RandomUtils.nextBytes(decodedKey.length), 0, decodedKey.length); //secure memory + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java new file mode 100644 index 0000000..6202e60 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/InstallJwtSettingsValidator.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.settings; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.model.JwtSettings; + +/** + * During Install or upgrade the validation is suppressed to keep existing data + * */ + +@Primary +@Profile("install") +@Component +@RequiredArgsConstructor +public class InstallJwtSettingsValidator implements JwtSettingsValidator { + + @Override + public void validate(JwtSettings jwtSettings) { + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java new file mode 100644 index 0000000..bf02be5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.settings; + +import org.thingsboard.server.common.data.security.model.JwtSettings; + +public interface JwtSettingsService { + + String ADMIN_SETTINGS_JWT_KEY = "jwt"; + String TOKEN_SIGNING_KEY_DEFAULT = "thingsboardDefaultSigningKey"; + + JwtSettings getJwtSettings(); + + JwtSettings reloadJwtSettings(); + + void createRandomJwtSettings(); + + void saveLegacyYmlSettings(); + + JwtSettings saveJwtSettings(JwtSettings jwtSettings); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java new file mode 100644 index 0000000..06a82fc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsValidator.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.jwt.settings; + +import org.thingsboard.server.common.data.security.model.JwtSettings; + +public interface JwtSettingsValidator { + + void validate(JwtSettings jwtSettings); +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java new file mode 100644 index 0000000..0f36273 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -0,0 +1,190 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.LockedException; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.common.msg.tools.TbRateLimits; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Service +@RequiredArgsConstructor +@TbCoreComponent +public class DefaultTwoFactorAuthService implements TwoFactorAuthService { + + private final TwoFaConfigManager configManager; + private final SystemSecurityService systemSecurityService; + private final UserService userService; + private final Map> providers = new EnumMap<>(TwoFaProviderType.class); + + private static final ThingsboardException ACCOUNT_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA is not configured for account", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_AVAILABLE_ERROR = new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.GENERAL); + + private final ConcurrentMap> verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap> verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); + + @Override + public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { + return configManager.getAccountTwoFaSettings(tenantId, userId) + .map(settings -> !settings.getConfigs().isEmpty()) + .orElse(false); + } + + @Override + public void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException { + getTwoFaProvider(providerType).check(tenantId); + } + + + @Override + public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception { + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); + prepareVerificationCode(user, accountConfig, checkLimits); + } + + @Override + public void prepareVerificationCode(SecurityUser user, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { + PlatformTwoFaSettings twoFaSettings = configManager.getPlatformTwoFaSettings(user.getTenantId(), true) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); + if (checkLimits) { + Integer minVerificationCodeSendPeriod = twoFaSettings.getMinVerificationCodeSendPeriod(); + String rateLimit = null; + if (minVerificationCodeSendPeriod != null && minVerificationCodeSendPeriod > 4) { + rateLimit = "1:" + minVerificationCodeSendPeriod; + } + checkRateLimits(user.getId(), accountConfig.getProviderType(), rateLimit, verificationCodeSendingRateLimits); + } + + TwoFaProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); + getTwoFaProvider(accountConfig.getProviderType()).prepareVerificationCode(user, providerConfig, accountConfig); + } + + + @Override + public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException { + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); + return checkVerificationCode(user, verificationCode, accountConfig, checkLimits); + } + + @Override + public boolean checkVerificationCode(SecurityUser user, String verificationCode, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { + if (!userService.findUserCredentialsByUserId(user.getTenantId(), user.getId()).isEnabled()) { + throw new ThingsboardException("User is disabled", ThingsboardErrorCode.AUTHENTICATION); + } + + PlatformTwoFaSettings twoFaSettings = configManager.getPlatformTwoFaSettings(user.getTenantId(), true) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); + if (checkLimits) { + checkRateLimits(user.getId(), accountConfig.getProviderType(), twoFaSettings.getVerificationCodeCheckRateLimit(), verificationCodeCheckingRateLimits); + } + TwoFaProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); + + boolean verificationSuccess = false; + if (StringUtils.isNotBlank(verificationCode)) { + if (StringUtils.isNumeric(verificationCode) || accountConfig.getProviderType() == TwoFaProviderType.BACKUP_CODE) { + verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(user, verificationCode, providerConfig, accountConfig); + } + } + if (checkLimits) { + try { + systemSecurityService.validateTwoFaVerification(user, verificationSuccess, twoFaSettings); + } catch (LockedException e) { + verificationCodeCheckingRateLimits.remove(user.getId()); + verificationCodeSendingRateLimits.remove(user.getId()); + throw new ThingsboardException(e.getMessage(), ThingsboardErrorCode.AUTHENTICATION); + } + if (verificationSuccess) { + verificationCodeCheckingRateLimits.remove(user.getId()); + verificationCodeSendingRateLimits.remove(user.getId()); + } + } + return verificationSuccess; + } + + private void checkRateLimits(UserId userId, TwoFaProviderType providerType, String rateLimitConfig, + ConcurrentMap> rateLimits) throws ThingsboardException { + if (StringUtils.isNotEmpty(rateLimitConfig)) { + ConcurrentMap providersRateLimits = rateLimits.computeIfAbsent(userId, i -> new ConcurrentHashMap<>()); + + TbRateLimits rateLimit = providersRateLimits.get(providerType); + if (rateLimit == null || !rateLimit.getConfiguration().equals(rateLimitConfig)) { + rateLimit = new TbRateLimits(rateLimitConfig, true); + providersRateLimits.put(providerType, rateLimit); + } + if (!rateLimit.tryConsume()) { + throw new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } else { + rateLimits.remove(userId); + } + } + + + @Override + public TwoFaAccountConfig generateNewAccountConfig(User user, TwoFaProviderType providerType) throws ThingsboardException { + TwoFaProviderConfig providerConfig = getTwoFaProviderConfig(user.getTenantId(), providerType); + return getTwoFaProvider(providerType).generateNewAccountConfig(user, providerConfig); + } + + + private TwoFaProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException { + return configManager.getPlatformTwoFaSettings(tenantId, true) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); + } + + private TwoFaProvider getTwoFaProvider(TwoFaProviderType providerType) throws ThingsboardException { + return Optional.ofNullable(providers.get(providerType)) + .orElseThrow(() -> PROVIDER_NOT_AVAILABLE_ERROR); + } + + @Autowired + private void setProviders(Collection providers) { + providers.forEach(provider -> { + this.providers.put(provider.getType(), provider); + }); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java new file mode 100644 index 0000000..aabda27 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface TwoFactorAuthService { + + boolean isTwoFaEnabled(TenantId tenantId, UserId userId); + + void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; + + + void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception; + + void prepareVerificationCode(SecurityUser user, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; + + + boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException; + + boolean checkVerificationCode(SecurityUser user, String verificationCode, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; + + + TwoFaAccountConfig generateNewAccountConfig(User user, TwoFaProviderType providerType) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java new file mode 100644 index 0000000..ad2eceb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -0,0 +1,187 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.dao.service.ConstraintValidator; +import org.thingsboard.server.dao.settings.AdminSettingsDao; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.user.UserAuthSettingsDao; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class DefaultTwoFaConfigManager implements TwoFaConfigManager { + + private final UserAuthSettingsDao userAuthSettingsDao; + private final AdminSettingsService adminSettingsService; + private final AdminSettingsDao adminSettingsDao; + @Autowired @Lazy + private TwoFactorAuthService twoFactorAuthService; + + protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; + + + @Override + public Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId) { + PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null); + return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .map(userAuthSettings -> { + AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings(); + if (twoFaSettings == null) return null; + boolean updateNeeded; + + Map configs = twoFaSettings.getConfigs(); + updateNeeded = configs.keySet().removeIf(providerType -> { + return platformTwoFaSettings == null || platformTwoFaSettings.getProviderConfig(providerType).isEmpty(); + }); + if (configs.size() == 1 && configs.containsKey(TwoFaProviderType.BACKUP_CODE)) { + configs.remove(TwoFaProviderType.BACKUP_CODE); + updateNeeded = true; + } + if (!configs.isEmpty() && configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { + configs.values().stream() + .filter(config -> config.getProviderType() != TwoFaProviderType.BACKUP_CODE) + .findFirst().ifPresent(config -> config.setUseByDefault(true)); + updateNeeded = true; + } + + if (updateNeeded) { + twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings); + } + return twoFaSettings; + }); + } + + protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { + UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .orElseGet(() -> { + UserAuthSettings newUserAuthSettings = new UserAuthSettings(); + newUserAuthSettings.setUserId(userId); + return newUserAuthSettings; + }); + userAuthSettings.setTwoFaSettings(settings); + settings.getConfigs().values().forEach(accountConfig -> accountConfig.setSerializeHiddenFields(true)); + userAuthSettingsDao.save(tenantId, userAuthSettings); + settings.getConfigs().values().forEach(accountConfig -> accountConfig.setSerializeHiddenFields(false)); + return settings; + } + + + @Override + public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { + return getAccountTwoFaSettings(tenantId, userId) + .map(AccountTwoFaSettings::getConfigs) + .flatMap(configs -> Optional.ofNullable(configs.get(providerType))); + } + + @Override + public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) { + getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) + .orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured")); + + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> { + AccountTwoFaSettings newSettings = new AccountTwoFaSettings(); + newSettings.setConfigs(new LinkedHashMap<>()); + return newSettings; + }); + Map configs = settings.getConfigs(); + if (configs.isEmpty() && accountConfig.getProviderType() == TwoFaProviderType.BACKUP_CODE) { + throw new IllegalArgumentException("To use 2FA backup codes you first need to configure at least one provider"); + } + if (accountConfig.isUseByDefault()) { + configs.values().forEach(config -> config.setUseByDefault(false)); + } + configs.put(accountConfig.getProviderType(), accountConfig); + if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { + configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true)); + } + return saveAccountTwoFaSettings(tenantId, userId, settings); + } + + @Override + public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId) + .orElseThrow(() -> new IllegalArgumentException("2FA not configured")); + settings.getConfigs().remove(providerType); + if (settings.getConfigs().size() == 1) { + settings.getConfigs().remove(TwoFaProviderType.BACKUP_CODE); + } + if (!settings.getConfigs().isEmpty() && settings.getConfigs().values().stream() + .noneMatch(TwoFaAccountConfig::isUseByDefault)) { + settings.getConfigs().values().stream() + .min(Comparator.comparing(TwoFaAccountConfig::getProviderType)) + .ifPresent(config -> config.setUseByDefault(true)); + } + return saveAccountTwoFaSettings(tenantId, userId, settings); + } + + + private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFaProviderType providerType) { + return getPlatformTwoFaSettings(tenantId, true) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)); + } + + @Override + public Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault) { + return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .map(adminSettings -> JacksonUtil.treeToValue(adminSettings.getJsonValue(), PlatformTwoFaSettings.class)); + } + + @Override + public PlatformTwoFaSettings savePlatformTwoFaSettings(TenantId tenantId, PlatformTwoFaSettings twoFactorAuthSettings) throws ThingsboardException { + ConstraintValidator.validateFields(twoFactorAuthSettings); + for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) { + twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType()); + } + + AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .orElseGet(() -> { + AdminSettings newSettings = new AdminSettings(); + newSettings.setKey(TWO_FACTOR_AUTH_SETTINGS_KEY); + return newSettings; + }); + settings.setJsonValue(JacksonUtil.valueToTree(twoFactorAuthSettings)); + adminSettingsService.saveAdminSettings(tenantId, settings); + return twoFactorAuthSettings; + } + + @Override + public void deletePlatformTwoFaSettings(TenantId tenantId) { + Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .ifPresent(adminSettings -> adminSettingsDao.removeById(tenantId, adminSettings.getId().getId())); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java new file mode 100644 index 0000000..c0e3200 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.config; + +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; + +import java.util.Optional; + +public interface TwoFaConfigManager { + + Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); + + + Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + + AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig); + + AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + + + Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault); + + PlatformTwoFaSettings savePlatformTwoFaSettings(TenantId tenantId, PlatformTwoFaSettings twoFactorAuthSettings) throws ThingsboardException; + + void deletePlatformTwoFaSettings(TenantId tenantId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java new file mode 100644 index 0000000..2522d3e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface TwoFaProvider { + + A generateNewAccountConfig(User user, C providerConfig); + + default void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException {} + + boolean checkVerificationCode(SecurityUser user, String code, C providerConfig, A accountConfig); + + default void check(TenantId tenantId) throws ThingsboardException {}; + + + TwoFaProviderType getType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java new file mode 100644 index 0000000..ac8d76b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.CollectionsUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.security.model.mfa.account.BackupCodeTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.BackupCodeTwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@TbCoreComponent +public class BackupCodeTwoFaProvider implements TwoFaProvider { + + @Autowired @Lazy + private TwoFaConfigManager twoFaConfigManager; + + @Override + public BackupCodeTwoFaAccountConfig generateNewAccountConfig(User user, BackupCodeTwoFaProviderConfig providerConfig) { + BackupCodeTwoFaAccountConfig config = new BackupCodeTwoFaAccountConfig(); + config.setCodes(generateCodes(providerConfig.getCodesQuantity(), 8)); + config.setSerializeHiddenFields(true); + return config; + } + + private static Set generateCodes(int count, int length) { + return Stream.generate(() -> StringUtils.random(length, "0123456789abcdef")) + .distinct().limit(count) + .collect(Collectors.toSet()); + } + + @Override + public boolean checkVerificationCode(SecurityUser user, String code, BackupCodeTwoFaProviderConfig providerConfig, BackupCodeTwoFaAccountConfig accountConfig) { + if (CollectionsUtil.contains(accountConfig.getCodes(), code)) { + accountConfig.getCodes().remove(code); + twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return true; + } else { + return false; + } + } + + @Override + public TwoFaProviderType getType() { + return TwoFaProviderType.BACKUP_CODE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFaProvider.java new file mode 100644 index 0000000..085b5a9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFaProvider.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider.impl; + +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.model.mfa.account.EmailTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.EmailTwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Service +@TbCoreComponent +public class EmailTwoFaProvider extends OtpBasedTwoFaProvider { + + private final MailService mailService; + + protected EmailTwoFaProvider(CacheManager cacheManager, MailService mailService) { + super(cacheManager); + this.mailService = mailService; + } + + @Override + public EmailTwoFaAccountConfig generateNewAccountConfig(User user, EmailTwoFaProviderConfig providerConfig) { + EmailTwoFaAccountConfig config = new EmailTwoFaAccountConfig(); + config.setEmail(user.getEmail()); + return config; + } + + @Override + public void check(TenantId tenantId) throws ThingsboardException { + try { + mailService.testConnection(tenantId); + } catch (Exception e) { + throw new ThingsboardException("Mail service is not set up", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + + @Override + protected void sendVerificationCode(SecurityUser user, String verificationCode, EmailTwoFaProviderConfig providerConfig, EmailTwoFaAccountConfig accountConfig) throws ThingsboardException { + mailService.sendTwoFaVerificationEmail(accountConfig.getEmail(), verificationCode, providerConfig.getVerificationCodeLifetime()); + } + + @Override + public TwoFaProviderType getType() { + return TwoFaProviderType.EMAIL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java new file mode 100644 index 0000000..b75e81b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider.impl; + +import lombok.Data; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.mfa.account.OtpBasedTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.OtpBasedTwoFaProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.io.Serializable; +import java.util.concurrent.TimeUnit; + +public abstract class OtpBasedTwoFaProvider implements TwoFaProvider { + + private final Cache verificationCodesCache; + + protected OtpBasedTwoFaProvider(CacheManager cacheManager) { + this.verificationCodesCache = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE); + } + + + @Override + public final void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException { + String verificationCode = StringUtils.randomNumeric(6); + sendVerificationCode(user, verificationCode, providerConfig, accountConfig); + verificationCodesCache.put(user.getId(), new Otp(System.currentTimeMillis(), verificationCode, accountConfig)); + } + + protected abstract void sendVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) throws ThingsboardException; + + + @Override + public final boolean checkVerificationCode(SecurityUser user, String code, C providerConfig, A accountConfig) { + Otp correctVerificationCode = verificationCodesCache.get(user.getId(), Otp.class); + if (correctVerificationCode != null) { + if (System.currentTimeMillis() - correctVerificationCode.getTimestamp() + > TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) { + verificationCodesCache.evict(user.getId()); + return false; + } + if (code.equals(correctVerificationCode.getValue()) + && accountConfig.equals(correctVerificationCode.getAccountConfig())) { + verificationCodesCache.evict(user.getId()); + return true; + } + } + return false; + } + + + @Data + public static class Otp implements Serializable { + private final long timestamp; + private final String value; + private final OtpBasedTwoFaAccountConfig accountConfig; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java new file mode 100644 index 0000000..419a60b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFaProvider.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider.impl; + +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Map; + +@Service +@TbCoreComponent +public class SmsTwoFaProvider extends OtpBasedTwoFaProvider { + + private final SmsService smsService; + + public SmsTwoFaProvider(CacheManager cacheManager, SmsService smsService) { + super(cacheManager); + this.smsService = smsService; + } + + + @Override + public SmsTwoFaAccountConfig generateNewAccountConfig(User user, SmsTwoFaProviderConfig providerConfig) { + return new SmsTwoFaAccountConfig(); + } + + @Override + protected void sendVerificationCode(SecurityUser user, String verificationCode, SmsTwoFaProviderConfig providerConfig, SmsTwoFaAccountConfig accountConfig) throws ThingsboardException { + Map messageData = Map.of( + "code", verificationCode, + "userEmail", user.getEmail() + ); + String message = TbNodeUtils.processTemplate(providerConfig.getSmsVerificationMessageTemplate(), messageData); + String phoneNumber = accountConfig.getPhoneNumber(); + + smsService.sendSms(user.getTenantId(), user.getCustomerId(), new String[]{phoneNumber}, message); + } + + @Override + public void check(TenantId tenantId) throws ThingsboardException { + if (!smsService.isConfigured(tenantId)) { + throw new ThingsboardException("SMS service is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + + + @Override + public TwoFaProviderType getType() { + return TwoFaProviderType.SMS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java new file mode 100644 index 0000000..f634185 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.mfa.provider.impl; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.lang3.RandomUtils; +import org.apache.http.client.utils.URIBuilder; +import org.jboss.aerogear.security.otp.Totp; +import org.jboss.aerogear.security.otp.api.Base32; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFaProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Service +@RequiredArgsConstructor +@TbCoreComponent +public class TotpTwoFaProvider implements TwoFaProvider { + + @Override + public final TotpTwoFaAccountConfig generateNewAccountConfig(User user, TotpTwoFaProviderConfig providerConfig) { + TotpTwoFaAccountConfig config = new TotpTwoFaAccountConfig(); + String secretKey = generateSecretKey(); + config.setAuthUrl(getTotpAuthUrl(user, secretKey, providerConfig)); + return config; + } + + @Override + public final boolean checkVerificationCode(SecurityUser user, String code, TotpTwoFaProviderConfig providerConfig, TotpTwoFaAccountConfig accountConfig) { + String secretKey = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build().getQueryParams().getFirst("secret"); + return new Totp(secretKey).verify(code); + } + + @SneakyThrows + private String getTotpAuthUrl(User user, String secretKey, TotpTwoFaProviderConfig providerConfig) { + URIBuilder uri = new URIBuilder() + .setScheme("otpauth") + .setHost("totp") + .setParameter("issuer", providerConfig.getIssuerName()) + .setPath("/" + providerConfig.getIssuerName() + ":" + user.getEmail()) + .setParameter("secret", secretKey); + return uri.build().toASCIIString(); + } + + private String generateSecretKey() { + return Base32.encode(RandomUtils.nextBytes(20)); + } + + + @Override + public TwoFaProviderType getType() { + return TwoFaProviderType.TOTP; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java new file mode 100644 index 0000000..ba0e4aa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java @@ -0,0 +1,229 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.StringUtils; +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.id.DashboardId; +import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.entitiy.user.TbUserService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +public abstract class AbstractOAuth2ClientMapper { + private static final int DASHBOARDS_REQUEST_LIMIT = 10; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private UserService userService; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private TenantService tenantService; + + @Autowired + private CustomerService customerService; + + @Autowired + private DashboardService dashboardService; + + @Autowired + private InstallScripts installScripts; + + @Autowired + private TbUserService tbUserService; + + @Autowired + protected TbTenantProfileCache tenantProfileCache; + + @Autowired + protected TbClusterService tbClusterService; + + @Value("${edges.enabled}") + @Getter + private boolean edgesEnabled; + + private final Lock userCreationLock = new ReentrantLock(); + + protected SecurityUser getOrCreateSecurityUserFromOAuth2User(OAuth2User oauth2User, OAuth2Registration registration) { + + OAuth2MapperConfig config = registration.getMapperConfig(); + + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, oauth2User.getEmail()); + + User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail()); + + if (user == null && !config.isAllowUserCreation()) { + throw new UsernameNotFoundException("User not found: " + oauth2User.getEmail()); + } + + if (user == null) { + userCreationLock.lock(); + try { + user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, oauth2User.getEmail()); + if (user == null) { + user = new User(); + if (oauth2User.getCustomerId() == null && StringUtils.isEmpty(oauth2User.getCustomerName())) { + user.setAuthority(Authority.TENANT_ADMIN); + } else { + user.setAuthority(Authority.CUSTOMER_USER); + } + TenantId tenantId = oauth2User.getTenantId() != null ? + oauth2User.getTenantId() : getTenantId(oauth2User.getTenantName()); + user.setTenantId(tenantId); + CustomerId customerId = oauth2User.getCustomerId() != null ? + oauth2User.getCustomerId() : getCustomerId(user.getTenantId(), oauth2User.getCustomerName()); + user.setCustomerId(customerId); + user.setEmail(oauth2User.getEmail()); + user.setFirstName(oauth2User.getFirstName()); + user.setLastName(oauth2User.getLastName()); + + ObjectNode additionalInfo = objectMapper.createObjectNode(); + + if (!StringUtils.isEmpty(oauth2User.getDefaultDashboardName())) { + Optional dashboardIdOpt = + user.getAuthority() == Authority.TENANT_ADMIN ? + getDashboardId(tenantId, oauth2User.getDefaultDashboardName()) + : getDashboardId(tenantId, customerId, oauth2User.getDefaultDashboardName()); + if (dashboardIdOpt.isPresent()) { + additionalInfo.put("defaultDashboardFullscreen", oauth2User.isAlwaysFullScreen()); + additionalInfo.put("defaultDashboardId", dashboardIdOpt.get().getId().toString()); + } + } + + if (registration.getAdditionalInfo() != null && + registration.getAdditionalInfo().has("providerName")) { + additionalInfo.put("authProviderName", registration.getAdditionalInfo().get("providerName").asText()); + } + + user.setAdditionalInfo(additionalInfo); + + user = tbUserService.save(tenantId, customerId, user, false, null, null); + if (config.isActivateUser()) { + UserCredentials userCredentials = userService.findUserCredentialsByUserId(user.getTenantId(), user.getId()); + userService.activateUserCredentials(user.getTenantId(), userCredentials.getActivateToken(), passwordEncoder.encode("")); + } + } + } catch (Exception e) { + log.error("Can't get or create security user from oauth2 user", e); + throw new RuntimeException("Can't get or create security user from oauth2 user", e); + } finally { + userCreationLock.unlock(); + } + } + + try { + SecurityUser securityUser = new SecurityUser(user, true, principal); + return (SecurityUser) new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities()).getPrincipal(); + } catch (Exception e) { + log.error("Can't get or create security user from oauth2 user", e); + throw new RuntimeException("Can't get or create security user from oauth2 user", e); + } + } + + private TenantId getTenantId(String tenantName) throws IOException { + List tenants = tenantService.findTenants(new PageLink(1, 0, tenantName)).getData(); + Tenant tenant; + if (tenants == null || tenants.isEmpty()) { + tenant = new Tenant(); + tenant.setTitle(tenantName); + tenant = tenantService.saveTenant(tenant); + installScripts.createDefaultRuleChains(tenant.getId()); + installScripts.createDefaultEdgeRuleChains(tenant.getId()); + tenantProfileCache.evict(tenant.getId()); + tbClusterService.onTenantChange(tenant, null); + tbClusterService.broadcastEntityStateChangeEvent(tenant.getId(), tenant.getId(), + ComponentLifecycleEvent.CREATED); + } else { + tenant = tenants.get(0); + } + return tenant.getTenantId(); + } + + private CustomerId getCustomerId(TenantId tenantId, String customerName) { + if (StringUtils.isEmpty(customerName)) { + return null; + } + Optional customerOpt = customerService.findCustomerByTenantIdAndTitle(tenantId, customerName); + if (customerOpt.isPresent()) { + return customerOpt.get().getId(); + } else { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle(customerName); + return customerService.saveCustomer(customer).getId(); + } + } + + private Optional getDashboardId(TenantId tenantId, String dashboardName) { + return Optional.ofNullable(dashboardService.findFirstDashboardInfoByTenantIdAndName(tenantId, dashboardName)).map(IdBased::getId); + } + + private Optional getDashboardId(TenantId tenantId, CustomerId customerId, String dashboardName) { + PageData dashboardsPage; + PageLink pageLink = null; + do { + pageLink = pageLink == null ? new PageLink(DASHBOARDS_REQUEST_LIMIT) : pageLink.nextPageLink(); + dashboardsPage = dashboardService.findDashboardsByTenantIdAndCustomerId(tenantId, customerId, pageLink); + Optional dashboardInfoOpt = dashboardsPage.getData().stream() + .filter(dashboardInfo -> dashboardName.equals(dashboardInfo.getName())) + .findAny(); + if (dashboardInfoOpt.isPresent()) { + return dashboardInfoOpt.map(DashboardInfo::getId); + } + } while (dashboardsPage.hasNext()); + return Optional.empty(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java new file mode 100644 index 0000000..5b2036c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java @@ -0,0 +1,103 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +@Service(value = "appleOAuth2ClientMapper") +@Slf4j +@TbCoreComponent +public class AppleOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper { + + private static final String USER = "user"; + private static final String NAME = "name"; + private static final String FIRST_NAME = "firstName"; + private static final String LAST_NAME = "lastName"; + private static final String EMAIL = "email"; + + @Override + public SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration) { + OAuth2MapperConfig config = registration.getMapperConfig(); + Map attributes = updateAttributesFromRequestParams(request, token.getPrincipal().getAttributes()); + String email = BasicMapperUtils.getStringAttributeByKey(attributes, config.getBasic().getEmailAttributeKey()); + OAuth2User oauth2User = BasicMapperUtils.getOAuth2User(email, attributes, config); + + return getOrCreateSecurityUserFromOAuth2User(oauth2User, registration); + } + + private static Map updateAttributesFromRequestParams(HttpServletRequest request, Map attributes) { + Map updated = attributes; + MultiValueMap params = toMultiMap(request.getParameterMap()); + String userValue = params.getFirst(USER); + if (StringUtils.hasText(userValue)) { + JsonNode user = null; + try { + user = JacksonUtil.toJsonNode(userValue); + } catch (Exception e) {} + if (user != null) { + updated = new HashMap<>(attributes); + if (user.has(NAME)) { + JsonNode name = user.get(NAME); + if (name.isObject()) { + JsonNode firstName = name.get(FIRST_NAME); + if (firstName != null && firstName.isTextual()) { + updated.put(FIRST_NAME, firstName.asText()); + } + JsonNode lastName = name.get(LAST_NAME); + if (lastName != null && lastName.isTextual()) { + updated.put(LAST_NAME, lastName.asText()); + } + } + } + if (user.has(EMAIL)) { + JsonNode email = user.get(EMAIL); + if (email != null && email.isTextual()) { + updated.put(EMAIL, email.asText()); + } + } + } + } + return updated; + } + + private static MultiValueMap toMultiMap(Map map) { + MultiValueMap params = new LinkedMultiValueMap<>(map.size()); + map.forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + params.add(key, value); + } + } + }); + return params; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicMapperUtils.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicMapperUtils.java new file mode 100644 index 0000000..e8e1011 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicMapperUtils.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.text.StrSubstitutor; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.dao.oauth2.OAuth2User; + +import java.util.Map; + +@Slf4j +public class BasicMapperUtils { + private static final String START_PLACEHOLDER_PREFIX = "%{"; + private static final String END_PLACEHOLDER_PREFIX = "}"; + + public static OAuth2User getOAuth2User(String email, Map attributes, OAuth2MapperConfig config) { + OAuth2User oauth2User = new OAuth2User(); + oauth2User.setEmail(email); + oauth2User.setTenantName(getTenantName(email, attributes, config)); + if (!StringUtils.isEmpty(config.getBasic().getLastNameAttributeKey())) { + String lastName = getStringAttributeByKey(attributes, config.getBasic().getLastNameAttributeKey()); + oauth2User.setLastName(lastName); + } + if (!StringUtils.isEmpty(config.getBasic().getFirstNameAttributeKey())) { + String firstName = getStringAttributeByKey(attributes, config.getBasic().getFirstNameAttributeKey()); + oauth2User.setFirstName(firstName); + } + if (!StringUtils.isEmpty(config.getBasic().getCustomerNamePattern())) { + StrSubstitutor sub = new StrSubstitutor(attributes, START_PLACEHOLDER_PREFIX, END_PLACEHOLDER_PREFIX); + String customerName = sub.replace(config.getBasic().getCustomerNamePattern()); + oauth2User.setCustomerName(customerName); + } + oauth2User.setAlwaysFullScreen(config.getBasic().isAlwaysFullScreen()); + if (!StringUtils.isEmpty(config.getBasic().getDefaultDashboardName())) { + oauth2User.setDefaultDashboardName(config.getBasic().getDefaultDashboardName()); + } + return oauth2User; + } + + public static String getTenantName(String email, Map attributes, OAuth2MapperConfig config) { + switch (config.getBasic().getTenantNameStrategy()) { + case EMAIL: + return email; + case DOMAIN: + return email.substring(email .indexOf("@") + 1); + case CUSTOM: + StrSubstitutor sub = new StrSubstitutor(attributes, START_PLACEHOLDER_PREFIX, END_PLACEHOLDER_PREFIX); + return sub.replace(config.getBasic().getTenantNamePattern()); + default: + throw new RuntimeException("Tenant Name Strategy with type " + config.getBasic().getTenantNameStrategy() + " is not supported!"); + } + } + + public static String getStringAttributeByKey(Map attributes, String key) { + String result = null; + try { + result = (String) attributes.get(key); + } catch (Exception e) { + log.warn("Can't convert attribute to String by key " + key); + } + return result; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java new file mode 100644 index 0000000..42f44a7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/BasicOAuth2ClientMapper.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +@Service(value = "basicOAuth2ClientMapper") +@Slf4j +@TbCoreComponent +public class BasicOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper { + + @Override + public SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration) { + OAuth2MapperConfig config = registration.getMapperConfig(); + Map attributes = token.getPrincipal().getAttributes(); + String email = BasicMapperUtils.getStringAttributeByKey(attributes, config.getBasic().getEmailAttributeKey()); + OAuth2User oauth2User = BasicMapperUtils.getOAuth2User(email, attributes, config); + + return getOrCreateSecurityUserFromOAuth2User(oauth2User, registration); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java new file mode 100644 index 0000000..e0f639b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtils.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import org.springframework.util.SerializationUtils; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Base64; +import java.util.Optional; + +public class CookieUtils { + + public static Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + + if (cookies != null && cookies.length > 0) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + + return Optional.empty(); + } + + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + for (Cookie cookie: cookies) { + if (cookie.getName().equals(name)) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + } + } + + public static String serialize(Object object) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(object)); + } + + public static T deserialize(Cookie cookie, Class cls) { + return cls.cast(SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()))); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java new file mode 100644 index 0000000..69f6549 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; + +@Service(value = "customOAuth2ClientMapper") +@Slf4j +@TbCoreComponent +public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper { + private static final String PROVIDER_ACCESS_TOKEN = "provider-access-token"; + + private static final ObjectMapper json = new ObjectMapper(); + + private RestTemplateBuilder restTemplateBuilder = new RestTemplateBuilder(); + + @PostConstruct + public void init() { + // Register time module to parse Instant objects. + // com.fasterxml.jackson.databind.exc.InvalidDefinitionException: + // Java 8 date/time type `java.time.Instant` not supported by default: + // add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling + json.registerModule(new JavaTimeModule()); + } + + @Override + public SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration) { + OAuth2MapperConfig config = registration.getMapperConfig(); + OAuth2User oauth2User = getOAuth2User(token, providerAccessToken, config.getCustom()); + return getOrCreateSecurityUserFromOAuth2User(oauth2User, registration); + } + + private synchronized OAuth2User getOAuth2User(OAuth2AuthenticationToken token, String providerAccessToken, OAuth2CustomMapperConfig custom) { + if (!StringUtils.isEmpty(custom.getUsername()) && !StringUtils.isEmpty(custom.getPassword())) { + restTemplateBuilder = restTemplateBuilder.basicAuthentication(custom.getUsername(), custom.getPassword()); + } + if (custom.isSendToken() && !StringUtils.isEmpty(providerAccessToken)) { + restTemplateBuilder = restTemplateBuilder.defaultHeader(PROVIDER_ACCESS_TOKEN, providerAccessToken); + } + + RestTemplate restTemplate = restTemplateBuilder.build(); + String request; + try { + request = json.writeValueAsString(token.getPrincipal()); + } catch (JsonProcessingException e) { + log.error("Can't convert principal to JSON string", e); + throw new RuntimeException("Can't convert principal to JSON string", e); + } + try { + return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); + } catch (Exception e) { + log.error("There was an error during connection to custom mapper endpoint", e); + throw new RuntimeException("Unable to login. Please contact your Administrator!"); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java new file mode 100644 index 0000000..d4d4d99 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import lombok.Data; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.dao.oauth2.OAuth2Configuration; +import org.thingsboard.server.dao.oauth2.OAuth2User; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; + +@Service(value = "githubOAuth2ClientMapper") +@Slf4j +@TbCoreComponent +public class GithubOAuth2ClientMapper extends AbstractOAuth2ClientMapper implements OAuth2ClientMapper { + private static final String EMAIL_URL_KEY = "emailUrl"; + + private static final String AUTHORIZATION = "Authorization"; + + private RestTemplateBuilder restTemplateBuilder = new RestTemplateBuilder(); + + @Autowired + private OAuth2Configuration oAuth2Configuration; + + @Override + public SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration) { + OAuth2MapperConfig config = registration.getMapperConfig(); + Map githubMapperConfig = oAuth2Configuration.getGithubMapper(); + String email = getEmail(githubMapperConfig.get(EMAIL_URL_KEY), providerAccessToken); + Map attributes = token.getPrincipal().getAttributes(); + OAuth2User oAuth2User = BasicMapperUtils.getOAuth2User(email, attributes, config); + return getOrCreateSecurityUserFromOAuth2User(oAuth2User, registration); + } + + private synchronized String getEmail(String emailUrl, String oauth2Token) { + restTemplateBuilder = restTemplateBuilder.defaultHeader(AUTHORIZATION, "token " + oauth2Token); + + RestTemplate restTemplate = restTemplateBuilder.build(); + GithubEmailsResponse githubEmailsResponse; + try { + githubEmailsResponse = restTemplate.getForEntity(emailUrl, GithubEmailsResponse.class).getBody(); + if (githubEmailsResponse == null){ + throw new RuntimeException("Empty Github response!"); + } + } catch (Exception e) { + log.error("There was an error during connection to Github API", e); + throw new RuntimeException("Unable to login. Please contact your Administrator!"); + } + Optional emailOpt = githubEmailsResponse.stream() + .filter(GithubEmailResponse::isPrimary) + .map(GithubEmailResponse::getEmail) + .findAny(); + if (emailOpt.isPresent()){ + return emailOpt.get(); + } else { + log.error("Could not find primary email from {}.", githubEmailsResponse); + throw new RuntimeException("Unable to login. Please contact your Administrator!"); + } + } + private static class GithubEmailsResponse extends ArrayList {} + + @Data + @ToString + private static class GithubEmailResponse { + private String email; + private boolean verified; + private boolean primary; + private String visibility; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..a6a5fa4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository { + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String PREV_URI_PARAMETER = "prevUri"; + public static final String PREV_URI_COOKIE_NAME = "prev_uri"; + private static final int cookieExpireSeconds = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + if (authorizationRequest == null) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + return; + } + if (request.getParameter(PREV_URI_PARAMETER) != null) { + CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PARAMETER), cookieExpireSeconds); + } + CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds); + } + + @SuppressWarnings("deprecation") + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { + return this.loadAuthorizationRequest(request); + } + + public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java new file mode 100644 index 0000000..f84a2ee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapper.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.http.HttpServletRequest; + +public interface OAuth2ClientMapper { + SecurityUser getOrCreateUserByClientPrincipal(HttpServletRequest request, OAuth2AuthenticationToken token, String providerAccessToken, OAuth2Registration registration); +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java new file mode 100644 index 0000000..3c1ac4a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/OAuth2ClientMapperProvider.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.oauth2.MapperType; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Component +@Slf4j +@TbCoreComponent +public class OAuth2ClientMapperProvider { + + @Autowired + @Qualifier("basicOAuth2ClientMapper") + private OAuth2ClientMapper basicOAuth2ClientMapper; + + @Autowired + @Qualifier("customOAuth2ClientMapper") + private OAuth2ClientMapper customOAuth2ClientMapper; + + @Autowired + @Qualifier("githubOAuth2ClientMapper") + private OAuth2ClientMapper githubOAuth2ClientMapper; + + @Autowired + @Qualifier("appleOAuth2ClientMapper") + private OAuth2ClientMapper appleOAuth2ClientMapper; + + public OAuth2ClientMapper getOAuth2ClientMapperByType(MapperType oauth2MapperType) { + switch (oauth2MapperType) { + case CUSTOM: + return customOAuth2ClientMapper; + case BASIC: + return basicOAuth2ClientMapper; + case GITHUB: + return githubOAuth2ClientMapper; + case APPLE: + return appleOAuth2ClientMapper; + default: + throw new RuntimeException("OAuth2ClientRegistrationMapper with type " + oauth2MapperType + " is not supported!"); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..c12c608 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationFailureHandler.java @@ -0,0 +1,75 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.system.SystemSecurityService; +import org.thingsboard.server.utils.MiscUtils; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@TbCoreComponent +@Component(value = "oauth2AuthenticationFailureHandler") +public class Oauth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + private final SystemSecurityService systemSecurityService; + + @Autowired + public Oauth2AuthenticationFailureHandler(final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository, + final SystemSecurityService systemSecurityService) { + this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository; + this.systemSecurityService = systemSecurityService; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, AuthenticationException exception) + throws IOException, ServletException { + String baseUrl; + String errorPrefix; + String callbackUrlScheme = null; + OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); + if (authorizationRequest != null) { + callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); + } + if (!StringUtils.isEmpty(callbackUrlScheme)) { + baseUrl = callbackUrlScheme + ":"; + errorPrefix = "/?error="; + } else { + baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + errorPrefix = "/login?loginError="; + } + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + getRedirectStrategy().sendRedirect(request, response, baseUrl + errorPrefix + + URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8.toString())); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..7de6c3f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.oauth2.OAuth2Registration; +import org.thingsboard.server.dao.oauth2.OAuth2Service; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; + +import static org.thingsboard.server.service.security.auth.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.PREV_URI_COOKIE_NAME; + +@Slf4j +@Component(value = "oauth2AuthenticationSuccessHandler") +@TbCoreComponent +public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenFactory tokenFactory; + private final OAuth2ClientMapperProvider oauth2ClientMapperProvider; + private final OAuth2Service oAuth2Service; + private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService; + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + private final SystemSecurityService systemSecurityService; + + @Autowired + public Oauth2AuthenticationSuccessHandler(final JwtTokenFactory tokenFactory, + final OAuth2ClientMapperProvider oauth2ClientMapperProvider, + final OAuth2Service oAuth2Service, + final OAuth2AuthorizedClientService oAuth2AuthorizedClientService, + final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository, + final SystemSecurityService systemSecurityService) { + this.tokenFactory = tokenFactory; + this.oauth2ClientMapperProvider = oauth2ClientMapperProvider; + this.oAuth2Service = oAuth2Service; + this.oAuth2AuthorizedClientService = oAuth2AuthorizedClientService; + this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository; + this.systemSecurityService = systemSecurityService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + OAuth2AuthorizationRequest authorizationRequest = httpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest(request); + String callbackUrlScheme = authorizationRequest.getAttribute(TbOAuth2ParameterNames.CALLBACK_URL_SCHEME); + String baseUrl; + if (!StringUtils.isEmpty(callbackUrlScheme)) { + baseUrl = callbackUrlScheme + ":"; + } else { + baseUrl = this.systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); + if (prevUrlOpt.isPresent()) { + baseUrl += prevUrlOpt.get().getValue(); + CookieUtils.deleteCookie(request, response, PREV_URI_COOKIE_NAME); + } + } + try { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; + + OAuth2Registration registration = oAuth2Service.findRegistration(UUID.fromString(token.getAuthorizedClientRegistrationId())); + OAuth2AuthorizedClient oAuth2AuthorizedClient = oAuth2AuthorizedClientService.loadAuthorizedClient( + token.getAuthorizedClientRegistrationId(), + token.getPrincipal().getName()); + OAuth2ClientMapper mapper = oauth2ClientMapperProvider.getOAuth2ClientMapperByType(registration.getMapperConfig().getType()); + SecurityUser securityUser = mapper.getOrCreateUserByClientPrincipal(request, token, oAuth2AuthorizedClient.getAccessToken().getTokenValue(), + registration); + + clearAuthenticationAttributes(request, response); + + JwtPair tokenPair = tokenFactory.createTokenPair(securityUser); + getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(baseUrl, tokenPair)); + systemSecurityService.logLoginAction(securityUser, new RestAuthenticationDetails(request), ActionType.LOGIN, registration.getName(), null); + } catch (Exception e) { + log.debug("Error occurred during processing authentication success result. " + + "request [{}], response [{}], authentication [{}]", request, response, authentication, e); + clearAuthenticationAttributes(request, response); + String errorPrefix; + if (!StringUtils.isEmpty(callbackUrlScheme)) { + errorPrefix = "/?error="; + } else { + errorPrefix = "/login?loginError="; + } + getRedirectStrategy().sendRedirect(request, response, baseUrl + errorPrefix + + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8.toString())); + } + } + + protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { + super.clearAuthenticationAttributes(request); + httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + } + + String getRedirectUrl(String baseUrl, JwtPair tokenPair) { + if (baseUrl.indexOf("?") > 0) { + baseUrl += "&"; + } else { + baseUrl += "/?"; + } + return baseUrl + "accessToken=" + tokenPair.getToken() + "&refreshToken=" + tokenPair.getRefreshToken(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/TbOAuth2ParameterNames.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/TbOAuth2ParameterNames.java new file mode 100644 index 0000000..50f4424 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/TbOAuth2ParameterNames.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.oauth2; + +public interface TbOAuth2ParameterNames { + + String CALLBACK_URL_SCHEME = "callback_url_scheme"; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java new file mode 100644 index 0000000..43fbb3d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginRequest.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel +public class LoginRequest { + + private String username; + + private String password; + + @JsonCreator + public LoginRequest(@JsonProperty("username") String username, @JsonProperty("password") String password) { + this.username = username; + this.password = password; + } + + @ApiModelProperty(position = 1, required = true, value = "User email", example = "tenant@thingsboard.org") + public String getUsername() { + return username; + } + + @ApiModelProperty(position = 2, required = true, value = "User password", example = "tenant") + public String getPassword() { + return password; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java new file mode 100644 index 0000000..551868b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/LoginResponse.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel +@Data +public class LoginResponse { + + @ApiModelProperty(position = 1, required = true, value = "JWT token", + example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZW5hbnRAdGhpbmdzYm9hcmQub3JnIi...") + private String token; + + @ApiModelProperty(position = 2, required = true, value = "Refresh token", + example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZW5hbnRAdGhpbmdzYm9hcmQub3JnIi...") + private String refreshToken; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java new file mode 100644 index 0000000..5f587e0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class PublicLoginRequest { + + private String publicId; + + @JsonCreator + public PublicLoginRequest(@JsonProperty("publicId") String publicId) { + this.publicId = publicId; + } + + public String getPublicId() { + return publicId; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java new file mode 100644 index 0000000..aedce8b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import lombok.Data; +import ua_parser.Client; +import ua_parser.Parser; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.Serializable; + +@Data +public class RestAuthenticationDetails implements Serializable { + + private final String clientAddress; + private final Client userAgent; + + public RestAuthenticationDetails(HttpServletRequest request) { + this.clientAddress = getClientIP(request); + this.userAgent = getUserAgent(request); + } + + private static String getClientIP(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader == null) { + return request.getRemoteAddr(); + } + return xfHeader.split(",")[0]; + } + + private static Client getUserAgent(HttpServletRequest request) { + try { + Parser uaParser = new Parser(); + return uaParser.parse(request.getHeader("User-Agent")); + } catch (IOException e) { + return new Client(null, null, null); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java new file mode 100644 index 0000000..affcd44 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetailsSource.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import org.springframework.security.authentication.AuthenticationDetailsSource; + +import javax.servlet.http.HttpServletRequest; + +public class RestAuthenticationDetailsSource implements + AuthenticationDetailsSource { + + public RestAuthenticationDetails buildDetails(HttpServletRequest context) { + return new RestAuthenticationDetails(context); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java new file mode 100644 index 0000000..b3ef88d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import java.util.UUID; + + +@Component +@Slf4j +@TbCoreComponent +public class RestAuthenticationProvider implements AuthenticationProvider { + + private final SystemSecurityService systemSecurityService; + private final UserService userService; + private final CustomerService customerService; + private final TwoFactorAuthService twoFactorAuthService; + + @Autowired + public RestAuthenticationProvider(final UserService userService, + final CustomerService customerService, + final SystemSecurityService systemSecurityService, + TwoFactorAuthService twoFactorAuthService) { + this.userService = userService; + this.customerService = customerService; + this.systemSecurityService = systemSecurityService; + this.twoFactorAuthService = twoFactorAuthService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + Assert.notNull(authentication, "No authentication data provided"); + + Object principal = authentication.getPrincipal(); + if (!(principal instanceof UserPrincipal)) { + throw new BadCredentialsException("Authentication Failed. Bad user principal."); + } + + UserPrincipal userPrincipal = (UserPrincipal) principal; + SecurityUser securityUser; + if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { + String username = userPrincipal.getValue(); + String password = (String) authentication.getCredentials(); + securityUser = authenticateByUsernameAndPassword(authentication, userPrincipal, username, password); + if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser.getId())) { + return new MfaAuthenticationToken(securityUser); + } else { + systemSecurityService.logLoginAction(securityUser, authentication.getDetails(), ActionType.LOGIN, null); + } + } else { + String publicId = userPrincipal.getValue(); + securityUser = authenticateByPublicId(userPrincipal, publicId); + } + + return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities()); + } + + private SecurityUser authenticateByUsernameAndPassword(Authentication authentication, UserPrincipal userPrincipal, String username, String password) { + User user = userService.findUserByEmail(TenantId.SYS_TENANT_ID, username); + if (user == null) { + throw new UsernameNotFoundException("User not found: " + username); + } + + try { + + UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, user.getId()); + if (userCredentials == null) { + throw new UsernameNotFoundException("User credentials not found"); + } + + try { + systemSecurityService.validateUserCredentials(user.getTenantId(), userCredentials, username, password); + } catch (LockedException e) { + systemSecurityService.logLoginAction(user, authentication.getDetails(), ActionType.LOCKOUT, null); + throw e; + } + + if (user.getAuthority() == null) + throw new InsufficientAuthenticationException("User has no authority assigned"); + + return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); + } catch (Exception e) { + systemSecurityService.logLoginAction(user, authentication.getDetails(), ActionType.LOGIN, e); + throw e; + } + } + + private SecurityUser authenticateByPublicId(UserPrincipal userPrincipal, String publicId) { + CustomerId customerId; + try { + customerId = new CustomerId(UUID.fromString(publicId)); + } catch (Exception e) { + throw new BadCredentialsException("Authentication Failed. Public Id is not valid."); + } + Customer publicCustomer = customerService.findCustomerById(TenantId.SYS_TENANT_ID, customerId); + if (publicCustomer == null) { + throw new UsernameNotFoundException("Public entity not found: " + publicId); + } + if (!publicCustomer.isPublic()) { + throw new BadCredentialsException("Authentication Failed. Public Id is not valid."); + } + User user = new User(new UserId(EntityId.NULL_UUID)); + user.setTenantId(publicCustomer.getTenantId()); + user.setCustomerId(publicCustomer.getId()); + user.setEmail(publicId); + user.setAuthority(Authority.CUSTOMER_USER); + user.setFirstName("Public"); + user.setLastName("Public"); + + return new SecurityUser(user, true, userPrincipal); + } + + @Override + public boolean supports(Class authentication) { + return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java new file mode 100644 index 0000000..5a1ee92 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationFailureHandler.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component(value = "defaultAuthenticationFailureHandler") +public class RestAwareAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private final ThingsboardErrorResponseHandler errorResponseHandler; + + @Autowired + public RestAwareAuthenticationFailureHandler(ThingsboardErrorResponseHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException e) throws IOException, ServletException { + errorResponseHandler.handle(e, response); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java new file mode 100644 index 0000000..f6d9fd8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.WebAttributes; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Component(value = "defaultAuthenticationSuccessHandler") +@RequiredArgsConstructor +public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + private final ObjectMapper mapper; + private final JwtTokenFactory tokenFactory; + private final TwoFaConfigManager twoFaConfigManager; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); + JwtPair tokenPair = new JwtPair(); + + if (authentication instanceof MfaAuthenticationToken) { + int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true) + .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) + .filter(time -> time > 0)) + .orElse((int) TimeUnit.MINUTES.toSeconds(30)); + tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); + tokenPair.setRefreshToken(null); + tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); + } else { + tokenPair = tokenFactory.createTokenPair(securityUser); + } + + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + mapper.writeValue(response.getWriter(), tokenPair); + + clearAuthenticationAttributes(request); + } + + /** + * Removes temporary authentication-related data which may have been stored + * in the session during the authentication process.. + * + */ + protected final void clearAuthenticationAttributes(HttpServletRequest request) { + HttpSession session = request.getSession(false); + + if (session == null) { + return; + } + + session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java new file mode 100644 index 0000000..16e6f50 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; +import org.thingsboard.server.service.security.model.UserPrincipal; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class RestLoginProcessingFilter extends AbstractAuthenticationProcessingFilter { + + private final AuthenticationDetailsSource authenticationDetailsSource = new RestAuthenticationDetailsSource(); + + private final AuthenticationSuccessHandler successHandler; + private final AuthenticationFailureHandler failureHandler; + + private final ObjectMapper objectMapper; + + public RestLoginProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler, + AuthenticationFailureHandler failureHandler, ObjectMapper mapper) { + super(defaultProcessUrl); + this.successHandler = successHandler; + this.failureHandler = failureHandler; + this.objectMapper = mapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + if (!HttpMethod.POST.name().equals(request.getMethod())) { + if(log.isDebugEnabled()) { + log.debug("Authentication method not supported. Request method: " + request.getMethod()); + } + throw new AuthMethodNotSupportedException("Authentication method not supported"); + } + + LoginRequest loginRequest; + try { + loginRequest = objectMapper.readValue(request.getReader(), LoginRequest.class); + } catch (Exception e) { + throw new AuthenticationServiceException("Invalid login request payload"); + } + + if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isEmpty(loginRequest.getPassword())) { + throw new AuthenticationServiceException("Username or Password not provided"); + } + + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, loginRequest.getUsername()); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(principal, loginRequest.getPassword()); + token.setDetails(authenticationDetailsSource.buildDetails(request)); + return this.getAuthenticationManager().authenticate(token); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + successHandler.onAuthenticationSuccess(request, response, authResult); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + failureHandler.onAuthenticationFailure(request, response, failed); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java new file mode 100644 index 0000000..d126739 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java @@ -0,0 +1,95 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.auth.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; +import org.thingsboard.server.service.security.model.UserPrincipal; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class RestPublicLoginProcessingFilter extends AbstractAuthenticationProcessingFilter { + + private final AuthenticationSuccessHandler successHandler; + private final AuthenticationFailureHandler failureHandler; + + private final ObjectMapper objectMapper; + + public RestPublicLoginProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler, + AuthenticationFailureHandler failureHandler, ObjectMapper mapper) { + super(defaultProcessUrl); + this.successHandler = successHandler; + this.failureHandler = failureHandler; + this.objectMapper = mapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + if (!HttpMethod.POST.name().equals(request.getMethod())) { + if(log.isDebugEnabled()) { + log.debug("Authentication method not supported. Request method: " + request.getMethod()); + } + throw new AuthMethodNotSupportedException("Authentication method not supported"); + } + + PublicLoginRequest loginRequest; + try { + loginRequest = objectMapper.readValue(request.getReader(), PublicLoginRequest.class); + } catch (Exception e) { + throw new AuthenticationServiceException("Invalid public login request payload"); + } + + if (StringUtils.isBlank(loginRequest.getPublicId())) { + throw new AuthenticationServiceException("Public Id is not provided"); + } + + UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, loginRequest.getPublicId()); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(principal, ""); + + return this.getAuthenticationManager().authenticate(token); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + successHandler.onAuthenticationSuccess(request, response, authResult); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + SecurityContextHolder.clearContext(); + failureHandler.onAuthenticationFailure(request, response, failed); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/device/DefaultDeviceAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/device/DefaultDeviceAuthService.java new file mode 100644 index 0000000..872217b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/device/DefaultDeviceAuthService.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.device; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; +import org.thingsboard.server.common.transport.auth.DeviceAuthResult; +import org.thingsboard.server.common.transport.auth.DeviceAuthService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@Service +@TbCoreComponent +@Slf4j +public class DefaultDeviceAuthService implements DeviceAuthService { + + private final DeviceService deviceService; + + private final DeviceCredentialsService deviceCredentialsService; + + public DefaultDeviceAuthService(DeviceService deviceService, DeviceCredentialsService deviceCredentialsService) { + this.deviceService = deviceService; + this.deviceCredentialsService = deviceCredentialsService; + } + + @Override + public DeviceAuthResult process(DeviceCredentialsFilter credentialsFilter) { + log.trace("Lookup device credentials using filter {}", credentialsFilter); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsFilter.getCredentialsId()); + if (credentials != null) { + log.trace("Credentials found {}", credentials); + if (credentials.getCredentialsType() == credentialsFilter.getCredentialsType()) { + switch (credentials.getCredentialsType()) { + case ACCESS_TOKEN: + // Credentials ID matches Credentials value in this + // primitive case; + return DeviceAuthResult.of(credentials.getDeviceId()); + case X509_CERTIFICATE: + return DeviceAuthResult.of(credentials.getDeviceId()); + case LWM2M_CREDENTIALS: + return DeviceAuthResult.of(credentials.getDeviceId()); + default: + return DeviceAuthResult.of("Credentials Type is not supported yet!"); + } + } else { + return DeviceAuthResult.of("Credentials Type mismatch!"); + } + } else { + log.trace("Credentials not found!"); + return DeviceAuthResult.of("Credentials Not Found!"); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/AuthMethodNotSupportedException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/AuthMethodNotSupportedException.java new file mode 100644 index 0000000..556ecd0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/AuthMethodNotSupportedException.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.exception; + +import org.springframework.security.authentication.AuthenticationServiceException; + +public class AuthMethodNotSupportedException extends AuthenticationServiceException { + private static final long serialVersionUID = 3705043083010304496L; + + public AuthMethodNotSupportedException(String msg) { + super(msg); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java new file mode 100644 index 0000000..ac95913 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/JwtExpiredTokenException.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.exception; + +import org.springframework.security.core.AuthenticationException; +import org.thingsboard.server.common.data.security.model.JwtToken; + +public class JwtExpiredTokenException extends AuthenticationException { + private static final long serialVersionUID = -5959543783324224864L; + + private JwtToken token; + + public JwtExpiredTokenException(String msg) { + super(msg); + } + + public JwtExpiredTokenException(JwtToken token, String msg, Throwable t) { + super(msg, t); + this.token = token; + } + + public String token() { + return this.token.getToken(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java b/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java new file mode 100644 index 0000000..6dd8a34 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/exception/UserPasswordExpiredException.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.exception; + +import org.springframework.security.authentication.CredentialsExpiredException; + +public class UserPasswordExpiredException extends CredentialsExpiredException { + + private final String resetToken; + + public UserPasswordExpiredException(String msg, String resetToken) { + super(msg); + this.resetToken = resetToken; + } + + public String getResetToken() { + return resetToken; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/ActivateUserRequest.java b/application/src/main/java/org/thingsboard/server/service/security/model/ActivateUserRequest.java new file mode 100644 index 0000000..50e918b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/ActivateUserRequest.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel +@Data +public class ActivateUserRequest { + + @ApiModelProperty(position = 1, value = "The activate token to verify", example = "AAB254FF67D..") + private String activateToken; + @ApiModelProperty(position = 2, value = "The new password to set", example = "secret") + private String password; +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/ChangePasswordRequest.java b/application/src/main/java/org/thingsboard/server/service/security/model/ChangePasswordRequest.java new file mode 100644 index 0000000..4a93040 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/ChangePasswordRequest.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel +@Data +public class ChangePasswordRequest { + + @ApiModelProperty(position = 1, value = "The old password", example = "OldPassword") + private String currentPassword; + @ApiModelProperty(position = 1, value = "The new password", example = "NewPassword") + private String newPassword; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordEmailRequest.java b/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordEmailRequest.java new file mode 100644 index 0000000..3b1563e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordEmailRequest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel +@Data +public class ResetPasswordEmailRequest { + + @ApiModelProperty(position = 1, value = "The email of the user", example = "user@example.com") + private String email; +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordRequest.java b/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordRequest.java new file mode 100644 index 0000000..8489f6f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/ResetPasswordRequest.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel +@Data +public class ResetPasswordRequest { + + @ApiModelProperty(position = 1, value = "The reset token to verify", example = "AAB254FF67D..") + private String resetToken; + @ApiModelProperty(position = 2, value = "The new password to set", example = "secret") + private String password; +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java new file mode 100644 index 0000000..b7f480d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.UserId; + +import java.util.Collection; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class SecurityUser extends User { + + private static final long serialVersionUID = -797397440703066079L; + + private Collection authorities; + private boolean enabled; + private UserPrincipal userPrincipal; + private String sessionId; + + public SecurityUser() { + super(); + } + + public SecurityUser(UserId id) { + super(id); + } + + public SecurityUser(User user, boolean enabled, UserPrincipal userPrincipal) { + super(user); + this.enabled = enabled; + this.userPrincipal = userPrincipal; + this.sessionId = UUID.randomUUID().toString(); + } + + public Collection getAuthorities() { + if (authorities == null) { + authorities = Stream.of(SecurityUser.this.getAuthority()) + .map(authority -> new SimpleGrantedAuthority(authority.name())) + .collect(Collectors.toList()); + } + return authorities; + } + + public boolean isEnabled() { + return enabled; + } + + public UserPrincipal getUserPrincipal() { + return userPrincipal; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setUserPrincipal(UserPrincipal userPrincipal) { + this.userPrincipal = userPrincipal; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java b/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java new file mode 100644 index 0000000..4eafe16 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model; + +import java.io.Serializable; + +public class UserPrincipal implements Serializable { + + private final Type type; + private final String value; + + public UserPrincipal(Type type, String value) { + this.type = type; + this.value = value; + } + + public Type getType() { + return type; + } + + public String getValue() { + return value; + } + + public enum Type { + USER_NAME, + PUBLIC_ID + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java new file mode 100644 index 0000000..f96e0bf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model.token; + +import org.thingsboard.server.common.data.security.model.JwtToken; + +public final class AccessJwtToken implements JwtToken { + private final String rawToken; + + public AccessJwtToken(String rawToken) { + this.rawToken = rawToken; + } + + public String getToken() { + return this.rawToken; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java new file mode 100644 index 0000000..71fd662 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -0,0 +1,223 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.SignatureException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; +import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; + +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtTokenFactory { + + private static final String SCOPES = "scopes"; + private static final String USER_ID = "userId"; + private static final String FIRST_NAME = "firstName"; + private static final String LAST_NAME = "lastName"; + private static final String ENABLED = "enabled"; + private static final String IS_PUBLIC = "isPublic"; + private static final String TENANT_ID = "tenantId"; + private static final String CUSTOMER_ID = "customerId"; + private static final String SESSION_ID = "sessionId"; + + private final JwtSettingsService jwtSettingsService; + + /** + * Factory method for issuing new JWT Tokens. + */ + public AccessJwtToken createAccessJwtToken(SecurityUser securityUser) { + if (securityUser.getAuthority() == null) { + throw new IllegalArgumentException("User doesn't have any privileges"); + } + + UserPrincipal principal = securityUser.getUserPrincipal(); + + JwtBuilder jwtBuilder = setUpToken(securityUser, securityUser.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.toList()), jwtSettingsService.getJwtSettings().getTokenExpirationTime()); + jwtBuilder.claim(FIRST_NAME, securityUser.getFirstName()) + .claim(LAST_NAME, securityUser.getLastName()) + .claim(ENABLED, securityUser.isEnabled()) + .claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID); + if (securityUser.getTenantId() != null) { + jwtBuilder.claim(TENANT_ID, securityUser.getTenantId().getId().toString()); + } + if (securityUser.getCustomerId() != null) { + jwtBuilder.claim(CUSTOMER_ID, securityUser.getCustomerId().getId().toString()); + } + + String token = jwtBuilder.compact(); + + return new AccessJwtToken(token); + } + + public SecurityUser parseAccessJwtToken(RawAccessJwtToken rawAccessToken) { + Jws jwsClaims = parseTokenClaims(rawAccessToken); + Claims claims = jwsClaims.getBody(); + String subject = claims.getSubject(); + @SuppressWarnings("unchecked") + List scopes = claims.get(SCOPES, List.class); + if (scopes == null || scopes.isEmpty()) { + throw new IllegalArgumentException("JWT Token doesn't have any scopes"); + } + + SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); + securityUser.setEmail(subject); + securityUser.setAuthority(Authority.parse(scopes.get(0))); + String tenantId = claims.get(TENANT_ID, String.class); + if (tenantId != null) { + securityUser.setTenantId(TenantId.fromUUID(UUID.fromString(tenantId))); + } else if (securityUser.getAuthority() == Authority.SYS_ADMIN) { + securityUser.setTenantId(TenantId.SYS_TENANT_ID); + } + String customerId = claims.get(CUSTOMER_ID, String.class); + if (customerId != null) { + securityUser.setCustomerId(new CustomerId(UUID.fromString(customerId))); + } + if (claims.get(SESSION_ID, String.class) != null) { + securityUser.setSessionId(claims.get(SESSION_ID, String.class)); + } + + UserPrincipal principal; + if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) { + securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); + securityUser.setLastName(claims.get(LAST_NAME, String.class)); + securityUser.setEnabled(claims.get(ENABLED, Boolean.class)); + boolean isPublic = claims.get(IS_PUBLIC, Boolean.class); + principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); + } else { + principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject); + } + securityUser.setUserPrincipal(principal); + + return securityUser; + } + + public JwtToken createRefreshToken(SecurityUser securityUser) { + UserPrincipal principal = securityUser.getUserPrincipal(); + + String token = setUpToken(securityUser, Collections.singletonList(Authority.REFRESH_TOKEN.name()), jwtSettingsService.getJwtSettings().getRefreshTokenExpTime()) + .claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID) + .setId(UUID.randomUUID().toString()).compact(); + + return new AccessJwtToken(token); + } + + public SecurityUser parseRefreshToken(RawAccessJwtToken rawAccessToken) { + Jws jwsClaims = parseTokenClaims(rawAccessToken); + Claims claims = jwsClaims.getBody(); + String subject = claims.getSubject(); + @SuppressWarnings("unchecked") + List scopes = claims.get(SCOPES, List.class); + if (scopes == null || scopes.isEmpty()) { + throw new IllegalArgumentException("Refresh Token doesn't have any scopes"); + } + if (!scopes.get(0).equals(Authority.REFRESH_TOKEN.name())) { + throw new IllegalArgumentException("Invalid Refresh Token scope"); + } + boolean isPublic = claims.get(IS_PUBLIC, Boolean.class); + UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); + SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); + securityUser.setUserPrincipal(principal); + if (claims.get(SESSION_ID, String.class) != null) { + securityUser.setSessionId(claims.get(SESSION_ID, String.class)); + } + return securityUser; + } + + public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) { + JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) + .claim(TENANT_ID, user.getTenantId().toString()); + if (user.getCustomerId() != null) { + jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString()); + } + return new AccessJwtToken(jwtBuilder.compact()); + } + + private JwtBuilder setUpToken(SecurityUser securityUser, List scopes, long expirationTime) { + if (StringUtils.isBlank(securityUser.getEmail())) { + throw new IllegalArgumentException("Cannot create JWT Token without username/email"); + } + + UserPrincipal principal = securityUser.getUserPrincipal(); + + Claims claims = Jwts.claims().setSubject(principal.getValue()); + claims.put(USER_ID, securityUser.getId().getId().toString()); + claims.put(SCOPES, scopes); + if (securityUser.getSessionId() != null) { + claims.put(SESSION_ID, securityUser.getSessionId()); + } + + ZonedDateTime currentTime = ZonedDateTime.now(); + + return Jwts.builder() + .setClaims(claims) + .setIssuer(jwtSettingsService.getJwtSettings().getTokenIssuer()) + .setIssuedAt(Date.from(currentTime.toInstant())) + .setExpiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant())) + .signWith(SignatureAlgorithm.HS512, jwtSettingsService.getJwtSettings().getTokenSigningKey()); + } + + public Jws parseTokenClaims(JwtToken token) { + try { + return Jwts.parser() + .setSigningKey(jwtSettingsService.getJwtSettings().getTokenSigningKey()) + .parseClaimsJws(token.getToken()); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException ex) { + log.debug("Invalid JWT Token", ex); + throw new BadCredentialsException("Invalid JWT token: ", ex); + } catch (SignatureException | ExpiredJwtException expiredEx) { + log.debug("JWT Token is expired", expiredEx); + throw new JwtExpiredTokenException(token, "JWT Token expired", expiredEx); + } + } + + public JwtPair createTokenPair(SecurityUser securityUser) { + JwtToken accessToken = createAccessJwtToken(securityUser); + JwtToken refreshToken = createRefreshToken(securityUser); + return new JwtPair(accessToken.getToken(), refreshToken.getToken()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java new file mode 100644 index 0000000..bae8cbb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureException; +import io.jsonwebtoken.UnsupportedJwtException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +public class OAuth2AppTokenFactory { + + private static final String CALLBACK_URL_SCHEME = "callbackUrlScheme"; + + private static final long MAX_EXPIRATION_TIME_DIFF_MS = TimeUnit.MINUTES.toMillis(5); + + public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) { + Jws jwsClaims; + try { + jwsClaims = Jwts.parser().setSigningKey(appSecret).parseClaimsJws(appToken); + } + catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { + throw new IllegalArgumentException("Invalid Application token: ", ex); + } catch (ExpiredJwtException expiredEx) { + throw new IllegalArgumentException("Application token expired", expiredEx); + } + Claims claims = jwsClaims.getBody(); + Date expiration = claims.getExpiration(); + if (expiration == null) { + throw new IllegalArgumentException("Application token must have expiration date"); + } + long timeDiff = expiration.getTime() - System.currentTimeMillis(); + if (timeDiff > MAX_EXPIRATION_TIME_DIFF_MS) { + throw new IllegalArgumentException("Application token expiration time can't be longer than 5 minutes"); + } + if (!claims.getIssuer().equals(appPackage)) { + throw new IllegalArgumentException("Application token issuer doesn't match application package"); + } + String callbackUrlScheme = claims.get(CALLBACK_URL_SCHEME, String.class); + if (StringUtils.isEmpty(callbackUrlScheme)) { + throw new IllegalArgumentException("Application token doesn't have callbackUrlScheme"); + } + return callbackUrlScheme; + } + +} 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 new file mode 100644 index 0000000..86d66fb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.model.token; + +import org.thingsboard.server.common.data.security.model.JwtToken; + +import java.io.Serializable; + +public class RawAccessJwtToken implements JwtToken, Serializable { + + private static final long serialVersionUID = -797397445703066079L; + + private String token; + + public RawAccessJwtToken(String token) { + this.token = token; + } + + @Override + public String getToken() { + return token; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/AbstractPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/AbstractPermissions.java new file mode 100644 index 0000000..2b36ff6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/AbstractPermissions.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import java.util.HashMap; +import java.util.Optional; + +public abstract class AbstractPermissions extends HashMap implements Permissions { + + public AbstractPermissions() { + super(); + } + + @Override + public Optional getPermissionChecker(Resource resource) { + PermissionChecker permissionChecker = this.get(resource); + return Optional.ofNullable(permissionChecker); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/AccessControlService.java b/application/src/main/java/org/thingsboard/server/service/security/permission/AccessControlService.java new file mode 100644 index 0000000..3c79e4a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/AccessControlService.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface AccessControlService { + + void checkPermission(SecurityUser user, Resource resource, Operation operation) throws ThingsboardException; + + void checkPermission(SecurityUser user, Resource resource, Operation operation, I entityId, T entity) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java new file mode 100644 index 0000000..cdd84b0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -0,0 +1,172 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.HasCustomerId; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Component(value = "customerUserPermissions") +public class CustomerUserPermissions extends AbstractPermissions { + + public CustomerUserPermissions() { + super(); + put(Resource.ALARM, customerAlarmPermissionChecker); + put(Resource.ASSET, customerEntityPermissionChecker); + put(Resource.DEVICE, customerEntityPermissionChecker); + put(Resource.CUSTOMER, customerPermissionChecker); + put(Resource.DASHBOARD, customerDashboardPermissionChecker); + put(Resource.ENTITY_VIEW, customerEntityPermissionChecker); + put(Resource.USER, userPermissionChecker); + put(Resource.WIDGETS_BUNDLE, widgetsPermissionChecker); + put(Resource.WIDGET_TYPE, widgetsPermissionChecker); + put(Resource.EDGE, customerEntityPermissionChecker); + put(Resource.RPC, rpcPermissionChecker); + put(Resource.DEVICE_PROFILE, profilePermissionChecker); + put(Resource.ASSET_PROFILE, profilePermissionChecker); + } + + private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() { + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + if (!(entity instanceof HasCustomerId)) { + return false; + } + return user.getCustomerId().equals(((HasCustomerId) entity).getCustomerId()); + } + }; + + private static final PermissionChecker customerEntityPermissionChecker = + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_CREDENTIALS, + Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY, Operation.RPC_CALL, Operation.CLAIM_DEVICES, + Operation.WRITE, Operation.WRITE_ATTRIBUTES, Operation.WRITE_TELEMETRY) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + if (!(entity instanceof HasCustomerId)) { + return false; + } + return operation.equals(Operation.CLAIM_DEVICES) || user.getCustomerId().equals(((HasCustomerId) entity).getCustomerId()); + } + }; + + private static final PermissionChecker customerPermissionChecker = + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + return user.getCustomerId().equals(entityId); + } + + }; + + private static final PermissionChecker customerDashboardPermissionChecker = + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, DashboardId dashboardId, DashboardInfo dashboard) { + + if (!super.hasPermission(user, operation, dashboardId, dashboard)) { + return false; + } + if (!user.getTenantId().equals(dashboard.getTenantId())) { + return false; + } + return dashboard.isAssignedToCustomer(user.getCustomerId()); + } + + }; + + private static final PermissionChecker userPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, UserId userId, User userEntity) { + if (!Authority.CUSTOMER_USER.equals(userEntity.getAuthority())) { + return false; + } + return user.getId().equals(userId); + } + + }; + + private static final PermissionChecker widgetsPermissionChecker = new PermissionChecker.GenericPermissionChecker(Operation.READ) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return true; + } + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + + private static final PermissionChecker rpcPermissionChecker = new PermissionChecker.GenericPermissionChecker(Operation.READ) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return true; + } + return user.getTenantId().equals(entity.getTenantId()); + } + }; + + private static final PermissionChecker profilePermissionChecker = new PermissionChecker.GenericPermissionChecker(Operation.READ) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return true; + } + return user.getTenantId().equals(entity.getTenantId()); + } + }; +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java new file mode 100644 index 0000000..64c7f05 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.*; + +import static org.thingsboard.server.dao.service.Validator.validateId; + +@Service +@Slf4j +public class DefaultAccessControlService implements AccessControlService { + + private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; + + private final Map authorityPermissions = new HashMap<>(); + + public DefaultAccessControlService( + @Qualifier("sysAdminPermissions") Permissions sysAdminPermissions, + @Qualifier("tenantAdminPermissions") Permissions tenantAdminPermissions, + @Qualifier("customerUserPermissions") Permissions customerUserPermissions) { + authorityPermissions.put(Authority.SYS_ADMIN, sysAdminPermissions); + authorityPermissions.put(Authority.TENANT_ADMIN, tenantAdminPermissions); + authorityPermissions.put(Authority.CUSTOMER_USER, customerUserPermissions); + } + + @Override + public void checkPermission(SecurityUser user, Resource resource, Operation operation) throws ThingsboardException { + PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource); + if (!permissionChecker.hasPermission(user, operation)) { + permissionDenied(); + } + } + + @Override + @SuppressWarnings("unchecked") + public void checkPermission(SecurityUser user, Resource resource, + Operation operation, I entityId, T entity) throws ThingsboardException { + PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource); + if (!permissionChecker.hasPermission(user, operation, entityId, entity)) { + permissionDenied(); + } + } + + private PermissionChecker getPermissionChecker(Authority authority, Resource resource) throws ThingsboardException { + Permissions permissions = authorityPermissions.get(authority); + if (permissions == null) { + permissionDenied(); + } + Optional permissionChecker = permissions.getPermissionChecker(resource); + if (!permissionChecker.isPresent()) { + permissionDenied(); + } + return permissionChecker.get(); + } + + private void permissionDenied() throws ThingsboardException { + throw new ThingsboardException(YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION, + ThingsboardErrorCode.PERMISSION_DENIED); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Operation.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Operation.java new file mode 100644 index 0000000..d261107 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Operation.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +public enum Operation { + + ALL, CREATE, READ, WRITE, DELETE, ASSIGN_TO_CUSTOMER, UNASSIGN_FROM_CUSTOMER, RPC_CALL, + READ_CREDENTIALS, WRITE_CREDENTIALS, READ_ATTRIBUTES, WRITE_ATTRIBUTES, READ_TELEMETRY, WRITE_TELEMETRY, CLAIM_DEVICES, + ASSIGN_TO_TENANT + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/PermissionChecker.java b/application/src/main/java/org/thingsboard/server/service/security/permission/PermissionChecker.java new file mode 100644 index 0000000..217c488 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/PermissionChecker.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.thingsboard.server.common.data.HasCustomerId; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public interface PermissionChecker { + + default boolean hasPermission(SecurityUser user, Operation operation) { + return false; + } + + default boolean hasPermission(SecurityUser user, Operation operation, I entityId, T entity) { + return false; + } + + public class GenericPermissionChecker implements PermissionChecker { + + private final Set allowedOperations; + + public GenericPermissionChecker(Operation... operations) { + allowedOperations = new HashSet(Arrays.asList(operations)); + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return allowedOperations.contains(Operation.ALL) || allowedOperations.contains(operation); + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, I entityId, T entity) { + return allowedOperations.contains(Operation.ALL) || allowedOperations.contains(operation); + } + } + + public static PermissionChecker denyAllPermissionChecker = new PermissionChecker() {}; + + public static PermissionChecker allowAllPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + return true; + } + }; + + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Permissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Permissions.java new file mode 100644 index 0000000..9df6da0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Permissions.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import java.util.Optional; + +public interface Permissions { + + Optional getPermissionChecker(Resource resource); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java new file mode 100644 index 0000000..a9484a2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.thingsboard.server.common.data.EntityType; + +import java.util.Optional; + +public enum Resource { + ADMIN_SETTINGS(), + ALARM(EntityType.ALARM), + DEVICE(EntityType.DEVICE), + ASSET(EntityType.ASSET), + CUSTOMER(EntityType.CUSTOMER), + DASHBOARD(EntityType.DASHBOARD), + ENTITY_VIEW(EntityType.ENTITY_VIEW), + TENANT(EntityType.TENANT), + RULE_CHAIN(EntityType.RULE_CHAIN), + USER(EntityType.USER), + WIDGETS_BUNDLE(EntityType.WIDGETS_BUNDLE), + WIDGET_TYPE(EntityType.WIDGET_TYPE), + OAUTH2_CONFIGURATION_INFO(), + OAUTH2_CONFIGURATION_TEMPLATE(), + TENANT_PROFILE(EntityType.TENANT_PROFILE), + DEVICE_PROFILE(EntityType.DEVICE_PROFILE), + ASSET_PROFILE(EntityType.ASSET_PROFILE), + API_USAGE_STATE(EntityType.API_USAGE_STATE), + TB_RESOURCE(EntityType.TB_RESOURCE), + OTA_PACKAGE(EntityType.OTA_PACKAGE), + EDGE(EntityType.EDGE), + RPC(EntityType.RPC), + QUEUE(EntityType.QUEUE), + VERSION_CONTROL; + + private final EntityType entityType; + + Resource() { + this.entityType = null; + } + + Resource(EntityType entityType) { + this.entityType = entityType; + } + + public Optional getEntityType() { + return Optional.ofNullable(entityType); + } + + public static Resource of(EntityType entityType) { + for (Resource resource : Resource.values()) { + if (resource.getEntityType().orElse(null) == entityType) { + return resource; + } + } + throw new IllegalArgumentException("Unknown EntityType: " + entityType.name()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java new file mode 100644 index 0000000..20e2001 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Component(value="sysAdminPermissions") +public class SysAdminPermissions extends AbstractPermissions { + + public SysAdminPermissions() { + super(); + put(Resource.ADMIN_SETTINGS, PermissionChecker.allowAllPermissionChecker); + put(Resource.DASHBOARD, new PermissionChecker.GenericPermissionChecker(Operation.READ)); + put(Resource.TENANT, PermissionChecker.allowAllPermissionChecker); + put(Resource.RULE_CHAIN, systemEntityPermissionChecker); + put(Resource.USER, userPermissionChecker); + put(Resource.WIDGETS_BUNDLE, systemEntityPermissionChecker); + put(Resource.WIDGET_TYPE, systemEntityPermissionChecker); + put(Resource.OAUTH2_CONFIGURATION_INFO, PermissionChecker.allowAllPermissionChecker); + put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, PermissionChecker.allowAllPermissionChecker); + put(Resource.TENANT_PROFILE, PermissionChecker.allowAllPermissionChecker); + put(Resource.TB_RESOURCE, systemEntityPermissionChecker); + put(Resource.QUEUE, systemEntityPermissionChecker); + } + + private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + + if (entity.getTenantId() != null && !entity.getTenantId().isNullUid()) { + return false; + } + return true; + } + }; + + private static final PermissionChecker userPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, UserId userId, User userEntity) { + if (Authority.CUSTOMER_USER.equals(userEntity.getAuthority())) { + return false; + } + return true; + } + + }; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java new file mode 100644 index 0000000..ef7991e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -0,0 +1,143 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.permission; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Component(value="tenantAdminPermissions") +public class TenantAdminPermissions extends AbstractPermissions { + + public TenantAdminPermissions() { + super(); + put(Resource.ADMIN_SETTINGS, PermissionChecker.allowAllPermissionChecker); + put(Resource.ALARM, tenantEntityPermissionChecker); + put(Resource.ASSET, tenantEntityPermissionChecker); + put(Resource.DEVICE, tenantEntityPermissionChecker); + put(Resource.CUSTOMER, tenantEntityPermissionChecker); + put(Resource.DASHBOARD, tenantEntityPermissionChecker); + put(Resource.ENTITY_VIEW, tenantEntityPermissionChecker); + put(Resource.TENANT, tenantPermissionChecker); + put(Resource.RULE_CHAIN, tenantEntityPermissionChecker); + put(Resource.USER, userPermissionChecker); + put(Resource.WIDGETS_BUNDLE, widgetsPermissionChecker); + put(Resource.WIDGET_TYPE, widgetsPermissionChecker); + put(Resource.DEVICE_PROFILE, tenantEntityPermissionChecker); + put(Resource.ASSET_PROFILE, tenantEntityPermissionChecker); + put(Resource.API_USAGE_STATE, tenantEntityPermissionChecker); + put(Resource.TB_RESOURCE, tbResourcePermissionChecker); + put(Resource.OTA_PACKAGE, tenantEntityPermissionChecker); + put(Resource.EDGE, tenantEntityPermissionChecker); + put(Resource.RPC, tenantEntityPermissionChecker); + put(Resource.QUEUE, queuePermissionChecker); + put(Resource.VERSION_CONTROL, PermissionChecker.allowAllPermissionChecker); + } + + public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + return true; + } + }; + + private static final PermissionChecker tenantPermissionChecker = + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) { + + @Override + @SuppressWarnings("unchecked") + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (!super.hasPermission(user, operation, entityId, entity)) { + return false; + } + if (!user.getTenantId().equals(entityId)) { + return false; + } + return true; + } + + }; + + private static final PermissionChecker userPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, UserId userId, User userEntity) { + if (Authority.SYS_ADMIN.equals(userEntity.getAuthority())) { + return false; + } + if (!user.getTenantId().equals(userEntity.getTenantId())) { + return false; + } + return true; + } + + }; + + private static final PermissionChecker widgetsPermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return operation == Operation.READ; + } + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + return true; + } + + }; + + private static final PermissionChecker tbResourcePermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return operation == Operation.READ; + } + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + return true; + } + + }; + + private static final PermissionChecker queuePermissionChecker = new PermissionChecker() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, EntityId entityId, HasTenantId entity) { + if (entity.getTenantId() == null || entity.getTenantId().isNullUid()) { + return operation == Operation.READ; + } + if (!user.getTenantId().equals(entity.getTenantId())) { + return false; + } + return true; + } + + }; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java new file mode 100644 index 0000000..97b4336 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -0,0 +1,323 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.system; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.passay.CharacterRule; +import org.passay.EnglishCharacterData; +import org.passay.LengthRule; +import org.passay.PasswordData; +import org.passay.PasswordValidator; +import org.passay.Rule; +import org.passay.RuleResult; +import org.passay.WhitespaceRule; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.dao.user.UserServiceImpl; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.utils.MiscUtils; +import ua_parser.Client; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE; + +@Service +@Slf4j +@TbCoreComponent +public class DefaultSystemSecurityService implements SystemSecurityService { + + @Autowired + private AdminSettingsService adminSettingsService; + + @Autowired + private BCryptPasswordEncoder encoder; + + @Autowired + private UserService userService; + + @Autowired + private MailService mailService; + + @Autowired + private AuditLogService auditLogService; + + @Resource + private SystemSecurityService self; + + @Cacheable(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings getSecuritySettings(TenantId tenantId) { + SecuritySettings securitySettings = null; + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(tenantId, "securitySettings"); + if (adminSettings != null) { + try { + securitySettings = JacksonUtil.convertValue(adminSettings.getJsonValue(), SecuritySettings.class); + } catch (Exception e) { + throw new RuntimeException("Failed to load security settings!", e); + } + } else { + securitySettings = new SecuritySettings(); + securitySettings.setPasswordPolicy(new UserPasswordPolicy()); + securitySettings.getPasswordPolicy().setMinimumLength(6); + } + return securitySettings; + } + + @CacheEvict(cacheNames = SECURITY_SETTINGS_CACHE, key = "'securitySettings'") + @Override + public SecuritySettings saveSecuritySettings(TenantId tenantId, SecuritySettings securitySettings) { + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(tenantId, "securitySettings"); + if (adminSettings == null) { + adminSettings = new AdminSettings(); + adminSettings.setTenantId(tenantId); + adminSettings.setKey("securitySettings"); + } + adminSettings.setJsonValue(JacksonUtil.valueToTree(securitySettings)); + AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(tenantId, adminSettings); + try { + return JacksonUtil.convertValue(savedAdminSettings.getJsonValue(), SecuritySettings.class); + } catch (Exception e) { + throw new RuntimeException("Failed to load security settings!", e); + } + } + + @Override + public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { + if (!encoder.matches(password, userCredentials.getPassword())) { + int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { + if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { + lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); + throw new LockedException("Authentication Failed. Username was locked due to security policy."); + } + } + throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); + } + + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + + userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); + + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { + if ((userCredentials.getCreatedTime() + + TimeUnit.DAYS.toMillis(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) + < System.currentTimeMillis()) { + userCredentials = userService.requestExpiredPasswordReset(tenantId, userCredentials.getId()); + throw new UserPasswordExpiredException("User password expired!", userCredentials.getResetToken()); + } + } + } + + @Override + public void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, PlatformTwoFaSettings twoFaSettings) { + TenantId tenantId = securityUser.getTenantId(); + UserId userId = securityUser.getId(); + + int failedVerificationAttempts; + if (!verificationSuccess) { + failedVerificationAttempts = userService.increaseFailedLoginAttempts(tenantId, userId); + } else { + userService.resetFailedLoginAttempts(tenantId, userId); + return; + } + + Integer maxVerificationFailures = twoFaSettings.getMaxVerificationFailuresBeforeUserLockout(); + if (maxVerificationFailures != null && maxVerificationFailures > 0 + && failedVerificationAttempts >= maxVerificationFailures) { + userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), maxVerificationFailures); + throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); + } + } + + private void lockAccount(UserId userId, String username, String userLockoutNotificationEmail, Integer maxFailedLoginAttempts) { + userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + if (StringUtils.isNotBlank(userLockoutNotificationEmail)) { + try { + mailService.sendAccountLockoutEmail(username, userLockoutNotificationEmail, maxFailedLoginAttempts); + } catch (ThingsboardException e) { + log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, userLockoutNotificationEmail, e); + } + } + } + + @Override + public void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException { + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + UserPasswordPolicy passwordPolicy = securitySettings.getPasswordPolicy(); + + List passwordRules = new ArrayList<>(); + passwordRules.add(new LengthRule(passwordPolicy.getMinimumLength(), Integer.MAX_VALUE)); + if (isPositiveInteger(passwordPolicy.getMinimumUppercaseLetters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.UpperCase, passwordPolicy.getMinimumUppercaseLetters())); + } + if (isPositiveInteger(passwordPolicy.getMinimumLowercaseLetters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.LowerCase, passwordPolicy.getMinimumLowercaseLetters())); + } + if (isPositiveInteger(passwordPolicy.getMinimumDigits())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.Digit, passwordPolicy.getMinimumDigits())); + } + if (isPositiveInteger(passwordPolicy.getMinimumSpecialCharacters())) { + passwordRules.add(new CharacterRule(EnglishCharacterData.Special, passwordPolicy.getMinimumSpecialCharacters())); + } + if (passwordPolicy.getAllowWhitespaces() != null && !passwordPolicy.getAllowWhitespaces()) { + passwordRules.add(new WhitespaceRule()); + } + PasswordValidator validator = new PasswordValidator(passwordRules); + PasswordData passwordData = new PasswordData(password); + RuleResult result = validator.validate(passwordData); + if (!result.isValid()) { + String message = String.join("\n", validator.getMessages(result)); + throw new DataValidationException(message); + } + + if (userCredentials != null && isPositiveInteger(passwordPolicy.getPasswordReuseFrequencyDays())) { + long passwordReuseFrequencyTs = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(passwordPolicy.getPasswordReuseFrequencyDays()); + User user = userService.findUserById(tenantId, userCredentials.getUserId()); + JsonNode additionalInfo = user.getAdditionalInfo(); + if (additionalInfo instanceof ObjectNode && additionalInfo.has(UserServiceImpl.USER_PASSWORD_HISTORY)) { + JsonNode userPasswordHistoryJson = additionalInfo.get(UserServiceImpl.USER_PASSWORD_HISTORY); + Map userPasswordHistoryMap = JacksonUtil.convertValue(userPasswordHistoryJson, new TypeReference<>() {}); + for (Map.Entry entry : userPasswordHistoryMap.entrySet()) { + if (encoder.matches(password, entry.getValue()) && Long.parseLong(entry.getKey()) > passwordReuseFrequencyTs) { + throw new DataValidationException("Password was already used for the last " + passwordPolicy.getPasswordReuseFrequencyDays() + " days"); + } + } + + } + } + } + + @Override + public String getBaseUrl(TenantId tenantId, CustomerId customerId, HttpServletRequest httpServletRequest) { + String baseUrl = null; + AdminSettings generalSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "general"); + + JsonNode prohibitDifferentUrl = generalSettings.getJsonValue().get("prohibitDifferentUrl"); + + if (prohibitDifferentUrl != null && prohibitDifferentUrl.asBoolean()) { + baseUrl = generalSettings.getJsonValue().get("baseUrl").asText(); + } + + if (StringUtils.isEmpty(baseUrl)) { + baseUrl = MiscUtils.constructBaseUrl(httpServletRequest); + } + + return baseUrl; + } + + @Override + public void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e) { + logLoginAction(user, authenticationDetails, actionType, null, e); + } + + @Override + public void logLoginAction(User user, Object authenticationDetails, ActionType actionType, String provider, Exception e) { + String clientAddress = "Unknown"; + String browser = "Unknown"; + String os = "Unknown"; + String device = "Unknown"; + if (authenticationDetails instanceof RestAuthenticationDetails) { + RestAuthenticationDetails details = (RestAuthenticationDetails) authenticationDetails; + clientAddress = details.getClientAddress(); + if (details.getUserAgent() != null) { + Client userAgent = details.getUserAgent(); + if (userAgent.userAgent != null) { + browser = userAgent.userAgent.family; + if (userAgent.userAgent.major != null) { + browser += " " + userAgent.userAgent.major; + if (userAgent.userAgent.minor != null) { + browser += "." + userAgent.userAgent.minor; + if (userAgent.userAgent.patch != null) { + browser += "." + userAgent.userAgent.patch; + } + } + } + } + if (userAgent.os != null) { + os = userAgent.os.family; + if (userAgent.os.major != null) { + os += " " + userAgent.os.major; + if (userAgent.os.minor != null) { + os += "." + userAgent.os.minor; + if (userAgent.os.patch != null) { + os += "." + userAgent.os.patch; + if (userAgent.os.patchMinor != null) { + os += "." + userAgent.os.patchMinor; + } + } + } + } + } + if (userAgent.device != null) { + device = userAgent.device.family; + } + } + } + if (actionType == ActionType.LOGIN && e == null) { + userService.setLastLoginTs(user.getTenantId(), user.getId()); + } + auditLogService.logEntityAction( + user.getTenantId(), user.getCustomerId(), user.getId(), + user.getName(), user.getId(), null, actionType, e, clientAddress, browser, os, device, provider); + } + + private static boolean isPositiveInteger(Integer val) { + return val != null && val.intValue() > 0; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java new file mode 100644 index 0000000..39d5935 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.security.system; + +import org.springframework.security.core.AuthenticationException; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.http.HttpServletRequest; + +public interface SystemSecurityService { + + SecuritySettings getSecuritySettings(TenantId tenantId); + + SecuritySettings saveSecuritySettings(TenantId tenantId, SecuritySettings securitySettings); + + void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; + + void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, PlatformTwoFaSettings twoFaSettings); + + void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException; + + String getBaseUrl(TenantId tenantId, CustomerId customerId, HttpServletRequest httpServletRequest); + + void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e); + + void logLoginAction(User user, Object authenticationDetails, ActionType actionType, String provider, Exception 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..9e5667a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.protobuf.InvalidProtocolBufferException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.io.Serializable; +import java.util.Collections; + +import static org.thingsboard.server.common.data.CacheConstants.SESSIONS_CACHE; + +/** + * Created by ashvayka on 29.10.18. + */ +@Service +@TbCoreComponent +@Slf4j +public class DefaultDeviceSessionCacheService implements DeviceSessionCacheService { + + @Autowired + protected TbTransactionalCache cache; + + @Override + public DeviceSessionsCacheEntry get(DeviceId deviceId) { + log.debug("[{}] Fetching session data from cache", deviceId); + return cache.getAndPutInTransaction(deviceId, () -> + DeviceSessionsCacheEntry.newBuilder().addAllSessions(Collections.emptyList()).build(), false); + } + + @Override + public DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions) { + log.debug("[{}] Pushing session data to cache: {}", deviceId, sessions); + cache.putIfAbsent(deviceId, sessions); + return sessions; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java new file mode 100644 index 0000000..b01f943 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.session; + +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; + +/** + * Created by ashvayka on 29.10.18. + */ +public interface DeviceSessionCacheService { + + DeviceSessionsCacheEntry get(DeviceId deviceId); + + DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/session/SessionCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/session/SessionCaffeineCache.java new file mode 100644 index 0000000..92eba5c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/SessionCaffeineCache.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.session; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.gen.transport.TransportProtos; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("SessionCache") +public class SessionCaffeineCache extends CaffeineTbTransactionalCache { + + public SessionCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.SESSIONS_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/session/SessionRedisCache.java b/application/src/main/java/org/thingsboard/server/service/session/SessionRedisCache.java new file mode 100644 index 0000000..27931a9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/SessionRedisCache.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.protobuf.InvalidProtocolBufferException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.gen.transport.TransportProtos; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("SessionCache") +public class SessionRedisCache extends RedisTbTransactionalCache { + + public SessionRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.SESSIONS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbRedisSerializer<>() { + @Override + public byte[] serialize(TransportProtos.DeviceSessionsCacheEntry deviceSessionsCacheEntry) throws SerializationException { + return deviceSessionsCacheEntry.toByteArray(); + } + + @Override + public TransportProtos.DeviceSessionsCacheEntry deserialize(DeviceId key, byte[] bytes) throws SerializationException { + try { + return TransportProtos.DeviceSessionsCacheEntry.parseFrom(bytes); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException("Failed to deserialize session cache entry"); + } + } + }); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/AbstractSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/AbstractSmsSender.java new file mode 100644 index 0000000..7997235 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/AbstractSmsSender.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.sms.SmsSender; +import org.thingsboard.rule.engine.api.sms.exception.SmsParseException; + +import java.util.regex.Pattern; + +@Slf4j +public abstract class AbstractSmsSender implements SmsSender { + + protected static final Pattern E_164_PHONE_NUMBER_PATTERN = Pattern.compile("^\\+[1-9]\\d{1,14}$"); + + private static final int MAX_SMS_MESSAGE_LENGTH = 1600; + private static final int MAX_SMS_SEGMENT_LENGTH = 70; + + protected String validatePhoneNumber(String phoneNumber) throws SmsParseException { + phoneNumber = phoneNumber.trim(); + if (!E_164_PHONE_NUMBER_PATTERN.matcher(phoneNumber).matches()) { + throw new SmsParseException("Invalid phone number format. Phone number must be in E.164 format."); + } + return phoneNumber; + } + + protected String prepareMessage(String message) { + message = message.replaceAll("^\"|\"$", "").replaceAll("\\\\n", "\n"); + if (message.length() > MAX_SMS_MESSAGE_LENGTH) { + log.warn("SMS message exceeds maximum symbols length and will be truncated"); + message = message.substring(0, MAX_SMS_MESSAGE_LENGTH); + } + return message; + } + + protected int countMessageSegments(String message) { + return (int)Math.ceil((double) message.length() / (double) MAX_SMS_SEGMENT_LENGTH); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java new file mode 100644 index 0000000..b42dcb6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms; + +import org.springframework.stereotype.Component; +import org.thingsboard.rule.engine.api.sms.SmsSender; +import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; +import org.thingsboard.server.common.data.sms.config.AwsSnsSmsProviderConfiguration; +import org.thingsboard.server.common.data.sms.config.SmppSmsProviderConfiguration; +import org.thingsboard.server.common.data.sms.config.SmsProviderConfiguration; +import org.thingsboard.server.common.data.sms.config.TwilioSmsProviderConfiguration; +import org.thingsboard.server.service.sms.aws.AwsSmsSender; +import org.thingsboard.server.service.sms.smpp.SmppSmsSender; +import org.thingsboard.server.service.sms.twilio.TwilioSmsSender; + +@Component +public class DefaultSmsSenderFactory implements SmsSenderFactory { + + @Override + public SmsSender createSmsSender(SmsProviderConfiguration config) { + switch (config.getType()) { + case AWS_SNS: + return new AwsSmsSender((AwsSnsSmsProviderConfiguration)config); + case TWILIO: + return new TwilioSmsSender((TwilioSmsProviderConfiguration)config); + case SMPP: + return new SmppSmsSender((SmppSmsProviderConfiguration) config); + default: + throw new RuntimeException("Unknown SMS provider type " + config.getType()); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java new file mode 100644 index 0000000..b51221b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.NestedRuntimeException; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.rule.engine.api.sms.SmsSender; +import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.sms.config.SmsProviderConfiguration; +import org.thingsboard.server.common.data.sms.config.TestSmsRequest; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +@Service +@Slf4j +public class DefaultSmsService implements SmsService { + + private final SmsSenderFactory smsSenderFactory; + private final AdminSettingsService adminSettingsService; + private final TbApiUsageStateService apiUsageStateService; + private final TbApiUsageReportClient apiUsageClient; + + private SmsSender smsSender; + + public DefaultSmsService(SmsSenderFactory smsSenderFactory, AdminSettingsService adminSettingsService, TbApiUsageStateService apiUsageStateService, TbApiUsageReportClient apiUsageClient) { + this.smsSenderFactory = smsSenderFactory; + this.adminSettingsService = adminSettingsService; + this.apiUsageStateService = apiUsageStateService; + this.apiUsageClient = apiUsageClient; + } + + @PostConstruct + private void init() { + updateSmsConfiguration(); + } + + @PreDestroy + private void destroy() { + if (this.smsSender != null) { + this.smsSender.destroy(); + } + } + + @Override + public void updateSmsConfiguration() { + AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "sms"); + if (settings != null) { + try { + JsonNode jsonConfig = settings.getJsonValue(); + SmsProviderConfiguration configuration = JacksonUtil.convertValue(jsonConfig, SmsProviderConfiguration.class); + SmsSender newSmsSender = this.smsSenderFactory.createSmsSender(configuration); + if (this.smsSender != null) { + this.smsSender.destroy(); + } + this.smsSender = newSmsSender; + } catch (Exception e) { + log.error("Failed to create SMS sender", e); + } + } + } + + private int sendSms(String numberTo, String message) throws ThingsboardException { + if (this.smsSender == null) { + throw new ThingsboardException("Unable to send SMS: no SMS provider configured!", ThingsboardErrorCode.GENERAL); + } + return this.sendSms(this.smsSender, numberTo, message); + } + + @Override + public void sendSms(TenantId tenantId, CustomerId customerId, String[] numbersTo, String message) throws ThingsboardException { + if (apiUsageStateService.getApiUsageState(tenantId).isSmsSendEnabled()) { + int smsCount = 0; + try { + for (String numberTo : numbersTo) { + smsCount += this.sendSms(numberTo, message); + } + } finally { + if (smsCount > 0) { + apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.SMS_EXEC_COUNT, smsCount); + } + } + } else { + throw new RuntimeException("SMS sending is disabled due to API limits!"); + } + } + + @Override + public void sendTestSms(TestSmsRequest testSmsRequest) throws ThingsboardException { + SmsSender testSmsSender; + try { + testSmsSender = this.smsSenderFactory.createSmsSender(testSmsRequest.getProviderConfiguration()); + } catch (Exception e) { + throw handleException(e); + } + this.sendSms(testSmsSender, testSmsRequest.getNumberTo(), testSmsRequest.getMessage()); + testSmsSender.destroy(); + } + + @Override + public boolean isConfigured(TenantId tenantId) { + return smsSender != null; + } + + private int sendSms(SmsSender smsSender, String numberTo, String message) throws ThingsboardException { + try { + return smsSender.sendSms(numberTo, message); + } catch (Exception e) { + throw handleException(e); + } + } + + private ThingsboardException handleException(Exception exception) { + String message; + if (exception instanceof NestedRuntimeException) { + message = ((NestedRuntimeException) exception).getMostSpecificCause().getMessage(); + } else { + message = exception.getMessage(); + } + log.warn("Unable to send SMS: {}", message); + return new ThingsboardException(String.format("Unable to send SMS: %s", message), + ThingsboardErrorCode.GENERAL); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/SmsExecutorService.java b/application/src/main/java/org/thingsboard/server/service/sms/SmsExecutorService.java new file mode 100644 index 0000000..3e377ab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/SmsExecutorService.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.AbstractListeningExecutor; + +@Component +public class SmsExecutorService extends AbstractListeningExecutor { + + @Value("${actors.rule.sms_thread_pool_size}") + private int smsExecutorThreadPoolSize; + + @Override + protected int getThreadPollSize() { + return smsExecutorThreadPoolSize; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/aws/AwsSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/aws/AwsSmsSender.java new file mode 100644 index 0000000..456ddb7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/aws/AwsSmsSender.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms.aws; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.sns.AmazonSNS; +import com.amazonaws.services.sns.AmazonSNSClient; +import com.amazonaws.services.sns.model.MessageAttributeValue; +import com.amazonaws.services.sns.model.PublishRequest; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.sms.exception.SmsException; +import org.thingsboard.rule.engine.api.sms.exception.SmsSendException; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.sms.config.AwsSnsSmsProviderConfiguration; +import org.thingsboard.server.service.sms.AbstractSmsSender; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +public class AwsSmsSender extends AbstractSmsSender { + + private static final Map SMS_ATTRIBUTES = new HashMap<>(); + + static { + SMS_ATTRIBUTES.put("AWS.SNS.SMS.SMSType", new MessageAttributeValue() + .withStringValue("Transactional") + .withDataType("String")); + } + + private AmazonSNS snsClient; + + public AwsSmsSender(AwsSnsSmsProviderConfiguration config) { + if (StringUtils.isEmpty(config.getAccessKeyId()) || StringUtils.isEmpty(config.getSecretAccessKey()) || StringUtils.isEmpty(config.getRegion())) { + throw new IllegalArgumentException("Invalid AWS sms provider configuration: aws accessKeyId, aws secretAccessKey and aws region should be specified!"); + } + AWSCredentials awsCredentials = new BasicAWSCredentials(config.getAccessKeyId(), config.getSecretAccessKey()); + AWSStaticCredentialsProvider credProvider = new AWSStaticCredentialsProvider(awsCredentials); + this.snsClient = AmazonSNSClient.builder() + .withCredentials(credProvider) + .withRegion(config.getRegion()) + .build(); + } + + @Override + public int sendSms(String numberTo, String message) throws SmsException { + numberTo = this.validatePhoneNumber(numberTo); + message = this.prepareMessage(message); + try { + PublishRequest publishRequest = new PublishRequest() + .withMessageAttributes(SMS_ATTRIBUTES) + .withPhoneNumber(numberTo) + .withMessage(message); + this.snsClient.publish(publishRequest); + return this.countMessageSegments(message); + } catch (Exception e) { + throw new SmsSendException("Failed to send SMS message - " + e.getMessage(), e); + } + } + + @Override + public void destroy() { + if (this.snsClient != null) { + try { + this.snsClient.shutdown(); + } catch (Exception e) { + log.error("Failed to shutdown SNS client during destroy()", e); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java new file mode 100644 index 0000000..8509b4e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/smpp/SmppSmsSender.java @@ -0,0 +1,186 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms.smpp; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.smpp.Connection; +import org.smpp.Data; +import org.smpp.Session; +import org.smpp.TCPIPConnection; +import org.smpp.TimeoutException; +import org.smpp.WrongSessionStateException; +import org.smpp.pdu.Address; +import org.smpp.pdu.BindReceiver; +import org.smpp.pdu.BindRequest; +import org.smpp.pdu.BindResponse; +import org.smpp.pdu.BindTransciever; +import org.smpp.pdu.BindTransmitter; +import org.smpp.pdu.PDUException; +import org.smpp.pdu.SubmitSM; +import org.smpp.pdu.SubmitSMResp; +import org.thingsboard.rule.engine.api.sms.exception.SmsException; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.sms.config.SmppSmsProviderConfiguration; +import org.thingsboard.server.service.sms.AbstractSmsSender; + +import java.io.IOException; +import java.util.Optional; + +@Slf4j +public class SmppSmsSender extends AbstractSmsSender { + protected SmppSmsProviderConfiguration config; + + protected Session smppSession; + + public SmppSmsSender(SmppSmsProviderConfiguration config) { + if (config.getBindType() == null) { + config.setBindType(SmppSmsProviderConfiguration.SmppBindType.TX); + } + if (StringUtils.isNotEmpty(config.getSourceAddress())) { + if (config.getSourceTon() == null) { + config.setSourceTon((byte) 5); + } + if (config.getSourceNpi() == null) { + config.setSourceNpi((byte) 0); + } + } + if (config.getDestinationTon() == null) { + config.setDestinationTon((byte) 5); + } + if (config.getDestinationNpi() == null) { + config.setDestinationNpi((byte) 0); + } + + this.config = config; + this.smppSession = initSmppSession(); + } + + private SmppSmsSender() {} // for testing purposes + + + @Override + public int sendSms(String numberTo, String message) throws SmsException { + try { + checkSmppSession(); + + SubmitSM request = new SubmitSM(); + if (StringUtils.isNotEmpty(config.getServiceType())) { + request.setServiceType(config.getServiceType()); + } + if (StringUtils.isNotEmpty(config.getSourceAddress())) { + request.setSourceAddr(new Address(config.getSourceTon(), config.getSourceNpi(), config.getSourceAddress())); + } + request.setDestAddr(new Address(config.getDestinationTon(), config.getDestinationNpi(), prepareNumber(numberTo))); + request.setShortMessage(message); + request.setDataCoding(Optional.ofNullable(config.getCodingScheme()).orElse((byte) 0)); + request.setReplaceIfPresentFlag((byte) 0); + request.setEsmClass((byte) 0); + request.setProtocolId((byte) 0); + request.setPriorityFlag((byte) 0); + request.setRegisteredDelivery((byte) 0); + request.setSmDefaultMsgId((byte) 0); + + SubmitSMResp response = smppSession.submit(request); + + log.debug("SMPP submit command status: {}", response.getCommandStatus()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return countMessageSegments(message); + } + + private synchronized void checkSmppSession() { + if (smppSession == null || !smppSession.isOpened()) { + smppSession = initSmppSession(); + } + } + + protected Session initSmppSession() { + try { + Connection connection = new TCPIPConnection(config.getHost(), config.getPort()); + Session session = new Session(connection); + + BindRequest bindRequest; + switch (config.getBindType()) { + case TX: + bindRequest = new BindTransmitter(); + break; + case RX: + bindRequest = new BindReceiver(); + break; + case TRX: + bindRequest = new BindTransciever(); + break; + default: + throw new UnsupportedOperationException("Unsupported bind type " + config.getBindType()); + } + + bindRequest.setSystemId(config.getSystemId()); + bindRequest.setPassword(config.getPassword()); + + byte interfaceVersion; + switch (config.getProtocolVersion()) { + case "3.3": + interfaceVersion = Data.SMPP_V33; + break; + case "3.4": + interfaceVersion = Data.SMPP_V34; + break; + default: + throw new UnsupportedOperationException("Unsupported SMPP version: " + config.getProtocolVersion()); + } + bindRequest.setInterfaceVersion(interfaceVersion); + + if (StringUtils.isNotEmpty(config.getSystemType())) { + bindRequest.setSystemType(config.getSystemType()); + } + if (StringUtils.isNotEmpty(config.getAddressRange())) { + bindRequest.setAddressRange(config.getDestinationTon(), config.getDestinationNpi(), config.getAddressRange()); + } + + BindResponse bindResponse = session.bind(bindRequest); + log.debug("SMPP bind response: {}", bindResponse.debugString()); + + if (bindResponse.getCommandStatus() != 0) { + throw new IllegalStateException("Error status when binding: " + bindResponse.getCommandStatus()); + } + + return session; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to establish SMPP session: " + ExceptionUtils.getRootCauseMessage(e), e); + } + } + + private String prepareNumber(String number) { + if (config.getDestinationTon() == Data.GSM_TON_INTERNATIONAL) { + return StringUtils.removeStart(number, "+"); + } + return number; + } + + @Override + public void destroy() { + try { + smppSession.unbind(); + smppSession.close(); + } catch (TimeoutException | PDUException | IOException | WrongSessionStateException e) { + throw new RuntimeException(e); + } + + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sms/twilio/TwilioSmsSender.java b/application/src/main/java/org/thingsboard/server/service/sms/twilio/TwilioSmsSender.java new file mode 100644 index 0000000..f9b0106 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sms/twilio/TwilioSmsSender.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sms.twilio; + +import com.twilio.http.TwilioRestClient; +import com.twilio.rest.api.v2010.account.Message; +import com.twilio.type.PhoneNumber; +import org.thingsboard.rule.engine.api.sms.exception.SmsException; +import org.thingsboard.rule.engine.api.sms.exception.SmsParseException; +import org.thingsboard.rule.engine.api.sms.exception.SmsSendException; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.sms.config.TwilioSmsProviderConfiguration; +import org.thingsboard.server.service.sms.AbstractSmsSender; + +import java.util.regex.Pattern; + +public class TwilioSmsSender extends AbstractSmsSender { + + private static final Pattern PHONE_NUMBERS_SID_MESSAGE_SERVICE_SID = Pattern.compile("^(PN|MG).*$"); + + private TwilioRestClient twilioRestClient; + private String numberFrom; + + private String validatePhoneTwilioNumber(String phoneNumber) throws SmsParseException { + phoneNumber = phoneNumber.trim(); + if (!E_164_PHONE_NUMBER_PATTERN.matcher(phoneNumber).matches() && !PHONE_NUMBERS_SID_MESSAGE_SERVICE_SID.matcher(phoneNumber).matches()) { + throw new SmsParseException("Invalid phone number format. Phone number must be in E.164 format/Phone Number's SID/Messaging Service SID."); + } + return phoneNumber; + } + + public TwilioSmsSender(TwilioSmsProviderConfiguration config) { + if (StringUtils.isEmpty(config.getAccountSid()) || StringUtils.isEmpty(config.getAccountToken()) || StringUtils.isEmpty(config.getNumberFrom())) { + throw new IllegalArgumentException("Invalid twilio sms provider configuration: accountSid, accountToken and numberFrom should be specified!"); + } + this.numberFrom = this.validatePhoneTwilioNumber(config.getNumberFrom()); + this.twilioRestClient = new TwilioRestClient.Builder(config.getAccountSid(), config.getAccountToken()).build(); + } + + @Override + public int sendSms(String numberTo, String message) throws SmsException { + numberTo = this.validatePhoneNumber(numberTo); + message = this.prepareMessage(message); + try { + String numSegments = Message.creator(new PhoneNumber(numberTo), new PhoneNumber(this.numberFrom), message).create(this.twilioRestClient).getNumSegments(); + return Integer.valueOf(numSegments); + } catch (Exception e) { + throw new SmsSendException("Failed to send SMS message - " + e.getMessage(), e); + } + } + + @Override + public void destroy() { + + } +} 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 new file mode 100644 index 0000000..d52c712 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -0,0 +1,798 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.state; + +import com.fasterxml.jackson.databind.node.ObjectNode; +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; +import com.google.common.util.concurrent.MoreExecutors; +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.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceIdInfo; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityListFilter; +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.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.sql.query.EntityQueryRepository; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.util.DbTypeInfoComponent; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.partition.AbstractPartitionBasedService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.DataConstants.ACTIVITY_EVENT; +import static org.thingsboard.server.common.data.DataConstants.CONNECT_EVENT; +import static org.thingsboard.server.common.data.DataConstants.DISCONNECT_EVENT; +import static org.thingsboard.server.common.data.DataConstants.INACTIVITY_EVENT; +import static org.thingsboard.server.common.data.DataConstants.SERVER_SCOPE; + +/** + * Created by ashvayka on 01.05.18. + */ +@Service +@TbCoreComponent +@Slf4j +public class DefaultDeviceStateService extends AbstractPartitionBasedService implements DeviceStateService { + + public static final String ACTIVITY_STATE = "active"; + public static final String LAST_CONNECT_TIME = "lastConnectTime"; + public static final String LAST_DISCONNECT_TIME = "lastDisconnectTime"; + public static final String LAST_ACTIVITY_TIME = "lastActivityTime"; + public static final String INACTIVITY_ALARM_TIME = "inactivityAlarmTime"; + public static final String INACTIVITY_TIMEOUT = "inactivityTimeout"; + + private static final List PERSISTENT_TELEMETRY_KEYS = Arrays.asList( + new EntityKey(EntityKeyType.TIME_SERIES, LAST_ACTIVITY_TIME), + new EntityKey(EntityKeyType.TIME_SERIES, INACTIVITY_ALARM_TIME), + new EntityKey(EntityKeyType.TIME_SERIES, INACTIVITY_TIMEOUT), + new EntityKey(EntityKeyType.TIME_SERIES, ACTIVITY_STATE), + new EntityKey(EntityKeyType.TIME_SERIES, LAST_CONNECT_TIME), + new EntityKey(EntityKeyType.TIME_SERIES, LAST_DISCONNECT_TIME), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT)); + + private static final List PERSISTENT_ATTRIBUTE_KEYS = Arrays.asList( + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, LAST_ACTIVITY_TIME), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_ALARM_TIME), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, ACTIVITY_STATE), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, LAST_CONNECT_TIME), + new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, LAST_DISCONNECT_TIME)); + + public static final List PERSISTENT_ATTRIBUTES = Arrays.asList(ACTIVITY_STATE, LAST_CONNECT_TIME, + LAST_DISCONNECT_TIME, LAST_ACTIVITY_TIME, INACTIVITY_ALARM_TIME, INACTIVITY_TIMEOUT); + private static final List PERSISTENT_ENTITY_FIELDS = Arrays.asList( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), + new EntityKey(EntityKeyType.ENTITY_FIELD, "type"), + new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + + private final TenantService tenantService; + private final DeviceService deviceService; + private final AttributesService attributesService; + private final TimeseriesService tsService; + private final TbClusterService clusterService; + private final PartitionService partitionService; + private final TbServiceInfoProvider serviceInfoProvider; + private final EntityQueryRepository entityQueryRepository; + private final DbTypeInfoComponent dbTypeInfoComponent; + + private TelemetrySubscriptionService tsSubService; + + @Value("${state.defaultInactivityTimeoutInSec}") + @Getter + @Setter + private long defaultInactivityTimeoutInSec; + + @Value("#{${state.defaultInactivityTimeoutInSec} * 1000}") + @Getter + @Setter + private long defaultInactivityTimeoutMs; + + @Value("${state.defaultStateCheckIntervalInSec}") + @Getter + private int defaultStateCheckIntervalInSec; + + @Value("${state.persistToTelemetry:false}") + @Getter + @Setter + private boolean persistToTelemetry; + + @Value("${state.initFetchPackSize:50000}") + @Getter + private int initFetchPackSize; + + private ListeningExecutorService deviceStateExecutor; + + final ConcurrentMap deviceStates = new ConcurrentHashMap<>(); + + public DefaultDeviceStateService(TenantService tenantService, DeviceService deviceService, + AttributesService attributesService, TimeseriesService tsService, + TbClusterService clusterService, PartitionService partitionService, + TbServiceInfoProvider serviceInfoProvider, + EntityQueryRepository entityQueryRepository, + DbTypeInfoComponent dbTypeInfoComponent) { + this.tenantService = tenantService; + this.deviceService = deviceService; + this.attributesService = attributesService; + this.tsService = tsService; + this.clusterService = clusterService; + this.partitionService = partitionService; + this.serviceInfoProvider = serviceInfoProvider; + this.entityQueryRepository = entityQueryRepository; + this.dbTypeInfoComponent = dbTypeInfoComponent; + } + + @Autowired + public void setTsSubService(TelemetrySubscriptionService tsSubService) { + this.tsSubService = tsSubService; + } + + @PostConstruct + public void init() { + super.init(); + deviceStateExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool( + Math.max(4, Runtime.getRuntime().availableProcessors()), "device-state")); + scheduledExecutor.scheduleAtFixedRate(this::updateInactivityStateIfExpired, new Random().nextInt(defaultStateCheckIntervalInSec), defaultStateCheckIntervalInSec, TimeUnit.SECONDS); + } + + @PreDestroy + public void stop() { + super.stop(); + if (deviceStateExecutor != null) { + deviceStateExecutor.shutdownNow(); + } + } + + @Override + protected String getServiceName() { + return "Device State"; + } + + @Override + protected String getSchedulerExecutorName() { + return "device-state-scheduled"; + } + + @Override + public void onDeviceConnect(TenantId tenantId, DeviceId deviceId) { + if (cleanDeviceStateIfBelongsExternalPartition(tenantId, deviceId)) { + return; + } + log.trace("on Device Connect [{}]", deviceId.getId()); + DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); + long ts = System.currentTimeMillis(); + stateData.getState().setLastConnectTime(ts); + save(deviceId, LAST_CONNECT_TIME, ts); + pushRuleEngineMessage(stateData, CONNECT_EVENT); + checkAndUpdateState(deviceId, stateData); + + } + + @Override + public void onDeviceActivity(TenantId tenantId, DeviceId deviceId, long lastReportedActivity) { + if (cleanDeviceStateIfBelongsExternalPartition(tenantId, deviceId)) { + return; + } + log.trace("on Device Activity [{}], lastReportedActivity [{}]", deviceId.getId(), lastReportedActivity); + final DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); + if (lastReportedActivity > 0 && lastReportedActivity > stateData.getState().getLastActivityTime()) { + updateActivityState(deviceId, stateData, lastReportedActivity); + } + } + + void updateActivityState(DeviceId deviceId, DeviceStateData stateData, long lastReportedActivity) { + log.trace("updateActivityState - fetched state {} for device {}, lastReportedActivity {}", stateData, deviceId, lastReportedActivity); + if (stateData != null) { + save(deviceId, LAST_ACTIVITY_TIME, lastReportedActivity); + DeviceState state = stateData.getState(); + state.setLastActivityTime(lastReportedActivity); + if (!state.isActive()) { + state.setActive(true); + save(deviceId, ACTIVITY_STATE, true); + pushRuleEngineMessage(stateData, ACTIVITY_EVENT); + } + } else { + log.debug("updateActivityState - fetched state IN NULL for device {}, lastReportedActivity {}", deviceId, lastReportedActivity); + cleanupEntity(deviceId); + } + } + + @Override + public void onDeviceDisconnect(TenantId tenantId, DeviceId deviceId) { + if (cleanDeviceStateIfBelongsExternalPartition(tenantId, deviceId)) { + return; + } + DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); + long ts = System.currentTimeMillis(); + stateData.getState().setLastDisconnectTime(ts); + save(deviceId, LAST_DISCONNECT_TIME, ts); + pushRuleEngineMessage(stateData, DISCONNECT_EVENT); + } + + @Override + public void onDeviceInactivityTimeoutUpdate(TenantId tenantId, DeviceId deviceId, long inactivityTimeout) { + if (cleanDeviceStateIfBelongsExternalPartition(tenantId, deviceId)) { + return; + } + if (inactivityTimeout <= 0L) { + inactivityTimeout = defaultInactivityTimeoutInSec; + } + log.trace("on Device Activity Timeout Update device id {} inactivityTimeout {}", deviceId, inactivityTimeout); + DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); + stateData.getState().setInactivityTimeout(inactivityTimeout); + checkAndUpdateState(deviceId, stateData); + } + + @Override + public void onQueueMsg(TransportProtos.DeviceStateServiceMsgProto proto, TbCallback callback) { + try { + TenantId tenantId = TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); + DeviceId deviceId = new DeviceId(new UUID(proto.getDeviceIdMSB(), proto.getDeviceIdLSB())); + if (proto.getDeleted()) { + onDeviceDeleted(tenantId, deviceId); + callback.onSuccess(); + } else { + Device device = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, deviceId); + if (device != null) { + if (proto.getAdded()) { + Futures.addCallback(fetchDeviceState(device), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable DeviceStateData state) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, device.getId()); + if (addDeviceUsingState(tpi, state)) { + save(deviceId, ACTIVITY_STATE, false); + callback.onSuccess(); + } else { + log.debug("[{}][{}] Device belongs to external partition. Probably rebalancing is in progress. Topic: {}" + , tenantId, deviceId, tpi.getFullTopicName()); + callback.onFailure(new RuntimeException("Device belongs to external partition " + tpi.getFullTopicName() + "!")); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to register device to the state service", t); + callback.onFailure(t); + } + }, deviceStateExecutor); + } else if (proto.getUpdated()) { + DeviceStateData stateData = getOrFetchDeviceStateData(device.getId()); + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("deviceName", device.getName()); + md.putValue("deviceType", device.getType()); + stateData.setMetaData(md); + callback.onSuccess(); + } + } else { + //Device was probably deleted while message was in queue; + callback.onSuccess(); + } + } + } catch (Exception e) { + log.trace("Failed to process queue msg: [{}]", proto, e); + callback.onFailure(e); + } + } + + @Override + protected Map>> onAddedPartitions(Set addedPartitions) { + var result = new HashMap>>(); + PageDataIterable deviceIdInfos = new PageDataIterable<>(deviceService::findDeviceIdInfos, initFetchPackSize); + Map> tpiDeviceMap = new HashMap<>(); + + for (DeviceIdInfo idInfo : deviceIdInfos) { + TopicPartitionInfo tpi; + try { + tpi = partitionService.resolve(ServiceType.TB_CORE, idInfo.getTenantId(), idInfo.getDeviceId()); + } catch (Exception e) { + log.warn("Failed to resolve partition for device with id [{}], tenant id [{}], customer id [{}]. Reason: {}", + idInfo.getDeviceId(), idInfo.getTenantId(), idInfo.getCustomerId(), e.getMessage()); + continue; + } + if (addedPartitions.contains(tpi) && !deviceStates.containsKey(idInfo.getDeviceId())) { + tpiDeviceMap.computeIfAbsent(tpi, tmp -> new ArrayList<>()).add(idInfo); + } + } + + for (var entry : tpiDeviceMap.entrySet()) { + AtomicInteger counter = new AtomicInteger(0); + // hard-coded limit of 1000 is due to the Entity Data Query limitations and should not be changed. + for (List partition : Lists.partition(entry.getValue(), 1000)) { + log.info("[{}] Submit task for device states: {}", entry.getKey(), partition.size()); + DevicePackFutureHolder devicePackFutureHolder = new DevicePackFutureHolder(); + var devicePackFuture = deviceStateExecutor.submit(() -> { + try { + List states; + if (persistToTelemetry && !dbTypeInfoComponent.isLatestTsDaoStoredToSql()) { + states = fetchDeviceStateDataUsingSeparateRequests(partition); + } else { + states = fetchDeviceStateDataUsingEntityDataQuery(partition); + } + if (devicePackFutureHolder.future == null || !devicePackFutureHolder.future.isCancelled()) { + for (var state : states) { + if (!addDeviceUsingState(entry.getKey(), state)) { + return; + } + checkAndUpdateState(state.getDeviceId(), state); + } + log.info("[{}] Initialized {} out of {} device states", entry.getKey().getPartition().orElse(0), counter.addAndGet(states.size()), entry.getValue().size()); + } + } catch (Throwable t) { + log.error("Unexpected exception while device pack fetching", t); + throw t; + } + }); + devicePackFutureHolder.future = devicePackFuture; + result.computeIfAbsent(entry.getKey(), tmp -> new ArrayList<>()).add(devicePackFuture); + } + } + return result; + } + + private static class DevicePackFutureHolder { + private volatile ListenableFuture future; + } + + void checkAndUpdateState(@Nonnull DeviceId deviceId, @Nonnull DeviceStateData state) { + if (state.getState().isActive()) { + updateInactivityStateIfExpired(System.currentTimeMillis(), deviceId, state); + } else { + //trying to fix activity state + if (isActive(System.currentTimeMillis(), state.getState())) { + updateActivityState(deviceId, state, state.getState().getLastActivityTime()); + } + } + } + + private boolean addDeviceUsingState(TopicPartitionInfo tpi, DeviceStateData state) { + Set deviceIds = partitionedEntities.get(tpi); + if (deviceIds != null) { + deviceIds.add(state.getDeviceId()); + deviceStates.putIfAbsent(state.getDeviceId(), state); + return true; + } else { + log.debug("[{}] Device belongs to external partition {}", state.getDeviceId(), tpi.getFullTopicName()); + return false; + } + } + + void updateInactivityStateIfExpired() { + try { + final long ts = System.currentTimeMillis(); + partitionedEntities.forEach((tpi, deviceIds) -> { + log.debug("Calculating state updates. tpi {} for {} devices", tpi.getFullTopicName(), deviceIds.size()); + for (DeviceId deviceId : deviceIds) { + try { + updateInactivityStateIfExpired(ts, deviceId); + } catch (Exception e) { + log.warn("[{}] Failed to update inactivity state", deviceId, e); + } + } + }); + } catch (Throwable t) { + log.warn("Failed to update inactivity states", t); + } + } + + void updateInactivityStateIfExpired(long ts, DeviceId deviceId) { + DeviceStateData stateData = getOrFetchDeviceStateData(deviceId); + updateInactivityStateIfExpired(ts, deviceId, stateData); + } + + void updateInactivityStateIfExpired(long ts, DeviceId deviceId, DeviceStateData stateData) { + log.trace("Processing state {} for device {}", stateData, deviceId); + if (stateData != null) { + DeviceState state = stateData.getState(); + if (!isActive(ts, state) + && (state.getLastInactivityAlarmTime() == 0L || state.getLastInactivityAlarmTime() < state.getLastActivityTime()) + && stateData.getDeviceCreationTime() + state.getInactivityTimeout() < ts) { + if (partitionService.resolve(ServiceType.TB_CORE, stateData.getTenantId(), deviceId).isMyPartition()) { + state.setActive(false); + state.setLastInactivityAlarmTime(ts); + save(deviceId, ACTIVITY_STATE, false); + save(deviceId, INACTIVITY_ALARM_TIME, ts); + pushRuleEngineMessage(stateData, INACTIVITY_EVENT); + } else { + cleanupEntity(deviceId); + } + } + } else { + log.debug("[{}] Device that belongs to other server is detected and removed.", deviceId); + cleanupEntity(deviceId); + } + } + + boolean isActive(long ts, DeviceState state) { + return ts < state.getLastActivityTime() + state.getInactivityTimeout(); + } + + @Nonnull + DeviceStateData getOrFetchDeviceStateData(DeviceId deviceId) { + DeviceStateData deviceStateData = deviceStates.get(deviceId); + if (deviceStateData != null) { + return deviceStateData; + } + return fetchDeviceStateDataUsingEntityDataQuery(deviceId); + } + + DeviceStateData fetchDeviceStateDataUsingEntityDataQuery(final DeviceId deviceId) { + final Device device = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, deviceId); + if (device == null) { + log.warn("[{}] Failed to fetch device by Id!", deviceId); + throw new RuntimeException("Failed to fetch device by Id " + deviceId); + } + try { + DeviceStateData deviceStateData = fetchDeviceState(device).get(); + deviceStates.putIfAbsent(deviceId, deviceStateData); + return deviceStateData; + } catch (InterruptedException | ExecutionException e) { + log.warn("[{}] Failed to fetch device state!", deviceId, e); + throw new RuntimeException(e); + } + } + + private boolean cleanDeviceStateIfBelongsExternalPartition(TenantId tenantId, final DeviceId deviceId) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId); + boolean cleanup = !partitionedEntities.containsKey(tpi); + if (cleanup) { + cleanupEntity(deviceId); + log.debug("[{}][{}] device belongs to external partition. Probably rebalancing is in progress. Topic: {}" + , tenantId, deviceId, tpi.getFullTopicName()); + } + return cleanup; + } + + private void onDeviceDeleted(TenantId tenantId, DeviceId deviceId) { + cleanupEntity(deviceId); + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, deviceId); + Set deviceIdSet = partitionedEntities.get(tpi); + if (deviceIdSet != null) { + deviceIdSet.remove(deviceId); + } + } + + @Override + protected void cleanupEntityOnPartitionRemoval(DeviceId deviceId) { + cleanupEntity(deviceId); + } + + private void cleanupEntity(DeviceId deviceId) { + deviceStates.remove(deviceId); + } + + + private ListenableFuture fetchDeviceState(Device device) { + ListenableFuture future; + if (persistToTelemetry) { + ListenableFuture> tsData = tsService.findLatest(TenantId.SYS_TENANT_ID, device.getId(), PERSISTENT_ATTRIBUTES); + future = Futures.transform(tsData, extractDeviceStateData(device), deviceStateExecutor); + } else { + ListenableFuture> attrData = attributesService.find(TenantId.SYS_TENANT_ID, device.getId(), DataConstants.SERVER_SCOPE, PERSISTENT_ATTRIBUTES); + future = Futures.transform(attrData, extractDeviceStateData(device), deviceStateExecutor); + } + return transformInactivityTimeout(future); + } + + private ListenableFuture transformInactivityTimeout(ListenableFuture future) { + return Futures.transformAsync(future, deviceStateData -> { + if (!persistToTelemetry || deviceStateData.getState().getInactivityTimeout() != defaultInactivityTimeoutMs) { + return future; //fail fast + } + var attributesFuture = attributesService.find(TenantId.SYS_TENANT_ID, deviceStateData.getDeviceId(), SERVER_SCOPE, INACTIVITY_TIMEOUT); + return Futures.transform(attributesFuture, attributes -> { + attributes.flatMap(KvEntry::getLongValue).ifPresent((inactivityTimeout) -> { + if (inactivityTimeout > 0) { + deviceStateData.getState().setInactivityTimeout(inactivityTimeout); + } + }); + return deviceStateData; + }, deviceStateExecutor); + }, deviceStateExecutor); + } + + private Function, DeviceStateData> extractDeviceStateData(Device device) { + return new Function<>() { + @Nonnull + @Override + public DeviceStateData apply(@Nullable List data) { + try { + long lastActivityTime = getEntryValue(data, LAST_ACTIVITY_TIME, 0L); + long inactivityAlarmTime = getEntryValue(data, INACTIVITY_ALARM_TIME, 0L); + long inactivityTimeout = getEntryValue(data, INACTIVITY_TIMEOUT, defaultInactivityTimeoutMs); + //Actual active state by wall-clock will updated outside this method. This method is only for fetch persistent state + final boolean active = getEntryValue(data, ACTIVITY_STATE, false); + DeviceState deviceState = DeviceState.builder() + .active(active) + .lastConnectTime(getEntryValue(data, LAST_CONNECT_TIME, 0L)) + .lastDisconnectTime(getEntryValue(data, LAST_DISCONNECT_TIME, 0L)) + .lastActivityTime(lastActivityTime) + .lastInactivityAlarmTime(inactivityAlarmTime) + .inactivityTimeout(inactivityTimeout) + .build(); + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("deviceName", device.getName()); + md.putValue("deviceType", device.getType()); + DeviceStateData deviceStateData = DeviceStateData.builder() + .customerId(device.getCustomerId()) + .tenantId(device.getTenantId()) + .deviceId(device.getId()) + .deviceCreationTime(device.getCreatedTime()) + .metaData(md) + .state(deviceState).build(); + log.debug("[{}] Fetched device state from the DB {}", device.getId(), deviceStateData); + return deviceStateData; + } catch (Exception e) { + log.warn("[{}] Failed to fetch device state data", device.getId(), e); + throw new RuntimeException(e); + } + } + }; + } + + private List fetchDeviceStateDataUsingSeparateRequests(List deviceIds) { + List devices = deviceService.findDevicesByIds(deviceIds.stream().map(DeviceIdInfo::getDeviceId).collect(Collectors.toList())); + List> deviceStateFutures = new ArrayList<>(); + for (Device device : devices) { + deviceStateFutures.add(fetchDeviceState(device)); + } + try { + List result = Futures.successfulAsList(deviceStateFutures).get(5, TimeUnit.MINUTES); + boolean success = true; + for (int i = 0; i < result.size(); i++) { + success = false; + if (result.get(i) == null) { + DeviceIdInfo deviceIdInfo = deviceIds.get(i); + log.warn("[{}][{}] Failed to initialized device state due to:", deviceIdInfo.getTenantId(), deviceIdInfo.getDeviceId()); + } + } + return success ? result : result.stream().filter(Objects::nonNull).collect(Collectors.toList()); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + log.warn("Failed to initialized device state futures for ids: {} due to:", deviceIds, e); + throw new RuntimeException(e); + } + } + + private List fetchDeviceStateDataUsingEntityDataQuery(List deviceIds) { + EntityListFilter ef = new EntityListFilter(); + ef.setEntityType(EntityType.DEVICE); + ef.setEntityList(deviceIds.stream().map(DeviceIdInfo::getDeviceId).map(DeviceId::getId).map(UUID::toString).collect(Collectors.toList())); + + EntityDataQuery query = new EntityDataQuery(ef, + new EntityDataPageLink(deviceIds.size(), 0, null, null), + PERSISTENT_ENTITY_FIELDS, + persistToTelemetry ? PERSISTENT_TELEMETRY_KEYS : PERSISTENT_ATTRIBUTE_KEYS, Collections.emptyList()); + PageData queryResult = entityQueryRepository.findEntityDataByQueryInternal(query); + + Map deviceIdInfos = deviceIds.stream().collect(Collectors.toMap(DeviceIdInfo::getDeviceId, java.util.function.Function.identity())); + + return queryResult.getData().stream().map(ed -> toDeviceStateData(ed, deviceIdInfos.get(ed.getEntityId()))).collect(Collectors.toList()); + + } + + DeviceStateData toDeviceStateData(EntityData ed, DeviceIdInfo deviceIdInfo) { + long lastActivityTime = getEntryValue(ed, getKeyType(), LAST_ACTIVITY_TIME, 0L); + long inactivityAlarmTime = getEntryValue(ed, getKeyType(), INACTIVITY_ALARM_TIME, 0L); + long inactivityTimeout = getEntryValue(ed, getKeyType(), INACTIVITY_TIMEOUT, defaultInactivityTimeoutMs); + if (persistToTelemetry && inactivityTimeout == defaultInactivityTimeoutMs) { + log.trace("[{}] default value for inactivity timeout fetched {}, going to fetch inactivity timeout from attributes", + deviceIdInfo.getDeviceId(), inactivityTimeout); + inactivityTimeout = getEntryValue(ed, EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT, defaultInactivityTimeoutMs); + } + //Actual active state by wall-clock will updated outside this method. This method is only for fetch persistent state + final boolean active = getEntryValue(ed, getKeyType(), ACTIVITY_STATE, false); + DeviceState deviceState = DeviceState.builder() + .active(active) + .lastConnectTime(getEntryValue(ed, getKeyType(), LAST_CONNECT_TIME, 0L)) + .lastDisconnectTime(getEntryValue(ed, getKeyType(), LAST_DISCONNECT_TIME, 0L)) + .lastActivityTime(lastActivityTime) + .lastInactivityAlarmTime(inactivityAlarmTime) + .inactivityTimeout(inactivityTimeout) + .build(); + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("deviceName", getEntryValue(ed, EntityKeyType.ENTITY_FIELD, "name", "")); + md.putValue("deviceType", getEntryValue(ed, EntityKeyType.ENTITY_FIELD, "type", "")); + return DeviceStateData.builder() + .customerId(deviceIdInfo.getCustomerId()) + .tenantId(deviceIdInfo.getTenantId()) + .deviceId(deviceIdInfo.getDeviceId()) + .deviceCreationTime(getEntryValue(ed, EntityKeyType.ENTITY_FIELD, "createdTime", 0L)) + .metaData(md) + .state(deviceState).build(); + } + + private EntityKeyType getKeyType() { + return persistToTelemetry ? EntityKeyType.TIME_SERIES : EntityKeyType.SERVER_ATTRIBUTE; + } + + private String getEntryValue(EntityData ed, EntityKeyType keyType, String keyName, String defaultValue) { + return getEntryValue(ed, keyType, keyName, s -> s, defaultValue); + } + + private long getEntryValue(EntityData ed, EntityKeyType keyType, String keyName, long defaultValue) { + return getEntryValue(ed, keyType, keyName, Long::parseLong, defaultValue); + } + + private boolean getEntryValue(EntityData ed, EntityKeyType keyType, String keyName, boolean defaultValue) { + return getEntryValue(ed, keyType, keyName, Boolean::parseBoolean, defaultValue); + } + + private T getEntryValue(EntityData ed, EntityKeyType entityKeyType, String attributeName, Function converter, T defaultValue) { + if (ed != null && ed.getLatest() != null) { + var map = ed.getLatest().get(entityKeyType); + if (map != null) { + var value = map.get(attributeName); + if (value != null && !StringUtils.isEmpty(value.getValue())) { + try { + return converter.apply(value.getValue()); + } catch (Exception e) { + return defaultValue; + } + } + } + } + return defaultValue; + } + + + private long getEntryValue(List kvEntries, String attributeName, long defaultValue) { + if (kvEntries != null) { + for (KvEntry entry : kvEntries) { + if (entry != null && !StringUtils.isEmpty(entry.getKey()) && entry.getKey().equals(attributeName)) { + return entry.getLongValue().orElse(defaultValue); + } + } + } + return defaultValue; + } + + private boolean getEntryValue(List kvEntries, String attributeName, boolean defaultValue) { + if (kvEntries != null) { + for (KvEntry entry : kvEntries) { + if (entry != null && !StringUtils.isEmpty(entry.getKey()) && entry.getKey().equals(attributeName)) { + return entry.getBooleanValue().orElse(defaultValue); + } + } + } + return defaultValue; + } + + private void pushRuleEngineMessage(DeviceStateData stateData, String msgType) { + DeviceState state = stateData.getState(); + try { + String data; + if (msgType.equals(CONNECT_EVENT)) { + ObjectNode stateNode = JacksonUtil.convertValue(state, ObjectNode.class); + stateNode.remove(ACTIVITY_STATE); + data = JacksonUtil.toString(stateNode); + } else { + data = JacksonUtil.toString(state); + } + TbMsgMetaData md = stateData.getMetaData().copy(); + if (!persistToTelemetry) { + md.putValue(DataConstants.SCOPE, SERVER_SCOPE); + } + TbMsg tbMsg = TbMsg.newMsg(msgType, stateData.getDeviceId(), stateData.getCustomerId(), md, TbMsgDataType.JSON, data); + clusterService.pushMsgToRuleEngine(stateData.getTenantId(), stateData.getDeviceId(), tbMsg, null); + } catch (Exception e) { + log.warn("[{}] Failed to push inactivity alarm: {}", stateData.getDeviceId(), state, e); + } + } + + private void save(DeviceId deviceId, String key, long value) { + if (persistToTelemetry) { + tsSubService.saveAndNotifyInternal( + TenantId.SYS_TENANT_ID, deviceId, + Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(key, value))), + new TelemetrySaveCallback<>(deviceId, key, value)); + } else { + tsSubService.saveAttrAndNotify(TenantId.SYS_TENANT_ID, deviceId, DataConstants.SERVER_SCOPE, key, value, new TelemetrySaveCallback<>(deviceId, key, value)); + } + } + + private void save(DeviceId deviceId, String key, boolean value) { + if (persistToTelemetry) { + tsSubService.saveAndNotifyInternal( + TenantId.SYS_TENANT_ID, deviceId, + Collections.singletonList(new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(key, value))), + new TelemetrySaveCallback<>(deviceId, key, value)); + } else { + tsSubService.saveAttrAndNotify(TenantId.SYS_TENANT_ID, deviceId, DataConstants.SERVER_SCOPE, key, value, new TelemetrySaveCallback<>(deviceId, key, value)); + } + } + + private static class TelemetrySaveCallback implements FutureCallback { + private final DeviceId deviceId; + private final String key; + private final Object value; + + TelemetrySaveCallback(DeviceId deviceId, String key, Object value) { + this.deviceId = deviceId; + this.key = key; + this.value = value; + } + + @Override + public void onSuccess(@Nullable T result) { + log.trace("[{}] Successfully updated attribute [{}] with value [{}]", deviceId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to update attribute [{}] with value [{}]", deviceId, key, value, t); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/state/DeviceState.java b/application/src/main/java/org/thingsboard/server/service/state/DeviceState.java new file mode 100644 index 0000000..c2f9d17 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/state/DeviceState.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.state; + +import lombok.Builder; +import lombok.Data; + +/** + * Created by ashvayka on 01.05.18. + */ +@Data +@Builder +public class DeviceState { + + private boolean active; + private long lastConnectTime; + private long lastActivityTime; + private long lastDisconnectTime; + private long lastInactivityAlarmTime; + private long inactivityTimeout; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java b/application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java new file mode 100644 index 0000000..cea9431 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.state; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +/** + * Created by ashvayka on 01.05.18. + */ +@Data +@Builder +class DeviceStateData { + + private final TenantId tenantId; + private final CustomerId customerId; + private final DeviceId deviceId; + private final long deviceCreationTime; + private TbMsgMetaData metaData; + private final DeviceState state; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/state/DeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DeviceStateService.java new file mode 100644 index 0000000..e9fdca9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/state/DeviceStateService.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.state; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.common.msg.queue.TbCallback; + +/** + * Created by ashvayka on 01.05.18. + */ +public interface DeviceStateService extends ApplicationListener { + + void onDeviceConnect(TenantId tenantId, DeviceId deviceId); + + void onDeviceActivity(TenantId tenantId, DeviceId deviceId, long lastReportedActivityTime); + + void onDeviceDisconnect(TenantId tenantId, DeviceId deviceId); + + void onDeviceInactivityTimeoutUpdate(TenantId tenantId, DeviceId deviceId, long inactivityTimeout); + + void onQueueMsg(TransportProtos.DeviceStateServiceMsgProto proto, TbCallback bytes); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java new file mode 100644 index 0000000..e60a105 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.stats; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.server.actors.JsInvokeStats; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; + +import javax.annotation.PostConstruct; + +@Service +public class DefaultJsInvokeStats implements JsInvokeStats { + private static final String REQUESTS = "requests"; + private static final String RESPONSES = "responses"; + private static final String FAILURES = "failures"; + + private StatsCounter requestsCounter; + private StatsCounter responsesCounter; + private StatsCounter failuresCounter; + + @Autowired + private StatsFactory statsFactory; + + @PostConstruct + public void init() { + String key = StatsType.JS_INVOKE.getName(); + this.requestsCounter = statsFactory.createStatsCounter(key, REQUESTS); + this.responsesCounter = statsFactory.createStatsCounter(key, RESPONSES); + this.failuresCounter = statsFactory.createStatsCounter(key, FAILURES); + } + + @Override + public void incrementRequests(int amount) { + requestsCounter.add(amount); + } + + @Override + public void incrementResponses(int amount) { + responsesCounter.add(amount); + } + + @Override + public void incrementFailures(int amount) { + failuresCounter.add(amount); + } + + @Override + public int getRequests() { + return requestsCounter.get(); + } + + @Override + public int getResponses() { + return responsesCounter.get(); + } + + @Override + public int getFailures() { + return failuresCounter.get(); + } + + @Override + public void reset() { + requestsCounter.clear(); + responsesCounter.clear(); + failuresCounter.clear(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java new file mode 100644 index 0000000..86380ea --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/stats/DefaultRuleEngineStatisticsService.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.stats; + +import com.google.common.util.concurrent.FutureCallback; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; +import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +@TbRuleEngineComponent +@Service +@Slf4j +public class DefaultRuleEngineStatisticsService implements RuleEngineStatisticsService { + + public static final String TB_SERVICE_QUEUE = "TbServiceQueue"; + public static final FutureCallback CALLBACK = new FutureCallback() { + @Override + public void onSuccess(@Nullable Integer result) { + + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to persist statistics", t); + } + }; + + private final TbServiceInfoProvider serviceInfoProvider; + private final TelemetrySubscriptionService tsService; + private final Lock lock = new ReentrantLock(); + private final AssetService assetService; + private final ConcurrentMap tenantQueueAssets; + + public DefaultRuleEngineStatisticsService(TelemetrySubscriptionService tsService, TbServiceInfoProvider serviceInfoProvider, AssetService assetService) { + this.tsService = tsService; + this.serviceInfoProvider = serviceInfoProvider; + this.assetService = assetService; + this.tenantQueueAssets = new ConcurrentHashMap<>(); + } + + @Override + public void reportQueueStats(long ts, TbRuleEngineConsumerStats ruleEngineStats) { + String queueName = ruleEngineStats.getQueueName(); + ruleEngineStats.getTenantStats().forEach((id, stats) -> { + TenantId tenantId = TenantId.fromUUID(id); + try { + AssetId serviceAssetId = getServiceAssetId(tenantId, queueName); + if (stats.getTotalMsgCounter().get() > 0) { + List tsList = stats.getCounters().entrySet().stream() + .map(kv -> new BasicTsKvEntry(ts, new LongDataEntry(kv.getKey(), (long) kv.getValue().get()))) + .collect(Collectors.toList()); + if (!tsList.isEmpty()) { + tsService.saveAndNotifyInternal(tenantId, serviceAssetId, tsList, CALLBACK); + } + } + } catch (DataValidationException e) { + if (!e.getMessage().equalsIgnoreCase("Asset is referencing to non-existent tenant!")) { + throw e; + } + } + }); + ruleEngineStats.getTenantExceptions().forEach((tenantId, e) -> { + TsKvEntry tsKv = new BasicTsKvEntry(e.getTs(), new JsonDataEntry("ruleEngineException", e.toJsonString())); + try { + tsService.saveAndNotifyInternal(tenantId, getServiceAssetId(tenantId, queueName), Collections.singletonList(tsKv), CALLBACK); + } catch (DataValidationException e2) { + if (!e2.getMessage().equalsIgnoreCase("Asset is referencing to non-existent tenant!")) { + throw e2; + } + } + }); + } + + private AssetId getServiceAssetId(TenantId tenantId, String queueName) { + TenantQueueKey key = new TenantQueueKey(tenantId, queueName); + AssetId assetId = tenantQueueAssets.get(key); + if (assetId == null) { + lock.lock(); + try { + assetId = tenantQueueAssets.get(key); + if (assetId == null) { + Asset asset = assetService.findAssetByTenantIdAndName(tenantId, queueName + "_" + serviceInfoProvider.getServiceId()); + if (asset == null) { + asset = new Asset(); + asset.setTenantId(tenantId); + asset.setName(queueName + "_" + serviceInfoProvider.getServiceId()); + asset.setType(TB_SERVICE_QUEUE); + asset = assetService.saveAsset(asset); + } + assetId = asset.getId(); + tenantQueueAssets.put(key, assetId); + } + } finally { + lock.unlock(); + } + } + return assetId; + } + + @Data + private static class TenantQueueKey { + private final TenantId tenantId; + private final String queueName; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/stats/RuleEngineStatisticsService.java b/application/src/main/java/org/thingsboard/server/service/stats/RuleEngineStatisticsService.java new file mode 100644 index 0000000..6e82c00 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/stats/RuleEngineStatisticsService.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.stats; + +import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; + +public interface RuleEngineStatisticsService { + + void reportQueueStats(long ts, TbRuleEngineConsumerStats stats); +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java new file mode 100644 index 0000000..cdec2eb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java @@ -0,0 +1,603 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.alarm.Alarm; +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.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +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.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.gen.transport.TransportProtos.LocalSubscriptionServiceMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmSubscriptionUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionUpdateTsValue; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionUpdateValueListProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMsg; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.NotificationsTopicService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.state.DefaultDeviceStateService; +import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.function.Predicate; + +@Slf4j +@TbCoreComponent +@Service +public class DefaultSubscriptionManagerService extends TbApplicationEventListener implements SubscriptionManagerService { + + @Autowired + private AttributesService attrService; + + @Autowired + private TimeseriesService tsService; + + @Autowired + private NotificationsTopicService notificationsTopicService; + + @Autowired + private PartitionService partitionService; + + @Autowired + private TbServiceInfoProvider serviceInfoProvider; + + @Autowired + private TbQueueProducerProvider producerProvider; + + @Autowired + private TbLocalSubscriptionService localSubscriptionService; + + @Autowired + private DeviceStateService deviceStateService; + + @Autowired + private TbClusterService clusterService; + + private final Map> subscriptionsByEntityId = new ConcurrentHashMap<>(); + private final Map> subscriptionsByWsSessionId = new ConcurrentHashMap<>(); + private final ConcurrentMap> partitionedSubscriptions = new ConcurrentHashMap<>(); + private final Set currentPartitions = ConcurrentHashMap.newKeySet(); + + private ExecutorService tsCallBackExecutor; + private String serviceId; + private TbQueueProducer> toCoreNotificationsProducer; + + @PostConstruct + public void initExecutor() { + tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ts-sub-callback")); + serviceId = serviceInfoProvider.getServiceId(); + toCoreNotificationsProducer = producerProvider.getTbCoreNotificationsMsgProducer(); + } + + @PreDestroy + public void shutdownExecutor() { + if (tsCallBackExecutor != null) { + tsCallBackExecutor.shutdownNow(); + } + } + + @Override + public void addSubscription(TbSubscription subscription, TbCallback callback) { + log.trace("[{}][{}][{}] Registering subscription for entity [{}]", + subscription.getServiceId(), subscription.getSessionId(), subscription.getSubscriptionId(), subscription.getEntityId()); + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, subscription.getTenantId(), subscription.getEntityId()); + if (currentPartitions.contains(tpi)) { + partitionedSubscriptions.computeIfAbsent(tpi, k -> ConcurrentHashMap.newKeySet()).add(subscription); + callback.onSuccess(); + } else { + log.warn("[{}][{}] Entity belongs to external partition. Probably rebalancing is in progress. Topic: {}" + , subscription.getTenantId(), subscription.getEntityId(), tpi.getFullTopicName()); + callback.onFailure(new RuntimeException("Entity belongs to external partition " + tpi.getFullTopicName() + "!")); + } + boolean newSubscription = subscriptionsByEntityId + .computeIfAbsent(subscription.getEntityId(), k -> ConcurrentHashMap.newKeySet()).add(subscription); + subscriptionsByWsSessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()).put(subscription.getSubscriptionId(), subscription); + if (newSubscription) { + switch (subscription.getType()) { + case TIMESERIES: + handleNewTelemetrySubscription((TbTimeseriesSubscription) subscription); + break; + case ATTRIBUTES: + handleNewAttributeSubscription((TbAttributeSubscription) subscription); + break; + case ALARMS: + handleNewAlarmsSubscription((TbAlarmsSubscription) subscription); + break; + } + } + } + + @Override + public void cancelSubscription(String sessionId, int subscriptionId, TbCallback callback) { + log.debug("[{}][{}] Going to remove subscription.", sessionId, subscriptionId); + Map sessionSubscriptions = subscriptionsByWsSessionId.get(sessionId); + if (sessionSubscriptions != null) { + TbSubscription subscription = sessionSubscriptions.remove(subscriptionId); + if (subscription != null) { + removeSubscriptionFromEntityMap(subscription); + removeSubscriptionFromPartitionMap(subscription); + if (sessionSubscriptions.isEmpty()) { + subscriptionsByWsSessionId.remove(sessionId); + } + } else { + log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); + } + } else { + log.debug("[{}] No session subscriptions found!", sessionId); + } + callback.onSuccess(); + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent partitionChangeEvent) { + if (ServiceType.TB_CORE.equals(partitionChangeEvent.getServiceType())) { + Set removedPartitions = new HashSet<>(currentPartitions); + removedPartitions.removeAll(partitionChangeEvent.getPartitions()); + + currentPartitions.clear(); + currentPartitions.addAll(partitionChangeEvent.getPartitions()); + + // We no longer manage current partition of devices; + removedPartitions.forEach(partition -> { + Set subs = partitionedSubscriptions.remove(partition); + if (subs != null) { + subs.forEach(sub -> { + if (!serviceId.equals(sub.getServiceId())) { + removeSubscriptionFromEntityMap(sub); + } + }); + } + }); + } + } + + @Override + public void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts, TbCallback callback) { + onLocalTelemetrySubUpdate(entityId, + s -> { + if (TbSubscriptionType.TIMESERIES.equals(s.getType())) { + return (TbTimeseriesSubscription) s; + } else { + return null; + } + }, s -> true, s -> { + List subscriptionUpdate = null; + for (TsKvEntry kv : ts) { + if ((s.isAllKeys() || s.getKeyStates().containsKey((kv.getKey())))) { + if (subscriptionUpdate == null) { + subscriptionUpdate = new ArrayList<>(); + } + subscriptionUpdate.add(kv); + } + } + return subscriptionUpdate; + }, true); + if (entityId.getEntityType() == EntityType.DEVICE) { + updateDeviceInactivityTimeout(tenantId, entityId, ts); + } + callback.onSuccess(); + } + + @Override + public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, TbCallback callback) { + onAttributesUpdate(tenantId, entityId, scope, attributes, true, callback); + } + + @Override + public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback) { + onLocalTelemetrySubUpdate(entityId, + s -> { + if (TbSubscriptionType.ATTRIBUTES.equals(s.getType())) { + return (TbAttributeSubscription) s; + } else { + return null; + } + }, + s -> (TbAttributeSubscriptionScope.ANY_SCOPE.equals(s.getScope()) || scope.equals(s.getScope().name())), + s -> { + List subscriptionUpdate = null; + for (AttributeKvEntry kv : attributes) { + if (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey())) { + if (subscriptionUpdate == null) { + subscriptionUpdate = new ArrayList<>(); + } + subscriptionUpdate.add(new BasicTsKvEntry(kv.getLastUpdateTs(), kv)); + } + } + return subscriptionUpdate; + }, true); + if (entityId.getEntityType() == EntityType.DEVICE) { + if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope)) { + updateDeviceInactivityTimeout(tenantId, entityId, attributes); + } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { + clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onUpdate(tenantId, + new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, new ArrayList<>(attributes)) + , null); + } + } + callback.onSuccess(); + } + + private void updateDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List kvEntries) { + for (KvEntry kvEntry : kvEntries) { + if (kvEntry.getKey().equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { + deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), getLongValue(kvEntry)); + } + } + } + + private void deleteDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List keys) { + for (String key : keys) { + if (key.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { + deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), 0); + } + } + } + + @Override + public void onAlarmUpdate(TenantId tenantId, EntityId entityId, Alarm alarm, TbCallback callback) { + onLocalAlarmSubUpdate(entityId, + s -> { + if (TbSubscriptionType.ALARMS.equals(s.getType())) { + return (TbAlarmsSubscription) s; + } else { + return null; + } + }, + s -> alarm.getCreatedTime() >= s.getTs(), + s -> alarm, + false + ); + callback.onSuccess(); + } + + @Override + public void onAlarmDeleted(TenantId tenantId, EntityId entityId, Alarm alarm, TbCallback callback) { + onLocalAlarmSubUpdate(entityId, + s -> { + if (TbSubscriptionType.ALARMS.equals(s.getType())) { + return (TbAlarmsSubscription) s; + } else { + return null; + } + }, + s -> alarm.getCreatedTime() >= s.getTs(), + s -> alarm, + true + ); + callback.onSuccess(); + } + + @Override + public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback callback) { + onLocalTelemetrySubUpdate(entityId, + s -> { + if (TbSubscriptionType.ATTRIBUTES.equals(s.getType())) { + return (TbAttributeSubscription) s; + } else { + return null; + } + }, + s -> (TbAttributeSubscriptionScope.ANY_SCOPE.equals(s.getScope()) || scope.equals(s.getScope().name())), + s -> { + List subscriptionUpdate = null; + for (String key : keys) { + if (s.isAllKeys() || s.getKeyStates().containsKey(key)) { + if (subscriptionUpdate == null) { + subscriptionUpdate = new ArrayList<>(); + } + subscriptionUpdate.add(new BasicTsKvEntry(0, new StringDataEntry(key, ""))); + } + } + return subscriptionUpdate; + }, false); + if (entityId.getEntityType() == EntityType.DEVICE) { + if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) + || TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) { + deleteDeviceInactivityTimeout(tenantId, entityId, keys); + } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { + clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, + new DeviceId(entityId.getId()), scope, keys), null); + } + } + callback.onSuccess(); + } + + @Override + public void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback) { + onLocalTelemetrySubUpdate(entityId, + s -> { + if (TbSubscriptionType.TIMESERIES.equals(s.getType())) { + return (TbTimeseriesSubscription) s; + } else { + return null; + } + }, s -> true, s -> { + List subscriptionUpdate = null; + for (String key : keys) { + if (s.isAllKeys() || s.getKeyStates().containsKey(key)) { + if (subscriptionUpdate == null) { + subscriptionUpdate = new ArrayList<>(); + } + subscriptionUpdate.add(new BasicTsKvEntry(0, new StringDataEntry(key, ""))); + } + } + return subscriptionUpdate; + }, false); + if (entityId.getEntityType() == EntityType.DEVICE) { + deleteDeviceInactivityTimeout(tenantId, entityId, keys); + } + callback.onSuccess(); + } + + private void onLocalTelemetrySubUpdate(EntityId entityId, + Function castFunction, + Predicate filterFunction, + Function> processFunction, + boolean ignoreEmptyUpdates) { + Set entitySubscriptions = subscriptionsByEntityId.get(entityId); + if (entitySubscriptions != null) { + entitySubscriptions.stream().map(castFunction).filter(Objects::nonNull).filter(filterFunction).forEach(s -> { + List subscriptionUpdate = processFunction.apply(s); + if (subscriptionUpdate != null && !subscriptionUpdate.isEmpty()) { + if (serviceId.equals(s.getServiceId())) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(s.getSubscriptionId(), subscriptionUpdate); + localSubscriptionService.onSubscriptionUpdate(s.getSessionId(), update, TbCallback.EMPTY); + } else { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, s.getServiceId()); + toCoreNotificationsProducer.send(tpi, toProto(s, subscriptionUpdate, ignoreEmptyUpdates), null); + } + } + }); + } else { + log.debug("[{}] No device subscriptions to process!", entityId); + } + } + + private void onLocalAlarmSubUpdate(EntityId entityId, + Function castFunction, + Predicate filterFunction, + Function processFunction, boolean deleted) { + Set entitySubscriptions = subscriptionsByEntityId.get(entityId); + if (entitySubscriptions != null) { + entitySubscriptions.stream().map(castFunction).filter(Objects::nonNull).filter(filterFunction).forEach(s -> { + Alarm alarm = processFunction.apply(s); + if (alarm != null) { + if (serviceId.equals(s.getServiceId())) { + AlarmSubscriptionUpdate update = new AlarmSubscriptionUpdate(s.getSubscriptionId(), alarm, deleted); + localSubscriptionService.onSubscriptionUpdate(s.getSessionId(), update, TbCallback.EMPTY); + } else { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, s.getServiceId()); + toCoreNotificationsProducer.send(tpi, toProto(s, alarm, deleted), null); + } + } + }); + } else { + log.debug("[{}] No device subscriptions to process!", entityId); + } + } + + private void removeSubscriptionFromEntityMap(TbSubscription sub) { + Set entitySubSet = subscriptionsByEntityId.get(sub.getEntityId()); + if (entitySubSet != null) { + entitySubSet.remove(sub); + if (entitySubSet.isEmpty()) { + subscriptionsByEntityId.remove(sub.getEntityId()); + } + } + } + + private void removeSubscriptionFromPartitionMap(TbSubscription sub) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, sub.getTenantId(), sub.getEntityId()); + Set subs = partitionedSubscriptions.get(tpi); + if (subs != null) { + subs.remove(sub); + } + } + + private void handleNewAttributeSubscription(TbAttributeSubscription subscription) { + log.trace("[{}][{}][{}] Processing remote attribute subscription for entity [{}]", + serviceId, subscription.getSessionId(), subscription.getSubscriptionId(), subscription.getEntityId()); + + final Map keyStates = subscription.getKeyStates(); + DonAsynchron.withCallback(attrService.find(subscription.getTenantId(), subscription.getEntityId(), DataConstants.CLIENT_SCOPE, keyStates.keySet()), values -> { + List missedUpdates = new ArrayList<>(); + values.forEach(latestEntry -> { + if (latestEntry.getLastUpdateTs() > keyStates.get(latestEntry.getKey())) { + missedUpdates.add(new BasicTsKvEntry(latestEntry.getLastUpdateTs(), latestEntry)); + } + }); + if (!missedUpdates.isEmpty()) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, subscription.getServiceId()); + toCoreNotificationsProducer.send(tpi, toProto(subscription, missedUpdates), null); + } + }, + e -> log.error("Failed to fetch missed updates.", e), tsCallBackExecutor); + } + + private void handleNewAlarmsSubscription(TbAlarmsSubscription subscription) { + log.trace("[{}][{}][{}] Processing remote alarm subscription for entity [{}]", + serviceId, subscription.getSessionId(), subscription.getSubscriptionId(), subscription.getEntityId()); + //TODO: @dlandiak search all new alarms for this entity. + } + + private void handleNewTelemetrySubscription(TbTimeseriesSubscription subscription) { + log.trace("[{}][{}][{}] Processing remote telemetry subscription for entity [{}]", + serviceId, subscription.getSessionId(), subscription.getSubscriptionId(), subscription.getEntityId()); + + long curTs = System.currentTimeMillis(); + + if (subscription.isLatestValues()) { + DonAsynchron.withCallback(tsService.findLatest(subscription.getTenantId(), subscription.getEntityId(), subscription.getKeyStates().keySet()), + missedUpdates -> { + if (missedUpdates != null && !missedUpdates.isEmpty()) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, subscription.getServiceId()); + toCoreNotificationsProducer.send(tpi, toProto(subscription, missedUpdates), null); + } + }, + e -> log.error("Failed to fetch missed updates.", e), + tsCallBackExecutor); + } else { + List queries = new ArrayList<>(); + subscription.getKeyStates().forEach((key, value) -> { + if (curTs > value) { + long startTs = subscription.getStartTime() > 0 ? Math.max(subscription.getStartTime(), value + 1L) : (value + 1L); + long endTs = subscription.getEndTime() > 0 ? Math.min(subscription.getEndTime(), curTs) : curTs; + queries.add(new BaseReadTsKvQuery(key, startTs, endTs, 0, 1000, Aggregation.NONE)); + } + }); + if (!queries.isEmpty()) { + DonAsynchron.withCallback(tsService.findAll(subscription.getTenantId(), subscription.getEntityId(), queries), + missedUpdates -> { + if (missedUpdates != null && !missedUpdates.isEmpty()) { + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_CORE, subscription.getServiceId()); + toCoreNotificationsProducer.send(tpi, toProto(subscription, missedUpdates), null); + } + }, + e -> log.error("Failed to fetch missed updates.", e), + tsCallBackExecutor); + } + } + } + + private TbProtoQueueMsg toProto(TbSubscription subscription, List updates) { + return toProto(subscription, updates, true); + } + + private TbProtoQueueMsg toProto(TbSubscription subscription, List updates, boolean ignoreEmptyUpdates) { + TbSubscriptionUpdateProto.Builder builder = TbSubscriptionUpdateProto.newBuilder(); + + builder.setSessionId(subscription.getSessionId()); + builder.setSubscriptionId(subscription.getSubscriptionId()); + + Map> data = new TreeMap<>(); + for (TsKvEntry tsEntry : updates) { + List values = data.computeIfAbsent(tsEntry.getKey(), k -> new ArrayList<>()); + Object[] value = new Object[2]; + value[0] = tsEntry.getTs(); + value[1] = tsEntry.getValueAsString(); + values.add(value); + } + + data.forEach((key, value) -> { + TbSubscriptionUpdateValueListProto.Builder dataBuilder = TbSubscriptionUpdateValueListProto.newBuilder(); + dataBuilder.setKey(key); + boolean hasData = false; + for (Object v : value) { + Object[] array = (Object[]) v; + TbSubscriptionUpdateTsValue.Builder tsValueBuilder = TbSubscriptionUpdateTsValue.newBuilder(); + tsValueBuilder.setTs((long) array[0]); + String strVal = (String) array[1]; + if (strVal != null) { + hasData = true; + tsValueBuilder.setValue(strVal); + } + dataBuilder.addTsValue(tsValueBuilder.build()); + } + if (!ignoreEmptyUpdates || hasData) { + builder.addData(dataBuilder.build()); + } + }); + + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setToLocalSubscriptionServiceMsg( + LocalSubscriptionServiceMsgProto.newBuilder().setSubUpdate(builder.build()).build()) + .build(); + return new TbProtoQueueMsg<>(subscription.getEntityId().getId(), toCoreMsg); + } + + private TbProtoQueueMsg toProto(TbSubscription subscription, Alarm alarm, boolean deleted) { + TbAlarmSubscriptionUpdateProto.Builder builder = TbAlarmSubscriptionUpdateProto.newBuilder(); + + builder.setSessionId(subscription.getSessionId()); + builder.setSubscriptionId(subscription.getSubscriptionId()); + builder.setAlarm(JacksonUtil.toString(alarm)); + builder.setDeleted(deleted); + + ToCoreNotificationMsg toCoreMsg = ToCoreNotificationMsg.newBuilder().setToLocalSubscriptionServiceMsg( + LocalSubscriptionServiceMsgProto.newBuilder() + .setAlarmSubUpdate(builder.build()).build()) + .build(); + return new TbProtoQueueMsg<>(subscription.getEntityId().getId(), toCoreMsg); + } + + private static long getLongValue(KvEntry kve) { + switch (kve.getDataType()) { + case LONG: + return kve.getLongValue().orElse(0L); + case DOUBLE: + return kve.getDoubleValue().orElse(0.0).longValue(); + case STRING: + try { + return Long.parseLong(kve.getStrValue().orElse("0")); + } catch (NumberFormatException e) { + return 0L; + } + case JSON: + try { + return Long.parseLong(kve.getJsonValue().orElse("0")); + } catch (NumberFormatException e) { + return 0L; + } + default: + return 0L; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java new file mode 100644 index 0000000..3d9077c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -0,0 +1,711 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.CloseStatus; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.ComparisonTsValue; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.AggHistoryCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AggKey; +import org.thingsboard.server.service.telemetry.cmd.v2.AggTimeSeriesCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityHistoryCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.GetTsCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.LatestValueCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.TimeSeriesCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.UnsubscribeCmd; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@SuppressWarnings("UnstableApiUsage") +@Slf4j +@TbCoreComponent +@Service +public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubscriptionService { + + private static final int DEFAULT_LIMIT = 100; + private final Map> subscriptionsBySessionId = new ConcurrentHashMap<>(); + + @Autowired + private TelemetryWebSocketService wsService; + + @Autowired + private EntityService entityService; + + @Autowired + private AlarmService alarmService; + + @Autowired + private AttributesService attributesService; + + @Autowired + @Lazy + private TbLocalSubscriptionService localSubscriptionService; + + @Autowired + private TimeseriesService tsService; + + @Autowired + private TbServiceInfoProvider serviceInfoProvider; + + @Autowired + @Getter + private DbCallbackExecutorService dbCallbackExecutor; + + private ScheduledExecutorService scheduler; + + @Value("${database.ts.type}") + private String databaseTsType; + @Value("${server.ws.dynamic_page_link.refresh_interval:6}") + private long dynamicPageLinkRefreshInterval; + @Value("${server.ws.dynamic_page_link.refresh_pool_size:1}") + private int dynamicPageLinkRefreshPoolSize; + @Value("${server.ws.max_entities_per_data_subscription:1000}") + private int maxEntitiesPerDataSubscription; + @Value("${server.ws.max_entities_per_alarm_subscription:1000}") + private int maxEntitiesPerAlarmSubscription; + @Value("${server.ws.dynamic_page_link.max_alarm_queries_per_refresh_interval:10}") + private int maxAlarmQueriesPerRefreshInterval; + @Value("${ui.dashboard.max_datapoints_limit:50000}") + private int maxDatapointLimit; + + private ExecutorService wsCallBackExecutor; + private boolean tsInSqlDB; + private String serviceId; + private SubscriptionServiceStatistics stats = new SubscriptionServiceStatistics(); + + @PostConstruct + public void initExecutor() { + serviceId = serviceInfoProvider.getServiceId(); + wsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ws-entity-sub-callback")); + tsInSqlDB = databaseTsType.equalsIgnoreCase("sql") || databaseTsType.equalsIgnoreCase("timescale"); + ThreadFactory tbThreadFactory = ThingsBoardThreadFactory.forName("ws-entity-sub-scheduler"); + if (dynamicPageLinkRefreshPoolSize == 1) { + scheduler = Executors.newSingleThreadScheduledExecutor(tbThreadFactory); + } else { + scheduler = Executors.newScheduledThreadPool(dynamicPageLinkRefreshPoolSize, tbThreadFactory); + } + } + + @PreDestroy + public void shutdownExecutor() { + if (wsCallBackExecutor != null) { + wsCallBackExecutor.shutdownNow(); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + @Override + public void handleCmd(TelemetryWebSocketSessionRef session, EntityDataCmd cmd) { + TbEntityDataSubCtx ctx = getSubCtx(session.getSessionId(), cmd.getCmdId()); + if (ctx != null) { + log.debug("[{}][{}] Updating existing subscriptions using: {}", session.getSessionId(), cmd.getCmdId(), cmd); + if (cmd.hasAnyCmd()) { + ctx.clearEntitySubscriptions(); + } + } else { + log.debug("[{}][{}] Creating new subscription using: {}", session.getSessionId(), cmd.getCmdId(), cmd); + ctx = createSubCtx(session, cmd); + } + ctx.setCurrentCmd(cmd); + + // Fetch entity list using entity data query + if (cmd.getQuery() != null) { + if (ctx.getQuery() == null) { + log.debug("[{}][{}] Initializing data using query: {}", session.getSessionId(), cmd.getCmdId(), cmd.getQuery()); + } else { + log.debug("[{}][{}] Updating data using query: {}", session.getSessionId(), cmd.getCmdId(), cmd.getQuery()); + } + ctx.setAndResolveQuery(cmd.getQuery()); + EntityDataQuery query = ctx.getQuery(); + //Step 1. Update existing query with the contents of LatestValueCmd + if (cmd.getLatestCmd() != null) { + cmd.getLatestCmd().getKeys().forEach(key -> { + if (!query.getLatestValues().contains(key)) { + query.getLatestValues().add(key); + } + }); + } + long start = System.currentTimeMillis(); + ctx.fetchData(); + long end = System.currentTimeMillis(); + stats.getRegularQueryInvocationCnt().incrementAndGet(); + stats.getRegularQueryTimeSpent().addAndGet(end - start); + ctx.cancelTasks(); + if (ctx.getQuery().getPageLink().isDynamic()) { + //TODO: validate number of dynamic page links against rate limits. Ignore dynamic flag if limit is reached. + TbEntityDataSubCtx finalCtx = ctx; + ScheduledFuture task = scheduler.scheduleWithFixedDelay( + () -> refreshDynamicQuery(finalCtx), + dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS); + finalCtx.setRefreshTask(task); + } + } + + try { + List> cmdFutures = new ArrayList<>(); + if (cmd.getAggHistoryCmd() != null) { + cmdFutures.add(handleAggHistoryCmd(ctx, cmd.getAggHistoryCmd())); + } + if (cmd.getAggTsCmd() != null) { + cmdFutures.add(handleAggTsCmd(ctx, cmd.getAggTsCmd())); + } + if (cmd.getHistoryCmd() != null) { + cmdFutures.add(handleHistoryCmd(ctx, cmd.getHistoryCmd())); + } + if (cmdFutures.isEmpty()) { + handleRegularCommands(ctx, cmd); + } else { + TbEntityDataSubCtx finalCtx = ctx; + Futures.addCallback(Futures.allAsList(cmdFutures), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List result) { + handleRegularCommands(finalCtx, cmd); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId()); + } + }, wsCallBackExecutor); + } + } catch (RuntimeException e) { + handleWsCmdRuntimeException(ctx.getSessionId(), e, cmd); + } + } + + private void handleRegularCommands(TbEntityDataSubCtx ctx, EntityDataCmd cmd) { + try { + if (cmd.getLatestCmd() != null || cmd.getTsCmd() != null) { + if (cmd.getLatestCmd() != null) { + handleLatestCmd(ctx, cmd.getLatestCmd()); + } + if (cmd.getTsCmd() != null) { + handleTimeSeriesCmd(ctx, cmd.getTsCmd()); + } + } else { + checkAndSendInitialData(ctx); + } + } catch (RuntimeException e) { + handleWsCmdRuntimeException(ctx.getSessionId(), e, cmd); + } + } + + private void checkAndSendInitialData(@Nullable TbEntityDataSubCtx theCtx) { + if (!theCtx.isInitialDataSent()) { + EntityDataUpdate update = new EntityDataUpdate(theCtx.getCmdId(), theCtx.getData(), null, theCtx.getMaxEntitiesPerDataSubscription()); + theCtx.sendWsMsg(update); + theCtx.setInitialDataSent(true); + } + } + + private ListenableFuture handleAggHistoryCmd(TbEntityDataSubCtx ctx, AggHistoryCmd cmd) { + ConcurrentMap queries = new ConcurrentHashMap<>(); + for (AggKey key : cmd.getKeys()) { + if (key.getPreviousValueOnly() == null || !key.getPreviousValueOnly()) { + var query = new BaseReadTsKvQuery(key.getKey(), cmd.getStartTs(), cmd.getEndTs(), cmd.getEndTs() - cmd.getStartTs(), 1, key.getAgg()); + queries.put(query.getId(), new ReadTsKvQueryInfo(key, query, false)); + } + if (key.getPreviousStartTs() != null && key.getPreviousEndTs() != null && key.getPreviousEndTs() >= key.getPreviousStartTs()) { + var query = new BaseReadTsKvQuery(key.getKey(), key.getPreviousStartTs(), key.getPreviousEndTs(), key.getPreviousEndTs() - key.getPreviousStartTs(), 1, key.getAgg()); + queries.put(query.getId(), new ReadTsKvQueryInfo(key, query, true)); + } + } + return handleAggCmd(ctx, cmd.getKeys(), queries, cmd.getStartTs(), cmd.getEndTs(), false); + } + + private ListenableFuture handleAggTsCmd(TbEntityDataSubCtx ctx, AggTimeSeriesCmd cmd) { + ConcurrentMap queries = new ConcurrentHashMap<>(); + for (AggKey key : cmd.getKeys()) { + var query = new BaseReadTsKvQuery(key.getKey(), cmd.getStartTs(), cmd.getStartTs() + cmd.getTimeWindow(), cmd.getTimeWindow(), 1, key.getAgg()); + queries.put(query.getId(), new ReadTsKvQueryInfo(key, query, false)); + } + return handleAggCmd(ctx, cmd.getKeys(), queries, cmd.getStartTs(), cmd.getStartTs() + cmd.getTimeWindow(), true); + } + + private ListenableFuture handleAggCmd(TbEntityDataSubCtx ctx, List keys, ConcurrentMap queries, + long startTs, long endTs, boolean subscribe) { + Map>> fetchResultMap = new HashMap<>(); + List entityDataList = ctx.getData().getData(); + List queryList = queries.values().stream().map(ReadTsKvQueryInfo::getQuery).collect(Collectors.toList()); + entityDataList.forEach(entityData -> fetchResultMap.put(entityData, + tsService.findAllByQueries(ctx.getTenantId(), entityData.getEntityId(), queryList))); + return Futures.transform(Futures.allAsList(fetchResultMap.values()), f -> { + // Map that holds last ts for each key for each entity. + Map> lastTsEntityMap = new HashMap<>(); + fetchResultMap.forEach((entityData, future) -> { + try { + Map lastTsMap = new HashMap<>(); + lastTsEntityMap.put(entityData, lastTsMap); + + List queryResults = future.get(); + if (queryResults != null) { + for (ReadTsKvQueryResult queryResult : queryResults) { + ReadTsKvQueryInfo queryInfo = queries.get(queryResult.getQueryId()); + ComparisonTsValue comparisonTsValue = entityData.getAggLatest().computeIfAbsent(queryInfo.getKey().getId(), agg -> new ComparisonTsValue()); + if (queryInfo.isPrevious()) { + comparisonTsValue.setPrevious(queryResult.toTsValue(queryInfo.getQuery())); + } else { + comparisonTsValue.setCurrent(queryResult.toTsValue(queryInfo.getQuery())); + lastTsMap.put(queryInfo.getQuery().getKey(), queryResult.getLastEntryTs()); + } + } + } + // Populate with empty values if no data found. + keys.forEach(key -> { + entityData.getAggLatest().putIfAbsent(key.getId(), new ComparisonTsValue(TsValue.EMPTY, TsValue.EMPTY)); + }); + } catch (InterruptedException | ExecutionException e) { + log.warn("[{}][{}][{}] Failed to fetch historical data", ctx.getSessionId(), ctx.getCmdId(), entityData.getEntityId(), e); + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), "Failed to fetch historical data!")); + } + }); + ctx.getWsLock().lock(); + try { + EntityDataUpdate update; + if (!ctx.isInitialDataSent()) { + update = new EntityDataUpdate(ctx.getCmdId(), ctx.getData(), null, ctx.getMaxEntitiesPerDataSubscription()); + ctx.setInitialDataSent(true); + } else { + update = new EntityDataUpdate(ctx.getCmdId(), null, entityDataList, ctx.getMaxEntitiesPerDataSubscription()); + } + if (subscribe) { + ctx.createTimeSeriesSubscriptions(lastTsEntityMap, startTs, endTs, true); + } + ctx.sendWsMsg(update); + entityDataList.forEach(EntityData::clearTsAndAggData); + } finally { + ctx.getWsLock().unlock(); + } + return ctx; + }, wsCallBackExecutor); + } + + private void handleWsCmdRuntimeException(String sessionId, RuntimeException e, EntityDataCmd cmd) { + log.debug("[{}] Failed to process ws cmd: {}", sessionId, cmd, e); + wsService.close(sessionId, CloseStatus.SERVICE_RESTARTED); + } + + @Override + public void handleCmd(TelemetryWebSocketSessionRef session, EntityCountCmd cmd) { + TbEntityCountSubCtx ctx = getSubCtx(session.getSessionId(), cmd.getCmdId()); + if (ctx == null) { + ctx = createSubCtx(session, cmd); + long start = System.currentTimeMillis(); + ctx.fetchData(); + long end = System.currentTimeMillis(); + stats.getRegularQueryInvocationCnt().incrementAndGet(); + stats.getRegularQueryTimeSpent().addAndGet(end - start); + TbEntityCountSubCtx finalCtx = ctx; + ScheduledFuture task = scheduler.scheduleWithFixedDelay( + () -> refreshDynamicQuery(finalCtx), + dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS); + finalCtx.setRefreshTask(task); + } else { + log.debug("[{}][{}] Received duplicate command: {}", session.getSessionId(), cmd.getCmdId(), cmd); + } + } + + @Override + public void handleCmd(TelemetryWebSocketSessionRef session, AlarmDataCmd cmd) { + TbAlarmDataSubCtx ctx = getSubCtx(session.getSessionId(), cmd.getCmdId()); + if (ctx == null) { + log.debug("[{}][{}] Creating new alarm subscription using: {}", session.getSessionId(), cmd.getCmdId(), cmd); + ctx = createSubCtx(session, cmd); + } + ctx.setAndResolveQuery(cmd.getQuery()); + AlarmDataQuery adq = ctx.getQuery(); + long start = System.currentTimeMillis(); + ctx.fetchData(); + long end = System.currentTimeMillis(); + stats.getRegularQueryInvocationCnt().incrementAndGet(); + stats.getRegularQueryTimeSpent().addAndGet(end - start); + List entities = ctx.getEntitiesData(); + ctx.cancelTasks(); + ctx.clearEntitySubscriptions(); + if (entities.isEmpty()) { + AlarmDataUpdate update = new AlarmDataUpdate(cmd.getCmdId(), new PageData<>(), null, 0, 0); + ctx.sendWsMsg(update); + } else { + ctx.fetchAlarms(); + ctx.createLatestValuesSubscriptions(cmd.getQuery().getLatestValues()); + if (adq.getPageLink().getTimeWindow() > 0) { + TbAlarmDataSubCtx finalCtx = ctx; + ScheduledFuture task = scheduler.scheduleWithFixedDelay( + () -> refreshAlarmQuery(finalCtx), dynamicPageLinkRefreshInterval, dynamicPageLinkRefreshInterval, TimeUnit.SECONDS); + finalCtx.setRefreshTask(task); + } + } + } + + private boolean validate(TbAbstractSubCtx finalCtx) { + if (finalCtx.isStopped()) { + log.warn("[{}][{}][{}] Received validation task for already stopped context.", finalCtx.getTenantId(), finalCtx.getSessionId(), finalCtx.getCmdId()); + return false; + } + var cmdMap = subscriptionsBySessionId.get(finalCtx.getSessionId()); + if (cmdMap == null) { + log.warn("[{}][{}][{}] Received validation task for already removed session.", finalCtx.getTenantId(), finalCtx.getSessionId(), finalCtx.getCmdId()); + return false; + } else if (!cmdMap.containsKey(finalCtx.getCmdId())) { + log.warn("[{}][{}][{}] Received validation task for unregistered cmdId.", finalCtx.getTenantId(), finalCtx.getSessionId(), finalCtx.getCmdId()); + return false; + } + return true; + } + + private void refreshDynamicQuery(TbAbstractSubCtx finalCtx) { + try { + if (validate(finalCtx)) { + long start = System.currentTimeMillis(); + finalCtx.update(); + long end = System.currentTimeMillis(); + log.trace("[{}][{}] Executing query: {}", finalCtx.getSessionId(), finalCtx.getCmdId(), finalCtx.getQuery()); + stats.getDynamicQueryInvocationCnt().incrementAndGet(); + stats.getDynamicQueryTimeSpent().addAndGet(end - start); + } else { + finalCtx.stop(); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to refresh query", finalCtx.getSessionId(), finalCtx.getCmdId(), e); + } + } + + private void refreshAlarmQuery(TbAlarmDataSubCtx finalCtx) { + if (validate(finalCtx)) { + finalCtx.checkAndResetInvocationCounter(); + } else { + finalCtx.stop(); + } + } + + @Scheduled(fixedDelayString = "${server.ws.dynamic_page_link.stats:10000}") + public void printStats() { + int alarmQueryInvocationCntValue = stats.getAlarmQueryInvocationCnt().getAndSet(0); + long alarmQueryInvocationTimeValue = stats.getAlarmQueryTimeSpent().getAndSet(0); + int regularQueryInvocationCntValue = stats.getRegularQueryInvocationCnt().getAndSet(0); + long regularQueryInvocationTimeValue = stats.getRegularQueryTimeSpent().getAndSet(0); + int dynamicQueryInvocationCntValue = stats.getDynamicQueryInvocationCnt().getAndSet(0); + long dynamicQueryInvocationTimeValue = stats.getDynamicQueryTimeSpent().getAndSet(0); + long dynamicQueryCnt = subscriptionsBySessionId.values().stream().mapToLong(m -> m.values().stream().filter(TbAbstractSubCtx::isDynamic).count()).sum(); + if (regularQueryInvocationCntValue > 0 || dynamicQueryInvocationCntValue > 0 || dynamicQueryCnt > 0 || alarmQueryInvocationCntValue > 0) { + log.info("Stats: regularQueryInvocationCnt = [{}], regularQueryInvocationTime = [{}], " + + "dynamicQueryCnt = [{}] dynamicQueryInvocationCnt = [{}], dynamicQueryInvocationTime = [{}], " + + "alarmQueryInvocationCnt = [{}], alarmQueryInvocationTime = [{}]", + regularQueryInvocationCntValue, regularQueryInvocationTimeValue, + dynamicQueryCnt, dynamicQueryInvocationCntValue, dynamicQueryInvocationTimeValue, + alarmQueryInvocationCntValue, alarmQueryInvocationTimeValue); + } + } + + private TbEntityDataSubCtx createSubCtx(TelemetryWebSocketSessionRef sessionRef, EntityDataCmd cmd) { + Map sessionSubs = subscriptionsBySessionId.computeIfAbsent(sessionRef.getSessionId(), k -> new HashMap<>()); + TbEntityDataSubCtx ctx = new TbEntityDataSubCtx(serviceId, wsService, entityService, localSubscriptionService, + attributesService, stats, sessionRef, cmd.getCmdId(), maxEntitiesPerDataSubscription); + if (cmd.getQuery() != null) { + ctx.setAndResolveQuery(cmd.getQuery()); + } + sessionSubs.put(cmd.getCmdId(), ctx); + return ctx; + } + + private TbEntityCountSubCtx createSubCtx(TelemetryWebSocketSessionRef sessionRef, EntityCountCmd cmd) { + Map sessionSubs = subscriptionsBySessionId.computeIfAbsent(sessionRef.getSessionId(), k -> new HashMap<>()); + TbEntityCountSubCtx ctx = new TbEntityCountSubCtx(serviceId, wsService, entityService, localSubscriptionService, + attributesService, stats, sessionRef, cmd.getCmdId()); + if (cmd.getQuery() != null) { + ctx.setAndResolveQuery(cmd.getQuery()); + } + sessionSubs.put(cmd.getCmdId(), ctx); + return ctx; + } + + + private TbAlarmDataSubCtx createSubCtx(TelemetryWebSocketSessionRef sessionRef, AlarmDataCmd cmd) { + Map sessionSubs = subscriptionsBySessionId.computeIfAbsent(sessionRef.getSessionId(), k -> new HashMap<>()); + TbAlarmDataSubCtx ctx = new TbAlarmDataSubCtx(serviceId, wsService, entityService, localSubscriptionService, + attributesService, stats, alarmService, sessionRef, cmd.getCmdId(), maxEntitiesPerAlarmSubscription, + maxAlarmQueriesPerRefreshInterval); + ctx.setAndResolveQuery(cmd.getQuery()); + sessionSubs.put(cmd.getCmdId(), ctx); + return ctx; + } + + @SuppressWarnings("unchecked") + private T getSubCtx(String sessionId, int cmdId) { + Map sessionSubs = subscriptionsBySessionId.get(sessionId); + if (sessionSubs != null) { + return (T) sessionSubs.get(cmdId); + } else { + return null; + } + } + + private ListenableFuture handleTimeSeriesCmd(TbEntityDataSubCtx ctx, TimeSeriesCmd cmd) { + log.debug("[{}][{}] Fetching time-series data for last {} ms for keys: ({})", ctx.getSessionId(), ctx.getCmdId(), cmd.getTimeWindow(), cmd.getKeys()); + return handleGetTsCmd(ctx, cmd, true); + } + + + private ListenableFuture handleHistoryCmd(TbEntityDataSubCtx ctx, EntityHistoryCmd cmd) { + log.debug("[{}][{}] Fetching history data for start {} and end {} ms for keys: ({})", ctx.getSessionId(), ctx.getCmdId(), cmd.getStartTs(), cmd.getEndTs(), cmd.getKeys()); + return handleGetTsCmd(ctx, cmd, false); + } + + private ListenableFuture handleGetTsCmd(TbEntityDataSubCtx ctx, GetTsCmd cmd, boolean subscribe) { + Map queriesKeys = new ConcurrentHashMap<>(); + + List keys = cmd.getKeys(); + List finalTsKvQueryList; + List tsKvQueryList = keys.stream().map(key -> { + var query = new BaseReadTsKvQuery( + key, cmd.getStartTs(), cmd.getEndTs(), cmd.getInterval(), getLimit(cmd.getLimit()), cmd.getAgg() + ); + queriesKeys.put(query.getId(), query.getKey()); + return query; + }).collect(Collectors.toList()); + if (cmd.isFetchLatestPreviousPoint()) { + finalTsKvQueryList = new ArrayList<>(tsKvQueryList); + finalTsKvQueryList.addAll(keys.stream().map(key -> { + var query = new BaseReadTsKvQuery( + key, cmd.getStartTs() - TimeUnit.DAYS.toMillis(365), cmd.getStartTs(), cmd.getInterval(), 1, cmd.getAgg()); + queriesKeys.put(query.getId(), query.getKey()); + return query; + } + ).collect(Collectors.toList())); + } else { + finalTsKvQueryList = tsKvQueryList; + } + Map>> fetchResultMap = new HashMap<>(); + List entityDataList = ctx.getData().getData(); + entityDataList.forEach(entityData -> fetchResultMap.put(entityData, + tsService.findAllByQueries(ctx.getTenantId(), entityData.getEntityId(), finalTsKvQueryList))); + return Futures.transform(Futures.allAsList(fetchResultMap.values()), f -> { + // Map that holds last ts for each key for each entity. + Map> lastTsEntityMap = new HashMap<>(); + fetchResultMap.forEach((entityData, future) -> { + try { + Map lastTsMap = new HashMap<>(); + lastTsEntityMap.put(entityData, lastTsMap); + + List queryResults = future.get(); + if (queryResults != null) { + for (ReadTsKvQueryResult queryResult : queryResults) { + String queryKey = queriesKeys.get(queryResult.getQueryId()); + if (queryKey != null) { + entityData.getTimeseries().merge(queryKey, queryResult.toTsValues(), ArrayUtils::addAll); + lastTsMap.merge(queryKey, queryResult.getLastEntryTs(), Math::max); + } else { + log.warn("ReadTsKvQueryResult for {} {} has queryId not matching the initial query", + entityData.getEntityId().getEntityType(), entityData.getEntityId()); + } + } + } + // Populate with empty values if no data found. + keys.forEach(key -> { + if (!entityData.getTimeseries().containsKey(key)) { + entityData.getTimeseries().put(key, new TsValue[0]); + } + }); + + if (cmd.isFetchLatestPreviousPoint()) { + entityData.getTimeseries().values().forEach(dataArray -> Arrays.sort(dataArray, (o1, o2) -> Long.compare(o2.getTs(), o1.getTs()))); + } + } catch (InterruptedException | ExecutionException e) { + log.warn("[{}][{}][{}] Failed to fetch historical data", ctx.getSessionId(), ctx.getCmdId(), entityData.getEntityId(), e); + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), "Failed to fetch historical data!")); + } + }); + ctx.getWsLock().lock(); + try { + EntityDataUpdate update; + if (!ctx.isInitialDataSent()) { + update = new EntityDataUpdate(ctx.getCmdId(), ctx.getData(), null, ctx.getMaxEntitiesPerDataSubscription()); + ctx.setInitialDataSent(true); + } else { + update = new EntityDataUpdate(ctx.getCmdId(), null, entityDataList, ctx.getMaxEntitiesPerDataSubscription()); + } + if (subscribe) { + ctx.createTimeSeriesSubscriptions(lastTsEntityMap, cmd.getStartTs(), cmd.getEndTs()); + } + ctx.sendWsMsg(update); + entityDataList.forEach(EntityData::clearTsAndAggData); + } finally { + ctx.getWsLock().unlock(); + } + return ctx; + }, wsCallBackExecutor); + } + + private void handleLatestCmd(TbEntityDataSubCtx ctx, LatestValueCmd latestCmd) { + log.trace("[{}][{}] Going to process latest command: {}", ctx.getSessionId(), ctx.getCmdId(), latestCmd); + //Fetch the latest values for telemetry keys (in case they are not copied from NoSQL to SQL DB in hybrid mode. + if (!tsInSqlDB) { + log.trace("[{}][{}] Going to fetch missing latest values: {}", ctx.getSessionId(), ctx.getCmdId(), latestCmd); + List allTsKeys = latestCmd.getKeys().stream() + .filter(key -> key.getType().equals(EntityKeyType.TIME_SERIES)) + .map(EntityKey::getKey).collect(Collectors.toList()); + + Map>> missingTelemetryFutures = new HashMap<>(); + for (EntityData entityData : ctx.getData().getData()) { + Map> latestEntityData = entityData.getLatest(); + Map tsEntityData = latestEntityData.get(EntityKeyType.TIME_SERIES); + Set missingTsKeys = new LinkedHashSet<>(allTsKeys); + if (tsEntityData != null) { + missingTsKeys.removeAll(tsEntityData.keySet()); + } else { + tsEntityData = new HashMap<>(); + latestEntityData.put(EntityKeyType.TIME_SERIES, tsEntityData); + } + + ListenableFuture> missingTsData = tsService.findLatest(ctx.getTenantId(), entityData.getEntityId(), missingTsKeys); + missingTelemetryFutures.put(entityData, Futures.transform(missingTsData, this::toTsValue, MoreExecutors.directExecutor())); + } + Futures.addCallback(Futures.allAsList(missingTelemetryFutures.values()), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List> result) { + missingTelemetryFutures.forEach((key, value) -> { + try { + key.getLatest().get(EntityKeyType.TIME_SERIES).putAll(value.get()); + } catch (InterruptedException | ExecutionException e) { + log.warn("[{}][{}] Failed to lookup latest telemetry: {}:{}", ctx.getSessionId(), ctx.getCmdId(), key.getEntityId(), allTsKeys, e); + } + }); + EntityDataUpdate update; + ctx.getWsLock().lock(); + try { + ctx.createLatestValuesSubscriptions(latestCmd.getKeys()); + if (!ctx.isInitialDataSent()) { + update = new EntityDataUpdate(ctx.getCmdId(), ctx.getData(), null, ctx.getMaxEntitiesPerDataSubscription()); + ctx.setInitialDataSent(true); + } else { + update = new EntityDataUpdate(ctx.getCmdId(), null, ctx.getData().getData(), ctx.getMaxEntitiesPerDataSubscription()); + } + ctx.sendWsMsg(update); + } finally { + ctx.getWsLock().unlock(); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to process websocket command: {}:{}", ctx.getSessionId(), ctx.getCmdId(), ctx.getQuery(), latestCmd, t); + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), "Failed to process websocket command!")); + } + }, wsCallBackExecutor); + } else { + ctx.getWsLock().lock(); + try { + ctx.createLatestValuesSubscriptions(latestCmd.getKeys()); + checkAndSendInitialData(ctx); + } finally { + ctx.getWsLock().unlock(); + } + } + } + + private Map toTsValue(List data) { + return data.stream().collect(Collectors.toMap(TsKvEntry::getKey, value -> new TsValue(value.getTs(), value.getValueAsString()))); + } + + @Override + public void cancelSubscription(String sessionId, UnsubscribeCmd cmd) { + cleanupAndCancel(getSubCtx(sessionId, cmd.getCmdId())); + } + + private void cleanupAndCancel(TbAbstractSubCtx ctx) { + if (ctx != null) { + ctx.stop(); + if (ctx.getSessionId() != null) { + Map sessionSubs = subscriptionsBySessionId.get(ctx.getSessionId()); + if (sessionSubs != null) { + sessionSubs.remove(ctx.getCmdId()); + } + } + } + } + + @Override + public void cancelAllSessionSubscriptions(String sessionId) { + Map sessionSubs = subscriptionsBySessionId.remove(sessionId); + if (sessionSubs != null) { + sessionSubs.values().forEach(this::cleanupAndCancel); + } + } + + private int getLimit(int limit) { + return limit == 0 ? DEFAULT_LIMIT : limit; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java new file mode 100644 index 0000000..968e15e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -0,0 +1,212 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; + +@Slf4j +@TbCoreComponent +@Service +public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionService { + + private final Set currentPartitions = ConcurrentHashMap.newKeySet(); + private final Map> subscriptionsBySessionId = new ConcurrentHashMap<>(); + + @Autowired + private PartitionService partitionService; + + @Autowired + private TbClusterService clusterService; + + @Autowired + @Lazy + private SubscriptionManagerService subscriptionManagerService; + + private ExecutorService subscriptionUpdateExecutor; + + private TbApplicationEventListener partitionChangeListener = new TbApplicationEventListener<>() { + @Override + protected void onTbApplicationEvent(PartitionChangeEvent event) { + if (ServiceType.TB_CORE.equals(event.getServiceType())) { + currentPartitions.clear(); + currentPartitions.addAll(event.getPartitions()); + } + } + }; + + private TbApplicationEventListener clusterTopologyChangeListener = new TbApplicationEventListener<>() { + @Override + protected void onTbApplicationEvent(ClusterTopologyChangeEvent event) { + if (event.getQueueKeys().stream().anyMatch(key -> ServiceType.TB_CORE.equals(key.getType()))) { + /* + * If the cluster topology has changed, we need to push all current subscriptions to SubscriptionManagerService again. + * Otherwise, the SubscriptionManagerService may "forget" those subscriptions in case of restart. + * Although this is resource consuming operation, it is cheaper than sending ping/pong commands periodically + * It is also cheaper then caching the subscriptions by entity id and then lookup of those caches every time we have new telemetry in SubscriptionManagerService. + * Even if we cache locally the list of active subscriptions by entity id, it is still time consuming operation to get them from cache + * Since number of subscriptions is usually much less then number of devices that are pushing data. + */ + subscriptionsBySessionId.values().forEach(map -> map.values() + .forEach(sub -> pushSubscriptionToManagerService(sub, true))); + } + } + }; + + @PostConstruct + public void initExecutor() { + subscriptionUpdateExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); + } + + @PreDestroy + public void shutdownExecutor() { + if (subscriptionUpdateExecutor != null) { + subscriptionUpdateExecutor.shutdownNow(); + } + } + + @Override + @EventListener(PartitionChangeEvent.class) + public void onApplicationEvent(PartitionChangeEvent event) { + partitionChangeListener.onApplicationEvent(event); + } + + @Override + @EventListener(ClusterTopologyChangeEvent.class) + public void onApplicationEvent(ClusterTopologyChangeEvent event) { + clusterTopologyChangeListener.onApplicationEvent(event); + } + + //TODO 3.1: replace null callbacks with callbacks from websocket service. + @Override + public void addSubscription(TbSubscription subscription) { + pushSubscriptionToManagerService(subscription, true); + registerSubscription(subscription); + } + + private void pushSubscriptionToManagerService(TbSubscription subscription, boolean pushToLocalService) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, subscription.getTenantId(), subscription.getEntityId()); + if (currentPartitions.contains(tpi)) { + // Subscription is managed on the same server; + if (pushToLocalService) { + subscriptionManagerService.addSubscription(subscription, TbCallback.EMPTY); + } + } else { + // Push to the queue; + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toNewSubscriptionProto(subscription); + clusterService.pushMsgToCore(tpi, subscription.getEntityId().getId(), toCoreMsg, null); + } + } + + @Override + @SuppressWarnings("unchecked") + public void onSubscriptionUpdate(String sessionId, TelemetrySubscriptionUpdate update, TbCallback callback) { + TbSubscription subscription = subscriptionsBySessionId + .getOrDefault(sessionId, Collections.emptyMap()).get(update.getSubscriptionId()); + if (subscription != null) { + switch (subscription.getType()) { + case TIMESERIES: + TbTimeseriesSubscription tsSub = (TbTimeseriesSubscription) subscription; + update.getLatestValues().forEach((key, value) -> tsSub.getKeyStates().put(key, value)); + break; + case ATTRIBUTES: + TbAttributeSubscription attrSub = (TbAttributeSubscription) subscription; + update.getLatestValues().forEach((key, value) -> attrSub.getKeyStates().put(key, value)); + break; + } + subscriptionUpdateExecutor.submit(() -> subscription.getUpdateConsumer().accept(sessionId, update)); + } + callback.onSuccess(); + } + + @Override + @SuppressWarnings("unchecked") + public void onSubscriptionUpdate(String sessionId, AlarmSubscriptionUpdate update, TbCallback callback) { + TbSubscription subscription = subscriptionsBySessionId + .getOrDefault(sessionId, Collections.emptyMap()).get(update.getSubscriptionId()); + if (subscription != null && subscription.getType() == TbSubscriptionType.ALARMS) { + subscriptionUpdateExecutor.submit(() -> subscription.getUpdateConsumer().accept(sessionId, update)); + } + callback.onSuccess(); + } + + @Override + public void cancelSubscription(String sessionId, int subscriptionId) { + log.debug("[{}][{}] Going to remove subscription.", sessionId, subscriptionId); + Map sessionSubscriptions = subscriptionsBySessionId.get(sessionId); + if (sessionSubscriptions != null) { + TbSubscription subscription = sessionSubscriptions.remove(subscriptionId); + if (subscription != null) { + if (sessionSubscriptions.isEmpty()) { + subscriptionsBySessionId.remove(sessionId); + } + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, subscription.getTenantId(), subscription.getEntityId()); + if (currentPartitions.contains(tpi)) { + // Subscription is managed on the same server; + subscriptionManagerService.cancelSubscription(sessionId, subscriptionId, TbCallback.EMPTY); + } else { + // Push to the queue; + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toCloseSubscriptionProto(subscription); + clusterService.pushMsgToCore(tpi, subscription.getEntityId().getId(), toCoreMsg, null); + } + } else { + log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); + } + } else { + log.debug("[{}] No session subscriptions found!", sessionId); + } + } + + @Override + public void cancelAllSessionSubscriptions(String sessionId) { + Map subscriptions = subscriptionsBySessionId.get(sessionId); + if (subscriptions != null) { + Set toRemove = new HashSet<>(subscriptions.keySet()); + toRemove.forEach(id -> cancelSubscription(sessionId, id)); + } + } + + private void registerSubscription(TbSubscription subscription) { + Map sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); + sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/ReadTsKvQueryInfo.java b/application/src/main/java/org/thingsboard/server/service/subscription/ReadTsKvQueryInfo.java new file mode 100644 index 0000000..28ca2f6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/ReadTsKvQueryInfo.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Data; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.service.telemetry.cmd.v2.AggKey; + +@Data +public class ReadTsKvQueryInfo { + + private final AggKey key; + private final ReadTsKvQuery query; + private final boolean previous; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java new file mode 100644 index 0000000..15aab86 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +import java.util.List; + +public interface SubscriptionManagerService extends ApplicationListener { + + void addSubscription(TbSubscription subscription, TbCallback callback); + + void cancelSubscription(String sessionId, int subscriptionId, TbCallback callback); + + void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts, TbCallback callback); + + void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, TbCallback callback); + + void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback); + + void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback empty); + + void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback); + + void onAlarmUpdate(TenantId tenantId, EntityId entityId, Alarm alarm, TbCallback callback); + + void onAlarmDeleted(TenantId tenantId, EntityId entityId, Alarm alarm, TbCallback callback); + + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionServiceStatistics.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionServiceStatistics.java new file mode 100644 index 0000000..5c1bb93 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionServiceStatistics.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Data; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +@Data +public class SubscriptionServiceStatistics { + private AtomicInteger alarmQueryInvocationCnt = new AtomicInteger(); + private AtomicInteger regularQueryInvocationCnt = new AtomicInteger(); + private AtomicInteger dynamicQueryInvocationCnt = new AtomicInteger(); + private AtomicLong alarmQueryTimeSpent = new AtomicLong(); + private AtomicLong regularQueryTimeSpent = new AtomicLong(); + private AtomicLong dynamicQueryTimeSpent = new AtomicLong(); +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java new file mode 100644 index 0000000..271de08 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractDataSubCtx.java @@ -0,0 +1,255 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AbstractDataQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +public abstract class TbAbstractDataSubCtx> extends TbAbstractSubCtx { + + protected final Map subToEntityIdMap; + @Getter + protected PageData data; + + public TbAbstractDataSubCtx(String serviceId, TelemetryWebSocketService wsService, + EntityService entityService, TbLocalSubscriptionService localSubscriptionService, + AttributesService attributesService, SubscriptionServiceStatistics stats, + TelemetryWebSocketSessionRef sessionRef, int cmdId) { + super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId); + this.subToEntityIdMap = new ConcurrentHashMap<>(); + } + + @Override + public void fetchData() { + this.data = findEntityData(); + } + + protected PageData findEntityData() { + PageData result = entityService.findEntityDataByQuery(getTenantId(), getCustomerId(), buildEntityDataQuery()); + if (log.isTraceEnabled()) { + result.getData().forEach(ed -> { + log.trace("[{}][{}] EntityData: {}", getSessionId(), getCmdId(), ed); + }); + } + return result; + } + + @Override + public boolean isDynamic() { + return query != null && query.getPageLink().isDynamic(); + } + + @Override + protected synchronized void update() { + PageData newData = findEntityData(); + Map oldDataMap; + if (data != null && !data.getData().isEmpty()) { + oldDataMap = data.getData().stream().collect(Collectors.toMap(EntityData::getEntityId, Function.identity(), (a, b) -> a)); + } else { + oldDataMap = Collections.emptyMap(); + } + Map newDataMap = newData.getData().stream().collect(Collectors.toMap(EntityData::getEntityId, Function.identity(), (a, b) -> a)); + if (oldDataMap.size() == newDataMap.size() && oldDataMap.keySet().equals(newDataMap.keySet())) { + log.trace("[{}][{}] No updates to entity data found", sessionRef.getSessionId(), cmdId); + } else { + this.data = newData; + doUpdate(newDataMap); + } + } + + protected abstract void doUpdate(Map newDataMap); + + protected abstract EntityDataQuery buildEntityDataQuery(); + + public List getEntitiesData() { + return data.getData(); + } + + @Override + public void clearSubscriptions() { + clearEntitySubscriptions(); + super.clearSubscriptions(); + } + + public void clearEntitySubscriptions() { + if (subToEntityIdMap != null) { + for (Integer subId : subToEntityIdMap.keySet()) { + localSubscriptionService.cancelSubscription(sessionRef.getSessionId(), subId); + } + subToEntityIdMap.clear(); + } + } + + public void createLatestValuesSubscriptions(List keys) { + createSubscriptions(keys, true, 0, 0); + } + + public void createTimeSeriesSubscriptions(Map> entityKeyStates, long startTs, long endTs) { + createTimeSeriesSubscriptions(entityKeyStates, startTs, endTs, false); + } + + public void createTimeSeriesSubscriptions(Map> entityKeyStates, long startTs, long endTs, boolean resultToLatestValues) { + entityKeyStates.forEach((entityData, keyStates) -> { + int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet(); + subToEntityIdMap.put(subIdx, entityData.getEntityId()); + localSubscriptionService.addSubscription( + createTsSub(entityData, subIdx, false, startTs, endTs, keyStates, resultToLatestValues)); + }); + } + + private void createSubscriptions(List keys, boolean latestValues, long startTs, long endTs) { + Map> keysByType = getEntityKeyByTypeMap(keys); + for (EntityData entityData : data.getData()) { + List entitySubscriptions = addSubscriptions(entityData, keysByType, latestValues, startTs, endTs); + entitySubscriptions.forEach(localSubscriptionService::addSubscription); + } + } + + protected Map> getEntityKeyByTypeMap(List keys) { + Map> keysByType = new HashMap<>(); + keys.forEach(key -> keysByType.computeIfAbsent(key.getType(), k -> new ArrayList<>()).add(key)); + return keysByType; + } + + protected List addSubscriptions(EntityData entityData, Map> keysByType, boolean latestValues, long startTs, long endTs) { + List subscriptionList = new ArrayList<>(); + keysByType.forEach((keysType, keysList) -> { + int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet(); + subToEntityIdMap.put(subIdx, entityData.getEntityId()); + switch (keysType) { + case TIME_SERIES: + subscriptionList.add(createTsSub(entityData, subIdx, keysList, latestValues, startTs, endTs)); + break; + case CLIENT_ATTRIBUTE: + subscriptionList.add(createAttrSub(entityData, subIdx, keysType, TbAttributeSubscriptionScope.CLIENT_SCOPE, keysList)); + break; + case SHARED_ATTRIBUTE: + subscriptionList.add(createAttrSub(entityData, subIdx, keysType, TbAttributeSubscriptionScope.SHARED_SCOPE, keysList)); + break; + case SERVER_ATTRIBUTE: + subscriptionList.add(createAttrSub(entityData, subIdx, keysType, TbAttributeSubscriptionScope.SERVER_SCOPE, keysList)); + break; + case ATTRIBUTE: + subscriptionList.add(createAttrSub(entityData, subIdx, keysType, TbAttributeSubscriptionScope.ANY_SCOPE, keysList)); + break; + } + }); + return subscriptionList; + } + + private TbSubscription createAttrSub(EntityData entityData, int subIdx, EntityKeyType keysType, TbAttributeSubscriptionScope scope, List subKeys) { + Map keyStates = buildKeyStats(entityData, keysType, subKeys, true); + log.trace("[{}][{}][{}] Creating attributes subscription for [{}] with keys: {}", serviceId, cmdId, subIdx, entityData.getEntityId(), keyStates); + return TbAttributeSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionRef.getSessionId()) + .subscriptionId(subIdx) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityData.getEntityId()) + .updateConsumer((s, subscriptionUpdate) -> sendWsMsg(s, subscriptionUpdate, keysType)) + .allKeys(false) + .keyStates(keyStates) + .scope(scope) + .build(); + } + + private TbSubscription createTsSub(EntityData entityData, int subIdx, List subKeys, boolean latestValues, long startTs, long endTs) { + Map keyStates = buildKeyStats(entityData, EntityKeyType.TIME_SERIES, subKeys, latestValues); + if (!latestValues && entityData.getTimeseries() != null) { + entityData.getTimeseries().forEach((k, v) -> { + long ts = Arrays.stream(v).map(TsValue::getTs).max(Long::compareTo).orElse(0L); + log.trace("[{}][{}] Updating key: {} with ts: {}", serviceId, cmdId, k, ts); + if (!Aggregation.NONE.equals(getCurrentAggregation()) && ts < endTs) { + ts = endTs; + } + keyStates.put(k, ts); + }); + } + return createTsSub(entityData, subIdx, latestValues, startTs, endTs, keyStates); + } + + private TbTimeseriesSubscription createTsSub(EntityData entityData, int subIdx, boolean latestValues, long startTs, long endTs, Map keyStates) { + return createTsSub(entityData, subIdx, latestValues, startTs, endTs, keyStates, latestValues); + } + + private TbTimeseriesSubscription createTsSub(EntityData entityData, int subIdx, boolean latestValues, long startTs, long endTs, Map keyStates, boolean resultToLatestValues) { + log.trace("[{}][{}][{}] Creating time-series subscription for [{}] with keys: {}", serviceId, cmdId, subIdx, entityData.getEntityId(), keyStates); + return TbTimeseriesSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionRef.getSessionId()) + .subscriptionId(subIdx) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityData.getEntityId()) + .updateConsumer((sessionId, subscriptionUpdate) -> sendWsMsg(sessionId, subscriptionUpdate, EntityKeyType.TIME_SERIES, resultToLatestValues)) + .allKeys(false) + .keyStates(keyStates) + .latestValues(latestValues) + .startTime(startTs) + .endTime(endTs) + .build(); + } + + private void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType) { + sendWsMsg(sessionId, subscriptionUpdate, keyType, true); + } + + private Map buildKeyStats(EntityData entityData, EntityKeyType keysType, List subKeys, boolean latestValues) { + Map keyStates = new HashMap<>(); + subKeys.forEach(key -> keyStates.put(key.getKey(), 0L)); + if (latestValues && entityData.getLatest() != null) { + Map currentValues = entityData.getLatest().get(keysType); + if (currentValues != null) { + currentValues.forEach((k, v) -> { + if (subKeys.contains(new EntityKey(keysType, k))) { + log.trace("[{}][{}] Updating key: {} with ts: {}", serviceId, cmdId, k, v.getTs()); + keyStates.put(k, v.getTs()); + } + }); + } + } + return keyStates; + } + + abstract void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType, boolean resultToLatestValues); + + protected abstract Aggregation getCurrentAggregation(); +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java new file mode 100644 index 0000000..20e81f0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java @@ -0,0 +1,342 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.DynamicValueSourceType; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.FilterPredicateType; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.SimpleKeyFilterPredicate; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.CmdUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +@Data +public abstract class TbAbstractSubCtx { + + @Getter + protected final Lock wsLock = new ReentrantLock(true); + protected final String serviceId; + protected final SubscriptionServiceStatistics stats; + private final TelemetryWebSocketService wsService; + protected final EntityService entityService; + protected final TbLocalSubscriptionService localSubscriptionService; + protected final AttributesService attributesService; + protected final TelemetryWebSocketSessionRef sessionRef; + protected final int cmdId; + protected final Set subToDynamicValueKeySet; + @Getter + protected final Map> dynamicValues; + @Getter + @Setter + protected T query; + @Setter + protected volatile ScheduledFuture refreshTask; + protected volatile boolean stopped; + + public TbAbstractSubCtx(String serviceId, TelemetryWebSocketService wsService, + EntityService entityService, TbLocalSubscriptionService localSubscriptionService, + AttributesService attributesService, SubscriptionServiceStatistics stats, + TelemetryWebSocketSessionRef sessionRef, int cmdId) { + this.serviceId = serviceId; + this.wsService = wsService; + this.entityService = entityService; + this.localSubscriptionService = localSubscriptionService; + this.attributesService = attributesService; + this.stats = stats; + this.sessionRef = sessionRef; + this.cmdId = cmdId; + this.subToDynamicValueKeySet = ConcurrentHashMap.newKeySet(); + this.dynamicValues = new ConcurrentHashMap<>(); + } + + public void setAndResolveQuery(T query) { + dynamicValues.clear(); + this.query = query; + if (query != null && query.getKeyFilters() != null) { + for (KeyFilter filter : query.getKeyFilters()) { + registerDynamicValues(filter.getPredicate()); + } + } + resolve(getTenantId(), getCustomerId(), getUserId()); + } + + public void resolve(TenantId tenantId, CustomerId customerId, UserId userId) { + List> futures = new ArrayList<>(); + for (DynamicValueKey key : dynamicValues.keySet()) { + switch (key.getSourceType()) { + case CURRENT_TENANT: + futures.add(resolveEntityValue(tenantId, tenantId, key)); + break; + case CURRENT_CUSTOMER: + if (customerId != null && !customerId.isNullUid()) { + futures.add(resolveEntityValue(tenantId, customerId, key)); + } + break; + case CURRENT_USER: + if (userId != null && !userId.isNullUid()) { + futures.add(resolveEntityValue(tenantId, userId, key)); + } + break; + } + } + try { + Map> tmpSubMap = new HashMap<>(); + for (DynamicValueKeySub sub : Futures.successfulAsList(futures).get()) { + tmpSubMap.computeIfAbsent(sub.getEntityId(), tmp -> new HashMap<>()).put(sub.getKey().getSourceAttribute(), sub); + } + for (EntityId entityId : tmpSubMap.keySet()) { + Map keyStates = new HashMap<>(); + Map dynamicValueKeySubMap = tmpSubMap.get(entityId); + dynamicValueKeySubMap.forEach((k, v) -> keyStates.put(k, v.getLastUpdateTs())); + int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet(); + TbAttributeSubscription sub = TbAttributeSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionRef.getSessionId()) + .subscriptionId(subIdx) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .updateConsumer((s, subscriptionUpdate) -> dynamicValueSubUpdate(s, subscriptionUpdate, dynamicValueKeySubMap)) + .allKeys(false) + .keyStates(keyStates) + .scope(TbAttributeSubscriptionScope.SERVER_SCOPE) + .build(); + subToDynamicValueKeySet.add(subIdx); + localSubscriptionService.addSubscription(sub); + } + } catch (InterruptedException | ExecutionException e) { + log.info("[{}][{}][{}] Failed to resolve dynamic values: {}", tenantId, customerId, userId, dynamicValues.keySet()); + } + + } + + private void dynamicValueSubUpdate(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, + Map dynamicValueKeySubMap) { + Map latestUpdate = new HashMap<>(); + subscriptionUpdate.getData().forEach((k, v) -> { + Object[] data = (Object[]) v.get(0); + latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + }); + + boolean invalidateFilter = false; + for (Map.Entry entry : latestUpdate.entrySet()) { + String k = entry.getKey(); + TsValue tsValue = entry.getValue(); + DynamicValueKeySub sub = dynamicValueKeySubMap.get(k); + if (sub.updateValue(tsValue)) { + invalidateFilter = true; + updateDynamicValuesByKey(sub, tsValue); + } + } + + if (invalidateFilter) { + update(); + } + } + + public abstract boolean isDynamic(); + + public abstract void fetchData(); + + protected abstract void update(); + + public void clearSubscriptions() { + clearDynamicValueSubscriptions(); + } + + public void stop() { + stopped = true; + cancelTasks(); + clearSubscriptions(); + } + + @Data + private static class DynamicValueKeySub { + private final DynamicValueKey key; + private final EntityId entityId; + private long lastUpdateTs; + private String lastUpdateValue; + + boolean updateValue(TsValue value) { + if (value.getTs() > lastUpdateTs && (lastUpdateValue == null || !lastUpdateValue.equals(value.getValue()))) { + this.lastUpdateTs = value.getTs(); + this.lastUpdateValue = value.getValue(); + return true; + } else { + return false; + } + } + } + + private ListenableFuture resolveEntityValue(TenantId tenantId, EntityId entityId, DynamicValueKey key) { + ListenableFuture> entry = attributesService.find(tenantId, entityId, + TbAttributeSubscriptionScope.SERVER_SCOPE.name(), key.getSourceAttribute()); + return Futures.transform(entry, attributeOpt -> { + DynamicValueKeySub sub = new DynamicValueKeySub(key, entityId); + if (attributeOpt.isPresent()) { + AttributeKvEntry attribute = attributeOpt.get(); + sub.setLastUpdateTs(attribute.getLastUpdateTs()); + sub.setLastUpdateValue(attribute.getValueAsString()); + updateDynamicValuesByKey(sub, new TsValue(attribute.getLastUpdateTs(), attribute.getValueAsString())); + } + return sub; + }, MoreExecutors.directExecutor()); + } + + @SuppressWarnings("unchecked") + protected void updateDynamicValuesByKey(DynamicValueKeySub sub, TsValue tsValue) { + DynamicValueKey dvk = sub.getKey(); + switch (dvk.getPredicateType()) { + case STRING: + dynamicValues.get(dvk).forEach(dynamicValue -> dynamicValue.setResolvedValue(tsValue.getValue())); + break; + case NUMERIC: + try { + Double dValue = Double.parseDouble(tsValue.getValue()); + dynamicValues.get(dvk).forEach(dynamicValue -> dynamicValue.setResolvedValue(dValue)); + } catch (NumberFormatException e) { + dynamicValues.get(dvk).forEach(dynamicValue -> dynamicValue.setResolvedValue(null)); + } + break; + case BOOLEAN: + Boolean bValue = Boolean.parseBoolean(tsValue.getValue()); + dynamicValues.get(dvk).forEach(dynamicValue -> dynamicValue.setResolvedValue(bValue)); + break; + } + } + + @SuppressWarnings("unchecked") + private void registerDynamicValues(KeyFilterPredicate predicate) { + switch (predicate.getType()) { + case STRING: + case NUMERIC: + case BOOLEAN: + Optional value = getDynamicValueFromSimplePredicate((SimpleKeyFilterPredicate) predicate); + if (value.isPresent()) { + DynamicValue dynamicValue = value.get(); + DynamicValueKey key = new DynamicValueKey( + predicate.getType(), + dynamicValue.getSourceType(), + dynamicValue.getSourceAttribute()); + dynamicValues.computeIfAbsent(key, tmp -> new ArrayList<>()).add(dynamicValue); + } + break; + case COMPLEX: + ((ComplexFilterPredicate) predicate).getPredicates().forEach(this::registerDynamicValues); + } + } + + private Optional> getDynamicValueFromSimplePredicate(SimpleKeyFilterPredicate predicate) { + if (predicate.getValue().getUserValue() == null) { + return Optional.ofNullable(predicate.getValue().getDynamicValue()); + } else { + return Optional.empty(); + } + } + + public String getSessionId() { + return sessionRef.getSessionId(); + } + + public TenantId getTenantId() { + return sessionRef.getSecurityCtx().getTenantId(); + } + + public CustomerId getCustomerId() { + return sessionRef.getSecurityCtx().getCustomerId(); + } + + public UserId getUserId() { + return sessionRef.getSecurityCtx().getId(); + } + + protected void clearDynamicValueSubscriptions() { + if (subToDynamicValueKeySet != null) { + for (Integer subId : subToDynamicValueKeySet) { + localSubscriptionService.cancelSubscription(sessionRef.getSessionId(), subId); + } + subToDynamicValueKeySet.clear(); + } + } + + public void setRefreshTask(ScheduledFuture task) { + if (!stopped) { + this.refreshTask = task; + } else { + task.cancel(true); + } + } + + public void cancelTasks() { + if (this.refreshTask != null) { + log.trace("[{}][{}] Canceling old refresh task", sessionRef.getSessionId(), cmdId); + this.refreshTask.cancel(true); + } + } + + @Data + public static class DynamicValueKey { + @Getter + private final FilterPredicateType predicateType; + @Getter + private final DynamicValueSourceType sourceType; + @Getter + private final String sourceAttribute; + } + + public void sendWsMsg(CmdUpdate update) { + wsLock.lock(); + try { + wsService.sendWsMsg(sessionRef.getSessionId(), update); + } finally { + wsLock.unlock(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java new file mode 100644 index 0000000..30b58d9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java @@ -0,0 +1,351 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataPageLink; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataUpdate; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@ToString(callSuper = true) +public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx { + + private final AlarmService alarmService; + @Getter + @Setter + private final LinkedHashMap entitiesMap; + @Getter + @Setter + private final HashMap alarmsMap; + + private final int maxEntitiesPerAlarmSubscription; + + private final int maxAlarmQueriesPerRefreshInterval; + + @Getter + @Setter + private PageData alarms; + @Getter + @Setter + private boolean tooManyEntities; + + private int alarmInvocationAttempts; + + public TbAlarmDataSubCtx(String serviceId, TelemetryWebSocketService wsService, + EntityService entityService, TbLocalSubscriptionService localSubscriptionService, + AttributesService attributesService, SubscriptionServiceStatistics stats, AlarmService alarmService, + TelemetryWebSocketSessionRef sessionRef, int cmdId, + int maxEntitiesPerAlarmSubscription, int maxAlarmQueriesPerRefreshInterval) { + super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId); + this.maxEntitiesPerAlarmSubscription = maxEntitiesPerAlarmSubscription; + this.maxAlarmQueriesPerRefreshInterval = maxAlarmQueriesPerRefreshInterval; + this.alarmService = alarmService; + this.entitiesMap = new LinkedHashMap<>(); + this.alarmsMap = new HashMap<>(); + } + + public void fetchAlarms() { + alarmInvocationAttempts++; + log.trace("[{}] Fetching alarms: {}", cmdId, alarmInvocationAttempts); + if (alarmInvocationAttempts <= maxAlarmQueriesPerRefreshInterval) { + doFetchAlarms(); + } else { + log.trace("[{}] Ignore alarm fetch due to rate limit: [{}] of maximum [{}]", cmdId, alarmInvocationAttempts, maxAlarmQueriesPerRefreshInterval); + } + } + + private void doFetchAlarms() { + AlarmDataUpdate update; + if (!entitiesMap.isEmpty()) { + long start = System.currentTimeMillis(); + PageData alarms = alarmService.findAlarmDataByQueryForEntities(getTenantId(), query, getOrderedEntityIds()); + long end = System.currentTimeMillis(); + stats.getAlarmQueryInvocationCnt().incrementAndGet(); + stats.getAlarmQueryTimeSpent().addAndGet(end - start); + alarms = setAndMergeAlarmsData(alarms); + update = new AlarmDataUpdate(cmdId, alarms, null, maxEntitiesPerAlarmSubscription, data.getTotalElements()); + } else { + update = new AlarmDataUpdate(cmdId, new PageData<>(), null, maxEntitiesPerAlarmSubscription, data.getTotalElements()); + } + sendWsMsg(update); + } + + public void fetchData() { + resetInvocationCounter(); + log.trace("[{}] Fetching data: {}", cmdId, alarmInvocationAttempts); + super.fetchData(); + entitiesMap.clear(); + tooManyEntities = data.hasNext(); + for (EntityData entityData : data.getData()) { + entitiesMap.put(entityData.getEntityId(), entityData); + } + } + + public Collection getOrderedEntityIds() { + return entitiesMap.keySet(); + } + + public PageData setAndMergeAlarmsData(PageData alarms) { + this.alarms = alarms; + for (AlarmData alarmData : alarms.getData()) { + EntityId entityId = alarmData.getEntityId(); + if (entityId != null) { + EntityData entityData = entitiesMap.get(entityId); + if (entityData != null) { + alarmData.getLatest().putAll(entityData.getLatest()); + } + } + } + alarmsMap.clear(); + alarmsMap.putAll(alarms.getData().stream().collect(Collectors.toMap(AlarmData::getId, Function.identity(), (a, b) -> a))); + return this.alarms; + } + + @Override + public void createLatestValuesSubscriptions(List keys) { + super.createLatestValuesSubscriptions(keys); + createAlarmSubscriptions(); + } + + public void createAlarmSubscriptions() { + AlarmDataPageLink pageLink = query.getPageLink(); + long startTs = System.currentTimeMillis() - pageLink.getTimeWindow(); + for (EntityData entityData : entitiesMap.values()) { + createAlarmSubscriptionForEntity(pageLink, startTs, entityData); + } + } + + private void createAlarmSubscriptionForEntity(AlarmDataPageLink pageLink, long startTs, EntityData entityData) { + int subIdx = sessionRef.getSessionSubIdSeq().incrementAndGet(); + subToEntityIdMap.put(subIdx, entityData.getEntityId()); + log.trace("[{}][{}][{}] Creating alarms subscription for [{}] with query: {}", serviceId, cmdId, subIdx, entityData.getEntityId(), pageLink); + TbAlarmsSubscription subscription = TbAlarmsSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionRef.getSessionId()) + .subscriptionId(subIdx) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityData.getEntityId()) + .updateConsumer(this::sendWsMsg) + .ts(startTs) + .build(); + localSubscriptionService.addSubscription(subscription); + } + + @Override + void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType, boolean resultToLatestValues) { + EntityId entityId = subToEntityIdMap.get(subscriptionUpdate.getSubscriptionId()); + if (entityId != null) { + Map latestUpdate = new HashMap<>(); + subscriptionUpdate.getData().forEach((k, v) -> { + Object[] data = (Object[]) v.get(0); + latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + }); + EntityData entityData = entitiesMap.get(entityId); + entityData.getLatest().computeIfAbsent(keyType, tmp -> new HashMap<>()).putAll(latestUpdate); + log.trace("[{}][{}][{}][{}] Received subscription update: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), keyType, subscriptionUpdate); + List update = alarmsMap.values().stream().filter(alarm -> entityId.equals(alarm.getEntityId())).map(alarm -> { + alarm.getLatest().computeIfAbsent(keyType, tmp -> new HashMap<>()).putAll(latestUpdate); + return alarm; + }).collect(Collectors.toList()); + if (!update.isEmpty()) { + sendWsMsg(new AlarmDataUpdate(cmdId, null, update, maxEntitiesPerAlarmSubscription, data.getTotalElements())); + } + } else { + log.trace("[{}][{}][{}][{}] Received stale subscription update: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), keyType, subscriptionUpdate); + } + } + + @Override + protected Aggregation getCurrentAggregation() { + return Aggregation.NONE; + } + + private void sendWsMsg(String sessionId, AlarmSubscriptionUpdate subscriptionUpdate) { + Alarm alarm = subscriptionUpdate.getAlarm(); + AlarmId alarmId = alarm.getId(); + if (subscriptionUpdate.isAlarmDeleted()) { + Alarm deleted = alarmsMap.remove(alarmId); + if (deleted != null) { + fetchAlarms(); + } + } else { + AlarmData current = alarmsMap.get(alarmId); + boolean onCurrentPage = current != null; + boolean matchesFilter = filter(alarm); + if (onCurrentPage) { + if (matchesFilter) { + AlarmData updated = new AlarmData(alarm, current.getOriginatorName(), current.getEntityId()); + updated.getLatest().putAll(current.getLatest()); + alarmsMap.put(alarmId, updated); + sendWsMsg(new AlarmDataUpdate(cmdId, null, Collections.singletonList(updated), maxEntitiesPerAlarmSubscription, data.getTotalElements())); + } else { + fetchAlarms(); + } + } else if (matchesFilter && query.getPageLink().getPage() == 0) { + fetchAlarms(); + } + } + } + + public void cleanupOldAlarms() { + long expTime = System.currentTimeMillis() - query.getPageLink().getTimeWindow(); + boolean shouldRefresh = false; + for (AlarmData alarmData : alarms.getData()) { + if (alarmData.getCreatedTime() < expTime) { + shouldRefresh = true; + break; + } + } + if (shouldRefresh) { + doFetchAlarms(); + } + } + + private boolean filter(Alarm alarm) { + AlarmDataPageLink filter = query.getPageLink(); + long startTs = System.currentTimeMillis() - filter.getTimeWindow(); + if (alarm.getCreatedTime() < startTs) { + //Skip update that does not match time window. + return false; + } + if (filter.getTypeList() != null && !filter.getTypeList().isEmpty() && !filter.getTypeList().contains(alarm.getType())) { + return false; + } + if (filter.getSeverityList() != null && !filter.getSeverityList().isEmpty()) { + if (!filter.getSeverityList().contains(alarm.getSeverity())) { + return false; + } + } + if (filter.getStatusList() != null && !filter.getStatusList().isEmpty()) { + boolean matches = false; + for (AlarmSearchStatus status : filter.getStatusList()) { + if (status.getStatuses().contains(alarm.getStatus())) { + matches = true; + break; + } + } + if (!matches) { + return false; + } + } + return true; + } + + public synchronized void checkAndResetInvocationCounter() { + boolean fetchNeeded = this.alarmInvocationAttempts > maxAlarmQueriesPerRefreshInterval; + resetInvocationCounter(); + if (fetchNeeded) { + fetchAlarms(); + } else { + cleanupOldAlarms(); + } + } + + @Override + protected synchronized void doUpdate(Map newDataMap) { + resetInvocationCounter(); + entitiesMap.clear(); + tooManyEntities = data.hasNext(); + for (EntityData entityData : data.getData()) { + entitiesMap.put(entityData.getEntityId(), entityData); + } + fetchAlarms(); + List subIdsToCancel = new ArrayList<>(); + List subsToAdd = new ArrayList<>(); + Set currentSubs = new HashSet<>(); + subToEntityIdMap.forEach((subId, entityId) -> { + if (!newDataMap.containsKey(entityId)) { + subIdsToCancel.add(subId); + } else { + currentSubs.add(entityId); + } + }); + log.trace("[{}][{}] Subscriptions that are invalid: {}", sessionRef.getSessionId(), cmdId, subIdsToCancel); + subIdsToCancel.forEach(subToEntityIdMap::remove); + List newSubsList = newDataMap.entrySet().stream().filter(entry -> !currentSubs.contains(entry.getKey())).map(Map.Entry::getValue).collect(Collectors.toList()); + if (!newSubsList.isEmpty()) { + List keys = query.getLatestValues(); + if (keys != null && !keys.isEmpty()) { + Map> keysByType = getEntityKeyByTypeMap(keys); + newSubsList.forEach( + entity -> { + log.trace("[{}][{}] Found new subscription for entity: {}", sessionRef.getSessionId(), cmdId, entity.getEntityId()); + subsToAdd.addAll(addSubscriptions(entity, keysByType, true, 0, 0)); + } + ); + } + long startTs = System.currentTimeMillis() - query.getPageLink().getTimeWindow(); + newSubsList.forEach(entity -> createAlarmSubscriptionForEntity(query.getPageLink(), startTs, entity)); + } + subIdsToCancel.forEach(subId -> localSubscriptionService.cancelSubscription(getSessionId(), subId)); + subsToAdd.forEach(localSubscriptionService::addSubscription); + } + + private void resetInvocationCounter() { + alarmInvocationAttempts = 0; + } + + @Override + protected EntityDataQuery buildEntityDataQuery() { + EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); + EntityDataSortOrder entitiesSortOrder; + if (sortOrder == null || sortOrder.getKey().getType().equals(EntityKeyType.ALARM_FIELD)) { + entitiesSortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)); + } else { + entitiesSortOrder = sortOrder; + } + EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmsSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmsSubscription.java new file mode 100644 index 0000000..fba351f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmsSubscription.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Builder; +import lombok.Getter; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; + +import java.util.List; +import java.util.function.BiConsumer; + +public class TbAlarmsSubscription extends TbSubscription { + + @Getter + private final long ts; + + @Builder + public TbAlarmsSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, + BiConsumer updateConsumer, long ts) { + super(serviceId, sessionId, subscriptionId, tenantId, entityId, TbSubscriptionType.ALARMS, updateConsumer); + this.ts = ts; + } + + @Override + public boolean equals(Object o) { + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscription.java new file mode 100644 index 0000000..ebd3fe5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscription.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Builder; +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.Map; +import java.util.function.BiConsumer; + +public class TbAttributeSubscription extends TbSubscription { + + @Getter private final boolean allKeys; + @Getter private final Map keyStates; + @Getter private final TbAttributeSubscriptionScope scope; + + @Builder + public TbAttributeSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, + BiConsumer updateConsumer, + boolean allKeys, Map keyStates, TbAttributeSubscriptionScope scope) { + super(serviceId, sessionId, subscriptionId, tenantId, entityId, TbSubscriptionType.ATTRIBUTES, updateConsumer); + this.allKeys = allKeys; + this.keyStates = keyStates; + this.scope = scope; + } + + @Override + public boolean equals(Object o) { + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscriptionScope.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscriptionScope.java new file mode 100644 index 0000000..ae23d26 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAttributeSubscriptionScope.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +public enum TbAttributeSubscriptionScope { + + ANY_SCOPE, CLIENT_SCOPE, SHARED_SCOPE, SERVER_SCOPE + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityCountSubCtx.java new file mode 100644 index 0000000..7fc87eb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityCountSubCtx.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountUpdate; + +@Slf4j +public class TbEntityCountSubCtx extends TbAbstractSubCtx { + + private volatile int result; + + public TbEntityCountSubCtx(String serviceId, TelemetryWebSocketService wsService, EntityService entityService, + TbLocalSubscriptionService localSubscriptionService, AttributesService attributesService, + SubscriptionServiceStatistics stats, TelemetryWebSocketSessionRef sessionRef, int cmdId) { + super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId); + } + + @Override + public void fetchData() { + result = (int) entityService.countEntitiesByQuery(getTenantId(), getCustomerId(), query); + sendWsMsg(new EntityCountUpdate(cmdId, result)); + } + + @Override + protected void update() { + int newCount = (int) entityService.countEntitiesByQuery(getTenantId(), getCustomerId(), query); + if (newCount != result) { + result = newCount; + sendWsMsg(new EntityCountUpdate(cmdId, result)); + } + } + + @Override + public boolean isDynamic() { + return true; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java new file mode 100644 index 0000000..016ed19 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtx.java @@ -0,0 +1,242 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entity.EntityService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketService; +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.LatestValueCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.TimeSeriesCmd; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +public class TbEntityDataSubCtx extends TbAbstractDataSubCtx { + + @Getter + @Setter + private volatile boolean initialDataSent; + private TimeSeriesCmd curTsCmd; + private LatestValueCmd latestValueCmd; + @Getter + private final int maxEntitiesPerDataSubscription; + private Map> latestTsEntityData; + + public TbEntityDataSubCtx(String serviceId, TelemetryWebSocketService wsService, EntityService entityService, + TbLocalSubscriptionService localSubscriptionService, AttributesService attributesService, + SubscriptionServiceStatistics stats, TelemetryWebSocketSessionRef sessionRef, int cmdId, int maxEntitiesPerDataSubscription) { + super(serviceId, wsService, entityService, localSubscriptionService, attributesService, stats, sessionRef, cmdId); + this.maxEntitiesPerDataSubscription = maxEntitiesPerDataSubscription; + } + + @Override + public void fetchData() { + super.fetchData(); + this.updateLatestTsData(this.data); + } + + @Override + protected void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType, boolean resultToLatestValues) { + EntityId entityId = subToEntityIdMap.get(subscriptionUpdate.getSubscriptionId()); + if (entityId != null) { + log.trace("[{}][{}][{}][{}] Received subscription update: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), keyType, subscriptionUpdate); + if (resultToLatestValues) { + sendLatestWsMsg(entityId, sessionId, subscriptionUpdate, keyType); + } else { + sendTsWsMsg(entityId, sessionId, subscriptionUpdate, keyType); + } + } else { + log.trace("[{}][{}][{}][{}] Received stale subscription update: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), keyType, subscriptionUpdate); + } + } + + @Override + protected Aggregation getCurrentAggregation() { + return (this.curTsCmd == null || this.curTsCmd.getAgg() == null) ? Aggregation.NONE : this.curTsCmd.getAgg(); + } + + private void sendLatestWsMsg(EntityId entityId, String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType) { + Map latestUpdate = new HashMap<>(); + subscriptionUpdate.getData().forEach((k, v) -> { + Object[] data = (Object[]) v.get(0); + latestUpdate.put(k, new TsValue((Long) data[0], (String) data[1])); + }); + EntityData entityData = getDataForEntity(entityId); + if (entityData != null && entityData.getLatest() != null) { + Map latestCtxValues = entityData.getLatest().get(keyType); + log.trace("[{}][{}][{}] Going to compare update with {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), latestCtxValues); + if (latestCtxValues != null) { + latestCtxValues.forEach((k, v) -> { + TsValue update = latestUpdate.get(k); + if (update != null) { + //Ignore notifications about deleted keys + if (!(update.getTs() == 0 && (update.getValue() == null || update.getValue().isEmpty()))) { + if (update.getTs() < v.getTs()) { + log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + latestUpdate.remove(k); + } else if ((update.getTs() == v.getTs() && update.getValue().equals(v.getValue()))) { + log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + latestUpdate.remove(k); + } + } else { + log.trace("[{}][{}][{}] Received deleted notification for: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k); + } + } + }); + //Setting new values + latestUpdate.forEach(latestCtxValues::put); + } + } + if (!latestUpdate.isEmpty()) { + Map> latestMap = Collections.singletonMap(keyType, latestUpdate); + entityData = new EntityData(entityId, latestMap, null); + sendWsMsg(new EntityDataUpdate(cmdId, null, Collections.singletonList(entityData), maxEntitiesPerDataSubscription)); + } + } + + private void sendTsWsMsg(EntityId entityId, String sessionId, TelemetrySubscriptionUpdate subscriptionUpdate, EntityKeyType keyType) { + Map> tsUpdate = new HashMap<>(); + subscriptionUpdate.getData().forEach((k, v) -> { + Object[] data = (Object[]) v.get(0); + tsUpdate.computeIfAbsent(k, key -> new ArrayList<>()).add(new TsValue((Long) data[0], (String) data[1])); + }); + Map latestCtxValues = getLatestTsValuesForEntity(entityId); + log.trace("[{}][{}][{}] Going to compare update with {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), latestCtxValues); + if (latestCtxValues != null) { + latestCtxValues.forEach((k, v) -> { + List updateList = tsUpdate.get(k); + if (updateList != null) { + for (TsValue update : new ArrayList<>(updateList)) { + if (update.getTs() < v.getTs()) { + log.trace("[{}][{}][{}] Removed stale update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + // Looks like this is redundant feature and our UI is ready to merge the updates. + //updateList.remove(update); + } else if ((update.getTs() == v.getTs() && update.getValue().equals(v.getValue()))) { + log.trace("[{}][{}][{}] Removed duplicate update for key: {} and ts: {}", sessionId, cmdId, subscriptionUpdate.getSubscriptionId(), k, update.getTs()); + updateList.remove(update); + } + if (updateList.isEmpty()) { + tsUpdate.remove(k); + } + } + } + }); + //Setting new values + tsUpdate.forEach((k, v) -> { + Optional maxValue = v.stream().max(Comparator.comparingLong(TsValue::getTs)); + maxValue.ifPresent(max -> latestCtxValues.put(k, max)); + }); + } + if (!tsUpdate.isEmpty()) { + Map tsMap = new HashMap<>(); + tsUpdate.forEach((key, tsValue) -> tsMap.put(key, tsValue.toArray(new TsValue[tsValue.size()]))); + EntityData entityData = new EntityData(entityId, null, tsMap); + sendWsMsg(new EntityDataUpdate(cmdId, null, Collections.singletonList(entityData), maxEntitiesPerDataSubscription)); + } + } + + private EntityData getDataForEntity(EntityId entityId) { + return data.getData().stream().filter(item -> item.getEntityId().equals(entityId)).findFirst().orElse(null); + } + + private Map getLatestTsValuesForEntity(EntityId entityId) { + return latestTsEntityData.get(entityId); + } + + private void updateLatestTsData(PageData data) { + latestTsEntityData = new HashMap<>(); + data.getData().stream().forEach(entityData -> { + Map latestTsMap = new HashMap<>(); + latestTsEntityData.put(entityData.getEntityId(), latestTsMap); + if (entityData.getLatest() != null) { + Map latestTsValues = entityData.getLatest().get(EntityKeyType.TIME_SERIES); + if (latestTsValues != null) { + latestTsValues.forEach(latestTsMap::put); + } + } + }); + } + + @Override + public synchronized void doUpdate(Map newDataMap) { + this.updateLatestTsData(this.data); + List subIdsToCancel = new ArrayList<>(); + List subsToAdd = new ArrayList<>(); + Set currentSubs = new HashSet<>(); + subToEntityIdMap.forEach((subId, entityId) -> { + if (!newDataMap.containsKey(entityId)) { + subIdsToCancel.add(subId); + } else { + currentSubs.add(entityId); + } + }); + log.trace("[{}][{}] Subscriptions that are invalid: {}", sessionRef.getSessionId(), cmdId, subIdsToCancel); + subIdsToCancel.forEach(subToEntityIdMap::remove); + List newSubsList = newDataMap.entrySet().stream().filter(entry -> !currentSubs.contains(entry.getKey())).map(Map.Entry::getValue).collect(Collectors.toList()); + if (!newSubsList.isEmpty()) { + // NOTE: We ignore the TS subscriptions for new entities here, because widgets will re-init it's content and will create new subscriptions. + if (curTsCmd == null && latestValueCmd != null) { + List keys = latestValueCmd.getKeys(); + if (keys != null && !keys.isEmpty()) { + Map> keysByType = getEntityKeyByTypeMap(keys); + newSubsList.forEach( + entity -> { + log.trace("[{}][{}] Found new subscription for entity: {}", sessionRef.getSessionId(), cmdId, entity.getEntityId()); + subsToAdd.addAll(addSubscriptions(entity, keysByType, true, 0, 0)); + } + ); + } + } + } + subIdsToCancel.forEach(subId -> localSubscriptionService.cancelSubscription(getSessionId(), subId)); + subsToAdd.forEach(localSubscriptionService::addSubscription); + sendWsMsg(new EntityDataUpdate(cmdId, data, null, maxEntitiesPerDataSubscription)); + } + + public void setCurrentCmd(EntityDataCmd cmd) { + curTsCmd = cmd.getTsCmd(); + latestValueCmd = cmd.getLatestCmd(); + } + + @Override + protected EntityDataQuery buildEntityDataQuery() { + return query; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubscriptionService.java new file mode 100644 index 0000000..784fa7d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbEntityDataSubscriptionService.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import org.thingsboard.server.service.telemetry.TelemetryWebSocketSessionRef; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataUnsubscribeCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.UnsubscribeCmd; + +public interface TbEntityDataSubscriptionService { + + void handleCmd(TelemetryWebSocketSessionRef sessionId, EntityDataCmd cmd); + + void handleCmd(TelemetryWebSocketSessionRef sessionId, EntityCountCmd cmd); + + void handleCmd(TelemetryWebSocketSessionRef sessionId, AlarmDataCmd cmd); + + void cancelSubscription(String sessionId, UnsubscribeCmd subscriptionId); + + void cancelAllSessionSubscriptions(String sessionId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java new file mode 100644 index 0000000..e398111 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbLocalSubscriptionService.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +public interface TbLocalSubscriptionService { + + void addSubscription(TbSubscription subscription); + + void cancelSubscription(String sessionId, int subscriptionId); + + void cancelAllSessionSubscriptions(String sessionId); + + void onSubscriptionUpdate(String sessionId, TelemetrySubscriptionUpdate update, TbCallback callback); + + void onSubscriptionUpdate(String sessionId, AlarmSubscriptionUpdate update, TbCallback callback); + + void onApplicationEvent(PartitionChangeEvent event); + + void onApplicationEvent(ClusterTopologyChangeEvent event); +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java new file mode 100644 index 0000000..95078d8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscription.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.Objects; +import java.util.function.BiConsumer; + +@Data +@AllArgsConstructor +public abstract class TbSubscription { + + private final String serviceId; + private final String sessionId; + private final int subscriptionId; + private final TenantId tenantId; + private final EntityId entityId; + private final TbSubscriptionType type; + private final BiConsumer updateConsumer; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TbSubscription that = (TbSubscription) o; + return subscriptionId == that.subscriptionId && + sessionId.equals(that.sessionId) && + tenantId.equals(that.tenantId) && + entityId.equals(that.entityId) && + type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(sessionId, subscriptionId, tenantId, entityId, type); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionType.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionType.java new file mode 100644 index 0000000..86a98ba --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionType.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +public enum TbSubscriptionType { + TIMESERIES, ATTRIBUTES, ALARMS +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java new file mode 100644 index 0000000..f8359a2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java @@ -0,0 +1,344 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.SubscriptionMgrMsgProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeSubscriptionProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionCloseProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionKetStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbSubscriptionUpdateTsValue; +import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesDeleteProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesSubscriptionProto; +import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesUpdateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; +import org.thingsboard.server.service.telemetry.sub.AlarmSubscriptionUpdate; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +public class TbSubscriptionUtils { + + public static ToCoreMsg toNewSubscriptionProto(TbSubscription subscription) { + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + TbSubscriptionProto subscriptionProto = TbSubscriptionProto.newBuilder() + .setServiceId(subscription.getServiceId()) + .setSessionId(subscription.getSessionId()) + .setSubscriptionId(subscription.getSubscriptionId()) + .setTenantIdMSB(subscription.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(subscription.getTenantId().getId().getLeastSignificantBits()) + .setEntityType(subscription.getEntityId().getEntityType().name()) + .setEntityIdMSB(subscription.getEntityId().getId().getMostSignificantBits()) + .setEntityIdLSB(subscription.getEntityId().getId().getLeastSignificantBits()).build(); + + switch (subscription.getType()) { + case TIMESERIES: + TbTimeseriesSubscription tSub = (TbTimeseriesSubscription) subscription; + TbTimeSeriesSubscriptionProto.Builder tSubProto = TbTimeSeriesSubscriptionProto.newBuilder() + .setSub(subscriptionProto) + .setAllKeys(tSub.isAllKeys()); + tSub.getKeyStates().forEach((key, value) -> tSubProto.addKeyStates( + TbSubscriptionKetStateProto.newBuilder().setKey(key).setTs(value).build())); + tSubProto.setStartTime(tSub.getStartTime()); + tSubProto.setEndTime(tSub.getEndTime()); + tSubProto.setLatestValues(tSub.isLatestValues()); + msgBuilder.setTelemetrySub(tSubProto.build()); + break; + case ATTRIBUTES: + TbAttributeSubscription aSub = (TbAttributeSubscription) subscription; + TbAttributeSubscriptionProto.Builder aSubProto = TbAttributeSubscriptionProto.newBuilder() + .setSub(subscriptionProto) + .setAllKeys(aSub.isAllKeys()) + .setScope(aSub.getScope().name()); + aSub.getKeyStates().forEach((key, value) -> aSubProto.addKeyStates( + TbSubscriptionKetStateProto.newBuilder().setKey(key).setTs(value).build())); + msgBuilder.setAttributeSub(aSubProto.build()); + break; + case ALARMS: + TbAlarmsSubscription alarmSub = (TbAlarmsSubscription) subscription; + TransportProtos.TbAlarmSubscriptionProto.Builder alarmSubProto = TransportProtos.TbAlarmSubscriptionProto.newBuilder() + .setSub(subscriptionProto) + .setTs(alarmSub.getTs()); + msgBuilder.setAlarmSub(alarmSubProto.build()); + break; + } + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static ToCoreMsg toCloseSubscriptionProto(TbSubscription subscription) { + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + TbSubscriptionCloseProto closeProto = TbSubscriptionCloseProto.newBuilder() + .setSessionId(subscription.getSessionId()) + .setSubscriptionId(subscription.getSubscriptionId()).build(); + msgBuilder.setSubClose(closeProto); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static TbSubscription fromProto(TbAttributeSubscriptionProto attributeSub) { + TbSubscriptionProto subProto = attributeSub.getSub(); + TbAttributeSubscription.TbAttributeSubscriptionBuilder builder = TbAttributeSubscription.builder() + .serviceId(subProto.getServiceId()) + .sessionId(subProto.getSessionId()) + .subscriptionId(subProto.getSubscriptionId()) + .entityId(EntityIdFactory.getByTypeAndUuid(subProto.getEntityType(), new UUID(subProto.getEntityIdMSB(), subProto.getEntityIdLSB()))) + .tenantId(TenantId.fromUUID(new UUID(subProto.getTenantIdMSB(), subProto.getTenantIdLSB()))); + + builder.scope(TbAttributeSubscriptionScope.valueOf(attributeSub.getScope())); + builder.allKeys(attributeSub.getAllKeys()); + Map keyStates = new HashMap<>(); + attributeSub.getKeyStatesList().forEach(ksProto -> keyStates.put(ksProto.getKey(), ksProto.getTs())); + builder.keyStates(keyStates); + return builder.build(); + } + + public static TbSubscription fromProto(TbTimeSeriesSubscriptionProto telemetrySub) { + TbSubscriptionProto subProto = telemetrySub.getSub(); + TbTimeseriesSubscription.TbTimeseriesSubscriptionBuilder builder = TbTimeseriesSubscription.builder() + .serviceId(subProto.getServiceId()) + .sessionId(subProto.getSessionId()) + .subscriptionId(subProto.getSubscriptionId()) + .entityId(EntityIdFactory.getByTypeAndUuid(subProto.getEntityType(), new UUID(subProto.getEntityIdMSB(), subProto.getEntityIdLSB()))) + .tenantId(TenantId.fromUUID(new UUID(subProto.getTenantIdMSB(), subProto.getTenantIdLSB()))); + + builder.allKeys(telemetrySub.getAllKeys()); + Map keyStates = new HashMap<>(); + telemetrySub.getKeyStatesList().forEach(ksProto -> keyStates.put(ksProto.getKey(), ksProto.getTs())); + builder.startTime(telemetrySub.getStartTime()); + builder.endTime(telemetrySub.getEndTime()); + builder.latestValues(telemetrySub.getLatestValues()); + builder.keyStates(keyStates); + return builder.build(); + } + + public static TbSubscription fromProto(TransportProtos.TbAlarmSubscriptionProto alarmSub) { + TbSubscriptionProto subProto = alarmSub.getSub(); + TbAlarmsSubscription.TbAlarmsSubscriptionBuilder builder = TbAlarmsSubscription.builder() + .serviceId(subProto.getServiceId()) + .sessionId(subProto.getSessionId()) + .subscriptionId(subProto.getSubscriptionId()) + .entityId(EntityIdFactory.getByTypeAndUuid(subProto.getEntityType(), new UUID(subProto.getEntityIdMSB(), subProto.getEntityIdLSB()))) + .tenantId(TenantId.fromUUID(new UUID(subProto.getTenantIdMSB(), subProto.getTenantIdLSB()))); + builder.ts(alarmSub.getTs()); + return builder.build(); + } + + public static TelemetrySubscriptionUpdate fromProto(TbSubscriptionUpdateProto proto) { + if (proto.getErrorCode() > 0) { + return new TelemetrySubscriptionUpdate(proto.getSubscriptionId(), SubscriptionErrorCode.forCode(proto.getErrorCode()), proto.getErrorMsg()); + } else { + Map> data = new TreeMap<>(); + proto.getDataList().forEach(v -> { + List values = data.computeIfAbsent(v.getKey(), k -> new ArrayList<>()); + for (int i = 0; i < v.getTsValueCount(); i++) { + Object[] value = new Object[2]; + TbSubscriptionUpdateTsValue tsValue = v.getTsValue(i); + value[0] = tsValue.getTs(); + value[1] = tsValue.hasValue() ? tsValue.getValue() : null; + values.add(value); + } + }); + return new TelemetrySubscriptionUpdate(proto.getSubscriptionId(), data); + } + } + + public static AlarmSubscriptionUpdate fromProto(TransportProtos.TbAlarmSubscriptionUpdateProto proto) { + if (proto.getErrorCode() > 0) { + return new AlarmSubscriptionUpdate(proto.getSubscriptionId(), SubscriptionErrorCode.forCode(proto.getErrorCode()), proto.getErrorMsg()); + } else { + Alarm alarm = JacksonUtil.fromString(proto.getAlarm(), Alarm.class); + return new AlarmSubscriptionUpdate(proto.getSubscriptionId(), alarm); + } + } + + + public static ToCoreMsg toTimeseriesUpdateProto(TenantId tenantId, EntityId entityId, List ts) { + TbTimeSeriesUpdateProto.Builder builder = TbTimeSeriesUpdateProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + ts.forEach(v -> builder.addData(toKeyValueProto(v.getTs(), v).build())); + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setTsUpdate(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static ToCoreMsg toTimeseriesDeleteProto(TenantId tenantId, EntityId entityId, List keys) { + TbTimeSeriesDeleteProto.Builder builder = TbTimeSeriesDeleteProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.addAllKeys(keys); + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setTsDelete(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static ToCoreMsg toAttributesUpdateProto(TenantId tenantId, EntityId entityId, String scope, List attributes) { + TbAttributeUpdateProto.Builder builder = TbAttributeUpdateProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setScope(scope); + attributes.forEach(v -> builder.addData(toKeyValueProto(v.getLastUpdateTs(), v).build())); + + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setAttrUpdate(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { + TbAttributeDeleteProto.Builder builder = TbAttributeDeleteProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setScope(scope); + builder.addAllKeys(keys); + builder.setNotifyDevice(notifyDevice); + + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setAttrDelete(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + + private static TsKvProto.Builder toKeyValueProto(long ts, KvEntry attr) { + KeyValueProto.Builder dataBuilder = KeyValueProto.newBuilder(); + dataBuilder.setKey(attr.getKey()); + dataBuilder.setType(KeyValueType.forNumber(attr.getDataType().ordinal())); + switch (attr.getDataType()) { + case BOOLEAN: + attr.getBooleanValue().ifPresent(dataBuilder::setBoolV); + break; + case LONG: + attr.getLongValue().ifPresent(dataBuilder::setLongV); + break; + case DOUBLE: + attr.getDoubleValue().ifPresent(dataBuilder::setDoubleV); + break; + case JSON: + attr.getJsonValue().ifPresent(dataBuilder::setJsonV); + break; + case STRING: + attr.getStrValue().ifPresent(dataBuilder::setStringV); + break; + } + return TsKvProto.newBuilder().setTs(ts).setKv(dataBuilder); + } + + public static EntityId toEntityId(String entityType, long entityIdMSB, long entityIdLSB) { + return EntityIdFactory.getByTypeAndUuid(entityType, new UUID(entityIdMSB, entityIdLSB)); + } + + public static List toTsKvEntityList(List dataList) { + List result = new ArrayList<>(dataList.size()); + dataList.forEach(proto -> result.add(new BasicTsKvEntry(proto.getTs(), getKvEntry(proto.getKv())))); + return result; + } + + public static List toAttributeKvList(List dataList) { + List result = new ArrayList<>(dataList.size()); + dataList.forEach(proto -> result.add(new BaseAttributeKvEntry(getKvEntry(proto.getKv()), proto.getTs()))); + return result; + } + + private static KvEntry getKvEntry(KeyValueProto proto) { + KvEntry entry = null; + DataType type = DataType.values()[proto.getType().getNumber()]; + switch (type) { + case BOOLEAN: + entry = new BooleanDataEntry(proto.getKey(), proto.getBoolV()); + break; + case LONG: + entry = new LongDataEntry(proto.getKey(), proto.getLongV()); + break; + case DOUBLE: + entry = new DoubleDataEntry(proto.getKey(), proto.getDoubleV()); + break; + case STRING: + entry = new StringDataEntry(proto.getKey(), proto.getStringV()); + break; + case JSON: + entry = new JsonDataEntry(proto.getKey(), proto.getJsonV()); + break; + } + return entry; + } + + public static ToCoreMsg toAlarmUpdateProto(TenantId tenantId, EntityId entityId, Alarm alarm) { + TbAlarmUpdateProto.Builder builder = TbAlarmUpdateProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setAlarm(JacksonUtil.toString(alarm)); + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setAlarmUpdate(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } + + public static ToCoreMsg toAlarmDeletedProto(TenantId tenantId, EntityId entityId, Alarm alarm) { + TbAlarmDeleteProto.Builder builder = TbAlarmDeleteProto.newBuilder(); + builder.setEntityType(entityId.getEntityType().name()); + builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); + builder.setEntityIdLSB(entityId.getId().getLeastSignificantBits()); + builder.setTenantIdMSB(tenantId.getId().getMostSignificantBits()); + builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); + builder.setAlarm(JacksonUtil.toString(alarm)); + SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); + msgBuilder.setAlarmDelete(builder); + return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java new file mode 100644 index 0000000..e08f25a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbTimeseriesSubscription.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.subscription; + +import lombok.Builder; +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import java.util.Map; +import java.util.function.BiConsumer; + +public class TbTimeseriesSubscription extends TbSubscription { + + @Getter + private final boolean allKeys; + @Getter + private final Map keyStates; + @Getter + private final long startTime; + @Getter + private final long endTime; + @Getter + private final boolean latestValues; + + @Builder + public TbTimeseriesSubscription(String serviceId, String sessionId, int subscriptionId, TenantId tenantId, EntityId entityId, + BiConsumer updateConsumer, + boolean allKeys, Map keyStates, long startTime, long endTime, boolean latestValues) { + super(serviceId, sessionId, subscriptionId, tenantId, entityId, TbSubscriptionType.TIMESERIES, updateConsumer); + this.allKeys = allKeys; + this.keyStates = keyStates; + this.startTime = startTime; + this.endTime = endTime; + this.latestValues = latestValues; + } + + @Override + public boolean equals(Object o) { + return super.equals(o); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java new file mode 100644 index 0000000..f4e4e3d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -0,0 +1,172 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.sync.ThrowingRunnable; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.apiusage.RateLimitService; +import org.thingsboard.server.service.entitiy.TbNotificationEntityService; +import org.thingsboard.server.service.sync.ie.exporting.EntityExportService; +import org.thingsboard.server.service.sync.ie.exporting.impl.BaseEntityExportService; +import org.thingsboard.server.service.sync.ie.exporting.impl.DefaultEntityExportService; +import org.thingsboard.server.service.sync.ie.importing.EntityImportService; +import org.thingsboard.server.service.sync.ie.importing.impl.MissingEntityException; +import org.thingsboard.server.service.sync.vc.LoadEntityException; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultEntitiesExportImportService implements EntitiesExportImportService { + + private final Map> exportServices = new HashMap<>(); + private final Map> importServices = new HashMap<>(); + + private final RelationService relationService; + private final RateLimitService rateLimitService; + private final TbNotificationEntityService entityNotificationService; + + protected static final List SUPPORTED_ENTITY_TYPES = List.of( + EntityType.CUSTOMER, EntityType.ASSET_PROFILE, EntityType.ASSET, EntityType.RULE_CHAIN, + EntityType.DASHBOARD, EntityType.DEVICE_PROFILE, EntityType.DEVICE, + EntityType.ENTITY_VIEW, EntityType.WIDGETS_BUNDLE + ); + + + @Override + public , I extends EntityId> EntityExportData exportEntity(EntitiesExportCtx ctx, I entityId) throws ThingsboardException { + if (!rateLimitService.checkEntityExportLimit(ctx.getTenantId())) { + throw new ThingsboardException("Rate limit for entities export is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + + EntityType entityType = entityId.getEntityType(); + EntityExportService> exportService = getExportService(entityType); + + return exportService.getExportData(ctx, entityId); + } + + @Override + public , I extends EntityId> EntityImportResult importEntity(EntitiesImportCtx ctx, EntityExportData exportData) throws ThingsboardException { + if (!rateLimitService.checkEntityImportLimit(ctx.getTenantId())) { + throw new ThingsboardException("Rate limit for entities import is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + if (exportData.getEntity() == null || exportData.getEntity().getId() == null) { + throw new DataValidationException("Invalid entity data"); + } + + EntityType entityType = exportData.getEntityType(); + EntityImportService> importService = getImportService(entityType); + + EntityImportResult importResult = importService.importEntity(ctx, exportData); + ctx.putInternalId(exportData.getExternalId(), importResult.getSavedEntity().getId()); + + ctx.addReferenceCallback(exportData.getExternalId(), importResult.getSaveReferencesCallback()); + ctx.addEventCallback(importResult.getSendEventsCallback()); + return importResult; + } + + @Override + public void saveReferencesAndRelations(EntitiesImportCtx ctx) throws ThingsboardException { + for (Map.Entry callbackEntry : ctx.getReferenceCallbacks().entrySet()) { + EntityId externalId = callbackEntry.getKey(); + ThrowingRunnable saveReferencesCallback = callbackEntry.getValue(); + try { + saveReferencesCallback.run(); + } catch (MissingEntityException e) { + throw new LoadEntityException(externalId, e); + } + } + + relationService.saveRelations(ctx.getTenantId(), new ArrayList<>(ctx.getRelations())); + + for (EntityRelation relation : ctx.getRelations()) { + entityNotificationService.notifyRelation(ctx.getTenantId(), null, + relation, ctx.getUser(), ActionType.RELATION_ADD_OR_UPDATE, relation); + } + } + + + @Override + public Comparator getEntityTypeComparatorForImport() { + return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf); + } + + + @SuppressWarnings("unchecked") + private , D extends EntityExportData> EntityExportService getExportService(EntityType entityType) { + EntityExportService exportService = exportServices.get(entityType); + if (exportService == null) { + throw new IllegalArgumentException("Export for entity type " + entityType + " is not supported"); + } + return (EntityExportService) exportService; + } + + @SuppressWarnings("unchecked") + private , D extends EntityExportData> EntityImportService getImportService(EntityType entityType) { + EntityImportService importService = importServices.get(entityType); + if (importService == null) { + throw new IllegalArgumentException("Import for entity type " + entityType + " is not supported"); + } + return (EntityImportService) importService; + } + + @Autowired + private void setExportServices(DefaultEntityExportService defaultExportService, + Collection> exportServices) { + exportServices.stream() + .sorted(Comparator.comparing(exportService -> exportService.getSupportedEntityTypes().size(), Comparator.reverseOrder())) + .forEach(exportService -> { + exportService.getSupportedEntityTypes().forEach(entityType -> { + this.exportServices.put(entityType, exportService); + }); + }); + SUPPORTED_ENTITY_TYPES.forEach(entityType -> { + this.exportServices.putIfAbsent(entityType, defaultExportService); + }); + } + + @Autowired + private void setImportServices(Collection> importServices) { + importServices.forEach(entityImportService -> { + this.importServices.put(entityImportService.getEntityType(), entityImportService); + }); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java new file mode 100644 index 0000000..8657528 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/EntitiesExportImportService.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.Comparator; + +public interface EntitiesExportImportService { + + , I extends EntityId> EntityExportData exportEntity(EntitiesExportCtx ctx, I entityId) throws ThingsboardException; + + , I extends EntityId> EntityImportResult importEntity(EntitiesImportCtx ctx, EntityExportData exportData) throws ThingsboardException; + + + void saveReferencesAndRelations(EntitiesImportCtx ctx) throws ThingsboardException; + + Comparator getEntityTypeComparatorForImport(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java new file mode 100644 index 0000000..b5f5e6a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java @@ -0,0 +1,215 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.ExportableEntityDao; +import org.thingsboard.server.dao.asset.AssetProfileService; +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.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.permission.AccessControlService; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultExportableEntitiesService implements ExportableEntitiesService { + + private final Map> daos = new HashMap<>(); + private final Map> removers = new HashMap<>(); + + private final AccessControlService accessControlService; + + + @Override + public , I extends EntityId> E findEntityByTenantIdAndExternalId(TenantId tenantId, I externalId) { + EntityType entityType = externalId.getEntityType(); + Dao dao = getDao(entityType); + + E entity = null; + + if (dao instanceof ExportableEntityDao) { + ExportableEntityDao exportableEntityDao = (ExportableEntityDao) dao; + entity = exportableEntityDao.findByTenantIdAndExternalId(tenantId.getId(), externalId.getId()); + } + if (entity == null || !belongsToTenant(entity, tenantId)) { + return null; + } + + return entity; + } + + @Override + public , I extends EntityId> E findEntityByTenantIdAndId(TenantId tenantId, I id) { + E entity = findEntityById(id); + + if (entity == null || !belongsToTenant(entity, tenantId)) { + return null; + } + return entity; + } + + @Override + public , I extends EntityId> E findEntityById(I id) { + EntityType entityType = id.getEntityType(); + Dao dao = getDao(entityType); + if (dao == null) { + throw new IllegalArgumentException("Unsupported entity type " + entityType); + } + + return dao.findById(TenantId.SYS_TENANT_ID, id.getId()); + } + + @Override + public , I extends EntityId> E findEntityByTenantIdAndName(TenantId tenantId, EntityType entityType, String name) { + Dao dao = getDao(entityType); + + E entity = null; + + if (dao instanceof ExportableEntityDao) { + ExportableEntityDao exportableEntityDao = (ExportableEntityDao) dao; + try { + entity = exportableEntityDao.findByTenantIdAndName(tenantId.getId(), name); + } catch (UnsupportedOperationException ignored) { + } + } + if (entity == null || !belongsToTenant(entity, tenantId)) { + return null; + } + + return entity; + } + + @Override + public , I extends EntityId> PageData findEntitiesByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink) { + ExportableEntityDao dao = getExportableEntityDao(entityType); + if (dao != null) { + return dao.findByTenantId(tenantId.getId(), pageLink); + } else { + return new PageData<>(); + } + } + + @Override + public I getExternalIdByInternal(I internalId) { + ExportableEntityDao dao = getExportableEntityDao(internalId.getEntityType()); + if (dao != null) { + return dao.getExternalIdByInternal(internalId); + } else { + return null; + } + } + + private boolean belongsToTenant(HasId entity, TenantId tenantId) { + return tenantId.equals(((HasTenantId) entity).getTenantId()); + } + + + @Override + public void removeById(TenantId tenantId, I id) { + EntityType entityType = id.getEntityType(); + BiConsumer entityRemover = removers.get(entityType); + if (entityRemover == null) { + throw new IllegalArgumentException("Unsupported entity type " + entityType); + } + entityRemover.accept(tenantId, id); + } + + private > ExportableEntityDao getExportableEntityDao(EntityType entityType) { + Dao dao = getDao(entityType); + if (dao instanceof ExportableEntityDao) { + return (ExportableEntityDao) dao; + } else { + return null; + } + } + + @SuppressWarnings("unchecked") + private Dao getDao(EntityType entityType) { + return (Dao) daos.get(entityType); + } + + @Autowired + private void setDaos(Collection> daos) { + daos.forEach(dao -> { + if (dao.getEntityType() != null) { + this.daos.put(dao.getEntityType(), dao); + } + }); + } + + @Autowired + private void setRemovers(CustomerService customerService, AssetService assetService, RuleChainService ruleChainService, + DashboardService dashboardService, DeviceProfileService deviceProfileService, + AssetProfileService assetProfileService, DeviceService deviceService, WidgetsBundleService widgetsBundleService) { + removers.put(EntityType.CUSTOMER, (tenantId, entityId) -> { + customerService.deleteCustomer(tenantId, (CustomerId) entityId); + }); + removers.put(EntityType.ASSET, (tenantId, entityId) -> { + assetService.deleteAsset(tenantId, (AssetId) entityId); + }); + removers.put(EntityType.RULE_CHAIN, (tenantId, entityId) -> { + ruleChainService.deleteRuleChainById(tenantId, (RuleChainId) entityId); + }); + removers.put(EntityType.DASHBOARD, (tenantId, entityId) -> { + dashboardService.deleteDashboard(tenantId, (DashboardId) entityId); + }); + removers.put(EntityType.DEVICE_PROFILE, (tenantId, entityId) -> { + deviceProfileService.deleteDeviceProfile(tenantId, (DeviceProfileId) entityId); + }); + removers.put(EntityType.ASSET_PROFILE, (tenantId, entityId) -> { + assetProfileService.deleteAssetProfile(tenantId, (AssetProfileId) entityId); + }); + removers.put(EntityType.DEVICE, (tenantId, entityId) -> { + deviceService.deleteDevice(tenantId, (DeviceId) entityId); + }); + removers.put(EntityType.WIDGETS_BUNDLE, (tenantId, entityId) -> { + widgetsBundleService.deleteWidgetsBundle(tenantId, (WidgetsBundleId) entityId); + }); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/EntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/EntityExportService.java new file mode 100644 index 0000000..505d1b2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/EntityExportService.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting; + +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +public interface EntityExportService, D extends EntityExportData> { + + D getExportData(EntitiesExportCtx ctx, I entityId) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java new file mode 100644 index 0000000..0a99979 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +public interface ExportableEntitiesService { + + , I extends EntityId> E findEntityByTenantIdAndExternalId(TenantId tenantId, I externalId); + + , I extends EntityId> E findEntityByTenantIdAndId(TenantId tenantId, I id); + + , I extends EntityId> E findEntityById(I id); + + , I extends EntityId> E findEntityByTenantIdAndName(TenantId tenantId, EntityType entityType, String name); + + , I extends EntityId> PageData findEntitiesByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink); + + I getExternalIdByInternal(I internalId); + + void removeById(TenantId tenantId, I id); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetExportService.java new file mode 100644 index 0000000..5ae4965 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetExportService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +@Service +@TbCoreComponent +public class AssetExportService extends BaseEntityExportService> { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, Asset asset, EntityExportData exportData) { + asset.setCustomerId(getExternalIdOrElseInternal(ctx, asset.getCustomerId())); + asset.setAssetProfileId(getExternalIdOrElseInternal(ctx, asset.getAssetProfileId())); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.ASSET); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetProfileExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetProfileExportService.java new file mode 100644 index 0000000..a333767 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AssetProfileExportService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +@Service +@TbCoreComponent +public class AssetProfileExportService extends BaseEntityExportService> { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, AssetProfile assetProfile, EntityExportData exportData) { + assetProfile.setDefaultDashboardId(getExternalIdOrElseInternal(ctx, assetProfile.getDefaultDashboardId())); + assetProfile.setDefaultRuleChainId(getExternalIdOrElseInternal(ctx, assetProfile.getDefaultRuleChainId())); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.ASSET_PROFILE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java new file mode 100644 index 0000000..0162636 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +public abstract class BaseEntityExportService, D extends EntityExportData> extends DefaultEntityExportService { + + @Override + protected void setAdditionalExportData(EntitiesExportCtx ctx, E entity, D exportData) throws ThingsboardException { + setRelatedEntities(ctx, entity, (D) exportData); + super.setAdditionalExportData(ctx, entity, exportData); + } + + protected void setRelatedEntities(EntitiesExportCtx ctx, E mainEntity, D exportData) { + } + + protected D newExportData() { + return (D) new EntityExportData(); + } + + public abstract Set getSupportedEntityTypes(); + + protected void replaceUuidsRecursively(EntitiesExportCtx ctx, JsonNode node, Set skipFieldsSet) { + JacksonUtil.replaceUuidsRecursively(node, skipFieldsSet, uuid -> getExternalIdOrElseInternalByUuid(ctx, uuid)); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DashboardExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DashboardExportService.java new file mode 100644 index 0000000..9a3b195 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DashboardExportService.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.Lists; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; +import org.thingsboard.common.util.RegexUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; + +@Service +@TbCoreComponent +public class DashboardExportService extends BaseEntityExportService> { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, Dashboard dashboard, EntityExportData exportData) { + if (CollectionUtils.isNotEmpty(dashboard.getAssignedCustomers())) { + dashboard.getAssignedCustomers().forEach(customerInfo -> { + customerInfo.setCustomerId(getExternalIdOrElseInternal(ctx, customerInfo.getCustomerId())); + }); + } + for (JsonNode entityAlias : dashboard.getEntityAliasesConfig()) { + replaceUuidsRecursively(ctx, entityAlias, Collections.emptySet()); + } + for (JsonNode widgetConfig : dashboard.getWidgetsConfig()) { + replaceUuidsRecursively(ctx, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Collections.singleton("id")); + } + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.DASHBOARD); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java new file mode 100644 index 0000000..a00a7c0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -0,0 +1,184 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.sync.ie.AttributeExportData; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.relation.RelationDao; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.ie.exporting.EntityExportService; +import org.thingsboard.server.service.sync.ie.exporting.ExportableEntitiesService; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@Service +@TbCoreComponent +@Primary +public class DefaultEntityExportService, D extends EntityExportData> implements EntityExportService { + + @Autowired + @Lazy + protected ExportableEntitiesService exportableEntitiesService; + @Autowired + private RelationDao relationDao; + @Autowired + private AttributesService attributesService; + + @Override + public final D getExportData(EntitiesExportCtx ctx, I entityId) throws ThingsboardException { + D exportData = newExportData(); + + E entity = exportableEntitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entityId); + if (entity == null) { + throw new IllegalArgumentException(entityId.getEntityType() + " [" + entityId.getId() + "] not found"); + } + + exportData.setEntity(entity); + exportData.setEntityType(entityId.getEntityType()); + setAdditionalExportData(ctx, entity, exportData); + + var externalId = entity.getExternalId() != null ? entity.getExternalId() : entity.getId(); + ctx.putExternalId(entityId, externalId); + entity.setId(externalId); + entity.setTenantId(null); + + return exportData; + } + + protected void setAdditionalExportData(EntitiesExportCtx ctx, E entity, D exportData) throws ThingsboardException { + var exportSettings = ctx.getSettings(); + if (exportSettings.isExportRelations()) { + List relations = exportRelations(ctx, entity); + relations.forEach(relation -> { + relation.setFrom(getExternalIdOrElseInternal(ctx, relation.getFrom())); + relation.setTo(getExternalIdOrElseInternal(ctx, relation.getTo())); + }); + exportData.setRelations(relations); + } + if (exportSettings.isExportAttributes()) { + Map> attributes = exportAttributes(ctx, entity); + exportData.setAttributes(attributes); + } + } + + private List exportRelations(EntitiesExportCtx ctx, E entity) throws ThingsboardException { + List relations = new ArrayList<>(); + + List inboundRelations = relationDao.findAllByTo(ctx.getTenantId(), entity.getId(), RelationTypeGroup.COMMON); + relations.addAll(inboundRelations); + + List outboundRelations = relationDao.findAllByFrom(ctx.getTenantId(), entity.getId(), RelationTypeGroup.COMMON); + relations.addAll(outboundRelations); + return relations; + } + + private Map> exportAttributes(EntitiesExportCtx ctx, E entity) throws ThingsboardException { + List scopes; + if (entity.getId().getEntityType() == EntityType.DEVICE) { + scopes = List.of(DataConstants.SERVER_SCOPE, DataConstants.SHARED_SCOPE); + } else { + scopes = Collections.singletonList(DataConstants.SERVER_SCOPE); + } + Map> attributes = new LinkedHashMap<>(); + scopes.forEach(scope -> { + try { + attributes.put(scope, attributesService.findAll(ctx.getTenantId(), entity.getId(), scope).get().stream() + .map(attribute -> { + AttributeExportData attributeExportData = new AttributeExportData(); + attributeExportData.setKey(attribute.getKey()); + attributeExportData.setLastUpdateTs(attribute.getLastUpdateTs()); + attributeExportData.setStrValue(attribute.getStrValue().orElse(null)); + attributeExportData.setDoubleValue(attribute.getDoubleValue().orElse(null)); + attributeExportData.setLongValue(attribute.getLongValue().orElse(null)); + attributeExportData.setBooleanValue(attribute.getBooleanValue().orElse(null)); + attributeExportData.setJsonValue(attribute.getJsonValue().orElse(null)); + return attributeExportData; + }) + .collect(Collectors.toList())); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + }); + return attributes; + } + + protected ID getExternalIdOrElseInternal(EntitiesExportCtx ctx, ID internalId) { + if (internalId == null || internalId.isNullUid()) return internalId; + var result = ctx.getExternalId(internalId); + if (result == null) { + result = Optional.ofNullable(exportableEntitiesService.getExternalIdByInternal(internalId)) + .orElse(internalId); + ctx.putExternalId(internalId, result); + } + return result; + } + + protected UUID getExternalIdOrElseInternalByUuid(EntitiesExportCtx ctx, UUID internalUuid) { + for (EntityType entityType : EntityType.values()) { + EntityId internalId; + try { + internalId = EntityIdFactory.getByTypeAndUuid(entityType, internalUuid); + } catch (Exception e) { + continue; + } + EntityId externalId = ctx.getExternalId(internalId); + if (externalId != null) { + return externalId.getId(); + } + } + for (EntityType entityType : EntityType.values()) { + EntityId internalId; + try { + internalId = EntityIdFactory.getByTypeAndUuid(entityType, internalUuid); + } catch (Exception e) { + continue; + } + EntityId externalId = exportableEntitiesService.getExternalIdByInternal(internalId); + if (externalId != null) { + ctx.putExternalId(internalId, externalId); + return externalId.getId(); + } + } + return internalUuid; + } + + protected D newExportData() { + return (D) new EntityExportData(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java new file mode 100644 index 0000000..bc5136b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +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.common.data.sync.ie.DeviceExportData; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DeviceExportService extends BaseEntityExportService { + + private final DeviceCredentialsService deviceCredentialsService; + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, Device device, DeviceExportData exportData) { + device.setCustomerId(getExternalIdOrElseInternal(ctx, device.getCustomerId())); + device.setDeviceProfileId(getExternalIdOrElseInternal(ctx, device.getDeviceProfileId())); + if (ctx.getSettings().isExportCredentials()) { + var credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), device.getId()); + credentials.setId(null); + credentials.setDeviceId(null); + exportData.setCredentials(credentials); + } + } + + @Override + protected DeviceExportData newExportData() { + return new DeviceExportData(); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.DEVICE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java new file mode 100644 index 0000000..46423c6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +@Service +@TbCoreComponent +public class DeviceProfileExportService extends BaseEntityExportService> { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, DeviceProfile deviceProfile, EntityExportData exportData) { + deviceProfile.setDefaultDashboardId(getExternalIdOrElseInternal(ctx, deviceProfile.getDefaultDashboardId())); + deviceProfile.setDefaultRuleChainId(getExternalIdOrElseInternal(ctx, deviceProfile.getDefaultRuleChainId())); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.DEVICE_PROFILE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/EntityViewExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/EntityViewExportService.java new file mode 100644 index 0000000..3fff890 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/EntityViewExportService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.Set; + +@Service +@TbCoreComponent +public class EntityViewExportService extends BaseEntityExportService> { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, EntityView entityView, EntityExportData exportData) { + entityView.setEntityId(getExternalIdOrElseInternal(ctx, entityView.getEntityId())); + entityView.setCustomerId(getExternalIdOrElseInternal(ctx, entityView.getCustomerId())); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.ENTITY_VIEW); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java new file mode 100644 index 0000000..802a03a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.sync.ie.RuleChainExportData; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; +import org.thingsboard.common.util.RegexUtils; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class RuleChainExportService extends BaseEntityExportService { + + private final RuleChainService ruleChainService; + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, RuleChain ruleChain, RuleChainExportData exportData) { + RuleChainMetaData metaData = ruleChainService.loadRuleChainMetaData(ctx.getTenantId(), ruleChain.getId()); + Optional.ofNullable(metaData.getNodes()).orElse(Collections.emptyList()) + .forEach(ruleNode -> { + ruleNode.setRuleChainId(null); + ctx.putExternalId(ruleNode.getId(), ruleNode.getExternalId()); + ruleNode.setId(ctx.getExternalId(ruleNode.getId())); + ruleNode.setCreatedTime(0); + ruleNode.setExternalId(null); + replaceUuidsRecursively(ctx, ruleNode.getConfiguration(), Collections.emptySet()); + }); + Optional.ofNullable(metaData.getRuleChainConnections()).orElse(Collections.emptyList()) + .forEach(ruleChainConnectionInfo -> { + ruleChainConnectionInfo.setTargetRuleChainId(getExternalIdOrElseInternal(ctx, ruleChainConnectionInfo.getTargetRuleChainId())); + }); + exportData.setMetaData(metaData); + if (ruleChain.getFirstRuleNodeId() != null) { + ruleChain.setFirstRuleNodeId(ctx.getExternalId(ruleChain.getFirstRuleNodeId())); + } + } + + @Override + protected RuleChainExportData newExportData() { + return new RuleChainExportData(); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.RULE_CHAIN); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java new file mode 100644 index 0000000..d3dd239 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.sync.ie.WidgetsBundleExportData; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; + +import java.util.List; +import java.util.Set; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class WidgetsBundleExportService extends BaseEntityExportService { + + private final WidgetTypeService widgetTypeService; + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, WidgetsBundle widgetsBundle, WidgetsBundleExportData exportData) { + if (widgetsBundle.getTenantId() == null || widgetsBundle.getTenantId().isNullUid()) { + throw new IllegalArgumentException("Export of system Widget Bundles is not allowed"); + } + + List widgets = widgetTypeService.findWidgetTypesDetailsByTenantIdAndBundleAlias(ctx.getTenantId(), widgetsBundle.getAlias()); + exportData.setWidgets(widgets); + } + + @Override + protected WidgetsBundleExportData newExportData() { + return new WidgetsBundleExportData(); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.WIDGETS_BUNDLE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/EntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/EntityImportService.java new file mode 100644 index 0000000..9437f21 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/EntityImportService.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +public interface EntityImportService, D extends EntityExportData> { + + EntityImportResult importEntity(EntitiesImportCtx ctx, D exportData) throws ThingsboardException; + + EntityType getEntityType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java new file mode 100644 index 0000000..d78403a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -0,0 +1,321 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.csv; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FutureCallback; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import lombok.Data; +import lombok.SneakyThrows; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasAdditionalInfo; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumnType; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.controller.BaseController; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.action.EntityActionService; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.AccessControlService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import org.thingsboard.server.utils.CsvUtils; +import org.thingsboard.server.utils.TypeCastUtil; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public abstract class AbstractBulkImportService & HasTenantId> { + @Autowired + private TelemetrySubscriptionService tsSubscriptionService; + @Autowired + private TbTenantProfileCache tenantProfileCache; + @Autowired + private AccessControlService accessControlService; + @Autowired + private AccessValidator accessValidator; + @Autowired + private EntityActionService entityActionService; + + private ThreadPoolExecutor executor; + + @PostConstruct + private void initExecutor() { + if (executor == null) { + executor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors(), + 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(150_000), + ThingsBoardThreadFactory.forName("bulk-import"), new ThreadPoolExecutor.CallerRunsPolicy()); + executor.allowCoreThreadTimeOut(true); + } + } + + public final BulkImportResult processBulkImport(BulkImportRequest request, SecurityUser user) throws Exception { + List entitiesData = parseData(request); + + BulkImportResult result = new BulkImportResult<>(); + CountDownLatch completionLatch = new CountDownLatch(entitiesData.size()); + + SecurityContext securityContext = SecurityContextHolder.getContext(); + + entitiesData.forEach(entityData -> DonAsynchron.submit(() -> { + SecurityContextHolder.setContext(securityContext); + + ImportedEntityInfo importedEntityInfo = saveEntity(entityData.getFields(), user); + E entity = importedEntityInfo.getEntity(); + + saveKvs(user, entity, entityData.getKvs()); + + return importedEntityInfo; + }, + importedEntityInfo -> { + if (importedEntityInfo.isUpdated()) { + result.getUpdated().incrementAndGet(); + } else { + result.getCreated().incrementAndGet(); + } + completionLatch.countDown(); + }, + throwable -> { + result.getErrors().incrementAndGet(); + result.getErrorsList().add(String.format("Line %d: %s", entityData.getLineNumber(), ExceptionUtils.getRootCauseMessage(throwable))); + completionLatch.countDown(); + }, + executor)); + + completionLatch.await(); + return result; + } + + @SneakyThrows + private ImportedEntityInfo saveEntity(Map fields, SecurityUser user) { + ImportedEntityInfo importedEntityInfo = new ImportedEntityInfo<>(); + + E entity = findOrCreateEntity(user.getTenantId(), fields.get(BulkImportColumnType.NAME)); + if (entity.getId() != null) { + importedEntityInfo.setOldEntity((E) entity.getClass().getConstructor(entity.getClass()).newInstance(entity)); + importedEntityInfo.setUpdated(true); + } else { + setOwners(entity, user); + } + + setEntityFields(entity, fields); + accessControlService.checkPermission(user, Resource.of(getEntityType()), Operation.WRITE, entity.getId(), entity); + + E savedEntity = saveEntity(user, entity, fields); + + importedEntityInfo.setEntity(savedEntity); + return importedEntityInfo; + } + + + protected abstract E findOrCreateEntity(TenantId tenantId, String name); + + protected abstract void setOwners(E entity, SecurityUser user); + + protected abstract void setEntityFields(E entity, Map fields); + + protected abstract E saveEntity(SecurityUser user, E entity, Map fields); + + protected abstract EntityType getEntityType(); + + protected ObjectNode getOrCreateAdditionalInfoObj(HasAdditionalInfo entity) { + return entity.getAdditionalInfo() == null || entity.getAdditionalInfo().isNull() ? + JacksonUtil.newObjectNode() : (ObjectNode) entity.getAdditionalInfo(); + } + + private void saveKvs(SecurityUser user, E entity, Map data) { + Arrays.stream(BulkImportColumnType.values()) + .filter(BulkImportColumnType::isKv) + .map(kvType -> { + JsonObject kvs = new JsonObject(); + data.entrySet().stream() + .filter(dataEntry -> dataEntry.getKey().getType() == kvType && + StringUtils.isNotEmpty(dataEntry.getKey().getKey())) + .forEach(dataEntry -> kvs.add(dataEntry.getKey().getKey(), dataEntry.getValue().toJsonPrimitive())); + return Map.entry(kvType, kvs); + }) + .filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0) + .forEach(kvsEntry -> { + BulkImportColumnType kvType = kvsEntry.getKey(); + if (kvType == BulkImportColumnType.SHARED_ATTRIBUTE || kvType == BulkImportColumnType.SERVER_ATTRIBUTE) { + saveAttributes(user, entity, kvsEntry, kvType); + } else { + saveTelemetry(user, entity, kvsEntry); + } + }); + } + + @SneakyThrows + private void saveTelemetry(SecurityUser user, E entity, Map.Entry kvsEntry) { + List timeseries = JsonConverter.convertToTelemetry(kvsEntry.getValue(), System.currentTimeMillis()) + .entrySet().stream() + .flatMap(entry -> entry.getValue().stream().map(kvEntry -> new BasicTsKvEntry(entry.getKey(), kvEntry))) + .collect(Collectors.toList()); + + accessValidator.validateEntityAndCallback(user, Operation.WRITE_TELEMETRY, entity.getId(), (result, tenantId, entityId) -> { + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + long tenantTtl = TimeUnit.DAYS.toSeconds(((DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration()).getDefaultStorageTtlDays()); + tsSubscriptionService.saveAndNotify(tenantId, user.getCustomerId(), entityId, timeseries, tenantTtl, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + entityActionService.logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, + ActionType.TIMESERIES_UPDATED, null, timeseries); + } + + @Override + public void onFailure(Throwable t) { + entityActionService.logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, + ActionType.TIMESERIES_UPDATED, BaseController.toException(t), timeseries); + throw new RuntimeException(t); + } + }); + }); + } + + @SneakyThrows + private void saveAttributes(SecurityUser user, E entity, Map.Entry kvsEntry, BulkImportColumnType kvType) { + String scope = kvType.getKey(); + List attributes = new ArrayList<>(JsonConverter.convertToAttributes(kvsEntry.getValue())); + + accessValidator.validateEntityAndCallback(user, Operation.WRITE_ATTRIBUTES, entity.getId(), (result, tenantId, entityId) -> { + tsSubscriptionService.saveAndNotify(tenantId, entityId, scope, attributes, new FutureCallback<>() { + + @Override + public void onSuccess(Void unused) { + entityActionService.logEntityAction(user, (UUIDBased & EntityId) entityId, null, + null, ActionType.ATTRIBUTES_UPDATED, null, scope, attributes); + } + + @Override + public void onFailure(Throwable throwable) { + entityActionService.logEntityAction(user, (UUIDBased & EntityId) entityId, null, + null, ActionType.ATTRIBUTES_UPDATED, BaseController.toException(throwable), + scope, attributes); + throw new RuntimeException(throwable); + } + + }); + }); + } + + private List parseData(BulkImportRequest request) throws Exception { + List> records = CsvUtils.parseCsv(request.getFile(), request.getMapping().getDelimiter()); + AtomicInteger linesCounter = new AtomicInteger(0); + + if (request.getMapping().getHeader()) { + records.remove(0); + linesCounter.incrementAndGet(); + } + + List columnsMappings = request.getMapping().getColumns(); + return records.stream() + .map(record -> { + EntityData entityData = new EntityData(); + Stream.iterate(0, i -> i < record.size(), i -> i + 1) + .map(i -> Map.entry(columnsMappings.get(i), record.get(i))) + .filter(entry -> StringUtils.isNotEmpty(entry.getValue())) + .forEach(entry -> { + if (!entry.getKey().getType().isKv()) { + entityData.getFields().put(entry.getKey().getType(), entry.getValue()); + } else { + Map.Entry castResult = TypeCastUtil.castValue(entry.getValue()); + entityData.getKvs().put(entry.getKey(), new ParsedValue(castResult.getValue(), castResult.getKey())); + } + }); + entityData.setLineNumber(linesCounter.incrementAndGet()); + return entityData; + }) + .collect(Collectors.toList()); + } + + @PreDestroy + private void shutdownExecutor() { + if (!executor.isTerminating()) { + executor.shutdown(); + } + } + + @Data + protected static class EntityData { + private final Map fields = new LinkedHashMap<>(); + private final Map kvs = new LinkedHashMap<>(); + private int lineNumber; + } + + @Data + protected static class ParsedValue { + private final Object value; + private final DataType dataType; + + public JsonPrimitive toJsonPrimitive() { + switch (dataType) { + case STRING: + return new JsonPrimitive((String) value); + case LONG: + return new JsonPrimitive((Long) value); + case DOUBLE: + return new JsonPrimitive((Double) value); + case BOOLEAN: + return new JsonPrimitive((Boolean) value); + default: + return null; + } + } + + public String stringValue() { + return value.toString(); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/ImportedEntityInfo.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/ImportedEntityInfo.java new file mode 100644 index 0000000..d48e9a3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/ImportedEntityInfo.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.csv; + +import lombok.Data; + +@Data +public class ImportedEntityInfo { + private E entity; + private boolean isUpdated; + private E oldEntity; +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java new file mode 100644 index 0000000..fa35585 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetImportService.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class AssetImportService extends BaseEntityImportService> { + + private final AssetService assetService; + + @Override + protected void setOwner(TenantId tenantId, Asset asset, IdProvider idProvider) { + asset.setTenantId(tenantId); + asset.setCustomerId(idProvider.getInternalId(asset.getCustomerId())); + } + + @Override + protected Asset prepare(EntitiesImportCtx ctx, Asset asset, Asset old, EntityExportData exportData, IdProvider idProvider) { + asset.setAssetProfileId(idProvider.getInternalId(asset.getAssetProfileId())); + return asset; + } + + @Override + protected Asset saveOrUpdate(EntitiesImportCtx ctx, Asset asset, EntityExportData exportData, IdProvider idProvider) { + return assetService.saveAsset(asset); + } + + @Override + protected Asset deepCopy(Asset asset) { + return new Asset(asset); + } + + @Override + protected void cleanupForComparison(Asset e) { + super.cleanupForComparison(e); + if (e.getCustomerId() != null && e.getCustomerId().isNullUid()) { + e.setCustomerId(null); + } + } + + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java new file mode 100644 index 0000000..c6b33a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AssetProfileImportService.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class AssetProfileImportService extends BaseEntityImportService> { + + private final AssetProfileService assetProfileService; + + @Override + protected void setOwner(TenantId tenantId, AssetProfile assetProfile, IdProvider idProvider) { + assetProfile.setTenantId(tenantId); + } + + @Override + protected AssetProfile prepare(EntitiesImportCtx ctx, AssetProfile assetProfile, AssetProfile old, EntityExportData exportData, IdProvider idProvider) { + assetProfile.setDefaultRuleChainId(idProvider.getInternalId(assetProfile.getDefaultRuleChainId())); + assetProfile.setDefaultDashboardId(idProvider.getInternalId(assetProfile.getDefaultDashboardId())); + return assetProfile; + } + + @Override + protected AssetProfile saveOrUpdate(EntitiesImportCtx ctx, AssetProfile assetProfile, EntityExportData exportData, IdProvider idProvider) { + return assetProfileService.saveAssetProfile(assetProfile); + } + + @Override + protected void onEntitySaved(User user, AssetProfile savedAssetProfile, AssetProfile oldAssetProfile) throws ThingsboardException { + clusterService.broadcastEntityStateChangeEvent(user.getTenantId(), savedAssetProfile.getId(), + oldAssetProfile == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + entityNotificationService.notifyCreateOrUpdateOrDelete(savedAssetProfile.getTenantId(), null, + savedAssetProfile.getId(), savedAssetProfile, user, oldAssetProfile == null ? ActionType.ADDED : ActionType.UPDATED, true, null); + } + + @Override + protected AssetProfile deepCopy(AssetProfile assetProfile) { + return new AssetProfile(assetProfile); + } + + @Override + protected void cleanupForComparison(AssetProfile assetProfile) { + super.cleanupForComparison(assetProfile); + } + + @Override + public EntityType getEntityType() { + return EntityType.ASSET_PROFILE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java new file mode 100644 index 0000000..4fdd6cf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -0,0 +1,400 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.api.client.util.Objects; +import com.google.common.util.concurrent.FutureCallback; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.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.JsonDataEntry; +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.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.sync.ie.AttributeExportData; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.dao.relation.RelationDao; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.service.action.EntityActionService; +import org.thingsboard.server.service.entitiy.TbNotificationEntityService; +import org.thingsboard.server.service.sync.ie.exporting.ExportableEntitiesService; +import org.thingsboard.server.service.sync.ie.importing.EntityImportService; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +public abstract class BaseEntityImportService, D extends EntityExportData> implements EntityImportService { + + @Autowired + @Lazy + private ExportableEntitiesService exportableEntitiesService; + @Autowired + private RelationService relationService; + @Autowired + private RelationDao relationDao; + @Autowired + private TelemetrySubscriptionService tsSubService; + @Autowired + protected EntityActionService entityActionService; + @Autowired + protected TbClusterService clusterService; + @Autowired + protected TbNotificationEntityService entityNotificationService; + + @Transactional(rollbackFor = Exception.class) + @Override + public EntityImportResult importEntity(EntitiesImportCtx ctx, D exportData) throws ThingsboardException { + EntityImportResult importResult = new EntityImportResult<>(); + ctx.setCurrentImportResult(importResult); + importResult.setEntityType(getEntityType()); + IdProvider idProvider = new IdProvider(ctx, importResult); + + E entity = exportData.getEntity(); + entity.setExternalId(entity.getId()); + + E existingEntity = findExistingEntity(ctx, entity, idProvider); + importResult.setOldEntity(existingEntity); + + setOwner(ctx.getTenantId(), entity, idProvider); + if (existingEntity == null) { + entity.setId(null); + } else { + entity.setId(existingEntity.getId()); + entity.setCreatedTime(existingEntity.getCreatedTime()); + } + + E prepared = prepare(ctx, entity, existingEntity, exportData, idProvider); + + boolean saveOrUpdate = existingEntity == null || compare(ctx, exportData, prepared, existingEntity); + + if (saveOrUpdate) { + E savedEntity = saveOrUpdate(ctx, prepared, exportData, idProvider); + boolean created = existingEntity == null; + importResult.setCreated(created); + importResult.setUpdated(!created); + importResult.setSavedEntity(savedEntity); + ctx.putInternalId(exportData.getExternalId(), savedEntity.getId()); + } else { + importResult.setSavedEntity(existingEntity); + ctx.putInternalId(exportData.getExternalId(), existingEntity.getId()); + importResult.setUpdatedRelatedEntities(updateRelatedEntitiesIfUnmodified(ctx, prepared, exportData, idProvider)); + } + + processAfterSaved(ctx, importResult, exportData, idProvider); + + return importResult; + } + + protected boolean updateRelatedEntitiesIfUnmodified(EntitiesImportCtx ctx, E prepared, D exportData, IdProvider idProvider) { + return false; + } + + @Override + public abstract EntityType getEntityType(); + + protected abstract void setOwner(TenantId tenantId, E entity, IdProvider idProvider); + + protected abstract E prepare(EntitiesImportCtx ctx, E entity, E oldEntity, D exportData, IdProvider idProvider); + + protected boolean compare(EntitiesImportCtx ctx, D exportData, E prepared, E existing) { + var newCopy = deepCopy(prepared); + var existingCopy = deepCopy(existing); + cleanupForComparison(newCopy); + cleanupForComparison(existingCopy); + var result = !newCopy.equals(existingCopy); + if (result) { + log.debug("[{}] Found update.", prepared.getId()); + log.debug("[{}] From: {}", prepared.getId(), newCopy); + log.debug("[{}] To: {}", prepared.getId(), existingCopy); + } + return result; + } + + protected abstract E deepCopy(E e); + + protected void cleanupForComparison(E e) { + e.setTenantId(null); + e.setCreatedTime(0); + } + + protected abstract E saveOrUpdate(EntitiesImportCtx ctx, E entity, D exportData, IdProvider idProvider); + + + protected void processAfterSaved(EntitiesImportCtx ctx, EntityImportResult importResult, D exportData, IdProvider idProvider) throws ThingsboardException { + E savedEntity = importResult.getSavedEntity(); + E oldEntity = importResult.getOldEntity(); + + if (importResult.isCreated() || importResult.isUpdated()) { + importResult.addSendEventsCallback(() -> onEntitySaved(ctx.getUser(), savedEntity, oldEntity)); + } + + if (ctx.isUpdateRelations() && exportData.getRelations() != null) { + importRelations(ctx, exportData.getRelations(), importResult, idProvider); + } + if (ctx.isSaveAttributes() && exportData.getAttributes() != null) { + if (exportData.getAttributes().values().stream().anyMatch(d -> !d.isEmpty())) { + importResult.setUpdatedRelatedEntities(true); + } + importAttributes(ctx.getUser(), exportData.getAttributes(), importResult); + } + } + + private void importRelations(EntitiesImportCtx ctx, List relations, EntityImportResult importResult, IdProvider idProvider) { + var tenantId = ctx.getTenantId(); + E entity = importResult.getSavedEntity(); + importResult.addSaveReferencesCallback(() -> { + for (EntityRelation relation : relations) { + if (!relation.getTo().equals(entity.getId())) { + relation.setTo(idProvider.getInternalId(relation.getTo())); + } + if (!relation.getFrom().equals(entity.getId())) { + relation.setFrom(idProvider.getInternalId(relation.getFrom())); + } + } + + Map relationsMap = new LinkedHashMap<>(); + relations.forEach(r -> relationsMap.put(r, r)); + + if (importResult.getOldEntity() != null) { + List existingRelations = new ArrayList<>(); + existingRelations.addAll(relationDao.findAllByTo(tenantId, entity.getId(), RelationTypeGroup.COMMON)); + existingRelations.addAll(relationDao.findAllByFrom(tenantId, entity.getId(), RelationTypeGroup.COMMON)); + // dao is used here instead of service to avoid getting cached values, because relationService.deleteRelation will evict value from cache only after transaction is committed + + for (EntityRelation existingRelation : existingRelations) { + EntityRelation relation = relationsMap.get(existingRelation); + if (relation == null) { + importResult.setUpdatedRelatedEntities(true); + relationService.deleteRelation(ctx.getTenantId(), existingRelation.getFrom(), existingRelation.getTo(), existingRelation.getType(), existingRelation.getTypeGroup()); + importResult.addSendEventsCallback(() -> { + entityNotificationService.notifyRelation(tenantId, null, + existingRelation, ctx.getUser(), ActionType.RELATION_DELETED, existingRelation); + }); + } else if (Objects.equal(relation.getAdditionalInfo(), existingRelation.getAdditionalInfo())) { + relationsMap.remove(relation); + } + } + } + if (!relationsMap.isEmpty()) { + importResult.setUpdatedRelatedEntities(true); + ctx.addRelations(relationsMap.values()); + } + }); + } + + private void importAttributes(User user, Map> attributes, EntityImportResult importResult) { + E entity = importResult.getSavedEntity(); + importResult.addSaveReferencesCallback(() -> { + attributes.forEach((scope, attributesExportData) -> { + List attributeKvEntries = attributesExportData.stream() + .map(attributeExportData -> { + KvEntry kvEntry; + String key = attributeExportData.getKey(); + if (attributeExportData.getStrValue() != null) { + kvEntry = new StringDataEntry(key, attributeExportData.getStrValue()); + } else if (attributeExportData.getBooleanValue() != null) { + kvEntry = new BooleanDataEntry(key, attributeExportData.getBooleanValue()); + } else if (attributeExportData.getDoubleValue() != null) { + kvEntry = new DoubleDataEntry(key, attributeExportData.getDoubleValue()); + } else if (attributeExportData.getLongValue() != null) { + kvEntry = new LongDataEntry(key, attributeExportData.getLongValue()); + } else if (attributeExportData.getJsonValue() != null) { + kvEntry = new JsonDataEntry(key, attributeExportData.getJsonValue()); + } else { + throw new IllegalArgumentException("Invalid attribute export data"); + } + return new BaseAttributeKvEntry(kvEntry, attributeExportData.getLastUpdateTs()); + }) + .collect(Collectors.toList()); + // fixme: attributes are saved outside the transaction + tsSubService.saveAndNotify(user.getTenantId(), entity.getId(), scope, attributeKvEntries, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void unused) { + } + + @Override + public void onFailure(Throwable thr) { + log.error("Failed to import attributes for {} {}", entity.getId().getEntityType(), entity.getId(), thr); + } + }); + }); + }); + } + + protected void onEntitySaved(User user, E savedEntity, E oldEntity) throws ThingsboardException { + entityNotificationService.notifyCreateOrUpdateEntity(user.getTenantId(), savedEntity.getId(), savedEntity, + null, oldEntity == null ? ActionType.ADDED : ActionType.UPDATED, user); + } + + + @SuppressWarnings("unchecked") + protected E findExistingEntity(EntitiesImportCtx ctx, E entity, IdProvider idProvider) { + return (E) Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndExternalId(ctx.getTenantId(), entity.getId())) + .or(() -> Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entity.getId()))) + .or(() -> { + if (ctx.isFindExistingByName()) { + return Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndName(ctx.getTenantId(), getEntityType(), entity.getName())); + } else { + return Optional.empty(); + } + }) + .orElse(null); + } + + @SuppressWarnings("unchecked") + private HasId findInternalEntity(TenantId tenantId, ID externalId) { + return (HasId) Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndExternalId(tenantId, externalId)) + .or(() -> Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndId(tenantId, externalId))) + .orElseThrow(() -> new MissingEntityException(externalId)); + } + + + @SuppressWarnings("unchecked") + @RequiredArgsConstructor + protected class IdProvider { + private final EntitiesImportCtx ctx; + private final EntityImportResult importResult; + + public ID getInternalId(ID externalId) { + return getInternalId(externalId, true); + } + + public ID getInternalId(ID externalId, boolean throwExceptionIfNotFound) { + if (externalId == null || externalId.isNullUid()) return null; + + if (EntityType.TENANT.equals(externalId.getEntityType())) { + return (ID) ctx.getTenantId(); + } + + EntityId localId = ctx.getInternalId(externalId); + if (localId != null) { + return (ID) localId; + } + + HasId entity; + try { + entity = findInternalEntity(ctx.getTenantId(), externalId); + } catch (Exception e) { + if (throwExceptionIfNotFound) { + throw e; + } else { + importResult.setUpdatedAllExternalIds(false); + return null; + } + } + ctx.putInternalId(externalId, entity.getId()); + return entity.getId(); + } + + public Optional getInternalIdByUuid(UUID externalUuid, boolean fetchAllUUIDs, Set hints) { + if (externalUuid.equals(EntityId.NULL_UUID)) return Optional.empty(); + + for (EntityType entityType : EntityType.values()) { + Optional externalIdOpt = buildEntityId(entityType, externalUuid); + if (!externalIdOpt.isPresent()) { + continue; + } + EntityId internalId = ctx.getInternalId(externalIdOpt.get()); + if (internalId != null) { + return Optional.of(internalId); + } + } + + if (fetchAllUUIDs) { + for (EntityType entityType : hints) { + Optional internalId = lookupInDb(externalUuid, entityType); + if (internalId.isPresent()) return internalId; + } + for (EntityType entityType : EntityType.values()) { + if (hints.contains(entityType)) { + continue; + } + Optional internalId = lookupInDb(externalUuid, entityType); + if (internalId.isPresent()) return internalId; + } + } + + importResult.setUpdatedAllExternalIds(false); + return Optional.empty(); + } + + private Optional lookupInDb(UUID externalUuid, EntityType entityType) { + Optional externalIdOpt = buildEntityId(entityType, externalUuid); + if (externalIdOpt.isEmpty() || ctx.isNotFound(externalIdOpt.get())) { + return Optional.empty(); + } + EntityId internalId = getInternalId(externalIdOpt.get(), false); + if (internalId != null) { + return Optional.of(internalId); + } else { + ctx.registerNotFound(externalIdOpt.get()); + } + return Optional.empty(); + } + + private Optional buildEntityId(EntityType entityType, UUID externalUuid) { + try { + return Optional.of(EntityIdFactory.getByTypeAndUuid(entityType, externalUuid)); + } catch (Exception e) { + return Optional.empty(); + } + } + + } + + protected T getOldEntityField(O oldEntity, Function getter) { + return oldEntity == null ? null : getter.apply(oldEntity); + } + + protected void replaceIdsRecursively(EntitiesImportCtx ctx, IdProvider idProvider, JsonNode entityAlias, Set skipFieldsSet, LinkedHashSet hints) { + JacksonUtil.replaceUuidsRecursively(entityAlias, skipFieldsSet, + uuid -> idProvider.getInternalIdByUuid(uuid, ctx.isFinalImportAttempt(), hints).map(EntityId::getId).orElse(uuid)); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/CustomerImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/CustomerImportService.java new file mode 100644 index 0000000..d12ed6e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/CustomerImportService.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.customer.CustomerDao; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class CustomerImportService extends BaseEntityImportService> { + + private final CustomerService customerService; + private final CustomerDao customerDao; + + @Override + protected void setOwner(TenantId tenantId, Customer customer, IdProvider idProvider) { + customer.setTenantId(tenantId); + } + + @Override + protected Customer prepare(EntitiesImportCtx ctx, Customer customer, Customer old, EntityExportData exportData, IdProvider idProvider) { + if (customer.isPublic()) { + Customer publicCustomer = customerService.findOrCreatePublicCustomer(ctx.getTenantId()); + publicCustomer.setExternalId(customer.getExternalId()); + return publicCustomer; + } else { + return customer; + } + } + + @Override + protected Customer saveOrUpdate(EntitiesImportCtx ctx, Customer customer, EntityExportData exportData, IdProvider idProvider) { + if (!customer.isPublic()) { + return customerService.saveCustomer(customer); + } else { + return customerDao.save(ctx.getTenantId(), customer); + } + } + + @Override + protected Customer deepCopy(Customer customer) { + return new Customer(customer); + } + + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java new file mode 100644 index 0000000..5aa97a6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ShortCustomerInfo; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DashboardImportService extends BaseEntityImportService> { + + private static final LinkedHashSet HINTS = new LinkedHashSet<>(Arrays.asList(EntityType.DASHBOARD, EntityType.DEVICE, EntityType.ASSET)); + + private final DashboardService dashboardService; + + + @Override + protected void setOwner(TenantId tenantId, Dashboard dashboard, IdProvider idProvider) { + dashboard.setTenantId(tenantId); + } + + @Override + protected Dashboard findExistingEntity(EntitiesImportCtx ctx, Dashboard dashboard, IdProvider idProvider) { + Dashboard existingDashboard = super.findExistingEntity(ctx, dashboard, idProvider); + if (existingDashboard == null && ctx.isFindExistingByName()) { + existingDashboard = dashboardService.findTenantDashboardsByTitle(ctx.getTenantId(), dashboard.getName()).stream().findFirst().orElse(null); + } + return existingDashboard; + } + + @Override + protected Dashboard prepare(EntitiesImportCtx ctx, Dashboard dashboard, Dashboard old, EntityExportData exportData, IdProvider idProvider) { + for (JsonNode entityAlias : dashboard.getEntityAliasesConfig()) { + replaceIdsRecursively(ctx, idProvider, entityAlias, Collections.emptySet(), HINTS); + } + for (JsonNode widgetConfig : dashboard.getWidgetsConfig()) { + replaceIdsRecursively(ctx, idProvider, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Collections.singleton("id"), HINTS); + } + return dashboard; + } + + @Override + protected Dashboard saveOrUpdate(EntitiesImportCtx ctx, Dashboard dashboard, EntityExportData exportData, IdProvider idProvider) { + var tenantId = ctx.getTenantId(); + + Set assignedCustomers = Optional.ofNullable(dashboard.getAssignedCustomers()).orElse(Collections.emptySet()).stream() + .peek(customerInfo -> customerInfo.setCustomerId(idProvider.getInternalId(customerInfo.getCustomerId()))) + .collect(Collectors.toSet()); + + if (dashboard.getId() == null) { + dashboard.setAssignedCustomers(assignedCustomers); + dashboard = dashboardService.saveDashboard(dashboard); + for (ShortCustomerInfo customerInfo : assignedCustomers) { + dashboard = dashboardService.assignDashboardToCustomer(tenantId, dashboard.getId(), customerInfo.getCustomerId()); + } + } else { + Set existingAssignedCustomers = Optional.ofNullable(dashboardService.findDashboardById(tenantId, dashboard.getId()).getAssignedCustomers()) + .orElse(Collections.emptySet()).stream().map(ShortCustomerInfo::getCustomerId).collect(Collectors.toSet()); + Set newAssignedCustomers = assignedCustomers.stream().map(ShortCustomerInfo::getCustomerId).collect(Collectors.toSet()); + + Set toUnassign = new HashSet<>(existingAssignedCustomers); + toUnassign.removeAll(newAssignedCustomers); + for (CustomerId customerId : toUnassign) { + assignedCustomers = dashboardService.unassignDashboardFromCustomer(tenantId, dashboard.getId(), customerId).getAssignedCustomers(); + } + + Set toAssign = new HashSet<>(newAssignedCustomers); + toAssign.removeAll(existingAssignedCustomers); + for (CustomerId customerId : toAssign) { + assignedCustomers = dashboardService.assignDashboardToCustomer(tenantId, dashboard.getId(), customerId).getAssignedCustomers(); + } + dashboard.setAssignedCustomers(assignedCustomers); + dashboard = dashboardService.saveDashboard(dashboard); + } + return dashboard; + } + + @Override + protected Dashboard deepCopy(Dashboard dashboard) { + return new Dashboard(dashboard); + } + + @Override + protected boolean compare(EntitiesImportCtx ctx, EntityExportData exportData, Dashboard prepared, Dashboard existing) { + return super.compare(ctx, exportData, prepared, existing) || !prepared.getConfiguration().equals(existing.getConfiguration()); + } + + @Override + public EntityType getEntityType() { + return EntityType.DASHBOARD; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java new file mode 100644 index 0000000..2cf2c57 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.DeviceExportData; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DeviceImportService extends BaseEntityImportService { + + private final DeviceService deviceService; + private final DeviceCredentialsService credentialsService; + + @Override + protected void setOwner(TenantId tenantId, Device device, IdProvider idProvider) { + device.setTenantId(tenantId); + device.setCustomerId(idProvider.getInternalId(device.getCustomerId())); + } + + @Override + protected Device prepare(EntitiesImportCtx ctx, Device device, Device old, DeviceExportData exportData, IdProvider idProvider) { + device.setDeviceProfileId(idProvider.getInternalId(device.getDeviceProfileId())); + device.setFirmwareId(getOldEntityField(old, Device::getFirmwareId)); + device.setSoftwareId(getOldEntityField(old, Device::getSoftwareId)); + return device; + } + + @Override + protected Device deepCopy(Device d) { + return new Device(d); + } + + @Override + protected void cleanupForComparison(Device e) { + super.cleanupForComparison(e); + if (e.getCustomerId() != null && e.getCustomerId().isNullUid()) { + e.setCustomerId(null); + } + } + + @Override + protected Device saveOrUpdate(EntitiesImportCtx ctx, Device device, DeviceExportData exportData, IdProvider idProvider) { + if (exportData.getCredentials() != null && ctx.isSaveCredentials()) { + exportData.getCredentials().setId(null); + exportData.getCredentials().setDeviceId(null); + return deviceService.saveDeviceWithCredentials(device, exportData.getCredentials()); + } else { + return deviceService.saveDevice(device); + } + } + + @Override + protected boolean updateRelatedEntitiesIfUnmodified(EntitiesImportCtx ctx, Device prepared, DeviceExportData exportData, IdProvider idProvider) { + boolean updated = super.updateRelatedEntitiesIfUnmodified(ctx, prepared, exportData, idProvider); + var credentials = exportData.getCredentials(); + if (credentials != null && ctx.isSaveCredentials()) { + var existing = credentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), prepared.getId()); + credentials.setId(existing.getId()); + credentials.setDeviceId(prepared.getId()); + if (!existing.equals(credentials)) { + credentialsService.updateDeviceCredentials(ctx.getTenantId(), credentials); + updated = true; + } + } + return updated; + } + + @Override + protected void onEntitySaved(User user, Device savedDevice, Device oldDevice) throws ThingsboardException { + entityNotificationService.notifyCreateOrUpdateDevice(user.getTenantId(), savedDevice.getId(), savedDevice.getCustomerId(), + savedDevice, oldDevice, oldDevice == null ? ActionType.ADDED : ActionType.UPDATED, user); + } + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceProfileImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceProfileImportService.java new file mode 100644 index 0000000..72be8e6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceProfileImportService.java @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ota.OtaPackageStateService; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.Objects; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DeviceProfileImportService extends BaseEntityImportService> { + + private final DeviceProfileService deviceProfileService; + private final OtaPackageStateService otaPackageStateService; + + @Override + protected void setOwner(TenantId tenantId, DeviceProfile deviceProfile, IdProvider idProvider) { + deviceProfile.setTenantId(tenantId); + } + + @Override + protected DeviceProfile prepare(EntitiesImportCtx ctx, DeviceProfile deviceProfile, DeviceProfile old, EntityExportData exportData, IdProvider idProvider) { + deviceProfile.setDefaultRuleChainId(idProvider.getInternalId(deviceProfile.getDefaultRuleChainId())); + deviceProfile.setDefaultDashboardId(idProvider.getInternalId(deviceProfile.getDefaultDashboardId())); + deviceProfile.setFirmwareId(getOldEntityField(old, DeviceProfile::getFirmwareId)); + deviceProfile.setSoftwareId(getOldEntityField(old, DeviceProfile::getSoftwareId)); + return deviceProfile; + } + + @Override + protected DeviceProfile saveOrUpdate(EntitiesImportCtx ctx, DeviceProfile deviceProfile, EntityExportData exportData, IdProvider idProvider) { + return deviceProfileService.saveDeviceProfile(deviceProfile); + } + + @Override + protected void onEntitySaved(User user, DeviceProfile savedDeviceProfile, DeviceProfile oldDeviceProfile) throws ThingsboardException { + clusterService.onDeviceProfileChange(savedDeviceProfile, null); + clusterService.broadcastEntityStateChangeEvent(user.getTenantId(), savedDeviceProfile.getId(), + oldDeviceProfile == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + otaPackageStateService.update(savedDeviceProfile, + oldDeviceProfile != null && !Objects.equals(oldDeviceProfile.getFirmwareId(), savedDeviceProfile.getFirmwareId()), + oldDeviceProfile != null && !Objects.equals(oldDeviceProfile.getSoftwareId(), savedDeviceProfile.getSoftwareId())); + entityNotificationService.notifyCreateOrUpdateOrDelete(savedDeviceProfile.getTenantId(), null, + savedDeviceProfile.getId(), savedDeviceProfile, user, oldDeviceProfile == null ? ActionType.ADDED : ActionType.UPDATED, true, null); + } + + @Override + protected DeviceProfile deepCopy(DeviceProfile deviceProfile) { + return new DeviceProfile(deviceProfile); + } + + @Override + protected void cleanupForComparison(DeviceProfile deviceProfile) { + super.cleanupForComparison(deviceProfile); + deviceProfile.setFirmwareId(null); + deviceProfile.setSoftwareId(null); + } + + @Override + public EntityType getEntityType() { + return EntityType.DEVICE_PROFILE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/EntityViewImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/EntityViewImportService.java new file mode 100644 index 0000000..6856323 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/EntityViewImportService.java @@ -0,0 +1,89 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +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.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class EntityViewImportService extends BaseEntityImportService> { + + private final EntityViewService entityViewService; + + @Lazy + @Autowired + private TbEntityViewService tbEntityViewService; + + @Override + protected void setOwner(TenantId tenantId, EntityView entityView, IdProvider idProvider) { + entityView.setTenantId(tenantId); + entityView.setCustomerId(idProvider.getInternalId(entityView.getCustomerId())); + } + + @Override + protected EntityView prepare(EntitiesImportCtx ctx, EntityView entityView, EntityView old, EntityExportData exportData, IdProvider idProvider) { + entityView.setEntityId(idProvider.getInternalId(entityView.getEntityId())); + return entityView; + } + + @Override + protected EntityView saveOrUpdate(EntitiesImportCtx ctx, EntityView entityView, EntityExportData exportData, IdProvider idProvider) { + return entityViewService.saveEntityView(entityView); + } + + @Override + protected void onEntitySaved(User user, EntityView savedEntityView, EntityView oldEntityView) throws ThingsboardException { + tbEntityViewService.updateEntityViewAttributes(user.getTenantId(), savedEntityView, oldEntityView, user); + super.onEntitySaved(user, savedEntityView, oldEntityView); + clusterService.broadcastEntityStateChangeEvent(savedEntityView.getTenantId(), savedEntityView.getId(), + oldEntityView == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + } + + @Override + protected EntityView deepCopy(EntityView entityView) { + return new EntityView(entityView); + } + + @Override + protected void cleanupForComparison(EntityView e) { + super.cleanupForComparison(e); + if (e.getCustomerId() != null && e.getCustomerId().isNullUid()) { + e.setCustomerId(null); + } + } + + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ImportServiceException.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ImportServiceException.java new file mode 100644 index 0000000..1e86942 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/ImportServiceException.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +public class ImportServiceException extends RuntimeException{ + private static final long serialVersionUID = -4932715239522125041L; +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/MissingEntityException.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/MissingEntityException.java new file mode 100644 index 0000000..a0a961b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/MissingEntityException.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; + +public class MissingEntityException extends ImportServiceException { + + private static final long serialVersionUID = 3669135386955906022L; + @Getter + private final EntityId entityId; + + public MissingEntityException(EntityId entityId) { + this.entityId = entityId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/RuleChainImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/RuleChainImportService.java new file mode 100644 index 0000000..f01eb15 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/RuleChainImportService.java @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.sync.ie.RuleChainExportData; +import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.rule.RuleNodeDao; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class RuleChainImportService extends BaseEntityImportService { + + private static final LinkedHashSet HINTS = new LinkedHashSet<>(Arrays.asList(EntityType.RULE_CHAIN, EntityType.DEVICE, EntityType.ASSET)); + + private final RuleChainService ruleChainService; + private final RuleNodeDao ruleNodeDao; + + @Override + protected void setOwner(TenantId tenantId, RuleChain ruleChain, IdProvider idProvider) { + ruleChain.setTenantId(tenantId); + } + + @Override + protected RuleChain findExistingEntity(EntitiesImportCtx ctx, RuleChain ruleChain, IdProvider idProvider) { + RuleChain existingRuleChain = super.findExistingEntity(ctx, ruleChain, idProvider); + if (existingRuleChain == null && ctx.isFindExistingByName()) { + existingRuleChain = ruleChainService.findTenantRuleChainsByTypeAndName(ctx.getTenantId(), ruleChain.getType(), ruleChain.getName()).stream().findFirst().orElse(null); + } + return existingRuleChain; + } + + @Override + protected RuleChain prepare(EntitiesImportCtx ctx, RuleChain ruleChain, RuleChain old, RuleChainExportData exportData, IdProvider idProvider) { + RuleChainMetaData metaData = exportData.getMetaData(); + List ruleNodes = Optional.ofNullable(metaData.getNodes()).orElse(Collections.emptyList()); + if (old != null) { + List nodeIds = ruleNodes.stream().map(RuleNode::getId).collect(Collectors.toList()); + List existing = ruleNodeDao.findByExternalIds(old.getId(), nodeIds); + existing.forEach(node -> ctx.putInternalId(node.getExternalId(), node.getId())); + ruleNodes.forEach(node -> { + node.setRuleChainId(old.getId()); + node.setExternalId(node.getId()); + node.setId((RuleNodeId) ctx.getInternalId(node.getId())); + }); + } else { + ruleNodes.forEach(node -> { + node.setRuleChainId(null); + node.setExternalId(node.getId()); + node.setId(null); + }); + } + + ruleNodes.forEach(ruleNode -> replaceIdsRecursively(ctx, idProvider, ruleNode.getConfiguration(), Collections.emptySet(), HINTS)); + Optional.ofNullable(metaData.getRuleChainConnections()).orElse(Collections.emptyList()) + .forEach(ruleChainConnectionInfo -> { + ruleChainConnectionInfo.setTargetRuleChainId(idProvider.getInternalId(ruleChainConnectionInfo.getTargetRuleChainId(), false)); + }); + if (ruleChain.getFirstRuleNodeId() != null) { + ruleChain.setFirstRuleNodeId((RuleNodeId) ctx.getInternalId(ruleChain.getFirstRuleNodeId())); + } + return ruleChain; + } + + @Override + protected RuleChain saveOrUpdate(EntitiesImportCtx ctx, RuleChain ruleChain, RuleChainExportData exportData, IdProvider idProvider) { + ruleChain = ruleChainService.saveRuleChain(ruleChain); + if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + exportData.getMetaData().setRuleChainId(ruleChain.getId()); + ruleChainService.saveRuleChainMetaData(ctx.getTenantId(), exportData.getMetaData()); + return ruleChainService.findRuleChainById(ctx.getTenantId(), ruleChain.getId()); + } else { + return ruleChain; + } + } + + @Override + protected boolean compare(EntitiesImportCtx ctx, RuleChainExportData exportData, RuleChain prepared, RuleChain existing) { + boolean different = super.compare(ctx, exportData, prepared, existing); + if (!different) { + RuleChainMetaData newMD = exportData.getMetaData(); + RuleChainMetaData existingMD = ruleChainService.loadRuleChainMetaData(ctx.getTenantId(), prepared.getId()); + existingMD.setRuleChainId(null); + different = !newMD.equals(existingMD); + } + return different; + } + + @Override + protected void onEntitySaved(User user, RuleChain savedRuleChain, RuleChain oldRuleChain) throws ThingsboardException { + entityActionService.logEntityAction(user, savedRuleChain.getId(), savedRuleChain, null, + oldRuleChain == null ? ActionType.ADDED : ActionType.UPDATED, null); + if (savedRuleChain.getType() == RuleChainType.CORE) { + clusterService.broadcastEntityStateChangeEvent(user.getTenantId(), savedRuleChain.getId(), + oldRuleChain == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED); + } else if (savedRuleChain.getType() == RuleChainType.EDGE && oldRuleChain != null) { + entityActionService.sendEntityNotificationMsgToEdge(user.getTenantId(), savedRuleChain.getId(), EdgeEventActionType.UPDATED); + } + } + + @Override + protected RuleChain deepCopy(RuleChain ruleChain) { + return new RuleChain(ruleChain); + } + + @Override + public EntityType getEntityType() { + return EntityType.RULE_CHAIN; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/WidgetsBundleImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/WidgetsBundleImportService.java new file mode 100644 index 0000000..adbbcf6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/WidgetsBundleImportService.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.sync.ie.WidgetsBundleExportData; +import org.thingsboard.server.common.data.widget.BaseWidgetType; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetTypeInfo; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class WidgetsBundleImportService extends BaseEntityImportService { + + private final WidgetsBundleService widgetsBundleService; + private final WidgetTypeService widgetTypeService; + + @Override + protected void setOwner(TenantId tenantId, WidgetsBundle widgetsBundle, IdProvider idProvider) { + widgetsBundle.setTenantId(tenantId); + } + + @Override + protected WidgetsBundle prepare(EntitiesImportCtx ctx, WidgetsBundle widgetsBundle, WidgetsBundle old, WidgetsBundleExportData exportData, IdProvider idProvider) { + return widgetsBundle; + } + + @Override + protected WidgetsBundle saveOrUpdate(EntitiesImportCtx ctx, WidgetsBundle widgetsBundle, WidgetsBundleExportData exportData, IdProvider idProvider) { + WidgetsBundle savedWidgetsBundle = widgetsBundleService.saveWidgetsBundle(widgetsBundle); + if (widgetsBundle.getId() == null) { + for (WidgetTypeDetails widget : exportData.getWidgets()) { + widget.setId(null); + widget.setTenantId(ctx.getTenantId()); + widget.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetTypeService.saveWidgetType(widget); + } + } else { + Map existingWidgets = widgetTypeService.findWidgetTypesInfosByTenantIdAndBundleAlias(ctx.getTenantId(), savedWidgetsBundle.getAlias()).stream() + .collect(Collectors.toMap(BaseWidgetType::getAlias, w -> w)); + for (WidgetTypeDetails widget : exportData.getWidgets()) { + WidgetTypeInfo existingWidget; + if ((existingWidget = existingWidgets.remove(widget.getAlias())) != null) { + widget.setId(existingWidget.getId()); + widget.setCreatedTime(existingWidget.getCreatedTime()); + } else { + widget.setId(null); + } + widget.setTenantId(ctx.getTenantId()); + widget.setBundleAlias(savedWidgetsBundle.getAlias()); + widgetTypeService.saveWidgetType(widget); + } + existingWidgets.values().stream() + .map(BaseWidgetType::getId) + .forEach(widgetTypeId -> widgetTypeService.deleteWidgetType(ctx.getTenantId(), widgetTypeId)); + } + return savedWidgetsBundle; + } + + @Override + protected boolean compare(EntitiesImportCtx ctx, WidgetsBundleExportData exportData, WidgetsBundle prepared, WidgetsBundle existing) { + return true; + } + + @Override + protected void onEntitySaved(User user, WidgetsBundle savedWidgetsBundle, WidgetsBundle oldWidgetsBundle) throws ThingsboardException { + entityNotificationService.notifySendMsgToEdgeService(user.getTenantId(), savedWidgetsBundle.getId(), + oldWidgetsBundle == null ? EdgeEventActionType.ADDED : EdgeEventActionType.UPDATED); + } + + @Override + protected WidgetsBundle deepCopy(WidgetsBundle widgetsBundle) { + return new WidgetsBundle(widgetsBundle); + } + + @Override + public EntityType getEntityType() { + return EntityType.WIDGETS_BUNDLE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java new file mode 100644 index 0000000..8cf17fd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -0,0 +1,635 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.TbStopWatch; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.sync.ThrowingRunnable; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.common.data.sync.ie.EntityImportSettings; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; +import org.thingsboard.server.common.data.sync.vc.EntityDataDiff; +import org.thingsboard.server.common.data.sync.vc.EntityDataInfo; +import org.thingsboard.server.common.data.sync.vc.EntityLoadError; +import org.thingsboard.server.common.data.sync.vc.EntityTypeLoadResult; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionLoadResult; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; +import org.thingsboard.server.common.data.sync.vc.request.create.AutoVersionCreateConfig; +import org.thingsboard.server.common.data.sync.vc.request.create.ComplexVersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.create.EntityTypeVersionCreateConfig; +import org.thingsboard.server.common.data.sync.vc.request.create.SingleEntityVersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.create.SyncStrategy; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.EntityTypeVersionLoadRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.SingleEntityVersionLoadRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadConfig; +import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadRequest; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.TbNotificationEntityService; +import org.thingsboard.server.service.sync.ie.EntitiesExportImportService; +import org.thingsboard.server.service.sync.ie.exporting.ExportableEntitiesService; +import org.thingsboard.server.service.sync.ie.importing.impl.MissingEntityException; +import org.thingsboard.server.service.sync.vc.autocommit.TbAutoCommitSettingsService; +import org.thingsboard.server.service.sync.vc.data.CommitGitRequest; +import org.thingsboard.server.service.sync.vc.data.ComplexEntitiesExportCtx; +import org.thingsboard.server.service.sync.vc.data.EntitiesExportCtx; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; +import org.thingsboard.server.service.sync.vc.data.EntityTypeExportCtx; +import org.thingsboard.server.service.sync.vc.data.ReimportTask; +import org.thingsboard.server.service.sync.vc.data.SimpleEntitiesExportCtx; +import org.thingsboard.server.service.sync.vc.repository.TbRepositorySettingsService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.google.common.util.concurrent.Futures.transform; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +@Slf4j +public class DefaultEntitiesVersionControlService implements EntitiesVersionControlService { + + private final TbRepositorySettingsService repositorySettingsService; + private final TbAutoCommitSettingsService autoCommitSettingsService; + private final GitVersionControlQueueService gitServiceQueue; + private final EntitiesExportImportService exportImportService; + private final ExportableEntitiesService exportableEntitiesService; + private final TbNotificationEntityService entityNotificationService; + private final EdgeService edgeService; + private final TransactionTemplate transactionTemplate; + private final TbTransactionalCache taskCache; + + private ListeningExecutorService executor; + + @Value("${vc.thread_pool_size:4}") + private int threadPoolSize; + + @PostConstruct + public void init() { + executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, DefaultEntitiesVersionControlService.class)); + } + + @PreDestroy + public void shutdown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception { + var pendingCommit = gitServiceQueue.prepareCommit(user, request); + DonAsynchron.withCallback(pendingCommit, commit -> { + cachePut(commit.getTxId(), new VersionCreationResult()); + try { + EntitiesExportCtx theCtx; + switch (request.getType()) { + case SINGLE_ENTITY: { + var ctx = new SimpleEntitiesExportCtx(user, commit, (SingleEntityVersionCreateRequest) request); + handleSingleEntityRequest(ctx); + theCtx = ctx; + break; + } + case COMPLEX: { + var ctx = new ComplexEntitiesExportCtx(user, commit, (ComplexVersionCreateRequest) request); + handleComplexRequest(ctx); + theCtx = ctx; + break; + } + default: + throw new RuntimeException("Unsupported request type: " + request.getType()); + } + var resultFuture = Futures.transformAsync(Futures.allAsList(theCtx.getFutures()), f -> gitServiceQueue.push(commit), executor); + DonAsynchron.withCallback(resultFuture, result -> cachePut(commit.getTxId(), result), e -> processCommitError(user, request, commit, e), executor); + } catch (Exception e) { + processCommitError(user, request, commit, e); + } + }, t -> log.debug("[{}] Failed to prepare the commit: {}", user.getId(), request, t)); + + return transform(pendingCommit, CommitGitRequest::getTxId, MoreExecutors.directExecutor()); + } + + @Override + public VersionCreationResult getVersionCreateStatus(User user, UUID requestId) throws ThingsboardException { + return getStatus(user, requestId, VersionControlTaskCacheEntry::getExportResult); + } + + @Override + public VersionLoadResult getVersionLoadStatus(User user, UUID requestId) throws ThingsboardException { + return getStatus(user, requestId, VersionControlTaskCacheEntry::getImportResult); + } + + private T getStatus(User user, UUID requestId, Function getter) throws ThingsboardException { + var cacheEntry = taskCache.get(requestId); + if (cacheEntry == null || cacheEntry.get() == null) { + log.debug("[{}] No cache record: {}", requestId, cacheEntry); + throw new ThingsboardException(ThingsboardErrorCode.ITEM_NOT_FOUND); + } else { + var entry = cacheEntry.get(); + log.debug("[{}] Cache get: {}", requestId, entry); + var result = getter.apply(entry); + if (result == null) { + throw new ThingsboardException(ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } else { + return result; + } + } + } + + private void handleSingleEntityRequest(SimpleEntitiesExportCtx ctx) throws Exception { + ctx.add(saveEntityData(ctx, ctx.getRequest().getEntityId())); + } + + private void handleComplexRequest(ComplexEntitiesExportCtx parentCtx) { + ComplexVersionCreateRequest request = parentCtx.getRequest(); + request.getEntityTypes().forEach((entityType, config) -> { + EntityTypeExportCtx ctx = new EntityTypeExportCtx(parentCtx, config, request.getSyncStrategy(), entityType); + if (ctx.isOverwrite()) { + ctx.add(gitServiceQueue.deleteAll(ctx.getCommit(), entityType)); + } + + if (config.isAllEntities()) { + DaoUtil.processInBatches(pageLink -> exportableEntitiesService.findEntitiesByTenantId(ctx.getTenantId(), entityType, pageLink) + , 100, entity -> { + try { + ctx.add(saveEntityData(ctx, entity.getId())); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } else { + for (UUID entityId : config.getEntityIds()) { + try { + ctx.add(saveEntityData(ctx, EntityIdFactory.getByTypeAndUuid(entityType, entityId))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + }); + } + + private ListenableFuture saveEntityData(EntitiesExportCtx ctx, EntityId entityId) throws Exception { + EntityExportData> entityData = exportImportService.exportEntity(ctx, entityId); + return gitServiceQueue.addToCommit(ctx.getCommit(), entityData); + } + + @Override + public ListenableFuture> listEntityVersions(TenantId tenantId, String branch, EntityId externalId, PageLink pageLink) throws Exception { + return gitServiceQueue.listVersions(tenantId, branch, externalId, pageLink); + } + + @Override + public ListenableFuture> listEntityTypeVersions(TenantId tenantId, String branch, EntityType entityType, PageLink pageLink) throws Exception { + return gitServiceQueue.listVersions(tenantId, branch, entityType, pageLink); + } + + @Override + public ListenableFuture> listVersions(TenantId tenantId, String branch, PageLink pageLink) throws Exception { + return gitServiceQueue.listVersions(tenantId, branch, pageLink); + } + + @Override + public ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId, EntityType entityType) throws Exception { + return gitServiceQueue.listEntitiesAtVersion(tenantId, versionId, entityType); + } + + @Override + public ListenableFuture> listAllEntitiesAtVersion(TenantId tenantId, String versionId) throws Exception { + return gitServiceQueue.listEntitiesAtVersion(tenantId, versionId); + } + + @SuppressWarnings({"UnstableApiUsage", "rawtypes"}) + @Override + public UUID loadEntitiesVersion(User user, VersionLoadRequest request) throws Exception { + EntitiesImportCtx ctx = new EntitiesImportCtx(UUID.randomUUID(), user, request.getVersionId()); + cachePut(ctx.getRequestId(), VersionLoadResult.empty()); + switch (request.getType()) { + case SINGLE_ENTITY: { + SingleEntityVersionLoadRequest versionLoadRequest = (SingleEntityVersionLoadRequest) request; + VersionLoadConfig config = versionLoadRequest.getConfig(); + ListenableFuture future = gitServiceQueue.getEntity(user.getTenantId(), request.getVersionId(), versionLoadRequest.getExternalEntityId()); + DonAsynchron.withCallback(future, + entityData -> doInTemplate(ctx, request, c -> loadSingleEntity(c, config, entityData)), + e -> processLoadError(ctx, e), executor); + break; + } + case ENTITY_TYPE: { + EntityTypeVersionLoadRequest versionLoadRequest = (EntityTypeVersionLoadRequest) request; + executor.submit(() -> doInTemplate(ctx, request, c -> loadMultipleEntities(c, versionLoadRequest))); + break; + } + default: + throw new IllegalArgumentException("Unsupported version load request"); + } + + return ctx.getRequestId(); + } + + private VersionLoadResult doInTemplate(EntitiesImportCtx ctx, VersionLoadRequest request, Function function) { + try { + VersionLoadResult result = transactionTemplate.execute(status -> function.apply(ctx)); + for (ThrowingRunnable throwingRunnable : ctx.getEventCallbacks()) { + throwingRunnable.run(); + } + result.setDone(true); + return cachePut(ctx.getRequestId(), result); + } catch (LoadEntityException e) { + return cachePut(ctx.getRequestId(), onError(e.getExternalId(), e.getCause())); + } catch (Exception e) { + log.info("[{}] Failed to process request [{}] due to: ", ctx.getTenantId(), request, e); + return cachePut(ctx.getRequestId(), VersionLoadResult.error(EntityLoadError.runtimeError(e))); + } + } + + private VersionLoadResult loadSingleEntity(EntitiesImportCtx ctx, VersionLoadConfig config, EntityExportData entityData) { + try { + ctx.setSettings(EntityImportSettings.builder() + .updateRelations(config.isLoadRelations()) + .saveAttributes(config.isLoadAttributes()) + .saveCredentials(config.isLoadCredentials()) + .findExistingByName(false) + .build()); + ctx.setFinalImportAttempt(true); + EntityImportResult importResult = exportImportService.importEntity(ctx, entityData); + + exportImportService.saveReferencesAndRelations(ctx); + + return VersionLoadResult.success(EntityTypeLoadResult.builder() + .entityType(importResult.getEntityType()) + .created(importResult.getOldEntity() == null ? 1 : 0) + .updated(importResult.getOldEntity() != null ? 1 : 0) + .deleted(0) + .build()); + } catch (Exception e) { + throw new LoadEntityException(entityData.getExternalId(), e); + } + } + + @SneakyThrows + private VersionLoadResult loadMultipleEntities(EntitiesImportCtx ctx, EntityTypeVersionLoadRequest request) { + var sw = TbStopWatch.create("before"); + + List entityTypes = request.getEntityTypes().keySet().stream() + .sorted(exportImportService.getEntityTypeComparatorForImport()).collect(Collectors.toList()); + for (EntityType entityType : entityTypes) { + log.debug("[{}] Loading {} entities", ctx.getTenantId(), entityType); + sw.startNew("Entities " + entityType.name()); + ctx.setSettings(getEntityImportSettings(request, entityType)); + importEntities(ctx, entityType); + persistToCache(ctx); + } + + sw.startNew("Reimport"); + reimport(ctx); + persistToCache(ctx); + + sw.startNew("Remove Others"); + request.getEntityTypes().keySet().stream() + .filter(entityType -> request.getEntityTypes().get(entityType).isRemoveOtherEntities()) + .sorted(exportImportService.getEntityTypeComparatorForImport().reversed()) + .forEach(entityType -> removeOtherEntities(ctx, entityType)); + persistToCache(ctx); + + sw.startNew("References and Relations"); + exportImportService.saveReferencesAndRelations(ctx); + + sw.stop(); + for (var task : sw.getTaskInfo()) { + log.info("[{}] Executed: {} in {}ms", ctx.getTenantId(), task.getTaskName(), task.getTimeMillis()); + } + log.info("[{}] Total time: {}ms", ctx.getTenantId(), sw.getTotalTimeMillis()); + return VersionLoadResult.success(new ArrayList<>(ctx.getResults().values())); + } + + private EntityImportSettings getEntityImportSettings(EntityTypeVersionLoadRequest request, EntityType entityType) { + var config = request.getEntityTypes().get(entityType); + return EntityImportSettings.builder() + .updateRelations(config.isLoadRelations()) + .saveAttributes(config.isLoadAttributes()) + .saveCredentials(config.isLoadCredentials()) + .findExistingByName(config.isFindExistingEntityByName()) + .build(); + } + + @SneakyThrows + @SuppressWarnings({"rawtypes", "unchecked"}) + private void importEntities(EntitiesImportCtx ctx, EntityType entityType) { + int limit = 100; + int offset = 0; + List entityDataList; + do { + try { + entityDataList = gitServiceQueue.getEntities(ctx.getTenantId(), ctx.getVersionId(), entityType, offset, limit).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + log.debug("[{}] Loading {} entities pack ({})", ctx.getTenantId(), entityType, entityDataList.size()); + for (EntityExportData entityData : entityDataList) { + EntityExportData reimportBackup = JacksonUtil.clone(entityData); + EntityImportResult importResult; + try { + importResult = exportImportService.importEntity(ctx, entityData); + } catch (Exception e) { + throw new LoadEntityException(entityData.getExternalId(), e); + } + registerResult(ctx, entityType, importResult); + + if (!importResult.isUpdatedAllExternalIds()) { + ctx.getToReimport().put(entityData.getEntity().getExternalId(), new ReimportTask(reimportBackup, ctx.getSettings())); + continue; + } + ctx.getImportedEntities().computeIfAbsent(entityType, t -> new HashSet<>()) + .add(importResult.getSavedEntity().getId()); + } + log.debug("Imported {} pack for tenant {}", entityType, ctx.getTenantId()); + offset += limit; + } while (entityDataList.size() == limit); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void reimport(EntitiesImportCtx ctx) { + ctx.setFinalImportAttempt(true); + ctx.getToReimport().forEach((externalId, task) -> { + try { + EntityExportData entityData = task.getData(); + var settings = task.getSettings(); + ctx.setSettings(settings); + EntityImportResult importResult = exportImportService.importEntity(ctx, entityData); + + ctx.getImportedEntities().computeIfAbsent(externalId.getEntityType(), t -> new HashSet<>()) + .add(importResult.getSavedEntity().getId()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private void removeOtherEntities(EntitiesImportCtx ctx, EntityType entityType) { + DaoUtil.processInBatches(pageLink -> { + return exportableEntitiesService.findEntitiesByTenantId(ctx.getTenantId(), entityType, pageLink); + }, 100, entity -> { + if (ctx.getImportedEntities().get(entityType) == null || !ctx.getImportedEntities().get(entityType).contains(entity.getId())) { + List relatedEdgeIds = edgeService.findAllRelatedEdgeIds(ctx.getTenantId(), entity.getId()); + exportableEntitiesService.removeById(ctx.getTenantId(), entity.getId()); + + ctx.addEventCallback(() -> { + entityNotificationService.notifyDeleteEntity(ctx.getTenantId(), entity.getId(), + entity, null, ActionType.DELETED, relatedEdgeIds, ctx.getUser()); + }); + ctx.registerDeleted(entityType); + } + }); + } + + private VersionLoadResult onError(EntityId externalId, Throwable e) { + return analyze(e, externalId).orElse(VersionLoadResult.error(EntityLoadError.runtimeError(e))); + } + + private Optional analyze(Throwable e, EntityId externalId) { + if (e == null) { + return Optional.empty(); + } else { + if (e instanceof DeviceCredentialsValidationException) { + return Optional.of(VersionLoadResult.error(EntityLoadError.credentialsError(externalId))); + } else if (e instanceof MissingEntityException) { + return Optional.of(VersionLoadResult.error(EntityLoadError.referenceEntityError(externalId, ((MissingEntityException) e).getEntityId()))); + } else { + return analyze(e.getCause(), externalId); + } + } + } + + @Override + public ListenableFuture compareEntityDataToVersion(User user, EntityId entityId, String versionId) throws Exception { + HasId entity = exportableEntitiesService.findEntityByTenantIdAndId(user.getTenantId(), entityId); + if (!(entity instanceof ExportableEntity)) throw new IllegalArgumentException("Unsupported entity type"); + + EntityId externalId = ((ExportableEntity) entity).getExternalId(); + if (externalId == null) externalId = entityId; + + return transform(gitServiceQueue.getEntity(user.getTenantId(), versionId, externalId), + otherVersion -> { + SimpleEntitiesExportCtx ctx = new SimpleEntitiesExportCtx(user, null, null, EntityExportSettings.builder() + .exportRelations(otherVersion.hasRelations()) + .exportAttributes(otherVersion.hasAttributes()) + .exportCredentials(otherVersion.hasCredentials()) + .build()); + EntityExportData currentVersion; + try { + currentVersion = exportImportService.exportEntity(ctx, entityId); + } catch (ThingsboardException e) { + throw new RuntimeException(e); + } + return new EntityDataDiff(currentVersion.sort(), otherVersion.sort()); + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId) { + return Futures.transform(gitServiceQueue.getEntity(user.getTenantId(), versionId, entityId), + entity -> new EntityDataInfo(entity.hasRelations(), entity.hasAttributes(), entity.hasCredentials()), MoreExecutors.directExecutor()); + } + + + @Override + public ListenableFuture> listBranches(TenantId tenantId) throws Exception { + return gitServiceQueue.listBranches(tenantId); + } + + @Override + public RepositorySettings getVersionControlSettings(TenantId tenantId) { + return repositorySettingsService.get(tenantId); + } + + @Override + public ListenableFuture saveVersionControlSettings(TenantId tenantId, RepositorySettings versionControlSettings) { + var restoredSettings = this.repositorySettingsService.restore(tenantId, versionControlSettings); + try { + var future = gitServiceQueue.initRepository(tenantId, restoredSettings); + return Futures.transform(future, f -> repositorySettingsService.save(tenantId, restoredSettings), MoreExecutors.directExecutor()); + } catch (Exception e) { + log.debug("{} Failed to init repository: {}", tenantId, versionControlSettings, e); + throw new RuntimeException("Failed to init repository!", e); + } + } + + @Override + public ListenableFuture deleteVersionControlSettings(TenantId tenantId) throws Exception { + if (repositorySettingsService.delete(tenantId)) { + return gitServiceQueue.clearRepository(tenantId); + } else { + return Futures.immediateFuture(null); + } + } + + @Override + public ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws ThingsboardException { + settings = this.repositorySettingsService.restore(tenantId, settings); + try { + return gitServiceQueue.testRepository(tenantId, settings); + } catch (Exception e) { + throw new ThingsboardException(String.format("Unable to access repository: %s", getCauseMessage(e)), + ThingsboardErrorCode.GENERAL); + } + } + + @Override + public ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + var repositorySettings = repositorySettingsService.get(user.getTenantId()); + if (repositorySettings == null || repositorySettings.isReadOnly()) { + return Futures.immediateFuture(null); + } + var autoCommitSettings = autoCommitSettingsService.get(user.getTenantId()); + if (autoCommitSettings == null) { + return Futures.immediateFuture(null); + } + var entityType = entityId.getEntityType(); + AutoVersionCreateConfig autoCommitConfig = autoCommitSettings.get(entityType); + if (autoCommitConfig == null) { + return Futures.immediateFuture(null); + } + SingleEntityVersionCreateRequest vcr = new SingleEntityVersionCreateRequest(); + var autoCommitBranchName = autoCommitConfig.getBranch(); + if (StringUtils.isEmpty(autoCommitBranchName)) { + autoCommitBranchName = StringUtils.isNotEmpty(repositorySettings.getDefaultBranch()) ? repositorySettings.getDefaultBranch() : "auto-commits"; + } + vcr.setBranch(autoCommitBranchName); + vcr.setVersionName("auto-commit at " + Instant.ofEpochSecond(System.currentTimeMillis() / 1000)); + vcr.setEntityId(entityId); + vcr.setConfig(autoCommitConfig); + return saveEntitiesVersion(user, vcr); + } + + @Override + public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + var repositorySettings = repositorySettingsService.get(user.getTenantId()); + if (repositorySettings == null || repositorySettings.isReadOnly()) { + return Futures.immediateFuture(null); + } + var autoCommitSettings = autoCommitSettingsService.get(user.getTenantId()); + if (autoCommitSettings == null) { + return Futures.immediateFuture(null); + } + AutoVersionCreateConfig autoCommitConfig = autoCommitSettings.get(entityType); + if (autoCommitConfig == null) { + return Futures.immediateFuture(null); + } + var autoCommitBranchName = autoCommitConfig.getBranch(); + if (StringUtils.isEmpty(autoCommitBranchName)) { + autoCommitBranchName = StringUtils.isNotEmpty(repositorySettings.getDefaultBranch()) ? repositorySettings.getDefaultBranch() : "auto-commits"; + } + ComplexVersionCreateRequest vcr = new ComplexVersionCreateRequest(); + vcr.setBranch(autoCommitBranchName); + vcr.setVersionName("auto-commit at " + Instant.ofEpochSecond(System.currentTimeMillis() / 1000)); + vcr.setSyncStrategy(SyncStrategy.MERGE); + + EntityTypeVersionCreateConfig vcrConfig = new EntityTypeVersionCreateConfig(); + vcrConfig.setEntityIds(entityIds); + vcr.setEntityTypes(Collections.singletonMap(entityType, vcrConfig)); + return saveEntitiesVersion(user, vcr); + } + + private String getCauseMessage(Exception e) { + String message; + if (e.getCause() != null && StringUtils.isNotEmpty(e.getCause().getMessage())) { + message = e.getCause().getMessage(); + } else { + message = e.getMessage(); + } + return message; + } + + private void registerResult(EntitiesImportCtx ctx, EntityType entityType, EntityImportResult importResult) { + if (importResult.isCreated()) { + ctx.registerResult(entityType, true); + } else if (importResult.isUpdated() || importResult.isUpdatedRelatedEntities()) { + ctx.registerResult(entityType, false); + } + } + + private void processCommitError(User user, VersionCreateRequest request, CommitGitRequest commit, Throwable e) { + log.debug("[{}] Failed to prepare the commit: {}", user.getId(), request, e); + cachePut(commit.getTxId(), new VersionCreationResult(e.getMessage())); + } + + private void processLoadError(EntitiesImportCtx ctx, Throwable e) { + log.debug("[{}] Failed to load the commit: {}", ctx.getRequestId(), ctx.getVersionId(), e); + cachePut(ctx.getRequestId(), VersionLoadResult.error(EntityLoadError.runtimeError(e))); + } + + private void cachePut(UUID requestId, VersionCreationResult result) { + taskCache.put(requestId, VersionControlTaskCacheEntry.newForExport(result)); + } + + private VersionLoadResult cachePut(UUID requestId, VersionLoadResult result) { + log.debug("[{}] Cache put: {}", requestId, result); + taskCache.put(requestId, VersionControlTaskCacheEntry.newForImport(result)); + return result; + } + + private void persistToCache(EntitiesImportCtx ctx) { + cachePut(ctx.getRequestId(), VersionLoadResult.success(new ArrayList<>(ctx.getResults().values()))); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java new file mode 100644 index 0000000..413980d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultGitVersionControlQueueService.java @@ -0,0 +1,605 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import com.google.common.collect.Iterables; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import com.google.protobuf.ByteString; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.CollectionsUtil; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.EntityVersionsDiff; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.CommitRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.EntitiesContentRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.EntityContentRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GenericRepositoryRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ListEntitiesRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ListVersionsRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PrepareMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToVersionControlServiceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.VersionControlResponseMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.scheduler.SchedulerComponent; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.ClearRepositoryGitRequest; +import org.thingsboard.server.service.sync.vc.data.CommitGitRequest; +import org.thingsboard.server.service.sync.vc.data.ContentsDiffGitRequest; +import org.thingsboard.server.service.sync.vc.data.EntitiesContentGitRequest; +import org.thingsboard.server.service.sync.vc.data.EntityContentGitRequest; +import org.thingsboard.server.service.sync.vc.data.ListBranchesGitRequest; +import org.thingsboard.server.service.sync.vc.data.ListEntitiesGitRequest; +import org.thingsboard.server.service.sync.vc.data.ListVersionsGitRequest; +import org.thingsboard.server.service.sync.vc.data.PendingGitRequest; +import org.thingsboard.server.service.sync.vc.data.VersionsDiffGitRequest; +import org.thingsboard.server.service.sync.vc.data.VoidGitRequest; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +@TbCoreComponent +@Service +@Slf4j +public class DefaultGitVersionControlQueueService implements GitVersionControlQueueService { + + private final TbServiceInfoProvider serviceInfoProvider; + private final TbClusterService clusterService; + private final DataDecodingEncodingService encodingService; + private final DefaultEntitiesVersionControlService entitiesVersionControlService; + private final SchedulerComponent scheduler; + + private final Map> pendingRequestMap = new HashMap<>(); + private final Map> chunkedMsgs = new ConcurrentHashMap<>(); + + @Value("${queue.vc.request-timeout:60000}") + private int requestTimeout; + @Value("${queue.vc.msg-chunk-size:500000}") + private int msgChunkSize; + + public DefaultGitVersionControlQueueService(TbServiceInfoProvider serviceInfoProvider, TbClusterService clusterService, + DataDecodingEncodingService encodingService, + @Lazy DefaultEntitiesVersionControlService entitiesVersionControlService, + SchedulerComponent scheduler) { + this.serviceInfoProvider = serviceInfoProvider; + this.clusterService = clusterService; + this.encodingService = encodingService; + this.entitiesVersionControlService = entitiesVersionControlService; + this.scheduler = scheduler; + } + + @Override + public ListenableFuture prepareCommit(User user, VersionCreateRequest request) { + SettableFuture future = SettableFuture.create(); + + CommitGitRequest commit = new CommitGitRequest(user.getTenantId(), request); + registerAndSend(commit, builder -> builder.setCommitRequest( + buildCommitRequest(commit).setPrepareMsg(getCommitPrepareMsg(user, request)).build() + ).build(), wrap(future, commit)); + return future; + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public ListenableFuture addToCommit(CommitGitRequest commit, EntityExportData> entityData) { + String path = getRelativePath(entityData.getEntityType(), entityData.getExternalId()); + String entityDataJson = JacksonUtil.toPrettyString(entityData.sort()); + + Iterable entityDataChunks = StringUtils.split(entityDataJson, msgChunkSize); + String chunkedMsgId = UUID.randomUUID().toString(); + int chunksCount = Iterables.size(entityDataChunks); + + AtomicInteger chunkIndex = new AtomicInteger(); + List> futures = new ArrayList<>(); + entityDataChunks.forEach(chunk -> { + SettableFuture chunkFuture = SettableFuture.create(); + log.trace("[{}] sending chunk {} for 'addToCommit'", chunkedMsgId, chunkIndex.get()); + registerAndSend(commit, builder -> builder.setCommitRequest( + buildCommitRequest(commit).setAddMsg( + TransportProtos.AddMsg.newBuilder() + .setRelativePath(path).setEntityDataJsonChunk(chunk) + .setChunkedMsgId(chunkedMsgId).setChunkIndex(chunkIndex.getAndIncrement()) + .setChunksCount(chunksCount).build() + ).build() + ).build(), wrap(chunkFuture, null)); + futures.add(chunkFuture); + }); + return Futures.transform(Futures.allAsList(futures), r -> { + log.trace("[{}] sent all chunks for 'addToCommit'", chunkedMsgId); + return null; + }, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture deleteAll(CommitGitRequest commit, EntityType entityType) { + SettableFuture future = SettableFuture.create(); + + String path = getRelativePath(entityType, null); + + registerAndSend(commit, builder -> builder.setCommitRequest( + buildCommitRequest(commit).setDeleteMsg( + TransportProtos.DeleteMsg.newBuilder().setRelativePath(path).build() + ).build() + ).build(), wrap(future, null)); + + return future; + } + + @Override + public ListenableFuture push(CommitGitRequest commit) { + registerAndSend(commit, builder -> builder.setCommitRequest( + buildCommitRequest(commit).setPushMsg( + TransportProtos.PushMsg.newBuilder().build() + ).build() + ).build(), wrap(commit.getFuture())); + + return commit.getFuture(); + } + + @Override + public ListenableFuture> listVersions(TenantId tenantId, String branch, PageLink pageLink) { + + return listVersions(tenantId, + applyPageLinkParameters( + ListVersionsRequestMsg.newBuilder() + .setBranchName(branch), + pageLink + ).build()); + } + + @Override + public ListenableFuture> listVersions(TenantId tenantId, String branch, EntityType entityType, PageLink pageLink) { + return listVersions(tenantId, + applyPageLinkParameters( + ListVersionsRequestMsg.newBuilder() + .setBranchName(branch) + .setEntityType(entityType.name()), + pageLink + ).build()); + } + + @Override + public ListenableFuture> listVersions(TenantId tenantId, String branch, EntityId entityId, PageLink pageLink) { + return listVersions(tenantId, + applyPageLinkParameters( + ListVersionsRequestMsg.newBuilder() + .setBranchName(branch) + .setEntityType(entityId.getEntityType().name()) + .setEntityIdMSB(entityId.getId().getMostSignificantBits()) + .setEntityIdLSB(entityId.getId().getLeastSignificantBits()), + pageLink + ).build()); + } + + private ListVersionsRequestMsg.Builder applyPageLinkParameters(ListVersionsRequestMsg.Builder builder, PageLink pageLink) { + builder.setPageSize(pageLink.getPageSize()) + .setPage(pageLink.getPage()); + if (pageLink.getTextSearch() != null) { + builder.setTextSearch(pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + if (pageLink.getSortOrder().getProperty() != null) { + builder.setSortProperty(pageLink.getSortOrder().getProperty()); + } + if (pageLink.getSortOrder().getDirection() != null) { + builder.setSortDirection(pageLink.getSortOrder().getDirection().name()); + } + } + return builder; + } + + private ListenableFuture> listVersions(TenantId tenantId, ListVersionsRequestMsg requestMsg) { + ListVersionsGitRequest request = new ListVersionsGitRequest(tenantId); + return sendRequest(request, builder -> builder.setListVersionRequest(requestMsg)); + } + + @Override + public ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId, EntityType entityType) { + return listEntitiesAtVersion(tenantId, ListEntitiesRequestMsg.newBuilder() + .setVersionId(versionId) + .setEntityType(entityType.name()) + .build()); + } + + @Override + public ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId) { + return listEntitiesAtVersion(tenantId, ListEntitiesRequestMsg.newBuilder() + .setVersionId(versionId) + .build()); + } + + private ListenableFuture> listEntitiesAtVersion(TenantId tenantId, TransportProtos.ListEntitiesRequestMsg requestMsg) { + ListEntitiesGitRequest request = new ListEntitiesGitRequest(tenantId); + return sendRequest(request, builder -> builder.setListEntitiesRequest(requestMsg)); + } + + @Override + public ListenableFuture> listBranches(TenantId tenantId) { + ListBranchesGitRequest request = new ListBranchesGitRequest(tenantId); + return sendRequest(request, builder -> builder.setListBranchesRequest(TransportProtos.ListBranchesRequestMsg.newBuilder().build())); + } + + @Override + public ListenableFuture> getVersionsDiff(TenantId tenantId, EntityType entityType, EntityId externalId, String versionId1, String versionId2) { + String path = entityType != null ? getRelativePath(entityType, externalId) : ""; + VersionsDiffGitRequest request = new VersionsDiffGitRequest(tenantId, path, versionId1, versionId2); + return sendRequest(request, builder -> builder.setVersionsDiffRequest(TransportProtos.VersionsDiffRequestMsg.newBuilder() + .setPath(request.getPath()) + .setVersionId1(request.getVersionId1()) + .setVersionId2(request.getVersionId2()) + .build())); + } + + @Override + @SuppressWarnings("rawtypes") + public ListenableFuture getEntity(TenantId tenantId, String versionId, EntityId entityId) { + EntityContentGitRequest request = new EntityContentGitRequest(tenantId, versionId, entityId); + chunkedMsgs.put(request.getRequestId(), new HashMap<>()); + registerAndSend(request, builder -> builder.setEntityContentRequest(EntityContentRequestMsg.newBuilder() + .setVersionId(versionId) + .setEntityType(entityId.getEntityType().name()) + .setEntityIdMSB(entityId.getId().getMostSignificantBits()) + .setEntityIdLSB(entityId.getId().getLeastSignificantBits())).build() + , wrap(request.getFuture())); + return request.getFuture(); + } + + private void registerAndSend(PendingGitRequest request, + Function enrichFunction, TbQueueCallback callback) { + registerAndSend(request, enrichFunction, null, callback); + } + + private void registerAndSend(PendingGitRequest request, + Function enrichFunction, RepositorySettings settings, TbQueueCallback callback) { + if (!request.getFuture().isDone()) { + pendingRequestMap.putIfAbsent(request.getRequestId(), request); + var requestBody = enrichFunction.apply(newRequestProto(request, settings)); + log.trace("[{}][{}] PUSHING request: {}", request.getTenantId(), request.getRequestId(), requestBody); + clusterService.pushMsgToVersionControl(request.getTenantId(), requestBody, callback); + if (request.getTimeoutTask() == null) { + request.setTimeoutTask(scheduler.schedule(() -> processTimeout(request.getRequestId()), requestTimeout, TimeUnit.MILLISECONDS)); + } + } else { + throw new RuntimeException("Future is already done!"); + } + } + + private ListenableFuture sendRequest(PendingGitRequest request, Consumer enrichFunction) { + registerAndSend(request, builder -> { + enrichFunction.accept(builder); + return builder.build(); + }, wrap(request.getFuture())); + return request.getFuture(); + } + + @Override + @SuppressWarnings("rawtypes") + public ListenableFuture> getEntities(TenantId tenantId, String versionId, EntityType entityType, int offset, int limit) { + EntitiesContentGitRequest request = new EntitiesContentGitRequest(tenantId, versionId, entityType); + chunkedMsgs.put(request.getRequestId(), new HashMap<>()); + registerAndSend(request, builder -> builder.setEntitiesContentRequest(EntitiesContentRequestMsg.newBuilder() + .setVersionId(versionId) + .setEntityType(entityType.name()) + .setOffset(offset) + .setLimit(limit) + ).build() + , wrap(request.getFuture())); + + return request.getFuture(); + } + + @Override + public ListenableFuture initRepository(TenantId tenantId, RepositorySettings settings) { + VoidGitRequest request = new VoidGitRequest(tenantId); + + registerAndSend(request, builder -> builder.setInitRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build() + , settings, wrap(request.getFuture())); + + return request.getFuture(); + } + + @Override + public ListenableFuture testRepository(TenantId tenantId, RepositorySettings settings) { + VoidGitRequest request = new VoidGitRequest(tenantId); + + registerAndSend(request, builder -> builder + .setTestRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build() + , settings, wrap(request.getFuture())); + + return request.getFuture(); + } + + @Override + public ListenableFuture clearRepository(TenantId tenantId) { + ClearRepositoryGitRequest request = new ClearRepositoryGitRequest(tenantId); + + registerAndSend(request, builder -> builder.setClearRepositoryRequest(GenericRepositoryRequestMsg.newBuilder().build()).build() + , wrap(request.getFuture())); + + return request.getFuture(); + } + + @Override + public void processResponse(VersionControlResponseMsg vcResponseMsg) { + UUID requestId = new UUID(vcResponseMsg.getRequestIdMSB(), vcResponseMsg.getRequestIdLSB()); + PendingGitRequest request = pendingRequestMap.get(requestId); + if (request == null) { + log.debug("[{}] received stale response: {}", requestId, vcResponseMsg); + return; + } else { + log.debug("[{}] processing response: {}", requestId, vcResponseMsg); + } + var future = request.getFuture(); + boolean completed = true; + if (!StringUtils.isEmpty(vcResponseMsg.getError())) { + future.setException(new RuntimeException(vcResponseMsg.getError())); + } else { + try { + if (vcResponseMsg.hasGenericResponse()) { + future.set(null); + } else if (vcResponseMsg.hasCommitResponse()) { + var commitResponse = vcResponseMsg.getCommitResponse(); + var commitResult = new VersionCreationResult(); + if (commitResponse.getTs() > 0) { + commitResult.setVersion(new EntityVersion(commitResponse.getTs(), commitResponse.getCommitId(), commitResponse.getName(), commitResponse.getAuthor())); + } + commitResult.setAdded(commitResponse.getAdded()); + commitResult.setRemoved(commitResponse.getRemoved()); + commitResult.setModified(commitResponse.getModified()); + commitResult.setDone(true); + ((CommitGitRequest) request).getFuture().set(commitResult); + } else if (vcResponseMsg.hasListBranchesResponse()) { + var listBranchesResponse = vcResponseMsg.getListBranchesResponse(); + ((ListBranchesGitRequest) request).getFuture().set(listBranchesResponse.getBranchesList().stream().map(this::getBranchInfo).collect(Collectors.toList())); + } else if (vcResponseMsg.hasListEntitiesResponse()) { + var listEntitiesResponse = vcResponseMsg.getListEntitiesResponse(); + ((ListEntitiesGitRequest) request).getFuture().set( + listEntitiesResponse.getEntitiesList().stream().map(this::getVersionedEntityInfo).collect(Collectors.toList())); + } else if (vcResponseMsg.hasListVersionsResponse()) { + var listVersionsResponse = vcResponseMsg.getListVersionsResponse(); + ((ListVersionsGitRequest) request).getFuture().set(toPageData(listVersionsResponse)); + } else if (vcResponseMsg.hasEntityContentResponse()) { + TransportProtos.EntityContentResponseMsg responseMsg = vcResponseMsg.getEntityContentResponse(); + log.trace("Received chunk {} for 'getEntity'", responseMsg.getChunkIndex()); + var joined = joinChunks(requestId, responseMsg, 0, 1); + if (joined.isPresent()) { + log.trace("Collected all chunks for 'getEntity'"); + ((EntityContentGitRequest) request).getFuture().set(joined.get().get(0)); + } else { + completed = false; + } + } else if (vcResponseMsg.hasEntitiesContentResponse()) { + TransportProtos.EntitiesContentResponseMsg responseMsg = vcResponseMsg.getEntitiesContentResponse(); + TransportProtos.EntityContentResponseMsg item = responseMsg.getItem(); + if (responseMsg.getItemsCount() > 0) { + var joined = joinChunks(requestId, item, responseMsg.getItemIdx(), responseMsg.getItemsCount()); + if (joined.isPresent()) { + ((EntitiesContentGitRequest) request).getFuture().set(joined.get()); + } else { + completed = false; + } + } else { + ((EntitiesContentGitRequest) request).getFuture().set(Collections.emptyList()); + } + } else if (vcResponseMsg.hasVersionsDiffResponse()) { + TransportProtos.VersionsDiffResponseMsg diffResponse = vcResponseMsg.getVersionsDiffResponse(); + List entityVersionsDiffList = diffResponse.getDiffList().stream() + .map(diff -> EntityVersionsDiff.builder() + .externalId(EntityIdFactory.getByTypeAndUuid(EntityType.valueOf(diff.getEntityType()), + new UUID(diff.getEntityIdMSB(), diff.getEntityIdLSB()))) + .entityDataAtVersion1(StringUtils.isNotEmpty(diff.getEntityDataAtVersion1()) ? + toData(diff.getEntityDataAtVersion1()) : null) + .entityDataAtVersion2(StringUtils.isNotEmpty(diff.getEntityDataAtVersion2()) ? + toData(diff.getEntityDataAtVersion2()) : null) + .rawDiff(diff.getRawDiff()) + .build()) + .collect(Collectors.toList()); + ((VersionsDiffGitRequest) request).getFuture().set(entityVersionsDiffList); + } + } catch (Exception e) { + future.setException(e); + throw e; + } + } + if (completed) { + removePendingRequest(requestId); + } + } + + @SuppressWarnings("rawtypes") + private Optional> joinChunks(UUID requestId, TransportProtos.EntityContentResponseMsg responseMsg, int itemIdx, int expectedMsgCount) { + var chunksMap = chunkedMsgs.get(requestId); + if (chunksMap == null) { + return Optional.empty(); + } + String[] msgChunks = chunksMap.computeIfAbsent(itemIdx, id -> new String[responseMsg.getChunksCount()]); + msgChunks[responseMsg.getChunkIndex()] = responseMsg.getData(); + if (chunksMap.size() == expectedMsgCount && chunksMap.values().stream() + .allMatch(chunks -> CollectionsUtil.countNonNull(chunks) == chunks.length)) { + return Optional.of(chunksMap.entrySet().stream() + .sorted(Comparator.comparingInt(Map.Entry::getKey)).map(Map.Entry::getValue) + .map(chunks -> String.join("", chunks)) + .map(this::toData) + .collect(Collectors.toList())); + } else { + return Optional.empty(); + } + } + + private void processTimeout(UUID requestId) { + PendingGitRequest pendingRequest = removePendingRequest(requestId); + if (pendingRequest != null) { + log.debug("[{}] request timed out ({} ms}", requestId, requestTimeout); + pendingRequest.getFuture().setException(new TimeoutException("Request timed out")); + } + } + + private PendingGitRequest removePendingRequest(UUID requestId) { + PendingGitRequest pendingRequest = pendingRequestMap.remove(requestId); + if (pendingRequest != null && pendingRequest.getTimeoutTask() != null) { + pendingRequest.getTimeoutTask().cancel(true); + pendingRequest.setTimeoutTask(null); + } + chunkedMsgs.remove(requestId); + return pendingRequest; + } + + private PageData toPageData(TransportProtos.ListVersionsResponseMsg listVersionsResponse) { + var listVersions = listVersionsResponse.getVersionsList().stream().map(this::getEntityVersion).collect(Collectors.toList()); + return new PageData<>(listVersions, listVersionsResponse.getTotalPages(), listVersionsResponse.getTotalElements(), listVersionsResponse.getHasNext()); + } + + private EntityVersion getEntityVersion(TransportProtos.EntityVersionProto proto) { + return new EntityVersion(proto.getTs(), proto.getId(), proto.getName(), proto.getAuthor()); + } + + private VersionedEntityInfo getVersionedEntityInfo(TransportProtos.VersionedEntityInfoProto proto) { + return new VersionedEntityInfo(EntityIdFactory.getByTypeAndUuid(proto.getEntityType(), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB()))); + } + + private BranchInfo getBranchInfo(TransportProtos.BranchInfoProto proto) { + return new BranchInfo(proto.getName(), proto.getIsDefault()); + } + + @SuppressWarnings("rawtypes") + @SneakyThrows + private EntityExportData toData(String data) { + return JacksonUtil.fromString(data, EntityExportData.class); + } + + //The future will be completed when the corresponding result arrives from kafka + private static TbQueueCallback wrap(SettableFuture future) { + return new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + }; + } + + //The future will be completed when the request is successfully sent to kafka + private TbQueueCallback wrap(SettableFuture future, T value) { + return new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + future.set(value); + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + }; + } + + private static String getRelativePath(EntityType entityType, EntityId entityId) { + String path = entityType.name().toLowerCase(); + if (entityId != null) { + path += "/" + entityId + ".json"; + } + return path; + } + + private static PrepareMsg getCommitPrepareMsg(User user, VersionCreateRequest request) { + return PrepareMsg.newBuilder().setCommitMsg(request.getVersionName()) + .setBranchName(request.getBranch()).setAuthorName(getAuthorName(user)).setAuthorEmail(user.getEmail()).build(); + } + + private static String getAuthorName(User user) { + List parts = new ArrayList<>(); + if (StringUtils.isNotBlank(user.getFirstName())) { + parts.add(user.getFirstName()); + } + if (StringUtils.isNotBlank(user.getLastName())) { + parts.add(user.getLastName()); + } + if (parts.isEmpty()) { + parts.add(user.getName()); + } + return String.join(" ", parts); + } + + private ToVersionControlServiceMsg.Builder newRequestProto(PendingGitRequest request, RepositorySettings settings) { + var tenantId = request.getTenantId(); + var requestId = request.getRequestId(); + var builder = ToVersionControlServiceMsg.newBuilder() + .setNodeId(serviceInfoProvider.getServiceId()) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()); + RepositorySettings vcSettings = settings; + if (vcSettings == null && request.requiresSettings()) { + vcSettings = entitiesVersionControlService.getVersionControlSettings(tenantId); + } + if (vcSettings != null) { + builder.setVcSettings(ByteString.copyFrom(encodingService.encode(vcSettings))); + } else if (request.requiresSettings()) { + throw new RuntimeException("No entity version control settings provisioned!"); + } + return builder; + } + + private CommitRequestMsg.Builder buildCommitRequest(CommitGitRequest commit) { + return CommitRequestMsg.newBuilder().setTxId(commit.getTxId().toString()); + } +} + diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java new file mode 100644 index 0000000..8a2410e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; +import org.thingsboard.server.common.data.sync.vc.EntityDataDiff; +import org.thingsboard.server.common.data.sync.vc.EntityDataInfo; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionLoadResult; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadRequest; + +import java.util.List; +import java.util.UUID; + +public interface EntitiesVersionControlService { + + ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception; + + VersionCreationResult getVersionCreateStatus(User user, UUID requestId) throws ThingsboardException; + + ListenableFuture> listEntityVersions(TenantId tenantId, String branch, EntityId externalId, PageLink pageLink) throws Exception; + + ListenableFuture> listEntityTypeVersions(TenantId tenantId, String branch, EntityType entityType, PageLink pageLink) throws Exception; + + ListenableFuture> listVersions(TenantId tenantId, String branch, PageLink pageLink) throws Exception; + + ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId, EntityType entityType) throws Exception; + + ListenableFuture> listAllEntitiesAtVersion(TenantId tenantId, String versionId) throws Exception; + + UUID loadEntitiesVersion(User user, VersionLoadRequest request) throws Exception; + + VersionLoadResult getVersionLoadStatus(User user, UUID requestId) throws ThingsboardException; + + ListenableFuture compareEntityDataToVersion(User user, EntityId entityId, String versionId) throws Exception; + + ListenableFuture> listBranches(TenantId tenantId) throws Exception; + + RepositorySettings getVersionControlSettings(TenantId tenantId); + + ListenableFuture saveVersionControlSettings(TenantId tenantId, RepositorySettings versionControlSettings); + + ListenableFuture deleteVersionControlSettings(TenantId tenantId) throws Exception; + + ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception; + + ListenableFuture autoCommit(User user, EntityId entityId) throws Exception; + + ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception; + + ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/GitVersionControlQueueService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/GitVersionControlQueueService.java new file mode 100644 index 0000000..feba7db --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/GitVersionControlQueueService.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; +import org.thingsboard.server.common.data.sync.vc.EntityVersionsDiff; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; +import org.thingsboard.server.gen.transport.TransportProtos.VersionControlResponseMsg; +import org.thingsboard.server.service.sync.vc.data.CommitGitRequest; + +import java.util.List; + +public interface GitVersionControlQueueService { + + ListenableFuture prepareCommit(User user, VersionCreateRequest request); + + ListenableFuture addToCommit(CommitGitRequest commit, EntityExportData> entityData); + + ListenableFuture deleteAll(CommitGitRequest pendingCommit, EntityType entityType); + + ListenableFuture push(CommitGitRequest commit); + + ListenableFuture> listVersions(TenantId tenantId, String branch, PageLink pageLink); + + ListenableFuture> listVersions(TenantId tenantId, String branch, EntityType entityType, PageLink pageLink); + + ListenableFuture> listVersions(TenantId tenantId, String branch, EntityId entityId, PageLink pageLink); + + ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId, EntityType entityType); + + ListenableFuture> listEntitiesAtVersion(TenantId tenantId, String versionId); + + ListenableFuture> listBranches(TenantId tenantId); + + ListenableFuture getEntity(TenantId tenantId, String versionId, EntityId entityId); + + ListenableFuture> getEntities(TenantId tenantId, String versionId, EntityType entityType, int offset, int limit); + + ListenableFuture> getVersionsDiff(TenantId tenantId, EntityType entityType, EntityId externalId, String versionId1, String versionId2); + + ListenableFuture initRepository(TenantId tenantId, RepositorySettings settings); + + ListenableFuture testRepository(TenantId tenantId, RepositorySettings settings); + + ListenableFuture clearRepository(TenantId tenantId); + + void processResponse(VersionControlResponseMsg vcResponseMsg); +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/LoadEntityException.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/LoadEntityException.java new file mode 100644 index 0000000..13d8280 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/LoadEntityException.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; + +@SuppressWarnings("rawtypes") +public class LoadEntityException extends RuntimeException { + + private static final long serialVersionUID = -1749719992370409504L; + @Getter + private final EntityId externalId; + + public LoadEntityException(EntityId externalId, Throwable cause) { + super(cause); + this.externalId = externalId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/TbAbstractVersionControlSettingsService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/TbAbstractVersionControlSettingsService.java new file mode 100644 index 0000000..3a674ee --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/TbAbstractVersionControlSettingsService.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.settings.AdminSettingsService; + +import java.io.Serializable; + +public abstract class TbAbstractVersionControlSettingsService { + + private final String settingsKey; + private final AdminSettingsService adminSettingsService; + private final TbTransactionalCache cache; + private final Class clazz; + + public TbAbstractVersionControlSettingsService(AdminSettingsService adminSettingsService, TbTransactionalCache cache, Class clazz, String settingsKey) { + this.adminSettingsService = adminSettingsService; + this.cache = cache; + this.clazz = clazz; + this.settingsKey = settingsKey; + } + + public T get(TenantId tenantId) { + return cache.getAndPutInTransaction(tenantId, () -> { + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByTenantIdAndKey(tenantId, settingsKey); + if (adminSettings != null) { + try { + return JacksonUtil.convertValue(adminSettings.getJsonValue(), clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to load " + settingsKey + " settings!", e); + } + } + return null; + }, true); + } + + public T save(TenantId tenantId, T settings) { + AdminSettings adminSettings = adminSettingsService.findAdminSettingsByTenantIdAndKey(tenantId, settingsKey); + if (adminSettings == null) { + adminSettings = new AdminSettings(); + adminSettings.setKey(settingsKey); + adminSettings.setTenantId(tenantId); + } + adminSettings.setJsonValue(JacksonUtil.valueToTree(settings)); + AdminSettings savedAdminSettings = adminSettingsService.saveAdminSettings(tenantId, adminSettings); + T savedSettings; + try { + savedSettings = JacksonUtil.convertValue(savedAdminSettings.getJsonValue(), clazz); + } catch (Exception e) { + throw new RuntimeException("Failed to load auto commit settings!", e); + } + //API calls to adminSettingsService are not in transaction, so we can simply evict the cache. + cache.evict(tenantId); + return savedSettings; + } + + public boolean delete(TenantId tenantId) { + boolean result = adminSettingsService.deleteAdminSettingsByTenantIdAndKey(tenantId, settingsKey); + cache.evict(tenantId); + return result; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCacheEntry.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCacheEntry.java new file mode 100644 index 0000000..dcf3ef3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCacheEntry.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.VersionLoadResult; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +public class VersionControlTaskCacheEntry implements Serializable { + + private static final long serialVersionUID = -7875992200801588119L; + + private VersionCreationResult exportResult; + private VersionLoadResult importResult; + + public static VersionControlTaskCacheEntry newForExport(VersionCreationResult result) { + return new VersionControlTaskCacheEntry(result, null); + } + + public static VersionControlTaskCacheEntry newForImport(VersionLoadResult result) { + return new VersionControlTaskCacheEntry(null, result); + } + + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCaffeineCache.java new file mode 100644 index 0000000..e58ceba --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskCaffeineCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("VersionControlTaskCache") +public class VersionControlTaskCaffeineCache extends CaffeineTbTransactionalCache { + + public VersionControlTaskCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.VERSION_CONTROL_TASK_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskRedisCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskRedisCache.java new file mode 100644 index 0000000..6215744 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/VersionControlTaskRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbFSTRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; + +import java.util.UUID; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("VersionControlTaskCache") +public class VersionControlTaskRedisCache extends RedisTbTransactionalCache { + + public VersionControlTaskRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.VERSION_CONTROL_TASK_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbFSTRedisSerializer<>()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsCaffeineCache.java new file mode 100644 index 0000000..47b9e5f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsCaffeineCache.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.autocommit; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("AutoCommitSettingsCache") +public class AutoCommitSettingsCaffeineCache extends CaffeineTbTransactionalCache { + + public AutoCommitSettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AUTO_COMMIT_SETTINGS_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsRedisCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsRedisCache.java new file mode 100644 index 0000000..e3c9188 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/AutoCommitSettingsRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.autocommit; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbFSTRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("AutoCommitSettingsCache") +public class AutoCommitSettingsRedisCache extends RedisTbTransactionalCache { + + public AutoCommitSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AUTO_COMMIT_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbFSTRedisSerializer<>()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/DefaultTbAutoCommitSettingsService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/DefaultTbAutoCommitSettingsService.java new file mode 100644 index 0000000..b6e8d45 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/DefaultTbAutoCommitSettingsService.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.autocommit; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.TbAbstractVersionControlSettingsService; + +@Service +@TbCoreComponent +public class DefaultTbAutoCommitSettingsService extends TbAbstractVersionControlSettingsService implements TbAutoCommitSettingsService { + + public static final String SETTINGS_KEY = "autoCommitSettings"; + + public DefaultTbAutoCommitSettingsService(AdminSettingsService adminSettingsService, TbTransactionalCache cache) { + super(adminSettingsService, cache, AutoCommitSettings.class, SETTINGS_KEY); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/TbAutoCommitSettingsService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/TbAutoCommitSettingsService.java new file mode 100644 index 0000000..5197848 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/autocommit/TbAutoCommitSettingsService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.autocommit; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.AutoCommitSettings; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; + +public interface TbAutoCommitSettingsService { + + AutoCommitSettings get(TenantId tenantId); + + AutoCommitSettings save(TenantId tenantId, AutoCommitSettings settings); + + boolean delete(TenantId tenantId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ClearRepositoryGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ClearRepositoryGitRequest.java new file mode 100644 index 0000000..09245eb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ClearRepositoryGitRequest.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.id.TenantId; + +public class ClearRepositoryGitRequest extends VoidGitRequest { + + public ClearRepositoryGitRequest(TenantId tenantId) { + super(tenantId); + } + + public boolean requiresSettings() { + return false; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/CommitGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/CommitGitRequest.java new file mode 100644 index 0000000..a989439 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/CommitGitRequest.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.VersionCreationResult; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; + +import java.util.UUID; + +public class CommitGitRequest extends PendingGitRequest { + + @Getter + private final UUID txId; + private final VersionCreateRequest request; + + public CommitGitRequest(TenantId tenantId, VersionCreateRequest request) { + super(tenantId); + this.txId = UUID.randomUUID(); + this.request = request; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ComplexEntitiesExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ComplexEntitiesExportCtx.java new file mode 100644 index 0000000..e8577b0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ComplexEntitiesExportCtx.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; +import org.thingsboard.server.common.data.sync.vc.request.create.ComplexVersionCreateRequest; + +import java.util.HashMap; +import java.util.Map; + +public class ComplexEntitiesExportCtx extends EntitiesExportCtx { + + private final Map settings = new HashMap<>(); + + public ComplexEntitiesExportCtx(User user, CommitGitRequest commit, ComplexVersionCreateRequest request) { + super(user, commit, request); + request.getEntityTypes().forEach((type, config) -> settings.put(type, buildExportSettings(config))); + } + + public EntityExportSettings getSettings(EntityType entityType) { + return settings.get(entityType); + } + + @Override + public EntityExportSettings getSettings() { + throw new RuntimeException("Not implemented. Use EntityTypeExportCtx instead!"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ContentsDiffGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ContentsDiffGitRequest.java new file mode 100644 index 0000000..7c3fb7a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ContentsDiffGitRequest.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.TenantId; + +@Getter +public class ContentsDiffGitRequest extends PendingGitRequest { + + private final String content1; + private final String content2; + + public ContentsDiffGitRequest(TenantId tenantId, String content1, String content2) { + super(tenantId); + this.content1 = content1; + this.content2 = content2; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesContentGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesContentGitRequest.java new file mode 100644 index 0000000..ad653ed --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesContentGitRequest.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; + +import java.util.List; + +@Getter +public class EntitiesContentGitRequest extends PendingGitRequest> { + + private final String versionId; + private final EntityType entityType; + + public EntitiesContentGitRequest(TenantId tenantId, String versionId, EntityType entityType) { + super(tenantId); + this.versionId = versionId; + this.entityType = entityType; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java new file mode 100644 index 0000000..4c89c91 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesExportCtx.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateConfig; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Data +public abstract class EntitiesExportCtx { + + protected final User user; + protected final CommitGitRequest commit; + protected final R request; + private final List> futures; + private final Map externalIdMap; + + public EntitiesExportCtx(User user, CommitGitRequest commit, R request) { + this.user = user; + this.commit = commit; + this.request = request; + this.futures = new ArrayList<>(); + this.externalIdMap = new HashMap<>(); + } + + protected EntitiesExportCtx(EntitiesExportCtx other) { + this.user = other.getUser(); + this.commit = other.getCommit(); + this.request = other.getRequest(); + this.futures = other.getFutures(); + this.externalIdMap = other.getExternalIdMap(); + } + + public void add(ListenableFuture future) { + futures.add(future); + } + + public TenantId getTenantId() { + return user.getTenantId(); + } + + protected static EntityExportSettings buildExportSettings(VersionCreateConfig config) { + return EntityExportSettings.builder() + .exportRelations(config.isSaveRelations()) + .exportAttributes(config.isSaveAttributes()) + .exportCredentials(config.isSaveCredentials()) + .build(); + } + + public abstract EntityExportSettings getSettings(); + + @SuppressWarnings("unchecked") + public ID getExternalId(ID internalId) { + var result = externalIdMap.get(internalId); + log.debug("[{}][{}] Local cache {} for id", internalId.getEntityType(), internalId.getId(), result != null ? "hit" : "miss"); + return (ID) result; + } + + public void putExternalId(EntityId internalId, EntityId externalId) { + log.debug("[{}][{}] Local cache put: {}", internalId.getEntityType(), internalId.getId(), externalId); + externalIdMap.put(internalId, externalId != null ? externalId : internalId); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java new file mode 100644 index 0000000..fddc918 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntitiesImportCtx.java @@ -0,0 +1,143 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.sync.ThrowingRunnable; +import org.thingsboard.server.common.data.sync.ie.EntityImportResult; +import org.thingsboard.server.common.data.sync.ie.EntityImportSettings; +import org.thingsboard.server.common.data.sync.vc.EntityTypeLoadResult; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Slf4j +@Data +public class EntitiesImportCtx { + + private final UUID requestId; + private final User user; + private final String versionId; + + private final Map results = new HashMap<>(); + private final Map> importedEntities = new HashMap<>(); + private final Map toReimport = new HashMap<>(); + private final Map referenceCallbacks = new HashMap<>(); + private final List eventCallbacks = new ArrayList<>(); + private final Map externalToInternalIdMap = new HashMap<>(); + private final Set notFoundIds = new HashSet<>(); + + private final Set relations = new LinkedHashSet<>(); + + private boolean finalImportAttempt = false; + private EntityImportSettings settings; + private EntityImportResult currentImportResult; + + public EntitiesImportCtx(UUID requestId, User user, String versionId) { + this(requestId, user, versionId, null); + } + + public EntitiesImportCtx(UUID requestId, User user, String versionId, EntityImportSettings settings) { + this.requestId = requestId; + this.user = user; + this.versionId = versionId; + this.settings = settings; + } + + public TenantId getTenantId() { + return user.getTenantId(); + } + + public boolean isFindExistingByName() { + return getSettings().isFindExistingByName(); + } + + public boolean isUpdateRelations() { + return getSettings().isUpdateRelations(); + } + + public boolean isSaveAttributes() { + return getSettings().isSaveAttributes(); + } + + public boolean isSaveCredentials() { + return getSettings().isSaveCredentials(); + } + + public EntityId getInternalId(EntityId externalId) { + var result = externalToInternalIdMap.get(externalId); + log.debug("[{}][{}] Local cache {} for id", externalId.getEntityType(), externalId.getId(), result != null ? "hit" : "miss"); + return result; + } + + public void putInternalId(EntityId externalId, EntityId internalId) { + log.debug("[{}][{}] Local cache put: {}", externalId.getEntityType(), externalId.getId(), internalId); + externalToInternalIdMap.put(externalId, internalId); + } + + public void registerResult(EntityType entityType, boolean created) { + EntityTypeLoadResult result = results.computeIfAbsent(entityType, EntityTypeLoadResult::new); + if (created) { + result.setCreated(result.getCreated() + 1); + } else { + result.setUpdated(result.getUpdated() + 1); + } + } + + public void registerDeleted(EntityType entityType) { + EntityTypeLoadResult result = results.computeIfAbsent(entityType, EntityTypeLoadResult::new); + result.setDeleted(result.getDeleted() + 1); + } + + public void addRelations(Collection values) { + relations.addAll(values); + } + + public void addReferenceCallback(EntityId externalId, ThrowingRunnable tr) { + if (tr != null) { + referenceCallbacks.put(externalId, tr); + } + } + + public void addEventCallback(ThrowingRunnable tr) { + if (tr != null) { + eventCallbacks.add(tr); + } + } + + public void registerNotFound(EntityId externalId) { + notFoundIds.add(externalId); + } + + public boolean isNotFound(EntityId externalId) { + return notFoundIds.contains(externalId); + } + + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityContentGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityContentGitRequest.java new file mode 100644 index 0000000..6a1d60a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityContentGitRequest.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; + +@Getter +public class EntityContentGitRequest extends PendingGitRequest { + + private final String versionId; + private final EntityId entityId; + + public EntityContentGitRequest(TenantId tenantId, String versionId, EntityId entityId) { + super(tenantId); + this.versionId = versionId; + this.entityId = entityId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java new file mode 100644 index 0000000..80d9059 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/EntityTypeExportCtx.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.apache.commons.lang3.ObjectUtils; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; +import org.thingsboard.server.common.data.sync.vc.request.create.EntityTypeVersionCreateConfig; +import org.thingsboard.server.common.data.sync.vc.request.create.SyncStrategy; +import org.thingsboard.server.common.data.sync.vc.request.create.VersionCreateRequest; + +public class EntityTypeExportCtx extends EntitiesExportCtx { + + @Getter + private final EntityType entityType; + @Getter + private final boolean overwrite; + @Getter + private final EntityExportSettings settings; + + public EntityTypeExportCtx(EntitiesExportCtx parent, EntityTypeVersionCreateConfig config, SyncStrategy defaultSyncStrategy, EntityType entityType) { + super(parent); + this.entityType = entityType; + this.settings = EntityExportSettings.builder() + .exportRelations(config.isSaveRelations()) + .exportAttributes(config.isSaveAttributes()) + .exportCredentials(config.isSaveCredentials()) + .build(); + this.overwrite = ObjectUtils.defaultIfNull(config.getSyncStrategy(), defaultSyncStrategy) == SyncStrategy.OVERWRITE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListBranchesGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListBranchesGitRequest.java new file mode 100644 index 0000000..4d89efa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListBranchesGitRequest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.BranchInfo; + +import java.util.List; + +public class ListBranchesGitRequest extends PendingGitRequest> { + + public ListBranchesGitRequest(TenantId tenantId) { + super(tenantId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListEntitiesGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListEntitiesGitRequest.java new file mode 100644 index 0000000..3fc5317 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListEntitiesGitRequest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.VersionedEntityInfo; + +import java.util.List; + +public class ListEntitiesGitRequest extends PendingGitRequest> { + + public ListEntitiesGitRequest(TenantId tenantId) { + super(tenantId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListVersionsGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListVersionsGitRequest.java new file mode 100644 index 0000000..d40a589 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ListVersionsGitRequest.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.sync.vc.EntityVersion; + +public class ListVersionsGitRequest extends PendingGitRequest> { + + public ListVersionsGitRequest(TenantId tenantId) { + super(tenantId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/PendingGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/PendingGitRequest.java new file mode 100644 index 0000000..b34806a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/PendingGitRequest.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import com.google.common.util.concurrent.SettableFuture; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; +import java.util.concurrent.ScheduledFuture; + +@Getter +public class PendingGitRequest { + + private final long createdTime; + private final UUID requestId; + private final TenantId tenantId; + private final SettableFuture future; + @Setter + private ScheduledFuture timeoutTask; + + public PendingGitRequest(TenantId tenantId) { + this.createdTime = System.currentTimeMillis(); + this.requestId = UUID.randomUUID(); + this.tenantId = tenantId; + this.future = SettableFuture.create(); + } + + public boolean requiresSettings() { + return true; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ReimportTask.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ReimportTask.java new file mode 100644 index 0000000..97432ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/ReimportTask.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Data; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.common.data.sync.ie.EntityImportSettings; + +@Data +public class ReimportTask { + + private final EntityExportData data; + private final EntityImportSettings settings; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/SimpleEntitiesExportCtx.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/SimpleEntitiesExportCtx.java new file mode 100644 index 0000000..4cf5d93 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/SimpleEntitiesExportCtx.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; +import org.thingsboard.server.common.data.sync.vc.request.create.SingleEntityVersionCreateRequest; + +public class SimpleEntitiesExportCtx extends EntitiesExportCtx { + + @Getter + private final EntityExportSettings settings; + + public SimpleEntitiesExportCtx(User user, CommitGitRequest commit, SingleEntityVersionCreateRequest request) { + this(user, commit, request, request != null ? buildExportSettings(request.getConfig()) : null); + } + + public SimpleEntitiesExportCtx(User user, CommitGitRequest commit, SingleEntityVersionCreateRequest request, EntityExportSettings settings) { + super(user, commit, request); + this.settings = settings; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VersionsDiffGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VersionsDiffGitRequest.java new file mode 100644 index 0000000..e737066 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VersionsDiffGitRequest.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import lombok.Getter; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.EntityVersionsDiff; + +import java.util.List; + +@Getter +public class VersionsDiffGitRequest extends PendingGitRequest> { + + private final String path; + private final String versionId1; + private final String versionId2; + + public VersionsDiffGitRequest(TenantId tenantId, String path, String versionId1, String versionId2) { + super(tenantId); + this.path = path; + this.versionId1 = versionId1; + this.versionId2 = versionId2; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VoidGitRequest.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VoidGitRequest.java new file mode 100644 index 0000000..c48bfa7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/data/VoidGitRequest.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.data; + +import org.thingsboard.server.common.data.id.TenantId; + +public class VoidGitRequest extends PendingGitRequest { + + public VoidGitRequest(TenantId tenantId) { + super(tenantId); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/DefaultTbRepositorySettingsService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/DefaultTbRepositorySettingsService.java new file mode 100644 index 0000000..555490b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/DefaultTbRepositorySettingsService.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.repository; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; +import org.thingsboard.server.common.data.sync.vc.RepositoryAuthMethod; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.TbAbstractVersionControlSettingsService; + +@Service +@TbCoreComponent +public class DefaultTbRepositorySettingsService extends TbAbstractVersionControlSettingsService implements TbRepositorySettingsService { + + public static final String SETTINGS_KEY = "entitiesVersionControl"; + + public DefaultTbRepositorySettingsService(AdminSettingsService adminSettingsService, TbTransactionalCache cache) { + super(adminSettingsService, cache, RepositorySettings.class, SETTINGS_KEY); + } + + @Override + public RepositorySettings restore(TenantId tenantId, RepositorySettings settings) { + RepositorySettings storedSettings = get(tenantId); + if (storedSettings != null) { + RepositoryAuthMethod authMethod = settings.getAuthMethod(); + if (RepositoryAuthMethod.USERNAME_PASSWORD.equals(authMethod) && settings.getPassword() == null) { + settings.setPassword(storedSettings.getPassword()); + } else if (RepositoryAuthMethod.PRIVATE_KEY.equals(authMethod) && settings.getPrivateKey() == null) { + settings.setPrivateKey(storedSettings.getPrivateKey()); + if (settings.getPrivateKeyPassword() == null) { + settings.setPrivateKeyPassword(storedSettings.getPrivateKeyPassword()); + } + } + } + return settings; + } + + @Override + public RepositorySettings get(TenantId tenantId) { + RepositorySettings settings = super.get(tenantId); + if (settings != null) { + settings = new RepositorySettings(settings); + } + return settings; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsCaffeineCache.java new file mode 100644 index 0000000..60b7f50 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsCaffeineCache.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.repository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("RepositorySettingsCache") +public class RepositorySettingsCaffeineCache extends CaffeineTbTransactionalCache { + + public RepositorySettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.REPOSITORY_SETTINGS_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsRedisCache.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsRedisCache.java new file mode 100644 index 0000000..a49ace3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/RepositorySettingsRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.repository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbFSTRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("RepositorySettingsCache") +public class RepositorySettingsRedisCache extends RedisTbTransactionalCache { + + public RepositorySettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.REPOSITORY_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbFSTRedisSerializer<>()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/TbRepositorySettingsService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/TbRepositorySettingsService.java new file mode 100644 index 0000000..946d06c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/repository/TbRepositorySettingsService.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.vc.repository; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.sync.vc.RepositorySettings; + +public interface TbRepositorySettingsService { + + RepositorySettings restore(TenantId tenantId, RepositorySettings versionControlSettings); + + RepositorySettings get(TenantId tenantId); + + RepositorySettings save(TenantId tenantId, RepositorySettings versionControlSettings); + + boolean delete(TenantId tenantId); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java new file mode 100644 index 0000000..7645465 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java @@ -0,0 +1,101 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.TbApplicationEventListener; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.service.subscription.SubscriptionManagerService; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 27.03.18. + */ +@Slf4j +public abstract class AbstractSubscriptionService extends TbApplicationEventListener{ + + protected final Set currentPartitions = ConcurrentHashMap.newKeySet(); + + protected final TbClusterService clusterService; + protected final PartitionService partitionService; + protected Optional subscriptionManagerService; + + protected ExecutorService wsCallBackExecutor; + + public AbstractSubscriptionService(TbClusterService clusterService, + PartitionService partitionService) { + this.clusterService = clusterService; + this.partitionService = partitionService; + } + + @Autowired(required = false) + public void setSubscriptionManagerService(Optional subscriptionManagerService) { + this.subscriptionManagerService = subscriptionManagerService; + } + + abstract String getExecutorPrefix(); + + @PostConstruct + public void initExecutor() { + wsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(getExecutorPrefix() + "-service-ws-callback")); + } + + @PreDestroy + public void shutdownExecutor() { + if (wsCallBackExecutor != null) { + wsCallBackExecutor.shutdownNow(); + } + } + + @Override + protected void onTbApplicationEvent(PartitionChangeEvent partitionChangeEvent) { + if (ServiceType.TB_CORE.equals(partitionChangeEvent.getServiceType())) { + currentPartitions.clear(); + currentPartitions.addAll(partitionChangeEvent.getPartitions()); + } + } + + protected void addWsCallback(ListenableFuture saveFuture, Consumer callback) { + Futures.addCallback(saveFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable T result) { + callback.accept(result); + } + + @Override + public void onFailure(Throwable t) { + } + }, wsCallBackExecutor); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AlarmSubscriptionService.java new file mode 100644 index 0000000..a80ec0f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AlarmSubscriptionService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.rule.engine.api.RuleEngineAlarmService; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +/** + * Created by ashvayka on 27.03.18. + */ +public interface AlarmSubscriptionService extends RuleEngineAlarmService, ApplicationListener { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/AttributeData.java b/application/src/main/java/org/thingsboard/server/service/telemetry/AttributeData.java new file mode 100644 index 0000000..5eb258c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/AttributeData.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel +public class AttributeData implements Comparable{ + + private final long lastUpdateTs; + private final String key; + private final Object value; + + public AttributeData(long lastUpdateTs, String key, Object value) { + super(); + this.lastUpdateTs = lastUpdateTs; + this.key = key; + this.value = value; + } + + @ApiModelProperty(position = 1, value = "Timestamp last updated attribute, in milliseconds", example = "1609459200000", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public long getLastUpdateTs() { + return lastUpdateTs; + } + + @ApiModelProperty(position = 2, value = "String representing attribute key", example = "active", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public String getKey() { + return key; + } + + @ApiModelProperty(position = 3, value = "Object representing value of attribute key", example = "false", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public Object getValue() { + return value; + } + + @Override + public int compareTo(AttributeData o) { + return Long.compare(lastUpdateTs, o.lastUpdateTs); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java new file mode 100644 index 0000000..956dde8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -0,0 +1,219 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import com.fasterxml.jackson.databind.JsonNode; +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.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmQuery; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.alarm.AlarmOperationResult; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.service.subscription.SubscriptionManagerService; +import org.thingsboard.server.service.subscription.TbSubscriptionUtils; + +import java.util.Collection; +import java.util.Optional; + +/** + * Created by ashvayka on 27.03.18. + */ +@Service +@Slf4j +public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService implements AlarmSubscriptionService { + + private final AlarmService alarmService; + private final TbApiUsageReportClient apiUsageClient; + private final TbApiUsageStateService apiUsageStateService; + + public DefaultAlarmSubscriptionService(TbClusterService clusterService, + PartitionService partitionService, + AlarmService alarmService, + TbApiUsageReportClient apiUsageClient, + TbApiUsageStateService apiUsageStateService) { + super(clusterService, partitionService); + this.alarmService = alarmService; + this.apiUsageClient = apiUsageClient; + this.apiUsageStateService = apiUsageStateService; + } + + @Autowired(required = false) + public void setSubscriptionManagerService(Optional subscriptionManagerService) { + this.subscriptionManagerService = subscriptionManagerService; + } + + @Override + String getExecutorPrefix() { + return "alarm"; + } + + @Override + public Alarm createOrUpdateAlarm(Alarm alarm) { + AlarmOperationResult result = alarmService.createOrUpdateAlarm(alarm, apiUsageStateService.getApiUsageState(alarm.getTenantId()).isAlarmCreationEnabled()); + if (result.isSuccessful()) { + onAlarmUpdated(result); + } + if (result.isCreated()) { + apiUsageClient.report(alarm.getTenantId(), null, ApiUsageRecordKey.CREATED_ALARMS_COUNT); + } + return result.getAlarm(); + } + + @Override + public Boolean deleteAlarm(TenantId tenantId, AlarmId alarmId) { + AlarmOperationResult result = alarmService.deleteAlarm(tenantId, alarmId); + onAlarmDeleted(result); + return result.isSuccessful(); + } + + @Override + public ListenableFuture ackAlarm(TenantId tenantId, AlarmId alarmId, long ackTs) { + ListenableFuture result = alarmService.ackAlarm(tenantId, alarmId, ackTs); + Futures.addCallback(result, new AlarmUpdateCallback(), wsCallBackExecutor); + return Futures.transform(result, AlarmOperationResult::isSuccessful, wsCallBackExecutor); + } + + @Override + public ListenableFuture clearAlarm(TenantId tenantId, AlarmId alarmId, JsonNode details, long clearTs) { + ListenableFuture result = clearAlarmForResult(tenantId, alarmId, details, clearTs); + return Futures.transform(result, AlarmOperationResult::isSuccessful, wsCallBackExecutor); + } + + @Override + public ListenableFuture clearAlarmForResult(TenantId tenantId, AlarmId alarmId, JsonNode details, long clearTs) { + ListenableFuture result = alarmService.clearAlarm(tenantId, alarmId, details, clearTs); + Futures.addCallback(result, new AlarmUpdateCallback(), wsCallBackExecutor); + return result; + } + + @Override + public ListenableFuture findAlarmByIdAsync(TenantId tenantId, AlarmId alarmId) { + return alarmService.findAlarmByIdAsync(tenantId, alarmId); + } + + @Override + public Alarm findAlarmById(TenantId tenantId, AlarmId alarmId) { + return alarmService.findAlarmById(tenantId, alarmId); + } + + @Override + public ListenableFuture findAlarmInfoByIdAsync(TenantId tenantId, AlarmId alarmId) { + return alarmService.findAlarmInfoByIdAsync(tenantId, alarmId); + } + + @Override + public ListenableFuture> findAlarms(TenantId tenantId, AlarmQuery query) { + return alarmService.findAlarms(tenantId, query); + } + + @Override + public ListenableFuture> findCustomerAlarms(TenantId tenantId, CustomerId customerId, AlarmQuery query) { + return alarmService.findCustomerAlarms(tenantId, customerId, query); + } + + @Override + public AlarmSeverity findHighestAlarmSeverity(TenantId tenantId, EntityId entityId, AlarmSearchStatus alarmSearchStatus, AlarmStatus alarmStatus) { + return alarmService.findHighestAlarmSeverity(tenantId, entityId, alarmSearchStatus, alarmStatus); + } + + @Override + public PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds) { + return alarmService.findAlarmDataByQueryForEntities(tenantId, query, orderedEntityIds); + } + + @Override + public ListenableFuture findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type) { + return alarmService.findLatestByOriginatorAndType(tenantId, originator, type); + } + + private void onAlarmUpdated(AlarmOperationResult result) { + wsCallBackExecutor.submit(() -> { + Alarm alarm = result.getAlarm(); + TenantId tenantId = result.getAlarm().getTenantId(); + for (EntityId entityId : result.getPropagatedEntitiesList()) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + subscriptionManagerService.get().onAlarmUpdate(tenantId, entityId, alarm, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toAlarmUpdateProto(tenantId, entityId, alarm); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + }); + } + + private void onAlarmDeleted(AlarmOperationResult result) { + wsCallBackExecutor.submit(() -> { + Alarm alarm = result.getAlarm(); + TenantId tenantId = result.getAlarm().getTenantId(); + for (EntityId entityId : result.getPropagatedEntitiesList()) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + subscriptionManagerService.get().onAlarmDeleted(tenantId, entityId, alarm, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toAlarmDeletedProto(tenantId, entityId, alarm); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + }); + } + + private class AlarmUpdateCallback implements FutureCallback { + @Override + public void onSuccess(@Nullable AlarmOperationResult result) { + onAlarmUpdated(result); + } + + @Override + public void onFailure(Throwable t) { + log.warn("Failed to update alarm", t); + } + } + +} 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 new file mode 100644 index 0000000..667ad5b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -0,0 +1,499 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +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.DeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +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.kv.TsKvLatestRemovingResult; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.subscription.TbSubscriptionUtils; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 27.03.18. + */ +@Service +@Slf4j +public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionService implements TelemetrySubscriptionService { + + private final AttributesService attrService; + private final TimeseriesService tsService; + private final TbEntityViewService tbEntityViewService; + private final TbApiUsageReportClient apiUsageClient; + private final TbApiUsageStateService apiUsageStateService; + + private ExecutorService tsCallBackExecutor; + + public DefaultTelemetrySubscriptionService(AttributesService attrService, + TimeseriesService tsService, + @Lazy TbEntityViewService tbEntityViewService, + TbClusterService clusterService, + PartitionService partitionService, + TbApiUsageReportClient apiUsageClient, + TbApiUsageStateService apiUsageStateService) { + super(clusterService, partitionService); + this.attrService = attrService; + this.tsService = tsService; + this.tbEntityViewService = tbEntityViewService; + this.apiUsageClient = apiUsageClient; + this.apiUsageStateService = apiUsageStateService; + } + + @PostConstruct + public void initExecutor() { + super.initExecutor(); + tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ts-service-ts-callback")); + } + + @Override + protected String getExecutorPrefix() { + return "ts"; + } + + @PreDestroy + public void shutdownExecutor() { + if (tsCallBackExecutor != null) { + tsCallBackExecutor.shutdownNow(); + } + super.shutdownExecutor(); + } + + @Override + public ListenableFuture saveAndNotify(TenantId tenantId, EntityId entityId, TsKvEntry ts) { + SettableFuture future = SettableFuture.create(); + saveAndNotify(tenantId, entityId, Collections.singletonList(ts), new VoidFutureCallback(future)); + return future; + } + + @Override + public void saveAndNotify(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback) { + saveAndNotify(tenantId, null, entityId, ts, 0L, callback); + } + + @Override + public void saveAndNotify(TenantId tenantId, CustomerId customerId, EntityId entityId, List ts, long ttl, FutureCallback callback) { + doSaveAndNotify(tenantId, customerId, entityId, ts, ttl, callback, true); + } + + @Override + public void saveWithoutLatestAndNotify(TenantId tenantId, CustomerId customerId, EntityId entityId, List ts, long ttl, FutureCallback callback) { + doSaveAndNotify(tenantId, customerId, entityId, ts, ttl, callback, false); + } + + private void doSaveAndNotify(TenantId tenantId, CustomerId customerId, EntityId entityId, List ts, long ttl, FutureCallback callback, boolean saveLatest) { + checkInternalEntity(entityId); + boolean sysTenant = TenantId.SYS_TENANT_ID.equals(tenantId) || tenantId == null; + if (sysTenant || apiUsageStateService.getApiUsageState(tenantId).isDbStorageEnabled()) { + if (saveLatest) { + saveAndNotifyInternal(tenantId, entityId, ts, ttl, getCallback(tenantId, customerId, sysTenant, callback)); + } else { + saveWithoutLatestAndNotifyInternal(tenantId, entityId, ts, ttl, getCallback(tenantId, customerId, sysTenant, callback)); + } + } else { + callback.onFailure(new RuntimeException("DB storage writes are disabled due to API limits!")); + } + } + + private FutureCallback getCallback(TenantId tenantId, CustomerId customerId, boolean sysTenant, FutureCallback callback) { + return new FutureCallback<>() { + @Override + public void onSuccess(Integer result) { + if (!sysTenant && result != null && result > 0) { + apiUsageClient.report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, result); + } + callback.onSuccess(null); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + @Override + public void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback) { + saveAndNotifyInternal(tenantId, entityId, ts, 0L, callback); + } + + @Override + public void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, long ttl, FutureCallback callback) { + ListenableFuture saveFuture = tsService.save(tenantId, entityId, ts, ttl); + addCallbacks(tenantId, entityId, ts, callback, saveFuture); + } + + private void saveWithoutLatestAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, long ttl, FutureCallback callback) { + ListenableFuture saveFuture = tsService.saveWithoutLatest(tenantId, entityId, ts, ttl); + addCallbacks(tenantId, entityId, ts, callback, saveFuture); + } + + private void addCallbacks(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback, ListenableFuture saveFuture) { + addMainCallback(saveFuture, callback); + addWsCallback(saveFuture, success -> onTimeSeriesUpdate(tenantId, entityId, ts)); + if (EntityType.DEVICE.equals(entityId.getEntityType()) || EntityType.ASSET.equals(entityId.getEntityType())) { + Futures.addCallback(this.tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId), + new FutureCallback>() { + @Override + public void onSuccess(@Nullable List result) { + if (result != null && !result.isEmpty()) { + Map> tsMap = new HashMap<>(); + for (TsKvEntry entry : ts) { + tsMap.computeIfAbsent(entry.getKey(), s -> new ArrayList<>()).add(entry); + } + for (EntityView entityView : result) { + List keys = entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null ? + entityView.getKeys().getTimeseries() : new ArrayList<>(tsMap.keySet()); + List entityViewLatest = new ArrayList<>(); + long startTs = entityView.getStartTimeMs(); + long endTs = entityView.getEndTimeMs() == 0 ? Long.MAX_VALUE : entityView.getEndTimeMs(); + for (String key : keys) { + List entries = tsMap.get(key); + if (entries != null) { + Optional tsKvEntry = entries.stream() + .filter(entry -> entry.getTs() > startTs && entry.getTs() <= endTs) + .max(Comparator.comparingLong(TsKvEntry::getTs)); + tsKvEntry.ifPresent(entityViewLatest::add); + } + } + if (!entityViewLatest.isEmpty()) { + saveLatestAndNotify(tenantId, entityView.getId(), entityViewLatest, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + } + + @Override + public void onFailure(Throwable t) { + } + }); + } + } + } + } + + @Override + public void onFailure(Throwable t) { + log.error("Error while finding entity views by tenantId and entityId", t); + } + }, MoreExecutors.directExecutor()); + } + } + + @Override + + public void saveAndNotify(TenantId tenantId, EntityId entityId, String scope, List attributes, FutureCallback callback) { + saveAndNotify(tenantId, entityId, scope, attributes, true, callback); + } + + @Override + public void saveAndNotify(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, FutureCallback callback) { + checkInternalEntity(entityId); + saveAndNotifyInternal(tenantId, entityId, scope, attributes, notifyDevice, callback); + } + + @Override + public void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, FutureCallback callback) { + ListenableFuture> saveFuture = attrService.save(tenantId, entityId, scope, attributes); + addVoidCallback(saveFuture, callback); + addWsCallback(saveFuture, success -> onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice)); + } + + @Override + public void saveLatestAndNotify(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback) { + checkInternalEntity(entityId); + saveLatestAndNotifyInternal(tenantId, entityId, ts, callback); + } + + @Override + public void saveLatestAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback) { + ListenableFuture> saveFuture = tsService.saveLatest(tenantId, entityId, ts); + addVoidCallback(saveFuture, callback); + addWsCallback(saveFuture, success -> onTimeSeriesUpdate(tenantId, entityId, ts)); + } + + @Override + public void deleteAndNotify(TenantId tenantId, EntityId entityId, String scope, List keys, FutureCallback callback) { + checkInternalEntity(entityId); + deleteAndNotifyInternal(tenantId, entityId, scope, keys, false, callback); + } + + @Override + public void deleteAndNotify(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, FutureCallback callback) { + checkInternalEntity(entityId); + deleteAndNotifyInternal(tenantId, entityId, scope, keys, notifyDevice, callback); + } + + @Override + public void deleteAndNotifyInternal(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, FutureCallback callback) { + ListenableFuture> deleteFuture = attrService.removeAll(tenantId, entityId, scope, keys); + addVoidCallback(deleteFuture, callback); + addWsCallback(deleteFuture, success -> onAttributesDelete(tenantId, entityId, scope, keys, notifyDevice)); + } + + @Override + public void deleteLatest(TenantId tenantId, EntityId entityId, List keys, FutureCallback callback) { + checkInternalEntity(entityId); + deleteLatestInternal(tenantId, entityId, keys, callback); + } + + @Override + public void deleteLatestInternal(TenantId tenantId, EntityId entityId, List keys, FutureCallback callback) { + ListenableFuture> deleteFuture = tsService.removeLatest(tenantId, entityId, keys); + addVoidCallback(deleteFuture, callback); + } + + @Override + public void deleteAllLatest(TenantId tenantId, EntityId entityId, FutureCallback> callback) { + ListenableFuture> deleteFuture = tsService.removeAllLatest(tenantId, entityId); + Futures.addCallback(deleteFuture, new FutureCallback>() { + @Override + public void onSuccess(@Nullable Collection result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }, tsCallBackExecutor); + } + + @Override + public void deleteTimeseriesAndNotify(TenantId tenantId, EntityId entityId, List keys, List deleteTsKvQueries, FutureCallback callback) { + ListenableFuture> deleteFuture = tsService.remove(tenantId, entityId, deleteTsKvQueries); + addVoidCallback(deleteFuture, callback); + addWsCallback(deleteFuture, list -> onTimeSeriesDelete(tenantId, entityId, keys, list)); + } + + @Override + public void saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, long value, FutureCallback callback) { + saveAndNotify(tenantId, entityId, scope, Collections.singletonList(new BaseAttributeKvEntry(new LongDataEntry(key, value) + , System.currentTimeMillis())), callback); + } + + @Override + public void saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, String value, FutureCallback callback) { + saveAndNotify(tenantId, entityId, scope, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(key, value) + , System.currentTimeMillis())), callback); + } + + @Override + public void saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, double value, FutureCallback callback) { + saveAndNotify(tenantId, entityId, scope, Collections.singletonList(new BaseAttributeKvEntry(new DoubleDataEntry(key, value) + , System.currentTimeMillis())), callback); + } + + @Override + public void saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, boolean value, FutureCallback callback) { + saveAndNotify(tenantId, entityId, scope, Collections.singletonList(new BaseAttributeKvEntry(new BooleanDataEntry(key, value) + , System.currentTimeMillis())), callback); + } + + @Override + public ListenableFuture saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, long value) { + SettableFuture future = SettableFuture.create(); + saveAttrAndNotify(tenantId, entityId, scope, key, value, new VoidFutureCallback(future)); + return future; + } + + @Override + public ListenableFuture saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, String value) { + SettableFuture future = SettableFuture.create(); + saveAttrAndNotify(tenantId, entityId, scope, key, value, new VoidFutureCallback(future)); + return future; + } + + @Override + public ListenableFuture saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, double value) { + SettableFuture future = SettableFuture.create(); + saveAttrAndNotify(tenantId, entityId, scope, key, value, new VoidFutureCallback(future)); + return future; + } + + @Override + public ListenableFuture saveAttrAndNotify(TenantId tenantId, EntityId entityId, String scope, String key, boolean value) { + SettableFuture future = SettableFuture.create(); + saveAttrAndNotify(tenantId, entityId, scope, key, value, new VoidFutureCallback(future)); + return future; + } + + private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + subscriptionManagerService.get().onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + + private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + subscriptionManagerService.get().onAttributesDelete(tenantId, entityId, scope, keys, notifyDevice, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys, notifyDevice); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + + private void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + subscriptionManagerService.get().onTimeSeriesUpdate(tenantId, entityId, ts, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toTimeseriesUpdateProto(tenantId, entityId, ts); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + + private void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, List ts) { + TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId); + if (currentPartitions.contains(tpi)) { + if (subscriptionManagerService.isPresent()) { + List updated = new ArrayList<>(); + List deleted = new ArrayList<>(); + + ts.stream().filter(Objects::nonNull).forEach(res -> { + if (res.isRemoved()) { + if (res.getData() != null) { + updated.add(res.getData()); + } else { + deleted.add(res.getKey()); + } + } + }); + + subscriptionManagerService.get().onTimeSeriesUpdate(tenantId, entityId, updated, TbCallback.EMPTY); + subscriptionManagerService.get().onTimeSeriesDelete(tenantId, entityId, deleted, TbCallback.EMPTY); + } else { + log.warn("Possible misconfiguration because subscriptionManagerService is null!"); + } + } else { + TransportProtos.ToCoreMsg toCoreMsg = TbSubscriptionUtils.toTimeseriesDeleteProto(tenantId, entityId, keys); + clusterService.pushMsgToCore(tpi, entityId.getId(), toCoreMsg, null); + } + } + + private void addVoidCallback(ListenableFuture saveFuture, final FutureCallback callback) { + Futures.addCallback(saveFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable S result) { + callback.onSuccess(null); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }, tsCallBackExecutor); + } + + private void addMainCallback(ListenableFuture saveFuture, final FutureCallback callback) { + Futures.addCallback(saveFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable S result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }, tsCallBackExecutor); + } + + private void checkInternalEntity(EntityId entityId) { + if (EntityType.API_USAGE_STATE.equals(entityId.getEntityType())) { + throw new RuntimeException("Can't update API Usage State!"); + } + } + + private static class VoidFutureCallback implements FutureCallback { + private final SettableFuture future; + + public VoidFutureCallback(SettableFuture future) { + this.future = future; + } + + @Override + public void onSuccess(Void result) { + future.set(null); + } + + @Override + public void onFailure(Throwable t) { + future.setException(t); + } + } + +} 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 new file mode 100644 index 0000000..06de7a5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java @@ -0,0 +1,943 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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 com.google.common.util.concurrent.MoreExecutors; +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.springframework.web.socket.CloseStatus; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.dao.util.TenantRateLimitException; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +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.security.model.UserPrincipal; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.subscription.TbAttributeSubscription; +import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; +import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService; +import org.thingsboard.server.service.subscription.TbLocalSubscriptionService; +import org.thingsboard.server.service.subscription.TbTimeseriesSubscription; +import org.thingsboard.server.service.telemetry.cmd.TelemetryPluginCmdsWrapper; +import org.thingsboard.server.service.telemetry.cmd.v1.AttributesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.GetHistoryCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.SubscriptionCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.TelemetryPluginCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.TimeseriesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.CmdUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.UnsubscribeCmd; +import org.thingsboard.server.service.telemetry.exception.UnauthorizedException; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.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.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Created by ashvayka on 27.03.18. + */ +@Service +@TbCoreComponent +@Slf4j +public class DefaultTelemetryWebSocketService implements TelemetryWebSocketService { + + public static final int NUMBER_OF_PING_ATTEMPTS = 3; + + private static final int DEFAULT_LIMIT = 100; + private static final Aggregation DEFAULT_AGGREGATION = Aggregation.NONE; + private static final int UNKNOWN_SUBSCRIPTION_ID = 0; + private static final String PROCESSING_MSG = "[{}] Processing: {}"; + private static final ObjectMapper jsonMapper = new ObjectMapper(); + private static final String FAILED_TO_FETCH_DATA = "Failed to fetch data!"; + private static final String FAILED_TO_FETCH_ATTRIBUTES = "Failed to fetch attributes!"; + private static final String SESSION_META_DATA_NOT_FOUND = "Session meta-data not found!"; + private static final String FAILED_TO_PARSE_WS_COMMAND = "Failed to parse websocket command!"; + + private final ConcurrentMap wsSessionsMap = new ConcurrentHashMap<>(); + + @Autowired + private TbLocalSubscriptionService oldSubService; + + @Autowired + private TbEntityDataSubscriptionService entityDataSubService; + + @Autowired + private TelemetryWebSocketMsgEndpoint msgEndpoint; + + @Autowired + private AccessValidator accessValidator; + + @Autowired + private AttributesService attributesService; + + @Autowired + private TimeseriesService tsService; + + @Autowired + private TbServiceInfoProvider serviceInfoProvider; + + @Autowired + private TbTenantProfileCache tenantProfileCache; + + @Value("${server.ws.ping_timeout:30000}") + private long pingTimeout; + + private ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); + private ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + + private ExecutorService executor; + private String serviceId; + + private ScheduledExecutorService pingExecutor; + + @PostConstruct + public void initExecutor() { + serviceId = serviceInfoProvider.getServiceId(); + executor = ThingsBoardExecutors.newWorkStealingPool(50, getClass()); + + pingExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("telemetry-web-socket-ping")); + pingExecutor.scheduleWithFixedDelay(this::sendPing, pingTimeout / NUMBER_OF_PING_ATTEMPTS, pingTimeout / NUMBER_OF_PING_ATTEMPTS, TimeUnit.MILLISECONDS); + } + + @PreDestroy + public void shutdownExecutor() { + if (pingExecutor != null) { + pingExecutor.shutdownNow(); + } + + if (executor != null) { + executor.shutdownNow(); + } + } + + @Override + public void handleWebSocketSessionEvent(TelemetryWebSocketSessionRef sessionRef, SessionEvent event) { + String sessionId = sessionRef.getSessionId(); + log.debug(PROCESSING_MSG, sessionId, event); + switch (event.getEventType()) { + case ESTABLISHED: + wsSessionsMap.put(sessionId, new WsSessionMetaData(sessionRef)); + break; + case ERROR: + log.debug("[{}] Unknown websocket session error: {}. ", sessionId, event.getError().orElse(null)); + break; + case CLOSED: + wsSessionsMap.remove(sessionId); + oldSubService.cancelAllSessionSubscriptions(sessionId); + entityDataSubService.cancelAllSessionSubscriptions(sessionId); + processSessionClose(sessionRef); + break; + } + } + + @Override + public void handleWebSocketMsg(TelemetryWebSocketSessionRef sessionRef, String msg) { + if (log.isTraceEnabled()) { + log.trace("[{}] Processing: {}", sessionRef.getSessionId(), msg); + } + + try { + TelemetryPluginCmdsWrapper cmdsWrapper = jsonMapper.readValue(msg, TelemetryPluginCmdsWrapper.class); + if (cmdsWrapper != null) { + if (cmdsWrapper.getAttrSubCmds() != null) { + cmdsWrapper.getAttrSubCmds().forEach(cmd -> { + if (processSubscription(sessionRef, cmd)) { + handleWsAttributesSubscriptionCmd(sessionRef, cmd); + } + }); + } + if (cmdsWrapper.getTsSubCmds() != null) { + cmdsWrapper.getTsSubCmds().forEach(cmd -> { + if (processSubscription(sessionRef, cmd)) { + handleWsTimeseriesSubscriptionCmd(sessionRef, cmd); + } + }); + } + if (cmdsWrapper.getHistoryCmds() != null) { + cmdsWrapper.getHistoryCmds().forEach(cmd -> handleWsHistoryCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getEntityDataCmds() != null) { + cmdsWrapper.getEntityDataCmds().forEach(cmd -> handleWsEntityDataCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getAlarmDataCmds() != null) { + cmdsWrapper.getAlarmDataCmds().forEach(cmd -> handleWsAlarmDataCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getEntityCountCmds() != null) { + cmdsWrapper.getEntityCountCmds().forEach(cmd -> handleWsEntityCountCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getEntityDataUnsubscribeCmds() != null) { + cmdsWrapper.getEntityDataUnsubscribeCmds().forEach(cmd -> handleWsDataUnsubscribeCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getAlarmDataUnsubscribeCmds() != null) { + cmdsWrapper.getAlarmDataUnsubscribeCmds().forEach(cmd -> handleWsDataUnsubscribeCmd(sessionRef, cmd)); + } + if (cmdsWrapper.getEntityCountUnsubscribeCmds() != null) { + cmdsWrapper.getEntityCountUnsubscribeCmds().forEach(cmd -> handleWsDataUnsubscribeCmd(sessionRef, cmd)); + } + } + } catch (IOException e) { + log.warn("Failed to decode subscription cmd: {}", e.getMessage(), e); + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(UNKNOWN_SUBSCRIPTION_ID, SubscriptionErrorCode.BAD_REQUEST, FAILED_TO_PARSE_WS_COMMAND)); + } + } + + private void handleWsEntityDataCmd(TelemetryWebSocketSessionRef sessionRef, EntityDataCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd.getCmdId(), sessionId) + && validateSubscriptionCmd(sessionRef, cmd)) { + entityDataSubService.handleCmd(sessionRef, cmd); + } + } + + private void handleWsEntityCountCmd(TelemetryWebSocketSessionRef sessionRef, EntityCountCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd.getCmdId(), sessionId) + && validateSubscriptionCmd(sessionRef, cmd)) { + entityDataSubService.handleCmd(sessionRef, cmd); + } + } + + private void handleWsAlarmDataCmd(TelemetryWebSocketSessionRef sessionRef, AlarmDataCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd.getCmdId(), sessionId) + && validateSubscriptionCmd(sessionRef, cmd)) { + entityDataSubService.handleCmd(sessionRef, cmd); + } + } + + private void handleWsDataUnsubscribeCmd(TelemetryWebSocketSessionRef sessionRef, UnsubscribeCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd.getCmdId(), sessionId)) { + entityDataSubService.cancelSubscription(sessionRef.getSessionId(), cmd); + } + } + + @Override + public void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate update) { + sendWsMsg(sessionId, update.getSubscriptionId(), update); + } + + @Override + public void sendWsMsg(String sessionId, CmdUpdate update) { + sendWsMsg(sessionId, update.getCmdId(), update); + } + + private void sendWsMsg(String sessionId, int cmdId, T update) { + WsSessionMetaData md = wsSessionsMap.get(sessionId); + if (md != null) { + sendWsMsg(md.getSessionRef(), cmdId, update); + } + } + + @Override + public void close(String sessionId, CloseStatus status) { + WsSessionMetaData md = wsSessionsMap.get(sessionId); + if (md != null) { + try { + msgEndpoint.close(md.getSessionRef(), status); + } catch (IOException e) { + log.warn("[{}] Failed to send session close: {}", sessionId, e); + } + } + } + + private void processSessionClose(TelemetryWebSocketSessionRef sessionRef) { + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + if (tenantProfileConfiguration != null) { + String sessionId = "[" + sessionRef.getSessionId() + "]"; + + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerTenant() > 0) { + Set tenantSubscriptions = tenantSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (tenantSubscriptions) { + tenantSubscriptions.removeIf(subId -> subId.startsWith(sessionId)); + } + } + if (sessionRef.getSecurityCtx().isCustomerUser()) { + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerCustomer() > 0) { + Set customerSessions = customerSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getCustomerId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (customerSessions) { + customerSessions.removeIf(subId -> subId.startsWith(sessionId)); + } + } + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerRegularUser() > 0 && UserPrincipal.Type.USER_NAME.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set regularUserSessions = regularUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (regularUserSessions) { + regularUserSessions.removeIf(subId -> subId.startsWith(sessionId)); + } + } + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (publicUserSessions) { + publicUserSessions.removeIf(subId -> subId.startsWith(sessionId)); + } + } + } + } + } + + private boolean processSubscription(TelemetryWebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + if (tenantProfileConfiguration == null) return true; + + String subId = "[" + sessionRef.getSessionId() + "]:[" + cmd.getCmdId() + "]"; + try { + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerTenant() > 0) { + Set tenantSubscriptions = tenantSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (tenantSubscriptions) { + if (cmd.isUnsubscribe()) { + tenantSubscriptions.remove(subId); + } else if (tenantSubscriptions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerTenant()) { + tenantSubscriptions.add(subId); + } else { + log.info("[{}][{}][{}] Failed to start subscription. Max tenant subscriptions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), subId); + msgEndpoint.close(sessionRef, CloseStatus.POLICY_VIOLATION.withReason("Max tenant subscriptions limit reached!")); + return false; + } + } + } + + if (sessionRef.getSecurityCtx().isCustomerUser()) { + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerCustomer() > 0) { + Set customerSessions = customerSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getCustomerId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (customerSessions) { + if (cmd.isUnsubscribe()) { + customerSessions.remove(subId); + } else if (customerSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerCustomer()) { + customerSessions.add(subId); + } else { + log.info("[{}][{}][{}] Failed to start subscription. Max customer subscriptions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), subId); + msgEndpoint.close(sessionRef, CloseStatus.POLICY_VIOLATION.withReason("Max customer subscriptions limit reached")); + return false; + } + } + } + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerRegularUser() > 0 && UserPrincipal.Type.USER_NAME.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set regularUserSessions = regularUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (regularUserSessions) { + if (regularUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerRegularUser()) { + regularUserSessions.add(subId); + } else { + log.info("[{}][{}][{}] Failed to start subscription. Max regular user subscriptions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), subId); + msgEndpoint.close(sessionRef, CloseStatus.POLICY_VIOLATION.withReason("Max regular user subscriptions limit reached")); + return false; + } + } + } + if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + synchronized (publicUserSessions) { + if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { + publicUserSessions.add(subId); + } else { + log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached" + , sessionRef.getSecurityCtx().getTenantId(), sessionRef.getSecurityCtx().getId(), subId); + msgEndpoint.close(sessionRef, CloseStatus.POLICY_VIOLATION.withReason("Max public user subscriptions limit reached")); + return false; + } + } + } + } + } catch (IOException e) { + log.warn("[{}] Failed to send session close: {}", sessionRef.getSessionId(), e); + return false; + } + return true; + } + + private void handleWsAttributesSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, AttributesSubscriptionCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd, sessionId)) { + if (cmd.isUnsubscribe()) { + unsubscribe(sessionRef, cmd, sessionId); + } else if (validateSubscriptionCmd(sessionRef, cmd)) { + EntityId entityId = EntityIdFactory.getByTypeAndId(cmd.getEntityType(), cmd.getEntityId()); + log.debug("[{}] fetching latest attributes ({}) values for device: {}", sessionId, cmd.getKeys(), entityId); + Optional> keysOptional = getKeys(cmd); + if (keysOptional.isPresent()) { + List keys = new ArrayList<>(keysOptional.get()); + handleWsAttributesSubscriptionByKeys(sessionRef, cmd, sessionId, entityId, keys); + } else { + handleWsAttributesSubscription(sessionRef, cmd, sessionId, entityId); + } + } + } + } + + private void handleWsAttributesSubscriptionByKeys(TelemetryWebSocketSessionRef sessionRef, + AttributesSubscriptionCmd cmd, String sessionId, EntityId entityId, + List keys) { + FutureCallback> callback = new FutureCallback<>() { + @Override + public void onSuccess(List data) { + List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), attributesData)); + + Map subState = new HashMap<>(keys.size()); + keys.forEach(key -> subState.put(key, 0L)); + attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); + + TbAttributeSubscriptionScope scope = StringUtils.isEmpty(cmd.getScope()) ? TbAttributeSubscriptionScope.ANY_SCOPE : TbAttributeSubscriptionScope.valueOf(cmd.getScope()); + + TbAttributeSubscription sub = TbAttributeSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionId) + .subscriptionId(cmd.getCmdId()) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .allKeys(false) + .keyStates(subState) + .scope(scope) + .updateConsumer(DefaultTelemetryWebSocketService.this::sendWsMsg) + .build(); + oldSubService.addSubscription(sub); + } + + @Override + public void onFailure(Throwable e) { + log.error(FAILED_TO_FETCH_ATTRIBUTES, e); + TelemetrySubscriptionUpdate update; + if (e instanceof UnauthorizedException) { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + FAILED_TO_FETCH_ATTRIBUTES); + } + sendWsMsg(sessionRef, update); + } + }; + + if (StringUtils.isEmpty(cmd.getScope())) { + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_ATTRIBUTES, entityId, getAttributesFetchCallback(sessionRef.getSecurityCtx().getTenantId(), entityId, keys, callback)); + } else { + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_ATTRIBUTES, entityId, getAttributesFetchCallback(sessionRef.getSecurityCtx().getTenantId(), entityId, cmd.getScope(), keys, callback)); + } + } + + private void handleWsHistoryCmd(TelemetryWebSocketSessionRef sessionRef, GetHistoryCmd cmd) { + String sessionId = sessionRef.getSessionId(); + WsSessionMetaData sessionMD = wsSessionsMap.get(sessionId); + if (sessionMD == null) { + log.warn("[{}] Session meta data not found. ", sessionId); + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + SESSION_META_DATA_NOT_FOUND); + sendWsMsg(sessionRef, update); + return; + } + if (cmd.getEntityId() == null || cmd.getEntityId().isEmpty() || cmd.getEntityType() == null || cmd.getEntityType().isEmpty()) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Device id is empty!"); + sendWsMsg(sessionRef, update); + return; + } + if (cmd.getKeys() == null || cmd.getKeys().isEmpty()) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Keys are empty!"); + sendWsMsg(sessionRef, update); + return; + } + EntityId entityId = EntityIdFactory.getByTypeAndId(cmd.getEntityType(), cmd.getEntityId()); + List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); + List queries = keys.stream().map(key -> new BaseReadTsKvQuery(key, cmd.getStartTs(), cmd.getEndTs(), cmd.getInterval(), getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))) + .collect(Collectors.toList()); + + FutureCallback> callback = new FutureCallback>() { + @Override + public void onSuccess(List data) { + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), data)); + } + + @Override + public void onFailure(Throwable e) { + TelemetrySubscriptionUpdate update; + if (UnauthorizedException.class.isInstance(e)) { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + FAILED_TO_FETCH_DATA); + } + sendWsMsg(sessionRef, update); + } + }; + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_TELEMETRY, entityId, + on(r -> Futures.addCallback(tsService.findAll(sessionRef.getSecurityCtx().getTenantId(), entityId, queries), callback, executor), callback::onFailure)); + } + + private void handleWsAttributesSubscription(TelemetryWebSocketSessionRef sessionRef, + AttributesSubscriptionCmd cmd, String sessionId, EntityId entityId) { + FutureCallback> callback = new FutureCallback<>() { + @Override + public void onSuccess(List data) { + List attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList()); + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), attributesData)); + + Map subState = new HashMap<>(attributesData.size()); + attributesData.forEach(v -> subState.put(v.getKey(), v.getTs())); + + TbAttributeSubscriptionScope scope = StringUtils.isEmpty(cmd.getScope()) ? TbAttributeSubscriptionScope.ANY_SCOPE : TbAttributeSubscriptionScope.valueOf(cmd.getScope()); + + TbAttributeSubscription sub = TbAttributeSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionId) + .subscriptionId(cmd.getCmdId()) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .allKeys(true) + .keyStates(subState) + .updateConsumer(DefaultTelemetryWebSocketService.this::sendWsMsg) + .scope(scope).build(); + oldSubService.addSubscription(sub); + } + + @Override + public void onFailure(Throwable e) { + log.error(FAILED_TO_FETCH_ATTRIBUTES, e); + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + FAILED_TO_FETCH_ATTRIBUTES); + sendWsMsg(sessionRef, update); + } + }; + + + if (StringUtils.isEmpty(cmd.getScope())) { + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_ATTRIBUTES, entityId, getAttributesFetchCallback(sessionRef.getSecurityCtx().getTenantId(), entityId, callback)); + } else { + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_ATTRIBUTES, entityId, getAttributesFetchCallback(sessionRef.getSecurityCtx().getTenantId(), entityId, cmd.getScope(), callback)); + } + } + + private void handleWsTimeseriesSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, TimeseriesSubscriptionCmd cmd) { + String sessionId = sessionRef.getSessionId(); + log.debug("[{}] Processing: {}", sessionId, cmd); + + if (validateSessionMetadata(sessionRef, cmd, sessionId)) { + if (cmd.isUnsubscribe()) { + unsubscribe(sessionRef, cmd, sessionId); + } else if (validateSubscriptionCmd(sessionRef, cmd)) { + EntityId entityId = EntityIdFactory.getByTypeAndId(cmd.getEntityType(), cmd.getEntityId()); + Optional> keysOptional = getKeys(cmd); + + if (keysOptional.isPresent()) { + handleWsTimeseriesSubscriptionByKeys(sessionRef, cmd, sessionId, entityId); + } else { + handleWsTimeseriesSubscription(sessionRef, cmd, sessionId, entityId); + } + } + } + } + + private void handleWsTimeseriesSubscriptionByKeys(TelemetryWebSocketSessionRef sessionRef, + TimeseriesSubscriptionCmd cmd, String sessionId, EntityId entityId) { + long startTs; + if (cmd.getTimeWindow() > 0) { + List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); + log.debug("[{}] fetching timeseries data for last {} ms for keys: ({}) for device : {}", sessionId, cmd.getTimeWindow(), cmd.getKeys(), entityId); + startTs = cmd.getStartTs(); + long endTs = cmd.getStartTs() + cmd.getTimeWindow(); + List queries = keys.stream().map(key -> new BaseReadTsKvQuery(key, startTs, endTs, cmd.getInterval(), + getLimit(cmd.getLimit()), getAggregation(cmd.getAgg()))).collect(Collectors.toList()); + + final FutureCallback> callback = getSubscriptionCallback(sessionRef, cmd, sessionId, entityId, startTs, keys); + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_TELEMETRY, entityId, + on(r -> Futures.addCallback(tsService.findAll(sessionRef.getSecurityCtx().getTenantId(), entityId, queries), callback, executor), callback::onFailure)); + } else { + List keys = new ArrayList<>(getKeys(cmd).orElse(Collections.emptySet())); + startTs = System.currentTimeMillis(); + log.debug("[{}] fetching latest timeseries data for keys: ({}) for device : {}", sessionId, cmd.getKeys(), entityId); + final FutureCallback> callback = getSubscriptionCallback(sessionRef, cmd, sessionId, entityId, startTs, keys); + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_TELEMETRY, entityId, + on(r -> Futures.addCallback(tsService.findLatest(sessionRef.getSecurityCtx().getTenantId(), entityId, keys), callback, executor), callback::onFailure)); + } + } + + private void handleWsTimeseriesSubscription(TelemetryWebSocketSessionRef sessionRef, + TimeseriesSubscriptionCmd cmd, String sessionId, EntityId entityId) { + FutureCallback> callback = new FutureCallback>() { + @Override + public void onSuccess(List data) { + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), data)); + Map subState = new HashMap<>(data.size()); + data.forEach(v -> subState.put(v.getKey(), v.getTs())); + + TbTimeseriesSubscription sub = TbTimeseriesSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionId) + .subscriptionId(cmd.getCmdId()) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .updateConsumer(DefaultTelemetryWebSocketService.this::sendWsMsg) + .allKeys(true) + .keyStates(subState).build(); + oldSubService.addSubscription(sub); + } + + @Override + public void onFailure(Throwable e) { + TelemetrySubscriptionUpdate update; + if (UnauthorizedException.class.isInstance(e)) { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.UNAUTHORIZED, + SubscriptionErrorCode.UNAUTHORIZED.getDefaultMsg()); + } else { + update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + FAILED_TO_FETCH_DATA); + } + sendWsMsg(sessionRef, update); + } + }; + accessValidator.validate(sessionRef.getSecurityCtx(), Operation.READ_TELEMETRY, entityId, + on(r -> Futures.addCallback(tsService.findAllLatest(sessionRef.getSecurityCtx().getTenantId(), entityId), callback, executor), callback::onFailure)); + } + + private FutureCallback> getSubscriptionCallback(final TelemetryWebSocketSessionRef sessionRef, final TimeseriesSubscriptionCmd cmd, final String sessionId, final EntityId entityId, final long startTs, final List keys) { + return new FutureCallback<>() { + @Override + public void onSuccess(List data) { + sendWsMsg(sessionRef, new TelemetrySubscriptionUpdate(cmd.getCmdId(), data)); + Map subState = new HashMap<>(keys.size()); + keys.forEach(key -> subState.put(key, startTs)); + data.forEach(v -> subState.put(v.getKey(), v.getTs())); + + TbTimeseriesSubscription sub = TbTimeseriesSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionId) + .subscriptionId(cmd.getCmdId()) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .updateConsumer(DefaultTelemetryWebSocketService.this::sendWsMsg) + .allKeys(false) + .keyStates(subState).build(); + oldSubService.addSubscription(sub); + } + + @Override + public void onFailure(Throwable e) { + if (e instanceof TenantRateLimitException || e.getCause() instanceof TenantRateLimitException) { + log.trace("[{}] Tenant rate limit detected for subscription: [{}]:{}", sessionRef.getSecurityCtx().getTenantId(), entityId, cmd); + } else { + log.info(FAILED_TO_FETCH_DATA, e); + } + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR, + FAILED_TO_FETCH_DATA); + sendWsMsg(sessionRef, update); + } + }; + } + + private void unsubscribe(TelemetryWebSocketSessionRef sessionRef, SubscriptionCmd cmd, String sessionId) { + if (cmd.getEntityId() == null || cmd.getEntityId().isEmpty()) { + oldSubService.cancelAllSessionSubscriptions(sessionId); + } else { + oldSubService.cancelSubscription(sessionId, cmd.getCmdId()); + } + } + + private boolean validateSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, EntityDataCmd cmd) { + if (cmd.getCmdId() < 0) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Cmd id is negative value!"); + sendWsMsg(sessionRef, update); + return false; + } else if (cmd.getQuery() == null && !cmd.hasAnyCmd()) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Query is empty!"); + sendWsMsg(sessionRef, update); + return false; + } + return true; + } + + private boolean validateSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, EntityCountCmd cmd) { + if (cmd.getCmdId() < 0) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Cmd id is negative value!"); + sendWsMsg(sessionRef, update); + return false; + } else if (cmd.getQuery() == null) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, "Query is empty!"); + sendWsMsg(sessionRef, update); + return false; + } + return true; + } + + private boolean validateSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, AlarmDataCmd cmd) { + if (cmd.getCmdId() < 0) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Cmd id is negative value!"); + sendWsMsg(sessionRef, update); + return false; + } else if (cmd.getQuery() == null) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Query is empty!"); + sendWsMsg(sessionRef, update); + return false; + } + return true; + } + + private boolean validateSubscriptionCmd(TelemetryWebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + if (cmd.getEntityId() == null || cmd.getEntityId().isEmpty()) { + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmd.getCmdId(), SubscriptionErrorCode.BAD_REQUEST, + "Device id is empty!"); + sendWsMsg(sessionRef, update); + return false; + } + return true; + } + + private boolean validateSessionMetadata(TelemetryWebSocketSessionRef sessionRef, SubscriptionCmd cmd, String sessionId) { + return validateSessionMetadata(sessionRef, cmd.getCmdId(), sessionId); + } + + private boolean validateSessionMetadata(TelemetryWebSocketSessionRef sessionRef, int cmdId, String sessionId) { + WsSessionMetaData sessionMD = wsSessionsMap.get(sessionId); + if (sessionMD == null) { + log.warn("[{}] Session meta data not found. ", sessionId); + TelemetrySubscriptionUpdate update = new TelemetrySubscriptionUpdate(cmdId, SubscriptionErrorCode.INTERNAL_ERROR, + SESSION_META_DATA_NOT_FOUND); + sendWsMsg(sessionRef, update); + return false; + } else { + return true; + } + } + + private void sendWsMsg(TelemetryWebSocketSessionRef sessionRef, EntityDataUpdate update) { + sendWsMsg(sessionRef, update.getCmdId(), update); + } + + private void sendWsMsg(TelemetryWebSocketSessionRef sessionRef, TelemetrySubscriptionUpdate update) { + sendWsMsg(sessionRef, update.getSubscriptionId(), update); + } + + private void sendWsMsg(TelemetryWebSocketSessionRef sessionRef, int cmdId, Object update) { + try { + String msg = jsonMapper.writeValueAsString(update); + executor.submit(() -> { + try { + msgEndpoint.send(sessionRef, cmdId, msg); + } catch (IOException e) { + log.warn("[{}] Failed to send reply: {}", sessionRef.getSessionId(), update, e); + } + }); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to encode reply: {}", sessionRef.getSessionId(), update, e); + } + } + + private void sendPing() { + long currentTime = System.currentTimeMillis(); + wsSessionsMap.values().forEach(md -> + executor.submit(() -> { + try { + msgEndpoint.sendPing(md.getSessionRef(), currentTime); + } catch (IOException e) { + log.warn("[{}] Failed to send ping: {}", md.getSessionRef().getSessionId(), e); + } + })); + } + + private static Optional> getKeys(TelemetryPluginCmd cmd) { + if (!StringUtils.isEmpty(cmd.getKeys())) { + Set keys = new HashSet<>(); + Collections.addAll(keys, cmd.getKeys().split(",")); + return Optional.of(keys); + } else { + return Optional.empty(); + } + } + + private ListenableFuture> mergeAllAttributesFutures(List>> futures) { + return Futures.transform(Futures.successfulAsList(futures), + (Function>, ? extends List>) input -> { + List tmp = new ArrayList<>(); + if (input != null) { + input.forEach(tmp::addAll); + } + return tmp; + }, executor); + } + + private FutureCallback getAttributesFetchCallback(final TenantId tenantId, final EntityId entityId, final List keys, final FutureCallback> callback) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable ValidationResult result) { + List>> futures = new ArrayList<>(); + for (String scope : DataConstants.allScopes()) { + futures.add(attributesService.find(tenantId, entityId, scope, keys)); + } + + ListenableFuture> future = mergeAllAttributesFutures(futures); + Futures.addCallback(future, callback, MoreExecutors.directExecutor()); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + private FutureCallback getAttributesFetchCallback(final TenantId tenantId, final EntityId entityId, final String scope, final List keys, final FutureCallback> callback) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable ValidationResult result) { + Futures.addCallback(attributesService.find(tenantId, entityId, scope, keys), callback, MoreExecutors.directExecutor()); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + private FutureCallback getAttributesFetchCallback(final TenantId tenantId, final EntityId entityId, final FutureCallback> callback) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable ValidationResult result) { + List>> futures = new ArrayList<>(); + for (String scope : DataConstants.allScopes()) { + futures.add(attributesService.findAll(tenantId, entityId, scope)); + } + + ListenableFuture> future = mergeAllAttributesFutures(futures); + Futures.addCallback(future, callback, MoreExecutors.directExecutor()); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + private FutureCallback getAttributesFetchCallback(final TenantId tenantId, final EntityId entityId, final String scope, final FutureCallback> callback) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable ValidationResult result) { + Futures.addCallback(attributesService.findAll(tenantId, entityId, scope), callback, MoreExecutors.directExecutor()); + } + + @Override + public void onFailure(Throwable t) { + callback.onFailure(t); + } + }; + } + + private FutureCallback on(Consumer success, Consumer failure) { + return new FutureCallback() { + @Override + public void onSuccess(@Nullable ValidationResult result) { + ValidationResultCode resultCode = result.getResultCode(); + if (resultCode == ValidationResultCode.OK) { + success.accept(null); + } else { + onFailure(ValidationCallback.getException(result)); + } + } + + @Override + public void onFailure(Throwable t) { + failure.accept(t); + } + }; + } + + + public static Aggregation getAggregation(String agg) { + return StringUtils.isEmpty(agg) ? DEFAULT_AGGREGATION : Aggregation.valueOf(agg); + } + + private int getLimit(int limit) { + return limit == 0 ? DEFAULT_LIMIT : limit; + } + + private DefaultTenantProfileConfiguration getTenantProfileConfiguration(TelemetryWebSocketSessionRef sessionRef) { + return Optional.ofNullable(tenantProfileCache.get(sessionRef.getSecurityCtx().getTenantId())) + .map(TenantProfile::getDefaultProfileConfiguration).orElse(null); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java new file mode 100644 index 0000000..298d894 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/InternalTelemetryService.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import com.google.common.util.concurrent.FutureCallback; +import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.List; + +/** + * Created by ashvayka on 27.03.18. + */ +public interface InternalTelemetryService extends RuleEngineTelemetryService { + + void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback); + + void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, long ttl, FutureCallback callback); + + void saveAndNotifyInternal(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, FutureCallback callback); + + void saveLatestAndNotifyInternal(TenantId tenantId, EntityId entityId, List ts, FutureCallback callback); + + void deleteAndNotifyInternal(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, FutureCallback callback); + + void deleteLatestInternal(TenantId tenantId, EntityId entityId, List keys, FutureCallback callback); + + + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/SessionEvent.java b/application/src/main/java/org/thingsboard/server/service/telemetry/SessionEvent.java new file mode 100644 index 0000000..527f5dc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/SessionEvent.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import lombok.Getter; +import lombok.ToString; + +import java.util.Optional; + +@ToString +public class SessionEvent { + + public enum SessionEventType { + ESTABLISHED, CLOSED, ERROR + }; + + @Getter + private final SessionEventType eventType; + @Getter + private final Optional error; + + private SessionEvent(SessionEventType eventType, Throwable error) { + super(); + this.eventType = eventType; + this.error = Optional.ofNullable(error); + } + + public static SessionEvent onEstablished() { + return new SessionEvent(SessionEventType.ESTABLISHED, null); + } + + public static SessionEvent onClosed() { + return new SessionEvent(SessionEventType.CLOSED, null); + } + + public static SessionEvent onError(Throwable t) { + return new SessionEvent(SessionEventType.ERROR, t); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryFeature.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryFeature.java new file mode 100644 index 0000000..b0a6397 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryFeature.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +/** + * Created by ashvayka on 08.05.17. + */ +public enum TelemetryFeature { + + ATTRIBUTES, TIMESERIES; + + public static TelemetryFeature forName(String name) { + return TelemetryFeature.valueOf(name.toUpperCase()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetrySubscriptionService.java new file mode 100644 index 0000000..db02296 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetrySubscriptionService.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import org.springframework.context.ApplicationListener; +import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; + +/** + * Created by ashvayka on 27.03.18. + */ +public interface TelemetrySubscriptionService extends InternalTelemetryService, ApplicationListener { + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketMsgEndpoint.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketMsgEndpoint.java new file mode 100644 index 0000000..d71777a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketMsgEndpoint.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import org.springframework.web.socket.CloseStatus; + +import java.io.IOException; + +/** + * Created by ashvayka on 27.03.18. + */ +public interface TelemetryWebSocketMsgEndpoint { + + void send(TelemetryWebSocketSessionRef sessionRef, int subscriptionId, String msg) throws IOException; + + void sendPing(TelemetryWebSocketSessionRef sessionRef, long currentTime) throws IOException; + + void close(TelemetryWebSocketSessionRef sessionRef, CloseStatus withReason) throws IOException; +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketService.java new file mode 100644 index 0000000..510283b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketService.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import org.springframework.web.socket.CloseStatus; +import org.thingsboard.server.service.telemetry.cmd.v2.CmdUpdate; +import org.thingsboard.server.service.telemetry.cmd.v2.DataUpdate; +import org.thingsboard.server.service.telemetry.sub.TelemetrySubscriptionUpdate; + +/** + * Created by ashvayka on 27.03.18. + */ +public interface TelemetryWebSocketService { + + void handleWebSocketSessionEvent(TelemetryWebSocketSessionRef sessionRef, SessionEvent sessionEvent); + + void handleWebSocketMsg(TelemetryWebSocketSessionRef sessionRef, String msg); + + void sendWsMsg(String sessionId, TelemetrySubscriptionUpdate update); + + void sendWsMsg(String sessionId, CmdUpdate update); + + void close(String sessionId, CloseStatus status); +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketSessionRef.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketSessionRef.java new file mode 100644 index 0000000..da8a896 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketSessionRef.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import lombok.Getter; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 27.03.18. + */ +public class TelemetryWebSocketSessionRef { + + private static final long serialVersionUID = 1L; + + @Getter + private final String sessionId; + @Getter + private final SecurityUser securityCtx; + @Getter + private final InetSocketAddress localAddress; + @Getter + private final InetSocketAddress remoteAddress; + @Getter + private final AtomicInteger sessionSubIdSeq; + + public TelemetryWebSocketSessionRef(String sessionId, SecurityUser securityCtx, InetSocketAddress localAddress, InetSocketAddress remoteAddress) { + this.sessionId = sessionId; + this.securityCtx = securityCtx; + this.localAddress = localAddress; + this.remoteAddress = remoteAddress; + this.sessionSubIdSeq = new AtomicInteger(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TelemetryWebSocketSessionRef that = (TelemetryWebSocketSessionRef) o; + return Objects.equals(sessionId, that.sessionId); + } + + @Override + public int hashCode() { + return Objects.hash(sessionId); + } + + @Override + public String toString() { + return "TelemetryWebSocketSessionRef{" + + "sessionId='" + sessionId + '\'' + + ", localAddress=" + localAddress + + ", remoteAddress=" + remoteAddress + + '}'; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketTextMsg.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketTextMsg.java new file mode 100644 index 0000000..44f2ea0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TelemetryWebSocketTextMsg.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import lombok.Data; + +/** + * Created by ashvayka on 27.03.18. + */ +@Data +public class TelemetryWebSocketTextMsg { + + private final TelemetryWebSocketSessionRef sessionRef; + private final String payload; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/TsData.java b/application/src/main/java/org/thingsboard/server/service/telemetry/TsData.java new file mode 100644 index 0000000..56d1785 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/TsData.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel +public class TsData implements Comparable{ + + private final long ts; + private final Object value; + + public TsData(long ts, Object value) { + super(); + this.ts = ts; + this.value = value; + } + + @ApiModelProperty(position = 1, value = "Timestamp last updated timeseries, in milliseconds", example = "1609459200000", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public long getTs() { + return ts; + } + + @ApiModelProperty(position = 2, value = "Object representing value of timeseries key", example = "20", accessMode = ApiModelProperty.AccessMode.READ_ONLY) + public Object getValue() { + return value; + } + + @Override + public int compareTo(TsData o) { + return Long.compare(ts, o.ts); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/WsSessionMetaData.java b/application/src/main/java/org/thingsboard/server/service/telemetry/WsSessionMetaData.java new file mode 100644 index 0000000..ab353c0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/WsSessionMetaData.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry; + + +/** + * Created by ashvayka on 27.03.18. + */ +public class WsSessionMetaData { + private TelemetryWebSocketSessionRef sessionRef; + private long lastActivityTime; + + public WsSessionMetaData(TelemetryWebSocketSessionRef sessionRef) { + super(); + this.sessionRef = sessionRef; + this.lastActivityTime = System.currentTimeMillis(); + } + + public TelemetryWebSocketSessionRef getSessionRef() { + return sessionRef; + } + + public void setSessionRef(TelemetryWebSocketSessionRef sessionRef) { + this.sessionRef = sessionRef; + } + + public long getLastActivityTime() { + return lastActivityTime; + } + + public void setLastActivityTime(long lastActivityTime) { + this.lastActivityTime = lastActivityTime; + } + + @Override + public String toString() { + return "WsSessionMetaData [sessionRef=" + sessionRef + ", lastActivityTime=" + lastActivityTime + "]"; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/TelemetryPluginCmdsWrapper.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/TelemetryPluginCmdsWrapper.java new file mode 100644 index 0000000..0c46f90 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/TelemetryPluginCmdsWrapper.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd; + +import lombok.Data; +import org.thingsboard.server.service.telemetry.cmd.v1.AttributesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.GetHistoryCmd; +import org.thingsboard.server.service.telemetry.cmd.v1.TimeseriesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.AlarmDataUnsubscribeCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityCountUnsubscribeCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataCmd; +import org.thingsboard.server.service.telemetry.cmd.v2.EntityDataUnsubscribeCmd; + +import java.util.List; + +/** + * @author Andrew Shvayka + */ +@Data +public class TelemetryPluginCmdsWrapper { + + private List attrSubCmds; + + private List tsSubCmds; + + private List historyCmds; + + private List entityDataCmds; + + private List entityDataUnsubscribeCmds; + + private List alarmDataCmds; + + private List alarmDataUnsubscribeCmds; + + private List entityCountCmds; + + private List entityCountUnsubscribeCmds; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/AttributesSubscriptionCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/AttributesSubscriptionCmd.java new file mode 100644 index 0000000..f5e2630 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/AttributesSubscriptionCmd.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v1; + +import lombok.NoArgsConstructor; +import org.thingsboard.server.service.telemetry.TelemetryFeature; + +/** + * @author Andrew Shvayka + */ +@NoArgsConstructor +public class AttributesSubscriptionCmd extends SubscriptionCmd { + + @Override + public TelemetryFeature getType() { + return TelemetryFeature.ATTRIBUTES; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/GetHistoryCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/GetHistoryCmd.java new file mode 100644 index 0000000..b74f2f7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/GetHistoryCmd.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v1; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author Andrew Shvayka + */ +@NoArgsConstructor +@AllArgsConstructor +@Data +public class GetHistoryCmd implements TelemetryPluginCmd { + + private int cmdId; + private String entityType; + private String entityId; + private String keys; + private long startTs; + private long endTs; + private long interval; + private int limit; + private String agg; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/SubscriptionCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/SubscriptionCmd.java new file mode 100644 index 0000000..0c912fa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/SubscriptionCmd.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v1; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.service.telemetry.TelemetryFeature; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public abstract class SubscriptionCmd implements TelemetryPluginCmd { + + private int cmdId; + private String entityType; + private String entityId; + private String keys; + private String scope; + private boolean unsubscribe; + + public abstract TelemetryFeature getType(); + + @Override + public String toString() { + return "SubscriptionCmd [entityType=" + entityType + ", entityId=" + entityId + ", tags=" + keys + ", unsubscribe=" + unsubscribe + "]"; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TelemetryPluginCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TelemetryPluginCmd.java new file mode 100644 index 0000000..7423e9e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TelemetryPluginCmd.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v1; + +/** + * @author Andrew Shvayka + */ +public interface TelemetryPluginCmd { + + int getCmdId(); + + void setCmdId(int cmdId); + + String getKeys(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TimeseriesSubscriptionCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TimeseriesSubscriptionCmd.java new file mode 100644 index 0000000..309a929 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v1/TimeseriesSubscriptionCmd.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v1; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.service.telemetry.TelemetryFeature; + +/** + * @author Andrew Shvayka + */ +@NoArgsConstructor +@AllArgsConstructor +@Data +public class TimeseriesSubscriptionCmd extends SubscriptionCmd { + + private long startTs; + private long timeWindow; + private long interval; + private int limit; + private String agg; + + @Override + public TelemetryFeature getType() { + return TelemetryFeature.TIMESERIES; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggHistoryCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggHistoryCmd.java new file mode 100644 index 0000000..423c55b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggHistoryCmd.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; + +import java.util.List; + +@Data +public class AggHistoryCmd { + + private List keys; + private long startTs; + private long endTs; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggKey.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggKey.java new file mode 100644 index 0000000..d567149 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggKey.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; +import org.thingsboard.server.common.data.kv.Aggregation; + +@Data +public class AggKey { + + private int id; + private String key; + private Aggregation agg; + + private Long previousStartTs; + private Long previousEndTs; + private Boolean previousValueOnly; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggTimeSeriesCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggTimeSeriesCmd.java new file mode 100644 index 0000000..1f4d88d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AggTimeSeriesCmd.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; + +import java.util.List; + +@Data +public class AggTimeSeriesCmd { + + private List keys; + private long startTs; + private long timeWindow; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataCmd.java new file mode 100644 index 0000000..c2c01f8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataCmd.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import org.thingsboard.server.common.data.query.AlarmDataQuery; + +public class AlarmDataCmd extends DataCmd { + + @Getter + private final AlarmDataQuery query; + + @JsonCreator + public AlarmDataCmd(@JsonProperty("cmdId") int cmdId, @JsonProperty("query") AlarmDataQuery query) { + super(cmdId); + this.query = query; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUnsubscribeCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUnsubscribeCmd.java new file mode 100644 index 0000000..153935b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUnsubscribeCmd.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; + +@Data +public class AlarmDataUnsubscribeCmd implements UnsubscribeCmd { + + private final int cmdId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java new file mode 100644 index 0000000..afd9d12 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/AlarmDataUpdate.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; + +import java.util.List; + +@ToString +public class AlarmDataUpdate extends DataUpdate { + + @Getter + private long allowedEntities; + @Getter + private long totalEntities; + + public AlarmDataUpdate(int cmdId, PageData data, List update, long allowedEntities, long totalEntities) { + super(cmdId, data, update, SubscriptionErrorCode.NO_ERROR.getCode(), null); + this.allowedEntities = allowedEntities; + this.totalEntities = totalEntities; + } + + public AlarmDataUpdate(int cmdId, int errorCode, String errorMsg) { + super(cmdId, null, null, errorCode, errorMsg); + } + + @Override + public CmdUpdateType getCmdUpdateType() { + return CmdUpdateType.ALARM_DATA; + } + + @JsonCreator + public AlarmDataUpdate(@JsonProperty("cmdId") int cmdId, + @JsonProperty("data") PageData data, + @JsonProperty("update") List update, + @JsonProperty("errorCode") int errorCode, + @JsonProperty("errorMsg") String errorMsg, + @JsonProperty("allowedEntities") long allowedEntities, + @JsonProperty("totalEntities") long totalEntities) { + super(cmdId, data, update, errorCode, errorMsg); + this.allowedEntities = allowedEntities; + this.totalEntities = totalEntities; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdate.java new file mode 100644 index 0000000..075c80b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdate.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class CmdUpdate { + + private final int cmdId; + private final int errorCode; + private final String errorMsg; + + public abstract CmdUpdateType getCmdUpdateType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdateType.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdateType.java new file mode 100644 index 0000000..cd78e76 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/CmdUpdateType.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +public enum CmdUpdateType { + ENTITY_DATA, + ALARM_DATA, + COUNT_DATA +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataCmd.java new file mode 100644 index 0000000..b93af17 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataCmd.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Data +public class DataCmd { + + @Getter + private final int cmdId; + + public DataCmd(int cmdId) { + this.cmdId = cmdId; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java new file mode 100644 index 0000000..eda60b8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/DataUpdate.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Getter; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; + +import java.util.List; + +public abstract class DataUpdate extends CmdUpdate { + + @Getter + private final PageData data; + @Getter + private final List update; + + public DataUpdate(int cmdId, PageData data, List update, int errorCode, String errorMsg) { + super(cmdId, errorCode, errorMsg); + this.data = data; + this.update = update; + } + + public DataUpdate(int cmdId, PageData data, List update) { + this(cmdId, data, update, SubscriptionErrorCode.NO_ERROR.getCode(), null); + } + + public DataUpdate(int cmdId, int errorCode, String errorMsg) { + this(cmdId, null, null, errorCode, errorMsg); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountCmd.java new file mode 100644 index 0000000..c71884d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountCmd.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityDataQuery; + +public class EntityCountCmd extends DataCmd { + + @Getter + private final EntityCountQuery query; + + @JsonCreator + public EntityCountCmd(@JsonProperty("cmdId") int cmdId, + @JsonProperty("query") EntityCountQuery query) { + super(cmdId); + this.query = query; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUnsubscribeCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUnsubscribeCmd.java new file mode 100644 index 0000000..c8daf30 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUnsubscribeCmd.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; + +@Data +public class EntityCountUnsubscribeCmd implements UnsubscribeCmd { + + private final int cmdId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUpdate.java new file mode 100644 index 0000000..de61d0e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityCountUpdate.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; + +import java.util.List; + +@ToString +public class EntityCountUpdate extends CmdUpdate { + + @Getter + private int count; + + public EntityCountUpdate(int cmdId, int count) { + super(cmdId, SubscriptionErrorCode.NO_ERROR.getCode(), null); + this.count = count; + } + + public EntityCountUpdate(int cmdId, int errorCode, String errorMsg) { + super(cmdId, errorCode, errorMsg); + } + + @Override + public CmdUpdateType getCmdUpdateType() { + return CmdUpdateType.COUNT_DATA; + } + + @JsonCreator + public EntityCountUpdate(@JsonProperty("cmdId") int cmdId, + @JsonProperty("count") int count, + @JsonProperty("errorCode") int errorCode, + @JsonProperty("errorMsg") String errorMsg) { + super(cmdId, errorCode, errorMsg); + this.count = count; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataCmd.java new file mode 100644 index 0000000..8a187af --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataCmd.java @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import org.thingsboard.server.common.data.query.EntityDataQuery; + +public class EntityDataCmd extends DataCmd { + + @Getter + private final EntityDataQuery query; + @Getter + private final EntityHistoryCmd historyCmd; + @Getter + private final LatestValueCmd latestCmd; + @Getter + private final TimeSeriesCmd tsCmd; + @Getter + private final AggHistoryCmd aggHistoryCmd; + @Getter + private final AggTimeSeriesCmd aggTsCmd; + + public EntityDataCmd(int cmdId, EntityDataQuery query, EntityHistoryCmd historyCmd, LatestValueCmd latestCmd, TimeSeriesCmd tsCmd) { + this(cmdId, query, historyCmd, latestCmd, tsCmd, null, null); + } + + @JsonCreator + public EntityDataCmd(@JsonProperty("cmdId") int cmdId, + @JsonProperty("query") EntityDataQuery query, + @JsonProperty("historyCmd") EntityHistoryCmd historyCmd, + @JsonProperty("latestCmd") LatestValueCmd latestCmd, + @JsonProperty("tsCmd") TimeSeriesCmd tsCmd, + @JsonProperty("aggHistoryCmd") AggHistoryCmd aggHistoryCmd, + @JsonProperty("aggTsCmd") AggTimeSeriesCmd aggTsCmd) { + super(cmdId); + this.query = query; + this.historyCmd = historyCmd; + this.latestCmd = latestCmd; + this.tsCmd = tsCmd; + this.aggHistoryCmd = aggHistoryCmd; + this.aggTsCmd = aggTsCmd; + } + + @JsonIgnore + public boolean hasAnyCmd() { + return historyCmd != null || latestCmd != null || tsCmd != null || aggHistoryCmd != null || aggTsCmd != null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUnsubscribeCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUnsubscribeCmd.java new file mode 100644 index 0000000..45aa0c5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUnsubscribeCmd.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; + +@Data +public class EntityDataUnsubscribeCmd implements UnsubscribeCmd { + + private final int cmdId; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java new file mode 100644 index 0000000..8da696c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityDataUpdate.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.ToString; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; + +import java.util.List; + +@ToString +public class EntityDataUpdate extends DataUpdate { + + @Getter + private long allowedEntities; + + public EntityDataUpdate(int cmdId, PageData data, List update, long allowedEntities) { + super(cmdId, data, update, SubscriptionErrorCode.NO_ERROR.getCode(), null); + this.allowedEntities = allowedEntities; + } + + public EntityDataUpdate(int cmdId, int errorCode, String errorMsg) { + super(cmdId, null, null, errorCode, errorMsg); + } + + @Override + public CmdUpdateType getCmdUpdateType() { + return CmdUpdateType.ENTITY_DATA; + } + + @JsonCreator + public EntityDataUpdate(@JsonProperty("cmdId") int cmdId, + @JsonProperty("data") PageData data, + @JsonProperty("update") List update, + @JsonProperty("errorCode") int errorCode, + @JsonProperty("errorMsg") String errorMsg) { + super(cmdId, data, update, errorCode, errorMsg); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityHistoryCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityHistoryCmd.java new file mode 100644 index 0000000..9467758 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/EntityHistoryCmd.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; +import org.thingsboard.server.common.data.kv.Aggregation; + +import java.util.List; + +@Data +public class EntityHistoryCmd implements GetTsCmd { + + private List keys; + private long startTs; + private long endTs; + private long interval; + private int limit; + private Aggregation agg; + private boolean fetchLatestPreviousPoint; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/GetTsCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/GetTsCmd.java new file mode 100644 index 0000000..724c2d6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/GetTsCmd.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import org.thingsboard.server.common.data.kv.Aggregation; + +import java.util.List; + +public interface GetTsCmd { + + long getStartTs(); + + long getEndTs(); + + List getKeys(); + + long getInterval(); + + int getLimit(); + + Aggregation getAgg(); + + boolean isFetchLatestPreviousPoint(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/LatestValueCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/LatestValueCmd.java new file mode 100644 index 0000000..184026a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/LatestValueCmd.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import lombok.Data; +import org.thingsboard.server.common.data.query.EntityKey; + +import java.util.List; + +@Data +public class LatestValueCmd { + + private List keys; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/TimeSeriesCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/TimeSeriesCmd.java new file mode 100644 index 0000000..daf9c32 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/TimeSeriesCmd.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.thingsboard.server.common.data.kv.Aggregation; + +import java.util.List; + +@Data +public class TimeSeriesCmd implements GetTsCmd { + + private List keys; + private long startTs; + private long timeWindow; + private long interval; + private int limit; + private Aggregation agg; + private boolean fetchLatestPreviousPoint; + + @JsonIgnore + @Override + public long getEndTs() { + return startTs + timeWindow; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/UnsubscribeCmd.java b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/UnsubscribeCmd.java new file mode 100644 index 0000000..eec564e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/cmd/v2/UnsubscribeCmd.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.cmd.v2; + +public interface UnsubscribeCmd { + + int getCmdId(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/AccessDeniedException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/AccessDeniedException.java new file mode 100644 index 0000000..43a7f8c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/AccessDeniedException.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class AccessDeniedException extends Exception implements ToErrorResponseEntity { + + public AccessDeniedException(String message) { + super(message); + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return new ResponseEntity<>(getMessage(), HttpStatus.FORBIDDEN); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/EntityNotFoundException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/EntityNotFoundException.java new file mode 100644 index 0000000..3ede4e7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/EntityNotFoundException.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class EntityNotFoundException extends Exception implements ToErrorResponseEntity { + + public EntityNotFoundException(String message) { + super(message); + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return new ResponseEntity<>(getMessage(), HttpStatus.NOT_FOUND); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InternalErrorException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InternalErrorException.java new file mode 100644 index 0000000..ae68ffd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InternalErrorException.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class InternalErrorException extends Exception implements ToErrorResponseEntity { + + public InternalErrorException(String message) { + super(message); + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return new ResponseEntity<>(getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InvalidParametersException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InvalidParametersException.java new file mode 100644 index 0000000..7452d25 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/InvalidParametersException.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class InvalidParametersException extends Exception implements ToErrorResponseEntity { + + public InvalidParametersException(String message) { + super(message); + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return new ResponseEntity<>(getMessage(), HttpStatus.BAD_REQUEST); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/ToErrorResponseEntity.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/ToErrorResponseEntity.java new file mode 100644 index 0000000..769c5ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/ToErrorResponseEntity.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.ResponseEntity; + +import java.io.Serializable; + +public interface ToErrorResponseEntity extends Serializable { + + ResponseEntity toErrorResponseEntity(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UnauthorizedException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UnauthorizedException.java new file mode 100644 index 0000000..b597a84 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UnauthorizedException.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Created by ashvayka on 21.02.17. + */ +public class UnauthorizedException extends Exception implements ToErrorResponseEntity { + + public UnauthorizedException(String message) { + super(message); + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return new ResponseEntity<>(getMessage(), HttpStatus.UNAUTHORIZED); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UncheckedApiException.java b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UncheckedApiException.java new file mode 100644 index 0000000..8ee4bfa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/exception/UncheckedApiException.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.exception; + +import org.springframework.http.ResponseEntity; + +import java.util.Objects; + +public class UncheckedApiException extends RuntimeException implements ToErrorResponseEntity { + + private final ToErrorResponseEntity cause; + + public UncheckedApiException(T cause) { + super(cause.getMessage(), Objects.requireNonNull(cause)); + this.cause = cause; + } + + @Override + public ResponseEntity toErrorResponseEntity() { + return cause.toErrorResponseEntity(); + } +} + diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/AlarmSubscriptionUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/AlarmSubscriptionUpdate.java new file mode 100644 index 0000000..3f4bc9c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/AlarmSubscriptionUpdate.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.sub; + +import lombok.Getter; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.query.AlarmData; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class AlarmSubscriptionUpdate { + + @Getter + private int subscriptionId; + @Getter + private int errorCode; + @Getter + private String errorMsg; + @Getter + private Alarm alarm; + @Getter + private boolean alarmDeleted; + + public AlarmSubscriptionUpdate(int subscriptionId, Alarm alarm) { + this(subscriptionId, alarm, false); + } + + public AlarmSubscriptionUpdate(int subscriptionId, Alarm alarm, boolean alarmDeleted) { + super(); + this.subscriptionId = subscriptionId; + this.alarm = alarm; + this.alarmDeleted = alarmDeleted; + } + + public AlarmSubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode) { + this(subscriptionId, errorCode, null); + } + + public AlarmSubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode, String errorMsg) { + super(); + this.subscriptionId = subscriptionId; + this.errorCode = errorCode.getCode(); + this.errorMsg = errorMsg != null ? errorMsg : errorCode.getDefaultMsg(); + } + + @Override + public String toString() { + return "AlarmUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", alarm=" + + alarm + "]"; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionErrorCode.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionErrorCode.java new file mode 100644 index 0000000..e5fe16d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionErrorCode.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.sub; + +public enum SubscriptionErrorCode { + + NO_ERROR(0), INTERNAL_ERROR(1, "Internal Server error!"), BAD_REQUEST(2, "Bad request"), UNAUTHORIZED(3, "Unauthorized"); + + private final int code; + private final String defaultMsg; + + private SubscriptionErrorCode(int code) { + this(code, null); + } + + private SubscriptionErrorCode(int code, String defaultMsg) { + this.code = code; + this.defaultMsg = defaultMsg; + } + + public static SubscriptionErrorCode forCode(int code) { + for (SubscriptionErrorCode errorCode : SubscriptionErrorCode.values()) { + if (errorCode.getCode() == code) { + return errorCode; + } + } + throw new IllegalArgumentException("Invalid error code: " + code); + } + + public int getCode() { + return code; + } + + public String getDefaultMsg() { + return defaultMsg; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionState.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionState.java new file mode 100644 index 0000000..1343527 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/SubscriptionState.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.sub; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.telemetry.TelemetryFeature; + +import java.util.Map; + +/** + * @author Andrew Shvayka + */ +@AllArgsConstructor +public class SubscriptionState { + + @Getter private final String wsSessionId; + @Getter private final int subscriptionId; + @Getter private final TenantId tenantId; + @Getter private final EntityId entityId; + @Getter private final TelemetryFeature type; + @Getter private final boolean allKeys; + @Getter private final Map keyStates; + @Getter private final String scope; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SubscriptionState that = (SubscriptionState) o; + + if (subscriptionId != that.subscriptionId) return false; + if (wsSessionId != null ? !wsSessionId.equals(that.wsSessionId) : that.wsSessionId != null) return false; + if (entityId != null ? !entityId.equals(that.entityId) : that.entityId != null) return false; + return type == that.type; + } + + @Override + public int hashCode() { + int result = wsSessionId != null ? wsSessionId.hashCode() : 0; + result = 31 * result + subscriptionId; + result = 31 * result + (entityId != null ? entityId.hashCode() : 0); + result = 31 * result + (type != null ? type.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "SubscriptionState{" + + "type=" + type + + ", entityId=" + entityId + + ", subscriptionId=" + subscriptionId + + ", wsSessionId='" + wsSessionId + '\'' + + '}'; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/TelemetrySubscriptionUpdate.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/TelemetrySubscriptionUpdate.java new file mode 100644 index 0000000..cbbce43 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/TelemetrySubscriptionUpdate.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.telemetry.sub; + +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class TelemetrySubscriptionUpdate { + + private int subscriptionId; + private int errorCode; + private String errorMsg; + private Map> data; + + public TelemetrySubscriptionUpdate(int subscriptionId, List data) { + super(); + this.subscriptionId = subscriptionId; + this.data = new TreeMap<>(); + if (data != null) { + for (TsKvEntry tsEntry : data) { + List values = this.data.computeIfAbsent(tsEntry.getKey(), k -> new ArrayList<>()); + Object[] value = new Object[2]; + value[0] = tsEntry.getTs(); + value[1] = tsEntry.getValueAsString(); + values.add(value); + } + } + } + + public TelemetrySubscriptionUpdate(int subscriptionId, Map> data) { + super(); + this.subscriptionId = subscriptionId; + this.data = data; + } + + public TelemetrySubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode) { + this(subscriptionId, errorCode, null); + } + + public TelemetrySubscriptionUpdate(int subscriptionId, SubscriptionErrorCode errorCode, String errorMsg) { + super(); + this.subscriptionId = subscriptionId; + this.errorCode = errorCode.getCode(); + this.errorMsg = errorMsg != null ? errorMsg : errorCode.getDefaultMsg(); + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public Map> getData() { + return data; + } + + public Map getLatestValues() { + if (data == null) { + return Collections.emptyMap(); + } else { + return data.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> { + List data = e.getValue(); + Object[] latest = (Object[]) data.get(data.size() - 1); + return (long) latest[0]; + })); + } + } + + public int getErrorCode() { + return errorCode; + } + + public String getErrorMsg() { + return errorMsg; + } + + @Override + public String toString() { + return "TsSubscriptionUpdate [subscriptionId=" + subscriptionId + ", errorCode=" + errorCode + ", errorMsg=" + errorMsg + ", data=" + + data + "]"; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/BasicCredentialsValidationResult.java b/application/src/main/java/org/thingsboard/server/service/transport/BasicCredentialsValidationResult.java new file mode 100644 index 0000000..e4c96ce --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/BasicCredentialsValidationResult.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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; + +enum BasicCredentialsValidationResult {HASH_MISMATCH, PASSWORD_MISMATCH, VALID} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTbCoreToTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTbCoreToTransportService.java new file mode 100644 index 0000000..0228743 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTbCoreToTransportService.java @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.stereotype.Service; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.queue.TbQueueCallback; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.NotificationsTopicService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.UUID; +import java.util.function.Consumer; + +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@Slf4j +@Service +@TbCoreComponent +public class DefaultTbCoreToTransportService implements TbCoreToTransportService { + + private final NotificationsTopicService notificationsTopicService; + private final TbQueueProducer> tbTransportProducer; + + public DefaultTbCoreToTransportService(NotificationsTopicService notificationsTopicService, TbQueueProducerProvider tbQueueProducerProvider) { + this.notificationsTopicService = notificationsTopicService; + this.tbTransportProducer = tbQueueProducerProvider.getTransportNotificationsMsgProducer(); + } + + @Override + public void process(String nodeId, ToTransportMsg msg) { + process(nodeId, msg, null, null); + } + + @Override + public void process(String nodeId, ToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { + if (nodeId == null || nodeId.isEmpty()) { + log.trace("process: skipping message without nodeId [{}], (ToTransportMsg) msg [{}]", nodeId, msg); + if (onSuccess != null) { + onSuccess.run(); + } + return; + } + TopicPartitionInfo tpi = notificationsTopicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, nodeId); + UUID sessionId = new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()); + log.trace("[{}][{}] Pushing session data to topic: {}", tpi.getFullTopicName(), sessionId, msg); + TbProtoQueueMsg queueMsg = new TbProtoQueueMsg<>(NULL_UUID, msg); + tbTransportProducer.send(tpi, queueMsg, new QueueCallbackAdaptor(onSuccess, onFailure)); + } + + private static class QueueCallbackAdaptor implements TbQueueCallback { + private final Runnable onSuccess; + private final Consumer onFailure; + + QueueCallbackAdaptor(Runnable onSuccess, Consumer onFailure) { + this.onSuccess = onSuccess; + this.onFailure = onFailure; + } + + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + if (onSuccess != null) { + onSuccess.run(); + } + } + + @Override + public void onFailure(Throwable t) { + if (onFailure != null) { + onFailure.accept(t); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java new file mode 100644 index 0000000..f8b42ec --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -0,0 +1,667 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.ByteString; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.device.credentials.ProvisionDeviceCredentialsData; +import org.thingsboard.server.common.data.device.data.CoapDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.PowerMode; +import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileCredentials; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.OtaPackageId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ota.OtaPackageType; +import org.thingsboard.server.common.data.ota.OtaPackageUtil; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.queue.Queue; +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.common.msg.EncryptionUtil; +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.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionFailedException; +import org.thingsboard.server.dao.device.provision.ProvisionRequest; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetDeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetDeviceRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetResourceRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetSnmpDevicesRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetSnmpDevicesResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; +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.ValidateDeviceLwM2MCredentialsRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; + +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.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; +import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Service +@TbCoreComponent +@RequiredArgsConstructor +public class DefaultTransportApiService implements TransportApiService { + + private static final ObjectMapper mapper = new ObjectMapper(); + + private final TbDeviceProfileCache deviceProfileCache; + private final TbTenantProfileCache tenantProfileCache; + private final TbApiUsageStateService apiUsageStateService; + private final DeviceService deviceService; + private final RelationService relationService; + private final DeviceCredentialsService deviceCredentialsService; + private final DbCallbackExecutorService dbCallbackExecutorService; + private final TbClusterService tbClusterService; + private final DataDecodingEncodingService dataDecodingEncodingService; + private final DeviceProvisionService deviceProvisionService; + private final TbResourceService resourceService; + private final OtaPackageService otaPackageService; + private final OtaPackageDataCache otaPackageDataCache; + private final QueueService queueService; + + private final ConcurrentMap deviceCreationLocks = new ConcurrentHashMap<>(); + + private static boolean checkIsMqttCredentials(DeviceCredentials credentials) { + return credentials != null && DeviceCredentialsType.MQTT_BASIC.equals(credentials.getCredentialsType()); + } + + @Override + public ListenableFuture> handle(TbProtoQueueMsg tbProtoQueueMsg) { + TransportApiRequestMsg transportApiRequestMsg = tbProtoQueueMsg.getValue(); + ListenableFuture result = null; + + if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { + ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); + result = validateCredentials(msg.getToken(), DeviceCredentialsType.ACCESS_TOKEN); + } else if (transportApiRequestMsg.hasValidateBasicMqttCredRequestMsg()) { + TransportProtos.ValidateBasicMqttCredRequestMsg msg = transportApiRequestMsg.getValidateBasicMqttCredRequestMsg(); + result = validateCredentials(msg); + } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { + ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); + result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { + result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); + } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { + result = handle(transportApiRequestMsg.getEntityProfileRequestMsg()); + } else if (transportApiRequestMsg.hasLwM2MRequestMsg()) { + result = handle(transportApiRequestMsg.getLwM2MRequestMsg()); + } else if (transportApiRequestMsg.hasValidateDeviceLwM2MCredentialsRequestMsg()) { + ValidateDeviceLwM2MCredentialsRequestMsg msg = transportApiRequestMsg.getValidateDeviceLwM2MCredentialsRequestMsg(); + result = validateCredentials(msg.getCredentialsId(), DeviceCredentialsType.LWM2M_CREDENTIALS); + } else if (transportApiRequestMsg.hasProvisionDeviceRequestMsg()) { + result = handle(transportApiRequestMsg.getProvisionDeviceRequestMsg()); + } else if (transportApiRequestMsg.hasResourceRequestMsg()) { + result = handle(transportApiRequestMsg.getResourceRequestMsg()); + } else if (transportApiRequestMsg.hasSnmpDevicesRequestMsg()) { + result = handle(transportApiRequestMsg.getSnmpDevicesRequestMsg()); + } else if (transportApiRequestMsg.hasDeviceRequestMsg()) { + result = handle(transportApiRequestMsg.getDeviceRequestMsg()); + } else if (transportApiRequestMsg.hasDeviceCredentialsRequestMsg()) { + result = handle(transportApiRequestMsg.getDeviceCredentialsRequestMsg()); + } else if (transportApiRequestMsg.hasOtaPackageRequestMsg()) { + result = handle(transportApiRequestMsg.getOtaPackageRequestMsg()); + } else if (transportApiRequestMsg.hasGetAllQueueRoutingInfoRequestMsg()) { + return Futures.transform(handle(transportApiRequestMsg.getGetAllQueueRoutingInfoRequestMsg()), value -> new TbProtoQueueMsg<>(tbProtoQueueMsg.getKey(), value, tbProtoQueueMsg.getHeaders()), MoreExecutors.directExecutor()); + } + + return Futures.transform(Optional.ofNullable(result).orElseGet(this::getEmptyTransportApiResponseFuture), + value -> new TbProtoQueueMsg<>(tbProtoQueueMsg.getKey(), value, tbProtoQueueMsg.getHeaders()), + MoreExecutors.directExecutor()); + } + + private ListenableFuture validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { + //TODO: Make async and enable caching + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + + private ListenableFuture validateCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { + DeviceCredentials credentials; + if (StringUtils.isEmpty(mqtt.getUserName())) { + credentials = checkMqttCredentials(mqtt, EncryptionUtil.getSha3Hash(mqtt.getClientId())); + if (credentials != null) { + return getDeviceInfo(credentials); + } else { + return getEmptyTransportApiResponseFuture(); + } + } else { + credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId( + EncryptionUtil.getSha3Hash("|", mqtt.getClientId(), mqtt.getUserName())); + if (checkIsMqttCredentials(credentials)) { + var validationResult = validateMqttCredentials(mqtt, credentials); + if (VALID.equals(validationResult)) { + return getDeviceInfo(credentials); + } else if (PASSWORD_MISMATCH.equals(validationResult)) { + return getEmptyTransportApiResponseFuture(); + } else { + return validateUserNameCredentials(mqtt); + } + } else { + return validateUserNameCredentials(mqtt); + } + } + } + + private ListenableFuture validateUserNameCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(mqtt.getUserName()); + if (credentials != null) { + switch (credentials.getCredentialsType()) { + case ACCESS_TOKEN: + return getDeviceInfo(credentials); + case MQTT_BASIC: + if (VALID.equals(validateMqttCredentials(mqtt, credentials))) { + return getDeviceInfo(credentials); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + } + return getEmptyTransportApiResponseFuture(); + } + + private DeviceCredentials checkMqttCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg clientCred, String credId) { + return checkMqttCredentials(clientCred, deviceCredentialsService.findDeviceCredentialsByCredentialsId(credId)); + } + + private DeviceCredentials checkMqttCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg clientCred, DeviceCredentials deviceCredentials) { + if (deviceCredentials != null && deviceCredentials.getCredentialsType() == DeviceCredentialsType.MQTT_BASIC) { + if (VALID.equals(validateMqttCredentials(clientCred, deviceCredentials))) { + return deviceCredentials; + } + } + return null; + } + + private BasicCredentialsValidationResult validateMqttCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg clientCred, DeviceCredentials deviceCredentials) { + BasicMqttCredentials dbCred = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials.class); + if (!StringUtils.isEmpty(dbCred.getClientId()) && !dbCred.getClientId().equals(clientCred.getClientId())) { + return BasicCredentialsValidationResult.HASH_MISMATCH; + } + if (!StringUtils.isEmpty(dbCred.getUserName()) && !dbCred.getUserName().equals(clientCred.getUserName())) { + return BasicCredentialsValidationResult.HASH_MISMATCH; + } + if (!StringUtils.isEmpty(dbCred.getPassword())) { + if (StringUtils.isEmpty(clientCred.getPassword())) { + return BasicCredentialsValidationResult.PASSWORD_MISMATCH; + } else { + return dbCred.getPassword().equals(clientCred.getPassword()) ? VALID : BasicCredentialsValidationResult.PASSWORD_MISMATCH; + } + } + return VALID; + } + + private ListenableFuture handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { + DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(TenantId.SYS_TENANT_ID, gatewayId); + return Futures.transform(gatewayFuture, gateway -> { + Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock()); + deviceCreationLock.lock(); + try { + Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); + if (device == null) { + TenantId tenantId = gateway.getTenantId(); + device = new Device(); + device.setTenantId(tenantId); + device.setName(requestMsg.getDeviceName()); + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType()); + device.setDeviceProfileId(deviceProfile.getId()); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); + device.setAdditionalInfo(additionalInfo); + Device savedDevice = deviceService.saveDevice(device); + tbClusterService.onDeviceUpdated(savedDevice, null); + device = savedDevice; + + relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created")); + + TbMsgMetaData metaData = new TbMsgMetaData(); + CustomerId customerId = gateway.getCustomerId(); + if (customerId != null && !customerId.isNullUid()) { + metaData.putValue("customerId", customerId.toString()); + } + metaData.putValue("gatewayId", gatewayId.toString()); + + DeviceId deviceId = device.getId(); + ObjectNode entityNode = mapper.valueToTree(device); + TbMsg tbMsg = TbMsg.newMsg(DataConstants.ENTITY_CREATED, deviceId, customerId, metaData, TbMsgDataType.JSON, mapper.writeValueAsString(entityNode)); + tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null); + } else { + JsonNode deviceAdditionalInfo = device.getAdditionalInfo(); + if (deviceAdditionalInfo == null) { + deviceAdditionalInfo = JacksonUtil.newObjectNode(); + } + if (deviceAdditionalInfo.isObject() && + (!deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY) + || !gatewayId.toString().equals(deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY).asText()))) { + ObjectNode newDeviceAdditionalInfo = (ObjectNode) deviceAdditionalInfo; + newDeviceAdditionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); + Device savedDevice = deviceService.saveDevice(device); + tbClusterService.onDeviceUpdated(savedDevice, device); + } + } + GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() + .setDeviceInfo(getDeviceInfoProto(device)); + DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId()); + if (deviceProfile != null) { + builder.setProfileBody(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile))); + } else { + log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId()); + } + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg(builder.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 handle(ProvisionDeviceRequestMsg requestMsg) { + ListenableFuture provisionResponseFuture = null; + try { + provisionResponseFuture = Futures.immediateFuture(deviceProvisionService.provisionDevice( + new ProvisionRequest( + requestMsg.getDeviceName(), + requestMsg.getCredentialsType() != null ? DeviceCredentialsType.valueOf(requestMsg.getCredentialsType().name()) : null, + new ProvisionDeviceCredentialsData(requestMsg.getCredentialsDataProto().getValidateDeviceTokenRequestMsg().getToken(), + requestMsg.getCredentialsDataProto().getValidateBasicMqttCredRequestMsg().getClientId(), + requestMsg.getCredentialsDataProto().getValidateBasicMqttCredRequestMsg().getUserName(), + requestMsg.getCredentialsDataProto().getValidateBasicMqttCredRequestMsg().getPassword(), + requestMsg.getCredentialsDataProto().getValidateDeviceX509CertRequestMsg().getHash()), + new ProvisionDeviceProfileCredentials( + requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceKey(), + requestMsg.getProvisionDeviceCredentialsMsg().getProvisionDeviceSecret())))); + } catch (ProvisionFailedException e) { + return Futures.immediateFuture(getTransportApiResponseMsg( + new DeviceCredentials(), + TransportProtos.ResponseStatus.valueOf(e.getMessage()))); + } + return Futures.transform(provisionResponseFuture, provisionResponse -> getTransportApiResponseMsg(provisionResponse.getDeviceCredentials(), TransportProtos.ResponseStatus.SUCCESS), + dbCallbackExecutorService); + } + + private TransportApiResponseMsg getTransportApiResponseMsg(DeviceCredentials + deviceCredentials, TransportProtos.ResponseStatus status) { + if (!status.equals(TransportProtos.ResponseStatus.SUCCESS)) { + return TransportApiResponseMsg.newBuilder().setProvisionDeviceResponseMsg(TransportProtos.ProvisionDeviceResponseMsg.newBuilder().setStatus(status).build()).build(); + } + TransportProtos.ProvisionDeviceResponseMsg.Builder provisionResponse = TransportProtos.ProvisionDeviceResponseMsg.newBuilder() + .setCredentialsType(TransportProtos.CredentialsType.valueOf(deviceCredentials.getCredentialsType().name())) + .setStatus(status); + switch (deviceCredentials.getCredentialsType()) { + case ACCESS_TOKEN: + provisionResponse.setCredentialsValue(deviceCredentials.getCredentialsId()); + break; + case MQTT_BASIC: + case X509_CERTIFICATE: + case LWM2M_CREDENTIALS: + provisionResponse.setCredentialsValue(deviceCredentials.getCredentialsValue()); + break; + } + + return TransportApiResponseMsg.newBuilder() + .setProvisionDeviceResponseMsg(provisionResponse.build()) + .build(); + } + + private ListenableFuture handle(GetEntityProfileRequestMsg requestMsg) { + EntityType entityType = EntityType.valueOf(requestMsg.getEntityType()); + UUID entityUuid = new UUID(requestMsg.getEntityIdMSB(), requestMsg.getEntityIdLSB()); + GetEntityProfileResponseMsg.Builder builder = GetEntityProfileResponseMsg.newBuilder(); + if (entityType.equals(EntityType.DEVICE_PROFILE)) { + DeviceProfileId deviceProfileId = new DeviceProfileId(entityUuid); + DeviceProfile deviceProfile = deviceProfileCache.find(deviceProfileId); + builder.setData(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile))); + } else if (entityType.equals(EntityType.TENANT)) { + TenantId tenantId = TenantId.fromUUID(entityUuid); + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + ApiUsageState state = apiUsageStateService.getApiUsageState(tenantId); + builder.setData(ByteString.copyFrom(dataDecodingEncodingService.encode(tenantProfile))); + builder.setApiState(ByteString.copyFrom(dataDecodingEncodingService.encode(state))); + } else { + throw new RuntimeException("Invalid entity profile request: " + entityType); + } + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setEntityProfileResponseMsg(builder).build()); + } + + private ListenableFuture handle(GetDeviceRequestMsg requestMsg) { + DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); + Device device = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, deviceId); + + TransportApiResponseMsg responseMsg; + if (device != null) { + UUID deviceProfileId = device.getDeviceProfileId().getId(); + responseMsg = TransportApiResponseMsg.newBuilder() + .setDeviceResponseMsg(TransportProtos.GetDeviceResponseMsg.newBuilder() + .setDeviceProfileIdMSB(deviceProfileId.getMostSignificantBits()) + .setDeviceProfileIdLSB(deviceProfileId.getLeastSignificantBits()) + .setDeviceTransportConfiguration(ByteString.copyFrom( + dataDecodingEncodingService.encode(device.getDeviceData().getTransportConfiguration()) + ))) + .build(); + } else { + responseMsg = TransportApiResponseMsg.getDefaultInstance(); + } + + return Futures.immediateFuture(responseMsg); + } + + private ListenableFuture handle(GetDeviceCredentialsRequestMsg requestMsg) { + DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(TenantId.SYS_TENANT_ID, deviceId); + + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + .setDeviceCredentialsResponseMsg(TransportProtos.GetDeviceCredentialsResponseMsg.newBuilder() + .setDeviceCredentialsData(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceCredentials)))) + .build()); + } + + + private ListenableFuture handle(GetResourceRequestMsg requestMsg) { + TenantId tenantId = TenantId.fromUUID(new UUID(requestMsg.getTenantIdMSB(), requestMsg.getTenantIdLSB())); + ResourceType resourceType = ResourceType.valueOf(requestMsg.getResourceType()); + String resourceKey = requestMsg.getResourceKey(); + TransportProtos.GetResourceResponseMsg.Builder builder = TransportProtos.GetResourceResponseMsg.newBuilder(); + TbResource resource = resourceService.getResource(tenantId, resourceType, resourceKey); + + if (resource == null && !tenantId.equals(TenantId.SYS_TENANT_ID)) { + resource = resourceService.getResource(TenantId.SYS_TENANT_ID, resourceType, resourceKey); + } + + if (resource != null) { + builder.setResource(ByteString.copyFrom(dataDecodingEncodingService.encode(resource))); + } + + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setResourceResponseMsg(builder).build()); + } + + private ListenableFuture handle(GetSnmpDevicesRequestMsg requestMsg) { + PageLink pageLink = new PageLink(requestMsg.getPageSize(), requestMsg.getPage()); + PageData result = deviceService.findDevicesIdsByDeviceProfileTransportType(DeviceTransportType.SNMP, pageLink); + + GetSnmpDevicesResponseMsg responseMsg = GetSnmpDevicesResponseMsg.newBuilder() + .addAllIds(result.getData().stream() + .map(UUID::toString) + .collect(Collectors.toList())) + .setHasNextPage(result.hasNext()) + .build(); + + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + .setSnmpDevicesResponseMsg(responseMsg) + .build()); + } + + private ListenableFuture getDeviceInfo(DeviceCredentials credentials) { + return Futures.transform(deviceService.findDeviceByIdAsync(TenantId.SYS_TENANT_ID, credentials.getDeviceId()), device -> { + if (device == null) { + log.trace("[{}] Failed to lookup device by id", credentials.getDeviceId()); + return getEmptyTransportApiResponse(); + } + try { + ValidateDeviceCredentialsResponseMsg.Builder builder = ValidateDeviceCredentialsResponseMsg.newBuilder(); + builder.setDeviceInfo(getDeviceInfoProto(device)); + DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId()); + if (deviceProfile != null) { + builder.setProfileBody(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile))); + } else { + log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId()); + } + if (!StringUtils.isEmpty(credentials.getCredentialsValue())) { + builder.setCredentialsBody(credentials.getCredentialsValue()); + } + return TransportApiResponseMsg.newBuilder() + .setValidateCredResponseMsg(builder.build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by id", credentials.getDeviceId(), e); + return getEmptyTransportApiResponse(); + } + }, MoreExecutors.directExecutor()); + } + + private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { + DeviceInfoProto.Builder builder = DeviceInfoProto.newBuilder() + .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) + .setCustomerIdMSB(Optional.ofNullable(device.getCustomerId()).map(customerId -> customerId.getId().getMostSignificantBits()).orElse(0L)) + .setCustomerIdLSB(Optional.ofNullable(device.getCustomerId()).map(customerId -> customerId.getId().getLeastSignificantBits()).orElse(0L)) + .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) + .setDeviceName(device.getName()) + .setDeviceType(device.getType()) + .setDeviceProfileIdMSB(device.getDeviceProfileId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(device.getDeviceProfileId().getId().getLeastSignificantBits()) + .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())); + + PowerSavingConfiguration psmConfiguration = null; + switch (device.getDeviceData().getTransportConfiguration().getType()) { + case LWM2M: + psmConfiguration = (Lwm2mDeviceTransportConfiguration) device.getDeviceData().getTransportConfiguration(); + break; + case COAP: + psmConfiguration = (CoapDeviceTransportConfiguration) device.getDeviceData().getTransportConfiguration(); + break; + } + + if (psmConfiguration != null) { + PowerMode powerMode = psmConfiguration.getPowerMode(); + if (powerMode != null) { + builder.setPowerMode(powerMode.name()); + if (powerMode.equals(PowerMode.PSM)) { + builder.setPsmActivityTimer(checkLong(psmConfiguration.getPsmActivityTimer())); + } else if (powerMode.equals(PowerMode.E_DRX)) { + builder.setEdrxCycle(checkLong(psmConfiguration.getEdrxCycle())); + builder.setPagingTransmissionWindow(checkLong(psmConfiguration.getPagingTransmissionWindow())); + } + } + } + return builder.build(); + } + + private ListenableFuture getEmptyTransportApiResponseFuture() { + return Futures.immediateFuture(getEmptyTransportApiResponse()); + } + + private TransportApiResponseMsg getEmptyTransportApiResponse() { + return TransportApiResponseMsg.newBuilder() + .setValidateCredResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); + } + + private ListenableFuture handle(TransportProtos.LwM2MRequestMsg requestMsg) { + if (requestMsg.hasRegistrationMsg()) { + return handleRegistration(requestMsg.getRegistrationMsg()); + } else { + return Futures.immediateFailedFuture(new RuntimeException("Not supported!")); + } + } + + private ListenableFuture handle(TransportProtos.GetOtaPackageRequestMsg requestMsg) { + TenantId tenantId = TenantId.fromUUID(new UUID(requestMsg.getTenantIdMSB(), requestMsg.getTenantIdLSB())); + DeviceId deviceId = new DeviceId(new UUID(requestMsg.getDeviceIdMSB(), requestMsg.getDeviceIdLSB())); + OtaPackageType otaPackageType = OtaPackageType.valueOf(requestMsg.getType()); + Device device = deviceService.findDeviceById(tenantId, deviceId); + + if (device == null) { + return getEmptyTransportApiResponseFuture(); + } + + OtaPackageId otaPackageId = OtaPackageUtil.getOtaPackageId(device, otaPackageType); + if (otaPackageId == null) { + DeviceProfile deviceProfile = deviceProfileCache.find(device.getDeviceProfileId()); + otaPackageId = OtaPackageUtil.getOtaPackageId(deviceProfile, otaPackageType); + } + + TransportProtos.GetOtaPackageResponseMsg.Builder builder = TransportProtos.GetOtaPackageResponseMsg.newBuilder(); + + if (otaPackageId == null) { + builder.setResponseStatus(TransportProtos.ResponseStatus.NOT_FOUND); + } else { + OtaPackageInfo otaPackageInfo = otaPackageService.findOtaPackageInfoById(tenantId, otaPackageId); + + if (otaPackageInfo == null) { + builder.setResponseStatus(TransportProtos.ResponseStatus.NOT_FOUND); + } else if (otaPackageInfo.hasUrl()) { + builder.setResponseStatus(TransportProtos.ResponseStatus.FAILURE); + log.trace("[{}] Can`t send OtaPackage with URL data!", otaPackageInfo.getId()); + } else { + builder.setResponseStatus(TransportProtos.ResponseStatus.SUCCESS); + builder.setOtaPackageIdMSB(otaPackageId.getId().getMostSignificantBits()); + builder.setOtaPackageIdLSB(otaPackageId.getId().getLeastSignificantBits()); + builder.setType(otaPackageInfo.getType().name()); + builder.setTitle(otaPackageInfo.getTitle()); + builder.setVersion(otaPackageInfo.getVersion()); + builder.setFileName(otaPackageInfo.getFileName()); + builder.setContentType(otaPackageInfo.getContentType()); + if (!otaPackageDataCache.has(otaPackageId.toString())) { + OtaPackage otaPackage = otaPackageService.findOtaPackageById(tenantId, otaPackageId); + otaPackageDataCache.put(otaPackageId.toString(), otaPackage.getData().array()); + } + } + } + + return Futures.immediateFuture( + TransportApiResponseMsg.newBuilder() + .setOtaPackageResponseMsg(builder.build()) + .build()); + } + + private ListenableFuture handleRegistration + (TransportProtos.LwM2MRegistrationRequestMsg msg) { + TenantId tenantId = TenantId.fromUUID(UUID.fromString(msg.getTenantId())); + String deviceName = msg.getEndpoint(); + Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(deviceName, id -> new ReentrantLock()); + deviceCreationLock.lock(); + try { + Device device = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName); + if (device == null) { + device = new Device(); + device.setTenantId(tenantId); + device.setName(deviceName); + device.setType("LwM2M"); + device = deviceService.saveDevice(device); + tbClusterService.onDeviceUpdated(device, null); + } + TransportProtos.LwM2MRegistrationResponseMsg registrationResponseMsg = + TransportProtos.LwM2MRegistrationResponseMsg.newBuilder() + .setDeviceInfo(getDeviceInfoProto(device)).build(); + TransportProtos.LwM2MResponseMsg responseMsg = TransportProtos.LwM2MResponseMsg.newBuilder().setRegistrationMsg(registrationResponseMsg).build(); + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder().setLwM2MResponseMsg(responseMsg).build()); + } catch (JsonProcessingException e) { + log.warn("[{}][{}] Failed to lookup device by gateway id and name", tenantId, deviceName, e); + throw new RuntimeException(e); + } finally { + deviceCreationLock.unlock(); + } + } + + private ListenableFuture handle(TransportProtos.GetAllQueueRoutingInfoRequestMsg requestMsg) { + return queuesToTransportApiResponseMsg(queueService.findAllQueues()); + } + + private ListenableFuture queuesToTransportApiResponseMsg(List queues) { + return Futures.immediateFuture(TransportApiResponseMsg.newBuilder() + .addAllGetQueueRoutingInfoResponseMsgs(queues.stream() + .map(queue -> TransportProtos.GetQueueRoutingInfoResponseMsg.newBuilder() + .setTenantIdMSB(queue.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(queue.getTenantId().getId().getLeastSignificantBits()) + .setQueueIdMSB(queue.getId().getId().getMostSignificantBits()) + .setQueueIdLSB(queue.getId().getId().getLeastSignificantBits()) + .setQueueName(queue.getName()) + .setQueueTopic(queue.getTopic()) + .setPartitions(queue.getPartitions()) + .build()).collect(Collectors.toList())).build()); + } + + + private Long checkLong(Long l) { + return l != null ? l : 0; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreToTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreToTransportService.java new file mode 100644 index 0000000..5f37758 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreToTransportService.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; + +import java.util.function.Consumer; + +public interface TbCoreToTransportService { + + void process(String nodeId, ToTransportMsg msg); + + void process(String nodeId, ToTransportMsg msg, Runnable onSuccess, Consumer onFailure); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java new file mode 100644 index 0000000..323ca25 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TbCoreTransportApiService.java @@ -0,0 +1,111 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.stats.MessagesStats; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.TbQueueResponseTemplate; +import org.thingsboard.server.queue.common.DefaultTbQueueResponseTemplate; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.*; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Service +@TbCoreComponent +public class TbCoreTransportApiService { + private final TbCoreQueueFactory tbCoreQueueFactory; + private final TransportApiService transportApiService; + private final StatsFactory statsFactory; + + @Value("${queue.transport_api.max_pending_requests:10000}") + private int maxPendingRequests; + @Value("${queue.transport_api.max_requests_timeout:10000}") + private long requestTimeout; + @Value("${queue.transport_api.request_poll_interval:25}") + private int responsePollDuration; + @Value("${queue.transport_api.max_callback_threads:100}") + private int maxCallbackThreads; + + private ExecutorService transportCallbackExecutor; + private TbQueueResponseTemplate, + TbProtoQueueMsg> transportApiTemplate; + + public TbCoreTransportApiService(TbCoreQueueFactory tbCoreQueueFactory, TransportApiService transportApiService, StatsFactory statsFactory) { + this.tbCoreQueueFactory = tbCoreQueueFactory; + this.transportApiService = transportApiService; + this.statsFactory = statsFactory; + } + + @PostConstruct + public void init() { + this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(maxCallbackThreads, getClass()); + TbQueueProducer> producer = tbCoreQueueFactory.createTransportApiResponseProducer(); + TbQueueConsumer> consumer = tbCoreQueueFactory.createTransportApiRequestConsumer(); + + String key = StatsType.TRANSPORT.getName(); + MessagesStats queueStats = statsFactory.createMessagesStats(key); + + DefaultTbQueueResponseTemplate.DefaultTbQueueResponseTemplateBuilder + , TbProtoQueueMsg> builder = DefaultTbQueueResponseTemplate.builder(); + builder.requestTemplate(consumer); + builder.responseTemplate(producer); + builder.maxPendingRequests(maxPendingRequests); + builder.requestTimeout(requestTimeout); + builder.pollInterval(responsePollDuration); + builder.executor(transportCallbackExecutor); + builder.handler(transportApiService); + builder.stats(queueStats); + transportApiTemplate = builder.build(); + } + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + log.info("Received application ready event. Starting polling for events."); + transportApiTemplate.init(transportApiService); + } + + @PreDestroy + public void destroy() { + if (transportApiTemplate != null) { + transportApiTemplate.stop(); + } + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java new file mode 100644 index 0000000..debb0ec --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.queue.TbQueueHandler; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface TransportApiService extends TbQueueHandler, TbProtoQueueMsg> { +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java new file mode 100644 index 0000000..6fa0e58 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport.msg; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Created by ashvayka on 09.10.18. + */ +@Data +public class TransportToDeviceActorMsgWrapper implements TbActorMsg, DeviceAwareMsg, TenantAwareMsg, Serializable { + + private static final long serialVersionUID = 7191333353202935941L; + + private final TenantId tenantId; + private final DeviceId deviceId; + private final TransportToDeviceActorMsg msg; + private final TbCallback callback; + + public TransportToDeviceActorMsgWrapper(TransportToDeviceActorMsg msg, TbCallback callback) { + this.msg = msg; + this.callback = callback; + this.tenantId = TenantId.fromUUID(new UUID(msg.getSessionInfo().getTenantIdMSB(), msg.getSessionInfo().getTenantIdLSB())); + this.deviceId = new DeviceId(new UUID(msg.getSessionInfo().getDeviceIdMSB(), msg.getSessionInfo().getDeviceIdLSB())); + } + + @Override + public MsgType getMsgType() { + return MsgType.TRANSPORT_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java new file mode 100644 index 0000000..c01b894 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.queue.discovery.PartitionService; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.Statement; + + +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractCleanUpService { + + private final PartitionService partitionService; + + protected boolean isSystemTenantPartitionMine(){ + return partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java new file mode 100644 index 0000000..e178055 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/AlarmsCleanUpService.java @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.dao.alarm.AlarmDao; +import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.action.EntityActionService; + +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@TbCoreComponent +@Service +@Slf4j +@RequiredArgsConstructor +public class AlarmsCleanUpService { + + @Value("${sql.ttl.alarms.removal_batch_size}") + private Integer removalBatchSize; + + private final TenantService tenantService; + private final AlarmDao alarmDao; + private final AlarmService alarmService; + private final RelationService relationService; + private final EntityActionService entityActionService; + private final PartitionService partitionService; + private final TbTenantProfileCache tenantProfileCache; + + @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.alarms.checking_interval})}", fixedDelayString = "${sql.ttl.alarms.checking_interval}") + public void cleanUp() { + PageLink tenantsBatchRequest = new PageLink(10_000, 0); + PageLink removalBatchRequest = new PageLink(removalBatchSize, 0 ); + PageData tenantsIds; + do { + tenantsIds = tenantService.findTenantsIds(tenantsBatchRequest); + for (TenantId tenantId : tenantsIds.getData()) { + if (!partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition()) { + continue; + } + + Optional tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration(); + if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getAlarmsTtlDays() == 0) { + continue; + } + + long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getAlarmsTtlDays()); + long expirationTime = System.currentTimeMillis() - ttl; + + long totalRemoved = 0; + while (true) { + PageData toRemove = alarmDao.findAlarmsIdsByEndTsBeforeAndTenantId(expirationTime, tenantId, removalBatchRequest); + toRemove.getData().forEach(alarmId -> { + relationService.deleteEntityRelations(tenantId, alarmId); + Alarm alarm = alarmService.deleteAlarm(tenantId, alarmId).getAlarm(); + entityActionService.pushEntityActionToRuleEngine(alarm.getOriginator(), alarm, tenantId, null, ActionType.ALARM_DELETE, null); + }); + + totalRemoved += toRemove.getTotalElements(); + if (!toRemove.hasNext()) { + break; + } + } + + if (totalRemoved > 0) { + log.info("Removed {} outdated alarm(s) for tenant {} older than {}", totalRemoved, tenantId, new Date(expirationTime)); + } + } + + tenantsBatchRequest = tenantsBatchRequest.nextPageLink(); + } while (tenantsIds.hasNext()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/AuditLogsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/AuditLogsCleanUpService.java new file mode 100644 index 0000000..11b1751 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/AuditLogsCleanUpService.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.audit.AuditLogDao; +import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.queue.discovery.PartitionService; + +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.dao.model.ModelConstants.AUDIT_LOG_COLUMN_FAMILY_NAME; + +@Service +@ConditionalOnExpression("${sql.ttl.audit_logs.enabled:true} && ${sql.ttl.audit_logs.ttl:0} > 0") +@Slf4j +public class AuditLogsCleanUpService extends AbstractCleanUpService { + + private final AuditLogDao auditLogDao; + private final SqlPartitioningRepository partitioningRepository; + + @Value("${sql.ttl.audit_logs.ttl:0}") + private long ttlInSec; + @Value("${sql.audit_logs.partition_size:168}") + private int partitionSizeInHours; + + public AuditLogsCleanUpService(PartitionService partitionService, AuditLogDao auditLogDao, SqlPartitioningRepository partitioningRepository) { + super(partitionService); + this.auditLogDao = auditLogDao; + this.partitioningRepository = partitioningRepository; + } + + @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.audit_logs.checking_interval_ms})}", + fixedDelayString = "${sql.ttl.audit_logs.checking_interval_ms}") + public void cleanUp() { + long auditLogsExpTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttlInSec); + if (isSystemTenantPartitionMine()) { + auditLogDao.cleanUpAuditLogs(auditLogsExpTime); + } else { + partitioningRepository.cleanupPartitionsCache(AUDIT_LOG_COLUMN_FAMILY_NAME, auditLogsExpTime, TimeUnit.HOURS.toMillis(partitionSizeInHours)); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java new file mode 100644 index 0000000..a56b196 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.edge.EdgeEventService; +import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.dao.model.ModelConstants.EDGE_EVENT_COLUMN_FAMILY_NAME; + +@TbCoreComponent +@Slf4j +@Service +@ConditionalOnExpression("${sql.ttl.edge_events.enabled:true} && ${sql.ttl.edge_events.edge_event_ttl:0} > 0") +public class EdgeEventsCleanUpService extends AbstractCleanUpService { + + public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = + "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.edge_events.execution_interval_ms})}"; + + @Value("${sql.ttl.edge_events.edge_events_ttl}") + private long ttl; + + @Value("${sql.edge_events.partition_size:168}") + private int partitionSizeInHours; + + @Value("${sql.ttl.edge_events.enabled:true}") + private boolean ttlTaskExecutionEnabled; + + private final EdgeEventService edgeEventService; + + private final SqlPartitioningRepository partitioningRepository; + + public EdgeEventsCleanUpService(PartitionService partitionService, EdgeEventService edgeEventService, SqlPartitioningRepository partitioningRepository) { + super(partitionService); + this.edgeEventService = edgeEventService; + this.partitioningRepository = partitioningRepository; + } + + @Scheduled(initialDelayString = RANDOM_DELAY_INTERVAL_MS_EXPRESSION, fixedDelayString = "${sql.ttl.edge_events.execution_interval_ms}") + public void cleanUp() { + long edgeEventsExpTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttl); + if (ttlTaskExecutionEnabled && isSystemTenantPartitionMine()) { + edgeEventService.cleanupEvents(edgeEventsExpTime); + } else { + partitioningRepository.cleanupPartitionsCache(EDGE_EVENT_COLUMN_FAMILY_NAME, edgeEventsExpTime, TimeUnit.HOURS.toMillis(partitionSizeInHours)); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/EventsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/EventsCleanUpService.java new file mode 100644 index 0000000..3dc66b6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/EventsCleanUpService.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class EventsCleanUpService extends AbstractCleanUpService { + + public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = + "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.events.execution_interval_ms})}"; + + @Value("${sql.ttl.events.events_ttl}") + private long ttlInSec; + + @Value("${sql.ttl.events.debug_events_ttl}") + private long debugTtlInSec; + + @Value("${sql.ttl.events.enabled}") + private boolean ttlTaskExecutionEnabled; + + private final EventService eventService; + + public EventsCleanUpService(PartitionService partitionService, EventService eventService) { + super(partitionService); + this.eventService = eventService; + } + + @Scheduled(initialDelayString = RANDOM_DELAY_INTERVAL_MS_EXPRESSION, fixedDelayString = "${sql.ttl.events.execution_interval_ms}") + public void cleanUp() { + if (ttlTaskExecutionEnabled) { + long ts = System.currentTimeMillis(); + long regularEventExpTs = ttlInSec > 0 ? ts - TimeUnit.SECONDS.toMillis(ttlInSec) : 0; + long debugEventExpTs = debugTtlInSec > 0 ? ts - TimeUnit.SECONDS.toMillis(debugTtlInSec) : 0; + eventService.cleanupEvents(regularEventExpTs, debugEventExpTs, isSystemTenantPartitionMine()); + } + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/TimeseriesCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/TimeseriesCleanUpService.java new file mode 100644 index 0000000..00b869d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/TimeseriesCleanUpService.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ttl.AbstractCleanUpService; + +@TbCoreComponent +@Slf4j +@Service +public class TimeseriesCleanUpService extends AbstractCleanUpService { + + @Value("${sql.ttl.ts.ts_key_value_ttl}") + protected long systemTtl; + + @Value("${sql.ttl.ts.enabled}") + private boolean ttlTaskExecutionEnabled; + + private final TimeseriesService timeseriesService; + + public TimeseriesCleanUpService(PartitionService partitionService, TimeseriesService timeseriesService) { + super(partitionService); + this.timeseriesService = timeseriesService; + } + + @Scheduled(initialDelayString = "${sql.ttl.ts.execution_interval_ms}", fixedDelayString = "${sql.ttl.ts.execution_interval_ms}") + public void cleanUp() { + if (ttlTaskExecutionEnabled && isSystemTenantPartitionMine()) { + timeseriesService.cleanup(systemTtl); + } + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java new file mode 100644 index 0000000..1c21cc6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.ttl.rpc; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.dao.rpc.RpcDao; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@TbCoreComponent +@Service +@Slf4j +@RequiredArgsConstructor +public class RpcCleanUpService { + @Value("${sql.ttl.rpc.enabled}") + private boolean ttlTaskExecutionEnabled; + + private final TenantService tenantService; + private final PartitionService partitionService; + private final TbTenantProfileCache tenantProfileCache; + private final RpcDao rpcDao; + + @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.rpc.checking_interval})}", fixedDelayString = "${sql.ttl.rpc.checking_interval}") + public void cleanUp() { + if (ttlTaskExecutionEnabled) { + PageLink tenantsBatchRequest = new PageLink(10_000, 0); + PageData tenantsIds; + do { + tenantsIds = tenantService.findTenantsIds(tenantsBatchRequest); + for (TenantId tenantId : tenantsIds.getData()) { + if (!partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition()) { + continue; + } + + Optional tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration(); + if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getRpcTtlDays() == 0) { + continue; + } + + long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getRpcTtlDays()); + long expirationTime = System.currentTimeMillis() - ttl; + + long totalRemoved = rpcDao.deleteOutdatedRpcByTenantId(tenantId, expirationTime); + + if (totalRemoved > 0) { + log.info("Removed {} outdated rpc(s) for tenant {} older than {}", totalRemoved, tenantId, new Date(expirationTime)); + } + } + + tenantsBatchRequest = tenantsBatchRequest.nextPageLink(); + } while (tenantsIds.hasNext()); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java new file mode 100644 index 0000000..bc1e04f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java @@ -0,0 +1,139 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.update; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.UpdateMessage; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Service +@TbCoreComponent +@Slf4j +public class DefaultUpdateService implements UpdateService { + + private static final String INSTANCE_ID_FILE = ".instance_id"; + private static final String UPDATE_SERVER_BASE_URL = "https://updates.thingsboard.io"; + + private static final String PLATFORM_PARAM = "platform"; + private static final String VERSION_PARAM = "version"; + private static final String INSTANCE_ID_PARAM = "instanceId"; + + @Value("${updates.enabled}") + private boolean updatesEnabled; + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, ThingsBoardThreadFactory.forName("tb-update-service")); + + private ScheduledFuture checkUpdatesFuture = null; + private RestTemplate restClient = new RestTemplate(); + + private UpdateMessage updateMessage; + + private String platform; + private String version; + private UUID instanceId = null; + + @PostConstruct + private void init() { + updateMessage = new UpdateMessage("", false); + if (updatesEnabled) { + try { + platform = System.getProperty("platform", "unknown"); + version = getClass().getPackage().getImplementationVersion(); + if (version == null) { + version = "unknown"; + } + instanceId = parseInstanceId(); + checkUpdatesFuture = scheduler.scheduleAtFixedRate(checkUpdatesRunnable, 0, 1, TimeUnit.HOURS); + } catch (Exception e) { + //Do nothing + } + } + } + + private UUID parseInstanceId() throws IOException { + UUID result = null; + Path instanceIdPath = Paths.get(INSTANCE_ID_FILE); + if (instanceIdPath.toFile().exists()) { + byte[] data = Files.readAllBytes(instanceIdPath); + if (data != null && data.length > 0) { + try { + result = UUID.fromString(new String(data)); + } catch (IllegalArgumentException e) { + //Do nothing + } + } + } + if (result == null) { + result = UUID.randomUUID(); + Files.write(instanceIdPath, result.toString().getBytes()); + } + return result; + } + + @PreDestroy + private void destroy() { + try { + if (checkUpdatesFuture != null) { + checkUpdatesFuture.cancel(true); + } + scheduler.shutdownNow(); + } catch (Exception e) { + //Do nothing + } + } + + Runnable checkUpdatesRunnable = () -> { + try { + log.trace("Executing check update method for instanceId [{}], platform [{}] and version [{}]", instanceId, platform, version); + ObjectNode request = new ObjectMapper().createObjectNode(); + request.put(PLATFORM_PARAM, platform); + request.put(VERSION_PARAM, version); + request.put(INSTANCE_ID_PARAM, instanceId.toString()); + JsonNode response = restClient.postForObject(UPDATE_SERVER_BASE_URL+"/api/thingsboard/updates", request, JsonNode.class); + updateMessage = new UpdateMessage( + response.get("message").asText(), + response.get("updateAvailable").asBoolean() + ); + } catch (Exception e) { + log.trace(e.getMessage()); + } + }; + + @Override + public UpdateMessage checkUpdates() { + return updateMessage; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/update/UpdateService.java b/application/src/main/java/org/thingsboard/server/service/update/UpdateService.java new file mode 100644 index 0000000..fce051d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/update/UpdateService.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.update; + +import org.thingsboard.server.common.data.UpdateMessage; + +public interface UpdateService { + + UpdateMessage checkUpdates(); + +} diff --git a/application/src/main/java/org/thingsboard/server/springfox/SpringfoxHandlerProviderBeanPostProcessor.java b/application/src/main/java/org/thingsboard/server/springfox/SpringfoxHandlerProviderBeanPostProcessor.java new file mode 100644 index 0000000..af03c72 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/springfox/SpringfoxHandlerProviderBeanPostProcessor.java @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.springfox; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.stereotype.Component; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; +import org.thingsboard.server.queue.util.TbCoreComponent; +import springfox.documentation.spring.web.plugins.WebMvcRequestHandlerProvider; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Collectors; + +@Component +//TODO: remove after fixing issue https://github.com/springfox/springfox/issues/3462 or after migration from springfox to springdoc +public class SpringfoxHandlerProviderBeanPostProcessor implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof WebMvcRequestHandlerProvider) { + customizeSpringfoxHandlerMappings(getHandlerMappings(bean)); + } + return bean; + } + + private void customizeSpringfoxHandlerMappings(List mappings) { + List copy = mappings.stream() + .filter(mapping -> mapping.getPatternParser() == null) + .collect(Collectors.toList()); + mappings.clear(); + mappings.addAll(copy); + } + + @SuppressWarnings("unchecked") + private List getHandlerMappings(Object bean) { + try { + Field field = ReflectionUtils.findField(bean.getClass(), "handlerMappings"); + field.setAccessible(true); + return (List) field.get(bean); + } catch (IllegalArgumentException | IllegalAccessException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java new file mode 100644 index 0000000..e4d0c13 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVRecord; +import org.apache.commons.io.input.CharSequenceReader; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CsvUtils { + + public static List> parseCsv(String content, Character delimiter) throws Exception { + CSVFormat csvFormat = delimiter.equals(',') ? CSVFormat.DEFAULT : CSVFormat.DEFAULT.withDelimiter(delimiter); + + List records; + try (CharSequenceReader reader = new CharSequenceReader(content)) { + records = csvFormat.parse(reader).getRecords(); + } + + return records.stream() + .map(record -> Stream.iterate(0, i -> i < record.size(), i -> i + 1) + .map(record::get) + .collect(Collectors.toList())) + .collect(Collectors.toList()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/utils/EventDeduplicationExecutor.java b/application/src/main/java/org/thingsboard/server/utils/EventDeduplicationExecutor.java new file mode 100644 index 0000000..d7e44e6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/EventDeduplicationExecutor.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.utils; + +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +/** + * This class deduplicate executions of the specified function. + * Useful in cluster mode, when you get event about partition change multiple times. + * Assuming that the function execution is expensive, we should execute it immediately when first time event occurs and + * later, once the processing of first event is done, process last pending task. + * + * @param

parameters of the function + */ +@Slf4j +public class EventDeduplicationExecutor

{ + private final String name; + private final ExecutorService executor; + private final Consumer

function; + private P pendingTask; + private boolean busy; + + public EventDeduplicationExecutor(String name, ExecutorService executor, Consumer

function) { + this.name = name; + this.executor = executor; + this.function = function; + } + + public void submit(P params) { + log.info("[{}] Going to submit: {}", name, params); + synchronized (EventDeduplicationExecutor.this) { + if (!busy) { + busy = true; + pendingTask = null; + try { + log.info("[{}] Submitting task: {}", name, params); + executor.submit(() -> { + try { + log.info("[{}] Executing task: {}", name, params); + function.accept(params); + } catch (Throwable e) { + log.warn("[{}] Failed to process task with parameters: {}", name, params, e); + throw e; + } finally { + unlockAndProcessIfAny(); + } + }); + } catch (Throwable e) { + log.warn("[{}] Failed to submit task with parameters: {}", name, params, e); + unlockAndProcessIfAny(); + throw e; + } + } else { + log.info("[{}] Task is already in progress. {} pending task: {}", name, pendingTask == null ? "adding" : "updating", params); + pendingTask = params; + } + } + } + + private void unlockAndProcessIfAny() { + synchronized (EventDeduplicationExecutor.this) { + busy = false; + if (pendingTask != null) { + submit(pendingTask); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java b/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java new file mode 100644 index 0000000..fbb217a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/MiscUtils.java @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.utils; + +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; + +import javax.servlet.http.HttpServletRequest; +import java.nio.charset.Charset; + + +/** + * @author Andrew Shvayka + */ +public class MiscUtils { + + public static final Charset UTF8 = Charset.forName("UTF-8"); + + public static String missingProperty(String propertyName) { + return "The " + propertyName + " property need to be set!"; + } + + @SuppressWarnings("deprecation") + public static HashFunction forName(String name) { + switch (name) { + case "murmur3_32": + return Hashing.murmur3_32(); + case "murmur3_128": + return Hashing.murmur3_128(); + case "crc32": + return Hashing.crc32(); + case "md5": + return Hashing.md5(); + default: + throw new IllegalArgumentException("Can't find hash function with name " + name); + } + } + + public static String constructBaseUrl(HttpServletRequest request) { + return String.format("%s://%s:%d", + getScheme(request), + getDomainName(request), + getPort(request)); + } + + public static String getScheme(HttpServletRequest request){ + String scheme = request.getScheme(); + String forwardedProto = request.getHeader("x-forwarded-proto"); + if (forwardedProto != null) { + scheme = forwardedProto; + } + return scheme; + } + + public static String getDomainName(HttpServletRequest request){ + return request.getServerName(); + } + + public static String getDomainNameAndPort(HttpServletRequest request){ + String domainName = getDomainName(request); + String scheme = getScheme(request); + int port = MiscUtils.getPort(request); + if (needsPort(scheme, port)) { + domainName += ":" + port; + } + return domainName; + } + + private static boolean needsPort(String scheme, int port) { + boolean isHttpDefault = "http".equals(scheme.toLowerCase()) && port == 80; + boolean isHttpsDefault = "https".equals(scheme.toLowerCase()) && port == 443; + return !isHttpDefault && !isHttpsDefault; + } + + public static int getPort(HttpServletRequest request){ + String forwardedProto = request.getHeader("x-forwarded-proto"); + + int serverPort = request.getServerPort(); + if (request.getHeader("x-forwarded-port") != null) { + try { + serverPort = request.getIntHeader("x-forwarded-port"); + } catch (NumberFormatException e) { + } + } else if (forwardedProto != null) { + switch (forwardedProto) { + case "http": + serverPort = 80; + break; + case "https": + serverPort = 443; + break; + } + } + return serverPort; + } +} diff --git a/application/src/main/java/org/thingsboard/server/utils/TypeCastUtil.java b/application/src/main/java/org/thingsboard/server/utils/TypeCastUtil.java new file mode 100644 index 0000000..b521b6f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/TypeCastUtil.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.utils; + +import org.apache.commons.lang3.math.NumberUtils; +import org.thingsboard.server.common.data.kv.DataType; + +import java.math.BigDecimal; +import java.util.Map; + +public class TypeCastUtil { + + private TypeCastUtil() {} + + public static Map.Entry castValue(String value) { + if (isNumber(value)) { + String formattedValue = value.replace(',', '.'); + try { + BigDecimal bd = new BigDecimal(formattedValue); + if (bd.stripTrailingZeros().scale() > 0 || isSimpleDouble(formattedValue)) { + if (bd.scale() <= 16) { + return Map.entry(DataType.DOUBLE, bd.doubleValue()); + } + } else { + return Map.entry(DataType.LONG, bd.longValueExact()); + } + } catch (RuntimeException ignored) {} + } else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { + return Map.entry(DataType.BOOLEAN, Boolean.parseBoolean(value)); + } + return Map.entry(DataType.STRING, value); + } + + private static boolean isNumber(String value) { + return NumberUtils.isNumber(value.replace(',', '.')); + } + + private static boolean isSimpleDouble(String valueAsString) { + return valueAsString.contains(".") && !valueAsString.contains("E") && !valueAsString.contains("e"); + } + +} diff --git a/application/src/main/resources/banner.txt b/application/src/main/resources/banner.txt new file mode 100644 index 0000000..57f907c --- /dev/null +++ b/application/src/main/resources/banner.txt @@ -0,0 +1,10 @@ + ______ __ _ ____ __ + /_ __/ / /_ (_) ____ ____ _ _____ / __ ) ____ ____ _ _____ ____/ / + / / / __ \ / / / __ \ / __ `/ / ___/ / __ | / __ \ / __ `/ / ___/ / __ / + / / / / / / / / / / / / / /_/ / (__ ) / /_/ / / /_/ // /_/ / / / / /_/ / +/_/ /_/ /_/ /_/ /_/ /_/ \__, / /____/ /_____/ \____/ \__,_/ /_/ \__,_/ + /____/ + + =================================================== + :: ${application.title} :: ${application.formatted-version} + =================================================== diff --git a/application/src/main/resources/i18n/messages.properties b/application/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000..678e4d4 --- /dev/null +++ b/application/src/main/resources/i18n/messages.properties @@ -0,0 +1,8 @@ +test.message.subject=Test message from Thingsboard +activation.subject=Your account activation on Thingsboard +account.activated.subject=Thingsboard - your account has been activated +reset.password.subject=Thingsboard - Password reset has been requested +password.was.reset.subject=Thingsboard - your account password has been reset +account.lockout.subject=Thingsboard - User account has been lockout +api.usage.state=Thingsboard - Account limits +2fa.verification.code.subject=ThingsBoard - 2FA verification code diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml new file mode 100644 index 0000000..941bd7d --- /dev/null +++ b/application/src/main/resources/logback.xml @@ -0,0 +1,61 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/application/src/main/resources/templates/2fa.verification.code.ftl b/application/src/main/resources/templates/2fa.verification.code.ftl new file mode 100644 index 0000000..0db3cd8 --- /dev/null +++ b/application/src/main/resources/templates/2fa.verification.code.ftl @@ -0,0 +1,117 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + + Email verification code + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+

Your verification code:

+
+

${code}

+
+ Please verify your access using the code above. +
+ This code will expire in ${expirationTimeSeconds} seconds. +
+ If you didn't request this code, you can ignore this email. +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/account.activated.ftl b/application/src/main/resources/templates/account.activated.ftl new file mode 100644 index 0000000..b34b574 --- /dev/null +++ b/application/src/main/resources/templates/account.activated.ftl @@ -0,0 +1,124 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Account Activated + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+

Your Thingsboard account has been activated

+
+ Congratulations! Your Thingsboard account has been activated. +
+ Now you can login to your Thingsboard space. +
+ +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/account.lockout.ftl b/application/src/main/resources/templates/account.lockout.ftl new file mode 100644 index 0000000..fa21653 --- /dev/null +++ b/application/src/main/resources/templates/account.lockout.ftl @@ -0,0 +1,114 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Account Lockout + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + +
+

Thingsboard user account has been locked out

+
+ Thingsboard user account ${lockoutAccount} has been locked out due to multiple authentication failures (more than ${maxFailedLoginAttempts}). +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/activation.ftl b/application/src/main/resources/templates/activation.ftl new file mode 100644 index 0000000..955ed25 --- /dev/null +++ b/application/src/main/resources/templates/activation.ftl @@ -0,0 +1,124 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Account Activation + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+

Activate your Thingsboard account

+
+ To confirm your email address and choose a password, just click the button below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/password.was.reset.ftl b/application/src/main/resources/templates/password.was.reset.ftl new file mode 100644 index 0000000..eef35a2 --- /dev/null +++ b/application/src/main/resources/templates/password.was.reset.ftl @@ -0,0 +1,124 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Account Password Has Been Reset + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+

Your Thingsboard account password has been reset

+
+ You have successfully created new password for your Thingsboard account. +
+ Now you can login to your Thingsboard space using your newly created password. +
+ +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/reset.password.ftl b/application/src/main/resources/templates/reset.password.ftl new file mode 100644 index 0000000..e1392e9 --- /dev/null +++ b/application/src/main/resources/templates/reset.password.ftl @@ -0,0 +1,124 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Reset Password Request + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + +
+

Password reset has been requested

+
+ You have requested password reset for your Thingsboard account. +
+ Click below in order to proceed password reset procedure. +
+ +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/templates/state.disabled.ftl b/application/src/main/resources/templates/state.disabled.ftl new file mode 100644 index 0000000..45eeda7 --- /dev/null +++ b/application/src/main/resources/templates/state.disabled.ftl @@ -0,0 +1,145 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + + Thingsboard - Api Usage State + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
Your ThingsBoard account feature was disabled
+
+
We have disabled the ${apiFeature} for your account because ThingsBoard has already ${apiLimitValueLabel}.
+
+
Please contact your system administrator to resolve the issue.
— The ThingsBoard
+
+ + + + + + +
This email was sent to ${targetEmail} by ThingsBoard.
+ + diff --git a/application/src/main/resources/templates/state.enabled.ftl b/application/src/main/resources/templates/state.enabled.ftl new file mode 100644 index 0000000..f14657e --- /dev/null +++ b/application/src/main/resources/templates/state.enabled.ftl @@ -0,0 +1,142 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + + Thingsboard - Api Usage State + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
Your ThingsBoard account feature was enabled
+
+
We have enabled the ${apiFeature} for your account and ThingsBoard is already able to ${apiLabel} messages.
+
+
— The ThingsBoard
+
+ + + + + + +
This email was sent to ${targetEmail} by ThingsBoard.
+ + diff --git a/application/src/main/resources/templates/state.warning.ftl b/application/src/main/resources/templates/state.warning.ftl new file mode 100644 index 0000000..cfba216 --- /dev/null +++ b/application/src/main/resources/templates/state.warning.ftl @@ -0,0 +1,145 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + + Thingsboard - Api Usage State + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
Warning: your ThingsBoard account feature may be disabled soon
+
+
ThingsBoard has already ${apiValueLabel}.
${apiFeature} will be disabled for your account once the limit will be reached.
+
+
Please contact your system administrator to resolve the issue.
— The ThingsBoard
+
+ + + + + + +
This email was sent to ${targetEmail} by ThingsBoard.
+ + diff --git a/application/src/main/resources/templates/test.ftl b/application/src/main/resources/templates/test.ftl new file mode 100644 index 0000000..3d8af98 --- /dev/null +++ b/application/src/main/resources/templates/test.ftl @@ -0,0 +1,114 @@ +<#-- + + Copyright © 2016-2022 The Thingsboard Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--> + + + + + +Thingsboard - Test Message + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + +
+

Test message from Thingsboard

+
+ This email is indicating that your outgoing mail settings were set up correctly. +
+ — The Thingsboard +
+ +
+
+ + diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml new file mode 100644 index 0000000..0814db0 --- /dev/null +++ b/application/src/main/resources/thingsboard.yml @@ -0,0 +1,1206 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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:8080}" + # Server forward headers strategy + forward_headers_strategy: "${HTTP_FORWARD_HEADERS_STRATEGY:NONE}" + # Server SSL configuration + ssl: + # Enable/disable SSL support + enabled: "${SSL_ENABLED:false}" + # Server SSL credentials + credentials: + # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) + type: "${SSL_CREDENTIALS_TYPE:PEM}" + # PEM server credentials + pem: + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) + cert_file: "${SSL_PEM_CERT:server.pem}" + # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; + key_file: "${SSL_PEM_KEY:server_key.pem}" + # Server certificate private key password (optional) + key_password: "${SSL_PEM_KEY_PASSWORD:server_key_password}" + # Keystore server credentials + keystore: + # Type of the key store (JKS or PKCS12) + type: "${SSL_KEY_STORE_TYPE:PKCS12}" + # Path to the key store that holds the SSL certificate + store_file: "${SSL_KEY_STORE:classpath:keystore/keystore.p12}" + # Password used to access the key store + store_password: "${SSL_KEY_STORE_PASSWORD:thingsboard}" + # Key alias + key_alias: "${SSL_KEY_ALIAS:tomcat}" + # Password used to access the key + key_password: "${SSL_KEY_PASSWORD:thingsboard}" + # HTTP/2 support (takes effect only if server SSL is enabled) + http2: + # Enable/disable HTTP/2 support + enabled: "${HTTP2_ENABLED:true}" + log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}" + ws: + send_timeout: "${TB_SERVER_WS_SEND_TIMEOUT:5000}" + # recommended timeout >= 30 seconds. Platform will attempt to send 'ping' request 3 times within the timeout + ping_timeout: "${TB_SERVER_WS_PING_TIMEOUT:30000}" + dynamic_page_link: + refresh_interval: "${TB_SERVER_WS_DYNAMIC_PAGE_LINK_REFRESH_INTERVAL_SEC:60}" + refresh_pool_size: "${TB_SERVER_WS_DYNAMIC_PAGE_LINK_REFRESH_POOL_SIZE:1}" + max_alarm_queries_per_refresh_interval: "${TB_SERVER_WS_MAX_ALARM_QUERIES_PER_REFRESH_INTERVAL:10}" + max_per_user: "${TB_SERVER_WS_DYNAMIC_PAGE_LINK_MAX_PER_USER:10}" + max_entities_per_data_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_DATA_SUBSCRIPTION:10000}" + max_entities_per_alarm_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_ALARM_SUBSCRIPTION:10000}" + rest: + server_side_rpc: + # Minimum value of the server side RPC timeout. May override value provided in the REST API call. + # Since 2.5 migration to queues, the RPC delay depends on the size of the pending messages in the queue, + # so default UI parameter of 500ms may not be sufficient for loaded environments. + min_timeout: "${MIN_SERVER_SIDE_RPC_TIMEOUT:5000}" + # Default value of the server side RPC timeout. + default_timeout: "${DEFAULT_SERVER_SIDE_RPC_TIMEOUT:10000}" + +# Application info +app: + # Application version + version: "@project.version@" + +# Zookeeper connection parameters. Used for service discovery. +zk: + # Enable/disable zookeeper discovery service. + enabled: "${ZOOKEEPER_ENABLED:false}" + # Zookeeper connect string + url: "${ZOOKEEPER_URL:localhost:2181}" + # Zookeeper retry interval in milliseconds + retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" + # Zookeeper connection timeout in milliseconds + connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" + # Zookeeper session timeout in milliseconds + session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" + # Name of the directory in zookeeper 'filesystem' + zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" + +cluster: + stats: + enabled: "${TB_CLUSTER_STATS_ENABLED:false}" + print_interval_ms: "${TB_CLUSTER_STATS_PRINT_INTERVAL_MS:10000}" + +# Plugins configuration parameters +plugins: + # Comma separated package list used during classpath scanning for plugins + scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}" + +# Security parameters +security: + # JWT Token parameters + jwt: # Since 3.4.2 values are persisted to the database during install or upgrade. On Install, the key will be generated randomly if no custom value set. You can change it later from Web UI under SYS_ADMIN + tokenExpirationTime: "${JWT_TOKEN_EXPIRATION_TIME:9000}" # Number of seconds (2.5 hours) + refreshTokenExpTime: "${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800}" # Number of seconds (1 week). + tokenIssuer: "${JWT_TOKEN_ISSUER:thingsboard.io}" + tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded + # 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}" + # Enable/disable case-sensitive username login + user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}" + claim: + # Enable/disable claiming devices, if false -> the device's [claimingAllowed] SERVER_SCOPE attribute must be set to [true] to allow claiming specific device + allowClaimingByDefault: "${SECURITY_CLAIM_ALLOW_CLAIMING_BY_DEFAULT:true}" + # Time allowed to claim the device in milliseconds + duration: "${SECURITY_CLAIM_DURATION:86400000}" # 1 minute, note this value must equal claimDevices.timeToLiveInMinutes value + basic: + enabled: "${SECURITY_BASIC_ENABLED:false}" + oauth2: + # Redirect URL where access code from external user management system will be processed + loginProcessingUrl: "${SECURITY_OAUTH2_LOGIN_PROCESSING_URL:/login/oauth2/code/}" + githubMapper: + emailUrl: "${SECURITY_OAUTH2_GITHUB_MAPPER_EMAIL_URL_KEY:https://api.github.com/user/emails}" + +# Usage statistics parameters +usage: + stats: + report: + enabled: "${USAGE_STATS_REPORT_ENABLED:true}" + enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" + interval: "${USAGE_STATS_REPORT_INTERVAL:10}" + check: + cycle: "${USAGE_STATS_CHECK_CYCLE:60000}" + +# UI parameters +ui: + # Dashboard parameters + dashboard: + # Maximum allowed datapoints fetched by widgets + max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" + # Help parameters + help: + # Base url for UI help assets + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-3.4.3}" + +database: + ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by single API call to fetch telemetry records + ts: + type: "${DATABASE_TS_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale) + ts_latest: + type: "${DATABASE_TS_LATEST_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale) + +# Cassandra driver configuration parameters +cassandra: + # Thingsboard cluster name + cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}" + # Thingsboard keyspace name + keyspace_name: "${CASSANDRA_KEYSPACE_NAME:thingsboard}" + # Specify node list + url: "${CASSANDRA_URL:127.0.0.1:9042}" + # Specify local datacenter name + local_datacenter: "${CASSANDRA_LOCAL_DATACENTER:datacenter1}" + ssl: + # Enable/disable secure connection + enabled: "${CASSANDRA_USE_SSL:false}" + # Enable/disable validation of Cassandra server hostname + # If enabled, hostname of Cassandra server must match CN of server certificate + hostname_validation: "${CASSANDRA_SSL_HOSTNAME_VALIDATION:true}" + # Set trust store for client authentication of server (optional, uses trust store from default SSLContext if not set) + trust_store: "${CASSANDRA_SSL_TRUST_STORE:}" + trust_store_password: "${CASSANDRA_SSL_TRUST_STORE_PASSWORD:}" + # Set key store for server authentication of client (optional, uses key store from default SSLContext if not set) + # A key store is only needed if the Cassandra server requires client authentication + key_store: "${CASSANDRA_SSL_KEY_STORE:}" + key_store_password: "${CASSANDRA_SSL_KEY_STORE_PASSWORD:}" + # Comma separated list of cipher suites (optional, uses Java default cipher suites if not set) + cipher_suites: "${CASSANDRA_SSL_CIPHER_SUITES:}" + # Enable/disable JMX + jmx: "${CASSANDRA_USE_JMX:false}" + # Enable/disable metrics collection. + metrics: "${CASSANDRA_USE_METRICS:false}" + # NONE SNAPPY LZ4 + compression: "${CASSANDRA_COMPRESSION:none}" + # Specify cassandra cluster initialization timeout in milliseconds (if no hosts available during startup) + 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 + username: "${CASSANDRA_USERNAME:}" + # Specify your password + password: "${CASSANDRA_PASSWORD:}" + # Astra DB connect https://astra.datastax.com/ + cloud: + # /etc/thingsboard/astra/secure-connect-thingsboard.zip + secure_connect_bundle_path: "${CASSANDRA_CLOUD_SECURE_BUNDLE_PATH:}" + # DucitQPHMzPCBOZqFYexAfKk + client_id: "${CASSANDRA_CLOUD_CLIENT_ID:}" + # ZnF7FpuHp43FP5BzM+KY8wGmSb4Ql6BhT4Z7sOU13ze+gXQ-n7OkFpNuB,oACUIQObQnK0g4bSPoZhK5ejkcF9F.j6f64j71Sr.tiRe0Fsq2hPS1ZCGSfAaIgg63IydG + client_secret: "${CASSANDRA_CLOUD_CLIENT_SECRET:}" + + # Cassandra cluster connection socket parameters # + socket: + connect_timeout: "${CASSANDRA_SOCKET_TIMEOUT:5000}" + read_timeout: "${CASSANDRA_SOCKET_READ_TIMEOUT:20000}" + keep_alive: "${CASSANDRA_SOCKET_KEEP_ALIVE:true}" + reuse_address: "${CASSANDRA_SOCKET_REUSE_ADDRESS:true}" + so_linger: "${CASSANDRA_SOCKET_SO_LINGER:}" + tcp_no_delay: "${CASSANDRA_SOCKET_TCP_NO_DELAY:false}" + receive_buffer_size: "${CASSANDRA_SOCKET_RECEIVE_BUFFER_SIZE:}" + send_buffer_size: "${CASSANDRA_SOCKET_SEND_BUFFER_SIZE:}" + + # Cassandra cluster connection query parameters # + query: + read_consistency_level: "${CASSANDRA_READ_CONSISTENCY_LEVEL:ONE}" + write_consistency_level: "${CASSANDRA_WRITE_CONSISTENCY_LEVEL:ONE}" + default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}" + # Specify partitioning size for timestamp key-value storage. Example: MINUTES, HOURS, DAYS, MONTHS, INDEFINITE + ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" + use_ts_key_value_partitioning_on_read: "${USE_TS_KV_PARTITIONING_ON_READ:true}" + ts_key_value_partitions_max_cache_size: "${TS_KV_PARTITIONS_MAX_CACHE_SIZE:100000}" + ts_key_value_ttl: "${TS_KV_TTL:0}" + buffer_size: "${CASSANDRA_QUERY_BUFFER_SIZE:200000}" + concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}" + permit_max_wait_time: "${PERMIT_MAX_WAIT_TIME:120000}" + dispatcher_threads: "${CASSANDRA_QUERY_DISPATCHER_THREADS:2}" + callback_threads: "${CASSANDRA_QUERY_CALLBACK_THREADS:4}" + poll_ms: "${CASSANDRA_QUERY_POLL_MS:50}" + rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}" + # set all data types values except target to null for the same ts on save + set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:false}" + # log one of cassandra queries with specified frequency (0 - logging is disabled) + print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}" + tenant_rate_limits: + print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}" + +# SQL configuration parameters +sql: + # Specify batch size for persisting attribute updates + attributes: + batch_size: "${SQL_ATTRIBUTES_BATCH_SIZE:10000}" + batch_max_delay: "${SQL_ATTRIBUTES_BATCH_MAX_DELAY_MS:100}" + stats_print_interval_ms: "${SQL_ATTRIBUTES_BATCH_STATS_PRINT_MS:10000}" + batch_threads: "${SQL_ATTRIBUTES_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + ts: + batch_size: "${SQL_TS_BATCH_SIZE:10000}" + batch_max_delay: "${SQL_TS_BATCH_MAX_DELAY_MS:100}" + stats_print_interval_ms: "${SQL_TS_BATCH_STATS_PRINT_MS:10000}" + batch_threads: "${SQL_TS_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + ts_latest: + batch_size: "${SQL_TS_LATEST_BATCH_SIZE:10000}" + batch_max_delay: "${SQL_TS_LATEST_BATCH_MAX_DELAY_MS:100}" + stats_print_interval_ms: "${SQL_TS_LATEST_BATCH_STATS_PRINT_MS:10000}" + batch_threads: "${SQL_TS_LATEST_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + update_by_latest_ts: "${SQL_TS_UPDATE_BY_LATEST_TIMESTAMP:true}" + events: + batch_size: "${SQL_EVENTS_BATCH_SIZE:10000}" + batch_max_delay: "${SQL_EVENTS_BATCH_MAX_DELAY_MS:100}" + stats_print_interval_ms: "${SQL_EVENTS_BATCH_STATS_PRINT_MS:10000}" + batch_threads: "${SQL_EVENTS_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + partition_size: "${SQL_EVENTS_REGULAR_PARTITION_SIZE_HOURS:168}" # Number of hours to partition the events. The current value corresponds to one week. + debug_partition_size: "${SQL_EVENTS_DEBUG_PARTITION_SIZE_HOURS:1}" # Number of hours to partition the debug events. The current value corresponds to one hour. + edge_events: + batch_size: "${SQL_EDGE_EVENTS_BATCH_SIZE:1000}" + batch_max_delay: "${SQL_EDGE_EVENTS_BATCH_MAX_DELAY_MS:100}" + stats_print_interval_ms: "${SQL_EDGE_EVENTS_BATCH_STATS_PRINT_MS:10000}" + partition_size: "${SQL_EDGE_EVENTS_PARTITION_SIZE_HOURS:168}" # Number of hours to partition the events. The current value corresponds to one week. + audit_logs: + partition_size: "${SQL_AUDIT_LOGS_PARTITION_SIZE_HOURS:168}" # Default value - 1 week + # Specify whether to sort entities before batch update. Should be enabled for cluster mode to avoid deadlocks + batch_sort: "${SQL_BATCH_SORT:true}" + # Specify whether to remove null characters from strValue of attributes and timeseries before insert + remove_null_chars: "${SQL_REMOVE_NULL_CHARS:true}" + # Specify whether to log database queries and their parameters generated by entity query repository + log_queries: "${SQL_LOG_QUERIES:false}" + log_queries_threshold: "${SQL_LOG_QUERIES_THRESHOLD:5000}" + log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" + log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" + postgres: + # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. + ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" + timescale: + # Specify Interval size for new data chunks storage. + chunk_time_interval: "${SQL_TIMESCALE_CHUNK_TIME_INTERVAL:604800000}" + batch_threads: "${SQL_TIMESCALE_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + ttl: + ts: + enabled: "${SQL_TTL_TS_ENABLED:true}" + execution_interval_ms: "${SQL_TTL_TS_EXECUTION_INTERVAL:86400000}" # Number of milliseconds. The current value corresponds to one day + ts_key_value_ttl: "${SQL_TTL_TS_TS_KEY_VALUE_TTL:0}" # Number of seconds + events: + enabled: "${SQL_TTL_EVENTS_ENABLED:true}" + execution_interval_ms: "${SQL_TTL_EVENTS_EXECUTION_INTERVAL:3600000}" # Number of milliseconds (max random initial delay and fixed period). + # Number of seconds. TTL is disabled by default. Accuracy of the cleanup depends on the sql.events.partition_size parameter. + events_ttl: "${SQL_TTL_EVENTS_EVENTS_TTL:0}" + # Number of seconds. The current value corresponds to one week. Accuracy of the cleanup depends on the sql.events.debug_partition_size parameter. + debug_events_ttl: "${SQL_TTL_EVENTS_DEBUG_EVENTS_TTL:604800}" + edge_events: + enabled: "${SQL_TTL_EDGE_EVENTS_ENABLED:true}" + execution_interval_ms: "${SQL_TTL_EDGE_EVENTS_EXECUTION_INTERVAL:86400000}" # Number of milliseconds. The current value corresponds to one day + edge_events_ttl: "${SQL_TTL_EDGE_EVENTS_TTL:2628000}" # Number of seconds. The current value corresponds to one month + alarms: + checking_interval: "${SQL_ALARMS_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours + removal_batch_size: "${SQL_ALARMS_TTL_REMOVAL_BATCH_SIZE:3000}" # To delete outdated alarms not all at once but in batches + rpc: + enabled: "${SQL_TTL_RPC_ENABLED:true}" + checking_interval: "${SQL_RPC_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours + audit_logs: + enabled: "${SQL_TTL_AUDIT_LOGS_ENABLED:true}" + ttl: "${SQL_TTL_AUDIT_LOGS_SECS:0}" # Disabled by default. Accuracy of the cleanup depends on the sql.audit_logs.partition_size + checking_interval_ms: "${SQL_TTL_AUDIT_LOGS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day + relations: + max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonable small to prevent infinite recursion as early as possible + pool_size: "${SQL_RELATIONS_POOL_SIZE:4}" # This value has to be reasonable small to prevent relation query blocking all other DB calls + query_timeout: "${SQL_RELATIONS_QUERY_TIMEOUT_SEC:20}" # This value has to be reasonable small to prevent relation query blocking all other DB calls + +# Actor system parameters +actors: + system: + throughput: "${ACTORS_SYSTEM_THROUGHPUT:5}" + scheduler_pool_size: "${ACTORS_SYSTEM_SCHEDULER_POOL_SIZE:1}" + max_actor_init_attempts: "${ACTORS_SYSTEM_MAX_ACTOR_INIT_ATTEMPTS:10}" + app_dispatcher_pool_size: "${ACTORS_SYSTEM_APP_DISPATCHER_POOL_SIZE:1}" + tenant_dispatcher_pool_size: "${ACTORS_SYSTEM_TENANT_DISPATCHER_POOL_SIZE:2}" + device_dispatcher_pool_size: "${ACTORS_SYSTEM_DEVICE_DISPATCHER_POOL_SIZE:4}" + rule_dispatcher_pool_size: "${ACTORS_SYSTEM_RULE_DISPATCHER_POOL_SIZE:4}" + tenant: + create_components_on_init: "${ACTORS_TENANT_CREATE_COMPONENTS_ON_INIT:true}" + session: + max_concurrent_sessions_per_device: "${ACTORS_MAX_CONCURRENT_SESSION_PER_DEVICE:1}" + sync: + # Default timeout for processing request using synchronous session (HTTP, CoAP) in milliseconds + timeout: "${ACTORS_SESSION_SYNC_TIMEOUT:10000}" + rule: + # Specify thread pool size for database request callbacks executor service + db_callback_thread_pool_size: "${ACTORS_RULE_DB_CALLBACK_THREAD_POOL_SIZE:50}" + # Specify thread pool size for mail sender executor service + mail_thread_pool_size: "${ACTORS_RULE_MAIL_THREAD_POOL_SIZE:40}" + # Specify thread pool size for password reset emails + mail_password_reset_thread_pool_size: "${ACTORS_RULE_MAIL_PASSWORD_RESET_THREAD_POOL_SIZE:10}" + # Specify thread pool size for sms sender executor service + sms_thread_pool_size: "${ACTORS_RULE_SMS_THREAD_POOL_SIZE:50}" + # Whether to allow usage of system mail service for rules + allow_system_mail_service: "${ACTORS_RULE_ALLOW_SYSTEM_MAIL_SERVICE:true}" + # Whether to allow usage of system sms service for rules + allow_system_sms_service: "${ACTORS_RULE_ALLOW_SYSTEM_SMS_SERVICE:true}" + # Specify thread pool size for external call service + external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:50}" + chain: + # Errors for particular actor are persisted once per specified amount of milliseconds + error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}" + debug_mode_rate_limits_per_tenant: + enabled: "${ACTORS_RULE_CHAIN_DEBUG_MODE_RATE_LIMITS_PER_TENANT_ENABLED:true}" + configuration: "${ACTORS_RULE_CHAIN_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}" + node: + # Errors for particular actor are persisted once per specified amount of milliseconds + error_persist_frequency: "${ACTORS_RULE_NODE_ERROR_FREQUENCY:3000}" + transaction: + # Size of queues which store messages for transaction rule nodes + queue_size: "${ACTORS_RULE_TRANSACTION_QUEUE_SIZE:15000}" + # Time in milliseconds for transaction to complete + duration: "${ACTORS_RULE_TRANSACTION_DURATION:60000}" + rpc: + max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" + sequential: "${ACTORS_RPC_SEQUENTIAL:false}" + statistics: + # Enable/disable actor statistics + enabled: "${ACTORS_STATISTICS_ENABLED:true}" + js_print_interval_ms: "${ACTORS_JS_STATISTICS_PRINT_INTERVAL_MS:10000}" + persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:3600000}" + +cache: + # caffeine or redis + type: "${CACHE_TYPE:caffeine}" + maximumPoolSize: "${CACHE_MAXIMUM_POOL_SIZE:16}" # max pool size to process futures that calls the external cache + attributes: + # make sure that if cache.type is 'redis' and cache.attributes.enabled is 'true' that you change 'maxmemory-policy' Redis config property to 'allkeys-lru', 'allkeys-lfu' or 'allkeys-random' + enabled: "${CACHE_ATTRIBUTES_ENABLED:true}" + specs: + relations: + timeToLiveInMinutes: "${CACHE_SPECS_RELATIONS_TTL:1440}" + maxSize: "${CACHE_SPECS_RELATIONS_MAX_SIZE:10000}" # maxSize: 0 means the cache is disabled + deviceCredentials: + timeToLiveInMinutes: "${CACHE_SPECS_DEVICE_CREDENTIALS_TTL:1440}" + maxSize: "${CACHE_SPECS_DEVICE_CREDENTIALS_MAX_SIZE:10000}" + devices: + timeToLiveInMinutes: "${CACHE_SPECS_DEVICES_TTL:1440}" + maxSize: "${CACHE_SPECS_DEVICES_MAX_SIZE:10000}" + sessions: + timeToLiveInMinutes: "${CACHE_SPECS_SESSIONS_TTL:1440}" + maxSize: "${CACHE_SPECS_SESSIONS_MAX_SIZE:10000}" + assets: + timeToLiveInMinutes: "${CACHE_SPECS_ASSETS_TTL:1440}" + maxSize: "${CACHE_SPECS_ASSETS_MAX_SIZE:10000}" + entityViews: + timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_VIEWS_TTL:1440}" + maxSize: "${CACHE_SPECS_ENTITY_VIEWS_MAX_SIZE:10000}" + claimDevices: + timeToLiveInMinutes: "${CACHE_SPECS_CLAIM_DEVICES_TTL:1440}" + maxSize: "${CACHE_SPECS_CLAIM_DEVICES_MAX_SIZE:1000}" + securitySettings: + timeToLiveInMinutes: "${CACHE_SPECS_SECURITY_SETTINGS_TTL:1440}" + maxSize: "${CACHE_SPECS_SECURITY_SETTINGS_MAX_SIZE:10000}" + tenantProfiles: + timeToLiveInMinutes: "${CACHE_SPECS_TENANT_PROFILES_TTL:1440}" + maxSize: "${CACHE_SPECS_TENANT_PROFILES_MAX_SIZE:10000}" + tenants: + timeToLiveInMinutes: "${CACHE_SPECS_TENANTS_TTL:1440}" + maxSize: "${CACHE_SPECS_TENANTS_MAX_SIZE:10000}" + tenantsExist: + # environment variables are intentionally the same as in 'tenants' cache to be equal. + timeToLiveInMinutes: "${CACHE_SPECS_TENANTS_TTL:1440}" + maxSize: "${CACHE_SPECS_TENANTS_MAX_SIZE:10000}" + deviceProfiles: + timeToLiveInMinutes: "${CACHE_SPECS_DEVICE_PROFILES_TTL:1440}" + maxSize: "${CACHE_SPECS_DEVICE_PROFILES_MAX_SIZE:10000}" + assetProfiles: + timeToLiveInMinutes: "${CACHE_SPECS_ASSET_PROFILES_TTL:1440}" + maxSize: "${CACHE_SPECS_ASSET_PROFILES_MAX_SIZE:10000}" + attributes: + timeToLiveInMinutes: "${CACHE_SPECS_ATTRIBUTES_TTL:1440}" + maxSize: "${CACHE_SPECS_ATTRIBUTES_MAX_SIZE:100000}" + userSessionsInvalidation: + # The value of this TTL is ignored and replaced by JWT refresh token expiration time + timeToLiveInMinutes: "0" + maxSize: "${CACHE_SPECS_USERS_UPDATE_TIME_MAX_SIZE:10000}" + otaPackages: + timeToLiveInMinutes: "${CACHE_SPECS_OTA_PACKAGES_TTL:60}" + maxSize: "${CACHE_SPECS_OTA_PACKAGES_MAX_SIZE:10}" + otaPackagesData: + timeToLiveInMinutes: "${CACHE_SPECS_OTA_PACKAGES_DATA_TTL:60}" + maxSize: "${CACHE_SPECS_OTA_PACKAGES_DATA_MAX_SIZE:10}" + edges: + timeToLiveInMinutes: "${CACHE_SPECS_EDGES_TTL:1440}" + maxSize: "${CACHE_SPECS_EDGES_MAX_SIZE:10000}" + repositorySettings: + timeToLiveInMinutes: "${CACHE_SPECS_REPOSITORY_SETTINGS_TTL:1440}" + maxSize: "${CACHE_SPECS_REPOSITORY_SETTINGS_MAX_SIZE:10000}" + autoCommitSettings: + timeToLiveInMinutes: "${CACHE_SPECS_AUTO_COMMIT_SETTINGS_TTL:1440}" + maxSize: "${CACHE_SPECS_AUTO_COMMIT_SETTINGS_MAX_SIZE:10000}" + twoFaVerificationCodes: + timeToLiveInMinutes: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_TTL:60}" + maxSize: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_MAX_SIZE:100000}" + versionControlTask: + timeToLiveInMinutes: "${CACHE_SPECS_VERSION_CONTROL_TASK_TTL:5}" + maxSize: "${CACHE_SPECS_VERSION_CONTROL_TASK_MAX_SIZE:100000}" + +#Disable this because it is not required. +spring.data.redis.repositories.enabled: false + +redis: + # standalone or cluster + connection: + type: "${REDIS_CONNECTION_TYPE:standalone}" + standalone: + host: "${REDIS_HOST:localhost}" + port: "${REDIS_PORT:6379}" + useDefaultClientConfig: "${REDIS_USE_DEFAULT_CLIENT_CONFIG:true}" + # this value may be used only if you used not default ClientConfig + clientName: "${REDIS_CLIENT_NAME:standalone}" + # this value may be used only if you used not default ClientConfig + connectTimeout: "${REDIS_CLIENT_CONNECT_TIMEOUT:30000}" + # this value may be used only if you used not default ClientConfig + readTimeout: "${REDIS_CLIENT_READ_TIMEOUT:60000}" + # this value may be used only if you used not default ClientConfig + usePoolConfig: "${REDIS_CLIENT_USE_POOL_CONFIG:false}" + cluster: + # Comma-separated list of "host:port" pairs to bootstrap from. + nodes: "${REDIS_NODES:}" + # Maximum number of redirects to follow when executing commands across the cluster. + max-redirects: "${REDIS_MAX_REDIRECTS:12}" + useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" + # db index + db: "${REDIS_DB:0}" + # db password + password: "${REDIS_PASSWORD:}" + # pool config + pool_config: + maxTotal: "${REDIS_POOL_CONFIG_MAX_TOTAL:128}" + maxIdle: "${REDIS_POOL_CONFIG_MAX_IDLE:128}" + minIdle: "${REDIS_POOL_CONFIG_MIN_IDLE:16}" + testOnBorrow: "${REDIS_POOL_CONFIG_TEST_ON_BORROW:true}" + testOnReturn: "${REDIS_POOL_CONFIG_TEST_ON_RETURN:true}" + testWhileIdle: "${REDIS_POOL_CONFIG_TEST_WHILE_IDLE:true}" + minEvictableMs: "${REDIS_POOL_CONFIG_MIN_EVICTABLE_MS:60000}" + evictionRunsMs: "${REDIS_POOL_CONFIG_EVICTION_RUNS_MS:30000}" + maxWaitMills: "${REDIS_POOL_CONFIG_MAX_WAIT_MS:60000}" + numberTestsPerEvictionRun: "${REDIS_POOL_CONFIG_NUMBER_TESTS_PER_EVICTION_RUN:3}" + blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" + # TTL for short-living SET commands that are used to replace DEL in order to enable transaction support + evictTtlInMs: "${REDIS_EVICT_TTL_MS:60000}" + +# Check new version updates parameters +updates: + # Enable/disable updates checking. + enabled: "${UPDATES_ENABLED:true}" + +spring.main.allow-circular-references: "true" + +# spring freemarker configuration +spring.freemarker.checkTemplateLocation: "false" + +# spring CORS configuration +spring.mvc.cors: + mappings: + # Intercept path + "[/api/**]": + #Comma-separated list of origins to allow. '*' allows all origins. When not set,CORS support is disabled. + allowed-origin-patterns: "*" + #Comma-separated list of methods to allow. '*' allows all methods. + allowed-methods: "*" + #Comma-separated list of headers to allow in a request. '*' allows all headers. + allowed-headers: "*" + #How long, in seconds, the response from a pre-flight request can be cached by clients. + max-age: "1800" + #Set whether credentials are supported. When not set, credentials are not supported. + allow-credentials: "true" + +# The default timeout for asynchronous requests in milliseconds +spring.mvc.async.request-timeout: "${SPRING_MVC_ASYNC_REQUEST_TIMEOUT:30000}" + +# For endpoints matching in Swagger +spring.mvc.pathmatch.matching-strategy: "ANT_PATH_MATCHER" + +# spring serve gzip compressed static resources +spring.resources.chain: + compressed: "true" + strategy: + content: + enabled: "true" + +spring.servlet.multipart.max-file-size: "50MB" +spring.servlet.multipart.max-request-size: "50MB" + +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation: "true" +# Note: as for current Spring JPA version, custom NullHandling for the Sort.Order is ignored and this parameter is used +spring.jpa.properties.hibernate.order_by.default_null_ordering: "${SPRING_JPA_PROPERTIES_HIBERNATE_ORDER_BY_DEFAULT_NULL_ORDERING:last}" + +# SQL DAO Configuration +spring: + data: + jpa: + repositories: + enabled: "true" + jpa: + properties: + javax.persistence.query.timeout: "${JAVAX_PERSISTENCE_QUERY_TIMEOUT:30000}" + open-in-view: "false" + hibernate: + ddl-auto: "none" + datasource: + driverClassName: "${SPRING_DRIVER_CLASS_NAME:org.postgresql.Driver}" + url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/thingsboard}" + username: postgres + password: admin + hikari: + maximumPoolSize: "${SPRING_DATASOURCE_MAXIMUM_POOL_SIZE:16}" + +# Audit log parameters +audit-log: + # Enable/disable audit log functionality. + enabled: "${AUDIT_LOG_ENABLED:true}" + # Logging levels per each entity type. + # Allowed values: OFF (disable), W (log write operations), RW (log read and write operations) + logging-level: + mask: + "device": "${AUDIT_LOG_MASK_DEVICE:W}" + "asset": "${AUDIT_LOG_MASK_ASSET:W}" + "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" + "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" + "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_ENTITY_VIEW:W}" + "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" + "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" + "edge": "${AUDIT_LOG_MASK_EDGE:W}" + "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" + "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" + sink: + # Type of external sink. possible options: none, elasticsearch + type: "${AUDIT_LOG_SINK_TYPE:none}" + # Name of the index where audit logs stored + # Index name could contain next placeholders (not mandatory): + # @{TENANT} - substituted by tenant ID + # @{DATE} - substituted by current date in format provided in audit_log.sink.date_format + index_pattern: "${AUDIT_LOG_SINK_INDEX_PATTERN:@{TENANT}_AUDIT_LOG_@{DATE}}" + # Date format. Details of the pattern could be found here: + # https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html + date_format: "${AUDIT_LOG_SINK_DATE_FORMAT:YYYY.MM.dd}" + scheme_name: "${AUDIT_LOG_SINK_SCHEME_NAME:http}" # http or https + host: "${AUDIT_LOG_SINK_HOST:localhost}" + port: "${AUDIT_LOG_SINK_PORT:9200}" + user_name: "${AUDIT_LOG_SINK_USER_NAME:}" + password: "${AUDIT_LOG_SINK_PASSWORD:}" + +state: + # Should be greater then transport.sessions.report_timeout + defaultInactivityTimeoutInSec: "${DEFAULT_INACTIVITY_TIMEOUT:600}" + defaultStateCheckIntervalInSec: "${DEFAULT_STATE_CHECK_INTERVAL:60}" + persistToTelemetry: "${PERSIST_STATE_TO_TELEMETRY:false}" + +tbel: + enabled: "${TBEL_ENABLED:true}" + max_total_args_size: "${TBEL_MAX_TOTAL_ARGS_SIZE:100000}" + max_result_size: "${TBEL_MAX_RESULT_SIZE:300000}" + max_script_body_size: "${TBEL_MAX_SCRIPT_BODY_SIZE:50000}" + # Maximum allowed TBEL script execution memory + max_memory_limit_mb: "${TBEL_MAX_MEMORY_LIMIT_MB: 8}" + # Maximum allowed TBEL script execution errors before it will be blacklisted + max_errors: "${TBEL_MAX_ERRORS:3}" + # TBEL Eval max request timeout in milliseconds. 0 - no timeout + max_requests_timeout: "${TBEL_MAX_REQUEST_TIMEOUT:500}" + # Maximum time in seconds for black listed function to stay in the list. + max_black_list_duration_sec: "${TBEL_MAX_BLACKLIST_DURATION_SEC:60}" + # Specify thread pool size for javascript executor service + thread_pool_size: "${TBEL_THREAD_POOL_SIZE:50}" + compiled_scripts_cache_size: "${TBEL_COMPILED_SCRIPTS_CACHE_SIZE:1000}" + stats: + enabled: "${TB_TBEL_STATS_ENABLED:false}" + print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}" + +js: + evaluator: "${JS_EVALUATOR:local}" # local/remote + max_total_args_size: "${JS_MAX_TOTAL_ARGS_SIZE:100000}" + max_result_size: "${JS_MAX_RESULT_SIZE:300000}" + max_script_body_size: "${JS_MAX_SCRIPT_BODY_SIZE:50000}" + # Built-in JVM JavaScript environment properties + local: + # Specify thread pool size for javascript executor service + js_thread_pool_size: "${LOCAL_JS_THREAD_POOL_SIZE:50}" + # 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:8000}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + max_errors: "${LOCAL_JS_SANDBOX_MAX_ERRORS:3}" + # JS Eval max request timeout. 0 - no timeout + max_requests_timeout: "${LOCAL_JS_MAX_REQUEST_TIMEOUT:0}" + # Maximum time in seconds for black listed function to stay in the list. + max_black_list_duration_sec: "${LOCAL_JS_SANDBOX_MAX_BLACKLIST_DURATION_SEC:60}" + stats: + enabled: "${TB_JS_LOCAL_STATS_ENABLED:false}" + print_interval_ms: "${TB_JS_LOCAL_STATS_PRINT_INTERVAL_MS:10000}" + # Remote JavaScript environment properties + remote: + # Specify thread pool size for javascript executor service + js_thread_pool_size: "${REMOTE_JS_THREAD_POOL_SIZE:50}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" + # Maximum time in seconds for black listed function to stay in the list. + max_black_list_duration_sec: "${REMOTE_JS_SANDBOX_MAX_BLACKLIST_DURATION_SEC:60}" + stats: + enabled: "${TB_JS_REMOTE_STATS_ENABLED:false}" + print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}" + +transport: + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:3000}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" + # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) + max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + client_side_rpc: + timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" + # Enable/disable http/mqtt/coap transport protocols (has higher priority than certain protocol's 'enabled' property) + api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" + log: + enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" + max_length: "${TB_TRANSPORT_LOG_MAX_LENGTH:1024}" + rate_limits: + # Enable or disable generic rate limits. Device and Tenant specific rate limits are controlled in Tenant Profile. + ip_limits_enabled: "${TB_TRANSPORT_IP_RATE_LIMITS_ENABLED:false}" + # Maximum number of connect attempts with invalid credentials + max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" + # Timeout to expire block IP addresses + ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" + # Local HTTP transport parameters + http: + enabled: "${HTTP_ENABLED:true}" + request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" + max_request_timeout: "${HTTP_MAX_REQUEST_TIMEOUT:300000}" + # 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}" + # Enable proxy protocol support. Disabled by default. If enabled, supports both v1 and v2. + # Useful to get the real IP address of the client in the logs and for rate limits. + proxy_enabled: "${MQTT_PROXY_PROTOCOL_ENABLED:false}" + timeout: "${MQTT_TIMEOUT:10000}" + msg_queue_size_per_device_limit: "${MQTT_MSG_QUEUE_SIZE_PER_DEVICE_LIMIT:100}" # messages await in the queue before device connected state. This limit works on low level before TenantProfileLimits mechanism + 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}" + so_keep_alive: "${NETTY_SO_KEEPALIVE:false}" + # MQTT SSL configuration + ssl: + # Enable/disable SSL support + enabled: "${MQTT_SSL_ENABLED:false}" + # MQTT SSL bind address + bind_address: "${MQTT_SSL_BIND_ADDRESS:0.0.0.0}" + # MQTT SSL bind port + bind_port: "${MQTT_SSL_BIND_PORT:8883}" + # SSL protocol: See https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#sslcontext-algorithms + protocol: "${MQTT_SSL_PROTOCOL:TLSv1.2}" + # Server SSL credentials + credentials: + # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) + type: "${MQTT_SSL_CREDENTIALS_TYPE:PEM}" + # PEM server credentials + pem: + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) + cert_file: "${MQTT_SSL_PEM_CERT:mqttserver.pem}" + # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; + key_file: "${MQTT_SSL_PEM_KEY:mqttserver_key.pem}" + # Server certificate private key password (optional) + key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:server_key_password}" + # Keystore server credentials + keystore: + # Type of the key store (JKS or PKCS12) + type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" + # Path to the key store that holds the SSL certificate + store_file: "${MQTT_SSL_KEY_STORE:mqttserver.jks}" + # Password used to access the key store + store_password: "${MQTT_SSL_KEY_STORE_PASSWORD:server_ks_password}" + # Optional alias of the private key; If not set, the platform will load the first private key from the keystore; + key_alias: "${MQTT_SSL_KEY_ALIAS:}" + # Optional password to access the private key. If not set, the platform will attempt to load the private keys that are not protected with the password; + key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" + # Skip certificate validity check for client certificates. + skip_validity_check_for_client_cert: "${MQTT_SSL_SKIP_VALIDITY_CHECK_FOR_CLIENT_CERT:false}" + # 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}" + piggyback_timeout: "${COAP_PIGGYBACK_TIMEOUT:500}" + psm_activity_timer: "${COAP_PSM_ACTIVITY_TIMER:10000}" + paging_transmission_window: "${COAP_PAGING_TRANSMISSION_WINDOW:10000}" + dtls: + # Enable/disable DTLS 1.2 support + enabled: "${COAP_DTLS_ENABLED:false}" + # RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000 + retransmission_timeout: "${COAP_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}" + # CoAP DTLS bind address + bind_address: "${COAP_DTLS_BIND_ADDRESS:0.0.0.0}" + # CoAP DTLS bind port + bind_port: "${COAP_DTLS_BIND_PORT:5684}" + # Server DTLS credentials + credentials: + # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) + type: "${COAP_DTLS_CREDENTIALS_TYPE:PEM}" + # PEM server credentials + pem: + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) + cert_file: "${COAP_DTLS_PEM_CERT:coapserver.pem}" + # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; + key_file: "${COAP_DTLS_PEM_KEY:coapserver_key.pem}" + # Server certificate private key password (optional) + key_password: "${COAP_DTLS_PEM_KEY_PASSWORD:server_key_password}" + # Keystore server credentials + keystore: + # Type of the key store (JKS or PKCS12) + type: "${COAP_DTLS_KEY_STORE_TYPE:JKS}" + # Path to the key store that holds the SSL certificate + store_file: "${COAP_DTLS_KEY_STORE:coapserver.jks}" + # Password used to access the key store + store_password: "${COAP_DTLS_KEY_STORE_PASSWORD:server_ks_password}" + # Key alias + key_alias: "${COAP_DTLS_KEY_ALIAS:serveralias}" + # Password used to access the key + key_password: "${COAP_DTLS_KEY_PASSWORD:server_key_password}" + x509: + # Skip certificate validity check for client certificates. + skip_validity_check_for_client_cert: "${TB_COAP_X509_DTLS_SKIP_VALIDITY_CHECK_FOR_CLIENT_CERT:false}" + dtls_session_inactivity_timeout: "${TB_COAP_X509_DTLS_SESSION_INACTIVITY_TIMEOUT:86400000}" + dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}" + # Local LwM2M transport parameters + lwm2m: + # Enable/disable lvm2m transport protocol. + enabled: "${LWM2M_ENABLED:true}" + dtls: + # RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000 + retransmission_timeout: "${LWM2M_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}" + server: + id: "${LWM2M_SERVER_ID:123}" + bind_address: "${LWM2M_BIND_ADDRESS:0.0.0.0}" + bind_port: "${LWM2M_BIND_PORT:5685}" + security: + bind_address: "${LWM2M_SECURITY_BIND_ADDRESS:0.0.0.0}" + bind_port: "${LWM2M_SECURITY_BIND_PORT:5686}" + # Server X509 Certificates support + credentials: + # Whether to enable LWM2M server X509 Certificate/RPK support + enabled: "${LWM2M_SERVER_CREDENTIALS_ENABLED:false}" + # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) + type: "${LWM2M_SERVER_CREDENTIALS_TYPE:PEM}" + # PEM server credentials + pem: + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) + cert_file: "${LWM2M_SERVER_PEM_CERT:lwm2mserver.pem}" + # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; + key_file: "${LWM2M_SERVER_PEM_KEY:lwm2mserver_key.pem}" + # Server certificate private key password (optional) + key_password: "${LWM2M_SERVER_PEM_KEY_PASSWORD:server_key_password}" + # Keystore server credentials + keystore: + # Type of the key store (JKS or PKCS12) + type: "${LWM2M_SERVER_KEY_STORE_TYPE:JKS}" + # Path to the key store that holds the SSL certificate + store_file: "${LWM2M_SERVER_KEY_STORE:lwm2mserver.jks}" + # Password used to access the key store + store_password: "${LWM2M_SERVER_KEY_STORE_PASSWORD:server_ks_password}" + # Key alias + key_alias: "${LWM2M_SERVER_KEY_ALIAS:server}" + # Password used to access the key + key_password: "${LWM2M_SERVER_KEY_PASSWORD:server_ks_password}" + # Only Certificate_x509: + skip_validity_check_for_client_cert: "${TB_LWM2M_SERVER_SECURITY_SKIP_VALIDITY_CHECK_FOR_CLIENT_CERT:false}" + bootstrap: + enabled: "${LWM2M_ENABLED_BS:true}" + id: "${LWM2M_SERVER_ID_BS:111}" + bind_address: "${LWM2M_BS_BIND_ADDRESS:0.0.0.0}" + bind_port: "${LWM2M_BS_BIND_PORT:5687}" + security: + bind_address: "${LWM2M_BS_SECURITY_BIND_ADDRESS:0.0.0.0}" + bind_port: "${LWM2M_BS_SECURITY_BIND_PORT:5688}" + # Bootstrap server X509 Certificates support + credentials: + # Whether to enable LWM2M bootstrap server X509 Certificate/RPK support + enabled: "${LWM2M_BS_CREDENTIALS_ENABLED:false}" + # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) + type: "${LWM2M_BS_CREDENTIALS_TYPE:PEM}" + # PEM server credentials + pem: + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) + cert_file: "${LWM2M_BS_PEM_CERT:lwm2mserver.pem}" + # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; + key_file: "${LWM2M_BS_PEM_KEY:lwm2mserver_key.pem}" + # Server certificate private key password (optional) + key_password: "${LWM2M_BS_PEM_KEY_PASSWORD:server_key_password}" + # Keystore server credentials + keystore: + # Type of the key store (JKS or PKCS12) + type: "${LWM2M_BS_KEY_STORE_TYPE:JKS}" + # Path to the key store that holds the SSL certificate + store_file: "${LWM2M_BS_KEY_STORE:lwm2mserver.jks}" + # Password used to access the key store + store_password: "${LWM2M_BS_KEY_STORE_PASSWORD:server_ks_password}" + # Key alias + key_alias: "${LWM2M_BS_KEY_ALIAS:bootstrap}" + # Password used to access the key + key_password: "${LWM2M_BS_KEY_PASSWORD:server_ks_password}" + security: + # X509 trust certificates + trust-credentials: + # Whether to load X509 trust certificates + enabled: "${LWM2M_TRUST_CREDENTIALS_ENABLED:false}" + # Trust certificates store type (PEM - pem certificates file; KEYSTORE - java keystore) + type: "${LWM2M_TRUST_CREDENTIALS_TYPE:PEM}" + # PEM certificates + pem: + # Path to the certificates file (holds trust certificates) + cert_file: "${LWM2M_TRUST_PEM_CERT:lwm2mtruststorechain.pem}" + # Keystore with trust certificates + keystore: + # Type of the key store (JKS or PKCS12) + type: "${LWM2M_TRUST_KEY_STORE_TYPE:JKS}" + # Path to the key store that holds the X509 certificates + store_file: "${LWM2M_TRUST_KEY_STORE:lwm2mtruststorechain.jks}" + # Password used to access the key store + store_password: "${LWM2M_TRUST_KEY_STORE_PASSWORD:server_ks_password}" + recommended_ciphers: "${LWM2M_RECOMMENDED_CIPHERS:false}" + recommended_supported_groups: "${LWM2M_RECOMMENDED_SUPPORTED_GROUPS:true}" + timeout: "${LWM2M_TIMEOUT:120000}" + uplink_pool_size: "${LWM2M_UPLINK_POOL_SIZE:10}" + downlink_pool_size: "${LWM2M_DOWNLINK_POOL_SIZE:10}" + ota_pool_size: "${LWM2M_OTA_POOL_SIZE:10}" + clean_period_in_sec: "${LWM2M_CLEAN_PERIOD_IN_SEC:2}" + log_max_length: "${LWM2M_LOG_MAX_LENGTH:1024}" + psm_activity_timer: "${LWM2M_PSM_ACTIVITY_TIMER:10000}" + paging_transmission_window: "${LWM2M_PAGING_TRANSMISSION_WINDOW:10000}" + network_config: # In this section you can specify custom parameters for LwM2M network configuration and expose the env variables to configure outside + # - key: "PROTOCOL_STAGE_THREAD_COUNT" + # value: "${LWM2M_PROTOCOL_STAGE_THREAD_COUNT:4}" + snmp: + enabled: "${SNMP_ENABLED:true}" + response_processing: + # parallelism level for executor (workStealingPool) that is responsible for handling responses from SNMP devices + parallelism_level: "${SNMP_RESPONSE_PROCESSING_PARALLELISM_LEVEL:20}" + # to configure SNMP to work over UDP or TCP + underlying_protocol: "${SNMP_UNDERLYING_PROTOCOL:udp}" + stats: + enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" + print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + +# Edges parameters +edges: + enabled: "${EDGES_ENABLED:true}" + rpc: + port: "${EDGES_RPC_PORT:7070}" + client_max_keep_alive_time_sec: "${EDGES_RPC_CLIENT_MAX_KEEP_ALIVE_TIME_SEC:300}" + ssl: + # Enable/disable SSL support + enabled: "${EDGES_RPC_SSL_ENABLED:false}" + cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" + private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" + max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" + storage: + max_read_records_count: "${EDGES_STORAGE_MAX_READ_RECORDS_COUNT:50}" + no_read_records_sleep: "${EDGES_NO_READ_RECORDS_SLEEP:1000}" + sleep_between_batches: "${EDGES_SLEEP_BETWEEN_BATCHES:10000}" + scheduler_pool_size: "${EDGES_SCHEDULER_POOL_SIZE:1}" + send_scheduler_pool_size: "${EDGES_SEND_SCHEDULER_POOL_SIZE:1}" + grpc_callback_thread_pool_size: "${EDGES_GRPC_CALLBACK_POOL_SIZE:1}" + edge_events_ttl: "${EDGES_EDGE_EVENTS_TTL:0}" + state: + persistToTelemetry: "${EDGES_PERSIST_STATE_TO_TELEMETRY:false}" + +swagger: + api_path_regex: "${SWAGGER_API_PATH_REGEX:/api/.*}" + security_path_regex: "${SWAGGER_SECURITY_PATH_REGEX:/api/.*}" + non_security_path_regex: "${SWAGGER_NON_SECURITY_PATH_REGEX:/api/(?:noauth|v1)/.*}" + title: "${SWAGGER_TITLE:ThingsBoard REST API}" + description: "${SWAGGER_DESCRIPTION: ThingsBoard open-source IoT platform REST API documentation.}" + contact: + name: "${SWAGGER_CONTACT_NAME:ThingsBoard team}" + url: "${SWAGGER_CONTACT_URL:https://thingsboard.io}" + email: "${SWAGGER_CONTACT_EMAIL:info@thingsboard.io}" + license: + title: "${SWAGGER_LICENSE_TITLE:Apache License Version 2.0}" + url: "${SWAGGER_LICENSE_URL:https://github.com/thingsboard/thingsboard/blob/master/LICENSE}" + version: "${SWAGGER_VERSION:}" + +queue: + type: "${TB_QUEUE_TYPE:in-memory}" # in-memory or kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) + in_memory: + stats: + # For debug lvl + print-interval-ms: "${TB_QUEUE_IN_MEMORY_STATS_PRINT_INTERVAL_MS:60000}" + kafka: + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + max.request.size: "${TB_KAFKA_MAX_REQUEST_SIZE:1048576}" + max.in.flight.requests.per.connection: "${TB_KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION:5}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + replication_factor: "${TB_QUEUE_KAFKA_REPLICATION_FACTOR:1}" + max_poll_interval_ms: "${TB_QUEUE_KAFKA_MAX_POLL_INTERVAL_MS:300000}" + max_poll_records: "${TB_QUEUE_KAFKA_MAX_POLL_RECORDS:8192}" + max_partition_fetch_bytes: "${TB_QUEUE_KAFKA_MAX_PARTITION_FETCH_BYTES:16777216}" + fetch_max_bytes: "${TB_QUEUE_KAFKA_FETCH_MAX_BYTES:134217728}" + use_confluent_cloud: "${TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD:false}" + confluent: + ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" + sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" + sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" + security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. + # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params + consumer-properties-per-topic: + tb_ota_package: + - key: max.poll.records + value: "${TB_QUEUE_KAFKA_OTA_MAX_POLL_RECORDS:10}" + tb_version_control: + - key: max.poll.interval.ms + value: "${TB_QUEUE_KAFKA_VC_MAX_POLL_INTERVAL_MS:600000}" + # tb_rule_engine.sq: + # - key: max.poll.records + # value: "${TB_QUEUE_KAFKA_SQ_MAX_POLL_RECORDS:1024}" + other: # In this section you can specify custom parameters for Kafka consumer/producer and expose the env variables to configure outside + - key: "request.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#producerconfigs_request.timeout.ms + value: "${TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS:30000}" # (30 seconds) + - key: "session.timeout.ms" # refer to https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#consumerconfigs_session.timeout.ms + value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) + topic-properties: + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + core: "${TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1}" + ota-updates: "${TB_QUEUE_KAFKA_OTA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + version-control: "${TB_QUEUE_KAFKA_VC_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + consumer-stats: + enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" + print-interval-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_MIN_PRINT_INTERVAL_MS:60000}" + kafka-response-timeout-ms: "${TB_QUEUE_KAFKA_CONSUMER_STATS_RESPONSE_TIMEOUT_MS:1000}" + aws_sqs: + use_default_credential_provider_chain: "${TB_QUEUE_AWS_SQS_USE_DEFAULT_CREDENTIAL_PROVIDER_CHAIN:false}" + access_key_id: "${TB_QUEUE_AWS_SQS_ACCESS_KEY_ID:YOUR_KEY}" + secret_access_key: "${TB_QUEUE_AWS_SQS_SECRET_ACCESS_KEY:YOUR_SECRET}" + region: "${TB_QUEUE_AWS_SQS_REGION:YOUR_REGION}" + threads_per_topic: "${TB_QUEUE_AWS_SQS_THREADS_PER_TOPIC:1}" + queue-properties: + rule-engine: "${TB_QUEUE_AWS_SQS_RE_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + core: "${TB_QUEUE_AWS_SQS_CORE_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + transport-api: "${TB_QUEUE_AWS_SQS_TA_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + notifications: "${TB_QUEUE_AWS_SQS_NOTIFICATIONS_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + js-executor: "${TB_QUEUE_AWS_SQS_JE_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + ota-updates: "${TB_QUEUE_AWS_SQS_OTA_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + version-control: "${TB_QUEUE_AWS_SQS_VC_QUEUE_PROPERTIES:VisibilityTimeout:30;MaximumMessageSize:262144;MessageRetentionPeriod:604800}" + # VisibilityTimeout in seconds;MaximumMessageSize in bytes;MessageRetentionPeriod in seconds + pubsub: + project_id: "${TB_QUEUE_PUBSUB_PROJECT_ID:YOUR_PROJECT_ID}" + service_account: "${TB_QUEUE_PUBSUB_SERVICE_ACCOUNT:YOUR_SERVICE_ACCOUNT}" + max_msg_size: "${TB_QUEUE_PUBSUB_MAX_MSG_SIZE:1048576}" #in bytes + max_messages: "${TB_QUEUE_PUBSUB_MAX_MESSAGES:1000}" + queue-properties: + rule-engine: "${TB_QUEUE_PUBSUB_RE_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + core: "${TB_QUEUE_PUBSUB_CORE_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + transport-api: "${TB_QUEUE_PUBSUB_TA_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + notifications: "${TB_QUEUE_PUBSUB_NOTIFICATIONS_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + js-executor: "${TB_QUEUE_PUBSUB_JE_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + version-control: "${TB_QUEUE_PUBSUB_VC_QUEUE_PROPERTIES:ackDeadlineInSec:30;messageRetentionInSec:604800}" + service_bus: + namespace_name: "${TB_QUEUE_SERVICE_BUS_NAMESPACE_NAME:YOUR_NAMESPACE_NAME}" + sas_key_name: "${TB_QUEUE_SERVICE_BUS_SAS_KEY_NAME:YOUR_SAS_KEY_NAME}" + sas_key: "${TB_QUEUE_SERVICE_BUS_SAS_KEY:YOUR_SAS_KEY}" + max_messages: "${TB_QUEUE_SERVICE_BUS_MAX_MESSAGES:1000}" + queue-properties: + rule-engine: "${TB_QUEUE_SERVICE_BUS_RE_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + core: "${TB_QUEUE_SERVICE_BUS_CORE_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + transport-api: "${TB_QUEUE_SERVICE_BUS_TA_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + notifications: "${TB_QUEUE_SERVICE_BUS_NOTIFICATIONS_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + js-executor: "${TB_QUEUE_SERVICE_BUS_JE_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + version-control: "${TB_QUEUE_SERVICE_BUS_VC_QUEUE_PROPERTIES:lockDurationInSec:30;maxSizeInMb:1024;messageTimeToLiveInSec:604800}" + rabbitmq: + exchange_name: "${TB_QUEUE_RABBIT_MQ_EXCHANGE_NAME:}" + host: "${TB_QUEUE_RABBIT_MQ_HOST:localhost}" + port: "${TB_QUEUE_RABBIT_MQ_PORT:5672}" + virtual_host: "${TB_QUEUE_RABBIT_MQ_VIRTUAL_HOST:/}" + username: "${TB_QUEUE_RABBIT_MQ_USERNAME:YOUR_USERNAME}" + password: "${TB_QUEUE_RABBIT_MQ_PASSWORD:YOUR_PASSWORD}" + automatic_recovery_enabled: "${TB_QUEUE_RABBIT_MQ_AUTOMATIC_RECOVERY_ENABLED:false}" + connection_timeout: "${TB_QUEUE_RABBIT_MQ_CONNECTION_TIMEOUT:60000}" + handshake_timeout: "${TB_QUEUE_RABBIT_MQ_HANDSHAKE_TIMEOUT:10000}" + queue-properties: + rule-engine: "${TB_QUEUE_RABBIT_MQ_RE_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + core: "${TB_QUEUE_RABBIT_MQ_CORE_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + transport-api: "${TB_QUEUE_RABBIT_MQ_TA_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + notifications: "${TB_QUEUE_RABBIT_MQ_NOTIFICATIONS_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + js-executor: "${TB_QUEUE_RABBIT_MQ_JE_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + version-control: "${TB_QUEUE_RABBIT_MQ_VC_QUEUE_PROPERTIES:x-max-length-bytes:1048576000;x-message-ttl:604800000}" + partitions: + hash_function_name: "${TB_QUEUE_PARTITIONS_HASH_FUNCTION_NAME:murmur3_128}" # murmur3_32, murmur3_128 or sha256 + transport_api: + requests_topic: "${TB_QUEUE_TRANSPORT_API_REQUEST_TOPIC:tb_transport.api.requests}" + responses_topic: "${TB_QUEUE_TRANSPORT_API_RESPONSE_TOPIC:tb_transport.api.responses}" + max_pending_requests: "${TB_QUEUE_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_QUEUE_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + max_callback_threads: "${TB_QUEUE_TRANSPORT_MAX_CALLBACK_THREADS:100}" + request_poll_interval: "${TB_QUEUE_TRANSPORT_REQUEST_POLL_INTERVAL_MS:25}" + response_poll_interval: "${TB_QUEUE_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + core: + topic: "${TB_QUEUE_CORE_TOPIC:tb_core}" + poll-interval: "${TB_QUEUE_CORE_POLL_INTERVAL_MS:25}" + partitions: "${TB_QUEUE_CORE_PARTITIONS:10}" + pack-processing-timeout: "${TB_QUEUE_CORE_PACK_PROCESSING_TIMEOUT_MS:2000}" + ota: + topic: "${TB_QUEUE_CORE_OTA_TOPIC:tb_ota_package}" + pack-interval-ms: "${TB_QUEUE_CORE_OTA_PACK_INTERVAL_MS:60000}" + pack-size: "${TB_QUEUE_CORE_OTA_PACK_SIZE:100}" + usage-stats-topic: "${TB_QUEUE_US_TOPIC:tb_usage_stats}" + stats: + enabled: "${TB_QUEUE_CORE_STATS_ENABLED:true}" + print-interval-ms: "${TB_QUEUE_CORE_STATS_PRINT_INTERVAL_MS:60000}" + vc: + topic: "${TB_QUEUE_VC_TOPIC:tb_version_control}" + partitions: "${TB_QUEUE_VC_PARTITIONS:10}" + poll-interval: "${TB_QUEUE_VC_INTERVAL_MS:25}" + pack-processing-timeout: "${TB_QUEUE_VC_PACK_PROCESSING_TIMEOUT_MS:60000}" + request-timeout: "${TB_QUEUE_VC_REQUEST_TIMEOUT:60000}" + msg-chunk-size: "${TB_QUEUE_VC_MSG_CHUNK_SIZE:250000}" + js: + # 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_RESPONSE_TOPIC:js_eval.responses}" + # JS Eval max pending requests + max_pending_requests: "${REMOTE_JS_MAX_PENDING_REQUESTS:10000}" + # JS Eval max request timeout + max_eval_requests_timeout: "${REMOTE_JS_MAX_EVAL_REQUEST_TIMEOUT:60000}" + # JS max request timeout + max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:10000}" + # JS execution max request timeout + max_exec_requests_timeout: "${REMOTE_JS_MAX_EXEC_REQUEST_TIMEOUT:2000}" + # JS response poll interval + response_poll_interval: "${REMOTE_JS_RESPONSE_POLL_INTERVAL_MS:25}" + rule-engine: + topic: "${TB_QUEUE_RULE_ENGINE_TOPIC:tb_rule_engine}" + poll-interval: "${TB_QUEUE_RULE_ENGINE_POLL_INTERVAL_MS:25}" + pack-processing-timeout: "${TB_QUEUE_RULE_ENGINE_PACK_PROCESSING_TIMEOUT_MS:2000}" + stats: + enabled: "${TB_QUEUE_RULE_ENGINE_STATS_ENABLED:true}" + print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}" + queues: + - name: "${TB_QUEUE_RE_MAIN_QUEUE_NAME:Main}" + topic: "${TB_QUEUE_RE_MAIN_TOPIC:tb_rule_engine.main}" + poll-interval: "${TB_QUEUE_RE_MAIN_POLL_INTERVAL_MS:25}" + partitions: "${TB_QUEUE_RE_MAIN_PARTITIONS:10}" + consumer-per-partition: "${TB_QUEUE_RE_MAIN_CONSUMER_PER_PARTITION:true}" + pack-processing-timeout: "${TB_QUEUE_RE_MAIN_PACK_PROCESSING_TIMEOUT_MS:2000}" + submit-strategy: + type: "${TB_QUEUE_RE_MAIN_SUBMIT_STRATEGY_TYPE:BURST}" # BURST, BATCH, SEQUENTIAL_BY_ORIGINATOR, SEQUENTIAL_BY_TENANT, SEQUENTIAL + # For BATCH only + batch-size: "${TB_QUEUE_RE_MAIN_SUBMIT_STRATEGY_BATCH_SIZE:1000}" # Maximum number of messages in batch + processing-strategy: + type: "${TB_QUEUE_RE_MAIN_PROCESSING_STRATEGY_TYPE:SKIP_ALL_FAILURES}" # SKIP_ALL_FAILURES, SKIP_ALL_FAILURES_AND_TIMED_OUT, RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + # For RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + retries: "${TB_QUEUE_RE_MAIN_PROCESSING_STRATEGY_RETRIES:3}" # Number of retries, 0 is unlimited + failure-percentage: "${TB_QUEUE_RE_MAIN_PROCESSING_STRATEGY_FAILURE_PERCENTAGE:0}" # Skip retry if failures or timeouts are less then X percentage of messages; + pause-between-retries: "${TB_QUEUE_RE_MAIN_PROCESSING_STRATEGY_RETRY_PAUSE:3}" # Time in seconds to wait in consumer thread before retries; + max-pause-between-retries: "${TB_QUEUE_RE_MAIN_PROCESSING_STRATEGY_MAX_RETRY_PAUSE:3}" # Max allowed time in seconds for pause between retries. + - name: "${TB_QUEUE_RE_HP_QUEUE_NAME:HighPriority}" + topic: "${TB_QUEUE_RE_HP_TOPIC:tb_rule_engine.hp}" + poll-interval: "${TB_QUEUE_RE_HP_POLL_INTERVAL_MS:25}" + partitions: "${TB_QUEUE_RE_HP_PARTITIONS:10}" + consumer-per-partition: "${TB_QUEUE_RE_HP_CONSUMER_PER_PARTITION:true}" + pack-processing-timeout: "${TB_QUEUE_RE_HP_PACK_PROCESSING_TIMEOUT_MS:2000}" + submit-strategy: + type: "${TB_QUEUE_RE_HP_SUBMIT_STRATEGY_TYPE:BURST}" # BURST, BATCH, SEQUENTIAL_BY_ORIGINATOR, SEQUENTIAL_BY_TENANT, SEQUENTIAL + # For BATCH only + batch-size: "${TB_QUEUE_RE_HP_SUBMIT_STRATEGY_BATCH_SIZE:100}" # Maximum number of messages in batch + processing-strategy: + type: "${TB_QUEUE_RE_HP_PROCESSING_STRATEGY_TYPE:RETRY_FAILED_AND_TIMED_OUT}" # SKIP_ALL_FAILURES, SKIP_ALL_FAILURES_AND_TIMED_OUT, RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + # For RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + retries: "${TB_QUEUE_RE_HP_PROCESSING_STRATEGY_RETRIES:0}" # Number of retries, 0 is unlimited + failure-percentage: "${TB_QUEUE_RE_HP_PROCESSING_STRATEGY_FAILURE_PERCENTAGE:0}" # Skip retry if failures or timeouts are less then X percentage of messages; + pause-between-retries: "${TB_QUEUE_RE_HP_PROCESSING_STRATEGY_RETRY_PAUSE:5}" # Time in seconds to wait in consumer thread before retries; + max-pause-between-retries: "${TB_QUEUE_RE_HP_PROCESSING_STRATEGY_MAX_RETRY_PAUSE:5}" # Max allowed time in seconds for pause between retries. + - name: "${TB_QUEUE_RE_SQ_QUEUE_NAME:SequentialByOriginator}" + topic: "${TB_QUEUE_RE_SQ_TOPIC:tb_rule_engine.sq}" + poll-interval: "${TB_QUEUE_RE_SQ_POLL_INTERVAL_MS:25}" + partitions: "${TB_QUEUE_RE_SQ_PARTITIONS:10}" + consumer-per-partition: "${TB_QUEUE_RE_SQ_CONSUMER_PER_PARTITION:true}" + pack-processing-timeout: "${TB_QUEUE_RE_SQ_PACK_PROCESSING_TIMEOUT_MS:2000}" + submit-strategy: + type: "${TB_QUEUE_RE_SQ_SUBMIT_STRATEGY_TYPE:SEQUENTIAL_BY_ORIGINATOR}" # BURST, BATCH, SEQUENTIAL_BY_ORIGINATOR, SEQUENTIAL_BY_TENANT, SEQUENTIAL + # For BATCH only + batch-size: "${TB_QUEUE_RE_SQ_SUBMIT_STRATEGY_BATCH_SIZE:100}" # Maximum number of messages in batch + processing-strategy: + type: "${TB_QUEUE_RE_SQ_PROCESSING_STRATEGY_TYPE:RETRY_FAILED_AND_TIMED_OUT}" # SKIP_ALL_FAILURES, SKIP_ALL_FAILURES_AND_TIMED_OUT, RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + # For RETRY_ALL, RETRY_FAILED, RETRY_TIMED_OUT, RETRY_FAILED_AND_TIMED_OUT + retries: "${TB_QUEUE_RE_SQ_PROCESSING_STRATEGY_RETRIES:3}" # Number of retries, 0 is unlimited + failure-percentage: "${TB_QUEUE_RE_SQ_PROCESSING_STRATEGY_FAILURE_PERCENTAGE:0}" # Skip retry if failures or timeouts are less then X percentage of messages; + pause-between-retries: "${TB_QUEUE_RE_SQ_PROCESSING_STRATEGY_RETRY_PAUSE:5}" # Time in seconds to wait in consumer thread before retries; + max-pause-between-retries: "${TB_QUEUE_RE_SQ_PROCESSING_STRATEGY_MAX_RETRY_PAUSE:5}" # Max allowed time in seconds for pause between retries. + transport: + # For high priority notifications that require minimum latency and processing time + notifications_topic: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_TOPIC:tb_transport.notifications}" + poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + +event: + debug: + max-symbols: "${TB_MAX_DEBUG_EVENT_SYMBOLS:4096}" + +service: + type: "${TB_SERVICE_TYPE:monolith}" # monolith or tb-core or tb-rule-engine + # Unique id for this service (autogenerated if empty) + id: "${TB_SERVICE_ID:}" + +metrics: + # Enable/disable actuator metrics. + enabled: "${METRICS_ENABLED:false}" + timer: + # Metrics percentiles returned by actuator for timer metrics. List of double values (divided by ,). + percentiles: "${METRICS_TIMER_PERCENTILES:0.5}" + +vc: + # Pool size for handling export tasks + thread_pool_size: "${TB_VC_POOL_SIZE:2}" + git: + # Pool size for handling the git IO operations + io_pool_size: "${TB_VC_GIT_POOL_SIZE:3}" + repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}" + +management: + endpoints: + web: + exposure: + # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). + include: '${METRICS_ENDPOINTS_EXPOSE:info}' diff --git a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java new file mode 100644 index 0000000..850e048 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.common.util.LinkedHashMapRemoveEldest; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.device.DeviceService; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; + +public class DeviceActorMessageProcessorTest { + + public static final long MAX_CONCURRENT_SESSIONS_PER_DEVICE = 10L; + ActorSystemContext systemContext; + DeviceService deviceService; + TenantId tenantId = TenantId.SYS_TENANT_ID; + DeviceId deviceId = DeviceId.fromString("78bf9b26-74ef-4af2-9cfb-ad6cf24ad2ec"); + + DeviceActorMessageProcessor processor; + + @Before + public void setUp() { + systemContext = mock(ActorSystemContext.class); + deviceService = mock(DeviceService.class); + willReturn(MAX_CONCURRENT_SESSIONS_PER_DEVICE).given(systemContext).getMaxConcurrentSessionsPerDevice(); + willReturn(deviceService).given(systemContext).getDeviceService(); + processor = new DeviceActorMessageProcessor(systemContext, tenantId, deviceId); + } + + @Test + public void givenSystemContext_whenNewInstance_thenVerifySessionMapMaxSize() { + assertThat(processor.sessions, instanceOf(LinkedHashMapRemoveEldest.class)); + assertThat(processor.sessions.getMaxEntries(), is(MAX_CONCURRENT_SESSIONS_PER_DEVICE)); + assertThat(processor.sessions.getRemovalConsumer(), notNullValue()); + } +} \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java new file mode 100644 index 0000000..d4d9456 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.stats; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.event.Event; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class StatsActorTest { + + StatsActor statsActor; + ActorSystemContext actorSystemContext; + EventService eventService; + TbServiceInfoProvider serviceInfoProvider; + + @BeforeEach + void setUp() { + actorSystemContext = mock(ActorSystemContext.class); + + eventService = mock(EventService.class); + willReturn(eventService).given(actorSystemContext).getEventService(); + serviceInfoProvider = mock(TbServiceInfoProvider.class); + willReturn(serviceInfoProvider).given(actorSystemContext).getServiceInfoProvider(); + + statsActor = new StatsActor(actorSystemContext); + } + + @Test + void givenEmptyStatMessage_whenOnStatsPersistMsg_thenNoAction() { + StatsPersistMsg emptyStats = new StatsPersistMsg(0, 0, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); + statsActor.onStatsPersistMsg(emptyStats); + verify(actorSystemContext, never()).getEventService(); + } + + @Test + void givenNonEmptyStatMessage_whenOnStatsPersistMsg_thenNoAction() { + statsActor.onStatsPersistMsg(new StatsPersistMsg(0, 1, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID)); + verify(eventService, times(1)).saveAsync(any(Event.class)); + statsActor.onStatsPersistMsg(new StatsPersistMsg(1, 0, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID)); + verify(eventService, times(2)).saveAsync(any(Event.class)); + statsActor.onStatsPersistMsg(new StatsPersistMsg(1, 1, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID)); + verify(eventService, times(3)).saveAsync(any(Event.class)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java new file mode 100644 index 0000000..0b9bf49 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.stats; + +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.id.TenantId; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatsPersistMsgTest { + + @Test + void testIsEmpty() { + StatsPersistMsg emptyStats = new StatsPersistMsg(0, 0, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); + assertThat(emptyStats.isEmpty()).isTrue(); + } + + @Test + void testNotEmpty() { + assertThat(new StatsPersistMsg(1, 0, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isEmpty()).isFalse(); + assertThat(new StatsPersistMsg(0, 1, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isEmpty()).isFalse(); + assertThat(new StatsPersistMsg(1, 1, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isEmpty()).isFalse(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java new file mode 100644 index 0000000..9893f08 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache; + +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootContextLoader; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CaffeineCacheDefaultConfigurationTest.class, loader = SpringBootContextLoader.class) +@ComponentScan({"org.thingsboard.server.cache"}) +@EnableConfigurationProperties +@Slf4j +public class CaffeineCacheDefaultConfigurationTest { + + @Autowired + CacheSpecsMap cacheSpecsMap; + + @Test + public void verifyTransactionAwareCacheManagerProxy() { + assertThat(cacheSpecsMap.getSpecs()).as("specs").isNotNull(); + cacheSpecsMap.getSpecs().forEach((name, cacheSpecs)->assertThat(cacheSpecs).as("cache %s specs", name).isNotNull()); + + SoftAssertions softly = new SoftAssertions(); + cacheSpecsMap.getSpecs().forEach((name, cacheSpecs)->{ + softly.assertThat(name).as("cache name").isNotEmpty(); + softly.assertThat(cacheSpecs.getTimeToLiveInMinutes()).as("cache %s time to live", name).isGreaterThan(0); + softly.assertThat(cacheSpecs.getMaxSize()).as("cache %s max size", name).isGreaterThan(0); + }); + softly.assertAll(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java new file mode 100644 index 0000000..ed8d863 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootContextLoader; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.socket.config.annotation.EnableWebSocket; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = AbstractControllerTest.class, loader = SpringBootContextLoader.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Configuration +@ComponentScan({"org.thingsboard.server"}) +@EnableWebSocket +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Slf4j +public abstract class AbstractControllerTest extends AbstractNotifyEntityTest { + + public static final String WS_URL = "ws://localhost:"; + + @LocalServerPort + protected int wsPort; + + private TbTestWebSocketClient wsClient; // lazy + + public TbTestWebSocketClient getWsClient() { + if (wsClient == null) { + synchronized (this) { + try { + if (wsClient == null) { + wsClient = buildAndConnectWebSocketClient(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + return wsClient; + } + + @Before + public void beforeWsTest() throws Exception { + // placeholder + } + + @After + public void afterWsTest() throws Exception { + if (wsClient != null) { + wsClient.close(); + } + } + + private TbTestWebSocketClient buildAndConnectWebSocketClient() throws URISyntaxException, InterruptedException { + TbTestWebSocketClient wsClient = new TbTestWebSocketClient(new URI(WS_URL + wsPort + "/api/ws/plugins/telemetry?token=" + token)); + assertThat(wsClient.connectBlocking(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + return wsClient; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java new file mode 100644 index 0000000..efa9d9f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.thingsboard.server.queue.memory.InMemoryStorage; + +@Slf4j +public abstract class AbstractInMemoryStorageTest { + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java new file mode 100644 index 0000000..8de7157 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -0,0 +1,621 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.extern.slf4j.Slf4j; +import org.mockito.ArgumentMatcher; +import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.thingsboard.server.service.entitiy.DefaultTbNotificationEntityService.edgeTypeByActionType; + +@Slf4j +public abstract class AbstractNotifyEntityTest extends AbstractWebTest { + + @SpyBean + protected TbClusterService tbClusterService; + + @SpyBean + protected AuditLogService auditLogService; + + protected final String msgErrorPermission = "You don't have permission to perform this operation!"; + protected final String msgErrorShouldBeSpecified = "should be specified"; + protected final String msgErrorNotFound = "Requested item wasn't found!"; + + + protected void testNotifyEntityAllOneTime(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionType, cntTime); + testLogEntityAction(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityAllOneTimeRelation(EntityRelation relation, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + Mockito.verify(tbClusterService, times(cntTime)).sendNotificationMsgToEdge(Mockito.eq(tenantId), + Mockito.isNull(), Mockito.isNull(), Mockito.any(), Mockito.eq(EdgeEventType.RELATION), + Mockito.eq(edgeTypeByActionType(actionType))); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(relation.getTo()); + ArgumentMatcher matcherEntityClassEquals = Objects::isNull; + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + extractMatcherAdditionalInfo(additionalInfo)); + matcherOriginatorId = argument -> argument.equals(relation.getFrom()); + testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + extractMatcherAdditionalInfo(additionalInfo)); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityAllManyRelation(EntityRelation relation, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, int cntTime) { + Mockito.verify(tbClusterService, times(cntTime)).sendNotificationMsgToEdge(Mockito.eq(tenantId), + Mockito.isNull(), Mockito.isNull(), Mockito.any(), Mockito.eq(EdgeEventType.RELATION), + Mockito.eq(edgeTypeByActionType(actionType))); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(relation.getFrom().getClass()); + ArgumentMatcher matcherEntityClassEquals = Objects::isNull; + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, + userName, actionType, cntTime * 2, 1); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, new Tenant(), cntTime * 3); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionType, cntTime); + testLogEntityActionEntityEqClass(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityNeverMsgToEdgeServiceOneTime(HasName entity, EntityId entityId, TenantId tenantId, + ActionType actionType) { + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionType, 1); + testLogEntityActionNever(entityId, entity); + testPushMsgToRuleEngineNever(entityId); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityOneTimeMsgToEdgeServiceNever(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, + String userName, ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType); + testLogEntityAction(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyManyEntityManyTimeMsgToEdgeServiceNever(HasName entity, HasName originator, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, int cntTime, Object... additionalInfo) { + EntityId entityId = createEntityId_NULL_UUID(entity); + EntityId originatorId = createEntityId_NULL_UUID(originator); + testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType); + ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + extractMatcherAdditionalInfo(additionalInfo)); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(HasName entity, HasName originator, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, ActionType actionTypeEdge, + int cntTime, int cntTimeEdge, int cntTimeRuleEngine, Object... additionalInfo) { + EntityId originatorId = createEntityId_NULL_UUID(originator); + testSendNotificationMsgToEdgeServiceTimeEntityEqAny(tenantId, actionTypeEdge, cntTimeEdge); + ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + extractMatcherAdditionalInfoClass(additionalInfo)); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTimeRuleEngine); + } + + protected void testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(HasName entity, HasName originator, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, ActionType actionTypeEdge, int cntTime, int cntTimeEdge, int cntAdditionalInfo) { + EntityId originatorId = createEntityId_NULL_UUID(originator); + testSendNotificationMsgToEdgeServiceTimeEntityEqAny(tenantId, actionTypeEdge, cntTimeEdge); + ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + cntAdditionalInfo); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTimeEdge); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyManyEntityManyTimeMsgToEdgeServiceNeverAdditionalInfoAny(HasName entity, HasName originator, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, int cntTime, int cntAdditionalInfo) { + EntityId entityId = createEntityId_NULL_UUID(entity); + EntityId originatorId = createEntityId_NULL_UUID(originator); + testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType); + ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + cntAdditionalInfo); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityBroadcastEntityStateChangeEventOneTime(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionType, cntTime); + testLogEntityAction(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + testBroadcastEntityStateChangeEventTime(entityId, tenantId, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType); + testLogEntityAction(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + testBroadcastEntityStateChangeEventTime(entityId, tenantId, cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityBroadcastEntityStateChangeEventMany(HasName entity, HasName originator, + TenantId tenantId, CustomerId customerId, + UserId userId, String userName, ActionType actionType, + ActionType actionTypeEdge, + int cntTime, int cntTimeEdge, int cntTimeRuleEngine, + int cntAdditionalInfo) { + EntityId entityId = createEntityId_NULL_UUID(entity); + EntityId originatorId = createEntityId_NULL_UUID(originator); + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionTypeEdge, cntTimeEdge); + ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + cntAdditionalInfo); + testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTimeRuleEngine); + testBroadcastEntityStateChangeEventTime(entityId, tenantId, cntTime); + } + + protected void testNotifyEntityMsgToEdgePushMsgToCoreOneTime(HasName entity, EntityId entityId, EntityId originatorId, + TenantId tenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, Object... additionalInfo) { + int cntTime = 1; + testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionType, cntTime); + testLogEntityAction(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + tesPushMsgToCoreTime(cntTime); + Mockito.reset(tbClusterService, auditLogService); + } + + protected void testNotifyEntityEqualsOneTimeServiceNeverError(HasName entity, TenantId tenantId, + UserId userId, String userName, ActionType actionType, Exception exp, + Object... additionalInfo) { + CustomerId customer_NULL_UUID = (CustomerId) EntityIdFactory.getByTypeAndUuid(EntityType.CUSTOMER, ModelConstants.NULL_UUID); + EntityId entity_originator_NULL_UUID = createEntityId_NULL_UUID(entity); + testNotificationMsgToEdgeServiceNeverWithActionType(entity_originator_NULL_UUID, actionType); + ArgumentMatcher matcherEntityEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherError = argument -> argument.getMessage().contains(exp.getMessage()) + & argument.getClass().equals(exp.getClass()); + testLogEntityActionErrorAdditionalInfo(matcherEntityEquals, entity_originator_NULL_UUID, tenantId, customer_NULL_UUID, userId, + userName, actionType, 1, matcherError, extractMatcherAdditionalInfo(additionalInfo)); + testPushMsgToRuleEngineNever(entity_originator_NULL_UUID); + } + + protected void testNotifyEntityIsNullOneTimeEdgeServiceNeverError(HasName entity, TenantId tenantId, + UserId userId, String userName, ActionType actionType, Exception exp, + Object... additionalInfo) { + CustomerId customer_NULL_UUID = (CustomerId) EntityIdFactory.getByTypeAndUuid(EntityType.CUSTOMER, ModelConstants.NULL_UUID); + EntityId entity_originator_NULL_UUID = createEntityId_NULL_UUID(entity); + testNotificationMsgToEdgeServiceNeverWithActionType(entity_originator_NULL_UUID, actionType); + ArgumentMatcher matcherEntityIsNull = Objects::isNull; + ArgumentMatcher matcherError = argument -> argument.getMessage().contains(exp.getMessage()) & + argument.getClass().equals(exp.getClass()); + testLogEntityActionErrorAdditionalInfo(matcherEntityIsNull, entity_originator_NULL_UUID, tenantId, customer_NULL_UUID, + userId, userName, actionType, 1, matcherError, extractMatcherAdditionalInfo(additionalInfo)); + testPushMsgToRuleEngineNever(entity_originator_NULL_UUID); + } + + protected void testNotifyEntityNever(EntityId entityId, HasName entity) { + entityId = entityId == null ? createEntityId_NULL_UUID(entity) : entityId; + testNotificationMsgToEdgeServiceNever(entityId); + testLogEntityActionNever(entityId, entity); + testPushMsgToRuleEngineNever(entityId); + Mockito.reset(tbClusterService, auditLogService); + } + + private void testNotificationMsgToEdgeServiceNeverWithActionType(EntityId entityId, ActionType actionType) { + EdgeEventActionType edgeEventActionType = ActionType.CREDENTIALS_UPDATED.equals(actionType) ? + EdgeEventActionType.CREDENTIALS_UPDATED : edgeTypeByActionType(actionType); + Mockito.verify(tbClusterService, never()).sendNotificationMsgToEdge(Mockito.any(), + Mockito.any(), Mockito.any(entityId.getClass()), Mockito.any(), Mockito.any(), Mockito.eq(edgeEventActionType)); + } + + private void testNotificationMsgToEdgeServiceNever(EntityId entityId) { + Mockito.verify(tbClusterService, never()).sendNotificationMsgToEdge(Mockito.any(), + Mockito.any(), Mockito.any(entityId.getClass()), Mockito.any(), Mockito.any(), Mockito.any()); + } + + private void testLogEntityActionNever(EntityId entityId, HasName entity) { + ArgumentMatcher matcherEntity = entity == null ? Objects::isNull : + argument -> argument.getClass().equals(entity.getClass()); + Mockito.verify(auditLogService, never()).logEntityAction(Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(entityId.getClass()), Mockito.argThat(matcherEntity), + Mockito.any(), Mockito.any()); + } + + private void testPushMsgToRuleEngineNever(EntityId entityId) { + Mockito.verify(tbClusterService, never()).pushMsgToRuleEngine(Mockito.any(), + Mockito.any(entityId.getClass()), Mockito.any(), Mockito.any()); + } + + protected void testBroadcastEntityStateChangeEventNever(EntityId entityId) { + Mockito.verify(tbClusterService, never()).broadcastEntityStateChangeEvent(Mockito.any(), + Mockito.any(entityId.getClass()), Mockito.any(ComponentLifecycleEvent.class)); + } + + private void testPushMsgToRuleEngineTime(ArgumentMatcher matcherOriginatorId, TenantId tenantId, HasName entity, int cntTime) { + tenantId = tenantId.isNullUid() && ((HasTenantId) entity).getTenantId() != null ? ((HasTenantId) entity).getTenantId() : tenantId; + Mockito.verify(tbClusterService, times(cntTime)).pushMsgToRuleEngine(Mockito.eq(tenantId), + Mockito.argThat(matcherOriginatorId), Mockito.any(TbMsg.class), Mockito.isNull()); + } + + private void testNotificationMsgToEdgeServiceTime(EntityId entityId, TenantId tenantId, ActionType actionType, int cntTime) { + EdgeEventActionType edgeEventActionType = ActionType.CREDENTIALS_UPDATED.equals(actionType) ? + EdgeEventActionType.CREDENTIALS_UPDATED : edgeTypeByActionType(actionType); + ArgumentMatcher matcherEntityId = cntTime == 1 ? argument -> argument.equals(entityId) : + argument -> argument.getClass().equals(entityId.getClass()); + Mockito.verify(tbClusterService, times(cntTime)).sendNotificationMsgToEdge(Mockito.eq(tenantId), + Mockito.any(), Mockito.argThat(matcherEntityId), Mockito.any(), Mockito.isNull(), + Mockito.eq(edgeEventActionType)); + } + + private void testSendNotificationMsgToEdgeServiceTimeEntityEqAny(TenantId tenantId, ActionType actionType, int cntTime) { + Mockito.verify(tbClusterService, times(cntTime)).sendNotificationMsgToEdge(Mockito.eq(tenantId), + Mockito.any(), Mockito.any(EntityId.class), Mockito.any(), Mockito.isNull(), + Mockito.eq(edgeTypeByActionType(actionType))); + } + + protected void testBroadcastEntityStateChangeEventTime(EntityId entityId, TenantId tenantId, int cntTime) { + ArgumentMatcher matcherTenantIdId = cntTime > 1 || tenantId == null ? argument -> argument.getClass().equals(TenantId.class) : + argument -> argument.equals(tenantId) ; + Mockito.verify(tbClusterService, times(cntTime)).broadcastEntityStateChangeEvent(Mockito.argThat(matcherTenantIdId), + Mockito.any(entityId.getClass()), Mockito.any(ComponentLifecycleEvent.class)); + } + + private void tesPushMsgToCoreTime(int cntTime) { + Mockito.verify(tbClusterService, times(cntTime)).pushMsgToCore(Mockito.any(ToDeviceActorNotificationMsg.class), Mockito.isNull()); + } + + private void testLogEntityAction(HasName entity, EntityId originatorId, TenantId tenantId, + CustomerId customerId, UserId userId, String userName, + ActionType actionType, int cntTime, Object... additionalInfo) { + ArgumentMatcher matcherEntityEquals = entity == null ? Objects::isNull : argument -> argument.toString().equals(entity.toString()); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfo(matcherEntityEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, + actionType, cntTime, extractMatcherAdditionalInfo(additionalInfo)); + } + + private void testLogEntityActionEntityEqClass(HasName entity, EntityId originatorId, TenantId tenantId, + CustomerId customerId, UserId userId, String userName, + ActionType actionType, int cntTime, Object... additionalInfo) { + ArgumentMatcher matcherEntityEquals = argument -> argument.getClass().equals(entity.getClass()); + ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); + ArgumentMatcher matcherCustomerId = customerId == null ? + argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherUserId = userId == null ? + argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); + testLogEntityActionAdditionalInfo(matcherEntityEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, + actionType, cntTime, extractMatcherAdditionalInfo(additionalInfo)); + } + + private void testLogEntityActionAdditionalInfo(ArgumentMatcher matcherEntity, ArgumentMatcher matcherOriginatorId, + TenantId tenantId, ArgumentMatcher matcherCustomerId, + ArgumentMatcher matcherUserId, String userName, ActionType actionType, + int cntTime, List> matcherAdditionalInfos) { + switch (matcherAdditionalInfos.size()) { + case 1: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.argThat(matcherAdditionalInfos.get(0))); + break; + case 2: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.argThat(matcherAdditionalInfos.get(0)), + Mockito.argThat(matcherAdditionalInfos.get(1))); + break; + case 3: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.argThat(matcherAdditionalInfos.get(0)), + Mockito.argThat(matcherAdditionalInfos.get(1)), + Mockito.argThat(matcherAdditionalInfos.get(2))); + break; + default: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull()); + } + } + + private void testLogEntityActionAdditionalInfoAny(ArgumentMatcher matcherEntity, ArgumentMatcher matcherOriginatorId, + TenantId tenantId, ArgumentMatcher matcherCustomerId, + ArgumentMatcher matcherUserId, String userName, + ActionType actionType, int cntTime, int cntAdditionalInfo) { + switch (cntAdditionalInfo) { + case 1: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.any()); + break; + case 2: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.any(), + Mockito.any()); + break; + case 3: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + break; + default: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.argThat(matcherCustomerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.argThat(matcherOriginatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.isNull()); + } + } + + private void testLogEntityActionErrorAdditionalInfo(ArgumentMatcher matcherEntity, EntityId originatorId, TenantId tenantId, + CustomerId customerId, UserId userId, String userName, ActionType actionType, + int cntTime, ArgumentMatcher matcherError, + List> matcherAdditionalInfos) { + ArgumentMatcher matcherUserId = userId == null ? argument -> argument.getClass().equals(UserId.class) : + argument -> argument.equals(userId); + switch (matcherAdditionalInfos.size()) { + case 1: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.eq(customerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.eq(originatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.argThat(matcherError), + Mockito.argThat(matcherAdditionalInfos.get(0))); + break; + case 2: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.eq(customerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.eq(originatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.argThat(matcherError), + Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(0))), + Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(1)))); + case 3: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.eq(customerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.eq(originatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.argThat(matcherError), + Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(0))), + Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(1))), + Mockito.argThat(Mockito.eq(matcherAdditionalInfos.get(2)))); + break; + default: + Mockito.verify(auditLogService, times(cntTime)) + .logEntityAction(Mockito.eq(tenantId), + Mockito.eq(customerId), + Mockito.argThat(matcherUserId), + Mockito.eq(userName), + Mockito.eq(originatorId), + Mockito.argThat(matcherEntity), + Mockito.eq(actionType), + Mockito.argThat(matcherError)); + } + } + + private List> extractMatcherAdditionalInfo(Object... additionalInfos) { + List> matcherAdditionalInfos = new ArrayList<>(additionalInfos.length); + for (Object additionalInfo : additionalInfos) { + matcherAdditionalInfos.add(argument -> argument.equals(extractParameter(additionalInfo.getClass(), additionalInfo))); + } + return matcherAdditionalInfos; + } + + private List> extractMatcherAdditionalInfoClass(Object... additionalInfos) { + List> matcherAdditionalInfos = new ArrayList<>(additionalInfos.length); + for (Object additionalInfo : additionalInfos) { + matcherAdditionalInfos.add(argument -> argument.getClass().equals(extractParameter(additionalInfo.getClass(), additionalInfo).getClass())); + } + return matcherAdditionalInfos; + } + + private T extractParameter(Class clazz, Object additionalInfo) { + T result = null; + if (additionalInfo != null) { + Object paramObject = additionalInfo; + if (clazz.isInstance(paramObject)) { + result = clazz.cast(paramObject); + } + } + return result; + } + + protected EntityId createEntityId_NULL_UUID(HasName entity) { + return EntityIdFactory.getByTypeAndUuid(entityClassToEntityTypeName(entity), ModelConstants.NULL_UUID); + } + + protected String msgErrorFieldLength(String fieldName) { + return fieldName + " length must be equal or less than 255"; + } + + protected String msgErrorNoFound(String entityClassName, String assetIdStr) { + return entityClassName + " with id [" + assetIdStr + "] is not found"; + } + + private String entityClassToEntityTypeName(HasName entity) { + String entityType = entityClassToString(entity); + return "SAVE_OTA_PACKAGE_INFO_REQUEST".equals(entityType) || "OTA_PACKAGE_INFO".equals(entityType)? + EntityType.OTA_PACKAGE.name().toUpperCase(Locale.ENGLISH) : entityType; + } + + private String entityClassToString(HasName entity) { + String className = entity.getClass().toString() + .substring(entity.getClass().toString().lastIndexOf(".") + 1); + List str = className.chars() + .mapToObj(x -> (Character.isUpperCase(x)) ? "_" + Character.toString(x) : Character.toString(x)) + .collect(Collectors.toList()); + return String.join("", str).toUpperCase(Locale.ENGLISH).substring(1); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java new file mode 100644 index 0000000..a3edae8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +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.rule.RuleChainService; + +import java.io.IOException; +import java.util.function.Predicate; + +/** + * Created by ashvayka on 20.03.18. + */ +@TestPropertySource(properties = { + "js.evaluator=mock", +}) +public abstract class AbstractRuleEngineControllerTest extends AbstractControllerTest { + + @Autowired + protected RuleChainService ruleChainService; + + protected RuleChain saveRuleChain(RuleChain ruleChain) throws Exception { + return doPost("/api/ruleChain", ruleChain, RuleChain.class); + } + + protected RuleChain getRuleChain(RuleChainId ruleChainId) throws Exception { + return doGet("/api/ruleChain/" + ruleChainId.getId().toString(), RuleChain.class); + } + + protected RuleChainMetaData saveRuleChainMetaData(RuleChainMetaData ruleChainMD) throws Exception { + return doPost("/api/ruleChain/metadata", ruleChainMD, RuleChainMetaData.class); + } + + protected RuleChainMetaData getRuleChainMetaData(RuleChainId ruleChainId) throws Exception { + return doGet("/api/ruleChain/metadata/" + ruleChainId.getId().toString(), RuleChainMetaData.class); + } + + protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { + return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE.getOldName(), limit); + } + + protected PageData getEvents(TenantId tenantId, EntityId entityId, String eventType, int limit) throws Exception { + TimePageLink pageLink = new TimePageLink(limit); + return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", + new TypeReference>() { + }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); + } + + + protected JsonNode getMetadata(EventInfo outEvent) { + String metaDataStr = outEvent.getBody().get("metadata").asText(); + try { + return mapper.readTree(metaDataStr); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + protected Predicate filterByCustomEvent() { + return event -> event.getBody().get("msgType").textValue().equals("CUSTOM"); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java new file mode 100644 index 0000000..9ea2699 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -0,0 +1,778 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwt; +import io.jsonwebtoken.Jwts; +import lombok.extern.slf4j.Slf4j; +import org.hamcrest.Matcher; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.mock.http.MockHttpInputMessage; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.context.WebApplicationContext; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.MqttTopics; +import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; +import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.config.ThingsboardSecurityConfiguration; +import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.service.mail.TestMailService; +import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; +import org.thingsboard.server.service.security.auth.rest.LoginRequest; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@Slf4j +public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { + public static final int TIMEOUT = 30; + + protected ObjectMapper mapper = new ObjectMapper(); + + protected static final String TEST_TENANT_NAME = "TEST TENANT"; + protected static final String TEST_DIFFERENT_TENANT_NAME = "TEST DIFFERENT TENANT"; + + protected static final String SYS_ADMIN_EMAIL = "sysadmin@thingsboard.org"; + private static final String SYS_ADMIN_PASSWORD = "sysadmin"; + + protected static final String TENANT_ADMIN_EMAIL = "testtenant@thingsboard.org"; + protected static final String TENANT_ADMIN_PASSWORD = "tenant"; + + protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org"; + private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; + + protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; + private static final String CUSTOMER_USER_PASSWORD = "customer"; + + protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; + private static final String DIFFERENT_CUSTOMER_USER_PASSWORD = "diffcustomer"; + + /** + * See {@link org.springframework.test.web.servlet.DefaultMvcResult#getAsyncResult(long)} + * and {@link org.springframework.mock.web.MockAsyncContext#getTimeout()} + */ + private static final long DEFAULT_TIMEOUT = -1L; + + protected MediaType contentType = MediaType.APPLICATION_JSON; + + protected MockMvc mockMvc; + + protected String token; + protected String refreshToken; + protected String username; + + protected TenantId tenantId; + protected UserId tenantAdminUserId; + protected CustomerId tenantAdminCustomerId; + protected CustomerId customerId; + protected TenantId differentTenantId; + protected CustomerId differentCustomerId; + protected UserId customerUserId; + + @SuppressWarnings("rawtypes") + private HttpMessageConverter mappingJackson2HttpMessageConverter; + + @SuppressWarnings("rawtypes") + private HttpMessageConverter stringHttpMessageConverter; + + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private TenantProfileService tenantProfileService; + + @Rule + public TestRule watcher = new TestWatcher() { + protected void starting(Description description) { + log.info("Starting test: {}", description.getMethodName()); + } + + protected void finished(Description description) { + log.info("Finished test: {}", description.getMethodName()); + } + }; + + @Autowired + void setConverters(HttpMessageConverter[] converters) { + + this.mappingJackson2HttpMessageConverter = Arrays.stream(converters) + .filter(hmc -> hmc instanceof MappingJackson2HttpMessageConverter) + .findAny() + .get(); + + this.stringHttpMessageConverter = Arrays.stream(converters) + .filter(hmc -> hmc instanceof StringHttpMessageConverter) + .findAny() + .get(); + + Assert.assertNotNull("the JSON message converter must not be null", + this.mappingJackson2HttpMessageConverter); + } + + @Before + public void setupWebTest() throws Exception { + log.info("Executing web test setup"); + + if (this.mockMvc == null) { + this.mockMvc = webAppContextSetup(webApplicationContext) + .apply(springSecurity()).build(); + } + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle(TEST_TENANT_NAME); + Tenant savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + tenantId = savedTenant.getId(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(tenantId); + tenantAdmin.setEmail(TENANT_ADMIN_EMAIL); + + tenantAdmin = createUserAndLogin(tenantAdmin, TENANT_ADMIN_PASSWORD); + tenantAdminUserId = tenantAdmin.getId(); + tenantAdminCustomerId = tenantAdmin.getCustomerId(); + + Customer customer = new Customer(); + customer.setTitle("Customer"); + customer.setTenantId(tenantId); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + customerId = savedCustomer.getId(); + + User customerUser = new User(); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(tenantId); + customerUser.setCustomerId(savedCustomer.getId()); + customerUser.setEmail(CUSTOMER_USER_EMAIL); + + customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); + customerUserId = customerUser.getId(); + + resetTokens(); + + log.info("Executed web test setup"); + } + + @After + public void teardownWebTest() throws Exception { + log.info("Executing web test teardown"); + + loginSysAdmin(); + doDelete("/api/tenant/" + tenantId.getId().toString()) + .andExpect(status().isOk()); + deleteDifferentTenant(); + + verifyNoTenantsLeft(); + + tenantProfileService.deleteTenantProfiles(TenantId.SYS_TENANT_ID); + + log.info("Executed web test teardown"); + } + + void verifyNoTenantsLeft() throws Exception { + List loadedTenants = new ArrayList<>(); + PageLink pageLink = new PageLink(10); + PageData pageData; + do { + pageData = doGetTypedWithPageLink("/api/tenants?", new TypeReference>() { + }, pageLink); + loadedTenants.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(loadedTenants).as("All tenants expected to be deleted, but some tenants left in the database").isEmpty(); + } + + protected void loginSysAdmin() throws Exception { + login(SYS_ADMIN_EMAIL, SYS_ADMIN_PASSWORD); + } + + protected void loginTenantAdmin() throws Exception { + login(TENANT_ADMIN_EMAIL, TENANT_ADMIN_PASSWORD); + } + + protected void loginCustomerUser() throws Exception { + login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); + } + + protected void loginUser(String userName, String password) throws Exception { + login(userName, password); + } + + protected Tenant savedDifferentTenant; + protected User savedDifferentTenantUser; + private Customer savedDifferentCustomer; + + protected void loginDifferentTenant() throws Exception { + if (savedDifferentTenant != null) { + login(DIFFERENT_TENANT_ADMIN_EMAIL, DIFFERENT_TENANT_ADMIN_PASSWORD); + } else { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle(TEST_DIFFERENT_TENANT_NAME); + savedDifferentTenant = doPost("/api/tenant", tenant, Tenant.class); + differentTenantId = savedDifferentTenant.getId(); + Assert.assertNotNull(savedDifferentTenant); + User differentTenantAdmin = new User(); + differentTenantAdmin.setAuthority(Authority.TENANT_ADMIN); + differentTenantAdmin.setTenantId(savedDifferentTenant.getId()); + differentTenantAdmin.setEmail(DIFFERENT_TENANT_ADMIN_EMAIL); + savedDifferentTenantUser = createUserAndLogin(differentTenantAdmin, DIFFERENT_TENANT_ADMIN_PASSWORD); + } + } + + protected void loginDifferentCustomer() throws Exception { + if (savedDifferentCustomer != null) { + login(savedDifferentCustomer.getEmail(), CUSTOMER_USER_PASSWORD); + } else { + createDifferentCustomer(); + + loginTenantAdmin(); + User differentCustomerUser = new User(); + differentCustomerUser.setAuthority(Authority.CUSTOMER_USER); + differentCustomerUser.setTenantId(tenantId); + differentCustomerUser.setCustomerId(savedDifferentCustomer.getId()); + differentCustomerUser.setEmail(DIFFERENT_CUSTOMER_USER_EMAIL); + + createUserAndLogin(differentCustomerUser, DIFFERENT_CUSTOMER_USER_PASSWORD); + } + } + + protected void createDifferentCustomer() throws Exception { + loginTenantAdmin(); + + Customer customer = new Customer(); + customer.setTitle("Different customer"); + savedDifferentCustomer = doPost("/api/customer", customer, Customer.class); + Assert.assertNotNull(savedDifferentCustomer); + differentCustomerId = savedDifferentCustomer.getId(); + + resetTokens(); + } + + protected void deleteDifferentTenant() throws Exception { + if (savedDifferentTenant != null) { + loginSysAdmin(); + doDelete("/api/tenant/" + savedDifferentTenant.getId().getId().toString()) + .andExpect(status().isOk()); + savedDifferentTenant = null; + } + } + + protected User createUserAndLogin(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + resetTokens(); + JsonNode activateRequest = getActivateRequest(password); + JsonNode tokenInfo = readResponse(doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenInfo, user.getEmail()); + return savedUser; + } + + protected User createUser(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + JsonNode activateRequest = getActivateRequest(password); + ResultActions resultActions = doPost("/api/noauth/activate", activateRequest); + resultActions.andExpect(status().isOk()); + return savedUser; + } + + private JsonNode getActivateRequest(String password) throws Exception { + doGet("/api/noauth/activate?activateToken={activateToken}", TestMailService.currentActivateToken) + .andExpect(status().isSeeOther()) + .andExpect(header().string(HttpHeaders.LOCATION, "/login/createPassword?activateToken=" + TestMailService.currentActivateToken)); + return new ObjectMapper().createObjectNode() + .put("activateToken", TestMailService.currentActivateToken) + .put("password", password); + } + + protected void login(String username, String password) throws Exception { + resetTokens(); + JsonNode tokenInfo = readResponse(doPost("/api/auth/login", new LoginRequest(username, password)).andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenInfo, username); + } + + protected void refreshToken() throws Exception { + this.token = null; + JsonNode tokenInfo = readResponse(doPost("/api/auth/token", new RefreshTokenRequest(this.refreshToken)).andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenInfo, this.username); + } + + protected void validateAndSetJwtToken(JsonNode tokenInfo, String username) { + Assert.assertNotNull(tokenInfo); + Assert.assertTrue(tokenInfo.has("token")); + Assert.assertTrue(tokenInfo.has("refreshToken")); + String token = tokenInfo.get("token").asText(); + String refreshToken = tokenInfo.get("refreshToken").asText(); + validateJwtToken(token, username); + validateJwtToken(refreshToken, username); + this.token = token; + this.refreshToken = refreshToken; + this.username = username; + } + + protected void validateJwtToken(String token, String username) { + Assert.assertNotNull(token); + Assert.assertFalse(token.isEmpty()); + int i = token.lastIndexOf('.'); + Assert.assertTrue(i > 0); + String withoutSignature = token.substring(0, i + 1); + Jwt jwsClaims = Jwts.parser().parseClaimsJwt(withoutSignature); + Claims claims = jwsClaims.getBody(); + String subject = claims.getSubject(); + Assert.assertEquals(username, subject); + } + + protected void resetTokens() throws Exception { + this.token = null; + this.refreshToken = null; + this.username = null; + } + + protected void logout() throws Exception { + doPost("/api/auth/logout").andExpect(status().isOk()); + } + + protected void setJwtToken(MockHttpServletRequestBuilder request) { + if (this.token != null) { + request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token); + } + } + + protected DeviceProfile createDeviceProfile(String name) { + return createDeviceProfile(name, null); + } + + protected DeviceProfile createDeviceProfile(String name, DeviceProfileTransportConfiguration deviceProfileTransportConfiguration) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName(name); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setDescription(name + " Test"); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); + deviceProfileData.setConfiguration(configuration); + if (deviceProfileTransportConfiguration != null) { + deviceProfile.setTransportType(deviceProfileTransportConfiguration.getType()); + deviceProfileData.setTransportConfiguration(deviceProfileTransportConfiguration); + } else { + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + deviceProfileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + } + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setDefault(false); + deviceProfile.setDefaultRuleChainId(null); + return deviceProfile; + } + + protected AssetProfile createAssetProfile(String name) { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setName(name); + assetProfile.setDescription(name + " Test"); + assetProfile.setDefault(false); + assetProfile.setDefaultRuleChainId(null); + return assetProfile; + } + + protected MqttDeviceProfileTransportConfiguration createMqttDeviceProfileTransportConfiguration(TransportPayloadTypeConfiguration transportPayloadTypeConfiguration, boolean sendAckOnValidationException) { + MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = new MqttDeviceProfileTransportConfiguration(); + mqttDeviceProfileTransportConfiguration.setDeviceTelemetryTopic(MqttTopics.DEVICE_TELEMETRY_TOPIC); + mqttDeviceProfileTransportConfiguration.setDeviceTelemetryTopic(MqttTopics.DEVICE_ATTRIBUTES_TOPIC); + mqttDeviceProfileTransportConfiguration.setSendAckOnValidationException(sendAckOnValidationException); + mqttDeviceProfileTransportConfiguration.setTransportPayloadTypeConfiguration(transportPayloadTypeConfiguration); + return mqttDeviceProfileTransportConfiguration; + } + + protected ProtoTransportPayloadConfiguration createProtoTransportPayloadConfiguration(String attributesProtoSchema, String telemetryProtoSchema, String rpcRequestProtoSchema, String rpcResponseProtoSchema) { + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = new ProtoTransportPayloadConfiguration(); + protoTransportPayloadConfiguration.setDeviceAttributesProtoSchema(attributesProtoSchema); + protoTransportPayloadConfiguration.setDeviceTelemetryProtoSchema(telemetryProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcRequestProtoSchema(rpcRequestProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcResponseProtoSchema(rpcResponseProtoSchema); + return protoTransportPayloadConfiguration; + } + + + protected ResultActions doGet(String urlTemplate, Object... urlVariables) throws Exception { + MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables); + setJwtToken(getRequest); + return mockMvc.perform(getRequest); + } + + protected T doGet(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { + return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); + } + + protected T doGet(String urlTemplate, Class responseClass, ResultMatcher resultMatcher, Object... urlVariables) throws Exception { + return readResponse(doGet(urlTemplate, urlVariables).andExpect(resultMatcher), responseClass); + } + + protected T doGetAsync(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { + return readResponse(doGetAsync(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); + } + + protected T doGetAsyncTyped(String urlTemplate, TypeReference responseType, Object... urlVariables) throws Exception { + return readResponse(doGetAsync(urlTemplate, urlVariables).andExpect(status().isOk()), responseType); + } + + protected ResultActions doGetAsync(String urlTemplate, Object... urlVariables) throws Exception { + MockHttpServletRequestBuilder getRequest; + getRequest = get(urlTemplate, urlVariables); + setJwtToken(getRequest); + return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn())); + } + + protected T doGetTyped(String urlTemplate, TypeReference responseType, Object... urlVariables) throws Exception { + return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType); + } + + protected T doGetTypedWithPageLink(String urlTemplate, TypeReference responseType, + PageLink pageLink, + Object... urlVariables) throws Exception { + List pageLinkVariables = new ArrayList<>(); + urlTemplate += "pageSize={pageSize}&page={page}"; + pageLinkVariables.add(pageLink.getPageSize()); + pageLinkVariables.add(pageLink.getPage()); + if (StringUtils.isNotEmpty(pageLink.getTextSearch())) { + urlTemplate += "&textSearch={textSearch}"; + pageLinkVariables.add(pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + urlTemplate += "&sortProperty={sortProperty}&sortOrder={sortOrder}"; + pageLinkVariables.add(pageLink.getSortOrder().getProperty()); + pageLinkVariables.add(pageLink.getSortOrder().getDirection().name()); + } + + Object[] vars = new Object[urlVariables.length + pageLinkVariables.size()]; + System.arraycopy(urlVariables, 0, vars, 0, urlVariables.length); + System.arraycopy(pageLinkVariables.toArray(), 0, vars, urlVariables.length, pageLinkVariables.size()); + + return readResponse(doGet(urlTemplate, vars).andExpect(status().isOk()), responseType); + } + + protected T doGetTypedWithTimePageLink(String urlTemplate, TypeReference responseType, + TimePageLink pageLink, + Object... urlVariables) throws Exception { + List pageLinkVariables = new ArrayList<>(); + urlTemplate += "pageSize={pageSize}&page={page}"; + pageLinkVariables.add(pageLink.getPageSize()); + pageLinkVariables.add(pageLink.getPage()); + if (pageLink.getStartTime() != null) { + urlTemplate += "&startTime={startTime}"; + pageLinkVariables.add(pageLink.getStartTime()); + } + if (pageLink.getEndTime() != null) { + urlTemplate += "&endTime={endTime}"; + pageLinkVariables.add(pageLink.getEndTime()); + } + if (StringUtils.isNotEmpty(pageLink.getTextSearch())) { + urlTemplate += "&textSearch={textSearch}"; + pageLinkVariables.add(pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + urlTemplate += "&sortProperty={sortProperty}&sortOrder={sortOrder}"; + pageLinkVariables.add(pageLink.getSortOrder().getProperty()); + pageLinkVariables.add(pageLink.getSortOrder().getDirection().name()); + } + Object[] vars = new Object[urlVariables.length + pageLinkVariables.size()]; + System.arraycopy(urlVariables, 0, vars, 0, urlVariables.length); + System.arraycopy(pageLinkVariables.toArray(), 0, vars, urlVariables.length, pageLinkVariables.size()); + + return readResponse(doGet(urlTemplate, vars).andExpect(status().isOk()), responseType); + } + + protected T doPost(String urlTemplate, Class responseClass, String... params) { + try { + return readResponse(doPost(urlTemplate, params).andExpect(status().isOk()), responseClass); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected T doPost(String urlTemplate, T content, Class responseClass, ResultMatcher resultMatcher, String... params) throws Exception { + return readResponse(doPost(urlTemplate, content, params).andExpect(resultMatcher), responseClass); + } + + protected T doPost(String urlTemplate, T content, Class responseClass, String... params) { + try { + return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected R doPostWithResponse(String urlTemplate, T content, Class responseClass, String... params) throws Exception { + return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass); + } + + protected R doPostWithTypedResponse(String urlTemplate, T content, TypeReference responseType, String... params) throws Exception { + return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseType); + } + + protected R doPostWithTypedResponse(String urlTemplate, T content, TypeReference responseType, ResultMatcher resultMatcher, String... params) throws Exception { + return readResponse(doPost(urlTemplate, content, params).andExpect(resultMatcher), responseType); + } + + protected T doPostAsync(String urlTemplate, T content, Class responseClass, ResultMatcher resultMatcher, String... params) throws Exception { + return readResponse(doPostAsync(urlTemplate, content, DEFAULT_TIMEOUT, params).andExpect(resultMatcher), responseClass); + } + + protected T doPostAsync(String urlTemplate, T content, Class responseClass, ResultMatcher resultMatcher, Long timeout, String... params) throws Exception { + return readResponse(doPostAsync(urlTemplate, content, timeout, params).andExpect(resultMatcher), responseClass); + } + + protected T doPostClaimAsync(String urlTemplate, Object content, Class responseClass, ResultMatcher resultMatcher, String... params) throws Exception { + return readResponse(doPostAsync(urlTemplate, content, DEFAULT_TIMEOUT, params).andExpect(resultMatcher), responseClass); + } + + protected T doDelete(String urlTemplate, Class responseClass, String... params) throws Exception { + return readResponse(doDelete(urlTemplate, params).andExpect(status().isOk()), responseClass); + } + + protected ResultActions doPost(String urlTemplate, String... params) throws Exception { + MockHttpServletRequestBuilder postRequest = post(urlTemplate); + setJwtToken(postRequest); + populateParams(postRequest, params); + return mockMvc.perform(postRequest); + } + + protected ResultActions doPost(String urlTemplate, T content, String... params) throws Exception { + MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); + setJwtToken(postRequest); + String json = json(content); + postRequest.contentType(contentType).content(json); + return mockMvc.perform(postRequest); + } + + protected ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception { + MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); + setJwtToken(postRequest); + String json = json(content); + postRequest.contentType(contentType).content(json); + MvcResult result = mockMvc.perform(postRequest).andReturn(); + result.getAsyncResult(timeout); + return mockMvc.perform(asyncDispatch(result)); + } + + protected ResultActions doDelete(String urlTemplate, String... params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate); + setJwtToken(deleteRequest); + populateParams(deleteRequest, params); + return mockMvc.perform(deleteRequest); + } + + protected void populateParams(MockHttpServletRequestBuilder request, String... params) { + if (params != null && params.length > 0) { + Assert.assertEquals(0, params.length % 2); + MultiValueMap paramsMap = new LinkedMultiValueMap<>(); + for (int i = 0; i < params.length; i += 2) { + paramsMap.add(params[i], params[i + 1]); + } + request.params(paramsMap); + } + } + + @SuppressWarnings("unchecked") + protected String json(Object o) throws IOException { + MockHttpOutputMessage mockHttpOutputMessage = new MockHttpOutputMessage(); + + HttpMessageConverter converter = o instanceof String ? stringHttpMessageConverter : mappingJackson2HttpMessageConverter; + converter.write(o, MediaType.APPLICATION_JSON, mockHttpOutputMessage); + return mockHttpOutputMessage.getBodyAsString(); + } + + @SuppressWarnings("unchecked") + protected T readResponse(ResultActions result, Class responseClass) throws Exception { + byte[] content = result.andReturn().getResponse().getContentAsByteArray(); + MockHttpInputMessage mockHttpInputMessage = new MockHttpInputMessage(content); + HttpMessageConverter converter = responseClass.equals(String.class) ? stringHttpMessageConverter : mappingJackson2HttpMessageConverter; + return (T) converter.read(responseClass, mockHttpInputMessage); + } + + protected T readResponse(ResultActions result, TypeReference type) throws Exception { + return readResponse(result.andReturn(), type); + } + + protected T readResponse(MvcResult result, TypeReference type) throws Exception { + byte[] content = result.getResponse().getContentAsByteArray(); + return mapper.readerFor(type).readValue(content); + } + + protected String getErrorMessage(ResultActions result) throws Exception { + return readResponse(result, JsonNode.class).get("message").asText(); + } + + public class IdComparator implements Comparator { + @Override + public int compare(D o1, D o2) { + return o1.getId().getId().compareTo(o2.getId().getId()); + } + } + + protected static ResultMatcher statusReason(Matcher matcher) { + return jsonPath("$.message", matcher); + } + + protected Edge constructEdge(String name, String type) { + return constructEdge(tenantId, name, type); + } + + protected Edge constructEdge(TenantId tenantId, String name, String type) { + Edge edge = new Edge(); + edge.setTenantId(tenantId); + edge.setName(name); + edge.setType(type); + edge.setSecret(StringUtils.randomAlphanumeric(20)); + edge.setRoutingKey(StringUtils.randomAlphanumeric(20)); + return edge; + } + + protected > ListenableFuture> deleteEntitiesAsync(String urlTemplate, List entities, ListeningExecutorService executor) { + List> futures = new ArrayList<>(entities.size()); + for (T entity : entities) { + futures.add(executor.submit(() -> + doDelete(urlTemplate + entity.getId().getId()) + .andExpect(status().isOk()))); + } + return Futures.allAsList(futures); + } + + protected void testEntityDaoWithRelationsOk(EntityId entityIdFrom, EntityId entityTo, String urlDelete) throws Exception { + createEntityRelation(entityIdFrom, entityTo, "TEST_TYPE"); + assertThat(findRelationsByTo(entityTo)).hasSize(1); + + doDelete(urlDelete).andExpect(status().isOk()); + + assertThat(findRelationsByTo(entityTo)).hasSize(0); + } + + protected void testEntityDaoWithRelationsTransactionalException(Dao dao, EntityId entityIdFrom, EntityId entityTo, + String urlDelete) throws Exception { + Mockito.doThrow(new ConstraintViolationException("mock message", new SQLException(), "MOCK_CONSTRAINT")).when(dao).removeById(any(), any()); + try { + createEntityRelation(entityIdFrom, entityTo, "TEST_TRANSACTIONAL_TYPE"); + assertThat(findRelationsByTo(entityTo)).hasSize(1); + + doDelete(urlDelete) + .andExpect(status().isInternalServerError()); + + assertThat(findRelationsByTo(entityTo)).hasSize(1); + } finally { + Mockito.reset(dao); + } + } + + protected void createEntityRelation(EntityId entityIdFrom, EntityId entityIdTo, String typeRelation) throws Exception { + EntityRelation relation = new EntityRelation(entityIdFrom, entityIdTo, typeRelation); + doPost("/api/relation", relation); + } + + protected List findRelationsByTo(EntityId entityId) throws Exception { + String url = String.format("/api/relations?toId=%s&toType=%s", entityId.getId(), entityId.getEntityType().name()); + MvcResult mvcResult = doGet(url).andReturn(); + + switch (mvcResult.getResponse().getStatus()) { + case 200: + return readResponse(mvcResult, new TypeReference<>() { + }); + case 404: + return Collections.emptyList(); + } + throw new AssertionError("Unexpected status " + mvcResult.getResponse().getStatus()); + } + + protected T getFieldValue(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return (T) field.get(target); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java new file mode 100644 index 0000000..285d647 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAdminControllerTest.java @@ -0,0 +1,194 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.security.model.JwtSettings; +import org.thingsboard.server.service.mail.DefaultMailService; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +public abstract class BaseAdminControllerTest extends AbstractControllerTest { + final JwtSettings defaultJwtSettings = new JwtSettings(9000, 604800, "thingsboard.io", "thingsboardDefaultSigningKey"); + + @Autowired + MailService mailService; + + @Autowired + DefaultMailService defaultMailService; + + @Test + public void testFindAdminSettingsByKey() throws Exception { + loginSysAdmin(); + doGet("/api/admin/settings/general") + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.key", is("general"))) + .andExpect(jsonPath("$.jsonValue.baseUrl", is("http://localhost:8080"))); + + doGet("/api/admin/settings/mail") + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.key", is("mail"))) + .andExpect(jsonPath("$.jsonValue.smtpProtocol", is("smtp"))) + .andExpect(jsonPath("$.jsonValue.smtpHost", is("localhost"))) + .andExpect(jsonPath("$.jsonValue.smtpPort", is("25"))); + + doGet("/api/admin/settings/unknown") + .andExpect(status().isNotFound()); + + } + + @Test + public void testSaveAdminSettings() throws Exception { + loginSysAdmin(); + AdminSettings adminSettings = doGet("/api/admin/settings/general", AdminSettings.class); + + JsonNode jsonValue = adminSettings.getJsonValue(); + ((ObjectNode) jsonValue).put("baseUrl", "http://myhost.org"); + adminSettings.setJsonValue(jsonValue); + + doPost("/api/admin/settings", adminSettings).andExpect(status().isOk()); + + doGet("/api/admin/settings/general") + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.jsonValue.baseUrl", is("http://myhost.org"))); + + ((ObjectNode) jsonValue).put("baseUrl", "http://localhost:8080"); + adminSettings.setJsonValue(jsonValue); + + doPost("/api/admin/settings", adminSettings) + .andExpect(status().isOk()); + } + + @Test + public void testSaveAdminSettingsWithEmptyKey() throws Exception { + loginSysAdmin(); + AdminSettings adminSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + adminSettings.setKey(null); + doPost("/api/admin/settings", adminSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Key should be specified"))); + } + + @Test + public void testChangeAdminSettingsKey() throws Exception { + loginSysAdmin(); + AdminSettings adminSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + adminSettings.setKey("newKey"); + doPost("/api/admin/settings", adminSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("is prohibited"))); + } + + @Test + public void testSendTestMail() throws Exception { + loginSysAdmin(); + AdminSettings adminSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + doPost("/api/admin/settings/testMail", adminSettings) + .andExpect(status().isOk()); + } + + @Test + public void testSendTestMailTimeout() throws Exception { + loginSysAdmin(); + AdminSettings adminSettings = doGet("/api/admin/settings/mail", AdminSettings.class); + ObjectNode objectNode = JacksonUtil.fromString(adminSettings.getJsonValue().toString(), ObjectNode.class); + + objectNode.put("smtpHost", "mail.gandi.net"); + objectNode.put("timeout", 1_000); + objectNode.put("username", "username"); + objectNode.put("password", "password"); + + adminSettings.setJsonValue(objectNode); + + Mockito.doAnswer((invocations) -> { + var jsonConfig = (JsonNode) invocations.getArgument(0); + var email = (String) invocations.getArgument(1); + + defaultMailService.sendTestMail(jsonConfig, email); + return null; + }).when(mailService).sendTestMail(Mockito.any(), Mockito.anyString()); + doPost("/api/admin/settings/testMail", adminSettings).andExpect(status().is5xxServerError()); + Mockito.doNothing().when(mailService).sendTestMail(Mockito.any(), Mockito.any()); + } + + void resetJwtSettingsToDefault() throws Exception { + loginSysAdmin(); + doPost("/api/admin/jwtSettings", defaultJwtSettings).andExpect(status().isOk()); // jwt test scenarios are always started from + loginTenantAdmin(); + } + + @Test + public void testGetAndSaveDefaultJwtSettings() throws Exception { + JwtSettings jwtSettings; + loginSysAdmin(); + + jwtSettings = doGet("/api/admin/jwtSettings", JwtSettings.class); + assertThat(jwtSettings).isEqualTo(defaultJwtSettings); + + doPost("/api/admin/jwtSettings", jwtSettings).andExpect(status().isOk()); + + jwtSettings = doGet("/api/admin/jwtSettings", JwtSettings.class); + assertThat(jwtSettings).isEqualTo(defaultJwtSettings); + + resetJwtSettingsToDefault(); + } + + @Test + public void testCreateJwtSettings() throws Exception { + loginSysAdmin(); + + JwtSettings jwtSettings = doGet("/api/admin/jwtSettings", JwtSettings.class); + assertThat(jwtSettings).isEqualTo(defaultJwtSettings); + + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString( + RandomStringUtils.randomAlphanumeric(256 / Byte.SIZE).getBytes(StandardCharsets.UTF_8))); + + doPost("/api/admin/jwtSettings", jwtSettings).andExpect(status().isOk()); + + doGet("/api/admin/jwtSettings").andExpect(status().isUnauthorized()); //the old JWT token does not work after signing key was changed! + + loginSysAdmin(); + JwtSettings newJwtSettings = doGet("/api/admin/jwtSettings", JwtSettings.class); + assertThat(jwtSettings).isEqualTo(newJwtSettings); + + resetJwtSettingsToDefault(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAlarmControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAlarmControllerTest.java new file mode 100644 index 0000000..493b181 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAlarmControllerTest.java @@ -0,0 +1,474 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.dao.alarm.AlarmDao; + +import java.util.LinkedList; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@ContextConfiguration(classes = {BaseAlarmControllerTest.Config.class}) +public abstract class BaseAlarmControllerTest extends AbstractControllerTest { + + public static final String TEST_ALARM_TYPE = "Test"; + + protected Device customerDevice; + + @Autowired + private AlarmDao alarmDao; + + static class Config { + @Bean + @Primary + public AlarmDao alarmDao(AlarmDao alarmDao) { + return Mockito.mock(AlarmDao.class, AdditionalAnswers.delegatesTo(alarmDao)); + } + } + + @Before + public void setup() throws Exception { + loginTenantAdmin(); + + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Test device"); + device.setLabel("Label"); + device.setType("Type"); + device.setCustomerId(customerId); + customerDevice = doPost("/api/device", device, Device.class); + + resetTokens(); + } + + @After + public void teardown() throws Exception { + loginSysAdmin(); + + deleteDifferentTenant(); + } + + @Test + public void testCreateAlarmViaCustomer() throws Exception { + loginCustomerUser(); + + Mockito.reset(tbClusterService, auditLogService); + + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + testNotifyEntityAllOneTime(alarm, alarm.getId(), alarm.getOriginator(), + tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.ADDED); + } + + @Test + public void testCreateAlarmViaTenant() throws Exception { + loginTenantAdmin(); + + Mockito.reset(tbClusterService, auditLogService); + + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + testNotifyEntityAllOneTime(alarm, alarm.getId(), alarm.getOriginator(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.ADDED); + } + + @Test + public void testUpdateAlarmViaCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + alarm.setSeverity(AlarmSeverity.MAJOR); + Alarm updatedAlarm = doPost("/api/alarm", alarm, Alarm.class); + Assert.assertNotNull(updatedAlarm); + Assert.assertEquals(AlarmSeverity.MAJOR, updatedAlarm.getSeverity()); + + testNotifyEntityAllOneTime(updatedAlarm, updatedAlarm.getId(), updatedAlarm.getOriginator(), + tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.UPDATED); + } + + @Test + public void testUpdateAlarmViaTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + alarm.setSeverity(AlarmSeverity.MAJOR); + Alarm updatedAlarm = doPost("/api/alarm", alarm, Alarm.class); + Assert.assertNotNull(updatedAlarm); + Assert.assertEquals(AlarmSeverity.MAJOR, updatedAlarm.getSeverity()); + + testNotifyEntityAllOneTime(updatedAlarm, updatedAlarm.getId(), updatedAlarm.getOriginator(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED); + } + + @Test + public void testUpdateAlarmViaDifferentTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + alarm.setSeverity(AlarmSeverity.MAJOR); + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm", alarm) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testUpdateAlarmViaDifferentCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentCustomer(); + alarm.setSeverity(AlarmSeverity.MAJOR); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm", alarm) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testDeleteAlarmViaCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId()).andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(alarm, alarm.getId(), alarm.getOriginator(), + tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED); + } + + @Test + public void testDeleteAlarmViaTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId()).andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(alarm, alarm.getId(), alarm.getOriginator(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED); + } + + @Test + public void testDeleteAlarmViaDifferentTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testDeleteAlarmViaDifferentCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentCustomer(); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testClearAlarmViaCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/clear").andExpect(status().isOk()); + + Alarm foundAlarm = doGet("/api/alarm/" + alarm.getId(), Alarm.class); + Assert.assertNotNull(foundAlarm); + Assert.assertEquals(AlarmStatus.CLEARED_UNACK, foundAlarm.getStatus()); + + testNotifyEntityAllOneTime(foundAlarm, foundAlarm.getId(), foundAlarm.getOriginator(), + tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.ALARM_CLEAR); + } + + @Test + public void testClearAlarmViaTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/clear").andExpect(status().isOk()); + Alarm foundAlarm = doGet("/api/alarm/" + alarm.getId(), Alarm.class); + Assert.assertNotNull(foundAlarm); + Assert.assertEquals(AlarmStatus.CLEARED_UNACK, foundAlarm.getStatus()); + + testNotifyEntityAllOneTime(foundAlarm, foundAlarm.getId(), foundAlarm.getOriginator(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.ALARM_CLEAR); + } + + @Test + public void testAcknowledgeAlarmViaCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk()); + + Alarm foundAlarm = doGet("/api/alarm/" + alarm.getId(), Alarm.class); + Assert.assertNotNull(foundAlarm); + Assert.assertEquals(AlarmStatus.ACTIVE_ACK, foundAlarm.getStatus()); + + testNotifyEntityAllOneTime(foundAlarm, foundAlarm.getId(), foundAlarm.getOriginator(), + tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.ALARM_ACK); + } + + @Test + public void testClearAlarmViaDifferentCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentCustomer(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/clear") + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testClearAlarmViaDifferentTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/clear") + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testAcknowledgeAlarmViaDifferentCustomer() throws Exception { + loginCustomerUser(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentCustomer(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/ack") + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(alarm.getId(), alarm); + } + + @Test + public void testAcknowledgeAlarmViaDifferentTenant() throws Exception { + loginTenantAdmin(); + Alarm alarm = createAlarm(TEST_ALARM_TYPE); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/alarm/" + alarm.getId() + "/ack") + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindAlarmsViaCustomerUser() throws Exception { + loginCustomerUser(); + + List createdAlarms = new LinkedList<>(); + + final int size = 10; + for (int i = 0; i < size; i++) { + createdAlarms.add( + createAlarm(TEST_ALARM_TYPE + i) + ); + } + + var response = doGetTyped( + "/api/alarm/" + EntityType.DEVICE + "/" + + customerDevice.getUuidId() + "?page=0&pageSize=" + size, + new TypeReference>() {} + ); + var foundAlarmInfos = response.getData(); + Assert.assertNotNull("Found pageData is null", foundAlarmInfos); + Assert.assertNotEquals( + "Expected alarms are not found!", + 0, foundAlarmInfos.size() + ); + + boolean allMatch = createdAlarms.stream() + .allMatch(alarm -> foundAlarmInfos.stream() + .map(Alarm::getType) + .anyMatch(type -> alarm.getType().equals(type)) + ); + Assert.assertTrue("Created alarm doesn't match any found!", allMatch); + } + + @Test + public void testFindAlarmsViaDifferentCustomerUser() throws Exception { + loginCustomerUser(); + + final int size = 10; + for (int i = 0; i < size; i++) { + createAlarm(TEST_ALARM_TYPE + i); + } + + loginDifferentCustomer(); + doGet("/api/alarm/" + EntityType.DEVICE + "/" + + customerDevice.getUuidId() + "?page=0&pageSize=" + size) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindAlarmsViaPublicCustomer() throws Exception { + loginTenantAdmin(); + + Device device = new Device(); + device.setName("Test Public Device"); + device.setLabel("Label"); + device.setCustomerId(customerId); + device = doPost("/api/device", device, Device.class); + device = doPost("/api/customer/public/device/" + device.getUuidId(), Device.class); + + String publicId = device.getCustomerId().toString(); + + Alarm alarm = Alarm.builder() + .originator(device.getId()) + .status(AlarmStatus.ACTIVE_UNACK) + .severity(AlarmSeverity.CRITICAL) + .type("Test") + .build(); + + Mockito.reset(tbClusterService, auditLogService); + + alarm = doPost("/api/alarm", alarm, Alarm.class); + Assert.assertNotNull("Saved alarm is null!", alarm); + + testNotifyEntityNeverMsgToEdgeServiceOneTime(alarm, alarm.getId(), tenantId, ActionType.ADDED); + + resetTokens(); + + JsonNode publicLoginRequest = JacksonUtil.toJsonNode("{\"publicId\": \"" + publicId + "\"}"); + JsonNode tokens = doPost("/api/auth/login/public", publicLoginRequest, JsonNode.class); + this.token = tokens.get("token").asText(); + + PageData pageData = doGetTyped( + "/api/alarm/DEVICE/" + device.getUuidId() + "?page=0&pageSize=1", new TypeReference>() {} + ); + + Assert.assertNotNull("Found pageData is null", pageData); + Assert.assertNotEquals("Expected alarms are not found!", 0, pageData.getTotalElements()); + + AlarmInfo alarmInfo = pageData.getData().get(0); + boolean equals = alarm.getId().equals(alarmInfo.getId()) && alarm.getType().equals(alarmInfo.getType()); + Assert.assertTrue("Created alarm doesn't match the found one!", equals); + } + + + @Test + public void testDeleteAlarmWithDeleteRelationsOk() throws Exception { + loginCustomerUser(); + AlarmId alarmId = createAlarm("Alarm for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(customerDevice.getId(), alarmId, "/api/alarm/" + alarmId); + } + + @Test + public void testDeleteAlarmExceptionWithRelationsTransactional() throws Exception { + loginCustomerUser(); + AlarmId alarmId = createAlarm("Alarm for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(alarmDao, customerDevice.getId(), alarmId, "/api/alarm/" + alarmId); + } + + private Alarm createAlarm(String type) throws Exception { + Alarm alarm = Alarm.builder() + .tenantId(tenantId) + .customerId(customerId) + .originator(customerDevice.getId()) + .status(AlarmStatus.ACTIVE_UNACK) + .severity(AlarmSeverity.CRITICAL) + .type(type) + .build(); + + alarm = doPost("/api/alarm", alarm, Alarm.class); + Assert.assertNotNull(alarm); + + return alarm; + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAssetControllerTest.java new file mode 100644 index 0000000..37cc703 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAssetControllerTest.java @@ -0,0 +1,960 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.asset.AssetDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.stats.DefaultRuleEngineStatisticsService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@ContextConfiguration(classes = {BaseAssetControllerTest.Config.class}) +public abstract class BaseAssetControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private AssetDao assetDao; + + static class Config { + @Bean + @Primary + public AssetDao assetDao(AssetDao assetDao) { + return Mockito.mock(AssetDao.class, AdditionalAnswers.delegatesTo(assetDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveAsset() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + + Mockito.reset(tbClusterService, auditLogService); + + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED); + + Assert.assertNotNull(savedAsset); + Assert.assertNotNull(savedAsset.getId()); + Assert.assertTrue(savedAsset.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedAsset.getTenantId()); + Assert.assertNotNull(savedAsset.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedAsset.getCustomerId().getId()); + Assert.assertEquals(asset.getName(), savedAsset.getName()); + + Mockito.reset(tbClusterService, auditLogService); + + savedAsset.setName("My new asset"); + doPost("/api/asset", savedAsset, Asset.class); + + testNotifyEntityAllOneTime(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); + + Asset foundAsset = doGet("/api/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertEquals(foundAsset.getName(), savedAsset.getName()); + } + + @Test + public void testSaveAssetWithViolationOfLengthValidation() throws Exception { + Asset asset = new Asset(); + asset.setName(StringUtils.randomAlphabetic(300)); + asset.setType("default"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = msgErrorFieldLength("name"); + doPost("/api/asset", asset) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(asset, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + asset.setName("Normal name"); + asset.setType(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("type"); + doPost("/api/asset", asset) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(asset, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + asset.setType("default"); + asset.setLabel(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("label"); + doPost("/api/asset", asset) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(asset, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testUpdateAssetFromDifferentTenant() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/asset", savedAsset) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedAsset.getId(), savedAsset); + + doDelete("/api/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedAsset.getId(), savedAsset); + + deleteDifferentTenant(); + } + + @Test + public void testSaveAssetWithProfileFromDifferentTenant() throws Exception { + loginDifferentTenant(); + AssetProfile differentProfile = createAssetProfile("Different profile"); + differentProfile = doPost("/api/assetProfile", differentProfile, AssetProfile.class); + + loginTenantAdmin(); + Asset asset = new Asset(); + asset.setName("My device"); + asset.setAssetProfileId(differentProfile.getId()); + doPost("/api/asset", asset).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Asset can`t be referencing to asset profile from different tenant!"))); + } + + @Test + public void testFindAssetById() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + Asset foundAsset = doGet("/api/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertNotNull(foundAsset); + Assert.assertEquals(savedAsset, foundAsset); + } + + @Test + public void testFindAssetTypesByTenantId() throws Exception { + List assets = new ArrayList<>(); + + Mockito.reset(tbClusterService, auditLogService); + + int cntTime = 3; + for (int i = 0; i < cntTime; i++) { + Asset asset = new Asset(); + asset.setName("My asset B" + i); + asset.setType("typeB"); + assets.add(doPost("/api/asset", asset, Asset.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Asset(), new Asset(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntTime); + + for (int i = 0; i < 7; i++) { + Asset asset = new Asset(); + asset.setName("My asset C" + i); + asset.setType("typeC"); + assets.add(doPost("/api/asset", asset, Asset.class)); + } + for (int i = 0; i < 9; i++) { + Asset asset = new Asset(); + asset.setName("My asset A" + i); + asset.setType("typeA"); + assets.add(doPost("/api/asset", asset, Asset.class)); + } + List assetTypes = doGetTyped("/api/asset/types", + new TypeReference>() { + }); + + Assert.assertNotNull(assetTypes); + Assert.assertEquals(3, assetTypes.size()); + Assert.assertEquals("typeA", assetTypes.get(0).getType()); + Assert.assertEquals("typeB", assetTypes.get(1).getType()); + Assert.assertEquals("typeC", assetTypes.get(2).getType()); + } + + @Test + public void testDeleteAsset() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedAsset.getId().getId().toString()); + + String assetIdStr = savedAsset.getId().getId().toString(); + doGet("/api/asset/" + assetIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Asset", assetIdStr)))); + } + + @Test + public void testDeleteAssetAssignedToEntityView() throws Exception { + Asset asset1 = new Asset(); + asset1.setName("My asset 1"); + asset1.setType("default"); + Asset savedAsset1 = doPost("/api/asset", asset1, Asset.class); + + Asset asset2 = new Asset(); + asset2.setName("My asset 2"); + asset2.setType("default"); + Asset savedAsset2 = doPost("/api/asset", asset2, Asset.class); + + EntityView view = new EntityView(); + view.setEntityId(savedAsset1.getId()); + view.setTenantId(savedTenant.getId()); + view.setName("My entity view"); + view.setType("default"); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Can't delete asset that has entity views"; + doDelete("/api/asset/" + savedAsset1.getId().getId().toString()) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityIsNullOneTimeEdgeServiceNeverError(savedAsset1, savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, new DataValidationException(msgError), savedAsset1.getId().getId().toString()); + + savedView.setEntityId(savedAsset2.getId()); + + doPost("/api/entityView", savedView, EntityView.class); + + doDelete("/api/asset/" + savedAsset1.getId().getId().toString()) + .andExpect(status().isOk()); + + String assetIdStr = savedAsset1.getId().getId().toString(); + doGet("/api/asset/" + assetIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Asset", assetIdStr)))); + } + + @Test + public void testSaveAssetWithEmptyType() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + + Mockito.reset(tbClusterService, auditLogService); + + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + Assert.assertEquals("default", savedAsset.getType()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + } + + @Test + public void testSaveAssetWithEmptyName() throws Exception { + Asset asset = new Asset(); + asset.setType("default"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Asset name " + msgErrorShouldBeSpecified; + doPost("/api/asset", asset) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(asset, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testAssignUnassignAssetToCustomer() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Mockito.reset(tbClusterService, auditLogService); + + Asset assignedAsset = doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertEquals(savedCustomer.getId(), assignedAsset.getCustomerId()); + + testNotifyEntityAllOneTime(assignedAsset, assignedAsset.getId(), assignedAsset.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ASSIGNED_TO_CUSTOMER, assignedAsset.getId().toString(), savedCustomer.getId().toString(), savedCustomer.getTitle()); + + Asset foundAsset = doGet("/api/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertEquals(savedCustomer.getId(), foundAsset.getCustomerId()); + + Mockito.reset(tbClusterService, auditLogService); + + Asset unassignedAsset = + doDelete("/api/customer/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertEquals(ModelConstants.NULL_UUID, unassignedAsset.getCustomerId().getId()); + + testNotifyEntityAllOneTime(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_CUSTOMER, savedAsset.getId().toString(), savedCustomer.getId().toString(), savedCustomer.getTitle()); + + foundAsset = doGet("/api/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertEquals(ModelConstants.NULL_UUID, foundAsset.getCustomerId().getId()); + } + + @Test + public void testAssignAssetToNonExistentCustomer() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + Mockito.reset(tbClusterService, auditLogService); + + String customerIdStr = Uuids.timeBased().toString(); + doPost("/api/customer/" + customerIdStr + + "/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Customer", customerIdStr)))); + + testNotifyEntityNever(asset.getId(), asset); + } + + @Test + public void testAssignAssetToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant tenant2 = new Tenant(); + tenant2.setTitle("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 = new Customer(); + customer.setTitle("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + login(tenantAdmin.getEmail(), "testPassword1"); + + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedAsset.getId(), savedAsset); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testFindTenantAssets() throws Exception { + List assets = new ArrayList<>(); + int cntEntity = 178; + + Mockito.reset(tbClusterService, auditLogService); + + for (int i = 0; i < cntEntity; i++) { + Asset asset = new Asset(); + asset.setName("Asset" + i); + asset.setType("default"); + assets.add(doPost("/api/asset", asset, Asset.class)); + } + List loadedAssets = new ArrayList<>(); + PageLink pageLink = new PageLink(23); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/assets?", + new TypeReference>() { + }, pageLink); + loadedAssets.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Asset(), new Asset(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity); + + loadedAssets.removeIf(asset -> asset.getType().equals(DefaultRuleEngineStatisticsService.TB_SERVICE_QUEUE)); + + Collections.sort(assets, idComparator); + Collections.sort(loadedAssets, idComparator); + + Assert.assertEquals(assets, loadedAssets); + } + + @Test + public void testFindTenantAssetsByName() throws Exception { + String title1 = "Asset title 1"; + List assetsTitle1 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType("default"); + assetsTitle1.add(doPost("/api/asset", asset, Asset.class)); + } + String title2 = "Asset title 2"; + List assetsTitle2 = new ArrayList<>(); + for (int i = 0; i < 75; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType("default"); + assetsTitle2.add(doPost("/api/asset", asset, Asset.class)); + } + + List loadedAssetsTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/assets?", + new TypeReference>() { + }, pageLink); + loadedAssetsTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsTitle1, idComparator); + Collections.sort(loadedAssetsTitle1, idComparator); + + Assert.assertEquals(assetsTitle1, loadedAssetsTitle1); + + List loadedAssetsTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/tenant/assets?", + new TypeReference>() { + }, pageLink); + loadedAssetsTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsTitle2, idComparator); + Collections.sort(loadedAssetsTitle2, idComparator); + + Assert.assertEquals(assetsTitle2, loadedAssetsTitle2); + + for (Asset asset : loadedAssetsTitle1) { + doDelete("/api/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/tenant/assets?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Asset asset : loadedAssetsTitle2) { + doDelete("/api/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/tenant/assets?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindTenantAssetsByType() throws Exception { + String title1 = "Asset title 1"; + String type1 = "typeA"; + List assetsType1 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType(type1); + assetsType1.add(doPost("/api/asset", asset, Asset.class)); + } + String title2 = "Asset title 2"; + String type2 = "typeB"; + List assetsType2 = new ArrayList<>(); + for (int i = 0; i < 75; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType(type2); + assetsType2.add(doPost("/api/asset", asset, Asset.class)); + } + + List loadedAssetsType1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/assets?type={type}&", + new TypeReference>() { + }, pageLink, type1); + loadedAssetsType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsType1, idComparator); + Collections.sort(loadedAssetsType1, idComparator); + + Assert.assertEquals(assetsType1, loadedAssetsType1); + + List loadedAssetsType2 = new ArrayList<>(); + pageLink = new PageLink(4); + do { + pageData = doGetTypedWithPageLink("/api/tenant/assets?type={type}&", + new TypeReference>() { + }, pageLink, type2); + loadedAssetsType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsType2, idComparator); + Collections.sort(loadedAssetsType2, idComparator); + + Assert.assertEquals(assetsType2, loadedAssetsType2); + + for (Asset asset : loadedAssetsType1) { + doDelete("/api/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/assets?type={type}&", + new TypeReference>() { + }, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Asset asset : loadedAssetsType2) { + doDelete("/api/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/assets?type={type}&", + new TypeReference>() { + }, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerAssets() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + List assets = new ArrayList<>(); + for (int i = 0; i < 128; i++) { + Asset asset = new Asset(); + asset.setName("Asset" + i); + asset.setType("default"); + asset = doPost("/api/asset", asset, Asset.class); + assets.add(doPost("/api/customer/" + customerId.getId().toString() + + "/asset/" + asset.getId().getId().toString(), Asset.class)); + } + + List loadedAssets = new ArrayList<>(); + PageLink pageLink = new PageLink(23); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?", + new TypeReference>() { + }, pageLink); + loadedAssets.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assets, idComparator); + Collections.sort(loadedAssets, idComparator); + + Assert.assertEquals(assets, loadedAssets); + } + + @Test + public void testFindCustomerAssetsByName() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Asset title 1"; + List assetsTitle1 = new ArrayList<>(); + for (int i = 0; i < 125; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType("default"); + asset = doPost("/api/asset", asset, Asset.class); + assetsTitle1.add(doPost("/api/customer/" + customerId.getId().toString() + + "/asset/" + asset.getId().getId().toString(), Asset.class)); + } + String title2 = "Asset title 2"; + List assetsTitle2 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType("default"); + asset = doPost("/api/asset", asset, Asset.class); + assetsTitle2.add(doPost("/api/customer/" + customerId.getId().toString() + + "/asset/" + asset.getId().getId().toString(), Asset.class)); + } + + List loadedAssetsTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?", + new TypeReference>() { + }, pageLink); + loadedAssetsTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsTitle1, idComparator); + Collections.sort(loadedAssetsTitle1, idComparator); + + Assert.assertEquals(assetsTitle1, loadedAssetsTitle1); + + List loadedAssetsTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?", + new TypeReference>() { + }, pageLink); + loadedAssetsTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsTitle2, idComparator); + Collections.sort(loadedAssetsTitle2, idComparator); + + Assert.assertEquals(assetsTitle2, loadedAssetsTitle2); + + for (Asset asset : loadedAssetsTitle1) { + doDelete("/api/customer/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Asset asset : loadedAssetsTitle2) { + doDelete("/api/customer/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerAssetsByType() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Asset title 1"; + String type1 = "typeC"; + List assetsType1 = new ArrayList<>(); + for (int i = 0; i < 125; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType(type1); + asset = doPost("/api/asset", asset, Asset.class); + assetsType1.add(doPost("/api/customer/" + customerId.getId().toString() + + "/asset/" + asset.getId().getId().toString(), Asset.class)); + } + String title2 = "Asset title 2"; + String type2 = "typeD"; + List assetsType2 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + Asset asset = new Asset(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + asset.setName(name); + asset.setType(type2); + asset = doPost("/api/asset", asset, Asset.class); + assetsType2.add(doPost("/api/customer/" + customerId.getId().toString() + + "/asset/" + asset.getId().getId().toString(), Asset.class)); + } + + List loadedAssetsType1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?type={type}&", + new TypeReference>() { + }, pageLink, type1); + loadedAssetsType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsType1, idComparator); + Collections.sort(loadedAssetsType1, idComparator); + + Assert.assertEquals(assetsType1, loadedAssetsType1); + + List loadedAssetsType2 = new ArrayList<>(); + pageLink = new PageLink(4); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?type={type}&", + new TypeReference>() { + }, pageLink, type2); + loadedAssetsType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetsType2, idComparator); + Collections.sort(loadedAssetsType2, idComparator); + + Assert.assertEquals(assetsType2, loadedAssetsType2); + + for (Asset asset : loadedAssetsType1) { + doDelete("/api/customer/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?type={type}&", + new TypeReference>() { + }, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Asset asset : loadedAssetsType2) { + doDelete("/api/customer/asset/" + asset.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/assets?type={type}&", + new TypeReference>() { + }, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testAssignAssetToEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/edge/" + savedEdge.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); + + testNotifyEntityAllOneTime(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ASSIGNED_TO_EDGE, + savedAsset.getId().getId().toString(), savedEdge.getId().getId().toString(), edge.getName()); + + + PageData pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/assets?", + new TypeReference>() { + }, new PageLink(100)); + + Assert.assertEquals(1, pageData.getData().size()); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/edge/" + savedEdge.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); + + + testNotifyEntityAllOneTime(savedAsset, savedAsset.getId(), savedAsset.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_EDGE, savedAsset.getId().getId().toString(), savedEdge.getId().getId().toString(), savedEdge.getName()); + + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/assets?", + new TypeReference>() { + }, new PageLink(100)); + + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testDeleteAssetWithDeleteRelationsOk() throws Exception { + AssetId assetId = createAsset("Asset for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), assetId, "/api/asset/" + assetId); + } + + @Test + public void testDeleteAssetExceptionWithRelationsTransactional() throws Exception { + AssetId assetId = createAsset("Asset for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId); + } + + private Asset createAsset(String name) { + Asset asset = new Asset(); + asset.setName(name); + asset.setType("default"); + return doPost("/api/asset", asset, Asset.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAssetProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAssetProfileControllerTest.java new file mode 100644 index 0000000..a911df7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAssetProfileControllerTest.java @@ -0,0 +1,463 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.asset.AssetProfileDao; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = {BaseAssetProfileControllerTest.Config.class}) +public abstract class BaseAssetProfileControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + private IdComparator assetProfileInfoIdComparator = new IdComparator<>(); + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private AssetProfileDao assetProfileDao; + + static class Config { + @Bean + @Primary + public AssetProfileDao assetProfileDao(AssetProfileDao assetProfileDao) { + return Mockito.mock(AssetProfileDao.class, AdditionalAnswers.delegatesTo(assetProfileDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveAssetProfile() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + + Mockito.reset(tbClusterService, auditLogService); + + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + Assert.assertNotNull(savedAssetProfile); + Assert.assertNotNull(savedAssetProfile.getId()); + Assert.assertTrue(savedAssetProfile.getCreatedTime() > 0); + Assert.assertEquals(assetProfile.getName(), savedAssetProfile.getName()); + Assert.assertEquals(assetProfile.getDescription(), savedAssetProfile.getDescription()); + Assert.assertEquals(assetProfile.isDefault(), savedAssetProfile.isDefault()); + Assert.assertEquals(assetProfile.getDefaultRuleChainId(), savedAssetProfile.getDefaultRuleChainId()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTime(savedAssetProfile, savedAssetProfile.getId(), savedAssetProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + savedAssetProfile.setName("New asset profile"); + doPost("/api/assetProfile", savedAssetProfile, AssetProfile.class); + AssetProfile foundAssetProfile = doGet("/api/assetProfile/" + savedAssetProfile.getId().getId().toString(), AssetProfile.class); + Assert.assertEquals(savedAssetProfile.getName(), foundAssetProfile.getName()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTime(foundAssetProfile, foundAssetProfile.getId(), foundAssetProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void saveAssetProfileWithViolationOfValidation() throws Exception { + String msgError = msgErrorFieldLength("name"); + + Mockito.reset(tbClusterService, auditLogService); + + AssetProfile createAssetProfile = this.createAssetProfile(StringUtils.randomAlphabetic(300)); + doPost("/api/assetProfile", createAssetProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(createAssetProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testFindAssetProfileById() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + AssetProfile foundAssetProfile = doGet("/api/assetProfile/" + savedAssetProfile.getId().getId().toString(), AssetProfile.class); + Assert.assertNotNull(foundAssetProfile); + Assert.assertEquals(savedAssetProfile, foundAssetProfile); + } + + @Test + public void whenGetAssetProfileById_thenPermissionsAreChecked() throws Exception { + AssetProfile assetProfile = createAssetProfile("Asset profile 1"); + assetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + loginDifferentTenant(); + + doGet("/api/assetProfile/" + assetProfile.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindAssetProfileInfoById() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + AssetProfileInfo foundAssetProfileInfo = doGet("/api/assetProfileInfo/" + savedAssetProfile.getId().getId().toString(), AssetProfileInfo.class); + Assert.assertNotNull(foundAssetProfileInfo); + Assert.assertEquals(savedAssetProfile.getId(), foundAssetProfileInfo.getId()); + Assert.assertEquals(savedAssetProfile.getName(), foundAssetProfileInfo.getName()); + + Customer customer = new Customer(); + customer.setTitle("Customer"); + customer.setTenantId(savedTenant.getId()); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + User customerUser = new User(); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedTenant.getId()); + customerUser.setCustomerId(savedCustomer.getId()); + customerUser.setEmail("customer2@thingsboard.org"); + + createUserAndLogin(customerUser, "customer"); + + foundAssetProfileInfo = doGet("/api/assetProfileInfo/" + savedAssetProfile.getId().getId().toString(), AssetProfileInfo.class); + Assert.assertNotNull(foundAssetProfileInfo); + Assert.assertEquals(savedAssetProfile.getId(), foundAssetProfileInfo.getId()); + Assert.assertEquals(savedAssetProfile.getName(), foundAssetProfileInfo.getName()); + } + + @Test + public void whenGetAssetProfileInfoById_thenPermissionsAreChecked() throws Exception { + AssetProfile assetProfile = createAssetProfile("Asset profile 1"); + assetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + loginDifferentTenant(); + doGet("/api/assetProfileInfo/" + assetProfile.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindDefaultAssetProfileInfo() throws Exception { + AssetProfileInfo foundDefaultAssetProfileInfo = doGet("/api/assetProfileInfo/default", AssetProfileInfo.class); + Assert.assertNotNull(foundDefaultAssetProfileInfo); + Assert.assertNotNull(foundDefaultAssetProfileInfo.getId()); + Assert.assertNotNull(foundDefaultAssetProfileInfo.getName()); + Assert.assertEquals("default", foundDefaultAssetProfileInfo.getName()); + } + + @Test + public void testSetDefaultAssetProfile() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile 1"); + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + Mockito.reset(tbClusterService, auditLogService); + + AssetProfile defaultAssetProfile = doPost("/api/assetProfile/" + savedAssetProfile.getId().getId().toString() + "/default", AssetProfile.class); + Assert.assertNotNull(defaultAssetProfile); + AssetProfileInfo foundDefaultAssetProfile = doGet("/api/assetProfileInfo/default", AssetProfileInfo.class); + Assert.assertNotNull(foundDefaultAssetProfile); + Assert.assertEquals(savedAssetProfile.getName(), foundDefaultAssetProfile.getName()); + Assert.assertEquals(savedAssetProfile.getId(), foundDefaultAssetProfile.getId()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(defaultAssetProfile, defaultAssetProfile.getId(), defaultAssetProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void testSaveAssetProfileWithEmptyName() throws Exception { + AssetProfile assetProfile = new AssetProfile(); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Asset profile name " + msgErrorShouldBeSpecified; + doPost("/api/assetProfile", assetProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(assetProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveAssetProfileWithSameName() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + doPost("/api/assetProfile", assetProfile).andExpect(status().isOk()); + AssetProfile assetProfile2 = this.createAssetProfile("Asset Profile"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Asset profile with such name already exists"; + doPost("/api/assetProfile", assetProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(assetProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testDeleteAssetProfileWithExistingAsset() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + Asset asset = new Asset(); + asset.setName("Test asset"); + asset.setAssetProfileId(savedAssetProfile.getId()); + + doPost("/api/asset", asset, Asset.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/assetProfile/" + savedAssetProfile.getId().getId().toString()) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("The asset profile referenced by the assets cannot be deleted"))); + + testNotifyEntityNever(savedAssetProfile.getId(), savedAssetProfile); + } + + @Test + public void testSaveAssetProfileWithRuleChainFromDifferentTenant() throws Exception { + loginDifferentTenant(); + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("Different rule chain"); + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + + loginTenantAdmin(); + + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + assetProfile.setDefaultRuleChainId(savedRuleChain.getId()); + doPost("/api/assetProfile", assetProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign rule chain from different tenant!"))); + } + + @Test + public void testSaveAssetProfileWithDashboardFromDifferentTenant() throws Exception { + loginDifferentTenant(); + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("Different dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + loginTenantAdmin(); + + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + assetProfile.setDefaultDashboardId(savedDashboard.getId()); + doPost("/api/assetProfile", assetProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign dashboard from different tenant!"))); + } + + @Test + public void testDeleteAssetProfile() throws Exception { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile"); + AssetProfile savedAssetProfile = doPost("/api/assetProfile", assetProfile, AssetProfile.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/assetProfile/" + savedAssetProfile.getId().getId().toString()) + .andExpect(status().isOk()); + + String savedAssetProfileIdStr = savedAssetProfile.getId().getId().toString(); + testNotifyEntityBroadcastEntityStateChangeEventOneTime(savedAssetProfile, savedAssetProfile.getId(), savedAssetProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedAssetProfileIdStr); + + doGet("/api/assetProfile/" + savedAssetProfile.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Asset profile", savedAssetProfileIdStr)))); + } + + @Test + public void testFindAssetProfiles() throws Exception { + List assetProfiles = new ArrayList<>(); + PageLink pageLink = new PageLink(17); + PageData pageData = doGetTypedWithPageLink("/api/assetProfiles?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + assetProfiles.addAll(pageData.getData()); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 28; + for (int i = 0; i < cntEntity; i++) { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile" + i); + assetProfiles.add(doPost("/api/assetProfile", assetProfile, AssetProfile.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new AssetProfile(), new AssetProfile(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ADDED, cntEntity, cntEntity, cntEntity); + Mockito.reset(tbClusterService, auditLogService); + + List loadedAssetProfiles = new ArrayList<>(); + pageLink = new PageLink(17); + do { + pageData = doGetTypedWithPageLink("/api/assetProfiles?", + new TypeReference<>() { + }, pageLink); + loadedAssetProfiles.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetProfiles, idComparator); + Collections.sort(loadedAssetProfiles, idComparator); + + Assert.assertEquals(assetProfiles, loadedAssetProfiles); + + for (AssetProfile assetProfile : loadedAssetProfiles) { + if (!assetProfile.isDefault()) { + doDelete("/api/assetProfile/" + assetProfile.getId().getId().toString()) + .andExpect(status().isOk()); + } + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(loadedAssetProfiles.get(0), loadedAssetProfiles.get(0), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, ActionType.DELETED, cntEntity, cntEntity, cntEntity, loadedAssetProfiles.get(0).getId().getId().toString()); + + pageLink = new PageLink(17); + pageData = doGetTypedWithPageLink("/api/assetProfiles?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + } + + @Test + public void testFindAssetProfileInfos() throws Exception { + List assetProfiles = new ArrayList<>(); + PageLink pageLink = new PageLink(17); + PageData assetProfilePageData = doGetTypedWithPageLink("/api/assetProfiles?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(assetProfilePageData.hasNext()); + Assert.assertEquals(1, assetProfilePageData.getTotalElements()); + assetProfiles.addAll(assetProfilePageData.getData()); + + for (int i = 0; i < 28; i++) { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile" + i); + assetProfiles.add(doPost("/api/assetProfile", assetProfile, AssetProfile.class)); + } + + List loadedAssetProfileInfos = new ArrayList<>(); + pageLink = new PageLink(17); + PageData pageData; + do { + pageData = doGetTypedWithPageLink("/api/assetProfileInfos?", + new TypeReference<>() { + }, pageLink); + loadedAssetProfileInfos.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(assetProfiles, idComparator); + Collections.sort(loadedAssetProfileInfos, assetProfileInfoIdComparator); + + List assetProfileInfos = assetProfiles.stream().map(assetProfile -> new AssetProfileInfo(assetProfile.getId(), + assetProfile.getName(), assetProfile.getImage(), assetProfile.getDefaultDashboardId())).collect(Collectors.toList()); + + Assert.assertEquals(assetProfileInfos, loadedAssetProfileInfos); + + for (AssetProfile assetProfile : assetProfiles) { + if (!assetProfile.isDefault()) { + doDelete("/api/assetProfile/" + assetProfile.getId().getId().toString()) + .andExpect(status().isOk()); + } + } + + pageLink = new PageLink(17); + pageData = doGetTypedWithPageLink("/api/assetProfileInfos?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + } + + @Test + public void testDeleteAssetProfileWithDeleteRelationsOk() throws Exception { + AssetProfileId assetProfileId = savedAssetProfile("AssetProfile for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), assetProfileId, "/api/assetProfile/" + assetProfileId); + } + + @Test + public void testDeleteAssetProfileExceptionWithRelationsTransactional() throws Exception { + AssetProfileId assetProfileId = savedAssetProfile("AssetProfile for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(assetProfileDao, savedTenant.getId(), assetProfileId, "/api/assetProfile/" + assetProfileId); + } + + private AssetProfile savedAssetProfile(String name) { + AssetProfile assetProfile = createAssetProfile(name); + return doPost("/api/assetProfile", assetProfile, AssetProfile.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAuditLogControllerTest.java new file mode 100644 index 0000000..16ac33b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAuditLogControllerTest.java @@ -0,0 +1,232 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.mock.mockito.SpyBean; +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.audit.ActionType; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.audit.AuditLogDao; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.service.ttl.AuditLogsCleanUpService; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseAuditLogControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private AuditLogDao auditLogDao; + @SpyBean + private SqlPartitioningRepository partitioningRepository; + @Autowired + private AuditLogsCleanUpService auditLogsCleanUpService; + + @Value("#{${sql.audit_logs.partition_size} * 60 * 60 * 1000}") + private long partitionDurationInMs; + @Value("${sql.ttl.audit_logs.ttl}") + private long auditLogsTtlInSec; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testAuditLogs() throws Exception { + for (int i = 0; i < 178; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + doPost("/api/device", device, Device.class); + } + + List loadedAuditLogs = new ArrayList<>(); + TimePageLink pageLink = new TimePageLink(23); + PageData pageData; + do { + pageData = doGetTypedWithTimePageLink("/api/audit/logs?", + new TypeReference>() { + }, pageLink); + loadedAuditLogs.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Assert.assertEquals(178, loadedAuditLogs.size()); + + loadedAuditLogs = new ArrayList<>(); + pageLink = new TimePageLink(23); + do { + pageData = doGetTypedWithTimePageLink("/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?", + new TypeReference>() { + }, pageLink); + loadedAuditLogs.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Assert.assertEquals(178, loadedAuditLogs.size()); + + loadedAuditLogs = new ArrayList<>(); + pageLink = new TimePageLink(23); + do { + pageData = doGetTypedWithTimePageLink("/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?", + new TypeReference>() { + }, pageLink); + loadedAuditLogs.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Assert.assertEquals(178, loadedAuditLogs.size()); + } + + @Test + public void testAuditLogs_byTenantIdAndEntityId() throws Exception { + Device device = new Device(); + device.setName("Device name"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + for (int i = 0; i < 178; i++) { + savedDevice.setName("Device name" + i); + doPost("/api/device", savedDevice, Device.class); + } + + List loadedAuditLogs = new ArrayList<>(); + TimePageLink pageLink = new TimePageLink(23); + PageData pageData; + do { + pageData = doGetTypedWithTimePageLink("/api/audit/logs/entity/DEVICE/" + savedDevice.getId().getId() + "?", + new TypeReference>() { + }, pageLink); + loadedAuditLogs.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Assert.assertEquals(179, loadedAuditLogs.size()); + } + + @Test + public void whenSavingNewAuditLog_thenCheckAndCreatePartitionIfNotExists() { + reset(partitioningRepository); + AuditLog auditLog = createAuditLog(ActionType.LOGIN, tenantAdminUserId); + verify(partitioningRepository).createPartitionIfNotExists(eq("audit_log"), eq(auditLog.getCreatedTime()), eq(partitionDurationInMs)); + + List partitions = partitioningRepository.fetchPartitions("audit_log"); + assertThat(partitions).singleElement().satisfies(partitionStartTs -> { + assertThat(partitionStartTs).isEqualTo(partitioningRepository.calculatePartitionStartTime(auditLog.getCreatedTime(), partitionDurationInMs)); + }); + } + + @Test + public void whenCleaningUpAuditLogsByTtl_thenDropOldPartitions() { + long oldAuditLogTs = LocalDate.of(2020, 10, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + long partitionStartTs = partitioningRepository.calculatePartitionStartTime(oldAuditLogTs, partitionDurationInMs); + partitioningRepository.createPartitionIfNotExists("audit_log", oldAuditLogTs, partitionDurationInMs); + List partitions = partitioningRepository.fetchPartitions("audit_log"); + assertThat(partitions).contains(partitionStartTs); + + auditLogsCleanUpService.cleanUp(); + partitions = partitioningRepository.fetchPartitions("audit_log"); + assertThat(partitions).doesNotContain(partitionStartTs); + assertThat(partitions).allSatisfy(partitionsStart -> { + long partitionEndTs = partitionsStart + partitionDurationInMs; + assertThat(partitionEndTs).isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(auditLogsTtlInSec)); + }); + } + + @Test + public void whenSavingAuditLogAndPartitionSaveErrorOccurred_thenSaveAuditLogAnyway() throws Exception { + // creating partition bigger than sql.audit_logs.partition_size + partitioningRepository.createPartitionIfNotExists("audit_log", System.currentTimeMillis(), TimeUnit.DAYS.toMillis(7)); + List partitions = partitioningRepository.fetchPartitions("audit_log"); + assertThat(partitions).size().isOne(); + partitioningRepository.cleanupPartitionsCache("audit_log", System.currentTimeMillis(), 0); + + assertDoesNotThrow(() -> { + // expecting partition overlap error on partition save + createAuditLog(ActionType.LOGIN, tenantAdminUserId); + }); + assertThat(partitioningRepository.fetchPartitions("audit_log")).isEqualTo(partitions); + } + + private AuditLog createAuditLog(ActionType actionType, EntityId entityId) { + AuditLog auditLog = new AuditLog(); + auditLog.setTenantId(tenantId); + auditLog.setCustomerId(null); + auditLog.setUserId(tenantAdminUserId); + auditLog.setEntityId(entityId); + auditLog.setUserName(tenantAdmin.getEmail()); + auditLog.setActionType(actionType); + return auditLogDao.save(tenantId, auditLog); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseAuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseAuthControllerTest.java new file mode 100644 index 0000000..627ce1a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseAuthControllerTest.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import org.junit.Test; +import org.thingsboard.server.common.data.security.Authority; + +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseAuthControllerTest extends AbstractControllerTest { + + @Test + public void testGetUser() throws Exception { + + doGet("/api/auth/user") + .andExpect(status().isUnauthorized()); + + loginSysAdmin(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + + loginTenantAdmin(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.TENANT_ADMIN.name()))) + .andExpect(jsonPath("$.email",is(TENANT_ADMIN_EMAIL))); + + loginCustomerUser(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.CUSTOMER_USER.name()))) + .andExpect(jsonPath("$.email",is(CUSTOMER_USER_EMAIL))); + } + + @Test + public void testLoginLogout() throws Exception { + loginSysAdmin(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + + TimeUnit.SECONDS.sleep(1); //We need to make sure that event for invalidating token was successfully processed + + logout(); + doGet("/api/auth/user") + .andExpect(status().isUnauthorized()); + + resetTokens(); + } + + @Test + public void testRefreshToken() throws Exception { + loginSysAdmin(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + + refreshToken(); + doGet("/api/auth/user") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authority",is(Authority.SYS_ADMIN.name()))) + .andExpect(jsonPath("$.email",is(SYS_ADMIN_EMAIL))); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseComponentDescriptorControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseComponentDescriptorControllerTest.java new file mode 100644 index 0000000..7a01092 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseComponentDescriptorControllerTest.java @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.rule.engine.filter.TbJsFilterNode; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentScope; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.security.Authority; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseComponentDescriptorControllerTest extends AbstractControllerTest { + + private static final int AMOUNT_OF_DEFAULT_FILTER_NODES = 4; + private Tenant savedTenant; + private User tenantAdmin; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetByClazz() throws Exception { + ComponentDescriptor descriptor = + doGet("/api/component/" + TbJsFilterNode.class.getName(), ComponentDescriptor.class); + + Assert.assertNotNull(descriptor); + Assert.assertNotNull(descriptor.getId()); + Assert.assertNotNull(descriptor.getName()); + Assert.assertEquals(ComponentScope.TENANT, descriptor.getScope()); + Assert.assertEquals(ComponentType.FILTER, descriptor.getType()); + Assert.assertEquals(descriptor.getClazz(), descriptor.getClazz()); + } + + @Test + public void testGetByType() throws Exception { + List descriptors = readResponse( + doGet("/api/components?componentTypes={componentTypes}&ruleChainType={ruleChainType}", ComponentType.FILTER, RuleChainType.CORE).andExpect(status().isOk()), new TypeReference>() { + }); + + Assert.assertNotNull(descriptors); + Assert.assertTrue(descriptors.size() >= AMOUNT_OF_DEFAULT_FILTER_NODES); + + for (ComponentType type : ComponentType.values()) { + doGet("/api/components?componentTypes={componentTypes}&ruleChainType={ruleChainType}", type, RuleChainType.CORE).andExpect(status().isOk()); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseCustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseCustomerControllerTest.java new file mode 100644 index 0000000..029d3c9 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseCustomerControllerTest.java @@ -0,0 +1,442 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.customer.CustomerDao; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = {BaseCustomerControllerTest.Config.class}) +public abstract class BaseCustomerControllerTest extends AbstractControllerTest { + static final TypeReference> PAGE_DATA_CUSTOMER_TYPE_REFERENCE = new TypeReference<>() { + }; + + ListeningExecutorService executor; + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private CustomerDao customerDao; + + static class Config { + @Bean + @Primary + public CustomerDao customerDao(CustomerDao customerDao) { + return Mockito.mock(CustomerDao.class, AdditionalAnswers.delegatesTo(customerDao)); + } + } + + + @Before + public void beforeTest() throws Exception { + executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); + + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + executor.shutdownNow(); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveCustomer() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + + Mockito.reset(tbClusterService, auditLogService); + + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedCustomer, savedCustomer.getId(), savedCustomer.getId(), + savedCustomer.getTenantId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + Assert.assertNotNull(savedCustomer); + Assert.assertNotNull(savedCustomer.getId()); + Assert.assertTrue(savedCustomer.getCreatedTime() > 0); + Assert.assertEquals(customer.getTitle(), savedCustomer.getTitle()); + + savedCustomer.setTitle("My new customer"); + + doPost("/api/customer", savedCustomer, Customer.class); + + testNotifyEntityAllOneTime(savedCustomer, savedCustomer.getId(), savedCustomer.getId(), savedCustomer.getTenantId(), + new CustomerId(CustomerId.NULL_UUID), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + + Customer foundCustomer = doGet("/api/customer/" + savedCustomer.getId().getId().toString(), Customer.class); + Assert.assertEquals(foundCustomer.getTitle(), savedCustomer.getTitle()); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveCustomerWithViolationOfValidation() throws Exception { + Customer customer = new Customer(); + customer.setTitle(StringUtils.randomAlphabetic(300)); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = msgErrorFieldLength("title"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + customer.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + customer.setTitle("Normal title"); + customer.setCity(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("city"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + customer.setCity("Normal city"); + customer.setCountry(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("country"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + customer.setCountry("Ukraine"); + customer.setPhone(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("phone"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + customer.setPhone("+3892555554512"); + customer.setState(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("state"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + customer.setState("Normal state"); + customer.setZip(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("zip or postal code"); + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testUpdateCustomerFromDifferentTenant() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + doPost("/api/customer", savedCustomer, Customer.class); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer", savedCustomer, Customer.class, status().isForbidden()); + + testNotifyEntityNever(savedCustomer.getId(), savedCustomer); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedCustomer.getId(), savedCustomer); + + deleteDifferentTenant(); + login(tenantAdmin.getName(), "testPassword1"); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedCustomer, savedCustomer.getId(), + savedCustomer.getId(), savedCustomer.getTenantId(), savedCustomer.getId(), tenantAdmin.getId(), + tenantAdmin.getEmail(), ActionType.DELETED, savedCustomer.getId().getId().toString()); + } + + @Test + public void testFindCustomerById() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Customer foundCustomer = doGet("/api/customer/" + savedCustomer.getId().getId().toString(), Customer.class); + Assert.assertNotNull(foundCustomer); + Assert.assertEquals(savedCustomer, foundCustomer); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDeleteCustomer() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedCustomer, savedCustomer.getId(), + savedCustomer.getId(), savedCustomer.getTenantId(), savedCustomer.getId(), tenantAdmin.getId(), + tenantAdmin.getEmail(), ActionType.DELETED, savedCustomer.getId().getId().toString()); + + String customerIdStr = savedCustomer.getId().getId().toString(); + doGet("/api/customer/" + customerIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Customer", customerIdStr)))); + } + + @Test + public void testSaveCustomerWithEmptyTitle() throws Exception { + Customer customer = new Customer(); + String msgError = "Customer title " + msgErrorShouldBeSpecified; + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer,savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveCustomerWithInvalidEmail() throws Exception { + Customer customer = new Customer(); + String msgError = "Invalid email address format 'invalid@mail'"; + customer.setTitle("My customer"); + customer.setEmail("invalid@mail"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer", customer) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(customer, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testFindCustomers() throws Exception { + TenantId tenantId = savedTenant.getId(); + + int cntEntity = 135; + + Mockito.reset(tbClusterService, auditLogService); + + List> futures = new ArrayList<>(cntEntity); + for (int i = 0; i < cntEntity; i++) { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle("Customer" + i); + futures.add(executor.submit(() -> + doPost("/api/customer", customer, Customer.class))); + } + List customers = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Customer(), new Customer(), + tenantId, tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity); + + List loadedCustomers = new ArrayList<>(135); + PageLink pageLink = new PageLink(23); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customers?", PAGE_DATA_CUSTOMER_TYPE_REFERENCE, pageLink); + loadedCustomers.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(customers).containsExactlyInAnyOrderElementsOf(loadedCustomers); + + deleteEntitiesAsync("/api/customer/", loadedCustomers, executor).get(TIMEOUT, TimeUnit.SECONDS); + } + + @Test + public void testFindCustomersByTitle() throws Exception { + TenantId tenantId = savedTenant.getId(); + + String title1 = "Customer title 1"; + List> futures = new ArrayList<>(143); + for (int i = 0; i < 143; i++) { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + String suffix = StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)); + String title = title1 + suffix; + title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase(); + customer.setTitle(title); + futures.add(executor.submit(() -> + doPost("/api/customer", customer, Customer.class))); + } + List customersTitle1 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + String title2 = "Customer title 2"; + futures = new ArrayList<>(175); + for (int i = 0; i < 175; i++) { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + String suffix = StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)); + String title = title2 + suffix; + title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase(); + customer.setTitle(title); + futures.add(executor.submit(() -> + doPost("/api/customer", customer, Customer.class))); + } + + List customersTitle2 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + List loadedCustomersTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customers?", PAGE_DATA_CUSTOMER_TYPE_REFERENCE, pageLink); + loadedCustomersTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(customersTitle1).as(title1).containsExactlyInAnyOrderElementsOf(loadedCustomersTitle1); + + List loadedCustomersTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/customers?", PAGE_DATA_CUSTOMER_TYPE_REFERENCE, pageLink); + loadedCustomersTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(customersTitle2).as(title2).containsExactlyInAnyOrderElementsOf(loadedCustomersTitle2); + + deleteEntitiesAsync("/api/customer/", loadedCustomersTitle1, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/customers?", PAGE_DATA_CUSTOMER_TYPE_REFERENCE, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + deleteEntitiesAsync("/api/customer/", loadedCustomersTitle2, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/customers?", PAGE_DATA_CUSTOMER_TYPE_REFERENCE, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testDeleteCustomerWithDeleteRelationsOk() throws Exception { + CustomerId customerId = createCustomer("Customer for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), customerId, "/api/customer/" + customerId); + } + + @Test + public void testDeleteCustomerExceptionWithRelationsTransactional() throws Exception { + CustomerId customerId = createCustomer("Customer for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId); + } + + private Customer createCustomer(String title) { + Customer customer = new Customer(); + customer.setTitle(title); + return doPost("/api/customer", customer, Customer.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDashboardControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDashboardControllerTest.java new file mode 100644 index 0000000..c6bf570 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDashboardControllerTest.java @@ -0,0 +1,504 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.dashboard.DashboardDao; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = {BaseDashboardControllerTest.Config.class}) +public abstract class BaseDashboardControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private DashboardDao dashboardDao; + + static class Config { + @Bean + @Primary + public DashboardDao dashboardDao(DashboardDao dashboardDao) { + return Mockito.mock(DashboardDao.class, AdditionalAnswers.delegatesTo(dashboardDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveDashboardInfoWithViolationOfValidation() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle(StringUtils.randomAlphabetic(300)); + String msgError = msgErrorFieldLength("title"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/dashboard", dashboard) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + dashboard.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(dashboard, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + } + + @Test + public void testUpdateDashboardFromDifferentTenant() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/dashboard", savedDashboard, Dashboard.class, status().isForbidden()); + + testNotifyEntityNever(savedDashboard.getId(), savedDashboard); + + deleteDifferentTenant(); + } + + @Test + public void testFindDashboardById() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + Dashboard foundDashboard = doGet("/api/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + Assert.assertNotNull(foundDashboard); + Assert.assertEquals(savedDashboard, foundDashboard); + } + + @Test + public void testDeleteDashboard() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/dashboard/" + savedDashboard.getId().getId().toString()).andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedDashboard, savedDashboard.getId(), savedDashboard.getId(), + savedDashboard.getTenantId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.DELETED, + savedDashboard.getId().getId().toString()); + + String dashboardIdStr = savedDashboard.getId().getId().toString(); + doGet("/api/dashboard/" + savedDashboard.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Dashboard", dashboardIdStr)))); + } + + @Test + public void testSaveDashboardWithEmptyTitle() throws Exception { + Dashboard dashboard = new Dashboard(); + String msgError = "Dashboard title " + msgErrorShouldBeSpecified;; + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/dashboard", dashboard) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(dashboard, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testAssignUnassignDashboardToCustomer() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Mockito.reset(tbClusterService, auditLogService); + + Dashboard assignedDashboard = doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + Assert.assertTrue(assignedDashboard.getAssignedCustomers().contains(savedCustomer.toShortCustomerInfo())); + + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(assignedDashboard, assignedDashboard.getId(), assignedDashboard.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ASSIGNED_TO_CUSTOMER, + assignedDashboard .getId().getId().toString(), savedCustomer.getId().getId().toString(), savedCustomer.getTitle()); + + Dashboard foundDashboard = doGet("/api/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + Assert.assertTrue(foundDashboard.getAssignedCustomers().contains(savedCustomer.toShortCustomerInfo())); + + Mockito.reset(tbClusterService, auditLogService); + + Dashboard unassignedDashboard = + doDelete("/api/customer/" + savedCustomer.getId().getId().toString() + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(assignedDashboard, assignedDashboard.getId(), assignedDashboard.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UNASSIGNED_FROM_CUSTOMER, + unassignedDashboard.getId().getId().toString(), savedCustomer.getId().getId().toString(), savedCustomer.getTitle()); + + Assert.assertTrue(unassignedDashboard.getAssignedCustomers() == null || unassignedDashboard.getAssignedCustomers().isEmpty()); + + foundDashboard = doGet("/api/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + Assert.assertTrue(foundDashboard.getAssignedCustomers() == null || foundDashboard.getAssignedCustomers().isEmpty()); + } + + @Test + public void testAssignDashboardToNonExistentCustomer() throws Exception { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + String customerIdStr = Uuids.timeBased().toString(); + doPost("/api/customer/" + customerIdStr + + "/dashboard/" + savedDashboard.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Customer", customerIdStr)))); + + Mockito.reset(tbClusterService, auditLogService); + testNotifyEntityNever(savedDashboard.getId(), savedDashboard); + } + + @Test + public void testAssignDashboardToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant tenant2 = new Tenant(); + tenant2.setTitle("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 = new Customer(); + customer.setTitle("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + login(tenantAdmin.getEmail(), "testPassword1"); + + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/dashboard/" + savedDashboard.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + Mockito.reset(tbClusterService, auditLogService); + testNotifyEntityNever(savedDashboard.getId(), savedDashboard); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedDashboard.getId(), savedDashboard); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testFindTenantDashboards() throws Exception { + List dashboards = new ArrayList<>(); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 173; + for (int i = 0; i < cntEntity; i++) { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("Dashboard" + i); + dashboards.add(new DashboardInfo(doPost("/api/dashboard", dashboard, Dashboard.class))); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Dashboard(), new Dashboard(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity); + + List loadedDashboards = new ArrayList<>(); + PageLink pageLink = new PageLink(24); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/dashboards?", + new TypeReference>() { + }, pageLink); + loadedDashboards.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(dashboards, idComparator); + Collections.sort(loadedDashboards, idComparator); + + Assert.assertEquals(dashboards, loadedDashboards); + } + + @Test + public void testFindTenantDashboardsByTitle() throws Exception { + String title1 = "Dashboard title 1"; + List dashboardsTitle1 = new ArrayList<>(); + int cntEntity = 134; + for (int i = 0; i < cntEntity; i++) { + Dashboard dashboard = new Dashboard(); + String suffix = StringUtils.randomAlphanumeric((int) (Math.random() * 15)); + String title = title1 + suffix; + title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase(); + dashboard.setTitle(title); + dashboardsTitle1.add(new DashboardInfo(doPost("/api/dashboard", dashboard, Dashboard.class))); + } + String title2 = "Dashboard title 2"; + List dashboardsTitle2 = new ArrayList<>(); + + for (int i = 0; i < 112; i++) { + Dashboard dashboard = new Dashboard(); + String suffix = StringUtils.randomAlphanumeric((int) (Math.random() * 15)); + String title = title2 + suffix; + title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase(); + dashboard.setTitle(title); + dashboardsTitle2.add(new DashboardInfo(doPost("/api/dashboard", dashboard, Dashboard.class))); + } + + List loadedDashboardsTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/dashboards?", + new TypeReference>() { + }, pageLink); + loadedDashboardsTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(dashboardsTitle1, idComparator); + Collections.sort(loadedDashboardsTitle1, idComparator); + + Assert.assertEquals(dashboardsTitle1, loadedDashboardsTitle1); + + List loadedDashboardsTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/tenant/dashboards?", + new TypeReference>() { + }, pageLink); + loadedDashboardsTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(dashboardsTitle2, idComparator); + Collections.sort(loadedDashboardsTitle2, idComparator); + + Assert.assertEquals(dashboardsTitle2, loadedDashboardsTitle2); + + Mockito.reset(tbClusterService, auditLogService); + + for (DashboardInfo dashboard : loadedDashboardsTitle1) { + doDelete("/api/dashboard/" + dashboard.getId().getId().toString()) + .andExpect(status().isOk()); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceNeverAdditionalInfoAny(new Dashboard(), new Dashboard(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, cntEntity, 1); + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/tenant/dashboards?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (DashboardInfo dashboard : loadedDashboardsTitle2) { + doDelete("/api/dashboard/" + dashboard.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/tenant/dashboards?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerDashboards() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 173; + List dashboards = new ArrayList<>(); + for (int i = 0; i < cntEntity; i++) { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("Dashboard" + i); + dashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + dashboards.add(new DashboardInfo(doPost("/api/customer/" + customerId.getId().toString() + + "/dashboard/" + dashboard.getId().getId().toString(), Dashboard.class))); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new Dashboard(), new Dashboard(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ASSIGNED_TO_CUSTOMER, cntEntity, cntEntity, cntEntity*2); + + List loadedDashboards = new ArrayList<>(); + PageLink pageLink = new PageLink(21); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/dashboards?", + new TypeReference>() { + }, pageLink); + loadedDashboards.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(dashboards, idComparator); + Collections.sort(loadedDashboards, idComparator); + + Assert.assertEquals(dashboards, loadedDashboards); + } + + @Test + public void testAssignDashboardToEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/edge/" + savedEdge.getId().getId().toString() + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + testNotifyEntityAllOneTime(savedDashboard, savedDashboard.getId(), savedDashboard.getId(), savedTenant.getId(), + tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ASSIGNED_TO_EDGE, + savedDashboard.getId().getId().toString(), savedEdge.getId().getId().toString(), savedEdge.getName()); + + PageData pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/dashboards?", + new TypeReference>() { + }, new PageLink(100)); + + Assert.assertEquals(1, pageData.getData().size()); + + doDelete("/api/edge/" + savedEdge.getId().getId().toString() + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/dashboards?", + new TypeReference>() { + }, new PageLink(100)); + + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testDeleteDashboardWithDeleteRelationsOk() throws Exception { + DashboardId dashboardId = createDashboard("Dashboard for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), dashboardId, "/api/dashboard/" + dashboardId); + } + + @Test + public void testDeleteDashboardExceptionWithRelationsTransactional() throws Exception { + DashboardId dashboardId = createDashboard("Dashboard for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(dashboardDao, savedTenant.getId(), dashboardId, "/api/dashboard/" + dashboardId); + } + + private Dashboard createDashboard(String title) { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle(title); + return doPost("/api/dashboard", dashboard, Dashboard.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java new file mode 100644 index 0000000..0e9d840 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceControllerTest.java @@ -0,0 +1,1360 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceCredentialsId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumnType; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; +import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; +import org.thingsboard.server.dao.device.DeviceDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; +import static org.thingsboard.server.common.data.ota.OtaPackageType.SOFTWARE; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@ContextConfiguration(classes = {BaseDeviceControllerTest.Config.class}) +public abstract class BaseDeviceControllerTest extends AbstractControllerTest { + static final TypeReference> PAGE_DATA_DEVICE_TYPE_REF = new TypeReference<>() { + }; + + ListeningExecutorService executor; + + List> futures; + PageData pageData; + + private Tenant savedTenant; + private User tenantAdmin; + + @SpyBean + private GatewayNotificationsService gatewayNotificationsService; + + @Autowired + private DeviceDao deviceDao; + + static class Config { + @Bean + @Primary + public DeviceDao deviceDao(DeviceDao deviceDao) { + return Mockito.mock(DeviceDao.class, AdditionalAnswers.delegatesTo(deviceDao)); + } + } + + @Before + public void beforeTest() throws Exception { + executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); + + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + executor.shutdownNow(); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveDevice() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device savedDevice = doPost("/api/device", device, Device.class); + + Device oldDevice = new Device(savedDevice); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedDevice, savedDevice.getId(), savedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + testNotificationUpdateGatewayNever(); + + Assert.assertNotNull(savedDevice); + Assert.assertNotNull(savedDevice.getId()); + Assert.assertTrue(savedDevice.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedDevice.getTenantId()); + Assert.assertNotNull(savedDevice.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedDevice.getCustomerId().getId()); + Assert.assertEquals(device.getName(), savedDevice.getName()); + + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + + Assert.assertNotNull(deviceCredentials); + Assert.assertNotNull(deviceCredentials.getId()); + Assert.assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId()); + Assert.assertEquals(DeviceCredentialsType.ACCESS_TOKEN, deviceCredentials.getCredentialsType()); + Assert.assertNotNull(deviceCredentials.getCredentialsId()); + Assert.assertEquals(20, deviceCredentials.getCredentialsId().length()); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + savedDevice.setName("My new device"); + doPost("/api/device", savedDevice, Device.class); + + testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), + tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); + testNotificationUpdateGatewayOneTime(savedDevice, oldDevice); + + Device foundDevice = doGet("/api/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(foundDevice.getName(), savedDevice.getName()); + } + + @Test + public void testSaveDeviceWithCredentials() throws Exception { + String testToken = "TEST_TOKEN"; + + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId(testToken); + + SaveDeviceWithCredentialsRequest saveRequest = new SaveDeviceWithCredentialsRequest(device, deviceCredentials); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device savedDevice = readResponse(doPost("/api/device-with-credentials", saveRequest).andExpect(status().isOk()), Device.class); + + Device oldDevice = new Device(savedDevice); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedDevice, savedDevice.getId(), savedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + testNotificationUpdateGatewayNever(); + + Assert.assertNotNull(savedDevice); + Assert.assertNotNull(savedDevice.getId()); + Assert.assertTrue(savedDevice.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedDevice.getTenantId()); + Assert.assertNotNull(savedDevice.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedDevice.getCustomerId().getId()); + Assert.assertEquals(device.getName(), savedDevice.getName()); + + DeviceCredentials foundDeviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + + Assert.assertNotNull(foundDeviceCredentials); + Assert.assertNotNull(foundDeviceCredentials.getId()); + Assert.assertEquals(savedDevice.getId(), foundDeviceCredentials.getDeviceId()); + Assert.assertEquals(DeviceCredentialsType.ACCESS_TOKEN, foundDeviceCredentials.getCredentialsType()); + Assert.assertEquals(testToken, foundDeviceCredentials.getCredentialsId()); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + savedDevice.setName("My new device"); + doPost("/api/device", savedDevice, Device.class); + + testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), + tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED); + testNotificationUpdateGatewayOneTime(savedDevice, oldDevice); + } + + @Test + public void saveDeviceWithViolationOfValidation() throws Exception { + Device device = new Device(); + device.setName(StringUtils.randomAlphabetic(300)); + device.setType("default"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String msgError = msgErrorFieldLength("name"); + doPost("/api/device", device) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + testNotificationUpdateGatewayNever(); + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + device.setTenantId(savedTenant.getId()); + msgError = msgErrorFieldLength("type"); + device.setType(StringUtils.randomAlphabetic(300)); + doPost("/api/device", device) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + testNotificationUpdateGatewayNever(); + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + msgError = msgErrorFieldLength("label"); + device.setType("Normal type"); + device.setLabel(StringUtils.randomAlphabetic(300)); + doPost("/api/device", device) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testUpdateDeviceFromDifferentTenant() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String savedDeviceIdStr = savedDevice.getId().getId().toString(); + doPost("/api/device", savedDevice) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", savedDeviceIdStr)))); + + testNotifyEntityNever(savedDevice.getId(), savedDevice); + testNotificationUpdateGatewayNever(); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doDelete("/api/device/" + savedDeviceIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", savedDeviceIdStr)))); + + testNotifyEntityNever(savedDevice.getId(), savedDevice); + testNotificationUpdateGatewayNever(); + + deleteDifferentTenant(); + } + + @Test + public void testSaveDeviceWithProfileFromDifferentTenant() throws Exception { + loginDifferentTenant(); + DeviceProfile differentProfile = createDeviceProfile("Different profile"); + differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class); + + loginTenantAdmin(); + Device device = new Device(); + device.setName("My device"); + device.setDeviceProfileId(differentProfile.getId()); + doPost("/api/device", device).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Device can`t be referencing to device profile from different tenant!"))); + } + + @Test + public void testSaveDeviceWithFirmwareFromDifferentTenant() throws Exception { + loginDifferentTenant(); + DeviceProfile differentProfile = createDeviceProfile("Different profile"); + differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class); + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(differentProfile.getId()); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle("title"); + firmwareInfo.setVersion("1.0"); + firmwareInfo.setUrl("test.url"); + firmwareInfo.setUsesUrl(true); + OtaPackageInfo savedFw = doPost("/api/otaPackage", firmwareInfo, OtaPackageInfo.class); + + loginTenantAdmin(); + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + device.setFirmwareId(savedFw.getId()); + doPost("/api/device", device).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign firmware from different tenant!"))); + } + + @Test + public void testSaveDeviceWithSoftwareFromDifferentTenant() throws Exception { + loginDifferentTenant(); + DeviceProfile differentProfile = createDeviceProfile("Different profile"); + differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class); + SaveOtaPackageInfoRequest softwareInfo = new SaveOtaPackageInfoRequest(); + softwareInfo.setDeviceProfileId(differentProfile.getId()); + softwareInfo.setType(SOFTWARE); + softwareInfo.setTitle("title"); + softwareInfo.setVersion("1.0"); + softwareInfo.setUrl("test.url"); + softwareInfo.setUsesUrl(true); + OtaPackageInfo savedSw = doPost("/api/otaPackage", softwareInfo, OtaPackageInfo.class); + + loginTenantAdmin(); + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + device.setSoftwareId(savedSw.getId()); + doPost("/api/device", device).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign software from different tenant!"))); + } + + @Test + public void testFindDeviceById() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + Device foundDevice = doGet("/api/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertNotNull(foundDevice); + Assert.assertEquals(savedDevice, foundDevice); + } + + @Test + public void testFindDeviceTypesByTenantId() throws Exception { + List devices = new ArrayList<>(); + + int cntEntity = 3; + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + for (int i = 0; i < cntEntity; i++) { + Device device = new Device(); + device.setName("My device B" + i); + device.setType("typeB"); + devices.add(doPost("/api/device", device, Device.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Device(), new Device(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity); + testNotificationUpdateGatewayNever(); + + for (int i = 0; i < 7; i++) { + Device device = new Device(); + device.setName("My device C" + i); + device.setType("typeC"); + devices.add(doPost("/api/device", device, Device.class)); + } + for (int i = 0; i < 9; i++) { + Device device = new Device(); + device.setName("My device A" + i); + device.setType("typeA"); + devices.add(doPost("/api/device", device, Device.class)); + } + List deviceTypes = doGetTyped("/api/device/types", + new TypeReference<>() { + }); + + Assert.assertNotNull(deviceTypes); + Assert.assertEquals(3, deviceTypes.size()); + Assert.assertEquals("typeA", deviceTypes.get(0).getType()); + Assert.assertEquals("typeB", deviceTypes.get(1).getType()); + Assert.assertEquals("typeC", deviceTypes.get(2).getType()); + + deleteEntitiesAsync("/api/device/", devices, executor).get(TIMEOUT, TimeUnit.SECONDS); + } + + @Test + public void testDeleteDevice() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doDelete("/api/device/" + savedDevice.getId().getId()) + .andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), + tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.DELETED, savedDevice.getId().getId().toString()); + testNotificationDeleteGatewayOneTime(savedDevice); + + EntityId savedDeviceId = savedDevice.getId(); + doGet("/api/device/" + savedDeviceId) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", savedDeviceId.getId().toString())))); + } + + @Test + public void testSaveDeviceWithEmptyType() throws Exception { + Device device = new Device(); + device.setName("My device"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device savedDevice = doPost("/api/device", device, Device.class); + Assert.assertEquals("default", savedDevice.getType()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedDevice, savedDevice.getId(), savedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testSaveDeviceWithEmptyName() throws Exception { + Device device = new Device(); + device.setType("default"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String msgError = "Device name " + msgErrorShouldBeSpecified; + doPost("/api/device", device) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testAssignUnassignDeviceToCustomer() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device assignedDevice = doPost("/api/customer/" + savedCustomer.getId().getId() + + "/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(savedCustomer.getId(), assignedDevice.getCustomerId()); + + testNotifyEntityAllOneTime(assignedDevice, assignedDevice.getId(), assignedDevice.getId(), savedTenant.getId(), + savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ASSIGNED_TO_CUSTOMER, + assignedDevice.getId().getId().toString(), savedCustomer.getId().getId().toString(), + savedCustomer.getTitle()); + testNotificationUpdateGatewayNever(); + + Device foundDevice = doGet("/api/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(savedCustomer.getId(), foundDevice.getCustomerId()); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device unassignedDevice = + doDelete("/api/customer/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(ModelConstants.NULL_UUID, unassignedDevice.getCustomerId().getId()); + + testNotifyEntityAllOneTime(unassignedDevice, unassignedDevice.getId(), unassignedDevice.getId(), savedTenant.getId(), + savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UNASSIGNED_FROM_CUSTOMER, + unassignedDevice.getId().getId().toString(), savedCustomer.getId().getId().toString(), + savedCustomer.getTitle()); + testNotificationDeleteGatewayNever(); + + foundDevice = doGet("/api/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(ModelConstants.NULL_UUID, foundDevice.getCustomerId().getId()); + } + + @Test + public void testAssignDeviceToNonExistentCustomer() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String customerIdStr = savedDevice.getId().toString(); + doPost("/api/customer/" + customerIdStr + + "/device/" + savedDevice.getId().getId()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Customer", customerIdStr)))); + + testNotifyEntityNever(savedDevice.getId(), savedDevice); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testAssignDeviceToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant tenant2 = new Tenant(); + tenant2.setTitle("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 = new Customer(); + customer.setTitle("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + login(tenantAdmin.getEmail(), "testPassword1"); + + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doPost("/api/customer/" + savedCustomer.getId().getId() + + "/device/" + savedDevice.getId().getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedDevice.getId(), savedDevice); + testNotificationUpdateGatewayNever(); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testFindDeviceCredentialsByDeviceId() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + Assert.assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId()); + } + + @Test + public void testSaveDeviceCredentials() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + Assert.assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId()); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId("access_token"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doPost("/api/device/credentials", deviceCredentials) + .andExpect(status().isOk()); + + testNotifyEntityMsgToEdgePushMsgToCoreOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), savedTenant.getId(), + tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.CREDENTIALS_UPDATED, deviceCredentials); + testNotificationUpdateGatewayNever(); + + DeviceCredentials foundDeviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + + Assert.assertEquals(deviceCredentials, foundDeviceCredentials); + } + + @Test + public void testSaveDeviceCredentialsWithEmptyDevice() throws Exception { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doPost("/api/device/credentials", deviceCredentials) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Incorrect deviceId null"))); + + testNotifyEntityNever(deviceCredentials.getDeviceId(), new Device()); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testSaveDeviceCredentialsWithEmptyCredentialsType() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + deviceCredentials.setCredentialsType(null); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String msgError = "Device credentials type " + msgErrorShouldBeSpecified; + doPost("/api/device/credentials", deviceCredentials) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityIsNullOneTimeEdgeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.CREDENTIALS_UPDATED, + new DataValidationException(msgError), deviceCredentials); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testSaveDeviceCredentialsWithEmptyCredentialsId() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + deviceCredentials.setCredentialsId(null); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String msgError = "Device credentials id " + msgErrorShouldBeSpecified; + doPost("/api/device/credentials", deviceCredentials) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityIsNullOneTimeEdgeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.CREDENTIALS_UPDATED, + new DeviceCredentialsValidationException(msgError), deviceCredentials); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testSaveNonExistentDeviceCredentials() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + DeviceCredentials newDeviceCredentials = new DeviceCredentials(new DeviceCredentialsId(Uuids.timeBased())); + newDeviceCredentials.setCreatedTime(deviceCredentials.getCreatedTime()); + newDeviceCredentials.setDeviceId(deviceCredentials.getDeviceId()); + newDeviceCredentials.setCredentialsType(deviceCredentials.getCredentialsType()); + newDeviceCredentials.setCredentialsId(deviceCredentials.getCredentialsId()); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + String msgError = "Unable to update non-existent device credentials"; + doPost("/api/device/credentials", newDeviceCredentials) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityIsNullOneTimeEdgeServiceNeverError(device, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.CREDENTIALS_UPDATED, + new DeviceCredentialsValidationException(msgError), newDeviceCredentials); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testSaveDeviceCredentialsWithNonExistentDevice() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + DeviceId deviceTimeBasedId = new DeviceId(Uuids.timeBased()); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + deviceCredentials.setDeviceId(deviceTimeBasedId); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doPost("/api/device/credentials", deviceCredentials) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", deviceTimeBasedId.toString())))); + + testNotifyEntityNever(savedDevice.getId(), savedDevice); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testFindTenantDevices() throws Exception { + int cntEntity = 178; + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + futures = new ArrayList<>(cntEntity); + for (int i = 0; i < cntEntity; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + futures.add(executor.submit(() -> + doPost("/api/device", device, Device.class))); + } + + List devices = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + testNotifyManyEntityManyTimeMsgToEdgeServiceNever(new Device(), new Device(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity); + testNotificationUpdateGatewayNever(); + + List loadedDevices = new ArrayList<>(cntEntity); + PageLink pageLink = new PageLink(23); + do { + pageData = doGetTypedWithPageLink("/api/tenant/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + + loadedDevices.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devices).containsExactlyInAnyOrderElementsOf(loadedDevices); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + deleteEntitiesAsync("/api/device/", loadedDevices, executor).get(TIMEOUT, TimeUnit.SECONDS); + + testNotifyManyEntityManyTimeMsgToEdgeServiceNeverAdditionalInfoAny(new Device(), new Device(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, cntEntity, 1); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testFindTenantDevicesByName() throws Exception { + String title1 = "Device title 1"; + + futures = new ArrayList<>(143); + for (int i = 0; i < 143; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType("default"); + futures.add(executor.submit(() -> + doPost("/api/device", device, Device.class))); + } + List devicesTitle1 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + String title2 = "Device title 2"; + futures = new ArrayList<>(75); + for (int i = 0; i < 75; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType("default"); + futures.add(executor.submit(() -> + doPost("/api/device", device, Device.class))); + } + List devicesTitle2 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + List loadedDevicesTitle1 = new ArrayList<>(143); + PageLink pageLink = new PageLink(15, 0, title1); + do { + pageData = doGetTypedWithPageLink("/api/tenant/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + loadedDevicesTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesTitle1).as(title1).containsExactlyInAnyOrderElementsOf(loadedDevicesTitle1); + + List loadedDevicesTitle2 = new ArrayList<>(75); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/tenant/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + loadedDevicesTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesTitle2).as(title2).containsExactlyInAnyOrderElementsOf(loadedDevicesTitle2); + + deleteEntitiesAsync("/api/device/", loadedDevicesTitle1, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/tenant/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + deleteEntitiesAsync("/api/device/", loadedDevicesTitle2, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/tenant/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindTenantDevicesByType() throws Exception { + String title1 = "Device title 1"; + String type1 = "typeA"; + futures = new ArrayList<>(143); + for (int i = 0; i < 143; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType(type1); + futures.add(executor.submit(() -> + doPost("/api/device", device, Device.class))); + if (i == 0) { + futures.get(0).get(TIMEOUT, TimeUnit.SECONDS); // wait for the device profile created first time + } + } + List devicesType1 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + String title2 = "Device title 2"; + String type2 = "typeB"; + futures = new ArrayList<>(75); + for (int i = 0; i < 75; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType(type2); + futures.add(executor.submit(() -> + doPost("/api/device", device, Device.class))); + if (i == 0) { + futures.get(0).get(TIMEOUT, TimeUnit.SECONDS); // wait for the device profile created first time + } + } + + List devicesType2 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + List loadedDevicesType1 = new ArrayList<>(143); + PageLink pageLink = new PageLink(15); + do { + pageData = doGetTypedWithPageLink("/api/tenant/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type1); + loadedDevicesType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesType1).as(title1).containsExactlyInAnyOrderElementsOf(loadedDevicesType1); + + List loadedDevicesType2 = new ArrayList<>(75); + pageLink = new PageLink(4); + do { + pageData = doGetTypedWithPageLink("/api/tenant/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type2); + loadedDevicesType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesType2).as(title2).containsExactlyInAnyOrderElementsOf(loadedDevicesType2); + + deleteEntitiesAsync("/api/device/", loadedDevicesType1, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + deleteEntitiesAsync("/api/device/", loadedDevicesType2, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerDevices() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + int cntEntity = 128; + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + futures = new ArrayList<>(cntEntity); + for (int i = 0; i < cntEntity; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + ListenableFuture future = executor.submit(() -> doPost("/api/device", device, Device.class)); + futures.add(Futures.transform(future, (dev) -> + doPost("/api/customer/" + customerId.getId() + + "/device/" + dev.getId().getId(), Device.class), MoreExecutors.directExecutor())); + } + + List devices = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new Device(), new Device(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ASSIGNED_TO_CUSTOMER, cntEntity, cntEntity, cntEntity * 2); + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + testNotificationUpdateGatewayNever(); + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + List loadedDevices = new ArrayList<>(cntEntity); + PageLink pageLink = new PageLink(23); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + loadedDevices.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devices).containsExactlyInAnyOrderElementsOf(loadedDevices); + + deleteEntitiesAsync("/api/customer/device/", loadedDevices, executor).get(TIMEOUT, TimeUnit.SECONDS); + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(new Device(), new Device(), + savedTenant.getId(), customerId, tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_CUSTOMER, ActionType.UNASSIGNED_FROM_CUSTOMER, cntEntity, cntEntity, 3); + testNotificationUpdateGatewayNever(); + } + + @Test + public void testFindCustomerDevicesByName() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Device title 1"; + futures = new ArrayList<>(125); + for (int i = 0; i < 125; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType("default"); + ListenableFuture future = executor.submit(() -> doPost("/api/device", device, Device.class)); + futures.add(Futures.transform(future, (dev) -> + doPost("/api/customer/" + customerId.getId() + + "/device/" + dev.getId().getId(), Device.class), MoreExecutors.directExecutor())); + } + List devicesTitle1 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + String title2 = "Device title 2"; + futures = new ArrayList<>(143); + for (int i = 0; i < 143; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType("default"); + ListenableFuture future = executor.submit(() -> doPost("/api/device", device, Device.class)); + futures.add(Futures.transform(future, (dev) -> + doPost("/api/customer/" + customerId.getId() + + "/device/" + dev.getId().getId(), Device.class), MoreExecutors.directExecutor())); + } + List devicesTitle2 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + List loadedDevicesTitle1 = new ArrayList<>(125); + PageLink pageLink = new PageLink(15, 0, title1); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + loadedDevicesTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesTitle1).as(title1).containsExactlyInAnyOrderElementsOf(loadedDevicesTitle1); + + List loadedDevicesTitle2 = new ArrayList<>(143); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + loadedDevicesTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesTitle2).as(title2).containsExactlyInAnyOrderElementsOf(loadedDevicesTitle2); + + deleteEntitiesAsync("/api/customer/device/", loadedDevicesTitle1, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + deleteEntitiesAsync("/api/customer/device/", loadedDevicesTitle2, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerDevicesByType() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Device title 1"; + String type1 = "typeC"; + futures = new ArrayList<>(125); + for (int i = 0; i < 125; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType(type1); + ListenableFuture future = executor.submit(() -> doPost("/api/device", device, Device.class)); + futures.add(Futures.transform(future, (dev) -> + doPost("/api/customer/" + customerId.getId() + + "/device/" + dev.getId().getId(), Device.class), MoreExecutors.directExecutor())); + if (i == 0) { + futures.get(0).get(TIMEOUT, TimeUnit.SECONDS); // wait for the device profile created first time + } + } + List devicesType1 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + String title2 = "Device title 2"; + String type2 = "typeD"; + futures = new ArrayList<>(143); + for (int i = 0; i < 143; i++) { + Device device = new Device(); + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + device.setName(name); + device.setType(type2); + ListenableFuture future = executor.submit(() -> doPost("/api/device", device, Device.class)); + futures.add(Futures.transform(future, (dev) -> + doPost("/api/customer/" + customerId.getId() + + "/device/" + dev.getId().getId(), Device.class), MoreExecutors.directExecutor())); + if (i == 0) { + futures.get(0).get(TIMEOUT, TimeUnit.SECONDS); // wait for the device profile created first time + } + } + List devicesType2 = Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); + + List loadedDevicesType1 = new ArrayList<>(125); + PageLink pageLink = new PageLink(15); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type1); + loadedDevicesType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesType1).as(title1).containsExactlyInAnyOrderElementsOf(loadedDevicesType1); + + List loadedDevicesType2 = new ArrayList<>(143); + pageLink = new PageLink(4); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type2); + loadedDevicesType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + assertThat(devicesType2).as(title2).containsExactlyInAnyOrderElementsOf(loadedDevicesType2); + + deleteEntitiesAsync("/api/customer/device/", loadedDevicesType1, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + deleteEntitiesAsync("/api/customer/device/", loadedDevicesType2, executor).get(TIMEOUT, TimeUnit.SECONDS); + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId() + "/devices?type={type}&", + PAGE_DATA_DEVICE_TYPE_REF, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testAssignDeviceToTenant() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Device anotherDevice = new Device(); + anotherDevice.setName("My device1"); + anotherDevice.setType("default"); + Device savedAnotherDevice = doPost("/api/device", anotherDevice, Device.class); + + EntityRelation relation = new EntityRelation(); + relation.setFrom(savedDevice.getId()); + relation.setTo(savedAnotherDevice.getId()); + relation.setTypeGroup(RelationTypeGroup.COMMON); + relation.setType("Contains"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + loginSysAdmin(); + Tenant tenant = new Tenant(); + tenant.setTitle("Different tenant"); + Tenant savedDifferentTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedDifferentTenant); + + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setTenantId(savedDifferentTenant.getId()); + user.setEmail("tenant9@thingsboard.org"); + user.setFirstName("Sam"); + user.setLastName("Downs"); + + createUserAndLogin(user, "testPassword1"); + + login("tenant2@thingsboard.org", "testPassword1"); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + Device assignedDevice = doPost("/api/tenant/" + savedDifferentTenant.getId().getId() + "/device/" + + savedDevice.getId().getId(), Device.class); + + doGet("/api/device/" + assignedDevice.getId().getId()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", assignedDevice.getId().getId().toString())))); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(assignedDevice, assignedDevice.getId(), assignedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ASSIGNED_TO_TENANT, savedDifferentTenant.getId().getId().toString(), savedDifferentTenant.getTitle()); + testNotificationUpdateGatewayNever(); + + login("tenant9@thingsboard.org", "testPassword1"); + + Device foundDevice1 = doGet("/api/device/" + assignedDevice.getId().getId(), Device.class); + Assert.assertNotNull(foundDevice1); + + doGet("/api/relation?fromId=" + savedDevice.getId().getId() + "&fromType=DEVICE&relationType=Contains&toId=" + + savedAnotherDevice.getId().getId() + "&toType=DEVICE") + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", savedAnotherDevice.getId().getId().toString())))); + + loginSysAdmin(); + doDelete("/api/tenant/" + savedDifferentTenant.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testAssignDeviceToEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doPost("/api/edge/" + savedEdge.getId().getId() + + "/device/" + savedDevice.getId().getId(), Device.class); + + testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ASSIGNED_TO_EDGE, + savedDevice.getId().getId().toString(), savedEdge.getId().getId().toString(), savedEdge.getName()); + testNotificationUpdateGatewayNever(); + + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, new PageLink(100)); + + Assert.assertEquals(1, pageData.getData().size()); + + Mockito.reset(tbClusterService, auditLogService, gatewayNotificationsService); + + doDelete("/api/edge/" + savedEdge.getId().getId() + + "/device/" + savedDevice.getId().getId(), Device.class); + + testNotifyEntityAllOneTime(savedDevice, savedDevice.getId(), savedDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_EDGE, savedDevice.getId().getId().toString(), savedEdge.getId().getId().toString(), savedEdge.getName()); + testNotificationUpdateGatewayNever(); + + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId() + "/devices?", + PAGE_DATA_DEVICE_TYPE_REF, new PageLink(100)); + + Assert.assertEquals(0, pageData.getData().size()); + } + + protected void testNotificationUpdateGatewayOneTime(Device device, Device oldDevice) { + Mockito.verify(gatewayNotificationsService, times(1)).onDeviceUpdated(Mockito.eq(device), Mockito.eq(oldDevice)); + } + + protected void testNotificationUpdateGatewayNever() { + Mockito.verify(gatewayNotificationsService, never()).onDeviceUpdated(Mockito.any(Device.class), Mockito.any(Device.class)); + } + + protected void testNotificationDeleteGatewayOneTime(Device device) { + Mockito.verify(gatewayNotificationsService, times(1)).onDeviceDeleted(device); + } + + protected void testNotificationDeleteGatewayNever() { + Mockito.verify(gatewayNotificationsService, never()).onDeviceDeleted(Mockito.any(Device.class)); + } + + @Test + public void testDeleteDashboardWithDeleteRelationsOk() throws Exception { + DeviceId deviceId = createDevice("Device for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), deviceId, "/api/device/" + deviceId); + } + + @Test + public void testDeleteDeviceExceptionWithRelationsTransactional() throws Exception { + DeviceId deviceId = createDevice("Device for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(deviceDao, savedTenant.getId(), deviceId, "/api/device/" + deviceId); + } + + @Test + public void testBulkImportDeviceWithoutCredentials() throws Exception { + String deviceName = "some_device"; + String deviceType = "some_type"; + BulkImportRequest request = new BulkImportRequest(); + request.setFile(String.format("NAME,TYPE\n%s,%s", deviceName, deviceType)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(1, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + Assert.assertNotNull(savedDevice); + Assert.assertEquals(deviceName, savedDevice.getName()); + Assert.assertEquals(deviceType, savedDevice.getType()); + + DeviceCredentials savedCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + + Assert.assertNotNull(savedCredentials); + Assert.assertNotNull(savedCredentials.getId()); + Assert.assertEquals(savedDevice.getId(), savedCredentials.getDeviceId()); + Assert.assertEquals(DeviceCredentialsType.ACCESS_TOKEN, savedCredentials.getCredentialsType()); + Assert.assertNotNull(savedCredentials.getCredentialsId()); + Assert.assertEquals(20, savedCredentials.getCredentialsId().length()); + + deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(0, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(1, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device updatedDevice = doGet("/api/device/" + savedDevice.getId().getId(), Device.class); + Assert.assertEquals(savedDevice, updatedDevice); + + DeviceCredentials updatedCredentials = + doGet("/api/device/" + savedDevice.getId().getId() + "/credentials", DeviceCredentials.class); + + Assert.assertEquals(savedCredentials, updatedCredentials); + } + + private Device createDevice(String name) { + Device device = new Device(); + device.setName(name); + device.setType("default"); + return doPost("/api/device", device, Device.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java new file mode 100644 index 0000000..3fe80b1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -0,0 +1,1004 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileInfo; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.profile.JsonTransportPayloadConfiguration; +import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; +import static org.thingsboard.server.common.data.ota.OtaPackageType.SOFTWARE; + +@ContextConfiguration(classes = {BaseDeviceProfileControllerTest.Config.class}) +public abstract class BaseDeviceProfileControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + private IdComparator deviceProfileInfoIdComparator = new IdComparator<>(); + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private DeviceProfileDao deviceProfileDao; + + static class Config { + @Bean + @Primary + public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) { + return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveDeviceProfile() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + + Mockito.reset(tbClusterService, auditLogService); + + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Assert.assertNotNull(savedDeviceProfile); + Assert.assertNotNull(savedDeviceProfile.getId()); + Assert.assertTrue(savedDeviceProfile.getCreatedTime() > 0); + Assert.assertEquals(deviceProfile.getName(), savedDeviceProfile.getName()); + Assert.assertEquals(deviceProfile.getDescription(), savedDeviceProfile.getDescription()); + Assert.assertEquals(deviceProfile.getProfileData(), savedDeviceProfile.getProfileData()); + Assert.assertEquals(deviceProfile.isDefault(), savedDeviceProfile.isDefault()); + Assert.assertEquals(deviceProfile.getDefaultRuleChainId(), savedDeviceProfile.getDefaultRuleChainId()); + Assert.assertEquals(DeviceProfileProvisionType.DISABLED, savedDeviceProfile.getProvisionType()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTime(savedDeviceProfile, savedDeviceProfile.getId(), savedDeviceProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + savedDeviceProfile.setName("New device profile"); + doPost("/api/deviceProfile", savedDeviceProfile, DeviceProfile.class); + DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class); + Assert.assertEquals(savedDeviceProfile.getName(), foundDeviceProfile.getName()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTime(foundDeviceProfile, foundDeviceProfile.getId(), foundDeviceProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void saveDeviceProfileWithViolationOfValidation() throws Exception { + String msgError = msgErrorFieldLength("name"); + + Mockito.reset(tbClusterService, auditLogService); + + DeviceProfile createDeviceProfile = this.createDeviceProfile(StringUtils.randomAlphabetic(300)); + doPost("/api/deviceProfile", createDeviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(createDeviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testFindDeviceProfileById() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class); + Assert.assertNotNull(foundDeviceProfile); + Assert.assertEquals(savedDeviceProfile, foundDeviceProfile); + } + + @Test + public void whenGetDeviceProfileById_thenPermissionsAreChecked() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + loginDifferentTenant(); + + doGet("/api/deviceProfile/" + deviceProfile.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindDeviceProfileInfoById() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + DeviceProfileInfo foundDeviceProfileInfo = doGet("/api/deviceProfileInfo/" + savedDeviceProfile.getId().getId().toString(), DeviceProfileInfo.class); + Assert.assertNotNull(foundDeviceProfileInfo); + Assert.assertEquals(savedDeviceProfile.getId(), foundDeviceProfileInfo.getId()); + Assert.assertEquals(savedDeviceProfile.getName(), foundDeviceProfileInfo.getName()); + Assert.assertEquals(savedDeviceProfile.getType(), foundDeviceProfileInfo.getType()); + + Customer customer = new Customer(); + customer.setTitle("Customer"); + customer.setTenantId(savedTenant.getId()); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + User customerUser = new User(); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedTenant.getId()); + customerUser.setCustomerId(savedCustomer.getId()); + customerUser.setEmail("customer2@thingsboard.org"); + + createUserAndLogin(customerUser, "customer"); + + foundDeviceProfileInfo = doGet("/api/deviceProfileInfo/" + savedDeviceProfile.getId().getId().toString(), DeviceProfileInfo.class); + Assert.assertNotNull(foundDeviceProfileInfo); + Assert.assertEquals(savedDeviceProfile.getId(), foundDeviceProfileInfo.getId()); + Assert.assertEquals(savedDeviceProfile.getName(), foundDeviceProfileInfo.getName()); + Assert.assertEquals(savedDeviceProfile.getType(), foundDeviceProfileInfo.getType()); + } + + @Test + public void whenGetDeviceProfileInfoById_thenPermissionsAreChecked() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + loginDifferentTenant(); + doGet("/api/deviceProfileInfo/" + deviceProfile.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + } + + @Test + public void testFindDefaultDeviceProfileInfo() throws Exception { + DeviceProfileInfo foundDefaultDeviceProfileInfo = doGet("/api/deviceProfileInfo/default", DeviceProfileInfo.class); + Assert.assertNotNull(foundDefaultDeviceProfileInfo); + Assert.assertNotNull(foundDefaultDeviceProfileInfo.getId()); + Assert.assertNotNull(foundDefaultDeviceProfileInfo.getName()); + Assert.assertNotNull(foundDefaultDeviceProfileInfo.getType()); + Assert.assertEquals(DeviceProfileType.DEFAULT, foundDefaultDeviceProfileInfo.getType()); + Assert.assertEquals("default", foundDefaultDeviceProfileInfo.getName()); + } + + @Test + public void testSetDefaultDeviceProfile() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile 1"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + Mockito.reset(tbClusterService, auditLogService); + + DeviceProfile defaultDeviceProfile = doPost("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString() + "/default", DeviceProfile.class); + Assert.assertNotNull(defaultDeviceProfile); + DeviceProfileInfo foundDefaultDeviceProfile = doGet("/api/deviceProfileInfo/default", DeviceProfileInfo.class); + Assert.assertNotNull(foundDefaultDeviceProfile); + Assert.assertEquals(savedDeviceProfile.getName(), foundDefaultDeviceProfile.getName()); + Assert.assertEquals(savedDeviceProfile.getId(), foundDefaultDeviceProfile.getId()); + Assert.assertEquals(savedDeviceProfile.getType(), foundDefaultDeviceProfile.getType()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(defaultDeviceProfile, defaultDeviceProfile.getId(), defaultDeviceProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void testSaveDeviceProfileWithEmptyName() throws Exception { + DeviceProfile deviceProfile = new DeviceProfile(); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile name " + msgErrorShouldBeSpecified; + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveDeviceProfileWithSameName() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isOk()); + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such name already exists"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveDeviceProfileWithSameProvisionDeviceKey() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setProvisionDeviceKey("testProvisionDeviceKey"); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isOk()); + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); + deviceProfile2.setProvisionDeviceKey("testProvisionDeviceKey"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such provision device key already exists"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testChangeDeviceProfileTypeNull() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + Mockito.reset(tbClusterService, auditLogService); + + savedDeviceProfile.setType(null); + String msgError = "Device profile type " + msgErrorShouldBeSpecified; + doPost("/api/deviceProfile", savedDeviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED, new DataValidationException(msgError)); + } + + @Test + public void testChangeDeviceProfileTransportTypeWithExistingDevices() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Device device = new Device(); + device.setName("Test device"); + device.setType("default"); + device.setDeviceProfileId(savedDeviceProfile.getId()); + doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Can't change device profile transport type because devices referenced it"; + savedDeviceProfile.setTransportType(DeviceTransportType.MQTT); + doPost("/api/deviceProfile", savedDeviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UPDATED, new DataValidationException(msgError)); + } + + @Test + public void testDeleteDeviceProfileWithExistingDevice() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + Device device = new Device(); + device.setName("Test device"); + device.setType("default"); + device.setDeviceProfileId(savedDeviceProfile.getId()); + + doPost("/api/device", device, Device.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString()) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("The device profile referenced by the devices cannot be deleted"))); + + testNotifyEntityNever(savedDeviceProfile.getId(), savedDeviceProfile); + } + + @Test + public void testSaveDeviceProfileWithRuleChainFromDifferentTenant() throws Exception { + loginDifferentTenant(); + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("Different rule chain"); + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + + loginTenantAdmin(); + + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setDefaultRuleChainId(savedRuleChain.getId()); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign rule chain from different tenant!"))); + } + + @Test + public void testSaveDeviceProfileWithDashboardFromDifferentTenant() throws Exception { + loginDifferentTenant(); + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("Different dashboard"); + Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + loginTenantAdmin(); + + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setDefaultDashboardId(savedDashboard.getId()); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign dashboard from different tenant!"))); + } + + @Test + public void testSaveDeviceProfileWithFirmwareFromDifferentTenant() throws Exception { + loginDifferentTenant(); + DeviceProfile differentProfile = createDeviceProfile("Different profile"); + differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class); + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(differentProfile.getId()); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle("title"); + firmwareInfo.setVersion("1.0"); + firmwareInfo.setUrl("test.url"); + firmwareInfo.setUsesUrl(true); + OtaPackageInfo savedFw = doPost("/api/otaPackage", firmwareInfo, OtaPackageInfo.class); + + loginTenantAdmin(); + + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setFirmwareId(savedFw.getId()); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign firmware from different tenant!"))); + } + + @Test + public void testSaveDeviceProfileWithSoftwareFromDifferentTenant() throws Exception { + loginDifferentTenant(); + DeviceProfile differentProfile = createDeviceProfile("Different profile"); + differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class); + SaveOtaPackageInfoRequest softwareInfo = new SaveOtaPackageInfoRequest(); + softwareInfo.setDeviceProfileId(differentProfile.getId()); + softwareInfo.setType(SOFTWARE); + softwareInfo.setTitle("title"); + softwareInfo.setVersion("1.0"); + softwareInfo.setUrl("test.url"); + softwareInfo.setUsesUrl(true); + OtaPackageInfo savedSw = doPost("/api/otaPackage", softwareInfo, OtaPackageInfo.class); + + loginTenantAdmin(); + + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setSoftwareId(savedSw.getId()); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Can't assign software from different tenant!"))); + } + + @Test + public void testDeleteDeviceProfile() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString()) + .andExpect(status().isOk()); + + String savedDeviceProfileIdFtr = savedDeviceProfile.getId().getId().toString(); + testNotifyEntityBroadcastEntityStateChangeEventOneTime(savedDeviceProfile, savedDeviceProfile.getId(), savedDeviceProfile.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedDeviceProfileIdFtr); + + doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device profile", savedDeviceProfileIdFtr)))); + } + + @Test + public void testFindDeviceProfiles() throws Exception { + List deviceProfiles = new ArrayList<>(); + PageLink pageLink = new PageLink(17); + PageData pageData = doGetTypedWithPageLink("/api/deviceProfiles?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + deviceProfiles.addAll(pageData.getData()); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 28; + for (int i = 0; i < cntEntity; i++) { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile" + i); + deviceProfiles.add(doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new DeviceProfile(), new DeviceProfile(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ADDED, cntEntity, cntEntity, cntEntity); + Mockito.reset(tbClusterService, auditLogService); + + List loadedDeviceProfiles = new ArrayList<>(); + pageLink = new PageLink(17); + do { + pageData = doGetTypedWithPageLink("/api/deviceProfiles?", + new TypeReference<>() { + }, pageLink); + loadedDeviceProfiles.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(deviceProfiles, idComparator); + Collections.sort(loadedDeviceProfiles, idComparator); + + Assert.assertEquals(deviceProfiles, loadedDeviceProfiles); + + for (DeviceProfile deviceProfile : loadedDeviceProfiles) { + if (!deviceProfile.isDefault()) { + doDelete("/api/deviceProfile/" + deviceProfile.getId().getId().toString()) + .andExpect(status().isOk()); + } + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(loadedDeviceProfiles.get(0), loadedDeviceProfiles.get(0), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, ActionType.DELETED, cntEntity, cntEntity, cntEntity, loadedDeviceProfiles.get(0).getId().getId().toString()); + + pageLink = new PageLink(17); + pageData = doGetTypedWithPageLink("/api/deviceProfiles?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + } + + @Test + public void testFindDeviceProfileInfos() throws Exception { + List deviceProfiles = new ArrayList<>(); + PageLink pageLink = new PageLink(17); + PageData deviceProfilePageData = doGetTypedWithPageLink("/api/deviceProfiles?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(deviceProfilePageData.hasNext()); + Assert.assertEquals(1, deviceProfilePageData.getTotalElements()); + deviceProfiles.addAll(deviceProfilePageData.getData()); + + for (int i = 0; i < 28; i++) { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile" + i); + deviceProfiles.add(doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class)); + } + + List loadedDeviceProfileInfos = new ArrayList<>(); + pageLink = new PageLink(17); + PageData pageData; + do { + pageData = doGetTypedWithPageLink("/api/deviceProfileInfos?", + new TypeReference<>() { + }, pageLink); + loadedDeviceProfileInfos.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(deviceProfiles, idComparator); + Collections.sort(loadedDeviceProfileInfos, deviceProfileInfoIdComparator); + + List deviceProfileInfos = deviceProfiles.stream().map(deviceProfile -> new DeviceProfileInfo(deviceProfile.getId(), + deviceProfile.getName(), deviceProfile.getImage(), deviceProfile.getDefaultDashboardId(), + deviceProfile.getType(), deviceProfile.getTransportType())).collect(Collectors.toList()); + + Assert.assertEquals(deviceProfileInfos, loadedDeviceProfileInfos); + + for (DeviceProfile deviceProfile : deviceProfiles) { + if (!deviceProfile.isDefault()) { + doDelete("/api/deviceProfile/" + deviceProfile.getId().getId().toString()) + .andExpect(status().isOk()); + } + } + + pageLink = new PageLink(17); + pageData = doGetTypedWithPageLink("/api/deviceProfileInfos?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!"); + } + + @Test + public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "option java_package = \"com.test.schemavalidation\";\n" + + "option java_multiple_files = true;\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!"); + } + + @Test + public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "import public \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "import \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "extend google.protobuf.MethodOptions {\n" + + " MyMessage my_method_option = 50007;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " option allow_alias = true;\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}\n" + + "\n" + + "message testMessage {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!"); + } + + @Test + public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!"); + } + + @Test + public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message testMessage {\n" + + " option allow_alias = true;\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " extensions 100 to 199;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message Foo {\n" + + " reserved 2, 15, 9 to 11;\n" + + " reserved \"foo\", \"bar\";\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " repeated group Result = 1 {\n" + + " optional string url = 2;\n" + + " optional string title = 3;\n" + + " repeated string snippets = 4;\n" + + " }\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SampleMessage {\n" + + " oneof test_oneof {\n" + + " string name = 1;\n" + + " group Result = 2 {\n" + + " \tstring url = 3;\n" + + " \tstring title = 4;\n" + + " \trepeated string snippets = 5;\n" + + " }\n" + + " }" + + "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " int64 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " repeated int32 someArray = 9;\n" + + " NestedJsonObject someNestedObject = 10;\n" + + " message NestedJsonObject {\n" + + " optional string key = 11;\n" + + " }\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int32 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception { + testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int64 ts = 1;\n" + + " string values = 2;\n" + + " \n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int64 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " repeated string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " repeated int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " repeated string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string methodName = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestIdentifier = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!"); + } + + @Test + public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception { + testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string parameters = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!"); + } + + @Test + public void testSaveDeviceProfileWithSendAckOnValidationException() throws Exception { + JsonTransportPayloadConfiguration jsonTransportPayloadConfiguration = new JsonTransportPayloadConfiguration(); + MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(jsonTransportPayloadConfiguration, true); + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Assert.assertNotNull(savedDeviceProfile); + Assert.assertEquals(savedDeviceProfile.getTransportType(), DeviceTransportType.MQTT); + Assert.assertTrue(savedDeviceProfile.getProfileData().getTransportConfiguration() instanceof MqttDeviceProfileTransportConfiguration); + MqttDeviceProfileTransportConfiguration transportConfiguration = (MqttDeviceProfileTransportConfiguration) savedDeviceProfile.getProfileData().getTransportConfiguration(); + Assert.assertTrue(transportConfiguration.isSendAckOnValidationException()); + DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class); + Assert.assertEquals(savedDeviceProfile, foundDeviceProfile); + } + + private DeviceProfile testSaveDeviceProfileWithProtoPayloadType(String schema) throws Exception { + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = this.createProtoTransportPayloadConfiguration(schema, schema, null, null); + MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Assert.assertNotNull(savedDeviceProfile); + DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class); + Assert.assertEquals(savedDeviceProfile, foundDeviceProfile); + return savedDeviceProfile; + } + + private void testSaveDeviceProfileWithInvalidProtoSchema(String schema, String errorMsg) throws Exception { + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = this.createProtoTransportPayloadConfiguration(schema, schema, null, null); + MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(errorMsg))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); + } + + private void testSaveDeviceProfileWithInvalidRpcRequestProtoSchema(String schema, String errorMsg) throws Exception { + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = this.createProtoTransportPayloadConfiguration(schema, schema, schema, null); + MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(errorMsg))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); + } + + @Test + public void testDeleteDeviceProfileWithDeleteRelationsOk() throws Exception { + DeviceProfileId deviceProfileId = savedDeviceProfile("DeviceProfile for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), deviceProfileId, "/api/deviceProfile/" + deviceProfileId); + } + + @Test + public void testDeleteDeviceProfileExceptionWithRelationsTransactional() throws Exception { + DeviceProfileId deviceProfileId = savedDeviceProfile("DeviceProfile for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(deviceProfileDao, savedTenant.getId(), deviceProfileId, "/api/deviceProfile/" + deviceProfileId); + } + + private DeviceProfile savedDeviceProfile(String name) { + DeviceProfile deviceProfile = createDeviceProfile(name); + return doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java new file mode 100644 index 0000000..fa466ce --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java @@ -0,0 +1,879 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +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.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.edge.EdgeDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.edge.imitator.EdgeImitator; +import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@TestPropertySource(properties = { + "edges.enabled=true", +}) +@ContextConfiguration(classes = {BaseEdgeControllerTest.Config.class}) +public abstract class BaseEdgeControllerTest extends AbstractControllerTest { + + public static final String EDGE_HOST = "localhost"; + public static final int EDGE_PORT = 7070; + + private IdComparator idComparator = new IdComparator<>(); + + private Tenant savedTenant; + private TenantId tenantId; + private User tenantAdmin; + + @Autowired + private EdgeDao edgeDao; + + static class Config { + @Bean + @Primary + public EdgeDao edgeDao(EdgeDao edgeDao) { + return Mockito.mock(EdgeDao.class, AdditionalAnswers.delegatesTo(edgeDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant for Edge"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + tenantId = savedTenant.getId(); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + + Mockito.reset(tbClusterService, auditLogService); + + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Assert.assertNotNull(savedEdge); + Assert.assertNotNull(savedEdge.getId()); + Assert.assertTrue(savedEdge.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedEdge.getTenantId()); + Assert.assertNotNull(savedEdge.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedEdge.getCustomerId().getId()); + Assert.assertEquals(edge.getName(), savedEdge.getName()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + savedEdge.setName("My new edge"); + doPost("/api/edge", savedEdge, Edge.class); + + Edge foundEdge = doGet("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(foundEdge.getName(), savedEdge.getName()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(foundEdge, foundEdge.getId(), foundEdge.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void testSaveEdgeWithViolationOfLengthValidation() throws Exception { + Edge edge = constructEdge(StringUtils.randomAlphabetic(300), "default"); + String msgError = msgErrorFieldLength("name"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/edge", edge) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(edge, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + msgError = msgErrorFieldLength("type"); + edge.setName("normal name"); + edge.setType(StringUtils.randomAlphabetic(300)); + doPost("/api/edge", edge) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(edge, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + msgError = msgErrorFieldLength("label"); + edge.setType("normal type"); + edge.setLabel(StringUtils.randomAlphabetic(300)); + doPost("/api/edge", edge) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(edge, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testFindEdgeById() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + Edge foundEdge = doGet("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertNotNull(foundEdge); + Assert.assertEquals(savedEdge, foundEdge); + } + + @Test + public void testFindEdgeTypesByTenantId() throws Exception { + List edges = new ArrayList<>(); + + int cntEntity = 3; + + Mockito.reset(tbClusterService, auditLogService); + + for (int i = 0; i < cntEntity; i++) { + Edge edge = constructEdge("My edge B" + i, "typeB"); + edges.add(doPost("/api/edge", edge, Edge.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceNeverAdditionalInfoAny(new Edge(), new Edge(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, cntEntity, 0); + + for (int i = 0; i < 7; i++) { + Edge edge = constructEdge("My edge C" + i, "typeC"); + edges.add(doPost("/api/edge", edge, Edge.class)); + } + for (int i = 0; i < 9; i++) { + Edge edge = constructEdge("My edge A" + i, "typeA"); + edges.add(doPost("/api/edge", edge, Edge.class)); + } + List edgeTypes = doGetTyped("/api/edge/types", + new TypeReference<>() { + }); + + Assert.assertNotNull(edgeTypes); + Assert.assertEquals(3, edgeTypes.size()); + Assert.assertEquals("typeA", edgeTypes.get(0).getType()); + Assert.assertEquals("typeB", edgeTypes.get(1).getType()); + Assert.assertEquals("typeC", edgeTypes.get(2).getType()); + } + + @Test + public void testDeleteEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedEdge.getId().getId().toString()); + + doGet("/api/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Edge", savedEdge.getId().getId().toString())))); + } + + @Test + public void testSaveEdgeWithEmptyType() throws Exception { + Edge edge = constructEdge("My edge", null); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Edge type " + msgErrorShouldBeSpecified; + doPost("/api/edge", edge) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(edge, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveEdgeWithEmptyName() throws Exception { + Edge edge = constructEdge(null, "default"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Edge name " + msgErrorShouldBeSpecified; + doPost("/api/edge", edge) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(edge, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testAssignUnassignEdgeToCustomer() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + Mockito.reset(tbClusterService, auditLogService); + + Edge assignedEdge = doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(savedCustomer.getId(), assignedEdge.getCustomerId()); + + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(assignedEdge, assignedEdge.getId(), assignedEdge.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ASSIGNED_TO_CUSTOMER, + assignedEdge.getId().getId().toString(), savedCustomer.getId().getId().toString(), savedCustomer.getTitle()); + + Edge foundEdge = doGet("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(savedCustomer.getId(), foundEdge.getCustomerId()); + + Edge unassignedEdge = + doDelete("/api/customer/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(ModelConstants.NULL_UUID, unassignedEdge.getCustomerId().getId()); + + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(unassignedEdge, unassignedEdge.getId(), unassignedEdge.getId(), + savedTenant.getId(), savedCustomer.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.UNASSIGNED_FROM_CUSTOMER, + unassignedEdge.getId().getId().toString(), savedCustomer.getId().getId().toString(), savedCustomer.getTitle()); + + foundEdge = doGet("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(ModelConstants.NULL_UUID, foundEdge.getCustomerId().getId()); + } + + @Test + public void testAssignEdgeToNonExistentCustomer() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Mockito.reset(tbClusterService, auditLogService); + + CustomerId customerId = new CustomerId(Uuids.timeBased()); + String customerIdStr = customerId.getId().toString(); + + String msgError = msgErrorNoFound("Customer", customerIdStr); + doPost("/api/customer/" + customerIdStr+ "/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityNever(savedEdge.getId(), savedEdge); + testNotifyEntityNever(customerId, new Customer()); + } + + @Test + public void testAssignEdgeToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant tenant2 = new Tenant(); + tenant2.setTitle("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 = new Customer(); + customer.setTitle("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + login(tenantAdmin.getEmail(), "testPassword1"); + + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + + "/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedEdge.getId(), savedEdge); + testNotifyEntityNever(savedCustomer.getId(), savedCustomer); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testFindTenantEdges() throws Exception { + List edges = new ArrayList<>(); + for (int i = 0; i < 178; i++) { + Edge edge = constructEdge("Edge" + i, "default"); + edges.add(doPost("/api/edge", edge, Edge.class)); + } + List loadedEdges = new ArrayList<>(); + PageLink pageLink = new PageLink(23); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/edges?", + new TypeReference<>() { + }, pageLink); + loadedEdges.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edges, idComparator); + Collections.sort(loadedEdges, idComparator); + + Assert.assertEquals(edges, loadedEdges); + } + + @Test + public void testFindTenantEdgesByName() throws Exception { + String title1 = "Edge title 1"; + List edgesTitle1 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, "default"); + edgesTitle1.add(doPost("/api/edge", edge, Edge.class)); + } + String title2 = "Edge title 2"; + List edgesTitle2 = new ArrayList<>(); + for (int i = 0; i < 75; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, "default"); + edgesTitle2.add(doPost("/api/edge", edge, Edge.class)); + } + + List loadedEdgesTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/edges?", + new TypeReference>() { + }, pageLink); + loadedEdgesTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesTitle1, idComparator); + Collections.sort(loadedEdgesTitle1, idComparator); + + Assert.assertEquals(edgesTitle1, loadedEdgesTitle1); + + List loadedEdgesTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/tenant/edges?", + new TypeReference>() { + }, pageLink); + loadedEdgesTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesTitle2, idComparator); + Collections.sort(loadedEdgesTitle2, idComparator); + + Assert.assertEquals(edgesTitle2, loadedEdgesTitle2); + + for (Edge edge : loadedEdgesTitle1) { + doDelete("/api/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/tenant/edges?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Edge edge : loadedEdgesTitle2) { + doDelete("/api/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/tenant/edges?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindTenantEdgesByType() throws Exception { + String title1 = "Edge title 1"; + String type1 = "typeA"; + List edgesType1 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, type1); + edgesType1.add(doPost("/api/edge", edge, Edge.class)); + } + String title2 = "Edge title 2"; + String type2 = "typeB"; + List edgesType2 = new ArrayList<>(); + for (int i = 0; i < 75; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, type2); + edgesType2.add(doPost("/api/edge", edge, Edge.class)); + } + + List loadedEdgesType1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/tenant/edges?type={type}&", + new TypeReference>() { + }, pageLink, type1); + loadedEdgesType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesType1, idComparator); + Collections.sort(loadedEdgesType1, idComparator); + + Assert.assertEquals(edgesType1, loadedEdgesType1); + + List loadedEdgesType2 = new ArrayList<>(); + pageLink = new PageLink(4); + do { + pageData = doGetTypedWithPageLink("/api/tenant/edges?type={type}&", + new TypeReference>() { + }, pageLink, type2); + loadedEdgesType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesType2, idComparator); + Collections.sort(loadedEdgesType2, idComparator); + + Assert.assertEquals(edgesType2, loadedEdgesType2); + + for (Edge edge : loadedEdgesType1) { + doDelete("/api/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/edges?type={type}&", + new TypeReference>() { + }, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Edge edge : loadedEdgesType2) { + doDelete("/api/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4); + pageData = doGetTypedWithPageLink("/api/tenant/edges?type={type}&", + new TypeReference>() { + }, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerEdges() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + Mockito.reset(tbClusterService, auditLogService); + + List edges = new ArrayList<>(); + int cntEntity = 128; + for (int i = 0; i < cntEntity; i++) { + Edge edge = constructEdge("Edge" + i, "default"); + edge = doPost("/api/edge", edge, Edge.class); + edges.add(doPost("/api/customer/" + customerId.getId().toString() + + "/edge/" + edge.getId().getId().toString(), Edge.class)); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new Edge(), new Edge(), + savedTenant.getId(), customerId, tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ASSIGNED_TO_CUSTOMER, ActionType.ASSIGNED_TO_CUSTOMER, cntEntity, cntEntity, cntEntity * 2, + new String(), new String(), new String()); + + List loadedEdges = new ArrayList<>(); + PageLink pageLink = new PageLink(23); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?", + new TypeReference>() { + }, pageLink); + loadedEdges.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edges, idComparator); + Collections.sort(loadedEdges, idComparator); + + Assert.assertEquals(edges, loadedEdges); + } + + @Test + public void testFindCustomerEdgesByName() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Edge title 1"; + List edgesTitle1 = new ArrayList<>(); + for (int i = 0; i < 125; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, "default"); + edge = doPost("/api/edge", edge, Edge.class); + edgesTitle1.add(doPost("/api/customer/" + customerId.getId().toString() + + "/edge/" + edge.getId().getId().toString(), Edge.class)); + } + String title2 = "Edge title 2"; + List edgesTitle2 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, "default"); + edge = doPost("/api/edge", edge, Edge.class); + edgesTitle2.add(doPost("/api/customer/" + customerId.getId().toString() + + "/edge/" + edge.getId().getId().toString(), Edge.class)); + } + + List loadedEdgesTitle1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?", + new TypeReference>() { + }, pageLink); + loadedEdgesTitle1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesTitle1, idComparator); + Collections.sort(loadedEdgesTitle1, idComparator); + + Assert.assertEquals(edgesTitle1, loadedEdgesTitle1); + + List loadedEdgesTitle2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?", + new TypeReference>() { + }, pageLink); + loadedEdgesTitle2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesTitle2, idComparator); + Collections.sort(loadedEdgesTitle2, idComparator); + + Assert.assertEquals(edgesTitle2, loadedEdgesTitle2); + + Mockito.reset(tbClusterService, auditLogService); + + for (Edge edge : loadedEdgesTitle1) { + doDelete("/api/customer/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + int cntEntity = loadedEdgesTitle1.size(); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(new Edge(), new Edge(), + savedTenant.getId(), customerId, tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_CUSTOMER, ActionType.UNASSIGNED_FROM_CUSTOMER, cntEntity, cntEntity, 3); + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Edge edge : loadedEdgesTitle2) { + doDelete("/api/customer/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?", + new TypeReference>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testFindCustomerEdgesByType() throws Exception { + Customer customer = new Customer(); + customer.setTitle("Test customer"); + customer = doPost("/api/customer", customer, Customer.class); + CustomerId customerId = customer.getId(); + + String title1 = "Edge title 1"; + String type1 = "typeC"; + List edgesType1 = new ArrayList<>(); + for (int i = 0; i < 125; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title1 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, type1); + edge = doPost("/api/edge", edge, Edge.class); + edgesType1.add(doPost("/api/customer/" + customerId.getId().toString() + + "/edge/" + edge.getId().getId().toString(), Edge.class)); + } + String title2 = "Edge title 2"; + String type2 = "typeD"; + List edgesType2 = new ArrayList<>(); + for (int i = 0; i < 143; i++) { + String suffix = StringUtils.randomAlphanumeric(15); + String name = title2 + suffix; + name = i % 2 == 0 ? name.toLowerCase() : name.toUpperCase(); + Edge edge = constructEdge(name, type2); + edge = doPost("/api/edge", edge, Edge.class); + edgesType2.add(doPost("/api/customer/" + customerId.getId().toString() + + "/edge/" + edge.getId().getId().toString(), Edge.class)); + } + + List loadedEdgesType1 = new ArrayList<>(); + PageLink pageLink = new PageLink(15, 0, title1); + PageData pageData = null; + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?type={type}&", + new TypeReference>() { + }, pageLink, type1); + loadedEdgesType1.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesType1, idComparator); + Collections.sort(loadedEdgesType1, idComparator); + + Assert.assertEquals(edgesType1, loadedEdgesType1); + + List loadedEdgesType2 = new ArrayList<>(); + pageLink = new PageLink(4, 0, title2); + do { + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?type={type}&", + new TypeReference>() { + }, pageLink, type2); + loadedEdgesType2.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgesType2, idComparator); + Collections.sort(loadedEdgesType2, idComparator); + + Assert.assertEquals(edgesType2, loadedEdgesType2); + + for (Edge edge : loadedEdgesType1) { + doDelete("/api/customer/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title1); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?type={type}&", + new TypeReference>() { + }, pageLink, type1); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + + for (Edge edge : loadedEdgesType2) { + doDelete("/api/customer/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + pageLink = new PageLink(4, 0, title2); + pageData = doGetTypedWithPageLink("/api/customer/" + customerId.getId().toString() + "/edges?type={type}&", + new TypeReference>() { + }, pageLink, type2); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testSyncEdge() throws Exception { + Edge edge = doPost("/api/edge", constructEdge("Test Sync Edge", "test"), Edge.class); + + Device device = new Device(); + device.setName("Test Sync Edge Device 1"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + doPost("/api/edge/" + edge.getId().getId().toString() + + "/device/" + savedDevice.getId().getId().toString(), Device.class); + + Asset asset = new Asset(); + asset.setName("Test Sync Edge Asset 1"); + asset.setType("test"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + doPost("/api/edge/" + edge.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); + + EdgeImitator edgeImitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); + edgeImitator.ignoreType(UserCredentialsUpdateMsg.class); + + edgeImitator.expectMessageAmount(19); + edgeImitator.connect(); + assertThat(edgeImitator.waitForMessages()).as("await for messages on first connect").isTrue(); + + assertThat(edgeImitator.findAllMessagesByType(QueueUpdateMsg.class)).as("one msg during sync process").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(RuleChainUpdateMsg.class)).as("one msg during sync process, another from edge creation").hasSize(2); + assertThat(edgeImitator.findAllMessagesByType(DeviceProfileUpdateMsg.class)).as("one msg during sync process for 'default' device profile").hasSize(3); + assertThat(edgeImitator.findAllMessagesByType(DeviceUpdateMsg.class)).as("one msg once device assigned to edge").hasSize(2); + assertThat(edgeImitator.findAllMessagesByType(AssetProfileUpdateMsg.class)).as("two msgs during sync process for 'default' and 'test' asset profiles").hasSize(4); + assertThat(edgeImitator.findAllMessagesByType(AssetUpdateMsg.class)).as("two msgs - one during sync process, and one more once asset assigned to edge").hasSize(2); + assertThat(edgeImitator.findAllMessagesByType(UserUpdateMsg.class)).as("one msg during sync process for tenant admin user").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(AdminSettingsUpdateMsg.class)).as("admin setting update").hasSize(4); + + edgeImitator.expectMessageAmount(14); + doPost("/api/edge/sync/" + edge.getId()); + assertThat(edgeImitator.waitForMessages()).as("await for messages after edge sync rest api call").isTrue(); + + assertThat(edgeImitator.findAllMessagesByType(QueueUpdateMsg.class)).as("queue msg").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(RuleChainUpdateMsg.class)).as("rule chain msg").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(DeviceProfileUpdateMsg.class)).as("device profile msg").hasSize(2); + assertThat(edgeImitator.findAllMessagesByType(AssetProfileUpdateMsg.class)).as("asset profile msg").hasSize(3); + assertThat(edgeImitator.findAllMessagesByType(AssetUpdateMsg.class)).as("asset update msg").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(UserUpdateMsg.class)).as("user update msg").hasSize(1); + assertThat(edgeImitator.findAllMessagesByType(AdminSettingsUpdateMsg.class)).as("admin setting update msg").hasSize(4); + assertThat(edgeImitator.findAllMessagesByType(DeviceUpdateMsg.class)).as("asset update msg").hasSize(1); + + edgeImitator.allowIgnoredTypes(); + try { + edgeImitator.disconnect(); + } catch (Exception ignored) { + } + + doDelete("/api/device/" + savedDevice.getId().getId().toString()) + .andExpect(status().isOk()); + doDelete("/api/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isOk()); + doDelete("/api/edge/" + edge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDeleteEdgeWithDeleteRelationsOk() throws Exception { + EdgeId edgeId = savedEdge("Edge for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), edgeId, "/api/edge/" + edgeId); + } + + @Test + public void testDeleteEdgeExceptionWithRelationsTransactional() throws Exception { + EdgeId edgeId = savedEdge("Edge for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(edgeDao, savedTenant.getId(), edgeId, "/api/edge/" + edgeId); + } + + private Edge savedEdge(String name) { + Edge edge = constructEdge(name, "default"); + return doPost("/api/edge", edge, Edge.class); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java new file mode 100644 index 0000000..ccf218c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java @@ -0,0 +1,206 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +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.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.edge.EdgeEventDao; +import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.service.ttl.EdgeEventsCleanUpService; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@TestPropertySource(properties = { + "edges.enabled=true", +}) +@Slf4j +public abstract class BaseEdgeEventControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private EdgeEventDao edgeEventDao; + @SpyBean + private SqlPartitioningRepository partitioningRepository; + @Autowired + private EdgeEventsCleanUpService edgeEventsCleanUpService; + + @Value("#{${sql.edge_events.partition_size} * 60 * 60 * 1000}") + private long partitionDurationInMs; + @Value("${sql.ttl.edge_events.edge_event_ttl}") + private long edgeEventTtlInSec; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + // sleep 1 seconds to avoid CREDENTIALS updated message for the user + // user credentials is going to be stored and updated event pushed to edge notification service + // while service will be processing this event edge could be already added and additional message will be pushed + Thread.sleep(1000); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetEdgeEvents() throws Exception { + Edge edge = constructEdge("TestEdge", "default"); + edge = doPost("/api/edge", edge, Edge.class); + + Device device = constructDevice("TestDevice", "default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + final EdgeId edgeId = edge.getId(); + doPost("/api/edge/" + edgeId.toString() + "/device/" + savedDevice.getId().toString(), Device.class); + + Asset asset = constructAsset("TestAsset", "default"); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + + doPost("/api/edge/" + edgeId.toString() + "/asset/" + savedAsset.getId().toString(), Asset.class); + + EntityRelation relation = new EntityRelation(savedAsset.getId(), savedDevice.getId(), EntityRelation.CONTAINS_TYPE); + + doPost("/api/relation", relation); + + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .until(() -> { + List edgeEvents = findEdgeEvents(edgeId); + return edgeEvents.size() == 4; + }); + List edgeEvents = findEdgeEvents(edgeId); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.RULE_CHAIN.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.DEVICE.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.ASSET.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.RELATION.equals(ee.getType()))); + } + + @Test + public void saveEdgeEvent_thenCreatePartitionIfNotExist() { + reset(partitioningRepository); + EdgeEvent edgeEvent = createEdgeEvent(); + verify(partitioningRepository).createPartitionIfNotExists(eq("edge_event"), eq(edgeEvent.getCreatedTime()), eq(partitionDurationInMs)); + List partitions = partitioningRepository.fetchPartitions("edge_event"); + assertThat(partitions).singleElement().satisfies(partitionStartTs -> { + assertThat(partitionStartTs).isEqualTo(partitioningRepository.calculatePartitionStartTime(edgeEvent.getCreatedTime(), partitionDurationInMs)); + }); + } + + @Test + public void cleanUpEdgeEventByTtl_dropOldPartitions() { + long oldEdgeEventTs = LocalDate.of(2020, 10, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + long partitionStartTs = partitioningRepository.calculatePartitionStartTime(oldEdgeEventTs, partitionDurationInMs); + partitioningRepository.createPartitionIfNotExists("edge_event", oldEdgeEventTs, partitionDurationInMs); + List partitions = partitioningRepository.fetchPartitions("edge_event"); + assertThat(partitions).contains(partitionStartTs); + + edgeEventsCleanUpService.cleanUp(); + partitions = partitioningRepository.fetchPartitions("edge_event"); + assertThat(partitions).doesNotContain(partitionStartTs); + assertThat(partitions).allSatisfy(partitionsStart -> { + long partitionEndTs = partitionsStart + partitionDurationInMs; + assertThat(partitionEndTs).isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(edgeEventTtlInSec)); + }); + } + + private List findEdgeEvents(EdgeId edgeId) throws Exception { + return doGetTypedWithTimePageLink("/api/edge/" + edgeId.toString() + "/events?", + new TypeReference>() { + }, new TimePageLink(10)).getData(); + } + + private Device constructDevice(String name, String type) { + Device device = new Device(); + device.setName(name); + device.setType(type); + return device; + } + + private Asset constructAsset(String name, String type) { + Asset asset = new Asset(); + asset.setName(name); + asset.setType(type); + return asset; + } + + private EdgeEvent createEdgeEvent() { + EdgeEvent edgeEvent = new EdgeEvent(); + edgeEvent.setCreatedTime(System.currentTimeMillis()); + edgeEvent.setTenantId(tenantId); + edgeEvent.setAction(EdgeEventActionType.ADDED); + edgeEvent.setEntityId(tenantAdmin.getUuidId()); + edgeEvent.setType(EdgeEventType.ALARM); + try { + edgeEventDao.saveAsync(edgeEvent).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return edgeEvent; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java new file mode 100644 index 0000000..85d68a4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java @@ -0,0 +1,406 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.DynamicValueSourceType; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityListFilter; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; +import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.data.security.Authority; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseEntityQueryControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + private User tenantAdmin; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testCountEntitiesByQuery() throws Exception { + List devices = new ArrayList<>(); + for (int i = 0; i < 97; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceType("default"); + filter.setDeviceNameFilter(""); + + EntityCountQuery countQuery = new EntityCountQuery(filter); + + Long count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); + Assert.assertEquals(97, count.longValue()); + + filter.setDeviceType("unknown"); + count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); + Assert.assertEquals(0, count.longValue()); + + filter.setDeviceType("default"); + filter.setDeviceNameFilter("Device1"); + + count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); + Assert.assertEquals(11, count.longValue()); + + EntityListFilter entityListFilter = new EntityListFilter(); + entityListFilter.setEntityType(EntityType.DEVICE); + entityListFilter.setEntityList(devices.stream().map(Device::getId).map(DeviceId::toString).collect(Collectors.toList())); + + countQuery = new EntityCountQuery(entityListFilter); + + count = doPostWithResponse("/api/entitiesQuery/count", countQuery, Long.class); + Assert.assertEquals(97, count.longValue()); + + EntityTypeFilter filter2 = new EntityTypeFilter(); + filter2.setEntityType(EntityType.DEVICE); + + EntityCountQuery countQuery2 = new EntityCountQuery(filter2); + + Long count2 = doPostWithResponse("/api/entitiesQuery/count", countQuery2, Long.class); + Assert.assertEquals(97, count2.longValue()); + } + + @Test + public void testSimpleFindEntityDataByQuery() throws Exception { + List devices = new ArrayList<>(); + for (int i = 0; i < 97; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceType("default"); + filter.setDeviceNameFilter(""); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, null, null); + + PageData data = + doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + + Assert.assertEquals(97, data.getTotalElements()); + Assert.assertEquals(10, data.getTotalPages()); + Assert.assertTrue(data.hasNext()); + Assert.assertEquals(10, data.getData().size()); + + List loadedEntities = new ArrayList<>(data.getData()); + while (data.hasNext()) { + query = query.next(); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + loadedEntities.addAll(data.getData()); + } + Assert.assertEquals(97, loadedEntities.size()); + + List loadedIds = loadedEntities.stream().map(EntityData::getEntityId).collect(Collectors.toList()); + List deviceIds = devices.stream().map(Device::getId).collect(Collectors.toList()); + + Assert.assertEquals(deviceIds, loadedIds); + + List loadedNames = loadedEntities.stream().map(entityData -> + entityData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).collect(Collectors.toList()); + List deviceNames = devices.stream().map(Device::getName).collect(Collectors.toList()); + + Assert.assertEquals(deviceNames, loadedNames); + + sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.DESC + ); + + pageLink = new EntityDataPageLink(10, 0, "device1", sortOrder); + query = new EntityDataQuery(filter, pageLink, entityFields, null, null); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + Assert.assertEquals(11, data.getTotalElements()); + Assert.assertEquals("Device19", data.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()); + + + EntityTypeFilter filter2 = new EntityTypeFilter(); + filter2.setEntityType(EntityType.DEVICE); + + EntityDataSortOrder sortOrder2 = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink2 = new EntityDataPageLink(10, 0, null, sortOrder2); + List entityFields2 = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + EntityDataQuery query2 = new EntityDataQuery(filter2, pageLink2, entityFields2, null, null); + + PageData data2 = + doPostWithTypedResponse("/api/entitiesQuery/find", query2, new TypeReference>() { + }); + + Assert.assertEquals(97, data2.getTotalElements()); + Assert.assertEquals(10, data2.getTotalPages()); + Assert.assertTrue(data2.hasNext()); + Assert.assertEquals(10, data2.getData().size()); + + } + + @Test + public void testFindEntityDataByQueryWithAttributes() throws Exception { + List devices = new ArrayList<>(); + List temperatures = new ArrayList<>(); + List highTemperatures = new ArrayList<>(); + for (int i = 0; i < 67; i++) { + Device device = new Device(); + String name = "Device" + i; + device.setName(name); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device?accessToken=" + name, device, Device.class)); + Thread.sleep(1); + long temperature = (long) (Math.random() * 100); + temperatures.add(temperature); + if (temperature > 45) { + highTemperatures.add(temperature); + } + } + for (int i = 0; i < devices.size(); i++) { + Device device = devices.get(i); + String payload = "{\"temperature\":" + temperatures.get(i) + "}"; + doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, payload, String.class, status().isOk()); + } + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceType("default"); + filter.setDeviceNameFilter(""); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); + PageData data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + + List loadedEntities = new ArrayList<>(data.getData()); + while (data.hasNext()) { + query = query.next(); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + loadedEntities.addAll(data.getData()); + } + Assert.assertEquals(67, loadedEntities.size()); + + List loadedTemperatures = loadedEntities.stream().map(entityData -> + entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); + List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); + Assert.assertEquals(deviceTemperatures, loadedTemperatures); + + pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + KeyFilter highTemperatureFilter = new KeyFilter(); + highTemperatureFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromDouble(45)); + predicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + highTemperatureFilter.setPredicate(predicate); + List keyFilters = Collections.singletonList(highTemperatureFilter); + + query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + loadedEntities = new ArrayList<>(data.getData()); + while (data.hasNext()) { + query = query.next(); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() { + }); + loadedEntities.addAll(data.getData()); + } + Assert.assertEquals(highTemperatures.size(), loadedEntities.size()); + + List loadedHighTemperatures = loadedEntities.stream().map(entityData -> + entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("temperature").getValue()).collect(Collectors.toList()); + List deviceHighTemperatures = highTemperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); + + Assert.assertEquals(deviceHighTemperatures, loadedHighTemperatures); + } + + @Test + public void testFindEntityDataByQueryWithDynamicValue() throws Exception { + int numOfDevices = 2; + + for (int i = 0; i < numOfDevices; i++) { + Device device = new Device(); + String name = "Device" + i; + device.setName(name); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + + Device savedDevice1 = doPost("/api/device?accessToken=" + name, device, Device.class); + JsonNode content = JacksonUtil.toJsonNode("{\"alarmActiveTime\": 1" + i + "}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + savedDevice1.getUuidId() + "/SERVER_SCOPE", content) + .andExpect(status().isOk()); + } + JsonNode content = JacksonUtil.toJsonNode("{\"dynamicValue\": 0}"); + doPost("/api/plugins/telemetry/" + EntityType.TENANT.name() + "/" + tenantId.getId() + "/SERVER_SCOPE", content) + .andExpect(status().isOk()); + + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceType("default"); + filter.setDeviceNameFilter(""); + + KeyFilter highTemperatureFilter = new KeyFilter(); + highTemperatureFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "alarmActiveTime")); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + + DynamicValue dynamicValue = + new DynamicValue<>(DynamicValueSourceType.CURRENT_TENANT, "dynamicValue"); + FilterPredicateValue predicateValue = new FilterPredicateValue<>(0.0, null, dynamicValue); + + predicate.setValue(predicateValue); + predicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + highTemperatureFilter.setPredicate(predicate); + + List keyFilters = Collections.singletonList(highTemperatureFilter); + + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "alarmActiveTime")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); + + Awaitility.await() + .alias("data by query") + .atMost(30, TimeUnit.SECONDS) + .until(() -> { + var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + var loadedEntities = new ArrayList<>(data.getData()); + return loadedEntities.size() == numOfDevices; + }); + + var data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference>() {}); + var loadedEntities = new ArrayList<>(data.getData()); + + Assert.assertEquals(numOfDevices, loadedEntities.size()); + + for (int i = 0; i < numOfDevices; i++) { + var entity = loadedEntities.get(i); + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue(); + + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("1" + i, alarmActiveTime); + } + } + + @Test + public void givenInvalidEntityDataPageLink_thenReturnError() throws Exception { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceType("default"); + filter.setDeviceNameFilter(""); + + String invalidSortProperty = "created(Time)"; + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, invalidSortProperty), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); + + ResultActions result = doPost("/api/entitiesQuery/find", query).andExpect(status().isBadRequest()); + assertThat(getErrorMessage(result)).contains("Invalid").contains("sort property"); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityRelationControllerTest.java new file mode 100644 index 0000000..2d28e32 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityRelationControllerTest.java @@ -0,0 +1,631 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationsQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.relation.RelationsSearchParameters; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.relation.RelationService; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +public abstract class BaseEntityRelationControllerTest extends AbstractControllerTest { + + public static final String BASE_DEVICE_NAME = "Test dummy device"; + + @Autowired + RelationService relationService; + + private IdComparator idComparator; + private Tenant savedTenant; + private User tenantAdmin; + private Device mainDevice; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + idComparator = new IdComparator<>(); + + Tenant tenant = new Tenant(); + tenant.setTitle("Test tenant"); + + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + + Device device = new Device(); + device.setName("Main test device"); + device.setType("default"); + mainDevice = doPost("/api/device", device, Device.class); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveAndFindRelation() throws Exception { + Device device = buildSimpleDevice("Test device 1"); + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/relation", relation).andExpect(status().isOk()); + + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", device.getUuidId(), EntityType.DEVICE + ); + + EntityRelation foundRelation = doGet(url, EntityRelation.class); + + Assert.assertNotNull("Relation is not found!", foundRelation); + Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); + + testNotifyEntityAllOneTimeRelation(foundRelation, + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.RELATION_ADD_OR_UPDATE, foundRelation); + } + + @Test + public void testSaveWithDeviceFromNotCreated() throws Exception { + Device device = new Device(); + device.setName("Test device 2"); + device.setType("default"); + EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/relation", relation) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Parameter entityId can't be empty!"))); + + testNotifyEntityNever(mainDevice.getId(), null); + } + + @Test + public void testSaveWithDeviceToNotCreated() throws Exception { + Device device = new Device(); + device.setName("Test device 2"); + device.setType("default"); + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/relation", relation) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Parameter entityId can't be empty!"))); + + testNotifyEntityNever(mainDevice.getId(), null); + } + + @Test + public void testSaveWithDeviceToMissing() throws Exception { + Device device = new Device(); + device.setName("Test device 2"); + device.setType("default"); + device.setId(new DeviceId(UUID.randomUUID())); + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/relation", relation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", device.getId().getId().toString())))); + + testNotifyEntityNever(mainDevice.getId(), null); + } + + @Test + public void testSaveAndFindRelationsByFrom() throws Exception { + final int numOfDevices = 30; + + Mockito.reset(tbClusterService, auditLogService); + + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + EntityRelation relationTest = createFromRelation(mainDevice, mainDevice, "TEST_NOTIFY_ENTITY"); + testNotifyEntityAllManyRelation(relationTest, savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.RELATION_ADD_OR_UPDATE, numOfDevices); + + String url = String.format("/api/relations?fromId=%s&fromType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + assertFoundList(url, numOfDevices); + } + + @Test + public void testSaveAndFindRelationsByTo() throws Exception { + final int numOfDevices = 30; + createDevicesByTo(numOfDevices, BASE_DEVICE_NAME); + String url = String.format("/api/relations?toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + assertFoundList(url, numOfDevices); + } + + @Test + public void testSaveAndFindRelationsByFromWithRelationType() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + Device device = buildSimpleDevice("Unique dummy test device "); + String relationType = "TEST"; + EntityRelation relation = createFromRelation(mainDevice, device, relationType); + + doPost("/api/relation", relation).andExpect(status().isOk()); + String url = String.format("/api/relations?fromId=%s&fromType=%s&relationType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, relationType + ); + + assertFoundList(url, 1); + } + + @Test + public void testSaveAndFindRelationsByFromWithRelationTypeOther() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + Device device = buildSimpleDevice("Unique dummy test device "); + String relationType = "TEST"; + EntityRelation relation = createFromRelation(mainDevice, device, relationType); + + doPost("/api/relation", relation).andExpect(status().isOk()); + + String relationTypeOther = "TEST_OTHER"; + String url = String.format("/api/relations?fromId=%s&fromType=%s&relationType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, relationTypeOther + ); + + assertFoundList(url, 0); + } + + @Test + public void testSaveAndFindRelationsByToWithRelationType() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + Device device = buildSimpleDevice("Unique dummy test device "); + String relationType = "TEST"; + EntityRelation relation = createFromRelation(device, mainDevice, relationType); + + doPost("/api/relation", relation).andExpect(status().isOk()); + String url = String.format("/api/relations?toId=%s&toType=%s&relationType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, relationType + ); + + assertFoundList(url, 1); + } + + + @Test + public void testSaveAndFindRelationsByToWithRelationTypeOther() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + Device device = buildSimpleDevice("Unique dummy test device "); + String relationType = "TEST"; + EntityRelation relation = createFromRelation(device, mainDevice, relationType); + + doPost("/api/relation", relation).andExpect(status().isOk()); + + String relationTypeOther = "TEST_OTHER"; + String url = String.format("/api/relations?toId=%s&toType=%s&relationType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, relationTypeOther + ); + + assertFoundList(url, 0); + } + + @Test + public void testFindRelationsInfoByFrom() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + String url = String.format("/api/relations/info?fromId=%s&fromType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + List relationsInfos = + JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() { + }); + + Assert.assertNotNull("Relations is not found!", relationsInfos); + Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", + numOfDevices, relationsInfos.size()); + + assertRelationsInfosByFrom(relationsInfos); + } + + @Test + public void testFindRelationsInfoByTo() throws Exception { + final int numOfDevices = 30; + createDevicesByTo(numOfDevices, BASE_DEVICE_NAME); + String url = String.format("/api/relations/info?toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + List relationsInfos = + JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() { + }); + + Assert.assertNotNull("Relations is not found!", relationsInfos); + Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", + numOfDevices, relationsInfos.size()); + + assertRelationsInfosByTo(relationsInfos); + } + + @Test + public void testDeleteRelation() throws Exception { + Device device = buildSimpleDevice("Test device 1"); + + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", device.getUuidId(), EntityType.DEVICE + ); + + EntityRelation foundRelation = doGet(url, EntityRelation.class); + + Assert.assertNotNull("Relation is not found!", foundRelation); + Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete(url).andExpect(status().isOk()); + + testNotifyEntityAllOneTimeRelation(foundRelation, + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.RELATION_DELETED, foundRelation); + + doGet(url).andExpect(status().is4xxClientError()); + } + + @Test + public void testDeleteRelationWithOtherFromDeviceError() throws Exception { + Device device = buildSimpleDevice("Test device 1"); + + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + Device device2 = buildSimpleDevice("Test device 2"); + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + device2.getUuidId(), EntityType.DEVICE, + "CONTAINS", device.getUuidId(), EntityType.DEVICE + ); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete(url) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNotFound))); + + testNotifyEntityNever(mainDevice.getId(), null); + } + + @Test + public void testDeleteRelationWithOtherToDeviceError() throws Exception { + Device device = buildSimpleDevice("Test device 1"); + + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + Device device2 = buildSimpleDevice("Test device 2"); + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", device2.getUuidId(), EntityType.DEVICE + ); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete(url) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNotFound))); + + testNotifyEntityNever(mainDevice.getId(), null); + } + + @Test + public void testDeleteRelations() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME + " from"); + createDevicesByTo(numOfDevices, BASE_DEVICE_NAME + " to"); + + String urlTo = String.format("/api/relations?toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + String urlFrom = String.format("/api/relations?fromId=%s&fromType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + assertFoundList(urlTo, numOfDevices); + assertFoundList(urlFrom, numOfDevices); + + String url = String.format("/api/relations?entityId=%s&entityType=%s", + mainDevice.getUuidId(), EntityType.DEVICE + ); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete(url).andExpect(status().isOk()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(null, mainDevice.getId(), mainDevice.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.RELATIONS_DELETED); + + Assert.assertTrue( + "Performed deletion of all relations but some relations were found!", + doGet(urlTo, List.class).isEmpty() + ); + Assert.assertTrue( + "Performed deletion of all relations but some relations were found!", + doGet(urlFrom, List.class).isEmpty() + ); + } + + @Test + public void testFindRelationsByFromQuery() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(new RelationsSearchParameters( + mainDevice.getUuidId(), EntityType.DEVICE, + EntitySearchDirection.FROM, + RelationTypeGroup.COMMON, + 1, true + )); + query.setFilters(Collections.singletonList( + new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE)) + )); + + List relations = readResponse( + doPost("/api/relations", query).andExpect(status().isOk()), + new TypeReference>() { + } + ); + + assertFoundRelations(relations, numOfDevices); + } + + @Test + public void testFindRelationsByToQuery() throws Exception { + final int numOfDevices = 30; + createDevicesByTo(numOfDevices, BASE_DEVICE_NAME); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(new RelationsSearchParameters( + mainDevice.getUuidId(), EntityType.DEVICE, + EntitySearchDirection.TO, + RelationTypeGroup.COMMON, + 1, true + )); + query.setFilters(Collections.singletonList( + new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE)) + )); + + List relations = readResponse( + doPost("/api/relations", query).andExpect(status().isOk()), + new TypeReference<>() { + } + ); + + assertFoundRelations(relations, numOfDevices); + } + + @Test + public void testFindRelationsInfoByFromQuery() throws Exception { + final int numOfDevices = 30; + createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(new RelationsSearchParameters( + mainDevice.getUuidId(), EntityType.DEVICE, + EntitySearchDirection.FROM, + RelationTypeGroup.COMMON, + 1, true + )); + query.setFilters(Collections.singletonList( + new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE)) + )); + + List relationsInfo = readResponse( + doPost("/api/relations/info", query).andExpect(status().isOk()), + new TypeReference<>() { + } + ); + + assertRelationsInfosByFrom(relationsInfo); + } + + @Test + public void testFindRelationsInfoByToQuery() throws Exception { + final int numOfDevices = 30; + createDevicesByTo(numOfDevices, BASE_DEVICE_NAME); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(new RelationsSearchParameters( + mainDevice.getUuidId(), EntityType.DEVICE, + EntitySearchDirection.TO, + RelationTypeGroup.COMMON, + 1, true + )); + query.setFilters(Collections.singletonList( + new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE)) + )); + + List relationsInfo = readResponse( + doPost("/api/relations/info", query).andExpect(status().isOk()), + new TypeReference<>() { + } + ); + + assertRelationsInfosByTo(relationsInfo); + } + + @Test + public void testCreateRelationFromTenantToDevice() throws Exception { + EntityRelation relation = new EntityRelation(tenantAdmin.getTenantId(), mainDevice.getId(), "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + tenantAdmin.getTenantId(), EntityType.TENANT, + "CONTAINS", mainDevice.getUuidId(), EntityType.DEVICE + ); + + EntityRelation foundRelation = doGet(url, EntityRelation.class); + + Assert.assertNotNull("Relation is not found!", foundRelation); + Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); + } + + @Test + public void testCreateRelationFromDeviceToTenant() throws Exception { + EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantAdmin.getTenantId(), "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", tenantAdmin.getTenantId(), EntityType.TENANT + ); + + EntityRelation foundRelation = doGet(url, EntityRelation.class); + + Assert.assertNotNull("Relation is not found!", foundRelation); + Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); + } + + @Test + public void testSaveAndFindRelationDifferentTenant() throws Exception { + Device device = buildSimpleDevice("Test device 1"); + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + + doPost("/api/relation", relation).andExpect(status().isOk()); + + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + "CONTAINS", device.getUuidId(), EntityType.DEVICE + ); + + loginDifferentTenant(); + + doGet(url) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Device", relation.getFrom().getId().toString())))); + + deleteDifferentTenant(); + } + + private Device buildSimpleDevice(String name) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType("default"); + device = doPost("/api/device", device, Device.class); + return device; + } + + private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) { + return new EntityRelation(mainDevice.getId(), device.getId(), relationType); + } + + private void createDevicesByFrom(int numOfDevices, String baseName) throws Exception { + for (int i = 0; i < numOfDevices; i++) { + Device device = buildSimpleDevice(baseName + i); + + EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + } + } + + private void createDevicesByTo(int numOfDevices, String baseName) throws Exception { + for (int i = 0; i < numOfDevices; i++) { + Device device = buildSimpleDevice(baseName + i); + EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + } + } + + private void assertFoundRelations(List relations, int numOfDevices) { + Assert.assertNotNull("Relations is not found!", relations); + Assert.assertEquals("List of found relations is not equal to number of created relations!", + numOfDevices, relations.size()); + } + + private void assertFoundList(String url, int numOfDevices) throws Exception { + @SuppressWarnings("unchecked") + List relations = doGet(url, List.class); + assertFoundRelations(relations, numOfDevices); + } + + private void assertRelationsInfosByFrom(List relationsInfos) { + for (EntityRelationInfo info : relationsInfos) { + Assert.assertEquals("Wrong FROM entityId!", mainDevice.getId(), info.getFrom()); + Assert.assertTrue("Wrong FROM name!", info.getToName().contains(BASE_DEVICE_NAME)); + Assert.assertEquals("Wrong relationType!", "CONTAINS", info.getType()); + } + } + + private void assertRelationsInfosByTo(List relationsInfos) { + for (EntityRelationInfo info : relationsInfos) { + Assert.assertEquals("Wrong TO entityId!", mainDevice.getId(), info.getTo()); + Assert.assertTrue("Wrong TO name!", info.getFromName().contains(BASE_DEVICE_NAME)); + Assert.assertEquals("Wrong relationType!", "CONTAINS", info.getType()); + } + } +} 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..fe3fefc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -0,0 +1,820 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +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.persist.MemoryPersistence; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.common.util.ThingsBoardExecutors; +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.EntityViewInfo; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.objects.AttributesEntityView; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.dao.entityview.EntityViewDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static java.util.concurrent.TimeUnit.HOURS; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@TestPropertySource(properties = { + "transport.mqtt.enabled=true", + "js.evaluator=mock", +}) +@Slf4j +@ContextConfiguration(classes = {BaseEntityViewControllerTest.Config.class}) +public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { + static final TypeReference> PAGE_DATA_ENTITY_VIEW_TYPE_REF = new TypeReference<>() { + }; + static final TypeReference> PAGE_DATA_ENTITY_VIEW_INFO_TYPE_REF = new TypeReference<>() { + }; + + private Device testDevice; + private TelemetryEntityView telemetry; + + List> deleteFutures = new ArrayList<>(); + ListeningExecutorService executor; + + @Autowired + private EntityViewDao entityViewDao; + + static class Config { + @Bean + @Primary + public EntityViewDao entityViewDao(EntityViewDao entityViewDao) { + return Mockito.mock(EntityViewDao.class, AdditionalAnswers.delegatesTo(entityViewDao)); + } + } + + @Before + public void beforeTest() throws Exception { + executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); + + loginTenantAdmin(); + + Device device = new Device(); + device.setName("Test device 4view"); + device.setType("default"); + testDevice = doPost("/api/device", device, Device.class); + + telemetry = new TelemetryEntityView( + List.of("tsKey1", "tsKey2", "tsKey3"), + new AttributesEntityView( + List.of("caKey1", "caKey2", "caKey3", "caKey4"), + List.of("saKey1", "saKey2", "saKey3", "saKey4"), + List.of("shKey1", "shKey2", "shKey3", "shKey4"))); + } + + @After + public void afterTest() throws Exception { + executor.shutdownNow(); + } + + @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 { + String name = "Test entity view"; + + Mockito.reset(tbClusterService, auditLogService); + + EntityView savedView = getNewSavedEntityView(name); + + Assert.assertNotNull(savedView); + Assert.assertNotNull(savedView.getId()); + Assert.assertTrue(savedView.getCreatedTime() > 0); + assertEquals(tenantId, savedView.getTenantId()); + Assert.assertNotNull(savedView.getCustomerId()); + assertEquals(NULL_UUID, savedView.getCustomerId().getId()); + assertEquals(name, savedView.getName()); + + EntityView foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + + assertEquals(savedView, foundEntityView); + + testBroadcastEntityStateChangeEventTime(foundEntityView.getId(), tenantId, 1); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(foundEntityView, foundEntityView, + tenantId, tenantAdminCustomerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ADDED, ActionType.ADDED, 1, 0, 1); + Mockito.reset(tbClusterService, auditLogService); + + savedView.setName("New test entity view"); + + doPost("/api/entityView", savedView, EntityView.class); + foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + + assertEquals(savedView, foundEntityView); + + testBroadcastEntityStateChangeEventTime(foundEntityView.getId(), tenantId, 1); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(foundEntityView, foundEntityView, + tenantId, tenantAdminCustomerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.UPDATED, ActionType.UPDATED, 1, 1, 5); + + doGet("/api/tenant/entityViews?entityViewName=" + name) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNotFound))); + } + + @Test + public void testSaveEntityViewWithViolationOfValidation() throws Exception { + EntityView entityView = createEntityView(StringUtils.randomAlphabetic(300), 0, 0); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = msgErrorFieldLength("name"); + doPost("/api/entityView", entityView) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(entityView, + tenantId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ADDED, new DataValidationException(msgError)); + Mockito.reset(tbClusterService, auditLogService); + + entityView.setName("Normal name"); + msgError = msgErrorFieldLength("type"); + entityView.setType(StringUtils.randomAlphabetic(300)); + doPost("/api/entityView", entityView) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(entityView, + tenantId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testUpdateEntityViewFromDifferentTenant() throws Exception { + EntityView savedView = getNewSavedEntityView("Test entity view"); + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/entityView", savedView) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedView.getId(), savedView); + + deleteDifferentTenant(); + } + + @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); + + Mockito.reset(tbClusterService, auditLogService); + + String entityIdStr = savedView.getId().getId().toString(); + doDelete("/api/entityView/" + entityIdStr) + .andExpect(status().isOk()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedView, savedView.getId(), savedView.getId(), + tenantId, view.getCustomerId(), tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.DELETED, entityIdStr); + + doGet("/api/entityView/" + entityIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Entity view",entityIdStr)))); + } + + @Test + public void testSaveEntityViewWithEmptyName() throws Exception { + EntityView entityView = new EntityView(); + entityView.setType("default"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Entity view name " + msgErrorShouldBeSpecified; + doPost("/api/entityView", entityView) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(entityView, + tenantId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ADDED, new DataValidationException(msgError)); + } + + @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()); + + Mockito.reset(tbClusterService, auditLogService); + + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + testBroadcastEntityStateChangeEventTime(savedView.getId(), tenantId, 1); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(savedView, savedView, + tenantId, tenantAdminCustomerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.UPDATED, ActionType.UPDATED, 1, 1, 5); + Mockito.reset(tbClusterService, auditLogService); + + 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()); + + testBroadcastEntityStateChangeEventNever(foundView.getId()); + testNotifyEntityAllOneTime(foundView, foundView.getId(), foundView.getId(), + tenantId, foundView.getCustomerId(), tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ASSIGNED_TO_CUSTOMER, + foundView.getId().getId().toString(), foundView.getCustomerId().getId().toString(), savedCustomer.getTitle()); + + 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()); + + testBroadcastEntityStateChangeEventNever(foundView.getId()); + testNotifyEntityAllOneTime(unAssignedView, savedView.getId(), savedView.getId(), + tenantId, savedView.getCustomerId(), tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.UNASSIGNED_FROM_CUSTOMER, + savedView.getCustomerId().getId().toString(), savedCustomer.getTitle()); + } + + @Test + public void testAssignEntityViewToNonExistentCustomer() throws Exception { + EntityView savedView = getNewSavedEntityView("Test entity view"); + + Mockito.reset(tbClusterService, auditLogService); + + String customerIdStr = Uuids.timeBased().toString(); + String msgError = msgErrorNoFound("Customer", customerIdStr); + doPost("/api/customer/" + customerIdStr + "/device/" + savedView.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityNever(savedView.getId(), savedView); + } + + @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(TENANT_ADMIN_EMAIL, TENANT_ADMIN_PASSWORD); + + EntityView savedView = getNewSavedEntityView("Test entity view"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedView.getId(), savedView); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetCustomerEntityViews() throws Exception { + Customer customer = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class); + CustomerId customerId = customer.getId(); + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViewInfos?"; + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 128; + List> viewFutures = new ArrayList<>(cntEntity); + for (int i = 0; i < cntEntity; i++) { + String entityName = "Test entity view " + i; + viewFutures.add(executor.submit(() -> + new EntityViewInfo(doPost("/api/customer/" + customerId.getId().toString() + "/entityView/" + + getNewSavedEntityView(entityName).getId().getId().toString(), EntityView.class), + customer.getTitle(), customer.isPublic()))); + } + List entityViewInfos = Futures.allAsList(viewFutures).get(TIMEOUT, SECONDS); + List loadedViews = loadListOfInfo(new PageLink(23), urlTemplate); + + assertThat(entityViewInfos).containsExactlyInAnyOrderElementsOf(loadedViews); + + testNotifyEntityBroadcastEntityStateChangeEventMany(new EntityView(), new EntityView(), + tenantId, tenantAdminCustomerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ADDED, ActionType.ADDED, cntEntity, 0, cntEntity*2, 0); + + testNotifyEntityBroadcastEntityStateChangeEventMany(new EntityView(), new EntityView(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.ASSIGNED_TO_CUSTOMER, ActionType.ASSIGNED_TO_CUSTOMER, cntEntity, cntEntity, + cntEntity*2, 3); + } + + @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 namesOfView1 = Futures.allAsList(fillListByTemplate(125, name1, "/api/customer/" + customerId.getId().toString() + + "/entityView/")).get(TIMEOUT, SECONDS); + List loadedNamesOfView1 = loadListOf(new PageLink(15, 0, name1), urlTemplate); + assertThat(namesOfView1).as(name1).containsExactlyInAnyOrderElementsOf(loadedNamesOfView1); + + String name2 = "Entity view name2"; + List namesOfView2 = Futures.allAsList(fillListByTemplate(143, name2, "/api/customer/" + customerId.getId().toString() + + "/entityView/")).get(TIMEOUT, SECONDS); + List loadedNamesOfView2 = loadListOf(new PageLink(4, 0, name2), urlTemplate); + assertThat(namesOfView2).as(name2).containsExactlyInAnyOrderElementsOf(loadedNamesOfView2); + + deleteFutures.clear(); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = loadedNamesOfView1.size(); + for (EntityView view : loadedNamesOfView1) { + deleteFutures.add(executor.submit(() -> + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()))); + } + Futures.allAsList(deleteFutures).get(TIMEOUT, SECONDS); + + testBroadcastEntityStateChangeEventNever(loadedNamesOfView1.get(0).getId()); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(new EntityView(), new EntityView(), + tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, + ActionType.UNASSIGNED_FROM_CUSTOMER, ActionType.UNASSIGNED_FROM_CUSTOMER, cntEntity, cntEntity, 2); + + PageData pageData = doGetTypedWithPageLink(urlTemplate, PAGE_DATA_ENTITY_VIEW_TYPE_REF, + new PageLink(4, 0, name1)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + + deleteFutures.clear(); + for (EntityView view : loadedNamesOfView2) { + deleteFutures.add(executor.submit(() -> + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()))); + } + Futures.allAsList(deleteFutures).get(TIMEOUT, SECONDS); + + pageData = doGetTypedWithPageLink(urlTemplate, PAGE_DATA_ENTITY_VIEW_TYPE_REF, + new PageLink(4, 0, name2)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + } + + @Test + public void testGetTenantEntityViews() throws Exception { + List> entityViewInfoFutures = new ArrayList<>(178); + for (int i = 0; i < 178; i++) { + ListenableFuture entityViewFuture = getNewSavedEntityViewAsync("Test entity view" + i); + entityViewInfoFutures.add(Futures.transform(entityViewFuture, + view -> new EntityViewInfo(view, null, false), + MoreExecutors.directExecutor())); + } + List entityViewInfos = Futures.allAsList(entityViewInfoFutures).get(TIMEOUT, SECONDS); + List loadedViews = loadListOfInfo(new PageLink(23), "/api/tenant/entityViewInfos?"); + assertThat(entityViewInfos).containsExactlyInAnyOrderElementsOf(loadedViews); + } + + @Test + public void testGetTenantEntityViewsByName() throws Exception { + String name1 = "Entity view name1"; + List namesOfView1 = Futures.allAsList(fillListOf(17, name1)).get(TIMEOUT, SECONDS); + List loadedNamesOfView1 = loadListOf(new PageLink(5, 0, name1), "/api/tenant/entityViews?"); + assertThat(namesOfView1).as(name1).containsExactlyInAnyOrderElementsOf(loadedNamesOfView1); + + String name2 = "Entity view name2"; + List namesOfView2 = Futures.allAsList(fillListOf(15, name2)).get(TIMEOUT, SECONDS); + ; + List loadedNamesOfView2 = loadListOf(new PageLink(4, 0, name2), "/api/tenant/entityViews?"); + assertThat(namesOfView2).as(name2).containsExactlyInAnyOrderElementsOf(loadedNamesOfView2); + + deleteFutures.clear(); + for (EntityView view : loadedNamesOfView1) { + deleteFutures.add(executor.submit(() -> + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()))); + } + Futures.allAsList(deleteFutures).get(TIMEOUT, SECONDS); + + PageData pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", PAGE_DATA_ENTITY_VIEW_TYPE_REF, + new PageLink(4, 0, name1)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + + deleteFutures.clear(); + for (EntityView view : loadedNamesOfView2) { + deleteFutures.add(executor.submit(() -> + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()))); + } + Futures.allAsList(deleteFutures).get(TIMEOUT, SECONDS); + + pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", PAGE_DATA_ENTITY_VIEW_TYPE_REF, + new PageLink(4, 0, name2)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + } + + @Test + public void testTheCopyOfAttrsIntoTSForTheView() throws Exception { + Set expectedActualAttributesSet = Set.of("caKey1", "caKey2", "caKey3", "caKey4"); + Set actualAttributesSet = + putAttributesAndWait("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}", expectedActualAttributesSet); + EntityView savedView = getNewSavedEntityView("Test entity view"); + + List> values = await("telemetry/ENTITY_VIEW") + .atMost(TIMEOUT, SECONDS) + .until(() -> doGetAsyncTyped("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), new TypeReference<>() { + }), + x -> x.size() >= expectedActualAttributesSet.size()); + + 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 { + long now = System.currentTimeMillis(); + Set expectedActualAttributesSet = Set.of("caKey1", "caKey2", "caKey3", "caKey4"); + Set actualAttributesSet = + putAttributesAndWait("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}", expectedActualAttributesSet); + + List> values = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + testDevice.getId() + + "/values/attributes?keys=" + String.join(",", expectedActualAttributesSet), new TypeReference<>() { + }); + assertEquals(expectedActualAttributesSet.size(), values.size()); + + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + view.setName("Test entity view"); + view.setType("default"); + view.setKeys(telemetry); + view.setStartTimeMs(now - HOURS.toMillis(1)); + view.setEndTimeMs(now - 1); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + values = doGetAsyncTyped("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", expectedActualAttributesSet), new TypeReference<>() { + }); + assertEquals(0, values.size()); + } + + + @Test + public void testGetTelemetryWhenEntityViewTimeRangeInsideTimestampRange() throws Exception { + DeviceTypeFilter dtf = new DeviceTypeFilter(testDevice.getType(), testDevice.getName()); + List tsKeys = List.of("tsKey1", "tsKey2", "tsKey3"); + + DeviceCredentials deviceCredentials = doGet("/api/device/" + testDevice.getId().getId() + "/credentials", DeviceCredentials.class); + assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); + String accessToken = deviceCredentials.getCredentialsId(); + assertNotNull(accessToken); + + long now = System.currentTimeMillis(); + getWsClient().subscribeTsUpdate(tsKeys, now, TimeUnit.HOURS.toMillis(1), dtf); + + getWsClient().registerWaitForUpdate(); + uploadTelemetry("{\"tsKey1\":\"value1\", \"tsKey2\":true, \"tsKey3\":40.0}", accessToken); + getWsClient().waitForUpdate(); + + long startTimeMs = getCurTsButNotPrevTs(now); + + getWsClient().registerWaitForUpdate(); + uploadTelemetry("{\"tsKey1\":\"value2\", \"tsKey2\":false, \"tsKey3\":80.0}", accessToken); + getWsClient().waitForUpdate(); + + long middleOfTestMs = getCurTsButNotPrevTs(startTimeMs); + + getWsClient().registerWaitForUpdate(); + uploadTelemetry("{\"tsKey1\":\"value3\", \"tsKey2\":false, \"tsKey3\":120.0}", accessToken); + getWsClient().waitForUpdate(); + + long endTimeMs = getCurTsButNotPrevTs(middleOfTestMs); + getWsClient().registerWaitForUpdate(); + uploadTelemetry("{\"tsKey1\":\"value4\", \"tsKey2\":true, \"tsKey3\":160.0}", accessToken); + getWsClient().waitForUpdate(); + + String deviceId = testDevice.getId().getId().toString(); + Set keys = getTelemetryKeys("DEVICE", deviceId); + + EntityView view = createEntityView("Test entity view", startTimeMs, endTimeMs); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + String entityViewId = savedView.getId().getId().toString(); + + Map>> actualDeviceValues = getTelemetryValues("DEVICE", deviceId, keys, 0L, middleOfTestMs); + Assert.assertEquals(2, actualDeviceValues.get("tsKey1").size()); + Assert.assertEquals(2, actualDeviceValues.get("tsKey2").size()); + Assert.assertEquals(2, actualDeviceValues.get("tsKey3").size()); + + Map>> actualEntityViewValues = getTelemetryValues("ENTITY_VIEW", entityViewId, keys, 0L, middleOfTestMs); + Assert.assertEquals(1, actualEntityViewValues.get("tsKey1").size()); + Assert.assertEquals(1, actualEntityViewValues.get("tsKey2").size()); + Assert.assertEquals(1, actualEntityViewValues.get("tsKey3").size()); + } + + private static long getCurTsButNotPrevTs(long prevTs) throws InterruptedException { + long result = System.currentTimeMillis(); + if (prevTs == result) { + Thread.sleep(1); + return getCurTsButNotPrevTs(prevTs); + } else { + return result; + } + } + + private void uploadTelemetry(String strKvs, String accessToken) throws Exception { + String viewDeviceId = testDevice.getId().getId().toString(); + + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId, new MemoryPersistence()); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + awaitConnected(client, SECONDS.toMillis(30)); + MqttMessage message = new MqttMessage(); + message.setPayload(strKvs.getBytes()); + IMqttDeliveryToken token = client.publish("v1/devices/me/telemetry", message); + await("mqtt ack").pollInterval(5, MILLISECONDS).atMost(TIMEOUT, SECONDS).until(() -> token.getMessage() == null); + client.disconnect(); + } + + private void awaitConnected(MqttAsyncClient client, long ms) throws InterruptedException { + await("awaitConnected").pollInterval(5, MILLISECONDS).atMost(TIMEOUT, SECONDS) + .until(client::isConnected); + } + + private Set getTelemetryKeys(String type, String id) throws Exception { + return new HashSet<>(doGetAsyncTyped("/api/plugins/telemetry/" + type + "/" + id + "/keys/timeseries", new TypeReference<>() { + })); + } + + private Set getAttributeKeys(String type, String id) throws Exception { + return new HashSet<>(doGetAsyncTyped("/api/plugins/telemetry/" + type + "/" + id + "/keys/attributes", new TypeReference<>() { + })); + } + + private Map>> getTelemetryValues(String type, String id, Set keys, Long startTs, Long endTs) throws Exception { + return doGetAsyncTyped("/api/plugins/telemetry/" + type + "/" + id + + "/values/timeseries?keys=" + String.join(",", keys) + "&startTs=" + startTs + "&endTs=" + endTs, new TypeReference<>() { + }); + } + + private Set putAttributesAndWait(String stringKV, Set expectedKeySet) throws Exception { + DeviceTypeFilter dtf = new DeviceTypeFilter(testDevice.getType(), testDevice.getName()); + List keysToSubscribe = expectedKeySet.stream() + .map(key -> new EntityKey(EntityKeyType.CLIENT_ATTRIBUTE, key)) + .collect(Collectors.toList()); + + getWsClient().subscribeLatestUpdate(keysToSubscribe, dtf); + + 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, new MemoryPersistence()); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + awaitConnected(client, SECONDS.toMillis(30)); + MqttMessage message = new MqttMessage(); + message.setPayload((stringKV).getBytes()); + getWsClient().registerWaitForUpdate(); + IMqttDeliveryToken token = client.publish("v1/devices/me/attributes", message); + await("mqtt ack").pollInterval(5, MILLISECONDS).atMost(TIMEOUT, SECONDS).until(() -> token.getMessage() == null); + assertThat(getWsClient().waitForUpdate()).as("ws update received").isNotBlank(); + return getAttributeKeys("DEVICE", viewDeviceId); + } + + private Object getValue(List> 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) { + EntityView view = createEntityView(name, 0, 0); + return doPost("/api/entityView", view, EntityView.class); + } + + private ListenableFuture getNewSavedEntityViewAsync(String name) { + return executor.submit(() -> getNewSavedEntityView(name)); + } + + private EntityView createEntityView(String name, long startTimeMs, long endTimeMs) { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + 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> fillListByTemplate(int limit, String partOfName, String urlTemplate) { + List> futures = new ArrayList<>(limit); + for (ListenableFuture viewFuture : fillListOf(limit, partOfName)) { + futures.add(Futures.transform(viewFuture, view -> + doPost(urlTemplate + view.getId().getId().toString(), EntityView.class), + MoreExecutors.directExecutor())); + } + return futures; + } + + private List> fillListOf(int limit, String partOfName) { + List> viewNameFutures = new ArrayList<>(limit); + for (int i = 0; i < limit; i++) { + boolean even = i % 2 == 0; + ListenableFuture customerFuture = executor.submit(() -> { + Customer customer = getNewCustomer("Test customer " + Math.random()); + return doPost("/api/customer", customer, Customer.class).getId(); + }); + + viewNameFutures.add(Futures.transform(customerFuture, customerId -> { + String fullName = partOfName + ' ' + StringUtils.randomAlphanumeric(15); + fullName = even ? fullName.toLowerCase() : fullName.toUpperCase(); + EntityView view = getNewSavedEntityView(fullName); + view.setCustomerId(customerId); + return doPost("/api/entityView", view, EntityView.class); + }, MoreExecutors.directExecutor())); + } + return viewNameFutures; + } + + private List loadListOf(PageLink pageLink, String urlTemplate) throws Exception { + List loadedItems = new ArrayList<>(); + PageData pageData; + do { + pageData = doGetTypedWithPageLink(urlTemplate, PAGE_DATA_ENTITY_VIEW_TYPE_REF, pageLink); + loadedItems.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + return loadedItems; + } + + private List loadListOfInfo(PageLink pageLink, String urlTemplate) throws Exception { + List loadedItems = new ArrayList<>(); + PageData pageData; + do { + pageData = doGetTypedWithPageLink(urlTemplate, PAGE_DATA_ENTITY_VIEW_INFO_TYPE_REF, pageLink); + loadedItems.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + return loadedItems; + } + + @Test + public void testAssignEntityViewToEdge() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + EntityView savedEntityView = getNewSavedEntityView("My entityView"); + + doPost("/api/edge/" + savedEdge.getId().getId().toString() + + "/device/" + testDevice.getId().getId().toString(), Device.class); + + doPost("/api/edge/" + savedEdge.getId().getId().toString() + + "/entityView/" + savedEntityView.getId().getId().toString(), EntityView.class); + + PageData pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/entityViews?", + PAGE_DATA_ENTITY_VIEW_TYPE_REF, new PageLink(100)); + + Assert.assertEquals(1, pageData.getData().size()); + + doDelete("/api/edge/" + savedEdge.getId().getId().toString() + + "/entityView/" + savedEntityView.getId().getId().toString(), EntityView.class); + + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId().toString() + "/entityViews?", + PAGE_DATA_ENTITY_VIEW_TYPE_REF, new PageLink(100)); + + Assert.assertEquals(0, pageData.getData().size()); + } + + @Test + public void testDeleteEntityViewWithDeleteRelationsOk() throws Exception { + EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(tenantId, entityViewId, "/api/entityView/" + entityViewId); + } + + @Test + public void testDeleteEntityViewExceptionWithRelationsTransactional() throws Exception { + EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseOtaPackageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseOtaPackageControllerTest.java new file mode 100644 index 0000000..c256a6b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseOtaPackageControllerTest.java @@ -0,0 +1,432 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.OtaPackageInfo; +import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; + +public abstract class BaseOtaPackageControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + + public static final String TITLE = "My firmware"; + private static final String FILE_NAME = "filename.txt"; + private static final String VERSION = "v1.0"; + private static final String CONTENT_TYPE = "text/plain"; + private static final String CHECKSUM_ALGORITHM = "SHA256"; + private static final String CHECKSUM = "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"; + private static final ByteBuffer DATA = ByteBuffer.wrap(new byte[]{1}); + + private Tenant savedTenant; + private User tenantAdmin; + private DeviceProfileId deviceProfileId; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Assert.assertNotNull(savedDeviceProfile); + deviceProfileId = savedDeviceProfile.getId(); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveFirmware() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + Mockito.reset(tbClusterService, auditLogService); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + Assert.assertNotNull(savedFirmwareInfo); + Assert.assertNotNull(savedFirmwareInfo.getId()); + Assert.assertTrue(savedFirmwareInfo.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedFirmwareInfo.getTenantId()); + Assert.assertEquals(firmwareInfo.getTitle(), savedFirmwareInfo.getTitle()); + Assert.assertEquals(firmwareInfo.getVersion(), savedFirmwareInfo.getVersion()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedFirmwareInfo, savedFirmwareInfo.getId(), savedFirmwareInfo.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + savedFirmwareInfo.setAdditionalInfo(JacksonUtil.newObjectNode()); + + save(new SaveOtaPackageInfoRequest(savedFirmwareInfo, false)); + + OtaPackageInfo foundFirmwareInfo = doGet("/api/otaPackage/info/" + savedFirmwareInfo.getId().getId().toString(), OtaPackageInfo.class); + Assert.assertEquals(foundFirmwareInfo.getTitle(), savedFirmwareInfo.getTitle()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(foundFirmwareInfo, foundFirmwareInfo.getId(), foundFirmwareInfo.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void saveOtaPackageInfoWithViolationOfLengthValidation() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(StringUtils.randomAlphabetic(300)); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + String msgError = msgErrorFieldLength("title"); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/otaPackage", firmwareInfo) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + firmwareInfo.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(firmwareInfo, + savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, new DataValidationException(msgError)); + + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(StringUtils.randomAlphabetic(300)); + msgError = msgErrorFieldLength("version"); + doPost("/api/otaPackage", firmwareInfo) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + firmwareInfo.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(firmwareInfo, + savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, new DataValidationException(msgError)); + + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(true); + msgError = msgErrorFieldLength("url"); + firmwareInfo.setUrl(StringUtils.randomAlphabetic(300)); + doPost("/api/otaPackage", firmwareInfo) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + firmwareInfo.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(firmwareInfo, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testSaveFirmwareData() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + Assert.assertNotNull(savedFirmwareInfo); + Assert.assertNotNull(savedFirmwareInfo.getId()); + Assert.assertTrue(savedFirmwareInfo.getCreatedTime() > 0); + Assert.assertEquals(savedTenant.getId(), savedFirmwareInfo.getTenantId()); + Assert.assertEquals(firmwareInfo.getTitle(), savedFirmwareInfo.getTitle()); + Assert.assertEquals(firmwareInfo.getVersion(), savedFirmwareInfo.getVersion()); + + savedFirmwareInfo.setAdditionalInfo(JacksonUtil.newObjectNode()); + + save(new SaveOtaPackageInfoRequest(savedFirmwareInfo, false)); + + OtaPackageInfo foundFirmwareInfo = doGet("/api/otaPackage/info/" + savedFirmwareInfo.getId().getId().toString(), OtaPackageInfo.class); + Assert.assertEquals(foundFirmwareInfo.getTitle(), savedFirmwareInfo.getTitle()); + + MockMultipartFile testData = new MockMultipartFile("file", FILE_NAME, CONTENT_TYPE, DATA.array()); + + Mockito.reset(tbClusterService, auditLogService); + + OtaPackage savedFirmware = savaData("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString() + + "?checksum={checksum}&checksumAlgorithm={checksumAlgorithm}", testData, CHECKSUM, CHECKSUM_ALGORITHM); + + Assert.assertEquals(FILE_NAME, savedFirmware.getFileName()); + Assert.assertEquals(CONTENT_TYPE, savedFirmware.getContentType()); + Assert.assertEquals(CHECKSUM_ALGORITHM, savedFirmware.getChecksumAlgorithm().name()); + Assert.assertEquals(CHECKSUM, savedFirmware.getChecksum()); + + testNotifyEntityAllOneTime(savedFirmware, savedFirmware.getId(), savedFirmware.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void testUpdateFirmwareFromDifferentTenant() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + loginDifferentTenant(); + + Mockito.reset(tbClusterService, auditLogService); + + doPost("/api/otaPackage", + new SaveOtaPackageInfoRequest(savedFirmwareInfo, false)) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString(msgErrorPermission))); + + testNotifyEntityNever(savedFirmwareInfo.getId(), savedFirmwareInfo); + + deleteDifferentTenant(); + } + + @Test + public void testFindFirmwareInfoById() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + OtaPackageInfo foundFirmware = doGet("/api/otaPackage/info/" + savedFirmwareInfo.getId().getId().toString(), OtaPackageInfo.class); + Assert.assertNotNull(foundFirmware); + Assert.assertEquals(savedFirmwareInfo, foundFirmware); + } + + @Test + public void testFindFirmwareById() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + MockMultipartFile testData = new MockMultipartFile("file", FILE_NAME, CONTENT_TYPE, DATA.array()); + + OtaPackageInfo savedFirmware = savaData("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString() + + "?checksum={checksum}&checksumAlgorithm={checksumAlgorithm}", testData, CHECKSUM, CHECKSUM_ALGORITHM); + + OtaPackage foundFirmware = doGet("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString(), OtaPackage.class); + Assert.assertNotNull(foundFirmware); + Assert.assertEquals(savedFirmware, foundFirmware); + Assert.assertEquals(DATA, foundFirmware.getData()); + } + + @Test + public void testDeleteFirmware() throws Exception { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityAllOneTime(savedFirmwareInfo, savedFirmwareInfo.getId(), savedFirmwareInfo.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedFirmwareInfo.getId().getId().toString()); + + doGet("/api/otaPackage/info/" + savedFirmwareInfo.getId().getId().toString()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNotFound))); + } + + @Test + public void testFindTenantFirmwares() throws Exception { + + Mockito.reset(tbClusterService, auditLogService); + + List otaPackages = new ArrayList<>(); + int cntEntity = 165; + int startIndexSaveData = 101; + for (int i = 0; i < cntEntity; i++) { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION + i); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + if (i >= startIndexSaveData) { + MockMultipartFile testData = new MockMultipartFile("file", FILE_NAME, CONTENT_TYPE, DATA.array()); + + OtaPackage savedFirmware = savaData("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString() + "?checksum={checksum}&checksumAlgorithm={checksumAlgorithm}", testData, CHECKSUM, CHECKSUM_ALGORITHM); + savedFirmwareInfo = new OtaPackageInfo(savedFirmware); + } + otaPackages.add(savedFirmwareInfo); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new OtaPackageInfo(), new OtaPackageInfo(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ADDED, cntEntity, 0, (cntEntity*2 - startIndexSaveData)); + + List loadedFirmwares = new ArrayList<>(); + PageLink pageLink = new PageLink(24); + PageData pageData; + do { + pageData = doGetTypedWithPageLink("/api/otaPackages?", + new TypeReference<>() { + }, pageLink); + loadedFirmwares.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(otaPackages, idComparator); + Collections.sort(loadedFirmwares, idComparator); + + Assert.assertEquals(otaPackages, loadedFirmwares); + } + + @Test + public void testFindTenantFirmwaresByHasData() throws Exception { + List otaPackagesWithData = new ArrayList<>(); + List allOtaPackages = new ArrayList<>(); + + for (int i = 0; i < 165; i++) { + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION + i); + firmwareInfo.setUsesUrl(false); + + OtaPackageInfo savedFirmwareInfo = save(firmwareInfo); + + if (i > 100) { + MockMultipartFile testData = new MockMultipartFile("file", FILE_NAME, CONTENT_TYPE, DATA.array()); + + OtaPackage savedFirmware = savaData("/api/otaPackage/" + savedFirmwareInfo.getId().getId().toString() + "?checksum={checksum}&checksumAlgorithm={checksumAlgorithm}", testData, CHECKSUM, CHECKSUM_ALGORITHM); + savedFirmwareInfo = new OtaPackageInfo(savedFirmware); + otaPackagesWithData.add(savedFirmwareInfo); + } + + allOtaPackages.add(savedFirmwareInfo); + } + + List loadedOtaPackagesWithData = new ArrayList<>(); + PageLink pageLink = new PageLink(24); + PageData pageData; + do { + pageData = doGetTypedWithPageLink("/api/otaPackages/" + deviceProfileId.toString() + "/FIRMWARE?", + new TypeReference<>() { + }, pageLink); + loadedOtaPackagesWithData.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + List allLoadedOtaPackages = new ArrayList<>(); + pageLink = new PageLink(24); + do { + pageData = doGetTypedWithPageLink("/api/otaPackages?", + new TypeReference<>() { + }, pageLink); + allLoadedOtaPackages.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(otaPackagesWithData, idComparator); + Collections.sort(allOtaPackages, idComparator); + Collections.sort(loadedOtaPackagesWithData, idComparator); + Collections.sort(allLoadedOtaPackages, idComparator); + + Assert.assertEquals(otaPackagesWithData, loadedOtaPackagesWithData); + Assert.assertEquals(allOtaPackages, allLoadedOtaPackages); + } + + private OtaPackageInfo save(SaveOtaPackageInfoRequest firmwareInfo) throws Exception { + return doPost("/api/otaPackage", firmwareInfo, OtaPackageInfo.class); + } + + protected OtaPackage savaData(String urlTemplate, MockMultipartFile content, String... params) throws Exception { + MockMultipartHttpServletRequestBuilder postRequest = MockMvcRequestBuilders.multipart(urlTemplate, params); + postRequest.file(content); + setJwtToken(postRequest); + return readResponse(mockMvc.perform(postRequest).andExpect(status().isOk()), OtaPackage.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseRpcControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseRpcControllerTest.java new file mode 100644 index 0000000..81ac7c4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseRpcControllerTest.java @@ -0,0 +1,196 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.web.servlet.MvcResult; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.*; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.rpc.Rpc; +import org.thingsboard.server.common.data.rpc.RpcStatus; +import org.thingsboard.server.common.data.security.Authority; + +import java.util.List; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseRpcControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + private User tenantAdmin; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + private Device createDefaultDevice() { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + + return device; + } + + private ObjectNode createDefaultRpc() { + ObjectNode rpc = JacksonUtil.newObjectNode(); + rpc.put("method", "setGpio"); + + ObjectNode params = JacksonUtil.newObjectNode(); + + params.put("pin", 7); + params.put("value", 1); + + rpc.set("params", params); + rpc.put("persistent", true); + rpc.put("timeout", 5000); + + return rpc; + } + + private Rpc getRpcById(String rpcId) throws Exception { + return doGet("/api/rpc/persistent/" + rpcId, Rpc.class); + } + + private MvcResult removeRpcById(String rpcId) throws Exception { + return doDelete("/api/rpc/persistent/" + rpcId).andReturn(); + } + + @Test + public void testSaveRpc() throws Exception { + Device device = createDefaultDevice(); + Device savedDevice = doPost("/api/device", device, Device.class); + + ObjectNode rpc = createDefaultRpc(); + String result = doPostAsync( + "/api/rpc/oneway/" + savedDevice.getId().getId().toString(), + JacksonUtil.toString(rpc), + String.class, + status().isOk() + ); + String rpcId = JacksonUtil.fromString(result, JsonNode.class) + .get("rpcId") + .asText(); + Rpc savedRpc = getRpcById(rpcId); + + Assert.assertNotNull(savedRpc); + Assert.assertEquals(savedDevice.getId(), savedRpc.getDeviceId()); + } + + @Test + public void testDeleteRpc() throws Exception { + Device device = createDefaultDevice(); + Device savedDevice = doPost("/api/device", device, Device.class); + + ObjectNode rpc = createDefaultRpc(); + String result = doPostAsync( + "/api/rpc/oneway/" + savedDevice.getId().getId().toString(), + JacksonUtil.toString(rpc), + String.class, + status().isOk() + ); + String rpcId = JacksonUtil.fromString(result, JsonNode.class) + .get("rpcId") + .asText(); + Rpc savedRpc = getRpcById(rpcId); + + MvcResult mvcResult = removeRpcById(savedRpc.getId().getId().toString()); + MvcResult res = doGet("/api/rpc/persistent/" + rpcId) + .andExpect(status().isNotFound()) + .andReturn(); + + JsonNode deleteResponse = JacksonUtil.fromString(res.getResponse().getContentAsString(), JsonNode.class); + Assert.assertEquals(404, deleteResponse.get("status").asInt()); + + String url = "/api/rpc/persistent/device/" + savedDevice.getUuidId().toString() + + "?" + "page=0" + "&" + + "pageSize=" + Integer.MAX_VALUE + "&" + + "rpcStatus=" + RpcStatus.DELETED.name(); + MvcResult byDeviceResult = doGet(url).andReturn(); + JsonNode byDeviceResponse = JacksonUtil.fromString(byDeviceResult.getResponse().getContentAsString(), JsonNode.class); + + Assert.assertEquals(500, byDeviceResponse.get("status").asInt()); + } + + @Test + public void testGetRpcsByDeviceId() throws Exception { + Device device = createDefaultDevice(); + Device savedDevice = doPost("/api/device", device, Device.class); + + ObjectNode rpc = createDefaultRpc(); + + String result = doPostAsync( + "/api/rpc/oneway/" + savedDevice.getId().getId().toString(), + JacksonUtil.toString(rpc), + String.class, + status().isOk() + ); + String rpcId = JacksonUtil.fromString(result, JsonNode.class) + .get("rpcId") + .asText(); + + String url = "/api/rpc/persistent/device/" + savedDevice.getId().getId() + + "?" + "page=0" + "&" + + "pageSize=" + Integer.MAX_VALUE + "&" + + "rpcStatus=" + RpcStatus.QUEUED; + + MvcResult byDeviceResult = doGetAsync(url).andReturn(); + + List byDeviceRpcs = JacksonUtil.fromString( + byDeviceResult + .getResponse() + .getContentAsString(), + new TypeReference>() {} + ).getData(); + + + boolean found = byDeviceRpcs.stream().anyMatch(r -> + r.getUuidId().toString().equals(rpcId) + && r.getDeviceId().equals(savedDevice.getId()) + ); + + Assert.assertTrue(found); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseRuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseRuleChainControllerTest.java new file mode 100644 index 0000000..8daf835 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseRuleChainControllerTest.java @@ -0,0 +1,293 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.core.type.TypeReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ContextConfiguration; +import org.thingsboard.rule.engine.action.TbCreateAlarmNode; +import org.thingsboard.rule.engine.action.TbCreateAlarmNodeConfiguration; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.rule.RuleChainDao; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = {BaseRuleChainControllerTest.Config.class}) +public abstract class BaseRuleChainControllerTest extends AbstractControllerTest { + + private IdComparator idComparator = new IdComparator<>(); + + private Tenant savedTenant; + private User tenantAdmin; + + @Autowired + private RuleChainDao ruleChainDao; + + static class Config { + @Bean + @Primary + public RuleChainDao ruleChainDao(RuleChainDao ruleChainDao) { + return Mockito.mock(RuleChainDao.class, AdditionalAnswers.delegatesTo(ruleChainDao)); + } + } + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveRuleChain() throws Exception { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain"); + + Mockito.reset(tbClusterService, auditLogService); + + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + Assert.assertNotNull(savedRuleChain); + Assert.assertNotNull(savedRuleChain.getId()); + Assert.assertTrue(savedRuleChain.getCreatedTime() > 0); + Assert.assertEquals(ruleChain.getName(), savedRuleChain.getName()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedRuleChain, savedRuleChain.getId(), savedRuleChain.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + savedRuleChain.setName("New RuleChain"); + doPost("/api/ruleChain", savedRuleChain, RuleChain.class); + RuleChain foundRuleChain = doGet("/api/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); + Assert.assertEquals(savedRuleChain.getName(), foundRuleChain.getName()); + + testNotifyEntityOneTimeMsgToEdgeServiceNever(savedRuleChain, savedRuleChain.getId(), savedRuleChain.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UPDATED); + } + + @Test + public void testSaveRuleChainWithViolationOfLengthValidation() throws Exception { + + Mockito.reset(tbClusterService, auditLogService); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(StringUtils.randomAlphabetic(300)); + String msgError = msgErrorFieldLength("name"); + doPost("/api/ruleChain", ruleChain) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + ruleChain.setTenantId(savedTenant.getId()); + testNotifyEntityEqualsOneTimeServiceNeverError(ruleChain, + savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, new DataValidationException(msgError)); + } + + @Test + public void testFindRuleChainById() throws Exception { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain"); + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + RuleChain foundRuleChain = doGet("/api/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); + Assert.assertNotNull(foundRuleChain); + Assert.assertEquals(savedRuleChain, foundRuleChain); + } + + @Test + public void testDeleteRuleChain() throws Exception { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain"); + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + + Mockito.reset(tbClusterService, auditLogService); + + String entityIdStr = savedRuleChain.getId().getId().toString(); + doDelete("/api/ruleChain/" + savedRuleChain.getId().getId().toString()) + .andExpect(status().isOk()); + + testNotifyEntityBroadcastEntityStateChangeEventOneTimeMsgToEdgeServiceNever(savedRuleChain, savedRuleChain.getId(), savedRuleChain.getId(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.DELETED, savedRuleChain.getId().getId().toString()); + + doGet("/api/ruleChain/" + entityIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Rule chain", entityIdStr)))); + } + + @Test + public void testFindEdgeRuleChainsByTenantIdAndName() throws Exception { + Edge edge = constructEdge("My edge", "default"); + Edge savedEdge = doPost("/api/edge", edge, Edge.class); + + + List edgeRuleChains = new ArrayList<>(); + PageLink pageLink = new PageLink(17); + PageData pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId() + "/ruleChains?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + edgeRuleChains.addAll(pageData.getData()); + + Mockito.reset(tbClusterService, auditLogService); + + int cntEntity = 28; + for (int i = 0; i < cntEntity; i++) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain " + i); + ruleChain.setType(RuleChainType.EDGE); + RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + doPost("/api/edge/" + savedEdge.getId().getId().toString() + + "/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); + edgeRuleChains.add(savedRuleChain); + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new RuleChain(), new RuleChain(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED, ActionType.ADDED, cntEntity, 0, cntEntity * 2); + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new RuleChain(), new RuleChain(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ASSIGNED_TO_EDGE, ActionType.ASSIGNED_TO_EDGE, cntEntity, cntEntity, cntEntity * 2, + new String(), new String(), new String()); + Mockito.reset(tbClusterService, auditLogService); + + List loadedEdgeRuleChains = new ArrayList<>(); + pageLink = new PageLink(17); + do { + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId() + "/ruleChains?", + new TypeReference<>() { + }, pageLink); + loadedEdgeRuleChains.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageLink.nextPageLink(); + } + } while (pageData.hasNext()); + + Collections.sort(edgeRuleChains, idComparator); + Collections.sort(loadedEdgeRuleChains, idComparator); + + Assert.assertEquals(edgeRuleChains, loadedEdgeRuleChains); + + for (RuleChain ruleChain : loadedEdgeRuleChains) { + if (!ruleChain.isRoot()) { + doDelete("/api/edge/" + savedEdge.getId().getId().toString() + + "/ruleChain/" + ruleChain.getId().getId().toString(), RuleChain.class); + } + } + + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(new RuleChain(), new RuleChain(), + savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.UNASSIGNED_FROM_EDGE, ActionType.UNASSIGNED_FROM_EDGE, cntEntity, cntEntity, 3); + + pageLink = new PageLink(17); + pageData = doGetTypedWithPageLink("/api/edge/" + savedEdge.getId().getId() + "/ruleChains?", + new TypeReference<>() { + }, pageLink); + Assert.assertFalse(pageData.hasNext()); + Assert.assertEquals(1, pageData.getTotalElements()); + } + + @Test + public void testDeleteRuleChainWithDeleteRelationsOk() throws Exception { + RuleChainId ruleChainId = createRuleChain("RuleChain for Test WithRelationsOk").getId(); + testEntityDaoWithRelationsOk(savedTenant.getId(), ruleChainId, "/api/ruleChain/" + ruleChainId); + } + + @Test + public void testDeleteRuleChainExceptionWithRelationsTransactional() throws Exception { + RuleChainId ruleChainId = createRuleChain("RuleChain for Test WithRelations Transactional Exception").getId(); + testEntityDaoWithRelationsTransactionalException(ruleChainDao, savedTenant.getId(), ruleChainId, "/api/ruleChain/" + ruleChainId); + } + + @Test + public void givenRuleNodeWithInvalidConfiguration_thenReturnError() throws Exception { + RuleChain ruleChain = createRuleChain("Rule chain with invalid nodes"); + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + + RuleNode createAlarmNode = new RuleNode(); + createAlarmNode.setName("Create alarm"); + createAlarmNode.setType(TbCreateAlarmNode.class.getName()); + TbCreateAlarmNodeConfiguration invalidCreateAlarmNodeConfiguration = new TbCreateAlarmNodeConfiguration(); + invalidCreateAlarmNodeConfiguration.setSeverity("yyy", + "bambam", + "

Link!!!

1221", + "

Please log in to proceed

Username:

Password:



", + " ", + "123 bebe" + }) + public void givenEntityWithMaliciousPropertyValue_thenReturnValidationError(String maliciousString) { + Asset invalidAsset = new Asset(); + invalidAsset.setName(maliciousString); + + assertThatThrownBy(() -> { + ConstraintValidator.validateFields(invalidAsset); + }).hasMessageContaining("is malformed"); + } + + @Test + public void givenEntityWithMaliciousValueInAdditionalInfo_thenReturnValidationError() { + Asset invalidAsset = new Asset(); + String maliciousValue = "qwertyqwerty"; + invalidAsset.setAdditionalInfo(JacksonUtil.newObjectNode() + .set("description", new TextNode(maliciousValue))); + + assertThatThrownBy(() -> { + ConstraintValidator.validateFields(invalidAsset); + }).hasMessageContaining("is malformed"); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java new file mode 100644 index 0000000..4abd33a --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -0,0 +1,292 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.attributes; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.attributes.AttributeCacheKey; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.service.AbstractServiceTest; + +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 java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * @author Andrew Shvayka + */ +@Slf4j +public abstract class BaseAttributesServiceTest extends AbstractServiceTest { + + private static final String OLD_VALUE = "OLD VALUE"; + private static final String NEW_VALUE = "NEW VALUE"; + + @Autowired + private TbTransactionalCache cache; + + @Autowired + private AttributesService attributesService; + + @Before + public void before() { + } + + @Test + public void saveAndFetch() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + KvEntry attrValue = new StringDataEntry("attribute1", "value1"); + AttributeKvEntry attr = new BaseAttributeKvEntry(attrValue, 42L); + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attr)).get(); + Optional saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, attr.getKey()).get(); + Assert.assertTrue(saved.isPresent()); + Assert.assertEquals(attr, saved.get()); + } + + @Test + public void saveMultipleTypeAndFetch() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + KvEntry attrOldValue = new StringDataEntry("attribute1", "value1"); + AttributeKvEntry attrOld = new BaseAttributeKvEntry(attrOldValue, 42L); + + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrOld)).get(); + Optional saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()).get(); + + Assert.assertTrue(saved.isPresent()); + Assert.assertEquals(attrOld, saved.get()); + + KvEntry attrNewValue = new StringDataEntry("attribute1", "value2"); + AttributeKvEntry attrNew = new BaseAttributeKvEntry(attrNewValue, 73L); + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrNew)).get(); + + saved = attributesService.find(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, attrOld.getKey()).get(); + Assert.assertEquals(attrNew, saved.get()); + } + + @Test + public void findAll() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + KvEntry attrAOldValue = new StringDataEntry("A", "value1"); + AttributeKvEntry attrAOld = new BaseAttributeKvEntry(attrAOldValue, 42L); + KvEntry attrANewValue = new StringDataEntry("A", "value2"); + AttributeKvEntry attrANew = new BaseAttributeKvEntry(attrANewValue, 73L); + KvEntry attrBNewValue = new StringDataEntry("B", "value3"); + AttributeKvEntry attrBNew = new BaseAttributeKvEntry(attrBNewValue, 73L); + + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrAOld)).get(); + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrANew)).get(); + attributesService.save(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE, Collections.singletonList(attrBNew)).get(); + + List saved = attributesService.findAll(SYSTEM_TENANT_ID, deviceId, DataConstants.CLIENT_SCOPE).get(); + + Assert.assertNotNull(saved); + Assert.assertEquals(2, saved.size()); + + Assert.assertEquals(attrANew, saved.get(0)); + Assert.assertEquals(attrBNew, saved.get(1)); + } + + @Test + public void testDummyRequestWithEmptyResult() throws Exception { + var future = attributesService.find(new TenantId(UUID.randomUUID()), new DeviceId(UUID.randomUUID()), DataConstants.SERVER_SCOPE, "TEST"); + Assert.assertNotNull(future); + var result = future.get(10, TimeUnit.SECONDS); + Assert.assertTrue(result.isEmpty()); + } + + @Test + public void testConcurrentTransaction() throws Exception { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + var scope = DataConstants.SERVER_SCOPE; + var key = "TEST"; + + var attrKey = new AttributeCacheKey(scope, deviceId, "TEST"); + var oldValue = new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry(key, OLD_VALUE)); + var newValue = new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry(key, NEW_VALUE)); + + var trx = cache.newTransactionForKey(attrKey); + cache.putIfAbsent(attrKey, newValue); + trx.putIfAbsent(attrKey, oldValue); + Assert.assertFalse(trx.commit()); + Assert.assertEquals(NEW_VALUE, getAttributeValue(tenantId, deviceId, scope, key)); + } + + @Test + public void testConcurrentFetchAndUpdate() throws Exception { + var tenantId = new TenantId(UUID.randomUUID()); + ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(2)); + try { + for (int i = 0; i < 100; i++) { + var deviceId = new DeviceId(UUID.randomUUID()); + testConcurrentFetchAndUpdate(tenantId, deviceId, pool); + } + } finally { + pool.shutdownNow(); + } + } + + @Test + public void testConcurrentFetchAndUpdateMulti() throws Exception { + var tenantId = new TenantId(UUID.randomUUID()); + ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(2)); + try { + for (int i = 0; i < 100; i++) { + var deviceId = new DeviceId(UUID.randomUUID()); + testConcurrentFetchAndUpdateMulti(tenantId, deviceId, pool); + } + } finally { + pool.shutdownNow(); + } + } + + @Test + public void testFetchAndUpdateEmpty() throws Exception { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + var scope = DataConstants.SERVER_SCOPE; + var key = "TEST"; + + Optional emptyValue = attributesService.find(tenantId, deviceId, scope, key).get(10, TimeUnit.SECONDS); + Assert.assertTrue(emptyValue.isEmpty()); + + saveAttribute(tenantId, deviceId, scope, key, NEW_VALUE); + Assert.assertEquals(NEW_VALUE, getAttributeValue(tenantId, deviceId, scope, key)); + } + + @Test + public void testFetchAndUpdateMulti() throws Exception { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + var scope = DataConstants.SERVER_SCOPE; + var key1 = "TEST1"; + var key2 = "TEST2"; + + var value = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertTrue(value.isEmpty()); + + saveAttribute(tenantId, deviceId, scope, key1, OLD_VALUE); + + value = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertEquals(1, value.size()); + Assert.assertEquals(OLD_VALUE, value.get(0)); + + saveAttribute(tenantId, deviceId, scope, key2, NEW_VALUE); + + value = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertEquals(2, value.size()); + Assert.assertTrue(value.contains(OLD_VALUE)); + Assert.assertTrue(value.contains(NEW_VALUE)); + + saveAttribute(tenantId, deviceId, scope, key1, NEW_VALUE); + + value = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertEquals(2, value.size()); + Assert.assertEquals(NEW_VALUE, value.get(0)); + Assert.assertEquals(NEW_VALUE, value.get(1)); + } + + private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { + var scope = DataConstants.SERVER_SCOPE; + var key = "TEST"; + saveAttribute(tenantId, deviceId, scope, key, OLD_VALUE); + List> futures = new ArrayList<>(); + futures.add(pool.submit(() -> { + var value = getAttributeValue(tenantId, deviceId, scope, key); + Assert.assertTrue(value.equals(OLD_VALUE) || value.equals(NEW_VALUE)); + })); + futures.add(pool.submit(() -> saveAttribute(tenantId, deviceId, scope, key, NEW_VALUE))); + Futures.allAsList(futures).get(10, TimeUnit.SECONDS); + Assert.assertEquals(NEW_VALUE, getAttributeValue(tenantId, deviceId, scope, key)); + } + + private void testConcurrentFetchAndUpdateMulti(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { + var scope = DataConstants.SERVER_SCOPE; + var key1 = "TEST1"; + var key2 = "TEST2"; + saveAttribute(tenantId, deviceId, scope, key1, OLD_VALUE); + saveAttribute(tenantId, deviceId, scope, key2, OLD_VALUE); + List> futures = new ArrayList<>(); + futures.add(pool.submit(() -> { + var value = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertEquals(2, value.size()); + Assert.assertTrue(value.contains(OLD_VALUE) || value.contains(NEW_VALUE)); + })); + futures.add(pool.submit(() -> { + saveAttribute(tenantId, deviceId, scope, key1, NEW_VALUE); + saveAttribute(tenantId, deviceId, scope, key2, NEW_VALUE); + })); + Futures.allAsList(futures).get(10, TimeUnit.SECONDS); + var newResult = getAttributeValues(tenantId, deviceId, scope, Arrays.asList(key1, key2)); + Assert.assertEquals(2, newResult.size()); + Assert.assertEquals(NEW_VALUE, newResult.get(0)); + Assert.assertEquals(NEW_VALUE, newResult.get(1)); + } + + private String getAttributeValue(TenantId tenantId, DeviceId deviceId, String scope, String key) { + try { + Optional entry = attributesService.find(tenantId, deviceId, scope, key).get(10, TimeUnit.SECONDS); + return entry.orElseThrow(RuntimeException::new).getStrValue().orElse("Unknown"); + } catch (Exception e) { + log.warn("Failed to get attribute", e.getCause()); + throw new RuntimeException(e); + } + } + + private List getAttributeValues(TenantId tenantId, DeviceId deviceId, String scope, List keys) { + try { + List entry = attributesService.find(tenantId, deviceId, scope, keys).get(10, TimeUnit.SECONDS); + return entry.stream().map(e -> e.getStrValue().orElse(null)).collect(Collectors.toList()); + } catch (Exception e) { + log.warn("Failed to get attributes", e.getCause()); + throw new RuntimeException(e); + } + } + + private void saveAttribute(TenantId tenantId, DeviceId deviceId, String scope, String key, String s) { + try { + AttributeKvEntry newEntry = new BaseAttributeKvEntry(System.currentTimeMillis(), new StringDataEntry(key, s)); + attributesService.save(tenantId, deviceId, scope, Collections.singletonList(newEntry)).get(10, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("Failed to save attribute", e.getCause()); + Assert.assertNull(e); + } + } + + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributesServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributesServiceSqlTest.java new file mode 100644 index 0000000..1144fa1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/sql/AttributesServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.attributes.sql; + +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.service.attributes.BaseAttributesServiceTest; + +@DaoSqlTest +public class AttributesServiceSqlTest extends BaseAttributesServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java new file mode 100644 index 0000000..26e0df8 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/event/BaseEventServiceTest.java @@ -0,0 +1,136 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.event; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.event.Event; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.event.RuleNodeDebugEvent; +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.EventId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.service.AbstractServiceTest; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; + +public abstract class BaseEventServiceTest extends AbstractServiceTest { + long timeBeforeStartTime; + long startTime; + long eventTime; + long endTime; + long timeAfterEndTime; + + @Before + public void before() throws ParseException { + timeBeforeStartTime = ISO_DATETIME_TIME_ZONE_FORMAT.parse("2016-11-01T11:30:00Z").getTime(); + startTime = ISO_DATETIME_TIME_ZONE_FORMAT.parse("2016-11-01T12:00:00Z").getTime(); + eventTime = ISO_DATETIME_TIME_ZONE_FORMAT.parse("2016-11-01T12:30:00Z").getTime(); + endTime = ISO_DATETIME_TIME_ZONE_FORMAT.parse("2016-11-01T13:00:00Z").getTime(); + timeAfterEndTime = ISO_DATETIME_TIME_ZONE_FORMAT.parse("2016-11-01T13:30:30Z").getTime(); + } + + @Test + public void saveEvent() throws Exception { + TenantId tenantId = new TenantId(Uuids.timeBased()); + DeviceId devId = new DeviceId(Uuids.timeBased()); + RuleNodeDebugEvent event = generateEvent(tenantId, devId); + eventService.saveAsync(event).get(); + List loaded = eventService.findLatestEvents(event.getTenantId(), devId, event.getType(), 1); + Assert.assertNotNull(loaded); + Assert.assertEquals(1, loaded.size()); + Assert.assertEquals(event.getData(), loaded.get(0).getBody().get("data").asText()); + } + + @Test + public void findEventsByTypeAndTimeAscOrder() throws Exception { + CustomerId customerId = new CustomerId(Uuids.timeBased()); + TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); + saveEventWithProvidedTime(timeBeforeStartTime, customerId, tenantId); + Event savedEvent = saveEventWithProvidedTime(eventTime, customerId, tenantId); + Event savedEvent2 = saveEventWithProvidedTime(eventTime + 1, customerId, tenantId); + Event savedEvent3 = saveEventWithProvidedTime(eventTime + 2, customerId, tenantId); + saveEventWithProvidedTime(timeAfterEndTime, customerId, tenantId); + + TimePageLink timePageLink = new TimePageLink(2, 0, "", new SortOrder("ts"), startTime, endTime); + + PageData events = eventService.findEvents(tenantId, customerId, EventType.DEBUG_RULE_NODE, timePageLink); + + Assert.assertNotNull(events.getData()); + Assert.assertEquals(2, events.getData().size()); + Assert.assertEquals(savedEvent.getUuidId(), events.getData().get(0).getUuidId()); + Assert.assertEquals(savedEvent2.getUuidId(), events.getData().get(1).getUuidId()); + Assert.assertTrue(events.hasNext()); + + events = eventService.findEvents(tenantId, customerId, EventType.DEBUG_RULE_NODE, timePageLink.nextPageLink()); + + Assert.assertNotNull(events.getData()); + Assert.assertEquals(1, events.getData().size()); + Assert.assertEquals(savedEvent3.getUuidId(), events.getData().get(0).getUuidId()); + Assert.assertFalse(events.hasNext()); + + eventService.cleanupEvents(timeBeforeStartTime - 1, timeAfterEndTime + 1, true); + } + + @Test + public void findEventsByTypeAndTimeDescOrder() throws Exception { + CustomerId customerId = new CustomerId(Uuids.timeBased()); + TenantId tenantId = TenantId.fromUUID(Uuids.timeBased()); + saveEventWithProvidedTime(timeBeforeStartTime, customerId, tenantId); + Event savedEvent = saveEventWithProvidedTime(eventTime, customerId, tenantId); + Event savedEvent2 = saveEventWithProvidedTime(eventTime + 1, customerId, tenantId); + Event savedEvent3 = saveEventWithProvidedTime(eventTime + 2, customerId, tenantId); + saveEventWithProvidedTime(timeAfterEndTime, customerId, tenantId); + + TimePageLink timePageLink = new TimePageLink(2, 0, "", new SortOrder("ts", SortOrder.Direction.DESC), startTime, endTime); + + PageData events = eventService.findEvents(tenantId, customerId, EventType.DEBUG_RULE_NODE, timePageLink); + + Assert.assertNotNull(events.getData()); + Assert.assertEquals(2, events.getData().size()); + Assert.assertEquals(savedEvent3.getUuidId(), events.getData().get(0).getUuidId()); + Assert.assertEquals(savedEvent2.getUuidId(), events.getData().get(1).getUuidId()); + Assert.assertTrue(events.hasNext()); + + events = eventService.findEvents(tenantId, customerId, EventType.DEBUG_RULE_NODE, timePageLink.nextPageLink()); + + Assert.assertNotNull(events.getData()); + Assert.assertEquals(1, events.getData().size()); + Assert.assertEquals(savedEvent.getUuidId(), events.getData().get(0).getUuidId()); + Assert.assertFalse(events.hasNext()); + + eventService.cleanupEvents(timeBeforeStartTime - 1, timeAfterEndTime + 1, true); + } + + private Event saveEventWithProvidedTime(long time, EntityId entityId, TenantId tenantId) throws Exception { + RuleNodeDebugEvent event = generateEvent(tenantId, entityId); + event.setId(new EventId(Uuids.timeBased())); + event.setCreatedTime(time); + eventService.saveAsync(event).get(); + return event; + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest.java new file mode 100644 index 0000000..00b598e --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/event/sql/EventServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.event.sql; + +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.service.event.BaseEventServiceTest; + +@DaoSqlTest +public class EventServiceSqlTest extends BaseEventServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/install/sql/EntitiesSchemaSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/install/sql/EntitiesSchemaSqlTest.java new file mode 100644 index 0000000..285ea5b --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/install/sql/EntitiesSchemaSqlTest.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.install.sql; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@DaoSqlTest +public class EntitiesSchemaSqlTest extends AbstractServiceTest { + + @Value("${classpath:sql/schema-entities.sql}") + private Path installScriptPath; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void testRepeatedInstall() throws IOException { + String installScript = Files.readString(installScriptPath); + try { + for (int i = 1; i <= 2; i++) { + jdbcTemplate.execute(installScript); + } + } catch (Exception e) { + Assertions.fail("Failed to execute reinstall", e); + } + } + + @Test + public void testRepeatedInstall_badScript() { + String illegalInstallScript = "CREATE TABLE IF NOT EXISTS qwerty ();\n" + + "ALTER TABLE qwerty ADD COLUMN first VARCHAR(10);"; + + assertDoesNotThrow(() -> { + jdbcTemplate.execute(illegalInstallScript); + }); + + try { + assertThatThrownBy(() -> { + jdbcTemplate.execute(illegalInstallScript); + }).getCause().hasMessageContaining("column").hasMessageContaining("already exists"); + } finally { + jdbcTemplate.execute("DROP TABLE qwerty;"); + } + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/AdminSettingsServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AdminSettingsServiceSqlTest.java new file mode 100644 index 0000000..f314643 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AdminSettingsServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseAdminSettingsServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class AdminSettingsServiceSqlTest extends BaseAdminSettingsServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/AlarmServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AlarmServiceSqlTest.java new file mode 100644 index 0000000..1fb4288 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AlarmServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseAlarmServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class AlarmServiceSqlTest extends BaseAlarmServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/ApiUsageStateServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/ApiUsageStateServiceSqlTest.java new file mode 100644 index 0000000..7e4bfd9 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/ApiUsageStateServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseApiUsageStateServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class ApiUsageStateServiceSqlTest extends BaseApiUsageStateServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetProfileServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetProfileServiceSqlTest.java new file mode 100644 index 0000000..11c61e7 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetProfileServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseAssetProfileServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class AssetProfileServiceSqlTest extends BaseAssetProfileServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetServiceSqlTest.java new file mode 100644 index 0000000..820c472 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/AssetServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseAssetServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class AssetServiceSqlTest extends BaseAssetServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/CustomerServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/CustomerServiceSqlTest.java new file mode 100644 index 0000000..91168ca --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/CustomerServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseCustomerServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class CustomerServiceSqlTest extends BaseCustomerServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/DashboardServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DashboardServiceSqlTest.java new file mode 100644 index 0000000..f171851 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DashboardServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseDashboardServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class DashboardServiceSqlTest extends BaseDashboardServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsCacheServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsCacheServiceSqlTest.java new file mode 100644 index 0000000..2732547 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsCacheServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseDeviceCredentialsCacheTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class DeviceCredentialsCacheServiceSqlTest extends BaseDeviceCredentialsCacheTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsServiceSqlTest.java new file mode 100644 index 0000000..34566fb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceCredentialsServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseDeviceCredentialsServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class DeviceCredentialsServiceSqlTest extends BaseDeviceCredentialsServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceProfileServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceProfileServiceSqlTest.java new file mode 100644 index 0000000..fba6af1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceProfileServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseDeviceProfileServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class DeviceProfileServiceSqlTest extends BaseDeviceProfileServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceServiceSqlTest.java new file mode 100644 index 0000000..d991da6 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/DeviceServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseDeviceServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class DeviceServiceSqlTest extends BaseDeviceServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeEventServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeEventServiceSqlTest.java new file mode 100644 index 0000000..9cea74a --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeEventServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseEdgeEventServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class EdgeEventServiceSqlTest extends BaseEdgeEventServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeServiceSqlTest.java new file mode 100644 index 0000000..46e38e5 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EdgeServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseEdgeServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class EdgeServiceSqlTest extends BaseEdgeServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/EntityServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EntityServiceSqlTest.java new file mode 100644 index 0000000..4151175 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/EntityServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseEntityServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class EntityServiceSqlTest extends BaseEntityServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ConfigTemplateServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ConfigTemplateServiceSqlTest.java new file mode 100644 index 0000000..15268c3 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ConfigTemplateServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseOAuth2ConfigTemplateServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class OAuth2ConfigTemplateServiceSqlTest extends BaseOAuth2ConfigTemplateServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ServiceSqlTest.java new file mode 100644 index 0000000..b939ed1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OAuth2ServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseOAuth2ServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class OAuth2ServiceSqlTest extends BaseOAuth2ServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/OtaPackageServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OtaPackageServiceSqlTest.java new file mode 100644 index 0000000..549e946 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/OtaPackageServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseOtaPackageServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class OtaPackageServiceSqlTest extends BaseOtaPackageServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/QueueServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/QueueServiceSqlTest.java new file mode 100644 index 0000000..320bb4f --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/QueueServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseQueueServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class QueueServiceSqlTest extends BaseQueueServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationCacheSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationCacheSqlTest.java new file mode 100644 index 0000000..a3af57d --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationCacheSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseRelationCacheTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class RelationCacheSqlTest extends BaseRelationCacheTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationServiceSqlTest.java new file mode 100644 index 0000000..3bf8bcb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RelationServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseRelationServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class RelationServiceSqlTest extends BaseRelationServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/RuleChainServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RuleChainServiceSqlTest.java new file mode 100644 index 0000000..1a52c53 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/RuleChainServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseRuleChainServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class RuleChainServiceSqlTest extends BaseRuleChainServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantProfileServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantProfileServiceSqlTest.java new file mode 100644 index 0000000..42b54d1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantProfileServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseTenantProfileServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class TenantProfileServiceSqlTest extends BaseTenantProfileServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantServiceSqlTest.java new file mode 100644 index 0000000..b2fcb16 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/TenantServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseTenantServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class TenantServiceSqlTest extends BaseTenantServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/UserServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/UserServiceSqlTest.java new file mode 100644 index 0000000..d22f46c --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/UserServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseUserServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class UserServiceSqlTest extends BaseUserServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetTypeServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetTypeServiceSqlTest.java new file mode 100644 index 0000000..689f193 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetTypeServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseWidgetTypeServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class WidgetTypeServiceSqlTest extends BaseWidgetTypeServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetsBundleServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetsBundleServiceSqlTest.java new file mode 100644 index 0000000..24e6a22 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/sql/WidgetsBundleServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.sql; + +import org.thingsboard.server.dao.service.BaseWidgetsBundleServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class WidgetsBundleServiceSqlTest extends BaseWidgetsBundleServiceTest { +} 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 new file mode 100644 index 0000000..1eea5d3 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -0,0 +1,706 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.timeseries; + +import com.datastax.oss.driver.api.core.uuid.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; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +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.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; +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; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * @author Andrew Shvayka + */ + +@Slf4j +public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { + static final int MAX_TIMEOUT = 30; + + private static final String STRING_KEY = "stringKey"; + private static final String LONG_KEY = "longKey"; + private static final String DOUBLE_KEY = "doubleKey"; + private static final String BOOLEAN_KEY = "booleanKey"; + + private static final long TS = 42L; + private static final String DESC_ORDER = "DESC"; + + KvEntry stringKvEntry = new StringDataEntry(STRING_KEY, "value"); + KvEntry longKvEntry = new LongDataEntry(LONG_KEY, Long.MAX_VALUE); + 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()); + + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + saveEntries(deviceId, TS); + + testLatestTsAndVerify(deviceId); + } + + private void testLatestTsAndVerify(EntityId entityId) throws ExecutionException, InterruptedException, TimeoutException { + List tsList = tsService.findAllLatest(tenantId, entityId).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertNotNull(tsList); + assertEquals(4, tsList.size()); + for (int i = 0; i < tsList.size(); i++) { + assertEquals(TS, tsList.get(i).getTs()); + } + + Collections.sort(tsList, (o1, o2) -> o1.getKey().compareTo(o2.getKey())); + + List expected = Arrays.asList( + toTsEntry(TS, stringKvEntry), + toTsEntry(TS, longKvEntry), + toTsEntry(TS, doubleKvEntry), + toTsEntry(TS, booleanKvEntry)); + Collections.sort(expected, (o1, o2) -> o1.getKey().compareTo(o2.getKey())); + + assertEquals(expected, tsList); + } + + private EntityView saveAndCreateEntityView(DeviceId deviceId, List timeseries) { + EntityView entityView = new EntityView(); + entityView.setName("entity_view_name"); + entityView.setType("default"); + entityView.setTenantId(tenantId); + TelemetryEntityView keys = new TelemetryEntityView(); + keys.setTimeseries(timeseries); + entityView.setKeys(keys); + entityView.setEntityId(deviceId); + return entityViewService.saveEntityView(entityView); + } + + @Test + public void testFindLatest() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + saveEntries(deviceId, TS); + + List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); + } + + @Test + public void testFindLatestWithoutLatestUpdate() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + saveEntriesWithoutLatest(deviceId, TS); + + List entries = tsService.findLatest(tenantId, deviceId, Collections.singleton(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(0)); + } + + @Test + public void testFindByQueryAscOrder() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + saveEntries(deviceId, TS - 3); + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + + List queries = new ArrayList<>(); + queries.add(new BaseReadTsKvQuery(STRING_KEY, TS - 3, TS, 0, 1000, Aggregation.NONE, "ASC")); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(3, entries.size()); + Assert.assertEquals(toTsEntry(TS - 3, stringKvEntry), entries.get(0)); + Assert.assertEquals(toTsEntry(TS - 2, stringKvEntry), entries.get(1)); + Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(2)); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(3, entries.size()); + Assert.assertEquals(toTsEntry(TS - 3, stringKvEntry), entries.get(0)); + Assert.assertEquals(toTsEntry(TS - 2, stringKvEntry), entries.get(1)); + Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(2)); + } + + @Test + public void testFindByQuery_whenPeriodEqualsOneMilisecondPeriod() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS - 1L); + saveEntries(deviceId, TS); + saveEntries(deviceId, TS + 1L); + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS, 1, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS, new LongDataEntry(LONG_KEY, 1L)), entries.get(0)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS, new LongDataEntry(LONG_KEY, 1L)), entries.get(0)); + } + + @Test + public void testFindByQuery_whenPeriodEqualsInterval() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS - 1L); + for (long i = TS; i <= TS + 100L; i += 10L) { + saveEntries(deviceId, i); + } + saveEntries(deviceId, TS + 100L + 1L); + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS + 100, 100, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS + 50, new LongDataEntry(LONG_KEY, 10L)), entries.get(0)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS + 50, new LongDataEntry(LONG_KEY, 10L)), entries.get(0)); + } + + @Test + public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS - 1L); + for (long i = TS; i <= TS + 100000L; i += 10000L) { + saveEntries(deviceId, i); + } + saveEntries(deviceId, TS + 100000L + 1L); + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS + 99999, 50000, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 75000 - 1, new LongDataEntry(LONG_KEY, 5L)), entries.get(1)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 75000 - 1, new LongDataEntry(LONG_KEY, 5L)), entries.get(1)); + } + + @Test + public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS - 1L); + for (long i = TS; i <= TS + 80000L; i += 10000L) { + saveEntries(deviceId, i); + } + saveEntries(deviceId, TS + 80000L + 1L); + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS + 80000, 50000, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 65000, new LongDataEntry(LONG_KEY, 3L)), entries.get(1)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 65000, new LongDataEntry(LONG_KEY, 3L)), entries.get(1)); + } + + @Test + public void testFindByQuery_whenPeriodHaveTwoIntervalWithEqualsLength_whereNotAllEntriesInRange() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000) { + saveEntries(deviceId, i); + } + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS + 99999, 50000, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 75000 - 1, new LongDataEntry(LONG_KEY, 4L)), entries.get(1)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 75000 - 1, new LongDataEntry(LONG_KEY, 4L)), entries.get(1)); + } + + @Test + public void testFindByQuery_whenPeriodHaveTwoInterval_whereSecondShorterThanFirst_andNotAllEntriesInRange() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + for (long i = TS - 1L; i <= TS + 100000L + 1L; i += 10000L) { + saveEntries(deviceId, i); + } + + List queries = List.of(new BaseReadTsKvQuery(LONG_KEY, TS, TS + 80000, 50000, 1, Aggregation.COUNT, DESC_ORDER)); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 65000, new LongDataEntry(LONG_KEY, 3L)), entries.get(1)); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(); + Assert.assertEquals(2, entries.size()); + Assert.assertEquals(toTsEntry(TS + 25000, new LongDataEntry(LONG_KEY, 5L)), entries.get(0)); + Assert.assertEquals(toTsEntry(TS + 65000, new LongDataEntry(LONG_KEY, 3L)), entries.get(1)); + } + + @Test + public void testFindByQueryDescOrder() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + saveEntries(deviceId, TS - 3); + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 1); + + List queries = new ArrayList<>(); + queries.add(new BaseReadTsKvQuery(STRING_KEY, TS - 3, TS, 0, 1000, Aggregation.NONE, "DESC")); + + List entries = tsService.findAll(tenantId, deviceId, queries).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(3, entries.size()); + Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(0)); + Assert.assertEquals(toTsEntry(TS - 2, stringKvEntry), entries.get(1)); + Assert.assertEquals(toTsEntry(TS - 3, stringKvEntry), entries.get(2)); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY)); + + entries = tsService.findAll(tenantId, entityView.getId(), queries).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(3, entries.size()); + Assert.assertEquals(toTsEntry(TS - 1, stringKvEntry), entries.get(0)); + Assert.assertEquals(toTsEntry(TS - 2, stringKvEntry), entries.get(1)); + Assert.assertEquals(toTsEntry(TS - 3, stringKvEntry), entries.get(2)); + } + + @Test + public void testFindAllByQueries_verifyQueryId() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS); + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 10); + + BaseReadTsKvQuery query = new BaseReadTsKvQuery(STRING_KEY, TS - 10, TS + 1, 0, 1000, Aggregation.NONE, "DESC"); + findAndVerifyQueryId(deviceId, query); + } + + @Test + public void testFindAllByQueries_verifyQueryId_forEntityView() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + saveEntries(deviceId, TS); + saveEntries(deviceId, TS - 2); + saveEntries(deviceId, TS - 12); + + EntityView entityView = saveAndCreateEntityView(deviceId, List.of(LONG_KEY)); + + BaseReadTsKvQuery query = new BaseReadTsKvQuery(LONG_KEY, TS - 10, TS + 1, 0, 1000, Aggregation.NONE, "DESC"); + findAndVerifyQueryId(entityView.getId(), query); + } + + private void findAndVerifyQueryId(EntityId entityId, ReadTsKvQuery query) throws InterruptedException, ExecutionException, TimeoutException { + List results = tsService.findAllByQueries(tenantId, entityId, List.of(query)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(results).isNotEmpty(); + assertThat(results).extracting(ReadTsKvQueryResult::getQueryId).containsOnly(query.getId()); + } + + @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(tenantId, deviceId, Collections.singletonList( + new BaseDeleteTsKvQuery(STRING_KEY, 25000, 45000, true))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList( + new BaseReadTsKvQuery(STRING_KEY, 5000, 45000, 10000, 10, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(2, list.size()); + + List latest = tsService.findLatest(tenantId, deviceId, Collections.singletonList(STRING_KEY)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + Assert.assertEquals(20000, latest.get(0).getTs()); + } + + @Test + public void testFindDeviceTsData() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + List entries = new ArrayList<>(); + + entries.add(save(deviceId, 5000, 100)); + entries.add(save(deviceId, 15000, 200)); + + entries.add(save(deviceId, 25000, 300)); + entries.add(save(deviceId, 35000, 400)); + + entries.add(save(deviceId, 45000, 500)); + entries.add(save(deviceId, 55000, 600)); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + assertEquals(55000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(600L), list.get(0).getLongValue()); + + assertEquals(45000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(500L), list.get(1).getLongValue()); + + assertEquals(35000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(400L), list.get(2).getLongValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.AVG))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(150.0), list.get(0).getDoubleValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(350.0), list.get(1).getDoubleValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(550.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.SUM))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(300L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(700L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(1100L), list.get(2).getLongValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.MIN))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(100L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(300L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(500L), list.get(2).getLongValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(200L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(400L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(600L), list.get(2).getLongValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.COUNT))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); + + + entries.add(save(deviceId, 65000, "A1")); + entries.add(save(deviceId, 75000, "A2")); + entries.add(save(deviceId, 85000, "B1")); + entries.add(save(deviceId, 95000, "B2")); + entries.add(save(deviceId, 105000, "C1")); + entries.add(save(deviceId, 115000, "C2")); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 60000, + 120000, 20000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + assertEquals(115000, list.get(0).getTs()); + assertEquals(java.util.Optional.of("C2"), list.get(0).getStrValue()); + + assertEquals(105000, list.get(1).getTs()); + assertEquals(java.util.Optional.of("C1"), list.get(1).getStrValue()); + + assertEquals(95000, list.get(2).getTs()); + assertEquals(java.util.Optional.of("B2"), list.get(2).getStrValue()); + + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 60000, + 120000, 20000, 3, Aggregation.MIN))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(70000, list.get(0).getTs()); + assertEquals(java.util.Optional.of("A1"), list.get(0).getStrValue()); + + assertEquals(90000, list.get(1).getTs()); + assertEquals(java.util.Optional.of("B1"), list.get(1).getStrValue()); + + assertEquals(110000, list.get(2).getTs()); + assertEquals(java.util.Optional.of("C1"), list.get(2).getStrValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 60000, + 120000, 20000, 3, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(70000, list.get(0).getTs()); + assertEquals(java.util.Optional.of("A2"), list.get(0).getStrValue()); + + assertEquals(90000, list.get(1).getTs()); + assertEquals(java.util.Optional.of("B2"), list.get(1).getStrValue()); + + assertEquals(110000, list.get(2).getTs()); + assertEquals(java.util.Optional.of("C2"), list.get(2).getStrValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 60000, + 120000, 20000, 3, Aggregation.COUNT))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(70000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(0).getLongValue()); + + assertEquals(90000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(1).getLongValue()); + + assertEquals(110000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); + } + + @Test + public void testFindDeviceLongAndDoubleTsData() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + List entries = new ArrayList<>(); + + entries.add(save(deviceId, 5000, 100)); + entries.add(save(deviceId, 15000, 200.0)); + + entries.add(save(deviceId, 25000, 300)); + entries.add(save(deviceId, 35000, 400.0)); + + entries.add(save(deviceId, 45000, 500)); + entries.add(save(deviceId, 55000, 600.0)); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + assertEquals(55000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(600.0), list.get(0).getDoubleValue()); + + assertEquals(45000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(500L), list.get(1).getLongValue()); + + assertEquals(35000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(400.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.AVG))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(150.0), list.get(0).getDoubleValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(350.0), list.get(1).getDoubleValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(550.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.SUM))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(300.0), list.get(0).getDoubleValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(700.0), list.get(1).getDoubleValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(1100.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.MIN))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(100.0), list.get(0).getDoubleValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(300.0), list.get(1).getDoubleValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(500.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(200.0), list.get(0).getDoubleValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(400.0), list.get(1).getDoubleValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(600.0), list.get(2).getDoubleValue()); + + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 20000, 3, Aggregation.COUNT))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(3, list.size()); + assertEquals(10000, list.get(0).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(0).getLongValue()); + + assertEquals(30000, list.get(1).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(1).getLongValue()); + + assertEquals(50000, list.get(2).getTs()); + assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); + } + + @Test + public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + save(deviceId, 2000000L, 95); + save(deviceId, 4000000L, 100); + save(deviceId, 6000000L, 105); + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0L, + 8000000L, 200000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + + tsService.remove(tenantId, deviceId, Collections.singletonList( + new BaseDeleteTsKvQuery(LONG_KEY, 0L, 8000000L, false))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0L, + 8000000L, 200000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(0, list.size()); + + save(deviceId, 2000000L, 99); + save(deviceId, 4000000L, 104); + save(deviceId, 6000000L, 109); + list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0L, + 8000000L, 200000, 3, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertEquals(3, list.size()); + } + + private TsKvEntry save(DeviceId deviceId, long ts, long value) throws Exception { + TsKvEntry entry = new BasicTsKvEntry(ts, new LongDataEntry(LONG_KEY, value)); + tsService.save(tenantId, deviceId, entry).get(MAX_TIMEOUT, TimeUnit.SECONDS); + return entry; + } + + private TsKvEntry save(DeviceId deviceId, long ts, double value) throws Exception { + TsKvEntry entry = new BasicTsKvEntry(ts, new DoubleDataEntry(LONG_KEY, value)); + tsService.save(tenantId, deviceId, entry).get(MAX_TIMEOUT, TimeUnit.SECONDS); + return entry; + } + + private TsKvEntry save(DeviceId deviceId, long ts, String value) throws Exception { + TsKvEntry entry = new BasicTsKvEntry(ts, new StringDataEntry(LONG_KEY, value)); + tsService.save(tenantId, deviceId, entry).get(MAX_TIMEOUT, TimeUnit.SECONDS); + return entry; + } + + + private void saveEntries(DeviceId deviceId, long ts) throws ExecutionException, InterruptedException, TimeoutException { + tsService.save(tenantId, deviceId, toTsEntry(ts, stringKvEntry)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + tsService.save(tenantId, deviceId, toTsEntry(ts, longKvEntry)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + tsService.save(tenantId, deviceId, toTsEntry(ts, doubleKvEntry)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + tsService.save(tenantId, deviceId, toTsEntry(ts, booleanKvEntry)).get(MAX_TIMEOUT, TimeUnit.SECONDS); + } + + private void saveEntriesWithoutLatest(DeviceId deviceId, long ts) throws ExecutionException, InterruptedException, TimeoutException { + List tsKvEntry = List.of( + toTsEntry(ts, stringKvEntry), + toTsEntry(ts, longKvEntry), + toTsEntry(ts, doubleKvEntry), + toTsEntry(ts, booleanKvEntry)); + tsService.saveWithoutLatest(tenantId, deviceId, tsKvEntry, 0).get(MAX_TIMEOUT, TimeUnit.SECONDS); + } + + private static TsKvEntry toTsEntry(long ts, KvEntry entry) { + return new BasicTsKvEntry(ts, entry); + } + + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java new file mode 100644 index 0000000..bf571a2 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.timeseries.nosql; + +import org.thingsboard.server.dao.service.DaoNoSqlTest; +import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; + +@DaoNoSqlTest +public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceTimescaleTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceTimescaleTest.java new file mode 100644 index 0000000..c36934c --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceTimescaleTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.timeseries.nosql; + +import org.thingsboard.server.dao.service.DaoTimescaleTest; +import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; + +@DaoTimescaleTest +public class TimeseriesServiceTimescaleTest extends BaseTimeseriesServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java new file mode 100644 index 0000000..2d26ce0 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service.timeseries.sql; + +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; + +@DaoSqlTest +public class TimeseriesServiceSqlTest extends BaseTimeseriesServiceTest { +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDaoTest.java new file mode 100644 index 0000000..32ac8a0 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDaoTest.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.alarm; + +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.id.AlarmId; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.alarm.AlarmDao; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Created by Valerii Sosliuk on 5/21/2017. + */ +@Slf4j +public class JpaAlarmDaoTest extends AbstractJpaDaoTest { + + @Autowired + private AlarmDao alarmDao; + + + @Test + public void testFindLatestByOriginatorAndType() throws ExecutionException, InterruptedException, TimeoutException { + log.info("Current system time in millis = {}", System.currentTimeMillis()); + UUID tenantId = UUID.fromString("d4b68f40-3e96-11e7-a884-898080180d6b"); + UUID originator1Id = UUID.fromString("d4b68f41-3e96-11e7-a884-898080180d6b"); + UUID originator2Id = UUID.fromString("d4b68f42-3e96-11e7-a884-898080180d6b"); + UUID alarm1Id = UUID.fromString("d4b68f43-3e96-11e7-a884-898080180d6b"); + UUID alarm2Id = UUID.fromString("d4b68f44-3e96-11e7-a884-898080180d6b"); + UUID alarm3Id = UUID.fromString("d4b68f45-3e96-11e7-a884-898080180d6b"); + int alarmCountBeforeSave = alarmDao.find(TenantId.fromUUID(tenantId)).size(); + saveAlarm(alarm1Id, tenantId, originator1Id, "TEST_ALARM"); + //The timestamp of the startTime should be different in order for test to always work + Thread.sleep(1); + saveAlarm(alarm2Id, tenantId, originator1Id, "TEST_ALARM"); + saveAlarm(alarm3Id, tenantId, originator2Id, "TEST_ALARM"); + int alarmCountAfterSave = alarmDao.find(TenantId.fromUUID(tenantId)).size(); + assertEquals(3, alarmCountAfterSave - alarmCountBeforeSave); + ListenableFuture future = alarmDao + .findLatestByOriginatorAndTypeAsync(TenantId.fromUUID(tenantId), new DeviceId(originator1Id), "TEST_ALARM"); + Alarm alarm = future.get(30, TimeUnit.SECONDS); + assertNotNull(alarm); + assertEquals(alarm2Id, alarm.getId().getId()); + } + + private void saveAlarm(UUID id, UUID tenantId, UUID deviceId, String type) { + Alarm alarm = new Alarm(); + alarm.setId(new AlarmId(id)); + alarm.setTenantId(TenantId.fromUUID(tenantId)); + alarm.setOriginator(new DeviceId(deviceId)); + alarm.setType(type); + alarm.setPropagate(true); + alarm.setStartTs(System.currentTimeMillis()); + alarm.setEndTs(System.currentTimeMillis()); + alarm.setStatus(AlarmStatus.ACTIVE_UNACK); + alarmDao.save(TenantId.fromUUID(tenantId), alarm); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java new file mode 100644 index 0000000..7c3365d --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java @@ -0,0 +1,256 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.asset; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.ListenableFuture; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.asset.AssetDao; +import org.thingsboard.server.dao.asset.AssetProfileDao; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Created by Valerii Sosliuk on 5/21/2017. + */ +public class JpaAssetDaoTest extends AbstractJpaDaoTest { + + UUID tenantId1; + UUID tenantId2; + UUID customerId1; + UUID customerId2; + List assets = new ArrayList<>(); + @Autowired + private AssetDao assetDao; + + @Autowired + private AssetProfileDao assetProfileDao; + + private Map savedAssetProfiles = new HashMap<>(); + + @Before + public void setUp() { + tenantId1 = Uuids.timeBased(); + tenantId2 = Uuids.timeBased(); + customerId1 = Uuids.timeBased(); + customerId2 = Uuids.timeBased(); + for (int i = 0; i < 60; i++) { + UUID assetId = Uuids.timeBased(); + UUID tenantId = i % 2 == 0 ? tenantId1 : tenantId2; + UUID customerId = i % 2 == 0 ? customerId1 : customerId2; + assets.add(saveAsset(assetId, tenantId, customerId, "ASSET_" + i)); + } + assertEquals(assets.size(), assetDao.find(TenantId.fromUUID(tenantId1)).size()); + } + + @After + public void tearDown() { + for (Asset asset : assets) { + assetDao.removeById(asset.getTenantId(), asset.getUuidId()); + } + assets.clear(); + for (AssetProfileId assetProfileId : savedAssetProfiles.values()) { + assetProfileDao.removeById(TenantId.SYS_TENANT_ID, assetProfileId.getId()); + } + savedAssetProfiles.clear(); + } + + @Test + public void testFindAssetsByTenantId() { + PageLink pageLink = new PageLink(20, 0, "ASSET_"); + PageData assets1 = assetDao.findAssetsByTenantId(tenantId1, pageLink); + assertEquals(20, assets1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets2 = assetDao.findAssetsByTenantId(tenantId1, pageLink); + assertEquals(10, assets2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets3 = assetDao.findAssetsByTenantId(tenantId1, pageLink); + assertEquals(0, assets3.getData().size()); + } + + @Test + public void testFindAssetsByTenantIdAndCustomerId() { + PageLink pageLink = new PageLink(20, 0, "ASSET_"); + PageData assets1 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(20, assets1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets2 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(10, assets2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets3 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(0, assets3.getData().size()); + } + + @Test + public void testFindAssetsByTenantIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { + List searchIds = getAssetsUuids(tenantId1); + + ListenableFuture> assetsFuture = assetDao + .findAssetsByTenantIdAndIdsAsync(tenantId1, searchIds); + List assets = assetsFuture.get(30, TimeUnit.SECONDS); + assertNotNull(assets); + assertEquals(searchIds.size(), assets.size()); + } + + @Test + public void testFindAssetsByTenantIdCustomerIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { + List searchIds = getAssetsUuids(tenantId1); + + ListenableFuture> assetsFuture = assetDao + .findAssetsByTenantIdAndCustomerIdAndIdsAsync(tenantId1, customerId1, searchIds); + List assets = assetsFuture.get(30, TimeUnit.SECONDS); + assertNotNull(assets); + assertEquals(searchIds.size(), assets.size()); + } + + private List getAssetsUuids(UUID tenantId) { + List result = new ArrayList<>(); + for (Asset asset : assets) { + if (asset.getTenantId().getId().equals(tenantId)) { + result.add(asset.getUuidId()); + } + } + return result; + } + + @Test + public void testFindAssetsByTenantIdAndName() { + UUID assetId = Uuids.timeBased(); + String name = "TEST_ASSET"; + assets.add(saveAsset(assetId, tenantId2, customerId2, name)); + + Optional assetOpt1 = assetDao.findAssetsByTenantIdAndName(tenantId2, name); + assertTrue("Optional expected to be non-empty", assetOpt1.isPresent()); + assertEquals(assetId, assetOpt1.get().getId().getId()); + + Optional assetOpt2 = assetDao.findAssetsByTenantIdAndName(tenantId2, "NON_EXISTENT_NAME"); + assertFalse("Optional expected to be empty", assetOpt2.isPresent()); + } + + @Test + public void testFindAssetsByTenantIdAndType() { + String type = "TYPE_2"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + + List foundedAssetsByType = assetDao + .findAssetsByTenantIdAndType(tenantId2, type, new PageLink(3)).getData(); + compareFoundedAssetByType(foundedAssetsByType, type); + } + + @Test + public void testFindAssetsByTenantIdAndCustomerIdAndType() { + String type = "TYPE_2"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + + List foundedAssetsByType = assetDao + .findAssetsByTenantIdAndCustomerIdAndType(tenantId2, customerId2, type, new PageLink(3)).getData(); + compareFoundedAssetByType(foundedAssetsByType, type); + } + + private void compareFoundedAssetByType(List foundedAssetsByType, String type) { + assertNotNull(foundedAssetsByType); + assertEquals(1, foundedAssetsByType.size()); + assertEquals(type, foundedAssetsByType.get(0).getType()); + } + + @Test + public void testFindTenantAssetTypesAsync() throws ExecutionException, InterruptedException, TimeoutException { + // Assets with type "TYPE_1" added in setUp method + assets.add(saveAsset(Uuids.timeBased(), tenantId1, customerId1, "TEST_ASSET_3", "TYPE_2")); + assets.add(saveAsset(Uuids.timeBased(), tenantId1, customerId1, "TEST_ASSET_4", "TYPE_3")); + assets.add(saveAsset(Uuids.timeBased(), tenantId1, customerId1, "TEST_ASSET_5", "TYPE_3")); + assets.add(saveAsset(Uuids.timeBased(), tenantId1, customerId1, "TEST_ASSET_6", "TYPE_3")); + + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET_7", "TYPE_4")); + + List tenant1Types = assetDao.findTenantAssetTypesAsync(tenantId1).get(30, TimeUnit.SECONDS); + assertNotNull(tenant1Types); + List tenant2Types = assetDao.findTenantAssetTypesAsync(tenantId2).get(30, TimeUnit.SECONDS); + assertNotNull(tenant2Types); + + List types = List.of("default", "TYPE_1", "TYPE_2", "TYPE_3", "TYPE_4"); + assertEquals(getDifferentTypesCount(types, tenant1Types), tenant1Types.size()); + assertEquals(getDifferentTypesCount(types, tenant2Types), tenant2Types.size()); + } + + private long getDifferentTypesCount(List types, List foundedAssetsTypes) { + return foundedAssetsTypes.stream().filter(type -> types.contains(type.getType())).count(); + } + + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name) { + return saveAsset(id, tenantId, customerId, name, null); + } + + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String type) { + if (type == null) { + type = "default"; + } + Asset asset = new Asset(); + asset.setId(new AssetId(id)); + asset.setTenantId(TenantId.fromUUID(tenantId)); + asset.setCustomerId(new CustomerId(customerId)); + asset.setName(name); + asset.setType(type); + asset.setAssetProfileId(assetProfileId(type)); + return assetDao.save(TenantId.fromUUID(tenantId), asset); + } + + private AssetProfileId assetProfileId(String type) { + AssetProfileId assetProfileId = savedAssetProfiles.get(type); + if (assetProfileId == null) { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setName(type); + assetProfile.setTenantId(TenantId.SYS_TENANT_ID); + assetProfile.setDescription("Test"); + AssetProfile savedAssetProfile = assetProfileDao.save(TenantId.SYS_TENANT_ID, assetProfile); + assetProfileId = savedAssetProfile.getId(); + savedAssetProfiles.put(type, assetProfileId); + } + return assetProfileId; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDaoTest.java new file mode 100644 index 0000000..510000b --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/audit/JpaAuditLogDaoTest.java @@ -0,0 +1,158 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.audit; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.audit.AuditLog; +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.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.audit.AuditLogDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class JpaAuditLogDaoTest extends AbstractJpaDaoTest { + List auditLogList = new ArrayList<>(); + UUID tenantId; + CustomerId customerId1; + CustomerId customerId2; + UserId userId1; + UserId userId2; + EntityId entityId1; + EntityId entityId2; + AuditLog neededFoundedAuditLog; + @Autowired + private AuditLogDao auditLogDao; + + @Before + public void setUp() { + setUpIds(); + for (int i = 0; i < 60; i++) { + ActionType actionType = i % 2 == 0 ? ActionType.ADDED : ActionType.DELETED; + CustomerId customerId = i % 4 == 0 ? customerId1 : customerId2; + UserId userId = i % 6 == 0 ? userId1 : userId2; + EntityId entityId = i % 10 == 0 ? entityId1 : entityId2; + auditLogList.add(createAuditLog(i, actionType, customerId, userId, entityId)); + } + assertEquals(auditLogList.size(), auditLogDao.find(TenantId.fromUUID(tenantId)).size()); + neededFoundedAuditLog = auditLogList.get(0); + assertNotNull(neededFoundedAuditLog); + } + + private void setUpIds() { + tenantId = Uuids.timeBased(); + customerId1 = new CustomerId(Uuids.timeBased()); + customerId2 = new CustomerId(Uuids.timeBased()); + userId1 = new UserId(Uuids.timeBased()); + userId2 = new UserId(Uuids.timeBased()); + entityId1 = new DeviceId(Uuids.timeBased()); + entityId2 = new DeviceId(Uuids.timeBased()); + } + + @After + public void tearDown() { + for (AuditLog auditLog : auditLogList) { + auditLogDao.removeById(TenantId.fromUUID(tenantId), auditLog.getUuidId()); + } + auditLogList.clear(); + } + + @Test + public void testFindById() { + AuditLog foundedAuditLogById = auditLogDao.findById(TenantId.fromUUID(tenantId), neededFoundedAuditLog.getUuidId()); + checkFoundedAuditLog(foundedAuditLogById); + } + + @Test + public void testFindByIdAsync() throws ExecutionException, InterruptedException, TimeoutException { + AuditLog foundedAuditLogById = auditLogDao + .findByIdAsync(TenantId.fromUUID(tenantId), neededFoundedAuditLog.getUuidId()).get(30, TimeUnit.SECONDS); + checkFoundedAuditLog(foundedAuditLogById); + } + + private void checkFoundedAuditLog(AuditLog foundedAuditLogById) { + assertNotNull(foundedAuditLogById); + assertEquals(neededFoundedAuditLog, foundedAuditLogById); + } + + @Test + public void testFindAuditLogsByTenantId() { + List foundedAuditLogs = auditLogDao.findAuditLogsByTenantId(tenantId, + List.of(ActionType.ADDED), + new TimePageLink(40)).getData(); + checkFoundedAuditLogsList(foundedAuditLogs, 30); + } + + @Test + public void testFindAuditLogsByTenantIdAndCustomerId() { + List foundedAuditLogs = auditLogDao.findAuditLogsByTenantIdAndCustomerId(tenantId, + customerId1, + List.of(ActionType.ADDED), + new TimePageLink(20)).getData(); + checkFoundedAuditLogsList(foundedAuditLogs, 15); + } + + @Test + public void testFindAuditLogsByTenantIdAndUserId() { + List foundedAuditLogs = auditLogDao.findAuditLogsByTenantIdAndUserId(tenantId, + userId1, + List.of(ActionType.ADDED), + new TimePageLink(20)).getData(); + checkFoundedAuditLogsList(foundedAuditLogs, 10); + } + + @Test + public void testFindAuditLogsByTenantIdAndEntityId() { + List foundedAuditLogs = auditLogDao.findAuditLogsByTenantIdAndEntityId(tenantId, + entityId1, + List.of(ActionType.ADDED), + new TimePageLink(10)).getData(); + checkFoundedAuditLogsList(foundedAuditLogs, 6); + } + + private void checkFoundedAuditLogsList(List foundedAuditLogs, int neededSizeForFoundedList) { + assertNotNull(foundedAuditLogs); + assertEquals(neededSizeForFoundedList, foundedAuditLogs.size()); + } + + private AuditLog createAuditLog(int number, ActionType actionType, CustomerId customerId, UserId userId, EntityId entityId) { + AuditLog auditLog = new AuditLog(); + auditLog.setTenantId(TenantId.fromUUID(tenantId)); + auditLog.setCustomerId(customerId); + auditLog.setUserId(userId); + auditLog.setEntityId(entityId); + auditLog.setUserName("AUDIT_LOG_" + number); + auditLog.setActionType(actionType); + return auditLogDao.save(TenantId.fromUUID(tenantId), auditLog); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDaoTest.java new file mode 100644 index 0000000..85b3c89 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDaoTest.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.component; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.ComponentDescriptorId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.plugin.ComponentDescriptor; +import org.thingsboard.server.common.data.plugin.ComponentScope; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.component.ComponentDescriptorDao; +import org.thingsboard.server.dao.service.AbstractServiceTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * Created by Valerii Sosliuk on 5/6/2017. + */ +public class JpaBaseComponentDescriptorDaoTest extends AbstractJpaDaoTest { + + final List componentTypes = List.of(ComponentType.FILTER, ComponentType.ACTION); + @Autowired + private ComponentDescriptorDao componentDescriptorDao; + + @Before + public void setUp() { + for (int i = 0; i < 20; i++) { + createComponentDescriptor(ComponentType.FILTER, ComponentScope.SYSTEM, i); + createComponentDescriptor(ComponentType.ACTION, ComponentScope.TENANT, i + 20); + } + } + + @After + public void tearDown() { + for (ComponentType componentType : componentTypes) { + List byTypeAndPageLink = componentDescriptorDao.findByTypeAndPageLink(AbstractServiceTest.SYSTEM_TENANT_ID, + componentType, new PageLink(20)).getData(); + for (ComponentDescriptor descriptor : byTypeAndPageLink) { + componentDescriptorDao.deleteById(AbstractServiceTest.SYSTEM_TENANT_ID, descriptor.getId()); + } + } + } + + @Test + public void findByType() { + PageLink pageLink = new PageLink(15, 0, "COMPONENT_"); + PageData components1 = componentDescriptorDao.findByTypeAndPageLink(AbstractServiceTest.SYSTEM_TENANT_ID, ComponentType.FILTER, pageLink); + assertEquals(15, components1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData components2 = componentDescriptorDao.findByTypeAndPageLink(AbstractServiceTest.SYSTEM_TENANT_ID, ComponentType.FILTER, pageLink); + assertEquals(5, components2.getData().size()); + } + + @Test + public void findByTypeAndScope() { + PageLink pageLink = new PageLink(15, 0, "COMPONENT_"); + PageData components1 = componentDescriptorDao.findByScopeAndTypeAndPageLink(AbstractServiceTest.SYSTEM_TENANT_ID, + ComponentScope.SYSTEM, ComponentType.FILTER, pageLink); + assertEquals(15, components1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData components2 = componentDescriptorDao.findByScopeAndTypeAndPageLink(AbstractServiceTest.SYSTEM_TENANT_ID, + ComponentScope.SYSTEM, ComponentType.FILTER, pageLink); + assertEquals(5, components2.getData().size()); + } + + private void createComponentDescriptor(ComponentType type, ComponentScope scope, int index) { + ComponentDescriptor component = new ComponentDescriptor(); + component.setId(new ComponentDescriptorId(Uuids.timeBased())); + component.setType(type); + component.setScope(scope); + component.setName("COMPONENT_" + index); + componentDescriptorDao.save(AbstractServiceTest.SYSTEM_TENANT_ID, component); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java new file mode 100644 index 0000000..5f05f2a --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.customer; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.customer.CustomerDao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Created by Valerii Sosliuk on 5/6/2017. + */ +public class JpaCustomerDaoTest extends AbstractJpaDaoTest { + + @Autowired + private CustomerDao customerDao; + + @Test + public void testFindByTenantId() { + UUID tenantId1 = Uuids.timeBased(); + UUID tenantId2 = Uuids.timeBased(); + + for (int i = 0; i < 20; i++) { + createCustomer(tenantId1, i); + createCustomer(tenantId2, i * 2); + } + + PageLink pageLink = new PageLink(15, 0, "CUSTOMER"); + PageData customers1 = customerDao.findCustomersByTenantId(tenantId1, pageLink); + assertEquals(15, customers1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData customers2 = customerDao.findCustomersByTenantId(tenantId1, pageLink); + assertEquals(5, customers2.getData().size()); + } + + @Test + public void testFindCustomersByTenantIdAndTitle() { + UUID tenantId = Uuids.timeBased(); + + for (int i = 0; i < 10; i++) { + createCustomer(tenantId, i); + } + + Optional customerOpt = customerDao.findCustomersByTenantIdAndTitle(tenantId, "CUSTOMER_5"); + assertTrue(customerOpt.isPresent()); + assertEquals("CUSTOMER_5", customerOpt.get().getTitle()); + } + + private void createCustomer(UUID tenantId, int index) { + Customer customer = new Customer(); + customer.setId(new CustomerId(Uuids.timeBased())); + customer.setTenantId(TenantId.fromUUID(tenantId)); + customer.setTitle("CUSTOMER_" + index); + customerDao.save(TenantId.fromUUID(tenantId), customer); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDaoTest.java new file mode 100644 index 0000000..f121be2 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDaoTest.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.dashboard; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.dashboard.DashboardInfoDao; +import org.thingsboard.server.dao.service.AbstractServiceTest; + +import java.util.List; +import java.util.UUID; + +/** + * Created by Valerii Sosliuk on 5/6/2017. + */ +public class JpaDashboardInfoDaoTest extends AbstractJpaDaoTest { + + @Autowired + private DashboardInfoDao dashboardInfoDao; + + @Test + public void testFindDashboardsByTenantId() { + UUID tenantId1 = Uuids.timeBased(); + UUID tenantId2 = Uuids.timeBased(); + + for (int i = 0; i < 20; i++) { + createDashboard(tenantId1, i); + createDashboard(tenantId2, i * 2); + } + + PageLink pageLink = new PageLink(15, 0, "DASHBOARD"); + PageData dashboardInfos1 = dashboardInfoDao.findDashboardsByTenantId(tenantId1, pageLink); + Assert.assertEquals(15, dashboardInfos1.getData().size()); + + PageData dashboardInfos2 = dashboardInfoDao.findDashboardsByTenantId(tenantId1, pageLink.nextPageLink()); + Assert.assertEquals(5, dashboardInfos2.getData().size()); + } + + private void createDashboard(UUID tenantId, int index) { + DashboardInfo dashboardInfo = new DashboardInfo(); + dashboardInfo.setId(new DashboardId(Uuids.timeBased())); + dashboardInfo.setTenantId(TenantId.fromUUID(tenantId)); + dashboardInfo.setTitle("DASHBOARD_" + index); + dashboardInfoDao.save(AbstractServiceTest.SYSTEM_TENANT_ID, dashboardInfo); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDaoTest.java new file mode 100644 index 0000000..776a7d6 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDaoTest.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.device.DeviceCredentialsDao; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.thingsboard.server.dao.service.AbstractServiceTest.SYSTEM_TENANT_ID; + +/** + * Created by Valerii Sosliuk on 5/6/2017. + */ +public class JpaDeviceCredentialsDaoTest extends AbstractJpaDaoTest { + + @Autowired + DeviceCredentialsDao deviceCredentialsDao; + + List deviceCredentialsList; + DeviceCredentials neededDeviceCredentials; + + @Before + public void setUp() { + deviceCredentialsList = List.of(createAndSaveDeviceCredentials(), createAndSaveDeviceCredentials()); + neededDeviceCredentials = deviceCredentialsList.get(0); + assertNotNull(neededDeviceCredentials); + } + + DeviceCredentials createAndSaveDeviceCredentials() { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId(UUID.randomUUID().toString()); + deviceCredentials.setCredentialsValue("CHECK123"); + deviceCredentials.setDeviceId(new DeviceId(UUID.randomUUID())); + return deviceCredentialsDao.save(TenantId.SYS_TENANT_ID, deviceCredentials); + } + + @After + public void deleteDeviceCredentials() { + for (DeviceCredentials credentials : deviceCredentialsList) { + deviceCredentialsDao.removeById(TenantId.SYS_TENANT_ID, credentials.getUuidId()); + } + } + + @Test + public void testFindByDeviceId() { + DeviceCredentials foundedDeviceCredentials = deviceCredentialsDao.findByDeviceId(SYSTEM_TENANT_ID, neededDeviceCredentials.getDeviceId().getId()); + assertNotNull(foundedDeviceCredentials); + assertEquals(neededDeviceCredentials.getId(), foundedDeviceCredentials.getId()); + assertEquals(neededDeviceCredentials.getCredentialsId(), foundedDeviceCredentials.getCredentialsId()); + } + + @Test + public void findByCredentialsId() { + DeviceCredentials foundedDeviceCredentials = deviceCredentialsDao.findByCredentialsId(SYSTEM_TENANT_ID, neededDeviceCredentials.getCredentialsId()); + assertNotNull(foundedDeviceCredentials); + assertEquals(neededDeviceCredentials.getId(), foundedDeviceCredentials.getId()); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceDaoTest.java new file mode 100644 index 0000000..1c21e5b --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/device/JpaDeviceDaoTest.java @@ -0,0 +1,171 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.device; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.device.DeviceDao; +import org.thingsboard.server.dao.device.DeviceProfileDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Created by Valerii Sosliuk on 5/6/2017. + */ +public class JpaDeviceDaoTest extends AbstractJpaDaoTest { + + public static final int COUNT_DEVICES = 40; + public static final String PREFIX_FOR_DEVICE_NAME = "SEARCH_TEXT_"; + List deviceIds; + UUID tenantId1; + UUID tenantId2; + UUID customerId1; + UUID customerId2; + @Autowired + private DeviceDao deviceDao; + + @Autowired + private DeviceProfileDao deviceProfileDao; + + private DeviceProfile savedDeviceProfile; + + ListeningExecutorService executor; + + @Before + public void setUp() { + createDeviceProfile(); + + tenantId1 = Uuids.timeBased(); + customerId1 = Uuids.timeBased(); + tenantId2 = Uuids.timeBased(); + customerId2 = Uuids.timeBased(); + + deviceIds = createDevices(tenantId1, tenantId2, customerId1, customerId2, COUNT_DEVICES); + } + + private void createDeviceProfile() { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName("TEST"); + deviceProfile.setTenantId(TenantId.SYS_TENANT_ID); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile.setDescription("Test"); + savedDeviceProfile = deviceProfileDao.save(TenantId.SYS_TENANT_ID, deviceProfile); + } + + @After + public void tearDown() throws Exception { + deviceDao.removeAllByIds(deviceIds); + deviceProfileDao.removeById(TenantId.SYS_TENANT_ID, savedDeviceProfile.getUuidId()); + if (executor != null) { + executor.shutdownNow(); + } + } + + @Test + public void testFindDevicesByTenantId() { + PageLink pageLink = new PageLink(15, 0, PREFIX_FOR_DEVICE_NAME); + PageData devices1 = deviceDao.findDevicesByTenantId(tenantId1, pageLink); + assertEquals(15, devices1.getData().size()); + + pageLink = pageLink.nextPageLink(); + + PageData devices2 = deviceDao.findDevicesByTenantId(tenantId1, pageLink); + assertEquals(5, devices2.getData().size()); + } + + @Test + public void testFindAsync() throws ExecutionException, InterruptedException, TimeoutException { + UUID tenantId = Uuids.timeBased(); + UUID customerId = Uuids.timeBased(); + // send to method getDevice() number = 40, because make random name is bad and name "SEARCH_TEXT_40" don't used + Device device = getDevice(tenantId, customerId, 40); + deviceIds.add(deviceDao.save(TenantId.fromUUID(tenantId), device).getUuidId()); + + UUID uuid = device.getId().getId(); + Device entity = deviceDao.findById(TenantId.fromUUID(tenantId), uuid); + assertNotNull(entity); + assertEquals(uuid, entity.getId().getId()); + + executor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope"))); + ListenableFuture future = executor.submit(() -> deviceDao.findById(TenantId.fromUUID(tenantId), uuid)); + Device asyncDevice = future.get(30, TimeUnit.SECONDS); + assertNotNull("Async device expected to be not null", asyncDevice); + } + + @Test + public void testFindDevicesByTenantIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { + ListenableFuture> devicesFuture = deviceDao.findDevicesByTenantIdAndIdsAsync(tenantId1, deviceIds); + List devices = devicesFuture.get(30, TimeUnit.SECONDS); + assertEquals(20, devices.size()); + } + + @Test + public void testFindDevicesByTenantIdAndCustomerIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { + ListenableFuture> devicesFuture = deviceDao.findDevicesByTenantIdCustomerIdAndIdsAsync(tenantId1, customerId1, deviceIds); + List devices = devicesFuture.get(30, TimeUnit.SECONDS); + assertEquals(20, devices.size()); + } + + private List createDevices(UUID tenantId1, UUID tenantId2, UUID customerId1, UUID customerId2, int count) { + List savedDevicesUUID = new ArrayList<>(); + for (int i = 0; i < count / 2; i++) { + savedDevicesUUID.add(deviceDao.save(TenantId.fromUUID(tenantId1), getDevice(tenantId1, customerId1, i)).getUuidId()); + savedDevicesUUID.add(deviceDao.save(TenantId.fromUUID(tenantId2), getDevice(tenantId2, customerId2, i + count / 2)).getUuidId()); + } + return savedDevicesUUID; + } + + private Device getDevice(UUID tenantId, UUID customerID, int number) { + return getDevice(tenantId, customerID, Uuids.timeBased(), number); + } + + private Device getDevice(UUID tenantId, UUID customerID, UUID deviceId, int number) { + Device device = new Device(); + device.setId(new DeviceId(deviceId)); + device.setTenantId(TenantId.fromUUID(tenantId)); + device.setCustomerId(new CustomerId(customerID)); + device.setName(PREFIX_FOR_DEVICE_NAME + number); + device.setDeviceProfileId(savedDeviceProfile.getId()); + return device; + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java new file mode 100644 index 0000000..b209137 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDaoTest.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.event; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.event.Event; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.event.StatisticsEvent; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.event.EventDao; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@Slf4j +public class JpaBaseEventDaoTest extends AbstractJpaDaoTest { + + @Autowired + private EventDao eventDao; + UUID tenantId = Uuids.timeBased(); + + + @Test + public void findEvent() throws InterruptedException, ExecutionException, TimeoutException { + UUID entityId = Uuids.timeBased(); + + Event event1 = getStatsEvent(Uuids.timeBased(), tenantId, entityId); + eventDao.saveAsync(event1).get(1, TimeUnit.MINUTES); + Thread.sleep(2); + Event event2 = getStatsEvent(Uuids.timeBased(), tenantId, entityId); + eventDao.saveAsync(event2).get(1, TimeUnit.MINUTES); + + List foundEvents = eventDao.findLatestEvents(tenantId, entityId, EventType.STATS, 1); + assertNotNull("Events expected to be not null", foundEvents); + assertEquals(1, foundEvents.size()); + assertEquals(event2, foundEvents.get(0)); + } + + @Test + public void findEventsByEntityIdAndPageLink() throws Exception { + UUID entityId1 = Uuids.timeBased(); + UUID entityId2 = Uuids.timeBased(); + long startTime = System.currentTimeMillis(); + + Event event1 = getStatsEvent(Uuids.timeBased(), tenantId, entityId1); + eventDao.saveAsync(event1).get(1, TimeUnit.MINUTES); + Thread.sleep(2); + Event event2 = getStatsEvent(Uuids.timeBased(), tenantId, entityId2); + eventDao.saveAsync(event2).get(1, TimeUnit.MINUTES); + + long endTime = System.currentTimeMillis(); + + PageData events1 = eventDao.findEvents(tenantId, entityId1, EventType.STATS, new TimePageLink(30)); + assertEquals(1, events1.getData().size()); + + PageData events2 = eventDao.findEvents(tenantId, entityId2, EventType.STATS, new TimePageLink(30)); + assertEquals(1, events2.getData().size()); + + PageData events3 = eventDao.findEvents(tenantId, Uuids.timeBased(), EventType.STATS, new TimePageLink(30)); + assertEquals(0, events3.getData().size()); + + + TimePageLink pageLink2 = new TimePageLink(30, 0, "", null, startTime, null); + PageData events12 = eventDao.findEvents(tenantId, entityId1, EventType.STATS, pageLink2); + assertEquals(1, events12.getData().size()); + assertEquals(event1, events12.getData().get(0)); + + TimePageLink pageLink3 = new TimePageLink(30, 0, "", null, startTime, endTime); + PageData events13 = eventDao.findEvents(tenantId, entityId1, EventType.STATS, pageLink3); + assertEquals(1, events13.getData().size()); + assertEquals(event1, events13.getData().get(0)); + + TimePageLink pageLink4 = new TimePageLink(5, 0, "", null, startTime, endTime); + PageData events14 = eventDao.findEvents(tenantId, entityId1, EventType.STATS, pageLink4); + assertEquals(1, events14.getData().size()); + assertEquals(event1, events14.getData().get(0)); + + pageLink4 = pageLink4.nextPageLink(); + PageData events6 = eventDao.findEvents(tenantId, entityId1, EventType.STATS, pageLink4); + assertEquals(0, events6.getData().size()); + + } + + private Event getStatsEvent(UUID eventId, UUID tenantId, UUID entityId) { + StatisticsEvent.StatisticsEventBuilder event = StatisticsEvent.builder(); + event.id(eventId); + event.ts(System.currentTimeMillis()); + event.tenantId(new TenantId(tenantId)); + event.entityId(entityId); + event.serviceId("server A"); + event.messagesProcessed(1); + event.errorsOccurred(0); + return event.build(); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java new file mode 100644 index 0000000..527f509 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepositoryTest.java @@ -0,0 +1,69 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = DefaultEntityQueryRepository.class) +public class DefaultEntityQueryRepositoryTest { + + @MockBean + NamedParameterJdbcTemplate jdbcTemplate; + @MockBean + TransactionTemplate transactionTemplate; + @MockBean + DefaultQueryLogComponent queryLog; + + @Autowired + DefaultEntityQueryRepository repo; + + /* + * This value has to be reasonable small to prevent infinite recursion as early as possible + * */ + @Test + public void givenDefaultMaxLevel_whenStaticConstant_thenEqualsTo() { + assertThat(repo.getMaxLevelAllowed(), equalTo(50)); + } + + @Test + public void givenMaxLevelZeroOrNegative_whenGetMaxLevel_thenReturnDefaultMaxLevel() { + assertThat(repo.getMaxLevel(0), equalTo(repo.getMaxLevelAllowed())); + assertThat(repo.getMaxLevel(-1), equalTo(repo.getMaxLevelAllowed())); + assertThat(repo.getMaxLevel(-2), equalTo(repo.getMaxLevelAllowed())); + assertThat(repo.getMaxLevel(Integer.MIN_VALUE), equalTo(repo.getMaxLevelAllowed())); + } + + @Test + public void givenMaxLevelPositive_whenGetMaxLevel_thenValueTheSame() { + assertThat(repo.getMaxLevel(1), equalTo(1)); + assertThat(repo.getMaxLevel(2), equalTo(2)); + assertThat(repo.getMaxLevel(repo.getMaxLevelAllowed()), equalTo(repo.getMaxLevelAllowed())); + assertThat(repo.getMaxLevel(repo.getMaxLevelAllowed() + 1), equalTo(repo.getMaxLevelAllowed())); + assertThat(repo.getMaxLevel(Integer.MAX_VALUE), equalTo(repo.getMaxLevelAllowed())); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java new file mode 100644 index 0000000..c3ac1f2 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/query/DefaultQueryLogComponentTest.java @@ -0,0 +1,154 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.runner.RunWith; +import org.mockito.BDDMockito; +import org.mockito.Mockito; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.times; + +@RunWith(SpringRunner.class) +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = DefaultQueryLogComponent.class) +@EnableConfigurationProperties +@TestPropertySource(properties = { + "sql.log_queries=true", + "sql.log_queries_threshold:2999" +}) + +public class DefaultQueryLogComponentTest { + + private TenantId tenantId; + private QueryContext ctx; + + @SpyBean + private DefaultQueryLogComponent queryLog; + + @Before + public void setUp() { + tenantId = new TenantId(UUID.fromString("97275c1c-9cf2-4d25-a68d-933031158f84")); + ctx = new QueryContext(new QuerySecurityContext(tenantId, null, EntityType.ALARM)); + } + + @Test + public void logQuery() { + + BDDMockito.willReturn("").given(queryLog).substituteParametersInSqlString("", ctx); + queryLog.logQuery(ctx, "", 3000); + + Mockito.verify(queryLog, times(1)).substituteParametersInSqlString("", ctx); + + } + + @Test + public void substituteParametersInSqlString_StringType() { + + String sql = "Select * from Table Where name = :name AND id = :id"; + String sqlToUse = "Select * from Table Where name = 'Mery''s' AND id = 'ID_1'"; + + ctx.addStringParameter("name", "Mery's"); + ctx.addStringParameter("id", "ID_1"); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } + + @Test + public void substituteParametersInSqlString_DoubleLongType() { + + double sum = 0.00000021d; + long price = 100000; + String sql = "Select * from Table Where sum = :sum AND price = :price"; + String sqlToUse = "Select * from Table Where sum = 2.1E-7 AND price = 100000"; + + ctx.addDoubleParameter("sum", sum); + ctx.addLongParameter("price", price); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } + + @Test + public void substituteParametersInSqlString_BooleanType() { + + String sql = "Select * from Table Where check = :check AND mark = :mark"; + String sqlToUse = "Select * from Table Where check = true AND mark = false"; + + ctx.addBooleanParameter("check", true); + ctx.addBooleanParameter("mark", false); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } + + @Test + public void substituteParametersInSqlString_UuidType() { + + UUID guid = UUID.randomUUID(); + String sql = "Select * from Table Where guid = :guid"; + String sqlToUse = "Select * from Table Where guid = '" + guid + "'"; + + ctx.addUuidParameter("guid", guid); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } + + @Test + public void substituteParametersInSqlString_StringListType() { + + List ids = List.of("ID_1'", "ID_2", "ID_3", "ID_4"); + + String sql = "Select * from Table Where id IN (:ids)"; + String sqlToUse = "Select * from Table Where id IN ('ID_1''', 'ID_2', 'ID_3', 'ID_4')"; + + ctx.addStringListParameter("ids", ids); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } + + @Test + public void substituteParametersInSqlString_UuidListType() { + + List guids = List.of(UUID.fromString("634a8d03-6871-4e01-94d0-876bf3e67dff"), UUID.fromString("3adbb5b8-4dc6-4faf-80dc-681a7b518b5e"), UUID.fromString("63a50f0c-2058-4d1d-8f15-812eb7f84412")); + + String sql = "Select * from Table Where guid IN (:guids)"; + String sqlToUse = "Select * from Table Where guid IN ('634a8d03-6871-4e01-94d0-876bf3e67dff', '3adbb5b8-4dc6-4faf-80dc-681a7b518b5e', '63a50f0c-2058-4d1d-8f15-812eb7f84412')"; + + ctx.addUuidListParameter("guids", guids); + + String sqlToUseResult = queryLog.substituteParametersInSqlString(sql, ctx); + assertEquals(sqlToUse, sqlToUseResult); + } +} + diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityDataAdapterTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityDataAdapterTest.java new file mode 100644 index 0000000..6d052db --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityDataAdapterTest.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EntityDataAdapterTest { + + @Test + public void testConvertValue() { + assertThat(EntityDataAdapter.convertValue("500")).isEqualTo("500"); + assertThat(EntityDataAdapter.convertValue("500D")).isEqualTo("500D"); //do not convert to Double !!! + assertThat(EntityDataAdapter.convertValue("0101010521130565")).isEqualTo("0101010521130565"); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityKeyMappingTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityKeyMappingTest.java new file mode 100644 index 0000000..eadfda2 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/query/EntityKeyMappingTest.java @@ -0,0 +1,103 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.query; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.List; + +@RunWith(SpringRunner.class ) +@SpringBootTest(classes = EntityKeyMapping.class) +public class EntityKeyMappingTest { + + @Autowired + private EntityKeyMapping entityKeyMapping; + + private static final List result = List.of("device1", "device2", "device3"); + + @Test + public void testSplitToList() { + String value = "device1, device2, device3"; + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testReplaceSingleQuote() { + String value = "'device1', 'device2', 'device3'"; + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testReplaceDoubleQuote() { + String value = "\"device1\", \"device2\", \"device3\""; + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testSplitWithoutSpace() { + String value = "\"device1\" , \"device2\" , \"device3\""; + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testSaveSpacesBetweenString() { + String value = "device 1 , device 2 , device 3"; + List result = List.of("device 1", "device 2", "device 3"); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testSaveQuoteInString() { + String value = "device ''1 , device \"\"2 , device \"'3"; + List result = List.of("device ''1", "device \"\"2", "device \"'3"); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } + + @Test + public void testNotDeleteQuoteWhenDifferentStyle() { + + String value = "\"device1\", 'device2', \"device3\""; + List result = List.of("\"device1\"", "'device2'", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + + value = "'device1', \"device2\", \"device3\""; + result = List.of("'device1'", "\"device2\"", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + + value = "device1, 'device2', \"device3\""; + result = List.of("device1", "'device2'", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + + + value = "'device1', device2, \"device3\""; + result = List.of("'device1'", "device2", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + + value = "device1, \"device2\", \"device3\""; + result = List.of("device1", "\"device2\"", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + + + value = "\"device1\", device2, \"device3\""; + result = List.of("\"device1\"", "device2", "\"device3\""); + Assert.assertEquals(entityKeyMapping.getListValuesWithoutQuote(value), result); + } +} \ No newline at end of file diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDaoTest.java new file mode 100644 index 0000000..916b572 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/tenant/JpaTenantDaoTest.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.tenant; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.service.BaseTenantProfileServiceTest; +import org.thingsboard.server.dao.tenant.TenantDao; +import org.thingsboard.server.dao.tenant.TenantProfileDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +/** + * Created by Valerii Sosliuk on 4/30/2017. + */ +public class JpaTenantDaoTest extends AbstractJpaDaoTest { + + @Autowired + private TenantDao tenantDao; + + @Autowired + private TenantProfileDao tenantProfileDao; + + List createdTenants = new ArrayList<>(); + TenantProfile tenantProfile; + + @Before + public void setUp() throws Exception { + tenantProfile = tenantProfileDao.save(TenantId.SYS_TENANT_ID, BaseTenantProfileServiceTest.createTenantProfile("default tenant profile")); + assertThat(tenantProfile).as("tenant profile").isNotNull(); + } + + @After + public void tearDown() throws Exception { + createdTenants.forEach((tenant)-> tenantDao.removeById(TenantId.SYS_TENANT_ID, tenant.getUuidId())); + tenantProfileDao.removeById(TenantId.SYS_TENANT_ID, tenantProfile.getUuidId()); + } + + @Test + //@DatabaseSetup("classpath:dbunit/empty_dataset.xml") + public void testFindTenants() { + createTenants(); + assertEquals(30, tenantDao.find(AbstractServiceTest.SYSTEM_TENANT_ID).size()); + + PageLink pageLink = new PageLink(20, 0, "title"); + PageData tenants1 = tenantDao.findTenants(AbstractServiceTest.SYSTEM_TENANT_ID, pageLink); + assertEquals(20, tenants1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData tenants2 = tenantDao.findTenants(AbstractServiceTest.SYSTEM_TENANT_ID, + pageLink); + assertEquals(10, tenants2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData tenants3 = tenantDao.findTenants(AbstractServiceTest.SYSTEM_TENANT_ID, + pageLink); + assertEquals(0, tenants3.getData().size()); + } + + private void createTenants() { + for (int i = 0; i < 30; i++) { + createTenant("TITLE", i); + } + } + + void createTenant(String title, int index) { + Tenant tenant = new Tenant(); + tenant.setId(TenantId.fromUUID(Uuids.timeBased())); + tenant.setTitle(title + "_" + index); + tenant.setTenantProfileId(tenantProfile.getId()); + createdTenants.add(tenantDao.save(TenantId.SYS_TENANT_ID, tenant)); + } + + @Test + //@DatabaseSetup("classpath:dbunit/empty_dataset.xml") + public void testIsExistsTenantById() { + final UUID uuid = Uuids.timeBased(); + final TenantId tenantId = new TenantId(uuid); + assertThat(tenantDao.existsById(tenantId, uuid)).as("Is tenant exists before save").isFalse(); + + final Tenant tenant = new Tenant(); + tenant.setId(tenantId); + tenant.setTitle("Tenant " + uuid); + tenant.setTenantProfileId(tenantProfile.getId()); + + createdTenants.add(tenantDao.save(TenantId.SYS_TENANT_ID, tenant)); + + assertThat(tenantDao.existsById(tenantId, uuid)).as("Is tenant exists after save").isTrue(); + + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java new file mode 100644 index 0000000..a933521 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserCredentialsDaoTest.java @@ -0,0 +1,103 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.user; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.user.UserCredentialsDao; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.thingsboard.server.dao.service.AbstractServiceTest.SYSTEM_TENANT_ID; + +/** + * Created by Valerii Sosliuk on 4/22/2017. + */ +public class JpaUserCredentialsDaoTest extends AbstractJpaDaoTest { + + public static final String ACTIVATE_TOKEN = "ACTIVATE_TOKEN_0"; + public static final String RESET_TOKEN = "RESET_TOKEN_0"; + public static final int COUNT_USER_CREDENTIALS = 2; + List userCredentialsList; + UserCredentials neededUserCredentials; + + @Autowired + private UserCredentialsDao userCredentialsDao; + + @Before + public void setUp() { + userCredentialsList = new ArrayList<>(); + for (int i=0; i userCredentials = userCredentialsDao.find(SYSTEM_TENANT_ID); + assertEquals(COUNT_USER_CREDENTIALS + 1, userCredentials.size()); + } + + @Test + public void testFindByUserId() { + UserCredentials foundedUserCredentials = userCredentialsDao.findByUserId(SYSTEM_TENANT_ID, neededUserCredentials.getUserId().getId()); + assertNotNull(foundedUserCredentials); + assertEquals(neededUserCredentials, foundedUserCredentials); + } + + @Test + public void testFindByActivateToken() { + UserCredentials foundedUserCredentials = userCredentialsDao.findByActivateToken(SYSTEM_TENANT_ID, ACTIVATE_TOKEN); + assertNotNull(foundedUserCredentials); + assertEquals(neededUserCredentials.getId(), foundedUserCredentials.getId()); + } + + @Test + public void testFindByResetToken() { + UserCredentials foundedUserCredentials = userCredentialsDao.findByResetToken(SYSTEM_TENANT_ID, RESET_TOKEN); + assertNotNull(foundedUserCredentials); + assertEquals(neededUserCredentials.getId(), foundedUserCredentials.getId()); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserDaoTest.java new file mode 100644 index 0000000..880087f --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/user/JpaUserDaoTest.java @@ -0,0 +1,158 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.user; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.user.UserDao; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +/** + * Created by Valerii Sosliuk on 4/18/2017. + */ +public class JpaUserDaoTest extends AbstractJpaDaoTest { + + // it comes from the DefaultSystemDataLoaderService super class + final int COUNT_CREATED_USER = 1; + final int COUNT_SYSADMIN_USER = 90; + UUID tenantId; + UUID customerId; + @Autowired + private UserDao userDao; + + @Before + public void setUp() { + tenantId = Uuids.timeBased(); + customerId = Uuids.timeBased(); + create30TenantAdminsAnd60CustomerUsers(tenantId, customerId); + } + + @After + public void tearDown() { + delete30TenantAdminsAnd60CustomerUsers(tenantId, customerId); + } + + @Test + public void testFindAll() { + List users = userDao.find(AbstractServiceTest.SYSTEM_TENANT_ID); + assertEquals(users.size(), COUNT_CREATED_USER + COUNT_SYSADMIN_USER); + } + + @Test + public void testFindByEmail() throws JsonProcessingException { + User user = new User(); + user.setId(new UserId(UUID.randomUUID())); + user.setTenantId(TenantId.fromUUID(UUID.randomUUID())); + user.setCustomerId(new CustomerId(UUID.randomUUID())); + user.setEmail("user@thingsboard.org"); + user.setFirstName("Jackson"); + user.setLastName("Roberts"); + ObjectMapper mapper = new ObjectMapper(); + String additionalInfo = "{\"key\":\"value-100\"}"; + JsonNode jsonNode = mapper.readTree(additionalInfo); + user.setAdditionalInfo(jsonNode); + userDao.save(AbstractServiceTest.SYSTEM_TENANT_ID, user); + assertEquals(1 + COUNT_SYSADMIN_USER + COUNT_CREATED_USER, userDao.find(AbstractServiceTest.SYSTEM_TENANT_ID).size()); + User savedUser = userDao.findByEmail(AbstractServiceTest.SYSTEM_TENANT_ID, "user@thingsboard.org"); + assertNotNull(savedUser); + assertEquals(additionalInfo, savedUser.getAdditionalInfo().toString()); + } + + @Test + public void testFindTenantAdmins() { + PageLink pageLink = new PageLink(20); + PageData tenantAdmins1 = userDao.findTenantAdmins(tenantId, pageLink); + assertEquals(20, tenantAdmins1.getData().size()); + pageLink = pageLink.nextPageLink(); + PageData tenantAdmins2 = userDao.findTenantAdmins(tenantId, + pageLink); + assertEquals(10, tenantAdmins2.getData().size()); + pageLink = pageLink.nextPageLink(); + PageData tenantAdmins3 = userDao.findTenantAdmins(tenantId, + pageLink); + assertEquals(0, tenantAdmins3.getData().size()); + } + + @Test + public void testFindCustomerUsers() { + PageLink pageLink = new PageLink(40); + PageData customerUsers1 = userDao.findCustomerUsers(tenantId, customerId, pageLink); + assertEquals(40, customerUsers1.getData().size()); + pageLink = pageLink.nextPageLink(); + PageData customerUsers2 = userDao.findCustomerUsers(tenantId, customerId, + pageLink); + assertEquals(20, customerUsers2.getData().size()); + pageLink = pageLink.nextPageLink(); + PageData customerUsers3 = userDao.findCustomerUsers(tenantId, customerId, + pageLink); + assertEquals(0, customerUsers3.getData().size()); + } + + private void create30TenantAdminsAnd60CustomerUsers(UUID tenantId, UUID customerId) { + // Create 30 tenant admins and 60 customer users + for (int i = 0; i < 30; i++) { + saveUser(tenantId, NULL_UUID); + saveUser(tenantId, customerId); + saveUser(tenantId, customerId); + } + } + + private void saveUser(UUID tenantId, UUID customerId) { + User user = new User(); + UUID id = Uuids.timeBased(); + user.setId(new UserId(id)); + user.setTenantId(TenantId.fromUUID(tenantId)); + user.setCustomerId(new CustomerId(customerId)); + if (customerId == NULL_UUID) { + user.setAuthority(Authority.TENANT_ADMIN); + } else { + user.setAuthority(Authority.CUSTOMER_USER); + } + String idString = id.toString(); + String email = idString.substring(0, idString.indexOf('-')) + "@thingsboard.org"; + user.setEmail(email); + userDao.save(AbstractServiceTest.SYSTEM_TENANT_ID, user); + } + + private void delete30TenantAdminsAnd60CustomerUsers(UUID tenantId, UUID customerId) { + List data = userDao.findCustomerUsers(tenantId, customerId, new PageLink(60)).getData(); + data.addAll(userDao.findTenantAdmins(tenantId, new PageLink(30)).getData()); + for (User user : data) { + userDao.removeById(user.getTenantId(), user.getUuidId()); + } + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java new file mode 100644 index 0000000..1dcc8f0 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDaoTest.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.widget; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetType; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.widget.WidgetTypeDao; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Created by Valerii Sosliuk on 4/30/2017. + */ +public class JpaWidgetTypeDaoTest extends AbstractJpaDaoTest { + + final String BUNDLE_ALIAS = "BUNDLE_ALIAS"; + final int WIDGET_TYPE_COUNT = 3; + List widgetTypeList; + + @Autowired + private WidgetTypeDao widgetTypeDao; + + @Before + public void setUp() { + widgetTypeList = new ArrayList<>(); + for (int i = 0; i < WIDGET_TYPE_COUNT; i++) { + widgetTypeList.add(createAndSaveWidgetType(i)); + } + } + + WidgetType createAndSaveWidgetType(int number) { + WidgetTypeDetails widgetType = new WidgetTypeDetails(); + widgetType.setTenantId(TenantId.SYS_TENANT_ID); + widgetType.setName("WIDGET_TYPE_" + number); + widgetType.setAlias("ALIAS_" + number); + widgetType.setBundleAlias(BUNDLE_ALIAS); + return widgetTypeDao.save(TenantId.SYS_TENANT_ID, widgetType); + } + + @After + public void deleteAllWidgetType() { + for (WidgetType widgetType : widgetTypeList) { + widgetTypeDao.removeById(TenantId.SYS_TENANT_ID, widgetType.getUuidId()); + } + } + + @Test + public void testFindByTenantIdAndBundleAlias() { + List widgetTypes = widgetTypeDao.findWidgetTypesByTenantIdAndBundleAlias(TenantId.SYS_TENANT_ID.getId(), BUNDLE_ALIAS); + assertEquals(WIDGET_TYPE_COUNT, widgetTypes.size()); + } + + @Test + public void testFindByTenantIdAndBundleAliasAndAlias() { + WidgetType result = widgetTypeList.get(0); + assertNotNull(result); + WidgetType widgetType = widgetTypeDao.findByTenantIdBundleAliasAndAlias(TenantId.SYS_TENANT_ID.getId(), BUNDLE_ALIAS, "ALIAS_0"); + assertEquals(result.getId(), widgetType.getId()); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java new file mode 100644 index 0000000..8b138b1 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/widget/JpaWidgetsBundleDaoTest.java @@ -0,0 +1,172 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.widget; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.junit.After; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.AbstractJpaDaoTest; +import org.thingsboard.server.dao.widget.WidgetsBundleDao; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; + +/** + * Created by Valerii Sosliuk on 4/23/2017. + */ +public class JpaWidgetsBundleDaoTest extends AbstractJpaDaoTest { + + List widgetsBundles; + @Autowired + private WidgetsBundleDao widgetsBundleDao; + + @After + public void tearDown() { + for (WidgetsBundle widgetsBundle : widgetsBundles) { + widgetsBundleDao.removeById(widgetsBundle.getTenantId(), widgetsBundle.getUuidId()); + } + + } + + @Test + public void testFindAll() { + createSystemWidgetBundles(7, "WB_"); + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); + assertEquals(7, widgetsBundles.size()); + } + + @Test + public void testFindWidgetsBundleByTenantIdAndAlias() { + createSystemWidgetBundles(1, "WB_"); + WidgetsBundle widgetsBundle = widgetsBundleDao.findWidgetsBundleByTenantIdAndAlias( + TenantId.SYS_TENANT_ID.getId(), "WB_" + 0); + widgetsBundles = List.of(widgetsBundle); + assertEquals("WB_" + 0, widgetsBundle.getAlias()); + } + + @Test + public void testFindSystemWidgetsBundles() { + createSystemWidgetBundles(30, "WB_"); + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); + assertEquals(30, widgetsBundles.size()); + // Get first page + PageLink pageLink = new PageLink(10, 0, "WB"); + PageData widgetsBundles1 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, pageLink); + assertEquals(10, widgetsBundles1.getData().size()); + // Get next page + pageLink = pageLink.nextPageLink(); + PageData widgetsBundles2 = widgetsBundleDao.findSystemWidgetsBundles(TenantId.SYS_TENANT_ID, pageLink); + assertEquals(10, widgetsBundles2.getData().size()); + } + + @Test + public void testFindWidgetsBundlesByTenantId() { + UUID tenantId1 = Uuids.timeBased(); + UUID tenantId2 = Uuids.timeBased(); + // Create a bunch of widgetBundles + for (int i = 0; i < 10; i++) { + createWidgetBundles(3, tenantId1, "WB1_"); + createWidgetBundles(5, tenantId2, "WB2_"); + createSystemWidgetBundles(10, "WB_SYS_"); + } + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); + assertEquals(180, widgetsBundleDao.find(TenantId.SYS_TENANT_ID).size()); + + PageLink pageLink1 = new PageLink(40, 0, "WB"); + PageData widgetsBundles1 = widgetsBundleDao.findTenantWidgetsBundlesByTenantId(tenantId1, pageLink1); + assertEquals(30, widgetsBundles1.getData().size()); + + PageLink pageLink2 = new PageLink(40, 0, "WB"); + PageData widgetsBundles2 = widgetsBundleDao.findTenantWidgetsBundlesByTenantId(tenantId2, pageLink2); + assertEquals(40, widgetsBundles2.getData().size()); + + pageLink2 = pageLink2.nextPageLink(); + PageData widgetsBundles3 = widgetsBundleDao.findTenantWidgetsBundlesByTenantId(tenantId2, pageLink2); + assertEquals(10, widgetsBundles3.getData().size()); + } + + @Test + public void testFindAllWidgetsBundlesByTenantId() { + UUID tenantId1 = Uuids.timeBased(); + UUID tenantId2 = Uuids.timeBased(); + // Create a bunch of widgetBundles + for (int i = 0; i < 10; i++) { + createWidgetBundles(5, tenantId1, "WB1_"); + createWidgetBundles(3, tenantId2, "WB2_"); + createSystemWidgetBundles(2, "WB_SYS_"); + } + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); + assertEquals(100, widgetsBundleDao.find(TenantId.SYS_TENANT_ID).size()); + + PageLink pageLink = new PageLink(30, 0, "WB"); + PageData widgetsBundles1 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, pageLink); + assertEquals(30, widgetsBundles1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData widgetsBundles2 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, pageLink); + assertEquals(30, widgetsBundles2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData widgetsBundles3 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, pageLink); + assertEquals(10, widgetsBundles3.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId1, pageLink); + assertEquals(0, widgetsBundles4.getData().size()); + } + + @Test + public void testSearchTextNotFound() { + UUID tenantId = Uuids.timeBased(); + createWidgetBundles(5, tenantId, "ABC_"); + createSystemWidgetBundles(5, "SYS_"); + widgetsBundles = widgetsBundleDao.find(TenantId.SYS_TENANT_ID); + assertEquals(10, widgetsBundleDao.find(TenantId.SYS_TENANT_ID).size()); + PageLink textPageLink = new PageLink(30, 0, "TEXT_NOT_FOUND"); + PageData widgetsBundles4 = widgetsBundleDao.findAllTenantWidgetsBundlesByTenantId(tenantId, textPageLink); + assertEquals(0, widgetsBundles4.getData().size()); + } + + private void createWidgetBundles(int count, UUID tenantId, String prefix) { + for (int i = 0; i < count; i++) { + WidgetsBundle widgetsBundle = new WidgetsBundle(); + widgetsBundle.setAlias(prefix + i); + widgetsBundle.setTitle(prefix + i); + widgetsBundle.setId(new WidgetsBundleId(Uuids.timeBased())); + widgetsBundle.setTenantId(TenantId.fromUUID(tenantId)); + widgetsBundleDao.save(TenantId.SYS_TENANT_ID, widgetsBundle); + } + } + + private void createSystemWidgetBundles(int count, String prefix) { + for (int i = 0; i < count; i++) { + WidgetsBundle widgetsBundle = new WidgetsBundle(); + widgetsBundle.setAlias(prefix + i); + widgetsBundle.setTitle(prefix + i); + widgetsBundle.setTenantId(TenantId.SYS_TENANT_ID); + widgetsBundle.setId(new WidgetsBundleId(Uuids.timeBased())); + widgetsBundleDao.save(TenantId.SYS_TENANT_ID, widgetsBundle); + } + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java new file mode 100644 index 0000000..9c4b1cb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java @@ -0,0 +1,156 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.sqlts; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQuery; +import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; +import org.thingsboard.server.common.data.kv.TsKvEntry; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.willCallRealMethod; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.thingsboard.server.common.data.id.TenantId.SYS_TENANT_ID; +import static org.thingsboard.server.common.data.kv.Aggregation.COUNT; + +public class AbstractChunkedAggregationTimeseriesDaoTest { + + final int LIMIT = 1; + final String TEMP = "temp"; + final String DESC = "DESC"; + private AbstractChunkedAggregationTimeseriesDao tsDao; + + @Before + public void setUp() throws Exception { + tsDao = spy(AbstractChunkedAggregationTimeseriesDao.class); + Optional optionalListenableFuture = Optional.of(mock(TsKvEntry.class)); + willReturn(Futures.immediateFuture(optionalListenableFuture)).given(tsDao).findAndAggregateAsync(any(), anyString(), anyLong(), anyLong(), anyLong(), any()); + willReturn(Futures.immediateFuture(mock(ReadTsKvQueryResult.class))).given(tsDao).getReadTsKvQueryResultFuture(any(), any()); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenLastIntervalShorterThanOthersAndEqualsEndTs() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, 2000, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 1, 2001, 1001, LIMIT, COUNT, DESC); + ReadTsKvQuery subQuerySecond = new BaseReadTsKvQuery(TEMP, 2001, 3000, 2501, LIMIT, COUNT, DESC); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(2)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 1, 2001, getTsForReadTsKvQuery(1, 2001), COUNT); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQuerySecond.getKey(), 2001, 3000, getTsForReadTsKvQuery(2001, 3000), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsPeriod() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, 3000, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 1, 3001, 1501, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + assertThat(tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query)).isNotNull(); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 1, 3000, getTsForReadTsKvQuery(1, 3000), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsPeriodMinusOne() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, 2999, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 1, 3000, 1500, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 1, 3000, getTsForReadTsKvQuery(1, 3000), COUNT); + + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsPeriodPlusOne() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, 3001, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 1, 3001, 1501, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 1, 3000, getTsForReadTsKvQuery(1, 3000), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsOneMillisecondAndStartTsIsZero() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 0, 0, 1, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 0, 1, 0, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 0, 1, getTsForReadTsKvQuery(0, 1), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsOneMillisecondAndStartTsIsOne() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 1, 1, LIMIT, COUNT, DESC); + ReadTsKvQuery subQuery = new BaseReadTsKvQuery(TEMP, 1, 2, 1, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQuery.getKey(), 1, 2, getTsForReadTsKvQuery(1, 2), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsOneMillisecondAndStartTsIsIntegerMax() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, Integer.MAX_VALUE, Integer.MAX_VALUE, 1, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, Integer.MAX_VALUE, Integer.MAX_VALUE + 1L, Integer.MAX_VALUE, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), Integer.MAX_VALUE, 1L + Integer.MAX_VALUE, getTsForReadTsKvQuery(Integer.MAX_VALUE, 1L + Integer.MAX_VALUE), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenIntervalEqualsBigNumber() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, Integer.MAX_VALUE, LIMIT, COUNT, DESC); + ReadTsKvQuery subQueryFirst = new BaseReadTsKvQuery(TEMP, 1, 3001, 1501, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, subQueryFirst.getKey(), 1, 3000, getTsForReadTsKvQuery(1, 3000), COUNT); + } + + @Test + public void givenIntervalNotMultiplePeriod_whenAggregateCount_thenCountIntervalEqualsPeriodSize() { + ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, 3, LIMIT, COUNT, DESC); + willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); + verify(tsDao, times(1000)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); + for (long i = 1; i <= 3000; i += 3) { + verify(tsDao, times(1)).findAndAggregateAsync(SYS_TENANT_ID, TEMP, i, Math.min(i + 3, 3000), getTsForReadTsKvQuery(i, i + 3), COUNT); + } + } + + long getTsForReadTsKvQuery(long startTs, long endTs) { + return startTs + (endTs - startTs) / 2L; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningDaysAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningDaysAlwaysExistsTest.java new file mode 100644 index 0000000..d5e8350 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningDaysAlwaysExistsTest.java @@ -0,0 +1,133 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=DAYS", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningDaysAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsDays() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("DAYS"); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T00:00:01Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T00:00:00Z").getTime()); + } + + @Test + public void testCalculatePartitionsDays() throws ParseException { + long startTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime()); + long nextTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-12T23:59:59Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-15T00:00:00Z").getTime()); + log.info("startTs {}, nextTs {}, endTs {}", startTs, nextTs, endTs); + + assertThat(tsDao.calculatePartitions(0, 0)).isEqualTo(List.of(0L)); + assertThat(tsDao.calculatePartitions(0, 1)).isEqualTo(List.of(0L, 1L)); + + assertThat(tsDao.calculatePartitions(startTs, startTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime())); + assertThat(tsDao.calculatePartitions(startTs, nextTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-11T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-12T00:00:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, endTs)).hasSize(6).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-11T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-12T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-13T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-14T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-15T00:00:00Z").getTime())); + + long leapStartTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-27T00:00:00Z").getTime()); + long leapEndTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-03-01T00:00:00Z").getTime()); + assertThat(tsDao.calculatePartitions(leapStartTs, leapEndTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-27T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-28T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-29T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-03-01T00:00:00Z").getTime())); + + long newYearStartTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-12-30T00:00:00Z").getTime()); + long newYearEndTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime()); + assertThat(tsDao.calculatePartitions(newYearStartTs, newYearEndTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-12-30T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-12-31T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime())); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningHoursAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningHoursAlwaysExistsTest.java new file mode 100644 index 0000000..d538165 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningHoursAlwaysExistsTest.java @@ -0,0 +1,134 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=HOURS", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningHoursAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsHours() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("HOURS"); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T01:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T01:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T02:00:01Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T02:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:00:00Z").getTime()); + } + + @Test + public void testCalculatePartitionsHours() throws ParseException { + long startTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime()); + long nextTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T03:59:59Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-11T00:59:00Z").getTime()); + log.info("startTs {}, nextTs {}, endTs {}", startTs, nextTs, endTs); + + assertThat(tsDao.calculatePartitions(0, 0)).isEqualTo(List.of(0L)); + assertThat(tsDao.calculatePartitions(0, 1)).isEqualTo(List.of(0L, 1L)); + + assertThat(tsDao.calculatePartitions(startTs, startTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime())); + assertThat(tsDao.calculatePartitions(startTs, nextTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T01:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T02:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T03:00:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, endTs)).hasSize(25).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T01:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T02:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T03:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T04:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T05:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T06:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T07:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T08:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T09:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T10:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T11:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T12:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T13:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T14:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T15:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T16:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T17:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T18:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T19:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T20:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T21:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T22:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T23:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-11T00:00:00Z").getTime())); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningIndefiniteAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningIndefiniteAlwaysExistsTest.java new file mode 100644 index 0000000..82f94cd --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningIndefiniteAlwaysExistsTest.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=INDEFINITE", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningIndefiniteAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsIndefinite() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("INDEFINITE"); + assertThat(tsDao.toPartitionTs(ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo(0L); + } + + + @Test + public void testCalculatePartitionsIndefinite() throws ParseException { + //Indefinite partitioning should never call tsDao.calculatePartitions() + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java new file mode 100644 index 0000000..ffdf18c --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=MINUTES", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsMinutes() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("MINUTES"); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T00:01:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-02T00:01:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T00:02:01Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-03T00:02:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:00Z").getTime()); + } + + + @Test + public void testCalculatePartitionsMinutes() throws ParseException { + long startTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime()); + long nextTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:02:59Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:10:00Z").getTime()); + log.info("startTs {}, nextTs {}, endTs {}", startTs, nextTs, endTs); + + assertThat(tsDao.calculatePartitions(0, 0)).isEqualTo(List.of(0L)); + assertThat(tsDao.calculatePartitions(0, 1)).isEqualTo(List.of(0L, 1L)); + + assertThat(tsDao.calculatePartitions(startTs, startTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime())); + assertThat(tsDao.calculatePartitions(startTs, nextTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:01:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:02:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, endTs)).hasSize(11).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:01:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:02:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:03:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:04:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:05:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:06:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:07:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:08:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:09:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:10:00Z").getTime())); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java new file mode 100644 index 0000000..8c7d12d --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java @@ -0,0 +1,134 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=MONTHS", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsMonths() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("MONTHS"); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo(1640995200000L).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:00Z").getTime())).isEqualTo(1651363200000L).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:01Z").getTime())).isEqualTo(1651363200000L).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:59Z").getTime())).isEqualTo(1651363200000L).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:59Z").getTime())).isEqualTo(1701388800000L).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-01T00:00:00Z").getTime()); + } + + @Test + public void testCalculatePartitionsMonths() throws ParseException { + long startTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long nextTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-31T23:59:59Z").getTime()); + long leapTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-29T23:59:59Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + log.info("startTs {}, nextTs {}, leapTs {}, endTs {}", startTs, nextTs, leapTs, endTs); + + assertThat(tsDao.calculatePartitions(0, 0)).isEqualTo(List.of(0L)); + assertThat(tsDao.calculatePartitions(0, 1)).isEqualTo(List.of(0L, 1L)); + + assertThat(tsDao.calculatePartitions(startTs, startTs)).isEqualTo(List.of(1575158400000L)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-01T00:00:00Z").getTime())); + assertThat(tsDao.calculatePartitions(startTs, nextTs)).isEqualTo(List.of(1575158400000L, 1577836800000L)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-01T00:00:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, leapTs)).isEqualTo(List.of(1575158400000L, 1577836800000L, 1580515200000L)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-01T00:00:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, endTs)).hasSize(14).isEqualTo(List.of( + 1575158400000L, + 1577836800000L, 1580515200000L, 1583020800000L, + 1585699200000L, 1588291200000L, 1590969600000L, + 1593561600000L, 1596240000000L, 1598918400000L, + 1601510400000L, 1604188800000L, 1606780800000L, + 1609459200000L)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-02-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-03-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-04-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-05-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-06-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-07-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-08-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-09-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-10-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-11-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-12-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime())); + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java new file mode 100644 index 0000000..4b2bcb6 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java @@ -0,0 +1,116 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.timeseries; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.dao.cassandra.CassandraCluster; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; +import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; + +import java.text.ParseException; +import java.util.List; + +import static org.apache.commons.lang3.time.DateFormatUtils.ISO_DATETIME_TIME_ZONE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = CassandraBaseTimeseriesDao.class) +@TestPropertySource(properties = { + "database.ts.type=cassandra", + "cassandra.query.ts_key_value_partitioning=YEARS", + "cassandra.query.use_ts_key_value_partitioning_on_read=false", + "cassandra.query.ts_key_value_partitions_max_cache_size=100000", + "cassandra.query.ts_key_value_partitions_cache_stats_enabled=true", + "cassandra.query.ts_key_value_partitions_cache_stats_interval=60", + "cassandra.query.ts_key_value_ttl=0", + "cassandra.query.set_null_values_enabled=false", +}) +@Slf4j +public class CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest { + + @Autowired + CassandraBaseTimeseriesDao tsDao; + + @MockBean(answer = Answers.RETURNS_MOCKS) + @Qualifier("CassandraCluster") + CassandraCluster cassandraCluster; + + @MockBean + CassandraBufferedRateReadExecutor cassandraBufferedRateReadExecutor; + @MockBean + CassandraBufferedRateWriteExecutor cassandraBufferedRateWriteExecutor; + + @Test + public void testToPartitionsYears() throws ParseException { + assertThat(tsDao.getPartitioning()).isEqualTo("YEARS"); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:00Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-01T00:00:01Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-05-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime()); + assertThat(tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-12-31T23:59:59Z").getTime())).isEqualTo( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-01-01T00:00:00Z").getTime()); + } + + @Test + public void testCalculatePartitionsYears() throws ParseException { + long startTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-01-01T00:00:00Z").getTime()); + long nextTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-10-12T23:59:59Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2025-07-15T00:00:00Z").getTime()); + log.info("startTs {}, nextTs {}, endTs {}", startTs, nextTs, endTs); + + assertThat(tsDao.calculatePartitions(0, 0)).isEqualTo(List.of(0L)); + assertThat(tsDao.calculatePartitions(0, 1)).isEqualTo(List.of(0L, 1L)); + + assertThat(tsDao.calculatePartitions(startTs, startTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-01-01T00:00:00Z").getTime())); + assertThat(tsDao.calculatePartitions(startTs, nextTs)).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime())); + + assertThat(tsDao.calculatePartitions(startTs, endTs)).hasSize(7).isEqualTo(List.of( + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2019-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2020-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2022-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2023-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2024-01-01T00:00:00Z").getTime(), + ISO_DATETIME_TIME_ZONE_FORMAT.parse("2025-01-01T00:00:00Z").getTime())); + } + +} diff --git a/dao/src/test/resources/TestJsonData.json b/dao/src/test/resources/TestJsonData.json new file mode 100644 index 0000000..22e3ab3 --- /dev/null +++ b/dao/src/test/resources/TestJsonData.json @@ -0,0 +1,5 @@ +{ + "fieldA": "field A value", + "fieldB": "field B value", + "fieldC": 42 +} \ No newline at end of file diff --git a/dao/src/test/resources/TestJsonDescriptor.json b/dao/src/test/resources/TestJsonDescriptor.json new file mode 100644 index 0000000..4c8c7a4 --- /dev/null +++ b/dao/src/test/resources/TestJsonDescriptor.json @@ -0,0 +1,27 @@ +{ + "schema": { + "title": "Simple Schema", + "type": "object", + "properties": { + "fieldA": { + "type": "string" + }, + "fieldB": { + "type": "string" + }, + "fieldC": { + "description": "Age in years", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "fieldA", + "fieldB", + "fieldC" + ] + }, + "form": [ + "*" + ] +} \ No newline at end of file diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties new file mode 100644 index 0000000..1513101 --- /dev/null +++ b/dao/src/test/resources/application-test.properties @@ -0,0 +1,126 @@ +zk.enabled=false +zk.url=localhost:2181 +zk.zk_dir=/thingsboard + +updates.enabled=false + +audit-log.enabled=true +audit-log.sink.type=none + +cache.type=caffeine +cache.maximumPoolSize=16 +cache.attributes.enabled=true +#cache.type=redis + +cache.specs.relations.timeToLiveInMinutes=1440 +cache.specs.relations.maxSize=100000 + +cache.specs.deviceCredentials.timeToLiveInMinutes=1440 +cache.specs.deviceCredentials.maxSize=100000 + +cache.specs.devices.timeToLiveInMinutes=1440 +cache.specs.devices.maxSize=100000 + +cache.specs.sessions.timeToLiveInMinutes=1440 +cache.specs.sessions.maxSize=100000 + +cache.specs.assets.timeToLiveInMinutes=1440 +cache.specs.assets.maxSize=100000 + +cache.specs.entityViews.timeToLiveInMinutes=1440 +cache.specs.entityViews.maxSize=100000 + +cache.specs.claimDevices.timeToLiveInMinutes=1440 +cache.specs.claimDevices.maxSize=100000 + +cache.specs.tenants.timeToLiveInMinutes=1440 +cache.specs.tenants.maxSize=100000 + +cache.specs.tenantsExist.timeToLiveInMinutes=1440 +cache.specs.tenantsExist.maxSize=100000 + +cache.specs.securitySettings.timeToLiveInMinutes=1440 +cache.specs.securitySettings.maxSize=100000 + +cache.specs.tenantProfiles.timeToLiveInMinutes=1440 +cache.specs.tenantProfiles.maxSize=100000 + +cache.specs.deviceProfiles.timeToLiveInMinutes=1440 +cache.specs.deviceProfiles.maxSize=100000 + +cache.specs.assetProfiles.timeToLiveInMinutes=1440 +cache.specs.assetProfiles.maxSize=100000 + +cache.specs.attributes.timeToLiveInMinutes=1440 +cache.specs.attributes.maxSize=100000 + +cache.specs.tokensOutdatageTime.timeToLiveInMinutes=1440 +cache.specs.tokensOutdatageTime.maxSize=100000 + +cache.specs.otaPackages.timeToLiveInMinutes=1440 +cache.specs.otaPackages.maxSize=100000 + +cache.specs.otaPackagesData.timeToLiveInMinutes=1440 +cache.specs.otaPackagesData.maxSize=100000 + +cache.specs.edges.timeToLiveInMinutes=1440 +cache.specs.edges.maxSize=100000 + + +redis.connection.host=localhost +redis.connection.port=6379 +redis.connection.db=0 +redis.connection.password= + +security.user_login_case_sensitive=true +security.claim.allowClaimingByDefault=true +security.claim.duration=60000 + +database.ts_max_intervals=700 + +sql.remove_null_chars=true + +# Edge disabled to speed up the context init. Will be enabled by @TestPropertySource in respective tests +edges.enabled=false + +# Transports disabled to speed up the context init. Particular transport will be enabled with @TestPropertySource in respective tests +transport.http.enabled=false +transport.mqtt.enabled=false +transport.coap.enabled=false +transport.lwm2m.enabled=false +transport.snmp.enabled=false + +# Low latency settings to perform tests as fast as possible +sql.attributes.batch_max_delay=5 +sql.attributes.batch_threads=2 +sql.ts.batch_max_delay=5 +sql.ts.batch_threads=2 +sql.ts_latest.batch_max_delay=5 +sql.ts_latest.batch_threads=2 +sql.events.batch_max_delay=5 +sql.events.batch_threads=2 +actors.system.tenant_dispatcher_pool_size=4 +actors.system.device_dispatcher_pool_size=8 +actors.system.rule_dispatcher_pool_size=12 +transport.sessions.report_timeout=10000 +queue.transport_api.request_poll_interval=5 +queue.transport_api.response_poll_interval=5 +queue.transport.poll_interval=5 +queue.core.poll-interval=5 +queue.core.partitions=2 +queue.rule-engine.poll-interval=5 +queue.rule-engine.queues[0].poll-interval=5 +queue.rule-engine.queues[0].partitions=2 +queue.rule-engine.queues[0].processing-strategy.retries=1 +queue.rule-engine.queues[0].processing-strategy.pause-between-retries=0 +queue.rule-engine.queues[0].processing-strategy.max-pause-between-retries=0 +queue.rule-engine.queues[1].poll-interval=5 +queue.rule-engine.queues[1].partitions=2 +queue.rule-engine.queues[1].processing-strategy.retries=1 +queue.rule-engine.queues[1].processing-strategy.pause-between-retries=0 +queue.rule-engine.queues[1].processing-strategy.max-pause-between-retries=0 +queue.rule-engine.queues[2].poll-interval=5 +queue.rule-engine.queues[2].partitions=2 +queue.rule-engine.queues[2].processing-strategy.retries=1 +queue.rule-engine.queues[2].processing-strategy.pause-between-retries=0 +queue.rule-engine.queues[2].processing-strategy.max-pause-between-retries=0 diff --git a/dao/src/test/resources/cassandra-test.properties b/dao/src/test/resources/cassandra-test.properties new file mode 100644 index 0000000..5765153 --- /dev/null +++ b/dao/src/test/resources/cassandra-test.properties @@ -0,0 +1,71 @@ +cassandra.cluster_name=Thingsboard Cluster + +cassandra.keyspace_name=thingsboard + +cassandra.url=127.0.0.1:9142 + +cassandra.local_datacenter=datacenter1 + +cassandra.ssl.enabled=false +cassandra.ssl.hostname_validation=false +cassandra.ssl.trust_store= +cassandra.ssl.trust_store_password= +cassandra.ssl.key_store= +cassandra.ssl.key_store_password= +cassandra.ssl.cipher_suites= + +cassandra.jmx=false + +cassandra.metrics=false + +cassandra.compression=none + +cassandra.init_timeout_ms=60000 + +cassandra.init_retry_interval_ms=3000 + +cassandra.credentials=false + +cassandra.username= + +cassandra.password= + +cassandra.socket.connect_timeout=5000 + +cassandra.socket.read_timeout=12000 + +cassandra.socket.keep_alive=true + +cassandra.socket.reuse_address=true + +cassandra.socket.so_linger= + +cassandra.socket.tcp_no_delay=false + +cassandra.socket.receive_buffer_size= + +cassandra.socket.send_buffer_size= + +cassandra.query.read_consistency_level=ONE + +cassandra.query.write_consistency_level=ONE + +cassandra.query.default_fetch_size=2000 + +cassandra.query.ts_key_value_partitioning=HOURS + +cassandra.query.ts_key_value_partitions_max_cache_size=100000 + +cassandra.query.ts_key_value_ttl=0 + +cassandra.query.max_limit_per_request=1000 +cassandra.query.buffer_size=100000 +cassandra.query.concurrent_limit=1000 +cassandra.query.permit_max_wait_time=20000 +cassandra.query.rate_limit_print_interval_ms=30000 +cassandra.query.set_null_values_enabled=false +cassandra.query.tenant_rate_limits.enabled=false +cassandra.query.tenant_rate_limits.configuration=5000:1,100000:60 +cassandra.query.tenant_rate_limits.print_tenant_names=false + +service.type=monolith diff --git a/dao/src/test/resources/cassandra-test.yaml b/dao/src/test/resources/cassandra-test.yaml new file mode 100644 index 0000000..e60f248 --- /dev/null +++ b/dao/src/test/resources/cassandra-test.yaml @@ -0,0 +1,592 @@ +# Cassandra storage config YAML + +# NOTE: +# See http://wiki.apache.org/cassandra/StorageConfiguration for +# full explanations of configuration directives +# /NOTE + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +cluster_name: 'Test Cluster' + +# You should always specify InitialToken when setting up a production +# cluster for the first time, and often when adding capacity later. +# The principle is that each node should be given an equal slice of +# the token ring; see http://wiki.apache.org/cassandra/Operations +# for more details. +# +# If blank, Cassandra will request a token bisecting the range of +# the heaviest-loaded existing node. If there is no load information +# available, such as is the case with a new cluster, it will pick +# a random token, which will lead to hot spots. +num_tokens: 1 + +initial_token: 0 + +# See http://wiki.apache.org/cassandra/HintedHandoff +hinted_handoff_enabled: true +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +max_hint_window_in_ms: 10800000 # 3 hours +# Maximum throttle in KBs per second, per delivery thread. This will be +# reduced proportionally to the number of nodes in the cluster. (If there +# are two nodes in the cluster, each delivery thread will use the maximum +# rate; if there are three, each will throttle to half of the maximum, +# since we expect two nodes to be delivering hints simultaneously.) +hinted_handoff_throttle_in_kb: 1024 +# Number of threads with which to deliver hints; +# Consider increasing this number when you have multi-dc deployments, since +# cross-dc handoff tends to be slower +max_hints_delivery_threads: 2 + +# The following setting populates the page cache on memtable flush and compaction +# WARNING: Enable this setting only when the whole node's data fits in memory. +# Defaults to: false +# populate_io_cache_on_flush: false + +# Authentication backend, implementing IAuthenticator; used to identify users +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthenticator, +# PasswordAuthenticator}. +# +# - AllowAllAuthenticator performs no checks - set it to disable authentication. +# - PasswordAuthenticator relies on username/password pairs to authenticate +# users. It keeps usernames and hashed passwords in system_auth.credentials table. +# Please increase system_auth keyspace replication factor if you use this authenticator. +authenticator: AllowAllAuthenticator + +# Authorization backend, implementing IAuthorizer; used to limit access/provide permissions +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, +# CassandraAuthorizer}. +# +# - AllowAllAuthorizer allows any action to any user - set it to disable authorization. +# - CassandraAuthorizer stores permissions in system_auth.permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +authorizer: AllowAllAuthorizer + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 2000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +permissions_validity_in_ms: 2000 + + +# The partitioner is responsible for distributing rows (by key) across +# nodes in the cluster. Any IPartitioner may be used, including your +# own as long as it is on the classpath. Out of the box, Cassandra +# provides org.apache.cassandra.dht.{Murmur3Partitioner, RandomPartitioner +# ByteOrderedPartitioner, OrderPreservingPartitioner (deprecated)}. +# +# - RandomPartitioner distributes rows across the cluster evenly by md5. +# This is the default prior to 1.2 and is retained for compatibility. +# - Murmur3Partitioner is similar to RandomPartioner but uses Murmur3_128 +# Hash Function instead of md5. When in doubt, this is the best option. +# - ByteOrderedPartitioner orders rows lexically by key bytes. BOP allows +# scanning rows in key order, but the ordering can generate hot spots +# for sequential insertion workloads. +# - OrderPreservingPartitioner is an obsolete form of BOP, that stores +# - keys in a less-efficient format and only works with keys that are +# UTF8-encoded Strings. +# - CollatingOPP collates according to EN,US rules rather than lexical byte +# ordering. Use this as an example if you need custom collation. +# +# See http://wiki.apache.org/cassandra/Operations for more on +# partitioners and token selection. +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# directories where Cassandra should store data on disk. +data_file_directories: + - target/embeddedCassandra/data + +# commit log +commitlog_directory: target/embeddedCassandra/commitlog + +hints_directory: target/embeddedCassandra/hints + +cdc_raw_directory: target/embeddedCassandra/cdc + +# policy for data disk failures: +# stop: shut down gossip and Thrift, leaving the node effectively dead, but +# can still be inspected via JMX. +# best_effort: stop using the failed disk and respond to requests based on +# remaining available sstables. This means you WILL see obsolete +# data at CL.ONE! +# ignore: ignore fatal errors and let requests fail, as in pre-1.2 Cassandra +disk_failure_policy: stop + + +# Maximum size of the key cache in memory. +# +# Each key cache hit saves 1 seek and each row cache hit saves 2 seeks at the +# minimum, sometimes more. The key cache is fairly tiny for the amount of +# time it saves, so it's worthwhile to use it at large numbers. +# The row cache saves even more time, but must store the whole values of +# its rows, so it is extremely space-intensive. It's best to only use the +# row cache if you have hot rows or static rows. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(5% of Heap (in MB), 100MB)). Set to 0 to disable key cache. +key_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# safe the keys cache. Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 14400 or 4 hours. +key_cache_save_period: 14400 + +# Number of keys from the key cache to save +# Disabled by default, meaning all keys are going to be saved +# key_cache_keys_to_save: 100 + +# Maximum size of the row cache in memory. +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is 0, to disable row caching. +row_cache_size_in_mb: 0 + +# Duration in seconds after which Cassandra should +# safe the row cache. Caches are saved to saved_caches_directory as specified +# in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 0 to disable saving the row cache. +row_cache_save_period: 0 + +# Number of keys from the row cache to save +# Disabled by default, meaning all keys are going to be saved +# row_cache_keys_to_save: 100 + +# saved caches +saved_caches_directory: target/embeddedCassandra/saved_caches + +# commitlog_sync may be either "periodic" or "batch." +# When in batch mode, Cassandra won't ack writes until the commit log +# has been fsynced to disk. It will wait up to +# commitlog_sync_batch_window_in_ms milliseconds for other writes, before +# performing the sync. +# +# commitlog_sync: batch +# commitlog_sync_batch_window_in_ms: 50 +# +# the other option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +commitlog_segment_size_in_mb: 32 + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "127.0.0.1" + + +# For workloads with more data than can fit in memory, Cassandra's +# bottleneck will be reads that need to fetch data from +# disk. "concurrent_reads" should be set to (16 * number_of_drives) in +# order to allow the operations to enqueue low enough in the stack +# that the OS and drives can reorder them. +# +# On the other hand, since writes are almost never IO bound, the ideal +# number of "concurrent_writes" is dependent on the number of cores in +# your system; (8 * number_of_cores) is a good rule of thumb. +concurrent_reads: 32 +concurrent_writes: 32 + +# Total memory to use for memtables. Cassandra will flush the largest +# memtable when this much memory is used. +# If omitted, Cassandra will set it to 1/3 of the heap. +# memtable_total_space_in_mb: 2048 + +# Total space to use for commitlogs. +# If space gets above this value (it will round up to the next nearest +# segment multiple), Cassandra will flush every dirty CF in the oldest +# segment and remove it. +# commitlog_total_space_in_mb: 4096 + +# This sets the amount of memtable flush writer threads. These will +# be blocked by disk io, and each one will hold a memtable in memory +# while blocked. If you have a large heap and many data directories, +# you can increase this value for better flush performance. +# By default this will be set to the amount of data directories defined. +#memtable_flush_writers: 1 + +# the number of full memtables to allow pending flush, that is, +# waiting for a writer thread. At a minimum, this should be set to +# the maximum number of secondary indexes created on a single CF. +#memtable_flush_queue_size: 4 + +# Whether to, when doing sequential writing, fsync() at intervals in +# order to force the operating system to flush the dirty +# buffers. Enable this to avoid sudden dirty buffer flushing from +# impacting read latencies. Almost always a good idea on SSD:s; not +# necessarily on platters. +trickle_fsync: false +trickle_fsync_interval_in_kb: 10240 + +# TCP port, for commands and data +storage_port: 7010 + +# SSL port, for encrypted communication. Unused unless enabled in +# encryption_options +ssl_storage_port: 7011 + +# Address to bind to and tell other Cassandra nodes to connect to. You +# _must_ change this if you want multiple nodes to be able to +# communicate! +# +# Leaving it blank leaves it up to InetAddress.getLocalHost(). This +# will always do the Right Thing *if* the node is properly configured +# (hostname, name resolution, etc), and the Right Thing is to use the +# address associated with the hostname (it might not be). +# +# Setting this to 0.0.0.0 is always wrong. +listen_address: 127.0.0.1 + +start_native_transport: true +# port for the CQL native transport to listen for clients on +native_transport_port: 9142 + +# Whether to start the thrift rpc server. +start_rpc: false + +# Address to broadcast to other Cassandra nodes +# Leaving this blank will set it to the same value as listen_address +# broadcast_address: 1.2.3.4 + +# The address to bind the Thrift RPC service to -- clients connect +# here. Unlike ListenAddress above, you *can* specify 0.0.0.0 here if +# you want Thrift to listen on all interfaces. +# +# Leaving this blank has the same effect it does for ListenAddress, +# (i.e. it will be based on the configured hostname of the node). +rpc_address: localhost +# port for Thrift to listen for clients on +rpc_port: 9171 + +# enable or disable keepalive on rpc connections +rpc_keepalive: true + +# Cassandra provides three options for the RPC Server: +# +# sync -> One connection per thread in the rpc pool (see below). +# For a very large number of clients, memory will be your limiting +# factor; on a 64 bit JVM, 128KB is the minimum stack size per thread. +# Connection pooling is very, very strongly recommended. +# +# async -> Nonblocking server implementation with one thread to serve +# rpc connections. This is not recommended for high throughput use +# cases. Async has been tested to be about 50% slower than sync +# or hsha and is deprecated: it will be removed in the next major release. +# +# hsha -> Stands for "half synchronous, half asynchronous." The rpc thread pool +# (see below) is used to manage requests, but the threads are multiplexed +# across the different clients. +# +# The default is sync because on Windows hsha is about 30% slower. On Linux, +# sync/hsha performance is about the same, with hsha of course using less memory. +rpc_server_type: sync + +# Uncomment rpc_min|max|thread to set request pool size. +# You would primarily set max for the sync server to safeguard against +# misbehaved clients; if you do hit the max, Cassandra will block until one +# disconnects before accepting more. The defaults for sync are min of 16 and max +# unlimited. +# +# For the Hsha server, the min and max both default to quadruple the number of +# CPU cores. +# +# This configuration is ignored by the async server. +# +# rpc_min_threads: 16 +# rpc_max_threads: 2048 + +# uncomment to set socket buffer sizes on rpc connections +# rpc_send_buff_size_in_bytes: +# rpc_recv_buff_size_in_bytes: + +# Frame size for thrift (maximum field length). +# 0 disables TFramedTransport in favor of TSocket. This option +# is deprecated; we strongly recommend using Framed mode. +thrift_framed_transport_size_in_mb: 15 + +# The max length of a thrift message, including all fields and +# internal thrift overhead. +thrift_max_message_length_in_mb: 16 + +# Set to true to have Cassandra create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# Keyspace data. Removing these links is the operator's +# responsibility. +incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Cassandra won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +auto_snapshot: false + +# Add column indexes to a row after its contents reach this size. +# Increase if your column values are large, or if you have a very large +# number of columns. The competing causes are, Cassandra has to +# deserialize this much of the row to read a single column, so you want +# it to be small - at least if you do many partial-row reads - but all +# the index data is read for each access, so you don't want to generate +# that wastefully either. +column_index_size_in_kb: 64 + +# Size limit for rows being compacted in memory. Larger rows will spill +# over to disk and use a slower two-pass compaction process. A message +# will be logged specifying the row key. +#in_memory_compaction_limit_in_mb: 64 + +# Number of simultaneous compactions to allow, NOT including +# validation "compactions" for anti-entropy repair. Simultaneous +# compactions can help preserve read performance in a mixed read/write +# workload, by mitigating the tendency of small sstables to accumulate +# during a single long running compactions. The default is usually +# fine and if you experience problems with compaction running too +# slowly or too fast, you should look at +# compaction_throughput_mb_per_sec first. +# +# This setting has no effect on LeveledCompactionStrategy. +# +# concurrent_compactors defaults to the number of cores. +# Uncomment to make compaction mono-threaded, the pre-0.8 default. +#concurrent_compactors: 1 + +# Multi-threaded compaction. When enabled, each compaction will use +# up to one thread per core, plus one thread per sstable being merged. +# This is usually only useful for SSD-based hardware: otherwise, +# your concern is usually to get compaction to do LESS i/o (see: +# compaction_throughput_mb_per_sec), not more. +#multithreaded_compaction: false + +# Throttles compaction to the given total throughput across the entire +# system. The faster you insert data, the faster you need to compact in +# order to keep the sstable count down, but in general, setting this to +# 16 to 32 times the rate you are inserting data is more than sufficient. +# Setting this to 0 disables throttling. Note that this account for all types +# of compaction, including validation compaction. +compaction_throughput_mb_per_sec: 16 + +# Track cached row keys during compaction, and re-cache their new +# positions in the compacted sstable. Disable if you use really large +# key caches. +#compaction_preheat_key_cache: true + +# Throttles all outbound streaming file transfers on this node to the +# given total throughput in Mbps. This is necessary because Cassandra does +# mostly sequential IO when streaming data during bootstrap or repair, which +# can lead to saturating the network connection and degrading rpc performance. +# When unset, the default is 200 Mbps or 25 MB/s. +# stream_throughput_outbound_megabits_per_sec: 200 + +# How long the coordinator should wait for read operations to complete +read_request_timeout_in_ms: 5000 +# How long the coordinator should wait for seq or index scans to complete +range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete +write_request_timeout_in_ms: 10000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations +request_timeout_in_ms: 10000 + +# Enable operation timeout information exchange between nodes to accurately +# measure request timeouts. If disabled, replicas will assume that requests +# were forwarded to them instantly by the coordinator, which means that +# under overload conditions we will waste that much extra time processing +# already-timed-out requests. +# +# Warning: before enabling this property make sure to ntp is installed +# and the times are synchronized between the nodes. +cross_node_timeout: false + +# Enable socket timeout for streaming operation. +# When a timeout occurs during streaming, streaming is retried from the start +# of the current file. This _can_ involve re-streaming an important amount of +# data, so you should avoid setting the value too low. +# Default value is 0, which never timeout streams. +# streaming_socket_timeout_in_ms: 0 + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# endpoint_snitch -- Set this to a class that implements +# IEndpointSnitch. The snitch has two functions: +# - it teaches Cassandra enough about your network topology to route +# requests efficiently +# - it allows Cassandra to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Cassandra will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# IF YOU CHANGE THE SNITCH AFTER DATA IS INSERTED INTO THE CLUSTER, +# YOU MUST RUN A FULL REPAIR, SINCE THE SNITCH AFFECTS WHERE REPLICAS +# ARE PLACED. +# +# Out of the box, Cassandra provides +# - SimpleSnitch: +# Treats Strategy order as proximity. This improves cache locality +# when disabling read repair, which can further improve throughput. +# Only appropriate for single-datacenter deployments. +# - PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# - RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's +# IP address, respectively. Unless this happens to match your +# deployment conventions (as it did Facebook's), this is best used +# as an example of writing a custom Snitch class. +# - Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the Datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# - Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Cassandra will switch to the private IP after +# establishing a connection.) +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# controls how often to perform the more expensive part of host score +# calculation +dynamic_snitch_update_interval_in_ms: 100 +# controls how often to reset all host scores, allowing a bad host to +# possibly recover +dynamic_snitch_reset_interval_in_ms: 600000 +# if set greater than zero and read_repair_chance is < 1.0, this will allow +# 'pinning' of replicas to hosts in order to increase cache capacity. +# The badness threshold will control how much worse the pinned host has to be +# before the dynamic snitch will prefer other replicas over it. This is +# expressed as a double which represents a percentage. Thus, a value of +# 0.2 means Cassandra would continue to prefer the static snitch values +# until the pinned host was 20% worse than the fastest. +dynamic_snitch_badness_threshold: 0.1 + +# request_scheduler -- Set this to a class that implements +# RequestScheduler, which will schedule incoming client requests +# according to the specific policy. This is useful for multi-tenancy +# with a single Cassandra cluster. +# NOTE: This is specifically for requests from the client and does +# not affect inter node communication. +# org.apache.cassandra.scheduler.NoScheduler - No scheduling takes place +# org.apache.cassandra.scheduler.RoundRobinScheduler - Round robin of +# client requests to a node with a separate queue for each +# request_scheduler_id. The scheduler is further customized by +# request_scheduler_options as described below. +request_scheduler: org.apache.cassandra.scheduler.NoScheduler + +# Scheduler Options vary based on the type of scheduler +# NoScheduler - Has no options +# RoundRobin +# - throttle_limit -- The throttle_limit is the number of in-flight +# requests per client. Requests beyond +# that limit are queued up until +# running requests can complete. +# The value of 80 here is twice the number of +# concurrent_reads + concurrent_writes. +# - default_weight -- default_weight is optional and allows for +# overriding the default which is 1. +# - weights -- Weights are optional and will default to 1 or the +# overridden default_weight. The weight translates into how +# many requests are handled during each turn of the +# RoundRobin, based on the scheduler id. +# +# request_scheduler_options: +# throttle_limit: 80 +# default_weight: 5 +# weights: +# Keyspace1: 1 +# Keyspace2: 5 + +# request_scheduler_id -- An identifer based on which to perform +# the request scheduling. Currently the only valid option is keyspace. +# request_scheduler_id: keyspace + +# index_interval controls the sampling of entries from the primrary +# row index in terms of space versus time. The larger the interval, +# the smaller and less effective the sampling will be. In technicial +# terms, the interval coresponds to the number of index entries that +# are skipped between taking each sample. All the sampled entries +# must fit in memory. Generally, a value between 128 and 512 here +# coupled with a large key cache size on CFs results in the best trade +# offs. This value is not often changed, however if you have many +# very small rows (many to an OS page), then increasing this will +# often lower memory usage without a impact on performance. +index_interval: 128 + +# Enable or disable inter-node encryption +# Default settings are TLS v1, RSA 1024-bit keys (it is imperative that +# users generate their own keys) TLS_RSA_WITH_AES_128_CBC_SHA as the cipher +# suite for authentication, key exchange and encryption of the actual data transfers. +# NOTE: No custom encryption options are enabled at the moment +# The available internode options are : all, none, dc, rack +# +# If set to dc cassandra will encrypt the traffic between the DCs +# If set to rack cassandra will encrypt the traffic between the racks +# +# The passwords used in these options must match the passwords used when generating +# the keystore and truststore. For instructions on generating these files, see: +# http://download.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# +server_encryption_options: + internode_encryption: none + keystore: conf/.keystore + keystore_password: cassandra + truststore: conf/.truststore + truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA] \ No newline at end of file diff --git a/dao/src/test/resources/logback.xml b/dao/src/test/resources/logback.xml new file mode 100644 index 0000000..f74ba57 --- /dev/null +++ b/dao/src/test/resources/logback.xml @@ -0,0 +1,19 @@ + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties new file mode 100644 index 0000000..1c6d244 --- /dev/null +++ b/dao/src/test/resources/nosql-test.properties @@ -0,0 +1,29 @@ +database.ts.type=cassandra +database.ts_latest.type=cassandra + +sql.ts_inserts_executor_type=fixed +sql.ts_inserts_fixed_thread_pool_size=10 + +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.properties.hibernate.order_by.default_null_ordering=last +spring.jpa.properties.hibernate.jdbc.log.warnings=false + +spring.jpa.show-sql=false + +spring.jpa.hibernate.ddl-auto=none +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.url=jdbc:tc:postgresql:12.8:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.hikari.maximumPoolSize = 50 + +queue.rule-engine.queues[0].name=Main +queue.rule-engine.queues[0].topic=tb_rule_engine.main +queue.rule-engine.queues[0].poll-interval=5 +queue.rule-engine.queues[0].partitions=2 +queue.rule-engine.queues[0].pack-processing-timeout=3000 +queue.rule-engine.queues[0].processing-strategy.type=SKIP_ALL_FAILURES +queue.rule-engine.queues[0].processing-strategy.retries=1 +queue.rule-engine.queues[0].processing-strategy.pause-between-retries=0 +queue.rule-engine.queues[0].processing-strategy.max-pause-between-retries=0 +queue.rule-engine.queues[0].submit-strategy.type=BURST diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties new file mode 100644 index 0000000..b2ded71 --- /dev/null +++ b/dao/src/test/resources/sql-test.properties @@ -0,0 +1,55 @@ +database.ts.type=sql +database.ts_latest.type=sql + +sql.ts_inserts_executor_type=fixed +sql.ts_inserts_fixed_thread_pool_size=200 +sql.ts_key_value_partitioning=MONTHS +# +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.properties.hibernate.order_by.default_null_ordering=last +spring.jpa.properties.hibernate.jdbc.log.warnings=false + +spring.jpa.show-sql=false + +spring.jpa.hibernate.ddl-auto=none +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.url=jdbc:tc:postgresql:12.8:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.hikari.maximumPoolSize = 50 + +service.type=monolith + +#database.ts.type=timescale +#database.ts.type=sql +#database.entities.type=sql +# +#sql.ts_inserts_executor_type=fixed +#sql.ts_inserts_fixed_thread_pool_size=200 +#sql.ts_key_value_partitioning=MONTHS +# +#spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +#spring.jpa.show-sql=false +#spring.jpa.hibernate.ddl-auto=none +# +#spring.datasource.username=postgres +#spring.datasource.password=postgres +#spring.datasource.url=jdbc:postgresql://localhost:5432/sqltest +#spring.datasource.driverClassName=org.postgresql.Driver +#spring.datasource.hikari.maximumPoolSize = 50 + +queue.core.pack-processing-timeout=3000 +queue.rule-engine.pack-processing-timeout=3000 + +queue.rule-engine.queues[0].name=Main +queue.rule-engine.queues[0].topic=tb_rule_engine.main +queue.rule-engine.queues[0].poll-interval=5 +queue.rule-engine.queues[0].partitions=2 +queue.rule-engine.queues[0].pack-processing-timeout=3000 +queue.rule-engine.queues[0].processing-strategy.type=SKIP_ALL_FAILURES +queue.rule-engine.queues[0].processing-strategy.retries=1 +queue.rule-engine.queues[0].processing-strategy.pause-between-retries=0 +queue.rule-engine.queues[0].processing-strategy.max-pause-between-retries=0 +queue.rule-engine.queues[0].submit-strategy.type=BURST + +sql.log_entity_queries=true diff --git a/dao/src/test/resources/sql/hsql/drop-all-tables.sql b/dao/src/test/resources/sql/hsql/drop-all-tables.sql new file mode 100644 index 0000000..3ad3dac --- /dev/null +++ b/dao/src/test/resources/sql/hsql/drop-all-tables.sql @@ -0,0 +1,42 @@ +DROP TABLE IF EXISTS admin_settings; +DROP TABLE IF EXISTS entity_alarm; +DROP TABLE IF EXISTS alarm; +DROP TABLE IF EXISTS asset; +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS attribute_kv; +DROP TABLE IF EXISTS component_descriptor; +DROP TABLE IF EXISTS customer; +DROP TABLE IF EXISTS device; +DROP TABLE IF EXISTS device_credentials; +DROP TABLE IF EXISTS event; +DROP TABLE IF EXISTS relation; +DROP TABLE IF EXISTS tb_user; +DROP TABLE IF EXISTS tenant; +DROP TABLE IF EXISTS ts_kv; +DROP TABLE IF EXISTS ts_kv_dictionary; +DROP TABLE IF EXISTS ts_kv_latest; +DROP TABLE IF EXISTS user_credentials; +DROP TABLE IF EXISTS widget_type; +DROP TABLE IF EXISTS widgets_bundle; +DROP TABLE IF EXISTS entity_view; +DROP TABLE IF EXISTS device_profile; +DROP TABLE IF EXISTS tenant_profile; +DROP TABLE IF EXISTS dashboard; +DROP TABLE IF EXISTS rule_node_state; +DROP TABLE IF EXISTS rule_node; +DROP TABLE IF EXISTS rule_chain; +DROP TABLE IF EXISTS oauth2_mobile; +DROP TABLE IF EXISTS oauth2_domain; +DROP TABLE IF EXISTS oauth2_registration; +DROP TABLE IF EXISTS oauth2_params; +DROP TABLE IF EXISTS oauth2_client_registration_template; +DROP TABLE IF EXISTS oauth2_client_registration; +DROP TABLE IF EXISTS oauth2_client_registration_info; +DROP TABLE IF EXISTS api_usage_state; +DROP TABLE IF EXISTS resource; +DROP TABLE IF EXISTS ota_package; +DROP TABLE IF EXISTS edge; +DROP TABLE IF EXISTS edge_event; +DROP TABLE IF EXISTS rpc; +DROP TABLE IF EXISTS queue; +DROP FUNCTION IF EXISTS to_uuid; diff --git a/dao/src/test/resources/sql/psql/drop-all-tables.sql b/dao/src/test/resources/sql/psql/drop-all-tables.sql new file mode 100644 index 0000000..2116bc3 --- /dev/null +++ b/dao/src/test/resources/sql/psql/drop-all-tables.sql @@ -0,0 +1,43 @@ +DROP TABLE IF EXISTS admin_settings; +DROP TABLE IF EXISTS entity_alarm; +DROP TABLE IF EXISTS alarm; +DROP TABLE IF EXISTS asset; +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS attribute_kv; +DROP TABLE IF EXISTS component_descriptor; +DROP TABLE IF EXISTS customer; +DROP TABLE IF EXISTS device; +DROP TABLE IF EXISTS device_credentials; +DROP TABLE IF EXISTS event; +DROP TABLE IF EXISTS relation; +DROP TABLE IF EXISTS tb_user; +DROP TABLE IF EXISTS tenant; +DROP TABLE IF EXISTS ts_kv; +DROP TABLE IF EXISTS ts_kv_latest; +DROP TABLE IF EXISTS ts_kv_dictionary; +DROP TABLE IF EXISTS user_credentials; +DROP TABLE IF EXISTS widget_type; +DROP TABLE IF EXISTS widgets_bundle; +DROP TABLE IF EXISTS entity_view; +DROP TABLE IF EXISTS device_profile; +DROP TABLE IF EXISTS tenant_profile; +DROP TABLE IF EXISTS asset_profile; +DROP TABLE IF EXISTS dashboard; +DROP TABLE IF EXISTS rule_node_state; +DROP TABLE IF EXISTS rule_node; +DROP TABLE IF EXISTS rule_chain; +DROP TABLE IF EXISTS tb_schema_settings; +DROP TABLE IF EXISTS oauth2_mobile; +DROP TABLE IF EXISTS oauth2_domain; +DROP TABLE IF EXISTS oauth2_registration; +DROP TABLE IF EXISTS oauth2_params; +DROP TABLE IF EXISTS oauth2_client_registration_template; +DROP TABLE IF EXISTS oauth2_client_registration; +DROP TABLE IF EXISTS oauth2_client_registration_info; +DROP TABLE IF EXISTS api_usage_state; +DROP TABLE IF EXISTS resource; +DROP TABLE IF EXISTS firmware; +DROP TABLE IF EXISTS edge; +DROP TABLE IF EXISTS edge_event; +DROP TABLE IF EXISTS rpc; +DROP TABLE IF EXISTS queue; \ No newline at end of file diff --git a/dao/src/test/resources/sql/system-data.sql b/dao/src/test/resources/sql/system-data.sql new file mode 100644 index 0000000..bfa33c2 --- /dev/null +++ b/dao/src/test/resources/sql/system-data.sql @@ -0,0 +1,51 @@ +-- +-- Copyright © 2016-2020 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +/** SYSTEM **/ + +/** System admin **/ +INSERT INTO tb_user ( id, created_time, tenant_id, customer_id, email, search_text, authority ) +VALUES ( '5a797660-4612-11e7-a919-92ebcb67fe33', 1592576748000, '13814000-1dd2-11b2-8080-808080808080', '13814000-1dd2-11b2-8080-808080808080', 'sysadmin@thingsboard.org', + 'sysadmin@thingsboard.org', 'SYS_ADMIN' ); + +INSERT INTO user_credentials ( id, created_time, user_id, enabled, password ) +VALUES ( '61441950-4612-11e7-a919-92ebcb67fe33', 1592576748000, '5a797660-4612-11e7-a919-92ebcb67fe33', true, + '$2a$10$5JTB8/hxWc9WAy62nCGSxeefl3KWmipA9nFpVdDa0/xfIseeBB4Bu' ); + +/** System settings **/ +INSERT INTO admin_settings ( id, created_time, tenant_id, key, json_value ) +VALUES ( '6a2266e4-4612-11e7-a919-92ebcb67fe33', 1592576748000, '13814000-1dd2-11b2-8080-808080808080', 'general', '{ + "baseUrl": "http://localhost:8080" +}' ); + +INSERT INTO admin_settings ( id, created_time, tenant_id, key, json_value ) +VALUES ( '6eaaefa6-4612-11e7-a919-92ebcb67fe33', 1592576748000, '13814000-1dd2-11b2-8080-808080808080', 'mail', '{ + "mailFrom": "Thingsboard ", + "smtpProtocol": "smtp", + "smtpHost": "localhost", + "smtpPort": "25", + "timeout": "10000", + "enableTls": false, + "tlsVersion": "TLSv1.2", + "username": "", + "password": "" +}' ); + +INSERT INTO queue ( id, created_time, tenant_id, name, topic, poll_interval, partitions, consumer_per_partition, pack_processing_timeout, submit_strategy, processing_strategy ) +VALUES ( '6eaaefa6-4612-11e7-a919-92ebcb67fe33', 1592576748000 ,'13814000-1dd2-11b2-8080-808080808080', 'Main' ,'tb_rule_engine.main', 25, 10, true, 2000, + '{"type": "BURST", "batchSize": 1000}', + '{"type": "SKIP_ALL_FAILURES", "retries": 3, "failurePercentage": 0.0, "pauseBetweenRetries": 3, "maxPauseBetweenRetries": 3}' +); diff --git a/dao/src/test/resources/sql/system-test-psql.sql b/dao/src/test/resources/sql/system-test-psql.sql new file mode 100644 index 0000000..8d3f08a --- /dev/null +++ b/dao/src/test/resources/sql/system-test-psql.sql @@ -0,0 +1,2 @@ +--PostgreSQL specific truncate to fit constraints +TRUNCATE TABLE device_credentials, device, device_profile, asset, asset_profile, ota_package, rule_node_state, rule_node, rule_chain; \ No newline at end of file diff --git a/dao/src/test/resources/sql/system-test.sql b/dao/src/test/resources/sql/system-test.sql new file mode 100644 index 0000000..6660dd5 --- /dev/null +++ b/dao/src/test/resources/sql/system-test.sql @@ -0,0 +1,8 @@ +TRUNCATE TABLE device_credentials; +TRUNCATE TABLE device; +TRUNCATE TABLE asset; +TRUNCATE TABLE device_profile CASCADE; +TRUNCATE TABLE asset_profile CASCADE; +TRUNCATE TABLE rule_node_state; +TRUNCATE TABLE rule_node; +TRUNCATE TABLE rule_chain; \ No newline at end of file diff --git a/dao/src/test/resources/sql/timescale/drop-all-tables.sql b/dao/src/test/resources/sql/timescale/drop-all-tables.sql new file mode 100644 index 0000000..80330a5 --- /dev/null +++ b/dao/src/test/resources/sql/timescale/drop-all-tables.sql @@ -0,0 +1,38 @@ +DROP TABLE IF EXISTS admin_settings; +DROP TABLE IF EXISTS entity_alarm; +DROP TABLE IF EXISTS alarm; +DROP TABLE IF EXISTS asset; +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS attribute_kv; +DROP TABLE IF EXISTS component_descriptor; +DROP TABLE IF EXISTS customer; +DROP TABLE IF EXISTS device; +DROP TABLE IF EXISTS device_credentials; +DROP TABLE IF EXISTS event; +DROP TABLE IF EXISTS relation; +DROP TABLE IF EXISTS tb_user; +DROP TABLE IF EXISTS tenant; +DROP TABLE IF EXISTS ts_kv; +DROP TABLE IF EXISTS ts_kv_latest; +DROP TABLE IF EXISTS ts_kv_dictionary; +DROP TABLE IF EXISTS user_credentials; +DROP TABLE IF EXISTS widget_type; +DROP TABLE IF EXISTS widgets_bundle; +DROP TABLE IF EXISTS rule_node_state; +DROP TABLE IF EXISTS rule_node; +DROP TABLE IF EXISTS rule_chain; +DROP TABLE IF EXISTS entity_view; +DROP TABLE IF EXISTS device_profile; +DROP TABLE IF EXISTS tenant_profile; +DROP TABLE IF EXISTS asset_profile; +DROP TABLE IF EXISTS dashboard; +DROP TABLE IF EXISTS edge; +DROP TABLE IF EXISTS edge_event; +DROP TABLE IF EXISTS tb_schema_settings; +DROP TABLE IF EXISTS oauth2_client_registration; +DROP TABLE IF EXISTS oauth2_client_registration_info; +DROP TABLE IF EXISTS oauth2_client_registration_template; +DROP TABLE IF EXISTS api_usage_state; +DROP TABLE IF EXISTS resource; +DROP TABLE IF EXISTS firmware; +DROP TABLE IF EXISTS queue; diff --git a/dao/src/test/resources/timescale-test.properties b/dao/src/test/resources/timescale-test.properties new file mode 100644 index 0000000..2c5552c --- /dev/null +++ b/dao/src/test/resources/timescale-test.properties @@ -0,0 +1,18 @@ +database.ts.type=timescale +database.ts_latest.type=timescale + +sql.ts_inserts_executor_type=fixed +sql.ts_inserts_fixed_thread_pool_size=10 + +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.properties.hibernate.order_by.default_null_ordering=last +spring.jpa.properties.hibernate.jdbc.log.warnings=false + +spring.jpa.show-sql=false + +spring.jpa.hibernate.ddl-auto=none +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb +spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.hikari.maximumPoolSize = 50 diff --git a/dao/src/test/resources/xss-policy.xml b/dao/src/test/resources/xss-policy.xml new file mode 100644 index 0000000..6ea6660 --- /dev/null +++ b/dao/src/test/resources/xss-policy.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + g + grin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..f33ed50 --- /dev/null +++ b/docker/.env @@ -0,0 +1,31 @@ +TB_QUEUE_TYPE=kafka + +# redis or redis-cluster +CACHE=redis + +DOCKER_REPO=thingsboard + +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 +LWM2M_TRANSPORT_DOCKER_NAME=tb-lwm2m-transport +SNMP_TRANSPORT_DOCKER_NAME=tb-snmp-transport +TB_VC_EXECUTOR_DOCKER_NAME=tb-vc-executor + +TB_VERSION=latest + +# Database used by ThingsBoard, can be either postgres (PostgreSQL) or hybrid (PostgreSQL for entities database and Cassandra for timeseries database). +# According to the database type corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.hybrid.yml for details). + +DATABASE=postgres + +LOAD_BALANCER_NAME=haproxy-certbot + +# If enabled Prometheus and Grafana containers are deployed along with other containers +MONITORING_ENABLED=false + +# Limit memory usage for each Java application +# JAVA_OPTS=-Xmx2048M -Xms2048M -Xss384k -XX:+AlwaysPreTouch diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..c9172ae --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,17 @@ +haproxy/certs.d/** +haproxy/letsencrypt/** +tb-node/log/** +tb-node/db/** +tb-node/postgres/** +tb-node/cassandra/** +tb-transports/*/log +tb-vc-executor/log/** +tb-node/redis-cluster-data-0/** +tb-node/redis-cluster-data-1/** +tb-node/redis-cluster-data-2/** +tb-node/redis-cluster-data-3/** +tb-node/redis-cluster-data-4/** +tb-node/redis-cluster-data-5/** +tb-node/redis-data/** + +!.env diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..71ea87f --- /dev/null +++ b/docker/README.md @@ -0,0 +1,119 @@ +# 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 ThingsBoard. +In order to set database type change the value of `DATABASE` variable in `.env` file to one of the following: + +- `postgres` - use PostgreSQL database; +- `hybrid` - use PostgreSQL for entities database and Cassandra for timeseries database; + +**NOTE**: According to the database type corresponding docker service will be deployed (see `docker-compose.postgres.yml`, `docker-compose.hybrid.yml` for details). + +In order to set cache type change the value of `CACHE` variable in `.env` file to one of the following: + +- `redis` - use Redis standalone cache (1 node - 1 master); +- `redis-cluster` - use Redis cluster cache (6 nodes - 3 masters, 3 slaves); + +**NOTE**: According to the cache type corresponding docker service will be deployed (see `docker-compose.redis.yml`, `docker-compose.redis-cluster.yml` for details). + +Execute the following command to create log folders for the services and chown of these folders to the docker container users. +To be able to change user, **chown** command is used, which requires sudo permissions (script will request password for a sudo access): + +` +$ ./docker-create-log-folders.sh +` + +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: + +- **System Administrator**: sysadmin@thingsboard.org / sysadmin + +If you installed DataBase with demo data (using `--loadDemo` flag) you can also use the following credentials: + +- **Tenant Administrator**: tenant@thingsboard.org / tenant +- **Customer User**: customer@thingsboard.org / customer + +In case of any issues you can examine service logs for errors. +For example to see ThingsBoard node logs execute the following command: + +` +$ docker-compose logs -f tb-core1 tb-core2 tb-rule-engine1 tb-rule-engine2 tb-mqtt-transport1 tb-mqtt-transport2 +` + +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. + + +## Monitoring + +If you want to enable monitoring with Prometheus and Grafana you need to set MONITORING_ENABLED environment variable to true. +After this Prometheus and Grafana containers will be deployed. You can reach Prometheus at `http://localhost:9090` and Grafana at `http://localhost:3000` (default login is `admin` and password `foobar`). +To change Grafana password you need to update `GF_SECURITY_ADMIN_PASSWORD` environment variable at `./monitoring/grafana/config.monitoring` file. +Dashboards are loaded from `./monitoring/grafana/provisioning/dashboards` directory. + +If you want to add new monitoring jobs for Prometheus update `./monitoring/prometheus/prometheus.yml` file. \ No newline at end of file diff --git a/docker/cache-redis-cluster.env b/docker/cache-redis-cluster.env new file mode 100644 index 0000000..a3b5160 --- /dev/null +++ b/docker/cache-redis-cluster.env @@ -0,0 +1,5 @@ +CACHE_TYPE=redis +REDIS_CONNECTION_TYPE=cluster +REDIS_NODES=redis-node-0:6379,redis-node-1:6379,redis-node-2:6379,redis-node-3:6379,redis-node-4:6379,redis-node-5:6379 +REDIS_USE_DEFAULT_POOL_CONFIG=false +REDIS_PASSWORD=thingsboard diff --git a/docker/cache-redis.env b/docker/cache-redis.env new file mode 100644 index 0000000..7b92620 --- /dev/null +++ b/docker/cache-redis.env @@ -0,0 +1,2 @@ +CACHE_TYPE=redis +REDIS_HOST=redis diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh new file mode 100644 index 0000000..c1963cc --- /dev/null +++ b/docker/compose-utils.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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" + ;; + hybrid) + ADDITIONAL_COMPOSE_ARGS="-f docker-compose.hybrid.yml" + ;; + *) + echo "Unknown DATABASE value specified in the .env file: '${DATABASE}'. Should be either 'postgres' or 'hybrid'." >&2 + exit 1 + esac + echo $ADDITIONAL_COMPOSE_ARGS +} + +function additionalComposeQueueArgs() { + source .env + ADDITIONAL_COMPOSE_QUEUE_ARGS="" + case $TB_QUEUE_TYPE in + kafka) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.kafka.yml" + ;; + confluent) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.confluent.yml" + ;; + aws-sqs) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.aws-sqs.yml" + ;; + pubsub) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.pubsub.yml" + ;; + rabbitmq) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.rabbitmq.yml" + ;; + service-bus) + ADDITIONAL_COMPOSE_QUEUE_ARGS="-f docker-compose.service-bus.yml" + ;; + *) + echo "Unknown Queue service TB_QUEUE_TYPE value specified in the .env file: '${TB_QUEUE_TYPE}'. Should be either 'kafka' or 'confluent' or 'aws-sqs' or 'pubsub' or 'rabbitmq' or 'service-bus'." >&2 + exit 1 + esac + echo $ADDITIONAL_COMPOSE_QUEUE_ARGS +} + +function additionalComposeMonitoringArgs() { + source .env + + if [ "$MONITORING_ENABLED" = true ] + then + ADDITIONAL_COMPOSE_MONITORING_ARGS="-f docker-compose.prometheus-grafana.yml" + echo $ADDITIONAL_COMPOSE_MONITORING_ARGS + else + echo "" + fi +} + +function additionalComposeCacheArgs() { + source .env + CACHE_COMPOSE_ARGS="" + CACHE="${CACHE:-redis}" + case $CACHE in + redis) + CACHE_COMPOSE_ARGS="-f docker-compose.redis.yml" + ;; + redis-cluster) + CACHE_COMPOSE_ARGS="-f docker-compose.redis-cluster.yml" + ;; + *) + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster'." >&2 + exit 1 + esac + echo $CACHE_COMPOSE_ARGS +} + +function additionalStartupServices() { + source .env + ADDITIONAL_STARTUP_SERVICES="" + case $DATABASE in + postgres) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES postgres" + ;; + hybrid) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES postgres cassandra" + ;; + *) + echo "Unknown DATABASE value specified in the .env file: '${DATABASE}'. Should be either 'postgres' or 'hybrid'." >&2 + exit 1 + esac + + CACHE="${CACHE:-redis}" + case $CACHE in + redis) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES redis" + ;; + redis-cluster) + ADDITIONAL_STARTUP_SERVICES="$ADDITIONAL_STARTUP_SERVICES redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5" + ;; + *) + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster'." >&2 + exit 1 + esac + + echo $ADDITIONAL_STARTUP_SERVICES +} + +function permissionList() { + PERMISSION_LIST=" + 799 799 tb-node/log + 799 799 tb-transports/coap/log + 799 799 tb-transports/lwm2m/log + 799 799 tb-transports/http/log + 799 799 tb-transports/mqtt/log + 799 799 tb-transports/snmp/log + 799 799 tb-transports/coap/log + 799 799 tb-vc-executor/log + 999 999 tb-node/postgres + " + + source .env + + if [ "$DATABASE" = "hybrid" ]; then + PERMISSION_LIST="$PERMISSION_LIST + 999 999 tb-node/cassandra + " + fi + + CACHE="${CACHE:-redis}" + case $CACHE in + redis) + PERMISSION_LIST="$PERMISSION_LIST + 1001 1001 tb-node/redis-data + " + ;; + redis-cluster) + PERMISSION_LIST="$PERMISSION_LIST + 1001 1001 tb-node/redis-cluster-data-0 + 1001 1001 tb-node/redis-cluster-data-1 + 1001 1001 tb-node/redis-cluster-data-2 + 1001 1001 tb-node/redis-cluster-data-3 + 1001 1001 tb-node/redis-cluster-data-4 + 1001 1001 tb-node/redis-cluster-data-5 + " + ;; + *) + echo "Unknown CACHE value specified in the .env file: '${CACHE}'. Should be either 'redis' or 'redis-cluster'." >&2 + exit 1 + esac + + echo "$PERMISSION_LIST" +} + +function checkFolders() { + EXIT_CODE=0 + PERMISSION_LIST=$(permissionList) || exit $? + set -e + while read -r USR GRP DIR + do + if [ -z "$DIR" ]; then # skip empty lines + continue + fi + MESSAGE="Checking user ${USR} group ${GRP} dir ${DIR}" + if [[ -d "$DIR" ]] && + [[ $(ls -ldn "$DIR" | awk '{print $3}') -eq "$USR" ]] && + [[ $(ls -ldn "$DIR" | awk '{print $4}') -eq "$GRP" ]] + then + MESSAGE="$MESSAGE OK" + else + if [ "$1" = "--create" ]; then + echo "Create and chown: user ${USR} group ${GRP} dir ${DIR}" + mkdir -p "$DIR" && sudo chown -R "$USR":"$GRP" "$DIR" + else + echo "$MESSAGE FAILED" + EXIT_CODE=1 + fi + fi + done < <(echo "$PERMISSION_LIST") + return $EXIT_CODE +} + +function composeVersion() { + #Checking whether "set -e" shell option should be restored after Compose version check + FLAG_SET=false + if [[ $SHELLOPTS =~ errexit ]]; then + set +e + FLAG_SET=true + fi + + #Checking Compose V1 availablity + docker-compose version >/dev/null 2>&1 + if [ $? -eq 0 ]; then status_v1=true; else status_v1=false; fi + + #Checking Compose V2 availablity + docker compose version >/dev/null 2>&1 + if [ $? -eq 0 ]; then status_v2=true; else status_v2=false; fi + + COMPOSE_VERSION="" + + if $status_v2 ; then + COMPOSE_VERSION="V2" + elif $status_v1 ; then + COMPOSE_VERSION="V1" + else + echo "Docker Compose plugin is not detected. Please check your environment." >&2 + exit 1 + fi + + echo $COMPOSE_VERSION + + if $FLAG_SET ; then set -e; fi +} diff --git a/docker/docker-check-log-folders.sh b/docker/docker-check-log-folders.sh new file mode 100644 index 0000000..66e7ea3 --- /dev/null +++ b/docker/docker-check-log-folders.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e +source compose-utils.sh +checkFolders || exit $? +echo "OK" diff --git a/docker/docker-compose.aws-sqs.yml b/docker/docker-compose.aws-sqs.yml new file mode 100644 index 0000000..d6a542a --- /dev/null +++ b/docker/docker-compose.aws-sqs.yml @@ -0,0 +1,61 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-js-executor: + env_file: + - queue-aws-sqs.env + tb-core1: + env_file: + - queue-aws-sqs.env + tb-core2: + env_file: + - queue-aws-sqs.env + tb-rule-engine1: + env_file: + - queue-aws-sqs.env + tb-rule-engine2: + env_file: + - queue-aws-sqs.env + tb-mqtt-transport1: + env_file: + - queue-aws-sqs.env + tb-mqtt-transport2: + env_file: + - queue-aws-sqs.env + tb-http-transport1: + env_file: + - queue-aws-sqs.env + tb-http-transport2: + env_file: + - queue-aws-sqs.env + tb-coap-transport: + env_file: + - queue-aws-sqs.env + tb-lwm2m-transport: + env_file: + - queue-aws-sqs.env + tb-snmp-transport: + env_file: + - queue-aws-sqs.env + tb-vc-executor1: + env_file: + - queue-aws-sqs.env + tb-vc-executor2: + env_file: + - queue-aws-sqs.env diff --git a/docker/docker-compose.cassandra.volumes.yml b/docker/docker-compose.cassandra.volumes.yml new file mode 100644 index 0000000..6efdd84 --- /dev/null +++ b/docker/docker-compose.cassandra.volumes.yml @@ -0,0 +1,27 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + cassandra: + volumes: + - cassandra-volume:/var/lib/cassandra + +volumes: + cassandra-volume: + external: + name: ${CASSANDRA_DATA_VOLUME} diff --git a/docker/docker-compose.confluent.yml b/docker/docker-compose.confluent.yml new file mode 100644 index 0000000..e7eee83 --- /dev/null +++ b/docker/docker-compose.confluent.yml @@ -0,0 +1,61 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-js-executor: + env_file: + - queue-confluent.env + tb-core1: + env_file: + - queue-confluent.env + tb-core2: + env_file: + - queue-confluent.env + tb-rule-engine1: + env_file: + - queue-confluent.env + tb-rule-engine2: + env_file: + - queue-confluent.env + tb-mqtt-transport1: + env_file: + - queue-confluent.env + tb-mqtt-transport2: + env_file: + - queue-confluent.env + tb-http-transport1: + env_file: + - queue-confluent.env + tb-http-transport2: + env_file: + - queue-confluent.env + tb-coap-transport: + env_file: + - queue-confluent.env + tb-lwm2m-transport: + env_file: + - queue-confluent.env + tb-snmp-transport: + env_file: + - queue-confluent.env + tb-vc-executor1: + env_file: + - queue-confluent.env + tb-vc-executor2: + env_file: + - queue-confluent.env diff --git a/docker/docker-compose.hybrid.yml b/docker/docker-compose.hybrid.yml new file mode 100644 index 0000000..2c84b41 --- /dev/null +++ b/docker/docker-compose.hybrid.yml @@ -0,0 +1,60 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + postgres: + restart: always + image: "postgres:12" + ports: + - "5432" + environment: + POSTGRES_DB: thingsboard + POSTGRES_PASSWORD: postgres + volumes: + - ./tb-node/postgres:/var/lib/postgresql/data + cassandra: + restart: always + image: "cassandra:4.0.4" + ports: + - "9042" + volumes: + - ./tb-node/cassandra:/var/lib/cassandra + tb-core1: + env_file: + - tb-node.hybrid.env + depends_on: + - postgres + - cassandra + tb-core2: + env_file: + - tb-node.hybrid.env + depends_on: + - postgres + - cassandra + tb-rule-engine1: + env_file: + - tb-node.hybrid.env + depends_on: + - postgres + - cassandra + tb-rule-engine2: + env_file: + - tb-node.hybrid.env + depends_on: + - postgres + - cassandra diff --git a/docker/docker-compose.kafka.yml b/docker/docker-compose.kafka.yml new file mode 100644 index 0000000..99b06d2 --- /dev/null +++ b/docker/docker-compose.kafka.yml @@ -0,0 +1,98 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + kafka: + restart: always + image: "bitnami/kafka:3.2.0" + ports: + - "9092:9092" + env_file: + - kafka.env + depends_on: + - zookeeper + tb-js-executor: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-core1: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-core2: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-rule-engine1: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-rule-engine2: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-mqtt-transport1: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-mqtt-transport2: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-http-transport1: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-http-transport2: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-coap-transport: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-lwm2m-transport: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-snmp-transport: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-vc-executor1: + env_file: + - queue-kafka.env + depends_on: + - kafka + tb-vc-executor2: + env_file: + - queue-kafka.env + depends_on: + - kafka diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml new file mode 100644 index 0000000..8a57c2b --- /dev/null +++ b/docker/docker-compose.postgres.volumes.yml @@ -0,0 +1,27 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + postgres: + volumes: + - postgres-db-volume:/var/lib/postgresql/data + +volumes: + postgres-db-volume: + external: + name: ${POSTGRES_DATA_VOLUME} diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml new file mode 100644 index 0000000..f9c27c4 --- /dev/null +++ b/docker/docker-compose.postgres.yml @@ -0,0 +1,49 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + postgres: + restart: always + image: "postgres:12" + ports: + - "5432" + environment: + POSTGRES_DB: thingsboard + POSTGRES_PASSWORD: postgres + volumes: + - ./tb-node/postgres:/var/lib/postgresql/data + tb-core1: + env_file: + - tb-node.postgres.env + depends_on: + - postgres + tb-core2: + env_file: + - tb-node.postgres.env + depends_on: + - postgres + tb-rule-engine1: + env_file: + - tb-node.postgres.env + depends_on: + - postgres + tb-rule-engine2: + env_file: + - tb-node.postgres.env + depends_on: + - postgres diff --git a/docker/docker-compose.prometheus-grafana.yml b/docker/docker-compose.prometheus-grafana.yml new file mode 100644 index 0000000..af560fe --- /dev/null +++ b/docker/docker-compose.prometheus-grafana.yml @@ -0,0 +1,47 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +volumes: + prometheus_data: {} + grafana_data: {} + +services: + + prometheus: + image: prom/prometheus:v2.1.0 + volumes: + - ./monitoring/prometheus/:/etc/prometheus/ + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + ports: + - 9090:9090 + restart: always + grafana: + image: grafana/grafana + user: "472" + depends_on: + - prometheus + ports: + - 3000:3000 + volumes: + - grafana_data:/var/lib/grafana + - ./monitoring/grafana/provisioning/:/etc/grafana/provisioning/ + env_file: + - monitoring/grafana/config.monitoring + restart: always \ No newline at end of file diff --git a/docker/docker-compose.pubsub.yml b/docker/docker-compose.pubsub.yml new file mode 100644 index 0000000..8e046b2 --- /dev/null +++ b/docker/docker-compose.pubsub.yml @@ -0,0 +1,61 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-js-executor: + env_file: + - queue-pubsub.env + tb-core1: + env_file: + - queue-pubsub.env + tb-core2: + env_file: + - queue-pubsub.env + tb-rule-engine1: + env_file: + - queue-pubsub.env + tb-rule-engine2: + env_file: + - queue-pubsub.env + tb-mqtt-transport1: + env_file: + - queue-pubsub.env + tb-mqtt-transport2: + env_file: + - queue-pubsub.env + tb-http-transport1: + env_file: + - queue-pubsub.env + tb-http-transport2: + env_file: + - queue-pubsub.env + tb-coap-transport: + env_file: + - queue-pubsub.env + tb-lwm2m-transport: + env_file: + - queue-pubsub.env + tb-snmp-transport: + env_file: + - queue-pubsub.env + tb-vc-executor1: + env_file: + - queue-pubsub.env + tb-vc-executor2: + env_file: + - queue-pubsub.env diff --git a/docker/docker-compose.rabbitmq.yml b/docker/docker-compose.rabbitmq.yml new file mode 100644 index 0000000..fc3b8a7 --- /dev/null +++ b/docker/docker-compose.rabbitmq.yml @@ -0,0 +1,61 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-js-executor: + env_file: + - queue-rabbitmq.env + tb-core1: + env_file: + - queue-rabbitmq.env + tb-core2: + env_file: + - queue-rabbitmq.env + tb-rule-engine1: + env_file: + - queue-rabbitmq.env + tb-rule-engine2: + env_file: + - queue-rabbitmq.env + tb-mqtt-transport1: + env_file: + - queue-rabbitmq.env + tb-mqtt-transport2: + env_file: + - queue-rabbitmq.env + tb-http-transport1: + env_file: + - queue-rabbitmq.env + tb-http-transport2: + env_file: + - queue-rabbitmq.env + tb-coap-transport: + env_file: + - queue-rabbitmq.env + tb-lwm2m-transport: + env_file: + - queue-rabbitmq.env + tb-snmp-transport: + env_file: + - queue-rabbitmq.env + tb-vc-executor1: + env_file: + - queue-rabbitmq.env + tb-vc-executor2: + env_file: + - queue-rabbitmq.env diff --git a/docker/docker-compose.redis-cluster.volumes.yml b/docker/docker-compose.redis-cluster.volumes.yml new file mode 100644 index 0000000..445b790 --- /dev/null +++ b/docker/docker-compose.redis-cluster.volumes.yml @@ -0,0 +1,58 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + # Redis cluster + redis-node-0: + volumes: + - redis-cluster-data-0:/bitnami/redis/data + redis-node-1: + volumes: + - redis-cluster-data-1:/bitnami/redis/data + redis-node-2: + volumes: + - redis-cluster-data-2:/bitnami/redis/data + redis-node-3: + volumes: + - redis-cluster-data-3:/bitnami/redis/data + redis-node-4: + volumes: + - redis-cluster-data-4:/bitnami/redis/data + redis-node-5: + volumes: + - redis-cluster-data-5:/bitnami/redis/data + +volumes: + redis-cluster-data-0: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_0} + redis-cluster-data-1: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_1} + redis-cluster-data-2: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_2} + redis-cluster-data-3: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_3} + redis-cluster-data-4: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_4} + redis-cluster-data-5: + external: + name: ${REDIS_CLUSTER_DATA_VOLUME_5} diff --git a/docker/docker-compose.redis-cluster.yml b/docker/docker-compose.redis-cluster.yml new file mode 100644 index 0000000..766a22b --- /dev/null +++ b/docker/docker-compose.redis-cluster.yml @@ -0,0 +1,156 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: +# Redis cluster + redis-node-0: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-0:/bitnami/redis/data + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + + redis-node-1: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-1:/bitnami/redis/data + depends_on: + - redis-node-0 + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + + redis-node-2: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-2:/bitnami/redis/data + depends_on: + - redis-node-1 + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + + redis-node-3: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-3:/bitnami/redis/data + depends_on: + - redis-node-2 + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + + redis-node-4: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-4:/bitnami/redis/data + depends_on: + - redis-node-3 + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + + redis-node-5: + image: bitnami/redis-cluster:7.0 + volumes: + - ./tb-node/redis-cluster-data-5:/bitnami/redis/data + depends_on: + - redis-node-0 + - redis-node-1 + - redis-node-2 + - redis-node-3 + - redis-node-4 + environment: + - 'REDIS_PASSWORD=thingsboard' + - 'REDISCLI_AUTH=thingsboard' + - 'REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5' + - 'REDIS_CLUSTER_REPLICAS=1' + - 'REDIS_CLUSTER_CREATOR=yes' + +# ThingsBoard setup to use redis-cluster + tb-core1: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-core2: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-rule-engine1: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-rule-engine2: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-mqtt-transport1: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-mqtt-transport2: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-http-transport1: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-http-transport2: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-coap-transport: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-lwm2m-transport: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-snmp-transport: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-vc-executor1: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 + tb-vc-executor2: + env_file: + - cache-redis-cluster.env + depends_on: + - redis-node-5 diff --git a/docker/docker-compose.redis.volumes.yml b/docker/docker-compose.redis.volumes.yml new file mode 100644 index 0000000..fb6fee2 --- /dev/null +++ b/docker/docker-compose.redis.volumes.yml @@ -0,0 +1,27 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + redis: + volumes: + - redis-data:/bitnami/redis/data + +volumes: + redis-data: + external: + name: ${REDIS_DATA_VOLUME} diff --git a/docker/docker-compose.redis.yml b/docker/docker-compose.redis.yml new file mode 100644 index 0000000..c1ec7c1 --- /dev/null +++ b/docker/docker-compose.redis.yml @@ -0,0 +1,97 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: +# Redis standalone + redis: + restart: always + image: bitnami/redis:7.0 + environment: + # ALLOW_EMPTY_PASSWORD is recommended only for development. + ALLOW_EMPTY_PASSWORD: "yes" + ports: + - '6379:6379' + volumes: + - ./tb-node/redis-data:/bitnami/redis/data + +# ThingsBoard setup to use redis-standalone + tb-core1: + env_file: + - cache-redis.env + depends_on: + - redis + tb-core2: + env_file: + - cache-redis.env + depends_on: + - redis + tb-rule-engine1: + env_file: + - cache-redis.env + depends_on: + - redis + tb-rule-engine2: + env_file: + - cache-redis.env + depends_on: + - redis + tb-mqtt-transport1: + env_file: + - cache-redis.env + depends_on: + - redis + tb-mqtt-transport2: + env_file: + - cache-redis.env + depends_on: + - redis + tb-http-transport1: + env_file: + - cache-redis.env + depends_on: + - redis + tb-http-transport2: + env_file: + - cache-redis.env + depends_on: + - redis + tb-coap-transport: + env_file: + - cache-redis.env + depends_on: + - redis + tb-lwm2m-transport: + env_file: + - cache-redis.env + depends_on: + - redis + tb-snmp-transport: + env_file: + - cache-redis.env + depends_on: + - redis + tb-vc-executor1: + env_file: + - cache-redis.env + depends_on: + - redis + tb-vc-executor2: + env_file: + - cache-redis.env + depends_on: + - redis diff --git a/docker/docker-compose.service-bus.yml b/docker/docker-compose.service-bus.yml new file mode 100644 index 0000000..ff099de --- /dev/null +++ b/docker/docker-compose.service-bus.yml @@ -0,0 +1,61 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-js-executor: + env_file: + - queue-service-bus.env + tb-core1: + env_file: + - queue-service-bus.env + tb-core2: + env_file: + - queue-service-bus.env + tb-rule-engine1: + env_file: + - queue-service-bus.env + tb-rule-engine2: + env_file: + - queue-service-bus.env + tb-mqtt-transport1: + env_file: + - queue-service-bus.env + tb-mqtt-transport2: + env_file: + - queue-service-bus.env + tb-http-transport1: + env_file: + - queue-service-bus.env + tb-http-transport2: + env_file: + - queue-service-bus.env + tb-coap-transport: + env_file: + - queue-service-bus.env + tb-lwm2m-transport: + env_file: + - queue-service-bus.env + tb-snmp-transport: + env_file: + - queue-service-bus.env + tb-vc-executor1: + env_file: + - queue-service-bus.env + tb-vc-executor2: + env_file: + - queue-service-bus.env diff --git a/docker/docker-compose.volumes.yml b/docker/docker-compose.volumes.yml new file mode 100644 index 0000000..8a30d9b --- /dev/null +++ b/docker/docker-compose.volumes.yml @@ -0,0 +1,81 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '3.0' + +services: + tb-core1: + volumes: + - tb-log-volume:/var/log/thingsboard + tb-core2: + volumes: + - tb-log-volume:/var/log/thingsboard + tb-rule-engine1: + volumes: + - tb-log-volume:/var/log/thingsboard + tb-rule-engine2: + volumes: + - tb-log-volume:/var/log/thingsboard + tb-coap-transport: + volumes: + - tb-coap-transport-log-volume:/var/log/tb-coap-transport + tb-lwm2m-transport: + volumes: + - tb-lwm2m-transport-log-volume:/var/log/tb-lwm2m-transport + tb-http-transport1: + volumes: + - tb-http-transport-log-volume:/var/log/tb-http-transport + tb-http-transport2: + volumes: + - tb-http-transport-log-volume:/var/log/tb-http-transport + tb-mqtt-transport1: + volumes: + - tb-mqtt-transport-log-volume:/var/log/tb-mqtt-transport + tb-mqtt-transport2: + volumes: + - tb-mqtt-transport-log-volume:/var/log/tb-mqtt-transport + tb-snmp-transport: + volumes: + - tb-snmp-transport-log-volume:/var/log/tb-snmp-transport + tb-vc-executor1: + volumes: + - tb-vc-executor-log-volume:/var/log/tb-vc-executor + tb-vc-executor2: + volumes: + - tb-vc-executor-log-volume:/var/log/tb-vc-executor + +volumes: + tb-log-volume: + external: + name: ${TB_LOG_VOLUME} + tb-coap-transport-log-volume: + external: + name: ${TB_COAP_TRANSPORT_LOG_VOLUME} + tb-lwm2m-transport-log-volume: + external: + name: ${TB_LWM2M_TRANSPORT_LOG_VOLUME} + tb-http-transport-log-volume: + external: + name: ${TB_HTTP_TRANSPORT_LOG_VOLUME} + tb-mqtt-transport-log-volume: + external: + name: ${TB_MQTT_TRANSPORT_LOG_VOLUME} + tb-snmp-transport-log-volume: + external: + name: ${TB_SNMP_TRANSPORT_LOG_VOLUME} + tb-vc-executor-log-volume: + external: + name: ${TB_VC_EXECUTOR_LOG_VOLUME} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..38b2b9b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,328 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +version: '3.0' + +services: + zookeeper: + restart: always + image: "zookeeper:3.8.0" + ports: + - "2181" + environment: + ZOO_MY_ID: 1 + ZOO_SERVERS: server.1=zookeeper:2888:3888;zookeeper:2181 + ZOO_ADMINSERVER_ENABLED: "false" + tb-js-executor: + restart: always + image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + deploy: + replicas: 10 + env_file: + - tb-js-executor.env + tb-core1: + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + - "7070" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" + environment: + TB_SERVICE_ID: tb-core1 + TB_SERVICE_TYPE: tb-core + EDGES_ENABLED: "true" + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-node.env + volumes: + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - zookeeper + - tb-js-executor + - tb-rule-engine1 + - tb-rule-engine2 + tb-core2: + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + - "7070" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" + environment: + TB_SERVICE_ID: tb-core2 + TB_SERVICE_TYPE: tb-core + EDGES_ENABLED: "true" + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-node.env + volumes: + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - zookeeper + - tb-js-executor + - tb-rule-engine1 + - tb-rule-engine2 + tb-rule-engine1: + 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: + TB_SERVICE_ID: tb-rule-engine1 + TB_SERVICE_TYPE: tb-rule-engine + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-node.env + volumes: + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - zookeeper + - tb-js-executor + tb-rule-engine2: + 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: + TB_SERVICE_ID: tb-rule-engine2 + TB_SERVICE_TYPE: tb-rule-engine + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-node.env + volumes: + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - zookeeper + - tb-js-executor + tb-mqtt-transport1: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "1883" + environment: + TB_SERVICE_ID: tb-mqtt-transport1 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-mqtt-transport.env + volumes: + - ./tb-transports/mqtt/conf:/config + - ./tb-transports/mqtt/log:/var/log/tb-mqtt-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-mqtt-transport2: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "1883" + environment: + TB_SERVICE_ID: tb-mqtt-transport2 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-mqtt-transport.env + volumes: + - ./tb-transports/mqtt/conf:/config + - ./tb-transports/mqtt/log:/var/log/tb-mqtt-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-http-transport1: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_SERVICE_ID: tb-http-transport1 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-http-transport.env + volumes: + - ./tb-transports/http/conf:/config + - ./tb-transports/http/log:/var/log/tb-http-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-http-transport2: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_SERVICE_ID: tb-http-transport2 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-http-transport.env + volumes: + - ./tb-transports/http/conf:/config + - ./tb-transports/http/log:/var/log/tb-http-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-coap-transport: + restart: always + image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5683:5683/udp" + environment: + TB_SERVICE_ID: tb-coap-transport + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-coap-transport.env + volumes: + - ./tb-transports/coap/conf:/config + - ./tb-transports/coap/log:/var/log/tb-coap-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-lwm2m-transport: + restart: always + image: "${DOCKER_REPO}/${LWM2M_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5685:5685/udp" + environment: + TB_SERVICE_ID: tb-lwm2m-transport + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-lwm2m-transport.env + volumes: + - ./tb-transports/lwm2m/conf:/config + - ./tb-transports/lwm2m/log:/var/log/tb-lwm2m-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-snmp-transport: + restart: always + image: "${DOCKER_REPO}/${SNMP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + environment: + TB_SERVICE_ID: tb-snmp-transport + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-snmp-transport.env + volumes: + - ./tb-transports/snmp/conf:/config + - ./tb-transports/snmp/log:/var/log/tb-snmp-transport + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + 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 + tb-vc-executor1: + restart: always + image: "${DOCKER_REPO}/${TB_VC_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_SERVICE_ID: tb-vc-executor1 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-vc-executor.env + volumes: + - ./tb-vc-executor/conf:/config + - ./tb-vc-executor/log:/var/log/tb-vc-executor + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + tb-vc-executor2: + restart: always + image: "${DOCKER_REPO}/${TB_VC_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + environment: + TB_SERVICE_ID: tb-vc-executor2 + JAVA_OPTS: "${JAVA_OPTS}" + env_file: + - tb-vc-executor.env + volumes: + - ./tb-vc-executor/conf:/config + - ./tb-vc-executor/log:/var/log/tb-vc-executor + depends_on: + - zookeeper + - tb-core1 + - tb-core2 + haproxy: + restart: always + container_name: "${LOAD_BALANCER_NAME}" + image: thingsboard/haproxy-certbot:1.3.0 + volumes: + - ./haproxy/config:/config + - ./haproxy/letsencrypt:/etc/letsencrypt + - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d + ports: + - "80:80" + - "443:443" + - "1883:1883" + - "7070:7070" + - "9999:9999" + cap_add: + - NET_ADMIN + environment: + HTTP_PORT: 80 + HTTPS_PORT: 443 + MQTT_PORT: 1883 + EDGES_RPC_PORT: 7070 + FORCE_HTTPS_REDIRECT: "false" + links: + - tb-core1 + - tb-core2 + - tb-web-ui1 + - tb-web-ui2 + - tb-mqtt-transport1 + - tb-mqtt-transport2 + - tb-http-transport1 + - tb-http-transport2 diff --git a/docker/docker-create-log-folders.sh b/docker/docker-create-log-folders.sh new file mode 100644 index 0000000..54a74f4 --- /dev/null +++ b/docker/docker-create-log-folders.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e +source compose-utils.sh +checkFolders --create diff --git a/docker/docker-install-tb.sh b/docker/docker-install-tb.sh new file mode 100644 index 0000000..eeab701 --- /dev/null +++ b/docker/docker-install-tb.sh @@ -0,0 +1,92 @@ +#!bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +checkFolders --create || exit $? + +if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then + + COMPOSE_ARGS="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + up -d ${ADDITIONAL_STARTUP_SERVICES}" + + case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS + ;; + V1) + docker-compose $COMPOSE_ARGS + ;; + *) + # unknown option + ;; + esac +fi + +COMPOSE_ARGS="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} \ + tb-core1" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS + ;; + V1) + docker-compose $COMPOSE_ARGS + ;; + *) + # unknown option + ;; +esac + + diff --git a/docker/docker-remove-services.sh b/docker/docker-remove-services.sh new file mode 100644 index 0000000..1e2a55f --- /dev/null +++ b/docker/docker-remove-services.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? + +COMPOSE_ARGS="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + down -v" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS + ;; + V1) + docker-compose $COMPOSE_ARGS + ;; + *) + # unknown option + ;; +esac diff --git a/docker/docker-start-services.sh b/docker/docker-start-services.sh new file mode 100644 index 0000000..3603382 --- /dev/null +++ b/docker/docker-start-services.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? + +checkFolders --create || exit $? + +COMPOSE_ARGS="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + up -d" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS + ;; + V1) + docker-compose --compatibility $COMPOSE_ARGS + ;; + *) + # unknown option + ;; +esac diff --git a/docker/docker-stop-services.sh b/docker/docker-stop-services.sh new file mode 100644 index 0000000..4edd755 --- /dev/null +++ b/docker/docker-stop-services.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +ADDITIONAL_COMPOSE_MONITORING_ARGS=$(additionalComposeMonitoringArgs) || exit $? + +COMPOSE_ARGS="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} ${ADDITIONAL_COMPOSE_MONITORING_ARGS} \ + stop" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS + ;; + V1) + docker-compose $COMPOSE_ARGS + ;; + *) + # unknown option + ;; +esac diff --git a/docker/docker-update-service.sh b/docker/docker-update-service.sh new file mode 100644 index 0000000..fe2e2b3 --- /dev/null +++ b/docker/docker-update-service.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +COMPOSE_ARGS_PULL="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + pull" + +COMPOSE_ARGS_BUILD="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + up -d --no-deps --build" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS_PULL $@ + docker compose $COMPOSE_ARGS_BUILD $@ + ;; + V1) + docker-compose $COMPOSE_ARGS_PULL $@ + docker-compose $COMPOSE_ARGS_BUILD $@ + ;; + *) + # unknown option + ;; +esac diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh new file mode 100644 index 0000000..18ce476 --- /dev/null +++ b/docker/docker-upgrade-tb.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +for i in "$@" +do +case $i in + --fromVersion=*) + FROM_VERSION="${i#*=}" + shift + ;; + *) + # unknown option + ;; +esac +done + +if [[ -z "${FROM_VERSION// }" ]]; then + echo "--fromVersion parameter is invalid or unspecified!" + echo "Usage: docker-upgrade-tb.sh --fromVersion={VERSION}" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +set -e + +source compose-utils.sh + +COMPOSE_VERSION=$(composeVersion) || exit $? + +ADDITIONAL_COMPOSE_QUEUE_ARGS=$(additionalComposeQueueArgs) || exit $? + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_CACHE_ARGS=$(additionalComposeCacheArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +checkFolders --create || exit $? + +COMPOSE_ARGS_PULL="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + pull \ + tb-core1" + +COMPOSE_ARGS_UP="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + up -d ${ADDITIONAL_STARTUP_SERVICES}" + +COMPOSE_ARGS_RUN="\ + -f docker-compose.yml ${ADDITIONAL_CACHE_ARGS} ${ADDITIONAL_COMPOSE_ARGS} ${ADDITIONAL_COMPOSE_QUEUE_ARGS} \ + run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} \ + tb-core1" + +case $COMPOSE_VERSION in + V2) + docker compose $COMPOSE_ARGS_PULL + docker compose $COMPOSE_ARGS_UP + docker compose $COMPOSE_ARGS_RUN + ;; + V1) + docker-compose $COMPOSE_ARGS_PULL + docker-compose $COMPOSE_ARGS_UP + docker-compose $COMPOSE_ARGS_RUN + ;; + *) + # unknown option + ;; +esac diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg new file mode 100644 index 0000000..aa75250 --- /dev/null +++ b/docker/haproxy/config/haproxy.cfg @@ -0,0 +1,121 @@ +#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 + +listen edges-rpc-in + bind *:${EDGES_RPC_PORT} + mode tcp + option clitcpka # For TCP keep-alive + timeout client 3h + timeout server 3h + option tcplog + balance leastconn + server tbEdgesRpc1 tb-core1:7070 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbEdgesRpc2 tb-core2:7070 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + +frontend http-in + bind *:${HTTP_PORT} alpn h2,http/1.1 + + option forwardfor + + http-request add-header "X-Forwarded-Proto" "http" + + acl transport_http_acl path_beg /api/v1/ + acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + + 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 + use_backend tb-api-backend if tb_api_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 alpn h2,http/1.1 + + option forwardfor + + http-request add-header "X-Forwarded-Proto" "https" + + acl transport_http_acl path_beg /api/v1/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + + use_backend tb-http-backend if transport_http_acl + use_backend tb-api-backend if tb_api_acl + + default_backend tb-web-backend + +backend letsencrypt_http + server letsencrypt_http_srv 127.0.0.1:8080 + +backend tb-web-backend + balance leastconn + option tcp-check + option log-health-checks + server tbWeb1 tb-web-ui1:8080 check 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 source + option tcp-check + option log-health-checks + server tbApi1 tb-core1:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbApi2 tb-core2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + http-request set-header X-Forwarded-Port %[dst_port] diff --git a/docker/kafka.env b/docker/kafka.env new file mode 100644 index 0000000..9c28885 --- /dev/null +++ b/docker/kafka.env @@ -0,0 +1,11 @@ +ALLOW_PLAINTEXT_LISTENER=yes +KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 +KAFKA_CFG_LISTENERS=INSIDE://:9093,OUTSIDE://:9092 +KAFKA_CFG_ADVERTISED_LISTENERS=INSIDE://:9093,OUTSIDE://kafka:9092 +KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT +KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=false +KAFKA_CFG_INTER_BROKER_LISTENER_NAME=INSIDE +KAFKA_CFG_LOG_RETENTION_BYTES=1073741824 +KAFKA_CFG_SEGMENT_BYTES=268435456 +KAFKA_CFG_LOG_RETENTION_MS=300000 +KAFKA_CFG_LOG_CLEANUP_POLICY=delete diff --git a/docker/monitoring/grafana/config.monitoring b/docker/monitoring/grafana/config.monitoring new file mode 100644 index 0000000..f12466b --- /dev/null +++ b/docker/monitoring/grafana/config.monitoring @@ -0,0 +1,2 @@ +GF_SECURITY_ADMIN_PASSWORD=foobar +GF_USERS_ALLOW_SIGN_UP=false diff --git a/docker/monitoring/grafana/provisioning/dashboards/attributes_cache.json b/docker/monitoring/grafana/provisioning/dashboards/attributes_cache.json new file mode 100644 index 0000000..2e21b9c --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/attributes_cache.json @@ -0,0 +1,330 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "increase(attributes_cache_total[1m])", + "interval": "", + "legendFormat": "{{job}} {{result}}", + "queryType": "randomWalk", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Cache Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum (increase(attributes_cache_total{result=\"hit\"}[1m]))", + "interval": "", + "legendFormat": "Hits", + "queryType": "randomWalk", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum (increase(attributes_cache_total{result=\"miss\"}[1m]))", + "hide": false, + "interval": "", + "legendFormat": "Misses", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Summarized Cache Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "100*\n(\n sum(increase(attributes_cache_total{result=\"hit\"}[1m])) / \n ( \n sum(increase(attributes_cache_total{result=\"hit\"}[1m]))\n + sum(increase(attributes_cache_total{result=\"miss\"}[1m]))\n )\n)", + "interval": "", + "legendFormat": "Hit Ratio, %", + "queryType": "randomWalk", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Attributes Cache Hit Ratio %", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": false, + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Attributes Cache", + "uid": "dxj2OYTMk", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json new file mode 100644 index 0000000..e04a5c2 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json @@ -0,0 +1,335 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 4, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(core_producer_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Core Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(core_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Core Starts", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 10 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(jsInvoke_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "JsInvoke Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Core and JS Metrics", + "uid": "lewbrlwjerwkj2", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/dashboard.yml b/docker/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..9387c50 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,27 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: 1 + +providers: +- name: 'Prometheus' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/docker/monitoring/grafana/provisioning/dashboards/db_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/db_metrics.json new file mode 100644 index 0000000..f5983cf --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/db_metrics.json @@ -0,0 +1,406 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 7, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_0_total[1m]))", + "interval": "", + "legendFormat": "queue-0 {{statsName}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_1_total[1m]))", + "interval": "", + "legendFormat": "queue-1 {{statsName}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_2_total[1m]))", + "interval": "", + "legendFormat": "queue-2 {{statsName}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (rate(attributes_queue_3_total[1m]))", + "interval": "", + "legendFormat": "queue-3 {{statsName}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Attributes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 11 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_queue_0_total[1m]))", + "interval": "", + "legendFormat": "queue-0 {{statsName}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_queue_1_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-1 {{statsName}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_queue_2_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-2 {{statsName}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_queue_3_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-3 {{statsName}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 11 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_0_total[1m]))", + "interval": "", + "legendFormat": "queue-0 {{statsName}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_1_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-1 {{statsName}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_2_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-2 {{statsName}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_3_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-3 {{statsName}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TS Latest", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "DB Metrics", + "uid": "lewbrlsssswjerwkj", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/hybrid_db_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/hybrid_db_metrics.json new file mode 100644 index 0000000..ed6bd41 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/hybrid_db_metrics.json @@ -0,0 +1,474 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 8, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_0_total[1m]))", + "interval": "", + "legendFormat": "queue-0 {{statsName}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_1_total[1m]))", + "interval": "", + "legendFormat": "queue-1 {{statsName}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(attributes_queue_2_total[1m]))", + "interval": "", + "legendFormat": "queue-2 {{statsName}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (rate(attributes_queue_3_total[1m]))", + "interval": "", + "legendFormat": "queue-3 {{statsName}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Attributes", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 20, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum (rateExecutor_currBuffer)", + "interval": "", + "legendFormat": "current buffer", + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Cassandra Current Buffer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 18, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by (statsName) (increase(rateExecutor_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TS", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_0_total[1m]))", + "interval": "", + "legendFormat": "queue-0 {{statsName}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_1_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-1 {{statsName}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_2_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-2 {{statsName}}", + "refId": "C" + }, + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ts_latest_queue_3_total[1m]))", + "hide": false, + "interval": "", + "legendFormat": "queue-3 {{statsName}}", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TS Latest", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Hybrid DB Metrics", + "uid": "lewbrlsssswje", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/rule_engine_latency.json b/docker/monitoring/grafana/provisioning/dashboards/rule_engine_latency.json new file mode 100644 index 0000000..1821eeb --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/rule_engine_latency.json @@ -0,0 +1,386 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 5, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by (job,quantile) (ruleEngine_Main_seconds)*1000", + "interval": "", + "legendFormat": "Main {{job}} Quantile - {{quantile}}, ms", + "refId": "B" + }, + { + "exemplar": true, + "expr": "sum by (job,quantile) (ruleEngine_HighPriority_seconds)*1000", + "hide": false, + "interval": "", + "legendFormat": "HighPriority {{job}} Quantile - {{quantile}}, ms", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum by (job,quantile) (ruleEngine_SequentialByOriginator_seconds)*1000", + "hide": false, + "interval": "", + "legendFormat": "SequentialByOriginator {{job}} Quantile - {{quantile}}, ms", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Quantiles Latency, ms", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "ruleEngine_Main_seconds_max *1000", + "interval": "", + "legendFormat": "Max - {{job}}, ms", + "refId": "A" + }, + { + "exemplar": true, + "expr": "ruleEngine_HighPriority_seconds_max *1000", + "hide": false, + "interval": "", + "legendFormat": "HighPriority - {{job}}, ms", + "refId": "B" + }, + { + "exemplar": true, + "expr": "ruleEngine_SequentialByOriginator_seconds_max *1000", + "hide": false, + "interval": "", + "legendFormat": "SequentialByOriginator - {{job}}, ms", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Max Latency, ms", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 9 + }, + "hiddenSeries": false, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(increase(ruleEngine_Main_seconds_sum[1m]) / increase(ruleEngine_Main_seconds_count[1m])) * 1000", + "interval": "", + "legendFormat": "Main {{job}}", + "refId": "A" + }, + { + "exemplar": true, + "expr": "(increase(ruleEngine_HighPriority_seconds_sum[1m]) / increase(ruleEngine_HighPriority_seconds_count[1m])) * 1000", + "hide": false, + "interval": "", + "legendFormat": "HighPriority {{job}}", + "refId": "B" + }, + { + "exemplar": true, + "expr": "(increase(ruleEngine_SequentialByOriginator_seconds_sum[1m]) / increase(ruleEngine_SequentialByOriginator_seconds_count[1m])) * 1000", + "hide": false, + "interval": "", + "legendFormat": "SequentialByOriginator {{job}}", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Average by 1m", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "10s", + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Rule Engine Latency", + "uid": "-qNMB1SGz", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/rule_engine_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/rule_engine_metrics.json new file mode 100644 index 0000000..9fc1ba2 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/rule_engine_metrics.json @@ -0,0 +1,338 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 3, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_Main_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[Main] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_HighPriority_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[HighPriority] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_SequentialByOriginator_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[SequentialByOriginator] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": false, + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Rule Engine Metrics", + "uid": "lewbrlwjerwkj1", + "version": 2 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/dashboards/single_service_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/single_service_metrics.json new file mode 100644 index 0000000..3236592 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/single_service_metrics.json @@ -0,0 +1,992 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 6, + "iteration": 1619603301193, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(core_producer_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Core Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(transport_producer_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Transport Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(ruleEngine_producer_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Rule Engine Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "hiddenSeries": false, + "id": 8, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(core_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Core Starts", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(transport_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Transport Consumer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "hiddenSeries": false, + "id": 15, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_Main_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[Main] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_HighPriority_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[HighPriority] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "hiddenSeries": false, + "id": 6, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName)(increase(ruleEngine_SequentialByOriginator_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "[SequentialByOriginator] Rule Engine Queue Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 32 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(jsInvoke_total{job=\"$job\"}[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "JsInvoke Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": false, + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": { + "selected": true, + "text": "tb-mqtt-transport1", + "value": "tb-mqtt-transport1" + }, + "description": null, + "error": null, + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "job", + "options": [ + { + "selected": false, + "text": "tb-core1", + "value": "tb-core1" + }, + { + "selected": false, + "text": "tb-core2", + "value": "tb-core2" + }, + { + "selected": false, + "text": "tb-rule-engine1", + "value": "tb-rule-engine1" + }, + { + "selected": false, + "text": "tb-rule-engine2", + "value": "tb-rule-engine2" + }, + { + "selected": true, + "text": "tb-mqtt-transport1", + "value": "tb-mqtt-transport1" + }, + { + "selected": false, + "text": "tb-mqtt-transport2", + "value": "tb-mqtt-transport2" + }, + { + "selected": false, + "text": "tb-http-transport1", + "value": "tb-http-transport1" + }, + { + "selected": false, + "text": "tb-http-transport2", + "value": "tb-http-transport2" + }, + { + "selected": false, + "text": "tb-coap-transport", + "value": "tb-coap-transport" + }, + { + "selected": false, + "text": "tb-lwm2m-transport", + "value": "tb-lwm2m-transport" + }, + { + "selected": false, + "text": "tb-snmp-transport", + "value": "tb-snmp-transport" + } + ], + "query": "tb-core1,tb-core2,tb-rule-engine1,tb-rule-engine2,tb-mqtt-transport1,tb-mqtt-transport2,tb-http-transport1,tb-http-transport2,tb-coap-transport,tb-lwm2m-transport,tb-snmp-transport", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Single Service Metrics", + "uid": "lewbrddddlwjerwkj", + "version": 1 +} diff --git a/docker/monitoring/grafana/provisioning/dashboards/transport_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/transport_metrics.json new file mode 100644 index 0000000..3027912 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/transport_metrics.json @@ -0,0 +1,532 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 19, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum (increase(transport_producer_total{statsName=\"totalMsgs\"}[1m]))", + "interval": "", + "legendFormat": "producer", + "refId": "A" + }, + { + "exemplar": true, + "expr": "sum (increase(transport_total{statsName=\"totalMsgs\"}[1m]))", + "hide": false, + "interval": "", + "legendFormat": "consumer", + "refId": "B" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Transport Producer/Consumer Comparison", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(increase(transport_total[1m]))", + "interval": "", + "legendFormat": "{{job}} - {{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Transport Consumer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "hiddenSeries": false, + "id": 17, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(increase(transport_producer_total[1m]))", + "interval": "", + "legendFormat": "{{job}} - {{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Transport Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "hiddenSeries": false, + "id": 16, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(increase(core_producer_total[1m]))", + "interval": "", + "legendFormat": "{{job}} - {{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Core Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 18 + }, + "hiddenSeries": false, + "id": 14, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(increase(ruleEngine_producer_total[1m]))", + "interval": "", + "legendFormat": "{{job}} - {{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Rule Engine Producer", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Transport Metrics", + "uid": "lewbrlwjerwkj", + "version": 4 +} \ No newline at end of file diff --git a/docker/monitoring/grafana/provisioning/datasources/datasource.yml b/docker/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..3f8db03 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,66 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# config file version +apiVersion: 1 + +# list of datasources that should be deleted from the database +deleteDatasources: + - name: Prometheus + orgId: 1 + +# list of datasources to insert/update depending +# whats available in the database +datasources: + # name of the datasource. Required +- name: Prometheus + # datasource type. Required + type: prometheus + # access mode. direct or proxy. Required + access: proxy + # org id. will default to orgId 1 if not specified + orgId: 1 + # url + url: http://prometheus:9090 + # database password, if used + password: + # database user, if used + user: + # database name, if used + database: + # enable/disable basic auth + basicAuth: false + # basic auth username, if used + basicAuthUser: + # basic auth password, if used + basicAuthPassword: + # enable/disable with credentials headers + withCredentials: + # mark as default datasource. Max one per org + isDefault: true + # fields that will be converted to json and stored in json_data + jsonData: + graphiteVersion: "1.1" + tlsAuth: false + tlsAuthWithCACert: false + # json object of data that will be encrypted. + secureJsonData: + tlsCACert: "..." + tlsClientCert: "..." + tlsClientKey: "..." + version: 1 + # allow users to edit datasources from the UI. + editable: true diff --git a/docker/monitoring/prometheus/prometheus.yml b/docker/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..9b5e684 --- /dev/null +++ b/docker/monitoring/prometheus/prometheus.yml @@ -0,0 +1,92 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# my global config +global: + scrape_interval: 15s # By default, scrape targets every 15 seconds. + evaluation_interval: 15s # By default, scrape targets every 15 seconds. + # scrape_timeout is set to the global default (10s). + + # Attach these labels to any time series or alerts when communicating with + # external systems (federation, remote storage, Alertmanager). + external_labels: + monitor: 'thingsboard' + + +# A scrape configuration containing exactly one endpoint to scrape: +# Here it's Prometheus itself. +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + + - job_name: 'prometheus' + scrape_interval: 5s + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'tb-core1' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-core1:8080' ] + + - job_name: 'tb-core2' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-core2:8080' ] + + - job_name: 'tb-rule-engine1' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-rule-engine1:8080' ] + + - job_name: 'tb-rule-engine2' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-rule-engine2:8080' ] + + - job_name: 'tb-mqtt-transport1' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-mqtt-transport1:8081' ] + + - job_name: 'tb-mqtt-transport2' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-mqtt-transport2:8081' ] + + - job_name: 'tb-http-transport1' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-http-transport1:8081' ] + + - job_name: 'tb-http-transport2' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-http-transport2:8081' ] + + - job_name: 'tb-coap-transport' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-coap-transport:8081' ] + + - job_name: 'tb-lwm2m-transport' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-lwm2m-transport:8081' ] + + - job_name: 'tb-snmp-transport' + metrics_path: /actuator/prometheus + static_configs: + - targets: [ 'tb-snmp-transport:8081' ] diff --git a/docker/queue-aws-sqs.env b/docker/queue-aws-sqs.env new file mode 100644 index 0000000..1cb9fd6 --- /dev/null +++ b/docker/queue-aws-sqs.env @@ -0,0 +1,4 @@ +TB_QUEUE_TYPE=aws-sqs +TB_QUEUE_AWS_SQS_ACCESS_KEY_ID=YOUR_KEY +TB_QUEUE_AWS_SQS_SECRET_ACCESS_KEY=YOUR_SECRET +TB_QUEUE_AWS_SQS_REGION=YOUR_REGION diff --git a/docker/queue-confluent.env b/docker/queue-confluent.env new file mode 100644 index 0000000..868a135 --- /dev/null +++ b/docker/queue-confluent.env @@ -0,0 +1,18 @@ +TB_QUEUE_TYPE=kafka + +TB_KAFKA_SERVERS=confluent.cloud:9092 +TB_QUEUE_KAFKA_REPLICATION_FACTOR=3 + +TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD=true +TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM=https +TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM=PLAIN +TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG=org.apache.kafka.common.security.plain.PlainLoginModule required username="CLUSTER_API_KEY" password="CLUSTER_API_SECRET"; +TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL=SASL_SSL +TB_QUEUE_KAFKA_CONFLUENT_USERNAME=CLUSTER_API_KEY +TB_QUEUE_KAFKA_CONFLUENT_PASSWORD=CLUSTER_API_SECRET + +TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 +TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 +TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 +TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 +TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600 diff --git a/docker/queue-kafka.env b/docker/queue-kafka.env new file mode 100644 index 0000000..6310794 --- /dev/null +++ b/docker/queue-kafka.env @@ -0,0 +1,2 @@ +TB_QUEUE_TYPE=kafka +TB_KAFKA_SERVERS=kafka:9092 diff --git a/docker/queue-pubsub.env b/docker/queue-pubsub.env new file mode 100644 index 0000000..5b4aeba --- /dev/null +++ b/docker/queue-pubsub.env @@ -0,0 +1,3 @@ +TB_QUEUE_TYPE=pubsub +TB_QUEUE_PUBSUB_PROJECT_ID=YOUR_PROJECT_ID +TB_QUEUE_PUBSUB_SERVICE_ACCOUNT=YOUR_SERVICE_ACCOUNT diff --git a/docker/queue-rabbitmq.env b/docker/queue-rabbitmq.env new file mode 100644 index 0000000..7c355a4 --- /dev/null +++ b/docker/queue-rabbitmq.env @@ -0,0 +1,5 @@ +TB_QUEUE_TYPE=rabbitmq +TB_QUEUE_RABBIT_MQ_HOST=localhost +TB_QUEUE_RABBIT_MQ_PORT=5672 +TB_QUEUE_RABBIT_MQ_USERNAME=YOUR_USERNAME +TB_QUEUE_RABBIT_MQ_PASSWORD=YOUR_PASSWORD \ No newline at end of file diff --git a/docker/queue-service-bus.env b/docker/queue-service-bus.env new file mode 100644 index 0000000..f54ce7e --- /dev/null +++ b/docker/queue-service-bus.env @@ -0,0 +1,4 @@ +TB_QUEUE_TYPE=service-bus +TB_QUEUE_SERVICE_BUS_NAMESPACE_NAME=YOUR_NAMESPACE_NAME +TB_QUEUE_SERVICE_BUS_SAS_KEY_NAME=YOUR_SAS_KEY_NAME +TB_QUEUE_SERVICE_BUS_SAS_KEY=YOUR_SAS_KEY diff --git a/docker/tb-coap-transport.env b/docker/tb-coap-transport.env new file mode 100644 index 0000000..079443c --- /dev/null +++ b/docker/tb-coap-transport.env @@ -0,0 +1,12 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 + +COAP_BIND_ADDRESS=0.0.0.0 +COAP_BIND_PORT=5683 +COAP_TIMEOUT=10000 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus +WEB_APPLICATION_ENABLE=true +WEB_APPLICATION_TYPE=servlet +HTTP_BIND_PORT=8081 diff --git a/docker/tb-http-transport.env b/docker/tb-http-transport.env new file mode 100644 index 0000000..7e06799 --- /dev/null +++ b/docker/tb-http-transport.env @@ -0,0 +1,9 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8081 +HTTP_REQUEST_TIMEOUT=60000 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus diff --git a/docker/tb-js-executor.env b/docker/tb-js-executor.env new file mode 100644 index 0000000..1938449 --- /dev/null +++ b/docker/tb-js-executor.env @@ -0,0 +1,7 @@ +REMOTE_JS_EVAL_REQUEST_TOPIC=js_eval.requests +LOGGER_LEVEL=info +LOG_FOLDER=logs +LOGGER_FILENAME=tb-js-executor-%DATE%.log +DOCKER_MODE=true +SCRIPT_BODY_TRACE_FREQUENCY=1000 +NODE_OPTIONS="--max-old-space-size=200" diff --git a/docker/tb-lwm2m-transport.env b/docker/tb-lwm2m-transport.env new file mode 100644 index 0000000..f284803 --- /dev/null +++ b/docker/tb-lwm2m-transport.env @@ -0,0 +1,12 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 + +LWM2M_BIND_ADDRESS=0.0.0.0 +LWM2M_BIND_PORT=5685 +LWM2M_TIMEOUT=10000 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus +WEB_APPLICATION_ENABLE=true +WEB_APPLICATION_TYPE=servlet +HTTP_BIND_PORT=8081 diff --git a/docker/tb-mqtt-transport.env b/docker/tb-mqtt-transport.env new file mode 100644 index 0000000..e38cb21 --- /dev/null +++ b/docker/tb-mqtt-transport.env @@ -0,0 +1,12 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 + +MQTT_BIND_ADDRESS=0.0.0.0 +MQTT_BIND_PORT=1883 +MQTT_TIMEOUT=10000 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus +WEB_APPLICATION_ENABLE=true +WEB_APPLICATION_TYPE=servlet +HTTP_BIND_PORT=8081 diff --git a/docker/tb-node.env b/docker/tb-node.env new file mode 100644 index 0000000..85a60eb --- /dev/null +++ b/docker/tb-node.env @@ -0,0 +1,11 @@ +# ThingsBoard server configuration + +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 +JS_EVALUATOR=remote +TRANSPORT_TYPE=remote + +HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE=false + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus diff --git a/docker/tb-node.hybrid.env b/docker/tb-node.hybrid.env new file mode 100644 index 0000000..e03239b --- /dev/null +++ b/docker/tb-node.hybrid.env @@ -0,0 +1,8 @@ +# ThingsBoard server configuration for Cassandra database + +DATABASE_TS_TYPE=cassandra +CASSANDRA_URL=cassandra:9042 +SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard +SPRING_DATASOURCE_USERNAME=postgres +SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb-node.postgres.env b/docker/tb-node.postgres.env new file mode 100644 index 0000000..633b8b6 --- /dev/null +++ b/docker/tb-node.postgres.env @@ -0,0 +1,7 @@ +# ThingsBoard server configuration for PostgreSQL database + +DATABASE_TS_TYPE=sql +SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard +SPRING_DATASOURCE_USERNAME=postgres +SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml new file mode 100644 index 0000000..c17e8a4 --- /dev/null +++ b/docker/tb-node/conf/logback.xml @@ -0,0 +1,77 @@ + + + + + + + /var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.log + + /var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker/tb-node/conf/thingsboard.conf b/docker/tb-node/conf/thingsboard.conf new file mode 100644 index 0000000..ead283d --- /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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/thingsboard/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/thingsboard/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=thingsboard.out +export LOADER_PATH=/usr/share/thingsboard/conf,/usr/share/thingsboard/extensions diff --git a/docker/tb-snmp-transport.env b/docker/tb-snmp-transport.env new file mode 100644 index 0000000..e2cc39d --- /dev/null +++ b/docker/tb-snmp-transport.env @@ -0,0 +1,8 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 + +METRICS_ENABLED=true +METRICS_ENDPOINTS_EXPOSE=prometheus +WEB_APPLICATION_ENABLE=true +WEB_APPLICATION_TYPE=servlet +HTTP_BIND_PORT=8081 diff --git a/docker/tb-transports/coap/conf/logback.xml b/docker/tb-transports/coap/conf/logback.xml new file mode 100644 index 0000000..65d13a5 --- /dev/null +++ b/docker/tb-transports/coap/conf/logback.xml @@ -0,0 +1,55 @@ + + + + + + + /var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.log + + /var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/tb-transports/coap/conf/tb-coap-transport.conf b/docker/tb-transports/coap/conf/tb-coap-transport.conf new file mode 100644 index 0000000..51fe4a8 --- /dev/null +++ b/docker/tb-transports/coap/conf/tb-coap-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-coap-transport/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-coap-transport/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-coap-transport.out +export LOADER_PATH=/usr/share/tb-coap-transport/conf diff --git a/docker/tb-transports/http/conf/logback.xml b/docker/tb-transports/http/conf/logback.xml new file mode 100644 index 0000000..a3ae374 --- /dev/null +++ b/docker/tb-transports/http/conf/logback.xml @@ -0,0 +1,55 @@ + + + + + + + /var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.log + + /var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/tb-transports/http/conf/tb-http-transport.conf b/docker/tb-transports/http/conf/tb-http-transport.conf new file mode 100644 index 0000000..5958b8b --- /dev/null +++ b/docker/tb-transports/http/conf/tb-http-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-http-transport/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-http-transport/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-http-transport.out +export LOADER_PATH=/usr/share/tb-http-transport/conf diff --git a/docker/tb-transports/lwm2m/conf/logback.xml b/docker/tb-transports/lwm2m/conf/logback.xml new file mode 100644 index 0000000..5546167 --- /dev/null +++ b/docker/tb-transports/lwm2m/conf/logback.xml @@ -0,0 +1,54 @@ + + + + + + + /var/log/tb-lwm2m-transport/${TB_SERVICE_ID}/tb-lwm2m-transport.log + + /var/log/tb-lwm2m-transport/${TB_SERVICE_ID}/tb-lwm2m-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + diff --git a/docker/tb-transports/lwm2m/conf/tb-lwm2m-transport.conf b/docker/tb-transports/lwm2m/conf/tb-lwm2m-transport.conf new file mode 100644 index 0000000..9b65a45 --- /dev/null +++ b/docker/tb-transports/lwm2m/conf/tb-lwm2m-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-lwm2m-transport/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-lwm2m-transport/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-lwm2m-transport.out +export LOADER_PATH=/usr/share/tb-lwm2m-transport/conf diff --git a/docker/tb-transports/mqtt/conf/logback.xml b/docker/tb-transports/mqtt/conf/logback.xml new file mode 100644 index 0000000..d4e7b81 --- /dev/null +++ b/docker/tb-transports/mqtt/conf/logback.xml @@ -0,0 +1,54 @@ + + + + + + + /var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.log + + /var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/tb-transports/mqtt/conf/tb-mqtt-transport.conf b/docker/tb-transports/mqtt/conf/tb-mqtt-transport.conf new file mode 100644 index 0000000..0d7e2dc --- /dev/null +++ b/docker/tb-transports/mqtt/conf/tb-mqtt-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-mqtt-transport/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-mqtt-transport/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-mqtt-transport.out +export LOADER_PATH=/usr/share/tb-mqtt-transport/conf diff --git a/docker/tb-transports/snmp/conf/logback.xml b/docker/tb-transports/snmp/conf/logback.xml new file mode 100644 index 0000000..0b6fbc7 --- /dev/null +++ b/docker/tb-transports/snmp/conf/logback.xml @@ -0,0 +1,54 @@ + + + + + + + /var/log/tb-snmp-transport/${TB_SERVICE_ID}/tb-snmp-transport.log + + /var/log/tb-snmp-transport/${TB_SERVICE_ID}/tb-snmp-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/tb-transports/snmp/conf/tb-snmp-transport.conf b/docker/tb-transports/snmp/conf/tb-snmp-transport.conf new file mode 100644 index 0000000..8348d6f --- /dev/null +++ b/docker/tb-transports/snmp/conf/tb-snmp-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-snmp-transport/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-snmp-transport/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-snmp-transport.out +export LOADER_PATH=/usr/share/tb-snmp-transport/conf diff --git a/docker/tb-vc-executor.env b/docker/tb-vc-executor.env new file mode 100644 index 0000000..f92e30b --- /dev/null +++ b/docker/tb-vc-executor.env @@ -0,0 +1,2 @@ +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 diff --git a/docker/tb-vc-executor/conf/logback.xml b/docker/tb-vc-executor/conf/logback.xml new file mode 100644 index 0000000..ebde7cb --- /dev/null +++ b/docker/tb-vc-executor/conf/logback.xml @@ -0,0 +1,54 @@ + + + + + + + /var/log/tb-vc-executor/${TB_SERVICE_ID}/tb-vc-executor.log + + /var/log/tb-vc-executor/${TB_SERVICE_ID}/tb-vc-executor.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + diff --git a/docker/tb-vc-executor/conf/tb-vc-executor.conf b/docker/tb-vc-executor/conf/tb-vc-executor.conf new file mode 100644 index 0000000..f140e3f --- /dev/null +++ b/docker/tb-vc-executor/conf/tb-vc-executor.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 -Xlog:gc*,heap*,age*,safepoint=debug:file=/var/log/tb-vc-executor/${TB_SERVICE_ID}-gc.log:time,uptime,level,tags:filecount=10,filesize=10M" +export JAVA_OPTS="$JAVA_OPTS -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-vc-executor/${TB_SERVICE_ID}-heapdump.bin" +export JAVA_OPTS="$JAVA_OPTS -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" +export JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled -XX:MaxTenuringThreshold=10" +export JAVA_OPTS="$JAVA_OPTS -XX:+ExitOnOutOfMemoryError" +export LOG_FILENAME=tb-vc-executor.out +export LOADER_PATH=/usr/share/tb-vc-executor/conf diff --git a/docker/tb-web-ui.env b/docker/tb-web-ui.env new file mode 100644 index 0000000..3916337 --- /dev/null +++ b/docker/tb-web-ui.env @@ -0,0 +1,8 @@ + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8080 +TB_ENABLE_PROXY=false +LOGGER_LEVEL=info +LOG_FOLDER=logs +LOGGER_FILENAME=tb-web-ui-%DATE%.log +DOCKER_MODE=true \ No newline at end of file diff --git a/img/logo.png b/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0f3b57bc6b939f6bac5409b0db48da92055892e5 GIT binary patch literal 6257 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 bFU0=?nJ3lqs&kJU00000NkvXXu0mjfg?Ie| literal 0 HcmV?d00001 diff --git a/license-header-template.txt b/license-header-template.txt new file mode 100644 index 0000000..ce05a62 --- /dev/null +++ b/license-header-template.txt @@ -0,0 +1,13 @@ +Copyright © ${project.inceptionYear}-2022 ${owner} + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..1b8f891 --- /dev/null +++ b/lombok.config @@ -0,0 +1,3 @@ +config.stopbubbling = true +lombok.anyconstructor.addconstructorproperties = true +lombok.copyableAnnotations += org.springframework.context.annotation.Lazy diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md new file mode 100644 index 0000000..a60e740 --- /dev/null +++ b/msa/black-box-tests/README.md @@ -0,0 +1,38 @@ + +## Black box tests execution +To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built.
+- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml): + + mvn clean install -Ddockerfile.skip=false +- Verify that the new local images were built: + + docker image ls +As result, in REPOSITORY column, next images should be present: + + thingsboard/tb-coap-transport + thingsboard/tb-lwm2m-transport + thingsboard/tb-http-transport + thingsboard/tb-mqtt-transport + thingsboard/tb-snmp-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 with Redis standalone: + + mvn clean install -DblackBoxTests.skip=false + +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory with Redis cluster: + + mvn clean install -DblackBoxTests.skip=false -DblackBoxTests.redisCluster=true + +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory in Hybrid mode (postgres + cassandra): + + mvn clean install -DblackBoxTests.skip=false -DblackBoxTests.hybridMode=true + +To run the black box tests with using local env run tests in the [msa/black-box-tests](../black-box-tests) directory with runLocal property: + + mvn clean install -DblackBoxTests.skip=false -DrunLocal=true + + + diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml new file mode 100644 index 0000000..0a0b1cb --- /dev/null +++ b/msa/black-box-tests/pom.xml @@ -0,0 +1,196 @@ + + + 4.0.0 + + + org.thingsboard + 3.4.3 + msa + + org.thingsboard.msa + black-box-tests + + ThingsBoard Black Box Tests + https://thingsboard.io + Project for ThingsBoard black box testing with using Docker + + + UTF-8 + ${basedir}/../.. + + + + + org.testcontainers + testcontainers + test + + + org.zeroturnaround + zt-exec + test + + + org.java-websocket + Java-WebSocket + test + + + org.apache.httpcomponents + httpclient + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.testng + testng + test + + + org.assertj + assertj-core + test + + + io.rest-assured + rest-assured + test + + + org.hamcrest + hamcrest-all + test + + + org.awaitility + awaitility + test + + + org.eclipse.californium + californium-core + + + ch.qos.logback + logback-classic + + + com.google.code.gson + gson + + + org.apache.commons + commons-lang3 + + + com.google.guava + guava + + + org.thingsboard + netty-mqtt + + + org.thingsboard + tools + + + org.thingsboard + rest-client + + + org.thingsboard.msa + js-executor + docker-info + + + org.thingsboard.msa + web-ui + docker-info + + + org.thingsboard.msa + tb-node + docker-info + + + org.thingsboard.msa.transport + coap + docker-info + + + org.thingsboard.msa.transport + http + docker-info + + + org.thingsboard.msa.transport + mqtt + docker-info + + + org.thingsboard.msa.transport + lwm2m + docker-info + + + org.thingsboard.msa.transport + snmp + docker-info + + + org.thingsboard.common + message + ${project.version} + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + src/test/resources/testNG.xml + + ${blackBoxTests.skip} + + + + org.apache.maven.surefire + surefire-testng + ${surefire.version} + + + + + + + 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..d259d46 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -0,0 +1,187 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.http.conn.ssl.TrustStrategy; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; +import org.testng.annotations.AfterSuite; +import org.testng.annotations.BeforeSuite; +import org.testng.annotations.Listeners; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.device.profile.AllowCreateNewDevicesDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; + +import java.net.URI; +import java.util.Map; +import java.util.Random; + + +@Slf4j +@Listeners(TestListener.class) +public abstract class AbstractContainerTest { + + protected final static String TEST_PROVISION_DEVICE_KEY = "test_provision_key"; + protected final static String TEST_PROVISION_DEVICE_SECRET = "test_provision_secret"; + protected static long timeoutMultiplier = 1; + protected ObjectMapper mapper = new ObjectMapper(); + private static final ContainerTestSuite containerTestSuite = ContainerTestSuite.getInstance(); + protected static TestRestClient testRestClient; + + @BeforeSuite + public void beforeSuite() { + if ("false".equals(System.getProperty("runLocal", "false"))) { + containerTestSuite.start(); + } + testRestClient = new TestRestClient(TestProperties.getBaseUrl()); + if (!"kafka".equals(System.getProperty("blackBoxTests.queue", "kafka"))) { + timeoutMultiplier = 10; + } + } + + @AfterSuite + public void afterSuite() { + if (containerTestSuite.isActive()) { + containerTestSuite.stop(); + } + } + + protected WsClient subscribeToWebSocket(DeviceId deviceId, String scope, CmdsType property) throws Exception { + String webSocketUrl = TestProperties.getWebSocketUrl(); + WsClient wsClient = new WsClient(new URI(webSocketUrl + "/api/ws/plugins/telemetry?token=" + testRestClient.getToken()), timeoutMultiplier); + if (webSocketUrl.matches("^(wss)://.*$")) { + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + wsClient.setSocketFactory(builder.build().getSocketFactory()); + } + 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 getExpectedLatestValues(long ts) { + return ImmutableMap.builder() + .put("booleanKey", ts) + .put("stringKey", ts) + .put("doubleKey", ts) + .put("longKey", ts) + .build(); + } + + protected JsonObject createGatewayConnectPayload(String deviceName){ + JsonObject payload = new JsonObject(); + payload.addProperty("device", deviceName); + return payload; + } + + protected JsonObject createGatewayPayload(String deviceName, long ts){ + JsonObject payload = new JsonObject(); + payload.add(deviceName, createGatewayTelemetryArray(ts)); + return payload; + } + + protected JsonArray createGatewayTelemetryArray(long ts){ + JsonArray telemetryArray = new JsonArray(); + if (ts > 0) + telemetryArray.add(createPayload(ts)); + else + telemetryArray.add(createPayload()); + return telemetryArray; + } + + 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; + } + } + + protected DeviceProfile updateDeviceProfileWithProvisioningStrategy(DeviceProfile deviceProfile, DeviceProfileProvisionType provisionType) { + DeviceProfileProvisionConfiguration provisionConfiguration; + String testProvisionDeviceKey = TEST_PROVISION_DEVICE_KEY; + deviceProfile.setProvisionType(provisionType); + switch(provisionType) { + case ALLOW_CREATE_NEW_DEVICES: + provisionConfiguration = new AllowCreateNewDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + break; + case CHECK_PRE_PROVISIONED_DEVICES: + provisionConfiguration = new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + break; + default: + case DISABLED: + testProvisionDeviceKey = null; + provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null); + break; + } + DeviceProfileData deviceProfileData = deviceProfile.getProfileData(); + deviceProfileData.setProvisionConfiguration(provisionConfiguration); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(testProvisionDeviceKey); + return testRestClient.postDeviceProfile(deviceProfile); + } + +} 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..2ff7088 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -0,0 +1,233 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.thingsboard.server.common.data.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.testng.Assert.fail; + +@Slf4j +public class ContainerTestSuite { + final static boolean IS_REDIS_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); + final static boolean IS_HYBRID_MODE = Boolean.parseBoolean(System.getProperty("blackBoxTests.hybridMode")); + final static String QUEUE_TYPE = System.getProperty("blackBoxTests.queue", "kafka"); + private static final String SOURCE_DIR = "./../../docker/"; + private static final String TB_CORE_LOG_REGEXP = ".*Starting polling for events.*"; + private static final String TRANSPORTS_LOG_REGEXP = ".*Going to recalculate partitions.*"; + private static final String TB_VC_LOG_REGEXP = TRANSPORTS_LOG_REGEXP; + private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*"; + private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400); + + private DockerComposeContainer testContainer; + private ThingsBoardDbInstaller installTb; + private boolean isActive; + + private static ContainerTestSuite containerTestSuite; + + public boolean isActive() { + return isActive; + } + + public void setActive(boolean active) { + isActive = active; + } + + private ContainerTestSuite() { + } + + public static ContainerTestSuite getInstance() { + if (containerTestSuite == null) { + containerTestSuite = new ContainerTestSuite(); + } + return containerTestSuite; + } + + public void start() { + installTb = new ThingsBoardDbInstaller(); + installTb.createVolumes(); + log.info("System property of blackBoxTests.redisCluster is {}", IS_REDIS_CLUSTER); + log.info("System property of blackBoxTests.hybridMode is {}", IS_HYBRID_MODE); + boolean skipTailChildContainers = Boolean.valueOf(System.getProperty("blackBoxTests.skipTailChildContainers")); + try { + final String targetDir = FileUtils.getTempDirectoryPath() + "/" + "ContainerTestSuite-" + UUID.randomUUID() + "/"; + log.info("targetDir {}", targetDir); + FileUtils.copyDirectory(new File(SOURCE_DIR), new File(targetDir)); + replaceInFile(targetDir + "docker-compose.yml", " container_name: \"${LOAD_BALANCER_NAME}\"", "", "container_name"); + + FileUtils.copyDirectory(new File("src/test/resources"), new File(targetDir)); + + class DockerComposeContainerImpl> extends DockerComposeContainer { + public DockerComposeContainerImpl(List composeFiles) { + super(composeFiles); + } + + @Override + public void stop() { + super.stop(); + tryDeleteDir(targetDir); + } + } + + List composeFiles = new ArrayList<>(Arrays.asList( + new File(targetDir + "docker-compose.yml"), + new File(targetDir + "docker-compose.volumes.yml"), + new File(targetDir + (IS_HYBRID_MODE ? "docker-compose.hybrid.yml" : "docker-compose.postgres.yml")), + new File(targetDir + "docker-compose.postgres.volumes.yml"), + new File(targetDir + "docker-compose." + QUEUE_TYPE + ".yml"), + new File(targetDir + (IS_REDIS_CLUSTER ? "docker-compose.redis-cluster.yml" : "docker-compose.redis.yml")), + new File(targetDir + (IS_REDIS_CLUSTER ? "docker-compose.redis-cluster.volumes.yml" : "docker-compose.redis.volumes.yml")) + )); + + Map queueEnv = new HashMap<>(); + queueEnv.put("TB_QUEUE_TYPE", QUEUE_TYPE); + switch (QUEUE_TYPE) { + case "kafka": + composeFiles.add(new File(targetDir + "docker-compose.kafka.yml")); + break; + case "aws-sqs": + replaceInFile(targetDir, "queue-aws-sqs.env", + Map.of("YOUR_KEY", getSysProp("blackBoxTests.awsKey"), + "YOUR_SECRET", getSysProp("blackBoxTests.awsSecret"), + "YOUR_REGION", getSysProp("blackBoxTests.awsRegion"))); + break; + case "rabbitmq": + composeFiles.add(new File(targetDir + "docker-compose.rabbitmq-server.yml")); + replaceInFile(targetDir, "queue-rabbitmq.env", + Map.of("localhost", "rabbitmq")); + break; + case "service-bus": + replaceInFile(targetDir, "queue-service-bus.env", + Map.of("YOUR_NAMESPACE_NAME", getSysProp("blackBoxTests.serviceBusNamespace"), + "YOUR_SAS_KEY_NAME", getSysProp("blackBoxTests.serviceBusSASPolicy"))); + replaceInFile(targetDir, "queue-service-bus.env", + Map.of("YOUR_SAS_KEY", getSysProp("blackBoxTests.serviceBusPrimaryKey"))); + break; + case "pubsub": + replaceInFile(targetDir, "queue-pubsub.env", + Map.of("YOUR_PROJECT_ID", getSysProp("blackBoxTests.pubSubProjectId"), + "YOUR_SERVICE_ACCOUNT", getSysProp("blackBoxTests.pubSubServiceAccount"))); + break; + default: + throw new RuntimeException("Unsupported queue type: " + QUEUE_TYPE); + } + + if (IS_HYBRID_MODE) { + composeFiles.add(new File(targetDir + "docker-compose.cassandra.volumes.yml")); + } + + testContainer = new DockerComposeContainerImpl<>(composeFiles) + .withPull(false) + .withLocalCompose(true) + .withTailChildContainers(!skipTailChildContainers) + .withEnv(installTb.getEnv()) + .withEnv(queueEnv) + .withEnv("LOAD_BALANCER_NAME", "") + .withExposedService("haproxy", 80, Wait.forHttp("/swagger-ui.html").withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-core1", Wait.forLogMessage(TB_CORE_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-core2", Wait.forLogMessage(TB_CORE_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-http-transport1", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-http-transport2", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-mqtt-transport1", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-mqtt-transport2", Wait.forLogMessage(TRANSPORTS_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-vc-executor1", Wait.forLogMessage(TB_VC_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-vc-executor2", Wait.forLogMessage(TB_VC_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)) + .waitingFor("tb-js-executor", Wait.forLogMessage(TB_JS_EXECUTOR_LOG_REGEXP, 1).withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)); + testContainer.start(); + setActive(true); + } catch (Exception e) { + log.error("Failed to create test container", e); + fail("Failed to create test container"); + } + } + public void stop() { + if (isActive) { + testContainer.stop(); + installTb.savaLogsAndRemoveVolumes(); + setActive(false); + } + } + + private static void replaceInFile(String targetDir, String fileName, Map replacements) throws IOException { + Path envFilePath = Path.of(targetDir, fileName); + String data = Files.readString(envFilePath); + for (var entry : replacements.entrySet()) { + data = data.replace(entry.getKey(), entry.getValue()); + } + Files.write(envFilePath, data.getBytes(StandardCharsets.UTF_8)); + } + + private static String getSysProp(String propertyName) { + var value = System.getProperty(propertyName); + if (StringUtils.isEmpty(value)) { + throw new RuntimeException("Please define system property: " + propertyName + "!"); + } + return value; + } + + private static void tryDeleteDir(String targetDir) { + try { + log.info("Trying to delete temp dir {}", targetDir); + FileUtils.deleteDirectory(new File(targetDir)); + } catch (IOException e) { + log.error("Can't delete temp directory " + targetDir, e); + } + } + + /** + * This workaround is actual until issue will be resolved: + * Support container_name in docker-compose file #2472 https://github.com/testcontainers/testcontainers-java/issues/2472 + * docker-compose files which contain container_name are not supported and the creation of DockerComposeContainer fails due to IllegalStateException. + * This has been introduced in #1151 as a quick fix for unintuitive feedback. https://github.com/testcontainers/testcontainers-java/issues/1151 + * Using the latest testcontainers and waiting for the fix... + */ + private static void replaceInFile(String sourceFilename, String target, String replacement, String verifyPhrase) { + try { + File file = new File(sourceFilename); + String sourceContent = FileUtils.readFileToString(file, StandardCharsets.UTF_8); + + String outputContent = sourceContent.replace(target, replacement); + assertThat(outputContent, (not(containsString(target)))); + assertThat(outputContent, (not(containsString(verifyPhrase)))); + + FileUtils.writeStringToFile(file, outputContent, StandardCharsets.UTF_8); + assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), is(outputContent)); + } catch (IOException e) { + log.error("failed to update file " + sourceFilename, e); + fail("failed to update file"); + } + } +} 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..3ac4782 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java @@ -0,0 +1,118 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + + +import com.google.common.base.Splitter; +import com.google.common.collect.Maps; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.CommandLine; +import org.zeroturnaround.exec.InvalidExitValueException; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.stream.Collectors.joining; + +@Slf4j +public class DockerComposeExecutor { + + String ENV_PROJECT_NAME = "COMPOSE_PROJECT_NAME"; + String ENV_COMPOSE_FILE = "COMPOSE_FILE"; + + private static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; + private static final String DOCKER_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker"; + + private final List composeFiles; + private final String identifier; + private String cmd = ""; + private Map env = new HashMap<>(); + + public DockerComposeExecutor(List composeFiles, String identifier) { + validateFileList(composeFiles); + this.composeFiles = composeFiles; + this.identifier = identifier; + } + + public DockerComposeExecutor withCommand(String cmd) { + this.cmd = cmd; + return this; + } + + public DockerComposeExecutor withEnv(Map env) { + this.env = env; + return this; + } + + public void invokeCompose() { + // bail out early + if (!CommandLine.executableExists(COMPOSE_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?"); + } + final Map environment = Maps.newHashMap(env); + environment.put(ENV_PROJECT_NAME, identifier); + final Stream absoluteDockerComposeFilePaths = composeFiles.stream().map(File::getAbsolutePath).map(Objects::toString); + final String composeFileEnvVariableValue = absoluteDockerComposeFilePaths.collect(joining(File.pathSeparator + "")); + log.debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + environment.put(ENV_COMPOSE_FILE, composeFileEnvVariableValue); + log.info("Local Docker Compose is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(COMPOSE_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).environment(environment).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker Compose has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker Compose exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker Compose command: " + cmd, e); + } + } + + public void invokeDocker() { + // bail out early + if (!CommandLine.executableExists(DOCKER_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker not found. Is " + DOCKER_EXECUTABLE + " on the PATH?"); + } + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + log.info("Local Docker is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(DOCKER_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker command: " + cmd, e); + } + } + + void validateFileList(List composeFiles) { + checkNotNull(composeFiles); + checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); + } + + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClient.java new file mode 100644 index 0000000..e00fdee --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClient.java @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import java.io.IOException; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapHandler; +import org.eclipse.californium.core.CoapObserveRelation; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.MediaTypeRegistry; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.elements.exception.ConnectorException; +import org.thingsboard.server.common.msg.session.FeatureType; + +public class TestCoapClient { + + private static final String COAP_BASE_URL = "coap://localhost:5683/api/v1/"; + private static final long CLIENT_REQUEST_TIMEOUT = 60000L; + + private final CoapClient client; + + public TestCoapClient(){ + this.client = createClient(); + } + + public TestCoapClient(String accessToken, FeatureType featureType) { + this.client = createClient(getFeatureTokenUrl(accessToken, featureType)); + } + + public TestCoapClient(String featureTokenUrl) { + this.client = createClient(featureTokenUrl); + } + + public void connectToCoap(String accessToken) { + setURI(accessToken, null); + } + + public void connectToCoap(String accessToken, FeatureType featureType) { + setURI(accessToken, featureType); + } + + public void disconnect() { + if (client != null) { + client.shutdown(); + } + } + + public CoapResponse postMethod(String requestBody) throws ConnectorException, IOException { + return this.postMethod(requestBody.getBytes()); + } + + public CoapResponse postMethod(byte[] requestBodyBytes) throws ConnectorException, IOException { + return client.setTimeout(CLIENT_REQUEST_TIMEOUT).post(requestBodyBytes, MediaTypeRegistry.APPLICATION_JSON); + } + + public void postMethod(CoapHandler handler, String payload, int format) { + client.post(handler, payload, format); + } + + public void postMethod(CoapHandler handler, byte[] payload, int format) { + client.post(handler, payload, format); + } + + public CoapResponse getMethod() throws ConnectorException, IOException { + return client.setTimeout(CLIENT_REQUEST_TIMEOUT).get(); + } + + public CoapObserveRelation getObserveRelation(TestCoapClientCallback callback){ + Request request = Request.newGet().setObserve(); + request.setType(CoAP.Type.CON); + return client.observe(request, callback); + } + + public void setURI(String featureTokenUrl) { + if (client == null) { + throw new RuntimeException("Failed to connect! CoapClient is not initialized!"); + } + client.setURI(featureTokenUrl); + } + + public void setURI(String accessToken, FeatureType featureType) { + if (featureType == null){ + featureType = FeatureType.ATTRIBUTES; + } + setURI(getFeatureTokenUrl(accessToken, featureType)); + } + + private CoapClient createClient() { + return new CoapClient(); + } + + private CoapClient createClient(String featureTokenUrl) { + return new CoapClient(featureTokenUrl); + } + + public static String getFeatureTokenUrl(FeatureType featureType) { + return COAP_BASE_URL + featureType.name().toLowerCase(); + } + + public static String getFeatureTokenUrl(String token, FeatureType featureType) { + return COAP_BASE_URL + token + "/" + featureType.name().toLowerCase(); + } + + public static String getFeatureTokenUrl(String token, FeatureType featureType, int requestId) { + return COAP_BASE_URL + token + "/" + featureType.name().toLowerCase() + "/" + requestId; + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClientCallback.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClientCallback.java new file mode 100644 index 0000000..b31f9c2 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestCoapClientCallback.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapHandler; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.coap.CoAP; + +import java.util.concurrent.CountDownLatch; + +@Slf4j +@Data +public class TestCoapClientCallback implements CoapHandler { + + protected final CountDownLatch latch; + protected Integer observe; + protected byte[] payloadBytes; + protected CoAP.ResponseCode responseCode; + + public TestCoapClientCallback() { + this.latch = new CountDownLatch(1); + } + + public TestCoapClientCallback(int subscribeCount) { + this.latch = new CountDownLatch(subscribeCount); + } + + public Integer getObserve() { + return observe; + } + + public byte[] getPayloadBytes() { + return payloadBytes; + } + + public CoAP.ResponseCode getResponseCode() { + return responseCode; + } + + @Override + public void onLoad(CoapResponse response) { + observe = response.getOptions().getObserve(); + payloadBytes = response.getPayload(); + responseCode = response.getCode(); + latch.countDown(); + } + + @Override + public void onError() { + log.warn("Command Response Ack Error, No connect"); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestListener.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestListener.java new file mode 100644 index 0000000..51bc75c --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestListener.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.extern.slf4j.Slf4j; +import org.testng.ITestContext; +import org.testng.ITestResult; +import org.testng.TestListenerAdapter; + +import static org.testng.internal.Utils.log; + +@Slf4j +public class TestListener extends TestListenerAdapter { + + @Override + public void onTestStart(ITestResult result) { + super.onTestStart(result); + log.info("===>>> Test started: " + result.getName()); + } + + /** + * Invoked when a test succeeds + */ + @Override + public void onTestSuccess(ITestResult result) { + super.onTestSuccess(result); + if (result != null) { + log.info("<<<=== Test completed successfully: " + result.getName()); + } + } + + /** + * Invoked when a test fails + */ + @Override + public void onTestFailure(ITestResult result) { + super.onTestFailure(result); + log.info("<<<=== Test failed: " + result.getName()); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java new file mode 100644 index 0000000..020dbf8 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +@Slf4j +public class TestProperties { + + private static final String HTTPS_URL = "https://localhost"; + + private static final String WSS_URL = "wss://localhost"; + + private static final ContainerTestSuite instance = ContainerTestSuite.getInstance(); + + private static Properties properties; + + public static String getBaseUrl() { + if (instance.isActive()) { + return HTTPS_URL; + } + return getProperties().getProperty("tb.baseUrl"); + } + + public static String getWebSocketUrl() { + if (instance.isActive()) { + return WSS_URL; + } + return getProperties().getProperty("tb.wsUrl"); + } + + private static Properties getProperties() { + if (properties == null) { + try (InputStream input = TestProperties.class.getClassLoader().getResourceAsStream("config.properties")) { + properties = new Properties(); + properties.load(input); + } catch (IOException ex) { + log.error("Exception while reading test properties " + ex.getMessage()); + } + } + return properties; + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java new file mode 100644 index 0000000..138c74e --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -0,0 +1,299 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.JsonNode; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.config.HeaderConfig; +import io.restassured.config.RestAssuredConfig; +import io.restassured.filter.log.RequestLoggingFilter; +import io.restassured.filter.log.ResponseLoggingFilter; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.ValidatableResponse; +import io.restassured.specification.RequestSpecification; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.security.DeviceCredentials; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.AnyOf.anyOf; +import static org.thingsboard.server.common.data.StringUtils.isEmpty; + +public class TestRestClient { + private static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + private final RequestSpecification requestSpec; + private String token; + private String refreshToken; + + public TestRestClient(String url) { + RestAssured.filters(new RequestLoggingFilter(), new ResponseLoggingFilter()); + + requestSpec = given().baseUri(url) + .contentType(ContentType.JSON) + .config(RestAssuredConfig.config() + .headerConfig(HeaderConfig.headerConfig() + .overwriteHeadersWithName(JWT_TOKEN_HEADER_PARAM, CONTENT_TYPE_HEADER))); + + if (url.matches("^(https)://.*$")) { + requestSpec.relaxedHTTPSValidation(); + } + } + + public void login(String username, String password) { + Map loginRequest = new HashMap<>(); + loginRequest.put("username", username); + loginRequest.put("password", password); + + JsonPath jsonPath = given().spec(requestSpec).body(loginRequest) + .post( "/api/auth/login") + .getBody().jsonPath(); + token = jsonPath.get("token"); + refreshToken = jsonPath.get("refreshToken"); + requestSpec.header(JWT_TOKEN_HEADER_PARAM, "Bearer " + token); + } + + public Device postDevice(String accessToken, Device device) { + return given().spec(requestSpec).body(device) + .pathParams("accessToken", accessToken) + .post("/api/device?accessToken={accessToken}") + .then() + .statusCode(HTTP_OK) + .extract() + .as(Device.class); + } + + public Device getDeviceByName(String deviceName) { + return given().spec(requestSpec).pathParam("deviceName", deviceName) + .get("/api/tenant/devices?deviceName={deviceName}") + .then() + .statusCode(HTTP_OK) + .extract() + .as(Device.class); + } + + public ValidatableResponse getDeviceById(DeviceId deviceId, int statusCode) { + return given().spec(requestSpec) + .pathParams("deviceId", deviceId.getId()) + .get("/api/device/{deviceId}") + .then() + .statusCode(statusCode); + } + public Device getDeviceById(DeviceId deviceId) { + return getDeviceById(deviceId, HTTP_OK) + .extract() + .as(Device.class); + } + public DeviceCredentials getDeviceCredentialsByDeviceId(DeviceId deviceId) { + return given().spec(requestSpec).get("/api/device/{deviceId}/credentials", deviceId.getId()) + .then() + .assertThat() + .statusCode(HTTP_OK) + .extract() + .as(DeviceCredentials.class); + } + + public ValidatableResponse postTelemetry(String credentialsId, JsonNode telemetry) { + return given().spec(requestSpec).body(telemetry) + .post("/api/v1/{credentialsId}/telemetry", credentialsId) + .then() + .statusCode(HTTP_OK); + } + + public ValidatableResponse deleteDevice(DeviceId deviceId) { + return given().spec(requestSpec) + .delete("/api/device/{deviceId}", deviceId.getId()) + .then() + .statusCode(HTTP_OK); + } + public ValidatableResponse deleteDeviceIfExists(DeviceId deviceId) { + return given().spec(requestSpec) + .delete("/api/device/{deviceId}", deviceId.getId()) + .then() + .statusCode(anyOf(is(HTTP_OK),is(HTTP_NOT_FOUND))); + } + + public ValidatableResponse postTelemetryAttribute(String entityType, DeviceId deviceId, String scope, JsonNode attribute) { + return given().spec(requestSpec).body(attribute) + .post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityType, deviceId.getId(), scope) + .then() + .statusCode(HTTP_OK); + } + + public ValidatableResponse postAttribute(String accessToken, JsonNode attribute) { + return given().spec(requestSpec).body(attribute) + .post("/api/v1/{accessToken}/attributes/", accessToken) + .then() + .statusCode(HTTP_OK); + } + + public JsonNode getAttributes(String accessToken, String clientKeys, String sharedKeys) { + return given().spec(requestSpec) + .queryParam("clientKeys", clientKeys) + .queryParam("sharedKeys", sharedKeys) + .get("/api/v1/{accessToken}/attributes", accessToken) + .then() + .statusCode(HTTP_OK) + .extract() + .as(JsonNode.class); + } + + public JsonPath postProvisionRequest(String provisionRequest) { + return given().spec(requestSpec) + .body(provisionRequest) + .post("/api/v1/provision") + .getBody() + .jsonPath(); + } + + public PageData getRuleChains(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); + return given().spec(requestSpec).queryParams(params) + .get("/api/ruleChains") + .then() + .statusCode(HTTP_OK) + .extract() + .as(new TypeRef>() {}); + } + + public RuleChain postRootRuleChain(RuleChain ruleChain) { + return given().spec(requestSpec) + .body(ruleChain) + .post("/api/ruleChain") + .then() + .statusCode(HTTP_OK) + .extract() + .as(RuleChain.class); + } + + public RuleChainMetaData postRuleChainMetadata(RuleChainMetaData ruleChainMetaData) { + return given().spec(requestSpec) + .body(ruleChainMetaData) + .post("/api/ruleChain/metadata") + .then() + .statusCode(HTTP_OK) + .extract() + .as(RuleChainMetaData.class); + } + + public void setRootRuleChain(RuleChainId ruleChainId) { + given().spec(requestSpec) + .post("/api/ruleChain/{ruleChainId}/root", ruleChainId.getId()) + .then() + .statusCode(HTTP_OK); + } + + public void deleteRuleChain(RuleChainId ruleChainId) { + given().spec(requestSpec) + .delete("/api/ruleChain/{ruleChainId}", ruleChainId.getId()) + .then() + .statusCode(HTTP_OK); + } + + private String getUrlParams(PageLink pageLink) { + String urlParams = "pageSize={pageSize}&page={page}"; + if (!isEmpty(pageLink.getTextSearch())) { + urlParams += "&textSearch={textSearch}"; + } + if (pageLink.getSortOrder() != null) { + urlParams += "&sortProperty={sortProperty}&sortOrder={sortOrder}"; + } + return urlParams; + } + + private void addPageLinkToParam(Map params, PageLink pageLink) { + params.put("pageSize", String.valueOf(pageLink.getPageSize())); + params.put("page", String.valueOf(pageLink.getPage())); + if (!isEmpty(pageLink.getTextSearch())) { + params.put("textSearch", pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + params.put("sortProperty", pageLink.getSortOrder().getProperty()); + params.put("sortOrder", pageLink.getSortOrder().getDirection().name()); + } + } + + public List findRelationByFrom(EntityId fromId, RelationTypeGroup relationTypeGroup) { + Map params = new HashMap<>(); + params.put("fromId", fromId.getId().toString()); + params.put("fromType", fromId.getEntityType().name()); + params.put("relationTypeGroup", relationTypeGroup.name()); + + return given().spec(requestSpec) + .pathParams(params) + .get("/api/relations?fromId={fromId}&fromType={fromType}&relationTypeGroup={relationTypeGroup}") + .then() + .statusCode(HTTP_OK) + .extract() + .as(new TypeRef>() {}); + } + + public JsonNode postServerSideRpc(DeviceId deviceId, JsonNode serverRpcPayload) { + return given().spec(requestSpec) + .body(serverRpcPayload) + .post("/api/rpc/twoway/{deviceId}", deviceId.getId()) + .then() + .statusCode(HTTP_OK) + .extract() + .as(JsonNode.class); + } + + public DeviceProfile getDeviceProfileById(DeviceProfileId deviceProfileId) { + return given().spec(requestSpec).get("/api/deviceProfile/{deviceProfileId}", deviceProfileId.getId()) + .then() + .assertThat() + .statusCode(HTTP_OK) + .extract() + .as(DeviceProfile.class); + } + + public DeviceProfile postDeviceProfile(DeviceProfile deviceProfile) { + return given().spec(requestSpec).body(deviceProfile) + .post("/api/deviceProfile") + .then() + .statusCode(HTTP_OK) + .extract() + .as(DeviceProfile.class); + } + + public String getToken() { + return token; + } + + public String getRefreshToken() { + return refreshToken; + } +} 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..ed606cd --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -0,0 +1,227 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.Base58; +import org.thingsboard.server.common.data.StringUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Slf4j +public class ThingsBoardDbInstaller { + + final static boolean IS_REDIS_CLUSTER = Boolean.parseBoolean(System.getProperty("blackBoxTests.redisCluster")); + final static boolean IS_HYBRID_MODE = Boolean.parseBoolean(System.getProperty("blackBoxTests.hybridMode")); + private final static String POSTGRES_DATA_VOLUME = "tb-postgres-test-data-volume"; + + private final static String CASSANDRA_DATA_VOLUME = "tb-cassandra-test-data-volume"; + private final static String REDIS_DATA_VOLUME = "tb-redis-data-volume"; + private final static String REDIS_CLUSTER_DATA_VOLUME = "tb-redis-cluster-data-volume"; + private final static String TB_LOG_VOLUME = "tb-log-test-volume"; + private final static String TB_COAP_TRANSPORT_LOG_VOLUME = "tb-coap-transport-log-test-volume"; + private final static String TB_LWM2M_TRANSPORT_LOG_VOLUME = "tb-lwm2m-transport-log-test-volume"; + private final static String TB_HTTP_TRANSPORT_LOG_VOLUME = "tb-http-transport-log-test-volume"; + private final static String TB_MQTT_TRANSPORT_LOG_VOLUME = "tb-mqtt-transport-log-test-volume"; + private final static String TB_SNMP_TRANSPORT_LOG_VOLUME = "tb-snmp-transport-log-test-volume"; + private final static String TB_VC_EXECUTOR_LOG_VOLUME = "tb-vc-executor-log-test-volume"; + private final static String JAVA_OPTS = "-Xmx512m"; + + private final DockerComposeExecutor dockerCompose; + + private final String postgresDataVolume; + private final String cassandraDataVolume; + + private final String redisDataVolume; + private final String redisClusterDataVolume; + private final String tbLogVolume; + private final String tbCoapTransportLogVolume; + private final String tbLwm2mTransportLogVolume; + private final String tbHttpTransportLogVolume; + private final String tbMqttTransportLogVolume; + private final String tbSnmpTransportLogVolume; + private final String tbVcExecutorLogVolume; + private final Map env; + + public ThingsBoardDbInstaller() { + log.info("System property of blackBoxTests.redisCluster is {}", IS_REDIS_CLUSTER); + log.info("System property of blackBoxTests.hybridMode is {}", IS_HYBRID_MODE); + List composeFiles = new ArrayList<>(Arrays.asList( + new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.volumes.yml"), + IS_HYBRID_MODE + ? new File("./../../docker/docker-compose.hybrid.yml") + : new File("./../../docker/docker-compose.postgres.yml"), + new File("./../../docker/docker-compose.postgres.volumes.yml"), + IS_REDIS_CLUSTER + ? new File("./../../docker/docker-compose.redis-cluster.yml") + : new File("./../../docker/docker-compose.redis.yml"), + IS_REDIS_CLUSTER + ? new File("./../../docker/docker-compose.redis-cluster.volumes.yml") + : new File("./../../docker/docker-compose.redis.volumes.yml") + )); + if (IS_HYBRID_MODE) { + composeFiles.add(new File("./../../docker/docker-compose.cassandra.volumes.yml")); + } + + String identifier = Base58.randomString(6).toLowerCase(); + String project = identifier + Base58.randomString(6).toLowerCase(); + + postgresDataVolume = project + "_" + POSTGRES_DATA_VOLUME; + cassandraDataVolume = project + "_" + CASSANDRA_DATA_VOLUME; + redisDataVolume = project + "_" + REDIS_DATA_VOLUME; + redisClusterDataVolume = project + "_" + REDIS_CLUSTER_DATA_VOLUME; + tbLogVolume = project + "_" + TB_LOG_VOLUME; + tbCoapTransportLogVolume = project + "_" + TB_COAP_TRANSPORT_LOG_VOLUME; + tbLwm2mTransportLogVolume = project + "_" + TB_LWM2M_TRANSPORT_LOG_VOLUME; + tbHttpTransportLogVolume = project + "_" + TB_HTTP_TRANSPORT_LOG_VOLUME; + tbMqttTransportLogVolume = project + "_" + TB_MQTT_TRANSPORT_LOG_VOLUME; + tbSnmpTransportLogVolume = project + "_" + TB_SNMP_TRANSPORT_LOG_VOLUME; + tbVcExecutorLogVolume = project + "_" + TB_VC_EXECUTOR_LOG_VOLUME; + + dockerCompose = new DockerComposeExecutor(composeFiles, project); + + env = new HashMap<>(); + env.put("JAVA_OPTS", JAVA_OPTS); + env.put("POSTGRES_DATA_VOLUME", postgresDataVolume); + if (IS_HYBRID_MODE) { + env.put("CASSANDRA_DATA_VOLUME", cassandraDataVolume); + } + env.put("TB_LOG_VOLUME", tbLogVolume); + env.put("TB_COAP_TRANSPORT_LOG_VOLUME", tbCoapTransportLogVolume); + env.put("TB_LWM2M_TRANSPORT_LOG_VOLUME", tbLwm2mTransportLogVolume); + env.put("TB_HTTP_TRANSPORT_LOG_VOLUME", tbHttpTransportLogVolume); + env.put("TB_MQTT_TRANSPORT_LOG_VOLUME", tbMqttTransportLogVolume); + env.put("TB_SNMP_TRANSPORT_LOG_VOLUME", tbSnmpTransportLogVolume); + env.put("TB_VC_EXECUTOR_LOG_VOLUME", tbVcExecutorLogVolume); + if (IS_REDIS_CLUSTER) { + for (int i = 0; i < 6; i++) { + env.put("REDIS_CLUSTER_DATA_VOLUME_" + i, redisClusterDataVolume + '-' + i); + } + } else { + env.put("REDIS_DATA_VOLUME", redisDataVolume); + } + dockerCompose.withEnv(env); + } + + public Map getEnv() { + return env; + } + + public void createVolumes() { + try { + + dockerCompose.withCommand("volume create " + postgresDataVolume); + dockerCompose.invokeDocker(); + + if (IS_HYBRID_MODE) { + dockerCompose.withCommand("volume create " + cassandraDataVolume); + dockerCompose.invokeDocker(); + } + + dockerCompose.withCommand("volume create " + tbLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbCoapTransportLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbLwm2mTransportLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbHttpTransportLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbMqttTransportLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbSnmpTransportLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbVcExecutorLogVolume); + dockerCompose.invokeDocker(); + + String additionalServices = ""; + if (IS_HYBRID_MODE) { + additionalServices += " cassandra"; + } + if (IS_REDIS_CLUSTER) { + for (int i = 0; i < 6; i++) { + additionalServices = additionalServices + " redis-node-" + i; + dockerCompose.withCommand("volume create " + redisClusterDataVolume + '-' + i); + dockerCompose.invokeDocker(); + } + } else { + additionalServices += " redis"; + dockerCompose.withCommand("volume create " + redisDataVolume); + dockerCompose.invokeDocker(); + } + + dockerCompose.withCommand("up -d postgres" + additionalServices); + dockerCompose.invokeCompose(); + + dockerCompose.withCommand("run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=true tb-core1"); + dockerCompose.invokeCompose(); + + } finally { + try { + dockerCompose.withCommand("down -v"); + dockerCompose.invokeCompose(); + } catch (Exception e) {} + } + } + + public void savaLogsAndRemoveVolumes() { + copyLogs(tbLogVolume, "./target/tb-logs/"); + copyLogs(tbCoapTransportLogVolume, "./target/tb-coap-transport-logs/"); + copyLogs(tbLwm2mTransportLogVolume, "./target/tb-lwm2m-transport-logs/"); + copyLogs(tbHttpTransportLogVolume, "./target/tb-http-transport-logs/"); + copyLogs(tbMqttTransportLogVolume, "./target/tb-mqtt-transport-logs/"); + copyLogs(tbSnmpTransportLogVolume, "./target/tb-snmp-transport-logs/"); + copyLogs(tbVcExecutorLogVolume, "./target/tb-vc-executor-logs/"); + + dockerCompose.withCommand("volume rm -f " + postgresDataVolume + " " + tbLogVolume + + " " + tbCoapTransportLogVolume + " " + tbLwm2mTransportLogVolume + " " + tbHttpTransportLogVolume + + " " + tbMqttTransportLogVolume + " " + tbSnmpTransportLogVolume + " " + tbVcExecutorLogVolume + + (IS_REDIS_CLUSTER + ? IntStream.range(0, 6).mapToObj(i -> " " + redisClusterDataVolume + '-' + i).collect(Collectors.joining()) + : redisDataVolume)); + dockerCompose.invokeDocker(); + } + + private void copyLogs(String volumeName, String targetDir) { + File tbLogsDir = new File(targetDir); + tbLogsDir.mkdirs(); + + String logsContainerName = "tb-logs-container-" + StringUtils.randomAlphanumeric(10); + + dockerCompose.withCommand("run -d --rm --name " + logsContainerName + " -v " + volumeName + ":/root alpine tail -f /dev/null"); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("cp " + logsContainerName + ":/root/. "+tbLogsDir.getAbsolutePath()); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("rm -f " + logsContainerName); + 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..d4ee31d --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 javax.net.ssl.SSLParameters; +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); + + private final long timeoutMultiplier; + + WsClient(URI serverUri, long timeoutMultiplier) { + super(serverUri); + this.timeoutMultiplier = timeoutMultiplier; + } + + @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 { + boolean result = latch.await(10 * timeoutMultiplier, TimeUnit.SECONDS); + if (result) { + return this.message; + } else { + log.error("Timeout, ws message wasn't received"); + throw new RuntimeException("Timeout, ws message wasn't received"); + } + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + } + return null; + } + + void waitForFirstReply() { + try { + boolean result = firstReply.await(10 * timeoutMultiplier, TimeUnit.SECONDS); + if (!result) { + log.error("Timeout, ws message wasn't received"); + throw new RuntimeException("Timeout, ws message wasn't received"); + } + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + throw new RuntimeException(e); + } + } + + @Override + protected void onSetSSLParameters(SSLParameters sslParameters) { + sslParameters.setEndpointIdentificationAlgorithm(null); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java new file mode 100644 index 0000000..fe3443d --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.fasterxml.jackson.databind.node.ObjectNode; +import com.google.gson.JsonObject; +import io.restassured.path.json.JsonPath; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.msg.session.FeatureType; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.TestCoapClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; + +public class CoapClientTest extends AbstractContainerTest { + private TestCoapClient client; + + private Device device; + @BeforeMethod + public void setUp() throws Exception { + testRestClient.login("tenant@thingsboard.org", "tenant"); + device = testRestClient.postDevice("", defaultDevicePrototype("http_")); + } + + @AfterMethod + public void tearDown() { + testRestClient.deleteDeviceIfExists(device.getId()); + } + + @Test + public void provisionRequestForDeviceWithPreProvisionedStrategy() throws Exception { + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + JsonNode provisionResponse = JacksonUtil.fromBytes(createCoapClientAndPublish(device.getName())); + + assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(provisionResponse.get("status").asText()).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithAllowToCreateNewDevicesStrategy() throws Exception { + + String testDeviceName = "test_provision_device"; + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); + + JsonNode provisionResponse = JacksonUtil.fromBytes(createCoapClientAndPublish(testDeviceName)); + + testRestClient.deleteDeviceIfExists(device.getId()); + device = testRestClient.getDeviceByName(testDeviceName); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(provisionResponse.get("status").asText()).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithDisabledProvisioningStrategy() throws Exception { + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + + JsonNode response = JacksonUtil.fromBytes(createCoapClientAndPublish(null)); + + assertThat(response.get("status").asText()).isEqualTo("NOT_FOUND"); + } + + private byte[] createCoapClientAndPublish(String deviceName) throws Exception { + String provisionRequestMsg = createTestProvisionMessage(deviceName); + client = new TestCoapClient(TestCoapClient.getFeatureTokenUrl(FeatureType.PROVISION)); + return client.postMethod(provisionRequestMsg.getBytes()).getPayload(); + } + + private String createTestProvisionMessage(String deviceName) { + ObjectNode provisionRequest = JacksonUtil.newObjectNode(); + provisionRequest.put("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.put("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + if (deviceName != null) { + provisionRequest.put("deviceName", deviceName); + } + return provisionRequest.toString(); + } + +} 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..adaefa8 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -0,0 +1,171 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.gson.JsonObject; +import io.restassured.path.json.JsonPath; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +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; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.common.data.DataConstants.DEVICE; +import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; + +public class HttpClientTest extends AbstractContainerTest { + private Device device; + @BeforeMethod + public void setUp() throws Exception { + testRestClient.login("tenant@thingsboard.org", "tenant"); + device = testRestClient.postDevice("", defaultDevicePrototype("http_")); + } + + @AfterMethod + public void tearDown() { + testRestClient.deleteDeviceIfExists(device.getId()); + } + + @Test + public void telemetryUpload() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + testRestClient.postTelemetry(deviceCredentials.getCredentialsId(), mapper.readTree(createPayload().toString())); + + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("booleanKey", "stringKey", "doubleKey", "longKey")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("booleanKey").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("stringKey").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("doubleKey").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("longKey").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void getAttributes() throws Exception { + String accessToken = testRestClient.getDeviceCredentialsByDeviceId(device.getId()).getCredentialsId(); + assertThat(accessToken).isNotNull(); + + JsonNode sharedAttribute = mapper.readTree(createPayload().toString()); + testRestClient.postTelemetryAttribute(DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + + JsonNode clientAttribute = mapper.readTree(createPayload().toString()); + testRestClient.postAttribute(accessToken, clientAttribute); + + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + JsonNode attributes = testRestClient.getAttributes(accessToken, null, null); + assertThat(attributes.get("shared")).isEqualTo(sharedAttribute); + assertThat(attributes.get("client")).isEqualTo(clientAttribute); + + JsonNode attributes2 = testRestClient.getAttributes(accessToken, null, "stringKey"); + assertThat(attributes2.get("shared").get("stringKey")).isEqualTo(sharedAttribute.get("stringKey")); + assertThat(attributes2.has("client")).isFalse(); + + JsonNode attributes3 = testRestClient.getAttributes(accessToken, "longKey,stringKey", null); + + assertThat(attributes3.has("shared")).isFalse(); + assertThat(attributes3.get("client").get("longKey")).isEqualTo(clientAttribute.get("longKey")); + assertThat(attributes3.get("client").get("stringKey")).isEqualTo(clientAttribute.get("stringKey")); + } + + @Test + public void provisionRequestForDeviceWithPreProvisionedStrategy() throws Exception { + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + provisionRequest.addProperty("deviceName", device.getName()); + + JsonPath provisionResponse = testRestClient.postProvisionRequest(provisionRequest.toString()); + + String credentialsType = provisionResponse.get("credentialsType"); + String credentialsValue = provisionResponse.get("credentialsValue"); + String status = provisionResponse.get("status"); + + assertThat(credentialsType).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(credentialsValue).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(status).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithAllowToCreateNewDevicesStrategy() throws Exception { + + String testDeviceName = "test_provision_device"; + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + provisionRequest.addProperty("deviceName", testDeviceName); + + JsonPath provisionResponse = testRestClient.postProvisionRequest(provisionRequest.toString()); + + String credentialsType = provisionResponse.get("credentialsType"); + String credentialsValue = provisionResponse.get("credentialsValue"); + String status = provisionResponse.get("status"); + + testRestClient.deleteDeviceIfExists(device.getId()); + device = testRestClient.getDeviceByName(testDeviceName); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + assertThat(credentialsType).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(credentialsValue).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(status).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithDisabledProvisioningStrategy() throws Exception { + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + + JsonPath provisionResponse = testRestClient.postProvisionRequest(provisionRequest.toString()); + + String status = provisionResponse.get("status"); + + assertThat(status).isEqualTo("NOT_FOUND"); + } + +} 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..347598a --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -0,0 +1,491 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.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.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.mqtt.MqttClient; +import org.thingsboard.mqtt.MqttClientConfig; +import org.thingsboard.mqtt.MqttHandler; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +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.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testng.Assert.fail; +import static org.thingsboard.server.common.data.DataConstants.DEVICE; +import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; + +@Slf4j +public class MqttClientTest extends AbstractContainerTest { + + private Device device; + @BeforeMethod + public void setUp() throws Exception { + testRestClient.login("tenant@thingsboard.org", "tenant"); + device = testRestClient.postDevice("", defaultDevicePrototype("http_")); + } + + @AfterMethod + public void tearDown() { + testRestClient.deleteDeviceIfExists(device.getId()); + } + @Test + public void telemetryUpload() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(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())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("booleanKey", "stringKey", "doubleKey", "longKey")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("booleanKey").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("stringKey").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("doubleKey").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("longKey").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void telemetryUploadWithTs() throws Exception { + long ts = 1451649600512L; + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(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())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(getExpectedLatestValues(ts)).isEqualTo(actualLatestTelemetry.getLatestValues()); + + assertThat(actualLatestTelemetry.getDataValuesByKey("booleanKey").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("stringKey").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("doubleKey").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("longKey").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void publishAttributeUpdateToServer() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(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())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("attr1", "attr2", "attr3", "attr4")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("attr1").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr2").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr3").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr4").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void requestAttributeValuesFromServer() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + + // Add a new client attribute + JsonObject clientAttributes = new JsonObject(); + String clientAttributeValue = StringUtils.randomAlphanumeric(8); + clientAttributes.addProperty("clientAttr", clientAttributeValue); + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())).get(); + + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received ws telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(1); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnly("clientAttr"); + assertThat(actualLatestTelemetry.getDataValuesByKey("clientAttr").get(1)).isEqualTo(clientAttributeValue); + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = StringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); + JsonNode sharedAttribute = mapper.readTree(sharedAttributes.toString()); + testRestClient.postTelemetryAttribute(DataConstants.DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + + // Subscribe to attributes response + mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + // 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())).get(); + MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class); + log.info("Received telemetry: {}", attributes); + + assertThat(attributes.getClient()).hasSize(1); + assertThat(attributes.getClient().get("clientAttr")).isEqualTo(clientAttributeValue); + + assertThat(attributes.getShared()).hasSize(1); + assertThat(attributes.getShared().get("sharedAttr")).isEqualTo(sharedAttributeValue); + } + + @Test + public void subscribeToAttributeUpdatesFromServer() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + String sharedAttributeName = "sharedAttr"; + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = StringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue); + JsonNode sharedAttribute = mapper.readTree(sharedAttributes.toString()); + + testRestClient.postTelemetryAttribute(DataConstants.DEVICE, device.getId(), SHARED_SCOPE, sharedAttribute); + + MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()) + .isEqualTo(sharedAttributeValue); + + // Update the shared attribute value + JsonObject updatedSharedAttributes = new JsonObject(); + String updatedSharedAttributeValue = StringUtils.randomAlphanumeric(8); + updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue); + testRestClient.postTelemetryAttribute(DEVICE, device.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); + + event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()) + .isEqualTo(updatedSharedAttributeValue); + } + + @Test + public void serverSideRpc() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + // Send an RPC from the server + JsonObject serverRpcPayload = new JsonObject(); + serverRpcPayload.addProperty("method", "getValue"); + serverRpcPayload.addProperty("params", true); + ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(getClass().getSimpleName()))); + ListenableFuture future = service.submit(() -> { + try { + return testRestClient.postServerSideRpc(device.getId(), mapper.readTree(serverRpcPayload.toString())); + } catch (IOException e) { + return null; + } + }); + + // Wait for RPC call from the server and send the response + MqttEvent requestFromServer = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + + assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}"); + + 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())).get(); + + JsonNode serverResponse = future.get(5 * timeoutMultiplier, TimeUnit.SECONDS); + service.shutdownNow(); + assertThat(serverResponse).isEqualTo(mapper.readTree(clientResponse.toString())); + } + + @Test + public void clientSideRpc() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // 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 * timeoutMultiplier); + // 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())).get(); + + // Check the response from the server + TimeUnit.SECONDS.sleep(1 * timeoutMultiplier); + MqttEvent responseFromServer = listener.getEvents().poll(1 * timeoutMultiplier, TimeUnit.SECONDS); + Integer responseId = Integer.valueOf(Objects.requireNonNull(responseFromServer).getTopic().substring("v1/devices/me/rpc/response/".length())); + assertThat(responseId).isEqualTo(requestId); + assertThat(mapper.readTree(responseFromServer.getMessage()).get("response").asText()).isEqualTo("requestReceived"); + + // Make the default rule chain a root again + testRestClient.setRootRuleChain(defaultRuleChainId); + + // Delete the created rule chain + testRestClient.deleteRuleChain(ruleChainId); + } + + @Test + public void deviceDeletedClosingSession() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + + testRestClient.deleteDeviceIfExists(device.getId()); + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + assertThat(mqttClient.isConnected()).isFalse(); + } + + @Test + public void provisionRequestForDeviceWithPreProvisionedStrategy() throws Exception { + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient("provision", listener); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + provisionRequest.addProperty("deviceName", device.getName()); + + mqttClient.publish("/provision/request", Unpooled.wrappedBuffer(provisionRequest.toString().getBytes())).get(); + + //Wait for response + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + MqttEvent provisionResponseMsg = listener.getEvents().poll(timeoutMultiplier, TimeUnit.SECONDS); + + assertThat(provisionResponseMsg).isNotNull(); + + JsonNode provisionResponse = mapper.readTree(provisionResponseMsg.getMessage()); + + assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(provisionResponse.get("status").asText()).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithAllowToCreateNewDevicesStrategy() throws Exception { + + String testDeviceName = "test_provision_device"; + + DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); + + deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient("provision", listener); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + provisionRequest.addProperty("deviceName", testDeviceName); + + mqttClient.publish("/provision/request", Unpooled.wrappedBuffer(provisionRequest.toString().getBytes())).get(); + + //Wait for response + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + MqttEvent provisionResponseMsg = listener.getEvents().poll(timeoutMultiplier, TimeUnit.SECONDS); + + assertThat(provisionResponseMsg).isNotNull(); + + JsonNode provisionResponse = mapper.readTree(provisionResponseMsg.getMessage()); + + testRestClient.deleteDeviceIfExists(device.getId()); + device = testRestClient.getDeviceByName(testDeviceName); + + DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + + assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); + assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(provisionResponse.get("status").asText()).isEqualTo("SUCCESS"); + + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); + } + + @Test + public void provisionRequestForDeviceWithDisabledProvisioningStrategy() throws Exception { + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient("provision", listener); + + JsonObject provisionRequest = new JsonObject(); + provisionRequest.addProperty("provisionDeviceKey", TEST_PROVISION_DEVICE_KEY); + provisionRequest.addProperty("provisionDeviceSecret", TEST_PROVISION_DEVICE_SECRET); + + mqttClient.publish("/provision/request", Unpooled.wrappedBuffer(provisionRequest.toString().getBytes())).get(); + + //Wait for response + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + MqttEvent provisionResponseMsg = listener.getEvents().poll(timeoutMultiplier, TimeUnit.SECONDS); + + assertThat(provisionResponseMsg).isNotNull(); + + JsonNode provisionResponse = mapper.readTree(provisionResponseMsg.getMessage()); + + assertThat(provisionResponse.get("status").asText()).isEqualTo("NOT_FOUND"); + } + + private RuleChainId createRootRuleChainForRpcResponse() throws Exception { + RuleChain newRuleChain = new RuleChain(); + newRuleChain.setName("testRuleChain"); + + RuleChain ruleChain = testRestClient.postRootRuleChain(newRuleChain); + + 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))); + + testRestClient.postRuleChainMetadata(ruleChainMetaData); + + // Set a new rule chain as root + testRestClient.setRootRuleChain(ruleChain.getId()); + return ruleChain.getId(); + } + + private RuleChainId getDefaultRuleChainId() { + PageData ruleChains = testRestClient.getRuleChains(new PageLink(40, 0)); + + Optional defaultRuleChain = ruleChains.getData() + .stream() + .filter(RuleChain::isRoot) + .findFirst(); + if (!defaultRuleChain.isPresent()) { + fail("Root rule chain wasn't found"); + } + return defaultRuleChain.get().getId(); + } + + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException, ExecutionException { + return getMqttClient(deviceCredentials.getCredentialsId(), listener); + } + + private MqttClient getMqttClient(String username, MqttMessageListener listener) throws InterruptedException, ExecutionException { + MqttClientConfig clientConfig = new MqttClientConfig(); + clientConfig.setClientId("MQTT client from test"); + clientConfig.setUsername(username); + MqttClient mqttClient = MqttClient.create(clientConfig, listener); + mqttClient.connect("localhost", 1883).get(); + return mqttClient; + } + + @Data + private class MqttMessageListener implements MqttHandler { + private final BlockingQueue events; + + private MqttMessageListener() { + events = new ArrayBlockingQueue<>(100); + } + + @Override + public void onMessage(String topic, ByteBuf message) { + 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/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java new file mode 100644 index 0000000..84b2dd7 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -0,0 +1,436 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +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.springframework.http.HttpStatus; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.mqtt.MqttClient; +import org.thingsboard.mqtt.MqttClientConfig; +import org.thingsboard.mqtt.MqttHandler; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +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; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Random; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.common.data.DataConstants.DEVICE; +import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype; + +@Slf4j +public class MqttGatewayClientTest extends AbstractContainerTest { + private Device gatewayDevice; + private MqttClient mqttClient; + private Device createdDevice; + private MqttMessageListener listener; + private JsonParser jsonParser = new JsonParser(); + + @BeforeMethod + public void createGateway() throws Exception { + testRestClient.login("tenant@thingsboard.org", "tenant"); + gatewayDevice = testRestClient.postDevice("", defaultGatewayPrototype()); + DeviceCredentials gatewayDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(gatewayDevice.getId()); + + this.listener = new MqttMessageListener(); + this.mqttClient = getMqttClient(gatewayDeviceCredentials, listener); + this.createdDevice = createDeviceThroughGateway(mqttClient, gatewayDevice); + } + + @AfterMethod + public void removeGateway() { + testRestClient.deleteDeviceIfExists(this.gatewayDevice.getId()); + testRestClient.deleteDeviceIfExists(this.createdDevice.getId()); + this.listener = null; + this.mqttClient = null; + this.createdDevice = null; + } + + @Test + public void telemetryUpload() throws Exception { + WsClient wsClient = subscribeToWebSocket(createdDevice.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + mqttClient.publish("v1/gateway/telemetry", Unpooled.wrappedBuffer(createGatewayPayload(createdDevice.getName(), -1).toString().getBytes())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("booleanKey", "stringKey", "doubleKey", "longKey")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("booleanKey").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("stringKey").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("doubleKey").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("longKey").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void telemetryUploadWithTs() throws Exception { + long ts = 1451649600512L; + + WsClient wsClient = subscribeToWebSocket(createdDevice.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + mqttClient.publish("v1/gateway/telemetry", Unpooled.wrappedBuffer(createGatewayPayload(createdDevice.getName(), ts).toString().getBytes())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("booleanKey", "stringKey", "doubleKey", "longKey")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("booleanKey").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("stringKey").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("doubleKey").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("longKey").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void publishAttributeUpdateToServer() throws Exception { + testRestClient.getDeviceCredentialsByDeviceId(createdDevice.getId()); + + WsClient wsClient = subscribeToWebSocket(createdDevice.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); + JsonObject clientAttributes = new JsonObject(); + clientAttributes.addProperty("attr1", "value1"); + clientAttributes.addProperty("attr2", true); + clientAttributes.addProperty("attr3", 42.0); + clientAttributes.addProperty("attr4", 73); + JsonObject gatewayClientAttributes = new JsonObject(); + gatewayClientAttributes.add(createdDevice.getName(), clientAttributes); + mqttClient.publish("v1/gateway/attributes", Unpooled.wrappedBuffer(gatewayClientAttributes.toString().getBytes())).get(); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received attributes: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(4); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnlyOnceElementsOf(Arrays.asList("attr1", "attr2", "attr3", "attr4")); + + assertThat(actualLatestTelemetry.getDataValuesByKey("attr1").get(1)).isEqualTo("value1"); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr2").get(1)).isEqualTo(Boolean.TRUE.toString()); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr3").get(1)).isEqualTo(Double.toString(42.0)); + assertThat(actualLatestTelemetry.getDataValuesByKey("attr4").get(1)).isEqualTo(Long.toString(73)); + } + + @Test + public void responseDataOnAttributesRequestCheck() throws Exception { + testRestClient.getDeviceCredentialsByDeviceId(createdDevice.getId()); + JsonObject sharedAttributes = new JsonObject(); + sharedAttributes.addProperty("attr1", "value1"); + sharedAttributes.addProperty("attr2", true); + sharedAttributes.addProperty("attr3", 42.0); + sharedAttributes.addProperty("attr4", 73); + + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + + testRestClient.postTelemetryAttribute(DataConstants.DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + var event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + + JsonObject requestData = new JsonObject(); + requestData.addProperty("id", 1); + requestData.addProperty("device", createdDevice.getName()); + requestData.addProperty("client", false); + requestData.addProperty("key", "attr1"); + + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); + event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + + JsonObject responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + assertThat(responseData.has("value")).isTrue(); + assertThat(responseData.get("value").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); + + requestData = new JsonObject(); + requestData.addProperty("id", 1); + requestData.addProperty("device", createdDevice.getName()); + requestData.addProperty("client", false); + JsonArray keys = new JsonArray(); + keys.add("attr1"); + keys.add("attr2"); + requestData.add("keys", keys); + + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); + event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + + assertThat(responseData.has("values")).isTrue(); + assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); + assertThat(responseData.get("values").getAsJsonObject().get("attr2").getAsString()).isEqualTo(sharedAttributes.get("attr2").getAsString()); + + requestData = new JsonObject(); + requestData.addProperty("id", 1); + requestData.addProperty("device", createdDevice.getName()); + requestData.addProperty("client", false); + keys = new JsonArray(); + keys.add("attr1"); + keys.add("undefined"); + requestData.add("keys", keys); + + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); + event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + + assertThat(responseData.has("values")).isTrue(); + assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString()); + assertThat(responseData.get("values").getAsJsonObject().entrySet()).hasSize(1); + } + + @Test + public void requestAttributeValuesFromServer() throws Exception { + WsClient wsClient = subscribeToWebSocket(createdDevice.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); + // Add a new client attribute + JsonObject clientAttributes = new JsonObject(); + String clientAttributeValue = StringUtils.randomAlphanumeric(8); + clientAttributes.addProperty("clientAttr", clientAttributeValue); + + JsonObject gatewayClientAttributes = new JsonObject(); + gatewayClientAttributes.add(createdDevice.getName(), clientAttributes); + mqttClient.publish("v1/gateway/attributes", Unpooled.wrappedBuffer(gatewayClientAttributes.toString().getBytes())).get(); + + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received ws telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + assertThat(actualLatestTelemetry.getData()).hasSize(1); + assertThat(actualLatestTelemetry.getLatestValues().keySet()).containsOnly("clientAttr"); + assertThat(actualLatestTelemetry.getDataValuesByKey("clientAttr").get(1)).isEqualTo(clientAttributeValue); + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = StringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); + + // Subscribe for attribute update event + mqttClient.on("v1/gateway/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(); + + testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + MqttEvent sharedAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + + // Catch attribute update event + assertThat(sharedAttributeEvent).isNotNull(); + assertThat(sharedAttributeEvent.getTopic()).isEqualTo("v1/gateway/attributes"); + + // Subscribe to attributes response + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + checkAttribute(true, clientAttributeValue); + checkAttribute(false, sharedAttributeValue); + } + + @Test + public void subscribeToAttributeUpdatesFromServer() throws Exception { + mqttClient.on("v1/gateway/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(); + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + String sharedAttributeName = "sharedAttr"; + // Add a new shared attribute + + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = StringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue); + + JsonObject gatewaySharedAttributeValue = new JsonObject(); + gatewaySharedAttributeValue.addProperty("device", createdDevice.getName()); + gatewaySharedAttributeValue.add("data", sharedAttributes); + + testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + + MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get("data").get(sharedAttributeName).asText()) + .isEqualTo(sharedAttributeValue); + + // Update the shared attribute value + JsonObject updatedSharedAttributes = new JsonObject(); + String updatedSharedAttributeValue = StringUtils.randomAlphanumeric(8); + updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue); + + JsonObject gatewayUpdatedSharedAttributeValue = new JsonObject(); + gatewayUpdatedSharedAttributeValue.addProperty("device", createdDevice.getName()); + gatewayUpdatedSharedAttributeValue.add("data", updatedSharedAttributes); + + testRestClient.postTelemetryAttribute(DEVICE, createdDevice.getId(), SHARED_SCOPE, mapper.readTree(updatedSharedAttributes.toString())); + event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + assertThat(mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get("data").get(sharedAttributeName).asText()) + .isEqualTo(updatedSharedAttributeValue); + } + + @Test + public void serverSideRpc() throws Exception { + String gatewayRpcTopic = "v1/gateway/rpc"; + mqttClient.on(gatewayRpcTopic, listener, MqttQoS.AT_LEAST_ONCE).get(); + + // Wait until subscription is processed + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + // Send an RPC from the server + JsonObject serverRpcPayload = new JsonObject(); + serverRpcPayload.addProperty("method", "getValue"); + serverRpcPayload.addProperty("params", true); + ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(getClass().getSimpleName()))); + ListenableFuture future = service.submit(() -> { + try { + return testRestClient.postServerSideRpc(createdDevice.getId(), mapper.readTree(serverRpcPayload.toString())); + } catch (IOException e) { + return null; + } + }); + + // Wait for RPC call from the server and send the response + MqttEvent requestFromServer = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + service.shutdownNow(); + + assertThat(requestFromServer).isNotNull(); + assertThat(requestFromServer.getMessage()).isNotNull(); + JsonNode requestFromServerJson = JacksonUtil.toJsonNode(requestFromServer.getMessage()); + assertThat(requestFromServerJson.get("device").asText()).isEqualTo(createdDevice.getName()); + assertThat(requestFromServerJson.get("data").get("method").asText()).isEqualTo("getValue"); + assertThat(requestFromServerJson.get("data").get("params").asText()).isEqualTo("true"); + int requestId = requestFromServerJson.get("data").get("id").asInt(); + + JsonObject clientResponse = new JsonObject(); + clientResponse.addProperty("response", "someResponse"); + JsonObject gatewayResponse = new JsonObject(); + gatewayResponse.addProperty("device", createdDevice.getName()); + gatewayResponse.addProperty("id", requestId); + gatewayResponse.add("data", clientResponse); + // Send a response to the server's RPC request + + mqttClient.publish(gatewayRpcTopic, Unpooled.wrappedBuffer(gatewayResponse.toString().getBytes())).get(); + JsonNode serverResponse = future.get(5 * timeoutMultiplier, TimeUnit.SECONDS); + + assertThat(serverResponse).isEqualTo(mapper.readTree(clientResponse.toString())); + } + + @Test + public void deviceCreationAfterDeleted() throws Exception { + testRestClient.deleteDevice(this.createdDevice.getId()); + testRestClient.getDeviceById(this.createdDevice.getId(), HttpStatus.NOT_FOUND.value()); + this.createdDevice = createDeviceThroughGateway(mqttClient, gatewayDevice); + } + + private void checkAttribute(boolean client, String expectedValue) throws Exception { + JsonObject gatewayAttributesRequest = new JsonObject(); + int messageId = new Random().nextInt(100); + gatewayAttributesRequest.addProperty("id", messageId); + gatewayAttributesRequest.addProperty("device", createdDevice.getName()); + gatewayAttributesRequest.addProperty("client", client); + String attributeName; + if (client) + attributeName = "clientAttr"; + else + attributeName = "sharedAttr"; + gatewayAttributesRequest.addProperty("key", attributeName); + log.info(gatewayAttributesRequest.toString()); + mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(gatewayAttributesRequest.toString().getBytes())).get(); + MqttEvent clientAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + assertThat(clientAttributeEvent).isNotNull(); + JsonObject responseMessage = new JsonParser().parse(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject(); + + assertThat(responseMessage.get("id").getAsInt()).isEqualTo(messageId); + assertThat(responseMessage.get("device").getAsString()).isEqualTo(createdDevice.getName()); + assertThat(responseMessage.entrySet()).hasSize(3); + assertThat(responseMessage.get("value").getAsString()).isEqualTo(expectedValue); + } + + private Device createDeviceThroughGateway(MqttClient mqttClient, Device gatewayDevice) throws Exception { + if (timeoutMultiplier > 1) { + TimeUnit.SECONDS.sleep(30); + } + + String deviceName = "mqtt_device" + RandomStringUtils.randomAlphabetic(5); + mqttClient.publish("v1/gateway/connect", Unpooled.wrappedBuffer(createGatewayConnectPayload(deviceName).toString().getBytes()), MqttQoS.AT_LEAST_ONCE).get(); + + if (timeoutMultiplier > 1) { + TimeUnit.SECONDS.sleep(30); + } + + List relations = testRestClient.findRelationByFrom(gatewayDevice.getId(), RelationTypeGroup.COMMON); + assertThat(relations).hasSize(1); + + EntityId createdEntityId = relations.get(0).getTo(); + DeviceId createdDeviceId = new DeviceId(createdEntityId.getId()); + return testRestClient.getDeviceById(createdDeviceId); + } + + 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 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/mapper/AttributesResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java new file mode 100644 index 0000000..ec803a6 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.mapper; + +import lombok.Data; + +import java.util.Map; + +@Data +public class AttributesResponse { + private Map client; + private Map shared; +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java new file mode 100644 index 0000000..8c722fd --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.mapper; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Data +public class WsTelemetryResponse implements Serializable { + private int subscriptionId; + private int errorCode; + private String errorMsg; + private Map>> data; + private Map latestValues; + + public List getDataValuesByKey(String key) { + return data.entrySet().stream() + .filter(e -> e.getKey().equals(key)) + .flatMap(e -> e.getValue().stream().flatMap(Collection::stream)) + .collect(Collectors.toList()); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/prototypes/DevicePrototypes.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/prototypes/DevicePrototypes.java new file mode 100644 index 0000000..7db3546 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/prototypes/DevicePrototypes.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.prototypes; + +import com.fasterxml.jackson.databind.JsonNode; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; + +public class DevicePrototypes { + public static Device defaultDevicePrototype(String name){ + Device device = new Device(); + device.setName(name + RandomStringUtils.randomAlphanumeric(7)); + device.setType("DEFAULT"); + return device; + } + + public static Device defaultGatewayPrototype() { + String isGateway = "{\"gateway\":true}"; + JsonNode additionalInfo = JacksonUtil.toJsonNode(isGateway); + Device gatewayDeviceTemplate = new Device(); + gatewayDeviceTemplate.setName("mqtt_gateway_" + RandomStringUtils.randomAlphanumeric(5)); + gatewayDeviceTemplate.setType("gateway"); + gatewayDeviceTemplate.setAdditionalInfo(additionalInfo); + return gatewayDeviceTemplate; + } +} 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 diff --git a/msa/black-box-tests/src/test/resources/config.properties b/msa/black-box-tests/src/test/resources/config.properties new file mode 100644 index 0000000..419c731 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/config.properties @@ -0,0 +1,2 @@ +tb.baseUrl=http://localhost:8080 +tb.wsUrl=ws://localhost:8080 diff --git a/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml new file mode 100644 index 0000000..21aba70 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml @@ -0,0 +1,69 @@ +# +# Copyright © 2016-2022 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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: + rabbitmq: + restart: always + image: rabbitmq:3 + ports: + - '5672:5672' + environment: + RABBITMQ_DEFAULT_USER: YOUR_USERNAME + RABBITMQ_DEFAULT_PASS: YOUR_PASSWORD + tb-js-executor: + depends_on: + - rabbitmq + tb-core1: + depends_on: + - rabbitmq + tb-core2: + depends_on: + - rabbitmq + tb-rule-engine1: + depends_on: + - rabbitmq + tb-rule-engine2: + depends_on: + - rabbitmq + tb-mqtt-transport1: + depends_on: + - rabbitmq + tb-mqtt-transport2: + depends_on: + - rabbitmq + tb-http-transport1: + depends_on: + - rabbitmq + tb-http-transport2: + depends_on: + - rabbitmq + tb-coap-transport: + depends_on: + - rabbitmq + tb-lwm2m-transport: + depends_on: + - rabbitmq + tb-snmp-transport: + depends_on: + - rabbitmq + tb-vc-executor1: + depends_on: + - rabbitmq + tb-vc-executor2: + depends_on: + - rabbitmq diff --git a/msa/black-box-tests/src/test/resources/logback.xml b/msa/black-box-tests/src/test/resources/logback.xml new file mode 100644 index 0000000..cdf87aa --- /dev/null +++ b/msa/black-box-tests/src/test/resources/logback.xml @@ -0,0 +1,32 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/msa/black-box-tests/src/test/resources/testNG.xml b/msa/black-box-tests/src/test/resources/testNG.xml new file mode 100644 index 0000000..45e93f7 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/testNG.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore new file mode 100644 index 0000000..3fef24c --- /dev/null +++ b/msa/js-executor/.gitignore @@ -0,0 +1,31 @@ +*.toDelete +output/** +*.class +*~ +*.iml +*/.idea/** +.idea/** +.idea +*.log +*.log.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] +*/.classpath +.classpath +*/.project +.project +.cache/** +target/ +logs/ +build/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/.env +node_modules +api/*.proto.js diff --git a/msa/js-executor/api/httpServer.ts b/msa/js-executor/api/httpServer.ts new file mode 100644 index 0000000..e1c294f --- /dev/null +++ b/msa/js-executor/api/httpServer.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 express from 'express'; +import { _logger} from '../config/logger'; +import http from 'http'; +import { Socket } from 'net'; + +export class HttpServer { + + private logger = _logger('httpServer'); + private app = express(); + private server: http.Server | null; + private connections: Socket[] = []; + + constructor(httpPort: number) { + this.app.get('/livenessProbe', async (req, res) => { + const message = { + now: new Date().toISOString() + }; + res.send(message); + }) + + this.server = this.app.listen(httpPort, () => { + this.logger.info('Started HTTP endpoint on port %s. Please, use /livenessProbe !', httpPort); + }).on('error', (error) => { + this.logger.error(error); + }); + + this.server.on('connection', connection => { + this.connections.push(connection); + connection.on('close', () => this.connections = this.connections.filter(curr => curr !== connection)); + }); + } + + async stop() { + if (this.server) { + this.logger.info('Stopping HTTP Server...'); + const _server = this.server; + this.server = null; + this.connections.forEach(curr => curr.end(() => curr.destroy())); + await new Promise( + (resolve, reject) => { + _server.close((err) => { + this.logger.info('HTTP Server stopped.'); + resolve(); + }); + } + ); + } + } +} diff --git a/msa/js-executor/api/jsExecutor.models.ts b/msa/js-executor/api/jsExecutor.models.ts new file mode 100644 index 0000000..17407f4 --- /dev/null +++ b/msa/js-executor/api/jsExecutor.models.ts @@ -0,0 +1,70 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 interface TbMessage { + scriptIdMSB: string; // deprecated + scriptIdLSB: string; // deprecated + scriptHash: string; +} + +export interface RemoteJsRequest { + compileRequest?: JsCompileRequest; + invokeRequest?: JsInvokeRequest; + releaseRequest?: JsReleaseRequest; +} + +export interface JsReleaseRequest extends TbMessage { + functionName: string; +} + +export interface JsInvokeRequest extends TbMessage { + functionName: string; + scriptBody: string; + timeout: number; + args: string[]; +} + +export interface JsCompileRequest extends TbMessage { + functionName: string; + scriptBody: string; +} + + +export interface JsReleaseResponse extends TbMessage { + success: boolean; +} + +export interface JsCompileResponse extends TbMessage { + success: boolean; + errorCode?: number; + errorDetails?: string; +} + +export interface JsInvokeResponse { + success: boolean; + result?: string; + errorCode?: number; + errorDetails?: string; +} + +export interface RemoteJsResponse { + requestIdMSB: string; + requestIdLSB: string; + compileResponse?: JsCompileResponse; + invokeResponse?: JsInvokeResponse; + releaseResponse?: JsReleaseResponse; +} diff --git a/msa/js-executor/api/jsExecutor.ts b/msa/js-executor/api/jsExecutor.ts new file mode 100644 index 0000000..f22f142 --- /dev/null +++ b/msa/js-executor/api/jsExecutor.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2022 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 vm, { Script } from 'vm'; + +export type TbScript = Script | Function; + +export class JsExecutor { + useSandbox: boolean; + + constructor(useSandbox: boolean) { + this.useSandbox = useSandbox; + } + + compileScript(code: string): Promise { + if (this.useSandbox) { + return this.createScript(code); + } else { + return this.createFunction(code); + } + } + + executeScript(script: TbScript, args: string[], timeout?: number): Promise { + if (this.useSandbox) { + return this.invokeScript(script as Script, args, timeout); + } else { + return this.invokeFunction(script as Function, args); + } + } + + private createScript(code: string): Promise\n
\n
\n
\n
${title}
\n
${apiState}
\n
\n
\n
${unit}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 10px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n\n" + }, + "title": "JavaScript functions", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "javascript_functions_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "javascript_functions", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "d4961bea-84de-e1af-e50f-666b98d34cd5" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "fd6df872-2ddf-0921-3929-2e7f55062fad" + }, + "7e235874-461b-e7c2-2fdd-d8762a020773": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "dbApiState", + "type": "timeseries", + "label": "apiState", + "color": "#2196f3", + "settings": {}, + "_hash": 0.8830669138660703, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "storageDataPointsLimit", + "type": "timeseries", + "label": "limit", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5463603803546802, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "storageDataPointsCount", + "type": "timeseries", + "label": "count", + "color": "#f44336", + "settings": {}, + "_hash": 0.5564241862015964, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "dbApiState", + "type": "timeseries", + "label": "apiStateClass", + "color": "#ffc107", + "settings": {}, + "_hash": 0.8737107059960671, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value ? value.toLowerCase() : '';" + }, + { + "name": "dbApiState", + "type": "timeseries", + "label": "cardId", + "color": "#607d8b", + "settings": {}, + "_hash": 0.051659774305067296, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return (Math.random()*100000).toFixed(0);" + }, + { + "name": "dbApiState", + "type": "timeseries", + "label": "title", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.6301889725474652, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.telemetry}\";" + }, + { + "name": "dbApiState", + "type": "timeseries", + "label": "unit", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.0027742924142306613, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.data-points-storage-days}\";" + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "#666666", + "padding": "0", + "settings": { + "cardHtml": "
\n \n \n
\n
\n
\n
${title}
\n
${apiState}
\n
\n
\n
${unit}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 10px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n\n" + }, + "title": "Telemetry persistence", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "telemetry_persistence_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "telemetry_persistence", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "6248831c-5b3f-8879-8548-afcf43f10610" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "7e235874-461b-e7c2-2fdd-d8762a020773" + }, + "08545554-a0e8-05c7-66df-6000cfeff8a4": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "ruleEngineApiState", + "type": "timeseries", + "label": "apiState", + "color": "#2196f3", + "settings": {}, + "_hash": 0.8830669138660703, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "ruleEngineExecutionLimit", + "type": "timeseries", + "label": "limit", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5463603803546802, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "ruleEngineExecutionCount", + "type": "timeseries", + "label": "count", + "color": "#f44336", + "settings": {}, + "_hash": 0.5564241862015964, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "ruleEngineApiState", + "type": "timeseries", + "label": "apiStateClass", + "color": "#ffc107", + "settings": {}, + "_hash": 0.8737107059960671, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value ? value.toLowerCase() : '';" + }, + { + "name": "ruleEngineApiState", + "type": "timeseries", + "label": "cardId", + "color": "#607d8b", + "settings": {}, + "_hash": 0.051659774305067296, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return (Math.random()*100000).toFixed(0);" + }, + { + "name": "ruleEngineApiState", + "type": "timeseries", + "label": "title", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.3551317421302518, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.rule-engine}\";" + }, + { + "name": "ruleEngineApiState", + "type": "timeseries", + "label": "unit", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.5100381746798048, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.executions}\";" + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "#666666", + "padding": "0", + "settings": { + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 10px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n\n", + "cardHtml": "
\n \n \n
\n
\n
\n
${title}
\n
${apiState}
\n
\n
\n
${unit}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n \n
\n
" + }, + "title": "Rule Engine execution", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "rule_engine_execution_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "rule_engine_execution", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "3c30248f-0cd8-fb97-a917-bc1e09984a79" + }, + { + "name": "rule_engine_statistics_details", + "icon": "show_chart", + "type": "openDashboardState", + "targetDashboardStateId": "rule_engine_statistics", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "04e4565a-9e24-23df-f376-f2ec70a8165f" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "08545554-a0e8-05c7-66df-6000cfeff8a4" + }, + "a245c67e-53ec-d299-fa89-69fe2062ccb2": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "transportApiState", + "type": "timeseries", + "label": "apiState", + "color": "#2196f3", + "settings": {}, + "_hash": 0.8830669138660703, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportMsgLimit", + "type": "timeseries", + "label": "limit", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5463603803546802, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportMsgCount", + "type": "timeseries", + "label": "count", + "color": "#f44336", + "settings": {}, + "_hash": 0.5564241862015964, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportApiState", + "type": "timeseries", + "label": "apiStateClass", + "color": "#ffc107", + "settings": {}, + "_hash": 0.8737107059960671, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value ? value.toLowerCase() : '';" + }, + { + "name": "transportApiState", + "type": "timeseries", + "label": "cardId", + "color": "#607d8b", + "settings": {}, + "_hash": 0.051659774305067296, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return (Math.random()*100000).toFixed(0);" + }, + { + "name": "transportDataPointsLimit", + "type": "timeseries", + "label": "pointsLimit", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.22082255831864894, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportDataPointsCount", + "type": "timeseries", + "label": "pointsCount", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.6340356364819146, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportApiState", + "type": "timeseries", + "label": "title", + "color": "#3f51b5", + "settings": {}, + "_hash": 0.6894070537030252, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.transport}\";" + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "#666666", + "padding": "0", + "settings": { + "cardHtml": "
\n \n \n
\n
\n
\n
\n ${title}\n
\n
${apiState}
\n
\n
\n
\n
\n
{i18n:api-usage.messages}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
{i18n:api-usage.data-points}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bars-row {\n flex: 1;\n display: flex;\n flex-direction: row;\n}\n\n.card .bar-column {\n flex: 1;\n display: flex;\n flex-direction: column;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 6px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n" + }, + "title": "Transport", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "transport_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "transport", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "46b7cefe-e1f2-67c1-4055-3a214520f869" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "a245c67e-53ec-d299-fa89-69fe2062ccb2" + }, + "a151ae60-0326-6116-d818-9070dda8e9c7": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "alarmApiState", + "type": "timeseries", + "label": "apiState", + "color": "#2196f3", + "settings": {}, + "_hash": 0.8830669138660703, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "createdAlarmsLimit", + "type": "timeseries", + "label": "limit", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5463603803546802, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "createdAlarmsCount", + "type": "timeseries", + "label": "count", + "color": "#f44336", + "settings": {}, + "_hash": 0.5564241862015964, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "alarmApiState", + "type": "timeseries", + "label": "apiStateClass", + "color": "#ffc107", + "settings": {}, + "_hash": 0.8737107059960671, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return value ? value.toLowerCase() : '';" + }, + { + "name": "alarmApiState", + "type": "timeseries", + "label": "cardId", + "color": "#607d8b", + "settings": {}, + "_hash": 0.051659774305067296, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return (Math.random()*100000).toFixed(0);" + }, + { + "name": "alarmApiState", + "type": "timeseries", + "label": "title", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.43439375716502227, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.alarm}\";" + }, + { + "name": "alarmApiState", + "type": "timeseries", + "label": "unit", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.9964061963495883, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.alarms-created}\";" + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "#666666", + "padding": "0", + "settings": { + "cardHtml": "
\n \n \n
\n
\n
\n
${title}
\n
${apiState}
\n
\n
\n
${unit}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
", + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 10px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n\n" + }, + "title": "Alarm created", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "email_messages_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "alarms_created", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": null, + "dialogTitle": null, + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "946ba769-84ac-1507-6baa-94701de8967b" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "a151ae60-0326-6116-d818-9070dda8e9c7" + }, + "68e16e98-0420-f72c-4848-41dedffd3904": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "ruleEngineExecutionCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#ab00ff", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.rule-engine-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-statistics}", + "icon": "show_chart", + "type": "openDashboardState", + "targetDashboardStateId": "rule_engine_statistics", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "f9f08190-9ed9-d802-5b7a-c57ff84b5648" + }, + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "rule_engine_execution", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "1aec196b-44ba-ddf4-c4dc-c3f60c1eb6fc" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "68e16e98-0420-f72c-4848-41dedffd3904" + }, + "2aa6b499-6e27-b315-6833-89c4d58485ce": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "transportMsgCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.transport-messages}", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportDataPointsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.46849996721308895, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": true, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + }, + "tooltipCumulative": false + }, + "title": "{i18n:api-usage.transport-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "transport", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "6ef12f6a-0266-25cf-6ca5-5dcb772252c6" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "2aa6b499-6e27-b315-6833-89c4d58485ce" + }, + "d890cea3-fba0-6474-9a21-fa780230dc62": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "jsExecutionCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.javascript-executions}", + "color": "#ff9900", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.javascript-functions-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "javascript_functions", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "4687d3f6-8800-a3b6-26e5-0d33f3b828a9" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "d890cea3-fba0-6474-9a21-fa780230dc62" + }, + "84b6cfa5-1449-e0f2-3560-f810d2dd7ead": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "storageDataPointsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.data-points-storage-days}", + "color": "#1039ee", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.telemetry-persistence-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "telemetry_persistence", + "setEntityId": false, + "stateEntityParamName": null, + "openRightLayout": false, + "id": "16707efb-e572-bd02-c219-55fc1b0f672a" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "84b6cfa5-1449-e0f2-3560-f810d2dd7ead" + }, + "d296b566-a000-7402-ae9d-c815381c5435": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "createdAlarmsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.alarms-created}", + "color": "#d35a00", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.alarms-created-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "alarms_created", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": null, + "dialogTitle": null, + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "371882f9-ea23-3abc-fca8-9449c5dfdd6b" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "d296b566-a000-7402-ae9d-c815381c5435" + }, + "00a02464-9509-911b-3b5e-21fb37629822": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "emailCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.email-messages}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.1348755140779876, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "smsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.sms-messages}", + "color": "#f36021", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 2400000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.notifications-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-details}", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "notifications", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": null, + "dialogTitle": null, + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "49aefac0-ec5e-d6f3-f39c-8744759f4b19" + } + ] + }, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "00a02464-9509-911b-3b5e-21fb37629822" + }, + "74199074-7873-6c6a-2a51-3fc614769f03": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "ruleEngineExecutionCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#ab00ff", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.rule-engine-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "74199074-7873-6c6a-2a51-3fc614769f03" + }, + "00006bc3-8a8d-b55e-ed39-e318f1bcd090": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "ruleEngineExecutionCount", + "type": "timeseries", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#ab00ff", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.rule-engine-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "00006bc3-8a8d-b55e-ed39-e318f1bcd090" + }, + "5f5ca59c-e507-5301-5910-7ad8cd34df40": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "jsExecutionCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.javascript-executions}", + "color": "#ff9900", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.javascript-functions-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "5f5ca59c-e507-5301-5910-7ad8cd34df40" + }, + "ada32ee9-44ed-48d1-c368-fd0c94b7607f": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "jsExecutionCount", + "type": "timeseries", + "label": "{i18n:api-usage.javascript-executions}", + "color": "#ff9900", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.javascript-functions-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "ada32ee9-44ed-48d1-c368-fd0c94b7607f" + }, + "98bae68b-0f35-72f2-a428-9b06889f1554": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "transportMsgCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.transport-messages}", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportDataPointsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.46849996721308895, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": true, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + }, + "tooltipCumulative": false + }, + "title": "{i18n:api-usage.transport-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "98bae68b-0f35-72f2-a428-9b06889f1554" + }, + "e61e5477-5a09-cc25-966b-f613d81da833": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "transportMsgCount", + "type": "timeseries", + "label": "{i18n:api-usage.transport-messages}", + "color": "#2196f3", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportDataPointsCount", + "type": "timeseries", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.46849996721308895, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 2592000000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", + "min": 0, + "tickDecimals": 0 + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": true, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + }, + "tooltipCumulative": false + }, + "title": "{i18n:api-usage.transport-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "e61e5477-5a09-cc25-966b-f613d81da833" + }, + "85fe0738-5326-f069-ab3f-30594bde5fed": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "storageDataPointsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.data-points-storage-days}", + "color": "#1039ee", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.telemetry-persistence-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "85fe0738-5326-f069-ab3f-30594bde5fed" + }, + "eaeb381a-437e-f6e9-60c9-6bc8826fdd44": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "storageDataPointsCount", + "type": "timeseries", + "label": "{i18n:api-usage.data-points-storage-days}", + "color": "#1039ee", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.telemetry-persistence-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "eaeb381a-437e-f6e9-60c9-6bc8826fdd44" + }, + "4b798823-b97d-9d6a-59dc-fcafd897fc23": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "createdAlarmsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.alarms-created}", + "color": "#d35a00", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.alarms-created-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "4b798823-b97d-9d6a-59dc-fcafd897fc23" + }, + "6a981580-7490-19dd-f937-b64cbf67a982": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "createdAlarmsCount", + "type": "timeseries", + "label": "{i18n:api-usage.alarms-created}", + "color": "#d35a00", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.alarms-created-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "6a981580-7490-19dd-f937-b64cbf67a982" + }, + "7302df65-1b0c-579e-bbdb-145126ae3392": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "smsCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.sms-messages}", + "color": "#f36021", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.sms-messages-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "7302df65-1b0c-579e-bbdb-145126ae3392" + }, + "fdb385e7-14fe-fc9f-ebdc-b400f26fc66b": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "smsCount", + "type": "timeseries", + "label": "{i18n:api-usage.sms-messages}", + "color": "#f36021", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.sms-messages-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "fdb385e7-14fe-fc9f-ebdc-b400f26fc66b" + }, + "2408ad30-163e-8221-08e1-a82b638be564": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 12, + "sizeY": 7, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "successfulMsgs", + "type": "timeseries", + "label": "{i18n:api-usage.successful}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.15490750967648736 + }, + { + "name": "failedMsgs", + "type": "timeseries", + "label": "{i18n:api-usage.permanent-failures}", + "color": "#ef5350", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.4186621166514697 + }, + { + "name": "tmpFailed", + "type": "timeseries", + "label": "{i18n:api-usage.processing-failures}", + "color": "#ffc107", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.49891007198715376 + } + ], + "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f" + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 3600000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 10000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Queue Stats", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "actions": {}, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": false, + "showTotal": true + } + }, + "id": "2408ad30-163e-8221-08e1-a82b638be564" + }, + "e43dcfe1-b970-6a11-ce0e-5769f3eb5e88": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "basic_timeseries", + "type": "timeseries", + "title": "New widget", + "sizeX": 12, + "sizeY": 7, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "timeoutMsgs", + "type": "timeseries", + "label": "{i18n:api-usage.permanent-timeouts}", + "color": "#4caf50", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.565222981550328 + }, + { + "name": "tmpTimeout", + "type": "timeseries", + "label": "{i18n:api-usage.processing-timeouts}", + "color": "#9c27b0", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": true, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.2679547062508352 + } + ], + "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f" + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 3600000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 10000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "Processing Failures and Timeouts", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "mobileHeight": null, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "widgetStyle": {}, + "useDashboardTimewindow": false, + "displayTimewindow": true, + "showLegend": true, + "actions": {}, + "legendConfig": { + "direction": "column", + "position": "bottom", + "showMin": true, + "showMax": true, + "showAvg": false, + "showTotal": true + } + }, + "id": "e43dcfe1-b970-6a11-ce0e-5769f3eb5e88" + }, + "a669cf86-e715-efa4-dd9a-b839abf499e9": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "timeseries_table", + "type": "timeseries", + "title": "New widget", + "sizeX": 24, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "dataKeys": [ + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Rule Chain", + "color": "#2196f3", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).ruleChainName;" + }, + "_hash": 0.9954481282345906 + }, + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Rule Node", + "color": "#4caf50", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).ruleNodeName;" + }, + "_hash": 0.18580357036589978 + }, + { + "name": "ruleEngineException", + "type": "timeseries", + "label": "Latest Error", + "color": "#f44336", + "settings": { + "useCellStyleFunction": false, + "useCellContentFunction": true, + "cellContentFunction": "return JSON.parse(value).message;" + }, + "_hash": 0.7255162989552142 + } + ], + "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f" + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 2592000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": { + "showTimestamp": true, + "displayPagination": true, + "defaultPageSize": 10 + }, + "title": "Exceptions", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "useDashboardTimewindow": false, + "showLegend": false, + "widgetStyle": {}, + "actions": {}, + "showTitleIcon": false, + "titleIcon": null, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "displayTimewindow": true + }, + "id": "a669cf86-e715-efa4-dd9a-b839abf499e9" + }, + "292eaded-4775-36f7-c896-98d57bdda936": { + "isSystemType": true, + "bundleAlias": "cards", + "typeAlias": "html_value_card", + "type": "latest", + "title": "New widget", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "emailApiState", + "type": "timeseries", + "label": "apiState", + "color": "#2196f3", + "settings": {}, + "_hash": 0.8830669138660703, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "emailLimit", + "type": "timeseries", + "label": "limit", + "color": "#4caf50", + "settings": {}, + "_hash": 0.5463603803546802, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "emailCount", + "type": "timeseries", + "label": "count", + "color": "#f44336", + "settings": {}, + "_hash": 0.5564241862015964, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportApiState", + "type": "timeseries", + "label": "cardId", + "color": "#607d8b", + "settings": {}, + "_hash": 0.051659774305067296, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return (Math.random()*100000).toFixed(0);" + }, + { + "name": "smsApiState", + "type": "timeseries", + "label": "apiStatePoint", + "color": "#e91e63", + "settings": {}, + "_hash": 0.2969682764607864, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "smsLimit", + "type": "timeseries", + "label": "pointsLimit", + "color": "#9c27b0", + "settings": {}, + "_hash": 0.22082255831864894, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "smsCount", + "type": "timeseries", + "label": "pointsCount", + "color": "#8bc34a", + "settings": {}, + "_hash": 0.6340356364819146, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + }, + { + "name": "transportApiState", + "type": "timeseries", + "label": "title", + "color": "#3f51b5", + "settings": {}, + "_hash": 0.6894070537030252, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": true, + "postFuncBody": "return \"{i18n:api-usage.notifications}\";" + } + ] + } + ], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "#666666", + "padding": "0", + "settings": { + "cardCss": ".card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 13px 13px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bars-row {\n flex: 1;\n display: flex;\n flex-direction: row;\n}\n\n.card .bar-column {\n flex: 1;\n display: flex;\n flex-direction: column;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n.card .mat-button {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.card .mat-button-wrapper {\n pointer-events: none;\n}\n\n.card .action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 6px;\n }\n .card .mat-button {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-button {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-button {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} \n\n", + "cardHtml": "
\n \n \n
\n
\n
\n
\n ${title}\n
\n
\n
\n
\n
\n
\n
{i18n:api-usage.email}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
{i18n:api-usage.sms}
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
" + }, + "title": "Notifications (Email/SMS)", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "cursor": "default" + }, + "titleStyle": { + "fontSize": "20px", + "fontWeight": 400 + }, + "useDashboardTimewindow": true, + "showLegend": false, + "actions": { + "elementClick": [ + { + "name": "transport_details", + "icon": "insert_chart", + "type": "openDashboardState", + "targetDashboardStateId": "notifications", + "setEntityId": false, + "stateEntityParamName": null, + "openInSeparateDialog": null, + "dialogTitle": null, + "dialogHideDashboardToolbar": true, + "dialogWidth": null, + "dialogHeight": null, + "openRightLayout": false, + "id": "46b7cefe-e1f2-67c1-4055-3a214520f869" + } + ] + }, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "" + }, + "row": 0, + "col": 0, + "id": "292eaded-4775-36f7-c896-98d57bdda936" + }, + "b3571a36-2106-1122-7d58-0d38bbb0e9c8": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "emailCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.email-messages}", + "color": "#d35a00", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000 + }, + "aggregation": { + "type": "SUM", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 1800000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.email-messages-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "b3571a36-2106-1122-7d58-0d38bbb0e9c8" + }, + "aa875b7f-e7c8-7529-1ae7-f456211b59cc": { + "isSystemType": true, + "bundleAlias": "charts", + "typeAlias": "timeseries_bars_flot", + "type": "timeseries", + "title": "New widget", + "sizeX": 8, + "sizeY": 5, + "config": { + "datasources": [ + { + "type": "entity", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, + "dataKeys": [ + { + "name": "emailCount", + "type": "timeseries", + "label": "{i18n:api-usage.email-messages}", + "color": "#d35a00", + "settings": { + "excludeFromStacking": false, + "hideDataByDefault": false, + "disableDataHiding": false, + "removeFromLegend": false, + "showLines": false, + "fillLines": false, + "showPoints": false, + "showPointShape": "circle", + "pointShapeFormatter": "", + "showPointsLineWidth": 5, + "showPointsRadius": 3, + "showSeparateAxis": false, + "axisPosition": "left", + "thresholds": [ + { + "thresholdValueSource": "predefinedValue" + } + ], + "comparisonSettings": { + "showValuesForComparison": true + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ] + } + ], + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 31536000000, + "interval": 1000 + }, + "aggregation": { + "type": "NONE", + "limit": 1000 + } + }, + "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", + "min": 0, + "tickDecimals": 0, + "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;" + }, + "grid": { + "color": "#545454", + "tickColor": "#DDDDDD", + "verticalLines": true, + "horizontalLines": true, + "outlineWidth": 1 + }, + "stack": false, + "tooltipIndividual": false, + "defaultBarWidth": 900000000, + "barAlignment": "left", + "timeForComparison": "months", + "xaxisSecond": { + "axisPosition": "top", + "showLabels": true + } + }, + "title": "{i18n:api-usage.email-messages-monthly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetStyle": {}, + "useDashboardTimewindow": false, + "showLegend": true, + "actions": {}, + "displayTimewindow": true, + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "legendConfig": { + "direction": "column", + "position": "bottom", + "sortDataKeys": false, + "showMin": false, + "showMax": false, + "showAvg": false, + "showTotal": true + } + }, + "row": 0, + "col": 0, + "id": "aa875b7f-e7c8-7529-1ae7-f456211b59cc" + } + }, + "states": { + "default": { + "name": "{i18n:api-usage.api-usage}", + "root": true, + "layouts": { + "main": { + "widgets": { + "fd6df872-2ddf-0921-3929-2e7f55062fad": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 8, + "mobileHeight": 3 + }, + "7e235874-461b-e7c2-2fdd-d8762a020773": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 12, + "mobileHeight": 3 + }, + "08545554-a0e8-05c7-66df-6000cfeff8a4": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 4, + "mobileHeight": 3 + }, + "a245c67e-53ec-d299-fa89-69fe2062ccb2": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 0, + "mobileHeight": 3 + }, + "a151ae60-0326-6116-d818-9070dda8e9c7": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 16, + "mobileHeight": 3 + }, + "292eaded-4775-36f7-c896-98d57bdda936": { + "sizeX": 4, + "sizeY": 2, + "row": 0, + "col": 20, + "mobileHeight": 3 + }, + "68e16e98-0420-f72c-4848-41dedffd3904": { + "sizeX": 8, + "sizeY": 4, + "row": 2, + "col": 8 + }, + "2aa6b499-6e27-b315-6833-89c4d58485ce": { + "sizeX": 8, + "sizeY": 4, + "row": 2, + "col": 0 + }, + "d890cea3-fba0-6474-9a21-fa780230dc62": { + "sizeX": 8, + "sizeY": 4, + "row": 2, + "col": 16 + }, + "84b6cfa5-1449-e0f2-3560-f810d2dd7ead": { + "sizeX": 8, + "sizeY": 4, + "row": 6, + "col": 0 + }, + "d296b566-a000-7402-ae9d-c815381c5435": { + "sizeX": 8, + "sizeY": 4, + "row": 6, + "col": 8 + }, + "00a02464-9509-911b-3b5e-21fb37629822": { + "sizeX": 8, + "sizeY": 4, + "row": 6, + "col": 16 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 5, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "transport": { + "name": "{i18n:api-usage.transport}", + "root": false, + "layouts": { + "main": { + "widgets": { + "98bae68b-0f35-72f2-a428-9b06889f1554": { + "sizeX": 24, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "e61e5477-5a09-cc25-966b-f613d81da833": { + "sizeX": 24, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "rule_engine_execution": { + "name": "{i18n:api-usage.rule-engine-executions}", + "root": false, + "layouts": { + "main": { + "widgets": { + "74199074-7873-6c6a-2a51-3fc614769f03": { + "sizeX": 24, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "00006bc3-8a8d-b55e-ed39-e318f1bcd090": { + "sizeX": 24, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "javascript_functions": { + "name": "{i18n:api-usage.javascript-functions}", + "root": false, + "layouts": { + "main": { + "widgets": { + "5f5ca59c-e507-5301-5910-7ad8cd34df40": { + "sizeX": 24, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "ada32ee9-44ed-48d1-c368-fd0c94b7607f": { + "sizeX": 24, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "telemetry_persistence": { + "name": "{i18n:api-usage.telemetry-persistence}", + "root": false, + "layouts": { + "main": { + "widgets": { + "85fe0738-5326-f069-ab3f-30594bde5fed": { + "sizeX": 24, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "eaeb381a-437e-f6e9-60c9-6bc8826fdd44": { + "sizeX": 24, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "rule_engine_statistics": { + "name": "{i18n:api-usage.rule-engine-statistics}", + "root": false, + "layouts": { + "main": { + "widgets": { + "2408ad30-163e-8221-08e1-a82b638be564": { + "sizeX": 12, + "sizeY": 7, + "mobileHeight": null, + "row": 0, + "col": 0 + }, + "e43dcfe1-b970-6a11-ce0e-5769f3eb5e88": { + "sizeX": 12, + "sizeY": 7, + "mobileHeight": null, + "row": 0, + "col": 12 + }, + "a669cf86-e715-efa4-dd9a-b839abf499e9": { + "sizeX": 24, + "sizeY": 5, + "row": 7, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "notifications": { + "name": "{i18n:api-usage.notifications-email-sms}", + "root": false, + "layouts": { + "main": { + "widgets": { + "7302df65-1b0c-579e-bbdb-145126ae3392": { + "sizeX": 12, + "sizeY": 6, + "row": 0, + "col": 12 + }, + "fdb385e7-14fe-fc9f-ebdc-b400f26fc66b": { + "sizeX": 12, + "sizeY": 6, + "row": 6, + "col": 12 + }, + "b3571a36-2106-1122-7d58-0d38bbb0e9c8": { + "sizeX": 12, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "aa875b7f-e7c8-7529-1ae7-f456211b59cc": { + "sizeX": 12, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + }, + "alarms_created": { + "name": "{i18n:api-usage.alarms-created}", + "root": false, + "layouts": { + "main": { + "widgets": { + "4b798823-b97d-9d6a-59dc-fcafd897fc23": { + "sizeX": 24, + "sizeY": 6, + "row": 0, + "col": 0 + }, + "6a981580-7490-19dd-f937-b64cbf67a982": { + "sizeX": 24, + "sizeY": 6, + "row": 6, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 10, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70 + } + } + } + } + }, + "entityAliases": { + "40193437-33ac-3172-eefd-0b08eb849062": { + "id": "40193437-33ac-3172-eefd-0b08eb849062", + "alias": "Api usage state", + "filter": { + "type": "apiUsageState", + "resolveMultiple": false + } + }, + "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f": { + "id": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f", + "alias": "TbServiceQueues", + "filter": { + "type": "assetType", + "resolveMultiple": true, + "assetType": "TbServiceQueue", + "assetNameFilter": "" + } + } + }, + "filters": {}, + "timewindow": { + "hideInterval": false, + "hideAggregation": false, + "hideAggInterval": false, + "selectedTab": 0, + "realtime": { + "timewindowMs": 86400000, + "interval": 3600000 + }, + "aggregation": { + "type": "NONE", + "limit": 50000 + } + }, + "settings": { + "stateControllerId": "entity", + "showTitle": false, + "showDashboardsSelect": false, + "showEntitiesSelect": false, + "showDashboardTimewindow": false, + "showDashboardExport": false, + "toolbarAlwaysOpen": true, + "titleColor": "rgba(0,0,0,0.870588)", + "showFilters": false, + "showDashboardLogo": false + } + }, + "name": "Api Usage" +} diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts new file mode 100644 index 0000000..5ed66c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-routing.module.ts @@ -0,0 +1,74 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { AssetProfilesTableConfigResolver } from './asset-profiles-table-config.resolver'; + +export const assetProfilesRoutes: Routes = [ + { + path: 'assetProfiles', + data: { + breadcrumb: { + label: 'asset-profile.asset-profiles', + icon: 'mdi:alpha-a-box' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'asset-profile.asset-profiles' + }, + resolve: { + entitiesTableConfig: AssetProfilesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'mdi:alpha-a-box' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'asset-profile.asset-profiles' + }, + resolve: { + entitiesTableConfig: AssetProfilesTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + providers: [ + AssetProfilesTableConfigResolver + ] +}) +export class AssetProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html new file mode 100644 index 0000000..4043a19 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html @@ -0,0 +1,27 @@ + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts new file mode 100644 index 0000000..381ae1d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { AssetProfile } from '@shared/models/asset.models'; + +@Component({ + selector: 'tb-asset-profile-tabs', + templateUrl: './asset-profile-tabs.component.html', + styleUrls: [] +}) +export class AssetProfileTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts new file mode 100644 index 0000000..09f2c53 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { AssetProfileTabsComponent } from './asset-profile-tabs.component'; +import { AssetProfileRoutingModule } from './asset-profile-routing.module'; + +@NgModule({ + declarations: [ + AssetProfileTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + AssetProfileRoutingModule + ] +}) +export class AssetProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts new file mode 100644 index 0000000..f33eccf --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts @@ -0,0 +1,190 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Resolve, Router } from '@angular/router'; +import { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { DialogService } from '@core/services/dialog.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; +import { AssetProfile, TB_SERVICE_QUEUE } from '@shared/models/asset.models'; +import { AssetProfileService } from '@core/http/asset-profile.service'; +import { AssetProfileComponent } from '@home/components/profile/asset-profile.component'; +import { AssetProfileTabsComponent } from './asset-profile-tabs.component'; + +@Injectable() +export class AssetProfilesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private assetProfileService: AssetProfileService, + private importExport: ImportExportService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private dialogService: DialogService, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.ASSET_PROFILE; + this.config.entityComponent = AssetProfileComponent; + this.config.entityTabsComponent = AssetProfileTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.ASSET_PROFILE); + this.config.entityResources = entityTypeResources.get(EntityType.ASSET_PROFILE); + + this.config.hideDetailsTabsOnEdit = false; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'asset-profile.name', '50%'), + new EntityTableColumn('description', 'asset-profile.description', '50%'), + new EntityTableColumn('isDefault', 'asset-profile.default', '60px', + entity => { + return checkBoxCell(entity.default); + }) + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('asset-profile.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportAssetProfile($event, entity) + }, + { + name: this.translate.instant('asset-profile.set-default'), + icon: 'flag', + isEnabled: (assetProfile) => !assetProfile.default && TB_SERVICE_QUEUE !== assetProfile.name, + onAction: ($event, entity) => this.setDefaultAssetProfile($event, entity) + } + ); + + this.config.deleteEntityTitle = assetProfile => this.translate.instant('asset-profile.delete-asset-profile-title', + { assetProfileName: assetProfile.name }); + this.config.deleteEntityContent = () => this.translate.instant('asset-profile.delete-asset-profile-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('asset-profile.delete-asset-profiles-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('asset-profile.delete-asset-profiles-text'); + + this.config.entitiesFetchFunction = pageLink => this.assetProfileService.getAssetProfiles(pageLink); + this.config.loadEntity = id => this.assetProfileService.getAssetProfile(id.id); + this.config.saveEntity = assetProfile => this.assetProfileService.saveAssetProfile(assetProfile); + this.config.deleteEntity = id => this.assetProfileService.deleteAssetProfile(id.id); + this.config.onEntityAction = action => this.onAssetProfileAction(action); + this.config.deleteEnabled = (assetProfile) => assetProfile && !assetProfile.default && TB_SERVICE_QUEUE !== assetProfile.name; + this.config.entitySelectionEnabled = (assetProfile) => assetProfile && !assetProfile.default && TB_SERVICE_QUEUE !== assetProfile.name; + this.config.detailsReadonly = (assetProfile) => assetProfile && TB_SERVICE_QUEUE === assetProfile.name; + this.config.addActionDescriptors = this.configureAddActions(); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('asset-profile.asset-profiles'); + + return this.config; + } + + configureAddActions(): Array { + const actions: Array = []; + actions.push( + { + name: this.translate.instant('asset-profile.create-asset-profile'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('asset-profile.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importAssetProfile($event) + } + ); + return actions; + } + + setDefaultAssetProfile($event: Event, assetProfile: AssetProfile) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('asset-profile.set-default-asset-profile-title', {assetProfileName: assetProfile.name}), + this.translate.instant('asset-profile.set-default-asset-profile-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.assetProfileService.setDefaultAssetProfile(assetProfile.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + private openAssetProfile($event: Event, assetProfile: AssetProfile) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree(['profiles', 'assetProfiles', assetProfile.id.id]); + this.router.navigateByUrl(url); + } + + importAssetProfile($event: Event) { + this.importExport.importAssetProfile().subscribe( + (assetProfile) => { + if (assetProfile) { + this.config.updateData(); + } + } + ); + } + + exportAssetProfile($event: Event, assetProfile: AssetProfile) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportAssetProfile(assetProfile.id.id); + } + + onAssetProfileAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openAssetProfile(action.event, action.entity); + return true; + case 'setDefault': + this.setDefaultAssetProfile(action.event, action.entity); + return true; + case 'export': + this.exportAssetProfile(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts new file mode 100644 index 0000000..b931257 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts @@ -0,0 +1,78 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { AssetsTableConfigResolver } from './assets-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; + +const routes: Routes = [ + { + path: 'assets', + data: { + breadcrumb: { + label: 'asset.assets', + icon: 'domain' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'asset.assets', + assetsType: 'tenant' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'domain' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'asset.assets', + assetsType: 'tenant' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + AssetsTableConfigResolver + ] +}) +export class AssetRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html new file mode 100644 index 0000000..1678e34 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html @@ -0,0 +1,23 @@ + + + diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.scss new file mode 100644 index 0000000..488e5b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + flex: 1; + display: flex; + justify-content: flex-start; + min-width: 150px; +} + +:host ::ng-deep { + tb-asset-profile-autocomplete { + width: 100%; + + mat-form-field { + font-size: 16px; + + .mat-form-field-wrapper { + padding-bottom: 0; + } + + .mat-form-field-underline { + bottom: 0; + } + + @media #{$mat-xs} { + width: 100%; + + .mat-form-field-infix { + width: auto !important; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.ts new file mode 100644 index 0000000..874f119 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AssetInfo } from '@shared/models/asset.models'; +import { AssetProfileId } from '@shared/models/id/asset-profile-id'; + +@Component({ + selector: 'tb-asset-table-header', + templateUrl: './asset-table-header.component.html', + styleUrls: ['./asset-table-header.component.scss'] +}) +export class AssetTableHeaderComponent extends EntityTableHeaderComponent { + + entityType = EntityType; + + constructor(protected store: Store) { + super(store); + } + + assetProfileChanged(assetProfileId: AssetProfileId) { + this.entitiesTableConfig.componentsData.assetProfileId = assetProfileId; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html new file mode 100644 index 0000000..22ab3d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts new file mode 100644 index 0000000..8b2622d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { AssetInfo } from '@app/shared/models/asset.models'; + +@Component({ + selector: 'tb-asset-tabs', + templateUrl: './asset-tabs.component.html', + styleUrls: [] +}) +export class AssetTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset.component.html new file mode 100644 index 0000000..16229a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.component.html @@ -0,0 +1,111 @@ + +
+ + + + + + +
+ +
+
+
+ + asset.assignedToCustomer + + +
+ {{ 'asset.asset-public' | translate }} +
+ +
+ + asset.name + + + {{ 'asset.name-required' | translate }} + + + {{ 'asset.name-max-length' | translate }} + + + + + + asset.label + + + {{ 'asset.label-max-length' | translate }} + + +
+ + asset.description + + +
+
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.component.scss b/ui-ngx/src/app/modules/home/pages/asset/asset.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts new file mode 100644 index 0000000..14ca4b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts @@ -0,0 +1,103 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { AssetInfo } from '@app/shared/models/asset.models'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; + +@Component({ + selector: 'tb-asset', + templateUrl: './asset.component.html', + styleUrls: ['./asset.component.scss'] +}) +export class AssetComponent extends EntityComponent { + + entityType = EntityType; + + assetScope: 'tenant' | 'customer' | 'customer_user' | 'edge'; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: AssetInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.assetScope = this.entitiesTableConfig.componentsData.assetScope; + super.ngOnInit(); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + isAssignedToCustomer(entity: AssetInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } + + buildForm(entity: AssetInfo): FormGroup { + return this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + assetProfileId: [entity ? entity.assetProfileId : null, [Validators.required]], + label: [entity ? entity.label : '', Validators.maxLength(255)], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + } + ) + } + ); + } + + updateForm(entity: AssetInfo) { + this.entityForm.patchValue({name: entity.name}); + this.entityForm.patchValue({assetProfileId: entity.assetProfileId}); + this.entityForm.patchValue({label: entity.label}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + } + + + onAssetIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('asset.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onAssetProfileUpdated() { + this.entitiesTableConfig.updateData(false); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts new file mode 100644 index 0000000..e2c7465 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts @@ -0,0 +1,41 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; +import { AssetComponent } from './asset.component'; +import { AssetTableHeaderComponent } from './asset-table-header.component'; +import { AssetRoutingModule } from './asset-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; + +@NgModule({ + declarations: [ + AssetComponent, + AssetTabsComponent, + AssetTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeDialogsModule, + AssetRoutingModule + ] +}) +export class AssetModule { } diff --git a/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts new file mode 100644 index 0000000..1e24e75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts @@ -0,0 +1,565 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { selectAuthUser } from '@core/auth/auth.selectors'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { AppState } from '@core/core.state'; +import { Authority } from '@app/shared/models/authority.enum'; +import { CustomerService } from '@core/http/customer.service'; +import { Customer } from '@app/shared/models/customer.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { + AssignToCustomerDialogComponent, + AssignToCustomerDialogData +} from '@modules/home/dialogs/assign-to-customer-dialog.component'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; +import { Asset, AssetInfo } from '@app/shared/models/asset.models'; +import { AssetService } from '@app/core/http/asset.service'; +import { AssetComponent } from '@modules/home/pages/asset/asset.component'; +import { AssetTableHeaderComponent } from '@modules/home/pages/asset/asset-table-header.component'; +import { AssetId } from '@app/shared/models/id/asset-id'; +import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; +import { DeviceInfo } from '@shared/models/device.models'; +import { EdgeService } from '@core/http/edge.service'; +import { + AddEntitiesToEdgeDialogComponent, + AddEntitiesToEdgeDialogData +} from '@home/dialogs/add-entities-to-edge-dialog.component'; + +@Injectable() +export class AssetsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + private customerId: string; + + constructor(private store: Store, + private broadcast: BroadcastService, + private assetService: AssetService, + private customerService: CustomerService, + private edgeService: EdgeService, + private dialogService: DialogService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.ASSET; + this.config.entityComponent = AssetComponent; + this.config.entityTabsComponent = AssetTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.ASSET); + this.config.entityResources = entityTypeResources.get(EntityType.ASSET); + + this.config.deleteEntityTitle = asset => this.translate.instant('asset.delete-asset-title', {assetName: asset.name}); + this.config.deleteEntityContent = () => this.translate.instant('asset.delete-asset-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('asset.delete-assets-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('asset.delete-assets-text'); + + this.config.loadEntity = id => this.assetService.getAssetInfo(id.id); + this.config.saveEntity = asset => { + return this.assetService.saveAsset(asset).pipe( + tap(() => { + this.broadcast.broadcast('assetSaved'); + }), + mergeMap((savedAsset) => this.assetService.getAssetInfo(savedAsset.id.id) + )); + }; + this.config.onEntityAction = action => this.onAssetAction(action, this.config); + this.config.detailsReadonly = () => (this.config.componentsData.assetScope === 'customer_user' || + this.config.componentsData.assetScope === 'edge_customer_user'); + + this.config.headerComponent = AssetTableHeaderComponent; + + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + this.config.componentsData = { + assetScope: route.data.assetsType, + assetProfileId: null, + assetType: '', + edgeId: routeParams.edgeId + }; + this.customerId = routeParams.customerId; + return this.store.pipe(select(selectAuthUser), take(1)).pipe( + tap((authUser) => { + if (authUser.authority === Authority.CUSTOMER_USER) { + if (route.data.assetsType === 'edge') { + this.config.componentsData.assetScope = 'edge_customer_user'; + } else { + this.config.componentsData.assetScope = 'customer_user'; + } + this.customerId = authUser.customerId; + } + }), + mergeMap(() => + this.customerId ? this.customerService.getCustomer(this.customerId) : of(null as Customer) + ), + map((parentCustomer) => { + if (parentCustomer) { + if (parentCustomer.additionalInfo && parentCustomer.additionalInfo.isPublic) { + this.config.tableTitle = this.translate.instant('customer.public-assets'); + } else { + this.config.tableTitle = parentCustomer.title + ': ' + this.translate.instant('asset.assets'); + } + } else if (this.config.componentsData.assetScope === 'edge') { + this.edgeService.getEdge(this.config.componentsData.edgeId).subscribe( + edge => this.config.tableTitle = edge.name + ': ' + this.translate.instant('asset.assets') + ); + } else { + this.config.tableTitle = this.translate.instant('asset.assets'); + } + this.config.columns = this.configureColumns(this.config.componentsData.assetScope); + this.configureEntityFunctions(this.config.componentsData.assetScope); + this.config.cellActionDescriptors = this.configureCellActions(this.config.componentsData.assetScope); + this.config.groupActionDescriptors = this.configureGroupActions(this.config.componentsData.assetScope); + this.config.addActionDescriptors = this.configureAddActions(this.config.componentsData.assetScope); + this.config.addEnabled = !(this.config.componentsData.assetScope === 'customer_user' || this.config.componentsData.assetScope === 'edge_customer_user'); + this.config.entitiesDeleteEnabled = this.config.componentsData.assetScope === 'tenant'; + this.config.deleteEnabled = () => this.config.componentsData.assetScope === 'tenant'; + return this.config; + }) + ); + } + + configureColumns(assetScope: string): Array> { + const columns: Array> = [ + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'asset.name', '25%'), + new EntityTableColumn('assetProfileName', 'asset-profile.asset-profile', '25%'), + new EntityTableColumn('label', 'asset.label', '25%'), + ]; + if (assetScope === 'tenant') { + columns.push( + new EntityTableColumn('customerTitle', 'customer.customer', '25%'), + new EntityTableColumn('customerIsPublic', 'asset.public', '60px', + entity => { + return checkBoxCell(entity.customerIsPublic); + }, () => ({}), false), + ); + } + return columns; + } + + configureEntityFunctions(assetScope: string): void { + if (assetScope === 'tenant') { + this.config.entitiesFetchFunction = pageLink => + this.assetService.getTenantAssetInfosByAssetProfileId(pageLink, this.config.componentsData.assetProfileId !== null ? + this.config.componentsData.assetProfileId.id : ''); + this.config.deleteEntity = id => this.assetService.deleteAsset(id.id); + } else if (assetScope === 'edge' || assetScope === 'edge_customer_user') { + this.config.entitiesFetchFunction = pageLink => + this.assetService.getEdgeAssets(this.config.componentsData.edgeId, pageLink, this.config.componentsData.assetType); + } else { + this.config.entitiesFetchFunction = pageLink => + this.assetService.getCustomerAssetInfosByAssetProfileId(this.customerId, pageLink, + this.config.componentsData.assetProfileId !== null ? this.config.componentsData.assetProfileId.id : ''); + this.config.deleteEntity = id => this.assetService.unassignAssetFromCustomer(id.id); + } + } + + configureCellActions(assetScope: string): Array> { + const actions: Array> = []; + if (assetScope === 'tenant') { + actions.push( + { + name: this.translate.instant('asset.make-public'), + icon: 'share', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.makePublic($event, entity) + }, + { + name: this.translate.instant('asset.assign-to-customer'), + icon: 'assignment_ind', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.assignToCustomer($event, [entity.id]) + }, + { + name: this.translate.instant('asset.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('asset.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + } + ); + } + if (assetScope === 'customer') { + actions.push( + { + name: this.translate.instant('asset.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('asset.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + } + ); + } + if (assetScope === 'edge') { + actions.push( + { + name: this.translate.instant('edge.unassign-from-edge'), + icon: 'assignment_return', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.unassignFromEdge($event, entity) + } + ); + } + return actions; + } + + configureGroupActions(assetScope: string): Array> { + const actions: Array> = []; + if (assetScope === 'tenant') { + actions.push( + { + name: this.translate.instant('asset.assign-assets'), + icon: 'assignment_ind', + isEnabled: true, + onAction: ($event, entities) => this.assignToCustomer($event, entities.map((entity) => entity.id)) + } + ); + } + if (assetScope === 'customer') { + actions.push( + { + name: this.translate.instant('asset.unassign-assets'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignAssetsFromCustomer($event, entities) + } + ); + } + if (assetScope === 'edge') { + actions.push( + { + name: this.translate.instant('asset.unassign-assets-from-edge'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignAssetsFromEdge($event, entities) + } + ); + } + return actions; + } + + configureAddActions(assetScope: string): Array { + const actions: Array = []; + if (assetScope === 'tenant') { + actions.push( + { + name: this.translate.instant('asset.add-asset-text'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('asset.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importAssets($event) + } + ); + } + if (assetScope === 'customer') { + actions.push( + { + name: this.translate.instant('asset.assign-new-asset'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addAssetsToCustomer($event) + } + ); + } + if (assetScope === 'edge') { + actions.push( + { + name: this.translate.instant('asset.assign-new-asset'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addAssetsToEdge($event) + } + ); + } + return actions; + } + + importAssets($event: Event) { + this.homeDialogs.importEntities(EntityType.ASSET).subscribe((res) => { + if (res) { + this.broadcast.broadcast('assetSaved'); + this.config.updateData(); + } + }); + } + + private openAsset($event: Event, asset: Asset, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([asset.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + addAssetsToCustomer($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.customerId, + entityType: EntityType.ASSET + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + makePublic($event: Event, asset: Asset) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('asset.make-public-asset-title', {assetName: asset.name}), + this.translate.instant('asset.make-public-asset-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.assetService.makeAssetPublic(asset.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + assignToCustomer($event: Event, assetIds: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AssignToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityIds: assetIds, + entityType: EntityType.ASSET + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromCustomer($event: Event, asset: AssetInfo) { + if ($event) { + $event.stopPropagation(); + } + const isPublic = asset.customerIsPublic; + let title; + let content; + if (isPublic) { + title = this.translate.instant('asset.make-private-asset-title', {assetName: asset.name}); + content = this.translate.instant('asset.make-private-asset-text'); + } else { + title = this.translate.instant('asset.unassign-asset-title', {assetName: asset.name}); + content = this.translate.instant('asset.unassign-asset-text'); + } + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.assetService.unassignAssetFromCustomer(asset.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.assetScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignAssetsFromCustomer($event: Event, assets: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('asset.unassign-assets-title', {count: assets.length}), + this.translate.instant('asset.unassign-assets-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + assets.forEach( + (asset) => { + tasks.push(this.assetService.unassignAssetFromCustomer(asset.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + onAssetAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openAsset(action.event, action.entity, config); + return true; + case 'makePublic': + this.makePublic(action.event, action.entity); + return true; + case 'assignToCustomer': + this.assignToCustomer(action.event, [action.entity.id]); + return true; + case 'unassignFromCustomer': + this.unassignFromCustomer(action.event, action.entity); + return true; + case 'unassignFromEdge': + this.unassignFromEdge(action.event, action.entity); + return true; + } + return false; + } + + addAssetsToEdge($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToEdgeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + edgeId: this.config.componentsData.edgeId, + entityType: EntityType.ASSET + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromEdge($event: Event, asset: AssetInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('asset.unassign-asset-from-edge-title', {assetName: asset.name}), + this.translate.instant('asset.unassign-asset-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.assetService.unassignAssetFromEdge(this.config.componentsData.edgeId, asset.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.assetScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignAssetsFromEdge($event: Event, assets: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('asset.unassign-assets-from-edge-title', {count: assets.length}), + this.translate.instant('asset.unassign-assets-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + assets.forEach( + (asset) => { + tasks.push(this.assetService.unassignAssetFromEdge(this.config.componentsData.edgeId, asset.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts new file mode 100644 index 0000000..17e5b8c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { AuditLogTableComponent } from '@home/components/audit-log/audit-log-table.component'; + +const routes: Routes = [ + { + path: 'auditLogs', + component: AuditLogTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'audit-log.audit-logs', + breadcrumb: { + label: 'audit-log.audit-logs', + icon: 'track_changes' + }, + isPage: true + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AuditLogRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log.module.ts b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log.module.ts new file mode 100644 index 0000000..adf7645 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log.module.ts @@ -0,0 +1,31 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AuditLogRoutingModule } from '@modules/home/pages/audit-log/audit-log-routing.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + CommonModule, + SharedModule, + AuditLogRoutingModule + ] +}) +export class AuditLogModule { } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts new file mode 100644 index 0000000..d2c0295 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts @@ -0,0 +1,280 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { UsersTableConfigResolver } from '../user/users-table-config.resolver'; +import { CustomersTableConfigResolver } from './customers-table-config.resolver'; +import { DevicesTableConfigResolver } from '@modules/home/pages/device/devices-table-config.resolver'; +import { AssetsTableConfigResolver } from '../asset/assets-table-config.resolver'; +import { DashboardsTableConfigResolver } from '@modules/home/pages/dashboard/dashboards-table-config.resolver'; +import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { dashboardBreadcumbLabelFunction, DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module'; +import { EdgesTableConfigResolver } from '@home/pages/edge/edges-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; + +const routes: Routes = [ + { + path: 'customers', + data: { + breadcrumb: { + label: 'customer.customers', + icon: 'supervisor_account' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.customers' + }, + resolve: { + entitiesTableConfig: CustomersTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'supervisor_account' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'customer.customers' + }, + resolve: { + entitiesTableConfig: CustomersTableConfigResolver + } + }, + { + path: ':customerId/users', + data: { + breadcrumb: { + label: 'user.customer-users', + icon: 'account_circle' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'user.customer-users' + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'account_circle' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'user.customer-users' + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + } + ] + }, + { + path: ':customerId/devices', + data: { + breadcrumb: { + label: 'customer.devices', + icon: 'devices_other' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.devices', + devicesType: 'customer' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'customer.devices', + devicesType: 'customer' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + } + ] + }, + { + path: ':customerId/assets', + data: { + breadcrumb: { + label: 'customer.assets', + icon: 'domain' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.assets', + assetsType: 'customer' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'domain' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'customer.assets', + assetsType: 'customer' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + } + ] + }, + { + path: ':customerId/edgeInstances', + data: { + breadcrumb: { + label: 'customer.edges', + icon: 'router' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.edges', + edgesType: 'customer' + }, + resolve: { + entitiesTableConfig: EdgesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'router' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'customer.edges', + edgesType: 'customer' + }, + resolve: { + entitiesTableConfig: EdgesTableConfigResolver + } + } + ] + }, + { + path: ':customerId/dashboards', + data: { + breadcrumb: { + label: 'customer.dashboards', + icon: 'dashboard' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.dashboards', + dashboardsType: 'customer' + }, + resolve: { + entitiesTableConfig: DashboardsTableConfigResolver + } + }, + { + path: ':dashboardId', + component: DashboardPageComponent, + data: { + breadcrumb: { + labelFunction: dashboardBreadcumbLabelFunction, + icon: 'dashboard' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'customer.dashboard', + widgetEditMode: false + }, + resolve: { + dashboard: DashboardResolver + } + } + ] + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + CustomersTableConfigResolver + ] +}) +export class CustomerRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html new file mode 100644 index 0000000..e6b374b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts new file mode 100644 index 0000000..118e016 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { Customer } from '@shared/models/customer.model'; + +@Component({ + selector: 'tb-customer-tabs', + templateUrl: './customer-tabs.component.html', + styleUrls: [] +}) +export class CustomerTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.component.html b/ui-ngx/src/app/modules/home/pages/customer/customer.component.html new file mode 100644 index 0000000..48deaf9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.component.html @@ -0,0 +1,110 @@ + +
+ + + + + + + +
+ +
+
+
+
+
+ + customer.title + + + {{ 'customer.title-required' | translate }} + + + {{ 'customer.title-max-length' | translate }} + + +
+ + customer.description + + +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.component.scss b/ui-ngx/src/app/modules/home/pages/customer/customer.component.scss new file mode 100644 index 0000000..7c14ed9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + .tb-default-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts b/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts new file mode 100644 index 0000000..c96f8a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.component.ts @@ -0,0 +1,99 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Customer } from '@shared/models/customer.model'; +import { ActionNotificationShow } from '@app/core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { ContactBasedComponent } from '../../components/entity/contact-based.component'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AuthState } from '@core/auth/auth.models'; + +@Component({ + selector: 'tb-customer', + templateUrl: './customer.component.html', + styleUrls: ['./customer.component.scss'] +}) +export class CustomerComponent extends ContactBasedComponent { + + isPublic = false; + + authState: AuthState = getCurrentAuthState(this.store); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: Customer, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildEntityForm(entity: Customer): FormGroup { + return this.fb.group( + { + title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + homeDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null], + homeDashboardHideToolbar: [entity && entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true] + } + ) + } + ); + } + + updateEntityForm(entity: Customer) { + this.isPublic = entity.additionalInfo && entity.additionalInfo.isPublic; + this.entityForm.patchValue({title: entity.title}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardId: entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardHideToolbar: entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true}}); + } + + onCustomerIdCopied(event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('customer.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + edgesSupportEnabled() { + return this.authState.edgesSupportEnabled; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.module.ts b/ui-ngx/src/app/modules/home/pages/customer/customer.module.ts new file mode 100644 index 0000000..3c80073 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.module.ts @@ -0,0 +1,37 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { CustomerComponent } from '@modules/home/pages/customer/customer.component'; +import { CustomerRoutingModule } from './customer-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { CustomerTabsComponent } from '@home/pages/customer/customer-tabs.component'; + +@NgModule({ + declarations: [ + CustomerComponent, + CustomerTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + CustomerRoutingModule + ] +}) +export class CustomerModule { } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts new file mode 100644 index 0000000..4533f18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts @@ -0,0 +1,209 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { Resolve, Router } from '@angular/router'; + +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { Customer } from '@app/shared/models/customer.model'; +import { CustomerService } from '@app/core/http/customer.service'; +import { CustomerComponent } from '@modules/home/pages/customer/customer.component'; +import { CustomerTabsComponent } from '@home/pages/customer/customer-tabs.component'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; + +@Injectable() +export class CustomersTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private customerService: CustomerService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private store: Store) { + + this.config.entityType = EntityType.CUSTOMER; + this.config.entityComponent = CustomerComponent; + this.config.entityTabsComponent = CustomerTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.CUSTOMER); + this.config.entityResources = entityTypeResources.get(EntityType.CUSTOMER); + const authState = getCurrentAuthState(this.store); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'customer.title', '25%'), + new EntityTableColumn('email', 'contact.email', '25%'), + new EntityTableColumn('country', 'contact.country', '25%'), + new EntityTableColumn('city', 'contact.city', '25%') + ); + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('customer.manage-customer-users'), + icon: 'account_circle', + isEnabled: (customer) => !customer.additionalInfo || !customer.additionalInfo.isPublic, + onAction: ($event, entity) => this.manageCustomerUsers($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-assets'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-assets') + : this.translate.instant('customer.manage-customer-assets'); + }, + icon: 'domain', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerAssets($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-devices'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-devices') + : this.translate.instant('customer.manage-customer-devices'); + }, + icon: 'devices_other', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerDevices($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-dashboards'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-dashboards') + : this.translate.instant('customer.manage-customer-dashboards'); + }, + icon: 'dashboard', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerDashboards($event, entity) + }); + if (authState.edgesSupportEnabled) { + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('customer.manage-customer-edges'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-edges') + : this.translate.instant('customer.manage-customer-edges'); + }, + icon: 'router', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerEdges($event, entity) + } + ); + } + + this.config.deleteEntityTitle = customer => this.translate.instant('customer.delete-customer-title', { customerTitle: customer.title }); + this.config.deleteEntityContent = () => this.translate.instant('customer.delete-customer-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('customer.delete-customers-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('customer.delete-customers-text'); + + this.config.entitiesFetchFunction = pageLink => this.customerService.getCustomers(pageLink); + this.config.loadEntity = id => this.customerService.getCustomer(id.id); + this.config.saveEntity = customer => this.customerService.saveCustomer(customer); + this.config.deleteEntity = id => this.customerService.deleteCustomer(id.id); + this.config.onEntityAction = action => this.onCustomerAction(action, this.config); + this.config.deleteEnabled = (customer) => customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic); + this.config.entitySelectionEnabled = (customer) => customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic); + this.config.detailsReadonly = (customer) => customer && customer.additionalInfo && customer.additionalInfo.isPublic; + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('customer.customers'); + + return this.config; + } + + private openCustomer($event: Event, customer: Customer, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([customer.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + manageCustomerUsers($event: Event, customer: Customer) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`customers/${customer.id.id}/users`); + } + + manageCustomerAssets($event: Event, customer: Customer) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`customers/${customer.id.id}/assets`); + } + + manageCustomerDevices($event: Event, customer: Customer) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`customers/${customer.id.id}/devices`); + } + + manageCustomerDashboards($event: Event, customer: Customer) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`customers/${customer.id.id}/dashboards`); + } + + manageCustomerEdges($event: Event, customer: Customer) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`customers/${customer.id.id}/edgeInstances`); + } + + onCustomerAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openCustomer(action.event, action.entity, config); + return true; + case 'manageUsers': + this.manageCustomerUsers(action.event, action.entity); + return true; + case 'manageAssets': + this.manageCustomerAssets(action.event, action.entity); + return true; + case 'manageDevices': + this.manageCustomerDevices(action.event, action.entity); + return true; + case 'manageDashboards': + this.manageCustomerDashboards(action.event, action.entity); + return true; + case 'manageEdges': + this.manageCustomerEdges(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html new file mode 100644 index 0000000..342909f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html @@ -0,0 +1,142 @@ + +
+ + + + + + + + +
+
+ +
+
+ + dashboard.assignedToCustomers + + +
+ + +
+ + dashboard.public-link + + + +
+
+
+
+ + dashboard.title + + + {{ 'dashboard.title-required' | translate }} + + + {{ 'dashboard.title-max-length' | translate }} + + +
+ + dashboard.description + + +
+
dashboard.mobile-app-settings
+ + + + {{ 'dashboard.mobile-hide' | translate }} + + + dashboard.mobile-order + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.scss b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts new file mode 100644 index 0000000..c124c90 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.ts @@ -0,0 +1,139 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { + Dashboard, + getDashboardAssignedCustomersText, + isCurrentPublicDashboardCustomer, + isPublicDashboard +} from '@shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-dashboard-form', + templateUrl: './dashboard-form.component.html', + styleUrls: ['./dashboard-form.component.scss'] +}) +export class DashboardFormComponent extends EntityComponent { + + dashboardScope: 'tenant' | 'customer' | 'customer_user' | 'edge'; + customerId: string; + + publicLink: string; + assignedCustomersText: string; + + constructor(protected store: Store, + protected translate: TranslateService, + private dashboardService: DashboardService, + @Inject('entity') protected entityValue: Dashboard, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.dashboardScope = this.entitiesTableConfig.componentsData.dashboardScope; + this.customerId = this.entitiesTableConfig.componentsData.customerId; + super.ngOnInit(); + } + + isPublic(entity: Dashboard): boolean { + return isPublicDashboard(entity); + } + + isCurrentPublicCustomer(entity: Dashboard): boolean { + return isCurrentPublicDashboardCustomer(entity, this.customerId); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: Dashboard): FormGroup { + this.updateFields(entity); + return this.fb.group( + { + title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], + image: [entity ? entity.image : null], + mobileHide: [entity ? entity.mobileHide : false], + mobileOrder: [entity ? entity.mobileOrder : null, [Validators.pattern(/^-?[0-9]+$/)]], + configuration: this.fb.group( + { + description: [entity && entity.configuration ? entity.configuration.description : ''], + } + ) + } + ); + } + + updateForm(entity: Dashboard) { + this.updateFields(entity); + this.entityForm.patchValue({title: entity.title}); + this.entityForm.patchValue({image: entity.image}); + this.entityForm.patchValue({mobileHide: entity.mobileHide}); + this.entityForm.patchValue({mobileOrder: entity.mobileOrder}); + this.entityForm.patchValue({configuration: {description: entity.configuration ? entity.configuration.description : ''}}); + } + + prepareFormValue(formValue: any): any { + const preparedValue = super.prepareFormValue(formValue); + preparedValue.configuration = {...(this.entity.configuration || {}), ...(preparedValue.configuration || {})}; + return preparedValue; + } + + onPublicLinkCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('dashboard.public-link-copied-message'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onDashboardIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('dashboard.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + private updateFields(entity: Dashboard): void { + if (entity && !isEqual(entity, {})) { + this.assignedCustomersText = getDashboardAssignedCustomersText(entity); + this.publicLink = this.dashboardService.getPublicDashboardLink(entity); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts new file mode 100644 index 0000000..6b85ade --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-routing.module.ts @@ -0,0 +1,100 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { DashboardsTableConfigResolver } from './dashboards-table-config.resolver'; +import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; +import { Observable } from 'rxjs'; +import { Dashboard } from '@app/shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class DashboardResolver implements Resolve { + + constructor(private dashboardService: DashboardService, + private dashboardUtils: DashboardUtilsService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const dashboardId = route.params.dashboardId; + return this.dashboardService.getDashboard(dashboardId).pipe( + map((dashboard) => this.dashboardUtils.validateAndUpdateDashboard(dashboard)) + ); + } +} + +export const dashboardBreadcumbLabelFunction: BreadCrumbLabelFunction + = ((route, translate, component) => component.dashboard.title); + +const routes: Routes = [ + { + path: 'dashboards', + data: { + breadcrumb: { + label: 'dashboard.dashboards', + icon: 'dashboard' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'dashboard.dashboards', + dashboardsType: 'tenant' + }, + resolve: { + entitiesTableConfig: DashboardsTableConfigResolver + } + }, + { + path: ':dashboardId', + component: DashboardPageComponent, + data: { + breadcrumb: { + labelFunction: dashboardBreadcumbLabelFunction, + icon: 'dashboard' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'dashboard.dashboard', + widgetEditMode: false + }, + resolve: { + dashboard: DashboardResolver + } + } + ] + } +]; + +// @dynamic +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + DashboardsTableConfigResolver, + DashboardResolver + ] +}) +export class DashboardRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html new file mode 100644 index 0000000..1ce526c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html @@ -0,0 +1,27 @@ + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.ts new file mode 100644 index 0000000..a4a0897 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { Dashboard } from '@shared/models/dashboard.models'; + +@Component({ + selector: 'tb-dashboard-tabs', + templateUrl: './dashboard-tabs.component.html', + styleUrls: [] +}) +export class DashboardTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts new file mode 100644 index 0000000..b9feff9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard.module.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; +import { DashboardFormComponent } from '@modules/home/pages/dashboard/dashboard-form.component'; +import { ManageDashboardCustomersDialogComponent } from '@modules/home/pages/dashboard/manage-dashboard-customers-dialog.component'; +import { DashboardRoutingModule } from './dashboard-routing.module'; +import { MakeDashboardPublicDialogComponent } from '@modules/home/pages/dashboard/make-dashboard-public-dialog.component'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.component'; + +@NgModule({ + declarations: [ + DashboardFormComponent, + DashboardTabsComponent, + ManageDashboardCustomersDialogComponent, + MakeDashboardPublicDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeDialogsModule, + DashboardRoutingModule + ] +}) +export class DashboardModule { } diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts new file mode 100644 index 0000000..097902d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts @@ -0,0 +1,650 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { selectAuthUser } from '@core/auth/auth.selectors'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { AppState } from '@core/core.state'; +import { Authority } from '@app/shared/models/authority.enum'; +import { CustomerService } from '@core/http/customer.service'; +import { Customer } from '@app/shared/models/customer.model'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; +import { + Dashboard, + DashboardInfo, + getDashboardAssignedCustomersText, + isCurrentPublicDashboardCustomer, + isPublicDashboard +} from '@app/shared/models/dashboard.models'; +import { DashboardService } from '@app/core/http/dashboard.service'; +import { DashboardFormComponent } from '@modules/home/pages/dashboard/dashboard-form.component'; +import { + ManageDashboardCustomersActionType, + ManageDashboardCustomersDialogComponent, + ManageDashboardCustomersDialogData +} from './manage-dashboard-customers-dialog.component'; +import { + MakeDashboardPublicDialogComponent, + MakeDashboardPublicDialogData +} from '@modules/home/pages/dashboard/make-dashboard-public-dialog.component'; +import { DashboardTabsComponent } from '@home/pages/dashboard/dashboard-tabs.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { EdgeService } from '@core/http/edge.service'; +import { + AddEntitiesToEdgeDialogComponent, + AddEntitiesToEdgeDialogData +} from '@home/dialogs/add-entities-to-edge-dialog.component'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; + +@Injectable() +export class DashboardsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private store: Store, + private dashboardService: DashboardService, + private customerService: CustomerService, + private edgeService: EdgeService, + private dialogService: DialogService, + private homeDialogs: HomeDialogsService, + private importExport: ImportExportService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.DASHBOARD; + this.config.entityComponent = DashboardFormComponent; + this.config.entityTabsComponent = DashboardTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.DASHBOARD); + this.config.entityResources = entityTypeResources.get(EntityType.DASHBOARD); + + this.config.rowPointer = true; + + this.config.deleteEntityTitle = dashboard => + this.translate.instant('dashboard.delete-dashboard-title', {dashboardTitle: dashboard.title}); + this.config.deleteEntityContent = () => this.translate.instant('dashboard.delete-dashboard-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('dashboard.delete-dashboards-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('dashboard.delete-dashboards-text'); + + this.config.loadEntity = id => this.dashboardService.getDashboard(id.id); + this.config.saveEntity = dashboard => { + return this.dashboardService.saveDashboard(dashboard as Dashboard); + }; + this.config.onEntityAction = action => this.onDashboardAction(action); + this.config.detailsReadonly = () => (this.config.componentsData.dashboardScope === 'customer_user' || + this.config.componentsData.dashboardScope === 'edge_customer_user'); + + this.config.handleRowClick = ($event, dashboard) => { + if (this.config.isDetailsOpen()) { + this.config.toggleEntityDetails($event, dashboard); + } else { + this.openDashboard($event, dashboard); + } + return true; + }; + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + this.config.componentsData = { + dashboardScope: route.data.dashboardsType, + customerId: routeParams.customerId, + edgeId: routeParams.edgeId + }; + return this.store.pipe(select(selectAuthUser), take(1)).pipe( + tap((authUser) => { + if (authUser.authority === Authority.CUSTOMER_USER) { + if (route.data.dashboardsType === 'edge') { + this.config.componentsData.dashboardScope = 'edge_customer_user'; + } else { + this.config.componentsData.dashboardScope = 'customer_user'; + } + this.config.componentsData.customerId = authUser.customerId; + } + }), + mergeMap(() => + this.config.componentsData.customerId ? + this.customerService.getCustomer(this.config.componentsData.customerId) : of(null as Customer) + ), + map((parentCustomer) => { + if (parentCustomer) { + if (parentCustomer.additionalInfo && parentCustomer.additionalInfo.isPublic) { + this.config.tableTitle = this.translate.instant('customer.public-dashboards'); + } else { + this.config.tableTitle = parentCustomer.title + ': ' + this.translate.instant('dashboard.dashboards'); + } + } else if (this.config.componentsData.dashboardScope === 'edge') { + this.edgeService.getEdge(this.config.componentsData.edgeId).subscribe( + edge => this.config.tableTitle = edge.name + ': ' + this.translate.instant('dashboard.dashboards') + ); + } else { + this.config.tableTitle = this.translate.instant('dashboard.dashboards'); + } + this.config.columns = this.configureColumns(this.config.componentsData.dashboardScope); + this.configureEntityFunctions(this.config.componentsData.dashboardScope); + this.config.cellActionDescriptors = this.configureCellActions(this.config.componentsData.dashboardScope); + this.config.groupActionDescriptors = this.configureGroupActions(this.config.componentsData.dashboardScope); + this.config.addActionDescriptors = this.configureAddActions(this.config.componentsData.dashboardScope); + this.config.addEnabled = !(this.config.componentsData.dashboardScope === 'customer_user' || + this.config.componentsData.dashboardScope === 'edge_customer_user'); + this.config.entitiesDeleteEnabled = this.config.componentsData.dashboardScope === 'tenant'; + this.config.deleteEnabled = () => this.config.componentsData.dashboardScope === 'tenant'; + return this.config; + }) + ); + } + + configureColumns(dashboardScope: string): Array> { + const columns: Array> = [ + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'dashboard.title', '50%') + ]; + if (dashboardScope === 'tenant') { + columns.push( + new EntityTableColumn('customersTitle', 'dashboard.assignedToCustomers', + '50%', entity => { + return getDashboardAssignedCustomersText(entity); + }, () => ({}), false), + new EntityTableColumn('dashboardIsPublic', 'dashboard.public', '60px', + entity => { + return checkBoxCell(isPublicDashboard(entity)); + }, () => ({}), false), + ); + } + return columns; + } + + configureEntityFunctions(dashboardScope: string): void { + if (dashboardScope === 'tenant') { + this.config.entitiesFetchFunction = pageLink => + this.dashboardService.getTenantDashboards(pageLink); + this.config.deleteEntity = id => this.dashboardService.deleteDashboard(id.id); + } else if (dashboardScope === 'edge' || dashboardScope === 'edge_customer_user') { + this.config.entitiesFetchFunction = pageLink => + this.dashboardService.getEdgeDashboards(this.config.componentsData.edgeId, pageLink, this.config.componentsData.dashboardsType); + } else { + this.config.entitiesFetchFunction = pageLink => + this.dashboardService.getCustomerDashboards(this.config.componentsData.customerId, pageLink); + this.config.deleteEntity = id => + this.dashboardService.unassignDashboardFromCustomer(this.config.componentsData.customerId, id.id); + } + } + + configureCellActions(dashboardScope: string): Array> { + const actions: Array> = []; + if (dashboardScope === 'tenant') { + actions.push( + { + name: this.translate.instant('dashboard.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportDashboard($event, entity) + }, + { + name: this.translate.instant('dashboard.make-public'), + icon: 'share', + isEnabled: (entity) => !isPublicDashboard(entity), + onAction: ($event, entity) => this.makePublic($event, entity) + }, + { + name: this.translate.instant('dashboard.make-private'), + icon: 'reply', + isEnabled: (entity) => isPublicDashboard(entity), + onAction: ($event, entity) => this.makePrivate($event, entity) + }, + { + name: this.translate.instant('dashboard.manage-assigned-customers'), + icon: 'assignment_ind', + isEnabled: () => true, + onAction: ($event, entity) => this.manageAssignedCustomers($event, entity) + } + ); + } + if (dashboardScope === 'customer') { + actions.push( + { + name: this.translate.instant('dashboard.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportDashboard($event, entity) + }, + { + name: this.translate.instant('dashboard.make-private'), + icon: 'reply', + isEnabled: (entity) => isCurrentPublicDashboardCustomer(entity, this.config.componentsData.customerId), + onAction: ($event, entity) => this.makePrivate($event, entity) + }, + { + name: this.translate.instant('dashboard.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => !isCurrentPublicDashboardCustomer(entity, this.config.componentsData.customerId), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity, this.config.componentsData.customerId) + } + ); + } + if (dashboardScope === 'edge') { + actions.push( + { + name: this.translate.instant('dashboard.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportDashboard($event, entity) + }, + { + name: this.translate.instant('edge.unassign-from-edge'), + icon: 'assignment_return', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.unassignFromEdge($event, entity) + } + ); + } + actions.push( + { + name: this.translate.instant('dashboard.dashboard-details'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.config.toggleEntityDetails($event, entity) + } + ); + return actions; + } + + configureGroupActions(dashboardScope: string): Array> { + const actions: Array> = []; + if (dashboardScope === 'tenant') { + actions.push( + { + name: this.translate.instant('dashboard.assign-dashboards'), + icon: 'assignment_ind', + isEnabled: true, + onAction: ($event, entities) => this.assignDashboardsToCustomers($event, entities.map((entity) => entity.id.id)) + } + ); + actions.push( + { + name: this.translate.instant('dashboard.unassign-dashboards'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignDashboardsFromCustomers($event, entities.map((entity) => entity.id.id)) + } + ); + } + if (dashboardScope === 'customer') { + actions.push( + { + name: this.translate.instant('dashboard.unassign-dashboards'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => + this.unassignDashboardsFromCustomer($event, entities.map((entity) => entity.id.id), this.config.componentsData.customerId) + } + ); + } + if (dashboardScope === 'edge') { + actions.push( + { + name: this.translate.instant('dashboard.unassign-dashboards'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignDashboardsFromEdge($event, entities) + } + ); + } + return actions; + } + + configureAddActions(dashboardScope: string): Array { + const actions: Array = []; + if (dashboardScope === 'tenant') { + actions.push( + { + name: this.translate.instant('dashboard.create-new-dashboard'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('dashboard.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importDashboard($event) + } + ); + } + if (dashboardScope === 'customer') { + actions.push( + { + name: this.translate.instant('dashboard.assign-new-dashboard'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addDashboardsToCustomer($event) + } + ); + } + if (dashboardScope === 'edge') { + actions.push( + { + name: this.translate.instant('dashboard.assign-new-dashboard'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addDashboardsToEdge($event) + } + ); + } + return actions; + } + + openDashboard($event: Event, dashboard: DashboardInfo) { + if ($event) { + $event.stopPropagation(); + } + if (this.config.componentsData.dashboardScope === 'customer') { + this.router.navigateByUrl(`customers/${this.config.componentsData.customerId}/dashboards/${dashboard.id.id}`); + } else if (this.config.componentsData.dashboardScope === 'edge') { + this.router.navigateByUrl(`edgeInstances/${this.config.componentsData.edgeId}/dashboards/${dashboard.id.id}`); + } else { + this.router.navigateByUrl(`dashboards/${dashboard.id.id}`); + } + } + + importDashboard($event: Event) { + this.importExport.importDashboard().subscribe( + (dashboard) => { + if (dashboard) { + this.config.updateData(); + } + } + ); + } + + exportDashboard($event: Event, dashboard: DashboardInfo) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportDashboard(dashboard.id.id); + } + + addDashboardsToCustomer($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.config.componentsData.customerId, + entityType: EntityType.DASHBOARD + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + makePublic($event: Event, dashboard: DashboardInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dashboardService.makeDashboardPublic(dashboard.id.id).subscribe( + (publicDashboard) => { + this.dialog.open + (MakeDashboardPublicDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dashboard: publicDashboard + } + }).afterClosed() + .subscribe(() => { + this.config.updateData(); + }); + } + ); + } + + makePrivate($event: Event, dashboard: DashboardInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('dashboard.make-private-dashboard-title', {dashboardTitle: dashboard.title}), + this.translate.instant('dashboard.make-private-dashboard-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.dashboardService.makeDashboardPrivate(dashboard.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + manageAssignedCustomers($event: Event, dashboard: DashboardInfo) { + const assignedCustomersIds = dashboard.assignedCustomers ? + dashboard.assignedCustomers.map(customerInfo => customerInfo.customerId.id) : []; + this.showManageAssignedCustomersDialog($event, [dashboard.id.id], 'manage', assignedCustomersIds); + } + + assignDashboardsToCustomers($event: Event, dashboardIds: Array) { + this.showManageAssignedCustomersDialog($event, dashboardIds, 'assign'); + } + + unassignDashboardsFromCustomers($event: Event, dashboardIds: Array) { + this.showManageAssignedCustomersDialog($event, dashboardIds, 'unassign'); + } + + showManageAssignedCustomersDialog($event: Event, dashboardIds: Array, + actionType: ManageDashboardCustomersActionType, + assignedCustomersIds?: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(ManageDashboardCustomersDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dashboardIds, + actionType, + assignedCustomersIds + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromCustomer($event: Event, dashboard: DashboardInfo, customerId: string) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('dashboard.unassign-dashboard-title', {dashboardTitle: dashboard.title}), + this.translate.instant('dashboard.unassign-dashboard-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.dashboardService.unassignDashboardFromCustomer(customerId, dashboard.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.dashboardScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignDashboardsFromCustomer($event: Event, dashboardIds: Array, customerId: string) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('dashboard.unassign-dashboards-title', {count: dashboardIds.length}), + this.translate.instant('dashboard.unassign-dashboards-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + dashboardIds.forEach( + (dashboardId) => { + tasks.push(this.dashboardService.unassignDashboardFromCustomer(customerId, dashboardId)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + onDashboardAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openDashboard(action.event, action.entity); + return true; + case 'export': + this.exportDashboard(action.event, action.entity); + return true; + case 'makePublic': + this.makePublic(action.event, action.entity); + return true; + case 'makePrivate': + this.makePrivate(action.event, action.entity); + return true; + case 'manageAssignedCustomers': + this.manageAssignedCustomers(action.event, action.entity); + return true; + case 'unassignFromCustomer': + this.unassignFromCustomer(action.event, action.entity, this.config.componentsData.customerId); + return true; + case 'unassignFromEdge': + this.unassignFromEdge(action.event, action.entity); + return true; + } + return false; + } + + addDashboardsToEdge($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToEdgeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + edgeId: this.config.componentsData.edgeId, + entityType: EntityType.DASHBOARD + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromEdge($event: Event, dashboard: DashboardInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('dashboard.unassign-dashboard-title', {dashboardTitle: dashboard.title}), + this.translate.instant('dashboard.unassign-dashboard-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.dashboardService.unassignDashboardFromEdge(this.config.componentsData.edgeId, dashboard.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.dashboardScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignDashboardsFromEdge($event: Event, dashboards: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('dashboard.unassign-dashboards-from-edge-title', {count: dashboards.length}), + this.translate.instant('dashboard.unassign-dashboards-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + dashboards.forEach( + (dashboard) => { + tasks.push(this.dashboardService.unassignDashboardFromEdge(this.config.componentsData.edgeId, dashboard.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html new file mode 100644 index 0000000..54b420b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html @@ -0,0 +1,63 @@ + +
+ +

{{ 'dashboard.public-dashboard-title' | translate }}

+ + +
+ + +
+
+ + +
+
{{ publicLink }}
+ +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.ts new file mode 100644 index 0000000..8f1fdd6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.ts @@ -0,0 +1,77 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder } from '@angular/forms'; +import { DashboardService } from '@core/http/dashboard.service'; +import { DashboardInfo } from '@app/shared/models/dashboard.models'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +export interface MakeDashboardPublicDialogData { + dashboard: DashboardInfo; +} + +@Component({ + selector: 'tb-make-dashboard-public-dialog', + templateUrl: './make-dashboard-public-dialog.component.html', + styleUrls: [] +}) +export class MakeDashboardPublicDialogComponent extends DialogComponent implements OnInit { + + dashboard: DashboardInfo; + + publicLink: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MakeDashboardPublicDialogData, + public translate: TranslateService, + private dashboardService: DashboardService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.dashboard = data.dashboard; + this.publicLink = dashboardService.getPublicDashboardLink(this.dashboard); + } + + ngOnInit(): void { + } + + close(): void { + this.dialogRef.close(); + } + + + onPublicLinkCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('dashboard.public-link-copied-message'), + type: 'success', + target: 'makeDashboardPublicDialogContent', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html new file mode 100644 index 0000000..62fddda --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.html @@ -0,0 +1,57 @@ + +
+ +

{{ titleText | translate }}

+ + +
+ + +
+
+
+ {{ labelText | translate }} + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts new file mode 100644 index 0000000..5d8e39e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts @@ -0,0 +1,130 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { forkJoin, Observable } from 'rxjs'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +export type ManageDashboardCustomersActionType = 'assign' | 'manage' | 'unassign'; + +export interface ManageDashboardCustomersDialogData { + actionType: ManageDashboardCustomersActionType; + dashboardIds: Array; + assignedCustomersIds?: Array; +} + +@Component({ + selector: 'tb-manage-dashboard-customers-dialog', + templateUrl: './manage-dashboard-customers-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ManageDashboardCustomersDialogComponent}], + styleUrls: [] +}) +export class ManageDashboardCustomersDialogComponent extends + DialogComponent implements OnInit, ErrorStateMatcher { + + dashboardCustomersFormGroup: FormGroup; + + submitted = false; + + entityType = EntityType; + + titleText: string; + labelText: string; + actionName: string; + + assignedCustomersIds: string[]; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ManageDashboardCustomersDialogData, + private dashboardService: DashboardService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.assignedCustomersIds = data.assignedCustomersIds || []; + switch (data.actionType) { + case 'assign': + this.titleText = 'dashboard.assign-to-customers'; + this.labelText = 'dashboard.assign-to-customers-text'; + this.actionName = 'action.assign'; + break; + case 'manage': + this.titleText = 'dashboard.manage-assigned-customers'; + this.labelText = 'dashboard.assigned-customers'; + this.actionName = 'action.update'; + break; + case 'unassign': + this.titleText = 'dashboard.unassign-from-customers'; + this.labelText = 'dashboard.unassign-from-customers-text'; + this.actionName = 'action.unassign'; + break; + } + } + + ngOnInit(): void { + this.dashboardCustomersFormGroup = this.fb.group({ + assignedCustomerIds: [[...this.assignedCustomersIds]] + }); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(false); + } + + submit(): void { + this.submitted = true; + const customerIds: Array = this.dashboardCustomersFormGroup.get('assignedCustomerIds').value; + const tasks: Observable[] = []; + + this.data.dashboardIds.forEach( + (dashboardId) => { + tasks.push(this.getManageDashboardCustomersTask(dashboardId, customerIds)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } + + private getManageDashboardCustomersTask(dashboardId: string, customerIds: Array): Observable { + switch (this.data.actionType) { + case 'assign': + return this.dashboardService.addDashboardCustomers(dashboardId, customerIds); + case 'manage': + return this.dashboardService.updateDashboardCustomers(dashboardId, customerIds); + case 'unassign': + return this.dashboardService.removeDashboardCustomers(dashboardId, customerIds); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts new file mode 100644 index 0000000..6ddbb00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-routing.module.ts @@ -0,0 +1,74 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { DeviceProfilesTableConfigResolver } from './device-profiles-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +export const deviceProfilesRoutes: Routes = [ + { + path: 'deviceProfiles', + data: { + breadcrumb: { + label: 'device-profile.device-profiles', + icon: 'mdi:alpha-d-box' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'device-profile.device-profiles' + }, + resolve: { + entitiesTableConfig: DeviceProfilesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'mdi:alpha-d-box' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'device-profile.device-profiles' + }, + resolve: { + entitiesTableConfig: DeviceProfilesTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + providers: [ + DeviceProfilesTableConfigResolver + ] +}) +export class DeviceProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html new file mode 100644 index 0000000..cfa6d8e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -0,0 +1,83 @@ + + +
+ + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + +
+ + +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+
+
+ +
+
+ + +
+
+
+ + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts new file mode 100644 index 0000000..a21e8de --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { + DeviceProfile, + DeviceTransportType, + deviceTransportTypeHintMap, + deviceTransportTypeTranslationMap +} from '@shared/models/device.models'; + +@Component({ + selector: 'tb-device-profile-tabs', + templateUrl: './device-profile-tabs.component.html', + styleUrls: [] +}) +export class DeviceProfileTabsComponent extends EntityTabsComponent { + + deviceTransportTypes = Object.values(DeviceTransportType); + + deviceTransportTypeTranslations = deviceTransportTypeTranslationMap; + + deviceTransportTypeHints = deviceTransportTypeHintMap; + + isTransportTypeChanged = false; + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + this.detailsForm.get('transportType').valueChanges.subscribe(() => { + this.isTransportTypeChanged = true; + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts new file mode 100644 index 0000000..759e960 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { DeviceProfileTabsComponent } from './device-profile-tabs.component'; +import { DeviceProfileRoutingModule } from './device-profile-routing.module'; + +@NgModule({ + declarations: [ + DeviceProfileTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + DeviceProfileRoutingModule + ] +}) +export class DeviceProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts new file mode 100644 index 0000000..9f026d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profiles-table-config.resolver.ts @@ -0,0 +1,224 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Resolve, Router } from '@angular/router'; +import { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { DialogService } from '@core/services/dialog.service'; +import { + DeviceProfile, + deviceProfileTypeTranslationMap, + deviceTransportTypeTranslationMap +} from '@shared/models/device.models'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { DeviceProfileComponent } from '@home/components/profile/device-profile.component'; +import { DeviceProfileTabsComponent } from './device-profile-tabs.component'; +import { MatDialog } from '@angular/material/dialog'; +import { + AddDeviceProfileDialogComponent, + AddDeviceProfileDialogData +} from '@home/components/profile/add-device-profile-dialog.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; + +@Injectable() +export class DeviceProfilesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private deviceProfileService: DeviceProfileService, + private importExport: ImportExportService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private dialogService: DialogService, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.DEVICE_PROFILE; + this.config.entityComponent = DeviceProfileComponent; + this.config.entityTabsComponent = DeviceProfileTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.DEVICE_PROFILE); + this.config.entityResources = entityTypeResources.get(EntityType.DEVICE_PROFILE); + + this.config.hideDetailsTabsOnEdit = false; + + this.config.addDialogStyle = {width: '1000px'}; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'device-profile.name', '20%'), + new EntityTableColumn('type', 'device-profile.type', '20%', (deviceProfile) => { + return this.translate.instant(deviceProfileTypeTranslationMap.get(deviceProfile.type)); + }), + new EntityTableColumn('transportType', 'device-profile.transport-type', '20%', (deviceProfile) => { + return this.translate.instant(deviceTransportTypeTranslationMap.get(deviceProfile.transportType)); + }), + new EntityTableColumn('description', 'device-profile.description', '40%'), + new EntityTableColumn('isDefault', 'device-profile.default', '60px', + entity => { + return checkBoxCell(entity.default); + }) + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('device-profile.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportDeviceProfile($event, entity) + }, + { + name: this.translate.instant('device-profile.set-default'), + icon: 'flag', + isEnabled: (deviceProfile) => !deviceProfile.default, + onAction: ($event, entity) => this.setDefaultDeviceProfile($event, entity) + } + ); + + this.config.deleteEntityTitle = deviceProfile => this.translate.instant('device-profile.delete-device-profile-title', + { deviceProfileName: deviceProfile.name }); + this.config.deleteEntityContent = () => this.translate.instant('device-profile.delete-device-profile-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('device-profile.delete-device-profiles-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('device-profile.delete-device-profiles-text'); + + this.config.entitiesFetchFunction = pageLink => this.deviceProfileService.getDeviceProfiles(pageLink); + this.config.loadEntity = id => this.deviceProfileService.getDeviceProfile(id.id); + this.config.saveEntity = (deviceProfile, originDeviceProfile) => + this.deviceProfileService.saveDeviceProfileAndConfirmOtaChange(originDeviceProfile, deviceProfile); + this.config.deleteEntity = id => this.deviceProfileService.deleteDeviceProfile(id.id); + this.config.onEntityAction = action => this.onDeviceProfileAction(action); + this.config.deleteEnabled = (deviceProfile) => deviceProfile && !deviceProfile.default; + this.config.entitySelectionEnabled = (deviceProfile) => deviceProfile && !deviceProfile.default; + this.config.addActionDescriptors = this.configureAddActions(); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('device-profile.device-profiles'); + + return this.config; + } + + configureAddActions(): Array { + const actions: Array = []; + actions.push( + { + name: this.translate.instant('device-profile.create-device-profile'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: () => this.addDeviceProfile() + }, + { + name: this.translate.instant('device-profile.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importDeviceProfile($event) + } + ); + return actions; + } + + addDeviceProfile() { + this.dialog.open(AddDeviceProfileDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + deviceProfileName: null, + transportType: null + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.config.updateData(); + } + } + ); + } + + setDefaultDeviceProfile($event: Event, deviceProfile: DeviceProfile) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('device-profile.set-default-device-profile-title', {deviceProfileName: deviceProfile.name}), + this.translate.instant('device-profile.set-default-device-profile-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.deviceProfileService.setDefaultDeviceProfile(deviceProfile.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + private openDeviceProfile($event: Event, deviceProfile: DeviceProfile) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree(['profiles', 'deviceProfiles', deviceProfile.id.id]); + this.router.navigateByUrl(url); + } + + importDeviceProfile($event: Event) { + this.importExport.importDeviceProfile().subscribe( + (deviceProfile) => { + if (deviceProfile) { + this.config.updateData(); + } + } + ); + } + + exportDeviceProfile($event: Event, deviceProfile: DeviceProfile) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportDeviceProfile(deviceProfile.id.id); + } + + onDeviceProfileAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openDeviceProfile(action.event, action.entity); + return true; + case 'setDefault': + this.setDefaultDeviceProfile(action.event, action.entity); + return true; + case 'export': + this.exportDeviceProfile(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.html new file mode 100644 index 0000000..59f30a1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.html @@ -0,0 +1,21 @@ + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts new file mode 100644 index 0000000..1e179a5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts @@ -0,0 +1,119 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + CoapDeviceTransportConfiguration, + DeviceTransportConfiguration, + DeviceTransportType +} from '@shared/models/device.models'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-coap-device-transport-configuration', + templateUrl: './coap-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CoapDeviceTransportConfigurationComponent), + multi: true + }] +}) +export class CoapDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { + + coapDeviceTransportForm: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.coapDeviceTransportForm = this.fb.group({ + powerMode: [null], + edrxCycle: [{disabled: true, value: 0}, Validators.required], + psmActivityTimer: [{disabled: true, value: 0}, Validators.required], + pagingTransmissionWindow: [{disabled: true, value: 0}, Validators.required] + }); + this.coapDeviceTransportForm.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.updateModel(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.coapDeviceTransportForm.disable({emitEvent: false}); + } else { + this.coapDeviceTransportForm.enable({emitEvent: false}); + this.coapDeviceTransportForm.get('powerMode').updateValueAndValidity({onlySelf: true}); + } + } + + writeValue(value: CoapDeviceTransportConfiguration | null): void { + if (isDefinedAndNotNull(value)) { + this.coapDeviceTransportForm.patchValue(value, {emitEvent: false}); + } else { + this.coapDeviceTransportForm.get('powerMode').patchValue(null, {emitEvent: false}); + } + if (!this.disabled) { + this.coapDeviceTransportForm.get('powerMode').updateValueAndValidity({onlySelf: true}); + } + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.coapDeviceTransportForm.valid) { + configuration = this.coapDeviceTransportForm.value; + configuration.type = DeviceTransportType.COAP; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.html new file mode 100644 index 0000000..083b8b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.html @@ -0,0 +1,24 @@ + +
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts new file mode 100644 index 0000000..bb4e2e1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts @@ -0,0 +1,97 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DefaultDeviceConfiguration, + DeviceConfiguration, + DeviceProfileType +} from '@shared/models/device.models'; + +@Component({ + selector: 'tb-default-device-configuration', + templateUrl: './default-device-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceConfigurationComponent), + multi: true + }] +}) +export class DefaultDeviceConfigurationComponent implements ControlValueAccessor, OnInit { + + defaultDeviceConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.defaultDeviceConfigurationFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + this.defaultDeviceConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.defaultDeviceConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.defaultDeviceConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DefaultDeviceConfiguration | null): void { + this.defaultDeviceConfigurationFormGroup.patchValue({configuration: value}, {emitEvent: false}); + } + + private updateModel() { + let configuration: DeviceConfiguration = null; + if (this.defaultDeviceConfigurationFormGroup.valid) { + configuration = this.defaultDeviceConfigurationFormGroup.getRawValue().configuration; + configuration.type = DeviceProfileType.DEFAULT; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.html new file mode 100644 index 0000000..b0a6d77 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.html @@ -0,0 +1,24 @@ + +
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts new file mode 100644 index 0000000..9405460 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts @@ -0,0 +1,97 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DefaultDeviceTransportConfiguration, + DeviceTransportConfiguration, + DeviceTransportType +} from '@shared/models/device.models'; + +@Component({ + selector: 'tb-default-device-transport-configuration', + templateUrl: './default-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceTransportConfigurationComponent), + multi: true + }] +}) +export class DefaultDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit { + + defaultDeviceTransportConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.defaultDeviceTransportConfigurationFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + this.defaultDeviceTransportConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.defaultDeviceTransportConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.defaultDeviceTransportConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DefaultDeviceTransportConfiguration | null): void { + this.defaultDeviceTransportConfigurationFormGroup.patchValue({configuration: value}, {emitEvent: false}); + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.defaultDeviceTransportConfigurationFormGroup.valid) { + configuration = this.defaultDeviceTransportConfigurationFormGroup.getRawValue().configuration; + configuration.type = DeviceTransportType.DEFAULT; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html new file mode 100644 index 0000000..5e5da11 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html @@ -0,0 +1,27 @@ + +
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts new file mode 100644 index 0000000..d755650 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts @@ -0,0 +1,103 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DeviceConfiguration, DeviceProfileType } from '@shared/models/device.models'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-device-configuration', + templateUrl: './device-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceConfigurationComponent), + multi: true + }] +}) +export class DeviceConfigurationComponent implements ControlValueAccessor, OnInit { + + deviceProfileType = DeviceProfileType; + + deviceConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + type: DeviceProfileType; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.deviceConfigurationFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + this.deviceConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.deviceConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.deviceConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DeviceConfiguration | null): void { + this.type = value?.type; + const configuration = deepClone(value); + if (configuration) { + delete configuration.type; + } + this.deviceConfigurationFormGroup.patchValue({configuration}, {emitEvent: false}); + } + + private updateModel() { + let configuration: DeviceConfiguration = null; + if (this.deviceConfigurationFormGroup.valid) { + configuration = this.deviceConfigurationFormGroup.getRawValue().configuration; + configuration.type = this.type; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html new file mode 100644 index 0000000..b796efc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html @@ -0,0 +1,43 @@ + +
+ + + + +
device.device-configuration
+
+
+ + +
+ + + +
device.transport-configuration
+
+
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts new file mode 100644 index 0000000..2b550e5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts @@ -0,0 +1,129 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DeviceData, + deviceProfileTypeConfigurationInfoMap, + deviceTransportTypeConfigurationInfoMap +} from '@shared/models/device.models'; + +@Component({ + selector: 'tb-device-data', + templateUrl: './device-data.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceDataComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceDataComponent), + multi: true + }, + ] +}) +export class DeviceDataComponent implements ControlValueAccessor, OnInit, Validator { + + deviceDataFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + displayDeviceConfiguration: boolean; + displayTransportConfiguration: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.deviceDataFormGroup = this.fb.group({ + configuration: [null, Validators.required], + transportConfiguration: [null, Validators.required] + }); + this.deviceDataFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.deviceDataFormGroup.disable({emitEvent: false}); + } else { + this.deviceDataFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DeviceData | null): void { + const deviceProfileType = value?.configuration?.type; + this.displayDeviceConfiguration = deviceProfileType && + deviceProfileTypeConfigurationInfoMap.get(deviceProfileType).hasDeviceConfiguration; + const deviceTransportType = value?.transportConfiguration?.type; + this.displayTransportConfiguration = deviceTransportType && + deviceTransportTypeConfigurationInfoMap.get(deviceTransportType).hasDeviceConfiguration; + this.deviceDataFormGroup.patchValue({configuration: value?.configuration}, {emitEvent: false}); + this.deviceDataFormGroup.patchValue({transportConfiguration: value?.transportConfiguration}, {emitEvent: false}); + } + + validate(): ValidationErrors | null { + return this.deviceDataFormGroup.valid ? null : { + deviceDataForm: false + }; + } + + private updateModel() { + let deviceData: DeviceData = null; + if (this.deviceDataFormGroup.valid) { + deviceData = this.deviceDataFormGroup.getRawValue(); + } + this.propagateChange(deviceData); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html new file mode 100644 index 0000000..1e28334 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html @@ -0,0 +1,51 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts new file mode 100644 index 0000000..fdf5d4f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts @@ -0,0 +1,126 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DeviceTransportConfiguration, DeviceTransportType } from '@shared/models/device.models'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-device-transport-configuration', + templateUrl: './device-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceTransportConfigurationComponent), + multi: true + }] +}) +export class DeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { + + deviceTransportType = DeviceTransportType; + + deviceTransportConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + transportType: DeviceTransportType; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.deviceTransportConfigurationFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + this.deviceTransportConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.deviceTransportConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.deviceTransportConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DeviceTransportConfiguration | null): void { + this.transportType = value?.type; + const configuration = deepClone(value); + if (configuration) { + delete configuration.type; + } + setTimeout(() => { + this.deviceTransportConfigurationFormGroup.patchValue({configuration}, {emitEvent: false}); + }, 0); + } + + validate(): ValidationErrors | null { + return this.deviceTransportConfigurationFormGroup.valid ? null : { + deviceTransportConfiguration: false + }; + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.deviceTransportConfigurationFormGroup.valid) { + configuration = this.deviceTransportConfigurationFormGroup.getRawValue().configuration; + configuration.type = this.transportType; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.html new file mode 100644 index 0000000..37c0573 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.html @@ -0,0 +1,21 @@ + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts new file mode 100644 index 0000000..1bb5188 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts @@ -0,0 +1,119 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DeviceTransportConfiguration, + DeviceTransportType, + Lwm2mDeviceTransportConfiguration +} from '@shared/models/device.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-lwm2m-device-transport-configuration', + templateUrl: './lwm2m-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mDeviceTransportConfigurationComponent), + multi: true + }] +}) +export class Lwm2mDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { + + lwm2mDeviceTransportConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.lwm2mDeviceTransportConfigurationFormGroup = this.fb.group({ + powerMode: [null], + edrxCycle: [{disabled: true, value: 0}, Validators.required], + psmActivityTimer: [{disabled: true, value: 0}, Validators.required], + pagingTransmissionWindow: [{disabled: true, value: 0}, Validators.required] + }); + this.lwm2mDeviceTransportConfigurationFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.updateModel(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.lwm2mDeviceTransportConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.lwm2mDeviceTransportConfigurationFormGroup.enable({emitEvent: false}); + this.lwm2mDeviceTransportConfigurationFormGroup.get('powerMode').updateValueAndValidity({onlySelf: true}); + } + } + + writeValue(value: Lwm2mDeviceTransportConfiguration | null): void { + if (isDefinedAndNotNull(value)) { + this.lwm2mDeviceTransportConfigurationFormGroup.patchValue(value, {emitEvent: false}); + } else { + this.lwm2mDeviceTransportConfigurationFormGroup.get('powerMode').patchValue(null, {emitEvent: false}); + } + if (!this.disabled) { + this.lwm2mDeviceTransportConfigurationFormGroup.get('powerMode').updateValueAndValidity({onlySelf: true}); + } + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.lwm2mDeviceTransportConfigurationFormGroup.valid) { + configuration = this.lwm2mDeviceTransportConfigurationFormGroup.value; + configuration.type = DeviceTransportType.LWM2M; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.html new file mode 100644 index 0000000..4c2ab47 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.html @@ -0,0 +1,24 @@ + +
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts new file mode 100644 index 0000000..c58fe58 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts @@ -0,0 +1,96 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DeviceTransportConfiguration, + DeviceTransportType, MqttDeviceTransportConfiguration +} from '@shared/models/device.models'; + +@Component({ + selector: 'tb-mqtt-device-transport-configuration', + templateUrl: './mqtt-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttDeviceTransportConfigurationComponent), + multi: true + }] +}) +export class MqttDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit { + + mqttDeviceTransportConfigurationFormGroup: FormGroup; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.mqttDeviceTransportConfigurationFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + this.mqttDeviceTransportConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.mqttDeviceTransportConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.mqttDeviceTransportConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MqttDeviceTransportConfiguration | null): void { + this.mqttDeviceTransportConfigurationFormGroup.patchValue({configuration: value}, {emitEvent: false}); + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.mqttDeviceTransportConfigurationFormGroup.valid) { + configuration = this.mqttDeviceTransportConfigurationFormGroup.getRawValue().configuration; + configuration.type = DeviceTransportType.MQTT; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html new file mode 100644 index 0000000..892ca41 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html @@ -0,0 +1,133 @@ + +
+
+ + device-profile.snmp.host + + + {{ 'device-profile.snmp.host-required' | translate }} + + + + device-profile.snmp.port + + + {{ 'device-profile.snmp.port-required' | translate }} + + + {{ 'device-profile.snmp.port-format' | translate }} + + +
+ + device-profile.snmp.protocol-version + + + {{ snmpDeviceProtocolVersion | lowercase }} + + + + {{ 'device-profile.snmp.protocol-version-required' | translate }} + + +
+ + device-profile.snmp.community + + + {{ 'device-profile.snmp.community-required' | translate }} + + +
+
+
+ + device-profile.snmp.user-name + + + {{ 'device-profile.snmp.user-name-required' | translate }} + + + + device-profile.snmp.security-name + + + {{ 'device-profile.snmp.security-name-required' | translate }} + + +
+
+ + device-profile.snmp.authentication-protocol + + + {{ snmpAuthenticationProtocolTranslation.get(snmpAuthenticationProtocol) }} + + + + {{ 'device-profile.snmp.authentication-protocol-required' | translate }} + + + + device-profile.snmp.authentication-passphrase + + + {{ 'device-profile.snmp.authentication-passphrase-required' | translate }} + + +
+
+ + device-profile.snmp.privacy-protocol + + + {{ snmpPrivacyProtocolTranslation.get(snmpPrivacyProtocol) }} + + + + {{ 'device-profile.snmp.privacy-protocol-required' | translate }} + + + + device-profile.snmp.privacy-passphrase + + + {{ 'device-profile.snmp.privacy-passphrase-required' | translate }} + + +
+
+ + device-profile.snmp.context-name + + + + device-profile.snmp.engine-id + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts new file mode 100644 index 0000000..b8282b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts @@ -0,0 +1,178 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + DeviceTransportConfiguration, + DeviceTransportType, + SnmpAuthenticationProtocol, + SnmpAuthenticationProtocolTranslationMap, + SnmpDeviceProtocolVersion, + SnmpDeviceTransportConfiguration, + SnmpPrivacyProtocol, + SnmpPrivacyProtocolTranslationMap +} from '@shared/models/device.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-snmp-device-transport-configuration', + templateUrl: './snmp-device-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), + multi: true + }] +}) +export class SnmpDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { + + snmpDeviceTransportConfigurationFormGroup: FormGroup; + + snmpDeviceProtocolVersions = Object.values(SnmpDeviceProtocolVersion); + snmpAuthenticationProtocols = Object.values(SnmpAuthenticationProtocol); + snmpAuthenticationProtocolTranslation = SnmpAuthenticationProtocolTranslationMap; + snmpPrivacyProtocols = Object.values(SnmpPrivacyProtocol); + snmpPrivacyProtocolTranslation = SnmpPrivacyProtocolTranslationMap; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.snmpDeviceTransportConfigurationFormGroup = this.fb.group({ + host: ['', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + port: [null, [Validators.required, Validators.min(0), Validators.pattern('[0-9]*')]], + protocolVersion: [SnmpDeviceProtocolVersion.V2C, Validators.required], + community: ['public', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + username: ['', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + securityName: ['public', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + contextName: [null], + authenticationProtocol: [SnmpAuthenticationProtocol.SHA_512, Validators.required], + authenticationPassphrase: ['', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + privacyProtocol: [SnmpPrivacyProtocol.DES, Validators.required], + privacyPassphrase: ['', [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]], + engineId: [''] + }); + this.snmpDeviceTransportConfigurationFormGroup.get('protocolVersion').valueChanges.subscribe((protocol: SnmpDeviceProtocolVersion) => { + this.updateDisabledFormValue(protocol); + }); + this.snmpDeviceTransportConfigurationFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + validate(): ValidationErrors | null { + return this.snmpDeviceTransportConfigurationFormGroup.valid ? null : { + snmpDeviceTransportConfiguration: false + }; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.snmpDeviceTransportConfigurationFormGroup.disable({emitEvent: false}); + } else { + this.snmpDeviceTransportConfigurationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: SnmpDeviceTransportConfiguration | null): void { + if (isDefinedAndNotNull(value)) { + this.snmpDeviceTransportConfigurationFormGroup.patchValue(value, {emitEvent: false}); + if (this.snmpDeviceTransportConfigurationFormGroup.enabled) { + this.updateDisabledFormValue(value.protocolVersion || SnmpDeviceProtocolVersion.V2C); + } + } + } + + isV3protocolVersion(): boolean { + return this.snmpDeviceTransportConfigurationFormGroup.get('protocolVersion').value === SnmpDeviceProtocolVersion.V3; + } + + private updateDisabledFormValue(protocol: SnmpDeviceProtocolVersion) { + if (protocol === SnmpDeviceProtocolVersion.V3) { + this.snmpDeviceTransportConfigurationFormGroup.get('community').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('username').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('securityName').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('contextName').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('authenticationProtocol').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('privacyProtocol').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('engineId').enable({emitEvent: false}); + } else { + this.snmpDeviceTransportConfigurationFormGroup.get('community').enable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('username').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('securityName').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('contextName').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('authenticationProtocol').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('privacyProtocol').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').disable({emitEvent: false}); + this.snmpDeviceTransportConfigurationFormGroup.get('engineId').disable({emitEvent: false}); + } + } + + private updateModel() { + let configuration: DeviceTransportConfiguration = null; + if (this.snmpDeviceTransportConfigurationFormGroup.valid) { + configuration = this.snmpDeviceTransportConfigurationFormGroup.value; + configuration.type = DeviceTransportType.SNMP; + } + this.propagateChange(configuration); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html new file mode 100644 index 0000000..c26022e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html @@ -0,0 +1,63 @@ + +
+ +

{{ 'device.device-credentials' | translate }}

+ + +
+ + +
+
+
+
+ + +
+
+ +
+ + + {{ 'device.loading-device-credentials' | translate }} + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts new file mode 100644 index 0000000..10bfb23 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts @@ -0,0 +1,110 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm } from '@angular/forms'; +import { DeviceService } from '@core/http/device.service'; +import { DeviceCredentials, DeviceProfileInfo, DeviceTransportType } from '@shared/models/device.models'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { forkJoin } from 'rxjs'; + +export interface DeviceCredentialsDialogData { + isReadOnly: boolean; + deviceId: string; + deviceProfileId: string; +} + +@Component({ + selector: 'tb-device-credentials-dialog', + templateUrl: './device-credentials-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: DeviceCredentialsDialogComponent}], + styleUrls: [] +}) +export class DeviceCredentialsDialogComponent extends + DialogComponent implements OnInit, ErrorStateMatcher { + + deviceCredentialsFormGroup: FormGroup; + deviceTransportType: DeviceTransportType; + isReadOnly: boolean; + loadingCredentials = true; + + private deviceCredentials: DeviceCredentials; + private submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: DeviceCredentialsDialogData, + private deviceService: DeviceService, + private deviceProfileService: DeviceProfileService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.isReadOnly = data.isReadOnly; + } + + ngOnInit(): void { + this.deviceCredentialsFormGroup = this.fb.group({ + credential: [null] + }); + if (this.isReadOnly) { + this.deviceCredentialsFormGroup.disable({emitEvent: false}); + } + this.loadDeviceCredentials(); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + loadDeviceCredentials() { + const task = []; + task.push(this.deviceService.getDeviceCredentials(this.data.deviceId)); + task.push(this.deviceProfileService.getDeviceProfileInfo(this.data.deviceProfileId)); + forkJoin(task).subscribe(([deviceCredentials, deviceProfile]: [DeviceCredentials, DeviceProfileInfo]) => { + this.deviceTransportType = deviceProfile.transportType; + this.deviceCredentials = deviceCredentials; + this.deviceCredentialsFormGroup.patchValue({ + credential: deviceCredentials + }, {emitEvent: false}); + this.loadingCredentials = false; + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + const deviceCredentialsValue = this.deviceCredentialsFormGroup.value.credential; + this.deviceCredentials = {...this.deviceCredentials, ...deviceCredentialsValue}; + this.deviceService.saveDeviceCredentials(this.deviceCredentials).subscribe( + (deviceCredentials) => { + this.dialogRef.close(deviceCredentials); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts b/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts new file mode 100644 index 0000000..ac29871 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-routing.module.ts @@ -0,0 +1,78 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { DevicesTableConfigResolver } from '@modules/home/pages/device/devices-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'devices', + data: { + breadcrumb: { + label: 'device.devices', + icon: 'devices_other' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'device.devices', + devicesType: 'tenant' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'device.devices', + devicesType: 'tenant' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + DevicesTableConfigResolver + ] +}) +export class DeviceRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.html b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.html new file mode 100644 index 0000000..1009c32 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.html @@ -0,0 +1,23 @@ + + + diff --git a/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.scss new file mode 100644 index 0000000..6708dfc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + flex: 1; + display: flex; + justify-content: flex-start; + min-width: 150px; +} + +:host ::ng-deep { + tb-device-profile-autocomplete { + width: 100%; + + mat-form-field { + font-size: 16px; + + .mat-form-field-wrapper { + padding-bottom: 0; + } + + .mat-form-field-underline { + bottom: 0; + } + + @media #{$mat-xs} { + width: 100%; + + .mat-form-field-infix { + width: auto !important; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts new file mode 100644 index 0000000..4b385b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; +import { DeviceInfo } from '@app/shared/models/device.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DeviceProfileId } from '../../../../shared/models/id/device-profile-id'; + +@Component({ + selector: 'tb-device-table-header', + templateUrl: './device-table-header.component.html', + styleUrls: ['./device-table-header.component.scss'] +}) +export class DeviceTableHeaderComponent extends EntityTableHeaderComponent { + + entityType = EntityType; + + constructor(protected store: Store) { + super(store); + } + + deviceProfileChanged(deviceProfileId: DeviceProfileId) { + this.entitiesTableConfig.componentsData.deviceProfileId = deviceProfileId; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html new file mode 100644 index 0000000..7ce78ec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts new file mode 100644 index 0000000..85e6d28 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DeviceInfo } from '@shared/models/device.models'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; + +@Component({ + selector: 'tb-device-tabs', + templateUrl: './device-tabs.component.html', + styleUrls: [] +}) +export class DeviceTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.html b/ui-ngx/src/app/modules/home/pages/device/device.component.html new file mode 100644 index 0000000..e619fa9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.html @@ -0,0 +1,152 @@ + +
+ + + + + + + +
+ + + +
+
+
+ + device.assignedToCustomer + + +
+ {{ 'device.device-public' | translate }} +
+
+
+ + device.name + + + {{ 'device.name-required' | translate }} + + + {{ 'device.name-max-length' | translate }} + + + + + + device.label + + + {{ 'device.label-max-length' | translate }} + + + + + + + + +
+
+ + {{ 'device.is-gateway' | translate }} + + + {{ 'device.overwrite-activity-time' | translate }} + +
+ + device.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.scss b/ui-ngx/src/app/modules/home/pages/device/device.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.ts b/ui-ngx/src/app/modules/home/pages/device/device.component.ts new file mode 100644 index 0000000..945ecba --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.ts @@ -0,0 +1,175 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + createDeviceConfiguration, + createDeviceTransportConfiguration, DeviceCredentials, + DeviceData, + DeviceInfo, + DeviceProfileInfo, + DeviceProfileType, + DeviceTransportType +} from '@shared/models/device.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { Subject } from 'rxjs'; +import { OtaUpdateType } from '@shared/models/ota-package.models'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { getEntityDetailsPageURL } from '@core/utils'; + +@Component({ + selector: 'tb-device', + templateUrl: './device.component.html', + styleUrls: ['./device.component.scss'] +}) +export class DeviceComponent extends EntityComponent { + + entityType = EntityType; + + deviceCredentials$: Subject; + + deviceScope: 'tenant' | 'customer' | 'customer_user' | 'edge' | 'edge_customer_user'; + + otaUpdateType = OtaUpdateType; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: DeviceInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.deviceScope = this.entitiesTableConfig.componentsData.deviceScope; + this.deviceCredentials$ = this.entitiesTableConfigValue.componentsData.deviceCredentials$; + super.ngOnInit(); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + isAssignedToCustomer(entity: DeviceInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } + + buildForm(entity: DeviceInfo): FormGroup { + const form = this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + deviceProfileId: [entity ? entity.deviceProfileId : null, [Validators.required]], + firmwareId: [entity ? entity.firmwareId : null], + softwareId: [entity ? entity.softwareId : null], + label: [entity ? entity.label : '', [Validators.maxLength(255)]], + deviceData: [entity ? entity.deviceData : null, [Validators.required]], + additionalInfo: this.fb.group( + { + gateway: [entity && entity.additionalInfo ? entity.additionalInfo.gateway : false], + overwriteActivityTime: [entity && entity.additionalInfo ? entity.additionalInfo.overwriteActivityTime : false], + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + } + ) + } + ); + form.get('deviceProfileId').valueChanges.pipe( + distinctUntilChanged((prev, curr) => prev?.id === curr?.id) + ).subscribe(profileId => { + if (profileId && this.isEdit) { + this.entityForm.patchValue({ + firmwareId: null, + softwareId: null + }, {emitEvent: false}); + } + }); + return form; + } + + updateForm(entity: DeviceInfo) { + this.entityForm.patchValue({ + name: entity.name, + deviceProfileId: entity.deviceProfileId, + firmwareId: entity.firmwareId, + softwareId: entity.softwareId, + label: entity.label, + deviceData: entity.deviceData, + additionalInfo: { + gateway: entity.additionalInfo ? entity.additionalInfo.gateway : false, + overwriteActivityTime: entity.additionalInfo ? entity.additionalInfo.overwriteActivityTime : false, + description: entity.additionalInfo ? entity.additionalInfo.description : '' + } + }); + } + + + onDeviceIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('device.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onDeviceProfileUpdated() { + this.entitiesTableConfig.updateData(false); + } + + onDeviceProfileChanged(deviceProfile: DeviceProfileInfo) { + if (deviceProfile && this.isEdit) { + const deviceProfileType: DeviceProfileType = deviceProfile.type; + const deviceTransportType: DeviceTransportType = deviceProfile.transportType; + let deviceData: DeviceData = this.entityForm.getRawValue().deviceData; + if (!deviceData) { + deviceData = { + configuration: createDeviceConfiguration(deviceProfileType), + transportConfiguration: createDeviceTransportConfiguration(deviceTransportType) + }; + this.entityForm.patchValue({deviceData}); + this.entityForm.markAsDirty(); + } else { + let changed = false; + if (deviceData.configuration.type !== deviceProfileType) { + deviceData.configuration = createDeviceConfiguration(deviceProfileType); + changed = true; + } + if (deviceData.transportConfiguration.type !== deviceTransportType) { + deviceData.transportConfiguration = createDeviceTransportConfiguration(deviceTransportType); + changed = true; + } + if (changed) { + this.entityForm.patchValue({deviceData}); + this.entityForm.markAsDirty(); + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device.module.ts b/ui-ngx/src/app/modules/home/pages/device/device.module.ts new file mode 100644 index 0000000..0754c74 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/device.module.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { DeviceComponent } from '@modules/home/pages/device/device.component'; +import { DeviceRoutingModule } from './device-routing.module'; +import { DeviceTableHeaderComponent } from '@modules/home/pages/device/device-table-header.component'; +import { DeviceCredentialsDialogComponent } from '@modules/home/pages/device/device-credentials-dialog.component'; +import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { DeviceTabsComponent } from '@home/pages/device/device-tabs.component'; +import { DefaultDeviceConfigurationComponent } from './data/default-device-configuration.component'; +import { DeviceConfigurationComponent } from './data/device-configuration.component'; +import { DeviceDataComponent } from './data/device-data.component'; +import { DefaultDeviceTransportConfigurationComponent } from './data/default-device-transport-configuration.component'; +import { DeviceTransportConfigurationComponent } from './data/device-transport-configuration.component'; +import { MqttDeviceTransportConfigurationComponent } from './data/mqtt-device-transport-configuration.component'; +import { CoapDeviceTransportConfigurationComponent } from './data/coap-device-transport-configuration.component'; +import { Lwm2mDeviceTransportConfigurationComponent } from './data/lwm2m-device-transport-configuration.component'; +import { SnmpDeviceTransportConfigurationComponent } from './data/snmp-device-transport-configuration.component'; +import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module'; +import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module'; + +@NgModule({ + declarations: [ + DefaultDeviceConfigurationComponent, + DeviceConfigurationComponent, + DefaultDeviceTransportConfigurationComponent, + MqttDeviceTransportConfigurationComponent, + CoapDeviceTransportConfigurationComponent, + Lwm2mDeviceTransportConfigurationComponent, + SnmpDeviceTransportConfigurationComponent, + DeviceTransportConfigurationComponent, + DeviceDataComponent, + DeviceComponent, + DeviceTabsComponent, + DeviceTableHeaderComponent, + DeviceCredentialsDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeDialogsModule, + DeviceCredentialsModule, + DeviceProfileCommonModule, + DeviceRoutingModule + ] +}) +export class DeviceModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts new file mode 100644 index 0000000..1edbb35 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -0,0 +1,644 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { AddEntityDialogData, EntityAction } from '@home/models/entity/entity-component.models'; +import { Device, DeviceCredentials, DeviceInfo } from '@app/shared/models/device.models'; +import { DeviceComponent } from '@modules/home/pages/device/device.component'; +import { forkJoin, Observable, of, Subject } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { selectAuthUser } from '@core/auth/auth.selectors'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { AppState } from '@core/core.state'; +import { DeviceService } from '@app/core/http/device.service'; +import { Authority } from '@app/shared/models/authority.enum'; +import { CustomerService } from '@core/http/customer.service'; +import { Customer } from '@app/shared/models/customer.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { DeviceTableHeaderComponent } from '@modules/home/pages/device/device-table-header.component'; +import { MatDialog } from '@angular/material/dialog'; +import { + DeviceCredentialsDialogComponent, + DeviceCredentialsDialogData +} from '@modules/home/pages/device/device-credentials-dialog.component'; +import { DialogService } from '@core/services/dialog.service'; +import { + AssignToCustomerDialogComponent, + AssignToCustomerDialogData +} from '@modules/home/dialogs/assign-to-customer-dialog.component'; +import { DeviceId } from '@app/shared/models/id/device-id'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; +import { DeviceTabsComponent } from '@home/pages/device/device-tabs.component'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; +import { DeviceWizardDialogComponent } from '@home/components/wizard/device-wizard-dialog.component'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { isDefinedAndNotNull } from '@core/utils'; +import { EdgeService } from '@core/http/edge.service'; +import { + AddEntitiesToEdgeDialogComponent, + AddEntitiesToEdgeDialogData +} from '@home/dialogs/add-entities-to-edge-dialog.component'; + +@Injectable() +export class DevicesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + private customerId: string; + + constructor(private store: Store, + private broadcast: BroadcastService, + private deviceService: DeviceService, + private customerService: CustomerService, + private dialogService: DialogService, + private edgeService: EdgeService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.DEVICE; + this.config.entityComponent = DeviceComponent; + this.config.entityTabsComponent = DeviceTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.DEVICE); + this.config.entityResources = entityTypeResources.get(EntityType.DEVICE); + + this.config.addDialogStyle = {width: '600px'}; + + this.config.deleteEntityTitle = device => this.translate.instant('device.delete-device-title', {deviceName: device.name}); + this.config.deleteEntityContent = () => this.translate.instant('device.delete-device-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('device.delete-devices-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('device.delete-devices-text'); + + this.config.loadEntity = id => this.deviceService.getDeviceInfo(id.id); + this.config.saveEntity = device => { + return this.deviceService.saveDevice(device).pipe( + tap(() => { + this.broadcast.broadcast('deviceSaved'); + }), + mergeMap((savedDevice) => this.deviceService.getDeviceInfo(savedDevice.id.id) + )); + }; + this.config.onEntityAction = action => this.onDeviceAction(action, this.config); + this.config.detailsReadonly = () => + (this.config.componentsData.deviceScope === 'customer_user' || this.config.componentsData.deviceScope === 'edge_customer_user'); + + this.config.headerComponent = DeviceTableHeaderComponent; + + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + this.config.componentsData = { + deviceScope: route.data.devicesType, + deviceProfileId: null, + deviceCredentials$: new Subject(), + edgeId: routeParams.edgeId + }; + this.customerId = routeParams.customerId; + this.config.componentsData.edgeId = routeParams.edgeId; + return this.store.pipe(select(selectAuthUser), take(1)).pipe( + tap((authUser) => { + if (authUser.authority === Authority.CUSTOMER_USER) { + if (route.data.devicesType === 'edge') { + this.config.componentsData.deviceScope = 'edge_customer_user'; + } else { + this.config.componentsData.deviceScope = 'customer_user'; + } + this.customerId = authUser.customerId; + } + }), + mergeMap(() => + this.customerId ? this.customerService.getCustomer(this.customerId) : of(null as Customer) + ), + map((parentCustomer) => { + if (parentCustomer) { + if (parentCustomer.additionalInfo && parentCustomer.additionalInfo.isPublic) { + this.config.tableTitle = this.translate.instant('customer.public-devices'); + } else { + this.config.tableTitle = parentCustomer.title + ': ' + this.translate.instant('device.devices'); + } + } else if (this.config.componentsData.deviceScope === 'edge') { + this.edgeService.getEdge(this.config.componentsData.edgeId).subscribe( + edge => this.config.tableTitle = edge.name + ': ' + this.translate.instant('device.devices') + ); + } else { + this.config.tableTitle = this.translate.instant('device.devices'); + } + this.config.columns = this.configureColumns(this.config.componentsData.deviceScope); + this.configureEntityFunctions(this.config.componentsData.deviceScope); + this.config.cellActionDescriptors = this.configureCellActions(this.config.componentsData.deviceScope); + this.config.groupActionDescriptors = this.configureGroupActions(this.config.componentsData.deviceScope); + this.config.addActionDescriptors = this.configureAddActions(this.config.componentsData.deviceScope); + this.config.addEnabled = !(this.config.componentsData.deviceScope === 'customer_user' || this.config.componentsData.deviceScope === 'edge_customer_user'); + this.config.entitiesDeleteEnabled = this.config.componentsData.deviceScope === 'tenant'; + this.config.deleteEnabled = () => this.config.componentsData.deviceScope === 'tenant'; + return this.config; + }) + ); + } + + configureColumns(deviceScope: string): Array> { + const columns: Array> = [ + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'device.name', '25%'), + new EntityTableColumn('deviceProfileName', 'device-profile.device-profile', '25%'), + new EntityTableColumn('label', 'device.label', '25%') + ]; + if (deviceScope === 'tenant') { + columns.push( + new EntityTableColumn('customerTitle', 'customer.customer', '25%'), + new EntityTableColumn('customerIsPublic', 'device.public', '60px', + entity => { + return checkBoxCell(entity.customerIsPublic); + }, () => ({}), false), + ); + } + columns.push( + new EntityTableColumn('gateway', 'device.is-gateway', '60px', + entity => { + return checkBoxCell(entity.additionalInfo && entity.additionalInfo.gateway); + }, () => ({}), false) + ); + return columns; + } + + configureEntityFunctions(deviceScope: string): void { + if (deviceScope === 'tenant') { + this.config.entitiesFetchFunction = pageLink => + this.deviceService.getTenantDeviceInfosByDeviceProfileId(pageLink, + this.config.componentsData.deviceProfileId !== null ? + this.config.componentsData.deviceProfileId.id : ''); + this.config.deleteEntity = id => this.deviceService.deleteDevice(id.id); + } else if (deviceScope === 'edge' || deviceScope === 'edge_customer_user') { + this.config.entitiesFetchFunction = pageLink => + this.deviceService.getEdgeDevices(this.config.componentsData.edgeId, pageLink, this.config.componentsData.edgeType); + } else { + this.config.entitiesFetchFunction = pageLink => + this.deviceService.getCustomerDeviceInfosByDeviceProfileId(this.customerId, pageLink, + this.config.componentsData.deviceProfileId !== null ? + this.config.componentsData.deviceProfileId.id : ''); + this.config.deleteEntity = id => this.deviceService.unassignDeviceFromCustomer(id.id); + } + } + + configureCellActions(deviceScope: string): Array> { + const actions: Array> = []; + if (deviceScope === 'tenant') { + actions.push( + { + name: this.translate.instant('device.make-public'), + icon: 'share', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.makePublic($event, entity) + }, + { + name: this.translate.instant('device.assign-to-customer'), + icon: 'assignment_ind', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.assignToCustomer($event, [entity.id]) + }, + { + name: this.translate.instant('device.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.manage-credentials'), + icon: 'security', + isEnabled: () => true, + onAction: ($event, entity) => this.manageCredentials($event, entity) + } + ); + } + if (deviceScope === 'customer') { + actions.push( + { + name: this.translate.instant('device.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.manage-credentials'), + icon: 'security', + isEnabled: () => true, + onAction: ($event, entity) => this.manageCredentials($event, entity) + } + ); + } + if (deviceScope === 'customer_user' || deviceScope === 'edge_customer_user') { + actions.push( + { + name: this.translate.instant('device.view-credentials'), + icon: 'security', + isEnabled: () => true, + onAction: ($event, entity) => this.manageCredentials($event, entity) + } + ); + } + if (deviceScope === 'edge') { + actions.push( + { + name: this.translate.instant('edge.unassign-from-edge'), + icon: 'assignment_return', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.unassignFromEdge($event, entity) + } + ); + } + return actions; + } + + configureGroupActions(deviceScope: string): Array> { + const actions: Array> = []; + if (deviceScope === 'tenant') { + actions.push( + { + name: this.translate.instant('device.assign-devices'), + icon: 'assignment_ind', + isEnabled: true, + onAction: ($event, entities) => this.assignToCustomer($event, entities.map((entity) => entity.id)) + } + ); + } + if (deviceScope === 'customer') { + actions.push( + { + name: this.translate.instant('device.unassign-devices'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignDevicesFromCustomer($event, entities) + } + ); + } + if (deviceScope === 'edge') { + actions.push( + { + name: this.translate.instant('device.unassign-devices-from-edge'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignDevicesFromEdge($event, entities) + } + ); + } + return actions; + } + + configureAddActions(deviceScope: string): Array { + const actions: Array = []; + if (deviceScope === 'tenant') { + actions.push( + { + name: this.translate.instant('device.add-device-text'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.deviceWizard($event) + }, + { + name: this.translate.instant('device.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importDevices($event) + }, + ); + } + if (deviceScope === 'customer') { + actions.push( + { + name: this.translate.instant('device.assign-new-device'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addDevicesToCustomer($event) + } + ); + } + if (deviceScope === 'edge') { + actions.push( + { + name: this.translate.instant('device.assign-new-device'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addDevicesToEdge($event) + } + ); + } + return actions; + } + + private openDevice($event: Event, device: Device, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([device.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + importDevices($event: Event) { + this.homeDialogs.importEntities(EntityType.DEVICE).subscribe((res) => { + if (res) { + this.broadcast.broadcast('deviceSaved'); + this.config.updateData(); + } + }); + } + + deviceWizard($event: Event) { + this.dialog.open>, + boolean>(DeviceWizardDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entitiesTableConfig: this.config + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.config.updateData(); + } + } + ); + } + + addDevicesToCustomer($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.customerId, + entityType: EntityType.DEVICE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + makePublic($event: Event, device: Device) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('device.make-public-device-title', {deviceName: device.name}), + this.translate.instant('device.make-public-device-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.deviceService.makeDevicePublic(device.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + assignToCustomer($event: Event, deviceIds: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AssignToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityIds: deviceIds, + entityType: EntityType.DEVICE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromCustomer($event: Event, device: DeviceInfo) { + if ($event) { + $event.stopPropagation(); + } + const isPublic = device.customerIsPublic; + let title; + let content; + if (isPublic) { + title = this.translate.instant('device.make-private-device-title', {deviceName: device.name}); + content = this.translate.instant('device.make-private-device-text'); + } else { + title = this.translate.instant('device.unassign-device-title', {deviceName: device.name}); + content = this.translate.instant('device.unassign-device-text'); + } + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.deviceService.unassignDeviceFromCustomer(device.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.deviceScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignDevicesFromCustomer($event: Event, devices: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('device.unassign-devices-title', {count: devices.length}), + this.translate.instant('device.unassign-devices-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + devices.forEach( + (device) => { + tasks.push(this.deviceService.unassignDeviceFromCustomer(device.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + manageCredentials($event: Event, device: Device) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(DeviceCredentialsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + deviceId: device.id.id, + deviceProfileId: device.deviceProfileId.id, + isReadOnly: this.config.componentsData.deviceScope === 'customer_user' || this.config.componentsData.deviceScope === 'edge_customer_user' + } + }).afterClosed().subscribe(deviceCredentials => { + if (isDefinedAndNotNull(deviceCredentials)) { + this.config.componentsData.deviceCredentials$.next(deviceCredentials); + } + }); + } + + onDeviceAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openDevice(action.event, action.entity, config); + return true; + case 'makePublic': + this.makePublic(action.event, action.entity); + return true; + case 'assignToCustomer': + this.assignToCustomer(action.event, [action.entity.id]); + return true; + case 'unassignFromCustomer': + this.unassignFromCustomer(action.event, action.entity); + return true; + case 'unassignFromEdge': + this.unassignFromEdge(action.event, action.entity); + return true; + case 'manageCredentials': + this.manageCredentials(action.event, action.entity); + return true; + } + return false; + } + + addDevicesToEdge($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToEdgeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + edgeId: this.config.componentsData.edgeId, + entityType: EntityType.DEVICE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromEdge($event: Event, device: DeviceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('device.unassign-device-from-edge-title', {deviceName: device.name}), + this.translate.instant('device.unassign-device-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.deviceService.unassignDeviceFromEdge(this.config.componentsData.edgeId, device.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.deviceScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignDevicesFromEdge($event: Event, devices: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('device.unassign-devices-from-edge-title', {count: devices.length}), + this.translate.instant('device.unassign-devices-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + devices.forEach( + (device) => { + tasks.push(this.deviceService.unassignDeviceFromEdge(this.config.componentsData.edgeId, device.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts new file mode 100644 index 0000000..f983819 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -0,0 +1,379 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { EdgesTableConfigResolver } from '@home/pages/edge/edges-table-config.resolver'; +import { AssetsTableConfigResolver } from '@home/pages/asset/assets-table-config.resolver'; +import { DevicesTableConfigResolver } from '@home/pages/device/devices-table-config.resolver'; +import { EntityViewsTableConfigResolver } from '@home/pages/entity-view/entity-views-table-config.resolver'; +import { DashboardsTableConfigResolver } from '@home/pages/dashboard/dashboards-table-config.resolver'; +import { RuleChainsTableConfigResolver } from '@home/pages/rulechain/rulechains-table-config.resolver'; +import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component'; +import { dashboardBreadcumbLabelFunction, DashboardResolver } from '@home/pages/dashboard/dashboard-routing.module'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { RuleChainType } from '@shared/models/rule-chain.models'; +import { + importRuleChainBreadcumbLabelFunction, + RuleChainMetaDataResolver, + ruleChainBreadcumbLabelFunction, + RuleChainImportGuard, + RuleChainResolver, + RuleNodeComponentsResolver, + TooltipsterResolver +} from '@home/pages/rulechain/rulechain-routing.module'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; + +const routes: Routes = [ + { + path: 'edgeInstances', + data: { + breadcrumb: { + label: 'edge.edge-instances', + icon: 'router' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.edge-instances', + edgesType: 'tenant' + }, + resolve: { + entitiesTableConfig: EdgesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'router' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.edge-instances', + edgesType: 'tenant' + }, + resolve: { + entitiesTableConfig: EdgesTableConfigResolver + } + }, + { + path: ':edgeId/assets', + data: { + breadcrumb: { + label: 'edge.assets', + icon: 'domain' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.assets', + assetsType: 'edge' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'domain' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.assets', + assetsType: 'edge' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + } + ] + }, + { + path: ':edgeId/devices', + data: { + breadcrumb: { + label: 'edge.devices', + icon: 'devices_other' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.devices', + devicesType: 'edge' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.devices', + devicesType: 'edge' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + } + ] + }, + { + path: ':edgeId/entityViews', + data: { + breadcrumb: { + label: 'edge.entity-views', + icon: 'view_quilt' + }, + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.entity-views', + entityViewsType: 'edge' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.entity-views', + entityViewsType: 'edge' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + } + ] + }, + { + path: ':edgeId/dashboards', + data: { + breadcrumb: { + label: 'edge.dashboards', + icon: 'dashboard' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + dashboardsType: 'edge' + }, + resolve: { + entitiesTableConfig: DashboardsTableConfigResolver + }, + }, + { + path: ':dashboardId', + component: DashboardPageComponent, + data: { + breadcrumb: { + labelFunction: dashboardBreadcumbLabelFunction, + icon: 'dashboard' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.dashboard', + widgetEditMode: false + }, + resolve: { + dashboard: DashboardResolver + } + } + ] + }, + { + path: ':edgeId/ruleChains', + data: { + breadcrumb: { + label: 'edge.edge-rulechains', + icon: 'settings_ethernet' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'edge.rulechains', + ruleChainsType: 'edge' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: false, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: RuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] + } + ] + }, + { + path: 'edgeManagement', + data: { + breadcrumb: { + label: 'edge.management', + icon: 'settings_input_antenna' + } + }, + children: [ + { + path: '', + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + redirectTo: '/edgeManagement/ruleChains' + } + }, + { + path: 'ruleChains', + data: { + breadcrumb: { + label: 'edge.rulechain-templates', + icon: 'settings_ethernet' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'edge.rulechain-templates', + ruleChainsType: 'edges' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: false, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: RuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + }, + { + path: 'ruleChain/import', + component: RuleChainPageComponent, + canActivate: [RuleChainImportGuard], + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: importRuleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: true, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] + } + ] + }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + EdgesTableConfigResolver + ] +}) +export class EdgeRoutingModule { +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html new file mode 100644 index 0000000..2c4b131 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html @@ -0,0 +1,23 @@ + + + diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.scss new file mode 100644 index 0000000..66a9d55 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + flex: 1; + display: flex; + justify-content: flex-start; + min-width: 150px; +} + +:host ::ng-deep { + tb-entity-subtype-select { + width: 100%; + + mat-form-field { + font-size: 16px; + + .mat-form-field-wrapper { + padding-bottom: 0; + } + + .mat-form-field-underline { + bottom: 0; + } + + @media #{$mat-xs} { + width: 100%; + + .mat-form-field-infix { + width: auto !important; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts new file mode 100644 index 0000000..6cec273 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { EntityType } from '@shared/models/entity-type.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EdgeInfo } from '@shared/models/edge.models'; + +@Component({ + selector: 'tb-edge-table-header', + templateUrl: './edge-table-header.component.html', + styleUrls: ['./edge-table-header.component.scss'] +}) +export class EdgeTableHeaderComponent extends EntityTableHeaderComponent { + + entityType = EntityType; + + constructor(protected store: Store) { + super(store); + } + + edgeTypeChanged(edgeType: string) { + this.entitiesTableConfig.componentsData.edgeType = edgeType; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html new file mode 100644 index 0000000..caaf368 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts new file mode 100644 index 0000000..5ac4c2d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EdgeInfo } from '@shared/models/edge.models'; +import { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; + +@Component({ + selector: 'tb-edge-tabs', + templateUrl: './edge-tabs.component.html', + styleUrls: [] +}) +export class EdgeTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html new file mode 100644 index 0000000..48df569 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html @@ -0,0 +1,183 @@ + +
+ + + + + +
+ + + + + +
+
+ + + + +
+
+
+ + edge.assignedToCustomer + + +
+ {{ 'edge.edge-public' | translate }} +
+
+
+ + edge.name + + + {{ 'edge.name-required' | translate }} + + + {{ 'edge.name-max-length' | translate }} + + + + +
+
+ + edge.edge-key + + + +
+
+ + edge.edge-secret + + + +
+
+ + edge.label + + + {{ 'edge.label-max-length' | translate }} + + +
+ + edge.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.scss b/ui-ngx/src/app/modules/home/pages/edge/edge.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts new file mode 100644 index 0000000..500aca6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts @@ -0,0 +1,136 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '@home/components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { EdgeInfo } from '@shared/models/edge.models'; +import { TranslateService } from '@ngx-translate/core'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { generateSecret, guid } from '@core/utils'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; + +@Component({ + selector: 'tb-edge', + templateUrl: './edge.component.html', + styleUrls: ['./edge.component.scss'] +}) +export class EdgeComponent extends EntityComponent { + + entityType = EntityType; + + edgeScope: 'tenant' | 'customer' | 'customer_user'; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: EdgeInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.edgeScope = this.entitiesTableConfig.componentsData.edgeScope; + super.ngOnInit(); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + isAssignedToCustomer(entity: EdgeInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } + + buildForm(entity: EdgeInfo): FormGroup { + const form = this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + type: [entity?.type ? entity.type : 'default', [Validators.required, Validators.maxLength(255)]], + label: [entity ? entity.label : '', Validators.maxLength(255)], + routingKey: this.fb.control({value: entity ? entity.routingKey : null, disabled: true}), + secret: this.fb.control({value: entity ? entity.secret : null, disabled: true}), + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''] + } + ) + } + ); + this.generateRoutingKeyAndSecret(entity, form); + return form; + } + + updateForm(entity: EdgeInfo) { + this.entityForm.patchValue({ + name: entity.name, + type: entity.type, + label: entity.label, + routingKey: entity.routingKey, + secret: entity.secret, + additionalInfo: { + description: entity.additionalInfo ? entity.additionalInfo.description : '' + } + }); + this.generateRoutingKeyAndSecret(entity, this.entityForm); + } + + updateFormState() { + super.updateFormState(); + this.entityForm.get('routingKey').disable({emitEvent: false}); + this.entityForm.get('secret').disable({emitEvent: false}); + } + + onEdgeIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('edge.id-copied-message'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onEdgeInfoCopied(type: string) { + const message = type === 'key' ? 'edge.edge-key-copied-message' + : 'edge.edge-secret-copied-message'; + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant(message), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + private generateRoutingKeyAndSecret(entity: EdgeInfo, form: FormGroup) { + if (entity && !entity.id) { + form.get('routingKey').patchValue(guid(), {emitEvent: false}); + form.get('secret').patchValue(generateSecret(20), {emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge.module.ts new file mode 100644 index 0000000..d34a699 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.module.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeDialogsModule } from '@home/dialogs/home-dialogs.module'; +import { HomeComponentsModule } from '@home/components/home-components.module'; +import { EdgeRoutingModule } from '@home/pages/edge/edge-routing.module'; +import { EdgeComponent } from '@modules/home/pages/edge/edge.component'; +import { EdgeTableHeaderComponent } from '@home/pages/edge/edge-table-header.component'; +import { EdgeTabsComponent } from '@home/pages/edge/edge-tabs.component'; + +@NgModule({ + declarations: [ + EdgeComponent, + EdgeTableHeaderComponent, + EdgeTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeDialogsModule, + HomeComponentsModule, + EdgeRoutingModule + ] +}) + +export class EdgeModule { } diff --git a/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts new file mode 100644 index 0000000..6f077fb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts @@ -0,0 +1,564 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { selectAuthUser } from '@core/auth/auth.selectors'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { AppState } from '@core/core.state'; +import { Authority } from '@app/shared/models/authority.enum'; +import { CustomerService } from '@core/http/customer.service'; +import { Customer } from '@app/shared/models/customer.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { + AssignToCustomerDialogComponent, + AssignToCustomerDialogData +} from '@modules/home/dialogs/assign-to-customer-dialog.component'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; +import { Edge, EdgeInfo } from '@shared/models/edge.models'; +import { EdgeService } from '@core/http/edge.service'; +import { EdgeComponent } from '@home/pages/edge/edge.component'; +import { EdgeTableHeaderComponent } from '@home/pages/edge/edge-table-header.component'; +import { EdgeId } from '@shared/models/id/edge-id'; +import { EdgeTabsComponent } from '@home/pages/edge/edge-tabs.component'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; + +@Injectable() +export class EdgesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + private customerId: string; + + constructor(private store: Store, + private broadcast: BroadcastService, + private edgeService: EdgeService, + private customerService: CustomerService, + private dialogService: DialogService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.EDGE; + this.config.entityComponent = EdgeComponent; + this.config.entityTabsComponent = EdgeTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.EDGE); + this.config.entityResources = entityTypeResources.get(EntityType.EDGE); + + this.config.deleteEntityTitle = edge => this.translate.instant('edge.delete-edge-title', {edgeName: edge.name}); + this.config.deleteEntityContent = () => this.translate.instant('edge.delete-edge-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('edge.delete-edges-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('edge.delete-edges-text'); + + this.config.loadEntity = id => this.edgeService.getEdgeInfo(id.id); + this.config.saveEntity = edge => { + return this.edgeService.saveEdge(edge).pipe( + tap(() => { + this.broadcast.broadcast('edgeSaved'); + }), + mergeMap((savedEdge) => this.edgeService.getEdgeInfo(savedEdge.id.id) + )); + }; + this.config.onEntityAction = action => this.onEdgeAction(action, this.config); + this.config.detailsReadonly = () => this.config.componentsData.edgeScope === 'customer_user'; + this.config.headerComponent = EdgeTableHeaderComponent; + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + this.config.componentsData = { + edgeScope: route.data.edgesType, + edgeType: '' + }; + this.customerId = routeParams.customerId; + return this.store.pipe(select(selectAuthUser), take(1)).pipe( + tap((authUser) => { + if (authUser.authority === Authority.CUSTOMER_USER) { + this.config.componentsData.edgeScope = 'customer_user'; + this.customerId = authUser.customerId; + } + }), + mergeMap(() => + this.customerId ? this.customerService.getCustomer(this.customerId) : of(null as Customer) + ), + map((parentCustomer) => { + if (parentCustomer) { + if (parentCustomer.additionalInfo && parentCustomer.additionalInfo.isPublic) { + this.config.tableTitle = this.translate.instant('customer.public-edges'); + } else { + this.config.tableTitle = parentCustomer.title + ': ' + this.translate.instant('edge.edge-instances'); + } + } else { + this.config.tableTitle = this.translate.instant('edge.edge-instances'); + } + this.config.columns = this.configureColumns(this.config.componentsData.edgeScope); + this.configureEntityFunctions(this.config.componentsData.edgeScope); + this.config.cellActionDescriptors = this.configureCellActions(this.config.componentsData.edgeScope); + this.config.groupActionDescriptors = this.configureGroupActions(this.config.componentsData.edgeScope); + this.config.addActionDescriptors = this.configureAddActions(this.config.componentsData.edgeScope); + this.config.addEnabled = this.config.componentsData.edgeScope !== 'customer_user'; + this.config.entitiesDeleteEnabled = this.config.componentsData.edgeScope === 'tenant'; + this.config.deleteEnabled = () => this.config.componentsData.edgeScope === 'tenant'; + return this.config; + }) + ); + } + + configureColumns(edgeScope: string): Array> { + const columns: Array> = [ + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'edge.name', '25%'), + new EntityTableColumn('type', 'edge.edge-type', '25%'), + new EntityTableColumn('label', 'edge.label', '25%') + ]; + if (edgeScope === 'tenant') { + columns.push( + new EntityTableColumn('customerTitle', 'customer.customer', '25%'), + new EntityTableColumn('customerIsPublic', 'edge.public', '60px', + entity => { + return checkBoxCell(entity.customerIsPublic); + }, () => ({}), false) + ); + } + return columns; + } + + configureEntityFunctions(edgeScope: string): void { + if (edgeScope === 'tenant') { + this.config.entitiesFetchFunction = pageLink => + this.edgeService.getTenantEdgeInfos(pageLink, this.config.componentsData.edgeType); + this.config.deleteEntity = id => this.edgeService.deleteEdge(id.id); + } + if (edgeScope === 'customer') { + this.config.entitiesFetchFunction = pageLink => + this.edgeService.getCustomerEdgeInfos(this.customerId, pageLink, this.config.componentsData.edgeType); + this.config.deleteEntity = id => this.edgeService.unassignEdgeFromCustomer(id.id); + } + if (edgeScope === 'customer_user') { + this.config.entitiesFetchFunction = pageLink => + this.edgeService.getCustomerEdgeInfos(this.customerId, pageLink, this.config.componentsData.edgeType); + this.config.deleteEntity = id => this.edgeService.unassignEdgeFromCustomer(id.id); + } + } + + configureCellActions(edgeScope: string): Array> { + const actions: Array> = []; + if (edgeScope === 'tenant') { + actions.push( + { + name: this.translate.instant('edge.make-public'), + icon: 'share', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.makePublic($event, entity) + }, + { + name: this.translate.instant('edge.assign-to-customer'), + icon: 'assignment_ind', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.assignToCustomer($event, [entity.id]) + }, + { + name: this.translate.instant('edge.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('edge.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('edge.edge-assets'), + icon: 'domain', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.ASSET) + }, + { + name: this.translate.instant('edge.edge-devices'), + icon: 'devices_other', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.DEVICE) + }, + { + name: this.translate.instant('edge.edge-entity-views'), + icon: 'view_quilt', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.ENTITY_VIEW) + }, + { + name: this.translate.instant('edge.edge-dashboards'), + icon: 'dashboard', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.DASHBOARD) + }, + { + name: this.translate.instant('edge.edge-rulechains'), + icon: 'settings_ethernet', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.RULE_CHAIN) + } + ); + } + if (edgeScope === 'customer') { + actions.push( + { + name: this.translate.instant('edge.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('edge.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + ); + } + if (edgeScope === 'customer_user') { + actions.push( + { + name: this.translate.instant('edge.edge-assets'), + icon: 'domain', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.ASSET) + }, + { + name: this.translate.instant('edge.edge-devices'), + icon: 'devices_other', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.DEVICE) + }, + { + name: this.translate.instant('edge.edge-entity-views'), + icon: 'view_quilt', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.ENTITY_VIEW) + }, + { + name: this.translate.instant('edge.edge-dashboards'), + icon: 'dashboard', + isEnabled: (entity) => true, + onAction: ($event, entity) => this.openEdgeEntitiesByType($event, entity, EntityType.DASHBOARD) + } + ); + } + return actions; + } + + configureGroupActions(edgeScope: string): Array> { + const actions: Array> = []; + if (edgeScope === 'tenant') { + actions.push( + { + name: this.translate.instant('edge.assign-edge-to-customer-text'), + icon: 'assignment_ind', + isEnabled: true, + onAction: ($event, entities) => this.assignToCustomer($event, entities.map((entity) => entity.id)) + } + ); + } + if (edgeScope === 'customer') { + actions.push( + { + name: this.translate.instant('edge.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignEdgesFromCustomer($event, entities) + } + ); + } + return actions; + } + + configureAddActions(edgeScope: string): Array { + const actions: Array = []; + if (edgeScope === 'tenant') { + actions.push( + { + name: this.translate.instant('edge.add-edge-text'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('edge.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importEdges($event) + } + ); + } + if (edgeScope === 'customer') { + actions.push( + { + name: this.translate.instant('edge.assign-new-edge'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addEdgesToCustomer($event) + } + ); + } + return actions; + } + + importEdges($event: Event) { + this.homeDialogs.importEntities(EntityType.EDGE).subscribe((res) => { + if (res) { + this.broadcast.broadcast('edgeSaved'); + this.config.updateData(); + } + }); + } + + addEdgesToCustomer($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.customerId, + entityType: EntityType.EDGE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private openEdge($event: Event, edge: Edge, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([edge.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + makePublic($event: Event, edge: Edge) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('edge.make-public-edge-title', {edgeName: edge.name}), + this.translate.instant('edge.make-public-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.edgeService.makeEdgePublic(edge.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + openEdgeEntitiesByType($event: Event, edge: Edge, entityType: EntityType) { + if ($event) { + $event.stopPropagation(); + } + let suffix: string; + switch (entityType) { + case EntityType.DEVICE: + suffix = 'devices'; + break; + case EntityType.ASSET: + suffix = 'assets'; + break; + case EntityType.EDGE: + suffix = 'assets'; + break; + case EntityType.ENTITY_VIEW: + suffix = 'entityViews'; + break; + case EntityType.DASHBOARD: + suffix = 'dashboards'; + break; + case EntityType.RULE_CHAIN: + suffix = 'ruleChains'; + break; + } + this.router.navigateByUrl(`edgeInstances/${edge.id.id}/${suffix}`); + } + + assignToCustomer($event: Event, edgesIds: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AssignToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityIds: edgesIds, + entityType: EntityType.EDGE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromCustomer($event: Event, edge: EdgeInfo) { + if ($event) { + $event.stopPropagation(); + } + const isPublic = edge.customerIsPublic; + let title; + let content; + if (isPublic) { + title = this.translate.instant('edge.make-private-edge-title', {edgeName: edge.name}); + content = this.translate.instant('edge.make-private-edge-text'); + } else { + title = this.translate.instant('edge.unassign-edge-title', {edgeName: edge.name}); + content = this.translate.instant('edge.unassign-edge-text'); + } + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.edgeService.unassignEdgeFromCustomer(edge.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.edgeScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignEdgesFromCustomer($event: Event, edges: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('edge.unassign-edges-title', {count: edges.length}), + this.translate.instant('edge.unassign-edges-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + edges.forEach( + (edge) => { + tasks.push(this.edgeService.unassignEdgeFromCustomer(edge.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + syncEdge($event, edge) { + if ($event) { + $event.stopPropagation(); + } + this.edgeService.syncEdge(edge.id.id).subscribe( + () => { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('edge.sync-process-started-successfully'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + ); + } + + onEdgeAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openEdge(action.event, action.entity, config); + return true; + case 'makePublic': + this.makePublic(action.event, action.entity); + return true; + case 'assignToCustomer': + this.assignToCustomer(action.event, [action.entity.id]); + return true; + case 'unassignFromCustomer': + this.unassignFromCustomer(action.event, action.entity); + return true; + case 'openEdgeAssets': + this.openEdgeEntitiesByType(action.event, action.entity, EntityType.ASSET); + return true; + case 'openEdgeDevices': + this.openEdgeEntitiesByType(action.event, action.entity, EntityType.DEVICE); + return true; + case 'openEdgeEntityViews': + this.openEdgeEntitiesByType(action.event, action.entity, EntityType.ENTITY_VIEW); + return true; + case 'openEdgeDashboards': + this.openEdgeEntitiesByType(action.event, action.entity, EntityType.DASHBOARD); + return true; + case 'openEdgeRuleChains': + this.openEdgeEntitiesByType(action.event, action.entity, EntityType.RULE_CHAIN); + return true; + case 'syncEdge': + this.syncEdge(action.event, action.entity); + return true; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts new file mode 100644 index 0000000..a083e8f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-routing.module.ts @@ -0,0 +1,78 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { EntityViewsTableConfigResolver } from '@modules/home/pages/entity-view/entity-views-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'entityViews', + data: { + breadcrumb: { + label: 'entity-view.entity-views', + icon: 'view_quilt' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'entity-view.entity-views', + entityViewsType: 'tenant' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'view_quilt' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'entity-view.entity-views', + entityViewsType: 'tenant' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + EntityViewsTableConfigResolver + ] +}) +export class EntityViewRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html new file mode 100644 index 0000000..8ce9340 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html @@ -0,0 +1,23 @@ + + + diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss new file mode 100644 index 0000000..66a9d55 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + flex: 1; + display: flex; + justify-content: flex-start; + min-width: 150px; +} + +:host ::ng-deep { + tb-entity-subtype-select { + width: 100%; + + mat-form-field { + font-size: 16px; + + .mat-form-field-wrapper { + padding-bottom: 0; + } + + .mat-form-field-underline { + bottom: 0; + } + + @media #{$mat-xs} { + width: 100%; + + .mat-form-field-infix { + width: auto !important; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.ts new file mode 100644 index 0000000..8ad78a4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; +import { EntityType } from '@shared/models/entity-type.models'; +import { EntityViewInfo } from '@app/shared/models/entity-view.models'; + +@Component({ + selector: 'tb-entity-view-table-header', + templateUrl: './entity-view-table-header.component.html', + styleUrls: ['./entity-view-table-header.component.scss'] +}) +export class EntityViewTableHeaderComponent extends EntityTableHeaderComponent { + + entityType = EntityType; + + constructor(protected store: Store) { + super(store); + } + + entityViewTypeChanged(entityViewType: string) { + this.entitiesTableConfig.componentsData.entityViewType = entityViewType; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html new file mode 100644 index 0000000..87ee70f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts new file mode 100644 index 0000000..b954f16 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { EntityViewInfo } from '@app/shared/models/entity-view.models'; + +@Component({ + selector: 'tb-entity-view-tabs', + templateUrl: './entity-view-tabs.component.html', + styleUrls: [] +}) +export class EntityViewTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html new file mode 100644 index 0000000..545ffb9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html @@ -0,0 +1,171 @@ + +
+ + + + + + +
+ +
+
+
+ + entity-view.assignedToCustomer + + +
+ {{ 'entity-view.entity-view-public' | translate }} +
+
+
+ + entity-view.name + + + {{ 'entity-view.name-required' | translate }} + + + {{ 'entity-view.name-max-length' | translate }} + + + + +
+ + + +
+
+ + + + +
entity-view.attributes-propagation
+
+
+
entity-view.attributes-propagation-hint
+ + + + + + + + + +
+ + + +
entity-view.timeseries-data
+
+
+
entity-view.timeseries-data-hint
+ + + +
+
+
+ + +
+ + entity-view.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.scss b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.scss new file mode 100644 index 0000000..6bcdcb1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-accordion-container { + margin-bottom: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts new file mode 100644 index 0000000..36a064c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts @@ -0,0 +1,141 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityViewInfo } from '@app/shared/models/entity-view.models'; +import { Observable } from 'rxjs'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@app/shared/models/id/entity-id'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; + +@Component({ + selector: 'tb-entity-view', + templateUrl: './entity-view.component.html', + styleUrls: ['./entity-view.component.scss'] +}) +export class EntityViewComponent extends EntityComponent { + + entityType = EntityType; + + dataKeyType = DataKeyType; + + entityViewScope: 'tenant' | 'customer' | 'customer_user' | 'edge'; + + allowedEntityTypes = [EntityType.DEVICE, EntityType.ASSET]; + + maxStartTimeMs: Observable; + minEndTimeMs: Observable; + + selectedEntityId: Observable; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: EntityViewInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.entityViewScope = this.entitiesTableConfig.componentsData.entityViewScope; + super.ngOnInit(); + this.maxStartTimeMs = this.entityForm.get('endTimeMs').valueChanges; + this.minEndTimeMs = this.entityForm.get('startTimeMs').valueChanges; + this.selectedEntityId = this.entityForm.get('entityId').valueChanges; + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + isAssignedToCustomer(entity: EntityViewInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } + + buildForm(entity: EntityViewInfo): FormGroup { + return this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + type: [entity ? entity.type : null, Validators.required], + entityId: [entity ? entity.entityId : null, [Validators.required]], + startTimeMs: [entity ? entity.startTimeMs : null], + endTimeMs: [entity ? entity.endTimeMs : null], + keys: this.fb.group( + { + attributes: this.fb.group( + { + cs: [entity && entity.keys && entity.keys.attributes ? entity.keys.attributes.cs : null], + sh: [entity && entity.keys && entity.keys.attributes ? entity.keys.attributes.sh : null], + ss: [entity && entity.keys && entity.keys.attributes ? entity.keys.attributes.ss : null], + } + ), + timeseries: [entity && entity.keys && entity.keys.timeseries ? entity.keys.timeseries : null] + } + ), + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + } + ) + } + ); + } + + updateForm(entity: EntityViewInfo) { + this.entityForm.patchValue({name: entity.name}); + this.entityForm.patchValue({type: entity.type}); + this.entityForm.patchValue({entityId: entity.entityId}); + this.entityForm.patchValue({startTimeMs: entity.startTimeMs}); + this.entityForm.patchValue({endTimeMs: entity.endTimeMs}); + this.entityForm.patchValue({ + keys: + { + attributes: { + cs: entity.keys && entity.keys.attributes ? entity.keys.attributes.cs : null, + sh: entity.keys && entity.keys.attributes ? entity.keys.attributes.sh : null, + ss: entity.keys && entity.keys.attributes ? entity.keys.attributes.ss : null, + }, + timeseries: entity.keys && entity.keys.timeseries ? entity.keys.timeseries : null + } + }); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + } + + + onEntityViewIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('entity-view.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.module.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.module.ts new file mode 100644 index 0000000..4a4ab29 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.module.ts @@ -0,0 +1,41 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; +import { EntityViewComponent } from '@modules/home/pages/entity-view/entity-view.component'; +import { EntityViewTableHeaderComponent } from './entity-view-table-header.component'; +import { EntityViewRoutingModule } from './entity-view-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { EntityViewTabsComponent } from '@home/pages/entity-view/entity-view-tabs.component'; + +@NgModule({ + declarations: [ + EntityViewComponent, + EntityViewTabsComponent, + EntityViewTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeDialogsModule, + EntityViewRoutingModule + ] +}) +export class EntityViewModule { } diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts new file mode 100644 index 0000000..21946a8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts @@ -0,0 +1,537 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { select, Store } from '@ngrx/store'; +import { selectAuthUser } from '@core/auth/auth.selectors'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { AppState } from '@core/core.state'; +import { Authority } from '@app/shared/models/authority.enum'; +import { CustomerService } from '@core/http/customer.service'; +import { Customer } from '@app/shared/models/customer.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { + AssignToCustomerDialogComponent, + AssignToCustomerDialogData +} from '@modules/home/dialogs/assign-to-customer-dialog.component'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; +import { EntityView, EntityViewInfo } from '@app/shared/models/entity-view.models'; +import { EntityViewService } from '@core/http/entity-view.service'; +import { EntityViewComponent } from '@modules/home/pages/entity-view/entity-view.component'; +import { EntityViewTableHeaderComponent } from '@modules/home/pages/entity-view/entity-view-table-header.component'; +import { EntityViewId } from '@shared/models/id/entity-view-id'; +import { EntityViewTabsComponent } from '@home/pages/entity-view/entity-view-tabs.component'; +import { EdgeService } from '@core/http/edge.service'; +import { + AddEntitiesToEdgeDialogComponent, + AddEntitiesToEdgeDialogData +} from '@home/dialogs/add-entities-to-edge-dialog.component'; + +@Injectable() +export class EntityViewsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + private customerId: string; + + constructor(private store: Store, + private broadcast: BroadcastService, + private entityViewService: EntityViewService, + private customerService: CustomerService, + private edgeService: EdgeService, + private dialogService: DialogService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.ENTITY_VIEW; + this.config.entityComponent = EntityViewComponent; + this.config.entityTabsComponent = EntityViewTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.ENTITY_VIEW); + this.config.entityResources = entityTypeResources.get(EntityType.ENTITY_VIEW); + + this.config.addDialogStyle = {maxWidth: '800px'}; + + this.config.deleteEntityTitle = entityView => + this.translate.instant('entity-view.delete-entity-view-title', {entityViewName: entityView.name}); + this.config.deleteEntityContent = () => this.translate.instant('entity-view.delete-entity-view-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('entity-view.delete-entity-views-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('entity-view.delete-entity-views-text'); + + this.config.loadEntity = id => this.entityViewService.getEntityViewInfo(id.id); + this.config.saveEntity = entityView => { + return this.entityViewService.saveEntityView(entityView).pipe( + tap(() => { + this.broadcast.broadcast('entityViewSaved'); + }), + mergeMap((savedEntityView) => this.entityViewService.getEntityViewInfo(savedEntityView.id.id) + )); + }; + this.config.onEntityAction = action => this.onEntityViewAction(action, this.config); + this.config.detailsReadonly = () => (this.config.componentsData.entityViewScope === 'customer_user' || + this.config.componentsData.entityViewScope === 'edge_customer_user'); + + this.config.headerComponent = EntityViewTableHeaderComponent; + + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + this.config.componentsData = { + entityViewScope: route.data.entityViewsType, + entityViewType: '', + edgeId: routeParams.edgeId + }; + this.customerId = routeParams.customerId; + return this.store.pipe(select(selectAuthUser), take(1)).pipe( + tap((authUser) => { + if (authUser.authority === Authority.CUSTOMER_USER) { + if (route.data.entityViewsType === 'edge') { + this.config.componentsData.entityViewScope = 'edge_customer_user'; + } else { + this.config.componentsData.entityViewScope = 'customer_user'; + } + this.customerId = authUser.customerId; + } + }), + mergeMap(() => + this.customerId ? this.customerService.getCustomer(this.customerId) : of(null as Customer) + ), + map((parentCustomer) => { + if (parentCustomer) { + if (parentCustomer.additionalInfo && parentCustomer.additionalInfo.isPublic) { + this.config.tableTitle = this.translate.instant('customer.public-entity-views'); + } else { + this.config.tableTitle = parentCustomer.title + ': ' + this.translate.instant('entity-view.entity-views'); + } + } else if (this.config.componentsData.entityViewScope === 'edge') { + this.edgeService.getEdge(this.config.componentsData.edgeId).subscribe( + edge => this.config.tableTitle = edge.name + ': ' + this.translate.instant('entity-view.entity-views') + ); + } else { + this.config.tableTitle = this.translate.instant('entity-view.entity-views'); + } + this.config.columns = this.configureColumns(this.config.componentsData.entityViewScope); + this.configureEntityFunctions(this.config.componentsData.entityViewScope); + this.config.cellActionDescriptors = this.configureCellActions(this.config.componentsData.entityViewScope); + this.config.groupActionDescriptors = this.configureGroupActions(this.config.componentsData.entityViewScope); + this.config.addActionDescriptors = this.configureAddActions(this.config.componentsData.entityViewScope); + this.config.addEnabled = !(this.config.componentsData.entityViewScope === 'customer_user' || + this.config.componentsData.entityViewScope === 'edge_customer_user'); + this.config.entitiesDeleteEnabled = this.config.componentsData.entityViewScope === 'tenant'; + this.config.deleteEnabled = () => this.config.componentsData.entityViewScope === 'tenant'; + return this.config; + }) + ); + } + + configureColumns(entityViewScope: string): Array> { + const columns: Array> = [ + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'entity-view.name', '33%'), + new EntityTableColumn('type', 'entity-view.entity-view-type', '33%'), + ]; + if (entityViewScope === 'tenant') { + columns.push( + new EntityTableColumn('customerTitle', 'customer.customer', '33%'), + new EntityTableColumn('customerIsPublic', 'entity-view.public', '60px', + entity => { + return checkBoxCell(entity.customerIsPublic); + }, () => ({}), false), + ); + } + return columns; + } + + configureEntityFunctions(entityViewScope: string): void { + if (entityViewScope === 'tenant') { + this.config.entitiesFetchFunction = pageLink => + this.entityViewService.getTenantEntityViewInfos(pageLink, this.config.componentsData.entityViewType); + this.config.deleteEntity = id => this.entityViewService.deleteEntityView(id.id); + } else if (entityViewScope === 'edge' || entityViewScope === 'edge_customer_user') { + this.config.entitiesFetchFunction = pageLink => + this.entityViewService.getEdgeEntityViews(this.config.componentsData.edgeId, pageLink, this.config.componentsData.entityViewType); + } else { + this.config.entitiesFetchFunction = pageLink => + this.entityViewService.getCustomerEntityViewInfos(this.customerId, pageLink, this.config.componentsData.entityViewType); + this.config.deleteEntity = id => this.entityViewService.unassignEntityViewFromCustomer(id.id); + } + } + + configureCellActions(entityViewScope: string): Array> { + const actions: Array> = []; + if (entityViewScope === 'tenant') { + actions.push( + { + name: this.translate.instant('entity-view.make-public'), + icon: 'share', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.makePublic($event, entity) + }, + { + name: this.translate.instant('entity-view.assign-to-customer'), + icon: 'assignment_ind', + isEnabled: (entity) => (!entity.customerId || entity.customerId.id === NULL_UUID), + onAction: ($event, entity) => this.assignToCustomer($event, [entity.id]) + }, + { + name: this.translate.instant('entity-view.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('entity-view.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + } + ); + } + if (entityViewScope === 'customer') { + actions.push( + { + name: this.translate.instant('entity-view.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('entity-view.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + } + ); + } + if (entityViewScope === 'edge') { + actions.push( + { + name: this.translate.instant('edge.unassign-from-edge'), + icon: 'assignment_return', + isEnabled: () => true, + onAction: ($event, entity) => this.unassignFromEdge($event, entity) + } + ); + } + return actions; + } + + configureGroupActions(entityViewScope: string): Array> { + const actions: Array> = []; + if (entityViewScope === 'tenant') { + actions.push( + { + name: this.translate.instant('entity-view.assign-entity-views'), + icon: 'assignment_ind', + isEnabled: true, + onAction: ($event, entities) => this.assignToCustomer($event, entities.map((entity) => entity.id)) + } + ); + } + if (entityViewScope === 'customer') { + actions.push( + { + name: this.translate.instant('entity-view.unassign-entity-views'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignEntityViewsFromCustomer($event, entities) + } + ); + } + if (entityViewScope === 'edge') { + actions.push( + { + name: this.translate.instant('entity-view.unassign-entity-views-from-edge'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignEntityViewsFromEdge($event, entities) + } + ); + } + return actions; + } + + configureAddActions(entityViewScope: string): Array { + const actions: Array = []; + if (entityViewScope === 'customer') { + actions.push( + { + name: this.translate.instant('entity-view.assign-new-entity-view'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addEntityViewsToCustomer($event) + } + ); + } + if (entityViewScope === 'edge') { + actions.push( + { + name: this.translate.instant('entity-view.assign-new-entity-view'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addEntityViewsToEdge($event) + } + ); + } + return actions; + } + + addEntityViewsToCustomer($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.customerId, + entityType: EntityType.ENTITY_VIEW + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private openEntityView($event: Event, entityView: EntityView, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([entityView.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + makePublic($event: Event, entityView: EntityView) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('entity-view.make-public-entity-view-title', {entityViewName: entityView.name}), + this.translate.instant('entity-view.make-public-entity-view-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.entityViewService.makeEntityViewPublic(entityView.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + assignToCustomer($event: Event, entityViewIds: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AssignToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityIds: entityViewIds, + entityType: EntityType.ENTITY_VIEW + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromCustomer($event: Event, entityView: EntityViewInfo) { + if ($event) { + $event.stopPropagation(); + } + const isPublic = entityView.customerIsPublic; + let title; + let content; + if (isPublic) { + title = this.translate.instant('entity-view.make-private-entity-view-title', {entityViewName: entityView.name}); + content = this.translate.instant('entity-view.make-private-entity-view-text'); + } else { + title = this.translate.instant('entity-view.unassign-entity-view-title', {entityViewName: entityView.name}); + content = this.translate.instant('entity-view.unassign-entity-view-text'); + } + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.entityViewService.unassignEntityViewFromCustomer(entityView.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.entityViewScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignEntityViewsFromCustomer($event: Event, entityViews: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('entity-view.unassign-entity-views-title', {count: entityViews.length}), + this.translate.instant('entity-view.unassign-entity-views-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + entityViews.forEach( + (entityView) => { + tasks.push(this.entityViewService.unassignEntityViewFromCustomer(entityView.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + onEntityViewAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openEntityView(action.event, action.entity, config); + return true; + case 'makePublic': + this.makePublic(action.event, action.entity); + return true; + case 'assignToCustomer': + this.assignToCustomer(action.event, [action.entity.id]); + return true; + case 'unassignFromCustomer': + this.unassignFromCustomer(action.event, action.entity); + return true; + case 'unassignFromEdge': + this.unassignFromEdge(action.event, action.entity); + return true; + } + return false; + } + + addEntityViewsToEdge($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToEdgeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + edgeId: this.config.componentsData.edgeId, + entityType: EntityType.ENTITY_VIEW + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + unassignFromEdge($event: Event, entityView: EntityViewInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('entity-view.unassign-entity-view-from-edge-title', {entityViewName: entityView.name}), + this.translate.instant('entity-view.unassign-entity-view-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.entityViewService.unassignEntityViewFromEdge(this.config.componentsData.edgeId, entityView.id.id).subscribe( + () => { + this.config.updateData(this.config.componentsData.entityViewScope !== 'tenant'); + } + ); + } + } + ); + } + + unassignEntityViewsFromEdge($event: Event, entityViews: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('entity-view.unassign-entity-views-from-edge-title', {count: entityViews.length}), + this.translate.instant('entity-view.unassign-entity-views-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + entityViews.forEach( + (entityView) => { + tasks.push(this.entityViewService.unassignEntityViewFromEdge(this.config.componentsData.edgeId, entityView.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts new file mode 100644 index 0000000..19da00e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts @@ -0,0 +1,62 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; + +import { HomeLinksComponent } from './home-links.component'; +import { Authority } from '@shared/models/authority.enum'; +import { Observable } from 'rxjs'; +import { HomeDashboard } from '@shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; + +@Injectable() +export class HomeDashboardResolver implements Resolve { + + constructor(private dashboardService: DashboardService) { + } + + resolve(): Observable { + return this.dashboardService.getHomeDashboard(); + } +} + +const routes: Routes = [ + { + path: 'home', + component: HomeLinksComponent, + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'home.home', + breadcrumb: { + label: 'home.home', + icon: 'home' + } + }, + resolve: { + homeDashboard: HomeDashboardResolver + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + HomeDashboardResolver + ] +}) +export class HomeLinksRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html new file mode 100644 index 0000000..b57bb1c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html @@ -0,0 +1,40 @@ + + + + + + + + {{section.name}} + + + + + + {{place.icon}} + + {{place.name}} + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.scss b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.scss new file mode 100644 index 0000000..4d833d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.scss @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + width: 100%; + height: 100%; +} + +:host ::ng-deep { + .tb-home-links { + .mat-headline { + font-size: 20px; + @media #{$mat-gt-xmd} { + font-size: 24px; + } + } + mat-card { + padding: 0; + margin: 8px; + mat-card-title { + margin: 0; + padding: 24px 16px 16px; + } + mat-card-title+mat-card-content { + padding-top: 0; + } + mat-card-content { + padding: 16px; + } + } + .tb-card-button { + width: 100%; + height: 100%; + max-width: 240px; + &:hover { + border-bottom: none; + } + &:focus { + border-bottom: none; + } + .mat-button-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + mat-icon { + margin: auto; + } + span { + height: 18px; + min-height: 36px; + max-height: 36px; + padding: 0 0 20px 0; + margin: auto; + font-size: 18px; + font-weight: 400; + line-height: 18px; + white-space: normal; + } + } + &.mat-raised-button.mat-primary { + .mat-ripple-element { + opacity: 0.3; + background-color: rgba(255, 255, 255, 0.3); + } + } + } + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts new file mode 100644 index 0000000..291324a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts @@ -0,0 +1,76 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { MenuService } from '@core/services/menu.service'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { HomeSection } from '@core/services/menu.models'; +import { ActivatedRoute } from '@angular/router'; +import { HomeDashboard } from '@shared/models/dashboard.models'; + +@Component({ + selector: 'tb-home-links', + templateUrl: './home-links.component.html', + styleUrls: ['./home-links.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class HomeLinksComponent implements OnInit { + + homeSections$ = this.menuService.homeSections(); + + cols = 2; + + homeDashboard: HomeDashboard = this.route.snapshot.data.homeDashboard; + + constructor(private menuService: MenuService, + public breakpointObserver: BreakpointObserver, + private cd: ChangeDetectorRef, + private route: ActivatedRoute) { + } + + ngOnInit() { + if (!this.homeDashboard) { + this.updateColumnCount(); + this.breakpointObserver + .observe([MediaBreakpoints.lg, MediaBreakpoints['gt-lg']]) + .subscribe((state: BreakpointState) => this.updateColumnCount()); + } + } + + private updateColumnCount() { + this.cols = 2; + if (this.breakpointObserver.isMatched(MediaBreakpoints.lg)) { + this.cols = 3; + } + if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-lg'])) { + this.cols = 4; + } + this.cd.detectChanges(); + } + + sectionColspan(section: HomeSection): number { + if (this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm'])) { + let colspan = this.cols; + if (section && section.places && section.places.length <= colspan) { + colspan = section.places.length; + } + return colspan; + } else { + return 2; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.module.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links.module.ts new file mode 100644 index 0000000..3158e0e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.module.ts @@ -0,0 +1,37 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HomeLinksRoutingModule } from './home-links-routing.module'; +import { HomeLinksComponent } from './home-links.component'; +import { SharedModule } from '@app/shared/shared.module'; +import { HomeComponentsModule } from '@home/components/home-components.module'; + +@NgModule({ + declarations: + [ + HomeLinksComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeLinksRoutingModule + ] +}) +export class HomeLinksModule { } diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.models.ts b/ui-ngx/src/app/modules/home/pages/home-pages.models.ts new file mode 100644 index 0000000..2d43c56 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-pages.models.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; + +export const entityDetailsPageBreadcrumbLabelFunction: BreadCrumbLabelFunction + = ((route, translate, component) => { + return component.entity?.name || component.headerSubtitle; +}); + diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts new file mode 100644 index 0000000..433595e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; + +import { AdminModule } from './admin/admin.module'; +import { HomeLinksModule } from './home-links/home-links.module'; +import { ProfileModule } from './profile/profile.module'; +import { SecurityModule } from '@home/pages/security/security.module'; +import { TenantModule } from '@modules/home/pages/tenant/tenant.module'; +import { CustomerModule } from '@modules/home/pages/customer/customer.module'; +import { AuditLogModule } from '@modules/home/pages/audit-log/audit-log.module'; +import { UserModule } from '@modules/home/pages/user/user.module'; +import { DeviceModule } from '@modules/home/pages/device/device.module'; +import { AssetModule } from '@modules/home/pages/asset/asset.module'; +import { EntityViewModule } from '@modules/home/pages/entity-view/entity-view.module'; +import { RuleChainModule } from '@modules/home/pages/rulechain/rulechain.module'; +import { WidgetLibraryModule } from '@modules/home/pages/widget/widget-library.module'; +import { DashboardModule } from '@modules/home/pages/dashboard/dashboard.module'; +import { TenantProfileModule } from './tenant-profile/tenant-profile.module'; +import { DeviceProfileModule } from './device-profile/device-profile.module'; +import { ApiUsageModule } from '@home/pages/api-usage/api-usage.module'; +import { EdgeModule } from '@home/pages/edge/edge.module'; +import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module'; +import { VcModule } from '@home/pages/vc/vc.module'; +import { AssetProfileModule } from '@home/pages/asset-profile/asset-profile.module'; +import { ProfilesModule } from '@home/pages/profiles/profiles.module'; + +@NgModule({ + exports: [ + AdminModule, + HomeLinksModule, + ProfileModule, + SecurityModule, + TenantProfileModule, + TenantModule, + DeviceProfileModule, + AssetProfileModule, + ProfilesModule, + DeviceModule, + AssetModule, + EdgeModule, + EntityViewModule, + CustomerModule, + RuleChainModule, + WidgetLibraryModule, + DashboardModule, + AuditLogModule, + ApiUsageModule, + OtaUpdateModule, + UserModule, + VcModule + ] +}) +export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts new file mode 100644 index 0000000..f83aca1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts @@ -0,0 +1,75 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { NgModule } from '@angular/core'; +import { OtaUpdateTableConfigResolve } from '@home/pages/ota-update/ota-update-table-config.resolve'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'otaUpdates', + data: { + breadcrumb: { + label: 'ota-update.ota-updates', + icon: 'memory' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'ota-update.ota-updates' + }, + resolve: { + entitiesTableConfig: OtaUpdateTableConfigResolve + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'memory' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'ota-update.ota-updates' + }, + resolve: { + entitiesTableConfig: OtaUpdateTableConfigResolve + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + OtaUpdateTableConfigResolve + ] +}) +export class OtaUpdateRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts new file mode 100644 index 0000000..1a5c0e1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts @@ -0,0 +1,170 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Resolve, Router } from '@angular/router'; +import { + CellActionDescriptorType, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { + ChecksumAlgorithmTranslationMap, + OtaPackage, + OtaPackageInfo, + OtaUpdateTypeTranslationMap +} from '@shared/models/ota-package.models'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { OtaPackageService } from '@core/http/ota-package.service'; +import { PageLink } from '@shared/models/page/page-link'; +import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Injectable() +export class OtaUpdateTableConfigResolve implements Resolve> { + + private readonly config: EntityTableConfig = + new EntityTableConfig(); + + constructor(private translate: TranslateService, + private datePipe: DatePipe, + private store: Store, + private otaPackageService: OtaPackageService, + private router: Router, + private fileSize: FileSizePipe) { + this.config.entityType = EntityType.OTA_PACKAGE; + this.config.entityComponent = OtaUpdateComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.OTA_PACKAGE); + this.config.entityResources = entityTypeResources.get(EntityType.OTA_PACKAGE); + + this.config.entityTitle = (otaPackage) => otaPackage ? otaPackage.title : ''; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'ota-update.title', '15%'), + new EntityTableColumn('version', 'ota-update.version', '15%'), + new EntityTableColumn('tag', 'ota-update.version-tag', '15%'), + new EntityTableColumn('type', 'ota-update.package-type', '15%', entity => { + return this.translate.instant(OtaUpdateTypeTranslationMap.get(entity.type)); + }), + new EntityTableColumn('url', 'ota-update.direct-url', '20%', entity => { + return entity.url ? (entity.url.length > 20 ? `${entity.url.slice(0, 20)}…` : entity.url) : ''; + }, () => ({}), true, () => ({}), () => undefined, false, + { + name: this.translate.instant('ota-update.copy-direct-url'), + icon: 'content_paste', + style: { + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (otaPackage) => !!otaPackage.url, + onAction: ($event, entity) => entity.url, + type: CellActionDescriptorType.COPY_BUTTON + }), + new EntityTableColumn('fileName', 'ota-update.file-name', '20%'), + new EntityTableColumn('dataSize', 'ota-update.file-size', '70px', entity => { + return entity.dataSize ? this.fileSize.transform(entity.dataSize) : ''; + }), + new EntityTableColumn('checksum', 'ota-update.checksum', '220px', entity => { + return entity.checksum ? this.checksumText(entity) : ''; + }, () => ({}), true, () => ({}), () => undefined, false, + { + name: this.translate.instant('ota-update.copy-checksum'), + icon: 'content_paste', + style: { + 'font-size': '16px', + color: 'rgba(0,0,0,.87)' + }, + isEnabled: (otaPackage) => !!otaPackage.checksum, + onAction: ($event, entity) => entity.checksum, + type: CellActionDescriptorType.COPY_BUTTON + }) + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('ota-update.download'), + icon: 'file_download', + isEnabled: (otaPackage) => otaPackage.hasData && !otaPackage.url, + onAction: ($event, entity) => this.exportPackage($event, entity) + } + ); + + this.config.deleteEntityTitle = otaPackage => this.translate.instant('ota-update.delete-ota-update-title', + { title: otaPackage.title }); + this.config.deleteEntityContent = () => this.translate.instant('ota-update.delete-ota-update-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('ota-update.delete-ota-updates-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('ota-update.delete-ota-updates-text'); + + this.config.entitiesFetchFunction = pageLink => this.otaPackageService.getOtaPackages(pageLink); + this.config.loadEntity = id => this.otaPackageService.getOtaPackageInfo(id.id); + this.config.saveEntity = otaPackage => this.otaPackageService.saveOtaPackage(otaPackage); + this.config.deleteEntity = id => this.otaPackageService.deleteOtaPackage(id.id); + + this.config.onEntityAction = action => this.onPackageAction(action); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('ota-update.packages-repository'); + return this.config; + } + + private openOtaPackage($event: Event, otaPackage: OtaPackageInfo) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree(['otaUpdates', otaPackage.id.id]); + this.router.navigateByUrl(url); + } + + exportPackage($event: Event, otaPackageInfo: OtaPackageInfo) { + if ($event) { + $event.stopPropagation(); + } + if (otaPackageInfo.url) { + window.open(otaPackageInfo.url, '_blank'); + } else { + this.otaPackageService.downloadOtaPackage(otaPackageInfo.id.id).subscribe(); + } + } + + checksumText(entity): string { + let text = `${ChecksumAlgorithmTranslationMap.get(entity.checksumAlgorithm)}: ${entity.checksum}`; + if (text.length > 20) { + text = `${text.slice(0, 20)}…`; + } + return text; + } + + onPackageAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openOtaPackage(action.event, action.entity); + return true; + case 'uploadPackage': + this.exportPackage(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html new file mode 100644 index 0000000..3545a63 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html @@ -0,0 +1,184 @@ + +
+ + + +
+ + + +
+
+
+
+
+
+ + ota-update.title + + + {{ 'ota-update.title-required' | translate }} + + + {{ 'ota-update.title-max-length' | translate }} + + + + ota-update.version + + + {{ 'ota-update.version-required' | translate }} + + + {{ 'ota-update.version-max-length' | translate }} + + +
+ + ota-update.version-tag + + ota-update.version-tag-hint + + + + + ota-update.package-type + + + {{ otaUpdateTypeTranslationMap.get(packageType) | translate }} + + + +
+
ota-update.warning-after-save-no-edit
+ + {{ "ota-update.upload-binary-file" | translate }} + {{ "ota-update.use-external-url" | translate }} + +
+
+
+ + + + {{ 'ota-update.auto-generate-checksum' | translate }} + +
+
+ + ota-update.checksum-algorithm + + + {{ checksumAlgorithmTranslationMap.get(checksumAlgorithm) }} + + + + + ota-update.checksum + + ota-update.checksum-hint + +
+
+
+ + ota-update.file-name + + + + ota-update.file-size-bytes + + + + ota-update.content-type + + +
+
+
+
+ + ota-update.direct-url + + + ota-update.direct-url-required + + +
+
+ + ota-update.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts new file mode 100644 index 0000000..77c70cc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts @@ -0,0 +1,197 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { combineLatest, Subject } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityComponent } from '@home/components/entity/entity.component'; +import { + ChecksumAlgorithm, + ChecksumAlgorithmTranslationMap, + OtaPackage, + OtaUpdateType, + OtaUpdateTypeTranslationMap +} from '@shared/models/ota-package.models'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { filter, startWith, takeUntil } from 'rxjs/operators'; +import { isNotEmptyStr } from '@core/utils'; + +@Component({ + selector: 'tb-ota-update', + templateUrl: './ota-update.component.html' +}) +export class OtaUpdateComponent extends EntityComponent implements OnInit, OnDestroy { + + private destroy$ = new Subject(); + + checksumAlgorithms = Object.values(ChecksumAlgorithm); + checksumAlgorithmTranslationMap = ChecksumAlgorithmTranslationMap; + packageTypes = Object.values(OtaUpdateType); + otaUpdateTypeTranslationMap = OtaUpdateTypeTranslationMap; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: OtaPackage, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + super.ngOnInit(); + if (this.isAdd) { + this.entityForm.get('isURL').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((isURL) => { + if (isURL === false) { + this.entityForm.get('url').clearValidators(); + this.entityForm.get('file').setValidators(Validators.required); + this.entityForm.get('url').updateValueAndValidity({emitEvent: false}); + this.entityForm.get('file').updateValueAndValidity({emitEvent: false}); + } else { + this.entityForm.get('file').clearValidators(); + this.entityForm.get('url').setValidators([Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*')]); + this.entityForm.get('file').updateValueAndValidity({emitEvent: false}); + this.entityForm.get('url').updateValueAndValidity({emitEvent: false}); + } + }); + combineLatest([ + this.entityForm.get('title').valueChanges.pipe(startWith('')), + this.entityForm.get('version').valueChanges.pipe(startWith('')) + ]).pipe( + filter(() => this.entityForm.get('tag').pristine), + takeUntil(this.destroy$) + ).subscribe(([title, version]) => { + const tag = (`${title} ${version}`).trim(); + this.entityForm.get('tag').patchValue(tag); + }); + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: OtaPackage): FormGroup { + const form = this.fb.group({ + title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], + version: [entity ? entity.version : '', [Validators.required, Validators.maxLength(255)]], + tag: [entity ? entity.tag : '', [Validators.maxLength(255)]], + type: [entity?.type ? entity.type : OtaUpdateType.FIRMWARE, Validators.required], + deviceProfileId: [entity ? entity.deviceProfileId : null, Validators.required], + checksumAlgorithm: [entity && entity.checksumAlgorithm ? entity.checksumAlgorithm : ChecksumAlgorithm.SHA256], + checksum: [entity ? entity.checksum : '', Validators.maxLength(1020)], + url: [entity ? entity.url : ''], + isURL: [false], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + } + ) + }); + if (this.isAdd) { + form.addControl('file', this.fb.control(null, Validators.required)); + form.addControl('generateChecksum', this.fb.control(true)); + } else { + form.addControl('fileName', this.fb.control(null)); + form.addControl('dataSize', this.fb.control(null)); + form.addControl('contentType', this.fb.control(null)); + } + return form; + } + + updateForm(entity: OtaPackage) { + this.entityForm.patchValue({ + title: entity.title, + version: entity.version, + tag: entity.tag, + type: entity.type, + deviceProfileId: entity.deviceProfileId, + checksumAlgorithm: entity.checksumAlgorithm, + checksum: entity.checksum, + fileName: entity.fileName, + dataSize: entity.dataSize, + contentType: entity.contentType, + url: entity.url, + isURL: isNotEmptyStr(entity.url), + additionalInfo: { + description: entity.additionalInfo ? entity.additionalInfo.description : '' + } + }); + if (!this.isAdd && this.entityForm.enabled) { + this.entityForm.disable({emitEvent: false}); + this.entityForm.get('additionalInfo').enable({emitEvent: false}); + } + } + + onPackageIdCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('ota-update.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onPackageChecksumCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('ota-update.checksum-copied-message'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onPackageDirectUrlCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('ota-update.checksum-copied-message'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + prepareFormValue(formValue: any): any { + if (formValue.isURL) { + delete formValue.file; + } else { + delete formValue.url; + } + delete formValue.generateChecksum; + return super.prepareFormValue(formValue); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts new file mode 100644 index 0000000..1734b10 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeComponentsModule } from '@home/components/home-components.module'; +import { OtaUpdateRoutingModule } from '@home/pages/ota-update/ota-update-routing.module'; +import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; + +@NgModule({ + declarations: [ + OtaUpdateComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + OtaUpdateRoutingModule + ] +}) +export class OtaUpdateModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts new file mode 100644 index 0000000..c14e450 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -0,0 +1,69 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; + +import { ProfileComponent } from './profile.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import { User } from '@shared/models/user.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserService } from '@core/http/user.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Observable } from 'rxjs'; + +@Injectable() +export class UserProfileResolver implements Resolve { + + constructor(private store: Store, + private userService: UserService) { + } + + resolve(): Observable { + const userId = getCurrentAuthUser(this.store).userId; + return this.userService.getUser(userId); + } +} + +const routes: Routes = [ + { + path: 'profile', + component: ProfileComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'profile.profile', + breadcrumb: { + label: 'profile.profile', + icon: 'account_circle' + } + }, + resolve: { + user: UserProfileResolver + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + UserProfileResolver + ] +}) +export class ProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html new file mode 100644 index 0000000..08999bb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -0,0 +1,92 @@ + +
+ + +
+
+ profile.profile + {{ profile ? profile.get('email').value : '' }} +
+
+ profile.last-login-time + +
+
+
+ + + + +
+ +
+
+ + user.email + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + + + user.first-name + + + + user.last-name + + + + language.language + + + {{ lang ? ('language.locales.' + lang | translate) : ''}} + + + +
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+ +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss new file mode 100644 index 0000000..407e88e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + mat-card.profile-card { + margin: 8px; + @media #{$mat-gt-sm} { + width: 60%; + } + .mat-headline { + margin: 0; + } + .profile-email { + font-size: 16px; + font-weight: 400; + } + .mat-subheader { + line-height: 24px; + color: rgba(0,0,0,0.54); + font-size: 14px; + font-weight: 400; + } + .profile-last-login-ts { + font-size: 16px; + font-weight: 400; + } + .tb-home-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts new file mode 100644 index 0000000..8a0fc76 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts @@ -0,0 +1,129 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { UserService } from '@core/http/user.service'; +import { AuthUser, User } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { ActionAuthUpdateUserDetails } from '@core/auth/auth.actions'; +import { environment as env } from '@env/environment'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions'; +import { ActivatedRoute } from '@angular/router'; +import { isDefinedAndNotNull } from '@core/utils'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; + +@Component({ + selector: 'tb-profile', + templateUrl: './profile.component.html', + styleUrls: ['./profile.component.scss'] +}) +export class ProfileComponent extends PageComponent implements OnInit, HasConfirmForm { + + authorities = Authority; + profile: FormGroup; + user: User; + languageList = env.supportedLangs; + private readonly authUser: AuthUser; + + constructor(protected store: Store, + private route: ActivatedRoute, + private userService: UserService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + this.authUser = getCurrentAuthUser(this.store); + } + + ngOnInit() { + this.buildProfileForm(); + this.userLoaded(this.route.snapshot.data.user); + } + + private buildProfileForm() { + this.profile = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + firstName: [''], + lastName: [''], + language: [''], + homeDashboardId: [null], + homeDashboardHideToolbar: [true] + }); + } + + save(): void { + this.user = {...this.user, ...this.profile.value}; + if (!this.user.additionalInfo) { + this.user.additionalInfo = {}; + } + this.user.additionalInfo.lang = this.profile.get('language').value; + this.user.additionalInfo.homeDashboardId = this.profile.get('homeDashboardId').value; + this.user.additionalInfo.homeDashboardHideToolbar = this.profile.get('homeDashboardHideToolbar').value; + this.userService.saveUser(this.user).subscribe( + (user) => { + this.userLoaded(user); + this.store.dispatch(new ActionAuthUpdateUserDetails({ userDetails: { + additionalInfo: {...user.additionalInfo}, + authority: user.authority, + createdTime: user.createdTime, + tenantId: user.tenantId, + customerId: user.customerId, + email: user.email, + firstName: user.firstName, + id: user.id, + lastName: user.lastName, + } })); + this.store.dispatch(new ActionSettingsChangeLanguage({ userLang: user.additionalInfo.lang })); + } + ); + } + + private userLoaded(user: User) { + this.user = user; + this.profile.reset(user); + let lang; + let homeDashboardId; + let homeDashboardHideToolbar = true; + if (user.additionalInfo) { + if (user.additionalInfo.lang) { + lang = user.additionalInfo.lang; + } + homeDashboardId = user.additionalInfo.homeDashboardId; + if (isDefinedAndNotNull(user.additionalInfo.homeDashboardHideToolbar)) { + homeDashboardHideToolbar = user.additionalInfo.homeDashboardHideToolbar; + } + } + if (!lang) { + lang = this.translate.currentLang; + } + this.profile.get('language').setValue(lang); + this.profile.get('homeDashboardId').setValue(homeDashboardId); + this.profile.get('homeDashboardHideToolbar').setValue(homeDashboardHideToolbar); + } + + confirmForm(): FormGroup { + return this.profile; + } + + isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts new file mode 100644 index 0000000..0a758ef --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ProfileComponent } from './profile.component'; +import { SharedModule } from '@shared/shared.module'; +import { ProfileRoutingModule } from './profile-routing.module'; + +@NgModule({ + declarations: [ + ProfileComponent + ], + imports: [ + CommonModule, + SharedModule, + ProfileRoutingModule + ] +}) +export class ProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profiles/profiles-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profiles/profiles-routing.module.ts new file mode 100644 index 0000000..2f415eb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profiles/profiles-routing.module.ts @@ -0,0 +1,51 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { RouterModule, Routes } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { NgModule } from '@angular/core'; +import { deviceProfilesRoutes } from '@home/pages/device-profile/device-profile-routing.module'; +import { assetProfilesRoutes } from '@home/pages/asset-profile/asset-profile-routing.module'; + +const routes: Routes = [ + { + path: 'profiles', + data: { + auth: [Authority.TENANT_ADMIN], + breadcrumb: { + label: 'profiles.profiles', + icon: 'badge' + } + }, + children: [ + { + path: '', + data: { + auth: [Authority.TENANT_ADMIN], + redirectTo: '/profiles/deviceProfiles' + } + }, + ...deviceProfilesRoutes, + ...assetProfilesRoutes + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ProfilesRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profiles/profiles.module.ts b/ui-ngx/src/app/modules/home/pages/profiles/profiles.module.ts new file mode 100644 index 0000000..a9b784b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profiles/profiles.module.ts @@ -0,0 +1,30 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { ProfilesRoutingModule } from './profiles-routing.module'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + SharedModule, + ProfilesRoutingModule + ] +}) +export class ProfilesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/public-api.ts b/ui-ngx/src/app/modules/home/pages/public-api.ts new file mode 100644 index 0000000..b07bb7e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/public-api.ts @@ -0,0 +1,17 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './home-pages.module'; diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html new file mode 100644 index 0000000..bc72cd9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html @@ -0,0 +1,56 @@ + +
+ +

rulenode.add

+ : {{ruleNode.component.name}} +
+ +
+ + +
+
+ + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss new file mode 100644 index 0000000..7b1fe7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import './../scss/constants'; + +:host { + .dialog-container { + min-width: 650px !important; + + @media #{$mat-lt-md} { + min-width: 100% !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.html new file mode 100644 index 0000000..4508f4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.html @@ -0,0 +1,55 @@ + +
+ +

rulenode.add-link

+ +
+ +
+ + +
+
+ + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.scss new file mode 100644 index 0000000..c63704c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-link-dialog.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + tb-rule-node-link { + form { + overflow: hidden !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html new file mode 100644 index 0000000..c818cf0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/create-nested-rulechain-dialog.component.html @@ -0,0 +1,65 @@ + +
+ +

rulenode.create-nested-rulechain

+ + +
+ + +
+ +
+ + rulechain.name + + + {{ 'rulechain.name-required' | translate }} + + + {{ 'rulechain.name-max-length' | translate }} + + +
+ + rulechain.description + + +
+
+ +
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html new file mode 100644 index 0000000..bf2099d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html @@ -0,0 +1,66 @@ + + + rulenode.link-labels + + + {{label.name}} + close + + + + + + + + +
+
+ rulenode.no-link-labels-found +
+ + + {{ translate.get('rulenode.no-link-label-matching', + {label: truncate.transform(searchText, true, 6, '...')}) | async }} + + + + rulenode.create-new-link-label + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts new file mode 100644 index 0000000..2bf501d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts @@ -0,0 +1,289 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { LinkLabel } from '@shared/models/rule-node.models'; +import { Observable, of } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { deepClone } from '@core/utils'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent, MatChipList } from '@angular/material/chips'; +import { TranslateService } from '@ngx-translate/core'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { catchError, map, mergeMap, share, startWith } from 'rxjs/operators'; +import { RuleChainService } from '@core/http/rule-chain.service'; + +@Component({ + selector: 'tb-link-labels', + templateUrl: './link-labels.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LinkLabelsComponent), + multi: true + }] +}) +export class LinkLabelsComponent implements ControlValueAccessor, OnInit, OnChanges { + + @ViewChild('chipList') chipList: MatChipList; + @ViewChild('labelAutocomplete') matAutocomplete: MatAutocomplete; + @ViewChild('labelInput') labelInput: ElementRef; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private allowCustomValue: boolean; + get allowCustom(): boolean { + return this.allowCustomValue; + } + @Input() + set allowCustom(value: boolean) { + this.allowCustomValue = coerceBooleanProperty(value); + this.separatorKeysCodes = this.allowCustomValue ? [ENTER, COMMA, SEMICOLON] : []; + } + + @Input() + allowedLabels: {[label: string]: LinkLabel}; + + @Input() + sourceRuleChainId: string; + + linksFormGroup: FormGroup; + + modelValue: Array; + + private labelsList: Array = []; + + separatorKeysCodes: number[] = []; + + filteredLabels: Observable>; + + labels: Array = []; + + searchText = ''; + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + public truncate: TruncatePipe, + public translate: TranslateService, + private ruleChainService: RuleChainService) { + this.linksFormGroup = this.fb.group({ + label: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + + ngOnInit(): void { + this.filteredLabels = this.linksFormGroup.get('label').valueChanges + .pipe( + startWith(''), + map((value) => value ? value : ''), + mergeMap(name => this.fetchLabels(name) ), + share() + ); + } + + ngOnChanges(changes: SimpleChanges): void { + let reloadLabels = false; + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (['allowCustom', 'allowedLabels', 'sourceRuleChainId'].includes(propName)) { + reloadLabels = true; + } + } + } + if (reloadLabels) { + this.linksFormGroup.get('label').patchValue('', {emitEvent: true}); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.linksFormGroup.disable({emitEvent: false}); + } else { + this.linksFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string[]): void { + this.searchText = ''; + this.labelsList.length = 0; + this.labels.length = 0; + this.modelValue = value; + this.prepareLabelsList().subscribe((labelsList) => { + this.labelsList = labelsList; + if (value) { + value.forEach((label) => { + if (this.allowedLabels[label]) { + this.labels.push(deepClone(this.allowedLabels[label])); + } else { + this.labels.push({ + name: label, + value: label + }); + } + }); + } + if (this.chipList && this.required) { + this.chipList.errorState = !this.labels.length; + } + this.linksFormGroup.get('label').patchValue('', {emitEvent: true}); + }); + } + + prepareLabelsList(): Observable> { + const labelsList: Array = []; + if (this.sourceRuleChainId) { + return this.ruleChainService.getRuleChainOutputLabels(this.sourceRuleChainId, {ignoreErrors: true}).pipe( + map((labels) => { + for (const label of labels) { + labelsList.push({ + name: label, + value: label + }); + } + return labelsList; + }), + catchError(() => { + return of(labelsList); + }) + ); + } else { + for (const label of Object.keys(this.allowedLabels)) { + labelsList.push({name: this.allowedLabels[label].name, value: this.allowedLabels[label].value}); + } + return of(labelsList); + } + } + + displayLabelFn(label?: LinkLabel): string | undefined { + return label ? label.name : undefined; + } + + textIsNotEmpty(text: string): boolean { + return (text && text.length > 0); + } + + createLinkLabel($event: Event, value: string) { + $event.preventDefault(); + this.transformLinkLabel(value); + } + + add(event: MatChipInputEvent): void { + if (!this.matAutocomplete.isOpen || this.allowCustom) { + this.transformLinkLabel(event.value); + } + } + + private fetchLabels(searchText?: string): Observable> { + this.searchText = searchText; + if (this.searchText && this.searchText.length) { + const search = this.searchText.toUpperCase(); + return of(this.labelsList.filter(label => label.name.toUpperCase().includes(search))); + } else { + return of(this.labelsList); + } + } + + private transformLinkLabel(value: string) { + if ((value || '').trim()) { + let newLabel: LinkLabel = null; + const labelName = value.trim(); + const existingLabel = this.labelsList.find(label => label.name === labelName); + if (existingLabel) { + newLabel = deepClone(existingLabel); + } else if (this.allowCustom) { + newLabel = { + name: labelName, + value: labelName + }; + } + if (newLabel) { + this.addLabel(newLabel); + } + } + this.clear(''); + } + + remove(label: LinkLabel) { + const index = this.labels.indexOf(label); + if (index >= 0) { + this.labels.splice(index, 1); + if (!this.labels.length) { + if (this.required) { + this.chipList.errorState = true; + } + } + this.updateModel(); + } + } + + selected(event: MatAutocompleteSelectedEvent): void { + this.addLabel(event.option.value); + this.clear(''); + } + + addLabel(label: LinkLabel): void { + const index = this.labels.findIndex(existinglabel => existinglabel.value === label.value); + if (index === -1) { + this.labels.push(label); + if (this.required) { + this.chipList.errorState = false; + } + this.updateModel(); + } + } + + onFocus() { + this.linksFormGroup.get('label').updateValueAndValidity({onlySelf: true, emitEvent: true}); + } + + clear(value: string = '') { + this.labelInput.nativeElement.value = value; + this.linksFormGroup.get('label').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.labelInput.nativeElement.blur(); + this.labelInput.nativeElement.focus(); + }, 0); + } + + private updateModel() { + const labels = this.labels.map((label => label.value)); + this.propagateChange(labels); + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss new file mode 100644 index 0000000..931f5ac --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@mixin rule-node-colors { + &.tb-filter-type { + background-color: #f1e861; + } + + &.tb-enrichment-type { + background-color: #cdf14e; + } + + &.tb-transformation-type { + background-color: #79cef1; + } + + &.tb-action-type { + background-color: #f1928f; + } + + &.tb-external-type { + background-color: #fbc766; + } + + &.tb-flow-type { + background-color: #d6c4f1; + } + + &.tb-unknown-type { + background-color: #f16c29; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html new file mode 100644 index 0000000..97f2214 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html @@ -0,0 +1,28 @@ + +
+ +
{{definedDirectiveError}}
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss new file mode 100644 index 0000000..cf19b3e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + tb-json-object-edit.tb-rule-node-configuration-json { + display: block; + height: 300px; + } + + .tb-rulenode-directive-error { + font-size: 13px; + font-weight: 400; + color: rgb(221, 44, 0); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts new file mode 100644 index 0000000..7c329cd --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts @@ -0,0 +1,210 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ComponentRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + IRuleNodeConfigurationComponent, + RuleNodeConfiguration, + RuleNodeDefinition +} from '@shared/models/rule-node.models'; +import { Subscription } from 'rxjs'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { TranslateService } from '@ngx-translate/core'; +import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component'; +import { deepClone } from '@core/utils'; +import { RuleChainType } from '@shared/models/rule-chain.models'; + +@Component({ + selector: 'tb-rule-node-config', + templateUrl: './rule-node-config.component.html', + styleUrls: ['./rule-node-config.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleNodeConfigComponent), + multi: true + }] +}) +export class RuleNodeConfigComponent implements ControlValueAccessor, OnInit, OnDestroy, AfterViewInit { + + @ViewChild('definedConfigContent', {read: ViewContainerRef, static: true}) definedConfigContainer: ViewContainerRef; + + @ViewChild('jsonObjectEditComponent') jsonObjectEditComponent: JsonObjectEditComponent; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @Input() + ruleNodeId: string; + + @Input() + ruleChainId: string; + + @Input() + ruleChainType: RuleChainType; + + nodeDefinitionValue: RuleNodeDefinition; + + @Input() + set nodeDefinition(nodeDefinition: RuleNodeDefinition) { + if (this.nodeDefinitionValue !== nodeDefinition) { + this.nodeDefinitionValue = nodeDefinition; + if (this.nodeDefinitionValue) { + this.validateDefinedDirective(); + } + } + } + + get nodeDefinition(): RuleNodeDefinition { + return this.nodeDefinitionValue; + } + + definedDirectiveError: string; + + ruleNodeConfigFormGroup: FormGroup; + + changeSubscription: Subscription; + + private definedConfigComponentRef: ComponentRef; + private definedConfigComponent: IRuleNodeConfigurationComponent; + + private configuration: RuleNodeConfiguration; + + private propagateChange = (v: any) => { }; + + constructor(private translate: TranslateService, + private ruleChainService: RuleChainService, + private fb: FormBuilder) { + this.ruleNodeConfigFormGroup = this.fb.group({ + configuration: [null, Validators.required] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + if (this.definedConfigComponentRef) { + this.definedConfigComponentRef.destroy(); + } + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.ruleNodeConfigFormGroup.disable({emitEvent: false}); + } else { + this.ruleNodeConfigFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: RuleNodeConfiguration): void { + this.configuration = deepClone(value); + if (this.changeSubscription) { + this.changeSubscription.unsubscribe(); + this.changeSubscription = null; + } + if (this.definedConfigComponent) { + this.definedConfigComponent.configuration = this.configuration; + this.changeSubscription = this.definedConfigComponent.configurationChanged.subscribe((configuration) => { + this.updateModel(configuration); + }); + } else { + this.ruleNodeConfigFormGroup.get('configuration').patchValue(this.configuration, {emitEvent: false}); + this.changeSubscription = this.ruleNodeConfigFormGroup.get('configuration').valueChanges.subscribe( + (configuration: RuleNodeConfiguration) => { + this.updateModel(configuration); + } + ); + } + } + + useDefinedDirective(): boolean { + return this.nodeDefinition && + (this.nodeDefinition.configDirective && + this.nodeDefinition.configDirective.length) && !this.definedDirectiveError; + } + + private updateModel(configuration: RuleNodeConfiguration) { + if (this.definedConfigComponent || this.ruleNodeConfigFormGroup.valid) { + this.propagateChange(configuration); + } else { + this.propagateChange(this.required ? null : configuration); + } + } + + private validateDefinedDirective() { + if (this.definedConfigComponentRef) { + this.definedConfigComponentRef.destroy(); + this.definedConfigComponentRef = null; + } + if (this.nodeDefinition.uiResourceLoadError && this.nodeDefinition.uiResourceLoadError.length) { + this.definedDirectiveError = this.nodeDefinition.uiResourceLoadError; + } else if (this.nodeDefinition.configDirective && this.nodeDefinition.configDirective.length) { + if (this.changeSubscription) { + this.changeSubscription.unsubscribe(); + this.changeSubscription = null; + } + this.definedConfigContainer.clear(); + const factory = this.ruleChainService.getRuleNodeConfigFactory(this.nodeDefinition.configDirective); + this.definedConfigComponentRef = this.definedConfigContainer.createComponent(factory); + this.definedConfigComponent = this.definedConfigComponentRef.instance; + this.definedConfigComponent.ruleNodeId = this.ruleNodeId; + this.definedConfigComponent.ruleChainId = this.ruleChainId; + this.definedConfigComponent.ruleChainType = this.ruleChainType; + this.definedConfigComponent.configuration = this.configuration; + this.changeSubscription = this.definedConfigComponent.configurationChanged.subscribe((configuration) => { + this.updateModel(configuration); + }); + } + } + + validate() { + if (this.useDefinedDirective()) { + this.definedConfigComponent.validate(); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html new file mode 100644 index 0000000..b6912f0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html @@ -0,0 +1,58 @@ + +
+ +
+
+
+
+
+ + rulenode.name + + + {{ 'rulenode.name-required' | translate }} + + + {{ 'rulenode.name-max-length' | translate }} + + + + {{ 'rulenode.debug-mode' | translate }} + +
+ + +
+ + rulenode.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss new file mode 100644 index 0000000..4dce885 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + form { + overflow-x: hidden !important; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts new file mode 100644 index 0000000..9f999fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts @@ -0,0 +1,133 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FcRuleNode, RuleNodeType } from '@shared/models/rule-node.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { Subscription } from 'rxjs'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { RuleNodeConfigComponent } from './rule-node-config.component'; +import { Router } from '@angular/router'; +import { RuleChainType } from '@app/shared/models/rule-chain.models'; + +@Component({ + selector: 'tb-rule-node', + templateUrl: './rule-node-details.component.html', + styleUrls: ['./rule-node-details.component.scss'] +}) +export class RuleNodeDetailsComponent extends PageComponent implements OnInit, OnChanges { + + @ViewChild('ruleNodeConfigComponent') ruleNodeConfigComponent: RuleNodeConfigComponent; + + @Input() + ruleNode: FcRuleNode; + + @Input() + ruleChainId: string; + + @Input() + ruleChainType: RuleChainType; + + @Input() + isEdit: boolean; + + @Input() + isReadOnly: boolean; + + @Input() + isAdd = false; + + ruleNodeType = RuleNodeType; + entityType = EntityType; + + ruleNodeFormGroup: FormGroup; + + private ruleNodeFormSubscription: Subscription; + + constructor(protected store: Store, + private fb: FormBuilder, + private ruleChainService: RuleChainService, + private router: Router) { + super(store); + this.ruleNodeFormGroup = this.fb.group({}); + } + + private buildForm() { + if (this.ruleNodeFormSubscription) { + this.ruleNodeFormSubscription.unsubscribe(); + this.ruleNodeFormSubscription = null; + } + if (this.ruleNode) { + this.ruleNodeFormGroup = this.fb.group({ + name: [this.ruleNode.name, [Validators.required, Validators.pattern('(.|\\s)*\\S(.|\\s)*'), Validators.maxLength(255)]], + debugMode: [this.ruleNode.debugMode, []], + configuration: [this.ruleNode.configuration, [Validators.required]], + additionalInfo: this.fb.group( + { + description: [this.ruleNode.additionalInfo ? this.ruleNode.additionalInfo.description : ''], + } + ) + }); + this.ruleNodeFormSubscription = this.ruleNodeFormGroup.valueChanges.subscribe(() => { + this.updateRuleNode(); + }); + } else { + this.ruleNodeFormGroup = this.fb.group({}); + } + } + + private updateRuleNode() { + const formValue = this.ruleNodeFormGroup.value || {}; + formValue.name = formValue.name.trim(); + Object.assign(this.ruleNode, formValue); + } + + ngOnInit(): void { + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'ruleNode') { + this.buildForm(); + } + } + } + } + + validate() { + this.ruleNodeConfigComponent.validate(); + } + + openRuleChain($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const ruleChainId = this.ruleNodeFormGroup.get('configuration')?.value?.ruleChainId; + if (ruleChainId) { + if (this.ruleChainType === RuleChainType.EDGE) { + this.router.navigateByUrl(`/edgeManagement/ruleChains/${ruleChainId}`); + } else { + this.router.navigateByUrl(`/ruleChains/${ruleChainId}`); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.html new file mode 100644 index 0000000..8e665b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.html @@ -0,0 +1,28 @@ + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts new file mode 100644 index 0000000..87a24c2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { FcRuleEdge, LinkLabel } from '@shared/models/rule-node.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-rule-node-link', + templateUrl: './rule-node-link.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleNodeLinkComponent), + multi: true + }] +}) +export class RuleNodeLinkComponent implements ControlValueAccessor, OnInit { + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private allowCustomValue: boolean; + get allowCustom(): boolean { + return this.allowCustomValue; + } + @Input() + set allowCustom(value: boolean) { + this.allowCustomValue = coerceBooleanProperty(value); + } + + @Input() + allowedLabels: {[label: string]: LinkLabel}; + + @Input() + sourceRuleChainId: string; + + ruleNodeLinkFormGroup: FormGroup; + + modelValue: FcRuleEdge; + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + public truncate: TruncatePipe, + public translate: TranslateService) { + this.ruleNodeLinkFormGroup = this.fb.group({ + labels: [[], Validators.required] + }); + this.ruleNodeLinkFormGroup.get('labels').valueChanges.subscribe( + (labels: string[]) => this.updateModel(labels) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.ruleNodeLinkFormGroup.disable({emitEvent: false}); + } else { + this.ruleNodeLinkFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: FcRuleEdge): void { + this.modelValue = value; + const labels = this.modelValue && this.modelValue.labels ? this.modelValue.labels : []; + this.ruleNodeLinkFormGroup.get('labels').patchValue(labels, {emitEvent: false}); + } + + private updateModel(labels: string[]) { + if (labels && labels.length) { + this.modelValue.labels = labels; + this.modelValue.label = labels.join(' / '); + this.propagateChange(this.modelValue); + } else { + this.propagateChange(this.required ? null : this.modelValue); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html new file mode 100644 index 0000000..bc80a35 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html @@ -0,0 +1,267 @@ + +
+ +
+
+
+ +
+ + + +
+ + + + + + + +
+
+
+ + + + {{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).icon }} +
{{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).name }}
+
+
+ + +
+
+
+ + +
+
+
+ + + + + + + + + +
+
+
{{editingRuleNode.component.name}}
+
 
+
{{editingRuleNode.component.configurationDescriptor.nodeDefinition.description}}
+
 
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+ {{contextInfo.icon}} + +
+
{{contextInfo.title}}
+
{{contextInfo.subtitle}}
+
+
+
+ + +
+
+
+
+ + +
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss new file mode 100644 index 0000000..ced04ed --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss @@ -0,0 +1,398 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './rule-node-colors'; + +.tb-rulechain { + width: 100%; + height: 100%; + + .mat-tab-group.tb-rulenode-details { + > .mat-tab-body-wrapper { + position: absolute; + top: 49px; + left: 0; + right: 0; + bottom: 0; + } + > .mat-tab-header { + .mat-tab-label { + min-width: 40px; + } + } + } + + button.mat-button.mat-icon-button.tb-fullscreen-button { + position: absolute; + top: 10px; + right: 10px; + background: #ccc; + color: #666; + opacity: .85; + margin: 0 6px; + z-index: 2; + } + + button.mat-button.mat-icon-button.version-control-button { + position: absolute; + top: 10px; + right: 60px; + opacity: .85; + margin: 0 6px; + z-index: 2; + &.mat-fab { + .mat-button-wrapper { + padding: 0; + } + } + } + + section.tb-header-buttons.tb-library-open { + position: absolute; + top: 0; + left: 0; + z-index: 2; + pointer-events: none; + + .mat-fab { + .mat-button-wrapper { + padding: 0; + } + } + + .mat-button.tb-btn-open-library { + top: 0; + left: 0; + width: 36px; + height: 36px; + margin: 4px 0 0 4px; + line-height: 36px; + opacity: .5; + } + } + + .tb-rulechain-library { + z-index: 1; + width: 250px; + min-width: 250px; + + .mat-toolbar { + height: 48px; + min-height: 48px; + padding: 0; + + .mat-toolbar-tools { + height: 48px; + padding: 0 6px; + font-size: 14px; + + .mat-form-field { + .mat-form-field-infix { + width: auto; + } + } + + .mat-button.mat-icon-button { + margin: 0; + + &.tb-small { + width: 32px; + height: 32px; + min-height: 32px; + padding: 6px; + line-height: 20px; + mat-icon { + width: 20px; + min-width: 20px; + height: 20px; + min-height: 20px; + font-size: 20px; + line-height: 20px; + } + } + } + } + } + .tb-rulechain-library-panel-group { + overflow-x: hidden; + overflow-y: auto; + .mat-expansion-panel { + border-radius: 0; + &:last-child { + margin-bottom: 5px; + } + .mat-expansion-panel-header { + background: #e6e6e6; + &:hover { + background: #dadada; + } + .mat-expansion-panel-header-title { + line-height: 48px; + height: 48px; + overflow: hidden; + .mat-icon { + min-width: 24px; + margin: auto 8px auto 0; + } + .tb-panel-title { + min-width: 130px; + user-select: none; + } + } + } + &.mat-expanded { + .mat-expansion-panel-header { + border-bottom: 1px solid; + border-color: #909090; + } + } + } + .mat-expansion-panel-body { + padding: 0; + } + .fc-canvas { + background: #f9f9f9; + } + } + } + .mat-drawer-content.tb-rulechain-graph-content { + overflow: hidden; + .tb-rulechain-graph { + z-index: 0; + overflow: auto; + } + } + .fc-canvas { + .fc-node { + border-radius: 8px; + &.fc-selected { + &:not(.fc-edit) { + margin: -3px; + border: solid 3px #f00; + } + } + } + + .fc-edit { + .fc-nodeedit, + .fc-nodedelete { + box-sizing: content-box; + border: solid 2px #fff; + background: #f83e05; + outline: none; + } + } + + .fc-nodeopen{ + display: block; + position: absolute; + top: 11px; + right: 10px; + border: 1px solid #FFFFFF; + border-radius: 4px; + line-height: 18px; + height: 22px; + width: 22px; + background: #886CB1; + color: #fff; + text-align: center; + cursor: pointer; + box-sizing: border-box; + + mat-icon{ + width: 16px; + min-width: 16px; + height: 16px; + min-height: 16px; + font-size: 16px; + } + + &:hover{ + background-color: #4E2D7E; + } + } + + .fc-arrow-marker { + polygon { + fill: #808080; + stroke: #808080; + } + } + + .fc-arrow-marker-selected { + polygon { + fill: #f00; + stroke: #f00; + } + } + + .fc-edge { + outline: none; + stroke: #808080; + + &.fc-selected { + stroke: #f00; + } + + &.fc-hover { + stroke: #808080; + } + } + + .edge-endpoint { + fill: #808080; + } + + .fc-edge-label { + opacity: 1 !important; + &:focus { + outline: 0; + } + + &.fc-selected { + .fc-edge-label-text { + span { + color: #fff !important; + background-color: #f00 !important; + border: solid #f00 !important; + } + } + } + + .fc-edge-label-text { + font-size: 14px !important; + font-weight: 600 !important; + + span { + background-color: #fff !important; + color: #003a79 !important; + border: solid 2px #003a79 !important; + } + } + } + } +} + +.tb-rule-node-tooltip, +.tb-rule-node-help { + color: #333; +} + +.tb-rule-node-tooltip { + max-width: 300px; + font-size: 14px; + + &.tb-lib-tooltip { + width: 300px; + } +} + +.tb-rule-node-help { + font-size: 16px; +} + +.tb-rule-node-error-tooltip { + font-size: 16px; + color: #ea0d0d; +} + +.tb-rule-node-tooltip, +.tb-rule-node-error-tooltip, +.tb-rule-node-help { + #tb-node-content { + .tb-node-title { + font-weight: 600; + } + + .tb-node-description { + font-style: italic; + color: #555; + } + + .tb-node-details { + padding-top: 10px; + padding-bottom: 10px; + } + + code { + padding: 0 3px 2px 3px; + margin: 1px; + font-size: 12px; + color: #ad1625; + white-space: nowrap; + background-color: #f7f7f9; + border: 1px solid #e1e1e8; + border-radius: 2px; + } + } +} + +.tb-rule-chain-context-menu { + min-width: 320px; + max-height: 404px; + border-radius: 8px; + margin-left: -20px; + + &.mat-menu-below { + margin-top: -60px; + } + + .mat-menu-content { + padding: 0; + display: flex; + flex-direction: column; + .tb-rule-chain-context-menu-container { + pointer-events: auto; + padding: 0 0 8px; + display: flex; + flex-direction: column; + overflow-y: auto; + } + } + + .tb-context-menu-header { + display: flex; + box-sizing: content-box; + flex-direction: row; + height: 36px; + min-height: 36px; + padding: 8px 5px 5px; + font-size: 14px; + + @include rule-node-colors(); + + &.tb-rulechain-header { + background-color: #aac7e4; + } + + &.tb-link-header { + background-color: #aac7e4; + } + + .mat-icon { + padding-right: 10px; + padding-left: 2px; + margin: auto; + } + + .tb-context-menu-title { + font-weight: 500; + } + + .tb-context-menu-subtitle { + font-size: 12px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts new file mode 100644 index 0000000..7776fa7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts @@ -0,0 +1,1856 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, EventEmitter, + HostBinding, + Inject, + OnDestroy, + OnInit, + QueryList, Renderer2, + SkipSelf, + ViewChild, + ViewChildren, ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard'; +import { TranslateService } from '@ngx-translate/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatExpansionPanel } from '@angular/material/expansion'; +import { DialogService } from '@core/services/dialog.service'; +import { AuthService } from '@core/auth/auth.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + inputNodeComponent, + NodeConnectionInfo, + RuleChain, + RuleChainImport, + RuleChainMetaData, + RuleChainType +} from '@shared/models/rule-chain.models'; +import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart'; +import { + FcRuleEdge, + FcRuleNode, + FcRuleNodeType, + getRuleNodeHelpLink, + LinkLabel, outputNodeClazz, ruleChainNodeClazz, + RuleNode, + RuleNodeComponentDescriptor, + RuleNodeType, + ruleNodeTypeDescriptors, + ruleNodeTypesLibrary +} from '@shared/models/rule-node.models'; +import { FcRuleNodeModel, FcRuleNodeTypeModel, RuleChainMenuContextInfo } from './rulechain-page.models'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { fromEvent, NEVER, Observable, of, ReplaySubject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, mergeMap, tap } from 'rxjs/operators'; +import { ISearchableComponent } from '../../models/searchable-component.models'; +import { deepClone } from '@core/utils'; +import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component'; +import { RuleNodeLinkComponent } from './rule-node-link.component'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service'; +import { Hotkey } from 'angular2-hotkeys'; +import { DebugEventType, EventType } from '@shared/models/event.models'; +import Timeout = NodeJS.Timeout; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityVersionCreateComponent } from '@home/components/vc/entity-version-create.component'; +import { VersionCreationResult } from '@shared/models/vc.models'; +import { VersionControlComponent } from '@home/components/vc/version-control.component'; + +@Component({ + selector: 'tb-rulechain-page', + templateUrl: './rulechain-page.component.html', + styleUrls: ['./rulechain-page.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class RuleChainPageComponent extends PageComponent + implements AfterViewInit, OnInit, OnDestroy, HasDirtyFlag, ISearchableComponent { + + get isDirty(): boolean { + return this.isDirtyValue || this.isImport; + } + + set isDirty(value: boolean) { + this.isDirtyValue = value; + } + + @HostBinding('style.width') width = '100%'; + @HostBinding('style.height') height = '100%'; + + @ViewChild('ruleNodeSearchInput') ruleNodeSearchInputField: ElementRef; + + @ViewChild('ruleChainCanvas', {static: true}) ruleChainCanvas: NgxFlowchartComponent; + + @ViewChildren('ruleNodeTypeExpansionPanels', + {read: MatExpansionPanel}) expansionPanels: QueryList; + + @ViewChild('ruleChainMenuTrigger', {static: true}) ruleChainMenuTrigger: MatMenuTrigger; + + eventTypes = EventType; + + debugEventTypes = DebugEventType; + + ruleChainMenuPosition = { x: '0px', y: '0px' }; + + contextMenuEvent: MouseEvent; + + ruleNodeTypeDescriptorsMap = ruleNodeTypeDescriptors; + ruleNodeTypesLibraryArray = ruleNodeTypesLibrary; + + isImport: boolean; + isDirtyValue: boolean; + isInvalid = false; + + ruleChainType: RuleChainType; + + errorTooltips: {[nodeId: string]: JQueryTooltipster.ITooltipsterInstance} = {}; + isFullscreen = false; + + selectedRuleNodeTabIndex = 0; + editingRuleNode: FcRuleNode = null; + isEditingRuleNode = false; + editingRuleNodeIndex = -1; + editingRuleNodeAllowCustomLabels = false; + editingRuleNodeLinkLabels: {[label: string]: LinkLabel}; + editingRuleNodeSourceRuleChainId: string; + + @ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent; + @ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent; + + editingRuleNodeLink: FcRuleEdge = null; + isEditingRuleNodeLink = false; + editingRuleNodeLinkIndex = -1; + + hotKeys: Hotkey[] = []; + + enableHotKeys = true; + isLibraryOpen = true; + + ruleNodeSearch = ''; + ruleNodeTypeSearch = ''; + + ruleChain: RuleChain; + ruleChainMetaData: RuleChainMetaData; + + ruleChainModel: FcRuleNodeModel = { + nodes: [], + edges: [] + }; + selectedObjects = []; + + editCallbacks: UserCallbacks = { + edgeDoubleClick: (event, edge) => { + this.openLinkDetails(edge); + }, + edgeEdit: (event, edge) => { + this.openLinkDetails(edge); + }, + nodeCallbacks: { + doubleClick: (event, node: FcRuleNode) => { + this.openNodeDetails(node); + }, + nodeEdit: (event, node: FcRuleNode) => { + this.openNodeDetails(node); + }, + mouseEnter: this.displayNodeDescriptionTooltip.bind(this), + mouseLeave: this.destroyTooltips.bind(this), + mouseDown: this.destroyTooltips.bind(this) + }, + isValidEdge: (source, destination) => { + return source.type === FlowchartConstants.rightConnectorType && destination.type === FlowchartConstants.leftConnectorType; + }, + createEdge: (event, edge: FcRuleEdge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode; + if (sourceNode.component.type === RuleNodeType.INPUT) { + const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); + if (found) { + this.ruleChainCanvas.modelService.edges.delete(found); + } + return of(edge); + } else { + if (edge.label) { + if (!edge.labels) { + edge.labels = edge.label.split(' / '); + } + return of(edge); + } else { + const labels = this.ruleChainService.getRuleNodeSupportedLinks(sourceNode.component); + const allowCustomLabels = this.ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component); + const sourceRuleChainId = this.ruleChainService.ruleNodeSourceRuleChainId(sourceNode.component, sourceNode.configuration); + this.enableHotKeys = false; + return this.addRuleNodeLink(edge, labels, allowCustomLabels, sourceRuleChainId).pipe( + tap(() => { + this.enableHotKeys = true; + }), + mergeMap((res) => { + if (res) { + return of(res); + } else { + return NEVER; + } + }) + ); + } + } + }, + dropNode: (event, node: FcRuleNode) => { + this.addRuleNode(node); + } + }; + + nextNodeID: number; + nextConnectorID: number; + inputConnectorId: number; + + ruleNodeTypesModel: {[type: string]: {model: FcRuleNodeTypeModel, selectedObjects: any[]}} = {}; + + nodeLibCallbacks: UserCallbacks = { + nodeCallbacks: { + mouseEnter: this.displayLibNodeDescriptionTooltip.bind(this), + mouseLeave: this.destroyTooltips.bind(this), + mouseDown: this.destroyTooltips.bind(this) + } + }; + + ruleNodeComponents: Array; + + flowchartConstants = FlowchartConstants; + + updateBreadcrumbs = new EventEmitter(); + + private rxSubscription: Subscription; + + private tooltipTimeout: Timeout; + + constructor(protected store: Store, + private route: ActivatedRoute, + private router: Router, + private ruleChainService: RuleChainService, + private authService: AuthService, + private translate: TranslateService, + private itembuffer: ItemBufferService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + public dialog: MatDialog, + public dialogService: DialogService, + public fb: FormBuilder) { + super(store); + this.rxSubscription = this.route.data.subscribe( + () => { + this.reset(); + this.init(); + } + ); + } + + ngOnInit() { + } + + ngAfterViewInit() { + fromEvent(this.ruleNodeSearchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + this.updateRuleChainLibrary(); + }) + ) + .subscribe(); + this.ruleChainCanvas.adjustCanvasSize(true); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.rxSubscription.unsubscribe(); + } + + onSearchTextUpdated(searchText: string) { + this.ruleNodeSearch = searchText; + this.updateRuleNodesHighlight(); + } + + private init() { + this.initHotKeys(); + this.isImport = this.route.snapshot.data.import; + this.ruleChainType = this.route.snapshot.data.ruleChainType; + if (this.isImport) { + const ruleChainImport: RuleChainImport = this.itembuffer.getRuleChainImport(); + this.ruleChain = ruleChainImport.ruleChain; + this.ruleChainMetaData = ruleChainImport.metadata; + } else { + this.ruleChain = this.route.snapshot.data.ruleChain; + this.ruleChainMetaData = this.route.snapshot.data.ruleChainMetaData; + } + this.ruleNodeComponents = this.route.snapshot.data.ruleNodeComponents; + for (const type of ruleNodeTypesLibrary) { + const desc = ruleNodeTypeDescriptors.get(type); + if (!desc.special) { + this.ruleNodeTypesModel[type] = { + model: { + nodes: [], + edges: [] + }, + selectedObjects: [] + }; + } + } + this.updateRuleChainLibrary(); + this.createRuleChainModel(); + } + + private reset(): void { + this.selectedObjects = []; + this.ruleChainModel.nodes = []; + this.ruleChainModel.edges = []; + this.ruleNodeTypesModel = {}; + if (this.ruleChainCanvas) { + this.ruleChainCanvas.adjustCanvasSize(true); + } + this.isEditingRuleNode = false; + this.isEditingRuleNodeLink = false; + this.updateRuleNodesHighlight(); + } + + private initHotKeys(): void { + if (!this.hotKeys.length) { + this.hotKeys.push( + new Hotkey('ctrl+a', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.ruleChainCanvas.modelService.selectAll(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulenode.select-all-objects')) + ); + this.hotKeys.push( + new Hotkey('ctrl+c', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.copyRuleNodes(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulenode.copy-selected')) + ); + this.hotKeys.push( + new Hotkey('ctrl+v', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + if (this.itembuffer.hasRuleNodes()) { + this.pasteRuleNodes(); + } + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('action.paste')) + ); + this.hotKeys.push( + new Hotkey('esc', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + event.stopPropagation(); + this.ruleChainCanvas.modelService.deselectAll(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulenode.deselect-all-objects')) + ); + this.hotKeys.push( + new Hotkey('ctrl+s', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.saveRuleChain(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('action.apply')) + ); + this.hotKeys.push( + new Hotkey('ctrl+z', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.revertRuleChain(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('action.decline-changes')) + ); + this.hotKeys.push( + new Hotkey('del', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.ruleChainCanvas.modelService.deleteSelected(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulenode.delete-selected-objects')) + ); + this.hotKeys.push( + new Hotkey('ctrl+r', (event: KeyboardEvent) => { + if (this.enableHotKeys && this.canCreateNestedRuleChain()) { + event.preventDefault(); + this.createNestedRuleChain(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulenode.create-nested-rulechain')) + ); + } + } + + updateRuleChainLibrary() { + const search = this.ruleNodeTypeSearch.toUpperCase(); + const res = this.ruleNodeComponents.filter( + (ruleNodeComponent) => ruleNodeComponent.name.toUpperCase().includes(search)); + this.loadRuleChainLibrary(res); + } + + private loadRuleChainLibrary(ruleNodeComponents: Array) { + for (const componentType of Object.keys(this.ruleNodeTypesModel)) { + this.ruleNodeTypesModel[componentType].model.nodes.length = 0; + } + ruleNodeComponents.forEach((ruleNodeComponent) => { + const componentType = ruleNodeComponent.type; + const model = this.ruleNodeTypesModel[componentType].model; + const desc = ruleNodeTypeDescriptors.get(RuleNodeType[componentType]); + let icon = desc.icon; + let iconUrl = null; + if (ruleNodeComponent.configurationDescriptor.nodeDefinition.icon) { + icon = ruleNodeComponent.configurationDescriptor.nodeDefinition.icon; + } + if (ruleNodeComponent.configurationDescriptor.nodeDefinition.iconUrl) { + iconUrl = ruleNodeComponent.configurationDescriptor.nodeDefinition.iconUrl; + } + const node: FcRuleNodeType = { + id: 'node-lib-' + componentType + '-' + model.nodes.length, + component: ruleNodeComponent, + name: '', + nodeClass: desc.nodeClass, + icon, + iconUrl, + x: 30, + y: 10 + 50 * model.nodes.length, + connectors: [] + }; + if (ruleNodeComponent.configurationDescriptor.nodeDefinition.inEnabled) { + node.connectors.push( + { + type: FlowchartConstants.leftConnectorType, + id: (model.nodes.length * 2) + '' + } + ); + } + if (ruleNodeComponent.configurationDescriptor.nodeDefinition.outEnabled) { + node.connectors.push( + { + type: FlowchartConstants.rightConnectorType, + id: (model.nodes.length * 2 + 1) + '' + } + ); + } + model.nodes.push(node); + }); + if (this.expansionPanels) { + for (let i = 0; i < ruleNodeTypesLibrary.length; i++) { + const panel = this.expansionPanels.find((item, index) => { + return index === i; + }); + if (panel) { + const type = ruleNodeTypesLibrary[i]; + if (!this.ruleNodeTypesModel[type].model.nodes.length) { + panel.close(); + } else { + panel.open(); + } + } + } + } + } + + private createRuleChainModel() { + this.nextNodeID = 1; + this.nextConnectorID = 1; + + this.selectedObjects = []; + this.ruleChainModel.nodes = []; + this.ruleChainModel.edges = []; + + this.inputConnectorId = this.nextConnectorID++; + this.ruleChainModel.nodes.push( + { + id: 'rule-chain-node-' + this.nextNodeID++, + component: inputNodeComponent, + name: '', + nodeClass: ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).nodeClass, + icon: ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).icon, + readonly: true, + x: 50, + y: 150, + connectors: [ + { + type: FlowchartConstants.rightConnectorType, + id: this.inputConnectorId + '' + }, + ] + + } + ); + const nodes: FcRuleNode[] = []; + this.ruleChainMetaData.nodes.forEach((ruleNode) => { + const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleNode.type); + const descriptor = ruleNodeTypeDescriptors.get(component.type); + let icon = descriptor.icon; + let iconUrl = null; + if (component.configurationDescriptor.nodeDefinition.icon) { + icon = component.configurationDescriptor.nodeDefinition.icon; + } + if (component.configurationDescriptor.nodeDefinition.iconUrl) { + iconUrl = component.configurationDescriptor.nodeDefinition.iconUrl; + } + const node: FcRuleNode = { + id: 'rule-chain-node-' + this.nextNodeID++, + ruleNodeId: ruleNode.id, + additionalInfo: ruleNode.additionalInfo, + configuration: ruleNode.configuration, + debugMode: ruleNode.debugMode, + x: Math.round(ruleNode.additionalInfo.layoutX), + y: Math.round(ruleNode.additionalInfo.layoutY), + component, + name: ruleNode.name, + nodeClass: descriptor.nodeClass, + icon, + iconUrl, + connectors: [], + ruleChainType: this.ruleChainType + }; + if (component.configurationDescriptor.nodeDefinition.inEnabled) { + node.connectors.push( + { + type: FlowchartConstants.leftConnectorType, + id: (this.nextConnectorID++) + '' + } + ); + } + if (component.configurationDescriptor.nodeDefinition.outEnabled) { + node.connectors.push( + { + type: FlowchartConstants.rightConnectorType, + id: (this.nextConnectorID++) + '' + } + ); + } + nodes.push(node); + this.ruleChainModel.nodes.push(node); + }); + if (this.ruleChainMetaData.firstNodeIndex > -1) { + const destNode = nodes[this.ruleChainMetaData.firstNodeIndex]; + if (destNode) { + const connectors = destNode.connectors.filter(connector => connector.type === FlowchartConstants.leftConnectorType); + if (connectors && connectors.length) { + const edge: FcRuleEdge = { + source: this.inputConnectorId + '', + destination: connectors[0].id + }; + this.ruleChainModel.edges.push(edge); + } + } + } + if (this.ruleChainMetaData.connections) { + const edgeMap: {[edgeKey: string]: FcRuleEdge} = {}; + this.ruleChainMetaData.connections.forEach((connection) => { + const sourceNode = nodes[connection.fromIndex]; + const destNode = nodes[connection.toIndex]; + if (sourceNode && destNode) { + const sourceConnectors = sourceNode.connectors.filter(connector => connector.type === FlowchartConstants.rightConnectorType); + const destConnectors = destNode.connectors.filter(connector => connector.type === FlowchartConstants.leftConnectorType); + if (sourceConnectors && sourceConnectors.length && destConnectors && destConnectors.length) { + const sourceId = sourceConnectors[0].id; + const destId = destConnectors[0].id; + const edgeKey = sourceId + '_' + destId; + let edge = edgeMap[edgeKey]; + if (!edge) { + edge = { + source: sourceId, + destination: destId, + label: connection.type, + labels: [connection.type] + }; + edgeMap[edgeKey] = edge; + this.ruleChainModel.edges.push(edge); + } else { + edge.label += ' / ' + connection.type; + edge.labels.push(connection.type); + } + } + } + }); + } + if (this.ruleChainCanvas) { + this.ruleChainCanvas.adjustCanvasSize(true); + } + this.isDirtyValue = false; + this.updateRuleNodesHighlight(); + this.validate(); + } + + openRuleChainContextMenu($event: MouseEvent) { + if (this.ruleChainCanvas.modelService && !$event.ctrlKey && !$event.metaKey) { + const x = $event.clientX; + const y = $event.clientY; + const item = this.ruleChainCanvas.modelService.getItemInfoAtPoint(x, y); + const contextInfo = this.prepareContextMenu(item); + if (contextInfo.menuItems && contextInfo.menuItems.length > 0) { + $event.preventDefault(); + $event.stopPropagation(); + this.contextMenuEvent = $event; + this.ruleChainMenuPosition.x = x + 'px'; + this.ruleChainMenuPosition.y = y + 'px'; + this.ruleChainMenuTrigger.menuData = { contextInfo }; + this.ruleChainMenuTrigger.openMenu(); + } + } + } + + onRuleChainContextMenuMouseLeave() { + this.ruleChainMenuTrigger.closeMenu(); + } + + private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo { + if (this.objectsSelected() || (!item.node && !item.edge)) { + return this.prepareRuleChainContextMenu(); + } else if (item.node) { + return this.prepareRuleNodeContextMenu(item.node); + } else if (item.edge) { + return this.prepareEdgeContextMenu(item.edge); + } + } + + private prepareRuleChainContextMenu(): RuleChainMenuContextInfo { + const contextInfo: RuleChainMenuContextInfo = { + headerClass: 'tb-rulechain-header', + icon: 'settings_ethernet', + title: this.ruleChain.name, + subtitle: this.translate.instant('rulechain.rulechain'), + menuItems: [] + }; + if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length) { + contextInfo.menuItems.push( + { + action: () => { + this.copyRuleNodes(); + }, + enabled: true, + value: 'rulenode.copy-selected', + icon: 'content_copy', + shortcut: 'M-C' + } + ); + } + contextInfo.menuItems.push( + { + action: ($event) => { + this.pasteRuleNodes($event); + }, + enabled: this.itembuffer.hasRuleNodes(), + value: 'action.paste', + icon: 'content_paste', + shortcut: 'M-V' + } + ); + contextInfo.menuItems.push( + { + divider: true + } + ); + if (this.objectsSelected()) { + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.deselectAll(); + }, + enabled: true, + value: 'rulenode.deselect-all', + icon: 'tab_unselected', + shortcut: 'Esc' + } + ); + if (this.canCreateNestedRuleChain()) { + contextInfo.menuItems.push( + { + action: () => { + this.createNestedRuleChain(); + }, + enabled: true, + value: 'rulenode.create-nested-rulechain', + icon: 'settings_ethernet', + shortcut: 'M-R' + } + ); + } + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.deleteSelected(); + }, + enabled: true, + value: 'rulenode.delete-selected', + icon: 'clear', + shortcut: 'Del' + } + ); + } else { + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.selectAll(); + }, + enabled: true, + value: 'rulenode.select-all', + icon: 'select_all', + shortcut: 'M-A' + } + ); + } + contextInfo.menuItems.push( + { + divider: true + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.saveRuleChain(); + }, + enabled: !(this.isInvalid || (!this.isDirty && !this.isImport)), + value: 'action.apply-changes', + icon: 'done', + shortcut: 'M-S' + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.revertRuleChain(); + }, + enabled: this.isDirty, + value: 'action.decline-changes', + icon: 'close', + shortcut: 'M-Z' + } + ); + return contextInfo; + } + + private prepareRuleNodeContextMenu(node: FcRuleNode): RuleChainMenuContextInfo { + const contextInfo: RuleChainMenuContextInfo = { + headerClass: node.nodeClass, + icon: node.icon, + iconUrl: node.iconUrl, + title: node.name, + subtitle: node.component.name, + menuItems: [] + }; + if (!node.readonly) { + contextInfo.menuItems.push( + { + action: () => { + this.openNodeDetails(node); + }, + enabled: true, + value: 'rulenode.details', + icon: 'menu' + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.copyNode(node); + }, + enabled: true, + value: 'action.copy', + icon: 'content_copy' + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.nodes.delete(node); + }, + enabled: true, + value: 'action.delete', + icon: 'clear', + shortcut: 'M-X' + } + ); + } + return contextInfo; + } + + private prepareEdgeContextMenu(edge: FcRuleEdge): RuleChainMenuContextInfo { + const contextInfo: RuleChainMenuContextInfo = { + headerClass: 'tb-link-header', + icon: 'trending_flat', + title: edge.label, + subtitle: this.translate.instant('rulenode.link'), + menuItems: [] + }; + const sourceNode: FcRuleNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + if (sourceNode.component.type !== RuleNodeType.INPUT) { + contextInfo.menuItems.push( + { + action: () => { + this.openLinkDetails(edge); + }, + enabled: true, + value: 'rulenode.details', + icon: 'menu' + } + ); + } + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.edges.delete(edge); + }, + enabled: true, + value: 'action.delete', + icon: 'clear', + shortcut: 'M-X' + } + ); + return contextInfo; + } + + private canCreateNestedRuleChain(): boolean { + const selectedNodes = this.ruleChainCanvas.modelService.nodes.getSelectedNodes(); + const selectedEdges = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); + if (selectedNodes.length > 1) { + const toIndexSet = new Set(); + selectedEdges.forEach((edge: FcRuleEdge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + const fromIndex = selectedNodes.indexOf(sourceNode); + const toIndex = selectedNodes.indexOf(destNode); + if (fromIndex > -1 && toIndex > -1) { + toIndexSet.add(toIndex); + } + }); + const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index)); + return noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled).length <= 1; + } + return false; + } + + private createNestedRuleChain() { + const selectedNodes = this.ruleChainCanvas.modelService.nodes.getSelectedNodes(); + const selectedEdges = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); + this.dialog.open(CreateNestedRuleChainDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + ruleChainType: this.ruleChainType + } + }).afterClosed().subscribe((ruleChain) => { + if (ruleChain) { + this.ruleChainCanvas.modelService.deselectAll(); + const ruleChainMetaData: RuleChainMetaData = { + ruleChainId: ruleChain.id, + nodes: [], + connections: [] + }; + let outputEdges: FcRuleEdge[] = []; + let minX: number = null; + let maxX = 0; + let minY = null; + let maxY = 0; + + selectedNodes.forEach((node: FcRuleNode) => { + const ruleNode: RuleNode = { + type: node.component.clazz, + name: node.name, + configuration: deepClone(node.configuration), + additionalInfo: node.additionalInfo ? deepClone(node.additionalInfo) : {}, + debugMode: node.debugMode + }; + if (minX === null) { + minX = node.x; + } else { + minX = Math.min(minX, node.x); + } + if (minY === null) { + minY = node.y; + } else { + minY = Math.min(minY, node.y); + } + maxX = Math.max(maxX, node.x); + maxY = Math.max(maxY, node.y); + ruleNode.additionalInfo.layoutX = Math.round(node.x); + ruleNode.additionalInfo.layoutY = Math.round(node.y); + ruleChainMetaData.nodes.push(ruleNode); + const outputConnectors = this.ruleChainCanvas.modelService.nodes.getConnectorsByType(node, FlowchartConstants.rightConnectorType); + outputConnectors.forEach(connector => { + const nodeOutputEdges = this.ruleChainCanvas.modelService.model.edges.filter(edge => edge.source === connector.id); + const outerEdges = nodeOutputEdges.filter(edge => { + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + return selectedNodes.indexOf(destNode) === -1; + }); + outputEdges = outputEdges.concat(outerEdges); + }); + }); + const toIndexSet = new Set(); + selectedEdges.forEach((edge: FcRuleEdge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + if (sourceNode.component.type !== RuleNodeType.INPUT) { + const fromIndex = selectedNodes.indexOf(sourceNode); + const toIndex = selectedNodes.indexOf(destNode); + if (fromIndex > -1 && toIndex > -1) { + const nodeConnection = { + fromIndex, + toIndex + } as NodeConnectionInfo; + edge.labels.forEach((label) => { + const newNodeConnection = deepClone(nodeConnection); + newNodeConnection.type = label; + ruleChainMetaData.connections.push(newNodeConnection); + }); + toIndexSet.add(toIndex); + } + } + }); + const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index)); + const possibleInputNodes = noInputNodes.filter((node: FcRuleNode) => + node.component.configurationDescriptor.nodeDefinition.inEnabled); + let inputEdges: FcRuleEdge[] = []; + if (possibleInputNodes.length) { + const firstNode = possibleInputNodes[0]; + const inputConnectors = this.ruleChainCanvas.modelService.nodes + .getConnectorsByType(firstNode, FlowchartConstants.leftConnectorType); + if (inputConnectors.length) { + const inputConnector = inputConnectors[0]; + const nodeInputEdges = this.ruleChainCanvas.modelService.model.edges.filter(edge => edge.destination === inputConnector.id); + inputEdges = inputEdges.concat(nodeInputEdges); + } + ruleChainMetaData.firstNodeIndex = selectedNodes.indexOf(firstNode); + } + outputEdges.forEach((outputEdge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(outputEdge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(outputEdge.destination); + const outputNode: RuleNode = { + type: outputNodeClazz, + name: outputEdge.label, + configuration: {}, + additionalInfo: {}, + debugMode: false + }; + outputNode.additionalInfo.layoutX = Math.round(destNode.x); + outputNode.additionalInfo.layoutY = Math.round(destNode.y); + ruleChainMetaData.nodes.push(outputNode); + const fromIndex = selectedNodes.indexOf(sourceNode); + const toIndex = ruleChainMetaData.nodes.length - 1; + const nodeConnection = { + fromIndex, + toIndex + } as NodeConnectionInfo; + outputEdge.labels.forEach((label) => { + const newNodeConnection = deepClone(nodeConnection); + newNodeConnection.type = label; + ruleChainMetaData.connections.push(newNodeConnection); + }); + }); + const deltaX = Math.round(minX - 375); + const deltaY = Math.round(minY - 150); + ruleChainMetaData.nodes.forEach((node) => { + node.additionalInfo.layoutX -= deltaX; + node.additionalInfo.layoutY -= deltaY; + }); + this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => { + const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz); + const descriptor = ruleNodeTypeDescriptors.get(component.type); + let icon = descriptor.icon; + let iconUrl = null; + if (component.configurationDescriptor.nodeDefinition.icon) { + icon = component.configurationDescriptor.nodeDefinition.icon; + } + if (component.configurationDescriptor.nodeDefinition.iconUrl) { + iconUrl = component.configurationDescriptor.nodeDefinition.iconUrl; + } + const ruleChainNodeX = (minX + maxX) / 2; + const ruleChainNodeY = (minY + maxY) / 2; + const ruleChainInputId = (this.nextConnectorID++) + ''; + const ruleChainOutputId = (this.nextConnectorID++) + ''; + const ruleChainNode: FcRuleNode = { + name: ruleChain.name, + component, + id: 'rule-chain-node-' + this.nextNodeID++, + configuration: { + ruleChainId: ruleChain.id.id + }, + debugMode: false, + x: Math.round(ruleChainNodeX), + y: Math.round(ruleChainNodeY), + nodeClass: descriptor.nodeClass, + icon, + iconUrl, + ruleChainType: this.ruleChainType, + connectors: [ + { + type: FlowchartConstants.leftConnectorType, + id: ruleChainInputId + }, + { + type: FlowchartConstants.rightConnectorType, + id: ruleChainOutputId + } + ] + }; + this.ruleChainModel.nodes.push(ruleChainNode); + inputEdges.forEach((inputEdge) => { + inputEdge.destination = ruleChainInputId; + }); + outputEdges.forEach((outputEdge) => { + outputEdge.source = ruleChainOutputId; + outputEdge.labels = [outputEdge.label]; + }); + selectedNodes.forEach((node) => { + this.ruleChainCanvas.modelService.nodes.delete(node); + }); + this.onModelChanged(); + this.updateRuleNodesHighlight(); + }); + } + }); + } + + onModelChanged() { + this.isDirtyValue = true; + this.validate(); + } + + helpLinkIdForRuleNodeType(): string { + let component: RuleNodeComponentDescriptor = null; + if (this.editingRuleNode) { + component = this.editingRuleNode.component; + } + return getRuleNodeHelpLink(component); + } + + openNodeDetails(node: FcRuleNode) { + if (node.component.type !== RuleNodeType.INPUT) { + this.enableHotKeys = false; + this.updateErrorTooltips(true); + this.isEditingRuleNodeLink = false; + this.editingRuleNodeLink = null; + this.isEditingRuleNode = true; + this.editingRuleNodeIndex = this.ruleChainModel.nodes.indexOf(node); + this.editingRuleNode = deepClone(node, ['component']); + setTimeout(() => { + this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); + }, 0); + } + } + + openLinkDetails(edge: FcRuleEdge) { + const sourceNode: FcRuleNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode; + if (sourceNode.component.type !== RuleNodeType.INPUT) { + this.enableHotKeys = false; + this.updateErrorTooltips(true); + this.isEditingRuleNode = false; + this.editingRuleNode = null; + this.editingRuleNodeLinkLabels = this.ruleChainService.getRuleNodeSupportedLinks(sourceNode.component); + this.editingRuleNodeAllowCustomLabels = this.ruleChainService.ruleNodeAllowCustomLinks(sourceNode.component); + this.editingRuleNodeSourceRuleChainId = + this.ruleChainService.ruleNodeSourceRuleChainId(sourceNode.component, sourceNode.configuration); + this.isEditingRuleNodeLink = true; + this.editingRuleNodeLinkIndex = this.ruleChainModel.edges.indexOf(edge); + this.editingRuleNodeLink = deepClone(edge); + setTimeout(() => { + this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); + }, 0); + } + } + + private copyNode(node: FcRuleNode) { + this.itembuffer.copyRuleNodes([node], []); + } + + private copyRuleNodes() { + const nodes: FcRuleNode[] = this.ruleChainCanvas.modelService.nodes.getSelectedNodes(); + const edges: FcRuleEdge[] = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); + const connections: RuleNodeConnection[] = []; + edges.forEach((edge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + const isInputSource = sourceNode.component.type === RuleNodeType.INPUT; + const fromIndex = nodes.indexOf(sourceNode); + const toIndex = nodes.indexOf(destNode); + if ( (isInputSource || fromIndex > -1) && toIndex > -1 ) { + const connection: RuleNodeConnection = { + isInputSource, + fromIndex, + toIndex, + label: edge.label, + labels: edge.labels + }; + connections.push(connection); + } + }); + this.itembuffer.copyRuleNodes(nodes, connections); + } + + private pasteRuleNodes(event?: MouseEvent) { + const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement); + let x: number; + let y: number; + if (event) { + const offset = canvas.offset(); + x = Math.round(event.clientX - offset.left); + y = Math.round(event.clientY - offset.top); + } else { + const scrollParent = canvas.parent(); + const scrollTop = scrollParent.scrollTop(); + const scrollLeft = scrollParent.scrollLeft(); + x = scrollLeft + scrollParent.width() / 2; + y = scrollTop + scrollParent.height() / 2; + } + const ruleNodes = this.itembuffer.pasteRuleNodes(x, y); + if (ruleNodes) { + this.ruleChainCanvas.modelService.deselectAll(); + const nodes: FcRuleNode[] = []; + ruleNodes.nodes.forEach((node) => { + node.id = 'rule-chain-node-' + this.nextNodeID++; + const component = node.component; + if (component.configurationDescriptor.nodeDefinition.inEnabled) { + node.connectors.push( + { + type: FlowchartConstants.leftConnectorType, + id: (this.nextConnectorID++) + '' + } + ); + } + if (component.configurationDescriptor.nodeDefinition.outEnabled) { + node.connectors.push( + { + type: FlowchartConstants.rightConnectorType, + id: (this.nextConnectorID++) + '' + } + ); + } + nodes.push(node); + this.ruleChainModel.nodes.push(node); + this.ruleChainCanvas.modelService.nodes.select(node); + }); + ruleNodes.connections.forEach((connection) => { + const sourceNode = nodes[connection.fromIndex]; + const destNode = nodes[connection.toIndex]; + if ( (connection.isInputSource || sourceNode) && destNode ) { + let source: string; + let destination: string; + if (connection.isInputSource) { + source = this.inputConnectorId + ''; + const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); + if (found) { + this.ruleChainCanvas.modelService.edges.delete(found); + } + } else { + const sourceConnectors = this.ruleChainCanvas.modelService.nodes + .getConnectorsByType(sourceNode, FlowchartConstants.rightConnectorType); + if (sourceConnectors && sourceConnectors.length) { + source = sourceConnectors[0].id; + } + } + const destConnectors = this.ruleChainCanvas.modelService.nodes + .getConnectorsByType(destNode, FlowchartConstants.leftConnectorType); + if (destConnectors && destConnectors.length) { + destination = destConnectors[0].id; + } + if (source && destination) { + const edge: FcRuleEdge = { + source, + destination, + label: connection.label, + labels: connection.labels + }; + this.ruleChainModel.edges.push(edge); + this.ruleChainCanvas.modelService.edges.select(edge); + } + } + }); + this.updateRuleNodesHighlight(); + this.validate(); + this.onModelChanged(); + } + } + + onDetailsDrawerClosed() { + this.onEditRuleNodeClosed(); + this.onEditRuleNodeLinkClosed(); + this.enableHotKeys = true; + this.updateErrorTooltips(false); + } + + onEditRuleNodeClosed() { + this.editingRuleNode = null; + this.isEditingRuleNode = false; + } + + onEditRuleNodeLinkClosed() { + this.editingRuleNodeLink = null; + this.isEditingRuleNodeLink = false; + } + + onRevertRuleNodeEdit() { + this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); + const node = this.ruleChainModel.nodes[this.editingRuleNodeIndex]; + this.editingRuleNode = deepClone(node, ['component']); + } + + onRevertRuleNodeLinkEdit() { + this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); + const edge = this.ruleChainModel.edges[this.editingRuleNodeLinkIndex]; + this.editingRuleNodeLink = deepClone(edge); + } + + saveRuleNode() { + this.ruleNodeComponent.validate(); + if (this.ruleNodeComponent.ruleNodeFormGroup.valid) { + this.ruleNodeComponent.ruleNodeFormGroup.markAsPristine(); + if (this.editingRuleNode.error) { + delete this.editingRuleNode.error; + } + this.ruleChainModel.nodes[this.editingRuleNodeIndex] = this.editingRuleNode; + this.editingRuleNode = deepClone(this.editingRuleNode, ['component']); + this.onModelChanged(); + this.updateRuleNodesHighlight(); + } + } + + saveRuleNodeLink() { + this.ruleNodeLinkComponent.ruleNodeLinkFormGroup.markAsPristine(); + this.ruleChainModel.edges[this.editingRuleNodeLinkIndex] = this.editingRuleNodeLink; + this.editingRuleNodeLink = deepClone(this.editingRuleNodeLink); + this.onModelChanged(); + } + + typeHeaderMouseEnter(event: MouseEvent, ruleNodeType: RuleNodeType) { + const type = ruleNodeTypeDescriptors.get(ruleNodeType); + this.displayTooltip(event, + '
' + + '
' + + '
' + this.translate.instant(type.name) + '
' + + '
' + this.translate.instant(type.details) + '
' + + '
' + + '
' + ); + } + + displayLibNodeDescriptionTooltip(event: MouseEvent, node: FcRuleNodeType) { + this.displayTooltip(event, + '
' + + '
' + + '
' + node.component.name + '
' + + '
' + node.component.configurationDescriptor.nodeDefinition.description + '
' + + '
' + node.component.configurationDescriptor.nodeDefinition.details + '
' + + '
' + + '
' + ); + } + + displayNodeDescriptionTooltip(event: MouseEvent, node: FcRuleNode) { + if (!this.errorTooltips[node.id]) { + let name: string; + let desc: string; + let details: string; + if (node.component.type === RuleNodeType.INPUT) { + name = this.translate.instant(ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).name); + desc = this.translate.instant(ruleNodeTypeDescriptors.get(RuleNodeType.INPUT).details); + } else { + name = node.name; + desc = this.translate.instant(ruleNodeTypeDescriptors.get(node.component.type).name) + ' - ' + node.component.name; + if (node.additionalInfo) { + details = node.additionalInfo.description; + } + } + let tooltipContent = '
' + + '
' + + '
' + name + '
' + + '
' + desc + '
'; + if (details) { + tooltipContent += '
' + details + '
'; + } + tooltipContent += '
' + + '
'; + this.displayTooltip(event, tooltipContent); + } + } + + destroyTooltips() { + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = null; + } + const instances = $.tooltipster.instances(); + instances.forEach((instance) => { + if (!instance.isErrorTooltip) { + instance.destroy(); + } + }); + } + + private updateRuleNodesHighlight() { + for (const ruleNode of this.ruleChainModel.nodes) { + ruleNode.highlighted = false; + } + if (this.ruleNodeSearch) { + const search = this.ruleNodeSearch.toUpperCase(); + const res = this.ruleChainModel.nodes.filter(node => node.name.toUpperCase().includes(search)); + if (res) { + for (const ruleNode of res) { + ruleNode.highlighted = true; + } + } + } + if (this.ruleChainCanvas) { + this.ruleChainCanvas.modelService.detectChanges(); + } + } + + objectsSelected(): boolean { + return this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length > 0 || + this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0; + } + + deleteSelected() { + this.ruleChainCanvas.modelService.deleteSelected(); + } + + isDebugModeEnabled(): boolean { + const res = this.ruleChainModel.nodes.find((node) => node.debugMode); + return typeof res !== 'undefined'; + } + + resetDebugModeInAllNodes() { + let changed = false; + this.ruleChainModel.nodes.forEach((node) => { + if (node.component.type !== RuleNodeType.INPUT) { + changed = changed || node.debugMode; + node.debugMode = false; + } + }); + if (changed) { + this.onModelChanged(); + } + } + + validate() { + setTimeout(() => { + this.isInvalid = false; + this.ruleChainModel.nodes.forEach((node) => { + if (node.error) { + this.isInvalid = true; + } + this.updateNodeErrorTooltip(node); + }); + }, 0); + } + + saveRuleChain(): Observable { + const saveResult = new ReplaySubject(); + let saveRuleChainObservable: Observable; + if (this.isImport) { + saveRuleChainObservable = this.ruleChainService.saveRuleChain(this.ruleChain); + } else { + saveRuleChainObservable = of(this.ruleChain); + } + saveRuleChainObservable.subscribe((ruleChain) => { + this.ruleChain = ruleChain; + const ruleChainMetaData: RuleChainMetaData = { + ruleChainId: this.ruleChain.id, + nodes: [], + connections: [] + }; + const nodes: FcRuleNode[] = []; + this.ruleChainModel.nodes.forEach((node) => { + if (node.component.type !== RuleNodeType.INPUT) { + const ruleNode: RuleNode = { + id: node.ruleNodeId, + type: node.component.clazz, + name: node.name, + configuration: node.configuration, + additionalInfo: node.additionalInfo ? node.additionalInfo : {}, + debugMode: node.debugMode + }; + ruleNode.additionalInfo.layoutX = Math.round(node.x); + ruleNode.additionalInfo.layoutY = Math.round(node.y); + ruleChainMetaData.nodes.push(ruleNode); + nodes.push(node); + } + }); + const firstNodeEdge = this.ruleChainModel.edges.find((edge) => edge.source === this.inputConnectorId + ''); + if (firstNodeEdge) { + const firstNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(firstNodeEdge.destination); + ruleChainMetaData.firstNodeIndex = nodes.indexOf(firstNode); + } + this.ruleChainModel.edges.forEach((edge) => { + const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); + const destNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.destination); + if (sourceNode.component.type !== RuleNodeType.INPUT) { + const fromIndex = nodes.indexOf(sourceNode); + const toIndex = nodes.indexOf(destNode); + const nodeConnection = { + fromIndex, + toIndex + } as NodeConnectionInfo; + edge.labels.forEach((label) => { + const newNodeConnection = deepClone(nodeConnection); + newNodeConnection.type = label; + ruleChainMetaData.connections.push(newNodeConnection); + }); + } + }); + this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe((savedRuleChainMetaData) => { + this.ruleChainMetaData = savedRuleChainMetaData; + if (this.isImport) { + this.isDirtyValue = false; + this.isImport = false; + if (this.ruleChainType !== RuleChainType.EDGE) { + this.router.navigateByUrl(`ruleChains/${this.ruleChain.id.id}`); + } else { + this.router.navigateByUrl(`edgeManagement/ruleChains/${this.ruleChain.id.id}`); + } + } else { + this.createRuleChainModel(); + } + saveResult.next(); + }); + }); + return saveResult; + } + + reloadRuleChain() { + this.ruleChainService.getRuleChain(this.ruleChain.id.id).subscribe((ruleChain) => { + this.ruleChain = ruleChain; + this.updateBreadcrumbs.emit(); + this.ruleChainService.getRuleChainMetadata(this.ruleChain.id.id).subscribe((ruleChainMetaData) => { + this.ruleChainMetaData = ruleChainMetaData; + this.isDirtyValue = false; + this.createRuleChainModel(); + }); + }); + } + + revertRuleChain() { + this.createRuleChainModel(); + } + + addRuleNode(ruleNode: FcRuleNode) { + ruleNode.configuration = deepClone(ruleNode.component.configurationDescriptor.nodeDefinition.defaultConfiguration); + const ruleChainId = this.ruleChain.id ? this.ruleChain.id.id : null; + this.enableHotKeys = false; + const ruleChainType = this.ruleChainType; + this.dialog.open(AddRuleNodeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + ruleNode, + ruleChainId, + ruleChainType + } + }).afterClosed().subscribe( + (addedRuleNode) => { + if (addedRuleNode) { + addedRuleNode.id = 'rule-chain-node-' + this.nextNodeID++; + addedRuleNode.connectors = []; + if (addedRuleNode.component.configurationDescriptor.nodeDefinition.inEnabled) { + addedRuleNode.connectors.push( + { + id: (this.nextConnectorID++) + '', + type: FlowchartConstants.leftConnectorType + } + ); + } + if (addedRuleNode.component.configurationDescriptor.nodeDefinition.outEnabled) { + addedRuleNode.connectors.push( + { + id: (this.nextConnectorID++) + '', + type: FlowchartConstants.rightConnectorType + } + ); + } + this.ruleChainModel.nodes.push(addedRuleNode); + this.onModelChanged(); + this.updateRuleNodesHighlight(); + } + this.enableHotKeys = true; + } + ); + } + + addRuleNodeLink(link: FcRuleEdge, labels: {[label: string]: LinkLabel}, + allowCustomLabels: boolean, sourceRuleChainId: string): Observable { + return this.dialog.open(AddRuleNodeLinkDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + link, + labels, + allowCustomLabels, + sourceRuleChainId + } + }).afterClosed(); + } + + toggleVersionControl($event: Event, versionControlButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = versionControlButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const versionControlPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, VersionControlComponent, 'leftTop', true, null, + { + detailsMode: true, + active: true, + singleEntityMode: true, + externalEntityId: this.ruleChain.externalId || this.ruleChain.id, + entityId: this.ruleChain.id, + entityName: this.ruleChain.name, + onBeforeCreateVersion: () => { + if (this.isDirty) { + return this.saveRuleChain(); + } else { + return of(null); + } + } + }, {}, {}, {}, true); + versionControlPopover.tbComponentRef.instance.popoverComponent = versionControlPopover; + versionControlPopover.tbComponentRef.instance.versionRestored.subscribe(() => { + this.reloadRuleChain(); + }); + } + } + + private updateNodeErrorTooltip(node: FcRuleNode) { + if (node.error) { + const element = $('#' + node.id); + let tooltip = this.errorTooltips[node.id]; + if (!tooltip || !element.hasClass('tooltipstered')) { + element.tooltipster( + { + theme: 'tooltipster-shadow', + delay: 0, + animationDuration: 0, + trigger: 'custom', + triggerOpen: { + click: false, + tap: false + }, + triggerClose: { + click: false, + tap: false, + scroll: false + }, + side: 'top', + trackOrigin: true + } + ); + const content = '
' + + '
' + + '
' + node.error + '
' + + '
' + + '
'; + const contentElement = $(content); + tooltip = element.tooltipster('instance'); + tooltip.isErrorTooltip = true; + tooltip.content(contentElement); + this.errorTooltips[node.id] = tooltip; + } + setTimeout(() => { + tooltip.open(); + }, 0); + } else { + if (this.errorTooltips[node.id]) { + const tooltip = this.errorTooltips[node.id]; + tooltip.destroy(); + delete this.errorTooltips[node.id]; + } + } + } + + private updateErrorTooltips(hide: boolean) { + for (const nodeId of Object.keys(this.errorTooltips)) { + const tooltip = this.errorTooltips[nodeId]; + if (hide) { + tooltip.close(); + } else { + tooltip.open(); + } + } + } + + private displayTooltip(event: MouseEvent, content: string) { + this.destroyTooltips(); + this.tooltipTimeout = setTimeout(() => { + const element = $(event.target); + element.tooltipster( + { + theme: 'tooltipster-shadow', + delay: 100, + trigger: 'custom', + triggerOpen: { + click: false, + tap: false + }, + triggerClose: { + click: true, + tap: true, + scroll: true + }, + side: 'right', + distance: 12, + trackOrigin: true + } + ); + const contentElement = $(content); + const tooltip = element.tooltipster('instance'); + tooltip.content(contentElement); + tooltip.open(); + }, 500); + } +} + +export interface AddRuleNodeLinkDialogData { + link: FcRuleEdge; + labels: {[label: string]: LinkLabel}; + allowCustomLabels: boolean; + sourceRuleChainId: string; +} + +@Component({ + selector: 'tb-add-rule-node-link-dialog', + templateUrl: './add-rule-node-link-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AddRuleNodeLinkDialogComponent}], + styleUrls: ['./add-rule-node-link-dialog.component.scss'] +}) +export class AddRuleNodeLinkDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + ruleNodeLinkFormGroup: FormGroup; + + link: FcRuleEdge; + labels: {[label: string]: LinkLabel}; + allowCustomLabels: boolean; + sourceRuleChainId: string; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AddRuleNodeLinkDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.link = this.data.link; + this.labels = this.data.labels; + this.allowCustomLabels = this.data.allowCustomLabels; + this.sourceRuleChainId = this.data.sourceRuleChainId; + + this.ruleNodeLinkFormGroup = this.fb.group({ + link: [deepClone(this.link), [Validators.required]] + } + ); + } + + ngOnInit(): void { + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + this.submitted = true; + const link: FcRuleEdge = this.ruleNodeLinkFormGroup.get('link').value; + this.link = {...this.link, ...link}; + this.dialogRef.close(this.link); + } +} + +export interface AddRuleNodeDialogData { + ruleNode: FcRuleNode; + ruleChainId: string; + ruleChainType: RuleChainType; +} + +@Component({ + selector: 'tb-add-rule-node-dialog', + templateUrl: './add-rule-node-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AddRuleNodeDialogComponent}], + styleUrls: ['./add-rule-node-dialog.component.scss'] +}) +export class AddRuleNodeDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + @ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent; + + ruleNode: FcRuleNode; + ruleChainId: string; + ruleChainType: RuleChainType; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AddRuleNodeDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + + this.ruleNode = this.data.ruleNode; + this.ruleChainId = this.data.ruleChainId; + this.ruleChainType = this.data.ruleChainType; + } + + ngOnInit(): void { + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + helpLinkIdForRuleNodeType(): string { + return getRuleNodeHelpLink(this.ruleNode.component); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + this.submitted = true; + + this.ruleNodeDetailsComponent.validate(); + if (this.ruleNodeDetailsComponent.ruleNodeFormGroup.valid) { + this.dialogRef.close(this.ruleNode); + } + } +} + +export interface CreateNestedRuleChainDialogData { + ruleChainType: RuleChainType; +} + +@Component({ + selector: 'tb-create-nested-rulechain-dialog', + templateUrl: './create-nested-rulechain-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: CreateNestedRuleChainDialogComponent}], + styleUrls: [] +}) +export class CreateNestedRuleChainDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + createNestedRuleChainFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CreateNestedRuleChainDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private fb: FormBuilder, + private ruleChainService: RuleChainService, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + + } + + ngOnInit(): void { + this.createNestedRuleChainFormGroup = this.fb.group( + { + name: ['', [Validators.required, Validators.maxLength(255)]], + additionalInfo: this.fb.group( + { + description: [''], + } + ) + } + ); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + this.submitted = true; + const ruleChain = { + name: this.createNestedRuleChainFormGroup.get('name').value, + debugMode: false, + type: this.data.ruleChainType, + additionalInfo: { + description: this.createNestedRuleChainFormGroup.get('additionalInfo').get('description').value + } + } as RuleChain; + this.ruleChainService.saveRuleChain(ruleChain).subscribe( + (savedRuleChain) => { + this.dialogRef.close(savedRuleChain); + } + ); + } +} + diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts new file mode 100644 index 0000000..a26b2c0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts @@ -0,0 +1,45 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { FcModel } from 'ngx-flowchart'; +import { FcRuleEdge, FcRuleNode, FcRuleNodeType } from '@shared/models/rule-node.models'; + +export interface FcRuleNodeTypeModel extends FcModel { + nodes: Array; +} + +export interface FcRuleNodeModel extends FcModel { + nodes: Array; + edges: Array; +} + +export interface RuleChainMenuItem { + action?: ($event: MouseEvent) => void; + enabled?: boolean; + value?: string; + icon?: string; + shortcut?: string; + divider?: boolean; +} + +export interface RuleChainMenuContextInfo { + headerClass: string; + icon: string; + iconUrl?: string; + title: string; + subtitle: string; + menuItems: RuleChainMenuItem[]; +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts new file mode 100644 index 0000000..d8a22c6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-routing.module.ts @@ -0,0 +1,205 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Inject, Injectable, NgModule, Optional } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + Resolve, + Router, + RouterModule, + RouterStateSnapshot, + Routes, + UrlTree +} from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { RuleChainsTableConfigResolver } from '@modules/home/pages/rulechain/rulechains-table-config.resolver'; +import { from, Observable } from 'rxjs'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; +import { + RuleChainMetaData, + RuleChain, RuleChainType +} from '@shared/models/rule-chain.models'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component'; +import { RuleNodeComponentDescriptor } from '@shared/models/rule-node.models'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { ItemBufferService } from '@core/public-api'; +import { MODULES_MAP } from '@shared/public-api'; +import { IModulesMap } from '@modules/common/modules-map.models'; + +@Injectable() +export class RuleChainResolver implements Resolve { + + constructor(private ruleChainService: RuleChainService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const ruleChainId = route.params.ruleChainId; + return this.ruleChainService.getRuleChain(ruleChainId); + } +} + +@Injectable() +export class RuleChainMetaDataResolver implements Resolve { + + constructor(private ruleChainService: RuleChainService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const ruleChainId = route.params.ruleChainId; + return this.ruleChainService.getRuleChainMetadata(ruleChainId); + } +} + +@Injectable() +export class RuleNodeComponentsResolver implements Resolve> { + + constructor(private ruleChainService: RuleChainService, + @Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap) { + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + return this.ruleChainService.getRuleNodeComponents(this.modulesMap, route.data.ruleChainType); + } +} + +@Injectable() +export class TooltipsterResolver implements Resolve { + + constructor() { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + return from(import('tooltipster')); + } +} + +@Injectable() +export class RuleChainImportGuard implements CanActivate { + + constructor(private itembuffer: ItemBufferService, + private router: Router) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): + Observable | Promise | boolean | UrlTree { + if (this.itembuffer.hasRuleChainImport()) { + return true; + } else { + return this.router.parseUrl('ruleChains'); + } + } + +} + +export const ruleChainBreadcumbLabelFunction: BreadCrumbLabelFunction + = ((route, translate, component) => { + let label: string = component.ruleChain.name; + if (component.ruleChain.root) { + label += ` (${translate.instant('rulechain.root')})`; + } + return label; +}); + +export const importRuleChainBreadcumbLabelFunction: BreadCrumbLabelFunction = + ((route, translate, component) => { + return `${translate.instant('rulechain.import')}: ${component.ruleChain.name}`; +}); + +const routes: Routes = [ + { + path: 'ruleChains', + data: { + breadcrumb: { + label: 'rulechain.rulechains', + icon: 'settings_ethernet' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.rulechains', + ruleChainsType: 'tenant' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.rulechain', + import: false, + ruleChainType: RuleChainType.CORE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: RuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + }, + { + path: 'ruleChain/import', + component: RuleChainPageComponent, + canActivate: [RuleChainImportGuard], + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: importRuleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.rulechain', + import: true, + ruleChainType: RuleChainType.CORE + }, + resolve: { + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] + } +]; + +// @dynamic +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + RuleChainsTableConfigResolver, + RuleChainResolver, + RuleChainMetaDataResolver, + RuleNodeComponentsResolver, + TooltipsterResolver, + RuleChainImportGuard + ] +}) +export class RuleChainRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html new file mode 100644 index 0000000..b865797 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts new file mode 100644 index 0000000..3b194b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { RuleChain } from '@shared/models/rule-chain.models'; + +@Component({ + selector: 'tb-rulechain-tabs', + templateUrl: './rulechain-tabs.component.html', + styleUrls: [] +}) +export class RuleChainTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html new file mode 100644 index 0000000..86a2660 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html @@ -0,0 +1,102 @@ + +
+ + + + + + + + +
+ +
+
+
+
+
+ + rulechain.name + + + {{ 'rulechain.name-required' | translate }} + + + {{ 'rulechain.name-max-length' | translate }} + + + + {{ 'rulechain.debug-mode' | translate }} + +
+ + rulechain.description + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts new file mode 100644 index 0000000..98d6a4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts @@ -0,0 +1,115 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { RuleChain } from '@shared/models/rule-chain.models'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; + +@Component({ + selector: 'tb-rulechain', + templateUrl: './rulechain.component.html', + styleUrls: ['./rulechain.component.scss'] +}) +export class RuleChainComponent extends EntityComponent { + + ruleChainScope: 'tenant' | 'edges' | 'edge'; + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: RuleChain, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + ngOnInit() { + this.ruleChainScope = this.entitiesTableConfig.componentsData.ruleChainScope; + super.ngOnInit(); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: RuleChain): FormGroup { + return this.fb.group( + { + name: [entity ? entity.name : '', [Validators.required, Validators.maxLength(255)]], + debugMode: [entity ? entity.debugMode : false], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + } + ) + } + ); + } + + updateForm(entity: RuleChain) { + this.entityForm.patchValue({name: entity.name}); + this.entityForm.patchValue({debugMode: entity.debugMode}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + } + + + onRuleChainIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('rulechain.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + isEdgeRootRuleChain() { + if (this.entitiesTableConfig && this.entityValue) { + return this.entitiesTableConfig.componentsData.edge?.rootRuleChainId?.id == this.entityValue.id.id; + } else { + return false; + } + } + + isAutoAssignToEdgeRuleChain() { + if (this.entitiesTableConfig && this.entityValue) { + return !this.entityValue.root && + this.entitiesTableConfig.componentsData?.autoAssignToEdgeRuleChainIds?.includes(this.entityValue.id.id); + } else { + return false; + } + } + + isNotAutoAssignToEdgeRuleChain() { + if (this.entitiesTableConfig && this.entityValue) { + return !this.entityValue.root && + !this.entitiesTableConfig.componentsData?.autoAssignToEdgeRuleChainIds?.includes(this.entityValue.id.id); + } else { + return false; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts new file mode 100644 index 0000000..7cc2a18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { RuleChainComponent } from '@modules/home/pages/rulechain/rulechain.component'; +import { RuleChainRoutingModule } from '@modules/home/pages/rulechain/rulechain-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component'; +import { + AddRuleNodeDialogComponent, + AddRuleNodeLinkDialogComponent, CreateNestedRuleChainDialogComponent, + RuleChainPageComponent +} from './rulechain-page.component'; +import { RuleNodeComponent } from '@home/pages/rulechain/rulenode.component'; +import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart'; +import { RuleNodeDetailsComponent } from './rule-node-details.component'; +import { RuleNodeLinkComponent } from './rule-node-link.component'; +import { LinkLabelsComponent } from '@home/pages/rulechain/link-labels.component'; +import { RuleNodeConfigComponent } from './rule-node-config.component'; + +@NgModule({ + declarations: [ + RuleChainComponent, + RuleChainTabsComponent, + RuleChainPageComponent, + RuleNodeComponent, + RuleNodeDetailsComponent, + RuleNodeConfigComponent, + LinkLabelsComponent, + RuleNodeLinkComponent, + AddRuleNodeLinkDialogComponent, + AddRuleNodeDialogComponent, + CreateNestedRuleChainDialogComponent + ], + providers: [ + { + provide: FC_NODE_COMPONENT_CONFIG, + useValue: { + nodeComponentType: RuleNodeComponent + } + } + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + RuleChainRoutingModule + ] +}) +export class RuleChainModule { } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts new file mode 100644 index 0000000..d6bde70 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -0,0 +1,593 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + CellActionDescriptor, + checkBoxCell, + DateEntityTableColumn, + EntityColumn, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { RuleChain, RuleChainType } from '@shared/models/rule-chain.models'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { RuleChainComponent } from '@modules/home/pages/rulechain/rulechain.component'; +import { DialogService } from '@core/services/dialog.service'; +import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { ItemBufferService } from '@core/services/item-buffer.service'; +import { EdgeService } from '@core/http/edge.service'; +import { forkJoin, Observable } from 'rxjs'; +import { + AddEntitiesToEdgeDialogComponent, + AddEntitiesToEdgeDialogData +} from '@home/dialogs/add-entities-to-edge-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { isUndefined } from '@core/utils'; +import { PageLink } from '@shared/models/page/page-link'; +import { Edge } from '@shared/models/edge.models'; +import { mergeMap } from 'rxjs/operators'; +import { PageData } from '@shared/models/page/page-data'; +import { HomeDialogsService } from '@home/dialogs/home-dialogs.service'; + +@Injectable() +export class RuleChainsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + private edge: Edge; + + constructor(private ruleChainService: RuleChainService, + private dialogService: DialogService, + private dialog: MatDialog, + private importExport: ImportExportService, + private itembuffer: ItemBufferService, + private edgeService: EdgeService, + private homeDialogs: HomeDialogsService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router) { + this.config.entityType = EntityType.RULE_CHAIN; + this.config.entityComponent = RuleChainComponent; + this.config.entityTabsComponent = RuleChainTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.RULE_CHAIN); + this.config.entityResources = entityTypeResources.get(EntityType.RULE_CHAIN); + + this.config.rowPointer = true; + + this.config.deleteEntityTitle = ruleChain => this.translate.instant('rulechain.delete-rulechain-title', + {ruleChainName: ruleChain.name}); + this.config.deleteEntityContent = () => this.translate.instant('rulechain.delete-rulechain-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('rulechain.delete-rulechains-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('rulechain.delete-rulechains-text'); + this.config.loadEntity = id => this.ruleChainService.getRuleChain(id.id); + this.config.saveEntity = ruleChain => this.saveRuleChain(ruleChain); + this.config.deleteEntity = id => this.ruleChainService.deleteRuleChain(id.id); + this.config.onEntityAction = action => this.onRuleChainAction(action); + this.config.handleRowClick = ($event, ruleChain) => { + if (this.config.isDetailsOpen()) { + this.config.toggleEntityDetails($event, ruleChain); + } else { + this.openRuleChain($event, ruleChain); + } + return true; + }; + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + const edgeId = route.params?.edgeId; + const ruleChainScope = route.data?.ruleChainsType ? route.data?.ruleChainsType : 'tenant'; + this.config.componentsData = { + ruleChainScope, + edgeId + }; + this.config.columns = this.configureEntityTableColumns(ruleChainScope); + this.config.entitiesFetchFunction = this.configureEntityFunctions(ruleChainScope, edgeId); + this.config.groupActionDescriptors = this.configureGroupActions(ruleChainScope); + this.config.addActionDescriptors = this.configureAddActions(ruleChainScope); + this.config.cellActionDescriptors = this.configureCellActions(ruleChainScope); + if (ruleChainScope === 'tenant' || ruleChainScope === 'edges') { + this.config.entitySelectionEnabled = ruleChain => ruleChain && !ruleChain.root; + this.config.deleteEnabled = (ruleChain) => ruleChain && !ruleChain.root; + this.config.entitiesDeleteEnabled = true; + this.config.tableTitle = this.configureTableTitle(ruleChainScope, null); + } else if (ruleChainScope === 'edge') { + this.config.entitySelectionEnabled = ruleChain => this.config.componentsData.edge.rootRuleChainId.id !== ruleChain.id.id; + this.edgeService.getEdge(edgeId).subscribe(edge => { + this.config.componentsData.edge = edge; + this.config.tableTitle = this.configureTableTitle(ruleChainScope, edge); + }); + this.config.entitiesDeleteEnabled = false; + } + return this.config; + } + + configureEntityTableColumns(ruleChainScope: string): Array> { + const columns: Array> = []; + columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'rulechain.name', '100%') + ); + if (ruleChainScope === 'tenant' || ruleChainScope === 'edge') { + columns.push( + new EntityTableColumn('root', 'rulechain.root', '60px', + entity => { + if (ruleChainScope === 'edge') { + return checkBoxCell((this.config.componentsData.edge.rootRuleChainId.id === entity.id.id)); + } else { + return checkBoxCell(entity.root); + } + }) + ); + } else if (ruleChainScope === 'edges') { + columns.push( + new EntityTableColumn('root', 'rulechain.edge-template-root', '60px', + entity => { + return checkBoxCell(entity.root); + }), + new EntityTableColumn('assignToEdge', 'rulechain.assign-to-edge', '60px', + entity => { + return checkBoxCell(this.isAutoAssignToEdgeRuleChain(entity)); + }) + ); + } + return columns; + } + + configureAddActions(ruleChainScope: string): Array { + const actions: Array = []; + if (ruleChainScope === 'tenant' || ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.create-new-rulechain'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('rulechain.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importRuleChain($event) + } + ); + } + if (ruleChainScope === 'edge') { + actions.push( + { + name: this.translate.instant('rulechain.assign-new-rulechain'), + icon: 'add', + isEnabled: () => true, + onAction: ($event) => this.addRuleChainsToEdge($event) + } + ); + } + return actions; + } + + configureEntityFunctions(ruleChainScope: string, edgeId: string): (pageLink) => Observable> { + if (ruleChainScope === 'tenant') { + return pageLink => this.fetchRuleChains(pageLink); + } else if (ruleChainScope === 'edges') { + return pageLink => this.fetchEdgeRuleChains(pageLink); + } else if (ruleChainScope === 'edge') { + return pageLink => this.ruleChainService.getEdgeRuleChains(edgeId, pageLink); + } + } + + configureTableTitle(ruleChainScope: string, edge: Edge): string { + if (ruleChainScope === 'tenant') { + return this.translate.instant('rulechain.rulechains'); + } else if (ruleChainScope === 'edges') { + return this.translate.instant('edge.rulechain-templates'); + } else if (ruleChainScope === 'edge') { + return this.config.tableTitle = edge.name + ': ' + this.translate.instant('edge.rulechains'); + } + } + + configureGroupActions(ruleChainScope: string): Array> { + const actions: Array> = []; + if (ruleChainScope === 'edge') { + actions.push( + { + name: this.translate.instant('rulechain.unassign-rulechains'), + icon: 'assignment_return', + isEnabled: true, + onAction: ($event, entities) => this.unassignRuleChainsFromEdge($event, entities) + } + ); + } + return actions; + } + + configureCellActions(ruleChainScope: string): Array> { + const actions: Array> = []; + actions.push( + { + name: this.translate.instant('rulechain.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportRuleChain($event, entity) + } + ); + if (ruleChainScope === 'tenant') { + actions.push( + { + name: this.translate.instant('rulechain.set-root'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setRootRuleChain($event, entity) + } + ); + } + if (ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.set-edge-template-root-rulechain'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setEdgeTemplateRootRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.set-auto-assign-to-edge'), + icon: 'bookmark_outline', + isEnabled: (entity) => this.isNotAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.setAutoAssignToEdgeRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.unset-auto-assign-to-edge'), + icon: 'bookmark', + isEnabled: (entity) => this.isAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.unsetAutoAssignToEdgeRuleChain($event, entity) + } + ); + } + if (ruleChainScope === 'edge') { + actions.push( + { + name: this.translate.instant('rulechain.set-root'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setRootRuleChain($event, entity) + }, + { + name: this.translate.instant('edge.unassign-from-edge'), + icon: 'assignment_return', + isEnabled: (entity) => entity.id.id !== this.config.componentsData.edge.rootRuleChainId.id, + onAction: ($event, entity) => this.unassignFromEdge($event, entity) + } + ); + } + actions.push( + { + name: this.translate.instant('rulechain.rulechain-details'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.config.toggleEntityDetails($event, entity) + } + ); + return actions; + } + + importRuleChain($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const expectedRuleChainType = this.config.componentsData.ruleChainScope === 'tenant' ? RuleChainType.CORE : RuleChainType.EDGE; + this.importExport.importRuleChain(expectedRuleChainType).subscribe((ruleChainImport) => { + if (ruleChainImport) { + this.itembuffer.storeRuleChainImport(ruleChainImport); + if (this.config.componentsData.ruleChainScope === 'edges') { + this.router.navigateByUrl(`edgeManagement/ruleChains/ruleChain/import`); + } else { + this.router.navigateByUrl(`ruleChains/ruleChain/import`); + } + } + }); + } + + openRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + if (this.config.componentsData.ruleChainScope === 'edges') { + this.router.navigateByUrl(`edgeManagement/ruleChains/${ruleChain.id.id}`); + } else if (this.config.componentsData.ruleChainScope === 'edge') { + this.router.navigateByUrl(`edgeInstances/${this.config.componentsData.edgeId}/ruleChains/${ruleChain.id.id}`); + } else { + this.router.navigateByUrl(`ruleChains/${ruleChain.id.id}`); + } + } + + saveRuleChain(ruleChain: RuleChain) { + if (isUndefined(ruleChain.type)) { + if (this.config.componentsData.ruleChainScope === 'tenant') { + ruleChain.type = RuleChainType.CORE; + } else if (this.config.componentsData.ruleChainScope === 'edges') { + ruleChain.type = RuleChainType.EDGE; + } else { + // safe fallback to default core type + ruleChain.type = RuleChainType.CORE; + } + } + return this.ruleChainService.saveRuleChain(ruleChain); + } + + exportRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportRuleChain(ruleChain.id.id); + } + + setRootRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.set-root-rulechain-title', {ruleChainName: ruleChain.name}), + this.translate.instant('rulechain.set-root-rulechain-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + if (this.config.componentsData.ruleChainScope === 'edge') { + this.ruleChainService.setEdgeRootRuleChain(this.config.componentsData.edgeId, ruleChain.id.id).subscribe( + (edge) => { + this.config.componentsData.edge = edge; + this.config.updateData(); + } + ); + } else { + this.ruleChainService.setRootRuleChain(ruleChain.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + } + ); + } + + onRuleChainAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openRuleChain(action.event, action.entity); + return true; + case 'export': + this.exportRuleChain(action.event, action.entity); + return true; + case 'setRoot': + this.setRootRuleChain(action.event, action.entity); + return true; + case 'setEdgeTemplateRoot': + this.setEdgeTemplateRootRuleChain(action.event, action.entity); + return true; + case 'unassignFromEdge': + this.unassignFromEdge(action.event, action.entity); + return true; + case 'setAutoAssignToEdge': + this.setAutoAssignToEdgeRuleChain(action.event, action.entity); + return true; + case 'unsetAutoAssignToEdge': + this.unsetAutoAssignToEdgeRuleChain(action.event, action.entity); + return true; + } + return false; + } + + setEdgeTemplateRootRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.set-edge-template-root-rulechain-title', {ruleChainName: ruleChain.name}), + this.translate.instant('rulechain.set-edge-template-root-rulechain-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.ruleChainService.setEdgeTemplateRootRuleChain(ruleChain.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + private checkMissingToRelatedRuleChains() { + this.edgeService.findMissingToRelatedRuleChains(this.config.componentsData.edgeId).subscribe( + (missingRuleChains) => { + if (missingRuleChains && Object.keys(missingRuleChains).length > 0) { + const formattedMissingRuleChains: Array = new Array(); + for (const missingRuleChain of Object.keys(missingRuleChains)) { + const arrayOfMissingRuleChains = missingRuleChains[missingRuleChain]; + const tmp = '- \'' + missingRuleChain + '\': \'' + arrayOfMissingRuleChains.join('\', ') + '\''; + formattedMissingRuleChains.push(tmp); + } + const message = this.translate.instant('edge.missing-related-rule-chains-text', + {missingRuleChains: formattedMissingRuleChains.join('
')}); + this.dialogService.alert(this.translate.instant('edge.missing-related-rule-chains-title'), + message, this.translate.instant('action.close'), true).subscribe( + () => { + this.config.updateData(); + } + ); + } else { + this.config.updateData(); + } + } + ); + } + + addRuleChainsToEdge($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddEntitiesToEdgeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + edgeId: this.config.componentsData.edgeId, + entityType: EntityType.RULE_CHAIN + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.checkMissingToRelatedRuleChains(); + } + } + ); + } + + unassignFromEdge($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.unassign-rulechain-title', {ruleChainName: ruleChain.name}), + this.translate.instant('rulechain.unassign-rulechain-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.ruleChainService.unassignRuleChainFromEdge(this.config.componentsData.edgeId, ruleChain.id.id).subscribe( + () => { + this.checkMissingToRelatedRuleChains(); + } + ); + } + } + ); + } + + unassignRuleChainsFromEdge($event: Event, ruleChains: Array) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.unassign-rulechains-from-edge-title', {count: ruleChains.length}), + this.translate.instant('rulechain.unassign-rulechains-from-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + const tasks: Observable[] = []; + ruleChains.forEach( + (ruleChain) => { + tasks.push(this.ruleChainService.unassignRuleChainFromEdge(this.config.componentsData.edgeId, ruleChain.id.id)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.checkMissingToRelatedRuleChains(); + } + ); + } + } + ); + } + + setAutoAssignToEdgeRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.set-auto-assign-to-edge-title', {ruleChainName: ruleChain.name}), + this.translate.instant('rulechain.set-auto-assign-to-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.ruleChainService.setAutoAssignToEdgeRuleChain(ruleChain.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + unsetAutoAssignToEdgeRuleChain($event: Event, ruleChain: RuleChain) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('rulechain.unset-auto-assign-to-edge-title', {ruleChainName: ruleChain.name}), + this.translate.instant('rulechain.unset-auto-assign-to-edge-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.ruleChainService.unsetAutoAssignToEdgeRuleChain(ruleChain.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + isNonRootRuleChain(ruleChain: RuleChain) { + if (this.config.componentsData.ruleChainScope === 'edge') { + return this.config.componentsData.edge.rootRuleChainId && + this.config.componentsData.edge.rootRuleChainId.id !== ruleChain.id.id; + } + return !ruleChain.root; + } + + isAutoAssignToEdgeRuleChain(ruleChain) { + return !ruleChain.root && this.config.componentsData.autoAssignToEdgeRuleChainIds.includes(ruleChain.id.id); + } + + isNotAutoAssignToEdgeRuleChain(ruleChain) { + return !ruleChain.root && !this.config.componentsData.autoAssignToEdgeRuleChainIds.includes(ruleChain.id.id); + } + + fetchRuleChains(pageLink: PageLink) { + return this.ruleChainService.getRuleChains(pageLink, RuleChainType.CORE); + } + + fetchEdgeRuleChains(pageLink: PageLink) { + return this.ruleChainService.getAutoAssignToEdgeRuleChains().pipe( + mergeMap((ruleChains) => { + this.config.componentsData.autoAssignToEdgeRuleChainIds = []; + ruleChains.map(ruleChain => this.config.componentsData.autoAssignToEdgeRuleChainIds.push(ruleChain.id.id)); + return this.ruleChainService.getRuleChains(pageLink, RuleChainType.EDGE); + }) + ); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html new file mode 100644 index 0000000..80ee155 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html @@ -0,0 +1,67 @@ + +
+
+
+ {{node.icon}} + +
+ {{ node.component.name }} + {{ node.name }} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ × +
+
+
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.scss new file mode 100644 index 0000000..38ef76f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.scss @@ -0,0 +1,154 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './rule-node-colors'; + +:host { + + .fc-node-overlay { + position: absolute; + pointer-events: none; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: #000; + border-radius: 5px; + opacity: 0; + } + + :host-context(.fc-hover) .fc-node-overlay { + opacity: 0.25; + transition: opacity .2s; + } + + :host-context(.fc-selected) .fc-node-overlay { + opacity: 0.25; + } + + :host-context(.fc-edit) { + .fc-nodeedit, + .fc-nodedelete { + box-sizing: content-box; + border: solid 2px #fff; + background: #f83e05; + outline: none; + } + } + + .tb-rule-node { + box-sizing: content-box; + display: flex; + flex-direction: row; + min-width: 150px; + max-width: 150px; + height: 32px; + min-height: 32px; + max-height: 32px; + padding: 5px 10px; + font-size: 12px; + line-height: 16px; + color: #333; + pointer-events: none; + background-color: #f15b26; + border: solid 1px #777; + border-radius: 5px; + + @include rule-node-colors(); + + &.tb-rule-node-highlighted:not(.tb-rule-node-invalid) { + box-shadow: 0 0 10px 6px #51cbee; + + .tb-node-title { + font-weight: 700; + text-decoration: underline; + } + } + + &.tb-rule-node-invalid { + box-shadow: 0 0 10px 6px #ff5c50; + } + + &.tb-input-type { + user-select: none; + background-color: #a3eaa9; + } + + mat-icon, img { + margin: auto; + width: 20px; + min-width: 20px; + height: 20px; + min-height: 20px; + padding-right: 4px; + font-size: 20px; + } + + .tb-node-title { + font-weight: 500; + } + + .tb-node-type, + .tb-node-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .fc-leftConnectors, + .fc-rightConnectors { + position: absolute; + top: 0; + + z-index: 0; + + display: flex; + flex-direction: column; + height: 100%; + + .fc-magnet { + align-items: center; + } + } + + .fc-leftConnectors { + left: -20px; + } + + .fc-rightConnectors { + right: -20px; + } + + .fc-magnet { + display: flex; + flex-grow: 1; + justify-content: center; + height: 60px; + } + + .fc-connector { + width: 14px; + height: 14px; + margin: 10px; + pointer-events: all; + background-color: #ccc; + border: 1px solid #333; + border-radius: 5px; + } + + .fc-connector.fc-hover { + background-color: #000; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts new file mode 100644 index 0000000..8b3ba9a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts @@ -0,0 +1,97 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { Component, OnInit } from '@angular/core'; +import { FcNodeComponent } from 'ngx-flowchart'; +import { FcRuleNode, RuleNodeType } from '@shared/models/rule-node.models'; +import { Router } from '@angular/router'; +import { RuleChainType } from '@app/shared/models/rule-chain.models'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: 'rule-node', + templateUrl: './rulenode.component.html', + styleUrls: ['./rulenode.component.scss'] +}) +export class RuleNodeComponent extends FcNodeComponent implements OnInit { + + iconUrl: SafeResourceUrl; + RuleNodeType = RuleNodeType; + + constructor(private sanitizer: DomSanitizer, + private translate: TranslateService, + private router: Router) { + super(); + } + + ngOnInit(): void { + super.ngOnInit(); + if (this.node.iconUrl) { + this.iconUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.node.iconUrl); + } + } + + openRuleChain($event: Event, node: FcRuleNode) { + if ($event) { + $event.stopPropagation(); + } + if (node.configuration?.ruleChainId) { + if (node.ruleChainType === RuleChainType.EDGE) { + this.router.navigateByUrl(`/edgeManagement/ruleChains/${node.configuration?.ruleChainId}`); + } else { + this.router.navigateByUrl(`/ruleChains/${node.configuration?.ruleChainId}`); + } + + } + } + + displayOpenRuleChainTooltip($event: MouseEvent, node: FcRuleNode) { + if ($event) { + $event.stopPropagation(); + } + this.userNodeCallbacks.mouseLeave($event, node); + const tooltipContent = '
' + + '
' + + '
' + this.translate.instant('rulechain.open-rulechain') + '
'; + const element = $($event.target); + element.tooltipster( + { + theme: 'tooltipster-shadow', + delay: 100, + trigger: 'custom', + triggerOpen: { + click: false, + tap: false + }, + triggerClose: { + click: true, + tap: true, + scroll: true, + mouseleave: true + }, + side: 'top', + distance: 12, + trackOrigin: true + } + ); + const tooltip = element.tooltipster('instance'); + const contentElement = $(tooltipContent); + tooltip.content(contentElement); + tooltip.open(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss new file mode 100644 index 0000000..df9da85 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; + +:host { + .mat-toolbar > h2 { + font-weight: 400; + letter-spacing: 0.25px; + } + + form { + display: block; + } + + .mat-body-1 { + margin-bottom: 0; + letter-spacing: 0.25px; + + &:not(:first-of-type) { + margin: 0 0 8px; + } + + &.description { + margin: 0; + color: rgba(0, 0, 0, 0.54); + + &:last-of-type { + margin-bottom: 24px; + } + } + } + + .input-container { + max-width: 290px; + } + + .code-container { + max-width: 170px; + + &.full-width-xs { + @media #{$mat-xs} { + max-width: 100%; + width: 100%; + } + } + } + + .result-title { + font: 500 18px / 24px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.1px; + margin: 8px 0; + text-align: center; + } + + .result-description { + text-align: center; + margin: 0 0 16px; + letter-spacing: 0.25px; + max-width: 500px; + } + + .step-description { + max-width: 450px; + + &.input { + margin: 12px 0 0; + } + } + + .qr-code-description { + text-align: center; + max-width: 180px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + margin-bottom: 0; + } + + .backup-code { + max-width: 500px; + + .container { + max-width: 500px; + margin: 40px 0 8px; + + .code { + letter-spacing: 0.25px; + padding: 0 24px; + margin-bottom: 16px; + font-family: Roboto Mono, "Helvetica Neue", monospace; + + &.even { + text-align: right; + } + } + } + + .action-buttons { + margin-bottom: 40px; + } + } + + & ::ng-deep { + .mat-horizontal-stepper-header { + pointer-events: none !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts new file mode 100644 index 0000000..d110843 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Type } from '@angular/core'; +import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { TotpAuthDialogComponent } from './totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from './sms-auth-dialog.component'; +import { EmailAuthDialogComponent } from './email-auth-dialog.component'; +import { + BackupCodeAuthDialogComponent +} from '@home/pages/security/authentication-dialog/backup-code-auth-dialog.component'; + +export const authenticationDialogMap = new Map>( + [ + [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], + [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], + [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent], + [TwoFactorAuthProviderType.BACKUP_CODE, BackupCodeAuthDialogComponent] + ] +); diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html new file mode 100644 index 0000000..8eb0124 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html @@ -0,0 +1,51 @@ + + +

security.2fa.dialog.get-backup-code-title

+ + +
+ + +
+
+

security.2fa.dialog.backup-code-description

+
+
+ {{ code }} +
+
+
+ + +
+

security.2fa.dialog.backup-code-warn

+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts new file mode 100644 index 0000000..bef3bec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder } from '@angular/forms'; +import { + AccountTwoFaSettings, + BackupCodeTwoFactorAuthAccountConfig, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { mergeMap, tap } from 'rxjs/operators'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { deepClone } from '@core/utils'; + +import printTemplate from '!raw-loader!./backup-code-print-template.raw'; + +@Component({ + selector: 'tb-backup-code-auth-dialog', + templateUrl: './backup-code-auth-dialog.component.html', + styleUrls: ['./authentication-dialog.component.scss'] +}) +export class BackupCodeAuthDialogComponent extends DialogComponent { + + private config: AccountTwoFaSettings; + backupCode: BackupCodeTwoFactorAuthAccountConfig; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + private importExportService: ImportExportService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe( + tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data), + mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true})) + ).subscribe((config) => { + this.config = config; + }); + } + + closeDialog() { + this.dialogRef.close(this.config); + } + + downloadFile() { + this.importExportService.exportText(this.backupCode.codes, 'backup-codes'); + } + + printCode() { + const codeTemplate = deepClone(this.backupCode.codes) + .map(code => `
${code}
`).join(''); + const printPage = printTemplate.replace('${codesBlock}', codeTemplate); + const newWindow = window.open('', 'Print backup code'); + + newWindow.document.open(); + newWindow.document.write(printPage); + + setTimeout(() => { + newWindow.print(); + + newWindow.document.close(); + + setTimeout(() => { + newWindow.close(); + }, 10); + }, 0); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw new file mode 100644 index 0000000..1ab7efc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-print-template.raw @@ -0,0 +1,50 @@ + + + + + Backup code + + + + + +
+
+

+ Backup codes +

+
+ ${codesBlock} +
+
+
+ + diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html new file mode 100644 index 0000000..653a3af --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html @@ -0,0 +1,111 @@ + + +

security.2fa.dialog.enable-email-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.email-step-label' | translate }} +
+

security.2fa.dialog.email-step-description

+
+ + + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: emailConfigForm.get('email').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + +
+ + +
+
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-email

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts new file mode 100644 index 0000000..88d85ce --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts @@ -0,0 +1,113 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + TwoFactorAuthAccountConfig, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { MatStepper } from '@angular/material/stepper'; + +export interface EmailAuthDialogData { + email: string; +} + +@Component({ + selector: 'tb-email-auth-dialog', + templateUrl: './email-auth-dialog.component.html', + styleUrls: ['./authentication-dialog.component.scss'] +}) +export class EmailAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TwoFactorAuthAccountConfig; + private config: AccountTwoFaSettings; + + emailConfigForm: FormGroup; + emailVerificationForm: FormGroup; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + @Inject(MAT_DIALOG_DATA) public data: EmailAuthDialogData, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.emailConfigForm = this.fb.group({ + email: [this.data.email, [Validators.required, Validators.email]] + }); + + this.emailVerificationForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + nextStep() { + switch (this.stepper.selectedIndex) { + case 0: + if (this.emailConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: true, + email: this.emailConfigForm.get('email').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.emailConfigForm); + } + break; + case 1: + if (this.emailVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.emailVerificationForm.get('verificationCode').value).subscribe((config) => { + this.config = config; + this.stepper.next(); + }); + } else { + this.showFormErrors(this.emailVerificationForm); + } + break; + } + } + + closeDialog() { + return this.dialogRef.close(this.config); + } + + private showFormErrors(form: FormGroup) { + Object.keys(form.controls).forEach(field => { + const control = form.get(field); + control.markAsTouched({onlySelf: true}); + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html new file mode 100644 index 0000000..e6529dc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html @@ -0,0 +1,104 @@ + + +

security.2fa.dialog.enable-sms-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.sms-step-label' | translate }} +
+

security.2fa.dialog.sms-step-description

+
+ + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: smsConfigForm.get('phone').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + +
+ + +
+
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-sms

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts new file mode 100644 index 0000000..136b196 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts @@ -0,0 +1,111 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + TwoFactorAuthAccountConfig, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { phoneNumberPattern } from '@shared/models/settings.models'; +import { MatStepper } from '@angular/material/stepper'; + +@Component({ + selector: 'tb-sms-auth-dialog', + templateUrl: './sms-auth-dialog.component.html', + styleUrls: ['./authentication-dialog.component.scss'] +}) +export class SMSAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TwoFactorAuthAccountConfig; + private config: AccountTwoFaSettings; + + phoneNumberPattern = phoneNumberPattern; + + smsConfigForm: FormGroup; + smsVerificationForm: FormGroup; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.smsConfigForm = this.fb.group({ + phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]] + }); + + this.smsVerificationForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + nextStep() { + switch (this.stepper.selectedIndex) { + case 0: + if (this.smsConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: true, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.smsConfigForm); + } + break; + case 1: + if (this.smsVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.smsVerificationForm.get('verificationCode').value).subscribe((config) => { + this.config = config; + this.stepper.next(); + }); + } else { + this.showFormErrors(this.smsVerificationForm); + } + break; + } + } + + closeDialog() { + return this.dialogRef.close(this.config); + } + + private showFormErrors(form: FormGroup) { + Object.keys(form.controls).forEach(field => { + const control = form.get(field); + control.markAsTouched({onlySelf: true}); + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html new file mode 100644 index 0000000..057b3af --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html @@ -0,0 +1,94 @@ + + +

security.2fa.dialog.enable-totp-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.totp-step-label' | translate }} +
+

security.2fa.dialog.totp-step-description-open

+

security.2fa.dialog.totp-step-description-install

+
+ +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

security.2fa.dialog.scan-qr-code

+ +

security.2fa.dialog.enter-verification-code

+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + +
+
+ +
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-totp

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts new file mode 100644 index 0000000..22ea542 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + TotpTwoFactorAuthAccountConfig, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { MatStepper } from '@angular/material/stepper'; + +@Component({ + selector: 'tb-totp-auth-dialog', + templateUrl: './totp-auth-dialog.component.html', + styleUrls: ['./authentication-dialog.component.scss'] +}) +export class TotpAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TotpTwoFactorAuthAccountConfig; + private config: AccountTwoFaSettings; + + totpConfigForm: FormGroup; + totpAuthURL: string; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => { + this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; + this.totpAuthURL = this.authAccountConfig.authUrl; + this.authAccountConfig.useByDefault = true; + import('qrcode').then((QRCode) => { + QRCode.toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); + this.canvasRef.nativeElement.style.width = 'auto'; + this.canvasRef.nativeElement.style.height = 'auto'; + }); + }); + this.totpConfigForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + onSaveConfig() { + if (this.totpConfigForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.totpConfigForm.get('verificationCode').value).subscribe((config) => { + this.config = config; + this.stepper.next(); + }); + } else { + Object.keys(this.totpConfigForm.controls).forEach(field => { + const control = this.totpConfigForm.get(field); + control.markAsTouched({onlySelf: true}); + }); + } + } + + closeDialog() { + return this.dialogRef.close(this.config); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts new file mode 100644 index 0000000..d2820e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts @@ -0,0 +1,84 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; + +import { SecurityComponent } from './security.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import { User } from '@shared/models/user.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserService } from '@core/http/user.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Observable } from 'rxjs'; +import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; + +@Injectable() +export class UserProfileResolver implements Resolve { + + constructor(private store: Store, + private userService: UserService) { + } + + resolve(): Observable { + const userId = getCurrentAuthUser(this.store).userId; + return this.userService.getUser(userId); + } +} + +@Injectable() +export class UserTwoFAProvidersResolver implements Resolve> { + + constructor(private twoFactorAuthService: TwoFactorAuthenticationService) { + } + + resolve(): Observable> { + return this.twoFactorAuthService.getAvailableTwoFaProviders(); + } +} + +const routes: Routes = [ + { + path: 'security', + component: SecurityComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'security.security', + breadcrumb: { + label: 'security.security', + icon: 'lock' + } + }, + resolve: { + user: UserProfileResolver, + providers: UserTwoFAProvidersResolver + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + UserProfileResolver, + UserTwoFAProvidersResolver + ] +}) +export class SecurityRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html new file mode 100644 index 0000000..1c29521 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -0,0 +1,173 @@ + +
+ + + profile.jwt-token + + +
+
{{ 'profile.token-valid-till' | translate }} {{ jwtTokenExpiration | date: 'yyyy-MM-dd HH:mm:ss' }}
+ +
+
+
+ + +
+
+
+

profile.change-password

+ + profile.current-password + + + + {{ 'security.password-requirement.incorrect-password-try-again' | translate }} + + + + login.new-password + + + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + + {{ changePassword.get('newPassword').getError('alreadyUsed') }} + + + {{ 'security.password-requirement.password-should-difference' | translate }} + + + {{ 'security.password-requirement.password-should-not-contain-spaces' | translate }} + + +
+ +
+ + login.new-password-again + + + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + +
+ +
+ +
+
+ +
+

security.password-requirement.password-requirements

+

security.password-requirement.at-least

+

+ + {{ 'security.password-requirement.uppercase-letter' | translate : {count: passwordPolicy.minimumUppercaseLetters} }} +

+

+ + {{ 'security.password-requirement.lowercase-letter' | translate : {count: passwordPolicy.minimumLowercaseLetters} }} +

+

+ + {{ 'security.password-requirement.digit' | translate : {count: passwordPolicy.minimumDigits} }} +

+

+ + {{ 'security.password-requirement.special-character' | translate : {count: passwordPolicy.minimumSpecialCharacters} }} +

+

+ + {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.minimumLength} }} +

+
+
+
+ + +
+
+
+
+ + + admin.2fa.2fa + + +
security.2fa.2fa-description
+
+ +

security.2fa.authenticate-with

+
+ +
+

{{ providersData.get(provider).name | translate }}

+
+
+ {{ providersData.get(provider).description | translate }} +
+ +
+ {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} +
+
+ + +
+ + security.2fa.main-2fa-method + + +
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.scss b/ui-ngx/src/app/modules/home/pages/security/security.component.scss new file mode 100644 index 0000000..948b509 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + .profile-container { + padding: 8px; + } + + mat-card.profile-card { + padding: 24px; + @media #{$mat-gt-sm} { + width: 80%; + } + @media #{$mat-gt-md} { + width: 55%; + } + @media #{$mat-gt-xl} { + width: 45%; + } + + .card-title { + font: 500 18px / 24px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.15px; + margin-top: 0; + } + + .mat-h4 { + font-weight: 500; + font-size: 14px; + letter-spacing: 0.25px; + margin: 0 0 4px; + } + + .change-password { + margin: 0; + + .mat-divider.mat-divider-vertical { + margin-bottom: 25px; + } + + .mat-form-field { + margin-bottom: 4px; + } + + .password-requirements > p { + margin: 0 0 8px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + + .mat-icon[data-mat-icon-name="check"] { + color: #24A148; + } + } + + .auth-title { + font-weight: 500; + margin: 0; + } + + .token-text { + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.25px; + padding: 8px 0; + + > .date { + opacity: .7; + } + } + } + + .description { + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.25px; + margin-right: 8px; + } + + .mat-divider-horizontal { + left: 16px; + right: 16px; + width: auto; + } + + .provider { + padding: 24px 0; + + .provider-title { + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.25px; + margin: 0 0 8px; + } + + .description { + max-width: 85%; + } + + .checkbox-label { + font-size: 14px; + letter-spacing: 0.25px; + opacity: 0.87; + } + + .mat-checkbox { + margin-top: 8px; + } + + .mat-stroked-button { + margin-top: 8px; + } + } +} +:host ::ng-deep { + .mat-form-field-appearance-fill .mat-form-field-underline::before { + background-color: transparent; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts new file mode 100644 index 0000000..016069a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -0,0 +1,382 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { User } from '@shared/models/user.model'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + AbstractControl, + FormBuilder, + FormGroup, FormGroupDirective, + NgForm, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { ActivatedRoute } from '@angular/router'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DatePipe } from '@angular/common'; +import { ClipboardService } from 'ngx-clipboard'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettingProviders, + AccountTwoFaSettings, + BackupCodeTwoFactorAuthAccountConfig, + EmailTwoFactorAuthAccountConfig, + SmsTwoFactorAuthAccountConfig, + twoFactorAuthProvidersData, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; +import { takeUntil, tap } from 'rxjs/operators'; +import { Observable, of, Subject } from 'rxjs'; +import { isDefinedAndNotNull, isEqual } from '@core/utils'; +import { AuthService } from '@core/auth/auth.service'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; + +@Component({ + selector: 'tb-security', + templateUrl: './security.component.html', + styleUrls: ['./security.component.scss'] +}) +export class SecurityComponent extends PageComponent implements OnInit, OnDestroy { + + private readonly destroy$ = new Subject(); + private accountConfig: AccountTwoFaSettingProviders; + + twoFactorAuth: FormGroup; + changePassword: FormGroup; + + user: User; + passwordPolicy: UserPasswordPolicy; + + allowTwoFactorProviders: TwoFactorAuthProviderType[] = []; + providersData = twoFactorAuthProvidersData; + twoFactorAuthProviderType = TwoFactorAuthProviderType; + useByDefault: TwoFactorAuthProviderType = null; + activeSingleProvider = true; + + get jwtToken(): string { + return `Bearer ${localStorage.getItem('jwt_token')}`; + } + + get jwtTokenExpiration(): string { + return localStorage.getItem('jwt_token_expiration'); + } + + get expirationJwtData(): string { + const expirationData = this.datePipe.transform(this.jwtTokenExpiration, 'yyyy-MM-dd HH:mm:ss'); + return this.translate.instant('profile.valid-till', { expirationData }); + } + + constructor(protected store: Store, + private route: ActivatedRoute, + private translate: TranslateService, + private twoFaService: TwoFactorAuthenticationService, + public dialog: MatDialog, + public dialogService: DialogService, + public fb: FormBuilder, + private datePipe: DatePipe, + private authService: AuthService, + private clipboardService: ClipboardService) { + super(store); + } + + ngOnInit() { + this.buildTwoFactorForm(); + this.user = this.route.snapshot.data.user; + this.twoFactorLoad(this.route.snapshot.data.providers); + this.buildChangePasswordForm(); + this.loadPasswordPolicy(); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private buildTwoFactorForm() { + this.twoFactorAuth = this.fb.group({ + TOTP: [false], + SMS: [false], + EMAIL: [false], + BACKUP_CODE: [{value: false, disabled: true}] + }); + this.twoFactorAuth.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: {TwoFactorAuthProviderType: boolean}) => { + const formActiveValue = Object.keys(value).filter(item => value[item] && item !== TwoFactorAuthProviderType.BACKUP_CODE); + this.activeSingleProvider = formActiveValue.length < 2; + if (formActiveValue.length) { + this.twoFactorAuth.get('BACKUP_CODE').enable({emitEvent: false}); + } else { + this.twoFactorAuth.get('BACKUP_CODE').disable({emitEvent: false}); + } + }); + } + + private twoFactorLoad(providers: TwoFactorAuthProviderType[]) { + if (providers.length) { + this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data)); + Object.values(TwoFactorAuthProviderType).forEach(type => { + if (providers.includes(type)) { + this.allowTwoFactorProviders.push(type); + } + }); + } + } + + private processTwoFactorAuthConfig(setting: AccountTwoFaSettings) { + this.accountConfig = setting?.configs || {}; + Object.values(TwoFactorAuthProviderType).forEach(provider => { + if (this.accountConfig[provider]) { + this.twoFactorAuth.get(provider).setValue(true); + if (this.accountConfig[provider].useByDefault) { + this.useByDefault = provider; + } + } else { + this.twoFactorAuth.get(provider).setValue(false); + } + }); + } + + private buildChangePasswordForm() { + this.changePassword = this.fb.group({ + currentPassword: [''], + newPassword: ['', Validators.required], + newPassword2: ['', this.samePasswordValidation(false, 'newPassword')] + }); + } + + private loadPasswordPolicy() { + this.authService.getUserPasswordPolicy().subscribe(policy => { + this.passwordPolicy = policy; + this.changePassword.get('newPassword').setValidators([ + this.passwordStrengthValidator(), + this.samePasswordValidation(true, 'currentPassword'), + Validators.required + ]); + this.changePassword.get('newPassword').updateValueAndValidity({emitEvent: false}); + }); + } + + private passwordStrengthValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + const errors: any = {}; + + if (this.passwordPolicy.minimumUppercaseLetters > 0 && + !new RegExp(`(?:.*?[A-Z]){${this.passwordPolicy.minimumUppercaseLetters}}`).test(value)) { + errors.notUpperCase = true; + } + + if (this.passwordPolicy.minimumLowercaseLetters > 0 && + !new RegExp(`(?:.*?[a-z]){${this.passwordPolicy.minimumLowercaseLetters}}`).test(value)) { + errors.notLowerCase = true; + } + + if (this.passwordPolicy.minimumDigits > 0 + && !new RegExp(`(?:.*?\\d){${this.passwordPolicy.minimumDigits}}`).test(value)) { + errors.notNumeric = true; + } + + if (this.passwordPolicy.minimumSpecialCharacters > 0 && + !new RegExp(`(?:.*?[\\W_]){${this.passwordPolicy.minimumSpecialCharacters}}`).test(value)) { + errors.notSpecial = true; + } + + if (!this.passwordPolicy.allowWhitespaces && /\s/.test(value)) { + errors.hasWhitespaces = true; + } + + if (this.passwordPolicy.minimumLength > 0 && value.length < this.passwordPolicy.minimumLength) { + errors.minLength = true; + } + + return isEqual(errors, {}) ? null : errors; + }; + } + + private samePasswordValidation(isSame: boolean, key: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value: string = control.value; + const keyValue = control.parent?.value[key]; + + if (isSame) { + return value === keyValue ? {samePassword: true} : null; + } + return value !== keyValue ? {differencePassword: true} : null; + }; + } + + trackByProvider(i: number, provider: TwoFactorAuthProviderType) { + return provider; + } + + copyToken() { + if (+this.jwtTokenExpiration < Date.now()) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('profile.tokenCopiedWarnMessage'), + type: 'warn', + duration: 1500, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } else { + this.clipboardService.copyFromContent(this.jwtToken); + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('profile.tokenCopiedSuccessMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + } + + confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) { + event.stopPropagation(); + event.preventDefault(); + if (this.twoFactorAuth.get(provider).disabled) { + return; + } + if (this.twoFactorAuth.get(provider).value) { + const providerName = this.translate.instant(`security.2fa.provider.${provider.toLowerCase()}`); + this.dialogService.confirm( + this.translate.instant('security.2fa.disable-2fa-provider-title', {name: providerName}), + this.translate.instant('security.2fa.disable-2fa-provider-text', {name: providerName}), + ).subscribe(res => { + if (res) { + this.twoFactorAuth.disable({emitEvent: false}); + this.twoFaService.deleteTwoFaAccountConfig(provider) + .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false}))) + .subscribe(data => this.processTwoFactorAuthConfig(data)); + } + }); + } else { + this.createdNewAuthConfig(provider); + } + } + + private createdNewAuthConfig(provider: TwoFactorAuthProviderType) { + const dialogData = provider === TwoFactorAuthProviderType.EMAIL ? {email: this.user.email} : {}; + this.dialog.open(authenticationDialogMap.get(provider), { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: dialogData + }).afterClosed().subscribe(res => { + if (isDefinedAndNotNull(res)) { + this.processTwoFactorAuthConfig(res); + } + }); + } + + changeDefaultProvider(event: MouseEvent, provider: TwoFactorAuthProviderType) { + event.stopPropagation(); + event.preventDefault(); + if (this.useByDefault !== provider) { + this.twoFactorAuth.disable({emitEvent: false}); + this.twoFaService.updateTwoFaAccountConfig(provider, true) + .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false}))) + .subscribe(data => this.processTwoFactorAuthConfig(data)); + } + } + + generateNewBackupCode() { + const codeLeft = (this.accountConfig[TwoFactorAuthProviderType.BACKUP_CODE] as BackupCodeTwoFactorAuthAccountConfig).codesLeft; + let subscription: Observable; + if (codeLeft) { + subscription = this.dialogService.confirm( + 'Get new set of backup codes?', + `If you get new backup codes, ${codeLeft} remaining codes you have left will be unusable.`, + '', + 'Get new codes' + ); + } else { + subscription = of(true); + } + subscription.subscribe(res => { + if (res) { + this.twoFactorAuth.disable({emitEvent: false}); + this.twoFaService.deleteTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE) + .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false}))) + .subscribe(() => this.createdNewAuthConfig(TwoFactorAuthProviderType.BACKUP_CODE)); + } + }); + } + + providerDataInfo(provider: TwoFactorAuthProviderType) { + const info = {info: null}; + const providerConfig = this.accountConfig[provider]; + if (isDefinedAndNotNull(providerConfig)) { + switch (provider) { + case TwoFactorAuthProviderType.EMAIL: + info.info = (providerConfig as EmailTwoFactorAuthAccountConfig).email; + break; + case TwoFactorAuthProviderType.SMS: + info.info = (providerConfig as SmsTwoFactorAuthAccountConfig).phoneNumber; + break; + case TwoFactorAuthProviderType.BACKUP_CODE: + info.info = (providerConfig as BackupCodeTwoFactorAuthAccountConfig).codesLeft; + break; + } + } + return info; + } + + onChangePassword(form: FormGroupDirective): void { + if (this.changePassword.valid) { + this.authService.changePassword(this.changePassword.get('currentPassword').value, + this.changePassword.get('newPassword').value, {ignoreErrors: true}).subscribe(() => { + this.discardChanges(form); + }, + (error) => { + if (error.status === 400 && error.error.message === 'Current password doesn\'t match!') { + this.changePassword.get('currentPassword').setErrors({differencePassword: true}); + } else if (error.status === 400 && error.error.message.startsWith('Password must')) { + this.loadPasswordPolicy(); + } else if (error.status === 400 && error.error.message.startsWith('Password was already used')) { + this.changePassword.get('newPassword').setErrors({alreadyUsed: error.error.message}); + } else { + this.store.dispatch(new ActionNotificationShow({ + message: error.error.message, + type: 'error', + target: 'changePassword' + })); + } + }); + } else { + this.changePassword.markAllAsTouched(); + } + } + + discardChanges(form: FormGroupDirective, event?: MouseEvent) { + if (event) { + event.stopPropagation(); + } + form.resetForm({ + currentPassword: '', + newPassword: '', + newPassword2: '' + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.module.ts b/ui-ngx/src/app/modules/home/pages/security/security.module.ts new file mode 100644 index 0000000..52d6636 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.module.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SecurityComponent } from './security.component'; +import { SharedModule } from '@shared/shared.module'; +import { SecurityRoutingModule } from './security-routing.module'; +import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from '@home/pages/security/authentication-dialog/sms-auth-dialog.component'; +import { EmailAuthDialogComponent } from '@home/pages/security/authentication-dialog/email-auth-dialog.component'; +import { + BackupCodeAuthDialogComponent +} from '@home/pages/security/authentication-dialog/backup-code-auth-dialog.component'; + +@NgModule({ + declarations: [ + SecurityComponent, + TotpAuthDialogComponent, + SMSAuthDialogComponent, + EmailAuthDialogComponent, + BackupCodeAuthDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + SecurityRoutingModule + ] +}) +export class SecurityModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts new file mode 100644 index 0000000..f17b780 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts @@ -0,0 +1,76 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { TenantProfilesTableConfigResolver } from './tenant-profiles-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'tenantProfiles', + data: { + breadcrumb: { + label: 'tenant-profile.tenant-profiles', + icon: 'mdi:alpha-t-box' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN], + title: 'tenant-profile.tenant-profiles' + }, + resolve: { + entitiesTableConfig: TenantProfilesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'mdi:alpha-t-box' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN], + title: 'tenant-profile.tenant-profiles' + }, + resolve: { + entitiesTableConfig: TenantProfilesTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + TenantProfilesTableConfigResolver + ] +}) +export class TenantProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html new file mode 100644 index 0000000..62caa10 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts new file mode 100644 index 0000000..64021d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { TenantProfile } from '@shared/models/tenant.model'; + +@Component({ + selector: 'tb-tenant-profile-tabs', + templateUrl: './tenant-profile-tabs.component.html', + styleUrls: [] +}) +export class TenantProfileTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts new file mode 100644 index 0000000..853835e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { TenantProfileRoutingModule } from './tenant-profile-routing.module'; +import { TenantProfileTabsComponent } from './tenant-profile-tabs.component'; + +@NgModule({ + declarations: [ + TenantProfileTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + TenantProfileRoutingModule + ] +}) +export class TenantProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts new file mode 100644 index 0000000..5701bff --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts @@ -0,0 +1,182 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Resolve, Router } from '@angular/router'; +import { TenantProfile } from '@shared/models/tenant.model'; +import { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + HeaderActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { TenantProfileService } from '@core/http/tenant-profile.service'; +import { TenantProfileComponent } from '../../components/profile/tenant-profile.component'; +import { TenantProfileTabsComponent } from './tenant-profile-tabs.component'; +import { DialogService } from '@core/services/dialog.service'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { map } from 'rxjs/operators'; +import { guid } from '@core/utils'; + +@Injectable() +export class TenantProfilesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private tenantProfileService: TenantProfileService, + private importExport: ImportExportService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialogService: DialogService) { + + this.config.entityType = EntityType.TENANT_PROFILE; + this.config.entityComponent = TenantProfileComponent; + this.config.entityTabsComponent = TenantProfileTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.TENANT_PROFILE); + this.config.entityResources = entityTypeResources.get(EntityType.TENANT_PROFILE); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('name', 'tenant-profile.name', '40%'), + new EntityTableColumn('description', 'tenant-profile.description', '60%'), + new EntityTableColumn('isDefault', 'tenant-profile.default', '60px', + entity => { + return checkBoxCell(entity.default); + }) + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('tenant-profile.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportTenantProfile($event, entity) + }, + { + name: this.translate.instant('tenant-profile.set-default'), + icon: 'flag', + isEnabled: (tenantProfile) => !tenantProfile.default, + onAction: ($event, entity) => this.setDefaultTenantProfile($event, entity) + } + ); + + this.config.deleteEntityTitle = tenantProfile => this.translate.instant('tenant-profile.delete-tenant-profile-title', + { tenantProfileName: tenantProfile.name }); + this.config.deleteEntityContent = () => this.translate.instant('tenant-profile.delete-tenant-profile-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('tenant-profile.delete-tenant-profiles-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('tenant-profile.delete-tenant-profiles-text'); + + this.config.entitiesFetchFunction = pageLink => this.tenantProfileService.getTenantProfiles(pageLink); + this.config.loadEntity = id => this.tenantProfileService.getTenantProfile(id.id); + this.config.saveEntity = tenantProfile => this.tenantProfileService.saveTenantProfile(tenantProfile); + this.config.deleteEntity = id => this.tenantProfileService.deleteTenantProfile(id.id); + this.config.onEntityAction = action => this.onTenantProfileAction(action); + this.config.deleteEnabled = (tenantProfile) => tenantProfile && !tenantProfile.default; + this.config.entitySelectionEnabled = (tenantProfile) => tenantProfile && !tenantProfile.default; + this.config.addActionDescriptors = this.configureAddActions(); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('tenant-profile.tenant-profiles'); + + return this.config; + } + + configureAddActions(): Array { + const actions: Array = []; + actions.push( + { + name: this.translate.instant('tenant-profile.create-tenant-profile'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('tenant-profile.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importTenantProfile($event) + } + ); + return actions; + } + + private openTenantProfile($event: Event, tenantProfile: TenantProfile) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree(['tenantProfiles', tenantProfile.id.id]); + this.router.navigateByUrl(url); + } + + setDefaultTenantProfile($event: Event, tenantProfile: TenantProfile) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('tenant-profile.set-default-tenant-profile-title', {tenantProfileName: tenantProfile.name}), + this.translate.instant('tenant-profile.set-default-tenant-profile-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((res) => { + if (res) { + this.tenantProfileService.setDefaultTenantProfile(tenantProfile.id.id).subscribe( + () => { + this.config.updateData(); + } + ); + } + } + ); + } + + importTenantProfile($event: Event) { + this.importExport.importTenantProfile().subscribe( + (deviceProfile) => { + if (deviceProfile) { + this.config.updateData(); + } + } + ); + } + + exportTenantProfile($event: Event, tenantProfile: TenantProfile) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportTenantProfile(tenantProfile.id.id); + } + + onTenantProfileAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openTenantProfile(action.event, action.entity); + return true; + case 'setDefault': + this.setDefaultTenantProfile(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts new file mode 100644 index 0000000..497a086 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts @@ -0,0 +1,115 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { TenantsTableConfigResolver } from '@modules/home/pages/tenant/tenants-table-config.resolver'; +import { UsersTableConfigResolver } from '../user/users-table-config.resolver'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'tenants', + data: { + breadcrumb: { + label: 'tenant.tenants', + icon: 'supervisor_account' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN], + title: 'tenant.tenants' + }, + resolve: { + entitiesTableConfig: TenantsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'supervisor_account' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN], + title: 'tenant.tenants' + }, + resolve: { + entitiesTableConfig: TenantsTableConfigResolver + } + }, + { + path: ':tenantId/users', + data: { + breadcrumb: { + label: 'user.tenant-admins', + icon: 'account_circle' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN], + title: 'user.tenant-admins' + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'account_circle' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN], + title: 'user.tenant-admins' + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + } + ] + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + TenantsTableConfigResolver + ] +}) +export class TenantRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html new file mode 100644 index 0000000..51db5ef --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts new file mode 100644 index 0000000..93d9d0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { TenantInfo } from '@shared/models/tenant.model'; + +@Component({ + selector: 'tb-tenant-tabs', + templateUrl: './tenant-tabs.component.html', + styleUrls: [] +}) +export class TenantTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html new file mode 100644 index 0000000..a46f64a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html @@ -0,0 +1,92 @@ + +
+ + + +
+ +
+
+
+
+
+ + tenant.title + + + {{ 'tenant.title-required' | translate }} + + + {{ 'tenant.title-max-length' | translate }} + + + + +
+ + tenant.description + + +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.scss b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.scss new file mode 100644 index 0000000..7c14ed9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + .tb-default-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts new file mode 100644 index 0000000..2f98d11 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts @@ -0,0 +1,104 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Tenant, TenantInfo } from '@app/shared/models/tenant.model'; +import { ActionNotificationShow } from '@app/core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { ContactBasedComponent } from '../../components/entity/contact-based.component'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-tenant', + templateUrl: './tenant.component.html', + styleUrls: ['./tenant.component.scss'] +}) +export class TenantComponent extends ContactBasedComponent { + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: TenantInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildEntityForm(entity: TenantInfo): FormGroup { + return this.fb.group( + { + title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], + tenantProfileId: [entity ? entity.tenantProfileId : null, [Validators.required]], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + homeDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null], + homeDashboardHideToolbar: [entity && entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true] + } + ) + } + ); + } + + updateEntityForm(entity: Tenant) { + this.entityForm.patchValue({title: entity.title}); + this.entityForm.patchValue({tenantProfileId: entity.tenantProfileId}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardId: entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardHideToolbar: entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true}}); + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } + + onTenantIdCopied(event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('tenant.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + + onTenantProfileUpdated() { + this.entitiesTableConfig.updateData(false); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts new file mode 100644 index 0000000..5aac175 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts @@ -0,0 +1,37 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { TenantComponent } from '@modules/home/pages/tenant/tenant.component'; +import { TenantRoutingModule } from '@modules/home/pages/tenant/tenant-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { TenantTabsComponent } from '@home/pages/tenant/tenant-tabs.component'; + +@NgModule({ + declarations: [ + TenantComponent, + TenantTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + TenantRoutingModule + ] +}) +export class TenantModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts new file mode 100644 index 0000000..5128fd8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts @@ -0,0 +1,117 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { Resolve, Router } from '@angular/router'; + +import { TenantInfo } from '@shared/models/tenant.model'; +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { TenantService } from '@core/http/tenant.service'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TenantComponent } from '@modules/home/pages/tenant/tenant.component'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { TenantTabsComponent } from '@home/pages/tenant/tenant-tabs.component'; +import { mergeMap } from 'rxjs/operators'; + +@Injectable() +export class TenantsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private tenantService: TenantService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router) { + + this.config.entityType = EntityType.TENANT; + this.config.entityComponent = TenantComponent; + this.config.entityTabsComponent = TenantTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.TENANT); + this.config.entityResources = entityTypeResources.get(EntityType.TENANT); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'tenant.title', '20%'), + new EntityTableColumn('tenantProfileName', 'tenant-profile.tenant-profile', '20%'), + new EntityTableColumn('email', 'contact.email', '20%'), + new EntityTableColumn('country', 'contact.country', '20%'), + new EntityTableColumn('city', 'contact.city', '20%') + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('tenant.manage-tenant-admins'), + icon: 'account_circle', + isEnabled: () => true, + onAction: ($event, entity) => this.manageTenantAdmins($event, entity) + } + ); + + this.config.deleteEntityTitle = tenant => this.translate.instant('tenant.delete-tenant-title', { tenantTitle: tenant.title }); + this.config.deleteEntityContent = () => this.translate.instant('tenant.delete-tenant-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('tenant.delete-tenants-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('tenant.delete-tenants-text'); + + this.config.entitiesFetchFunction = pageLink => this.tenantService.getTenantInfos(pageLink); + this.config.loadEntity = id => this.tenantService.getTenantInfo(id.id); + this.config.saveEntity = tenant => this.tenantService.saveTenant(tenant).pipe( + mergeMap((savedTenant) => this.tenantService.getTenantInfo(savedTenant.id.id)) + ); + this.config.deleteEntity = id => this.tenantService.deleteTenant(id.id); + this.config.onEntityAction = action => this.onTenantAction(action, this.config); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('tenant.tenants'); + + return this.config; + } + + private openTenant($event: Event, tenant: TenantInfo, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([tenant.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + manageTenantAdmins($event: Event, tenant: TenantInfo) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`tenants/${tenant.id.id}/users`); + } + + onTenantAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openTenant(action.event, action.entity, config); + return true; + case 'manageTenantAdmins': + this.manageTenantAdmins(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html new file mode 100644 index 0000000..696671a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html @@ -0,0 +1,57 @@ + +
+ +

user.activation-link

+ + +
+ + +
+
+
+ +
+
{{ activationLink }}
+ +
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts new file mode 100644 index 0000000..3cdb895 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts @@ -0,0 +1,66 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +export interface ActivationLinkDialogData { + activationLink: string; +} + +@Component({ + selector: 'tb-activation-link-dialog', + templateUrl: './activation-link-dialog.component.html' +}) +export class ActivationLinkDialogComponent extends DialogComponent implements OnInit { + + activationLink: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData, + public dialogRef: MatDialogRef, + private translate: TranslateService) { + super(store, router, dialogRef); + this.activationLink = this.data.activationLink; + } + + ngOnInit(): void { + } + + close(): void { + this.dialogRef.close(); + } + + onActivationLinkCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.activation-link-copied-message'), + type: 'success', + target: 'activationLinkDialogContent', + duration: 1200, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html new file mode 100644 index 0000000..e03afaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html @@ -0,0 +1,57 @@ + +
+ +

user.add

+ +
+ +
+ + +
+
+ + + user.activation-method + + + {{ activationMethodTranslations.get(activationMethodEnum[activationMethod]) | translate }} + + + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.scss new file mode 100644 index 0000000..46d983e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep .mat-padding{ + padding: 0; +} diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts new file mode 100644 index 0000000..8281ab9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormGroup } from '@angular/forms'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { Authority } from '@shared/models/authority.enum'; +import { ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { UserService } from '@core/http/user.service'; +import { Observable } from 'rxjs'; +import { + ActivationLinkDialogComponent, + ActivationLinkDialogData +} from '@modules/home/pages/user/activation-link-dialog.component'; +import { TenantId } from '@app/shared/models/id/tenant-id'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +export interface AddUserDialogData { + tenantId: string; + customerId: string; + authority: Authority; +} + +@Component({ + selector: 'tb-add-user-dialog', + templateUrl: './add-user-dialog.component.html', + styleUrls: ['./add-user-dialog.component.scss'] +}) +export class AddUserDialogComponent extends DialogComponent implements OnInit { + + detailsForm: FormGroup; + user: User; + + activationMethods = Object.keys(ActivationMethod); + activationMethodEnum = ActivationMethod; + + activationMethodTranslations = activationMethodTranslations; + + activationMethod = ActivationMethod.DISPLAY_ACTIVATION_LINK; + + @ViewChild(UserComponent, {static: true}) userComponent: UserComponent; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AddUserDialogData, + public dialogRef: MatDialogRef, + private userService: UserService, + private dialog: MatDialog) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.user = {} as User; + this.userComponent.isEdit = true; + this.userComponent.entity = this.user; + this.detailsForm = this.userComponent.entityForm; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.detailsForm.valid) { + this.user = {...this.user, ...this.userComponent.entityForm.value}; + this.user.authority = this.data.authority; + this.user.tenantId = new TenantId(this.data.tenantId); + this.user.customerId = new CustomerId(this.data.customerId); + const sendActivationEmail = this.activationMethod === ActivationMethod.SEND_ACTIVATION_MAIL; + this.userService.saveUser(this.user, sendActivationEmail).subscribe( + (user) => { + if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) { + this.userService.getActivationLink(user.id.id).subscribe( + (activationLink) => { + this.displayActivationLink(activationLink).subscribe( + () => { + this.dialogRef.close(user); + } + ); + } + ); + } else { + this.dialogRef.close(user); + } + } + ); + } + } + + displayActivationLink(activationLink: string): Observable { + return this.dialog.open(ActivationLinkDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + activationLink + } + }).afterClosed(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts b/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts new file mode 100644 index 0000000..7afbd13 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts @@ -0,0 +1,67 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { UsersTableConfigResolver } from '@modules/home/pages/user/users-table-config.resolver'; +import { Authority } from '@shared/models/authority.enum'; +import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; +import { BreadCrumbConfig } from '@shared/components/breadcrumb'; + +const routes: Routes = [ + { + path: 'users', + data: { + breadcrumb: { + skip: true + } + }, + children: [ + { + path: '', + redirectTo: '/', + pathMatch: 'full' + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'account_circle' + } as BreadCrumbConfig, + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'user.user', + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + UsersTableConfigResolver + ] +}) +export class UserRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html new file mode 100644 index 0000000..1ef7791 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts new file mode 100644 index 0000000..760c5d7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { User } from '@app/shared/models/user.model'; + +@Component({ + selector: 'tb-user-tabs', + templateUrl: './user-tabs.component.html', + styleUrls: [] +}) +export class UserTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.html b/ui-ngx/src/app/modules/home/pages/user/user.component.html new file mode 100644 index 0000000..4e88b01 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.html @@ -0,0 +1,133 @@ + +
+ + + + + + + +
+ +
+
+
+
+
+ + user.email + + + {{ 'user.invalid-email-format' | translate }} + + + {{ 'user.email-required' | translate }} + + + + user.first-name + + + + user.last-name + + +
+ + user.description + + +
+
+ + + {{ 'user.always-fullscreen' | translate }} + +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.scss b/ui-ngx/src/app/modules/home/pages/user/user.component.scss new file mode 100644 index 0000000..7c14ed9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../scss/constants"; + +:host { + .tb-default-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.ts b/ui-ngx/src/app/modules/home/pages/user/user.component.ts new file mode 100644 index 0000000..2398a07 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject, Optional } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { User } from '@shared/models/user.model'; +import { selectAuth } from '@core/auth/auth.selectors'; +import { map } from 'rxjs/operators'; +import { Authority } from '@shared/models/authority.enum'; +import { isDefinedAndNotNull, isUndefined } from '@core/utils'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; +import { ActionNotificationShow } from '@app/core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-user', + templateUrl: './user.component.html', + styleUrls: ['./user.component.scss'] +}) +export class UserComponent extends EntityComponent { + + authority = Authority; + + loginAsUserEnabled$ = this.store.pipe( + select(selectAuth), + map((auth) => auth.userTokenAccessEnabled) + ); + + constructor(protected store: Store, + @Optional() @Inject('entity') protected entityValue: User, + @Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef, + protected translate: TranslateService) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + isUserCredentialsEnabled(): boolean { + return this.entity.additionalInfo.userCredentialsEnabled === true; + } + + isUserCredentialPresent(): boolean { + return this.entity && this.entity.additionalInfo && isDefinedAndNotNull(this.entity.additionalInfo.userCredentialsEnabled); + } + + buildForm(entity: User): FormGroup { + return this.fb.group( + { + email: [entity ? entity.email : '', [Validators.required, Validators.email]], + firstName: [entity ? entity.firstName : ''], + lastName: [entity ? entity.lastName : ''], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + defaultDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null], + defaultDashboardFullscreen: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardFullscreen : false], + homeDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null], + homeDashboardHideToolbar: [entity && entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true] + } + ) + } + ); + } + + updateForm(entity: User) { + this.entityForm.patchValue({email: entity.email}); + this.entityForm.patchValue({firstName: entity.firstName}); + this.entityForm.patchValue({lastName: entity.lastName}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + this.entityForm.patchValue({additionalInfo: + {defaultDashboardId: entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null}}); + this.entityForm.patchValue({additionalInfo: + {defaultDashboardFullscreen: entity.additionalInfo ? entity.additionalInfo.defaultDashboardFullscreen : false}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardId: entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null}}); + this.entityForm.patchValue({additionalInfo: + {homeDashboardHideToolbar: entity.additionalInfo && + isDefinedAndNotNull(entity.additionalInfo.homeDashboardHideToolbar) ? entity.additionalInfo.homeDashboardHideToolbar : true}}); + } + + onUserIdCopied($event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + } + )) + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.module.ts b/ui-ngx/src/app/modules/home/pages/user/user.module.ts new file mode 100644 index 0000000..e231cfe --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.module.ts @@ -0,0 +1,41 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { UserRoutingModule } from '@modules/home/pages/user/user-routing.module'; +import { AddUserDialogComponent } from '@modules/home/pages/user/add-user-dialog.component'; +import { ActivationLinkDialogComponent } from '@modules/home/pages/user/activation-link-dialog.component'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { UserTabsComponent } from '@home/pages/user/user-tabs.component'; + +@NgModule({ + declarations: [ + UserComponent, + UserTabsComponent, + AddUserDialogComponent, + ActivationLinkDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + UserRoutingModule + ] +}) +export class UserModule { } diff --git a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts new file mode 100644 index 0000000..ec194ef --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts @@ -0,0 +1,264 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { User } from '@shared/models/user.model'; +import { UserService } from '@core/http/user.service'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { CustomerService } from '@core/http/customer.service'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { Authority } from '@shared/models/authority.enum'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { AddUserDialogComponent, AddUserDialogData } from '@modules/home/pages/user/add-user-dialog.component'; +import { AuthState } from '@core/auth/auth.models'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { selectAuth } from '@core/auth/auth.selectors'; +import { AuthService } from '@core/auth/auth.service'; +import { + ActivationLinkDialogComponent, + ActivationLinkDialogData +} from '@modules/home/pages/user/activation-link-dialog.component'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { TenantService } from '@app/core/http/tenant.service'; +import { TenantId } from '@app/shared/models/id/tenant-id'; +import { UserTabsComponent } from '@home/pages/user/user-tabs.component'; +import { isDefinedAndNotNull } from '@core/utils'; + +export interface UsersTableRouteData { + authority: Authority; +} + +@Injectable() +export class UsersTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + private tenantId: string; + private customerId: string; + private authority: Authority; + private authUser: User; + + constructor(private store: Store, + private userService: UserService, + private authService: AuthService, + private tenantService: TenantService, + private customerService: CustomerService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router, + private dialog: MatDialog) { + + this.config.entityType = EntityType.USER; + this.config.entityComponent = UserComponent; + this.config.entityTabsComponent = UserTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.USER); + this.config.entityResources = entityTypeResources.get(EntityType.USER); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('firstName', 'user.first-name', '33%'), + new EntityTableColumn('lastName', 'user.last-name', '33%'), + new EntityTableColumn('email', 'user.email', '33%') + ); + + this.config.deleteEnabled = user => user && user.id && user.id.id !== this.authUser.id.id; + this.config.deleteEntityTitle = user => this.translate.instant('user.delete-user-title', { userEmail: user.email }); + this.config.deleteEntityContent = () => this.translate.instant('user.delete-user-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('user.delete-users-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('user.delete-users-text'); + + this.config.loadEntity = id => this.userService.getUser(id.id); + this.config.saveEntity = user => this.saveUser(user); + this.config.deleteEntity = id => this.userService.deleteUser(id.id); + this.config.onEntityAction = action => this.onUserAction(action, this.config); + this.config.addEntity = () => this.addUser(); + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + return this.store.pipe(select(selectAuth), take(1)).pipe( + tap((auth) => { + this.authUser = auth.userDetails; + this.authority = routeParams.tenantId ? Authority.TENANT_ADMIN : Authority.CUSTOMER_USER; + if (this.authority === Authority.TENANT_ADMIN) { + this.tenantId = routeParams.tenantId; + this.customerId = NULL_UUID; + this.config.entitiesFetchFunction = pageLink => this.userService.getTenantAdmins(this.tenantId, pageLink); + } else { + this.tenantId = this.authUser.tenantId.id; + this.customerId = routeParams.customerId; + this.config.entitiesFetchFunction = pageLink => this.userService.getCustomerUsers(this.customerId, pageLink); + } + this.updateActionCellDescriptors(auth); + }), + mergeMap(() => { + if (this.authority === Authority.TENANT_ADMIN) { + return this.tenantService.getTenant(this.tenantId); + } else if (isDefinedAndNotNull(this.customerId)) { + return this.customerService.getCustomer(this.customerId); + } + return of({title: ''}); + }), + map((parentEntity) => { + if (this.authority === Authority.TENANT_ADMIN) { + this.config.tableTitle = parentEntity.title + ': ' + this.translate.instant('user.tenant-admins'); + } else { + this.config.tableTitle = parentEntity.title + ': ' + this.translate.instant('user.customer-users'); + } + return this.config; + }) + ); + } + + updateActionCellDescriptors(auth: AuthState) { + this.config.cellActionDescriptors.splice(0); + if (auth.userTokenAccessEnabled) { + this.config.cellActionDescriptors.push( + { + name: this.authority === Authority.TENANT_ADMIN ? + this.translate.instant('user.login-as-tenant-admin') : + this.translate.instant('user.login-as-customer-user'), + mdiIcon: 'mdi:login', + isEnabled: () => true, + onAction: ($event, entity) => this.loginAsUser($event, entity) + } + ); + } + } + + saveUser(user: User): Observable { + user.tenantId = new TenantId(this.tenantId); + user.customerId = new CustomerId(this.customerId); + user.authority = this.authority; + return this.userService.saveUser(user); + } + + addUser(): Observable { + return this.dialog.open(AddUserDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + tenantId: this.tenantId, + customerId: this.customerId, + authority: this.authority + } + }).afterClosed(); + } + + private openUser($event: Event, user: User, config: EntityTableConfig) { + if ($event) { + $event.stopPropagation(); + } + const url = this.router.createUrlTree([user.id.id], {relativeTo: config.getActivatedRoute()}); + this.router.navigateByUrl(url); + } + + loginAsUser($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.authService.loginAsUser(user.id.id).subscribe(); + } + + displayActivationLink($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.userService.getActivationLink(user.id.id).subscribe( + (activationLink) => { + this.dialog.open(ActivationLinkDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + activationLink + } + }); + } + ); + } + + resendActivation($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.userService.sendActivationEmail(user.email).subscribe(() => { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.activation-email-sent-message'), + type: 'success' + })); + }); + } + + setUserCredentialsEnabled($event: Event, user: User, userCredentialsEnabled: boolean) { + if ($event) { + $event.stopPropagation(); + } + this.userService.setUserCredentialsEnabled(user.id.id, userCredentialsEnabled).subscribe(() => { + if (!user.additionalInfo) { + user.additionalInfo = {}; + } + user.additionalInfo.userCredentialsEnabled = userCredentialsEnabled; + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant(userCredentialsEnabled ? 'user.enable-account-message' : 'user.disable-account-message'), + type: 'success' + })); + }); + } + + onUserAction(action: EntityAction, config: EntityTableConfig): boolean { + switch (action.action) { + case 'open': + this.openUser(action.event, action.entity, config); + return true; + case 'loginAsUser': + this.loginAsUser(action.event, action.entity); + return true; + case 'displayActivationLink': + this.displayActivationLink(action.event, action.entity); + return true; + case 'resendActivation': + this.resendActivation(action.event, action.entity); + return true; + case 'disableAccount': + this.setUserCredentialsEnabled(action.event, action.entity, false); + return true; + case 'enableAccount': + this.setUserCredentialsEnabled(action.event, action.entity, true); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts b/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts new file mode 100644 index 0000000..9117e7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/vc/vc-routing.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import { VersionControlComponent } from '@home/components/vc/version-control.component'; + +const routes: Routes = [ + { + path: 'vc', + component: VersionControlComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.TENANT_ADMIN], + title: 'version-control.version-control', + breadcrumb: { + label: 'version-control.version-control', + icon: 'history' + } + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [] +}) +export class VcRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/vc/vc.module.ts b/ui-ngx/src/app/modules/home/pages/vc/vc.module.ts new file mode 100644 index 0000000..a463a65 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/vc/vc.module.ts @@ -0,0 +1,31 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { VcRoutingModule } from '@home/pages/vc/vc-routing.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + CommonModule, + SharedModule, + VcRoutingModule + ] +}) +export class VcModule { } diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html new file mode 100644 index 0000000..b97691c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -0,0 +1,62 @@ + +
+ +

widget.save-widget-type-as

+ + +
+ + +
+
+
+ widget.save-widget-type-as-text + + widget.title + + + {{ 'widget.title-required' | translate }} + + + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts new file mode 100644 index 0000000..c9bda8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts @@ -0,0 +1,81 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; + +export interface SaveWidgetTypeAsDialogResult { + widgetName: string; + bundleId: string; + bundleAlias: string; +} + +@Component({ + selector: 'tb-save-widget-type-as-dialog', + templateUrl: './save-widget-type-as-dialog.component.html', + styleUrls: [] +}) +export class SaveWidgetTypeAsDialogComponent extends + DialogComponent implements OnInit { + + saveWidgetTypeAsFormGroup: FormGroup; + + bundlesScope: string; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + const authUser = getCurrentAuthUser(store); + if (authUser.authority === Authority.TENANT_ADMIN) { + this.bundlesScope = 'tenant'; + } else { + this.bundlesScope = 'system'; + } + } + + ngOnInit(): void { + this.saveWidgetTypeAsFormGroup = this.fb.group({ + title: [null, [Validators.required]], + widgetsBundle: [null, [Validators.required]] + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + saveAs(): void { + const widgetName: string = this.saveWidgetTypeAsFormGroup.get('title').value; + const widgetsBundle: WidgetsBundle = this.saveWidgetTypeAsFormGroup.get('widgetsBundle').value; + const result: SaveWidgetTypeAsDialogResult = { + widgetName, + bundleId: widgetsBundle.id.id, + bundleAlias: widgetsBundle.alias + }; + this.dialogRef.close(result); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html new file mode 100644 index 0000000..d15e8fb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html @@ -0,0 +1,57 @@ + +
+ +

widget.select-widget-type

+ + +
+ + +
+
+
+
+ +
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.scss new file mode 100644 index 0000000..f252068 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.scss @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + button.tb-card-button { + width: 100%; + height: 100%; + max-width: 240px; + .mat-button-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + mat-icon { + margin: auto; + } + span { + height: 18px; + min-height: 18px; + max-height: 18px; + margin: 0 0 20px 0; + font-size: 18px; + font-weight: 400; + line-height: 18px; + white-space: normal; + } + } + &.mat-raised-button.mat-primary { + .mat-ripple-element { + opacity: 0.3; + background-color: rgba(255, 255, 255, 0.3); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts new file mode 100644 index 0000000..bb1701d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { widgetType, widgetTypesData } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-select-widget-type-dialog', + templateUrl: './select-widget-type-dialog.component.html', + styleUrls: ['./select-widget-type-dialog.component.scss'] +}) +export class SelectWidgetTypeDialogComponent extends + DialogComponent { + + widgetTypes = widgetType; + + allWidgetTypes = Object.keys(widgetType); + + widgetTypesDataMap = widgetTypesData; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + } + + cancel(): void { + this.dialogRef.close(null); + } + + typeSelected(type: widgetType) { + this.dialogRef.close(type); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html new file mode 100644 index 0000000..14a1120 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -0,0 +1,321 @@ + +
+
+ + + + + + + + + + {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+
+ + + + + {{ 'widget.resource-is-module' | translate }} + + +
+
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + + + widget.description + + {{descriptionInput.value?.length || 0}}/255 + + + widget.settings-form-selector + + + + widget.data-key-settings-form-selector + + + + widget.latest-data-key-settings-form-selector + + +
+
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss new file mode 100644 index 0000000..7053545 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.scss @@ -0,0 +1,208 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +$edit-toolbar-height: 40px !default; + +tb-widget-editor { + flex: 1; + display: flex; + flex-direction: column; +} + + +.tb-editor { + .tb-split { + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + } + + mat-form-field.resource-field { + max-height: 40px; + margin: 10px 0 0; + .mat-form-field-wrapper { + padding-bottom: 0; + .mat-form-field-flex { + max-height: 40px; + .mat-form-field-infix { + border: 0; + } + } + .mat-form-field-underline { + bottom: 0; + } + } + } + + .ace_editor { + font-size: 14px !important; + } + + .tb-content { + border: 1px solid #c0c0c0; + } + + .gutter { + background-color: transparent; + + background-repeat: no-repeat; + background-position: 50%; + + &.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../assets/split.js/grips/vertical.png"); + } + + &.gutter-vertical { + cursor: row-resize; + background-image: url("../../../../../assets/split.js/grips/horizontal.png"); + } + } + + .tb-split.tb-split-horizontal, + .gutter.gutter-horizontal { + float: left; + height: 100%; + } + + .tb-split.tb-split-vertical { + display: flex; + + .tb-split.tb-content { + height: 100%; + } + } + + .container{ + width: 100%; + height: 100%; + } + + .mat-tab-label[aria-labelledby='hidden'] { + width: 0px !important; + min-width: 0px; + padding: 0px; + } + +} + +.tb-split-vertical { + mat-tab-group { + .mat-tab-body-wrapper { + height: calc(100% - 49px); + + mat-tab-body { + height: 100%; + + & > div { + height: 100%; + } + } + } + } +} + +div.tb-editor-area-title-panel { + position: absolute; + top: 5px; + right: 20px; + z-index: 5; + font-size: .8rem; + font-weight: 500; + + & > * { + &:not(:last-child) { + margin-right: 4px; + } + } + + label { + padding: 4px; + color: #00acc1; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + } + + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + font-size: .8rem; + line-height: 15px; + background: rgba(220, 220, 220, .35); + &:not(.tb-help-popup-button) { + color: #7b7b7b; + } + } + .tb-help-popup-button-loading { + background: #f3f3f3; + } +} + +.tb-resize-container { + position: relative; + width: 100%; + height: 100%; + overflow-y: auto; + + .ace_editor { + height: 100%; + } +} + +mat-toolbar.tb-edit-toolbar { + + min-height: $edit-toolbar-height !important; + max-height: $edit-toolbar-height !important; + + button.mat-button-base:not(.mat-icon-button) { + font-size: 12px; + line-height: 28px; + padding: 0 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + mat-icon { + height: 20px; + width: 20px; + font-size: 20px; + } + } + + mat-form-field { + input, mat-select { + font-size: 1.1rem; + font-weight: 400; + letter-spacing: .005em; + } + div.mat-form-field-infix { + padding-bottom: 5px; + } + &.tb-widget-title { + min-width: 250px; + } + } + + @media #{$mat-lt-lg} { + mat-form-field.tb-widget-title { + min-width: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts new file mode 100644 index 0000000..b12a0df --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts @@ -0,0 +1,790 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { PageComponent } from '@shared/components/page.component'; +import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { detailsToWidgetInfo, toWidgetInfo, WidgetInfo } from '@home/models/widget-component.models'; +import { + Widget, + WidgetConfig, + WidgetType, + widgetType, + WidgetTypeDetails, + widgetTypesData +} from '@shared/models/widget.models'; +import { ActivatedRoute, Router } from '@angular/router'; +import { deepClone } from '@core/utils'; +import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard'; +import { AuthUser } from '@shared/models/user.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Hotkey } from 'angular2-hotkeys'; +import { TranslateService } from '@ngx-translate/core'; +import { getCurrentIsLoading } from '@app/core/interceptors/load.selectors'; +import { Ace } from 'ace-builds'; +import { getAce, Range } from '@shared/models/ace/ace.models'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { WINDOW } from '@core/services/window.service'; +import { WindowMessage } from '@shared/models/window-message.model'; +import { ExceptionData } from '@shared/models/error.models'; +import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions'; +import { MatDialog } from '@angular/material/dialog'; +import { + SaveWidgetTypeAsDialogComponent, + SaveWidgetTypeAsDialogResult +} from '@home/pages/widget/save-widget-type-as-dialog.component'; +import { forkJoin, from, Subscription } from 'rxjs'; +import { ResizeObserver } from '@juggle/resize-observer'; +import Timeout = NodeJS.Timeout; +import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { map, tap } from 'rxjs/operators'; +import { beautifyCss, beautifyHtml, beautifyJs } from '@shared/models/beautify.models'; + +// @dynamic +@Component({ + selector: 'tb-widget-editor', + templateUrl: './widget-editor.component.html', + styleUrls: ['./widget-editor.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class WidgetEditorComponent extends PageComponent implements OnInit, OnDestroy, HasDirtyFlag { + + @ViewChild('topPanel', {static: true}) + topPanelElmRef: ElementRef; + + @ViewChild('topLeftPanel', {static: true}) + topLeftPanelElmRef: ElementRef; + + @ViewChild('topRightPanel', {static: true}) + topRightPanelElmRef: ElementRef; + + @ViewChild('bottomPanel', {static: true}) + bottomPanelElmRef: ElementRef; + + @ViewChild('javascriptPanel', {static: true}) + javascriptPanelElmRef: ElementRef; + + @ViewChild('framePanel', {static: true}) + framePanelElmRef: ElementRef; + + @ViewChild('htmlInput', {static: true}) + htmlInputElmRef: ElementRef; + + @ViewChild('cssInput', {static: true}) + cssInputElmRef: ElementRef; + + @ViewChild('settingsJsonInput', {static: true}) + settingsJsonInputElmRef: ElementRef; + + @ViewChild('dataKeySettingsJsonInput', {static: true}) + dataKeySettingsJsonInputElmRef: ElementRef; + + @ViewChild('latestDataKeySettingsJsonInput', {static: true}) + latestDataKeySettingsJsonInputElmRef: ElementRef; + + @ViewChild('javascriptInput', {static: true}) + javascriptInputElmRef: ElementRef; + + @ViewChild('widgetIFrame', {static: true}) + widgetIFrameElmRef: ElementRef; + + iframe: JQuery; + + widgetTypes = widgetType; + allWidgetTypes = Object.keys(widgetType); + widgetTypesDataMap = widgetTypesData; + + authUser: AuthUser; + + isReadOnly: boolean; + + widgetsBundle: WidgetsBundle; + widgetTypeDetails: WidgetTypeDetails; + widget: WidgetInfo; + origWidget: WidgetInfo; + + isDirty = false; + + fullscreen = false; + htmlFullscreen = false; + cssFullscreen = false; + jsonSettingsFullscreen = false; + jsonDataKeySettingsFullscreen = false; + jsonLatestDataKeySettingsFullscreen = false; + javascriptFullscreen = false; + iFrameFullscreen = false; + + aceEditors: Ace.Editor[] = []; + editorsResizeCafs: {[editorId: string]: CancelAnimationFrame} = {}; + htmlEditor: Ace.Editor; + cssEditor: Ace.Editor; + jsonSettingsEditor: Ace.Editor; + dataKeyJsonSettingsEditor: Ace.Editor; + latestDataKeyJsonSettingsEditor: Ace.Editor; + jsEditor: Ace.Editor; + aceResize$: ResizeObserver; + + onWindowMessageListener = this.onWindowMessage.bind(this); + + iframeWidgetEditModeInited = false; + saveWidgetPending = false; + saveWidgetAsPending = false; + + gotError = false; + errorMarkers: number[] = []; + errorAnnotationId = -1; + + saveWidgetTimeout: Timeout; + + hotKeys: Hotkey[] = []; + + private rxSubscriptions = new Array(); + + constructor(protected store: Store, + @Inject(WINDOW) private window: Window, + private route: ActivatedRoute, + private router: Router, + private widgetService: WidgetService, + private translate: TranslateService, + private raf: RafService, + private dialog: MatDialog) { + super(store); + + this.authUser = getCurrentAuthUser(store); + + this.rxSubscriptions.push(this.route.data.subscribe( + (data) => { + this.init(data); + } + )); + + this.initHotKeys(); + } + + private init(data: any) { + this.widgetsBundle = data.widgetsBundle; + if (this.authUser.authority === Authority.TENANT_ADMIN) { + this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; + } else { + this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; + } + this.widgetTypeDetails = data.widgetEditorData.widgetTypeDetails; + this.widget = data.widgetEditorData.widget; + if (this.widgetTypeDetails) { + const config = JSON.parse(this.widget.defaultConfig); + this.widget.defaultConfig = JSON.stringify(config); + } + this.origWidget = deepClone(this.widget); + if (!this.widgetTypeDetails) { + this.isDirty = true; + } + } + + ngOnInit(): void { + this.initSplitLayout(); + this.initAceEditors(); + this.iframe = $(this.widgetIFrameElmRef.nativeElement); + this.window.addEventListener('message', this.onWindowMessageListener); + this.iframe.attr('data-widget', JSON.stringify(this.widget)); + this.iframe.attr('src', '/widget-editor'); + } + + ngOnDestroy(): void { + this.window.removeEventListener('message', this.onWindowMessageListener); + this.aceEditors.forEach(editor => editor.destroy()); + this.aceResize$.disconnect(); + this.rxSubscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + this.rxSubscriptions.length = 0; + } + + private initHotKeys(): void { + this.hotKeys.push( + new Hotkey('ctrl+q', (event: KeyboardEvent) => { + if (!getCurrentIsLoading(this.store) && !this.undoDisabled()) { + event.preventDefault(); + this.undoWidget(); + } + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.undo')) + ); + this.hotKeys.push( + new Hotkey('ctrl+s', (event: KeyboardEvent) => { + if (!getCurrentIsLoading(this.store) && !this.saveDisabled()) { + event.preventDefault(); + this.saveWidget(); + } + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.save')) + ); + this.hotKeys.push( + new Hotkey('shift+ctrl+s', (event: KeyboardEvent) => { + if (!getCurrentIsLoading(this.store) && !this.saveAsDisabled()) { + event.preventDefault(); + this.saveWidgetAs(); + } + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.saveAs')) + ); + this.hotKeys.push( + new Hotkey('shift+ctrl+f', (event: KeyboardEvent) => { + event.preventDefault(); + this.fullscreen = !this.fullscreen; + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.toggle-fullscreen')) + ); + this.hotKeys.push( + new Hotkey('ctrl+enter', (event: KeyboardEvent) => { + event.preventDefault(); + this.applyWidgetScript(); + return false; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('widget.run')) + ); + } + + private initSplitLayout() { + Split([this.topPanelElmRef.nativeElement, this.bottomPanelElmRef.nativeElement], { + sizes: [35, 65], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }); + Split([this.topLeftPanelElmRef.nativeElement, this.topRightPanelElmRef.nativeElement], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + Split([this.javascriptPanelElmRef.nativeElement, this.framePanelElmRef.nativeElement], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + + private initAceEditors() { + this.aceResize$ = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const editor = this.aceEditors.find(aceEditor => aceEditor.container === entry.target); + this.onAceEditorResize(editor); + }); + }); + + const editorsObservables: Observable[] = []; + + + editorsObservables.push(this.createAceEditor(this.htmlInputElmRef, 'html').pipe( + tap((editor) => { + this.htmlEditor = editor; + this.htmlEditor.on('input', () => { + const editorValue = this.htmlEditor.getValue(); + if (this.widget.templateHtml !== editorValue) { + this.widget.templateHtml = editorValue; + this.isDirty = true; + } + }); + }) + )); + + editorsObservables.push(this.createAceEditor(this.cssInputElmRef, 'css').pipe( + tap((editor) => { + this.cssEditor = editor; + this.cssEditor.on('input', () => { + const editorValue = this.cssEditor.getValue(); + if (this.widget.templateCss !== editorValue) { + this.widget.templateCss = editorValue; + this.isDirty = true; + } + }); + }) + )); + + editorsObservables.push(this.createAceEditor(this.settingsJsonInputElmRef, 'json').pipe( + tap((editor) => { + this.jsonSettingsEditor = editor; + this.jsonSettingsEditor.on('input', () => { + const editorValue = this.jsonSettingsEditor.getValue(); + if (this.widget.settingsSchema !== editorValue) { + this.widget.settingsSchema = editorValue; + this.isDirty = true; + } + }); + }) + )); + + editorsObservables.push(this.createAceEditor(this.dataKeySettingsJsonInputElmRef, 'json').pipe( + tap((editor) => { + this.dataKeyJsonSettingsEditor = editor; + this.dataKeyJsonSettingsEditor.on('input', () => { + const editorValue = this.dataKeyJsonSettingsEditor.getValue(); + if (this.widget.dataKeySettingsSchema !== editorValue) { + this.widget.dataKeySettingsSchema = editorValue; + this.isDirty = true; + } + }); + }) + )); + + editorsObservables.push(this.createAceEditor(this.latestDataKeySettingsJsonInputElmRef, 'json').pipe( + tap((editor) => { + this.latestDataKeyJsonSettingsEditor = editor; + this.latestDataKeyJsonSettingsEditor.on('input', () => { + const editorValue = this.latestDataKeyJsonSettingsEditor.getValue(); + if (this.widget.latestDataKeySettingsSchema !== editorValue) { + this.widget.latestDataKeySettingsSchema = editorValue; + this.isDirty = true; + } + }); + }) + )); + + editorsObservables.push(this.createAceEditor(this.javascriptInputElmRef, 'javascript').pipe( + tap((editor) => { + this.jsEditor = editor; + this.jsEditor.on('input', () => { + const editorValue = this.jsEditor.getValue(); + if (this.widget.controllerScript !== editorValue) { + this.widget.controllerScript = editorValue; + this.isDirty = true; + } + }); + this.jsEditor.on('change', () => { + this.cleanupJsErrors(); + }); + this.jsEditor.completers = [widgetEditorCompleter, ...(this.jsEditor.completers || [])]; + }) + )); + + forkJoin(editorsObservables).subscribe( + () => { + this.setAceEditorValues(); + } + ); + + } + + private setAceEditorValues() { + this.htmlEditor.setValue(this.widget.templateHtml ? this.widget.templateHtml : '', -1); + this.cssEditor.setValue(this.widget.templateCss ? this.widget.templateCss : '', -1); + this.jsonSettingsEditor.setValue(this.widget.settingsSchema ? this.widget.settingsSchema : '', -1); + this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1); + this.latestDataKeyJsonSettingsEditor.setValue(this.widget.latestDataKeySettingsSchema ? + this.widget.latestDataKeySettingsSchema : '', -1); + this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1); + } + + private createAceEditor(editorElementRef: ElementRef, mode: string): Observable { + const editorElement = editorElementRef.nativeElement; + let editorOptions: Partial = { + mode: `ace/mode/${mode}`, + showGutter: true, + showPrintMargin: true + }; + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + editorOptions = {...editorOptions, ...advancedOptions}; + return getAce().pipe( + map((ace) => { + const aceEditor = ace.edit(editorElement, editorOptions); + aceEditor.session.setUseWrapMode(true); + this.aceEditors.push(aceEditor); + this.aceResize$.observe(editorElement); + return aceEditor; + }) + ); + } + + private onAceEditorResize(aceEditor: Ace.Editor) { + if (this.editorsResizeCafs[aceEditor.id]) { + this.editorsResizeCafs[aceEditor.id](); + delete this.editorsResizeCafs[aceEditor.id]; + } + this.editorsResizeCafs[aceEditor.id] = this.raf.raf(() => { + aceEditor.resize(); + aceEditor.renderer.updateFull(); + }); + } + + private onWindowMessage(event: MessageEvent) { + let message: WindowMessage; + if (event.data) { + try { + message = JSON.parse(event.data); + } catch (e) {} + } + if (message) { + switch (message.type) { + case 'widgetException': + this.onWidgetException(message.data); + break; + case 'widgetEditModeInited': + this.onWidgetEditModeInited(); + break; + case 'widgetEditUpdated': + this.onWidgetEditUpdated(message.data); + break; + } + } + } + + private onWidgetEditModeInited() { + this.iframeWidgetEditModeInited = true; + if (this.saveWidgetPending || this.saveWidgetAsPending) { + if (!this.saveWidgetTimeout) { + this.saveWidgetTimeout = setTimeout(() => { + if (!this.gotError) { + if (this.saveWidgetPending) { + this.commitSaveWidget(); + } else if (this.saveWidgetAsPending) { + this.commitSaveWidgetAs(); + } + } else { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('widget.unable-to-save-widget-error'), type: 'error'})); + this.saveWidgetPending = false; + this.saveWidgetAsPending = false; + } + this.saveWidgetTimeout = undefined; + }, 1500); + } + } + } + + private onWidgetEditUpdated(widget: Widget) { + this.widget.sizeX = widget.sizeX / 2; + this.widget.sizeY = widget.sizeY / 2; + this.widget.defaultConfig = JSON.stringify(widget.config); + this.iframe.attr('data-widget', JSON.stringify(this.widget)); + this.isDirty = true; + } + + private onWidgetException(details: ExceptionData) { + if (!this.gotError) { + this.gotError = true; + let errorInfo = 'Error:'; + if (details.name) { + errorInfo += ' ' + details.name + ':'; + } + if (details.message) { + errorInfo += ' ' + details.message; + } + if (details.lineNumber) { + errorInfo += '
Line ' + details.lineNumber; + if (details.columnNumber) { + errorInfo += ' column ' + details.columnNumber; + } + errorInfo += ' of script.'; + } + if (!this.saveWidgetPending && !this.saveWidgetAsPending) { + this.store.dispatch(new ActionNotificationShow( + {message: errorInfo, type: 'error', target: 'javascriptPanel'})); + } + if (details.lineNumber) { + const line = details.lineNumber - 1; + let column = 0; + if (details.columnNumber) { + column = details.columnNumber; + } + const errorMarkerId = this.jsEditor.session.addMarker(new Range(line, 0, line, Infinity), + 'ace_active-line', 'screenLine'); + this.errorMarkers.push(errorMarkerId); + const annotations = this.jsEditor.session.getAnnotations(); + const errorAnnotation: Ace.Annotation = { + row: line, + column, + text: details.message, + type: 'error' + }; + this.errorAnnotationId = annotations.push(errorAnnotation) - 1; + this.jsEditor.session.setAnnotations(annotations); + } + } + } + + private cleanupJsErrors() { + this.store.dispatch(new ActionNotificationHide({})); + this.errorMarkers.forEach((errorMarker) => { + this.jsEditor.session.removeMarker(errorMarker); + }); + this.errorMarkers.length = 0; + if (this.errorAnnotationId > -1) { + const annotations = this.jsEditor.session.getAnnotations(); + annotations.splice(this.errorAnnotationId, 1); + this.jsEditor.session.setAnnotations(annotations); + this.errorAnnotationId = -1; + } + } + + private commitSaveWidget() { + const id = (this.widgetTypeDetails && this.widgetTypeDetails.id) ? this.widgetTypeDetails.id : undefined; + const createdTime = (this.widgetTypeDetails && this.widgetTypeDetails.createdTime) ? this.widgetTypeDetails.createdTime : undefined; + this.widgetService.saveWidgetTypeDetails(this.widget, id, this.widgetsBundle.alias, createdTime).subscribe( + (widgetTypeDetails) => { + this.setWidgetTypeDetails(widgetTypeDetails); + this.saveWidgetPending = false; + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('widget.widget-saved'), type: 'success', duration: 500})); + }, + () => { + this.saveWidgetPending = false; + } + ); + } + + private commitSaveWidgetAs() { + this.dialog.open(SaveWidgetTypeAsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (saveWidgetAsData) => { + if (saveWidgetAsData) { + this.widget.widgetName = saveWidgetAsData.widgetName; + this.widget.alias = undefined; + const config = JSON.parse(this.widget.defaultConfig); + config.title = this.widget.widgetName; + this.widget.defaultConfig = JSON.stringify(config); + this.isDirty = false; + this.widgetService.saveWidgetTypeDetails(this.widget, undefined, saveWidgetAsData.bundleAlias, undefined).subscribe( + (widgetTypeDetails) => { + this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeDetails.id.id}`); + } + ); + } + this.saveWidgetAsPending = false; + } + ); + } + + private setWidgetTypeDetails(widgetTypeDetails: WidgetTypeDetails) { + this.widgetTypeDetails = widgetTypeDetails; + this.widget = detailsToWidgetInfo(this.widgetTypeDetails); + const config = JSON.parse(this.widget.defaultConfig); + this.widget.defaultConfig = JSON.stringify(config); + this.origWidget = deepClone(this.widget); + this.isDirty = false; + } + + applyWidgetScript(): void { + this.cleanupJsErrors(); + this.gotError = false; + this.iframeWidgetEditModeInited = false; + const config: WidgetConfig = JSON.parse(this.widget.defaultConfig); + config.title = this.widget.widgetName; + this.widget.defaultConfig = JSON.stringify(config); + this.iframe.attr('data-widget', JSON.stringify(this.widget)); + // @ts-ignore + this.iframe[0].contentWindow.location.reload(true); + } + + undoWidget(): void { + this.widget = deepClone(this.origWidget); + this.setAceEditorValues(); + this.isDirty = false; + this.applyWidgetScript(); + } + + saveWidget(): void { + if (!this.widget.widgetName) { + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('widget.missing-widget-title-error'), type: 'error'})); + } else { + this.saveWidgetPending = true; + this.applyWidgetScript(); + } + } + + saveWidgetAs(): void { + this.saveWidgetAsPending = true; + this.applyWidgetScript(); + } + + undoDisabled(): boolean { + return !this.isDirty + || !this.iframeWidgetEditModeInited + || this.saveWidgetPending + || this.saveWidgetAsPending; + } + + saveDisabled(): boolean { + return this.isReadOnly + || !this.isDirty + || !this.iframeWidgetEditModeInited + || this.saveWidgetPending + || this.saveWidgetAsPending; + } + + saveAsDisabled(): boolean { + return !this.iframeWidgetEditModeInited + || this.saveWidgetPending + || this.saveWidgetAsPending; + } + + beautifyCss(): void { + beautifyCss(this.widget.templateCss, {indent_size: 4}).subscribe( + (res) => { + if (this.widget.templateCss !== res) { + this.isDirty = true; + this.widget.templateCss = res; + this.cssEditor.setValue(this.widget.templateCss ? this.widget.templateCss : '', -1); + } + } + ); + } + + beautifyHtml(): void { + beautifyHtml(this.widget.templateHtml, {indent_size: 4, wrap_line_length: 60}).subscribe( + (res) => { + if (this.widget.templateHtml !== res) { + this.isDirty = true; + this.widget.templateHtml = res; + this.htmlEditor.setValue(this.widget.templateHtml ? this.widget.templateHtml : '', -1); + } + } + ); + } + + beautifyJson(): void { + beautifyJs(this.widget.settingsSchema, {indent_size: 4}).subscribe( + (res) => { + if (this.widget.settingsSchema !== res) { + this.isDirty = true; + this.widget.settingsSchema = res; + this.jsonSettingsEditor.setValue(this.widget.settingsSchema ? this.widget.settingsSchema : '', -1); + } + } + ); + } + + beautifyDataKeyJson(): void { + beautifyJs(this.widget.dataKeySettingsSchema, {indent_size: 4}).subscribe( + (res) => { + if (this.widget.dataKeySettingsSchema !== res) { + this.isDirty = true; + this.widget.dataKeySettingsSchema = res; + this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1); + } + } + ); + } + + beautifyLatestDataKeyJson(): void { + beautifyJs(this.widget.latestDataKeySettingsSchema, {indent_size: 4}).subscribe( + (res) => { + if (this.widget.latestDataKeySettingsSchema !== res) { + this.isDirty = true; + this.widget.latestDataKeySettingsSchema = res; + this.latestDataKeyJsonSettingsEditor.setValue(this.widget.latestDataKeySettingsSchema ? + this.widget.latestDataKeySettingsSchema : '', -1); + } + } + ); + } + + beautifyJs(): void { + beautifyJs(this.widget.controllerScript, {indent_size: 4, wrap_line_length: 60}).subscribe( + (res) => { + if (this.widget.controllerScript !== res) { + this.isDirty = true; + this.widget.controllerScript = res; + this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1); + } + } + ); + } + + removeResource(index: number) { + if (index > -1) { + if (this.widget.resources.splice(index, 1).length > 0) { + this.isDirty = true; + } + } + } + + addResource() { + this.widget.resources.push({url: ''}); + this.isDirty = true; + } + + widetTypeChanged() { + const config: WidgetConfig = JSON.parse(this.widget.defaultConfig); + if (this.widget.type !== widgetType.rpc && + this.widget.type !== widgetType.alarm) { + if (config.targetDeviceAliases) { + delete config.targetDeviceAliases; + } + if (config.alarmSource) { + delete config.alarmSource; + } + if (!config.datasources) { + config.datasources = []; + } + if (!config.timewindow) { + config.timewindow = { + realtime: { + timewindowMs: 60000 + } + }; + } + } else if (this.widget.type === widgetType.rpc) { + if (config.datasources) { + delete config.datasources; + } + if (config.alarmSource) { + delete config.alarmSource; + } + if (config.timewindow) { + delete config.timewindow; + } + if (!config.targetDeviceAliases) { + config.targetDeviceAliases = []; + } + } else { // alarm + if (config.datasources) { + delete config.datasources; + } + if (config.targetDeviceAliases) { + delete config.targetDeviceAliases; + } + if (!config.alarmSource) { + config.alarmSource = {}; + } + if (!config.timewindow) { + config.timewindow = { + realtime: { + timewindowMs: 24 * 60 * 60 * 1000 + } + }; + } + } + this.widget.defaultConfig = JSON.stringify(config); + this.isDirty = true; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts new file mode 100644 index 0000000..607896b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; +import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models'; +import { serviceCompletions } from '@shared/models/ace/service-completion.models'; + +const widgetEditorCompletions: TbEditorCompletions = { + ... {self: { + description: 'Built-in variable self that is a reference to the widget instance', + type: 'WidgetTypeInstance', + meta: 'object', + children: { + ...{ + onInit: { + description: 'The first function which is called when widget is ready for initialization.
Should be used to prepare widget DOM, process widget settings and initial subscription information.', + meta: 'function' + }, + onDataUpdated: { + description: 'Called when the new data is available from the widget subscription.
Latest data can be accessed from ' + + 'the defaultSubscription property of widget context (ctx).', + meta: 'function' + }, + onResize: { + description: 'Called when widget container is resized. Latest width and height can be obtained from widget context (ctx).', + meta: 'function' + }, + onEditModeChanged: { + description: 'Called when dashboard editing mode is changed. Latest mode is handled by isEdit property of widget context (ctx).', + meta: 'function' + }, + onMobileModeChanged: { + description: 'Called when dashboard view width crosses mobile breakpoint. Latest state is handled by isMobile property of widget context (ctx).', + meta: 'function' + }, + onDestroy: { + description: 'Called when widget element is destroyed. Should be used to cleanup all resources if necessary.', + meta: 'function' + }, + getSettingsSchema: { + description: 'Optional function returning widget settings schema json as alternative to Settings tab of Settings schema section.', + meta: 'function', + return: { + description: 'An widget settings schema json', + type: 'object' + } + }, + getDataKeySettingsSchema: { + description: 'Optional function returning particular data key settings schema json as alternative to Data key settings schema of Settings schema section.', + meta: 'function', + return: { + description: 'A particular data key settings schema json', + type: 'object' + } + }, + typeParameters: { + description: 'Returns object describing widget datasource parameters.', + meta: 'function', + return: { + description: 'An object describing widget datasource parameters.', + type: 'WidgetTypeParameters' + } + }, + actionSources: { + description: 'Returns map describing available widget action sources used to define user actions.', + meta: 'function', + return: { + description: 'A map of action sources by action source id.', + type: '{[actionSourceId: string]: WidgetActionSource}' + } + } + }, + ...widgetContextCompletions + } + }}, + ...widgetContextCompletions, + ...serviceCompletions +}; + +export const widgetEditorCompleter = new TbEditorCompleter(widgetEditorCompletions); diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts new file mode 100644 index 0000000..974f0f0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts @@ -0,0 +1,217 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '../../components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { WidgetsBundlesTableConfigResolver } from '@modules/home/pages/widget/widgets-bundles-table-config.resolver'; +import { WidgetLibraryComponent } from '@home/pages/widget/widget-library.component'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; +import { Observable } from 'rxjs'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetService } from '@core/http/widget.service'; +import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.component'; +import { map } from 'rxjs/operators'; +import { detailsToWidgetInfo, toWidgetInfo, WidgetInfo } from '@home/models/widget-component.models'; +import { widgetType, WidgetType, WidgetTypeDetails } from '@app/shared/models/widget.models'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { WidgetsData } from '@home/models/dashboard-component.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +export interface WidgetEditorData { + widgetTypeDetails: WidgetTypeDetails; + widget: WidgetInfo; +} + +@Injectable() +export class WidgetsBundleResolver implements Resolve { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + let widgetsBundleId = route.params.widgetsBundleId; + if (!widgetsBundleId) { + widgetsBundleId = route.parent.params.widgetsBundleId; + } + return this.widgetsService.getWidgetsBundle(widgetsBundleId); + } +} + +@Injectable() +export class WidgetsTypesDataResolver implements Resolve { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const widgetsBundle: WidgetsBundle = route.parent.data.widgetsBundle; + const bundleAlias = widgetsBundle.alias; + const isSystem = widgetsBundle.tenantId.id === NULL_UUID; + return this.widgetsService.loadBundleLibraryWidgets(bundleAlias, + isSystem).pipe( + map((widgets) => { + return { widgets }; + } + )); + } +} + +@Injectable() +export class WidgetEditorDataResolver implements Resolve { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + const widgetTypeId = route.params.widgetTypeId; + return this.widgetsService.getWidgetTypeById(widgetTypeId).pipe( + map((result) => { + return { + widgetTypeDetails: result, + widget: detailsToWidgetInfo(result) + }; + }) + ); + } +} + +@Injectable() +export class WidgetEditorAddDataResolver implements Resolve { + + constructor(private widgetsService: WidgetService) { + } + + resolve(route: ActivatedRouteSnapshot): Observable { + let widgetTypeParam = route.params.widgetType as widgetType; + if (!widgetTypeParam) { + widgetTypeParam = widgetType.timeseries; + } + return this.widgetsService.getWidgetTemplate(widgetTypeParam).pipe( + map((widget) => { + widget.widgetName = null; + return { + widgetTypeDetails: null, + widget + }; + }) + ); + } +} + +export const widgetTypesBreadcumbLabelFunction: BreadCrumbLabelFunction = ((route, translate) => + route.data.widgetsBundle.title); + +export const widgetEditorBreadcumbLabelFunction: BreadCrumbLabelFunction = + ((route, translate, component) => component ? component.widget.widgetName : ''); + +export const routes: Routes = [ + { + path: 'widgets-bundles', + data: { + breadcrumb: { + label: 'widgets-bundle.widgets-bundles', + icon: 'now_widgets' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widgets-bundle.widgets-bundles' + }, + resolve: { + entitiesTableConfig: WidgetsBundlesTableConfigResolver + } + }, + { + path: ':widgetsBundleId/widgetTypes', + data: { + breadcrumb: { + labelFunction: widgetTypesBreadcumbLabelFunction, + icon: 'now_widgets' + } as BreadCrumbConfig + }, + resolve: { + widgetsBundle: WidgetsBundleResolver + }, + children: [ + { + path: '', + component: WidgetLibraryComponent, + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widget.widget-library' + }, + resolve: { + widgetsData: WidgetsTypesDataResolver + } + }, + { + path: ':widgetTypeId', + component: WidgetEditorComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widget.editor', + breadcrumb: { + labelFunction: widgetEditorBreadcumbLabelFunction, + icon: 'insert_chart' + } as BreadCrumbConfig + }, + resolve: { + widgetEditorData: WidgetEditorDataResolver + } + }, + { + path: 'add/:widgetType', + component: WidgetEditorComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'widget.editor', + breadcrumb: { + labelFunction: widgetEditorBreadcumbLabelFunction, + icon: 'insert_chart' + } as BreadCrumbConfig + }, + resolve: { + widgetEditorData: WidgetEditorAddDataResolver + } + } + ] + } + ] + } +]; + +// @dynamic +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + WidgetsBundlesTableConfigResolver, + WidgetsBundleResolver, + WidgetsTypesDataResolver, + WidgetEditorDataResolver, + WidgetEditorAddDataResolver + ] +}) +export class WidgetLibraryRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html new file mode 100644 index 0000000..1ca4060 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html @@ -0,0 +1,42 @@ + +
+ + widgets-bundle.empty +
+ + + diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss new file mode 100644 index 0000000..c0f3e21 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + button.tb-add-new-widget { + padding-right: 12px; + font-size: 24px; + border-style: dashed; + border-width: 2px; + } +} + +:host ::ng-deep { + .tb-widget-library { + .tb-widget-container { + cursor: pointer; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts new file mode 100644 index 0000000..970fa8e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts @@ -0,0 +1,208 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { AuthUser } from '@shared/models/user.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { of } from 'rxjs'; +import { Widget, widgetType } from '@app/shared/models/widget.models'; +import { WidgetService } from '@core/http/widget.service'; +import { map, mergeMap } from 'rxjs/operators'; +import { DialogService } from '@core/services/dialog.service'; +import { FooterFabButtons } from '@app/shared/components/footer-fab-buttons.component'; +import { DashboardCallbacks, IDashboardComponent, WidgetsData } from '@home/models/dashboard-component.models'; +import { IAliasController, IStateController, StateParams } from '@app/core/api/widget-api.models'; +import { AliasController } from '@core/api/alias-controller'; +import { MatDialog } from '@angular/material/dialog'; +import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; +import { TranslateService } from '@ngx-translate/core'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityService } from '@core/http/entity.service'; + +@Component({ + selector: 'tb-widget-library', + templateUrl: './widget-library.component.html', + styleUrls: ['./widget-library.component.scss'] +}) +export class WidgetLibraryComponent extends PageComponent implements OnInit { + + authUser: AuthUser; + + isReadOnly: boolean; + + widgetsBundle: WidgetsBundle; + + widgetsData: WidgetsData; + + footerFabButtons: FooterFabButtons = { + fabTogglerName: 'widget.add-widget-type', + fabTogglerIcon: 'add', + buttons: [ + { + name: 'widget-type.create-new-widget-type', + icon: 'insert_drive_file', + onAction: ($event) => { + this.addWidgetType($event); + } + }, + { + name: 'widget-type.import', + icon: 'file_upload', + onAction: ($event) => { + this.importWidgetType($event); + } + } + ] + }; + + dashboardCallbacks: DashboardCallbacks = { + onEditWidget: this.openWidgetType.bind(this), + onWidgetClicked: this.openWidgetType.bind(this), + onExportWidget: this.exportWidgetType.bind(this), + onRemoveWidget: this.removeWidgetType.bind(this) + }; + + aliasController: IAliasController = new AliasController(this.utils, + this.entityService, + this.translate, + () => { return { + getStateParams(): StateParams { + return {}; + } + } as IStateController; + }, + {}, + {}); + + @ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent; + + constructor(protected store: Store, + private route: ActivatedRoute, + private router: Router, + private widgetService: WidgetService, + private dialogService: DialogService, + private importExport: ImportExportService, + private dialog: MatDialog, + private translate: TranslateService, + private utils: UtilsService, + private entityService: EntityService) { + super(store); + + this.authUser = getCurrentAuthUser(this.store); + this.widgetsBundle = this.route.snapshot.data.widgetsBundle; + this.widgetsData = this.route.snapshot.data.widgetsData; + if (this.authUser.authority === Authority.TENANT_ADMIN) { + this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID; + } else { + this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; + } + } + + ngOnInit(): void { + } + + addWidgetType($event: Event): void { + this.openWidgetType($event); + } + + importWidgetType($event: Event): void { + if ($event) { + $event.stopPropagation(); + } + this.importExport.importWidgetType(this.widgetsBundle.alias).subscribe( + (widgetTypeInstance) => { + if (widgetTypeInstance) { + this.reload(); + } + } + ); + } + + private reload() { + const bundleAlias = this.widgetsBundle.alias; + const isSystem = this.widgetsBundle.tenantId.id === NULL_UUID; + this.widgetService.loadBundleLibraryWidgets(bundleAlias, isSystem).subscribe( + (widgets) => { + this.widgetsData = {widgets}; + } + ); + } + + openWidgetType($event: Event, widget?: Widget): void { + if ($event) { + $event.stopPropagation(); + } + if (widget) { + this.router.navigate([widget.typeId.id], {relativeTo: this.route}); + } else { + this.dialog.open(SelectWidgetTypeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (type) => { + if (type) { + this.router.navigate(['add', type], {relativeTo: this.route}); + } + } + ); + } + } + + exportWidgetType($event: Event, widget: Widget): void { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportWidgetType(widget.typeId.id); + } + + removeWidgetType($event: Event, widget: Widget): void { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('widget.remove-widget-type-title', {widgetName: widget.config.title}), + this.translate.instant('widget.remove-widget-type-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + ).pipe( + mergeMap((result) => { + if (result) { + return this.widgetService.deleteWidgetType(widget.bundleAlias, widget.typeAlias, widget.isSystemType); + } else { + return of(false); + } + }), + map((result) => { + if (result !== false) { + this.reload(); + return true; + } else { + return false; + } + } + )).subscribe(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts new file mode 100644 index 0000000..6a6e4a4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.module.ts @@ -0,0 +1,45 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { WidgetsBundleComponent } from '@modules/home/pages/widget/widgets-bundle.component'; +import { WidgetLibraryRoutingModule } from '@modules/home/pages/widget/widget-library-routing.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { WidgetLibraryComponent } from './widget-library.component'; +import { WidgetEditorComponent } from '@home/pages/widget/widget-editor.component'; +import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; +import { SaveWidgetTypeAsDialogComponent } from './save-widget-type-as-dialog.component'; +import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-tabs.component'; + +@NgModule({ + declarations: [ + WidgetsBundleComponent, + WidgetLibraryComponent, + WidgetEditorComponent, + SelectWidgetTypeDialogComponent, + SaveWidgetTypeAsDialogComponent, + WidgetsBundleTabsComponent + ], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + WidgetLibraryRoutingModule + ] +}) +export class WidgetLibraryModule { } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html new file mode 100644 index 0000000..8301560 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts new file mode 100644 index 0000000..72a473b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-widgets-bundle-tabs', + templateUrl: './widgets-bundle-tabs.component.html', + styleUrls: [] +}) +export class WidgetsBundleTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + isTenantWidgetsBundle() { + return this.entity && this.entity.tenantId.id !== NULL_UUID; + } + + ngOnInit() { + super.ngOnInit(); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html new file mode 100644 index 0000000..05847aa --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -0,0 +1,63 @@ + +
+ + + +
+
+
+
+ + widgets-bundle.title + + + {{ 'widgets-bundle.title-required' | translate }} + + + {{ 'widgets-bundle.title-max-length' | translate }} + + + + + + widgets-bundle.description + + {{descriptionInput.value?.length || 0}}/255 + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.scss b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts new file mode 100644 index 0000000..3f4abd6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; + +@Component({ + selector: 'tb-widgets-bundle', + templateUrl: './widgets-bundle.component.html', + styleUrls: ['./widgets-bundle.component.scss'] +}) +export class WidgetsBundleComponent extends EntityComponent { + + constructor(protected store: Store, + @Inject('entity') protected entityValue: WidgetsBundle, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + public fb: FormBuilder, + protected cd: ChangeDetectorRef) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: WidgetsBundle): FormGroup { + return this.fb.group( + { + title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], + image: [entity ? entity.image : ''], + description: [entity ? entity.description : '', Validators.maxLength(255)] + } + ); + } + + updateForm(entity: WidgetsBundle) { + this.entityForm.patchValue({ + title: entity.title, + image: entity.image, + description: entity.description + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts new file mode 100644 index 0000000..f501007 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundles-table-config.resolver.ts @@ -0,0 +1,185 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +import { Resolve, Router } from '@angular/router'; +import { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetService } from '@app/core/http/widget.service'; +import { WidgetsBundleComponent } from '@modules/home/pages/widget/widgets-bundle.component'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthState, getCurrentAuthUser } from '@app/core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { DialogService } from '@core/services/dialog.service'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; +import { Direction } from '@shared/models/page/sort-order'; +import { map } from 'rxjs/operators'; +import { WidgetsBundleTabsComponent } from '@home/pages/widget/widgets-bundle-tabs.component'; + +@Injectable() +export class WidgetsBundlesTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private store: Store, + private dialogService: DialogService, + private widgetsService: WidgetService, + private translate: TranslateService, + private importExport: ImportExportService, + private datePipe: DatePipe, + private router: Router) { + + this.config.entityType = EntityType.WIDGETS_BUNDLE; + this.config.entityComponent = WidgetsBundleComponent; + this.config.entityTabsComponent = WidgetsBundleTabsComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.WIDGETS_BUNDLE); + this.config.entityResources = entityTypeResources.get(EntityType.WIDGETS_BUNDLE); + this.config.defaultSortOrder = {property: 'title', direction: Direction.ASC}; + + this.config.rowPointer = true; + + this.config.entityTitle = (widgetsBundle) => widgetsBundle ? + widgetsBundle.title : ''; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'widgets-bundle.title', '100%'), + new EntityTableColumn('tenantId', 'widgets-bundle.system', '60px', + entity => { + return checkBoxCell(entity.tenantId.id === NULL_UUID); + }), + ); + + this.config.addActionDescriptors.push( + { + name: this.translate.instant('widgets-bundle.create-new-widgets-bundle'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.config.getTable().addEntity($event) + }, + { + name: this.translate.instant('widgets-bundle.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: ($event) => this.importWidgetsBundle($event) + } + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('widgets-bundle.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportWidgetsBundle($event, entity) + }, + { + name: this.translate.instant('widgets-bundle.widgets-bundle-details'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.config.toggleEntityDetails($event, entity) + } + ); + + this.config.deleteEntityTitle = widgetsBundle => this.translate.instant('widgets-bundle.delete-widgets-bundle-title', + { widgetsBundleTitle: widgetsBundle.title }); + this.config.deleteEntityContent = () => this.translate.instant('widgets-bundle.delete-widgets-bundle-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('widgets-bundle.delete-widgets-bundles-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('widgets-bundle.delete-widgets-bundles-text'); + + + this.config.loadEntity = id => this.widgetsService.getWidgetsBundle(id.id); + this.config.saveEntity = widgetsBundle => this.widgetsService.saveWidgetsBundle(widgetsBundle); + this.config.deleteEntity = id => this.widgetsService.deleteWidgetsBundle(id.id); + this.config.onEntityAction = action => this.onWidgetsBundleAction(action); + + this.config.handleRowClick = ($event, widgetsBundle) => { + if (this.config.isDetailsOpen()) { + this.config.toggleEntityDetails($event, widgetsBundle); + } else { + this.openWidgetsBundle($event, widgetsBundle); + } + return true; + }; + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('widgets-bundle.widgets-bundles'); + const authUser = getCurrentAuthUser(this.store); + this.config.deleteEnabled = (widgetsBundle) => this.isWidgetsBundleEditable(widgetsBundle, authUser.authority); + this.config.entitySelectionEnabled = (widgetsBundle) => this.isWidgetsBundleEditable(widgetsBundle, authUser.authority); + this.config.detailsReadonly = (widgetsBundle) => !this.isWidgetsBundleEditable(widgetsBundle, authUser.authority); + const authState = getCurrentAuthState(this.store); + this.config.entitiesFetchFunction = pageLink => this.widgetsService.getWidgetBundles(pageLink); + return this.config; + } + + isWidgetsBundleEditable(widgetsBundle: WidgetsBundle, authority: Authority): boolean { + if (authority === Authority.TENANT_ADMIN) { + return widgetsBundle && widgetsBundle.tenantId && widgetsBundle.tenantId.id !== NULL_UUID; + } else { + return authority === Authority.SYS_ADMIN; + } + } + + importWidgetsBundle($event: Event) { + this.importExport.importWidgetsBundle().subscribe( + (widgetsBundle) => { + if (widgetsBundle) { + this.config.updateData(); + } + } + ); + } + + openWidgetsBundle($event: Event, widgetsBundle: WidgetsBundle) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`widgets-bundles/${widgetsBundle.id.id}/widgetTypes`); + } + + exportWidgetsBundle($event: Event, widgetsBundle: WidgetsBundle) { + if ($event) { + $event.stopPropagation(); + } + this.importExport.exportWidgetsBundle(widgetsBundle.id.id); + } + + onWidgetsBundleAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openWidgetsBundle(action.event, action.entity); + return true; + case 'export': + this.exportWidgetsBundle(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/public-api.ts b/ui-ngx/src/app/modules/home/public-api.ts new file mode 100644 index 0000000..a216fc2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/public-api.ts @@ -0,0 +1,17 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './home.module'; diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts new file mode 100644 index 0000000..874ade6 --- /dev/null +++ b/ui-ngx/src/app/modules/login/login-routing.module.ts @@ -0,0 +1,91 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { LoginComponent } from './pages/login/login.component'; +import { AuthGuard } from '@core/guards/auth.guard'; +import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component'; +import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component'; +import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; +import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; +import { Authority } from '@shared/models/authority.enum'; + +const routes: Routes = [ + { + path: 'login', + component: LoginComponent, + data: { + title: 'login.login', + module: 'public' + }, + canActivate: [AuthGuard] + }, + { + path: 'login/resetPasswordRequest', + component: ResetPasswordRequestComponent, + data: { + title: 'login.request-password-reset', + module: 'public' + }, + canActivate: [AuthGuard] + }, + { + path: 'login/resetPassword', + component: ResetPasswordComponent, + data: { + title: 'login.reset-password', + module: 'public' + }, + canActivate: [AuthGuard] + }, + { + path: 'login/resetExpiredPassword', + component: ResetPasswordComponent, + data: { + title: 'login.reset-password', + module: 'public', + expiredPassword: true + }, + canActivate: [AuthGuard] + }, + { + path: 'login/createPassword', + component: CreatePasswordComponent, + data: { + title: 'login.create-password', + module: 'public' + }, + canActivate: [AuthGuard] + }, + { + path: 'login/mfa', + component: TwoFactorAuthLoginComponent, + data: { + title: 'login.two-factor-authentication', + auth: [Authority.PRE_VERIFICATION_TOKEN], + module: 'public' + }, + canActivate: [AuthGuard] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class LoginRoutingModule { } diff --git a/ui-ngx/src/app/modules/login/login.module.ts b/ui-ngx/src/app/modules/login/login.module.ts new file mode 100644 index 0000000..19588cf --- /dev/null +++ b/ui-ngx/src/app/modules/login/login.module.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { LoginRoutingModule } from './login-routing.module'; +import { LoginComponent } from './pages/login/login.component'; +import { SharedModule } from '@app/shared/shared.module'; +import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component'; +import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component'; +import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; +import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; + +@NgModule({ + declarations: [ + LoginComponent, + ResetPasswordRequestComponent, + ResetPasswordComponent, + CreatePasswordComponent, + TwoFactorAuthLoginComponent + ], + imports: [ + CommonModule, + SharedModule, + LoginRoutingModule + ] +}) +export class LoginModule { } diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html new file mode 100644 index 0000000..764f5e5 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html @@ -0,0 +1,58 @@ + +
+ + + login.create-password + + + + + +
+
+
+ + + common.password + + lock + + + + login.password-again + + lock + + +
+ + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss b/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss new file mode 100644 index 0000000..e49395a --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + .tb-create-password-content { + background-color: #eee; + .tb-create-password-card { + @media #{$mat-gt-xs} { + width: 450px !important; + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts new file mode 100644 index 0000000..39e79c5 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -0,0 +1,74 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'tb-create-password', + templateUrl: './create-password.component.html', + styleUrls: ['./create-password.component.scss'] +}) +export class CreatePasswordComponent extends PageComponent implements OnInit, OnDestroy { + + activateToken = ''; + sub: Subscription; + + createPassword = this.fb.group({ + password: [''], + password2: [''] + }); + + constructor(protected store: Store, + private route: ActivatedRoute, + private authService: AuthService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.sub = this.route + .queryParams + .subscribe(params => { + this.activateToken = params.activateToken || ''; + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.sub.unsubscribe(); + } + + onCreatePassword() { + if (this.createPassword.get('password').value !== this.createPassword.get('password2').value) { + this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), + type: 'error' })); + } else { + this.authService.activate( + this.activateToken, + this.createPassword.get('password').value, true).subscribe(); + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html new file mode 100644 index 0000000..9a02067 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -0,0 +1,71 @@ + + diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.scss b/ui-ngx/src/app/modules/login/pages/login/login.component.scss new file mode 100644 index 0000000..34356eb --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.scss @@ -0,0 +1,93 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + .tb-login-content { + margin-top: 36px; + margin-bottom: 76px; + background-color: #eee; + .tb-login-form { + @media #{$mat-gt-xs} { + width: 550px !important; + } + + .forgot-password { + padding: 0 0.5em 1em; + .tb-reset-password { + padding: 0 6px; + } + } + + .tb-action-button{ + padding: 20px 0 16px; + } + } + + .oauth-container{ + padding: 0; + + .container-divider { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + + .line { + flex: 1; + } + + .mat-divider-horizontal{ + position: relative; + } + + .text { + padding-right: 10px; + padding-left: 10px; + } + } + + .material-icons{ + width: 20px; + min-width: 20px; + } + + a.login-with-button { + color: rgba(black, 0.87); + + &:hover { + border-bottom: 0; + } + + .icon{ + height: 20px; + width: 20px; + vertical-align: sub; + } + } + + .centered ::ng-deep .mat-button-wrapper { + display: flex; + justify-content: center; + align-items: center; + } + } + } +} + diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.ts b/ui-ngx/src/app/modules/login/pages/login/login.component.ts new file mode 100644 index 0000000..4b39c1e --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.ts @@ -0,0 +1,79 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Constants } from '@shared/models/constants'; +import { Router } from '@angular/router'; +import { OAuth2ClientInfo } from '@shared/models/oauth2.models'; + +@Component({ + selector: 'tb-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent extends PageComponent implements OnInit { + + loginFormGroup = this.fb.group({ + username: '', + password: '' + }); + oauth2Clients: Array = null; + + constructor(protected store: Store, + private authService: AuthService, + public fb: FormBuilder, + private router: Router) { + super(store); + } + + ngOnInit() { + this.oauth2Clients = this.authService.oauth2Clients; + } + + login(): void { + if (this.loginFormGroup.valid) { + this.authService.login(this.loginFormGroup.value).subscribe( + () => {}, + (error: HttpErrorResponse) => { + if (error && error.error && error.error.errorCode) { + if (error.error.errorCode === Constants.serverErrorCode.credentialsExpired) { + this.router.navigateByUrl(`login/resetExpiredPassword?resetToken=${error.error.resetToken}`); + } + } + } + ); + } else { + Object.keys(this.loginFormGroup.controls).forEach(field => { + const control = this.loginFormGroup.get(field); + control.markAsTouched({onlySelf: true}); + }); + } + } + + getOAuth2Uri(oauth2Client: OAuth2ClientInfo): string { + let result = ""; + if (this.authService.redirectUrl) { + result += "?prevUri=" + this.authService.redirectUrl; + } + return oauth2Client.url + result; + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html new file mode 100644 index 0000000..15e39d7 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html @@ -0,0 +1,55 @@ + +
+ + + login.request-password-reset + + + + + +
+
+
+ + + login.email + + email + + {{ 'user.invalid-email-format' | translate }} + + +
+ + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss new file mode 100644 index 0000000..9e64eef --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + .tb-request-password-reset-content { + background-color: #eee; + .tb-request-password-reset-card { + @media #{$mat-gt-xs} { + width: 450px !important; + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts new file mode 100644 index 0000000..330a3ea --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder, Validators } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-reset-password-request', + templateUrl: './reset-password-request.component.html', + styleUrls: ['./reset-password-request.component.scss'] +}) +export class ResetPasswordRequestComponent extends PageComponent implements OnInit { + + clicked: boolean = false; + + requestPasswordRequest = this.fb.group({ + email: ['', [Validators.email, Validators.required]] + }, {updateOn: 'submit'}); + + constructor(protected store: Store, + private authService: AuthService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + } + + disableInputs() { + this.requestPasswordRequest.disable(); + this.clicked = true; + } + + sendResetPasswordLink() { + if (this.requestPasswordRequest.valid) { + this.disableInputs(); + this.authService.sendResetPasswordLink(this.requestPasswordRequest.get('email').value).subscribe( + () => { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('login.password-link-sent-message'), + type: 'success' + })); + } + ); + } + } + +} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html new file mode 100644 index 0000000..c88b30d --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html @@ -0,0 +1,61 @@ + +
+ + + login.password-reset + + +
login.expired-password-reset-message
+
+ + + + +
+
+
+ + + login.new-password + + lock + + + + login.new-password-again + + lock + + +
+ + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss new file mode 100644 index 0000000..101054a --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + .tb-reset-password-content { + background-color: #eee; + .tb-reset-password-card { + @media #{$mat-gt-sm} { + width: 450px !important; + } + } + + .tb-card-title{ + padding-top: 0; + padding-bottom: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts new file mode 100644 index 0000000..5122862 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts @@ -0,0 +1,77 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'tb-reset-password', + templateUrl: './reset-password.component.html', + styleUrls: ['./reset-password.component.scss'] +}) +export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy { + + isExpiredPassword: boolean; + + resetToken = ''; + sub: Subscription; + + resetPassword = this.fb.group({ + newPassword: [''], + newPassword2: [''] + }); + + constructor(protected store: Store, + private route: ActivatedRoute, + private authService: AuthService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.isExpiredPassword = this.route.snapshot.data.expiredPassword; + this.sub = this.route + .queryParams + .subscribe(params => { + this.resetToken = params.resetToken || ''; + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.sub.unsubscribe(); + } + + onResetPassword() { + if (this.resetPassword.get('newPassword').value !== this.resetPassword.get('newPassword2').value) { + this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), + type: 'error' })); + } else { + this.authService.resetPassword( + this.resetToken, + this.resetPassword.get('newPassword').value).subscribe(); + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html new file mode 100644 index 0000000..cebcc49 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html @@ -0,0 +1,95 @@ + + diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss new file mode 100644 index 0000000..a50d31e --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + + .tb-two-factor-auth-login-content { + background-color: #eee; + + .tb-two-factor-auth-login-card { + padding: 48px 48px 48px 16px; + + @media #{$mat-gt-xs} { + width: 450px !important; + } + + .mat-card-title { + font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif; + } + + .mat-card-content { + margin-top: 44px; + margin-left: 40px; + } + + .mat-body { + letter-spacing: 0.25px; + line-height: 16px; + margin: 0; + } + + .code-block { + margin-top: 16px; + } + + .providers-container { + padding: 0; + + .mat-body { + padding-bottom: 8px; + } + } + + .timer { + font: 500 12px / 14px Roboto, "Helvetica Neue", sans-serif; + color: rgba(255, 255, 255, 0.8); + } + + .action-row:nth-child(n) { + min-height: 36px; + + .action-resend { + min-width: 50%; + } + } + } + } + + ::ng-deep { + button.provider { + text-align: start; + font-weight: 400; + + &:not(.mat-button-disabled) { + border-color: rgba(255, 255, 255, .8); + } + + .icon { + height: 18px; + width: 18px; + vertical-align: sub; + } + } + + .mat-form-field-invalid .mat-hint { + margin-top: 20px; + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts new file mode 100644 index 0000000..13e5911 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts @@ -0,0 +1,204 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + twoFactorAuthProvidersLoginData, + TwoFactorAuthProviderType, + TwoFaProviderInfo +} from '@shared/models/two-factor-auth.models'; +import { TranslateService } from '@ngx-translate/core'; +import { interval, Subscription } from 'rxjs'; +import { isEqual } from '@core/utils'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; + +@Component({ + selector: 'tb-two-factor-auth-login', + templateUrl: './two-factor-auth-login.component.html', + styleUrls: ['./two-factor-auth-login.component.scss'] +}) +export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { + + private providersInfo: TwoFaProviderInfo[]; + private prevProvider: TwoFactorAuthProviderType; + private timer: Subscription; + private minVerificationPeriod = 0; + private timerID: NodeJS.Timeout; + + showResendAction = false; + selectedProvider: TwoFactorAuthProviderType; + allowProviders: TwoFactorAuthProviderType[] = []; + + providersData = twoFactorAuthProvidersLoginData; + providerDescription = ''; + hideResendButton = true; + countDownTime = 0; + + maxLengthInput = 6; + inputMode = 'numeric'; + pattern = '[0-9]*'; + + verificationForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + + constructor(protected store: Store, + private twoFactorAuthService: TwoFactorAuthenticationService, + private authService: AuthService, + private translate: TranslateService, + private fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.providersInfo = this.authService.twoFactorAuthProviders; + Object.values(TwoFactorAuthProviderType).forEach(provider => { + const providerConfig = this.providersInfo.find(config => config.type === provider); + if (providerConfig) { + if (providerConfig.default) { + this.selectedProvider = providerConfig.type; + this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, { + contact: providerConfig.contact + }); + this.minVerificationPeriod = providerConfig?.minVerificationCodeSendPeriod || 30; + } + this.allowProviders.push(providerConfig.type); + } + }); + if (this.selectedProvider !== TwoFactorAuthProviderType.TOTP) { + this.sendCode(); + this.showResendAction = true; + } + this.timer = interval(1000).subscribe(() => this.updatedTime()); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.timer.unsubscribe(); + clearTimeout(this.timerID); + } + + sendVerificationCode() { + if (this.verificationForm.valid && this.selectedProvider) { + this.authService.checkTwoFaVerificationCode(this.selectedProvider, this.verificationForm.get('verificationCode').value).subscribe( + () => {}, + (error) => { + if (error.status === 400) { + this.verificationForm.get('verificationCode').setErrors({incorrectCode: true}); + } else if (error.status === 429) { + this.verificationForm.get('verificationCode').setErrors({tooManyRequest: true}); + this.timerID = setTimeout(() => { + let errors = this.verificationForm.get('verificationCode').errors; + delete errors.tooManyRequest; + if (isEqual(errors, {})) { + errors = null; + } + this.verificationForm.get('verificationCode').setErrors(errors); + }, 5000); + } else { + this.store.dispatch(new ActionNotificationShow({ + message: error.error.message, + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } + } + ); + } + } + + selectProvider(type: TwoFactorAuthProviderType) { + this.prevProvider = type === null ? this.selectedProvider : null; + this.selectedProvider = type; + this.showResendAction = false; + if (type !== null) { + this.verificationForm.get('verificationCode').reset(); + const providerConfig = this.providersInfo.find(config => config.type === type); + this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, { + contact: providerConfig.contact + }); + if (type !== TwoFactorAuthProviderType.TOTP && type !== TwoFactorAuthProviderType.BACKUP_CODE) { + this.sendCode(); + this.showResendAction = true; + this.minVerificationPeriod = providerConfig?.minVerificationCodeSendPeriod || 30; + } + if (type === TwoFactorAuthProviderType.BACKUP_CODE) { + this.verificationForm.get('verificationCode').setValidators([ + Validators.required, + Validators.minLength(8), + Validators.maxLength(8), + Validators.pattern(/^[\dabcdef]*$/) + ]); + this.maxLengthInput = 8; + this.inputMode = 'text'; + this.pattern = '[0-9abcdef]*'; + } else { + this.verificationForm.get('verificationCode').setValidators([ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]); + this.maxLengthInput = 6; + this.inputMode = 'numeric'; + this.pattern = '[0-9]*'; + } + this.verificationForm.get('verificationCode').updateValueAndValidity({emitEvent: false}); + } + } + + sendCode($event?: Event) { + if ($event) { + $event.stopPropagation(); + } + this.hideResendButton = true; + this.countDownTime = 0; + this.twoFactorAuthService.requestTwoFaVerificationCodeSend(this.selectedProvider).subscribe(() => { + this.countDownTime = this.minVerificationPeriod; + }, () => { + this.countDownTime = this.minVerificationPeriod; + }); + } + + cancelLogin() { + if (this.prevProvider) { + this.selectProvider(this.prevProvider); + } else { + this.authService.logout(); + } + } + + private updatedTime() { + if (this.countDownTime > 0) { + this.countDownTime--; + if (this.countDownTime === 0) { + this.hideResendButton = false; + } + } + } +} diff --git a/ui-ngx/src/app/shared/adapter/custom-datatime-adapter.ts b/ui-ngx/src/app/shared/adapter/custom-datatime-adapter.ts new file mode 100644 index 0000000..f2e520d --- /dev/null +++ b/ui-ngx/src/app/shared/adapter/custom-datatime-adapter.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { NativeDatetimeAdapter } from '@mat-datetimepicker/core'; + +@Injectable() +export class CustomDateAdapter extends NativeDatetimeAdapter { + + parse(value: string | number): Date { + if (typeof value === 'number') { + return new Date(value); + } + let newDate = value; + const formatToParts = Intl.DateTimeFormat(this.locale).formatToParts(); + if (formatToParts[0].type.toLowerCase() === 'day') { + const literal = formatToParts[1].value; + newDate = newDate.replace(new RegExp(`(\\d+[${literal}])(\\d+[${literal}])`), '$2$1'); + } + return newDate ? new Date(Date.parse(newDate)) : null; + } + +} diff --git a/ui-ngx/src/app/shared/animations/speed-dial-fab.animations.ts b/ui-ngx/src/app/shared/animations/speed-dial-fab.animations.ts new file mode 100644 index 0000000..871f1de --- /dev/null +++ b/ui-ngx/src/app/shared/animations/speed-dial-fab.animations.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { animate, keyframes, query, stagger, state, style, transition, trigger } from '@angular/animations'; + +export const speedDialFabAnimations = [ + trigger('fabToggler', [ + state('inactive', style({ + transform: 'rotate(0deg)' + })), + state('active', style({ + transform: 'rotate(225deg)' + })), + transition('* <=> *', animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)')), + ]), + trigger('speedDialStagger', [ + transition('* => *', [ + + query(':enter', style({ opacity: 0 }), {optional: true}), + + query(':enter', stagger('40ms', + [ + animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)', + keyframes( + [ + style({opacity: 0, transform: 'translateY(10px)'}), + style({opacity: 1, transform: 'translateY(0)'}), + ] + ) + ) + ] + ), {optional: true}), + + query(':leave', + animate('200ms cubic-bezier(0.4, 0.0, 0.2, 1)', + keyframes([ + style({opacity: 1}), + style({opacity: 0}), + ]) + ), {optional: true} + ) + + ]) + ]) +]; diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.html b/ui-ngx/src/app/shared/components/breadcrumb.component.html new file mode 100644 index 0000000..7a868c0 --- /dev/null +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.html @@ -0,0 +1,41 @@ + +
+

+ {{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }} +

+ + + + + + {{ breadcrumb.icon }} + + {{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }} + + + + + + {{ breadcrumb.icon }} + + {{ breadcrumb.ignoreTranslate ? (breadcrumb.labelFunction ? breadcrumb.labelFunction() : breadcrumb.label) : (breadcrumb.label | translate) }} + + > + +
diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.scss b/ui-ngx/src/app/shared/components/breadcrumb.component.scss new file mode 100644 index 0000000..b127364 --- /dev/null +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.scss @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + min-width: 0; + + .tb-breadcrumb { + font-size: 18px; + font-weight: 400; + overflow: hidden; + + h1, + a, + span:not(.divider) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + h1 { + font-size: 24px; + font-weight: 400; + } + + a { + border: none; + opacity: .75; + transition: opacity .35s; + color: inherit; + text-decoration: none; + outline: none; + } + + a:hover, + a:focus { + text-decoration: none !important; + border: none; + opacity: 1; + } + + .divider { + padding: 0 20px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.ts b/ui-ngx/src/app/shared/components/breadcrumb.component.ts new file mode 100644 index 0000000..df57460 --- /dev/null +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.ts @@ -0,0 +1,136 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { BehaviorSubject, Subject, Subscription } from 'rxjs'; +import { BreadCrumb, BreadCrumbConfig } from './breadcrumb'; +import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { guid } from '@core/utils'; +import { BroadcastService } from '@core/services/broadcast.service'; + +@Component({ + selector: 'tb-breadcrumb', + templateUrl: './breadcrumb.component.html', + styleUrls: ['./breadcrumb.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BreadcrumbComponent implements OnInit, OnDestroy { + + activeComponentValue: any; + updateBreadcrumbsSubscription: Subscription = null; + + @Input() + set activeComponent(activeComponent: any) { + if (this.updateBreadcrumbsSubscription) { + this.updateBreadcrumbsSubscription.unsubscribe(); + this.updateBreadcrumbsSubscription = null; + } + this.activeComponentValue = activeComponent; + if (this.activeComponentValue && this.activeComponentValue.updateBreadcrumbs) { + this.updateBreadcrumbsSubscription = this.activeComponentValue.updateBreadcrumbs.subscribe(() => { + this.breadcrumbs$.next(this.buildBreadCrumbs(this.activatedRoute.snapshot)); + }); + } + } + + breadcrumbs$: Subject> = new BehaviorSubject>(this.buildBreadCrumbs(this.activatedRoute.snapshot)); + + routerEventsSubscription = this.router.events.pipe( + filter((event) => event instanceof NavigationEnd ), + distinctUntilChanged(), + map( () => this.buildBreadCrumbs(this.activatedRoute.snapshot) ) + ).subscribe(breadcrumns => this.breadcrumbs$.next(breadcrumns) ); + + lastBreadcrumb$ = this.breadcrumbs$.pipe( + map( breadcrumbs => breadcrumbs[breadcrumbs.length - 1]) + ); + + constructor(private router: Router, + private activatedRoute: ActivatedRoute, + private broadcast: BroadcastService, + private cd: ChangeDetectorRef, + private translate: TranslateService) { + } + + ngOnInit(): void { + this.broadcast.on('updateBreadcrumb', () => { + this.cd.markForCheck(); + }); + } + + ngOnDestroy(): void { + if (this.routerEventsSubscription) { + this.routerEventsSubscription.unsubscribe(); + } + } + + private lastChild(route: ActivatedRouteSnapshot) { + let child = route; + while (child.firstChild !== null) { + child = child.firstChild; + } + return child; + } + + buildBreadCrumbs(route: ActivatedRouteSnapshot, breadcrumbs: Array = [], + lastChild?: ActivatedRouteSnapshot): Array { + if (!lastChild) { + lastChild = this.lastChild(route); + } + let newBreadcrumbs = breadcrumbs; + if (route.routeConfig && route.routeConfig.data) { + const breadcrumbConfig = route.routeConfig.data.breadcrumb as BreadCrumbConfig; + if (breadcrumbConfig && !breadcrumbConfig.skip) { + let label; + let labelFunction; + let ignoreTranslate; + if (breadcrumbConfig.labelFunction) { + labelFunction = () => { + return breadcrumbConfig.labelFunction(route, this.translate, this.activeComponentValue, lastChild.data); + }; + ignoreTranslate = true; + } else { + label = breadcrumbConfig.label || 'home.home'; + ignoreTranslate = false; + } + const icon = breadcrumbConfig.icon || 'home'; + const isMdiIcon = icon.startsWith('mdi:'); + const link = [ route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/') ]; + const breadcrumb = { + id: guid(), + label, + labelFunction, + ignoreTranslate, + icon, + isMdiIcon, + link, + queryParams: null + }; + newBreadcrumbs = [...breadcrumbs, breadcrumb]; + } + } + if (route.firstChild) { + return this.buildBreadCrumbs(route.firstChild, newBreadcrumbs, lastChild); + } + return newBreadcrumbs; + } + + trackByBreadcrumbs(index: number, breadcrumb: BreadCrumb){ + return breadcrumb.id; + } +} diff --git a/ui-ngx/src/app/shared/components/breadcrumb.ts b/ui-ngx/src/app/shared/components/breadcrumb.ts new file mode 100644 index 0000000..599f8b8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/breadcrumb.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ActivatedRouteSnapshot, Params } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { HasUUID } from '@shared/models/id/has-uuid'; + +export interface BreadCrumb extends HasUUID{ + label: string; + labelFunction?: () => string; + ignoreTranslate: boolean; + icon: string; + isMdiIcon: boolean; + link: any[]; + queryParams: Params; +} + +export type BreadCrumbLabelFunction = (route: ActivatedRouteSnapshot, translate: TranslateService, component: C, data?: any) => string; + +export interface BreadCrumbConfig { + labelFunction: BreadCrumbLabelFunction; + label: string; + icon: string; + skip: boolean; +} diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.html b/ui-ngx/src/app/shared/components/button/copy-button.component.html new file mode 100644 index 0000000..5311b2c --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.html @@ -0,0 +1,31 @@ + + diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.scss b/ui-ngx/src/app/shared/components/button/copy-button.component.scss new file mode 100644 index 0000000..9e04281 --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-icon-button{ + height: 32px; + width: 32px; + line-height: 32px; + .mat-icon.copied{ + color: #00C851 !important; + } + } + &:hover{ + .mat-icon{ + color: #28567E !important; + } + } +} diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.ts b/ui-ngx/src/app/shared/components/button/copy-button.component.ts new file mode 100644 index 0000000..57a55d4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.ts @@ -0,0 +1,87 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ClipboardService } from 'ngx-clipboard'; +import { TooltipPosition } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-copy-button', + styleUrls: ['copy-button.component.scss'], + templateUrl: './copy-button.component.html' +}) +export class CopyButtonComponent { + + private timer; + + copied = false; + + @Input() + copyText: string; + + @Input() + disabled = false; + + @Input() + mdiIcon: string; + + @Input() + icon: string; + + @Input() + tooltipText: string; + + @Input() + tooltipPosition: TooltipPosition; + + @Input() + style: {[key: string]: any} = {}; + + @Input() + color: string; + + @Output() + successCopied = new EventEmitter(); + + constructor(private clipboardService: ClipboardService, + private translate: TranslateService, + private cd: ChangeDetectorRef) { + } + + copy($event: Event): void { + $event.stopPropagation(); + if (this.timer) { + clearTimeout(this.timer); + } + this.clipboardService.copy(this.copyText); + this.successCopied.emit(this.copyText); + this.copied = true; + this.timer = setTimeout(() => { + this.copied = false; + this.cd.detectChanges(); + }, 1500); + } + + get matTooltipText(): string { + return this.copied ? this.translate.instant('ota-update.copied') : this.tooltipText; + } + + get matTooltipPosition(): TooltipPosition { + return this.copied ? 'below' : this.tooltipPosition; + } + +} diff --git a/ui-ngx/src/app/shared/components/button/toggle-password.component.html b/ui-ngx/src/app/shared/components/button/toggle-password.component.html new file mode 100644 index 0000000..2e4d6cd --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/toggle-password.component.html @@ -0,0 +1,20 @@ + + diff --git a/ui-ngx/src/app/shared/components/button/toggle-password.component.ts b/ui-ngx/src/app/shared/components/button/toggle-password.component.ts new file mode 100644 index 0000000..c1c0661 --- /dev/null +++ b/ui-ngx/src/app/shared/components/button/toggle-password.component.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef } from '@angular/core'; + +@Component({ + selector: 'tb-toggle-password', + templateUrl: 'toggle-password.component.html', + styleUrls: [], +}) +export class TogglePasswordComponent implements AfterViewInit { + showPassword = false; + hideToggle = false; + + private input: HTMLInputElement = null; + + constructor(private hostElement: ElementRef) { } + + togglePassword($event: Event) { + $event.stopPropagation(); + this.showPassword = !this.showPassword; + this.input.type = this.showPassword ? 'text' : 'password'; + } + + ngAfterViewInit() { + this.input = this.hostElement.nativeElement.closest('mat-form-field').querySelector('input[type="password"]'); + if (this.input === null) { + this.hideToggle = true; + } + } +} diff --git a/ui-ngx/src/app/shared/components/cheatsheet.component.ts b/ui-ngx/src/app/shared/components/cheatsheet.component.ts new file mode 100644 index 0000000..e90f72a --- /dev/null +++ b/ui-ngx/src/app/shared/components/cheatsheet.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { Hotkey, HotkeysService } from 'angular2-hotkeys'; +import { MousetrapInstance } from 'mousetrap'; +import * as Mousetrap from 'mousetrap'; + +@Component({ + selector : 'tb-hotkeys-cheatsheet', + styles : [` +.tb-hotkeys-container { + display: table !important; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + color: #333; + font-size: 1em; + background-color: rgba(255,255,255,0.9); + outline: 0; +} +.tb-hotkeys-container.fade { + z-index: -1024; + visibility: hidden; + opacity: 0; + -webkit-transition: opacity 0.15s linear; + -moz-transition: opacity 0.15s linear; + -o-transition: opacity 0.15s linear; + transition: opacity 0.15s linear; +} +.tb-hotkeys-container.fade.in { + z-index: 10002; + visibility: visible; + opacity: 1; +} +.tb-hotkeys-title { + font-weight: bold; + text-align: center; + font-size: 1.2em; +} +.tb-hotkeys { + width: 100%; + height: 100%; + display: table-cell; + vertical-align: middle; +} +.tb-hotkeys table { + margin: auto; + color: #333; +} +.tb-content { + display: table-cell; + vertical-align: middle; +} +.tb-hotkeys-keys { + padding: 5px; + text-align: right; +} +.tb-hotkeys-key { + display: inline-block; + color: #fff; + background-color: #333; + border: 1px solid #333; + border-radius: 5px; + text-align: center; + margin-right: 5px; + box-shadow: inset 0 1px 0 #666, 0 1px 0 #bbb; + padding: 5px 9px; + font-size: 1em; +} +.tb-hotkeys-text { + padding-left: 10px; + font-size: 1em; +} +.tb-hotkeys-close { + position: fixed; + top: 20px; + right: 20px; + font-size: 2em; + font-weight: bold; + padding: 5px 10px; + border: 1px solid #ddd; + border-radius: 5px; + min-height: 45px; + min-width: 45px; + text-align: center; +} +.tb-hotkeys-close:hover { + background-color: #fff; + cursor: pointer; +} +@media all and (max-width: 500px) { + .tb-hotkeys { + font-size: 0.8em; + } +} +@media all and (min-width: 750px) { + .tb-hotkeys { + font-size: 1.2em; + } +} `], + template : ``, +}) +export class TbCheatSheetComponent implements OnInit, OnDestroy { + + helpVisible = false; + @Input() title = 'Keyboard Shortcuts:'; + + @Input() + hotkeys: Hotkey[]; + + hotkeysList: Hotkey[]; + + private mousetrap: MousetrapInstance; + + constructor(private elementRef: ElementRef, + private hotkeysService: HotkeysService) { + this.mousetrap = new Mousetrap(this.elementRef.nativeElement); + this.mousetrap.bind('?', (event: KeyboardEvent, combo: string) => { + this.toggleCheatSheet(); + }); + } + + public ngOnInit(): void { + if (this.hotkeys) { + this.hotkeysList = this.hotkeys.filter(hotkey => hotkey.description); + } + } + + public setHotKeys(hotkeys: Hotkey[]) { + this.hotkeysList = hotkeys.filter(hotkey => hotkey.description); + } + + public toggleCheatSheet(): void { + this.helpVisible = !this.helpVisible; + } + + ngOnDestroy() { + this.mousetrap.unbind('?'); + } +} diff --git a/ui-ngx/src/app/shared/components/circular-progress.directive.ts b/ui-ngx/src/app/shared/components/circular-progress.directive.ts new file mode 100644 index 0000000..ab0b301 --- /dev/null +++ b/ui-ngx/src/app/shared/components/circular-progress.directive.ts @@ -0,0 +1,82 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ComponentFactoryResolver, ComponentRef, Directive, ElementRef, Input, ViewContainerRef } from '@angular/core'; +import { MatSpinner } from '@angular/material/progress-spinner'; + +@Directive({ + selector: '[tb-circular-progress]' +}) +export class CircularProgressDirective { + + showProgressValue = false; + + children: JQuery; + + cssWidth: any; + + @Input('tb-circular-progress') + set showProgress(showProgress: boolean) { + if (this.showProgressValue !== showProgress) { + const element = this.elementRef.nativeElement; + this.showProgressValue = showProgress; + this.spinnerRef.instance._elementRef.nativeElement.style.display = showProgress ? 'block' : 'none'; + if (showProgress) { + this.cssWidth = $(element).prop('style').width; + if (!this.cssWidth) { + $(element).css('width', ''); + const width = $(element).prop('offsetWidth'); + $(element).css('width', width + 'px'); + } + this.children = $(element).children(); + $(element).empty(); + $(element).append($(this.spinnerRef.instance._elementRef.nativeElement)); + } else { + $(element).empty(); + $(element).append(this.children); + if (this.cssWidth) { + $(element).css('width', this.cssWidth); + } else { + $(element).css('width', ''); + } + } + } + } + + spinnerRef: ComponentRef; + + constructor(private elementRef: ElementRef, + private componentFactoryResolver: ComponentFactoryResolver, + private viewContainerRef: ViewContainerRef) { + this.createCircularProgress(); + } + + createCircularProgress() { + this.elementRef.nativeElement.style.position = 'relative'; + const factory = this.componentFactoryResolver.resolveComponentFactory(MatSpinner); + this.spinnerRef = this.viewContainerRef.createComponent(factory, 0); + this.spinnerRef.instance.mode = 'indeterminate'; + this.spinnerRef.instance.diameter = 20; + const el = this.spinnerRef.instance._elementRef.nativeElement; + el.style.margin = 'auto'; + el.style.position = 'absolute'; + el.style.left = '0'; + el.style.right = '0'; + el.style.top = '0'; + el.style.bottom = '0'; + el.style.display = 'none'; + } +} diff --git a/ui-ngx/src/app/shared/components/color-input.component.html b/ui-ngx/src/app/shared/components/color-input.component.html new file mode 100644 index 0000000..2d88e0e --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-input.component.html @@ -0,0 +1,36 @@ + + + + {{icon}} + {{label}} + +
+
+
+ + + + {{ requiredText }} + +
diff --git a/ui-ngx/src/app/shared/components/color-input.component.scss b/ui-ngx/src/app/shared/components/color-input.component.scss new file mode 100644 index 0000000..467aa25 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-input.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-form-field { + width: 100%; + } +} + +:host ::ng-deep { + .mat-form-field-infix{ + width: 150px; + } +} diff --git a/ui-ngx/src/app/shared/components/color-input.component.ts b/ui-ngx/src/app/shared/components/color-input.component.ts new file mode 100644 index 0000000..20339e3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-input.component.ts @@ -0,0 +1,165 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DialogService } from '@core/services/dialog.service'; + +@Component({ + selector: 'tb-color-input', + templateUrl: './color-input.component.html', + styleUrls: ['./color-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorInputComponent), + multi: true + } + ] +}) +export class ColorInputComponent extends PageComponent implements OnInit, ControlValueAccessor { + + @Input() + icon: string; + + @Input() + label: string; + + @Input() + requiredText: string; + + private colorClearButtonValue: boolean; + get colorClearButton(): boolean { + return this.colorClearButtonValue; + } + @Input() + set colorClearButton(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.colorClearButtonValue !== newVal) { + this.colorClearButtonValue = newVal; + } + } + + private openOnInputValue: boolean; + get openOnInput(): boolean { + return this.openOnInputValue; + } + @Input() + set openOnInput(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.openOnInputValue !== newVal) { + this.openOnInputValue = newVal; + } + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + private modelValue: string; + + private propagateChange = null; + + public colorFormGroup: FormGroup; + + constructor(protected store: Store, + private dialogs: DialogService, + private translate: TranslateService, + private fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.colorFormGroup = this.fb.group({ + color: [null, this.required ? [Validators.required] : []] + }); + + this.colorFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + updateValidators() { + if (this.colorFormGroup) { + this.colorFormGroup.get('color').setValidators(this.required ? [Validators.required] : []); + this.colorFormGroup.get('color').updateValueAndValidity(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.colorFormGroup.disable({emitEvent: false}); + } else { + this.colorFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + this.modelValue = value; + this.colorFormGroup.patchValue( + { color: this.modelValue }, {emitEvent: false} + ); + } + + private updateModel() { + const color: string = this.colorFormGroup.get('color').value; + if (this.modelValue !== color) { + this.modelValue = color; + this.propagateChange(this.modelValue); + } + } + + showColorPicker() { + this.dialogs.colorPicker(this.colorFormGroup.get('color').value).subscribe( + (color) => { + if (color) { + this.colorFormGroup.patchValue( + {color}, {emitEvent: true} + ); + } + } + ); + } + + clear() { + this.colorFormGroup.get('color').patchValue(null, {emitEvent: true}); + } +} diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html new file mode 100644 index 0000000..012ed7d --- /dev/null +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -0,0 +1,70 @@ + +
+ + contact.country + + + {{ country }} + + + +
+ + contact.city + + + {{ 'contact.city-max-length' | translate }} + + + + contact.state + + + {{ 'contact.state-max-length' | translate }} + + + + contact.postal-code + + + {{ 'contact.postal-code-invalid' | translate }} + + +
+ + contact.address + + + + contact.address2 + + + + + + contact.email + + + {{ 'user.invalid-email-format' | translate }} + + +
diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts new file mode 100644 index 0000000..6226d70 --- /dev/null +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { COUNTRIES } from '@home/models/contact.models'; + +@Component({ + selector: 'tb-contact', + templateUrl: './contact.component.html' +}) +export class ContactComponent { + + @Input() + parentForm: FormGroup; + + @Input() isEdit: boolean; + + countries = COUNTRIES; + +} diff --git a/ui-ngx/src/app/shared/components/css.component.html b/ui-ngx/src/app/shared/components/css.component.html new file mode 100644 index 0000000..7f2f996 --- /dev/null +++ b/ui-ngx/src/app/shared/components/css.component.html @@ -0,0 +1,41 @@ + +
+
+ + + +
+
+ +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/css.component.scss b/ui-ngx/src/app/shared/components/css.component.scss new file mode 100644 index 0000000..93bbbfc --- /dev/null +++ b/ui-ngx/src/app/shared/components/css.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-css { + position: relative; + + &.tb-disabled { + color: rgba(0, 0, 0, .38); + } + + &.fill-height { + height: 100%; + } + + .tb-css-content-panel { + height: calc(100% - 40px); + border: 1px solid #c0c0c0; + + #tb-css-input { + width: 100%; + min-width: 200px; + height: 100%; + + &:not(.fill-height) { + min-height: 200px; + } + } + } + + .tb-css-toolbar { + & > * { + &:not(:last-child) { + margin-right: 4px; + } + } + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + background: rgba(220, 220, 220, .35); + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + font-size: .8rem; + line-height: 15px; + &:not(.tb-help-popup-button) { + color: #7b7b7b; + } + } + .tb-help-popup-button-loading { + background: #f3f3f3; + } + } +} diff --git a/ui-ngx/src/app/shared/components/css.component.ts b/ui-ngx/src/app/shared/components/css.component.ts new file mode 100644 index 0000000..efe0f3c --- /dev/null +++ b/ui-ngx/src/app/shared/components/css.component.ts @@ -0,0 +1,214 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { getAce } from '@shared/models/ace/ace.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { beautifyCss } from '@shared/models/beautify.models'; + +@Component({ + selector: 'tb-css', + templateUrl: './css.component.html', + styleUrls: ['./css.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CssComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CssComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None +}) +export class CssComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { + + @ViewChild('cssEditor', {static: true}) + cssEditorElmRef: ElementRef; + + private cssEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + private ignoreChange = false; + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + modelValue: string; + + hasErrors = false; + + private propagateChange = null; + + constructor(public elementRef: ElementRef, + private utils: UtilsService, + private translate: TranslateService, + protected store: Store, + private raf: RafService, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + const editorElement = this.cssEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/css', + showGutter: true, + showPrintMargin: true, + readOnly: this.disabled + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.cssEditor = ace.edit(editorElement, editorOptions); + this.cssEditor.session.setUseWrapMode(true); + this.cssEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.cssEditor.setReadOnly(this.disabled); + this.cssEditor.on('change', () => { + if (!this.ignoreChange) { + this.updateView(); + } + }); + // @ts-ignore + this.cssEditor.session.on('changeAnnotation', () => { + const annotations = this.cssEditor.session.getAnnotations(); + const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0; + if (this.hasErrors !== hasErrors) { + this.hasErrors = hasErrors; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + }); + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.cssEditor) { + this.cssEditor.destroy(); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.cssEditor.resize(); + this.cssEditor.renderer.updateFull(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.cssEditor) { + this.cssEditor.setReadOnly(this.disabled); + } + } + + public validate(c: FormControl) { + return (!this.hasErrors) ? null : { + css: { + valid: false, + }, + }; + } + + beautifyCss() { + beautifyCss(this.modelValue, {indent_size: 4}).subscribe( + (res) => { + if (this.modelValue !== res) { + this.cssEditor.setValue(res ? res : '', -1); + this.updateView(); + } + } + ); + } + + writeValue(value: string): void { + this.modelValue = value; + if (this.cssEditor) { + this.ignoreChange = true; + this.cssEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.cssEditor.getValue(); + if (this.modelValue !== editorValue) { + this.modelValue = editorValue; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + } +} diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html new file mode 100644 index 0000000..db76093 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html @@ -0,0 +1,50 @@ + + + + + + + + + + + {{ translate.get('dashboard.no-dashboards-matching', {entity: searchText}) | async }} + + + + + + + + + + diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts new file mode 100644 index 0000000..db040d8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -0,0 +1,245 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction } from '@shared/models/page/sort-order'; +import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { DashboardInfo } from '@app/shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { getCurrentAuthUser } from '@app/core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { FloatLabelType } from '@angular/material/form-field/form-field'; + +@Component({ + selector: 'tb-dashboard-autocomplete', + templateUrl: './dashboard-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DashboardAutocompleteComponent), + multi: true + }] +}) +export class DashboardAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + private dirty = false; + + selectDashboardFormGroup: FormGroup; + + modelValue: DashboardInfo | string | null; + + @Input() + useIdValue = true; + + @Input() + selectFirstDashboard = false; + + @Input() + placeholder: string; + + @Input() + dashboardsScope: 'customer' | 'tenant'; + + @Input() + tenantId: string; + + @Input() + customerId: string; + + @Input() + floatLabel: FloatLabelType = 'auto'; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('dashboardInput', {static: true}) dashboardInput: ElementRef; + + filteredDashboards: Observable>; + + searchText = ''; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private dashboardService: DashboardService, + private fb: FormBuilder) { + this.selectDashboardFormGroup = this.fb.group({ + dashboard: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredDashboards = this.selectDashboardFormGroup.get('dashboard').valueChanges + .pipe( + debounceTime(150), + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = this.useIdValue ? value.id.id : value; + } + this.updateView(modelValue); + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + distinctUntilChanged(), + switchMap(name => this.fetchDashboards(name) ), + share() + ); + } + + ngAfterViewInit(): void { + // this.selectFirstDashboardIfNeeded(); + } + + selectFirstDashboardIfNeeded(): void { + if (this.selectFirstDashboard && !this.modelValue) { + this.getDashboards(new PageLink(1)).subscribe( + (data) => { + if (data.data.length) { + const dashboard = data.data[0]; + this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; + this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: false}); + this.propagateChange(this.modelValue); + } + } + ); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectDashboardFormGroup.disable({emitEvent: false}); + } else { + this.selectDashboardFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DashboardInfo | string | null): void { + this.searchText = ''; + if (value != null) { + if (typeof value === 'string') { + this.dashboardService.getDashboardInfo(value).subscribe( + (dashboard) => { + this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; + this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: false}); + } + ); + } else { + this.modelValue = this.useIdValue ? value.id.id : value; + this.selectDashboardFormGroup.get('dashboard').patchValue(value, {emitEvent: false}); + } + } else { + this.modelValue = null; + this.selectDashboardFormGroup.get('dashboard').patchValue('', {emitEvent: false}); + this.selectFirstDashboardIfNeeded(); + } + this.dirty = true; + } + + updateView(value: DashboardInfo | string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayDashboardFn(dashboard?: DashboardInfo): string | undefined { + return dashboard ? dashboard.title : undefined; + } + + fetchDashboards(searchText?: string): Observable> { + this.searchText = searchText; + const pageLink = new PageLink(10, 0, searchText, { + property: 'title', + direction: Direction.ASC + }); + return this.getDashboards(pageLink).pipe( + catchError(() => of(emptyPageData())), + map(pageData => { + return pageData.data; + }) + ); + } + + getDashboards(pageLink: PageLink): Observable> { + let dashboardsObservable: Observable>; + const authUser = getCurrentAuthUser(this.store); + if (this.dashboardsScope === 'customer' || authUser.authority === Authority.CUSTOMER_USER) { + if (this.customerId) { + dashboardsObservable = this.dashboardService.getCustomerDashboards(this.customerId, pageLink, + {ignoreLoading: true}); + } else { + dashboardsObservable = of(emptyPageData()); + } + } else { + if (authUser.authority === Authority.SYS_ADMIN) { + if (this.tenantId) { + dashboardsObservable = this.dashboardService.getTenantDashboardsByTenantId(this.tenantId, pageLink, + {ignoreLoading: true}); + } else { + dashboardsObservable = of(emptyPageData()); + } + } else { + dashboardsObservable = this.dashboardService.getTenantDashboards(pageLink, + {ignoreLoading: true}); + } + } + return dashboardsObservable; + } + + onFocus() { + if (this.dirty) { + this.selectDashboardFormGroup.get('dashboard').updateValueAndValidity({onlySelf: true}); + this.dirty = false; + } + } + + clear() { + this.selectDashboardFormGroup.get('dashboard').patchValue(''); + setTimeout(() => { + this.dashboardInput.nativeElement.blur(); + this.dashboardInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html new file mode 100644 index 0000000..4a36ff8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html @@ -0,0 +1,28 @@ + +
+ + {{'dashboard.select-dashboard' | translate}} + + + {{dashboard.title}} + + + +
diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.scss b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.scss new file mode 100644 index 0000000..42ef8b9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.scss @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../scss/constants"; + +:host { + min-width: 300px; + max-width: 320px; + max-height: 150px; + overflow-x: hidden; + overflow-y: auto; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + @media (min-height: 350px) { + max-height: 250px; + } + + .mat-content { + background-color: #fff; + } +} + +:host ::ng-deep { + mat-form-field { + .mat-form-field-infix { + width: 100%; + mat-select { + .mat-select-value { + max-width: 100%; + } + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts new file mode 100644 index 0000000..c18b2ea --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts @@ -0,0 +1,48 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; +import { DashboardInfo } from '../models/dashboard.models'; + +export const DASHBOARD_SELECT_PANEL_DATA = new InjectionToken('DashboardSelectPanelData'); + +export interface DashboardSelectPanelData { + dashboards$: Observable>; + dashboardId: string; + onDashboardSelected: (dashboardId: string) => void; +} + +@Component({ + selector: 'tb-dashboard-select-panel', + templateUrl: './dashboard-select-panel.component.html', + styleUrls: ['./dashboard-select-panel.component.scss'] +}) +export class DashboardSelectPanelComponent { + + dashboards$: Observable>; + dashboardId: string; + + constructor(@Inject(DASHBOARD_SELECT_PANEL_DATA) + private data: DashboardSelectPanelData) { + this.dashboards$ = this.data.dashboards$; + this.dashboardId = this.data.dashboardId; + } + + public dashboardSelected(dashboardId: string) { + this.data.onDashboardSelected(dashboardId); + } +} diff --git a/ui-ngx/src/app/shared/components/dashboard-select.component.html b/ui-ngx/src/app/shared/components/dashboard-select.component.html new file mode 100644 index 0000000..7494bd4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select.component.html @@ -0,0 +1,35 @@ + + + + {{dashboard.title}} + + +
+ +
diff --git a/ui-ngx/src/app/shared/components/dashboard-select.component.scss b/ui-ngx/src/app/shared/components/dashboard-select.component.scss new file mode 100644 index 0000000..ff4ca09 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select.component.scss @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: min-content; //for Safari + min-width: 52px; + + mat-select { + max-width: 300px; + pointer-events: all; + } + + .tb-dashboard-select { + min-height: 32px; + + span { + pointer-events: all; + cursor: pointer; + } + } +} + +:host ::ng-deep { + mat-select { + .mat-select-value { + max-width: 282px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/dashboard-select.component.ts b/ui-ngx/src/app/shared/components/dashboard-select.component.ts new file mode 100644 index 0000000..c14e392 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-select.component.ts @@ -0,0 +1,227 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, + Inject, + Injector, + Input, + OnInit, + StaticProvider, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { PageLink } from '@shared/models/page/page-link'; +import { map, share } from 'rxjs/operators'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { DashboardInfo } from '@app/shared/models/dashboard.models'; +import { DashboardService } from '@core/http/dashboard.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { getCurrentAuthUser } from '@app/core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { TooltipPosition } from '@angular/material/tooltip'; +import { CdkOverlayOrigin, ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { DOCUMENT } from '@angular/common'; +import { WINDOW } from '@core/services/window.service'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + DASHBOARD_SELECT_PANEL_DATA, + DashboardSelectPanelComponent, + DashboardSelectPanelData +} from './dashboard-select-panel.component'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +// @dynamic +@Component({ + selector: 'tb-dashboard-select', + templateUrl: './dashboard-select.component.html', + styleUrls: ['./dashboard-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DashboardSelectComponent), + multi: true + }] +}) +export class DashboardSelectComponent implements ControlValueAccessor, OnInit { + + @Input() + dashboardsScope: 'customer' | 'tenant'; + + @Input() + customerId: string; + + @Input() + tooltipPosition: TooltipPosition = 'above'; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + dashboards$: Observable>; + + dashboardId: string | null; + + @ViewChild('dashboardSelectPanelOrigin') dashboardSelectPanelOrigin: CdkOverlayOrigin; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private dashboardService: DashboardService, + private overlay: Overlay, + private breakpointObserver: BreakpointObserver, + private viewContainerRef: ViewContainerRef, + @Inject(DOCUMENT) private document: Document, + @Inject(WINDOW) private window: Window) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + const pageLink = new PageLink(100); + + this.dashboards$ = this.getDashboards(pageLink).pipe( + map((pageData) => pageData.data), + share() + ); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: string | null): void { + this.dashboardId = value; + } + + dashboardIdChanged() { + this.updateView(); + } + + openDashboardSelectPanel() { + if (this.disabled) { + return; + } + const panelHeight = this.breakpointObserver.isMatched('min-height: 350px') ? 250 : 150; + const panelWidth = 300; + const position = this.overlay.position(); + const config = new OverlayConfig({ + panelClass: 'tb-dashboard-select-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + }); + const el = this.dashboardSelectPanelOrigin.elementRef.nativeElement; + const offset = el.getBoundingClientRect(); + const scrollTop = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0; + const scrollLeft = this.window.pageXOffset || this.document.documentElement.scrollLeft || this.document.body.scrollLeft || 0; + const bottomY = offset.bottom - scrollTop; + const leftX = offset.left - scrollLeft; + let originX; + let originY; + let overlayX; + let overlayY; + const wHeight = this.document.documentElement.clientHeight; + const wWidth = this.document.documentElement.clientWidth; + if (bottomY + panelHeight > wHeight) { + originY = 'top'; + overlayY = 'bottom'; + } else { + originY = 'bottom'; + overlayY = 'top'; + } + if (leftX + panelWidth > wWidth) { + originX = 'end'; + overlayX = 'end'; + } else { + originX = 'start'; + overlayX = 'start'; + } + const connectedPosition: ConnectedPosition = { + originX, + originY, + overlayX, + overlayY + }; + config.positionStrategy = position.flexibleConnectedTo(this.dashboardSelectPanelOrigin.elementRef) + .withPositions([connectedPosition]); + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const injector = this._createDashboardSelectPanelInjector( + overlayRef, + { + dashboards$: this.dashboards$, + dashboardId: this.dashboardId, + onDashboardSelected: (dashboardId) => { + overlayRef.dispose(); + this.dashboardId = dashboardId; + this.updateView(); + } + } + ); + overlayRef.attach(new ComponentPortal(DashboardSelectPanelComponent, this.viewContainerRef, injector)); + } + + private _createDashboardSelectPanelInjector(overlayRef: OverlayRef, data: DashboardSelectPanelData): Injector { + const providers: StaticProvider[] = [ + {provide: DASHBOARD_SELECT_PANEL_DATA, useValue: data}, + {provide: OverlayRef, useValue: overlayRef} + ]; + return Injector.create({parent: this.viewContainerRef.injector, providers}); + } + + private updateView() { + this.propagateChange(this.dashboardId); + } + + private getDashboards(pageLink: PageLink): Observable> { + let dashboardsObservable: Observable>; + const authUser = getCurrentAuthUser(this.store); + if (this.dashboardsScope === 'customer' || authUser.authority === Authority.CUSTOMER_USER) { + if (this.customerId && this.customerId !== NULL_UUID) { + dashboardsObservable = this.dashboardService.getCustomerDashboards(this.customerId, pageLink, + {ignoreLoading: true}); + } else { + dashboardsObservable = of(emptyPageData()); + } + } else { + dashboardsObservable = this.dashboardService.getTenantDashboards(pageLink, {ignoreLoading: true}); + } + return dashboardsObservable; + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog.component.ts b/ui-ngx/src/app/shared/components/dialog.component.ts new file mode 100644 index 0000000..0a5ac51 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog.component.ts @@ -0,0 +1,51 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Directive, OnDestroy } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { MatDialogRef } from '@angular/material/dialog'; +import { NavigationStart, Router, RouterEvent } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +@Directive() +export abstract class DialogComponent extends PageComponent implements OnDestroy { + + routerSubscription: Subscription; + + protected constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef) { + super(store); + this.routerSubscription = this.router.events + .pipe( + filter((event: RouterEvent) => event instanceof NavigationStart), + filter(() => !!this.dialogRef) + ) + .subscribe(() => { + this.dialogRef.close(); + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + if (this.routerSubscription) { + this.routerSubscription.unsubscribe(); + } + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html new file mode 100644 index 0000000..c3c4b59 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html @@ -0,0 +1,22 @@ + +

{{data.title}}

+
+
+ +
diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss new file mode 100644 index 0000000..f9550e5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-dialog-content { + padding: 0 24px 24px; + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts new file mode 100644 index 0000000..c29d66b --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +export interface AlertDialogData { + title: string; + message: string; + ok: string; +} + +@Component({ + selector: 'tb-alert-dialog', + templateUrl: './alert-dialog.component.html', + styleUrls: ['./alert-dialog.component.scss'] +}) +export class AlertDialogComponent { + constructor(public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AlertDialogData) {} +} diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html new file mode 100644 index 0000000..a7d4835 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html @@ -0,0 +1,41 @@ + +
+
+ + +
+
+ + + +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts new file mode 100644 index 0000000..9b3a5ac --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts @@ -0,0 +1,78 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; + +export interface ColorPickerDialogData { + color: string; +} + +@Component({ + selector: 'tb-color-picker-dialog', + templateUrl: './color-picker-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ColorPickerDialogComponent}], + styleUrls: [] +}) +export class ColorPickerDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + colorPickerFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ColorPickerDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.colorPickerFormGroup = this.fb.group({ + color: [this.data.color, [Validators.required]] + }); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + onColorChange(color: string) { + this.colorPickerFormGroup.get('color').setValue(color); + this.colorPickerFormGroup.markAsDirty(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + select(): void { + this.submitted = true; + const color: string = this.colorPickerFormGroup.get('color').value; + this.dialogRef.close(color); + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.html new file mode 100644 index 0000000..85fc26d --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.html @@ -0,0 +1,23 @@ + +

{{data.title}}

+
+
+ + +
diff --git a/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.scss new file mode 100644 index 0000000..f9550e5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-dialog-content { + padding: 0 24px 24px; + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..f4f4f7d --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +export interface ConfirmDialogData { + title: string; + message: string; + cancel: string; + ok: string; +} + +// @dynamic +@Component({ + selector: 'tb-confirm-dialog', + templateUrl: './confirm-dialog.component.html', + styleUrls: ['./confirm-dialog.component.scss'] +}) +export class ConfirmDialogComponent { + constructor(public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData) {} +} diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html new file mode 100644 index 0000000..482c1dd --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html @@ -0,0 +1,56 @@ + +
+ +

{{ title }}

+ + +
+ + +
+
+
+ + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts new file mode 100644 index 0000000..f811233 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts @@ -0,0 +1,66 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +export interface JsonObjectEditDialogData { + jsonValue: object; + title?: string; +} + +@Component({ + selector: 'tb-object-edit-dialog', + templateUrl: './json-object-edit-dialog.component.html', + styleUrls: [] +}) +export class JsonObjectEditDialogComponent extends DialogComponent implements OnInit { + + jsonFormGroup: FormGroup; + title: string; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: JsonObjectEditDialogData, + public dialogRef: MatDialogRef, + public fb: FormBuilder, + private translate: TranslateService) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.title = this.data.title ? this.data.title : this.translate.instant('details.edit-json'); + this.jsonFormGroup = this.fb.group({ + json: [this.data.jsonValue, []] + }); + } + + cancel(): void { + this.dialogRef.close(undefined); + } + + add(): void { + this.dialogRef.close(this.jsonFormGroup.get('json').value); + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html new file mode 100644 index 0000000..b671731 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html @@ -0,0 +1,78 @@ + +
+ +

{{ 'icon.select-icon' | translate }}

+ +
+ + + +
+ +
+ + +
+
+ +
+
+
+
+ + + + + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss new file mode 100644 index 0000000..d57be2f --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-material-icons-dialog { + position: relative; + } + .tb-icons-load { + top: 64px; + z-index: 3; + background: rgba(255, 255, 255, .75); + } +} + +:host ::ng-deep { + .tb-material-icons-dialog { + button.mat-icon-button.tb-select-icon-button { + width: 56px; + height: 56px; + padding: 16px; + margin: 10px; + border: solid 1px #ffa500; + border-radius: 0; + line-height: 0; + } + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts new file mode 100644 index 0000000..34fcdc8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts @@ -0,0 +1,106 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, Inject, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { UtilsService } from '@core/services/utils.service'; +import { FormControl } from '@angular/forms'; +import { merge, Observable, of } from 'rxjs'; +import { delay, map, mapTo, mergeMap, share, startWith, tap } from 'rxjs/operators'; + +export interface MaterialIconsDialogData { + icon: string; +} + +@Component({ + selector: 'tb-material-icons-dialog', + templateUrl: './material-icons-dialog.component.html', + providers: [], + styleUrls: ['./material-icons-dialog.component.scss'] +}) +export class MaterialIconsDialogComponent extends DialogComponent + implements OnInit, AfterViewInit { + + @ViewChildren('iconButtons') iconButtons: QueryList; + + selectedIcon: string; + icons$: Observable>; + loadingIcons$: Observable; + + showAllControl: FormControl; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MaterialIconsDialogData, + private utils: UtilsService, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + this.selectedIcon = data.icon; + this.showAllControl = new FormControl(false); + } + + ngOnInit(): void { + this.icons$ = this.showAllControl.valueChanges.pipe( + map((showAll) => { + return {firstTime: false, showAll}; + }), + startWith<{firstTime: boolean, showAll: boolean}>({firstTime: true, showAll: false}), + mergeMap((data) => { + if (data.showAll) { + return this.utils.getMaterialIcons().pipe(delay(100)); + } else { + const res = of(this.utils.getCommonMaterialIcons()); + return data.firstTime ? res : res.pipe(delay(50)); + } + }), + share() + ); + } + + ngAfterViewInit(): void { + this.loadingIcons$ = merge( + this.showAllControl.valueChanges.pipe( + mapTo(true), + ), + this.iconButtons.changes.pipe( + delay(100), + mapTo( false), + ) + ).pipe( + tap((loadingIcons) => { + if (loadingIcons) { + this.showAllControl.disable({emitEvent: false}); + } else { + this.showAllControl.enable({emitEvent: false}); + } + }), + share() + ); + } + + selectIcon(icon: string) { + this.dialogRef.close(icon); + } + + cancel(): void { + this.dialogRef.close(null); + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html new file mode 100644 index 0000000..a714ea8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html @@ -0,0 +1,127 @@ + +
+ +

{{ 'rulenode.test-script-function' | translate }} ({{ (scriptLang === scriptLanguage.JS ? 'rulenode.script-lang-java-script' : 'rulenode.script-lang-tbel') | translate }})

+ + +
+
+
+
+
+
+
+ +
+
+
+ + +
+ + +
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+
+ + + + +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.scss new file mode 100644 index 0000000..31fcff7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.scss @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-node-script-test-dialog { + width: 100%; + height: 100%; + + .tb-split { + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + } + + .ace_editor { + font-size: 14px !important; + } + + .tb-content { + padding-top: 5px; + padding-left: 5px; + border: 1px solid #c0c0c0; + } + .gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + } + + .gutter.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../assets/split.js/grips/vertical.png"); + } + + .gutter.gutter-vertical { + cursor: row-resize; + background-image: url("../../../../assets/split.js/grips/horizontal.png"); + } + + .tb-split.tb-split-horizontal, + .gutter.gutter-horizontal { + float: left; + height: 100%; + } + + .tb-split.tb-split-vertical { + display: flex; + + .tb-split.tb-content { + height: 100%; + } + } + + div.tb-editor-area-title-panel { + position: absolute; + top: 13px; + right: 40px; + z-index: 5; + font-size: .8rem; + font-weight: 500; + + &.tb-js-function { + right: 80px; + &.tb-js-function-help { + right: 116px; + } + } + + label { + padding: 4px; + color: #00acc1; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + } + + .mat-button { + min-width: 32px; + min-height: 15px; + padding: 4px; + margin: 0; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + background: rgba(220, 220, 220, .35); + } + } + + .tb-resize-container { + position: relative; + width: 100%; + height: 100%; + overflow-y: auto; + + .ace_editor { + height: 100%; + } + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts new file mode 100644 index 0000000..76b963b --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts @@ -0,0 +1,233 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, + HostBinding, + Inject, + OnInit, + QueryList, + SkipSelf, + ViewChild, + ViewChildren, + ViewEncapsulation +} from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { NEVER, Observable, of } from 'rxjs'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { ContentType } from '@shared/models/constants'; +import { JsonContentComponent } from '@shared/components/json-content.component'; +import { ScriptLanguage, TestScriptInputParams } from '@shared/models/rule-node.models'; +import { RuleChainService } from '@core/http/rule-chain.service'; +import { mergeMap } from 'rxjs/operators'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { beautifyJs } from '@shared/models/beautify.models'; + +export interface NodeScriptTestDialogData { + script: string; + scriptType: string; + functionTitle: string; + functionName: string; + argNames: string[]; + scriptLang?: ScriptLanguage; + msg?: any; + metadata?: {[key: string]: string}; + msgType?: string; + helpId?: string; +} + +// @dynamic +@Component({ + selector: 'tb-node-script-test-dialog', + templateUrl: './node-script-test-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: NodeScriptTestDialogComponent}], + styleUrls: ['./node-script-test-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class NodeScriptTestDialogComponent extends DialogComponent implements OnInit, AfterViewInit, ErrorStateMatcher { + + @HostBinding('style.width') width = '100%'; + @HostBinding('style.height') height = '100%'; + + @ViewChildren('topPanel') + topPanelElmRef: QueryList>; + + @ViewChildren('topLeftPanel') + topLeftPanelElmRef: QueryList>; + + @ViewChildren('topRightPanel') + topRightPanelElmRef: QueryList>; + + @ViewChildren('bottomPanel') + bottomPanelElmRef: QueryList>; + + @ViewChildren('bottomLeftPanel') + bottomLeftPanelElmRef: QueryList>; + + @ViewChildren('bottomRightPanel') + bottomRightPanelElmRef: QueryList>; + + @ViewChild('payloadContent', {static: true}) payloadContent: JsonContentComponent; + + nodeScriptTestFormGroup: FormGroup; + + functionTitle: string; + + submitted = false; + + contentTypes = ContentType; + + scriptLanguage = ScriptLanguage; + + scriptLang = this.data.scriptLang ? this.data.scriptLang : ScriptLanguage.JS; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: NodeScriptTestDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder, + private ruleChainService: RuleChainService) { + super(store, router, dialogRef); + this.functionTitle = this.data.functionTitle; + } + + ngOnInit(): void { + this.nodeScriptTestFormGroup = this.fb.group({ + payload: this.fb.group({ + msgType: [this.data.msgType, [Validators.required]], + msg: [null, []], + }), + metadata: [this.data.metadata, [Validators.required]], + script: [this.data.script, []], + output: ['', []] + }); + beautifyJs(JSON.stringify(this.data.msg), {indent_size: 4}).subscribe( + (res) => { + this.nodeScriptTestFormGroup.get('payload').get('msg').patchValue(res, {emitEvent: false}); + } + ); + } + + ngAfterViewInit(): void { + this.initSplitLayout(this.topPanelElmRef.first.nativeElement, + this.topLeftPanelElmRef.first.nativeElement, + this.topRightPanelElmRef.first.nativeElement, + this.bottomPanelElmRef.first.nativeElement, + this.bottomLeftPanelElmRef.first.nativeElement, + this.bottomRightPanelElmRef.first.nativeElement); + } + + private initSplitLayout(topPanel: any, + topLeftPanel: any, + topRightPanel: any, + bottomPanel: any, + bottomLeftPanel: any, + bottomRightPanel: any) { + + Split([topPanel, bottomPanel], { + sizes: [35, 65], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }); + + Split([topLeftPanel, topRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + + Split([bottomLeftPanel, bottomRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + test(): void { + this.testNodeScript().subscribe((output) => { + beautifyJs(output, {indent_size: 4}).subscribe( + (res) => { + this.nodeScriptTestFormGroup.get('output').setValue(res); + } + ); + }); + } + + private testNodeScript(): Observable { + if (this.checkInputParamErrors()) { + const inputParams: TestScriptInputParams = { + argNames: this.data.argNames, + scriptType: this.data.scriptType, + msgType: this.nodeScriptTestFormGroup.get('payload.msgType').value, + msg: this.nodeScriptTestFormGroup.get('payload.msg').value, + metadata: this.nodeScriptTestFormGroup.get('metadata').value, + script: this.nodeScriptTestFormGroup.get('script').value + }; + return this.ruleChainService.testScript(inputParams, this.scriptLang).pipe( + mergeMap((result) => { + if (result.error) { + this.store.dispatch(new ActionNotificationShow( + { + message: result.error, + type: 'error' + })); + return NEVER; + } else { + return of(result.output); + } + }) + ); + } else { + return NEVER; + } + } + + private checkInputParamErrors(): boolean { + this.payloadContent.validateOnSubmit(); + if (!this.nodeScriptTestFormGroup.get('payload').valid) { + return false; + } + return true; + } + + save(): void { + this.submitted = true; + this.testNodeScript().subscribe(() => { + this.nodeScriptTestFormGroup.get('script').markAsPristine(); + this.dialogRef.close(this.nodeScriptTestFormGroup.get('script').value); + }); + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.html new file mode 100644 index 0000000..cc10d92 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.html @@ -0,0 +1,24 @@ + +

Coming soon!

+
+

COMING SOON!

+
+
+ +
diff --git a/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.scss new file mode 100644 index 0000000..54b20e4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-dialog-content { + padding: 0 24px 24px; + img { + max-width: 500px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts new file mode 100644 index 0000000..d2ca3e3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'tb-todo-dialog', + templateUrl: './todo-dialog.component.html', + styleUrls: ['./todo-dialog.component.scss'] +}) +export class TodoDialogComponent { + constructor(public dialogRef: MatDialogRef) { + } +} diff --git a/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts b/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts new file mode 100644 index 0000000..b19e58b --- /dev/null +++ b/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts @@ -0,0 +1,127 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ComponentFactory, ComponentRef, + Directive, EventEmitter, Injector, + Input, + OnChanges, Output, Renderer2, + SimpleChange, + SimpleChanges, + TemplateRef, + ViewContainerRef +} from '@angular/core'; + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[tbComponentOutlet]', + exportAs: 'tbComponentOutlet' +}) +export class TbComponentOutletDirective<_T = unknown> implements OnChanges { + private componentRef: ComponentRef | null = null; + private context = new TbComponentOutletContext(); + @Input() tbComponentOutletContext: any | null = null; + @Input() tbComponentStyle: { [klass: string]: any } | null = null; + @Input() tbComponentInjector: Injector | null = null; + @Input() tbComponentOutlet: ComponentFactory = null; + @Output() componentChange = new EventEmitter>(); + + static ngTemplateContextGuard( + // tslint:disable-next-line:variable-name + _dir: TbComponentOutletDirective, + // tslint:disable-next-line:variable-name + _ctx: any + ): _ctx is TbComponentOutletContext { + return true; + } + + private recreateComponent(): void { + this.viewContainer.clear(); + this.componentRef = this.viewContainer.createComponent(this.tbComponentOutlet, 0, this.tbComponentInjector); + this.componentChange.next(this.componentRef); + if (this.tbComponentOutletContext) { + for (const propName of Object.keys(this.tbComponentOutletContext)) { + this.componentRef.instance[propName] = this.tbComponentOutletContext[propName]; + } + } + if (this.tbComponentStyle) { + for (const propName of Object.keys(this.tbComponentStyle)) { + this.renderer.setStyle(this.componentRef.location.nativeElement, propName, this.tbComponentStyle[propName]); + } + } + } + + private updateContext(): void { + const newCtx = this.tbComponentOutletContext; + const oldCtx = this.componentRef.instance as any; + if (newCtx) { + for (const propName of Object.keys(newCtx)) { + oldCtx[propName] = newCtx[propName]; + } + } + } + + constructor(private viewContainer: ViewContainerRef, + private renderer: Renderer2) {} + + ngOnChanges(changes: SimpleChanges): void { + const { tbComponentOutletContext, tbComponentOutlet } = changes; + const shouldRecreateComponent = (): boolean => { + let shouldOutletRecreate = false; + if (tbComponentOutlet) { + if (tbComponentOutlet.firstChange) { + shouldOutletRecreate = true; + } else { + const isPreviousOutletTemplate = tbComponentOutlet.previousValue instanceof ComponentFactory; + const isCurrentOutletTemplate = tbComponentOutlet.currentValue instanceof ComponentFactory; + shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate; + } + } + const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => { + const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); + const currCtxKeys = Object.keys(ctxChange.currentValue || {}); + if (prevCtxKeys.length === currCtxKeys.length) { + for (const propName of currCtxKeys) { + if (prevCtxKeys.indexOf(propName) === -1) { + return true; + } + } + return false; + } else { + return true; + } + }; + const shouldContextRecreate = + tbComponentOutletContext && hasContextShapeChanged(tbComponentOutletContext); + return shouldContextRecreate || shouldOutletRecreate; + }; + + if (tbComponentOutlet) { + this.context.$implicit = tbComponentOutlet.currentValue; + } + + const recreateComponent = shouldRecreateComponent(); + if (recreateComponent) { + this.recreateComponent(); + } else { + this.updateContext(); + } + } +} + +export class TbComponentOutletContext { + public $implicit: any; +} diff --git a/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts b/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts new file mode 100644 index 0000000..f426ece --- /dev/null +++ b/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + Directive, + EmbeddedViewRef, + Input, + OnChanges, + SimpleChange, + SimpleChanges, + TemplateRef, + ViewContainerRef +} from '@angular/core'; + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[tbStringTemplateOutlet]', + exportAs: 'tbStringTemplateOutlet' +}) +export class TbStringTemplateOutletDirective<_T = unknown> implements OnChanges { + private embeddedViewRef: EmbeddedViewRef | null = null; + private context = new TbStringTemplateOutletContext(); + @Input() tbStringTemplateOutletContext: any | null = null; + @Input() tbStringTemplateOutlet: any | TemplateRef = null; + + static ngTemplateContextGuard( + // tslint:disable-next-line:variable-name + _dir: TbStringTemplateOutletDirective, + // tslint:disable-next-line:variable-name + _ctx: any + ): _ctx is TbStringTemplateOutletContext { + return true; + } + + private recreateView(): void { + this.viewContainer.clear(); + const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef; + const templateRef = (isTemplateRef ? this.tbStringTemplateOutlet : this.templateRef) as any; + this.embeddedViewRef = this.viewContainer.createEmbeddedView( + templateRef, + isTemplateRef ? this.tbStringTemplateOutletContext : this.context + ); + } + + private updateContext(): void { + const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef; + const newCtx = isTemplateRef ? this.tbStringTemplateOutletContext : this.context; + const oldCtx = this.embeddedViewRef.context as any; + if (newCtx) { + for (const propName of Object.keys(newCtx)) { + oldCtx[propName] = newCtx[propName]; + } + } + } + + constructor(private viewContainer: ViewContainerRef, private templateRef: TemplateRef) {} + + ngOnChanges(changes: SimpleChanges): void { + const { tbStringTemplateOutletContext, tbStringTemplateOutlet } = changes; + const shouldRecreateView = (): boolean => { + let shouldOutletRecreate = false; + if (tbStringTemplateOutlet) { + if (tbStringTemplateOutlet.firstChange) { + shouldOutletRecreate = true; + } else { + const isPreviousOutletTemplate = tbStringTemplateOutlet.previousValue instanceof TemplateRef; + const isCurrentOutletTemplate = tbStringTemplateOutlet.currentValue instanceof TemplateRef; + shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate; + } + } + const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => { + const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); + const currCtxKeys = Object.keys(ctxChange.currentValue || {}); + if (prevCtxKeys.length === currCtxKeys.length) { + for (const propName of currCtxKeys) { + if (prevCtxKeys.indexOf(propName) === -1) { + return true; + } + } + return false; + } else { + return true; + } + }; + const shouldContextRecreate = + tbStringTemplateOutletContext && hasContextShapeChanged(tbStringTemplateOutletContext); + return shouldContextRecreate || shouldOutletRecreate; + }; + + if (tbStringTemplateOutlet) { + this.context.$implicit = tbStringTemplateOutlet.currentValue; + } + + const recreateView = shouldRecreateView(); + if (recreateView) { + this.recreateView(); + } else { + this.updateContext(); + } + } +} + +export class TbStringTemplateOutletContext { + public $implicit: any; +} diff --git a/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts b/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts new file mode 100644 index 0000000..454a4d5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts @@ -0,0 +1,103 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Directive, ElementRef, forwardRef, HostListener, Renderer2, SkipSelf } from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + FormGroupDirective, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + NgForm, + ValidationErrors, + Validator +} from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { isObject } from "@core/utils"; + +@Directive({ + selector: '[tb-json-to-string]', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbJsonToStringDirective), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TbJsonToStringDirective), + multi: true, + }, + { + provide: ErrorStateMatcher, + useExisting: TbJsonToStringDirective + }] +}) + +export class TbJsonToStringDirective implements ControlValueAccessor, Validator, ErrorStateMatcher { + private propagateChange = null; + private parseError: boolean; + private data: any; + + @HostListener('input', ['$event.target.value']) input(newValue: any): void { + try { + this.data = JSON.parse(newValue); + if (isObject(this.data)) { + this.parseError = false; + } else { + this.parseError = true; + } + } catch (e) { + this.parseError = true; + } + + this.propagateChange(this.data); + } + + constructor(private render: Renderer2, + private element: ElementRef, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher) { + + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.parseError); + return originalErrorState || customErrorState; + } + + validate(c: FormControl): ValidationErrors { + return (!this.parseError) ? null : { + invalidJSON: { + valid: false + } + }; + } + + writeValue(obj: any): void { + if (obj) { + this.data = obj; + this.parseError = false; + this.render.setProperty(this.element.nativeElement, 'value', JSON.stringify(obj)); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html new file mode 100644 index 0000000..c2b10c7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -0,0 +1,46 @@ + + + + + + + + + + + {{ translate.get(noEntitiesMatchingText, {entity: searchText}) | async }} + + + + + {{ entityRequiredText | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss new file mode 100644 index 0000000..eb75335 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-form-field-infix { + width: auto; + min-width: 100px; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts new file mode 100644 index 0000000..271be7b --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -0,0 +1,387 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { MatFormFieldAppearance } from '@angular/material/form-field/form-field'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { merge, Observable, of, Subject } from 'rxjs'; +import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { BaseData } from '@shared/models/base-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityService } from '@core/http/entity.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-entity-autocomplete', + templateUrl: './entity-autocomplete.component.html', + styleUrls: ['./entity-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAutocompleteComponent), + multi: true + }] +}) +export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + selectEntityFormGroup: FormGroup; + + modelValue: string | null; + + entityTypeValue: EntityType | AliasEntityType; + + entitySubtypeValue: string; + + @Input() + set entityType(entityType: EntityType) { + if (this.entityTypeValue !== entityType) { + this.entityTypeValue = entityType; + this.load(); + this.reset(); + this.refresh$.next([]); + this.dirty = true; + } + } + + @Input() + set entitySubtype(entitySubtype: string) { + if (this.entitySubtypeValue !== entitySubtype) { + this.entitySubtypeValue = entitySubtype; + const currentEntity = this.getCurrentEntity(); + if (currentEntity) { + if ((currentEntity as any).type !== this.entitySubtypeValue) { + this.reset(); + this.refresh$.next([]); + this.dirty = true; + } + } + this.selectEntityFormGroup.get('entity').updateValueAndValidity(); + } + } + + @Input() + excludeEntityIds: Array; + + @Input() + labelText: string; + + @Input() + requiredText: string; + + @Input() + appearance: MatFormFieldAppearance = 'legacy'; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @Output() + entityChanged = new EventEmitter>(); + + @ViewChild('entityInput', {static: true}) entityInput: ElementRef; + + entityText: string; + noEntitiesMatchingText: string; + entityRequiredText: string; + + filteredEntities: Observable>>; + + searchText = ''; + + private dirty = false; + + private refresh$ = new Subject>>(); + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.selectEntityFormGroup = this.fb.group({ + entity: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredEntities = merge( + this.refresh$.asObservable(), + this.selectEntityFormGroup.get('entity').valueChanges + .pipe( + debounceTime(150), + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value.id.id; + } + this.updateView(modelValue, value); + if (value === null) { + this.clear(); + } + }), + // startWith>(''), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + switchMap(name => this.fetchEntities(name)), + share() + ) + ); + } + + ngAfterViewInit(): void {} + + load(): void { + if (this.entityTypeValue) { + switch (this.entityTypeValue) { + case EntityType.ASSET: + this.entityText = 'asset.asset'; + this.noEntitiesMatchingText = 'asset.no-assets-matching'; + this.entityRequiredText = 'asset.asset-required'; + break; + case EntityType.DEVICE: + this.entityText = 'device.device'; + this.noEntitiesMatchingText = 'device.no-devices-matching'; + this.entityRequiredText = 'device.device-required'; + break; + case EntityType.EDGE: + this.entityText = 'edge.edge'; + this.noEntitiesMatchingText = 'edge.no-edges-matching'; + this.entityRequiredText = 'edge.edge-required'; + break; + case EntityType.ENTITY_VIEW: + this.entityText = 'entity-view.entity-view'; + this.noEntitiesMatchingText = 'entity-view.no-entity-views-matching'; + this.entityRequiredText = 'entity-view.entity-view-required'; + break; + case EntityType.RULE_CHAIN: + this.entityText = 'rulechain.rulechain'; + this.noEntitiesMatchingText = 'rulechain.no-rulechains-matching'; + this.entityRequiredText = 'rulechain.rulechain-required'; + break; + case EntityType.TENANT: + case AliasEntityType.CURRENT_TENANT: + this.entityText = 'tenant.tenant'; + this.noEntitiesMatchingText = 'tenant.no-tenants-matching'; + this.entityRequiredText = 'tenant.tenant-required'; + break; + case EntityType.CUSTOMER: + this.entityText = 'customer.customer'; + this.noEntitiesMatchingText = 'customer.no-customers-matching'; + this.entityRequiredText = 'customer.customer-required'; + break; + case EntityType.USER: + case AliasEntityType.CURRENT_USER: + this.entityText = 'user.user'; + this.noEntitiesMatchingText = 'user.no-users-matching'; + this.entityRequiredText = 'user.user-required'; + break; + case EntityType.DASHBOARD: + this.entityText = 'dashboard.dashboard'; + this.noEntitiesMatchingText = 'dashboard.no-dashboards-matching'; + this.entityRequiredText = 'dashboard.dashboard-required'; + break; + case EntityType.ALARM: + this.entityText = 'alarm.alarm'; + this.noEntitiesMatchingText = 'alarm.no-alarms-matching'; + this.entityRequiredText = 'alarm.alarm-required'; + break; + case AliasEntityType.CURRENT_CUSTOMER: + this.entityText = 'customer.default-customer'; + this.noEntitiesMatchingText = 'customer.no-customers-matching'; + this.entityRequiredText = 'customer.default-customer-required'; + break; + case AliasEntityType.CURRENT_USER_OWNER: + const authUser = getCurrentAuthUser(this.store); + if (authUser.authority === Authority.TENANT_ADMIN) { + this.entityText = 'tenant.tenant'; + this.noEntitiesMatchingText = 'tenant.no-tenants-matching'; + this.entityRequiredText = 'tenant.tenant-required'; + } else { + this.entityText = 'customer.customer'; + this.noEntitiesMatchingText = 'customer.no-customers-matching'; + this.entityRequiredText = 'customer.customer-required'; + } + break; + } + } + if (this.labelText && this.labelText.length) { + this.entityText = this.labelText; + } + if (this.requiredText && this.requiredText.length) { + this.entityRequiredText = this.requiredText; + } + const currentEntity = this.getCurrentEntity(); + if (currentEntity) { + const currentEntityType = currentEntity.id.entityType; + if (this.entityTypeValue && currentEntityType !== this.entityTypeValue) { + this.reset(); + } + } + } + + getCurrentEntity(): BaseData | null { + const currentEntity = this.selectEntityFormGroup.get('entity').value; + if (currentEntity && typeof currentEntity !== 'string') { + return currentEntity as BaseData; + } else { + return null; + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectEntityFormGroup.disable({emitEvent: false}); + } else { + this.selectEntityFormGroup.enable({emitEvent: false}); + } + } + + async writeValue(value: string | EntityId | null): Promise { + this.searchText = ''; + if (value !== null && (typeof value === 'string' || (value.entityType && value.id))) { + let targetEntityType: EntityType; + let id: string; + if (typeof value === 'string') { + targetEntityType = this.checkEntityType(this.entityTypeValue); + id = value; + } else { + targetEntityType = this.checkEntityType(value.entityType); + id = value.id; + } + let entity: BaseData = null; + try { + entity = await this.entityService.getEntity(targetEntityType, id, {ignoreLoading: true, ignoreErrors: true}).toPromise(); + } catch (e) { + this.propagateChange(null); + } + this.modelValue = entity !== null ? entity.id.id : null; + this.selectEntityFormGroup.get('entity').patchValue(entity !== null ? entity : '', {emitEvent: false}); + this.entityChanged.emit(entity); + } else { + this.modelValue = null; + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: false}); + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.selectEntityFormGroup.get('entity').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + reset() { + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: false}); + } + + updateView(value: string | null, entity: BaseData | null) { + if (!isEqual(this.modelValue, value)) { + this.modelValue = value; + this.propagateChange(this.modelValue); + this.entityChanged.emit(entity); + } + } + + displayEntityFn(entity?: BaseData): string | undefined { + return entity ? entity.name : undefined; + } + + fetchEntities(searchText?: string): Observable>> { + this.searchText = searchText; + const targetEntityType = this.checkEntityType(this.entityTypeValue); + return this.entityService.getEntitiesByNameFilter(targetEntityType, searchText, + 50, this.entitySubtypeValue, {ignoreLoading: true}).pipe( + catchError(() => of(null)), + map((data) => { + if (data) { + if (this.excludeEntityIds && this.excludeEntityIds.length) { + const entities: Array> = []; + data.forEach((entity) => { + if (this.excludeEntityIds.indexOf(entity.id.id) === -1) { + entities.push(entity); + } + }); + return entities; + } else { + return data; + } + } else { + return []; + } + } + )); + } + + clear() { + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: true}); + setTimeout(() => { + this.entityInput.nativeElement.blur(); + this.entityInput.nativeElement.focus(); + }, 0); + } + + checkEntityType(entityType: EntityType | AliasEntityType): EntityType { + if (entityType === AliasEntityType.CURRENT_CUSTOMER) { + return EntityType.CUSTOMER; + } else if (entityType === AliasEntityType.CURRENT_TENANT) { + return EntityType.TENANT; + } else if (entityType === AliasEntityType.CURRENT_USER) { + return EntityType.USER; + } else if (entityType === AliasEntityType.CURRENT_USER_OWNER) { + const authUser = getCurrentAuthUser(this.store); + if (authUser.authority === Authority.TENANT_ADMIN) { + return EntityType.TENANT; + } else { + return EntityType.CUSTOMER; + } + } + return entityType; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html new file mode 100644 index 0000000..954035e --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html @@ -0,0 +1,56 @@ + + + + + + + + + +
+
+ gateway.no-gateway-found +
+ + + gateway.no-gateway-matching + + + + gateway.create-new-gateway + +
+
+
+ + {{ 'gateway.gateway-name-required' | translate }} + +
diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts new file mode 100644 index 0000000..d78e0f0 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts @@ -0,0 +1,249 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ENTER } from '@angular/cdk/keycodes'; +import { Observable, of } from 'rxjs'; +import { filter, map, mergeMap, reduce, share, switchMap, tap } from 'rxjs/operators'; +import { EntityService } from '@core/http/entity.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { Device } from '@shared/models/device.models'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { DeviceService } from '@core/http/device.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; + +@Component({ + selector: 'tb-entity-gateway-select', + templateUrl: './entity-gateway-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityGatewaySelectComponent), + multi: true + }] +}) + +export class EntityGatewaySelectComponent implements ControlValueAccessor, OnInit { + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + set newGatewayType(value: string){ + this.gatewayType = value; + } + + @Input() + deviceName: string; + + @Input() + isStateForm: boolean; + + @Output() + private gatewayNameExist = new EventEmitter(); + + constructor(private store: Store, + private entityService: EntityService, + private dialogService: DialogService, + private deviceService: DeviceService, + private translate: TranslateService, + private fb: FormBuilder) { + } + + private gatewayType = 'Gateway'; + private dirty: boolean; + private requiredValue: boolean; + private gatewayList: Array; + + searchText = ''; + filteredGateways: Observable>; + selectDeviceGatewayFormGroup: FormGroup; + modelValue: string | null; + + @ViewChild('deviceGatewayInput', {static: true}) deviceGatewayInput: ElementRef; + private propagateChange = (v: any) => { }; + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.selectDeviceGatewayFormGroup = this.fb.group({ + gateway: this.fb.control({value: null, disabled: this.isStateForm}) + }); + this.loadGatewayList(); + this.filteredGateways = this.selectDeviceGatewayFormGroup.get('gateway').valueChanges + .pipe( + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value.id.id; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchGateway(name) ), + share() + ); + } + + fetchGateway(searchText?: string): Observable> { + this.searchText = searchText; + let result = []; + if (searchText && searchText.length) { + result = this.gatewayList.filter((gateway) => gateway.name.toLowerCase().includes(searchText.toLowerCase())); + } else { + result = this.gatewayList; + } + return of(result); + } + + onFocus() { + if (this.dirty) { + this.selectDeviceGatewayFormGroup.get('gateway').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + displayGatewayFn(gateway?: Device): string | undefined { + return gateway ? gateway.name : undefined; + } + + setDisabledState(isDisabled: boolean): void { + } + + writeValue(value: string | null): void { + if(value === null){ + this.searchText = ''; + this.selectDeviceGatewayFormGroup.get('gateway').patchValue('', {emitEvent: false}); + this.dirty = true; + } + } + + clear(value: string = '', hideList?: boolean) { + this.searchText = value; + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(value, {emitEvent: true}); + if(!hideList) { + setTimeout(() => { + this.deviceGatewayInput.nativeElement.blur(); + this.deviceGatewayInput.nativeElement.focus(); + }, 0); + } + } + + textIsNotEmpty(text: string): boolean { + return !!text && text.length > 0; + } + + gatewayNameEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + if (!this.modelValue) { + this.createGateway($event, this.searchText); + } + } + } + + createGateway($event: Event, gatewayName: string) { + $event.preventDefault(); + $event.stopPropagation(); + const title = this.translate.instant('gateway.create-new-gateway'); + const content = this.translate.instant('gateway.create-new-gateway-text', {gatewayName}); + this.dialogService.confirm(title, content, null, null, true).subscribe(value => { + if(value){ + this.createDeviceGateway(gatewayName); + } else { + this.clear('', true); + } + }); + } + + private createDeviceGateway(gatewayName: string){ + this.deviceService.findByName(gatewayName, {ignoreErrors: true}).subscribe(value => { + this.gatewayNameExist.emit(gatewayName) + }, () => { + const newGateway: Device = { + name: gatewayName, + label: null, + type: this.gatewayType, + additionalInfo: { + gateway: true + } + }; + + this.deviceService.saveDevice(newGateway).subscribe( + (device) => { + this.searchText = ''; + this.gatewayList.push(device); + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(device, {emitEvent: true}); + } + ); + }) + } + + private loadGatewayList(): void { + let listObservable: Observable; + if (getCurrentAuthUser(this.store).authority === Authority.SYS_ADMIN) { + listObservable = of([]); + } else { + const entityNameFilter = this.isStateForm && this.deviceName ? this.deviceName : ''; + listObservable = this.entityService.getEntitiesByNameFilter(EntityType.DEVICE, entityNameFilter, + -1, '', {ignoreLoading: true}); + } + listObservable.pipe( + map((devices) => devices ? devices.filter((device) => + (device as Device)?.additionalInfo?.gateway): []), + ).subscribe((devices) => { + this.gatewayList = devices; + if (!this.searchText) { + if (this.gatewayList.length) { + let foundGateway: Device = null; + if (this.deviceName) { + foundGateway = this.gatewayList.find((gateway) => gateway.name === this.deviceName); + } + if (!foundGateway) { + foundGateway = this.gatewayList[0]; + } + if (foundGateway) { + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(foundGateway, {emitEvent: true}); + } + } + } + }) + } + + private updateView(modelValue: any) { + this.propagateChange(modelValue); + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html new file mode 100644 index 0000000..1047aca --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html @@ -0,0 +1,50 @@ + + + + + {{key}} + close + + + + + + + + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.scss b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.scss new file mode 100644 index 0000000..bc23b76 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} + +:host ::ng-deep { + .mat-form-field-flex { + padding-top: 0; + .mat-form-field-infix { + border-top: 0; + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts new file mode 100644 index 0000000..3e5e433 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts @@ -0,0 +1,208 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, share } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityService } from '@core/http/entity.service'; +import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent, MatChipList } from '@angular/material/chips'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-entity-keys-list', + templateUrl: './entity-keys-list.component.html', + styleUrls: ['./entity-keys-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityKeysListComponent), + multi: true + } + ] +}) +export class EntityKeysListComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + keysListFormGroup: FormGroup; + + modelValue: Array | null; + + entityIdValue: EntityId; + + @Input() + set entityId(entityId: EntityId) { + if (!isEqual(this.entityIdValue, entityId)) { + this.entityIdValue = entityId; + this.dirty = true; + } + } + + @Input() + keysText: string; + + @Input() + dataKeyType: DataKeyType; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('keyInput') keyInput: ElementRef; + @ViewChild('keyAutocomplete') matAutocomplete: MatAutocomplete; + @ViewChild('chipList') chipList: MatChipList; + + filteredKeys: Observable>; + + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.keysListFormGroup = this.fb.group({ + key: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredKeys = this.keysListFormGroup.get('key').valueChanges + .pipe( + map((value) => value ? value : ''), + mergeMap(name => this.fetchKeys(name) ), + share() + ); + } + + ngAfterViewInit(): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.keysListFormGroup.disable({emitEvent: false}); + } else { + this.keysListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null) { + this.modelValue = [...value]; + } else { + this.modelValue = []; + } + } + + onFocus() { + if (this.dirty) { + this.keysListFormGroup.get('key').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + addKey(key: string): void { + if (!this.modelValue || this.modelValue.indexOf(key) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(key); + if (this.required) { + this.chipList.errorState = false; + } + } + this.propagateChange(this.modelValue); + } + + add(event: MatChipInputEvent): void { + if (!this.matAutocomplete.isOpen) { + const value = (event.value || '').trim(); + if (value) { + this.addKey(value); + } + this.clear('', document.activeElement === this.keyInput.nativeElement); + } + } + + remove(key: string) { + const index = this.modelValue.indexOf(key); + if (index >= 0) { + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + if (this.required) { + this.chipList.errorState = true; + } + } + this.propagateChange(this.modelValue.length ? this.modelValue : null); + } + } + + selected(event: MatAutocompleteSelectedEvent): void { + this.addKey(event.option.viewValue); + this.clear(''); + } + + displayKeyFn(key?: string): string | undefined { + return key ? key : undefined; + } + + fetchKeys(searchText?: string): Observable> { + this.searchText = searchText; + return this.entityIdValue ? this.entityService.getEntityKeys(this.entityIdValue, searchText, + this.dataKeyType, {ignoreLoading: true}).pipe( + map((data) => data ? data : [])) : of([]); + } + + clear(value: string = '', emitEvent = true) { + this.keyInput.nativeElement.value = value; + this.keysListFormGroup.get('key').patchValue(null, {emitEvent}); + if (emitEvent) { + setTimeout(() => { + this.keyInput.nativeElement.blur(); + this.keyInput.nativeElement.focus(); + }, 0); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html new file mode 100644 index 0000000..ed9ee76 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -0,0 +1,36 @@ + +
+ + + + +
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss new file mode 100644 index 0000000..f814f60 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { +} + +:host ::ng-deep { + tb-entity-list { + &.tb-not-empty { + .mat-form-field-flex { + padding-top: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts new file mode 100644 index 0000000..4604dd3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -0,0 +1,170 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { EntityService } from '@core/http/entity.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +interface EntityListSelectModel { + entityType: EntityType | AliasEntityType; + ids: Array; +} + +@Component({ + selector: 'tb-entity-list-select', + templateUrl: './entity-list-select.component.html', + styleUrls: ['./entity-list-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListSelectComponent), + multi: true + }] +}) + +export class EntityListSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entityListSelectFormGroup: FormGroup; + + modelValue: EntityListSelectModel = {entityType: null, ids: []}; + + @Input() + allowedEntityTypes: Array; + + @Input() + useAliasEntityTypes: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + displayEntityTypeSelect: boolean; + + private readonly defaultEntityType: EntityType | AliasEntityType = null; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private entityService: EntityService, + public translate: TranslateService, + private fb: FormBuilder) { + + const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, + this.useAliasEntityTypes); + if (entityTypes.length === 1) { + this.displayEntityTypeSelect = false; + this.defaultEntityType = entityTypes[0]; + } else { + this.displayEntityTypeSelect = true; + } + + this.entityListSelectFormGroup = this.fb.group({ + entityType: [this.defaultEntityType], + entityIds: [[]] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.entityListSelectFormGroup.get('entityType').valueChanges.subscribe( + (value) => { + this.updateView(value, this.modelValue.ids); + } + ); + this.entityListSelectFormGroup.get('entityIds').valueChanges.subscribe( + (values) => { + this.updateView(this.modelValue.entityType, values); + } + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.entityListSelectFormGroup.disable(); + } else { + this.entityListSelectFormGroup.enable(); + } + } + + writeValue(value: Array | null): void { + if (value != null && value.length > 0) { + const id = value[0]; + this.modelValue = { + entityType: id.entityType, + ids: value.map(val => val.id) + }; + } else { + this.modelValue = { + entityType: this.defaultEntityType, + ids: [] + }; + } + this.entityListSelectFormGroup.get('entityType').patchValue(this.modelValue.entityType, {emitEvent: true}); + this.entityListSelectFormGroup.get('entityIds').patchValue([...this.modelValue.ids], {emitEvent: true}); + } + + updateView(entityType: EntityType | AliasEntityType | null, entityIds: Array | null) { + if (this.modelValue.entityType !== entityType || + !this.compareIds(this.modelValue.ids, entityIds)) { + this.modelValue = { + entityType, + ids: this.modelValue.entityType !== entityType || !entityIds ? [] : [...entityIds] + }; + this.propagateChange(this.toEntityIds(this.modelValue)); + } + } + + compareIds(ids1: Array | null, ids2: Array | null): boolean { + if (ids1 !== null && ids2 !== null) { + return JSON.stringify(ids1) === JSON.stringify(ids2); + } else { + return ids1 === ids2; + } + } + + toEntityIds(modelValue: EntityListSelectModel): Array { + if (modelValue !== null && modelValue.entityType && modelValue.ids && modelValue.ids.length > 0) { + const entityType = modelValue.entityType; + return modelValue.ids.map(id => ({entityType, id})); + } else { + return null; + } + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html new file mode 100644 index 0000000..7a0b0a5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -0,0 +1,54 @@ + + + + + {{entity.name}} + close + + + + + + + + + + {{ translate.get('entity.no-entities-matching', {entity: searchText}) | async }} + + + + + {{ 'entity.entity-list-empty' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.scss b/ui-ngx/src/app/shared/components/entity/entity-list.component.scss new file mode 100644 index 0000000..82a32d7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-form-field { + .mat-form-field-infix { + border-top: none; + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts new file mode 100644 index 0000000..4a20499 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -0,0 +1,244 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityType } from '@shared/models/entity-type.models'; +import { BaseData } from '@shared/models/base-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityService } from '@core/http/entity.service'; +import { MatAutocomplete } from '@angular/material/autocomplete'; +import { MatChipList } from '@angular/material/chips'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'tb-entity-list', + templateUrl: './entity-list.component.html', + styleUrls: ['./entity-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListComponent), + multi: true + } + ] +}) +export class EntityListComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { + + entityListFormGroup: FormGroup; + + modelValue: Array | null; + + @Input() + entityType: EntityType; + + @Input() + subType: string; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + @ViewChild('entityInput') entityInput: ElementRef; + @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: true}) chipList: MatChipList; + + entities: Array> = []; + filteredEntities: Observable>>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.entityListFormGroup = this.fb.group({ + entities: [this.entities, this.required ? [Validators.required] : []], + entity: [null] + }); + } + + updateValidators() { + this.entityListFormGroup.get('entities').setValidators(this.required ? [Validators.required] : []); + this.entityListFormGroup.get('entities').updateValueAndValidity(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredEntities = this.entityListFormGroup.get('entity').valueChanges + .pipe( + // startWith>(''), + tap((value) => { + if (value && typeof value !== 'string') { + this.add(value); + } else if (value === null) { + this.clear(this.entityInput.nativeElement.value); + } + }), + filter((value) => typeof value === 'string'), + map((value) => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchEntities(name) ), + share() + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'entityType') { + this.reset(); + } + } + } + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entityListFormGroup.disable({emitEvent: false}); + } else { + this.entityListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null && value.length > 0) { + this.modelValue = [...value]; + this.entityService.getEntities(this.entityType, value).subscribe( + (entities) => { + this.entities = entities; + this.entityListFormGroup.get('entities').setValue(this.entities); + } + ); + } else { + this.entities = []; + this.entityListFormGroup.get('entities').setValue(this.entities); + this.modelValue = null; + } + this.dirty = true; + } + + reset() { + this.entities = []; + this.entityListFormGroup.get('entities').setValue(this.entities); + this.modelValue = null; + if (this.entityInput) { + this.entityInput.nativeElement.value = ''; + } + this.entityListFormGroup.get('entity').patchValue('', {emitEvent: false}); + this.propagateChange(this.modelValue); + this.dirty = true; + } + + add(entity: BaseData): void { + if (!this.modelValue || this.modelValue.indexOf(entity.id.id) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entity.id.id); + this.entities.push(entity); + this.entityListFormGroup.get('entities').setValue(this.entities); + } + this.propagateChange(this.modelValue); + this.clear(); + } + + remove(entity: BaseData) { + let index = this.entities.indexOf(entity); + if (index >= 0) { + this.entities.splice(index, 1); + this.entityListFormGroup.get('entities').setValue(this.entities); + index = this.modelValue.indexOf(entity.id.id); + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + this.modelValue = null; + } + this.propagateChange(this.modelValue); + this.clear(); + } + } + + displayEntityFn(entity?: BaseData): string | undefined { + return entity ? entity.name : undefined; + } + + fetchEntities(searchText?: string): Observable>> { + this.searchText = searchText; + + return this.entityService.getEntitiesByNameFilter(this.entityType, searchText, + 50, this.subType ? this.subType : '', {ignoreLoading: true}).pipe( + map((data) => data ? data : [])); + } + + onFocus() { + if (this.dirty) { + this.entityListFormGroup.get('entity').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + clear(value: string = '') { + this.entityInput.nativeElement.value = value; + this.entityListFormGroup.get('entity').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entityInput.nativeElement.blur(); + this.entityInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html new file mode 100644 index 0000000..8169fb4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -0,0 +1,37 @@ + +
+ + + + +
diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-select.component.scss new file mode 100644 index 0000000..c310477 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.scss @@ -0,0 +1,17 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts new file mode 100644 index 0000000..829242c --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -0,0 +1,163 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { EntityService } from '@core/http/entity.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-entity-select', + templateUrl: './entity-select.component.html', + styleUrls: ['./entity-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySelectComponent), + multi: true + }] +}) +export class EntitySelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entitySelectFormGroup: FormGroup; + + modelValue: EntityId = {entityType: null, id: null}; + + @Input() + allowedEntityTypes: Array; + + @Input() + useAliasEntityTypes: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + displayEntityTypeSelect: boolean; + + AliasEntityType = AliasEntityType; + + private readonly defaultEntityType: EntityType | AliasEntityType = null; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private entityService: EntityService, + public translate: TranslateService, + private fb: FormBuilder) { + + const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, + this.useAliasEntityTypes); + if (entityTypes.length === 1) { + this.displayEntityTypeSelect = false; + this.defaultEntityType = entityTypes[0]; + } else { + this.displayEntityTypeSelect = true; + } + + this.entitySelectFormGroup = this.fb.group({ + entityType: [this.defaultEntityType], + entityId: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.entitySelectFormGroup.get('entityType').valueChanges.subscribe( + (value) => { + this.updateView(value, this.modelValue.id); + } + ); + this.entitySelectFormGroup.get('entityId').valueChanges.subscribe( + (value) => { + const id = value ? (typeof value === 'string' ? value : value.id) : null; + this.updateView(this.modelValue.entityType, id); + } + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.entitySelectFormGroup.disable(); + } else { + this.entitySelectFormGroup.enable(); + } + } + + writeValue(value: EntityId | null): void { + if (value != null) { + if (value.id === NULL_UUID) { + value.id = null; + } + this.modelValue = value; + this.entitySelectFormGroup.get('entityType').patchValue(value.entityType, {emitEvent: true}); + this.entitySelectFormGroup.get('entityId').patchValue(value, {emitEvent: true}); + } else { + this.modelValue = { + entityType: this.defaultEntityType, + id: null + }; + this.entitySelectFormGroup.get('entityType').patchValue(this.defaultEntityType, {emitEvent: true}); + this.entitySelectFormGroup.get('entityId').patchValue(null, {emitEvent: true}); + } + } + + updateView(entityType: EntityType | AliasEntityType | null, entityId: string | null) { + if (this.modelValue.entityType !== entityType || this.modelValue.id !== entityId) { + this.modelValue = { + entityType, + id: this.modelValue.entityType !== entityType ? null : entityId + }; + + if (this.modelValue.entityType === AliasEntityType.CURRENT_TENANT + || this.modelValue.entityType === AliasEntityType.CURRENT_USER + || this.modelValue.entityType === AliasEntityType.CURRENT_USER_OWNER) { + this.modelValue.id = NULL_UUID; + } else if (this.modelValue.entityType === AliasEntityType.CURRENT_CUSTOMER && !this.modelValue.id) { + this.modelValue.id = NULL_UUID; + } + + if (this.modelValue.entityType && this.modelValue.id) { + this.propagateChange(this.modelValue); + } else { + this.propagateChange(null); + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html new file mode 100644 index 0000000..f8ec169 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html @@ -0,0 +1,46 @@ + + + {{ entitySubtypeText | translate }} + + + + + + + + + {{ entitySubtypeRequiredText | translate }} + + + {{ entitySubtypeMaxLength | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts new file mode 100644 index 0000000..1bae77a --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts @@ -0,0 +1,262 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable, of, Subscription, throwError } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + map, + publishReplay, + refCount, + switchMap, + tap +} from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { DeviceService } from '@core/http/device.service'; +import { EntitySubtype, EntityType } from '@app/shared/models/entity-type.models'; +import { BroadcastService } from '@app/core/services/broadcast.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { AssetService } from '@core/http/asset.service'; +import { EntityViewService } from '@core/http/entity-view.service'; +import { EdgeService } from '@core/http/edge.service'; + +@Component({ + selector: 'tb-entity-subtype-autocomplete', + templateUrl: './entity-subtype-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeAutocompleteComponent), + multi: true + }] +}) +export class EntitySubTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + subTypeFormGroup: FormGroup; + + modelValue: string | null; + + @Input() + entityType: EntityType; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('subTypeInput', {static: true}) subTypeInput: ElementRef; + + selectEntitySubtypeText: string; + entitySubtypeText: string; + entitySubtypeRequiredText: string; + entitySubtypeMaxLength: string; + + filteredSubTypes: Observable>; + + subTypes: Observable>; + + private broadcastSubscription: Subscription; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private deviceService: DeviceService, + private assetService: AssetService, + private edgeService: EdgeService, + private entityViewService: EntityViewService, + private fb: FormBuilder) { + this.subTypeFormGroup = this.fb.group({ + subType: [null, Validators.maxLength(255)] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + switch (this.entityType) { + case EntityType.ASSET: + this.selectEntitySubtypeText = 'asset.select-asset-type'; + this.entitySubtypeText = 'asset.asset-type'; + this.entitySubtypeRequiredText = 'asset.asset-type-required'; + this.entitySubtypeMaxLength = 'asset.asset-type-max-length'; + this.broadcastSubscription = this.broadcast.on('assetSaved', () => { + this.subTypes = null; + }); + break; + case EntityType.DEVICE: + this.selectEntitySubtypeText = 'device.select-device-type'; + this.entitySubtypeText = 'device.device-type'; + this.entitySubtypeRequiredText = 'device.device-type-required'; + this.entitySubtypeMaxLength = 'device.device-type-max-length'; + this.broadcastSubscription = this.broadcast.on('deviceSaved', () => { + this.subTypes = null; + }); + break; + case EntityType.EDGE: + this.selectEntitySubtypeText = 'edge.select-edge-type'; + this.entitySubtypeText = 'edge.edge-type'; + this.entitySubtypeRequiredText = 'edge.edge-type-required'; + this.entitySubtypeMaxLength = 'edge.type-max-length'; + this.broadcastSubscription = this.broadcast.on('edgeSaved', () => { + this.subTypes = null; + }); + break; + case EntityType.ENTITY_VIEW: + this.selectEntitySubtypeText = 'entity-view.select-entity-view-type'; + this.entitySubtypeText = 'entity-view.entity-view-type'; + this.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; + this.entitySubtypeMaxLength = 'entity-view.type-max-length' + this.broadcastSubscription = this.broadcast.on('entityViewSaved', () => { + this.subTypes = null; + }); + break; + } + + this.filteredSubTypes = this.subTypeFormGroup.get('subType').valueChanges + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(value => { + this.updateView(value); + }), + // startWith(''), + map(value => value ? value : ''), + switchMap(type => this.fetchSubTypes(type)) + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + if (this.broadcastSubscription) { + this.broadcastSubscription.unsubscribe(); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.subTypeFormGroup.disable({emitEvent: false}); + } else { + this.subTypeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + this.modelValue = value; + this.subTypeFormGroup.get('subType').patchValue(value, {emitEvent: false}); + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.subTypeFormGroup.get('subType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displaySubTypeFn(subType?: string): string | undefined { + return subType ? subType : undefined; + } + + fetchSubTypes(searchText?: string, strictMatch: boolean = false): Observable> { + this.searchText = searchText; + return this.getSubTypes().pipe( + map(subTypes => subTypes.filter(subType => { + if (strictMatch) { + return searchText ? subType === searchText : false; + } else { + return searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true; + } + })) + ); + } + + getSubTypes(): Observable> { + if (!this.subTypes) { + let subTypesObservable: Observable>; + switch (this.entityType) { + case EntityType.ASSET: + subTypesObservable = this.assetService.getAssetTypes({ignoreLoading: true}); + break; + case EntityType.DEVICE: + subTypesObservable = this.deviceService.getDeviceTypes({ignoreLoading: true}); + break; + case EntityType.EDGE: + subTypesObservable = this.edgeService.getEdgeTypes({ignoreLoading: true}); + break; + case EntityType.ENTITY_VIEW: + subTypesObservable = this.entityViewService.getEntityViewTypes({ignoreLoading: true}); + break; + } + if (subTypesObservable) { + this.subTypes = subTypesObservable.pipe( + catchError(() => of([] as Array)), + map(subTypes => subTypes.map(subType => subType.type)), + publishReplay(1), + refCount() + ); + } else { + return throwError(null); + } + } + return this.subTypes; + } + + clear() { + this.subTypeFormGroup.get('subType').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.subTypeInput.nativeElement.blur(); + this.subTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html new file mode 100644 index 0000000..5435ba2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html @@ -0,0 +1,57 @@ + + + + + {{entitySubtype}} + close + + + + + + + + + + {{ translate.get(noSubtypesMathingText, {entitySubtype: searchText}) | async }} + + + + + {{ subtypeListEmptyText | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss new file mode 100644 index 0000000..82a32d7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-form-field { + .mat-form-field-infix { + border-top: none; + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts new file mode 100644 index 0000000..6a0eb9e --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -0,0 +1,309 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable, Subscription, throwError } from 'rxjs'; +import { map, mergeMap, publishReplay, refCount, share } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { EntitySubtype, EntityType } from '@shared/models/entity-type.models'; +import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent, MatChipList } from '@angular/material/chips'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { AssetService } from '@core/http/asset.service'; +import { DeviceService } from '@core/http/device.service'; +import { EdgeService } from '@core/http/edge.service'; +import { EntityViewService } from '@core/http/entity-view.service'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; + +@Component({ + selector: 'tb-entity-subtype-list', + templateUrl: './entity-subtype-list.component.html', + styleUrls: ['./entity-subtype-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeListComponent), + multi: true + } + ] +}) +export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + entitySubtypeListFormGroup: FormGroup; + + modelValue: Array | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + @Input() + entityType: EntityType; + + @ViewChild('entitySubtypeInput') entitySubtypeInput: ElementRef; + @ViewChild('entitySubtypeAutocomplete') entitySubtypeAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: true}) chipList: MatChipList; + + entitySubtypeList: Array = []; + filteredEntitySubtypeList: Observable>; + entitySubtypes: Observable>; + + private broadcastSubscription: Subscription; + + placeholder: string; + secondaryPlaceholder: string; + noSubtypesMathingText: string; + subtypeListEmptyText: string; + + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private assetService: AssetService, + private deviceService: DeviceService, + private edgeService: EdgeService, + private entityViewService: EntityViewService, + private fb: FormBuilder) { + this.entitySubtypeListFormGroup = this.fb.group({ + entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []], + entitySubtype: [null] + }); + } + + updateValidators() { + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValidators(this.required ? [Validators.required] : []); + this.entitySubtypeListFormGroup.get('entitySubtypeList').updateValueAndValidity(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + switch (this.entityType) { + case EntityType.ASSET: + this.placeholder = this.required ? this.translate.instant('asset.enter-asset-type') + : this.translate.instant('asset.any-asset'); + this.secondaryPlaceholder = '+' + this.translate.instant('asset.asset-type'); + this.noSubtypesMathingText = 'asset.no-asset-types-matching'; + this.subtypeListEmptyText = 'asset.asset-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('assetSaved', () => { + this.entitySubtypes = null; + }); + break; + case EntityType.DEVICE: + this.placeholder = this.required ? this.translate.instant('device.enter-device-type') + : this.translate.instant('device.any-device'); + this.secondaryPlaceholder = '+' + this.translate.instant('device.device-type'); + this.noSubtypesMathingText = 'device.no-device-types-matching'; + this.subtypeListEmptyText = 'device.device-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('deviceSaved', () => { + this.entitySubtypes = null; + }); + break; + case EntityType.EDGE: + this.placeholder = this.required ? this.translate.instant('edge.enter-edge-type') + : this.translate.instant('edge.any-edge'); + this.secondaryPlaceholder = '+' + this.translate.instant('edge.edge-type'); + this.noSubtypesMathingText = 'edge.no-edge-types-matching'; + this.subtypeListEmptyText = 'edge.edge-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('edgeSaved', () => { + this.entitySubtypes = null; + }); + break; + case EntityType.ENTITY_VIEW: + this.placeholder = this.required ? this.translate.instant('entity-view.enter-entity-view-type') + : this.translate.instant('entity-view.any-entity-view'); + this.secondaryPlaceholder = '+' + this.translate.instant('entity-view.entity-view-type'); + this.noSubtypesMathingText = 'entity-view.no-entity-view-types-matching'; + this.subtypeListEmptyText = 'entity-view.entity-view-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('entityViewSaved', () => { + this.entitySubtypes = null; + }); + break; + } + + this.filteredEntitySubtypeList = this.entitySubtypeListFormGroup.get('entitySubtype').valueChanges + .pipe( + map(value => value ? value : ''), + mergeMap(name => this.fetchEntitySubtypes(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + if (this.broadcastSubscription) { + this.broadcastSubscription.unsubscribe(); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entitySubtypeListFormGroup.disable({emitEvent: false}); + } else { + this.entitySubtypeListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null && value.length > 0) { + this.modelValue = [...value]; + this.entitySubtypeList = [...value]; + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + } else { + this.entitySubtypeList = []; + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + this.modelValue = null; + } + this.dirty = true; + } + + private add(entitySubtype: string): void { + if (!this.modelValue || this.modelValue.indexOf(entitySubtype) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entitySubtype); + this.entitySubtypeList.push(entitySubtype); + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + } + this.propagateChange(this.modelValue); + } + + chipAdd(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + if (value) { + this.add(value); + } + this.clear(''); + } + + remove(entitySubtype: string) { + const index = this.entitySubtypeList.indexOf(entitySubtype); + if (index >= 0) { + this.entitySubtypeList.splice(index, 1); + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + this.modelValue = null; + } + this.propagateChange(this.modelValue); + } + } + + selected(event: MatAutocompleteSelectedEvent): void { + this.add(event.option.viewValue); + this.clear(''); + } + + displayEntitySubtypeFn(entitySubtype?: string): string | undefined { + return entitySubtype ? entitySubtype : undefined; + } + + fetchEntitySubtypes(searchText?: string): Observable> { + this.searchText = searchText; + return this.getEntitySubtypes().pipe( + map(subTypes => { + let result = subTypes.filter( subType => { + return searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true; + }); + if (!result.length) { + result = [searchText]; + } + return result; + }) + ); + } + + getEntitySubtypes(): Observable> { + if (!this.entitySubtypes) { + let subTypesObservable: Observable>; + switch (this.entityType) { + case EntityType.ASSET: + subTypesObservable = this.assetService.getAssetTypes({ignoreLoading: true}); + break; + case EntityType.DEVICE: + subTypesObservable = this.deviceService.getDeviceTypes({ignoreLoading: true}); + break; + case EntityType.EDGE: + subTypesObservable = this.edgeService.getEdgeTypes({ignoreLoading: true}); + break; + case EntityType.ENTITY_VIEW: + subTypesObservable = this.entityViewService.getEntityViewTypes({ignoreLoading: true}); + break; + } + if (subTypesObservable) { + this.entitySubtypes = subTypesObservable.pipe( + map(subTypes => subTypes.map(subType => subType.type)), + publishReplay(1), + refCount() + ); + } else { + return throwError(null); + } + } + return this.entitySubtypes; + } + + onFocus() { + if (this.dirty) { + this.entitySubtypeListFormGroup.get('entitySubtype').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + clear(value: string = '') { + this.entitySubtypeInput.nativeElement.value = value; + this.entitySubtypeListFormGroup.get('entitySubtype').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entitySubtypeInput.nativeElement.blur(); + this.entitySubtypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html new file mode 100644 index 0000000..3fa289b --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html @@ -0,0 +1,28 @@ + + + {{ entitySubtypeTitle | translate }} + + + {{ displaySubTypeFn(subType) }} + + + + {{ entitySubtypeRequiredText | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.scss new file mode 100644 index 0000000..66df772 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts new file mode 100644 index 0000000..d5b849e --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts @@ -0,0 +1,258 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, Subject, Subscription, throwError } from 'rxjs'; +import { map, mergeMap, publishReplay, refCount, startWith, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { DeviceService } from '@core/http/device.service'; +import { EntitySubtype, EntityType } from '@app/shared/models/entity-type.models'; +import { BroadcastService } from '@app/core/services/broadcast.service'; +import { AssetService } from '@core/http/asset.service'; +import { EdgeService } from '@core/http/edge.service'; +import { EntityViewService } from '@core/http/entity-view.service'; + +@Component({ + selector: 'tb-entity-subtype-select', + templateUrl: './entity-subtype-select.component.html', + styleUrls: ['./entity-subtype-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeSelectComponent), + multi: true + }] +}) +export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + subTypeFormGroup: FormGroup; + + modelValue: string | null = ''; + + @Input() + entityType: EntityType; + + @Input() + showLabel: boolean; + + @Input() + required: boolean; + + @Input() + disabled: boolean; + + @Input() + typeTranslatePrefix: string; + + entitySubtypeTitle: string; + entitySubtypeRequiredText: string; + + subTypesOptions: Observable>; + + private subTypesOptionsSubject: Subject = new Subject(); + + subTypes: Observable>; + + subTypesLoaded = false; + + private broadcastSubscription: Subscription; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private deviceService: DeviceService, + private assetService: AssetService, + private edgeService: EdgeService, + private entityViewService: EntityViewService, + private fb: FormBuilder) { + this.subTypeFormGroup = this.fb.group({ + subType: [''] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + switch (this.entityType) { + case EntityType.ASSET: + this.entitySubtypeTitle = 'asset.asset-type'; + this.entitySubtypeRequiredText = 'asset.asset-type-required'; + this.broadcastSubscription = this.broadcast.on('assetSaved', () => { + this.subTypes = null; + this.subTypesOptionsSubject.next(''); + }); + break; + case EntityType.DEVICE: + this.entitySubtypeTitle = 'device.device-type'; + this.entitySubtypeRequiredText = 'device.device-type-required'; + this.broadcastSubscription = this.broadcast.on('deviceSaved', () => { + this.subTypes = null; + this.subTypesOptionsSubject.next(''); + }); + break; + case EntityType.EDGE: + this.entitySubtypeTitle = 'edge.edge-type'; + this.entitySubtypeRequiredText = 'edge.edge-type-required'; + this.broadcastSubscription = this.broadcast.on('edgeSaved',() => { + this.subTypes = null; + this.subTypesOptionsSubject.next(''); + }); + break; + case EntityType.ENTITY_VIEW: + this.entitySubtypeTitle = 'entity-view.entity-view-type'; + this.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; + this.broadcastSubscription = this.broadcast.on('entityViewSaved', () => { + this.subTypes = null; + this.subTypesOptionsSubject.next(''); + }); + break; + } + + this.subTypesOptions = this.subTypesOptionsSubject.asObservable().pipe( + startWith(''), + mergeMap(() => this.getSubTypes()) + ); + + this.subTypeFormGroup.get('subType').valueChanges.subscribe( + (value) => { + let modelValue; + if (!value || value === '') { + modelValue = ''; + } else { + modelValue = value.type; + } + this.updateView(modelValue); + } + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + if (this.broadcastSubscription) { + this.broadcastSubscription.unsubscribe(); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.subTypeFormGroup.disable(); + } else { + this.subTypeFormGroup.enable(); + } + } + + writeValue(value: string | null): void { + if (value != null && value !== '') { + this.modelValue = value; + this.findSubTypes(value).subscribe( + (subTypes) => { + const subType = subTypes && subTypes.length === 1 ? subTypes[0] : ''; + this.subTypeFormGroup.get('subType').patchValue(subType, {emitEvent: true}); + } + ); + } else { + this.modelValue = ''; + this.subTypeFormGroup.get('subType').patchValue('', {emitEvent: true}); + } + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displaySubTypeFn(subType?: EntitySubtype | string): string | undefined { + if (subType && typeof subType !== 'string') { + if (this.typeTranslatePrefix) { + return this.translate.instant(this.typeTranslatePrefix + '.' + subType.type); + } else { + return subType.type; + } + } else { + return this.translate.instant('entity.all-subtypes'); + } + } + + findSubTypes(searchText?: string): Observable> { + return this.getSubTypes().pipe( + map(subTypes => subTypes.filter( subType => { + return searchText ? (typeof subType === 'string' ? false : subType.type === searchText) : false; + })) + ); + } + + getSubTypes(): Observable> { + if (!this.subTypes) { + switch (this.entityType) { + case EntityType.ASSET: + this.subTypes = this.assetService.getAssetTypes({ignoreLoading: true}); + break; + case EntityType.DEVICE: + this.subTypes = this.deviceService.getDeviceTypes({ignoreLoading: true}); + break; + case EntityType.EDGE: + this.subTypes = this.edgeService.getEdgeTypes({ignoreLoading: true}); + break; + case EntityType.ENTITY_VIEW: + this.subTypes = this.entityViewService.getEntityViewTypes({ignoreLoading: true}); + break; + } + if (this.subTypes) { + this.subTypes = this.subTypes.pipe( + map((allSubtypes) => { + allSubtypes.unshift(''); + this.subTypesLoaded = true; + return allSubtypes; + }), + tap((subTypes) => { + const type: EntitySubtype | string = this.subTypeFormGroup.get('subType').value; + const strType = typeof type === 'string' ? type : type.type; + const found = subTypes.find((subType) => { + if (typeof subType === 'string') { + return subType === type; + } else { + return subType.type === strType; + } + }); + if (found) { + this.subTypeFormGroup.get('subType').patchValue(found); + } + }), + publishReplay(1), + refCount() + ); + } else { + return throwError(null); + } + } + return this.subTypes; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html new file mode 100644 index 0000000..01f352c --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html @@ -0,0 +1,54 @@ + + + + + {{entityType.name}} + close + + + + + + + + + + {{ translate.get('entity.no-entity-types-matching', {entityType: searchText}) | async }} + + + + + {{ 'entity.entity-type-list-empty' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts new file mode 100644 index 0000000..fc31181 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts @@ -0,0 +1,243 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityService } from '@core/http/entity.service'; +import { MatAutocomplete } from '@angular/material/autocomplete'; +import { MatChipList } from '@angular/material/chips'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +interface EntityTypeInfo { + name: string; + value: EntityType; +} + +@Component({ + selector: 'tb-entity-type-list', + templateUrl: './entity-type-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityTypeListComponent), + multi: true + } + ] +}) +export class EntityTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entityTypeListFormGroup: FormGroup; + + modelValue: Array | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + @Input() + allowedEntityTypes: Array; + + @Input() + ignoreAuthorityFilter: boolean; + + @ViewChild('entityTypeInput') entityTypeInput: ElementRef; + @ViewChild('entityTypeAutocomplete') entityTypeAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: true}) chipList: MatChipList; + + allEntityTypeList: Array = []; + entityTypeList: Array = []; + filteredEntityTypeList: Observable>; + + placeholder: string; + secondaryPlaceholder: string; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.entityTypeListFormGroup = this.fb.group({ + entityTypeList: [this.entityTypeList, this.required ? [Validators.required] : []], + entityType: [null] + }); + } + + updateValidators() { + this.entityTypeListFormGroup.get('entityTypeList').setValidators(this.required ? [Validators.required] : []); + this.entityTypeListFormGroup.get('entityTypeList').updateValueAndValidity(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + this.placeholder = this.required ? this.translate.instant('entity.enter-entity-type') + : this.translate.instant('entity.any-entity'); + this.secondaryPlaceholder = '+' + this.translate.instant('entity.entity-type'); + + let entityTypes: Array; + if (this.ignoreAuthorityFilter && this.allowedEntityTypes + && this.allowedEntityTypes.length) { + entityTypes = []; + this.allowedEntityTypes.forEach((entityTypeValue) => { + entityTypes.push(entityTypeValue); + }); + } else { + entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes) as Array; + } + + entityTypes.forEach((entityType) => { + this.allEntityTypeList.push({ + name: this.translate.instant(entityTypeTranslations.get(entityType).type), + value: entityType as EntityType + }); + }); + + this.filteredEntityTypeList = this.entityTypeListFormGroup.get('entityType').valueChanges + .pipe( + // startWith>(''), + tap((value) => { + if (value && typeof value !== 'string') { + this.add(value); + } else if (value === null) { + this.clear(this.entityTypeInput.nativeElement.value); + } + }), + filter((value) => typeof value === 'string'), + map((value) => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchEntityTypes(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entityTypeListFormGroup.disable({emitEvent: false}); + } else { + this.entityTypeListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null && value.length > 0) { + this.modelValue = [...value]; + this.entityTypeList = []; + value.forEach((entityType) => { + this.entityTypeList.push({ + name: entityTypeTranslations.has(entityType) ? this.translate.instant(entityTypeTranslations.get(entityType).type) : 'Unknown', + value: entityType + }); + }); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + } else { + this.entityTypeList = []; + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + this.modelValue = null; + } + this.dirty = true; + } + + add(entityType: EntityTypeInfo): void { + if (!this.modelValue || this.modelValue.indexOf(entityType.value) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entityType.value); + this.entityTypeList.push(entityType); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + } + this.propagateChange(this.modelValue); + this.clear(); + } + + remove(entityType: EntityTypeInfo) { + const index = this.entityTypeList.indexOf(entityType); + if (index >= 0) { + this.entityTypeList.splice(index, 1); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + this.modelValue = null; + } + this.propagateChange(this.modelValue); + this.clear(); + } + } + + displayEntityTypeFn(entityType?: EntityTypeInfo): string | undefined { + return entityType ? entityType.name : undefined; + } + + fetchEntityTypes(searchText?: string): Observable> { + this.searchText = searchText; + let result = this.allEntityTypeList; + if (searchText && searchText.length) { + result = this.allEntityTypeList.filter((entityTypeInfo) => entityTypeInfo.name.toLowerCase().includes(searchText.toLowerCase())); + } + return of(result); + } + + onFocus() { + if (this.dirty) { + this.entityTypeListFormGroup.get('entityType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + clear(value: string = '') { + this.entityTypeInput.nativeElement.value = value; + this.entityTypeListFormGroup.get('entityType').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entityTypeInput.nativeElement.blur(); + this.entityTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html new file mode 100644 index 0000000..f73a756 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -0,0 +1,29 @@ + + + {{ 'entity.type' | translate }} + + + {{ displayEntityTypeFn(type) }} + + + + {{ 'entity.type-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss new file mode 100644 index 0000000..7f475b0 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-form-field { + width: 100%; + } +} + +:host ::ng-deep { + .mat-form-field-infix { + width: 100%; + } + .mat-select-value { + max-width: 200px; + min-width: 100px; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts new file mode 100644 index 0000000..a4127a7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -0,0 +1,160 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AliasEntityType, EntityType, entityTypeTranslations } from '@app/shared/models/entity-type.models'; +import { EntityService } from '@core/http/entity.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'tb-entity-type-select', + templateUrl: './entity-type-select.component.html', + styleUrls: ['./entity-type-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityTypeSelectComponent), + multi: true + }] +}) +export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { + + entityTypeFormGroup: FormGroup; + + modelValue: EntityType | AliasEntityType | null; + + @Input() + allowedEntityTypes: Array; + + @Input() + useAliasEntityTypes: boolean; + + @Input() + filterAllowedEntityTypes = true; + + private showLabelValue: boolean; + get showLabel(): boolean { + return this.showLabelValue; + } + @Input() + set showLabel(value: boolean) { + this.showLabelValue = coerceBooleanProperty(value); + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + entityTypes: Array; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private entityService: EntityService, + public translate: TranslateService, + private fb: FormBuilder) { + this.entityTypeFormGroup = this.fb.group({ + entityType: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.entityTypes = this.filterAllowedEntityTypes ? + this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, this.useAliasEntityTypes) : this.allowedEntityTypes; + this.entityTypeFormGroup.get('entityType').valueChanges.subscribe( + (value) => { + let modelValue; + if (!value || value === '') { + modelValue = null; + } else { + modelValue = value; + } + this.updateView(modelValue); + } + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'allowedEntityTypes') { + this.entityTypes = this.filterAllowedEntityTypes ? + this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, this.useAliasEntityTypes) : this.allowedEntityTypes; + const currentEntityType: EntityType | AliasEntityType = this.entityTypeFormGroup.get('entityType').value; + if (currentEntityType && !this.entityTypes.includes(currentEntityType)) { + this.entityTypeFormGroup.get('entityType').patchValue(null, {emitEvent: true}); + } + } + } + } + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.entityTypeFormGroup.disable(); + } else { + this.entityTypeFormGroup.enable(); + } + } + + writeValue(value: EntityType | AliasEntityType | null): void { + if (value != null) { + this.modelValue = value; + this.entityTypeFormGroup.get('entityType').patchValue(value, {emitEvent: true}); + } else { + this.modelValue = null; + this.entityTypeFormGroup.get('entityType').patchValue(null, {emitEvent: true}); + } + } + + updateView(value: EntityType | AliasEntityType | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayEntityTypeFn(entityType?: EntityType | AliasEntityType | null): string | undefined { + if (entityType) { + return this.translate.instant(entityTypeTranslations.get(entityType as EntityType).type); + } else { + return ''; + } + } +} diff --git a/ui-ngx/src/app/shared/components/fab-toolbar.component.html b/ui-ngx/src/app/shared/components/fab-toolbar.component.html new file mode 100644 index 0000000..cb0888c --- /dev/null +++ b/ui-ngx/src/app/shared/components/fab-toolbar.component.html @@ -0,0 +1,22 @@ + +
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/fab-toolbar.component.scss b/ui-ngx/src/app/shared/components/fab-toolbar.component.scss new file mode 100644 index 0000000..5b364f9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/fab-toolbar.component.scss @@ -0,0 +1,189 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 "sass:math"; + +$font-size: 10px !default; +@function rem($multiplier) { + @return $multiplier * $font-size; +} + +$button-fab-width: rem(5.600) !default; +$button-fab-height: rem(5.600) !default; +$button-fab-padding: rem(1.60) !default; +$icon-button-margin: rem(0.600) !default; +$z-index-fab: 20 !default; + +$swift-ease-in-duration: 0.3s !default; +$swift-ease-in-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2) !default; +$swift-ease-in: all $swift-ease-in-duration $swift-ease-in-timing-function !default; + +@mixin rtl-prop($ltr-prop, $rtl-prop, $value, $reset-value) { + #{$ltr-prop}: $value; + [dir=rtl] & { + #{$ltr-prop}: $reset-value; + #{$rtl-prop}: $value; + } +} + +@mixin fab-position($spot, $top: auto, $right: auto, $bottom: auto, $left: auto) { + &.mat-fab-#{$spot} { + top: $top; + right: $right; + bottom: $bottom; + left: $left; + position: absolute; + } +} + +@mixin fab-all-positions() { + @include fab-position(bottom-right, auto, math.div(($button-fab-width - $button-fab-padding), 2), math.div(($button-fab-height - $button-fab-padding), 2), auto); + @include fab-position(bottom-left, auto, auto, math.div(($button-fab-height - $button-fab-padding), 2), math.div(($button-fab-width - $button-fab-padding), 2)); + @include fab-position(top-right, math.div(($button-fab-height - $button-fab-padding), 2), math.div(($button-fab-width - $button-fab-padding), 2), auto, auto); + @include fab-position(top-left, math.div(($button-fab-height - $button-fab-padding), 2), auto, auto, math.div(($button-fab-width - $button-fab-padding), 2)); +} + +mat-fab-toolbar { + $icon-delay: 200ms; + @include fab-all-positions(); + display: block; + + .mat-fab-toolbar-wrapper { + display: block; + position: relative; + overflow: hidden; + + height: $button-fab-width + ($icon-button-margin * 2); + } + + mat-fab-trigger { + position: absolute; + z-index: $z-index-fab; + + button { + overflow: visible !important; + opacity: .5; + } + + .mat-fab-toolbar-background { + display: block; + position: absolute; + z-index: $z-index-fab + 1; + opacity: 1; + } + + mat-icon { + position: relative; + z-index: $z-index-fab + 2; + + opacity: 1; + + } + + } + + &.mat-left { + mat-fab-trigger { + @include rtl-prop(right, left, 0, auto); + } + + .mat-toolbar-tools { + flex-direction: row-reverse; + + > .mat-button:first-child { + @include rtl-prop(margin-right, margin-left, 0.6rem, auto) + } + + > .mat-button:first-child { + @include rtl-prop(margin-left, margin-right, -0.8rem, auto); + } + + + > .mat-button:last-child { + @include rtl-prop(margin-right, margin-left, 8px, auto); + } + + } + } + + &.mat-right { + mat-fab-trigger { + @include rtl-prop(left, right, 0, auto); + } + + .mat-toolbar-tools { + flex-direction: row; + } + } + + mat-toolbar { + padding: 0 !important; + background-color: transparent !important; + pointer-events: none; + position: relative; + z-index: $z-index-fab + 3; + + .mat-toolbar-tools { + padding: 0 20px !important; + margin-top: 3px; + } + + .mat-fab-action-item { + opacity: 0; + transform: scale(0); + } + } + + &.mat-is-open { + mat-fab-trigger > button { + box-shadow: none; + opacity: 1; + + mat-icon { + opacity: 0; + } + } + + .mat-fab-action-item { + opacity: 1; + transform: scale(1); + } + } + + &.mat-animation { + mat-fab-trigger { + button { + transition: opacity .3s cubic-bezier(.55, 0, .55, .2) .2s; + } + .mat-fab-toolbar-background { + transition: $swift-ease-in; + } + mat-icon { + transition: all $icon-delay ease-in; + } + } + mat-toolbar { + .mat-fab-action-item { + transition: $swift-ease-in; + transition-duration: math.div($swift-ease-in-duration, 2); + } + } + &.mat-is-open { + mat-fab-trigger > button { + transition: opacity .3s cubic-bezier(.55, 0, .55, .2); + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/fab-toolbar.component.ts b/ui-ngx/src/app/shared/components/fab-toolbar.component.ts new file mode 100644 index 0000000..2a56d0e --- /dev/null +++ b/ui-ngx/src/app/shared/components/fab-toolbar.component.ts @@ -0,0 +1,194 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + Directive, + ElementRef, + Inject, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; +import { WINDOW } from '@core/services/window.service'; +import { CanColorCtor, mixinColor } from '@angular/material/core'; +import { ResizeObserver } from '@juggle/resize-observer'; + +export declare type FabToolbarDirection = 'left' | 'right'; + +class MatFabToolbarBase { + // tslint:disable-next-line:variable-name + constructor(public _elementRef: ElementRef) {} +} +const MatFabToolbarMixinBase: CanColorCtor & typeof MatFabToolbarBase = mixinColor(MatFabToolbarBase); + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: 'mat-fab-trigger' +}) +export class FabTriggerDirective { + + constructor(private el: ElementRef) { + } + +} + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: 'mat-fab-actions' +}) +export class FabActionsDirective implements OnInit { + + constructor(private el: ElementRef) { + } + + ngOnInit(): void { + const element = $(this.el.nativeElement); + const children = element.children(); + children.wrap('
'); + } + +} + +// @dynamic +@Component({ + // tslint:disable-next-line:component-selector + selector: 'mat-fab-toolbar', + templateUrl: './fab-toolbar.component.html', + styleUrls: ['./fab-toolbar.component.scss'], + inputs: ['color'], + encapsulation: ViewEncapsulation.None +}) +export class FabToolbarComponent extends MatFabToolbarMixinBase implements OnInit, OnDestroy, AfterViewInit, OnChanges { + + private fabToolbarResize$: ResizeObserver; + + @Input() + isOpen: boolean; + + @Input() + direction: FabToolbarDirection; + + constructor(private el: ElementRef, + @Inject(WINDOW) private window: Window) { + super(el); + } + + ngOnInit(): void { + const element = $(this.el.nativeElement); + element.addClass('mat-fab-toolbar'); + element.find('mat-fab-trigger').find('button') + .prepend('
'); + element.addClass(`mat-${this.direction}`); + this.fabToolbarResize$ = new ResizeObserver(() => { + this.onFabToolbarResize(); + }); + this.fabToolbarResize$.observe(this.el.nativeElement); + } + + ngOnDestroy(): void { + this.fabToolbarResize$.disconnect(); + } + + ngAfterViewInit(): void { + this.triggerOpenClose(true); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'isOpen') { + this.triggerOpenClose(); + } + } + } + } + + private onFabToolbarResize() { + if (this.isOpen) { + this.triggerOpenClose(true); + } + } + + private triggerOpenClose(disableAnimation?: boolean): void { + const el = this.el.nativeElement; + const element = $(this.el.nativeElement); + if (disableAnimation) { + element.removeClass('mat-animation'); + } else { + element.addClass('mat-animation'); + } + const backgroundElement: HTMLElement = el.querySelector('.mat-fab-toolbar-background'); + const triggerElement: HTMLElement = el.querySelector('mat-fab-trigger button'); + const toolbarElement: HTMLElement = el.querySelector('mat-toolbar'); + const iconElement: HTMLElement = el.querySelector('mat-fab-trigger button mat-icon'); + const actions = element.find('mat-fab-actions').children(); + if (triggerElement && backgroundElement) { + const width = el.offsetWidth; + const scale = 2 * (width / triggerElement.offsetWidth); + + backgroundElement.style.borderRadius = width + 'px'; + + if (this.isOpen) { + element.addClass('mat-is-open'); + toolbarElement.style.pointerEvents = 'inherit'; + + backgroundElement.style.width = triggerElement.offsetWidth + 'px'; + backgroundElement.style.height = triggerElement.offsetHeight + 'px'; + backgroundElement.style.transform = 'scale(' + scale + ')'; + + backgroundElement.style.transitionDelay = '0ms'; + if (iconElement) { + iconElement.style.transitionDelay = disableAnimation ? '0ms' : '.3s'; + } + + actions.each((index, action) => { + action.style.transitionDelay = disableAnimation ? '0ms' : ((actions.length - index) * 25 + 'ms'); + }); + + } else { + element.removeClass('mat-is-open'); + toolbarElement.style.pointerEvents = 'none'; + + backgroundElement.style.transform = 'scale(1)'; + + backgroundElement.style.top = '0'; + + if (element.hasClass('mat-right')) { + backgroundElement.style.left = '0'; + backgroundElement.style.right = null; + } + + if (element.hasClass('mat-left')) { + backgroundElement.style.right = '0'; + backgroundElement.style.left = null; + } + + backgroundElement.style.transitionDelay = disableAnimation ? '0ms' : '200ms'; + + actions.each((index, action) => { + action.style.transitionDelay = (disableAnimation ? 0 : 200) + (index * 25) + 'ms'; + }); + } + } + } + +} diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html new file mode 100644 index 0000000..ea68448 --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -0,0 +1,52 @@ + +
+ + +
+
+ +
+
+
+ cloud_upload + {{ dropLabel }} + + +
+
+
+
+
+
+ +
{{ noFileText }}
+
{{ fileName }}
+
diff --git a/ui-ngx/src/app/shared/components/file-input.component.scss b/ui-ngx/src/app/shared/components/file-input.component.scss new file mode 100644 index 0000000..faee93f --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.scss @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../scss/constants"; + +$previewSize: 100px !default; + +:host { + + .tb-container { + margin-top: 0; + label.tb-title { + display: block; + padding-bottom: 8px; + } + } + + .tb-file-select-container { + position: relative; + width: 100%; + height: $previewSize; + } + + .tb-file-preview { + width: auto; + max-width: $previewSize; + height: auto; + max-height: $previewSize; + } + + .tb-file-clear-container { + position: relative; + float: right; + width: 48px; + height: $previewSize; + } + + .tb-file-clear-btn { + position: absolute !important; + top: 50%; + transform: translate(0%, -50%) !important; + } + + .file-input { + display: none; + } + + .tb-flow-drop { + position: relative; + height: $previewSize; + overflow: hidden; + border: 2px dashed rgba(0, 0, 0, 0.2); + border-radius: 4px; + box-sizing: border-box; + + .upload-label { + width: 100%; + height: 100%; + padding: 0 16px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: 16px; + color: rgba(0, 0, 0, 0.54); + text-align: center; + .mat-icon { + margin-right: 17px; + } + } + } +} + +:host ::ng-deep { + button.browse-file { + padding: 0; + font-size: 16px; + span.mat-button-wrapper { + display: block; + label { + display: block; + cursor: pointer; + padding: 0 16px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts new file mode 100644 index 0000000..51933d7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -0,0 +1,284 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnChanges, + OnDestroy, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { FlowDirective } from '@flowjs/ngx-flow'; +import { TranslateService } from '@ngx-translate/core'; +import { UtilsService } from '@core/services/utils.service'; + +@Component({ + selector: 'tb-file-input', + templateUrl: './file-input.component.html', + styleUrls: ['./file-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FileInputComponent), + multi: true + } + ] +}) +export class FileInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor, OnChanges { + + @Input() + label: string; + + @Input() + accept = '*/*'; + + @Input() + noFileText = 'import.no-file'; + + @Input() + inputId = this.utils.guid(); + + @Input() + allowedExtensions: string; + + @Input() + dropLabel: string; + + @Input() + contentConvertFunction: (content: string) => any; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + } + } + + private requiredAsErrorValue: boolean; + + get requiredAsError(): boolean { + return this.requiredAsErrorValue; + } + + @Input() + set requiredAsError(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredAsErrorValue !== newVal) { + this.requiredAsErrorValue = newVal; + } + } + + @Input() + disabled: boolean; + + @Input() + existingFileName: string; + + @Input() + readAsBinary = false; + + @Input() + workFromFileObj = false; + + private multipleFileValue = false; + + @Input() + set multipleFile(value: boolean) { + this.multipleFileValue = value; + if (this.flow?.flowJs) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + get multipleFile(): boolean { + return this.multipleFileValue; + } + + @Output() + fileNameChanged = new EventEmitter(); + + fileName: string | string[]; + fileContent: any; + files: File[]; + + @ViewChild('flow', {static: true}) + flow: FlowDirective; + + @ViewChild('flowInput', {static: true}) + flowInput: ElementRef; + + autoUploadSubscription: Subscription; + + private propagateChange = null; + + constructor(protected store: Store, + private utils: UtilsService, + public translate: TranslateService) { + super(store); + } + + ngAfterViewInit() { + this.autoUploadSubscription = this.flow.events$.subscribe(event => { + if (event.type === 'filesAdded') { + const readers = []; + (event.event[0] as flowjs.FlowFile[]).forEach(file => { + if (this.filterFile(file)) { + readers.push(this.readerAsFile(file)); + } + }); + if (readers.length) { + Promise.all(readers).then((files) => { + files = files.filter(file => file.fileContent != null || file.files != null); + if (files.length === 1) { + this.fileContent = files[0].fileContent; + this.fileName = files[0].fileName; + this.files = files[0].files; + this.updateModel(); + } else if (files.length > 1) { + this.fileContent = files.map(content => content.fileContent); + this.fileName = files.map(content => content.fileName); + this.files = files.map(content => content.files); + this.updateModel(); + } + }); + } + } + }); + if (!this.multipleFile) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + private readerAsFile(file: flowjs.FlowFile): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + let fileName = null; + let fileContent = null; + let files = null; + if (reader.readyState === reader.DONE) { + if (!this.workFromFileObj) { + fileContent = reader.result; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + fileContent = this.contentConvertFunction(fileContent); + } + fileName = fileContent ? file.name : null; + } + } else if (file.name || file.file){ + files = file.file; + fileName = file.name; + } + } + resolve({fileContent, fileName, files}); + }; + reader.onerror = () => { + resolve({fileContent: null, fileName: null, files: null}); + }; + if (this.readAsBinary) { + reader.readAsBinaryString(file.file); + } else { + reader.readAsText(file.file); + } + }); + } + + private filterFile(file: flowjs.FlowFile): boolean { + if (this.allowedExtensions) { + return this.allowedExtensions.split(',').indexOf(file.getExtension()) > -1; + } else { + return true; + } + } + + ngOnDestroy() { + if (this.autoUploadSubscription) { + this.autoUploadSubscription.unsubscribe(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + let fileName = null; + if (this.workFromFileObj && value instanceof File) { + fileName = Array.isArray(value) ? value.map(file => file.name) : value.name; + } + this.fileName = this.existingFileName || fileName; + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'existingFileName') { + this.fileName = this.existingFileName || null; + } + } + } + } + + private updateModel() { + if (this.workFromFileObj) { + this.propagateChange(this.files); + } else { + this.propagateChange(this.fileContent); + this.fileNameChanged.emit(this.fileName); + } + } + + clearFile() { + this.fileName = null; + this.fileContent = null; + this.files = null; + this.updateModel(); + } + + private updateMultipleFileMode(multiple: boolean) { + this.flow.flowJs.opts.singleFile = !multiple; + if (!multiple) { + this.flowInput.nativeElement.removeAttribute('multiple'); + } + } +} diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html new file mode 100644 index 0000000..20820ac --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html @@ -0,0 +1,39 @@ + + diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss new file mode 100644 index 0000000..eb923aa --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.scss @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + section.tb-footer-fab-buttons { + &:not(.relative-buttons) { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 30; + pointer-events: none; + } + + .fab-container { + display: flex; + flex-direction: column-reverse; + align-items: center; + > div { + display: flex; + flex-direction: column-reverse; + align-items: center; + + button { + margin-bottom: 17px; + } + } + } + + .tb-btn-footer { + &.fab-toggler { + margin-top: 0; + } + position: relative !important; + display: inline-block !important; + animation: tbMoveFromBottomFade .3s ease both; + + &.tb-hide { + animation: tbMoveToBottomFade .3s ease both; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts new file mode 100644 index 0000000..67912da --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts @@ -0,0 +1,95 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, HostListener, Input } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { speedDialFabAnimations } from '@shared/animations/speed-dial-fab.animations'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +export interface FooterFabButton { + name: string; + icon: string; + onAction: ($event: Event) => void; +} + +export interface FooterFabButtons { + fabTogglerName: string; + fabTogglerIcon: string; + buttons: Array; +} + +@Component({ + selector: 'tb-footer-fab-buttons', + templateUrl: './footer-fab-buttons.component.html', + styleUrls: ['./footer-fab-buttons.component.scss'], + animations: speedDialFabAnimations +}) +export class FooterFabButtonsComponent extends PageComponent { + + @Input() + footerFabButtons: FooterFabButtons; + + private relativeValue: boolean; + get relative(): boolean { + return this.relativeValue; + } + @Input() + set relative(value: boolean) { + this.relativeValue = coerceBooleanProperty(value); + } + + buttons: Array = []; + fabTogglerState = 'inactive'; + + closeTimeout = null; + + @HostListener('focusout', ['$event']) + onFocusOut($event) { + if (!this.closeTimeout) { + this.closeTimeout = setTimeout(() => { + this.hideItems(); + }, 100); + } + } + + @HostListener('focusin', ['$event']) + onFocusIn($event) { + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = null; + } + } + + constructor(protected store: Store) { + super(store); + } + + showItems() { + this.fabTogglerState = 'active'; + this.buttons = this.footerFabButtons.buttons; + } + + hideItems() { + this.fabTogglerState = 'inactive'; + this.buttons = []; + } + + onToggleFab() { + this.buttons.length ? this.hideItems() : this.showItems(); + } +} diff --git a/ui-ngx/src/app/shared/components/footer.component.html b/ui-ngx/src/app/shared/components/footer.component.html new file mode 100644 index 0000000..edd24d9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer.component.html @@ -0,0 +1,20 @@ + + diff --git a/ui-ngx/src/app/shared/components/footer.component.scss b/ui-ngx/src/app/shared/components/footer.component.scss new file mode 100644 index 0000000..b17be13 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer.component.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.footer-text { + position: absolute; + width: 100%; + bottom: 20px; + margin: 0; + left: 0; + line-height: 20px; + text-align: center; + small { + font-size: 14px; + color: #98a6ad; + } +} diff --git a/ui-ngx/src/app/shared/components/footer.component.ts b/ui-ngx/src/app/shared/components/footer.component.ts new file mode 100644 index 0000000..b132ef8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/footer.component.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; + +@Component({ + selector: 'tb-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'] +}) +export class FooterComponent { + + year = new Date().getFullYear(); + +} diff --git a/ui-ngx/src/app/shared/components/fullscreen.directive.ts b/ui-ngx/src/app/shared/components/fullscreen.directive.ts new file mode 100644 index 0000000..b61251c --- /dev/null +++ b/ui-ngx/src/app/shared/components/fullscreen.directive.ts @@ -0,0 +1,160 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + Directive, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + Renderer2, + SecurityContext, + SimpleChanges, + ViewContainerRef +} from '@angular/core'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +@Directive({ + selector: '[tb-fullscreen]' +}) +export class FullscreenDirective implements OnChanges, OnDestroy { + + fullscreenValue = false; + + private overlayRef: OverlayRef; + private parentElement: HTMLElement; + + @Input() + fullscreen: boolean; + + @Input() + fullscreenElement: HTMLElement; + + @Input() + fullscreenBackgroundStyle: {[klass: string]: any}; + + @Input() + fullscreenBackgroundImage: SafeStyle | string; + + @Output() + fullscreenChanged = new EventEmitter(); + + constructor(public elementRef: ElementRef, + private renderer: Renderer2, + private sanitizer: DomSanitizer, + private viewContainerRef: ViewContainerRef, + private overlay: Overlay) { + } + + ngOnChanges(changes: SimpleChanges): void { + let updateFullscreen = false; + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'fullscreen') { + updateFullscreen = true; + } + } + } + if (updateFullscreen) { + if (this.fullscreen) { + this.enterFullscreen(); + } else { + this.exitFullscreen(); + } + } + } + + ngOnDestroy(): void { + if (this.fullscreen) { + this.exitFullscreen(); + } + } + + enterFullscreen() { + const targetElement: HTMLElement = this.fullscreenElement || this.elementRef.nativeElement; + this.parentElement = targetElement.parentElement; + this.parentElement.removeChild(targetElement); + targetElement.classList.add('tb-fullscreen'); + const position = this.overlay.position(); + const config = new OverlayConfig({ + hasBackdrop: false, + panelClass: 'tb-fullscreen-parent' + }); + config.minWidth = '100%'; + config.minHeight = '100%'; + config.positionStrategy = position.global().top('0%').left('0%') + .right('0%').bottom('0%'); + + this.overlayRef = this.overlay.create(config); + this.overlayRef.attach(new EmptyPortal()); + if (this.fullscreenBackgroundStyle) { + for (const key of Object.keys(this.fullscreenBackgroundStyle)) { + this.setStyle(this.overlayRef.overlayElement, key, this.fullscreenBackgroundStyle[key]); + } + } + if (this.fullscreenBackgroundImage) { + this.setStyle(this.overlayRef.overlayElement, 'backgroundImage', this.fullscreenBackgroundImage); + } + this.overlayRef.overlayElement.appendChild( targetElement ); + this.fullscreenChanged.emit(true); + } + + private setStyle(el: any, nameAndUnit: string, value: any): void { + const [name, unit] = nameAndUnit.split('.'); + let renderValue: string|null = + this.sanitizer.sanitize(SecurityContext.STYLE, value as{} | string); + if (renderValue != null) { + renderValue = renderValue.toString(); + } + renderValue = renderValue != null && unit ? `${renderValue}${unit}` : renderValue; + if (renderValue != null) { + this.renderer.setStyle(this.overlayRef.overlayElement, name, renderValue); + } else { + this.renderer.removeStyle(this.overlayRef.overlayElement, name); + } + } + + exitFullscreen() { + const targetElement: HTMLElement = this.fullscreenElement || this.elementRef.nativeElement; + if (this.parentElement) { + this.overlayRef.overlayElement.removeChild( targetElement ); + this.parentElement.appendChild(targetElement); + this.parentElement = null; + } + targetElement.classList.remove('tb-fullscreen'); + if (this.elementRef) { + this.elementRef.nativeElement.classList.remove('tb-fullscreen'); + } + if (this.overlayRef) { + this.overlayRef.dispose(); + } + this.fullscreenChanged.emit(false); + } +} + +class EmptyPortal extends ComponentPortal { + + constructor() { + super(TbAnchorComponent); + } + +} diff --git a/ui-ngx/src/app/shared/components/help-markdown.component.html b/ui-ngx/src/app/shared/components/help-markdown.component.html new file mode 100644 index 0000000..cba58b6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-markdown.component.html @@ -0,0 +1,18 @@ + + diff --git a/ui-ngx/src/app/shared/components/help-markdown.component.scss b/ui-ngx/src/app/shared/components/help-markdown.component.scss new file mode 100644 index 0000000..e5978e0 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-markdown.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .tb-help-markdown { + overflow: auto; + max-width: 80vw; + max-height: 80vh; + margin-top: 30px; + } + .tb-help-markdown.tb-markdown-view { + h1, h2, h3, h4, h5, h6 { + &:first-child { + padding-top: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/help-markdown.component.ts b/ui-ngx/src/app/shared/components/help-markdown.component.ts new file mode 100644 index 0000000..97bd326 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-markdown.component.ts @@ -0,0 +1,106 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + EventEmitter, + Input, OnChanges, + OnDestroy, OnInit, + Output, SimpleChanges +} from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { share } from 'rxjs/operators'; +import { HelpService } from '@core/services/help.service'; + +@Component({ + selector: 'tb-help-markdown', + templateUrl: './help-markdown.component.html', + styleUrls: ['./help-markdown.component.scss'] +}) +export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges { + + @Input() helpId: string; + + @Input() helpContent: string; + + @Input() visible: boolean; + + @Input() style: { [klass: string]: any } = {}; + + @Output() markdownReady = new EventEmitter(); + + markdownText = new BehaviorSubject(null); + + markdownText$ = this.markdownText.pipe( + share() + ); + + private loadHelpPending = false; + + constructor(private help: HelpService) {} + + ngOnInit(): void { + this.loadHelpWhenVisible(); + } + + ngOnDestroy(): void { + this.markdownText.complete(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'visible') { + if (this.loadHelpPending) { + this.loadHelpPending = false; + this.loadHelp(); + } + } + if (propName === 'helpId' || propName === 'helpContent') { + this.markdownText.next(null); + this.loadHelpWhenVisible(); + } + } + } + } + + private loadHelpWhenVisible() { + if (this.visible) { + this.loadHelp(); + } else { + this.loadHelpPending = true; + } + } + + private loadHelp() { + if (this.helpId) { + this.help.getHelpContent(this.helpId).subscribe((content) => { + this.markdownText.next(content); + }); + } else if (this.helpContent) { + this.markdownText.next(this.helpContent); + } + } + + onMarkdownReady() { + this.markdownReady.next(); + } + + markdownClick($event: MouseEvent) { + } + +} diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html new file mode 100644 index 0000000..69f9356 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -0,0 +1,47 @@ + +
+
+ +
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/help-popup.component.scss b/ui-ngx/src/app/shared/components/help-popup.component.scss new file mode 100644 index 0000000..a7985f7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-popup.component.scss @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-help-popup-button-container { + width: initial; + display: inline-block; + vertical-align: middle; +} + +.tb-help-popup-button { + position: relative; + .mat-progress-spinner { + position: absolute; + top: 0; + left: 0; + background: #fff; + border-radius: 50%; + width: 32px !important; + height: 32px !important; + svg { + top: 6px; + left: 6px; + } + } +} + +.tb-help-popup-text-button { + position: relative; + padding: 0 2px 0 8px; + line-height: 28px; + &.mat-stroked-button { + padding: 0 1px 0 7px; + line-height: 26px; + } + .mat-icon { + padding-left: 4px; + } + .mat-progress-spinner { + display: inline-block; + margin-left: 4px; + margin-right: 5px; + } +} diff --git a/ui-ngx/src/app/shared/components/help-popup.component.ts b/ui-ngx/src/app/shared/components/help-popup.component.ts new file mode 100644 index 0000000..09a91bf --- /dev/null +++ b/ui-ngx/src/app/shared/components/help-popup.component.ts @@ -0,0 +1,102 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + ElementRef, + Input, OnChanges, + OnDestroy, + Renderer2, SimpleChanges, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { PopoverPlacement } from '@shared/components/popover.models'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[tb-help-popup], [tb-help-popup-content]', + templateUrl: './help-popup.component.html', + styleUrls: ['./help-popup.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class HelpPopupComponent implements OnChanges, OnDestroy { + + @ViewChild('toggleHelpButton', {read: ElementRef, static: false}) toggleHelpButton: ElementRef; + @ViewChild('toggleHelpTextButton', {read: ElementRef, static: false}) toggleHelpTextButton: ElementRef; + + // tslint:disable-next-line:no-input-rename + @Input('tb-help-popup') helpId: string; + + // tslint:disable-next-line:no-input-rename + @Input('tb-help-popup-content') helpContent: string; + + // tslint:disable-next-line:no-input-rename + @Input('trigger-text') triggerText: string; + + // tslint:disable-next-line:no-input-rename + @Input('trigger-style') triggerStyle: string; + + // tslint:disable-next-line:no-input-rename + @Input('tb-help-popup-placement') helpPopupPlacement: PopoverPlacement; + + // tslint:disable-next-line:no-input-rename + @Input('tb-help-popup-style') helpPopupStyle: { [klass: string]: any } = {}; + + popoverVisible = false; + popoverReady = true; + + triggerSafeHtml: SafeHtml = null; + textMode = false; + + constructor(private viewContainerRef: ViewContainerRef, + private element: ElementRef, + private sanitizer: DomSanitizer, + private renderer: Renderer2, + private popoverService: TbPopoverService) { + } + + ngOnChanges(changes: SimpleChanges): void { + if (isDefinedAndNotNull(this.triggerText)) { + this.triggerSafeHtml = this.sanitizer.bypassSecurityTrustHtml(this.triggerText); + } else { + this.triggerSafeHtml = null; + } + this.textMode = this.triggerSafeHtml != null; + } + + toggleHelp() { + const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement; + this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, + this.helpId, + this.helpContent, + (visible) => { + this.popoverVisible = visible; + }, (ready => { + this.popoverReady = ready; + }), + this.helpPopupPlacement, + {}, + this.helpPopupStyle); + } + + ngOnDestroy(): void { + } + +} diff --git a/ui-ngx/src/app/shared/components/help.component.html b/ui-ngx/src/app/shared/components/help.component.html new file mode 100644 index 0000000..601f558 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help.component.html @@ -0,0 +1,24 @@ + + diff --git a/ui-ngx/src/app/shared/components/help.component.ts b/ui-ngx/src/app/shared/components/help.component.ts new file mode 100644 index 0000000..588e906 --- /dev/null +++ b/ui-ngx/src/app/shared/components/help.component.ts @@ -0,0 +1,41 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input } from '@angular/core'; +import { HelpLinks } from '@shared/models/constants'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[tb-help]', + templateUrl: './help.component.html' +}) +export class HelpComponent { + + // tslint:disable-next-line:no-input-rename + @Input('tb-help') helpLinkId: string; + + gotoHelpPage(): void { + let helpUrl = HelpLinks.linksMap[this.helpLinkId]; + if (!helpUrl && this.helpLinkId && + (this.helpLinkId.startsWith('http://') || this.helpLinkId.startsWith('https://'))) { + helpUrl = this.helpLinkId; + } + if (helpUrl) { + window.open(helpUrl, '_blank'); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/hotkeys.directive.ts b/ui-ngx/src/app/shared/components/hotkeys.directive.ts new file mode 100644 index 0000000..c031b45 --- /dev/null +++ b/ui-ngx/src/app/shared/components/hotkeys.directive.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { Hotkey } from 'angular2-hotkeys'; +import { MousetrapInstance } from 'mousetrap'; +import * as Mousetrap from 'mousetrap'; +import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component'; + +@Directive({ + selector : '[tb-hotkeys]' +}) +export class TbHotkeysDirective implements OnInit, OnDestroy { + @Input() hotkeys: Hotkey[] = []; + @Input() cheatSheet: TbCheatSheetComponent; + + private mousetrap: MousetrapInstance; + private hotkeysList: Hotkey[] = []; + + private preventIn = ['INPUT', 'SELECT', 'TEXTAREA']; + + constructor(private elementRef: ElementRef) { + this.mousetrap = new Mousetrap(this.elementRef.nativeElement); + (this.elementRef.nativeElement as HTMLElement).tabIndex = -1; + (this.elementRef.nativeElement as HTMLElement).style.outline = '0'; + } + + ngOnInit() { + for (const hotkey of this.hotkeys) { + this.hotkeysList.push(hotkey); + this.bindEvent(hotkey); + } + if (this.cheatSheet) { + const hotkeyObj: Hotkey = new Hotkey( + '?', + (event: KeyboardEvent) => { + this.cheatSheet.toggleCheatSheet(); + return false; + }, + [], + 'Show / hide this help menu', + ); + this.hotkeysList.unshift(hotkeyObj); + this.bindEvent(hotkeyObj); + this.cheatSheet.setHotKeys(this.hotkeysList); + } + } + + private bindEvent(hotkey: Hotkey): void { + this.mousetrap.bind((hotkey as Hotkey).combo, (event: KeyboardEvent, combo: string) => { + let shouldExecute = true; + if (event) { + const target: HTMLElement = (event.target || event.srcElement) as HTMLElement; + const nodeName: string = target.nodeName.toUpperCase(); + if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) { + shouldExecute = true; + } else if (this.preventIn.indexOf(nodeName) > -1 && (hotkey as Hotkey). + allowIn.map(allow => allow.toUpperCase()).indexOf(nodeName) === -1) { + shouldExecute = false; + } + } + + if (shouldExecute) { + return (hotkey as Hotkey).callback.apply(this, [event, combo]); + } + }); + } + + ngOnDestroy() { + for (const hotkey of this.hotkeysList) { + this.mousetrap.unbind(hotkey.combo); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/html.component.html b/ui-ngx/src/app/shared/components/html.component.html new file mode 100644 index 0000000..78a4eae --- /dev/null +++ b/ui-ngx/src/app/shared/components/html.component.html @@ -0,0 +1,41 @@ + +
+
+ + + +
+
+ +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/html.component.scss b/ui-ngx/src/app/shared/components/html.component.scss new file mode 100644 index 0000000..e5e9ce8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/html.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-html { + position: relative; + + &.tb-disabled { + color: rgba(0, 0, 0, .38); + } + + &.fill-height { + height: 100%; + } + + .tb-html-content-panel { + height: calc(100% - 40px); + border: 1px solid #c0c0c0; + + #tb-html-input { + width: 100%; + min-width: 200px; + height: 100%; + } + } + + &:not(.tb-fullscreen) { + padding-bottom: 15px; + } + + .tb-html-toolbar { + & > * { + &:not(:last-child) { + margin-right: 4px; + } + } + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + background: rgba(220, 220, 220, .35); + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + font-size: .8rem; + line-height: 15px; + &:not(.tb-help-popup-button) { + color: #7b7b7b; + } + } + .tb-help-popup-button-loading { + background: #f3f3f3; + } + } +} diff --git a/ui-ngx/src/app/shared/components/html.component.ts b/ui-ngx/src/app/shared/components/html.component.ts new file mode 100644 index 0000000..6729284 --- /dev/null +++ b/ui-ngx/src/app/shared/components/html.component.ts @@ -0,0 +1,216 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { getAce } from '@shared/models/ace/ace.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { beautifyHtml } from '@shared/models/beautify.models'; + +@Component({ + selector: 'tb-html', + templateUrl: './html.component.html', + styleUrls: ['./html.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HtmlComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HtmlComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None +}) +export class HtmlComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { + + @ViewChild('htmlEditor', {static: true}) + htmlEditorElmRef: ElementRef; + + private htmlEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + private ignoreChange = false; + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + @Input() minHeight = '200px'; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + modelValue: string; + + hasErrors = false; + + private propagateChange = null; + + constructor(public elementRef: ElementRef, + private utils: UtilsService, + private translate: TranslateService, + protected store: Store, + private raf: RafService, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + const editorElement = this.htmlEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/html', + showGutter: true, + showPrintMargin: true, + readOnly: this.disabled + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.htmlEditor = ace.edit(editorElement, editorOptions); + this.htmlEditor.session.setUseWrapMode(true); + this.htmlEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.htmlEditor.setReadOnly(this.disabled); + this.htmlEditor.on('change', () => { + if (!this.ignoreChange) { + this.updateView(); + } + }); + // @ts-ignore + this.htmlEditor.session.on('changeAnnotation', () => { + const annotations = this.htmlEditor.session.getAnnotations(); + const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0; + if (this.hasErrors !== hasErrors) { + this.hasErrors = hasErrors; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + }); + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.htmlEditor) { + this.htmlEditor.destroy(); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.htmlEditor.resize(); + this.htmlEditor.renderer.updateFull(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.htmlEditor) { + this.htmlEditor.setReadOnly(this.disabled); + } + } + + public validate(c: FormControl) { + return (!this.hasErrors) ? null : { + html: { + valid: false, + }, + }; + } + + beautifyHtml() { + beautifyHtml(this.modelValue, {indent_size: 4}).subscribe( + (res) => { + if (this.modelValue !== res) { + this.htmlEditor.setValue(res ? res : '', -1); + this.updateView(); + } + } + ); + } + + writeValue(value: string): void { + this.modelValue = value; + if (this.htmlEditor) { + this.ignoreChange = true; + this.htmlEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.htmlEditor.getValue(); + if (this.modelValue !== editorValue) { + this.modelValue = editorValue; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + } +} diff --git a/ui-ngx/src/app/shared/components/image-input.component.html b/ui-ngx/src/app/shared/components/image-input.component.html new file mode 100644 index 0000000..4c88429 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image-input.component.html @@ -0,0 +1,65 @@ + +
+ + +
+
+
+
+
{{ (disabled ? 'dashboard.empty-image' : 'dashboard.no-image') | translate }}
+ +
+
+ +
+
+
+
+
+ cloud_upload + image-input.drop-image-or + + +
+
+
+ +
+
+
+
dashboard.maximum-upload-file-size
+
diff --git a/ui-ngx/src/app/shared/components/image-input.component.scss b/ui-ngx/src/app/shared/components/image-input.component.scss new file mode 100644 index 0000000..6209d61 --- /dev/null +++ b/ui-ngx/src/app/shared/components/image-input.component.scss @@ -0,0 +1,152 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../scss/constants"; + +$containerHeight: 120px !default; +$previewContainerWidth: 168px !default; +$previewSize: 96px !default; + +:host { + + .tb-container { + margin-top: 0; + label.tb-title { + display: block; + padding-bottom: 8px; + } + } + + .tb-image-select-container { + position: relative; + width: 100%; + height: $containerHeight; + } + + .image-container { + position: relative; + float: left; + height: $containerHeight; + padding: 12px; + margin-right: 8px; + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + } + + .image-content-container { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding-left: 8px; + height: $previewSize; + &.no-padding { + padding-left: 0px; + } + } + + .tb-image-preview { + width: auto; + max-width: $previewSize - 2px; + height: auto; + max-height: $previewSize - 2px; + } + + .tb-image-preview-container { + position: relative; + float: left; + width: $previewSize; + height: $previewSize; + margin-top: -1px; + margin-bottom: -1px; + border: 1px solid rgba(0, 0, 0, 0.54); + + div { + width: 100%; + font-size: 18px; + text-align: center; + } + + div, + .tb-image-preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .tb-image-clear-container { + position: relative; + float: right; + height: $previewSize; + display: flex; + align-items: center; + &.full-height { + height: $containerHeight; + } + } + + .file-input { + display: none; + } + + .tb-flow-drop { + position: relative; + height: $containerHeight; + overflow: hidden; + border: 2px dashed rgba(0, 0, 0, 0.2); + border-radius: 4px; + box-sizing: border-box; + + &.float-left { + float: left; + } + + .upload-label { + width: 100%; + height: 100%; + padding: 0 16px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: 16px; + color: rgba(0, 0, 0, 0.54); + text-align: center; + .mat-icon { + margin-right: 17px; + } + } + } + + .tb-hint{ + margin-top: 8px; + } +} + +:host ::ng-deep { + button.browse-file { + padding: 0; + font-size: 16px; + span.mat-button-wrapper { + display: block; + label { + display: block; + cursor: pointer; + padding: 0 16px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts new file mode 100644 index 0000000..026b64a --- /dev/null +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -0,0 +1,157 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, ChangeDetectorRef, Component, forwardRef, Input, OnDestroy, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { FlowDirective } from '@flowjs/ngx-flow'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { UtilsService } from '@core/services/utils.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { FileSizePipe } from '@shared/pipe/file-size.pipe'; + +@Component({ + selector: 'tb-image-input', + templateUrl: './image-input.component.html', + styleUrls: ['./image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageInputComponent), + multi: true + } + ] +}) +export class ImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + maxSizeByte: number; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + } + } + + @Input() + disabled: boolean; + + @Input() + showClearButton = true; + + @Input() + showPreview = true; + + @Input() + inputId = this.utils.guid(); + + imageUrl: string; + safeImageUrl: SafeUrl; + + @ViewChild('flow', {static: true}) + flow: FlowDirective; + + autoUploadSubscription: Subscription; + + private propagateChange = null; + + constructor(protected store: Store, + private utils: UtilsService, + private sanitizer: DomSanitizer, + private dialog: DialogService, + private translate: TranslateService, + private fileSize: FileSizePipe, + private cd: ChangeDetectorRef) { + super(store); + } + + ngAfterViewInit() { + this.autoUploadSubscription = this.flow.events$.subscribe(event => { + if (event.type === 'fileAdded') { + const file = (event.event[0] as flowjs.FlowFile).file; + if (this.maxSizeByte && this.maxSizeByte < file.size) { + this.dialog.alert( + this.translate.instant('dashboard.cannot-upload-file'), + this.translate.instant('dashboard.maximum-upload-file-size', {size: this.fileSize.transform(this.maxSizeByte)}) + ).subscribe( + () => { } + ); + return false; + } + const reader = new FileReader(); + reader.onload = (loadEvent) => { + if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { + this.imageUrl = reader.result; + this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + this.updateModel(); + } + }; + reader.readAsDataURL(file); + } + }); + } + + ngOnDestroy() { + this.autoUploadSubscription.unsubscribe(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: string): void { + this.imageUrl = value; + if (this.imageUrl) { + this.safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(this.imageUrl); + } else { + this.safeImageUrl = null; + } + } + + private updateModel() { + this.cd.markForCheck(); + this.propagateChange(this.imageUrl); + } + + clearImage() { + this.imageUrl = null; + this.safeImageUrl = null; + this.updateModel(); + } +} diff --git a/ui-ngx/src/app/shared/components/js-func.component.html b/ui-ngx/src/app/shared/components/js-func.component.html new file mode 100644 index 0000000..487bcb2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/js-func.component.html @@ -0,0 +1,48 @@ + +
+
+ + + + +
+
+
+ +
+
+
+
+
+
+
+ +
+
diff --git a/ui-ngx/src/app/shared/components/js-func.component.scss b/ui-ngx/src/app/shared/components/js-func.component.scss new file mode 100644 index 0000000..4373f0a --- /dev/null +++ b/ui-ngx/src/app/shared/components/js-func.component.scss @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-js-func { + position: relative; + + &.tb-disabled { + color: rgba(0, 0, 0, .38); + } + + &.fill-height { + height: 100%; + } + + &:not(.tb-js-func-title) { + .tb-js-func-panel { + margin-left: 15px; + } + } + + &.tb-js-func-title { + .tb-js-func-panel { + height: calc(100% - 40px); + } + } + + .tb-js-func-panel { + height: calc(100% - 80px); + border: 1px solid #c0c0c0; + + #tb-javascript-input { + width: 100%; + min-width: 200px; + height: 100%; + } + } + + &:not(.tb-fullscreen) { + &.tb-js-func-title { + padding-bottom: 15px; + } + } + + .tb-js-func-toolbar { + & > * { + &:not(:last-child) { + margin-right: 4px; + } + } + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + background: rgba(220, 220, 220, .35); + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + font-size: .8rem; + line-height: 15px; + &:not(.tb-help-popup-button) { + color: #7b7b7b; + } + } + .tb-help-popup-button-loading { + background: #f3f3f3; + } + } +} diff --git a/ui-ngx/src/app/shared/components/js-func.component.ts b/ui-ngx/src/app/shared/components/js-func.component.ts new file mode 100644 index 0000000..ceb0011 --- /dev/null +++ b/ui-ngx/src/app/shared/components/js-func.component.ts @@ -0,0 +1,415 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { getAce, Range } from '@shared/models/ace/ace.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UtilsService } from '@core/services/utils.service'; +import { guid, isUndefined } from '@app/core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { beautifyJs } from '@shared/models/beautify.models'; + +@Component({ + selector: 'tb-js-func', + templateUrl: './js-func.component.html', + styleUrls: ['./js-func.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsFuncComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsFuncComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None +}) +export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { + + @ViewChild('javascriptEditor', {static: true}) + javascriptEditorElmRef: ElementRef; + + private jsEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + private ignoreChange = false; + + toastTargetId = `jsFuncEditor-${guid()}`; + + @Input() functionTitle: string; + + @Input() functionName: string; + + @Input() functionArgs: Array; + + @Input() validationArgs: Array; + + @Input() resultType: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + @Input() minHeight = '200px'; + + @Input() editorCompleter: TbEditorCompleter; + + @Input() globalVariables: Array; + + @Input() disableUndefinedCheck = false; + + @Input() helpId: string; + + private noValidateValue: boolean; + get noValidate(): boolean { + return this.noValidateValue; + } + @Input() + set noValidate(value: boolean) { + this.noValidateValue = coerceBooleanProperty(value); + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + functionArgsString = ''; + + fullscreen = false; + + modelValue: string; + + functionValid = true; + + validationError: string; + + errorShowed = false; + + errorMarkers: number[] = []; + errorAnnotationId = -1; + + private propagateChange = null; + public hasErrors = false; + + constructor(public elementRef: ElementRef, + private utils: UtilsService, + private translate: TranslateService, + protected store: Store, + private raf: RafService, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + if (!this.resultType || this.resultType.length === 0) { + this.resultType = 'nocheck'; + } + if (this.functionArgs) { + this.functionArgs.forEach((functionArg) => { + if (this.functionArgsString.length > 0) { + this.functionArgsString += ', '; + } + this.functionArgsString += functionArg; + }); + } + const editorElement = this.javascriptEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/javascript', + showGutter: true, + showPrintMargin: true, + readOnly: this.disabled + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.jsEditor = ace.edit(editorElement, editorOptions); + this.jsEditor.session.setUseWrapMode(true); + this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.jsEditor.setReadOnly(this.disabled); + this.jsEditor.on('change', () => { + if (!this.ignoreChange) { + this.cleanupJsErrors(); + this.updateView(); + } + }); + if (!this.disableUndefinedCheck) { + // @ts-ignore + this.jsEditor.session.on('changeAnnotation', () => { + const annotations = this.jsEditor.session.getAnnotations(); + annotations.filter(annotation => annotation.text.includes('is not defined')).forEach(annotation => { + annotation.type = 'error'; + }); + this.jsEditor.renderer.setAnnotations(annotations); + const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0; + if (this.hasErrors !== hasErrors) { + this.hasErrors = hasErrors; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + }); + } + // @ts-ignore + if (!!this.jsEditor.session.$worker) { + const jsWorkerOptions = { + undef: !this.disableUndefinedCheck, + unused: true, + globals: {} + }; + if (!this.disableUndefinedCheck && this.functionArgs) { + this.functionArgs.forEach(arg => { + jsWorkerOptions.globals[arg] = false; + }); + } + if (!this.disableUndefinedCheck && this.globalVariables) { + this.globalVariables.forEach(arg => { + jsWorkerOptions.globals[arg] = false; + }); + } + // @ts-ignore + this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]); + } + if (this.editorCompleter) { + this.jsEditor.completers = [this.editorCompleter, ...(this.jsEditor.completers || [])]; + } + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.jsEditor) { + this.jsEditor.destroy(); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.jsEditor.resize(); + this.jsEditor.renderer.updateFull(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.jsEditor) { + this.jsEditor.setReadOnly(this.disabled); + } + } + + public validate(c: FormControl) { + return (this.functionValid && !this.hasErrors) ? null : { + jsFunc: { + valid: false, + }, + }; + } + + beautifyJs() { + beautifyJs(this.modelValue, {indent_size: 4, wrap_line_length: 60}).subscribe( + (res) => { + this.jsEditor.setValue(res ? res : '', -1); + this.updateView(); + } + ); + } + + validateOnSubmit(): void { + if (!this.disabled) { + this.cleanupJsErrors(); + this.functionValid = this.validateJsFunc(); + if (!this.functionValid) { + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + this.store.dispatch(new ActionNotificationShow( + { + message: this.validationError, + type: 'error', + target: this.toastTargetId, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + this.errorShowed = true; + } + } + } + + private validateJsFunc(): boolean { + try { + const toValidate = new Function(this.functionArgsString, this.modelValue); + if (this.noValidate) { + return true; + } + if (this.validationArgs) { + let res: any; + let validationError: any; + for (const validationArg of this.validationArgs) { + try { + res = toValidate.apply(this, validationArg); + validationError = null; + break; + } catch (e) { + validationError = e; + } + } + if (validationError) { + throw validationError; + } + if (this.resultType !== 'nocheck') { + if (this.resultType === 'any') { + if (isUndefined(res)) { + this.validationError = this.translate.instant('js-func.no-return-error'); + return false; + } + } else { + const resType = typeof res; + if (resType !== this.resultType) { + this.validationError = this.translate.instant('js-func.return-type-mismatch', {type: this.resultType}); + return false; + } + } + } + return true; + } else { + return true; + } + } catch (e) { + const details = this.utils.parseException(e); + let errorInfo = 'Error:'; + if (details.name) { + errorInfo += ' ' + details.name + ':'; + } + if (details.message) { + errorInfo += ' ' + details.message; + } + if (details.lineNumber) { + errorInfo += '
Line ' + details.lineNumber; + if (details.columnNumber) { + errorInfo += ' column ' + details.columnNumber; + } + errorInfo += ' of script.'; + } + this.validationError = errorInfo; + if (details.lineNumber) { + const line = details.lineNumber - 1; + let column = 0; + if (details.columnNumber) { + column = details.columnNumber; + } + const errorMarkerId = this.jsEditor.session.addMarker(new Range(line, 0, line, Infinity), + 'ace_active-line', 'screenLine'); + this.errorMarkers.push(errorMarkerId); + const annotations = this.jsEditor.session.getAnnotations(); + const errorAnnotation: Ace.Annotation = { + row: line, + column, + text: details.message, + type: 'error' + }; + this.errorAnnotationId = annotations.push(errorAnnotation) - 1; + this.jsEditor.session.setAnnotations(annotations); + } + return false; + } + } + + private cleanupJsErrors(): void { + if (this.errorShowed) { + this.store.dispatch(new ActionNotificationHide( + { + target: this.toastTargetId + })); + this.errorShowed = false; + } + this.errorMarkers.forEach((errorMarker) => { + this.jsEditor.session.removeMarker(errorMarker); + }); + this.errorMarkers.length = 0; + if (this.errorAnnotationId > -1) { + const annotations = this.jsEditor.session.getAnnotations(); + annotations.splice(this.errorAnnotationId, 1); + this.jsEditor.session.setAnnotations(annotations); + this.errorAnnotationId = -1; + } + } + + writeValue(value: string): void { + this.modelValue = value; + if (this.jsEditor) { + this.ignoreChange = true; + this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.jsEditor.getValue(); + if (this.modelValue !== editorValue) { + this.modelValue = editorValue; + this.functionValid = true; + this.propagateChange(this.modelValue); + this.cd.markForCheck(); + } + } +} diff --git a/ui-ngx/src/app/shared/components/json-content.component.html b/ui-ngx/src/app/shared/components/json-content.component.html new file mode 100644 index 0000000..35c1ced --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-content.component.html @@ -0,0 +1,47 @@ + +
+
+ + + + +
+
+ +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/json-content.component.scss b/ui-ngx/src/app/shared/components/json-content.component.scss new file mode 100644 index 0000000..c2194e6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-content.component.scss @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-json-content { + position: relative; + + &.fill-height { + height: 100%; + } + + &:not(.tb-fullscreen) { + padding-bottom: 15px; + } + + .tb-json-content-toolbar { + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + margin: 0; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + background: rgba(220, 220, 220, .35); + &:not(:last-child) { + margin-right: 4px; + } + } + } + + .tb-json-content-panel { + height: 100%; + border: 1px solid #c0c0c0; + + #tb-json-input { + width: 100%; + min-width: 200px; + height: 100%; + + &:not(.fill-height) { + min-height: 200px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/json-content.component.ts b/ui-ngx/src/app/shared/components/json-content.component.ts new file mode 100644 index 0000000..f11aa4f --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-content.component.ts @@ -0,0 +1,332 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild, ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ContentType, contentTypesMap } from '@shared/models/constants'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { guid } from '@core/utils'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { getAce } from '@shared/models/ace/ace.models'; +import { beautifyJs } from '@shared/models/beautify.models'; + +@Component({ + selector: 'tb-json-content', + templateUrl: './json-content.component.html', + styleUrls: ['./json-content.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonContentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsonContentComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None +}) +export class JsonContentComponent implements OnInit, ControlValueAccessor, Validator, OnChanges, OnDestroy { + + @ViewChild('jsonEditor', {static: true}) + jsonEditorElmRef: ElementRef; + + private jsonEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + private ignoreChange = false; + + toastTargetId = `jsonContentEditor-${guid()}`; + + @Input() label: string; + + @Input() contentType: ContentType; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + @Input() editorStyle: {[klass: string]: any}; + + private readonlyValue: boolean; + get readonly(): boolean { + return this.readonlyValue; + } + @Input() + set readonly(value: boolean) { + this.readonlyValue = coerceBooleanProperty(value); + } + + private validateContentValue: boolean; + get validateContent(): boolean { + return this.validateContentValue; + } + @Input() + set validateContent(value: boolean) { + this.validateContentValue = coerceBooleanProperty(value); + } + + private validateOnChangeValue: boolean; + get validateOnChange(): boolean { + return this.validateOnChangeValue; + } + @Input() + set validateOnChange(value: boolean) { + this.validateOnChangeValue = coerceBooleanProperty(value); + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + contentBody: string; + + contentValid: boolean; + + errorShowed = false; + + private propagateChange = null; + + constructor(public elementRef: ElementRef, + protected store: Store, + private raf: RafService, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + const editorElement = this.jsonEditorElmRef.nativeElement; + let mode = 'text'; + if (this.contentType) { + mode = contentTypesMap.get(this.contentType).code; + } + let editorOptions: Partial = { + mode: `ace/mode/${mode}`, + showGutter: true, + showPrintMargin: false, + readOnly: this.disabled || this.readonly + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.jsonEditor = ace.edit(editorElement, editorOptions); + this.jsonEditor.session.setUseWrapMode(true); + this.jsonEditor.setValue(this.contentBody ? this.contentBody : '', -1); + this.jsonEditor.setReadOnly(this.disabled || this.readonly); + this.jsonEditor.on('change', () => { + if (!this.ignoreChange) { + this.cleanupJsonErrors(); + this.updateView(); + } + }); + if (this.validateContent) { + this.jsonEditor.on('blur', () => { + this.contentValid = this.doValidate(true); + this.cd.markForCheck(); + }); + } + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.jsonEditor) { + this.jsonEditor.destroy(); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.jsonEditor.resize(); + this.jsonEditor.renderer.updateFull(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'contentType') { + if (this.jsonEditor) { + let mode = 'text'; + if (this.contentType) { + mode = contentTypesMap.get(this.contentType).code; + } + this.jsonEditor.session.setMode(`ace/mode/${mode}`); + } + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.jsonEditor) { + this.jsonEditor.setReadOnly(this.disabled || this.readonly); + } + } + + public validate(c: FormControl) { + return (this.contentValid) ? null : { + contentBody: { + valid: false, + }, + }; + } + + validateOnSubmit(): void { + if (!this.disabled && !this.readonly) { + this.cleanupJsonErrors(); + this.contentValid = true; + this.propagateChange(this.contentBody); + this.contentValid = this.doValidate(true); + this.propagateChange(this.contentBody); + this.cd.markForCheck(); + } + } + + private doValidate(showErrorToast = false): boolean { + try { + if (this.contentType === ContentType.JSON) { + JSON.parse(this.contentBody); + } + return true; + } catch (ex) { + if (showErrorToast) { + let errorInfo = 'Error:'; + if (ex.name) { + errorInfo += ' ' + ex.name + ':'; + } + if (ex.message) { + errorInfo += ' ' + ex.message; + } + this.store.dispatch(new ActionNotificationShow( + { + message: errorInfo, + type: 'error', + target: this.toastTargetId, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + this.errorShowed = true; + } + return false; + } + } + + cleanupJsonErrors(): void { + if (this.errorShowed) { + this.store.dispatch(new ActionNotificationHide( + { + target: this.toastTargetId + })); + this.errorShowed = false; + } + } + + writeValue(value: string): void { + this.contentBody = value; + this.contentValid = true; + if (this.jsonEditor) { + this.ignoreChange = true; + this.jsonEditor.setValue(this.contentBody ? this.contentBody : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.jsonEditor.getValue(); + if (this.contentBody !== editorValue) { + this.contentBody = editorValue; + this.contentValid = !this.validateOnChange || this.doValidate(); + this.propagateChange(this.contentBody); + this.cd.markForCheck(); + } + } + + beautifyJSON() { + beautifyJs(this.contentBody, {indent_size: 4, wrap_line_length: 60}).subscribe( + (res) => { + this.jsonEditor.setValue(res ? res : '', -1); + this.updateView(); + } + ); + } + + minifyJSON() { + const res = JSON.stringify(this.contentBody); + this.jsonEditor.setValue(res ? res : '', -1); + this.updateView(); + } + + onFullscreen() { + if (this.jsonEditor) { + setTimeout(() => { + this.jsonEditor.resize(); + }, 0); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/json-form/json-form-component.models.ts b/ui-ngx/src/app/shared/components/json-form/json-form-component.models.ts new file mode 100644 index 0000000..06b6265 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/json-form-component.models.ts @@ -0,0 +1,23 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { JsonSettingsSchema } from '@shared/models/widget.models'; + +export interface JsonFormComponentData extends JsonSettingsSchema { + model?: any; + settingsDirective?: string; +} diff --git a/ui-ngx/src/app/shared/components/json-form/json-form.component.html b/ui-ngx/src/app/shared/components/json-form/json-form.component.html new file mode 100644 index 0000000..e028084 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/json-form.component.html @@ -0,0 +1,23 @@ + +
+
+
+
diff --git a/ui-ngx/src/app/shared/components/json-form/json-form.component.scss b/ui-ngx/src/app/shared/components/json-form/json-form.component.scss new file mode 100644 index 0000000..a6df278 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/json-form.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-json-form { + padding: 12px; + padding-bottom: 14px !important; + overflow: auto; +} diff --git a/ui-ngx/src/app/shared/components/json-form/json-form.component.ts b/ui-ngx/src/app/shared/components/json-form/json-form.component.ts new file mode 100644 index 0000000..15f50d1 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/json-form.component.ts @@ -0,0 +1,310 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, Injector, + Input, + OnChanges, + OnDestroy, + OnInit, Renderer2, + SimpleChanges, + ViewChild, ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { deepClone, isString } from '@app/core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { JsonFormProps } from './react/json-form.models'; +import inspector from 'schema-inspector'; +import * as tinycolor_ from 'tinycolor2'; +import { DialogService } from '@app/core/services/dialog.service'; +// import * as React from 'react'; +// import * as ReactDOM from 'react-dom'; +// import ReactSchemaForm from './react/json-form-react'; +import JsonFormUtils from './react/json-form-utils'; +import { JsonFormComponentData } from './json-form-component.models'; +import { GroupInfo } from '@shared/models/widget.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { forkJoin, from } from 'rxjs'; +import { MouseEvent } from 'react'; +import { TbPopoverService } from '@shared/components/popover.service'; + +const tinycolor = tinycolor_; + +@Component({ + selector: 'tb-json-form', + templateUrl: './json-form.component.html', + styleUrls: ['./json-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonFormComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsonFormComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None +}) +export class JsonFormComponent implements OnInit, ControlValueAccessor, Validator, OnChanges, OnDestroy { + + @ViewChild('reactRoot', {static: true}) + reactRootElmRef: ElementRef; + + @ViewChild('reactFullscreen', {static: true}) + reactFullscreenElmRef: ElementRef; + + private readonlyValue: boolean; + get readonly(): boolean { + return this.readonlyValue; + } + @Input() + set required(value: boolean) { + this.readonlyValue = coerceBooleanProperty(value); + } + + formProps: JsonFormProps = { + isFullscreen: false, + option: { + formDefaults: { + startEmpty: true + } + }, + onModelChange: this.onModelChange.bind(this), + onColorClick: this.onColorClick.bind(this), + onIconClick: this.onIconClick.bind(this), + onToggleFullscreen: this.onToggleFullscreen.bind(this), + onHelpClick: this.onHelpClick.bind(this) + }; + + data: JsonFormComponentData; + + model: any; + schema: any; + form: any; + groupInfoes: GroupInfo[]; + + isModelValid = true; + + isFullscreen = false; + fullscreenFinishFn: (el: Element) => void; + + private propagateChange = null; + private propagateChangePending = false; + private writingValue = false; + private updateViewPending = false; + + constructor(public elementRef: ElementRef, + private translate: TranslateService, + private dialogs: DialogService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + protected store: Store, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + this.destroyReactSchemaForm(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (this.propagateChangePending) { + this.propagateChangePending = false; + setTimeout(() => { + this.propagateChange(this.data); + }, 0); + } + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + } + + public validate(c: FormControl) { + return this.isModelValid ? null : { + modelValid: false + }; + } + + writeValue(data: JsonFormComponentData): void { + this.writingValue = true; + this.data = data; + this.schema = this.data && this.data.schema ? deepClone(this.data.schema) : { + type: 'object' + }; + this.schema.strict = true; + this.form = this.data && this.data.form ? deepClone(this.data.form) : [ '*' ]; + this.groupInfoes = this.data && this.data.groupInfoes ? deepClone(this.data.groupInfoes) : []; + this.model = this.data && this.data.model || {}; + this.model = inspector.sanitize(this.schema, this.model).data; + this.updateAndRender(); + this.isModelValid = this.validateModel(); + this.writingValue = false; + if (!this.isModelValid || this.updateViewPending) { + this.updateView(); + } +} + + updateView() { + if (!this.writingValue) { + this.updateViewPending = false; + if (this.data) { + this.data.model = this.model; + if (this.propagateChange) { + try { + this.propagateChange(this.data); + } catch (e) { + this.propagateChangePending = true; + } + } else { + this.propagateChangePending = true; + } + } + } else { + this.updateViewPending = true; + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'readonly') { + this.updateAndRender(); + } + } + } + } + + private onModelChange(key: (string | number)[], val: any, forceUpdate = false) { + if (isString(val) && val === '') { + val = undefined; + } + if (JsonFormUtils.updateValue(key, this.model, val) || forceUpdate) { + this.isModelValid = this.validateModel(); + this.updateView(); + } + } + + private onColorClick(key: (string | number)[], + val: tinycolor.ColorFormats.RGBA, + colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) { + this.dialogs.colorPicker(tinycolor(val).toRgbString()).subscribe((color) => { + if (color && colorSelectedFn) { + colorSelectedFn(tinycolor(color).toRgb()); + } + }); + } + + private onIconClick(key: (string | number)[], + val: string, + iconSelectedFn: (icon: string) => void) { + this.dialogs.materialIconPicker(val).subscribe((icon) => { + if (icon && iconSelectedFn) { + iconSelectedFn(icon); + } + }); + } + + private onToggleFullscreen(fullscreenFinishFn?: (el: Element) => void) { + this.isFullscreen = !this.isFullscreen; + this.fullscreenFinishFn = fullscreenFinishFn; + this.cd.markForCheck(); + } + + onFullscreenChanged(fullscreen: boolean) { + this.formProps.isFullscreen = fullscreen; + this.renderReactSchemaForm(false); + if (this.fullscreenFinishFn) { + this.fullscreenFinishFn(this.reactFullscreenElmRef.nativeElement); + this.fullscreenFinishFn = null; + } + } + + private onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) { + const trigger = event.currentTarget as Element; + this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', helpVisibleFn, helpReadyFn); + } + + private updateAndRender() { + + this.formProps.option.formDefaults.readonly = this.readonly; + this.formProps.schema = this.schema; + this.formProps.form = this.form; + this.formProps.groupInfoes = this.groupInfoes; + this.formProps.model = this.model; + this.renderReactSchemaForm(); + } + + private renderReactSchemaForm(destroy: boolean = true) { + if (destroy) { + this.destroyReactSchemaForm(); + } + + // import ReactSchemaForm from './react/json-form-react'; + const reactSchemaFormObservables: Observable[] = []; + reactSchemaFormObservables.push(from(import('react'))); + reactSchemaFormObservables.push(from(import('react-dom'))); + reactSchemaFormObservables.push(from(import('./react/json-form-react'))); + forkJoin(reactSchemaFormObservables).subscribe( + (modules) => { + const react = modules[0]; + const reactDom = modules[1]; + const jsonFormReact = modules[2].default; + reactDom.render(react.createElement(jsonFormReact, this.formProps), this.reactRootElmRef.nativeElement); + } + ); + /* import('./react/json-form-react').then( + (mod) => { + ReactDOM.render(React.createElement(mod.default, this.formProps), this.reactRootElmRef.nativeElement); + } + );*/ + // ReactDOM.render(React.createElement(ReactSchemaForm, this.formProps), this.reactRootElmRef.nativeElement); + } + + private destroyReactSchemaForm() { + import('react-dom').then( + (reactDom) => { + reactDom.unmountComponentAtNode(this.reactRootElmRef.nativeElement); + } + ); + // ReactDOM.unmountComponentAtNode(this.reactRootElmRef.nativeElement); + } + + private validateModel(): boolean { + if (this.schema && this.model) { + return JsonFormUtils.validateBySchema(this.schema, this.model).valid; + } + return true; + } +} + diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx new file mode 100644 index 0000000..73d358e --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx @@ -0,0 +1,236 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import ThingsboardBaseComponent from './json-form-base-component'; +import reactCSS from 'reactcss'; +import Button from '@material-ui/core/Button'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { IEditorProps } from 'react-ace/src/types'; +import { mergeMap } from 'rxjs/operators'; +import { getAce } from '@shared/models/ace/ace.models'; +import { from } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { CircularProgress, IconButton } from '@material-ui/core'; +import { MouseEvent } from 'react'; +import { Help, HelpOutline } from '@material-ui/icons'; + +const ReactAce = React.lazy(() => { + return getAce().pipe( + mergeMap(() => { + return from(import('react-ace')); + }) + ).toPromise(); +}); + +interface ThingsboardAceEditorProps extends JsonFormFieldProps { + mode: string; + onTidy: (value: string) => Observable; +} + +interface ThingsboardAceEditorState extends JsonFormFieldState { + isFull: boolean; + fullscreenContainerElement: Element; + helpVisible: boolean; + helpReady: boolean; + focused: boolean; +} + +class ThingsboardAceEditor extends React.Component { + + private aceEditor: IEditorProps; + + constructor(props) { + super(props); + this.onValueChanged = this.onValueChanged.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onTidy = this.onTidy.bind(this); + this.onHelp = this.onHelp.bind(this); + this.onLoad = this.onLoad.bind(this); + this.onToggleFull = this.onToggleFull.bind(this); + const value = props.value ? props.value + '' : ''; + this.state = { + isFull: false, + fullscreenContainerElement: null, + helpVisible: false, + helpReady: true, + value, + focused: false + }; + } + + onValueChanged(value) { + this.setState({ + value + }); + this.props.onChangeValidate({ + target: { + value + } + }); + } + + onBlur() { + this.setState({ focused: false }); + } + + onFocus() { + this.setState({ focused: true }); + } + + onTidy() { + if (!this.props.form.readonly) { + const value = this.state.value; + this.props.onTidy(value).subscribe( + (processedValue) => { + this.setState({ + value: processedValue + }); + this.props.onChangeValidate({ + target: { + value: processedValue + } + }); + } + ); + } + } + + onHelp(event: MouseEvent) { + if (this.state.helpVisible && !this.state.helpReady) { + event.preventDefault(); + event.stopPropagation(); + } else { + this.props.onHelpClick(event, this.props.form.helpId, + (visible) => { + this.setState({ + helpVisible: visible + }); + }, (ready) => { + this.setState({ + helpReady: ready + }); + }); + } + } + + onLoad(editor: IEditorProps) { + this.aceEditor = editor; + } + + onToggleFull() { + this.props.onToggleFullscreen((el) => { + this.setState({ isFull: !this.state.isFull, fullscreenContainerElement: el }); + }); + } + + componentDidUpdate() { + } + + render() { + + const styles = reactCSS({ + default: { + tidyButtonStyle: { + color: '#7B7B7B', + minWidth: '32px', + minHeight: '15px', + lineHeight: '15px', + fontSize: '0.800rem', + margin: '0', + padding: '4px', + height: '23px', + borderRadius: '5px', + marginLeft: '5px' + } + } + }); + + let labelClass = 'tb-label'; + if (this.props.form.required) { + labelClass += ' tb-required'; + } + if (this.props.form.readonly) { + labelClass += ' tb-readonly'; + } + if (this.state.focused) { + labelClass += ' tb-focused'; + } + let containerClass = 'tb-container'; + const style = this.props.form.style || {width: '100%'}; + if (this.state.isFull) { + containerClass += ' fullscreen-form-field'; + } + const formDom = ( +
+ +
+
+ + { this.props.onTidy ? : null } + { this.props.form.helpId ?
+ + {this.state.helpVisible ? : } + + { this.state.helpVisible && !this.state.helpReady ? +
+ +
: null }
: null } + +
+ Loading...
}> + + +
+
{this.props.error}
+
+ ); + if (this.state.isFull) { + return ReactDOM.createPortal(formDom, this.state.fullscreenContainerElement); + } else { + return ( +
+ {formDom} +
+ ); + } + } +} + +export default ThingsboardBaseComponent(ThingsboardAceEditor); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx new file mode 100644 index 0000000..f862f07 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx @@ -0,0 +1,179 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import JsonFormUtils from './json-form-utils'; +import ThingsboardBaseComponent from './json-form-base-component'; +import Button from '@material-ui/core/Button'; +import _ from 'lodash'; +import IconButton from '@material-ui/core/IconButton'; +import Clear from '@material-ui/icons/Clear'; +import Add from '@material-ui/icons/Add'; +import Tooltip from '@material-ui/core/Tooltip'; +import { + JsonFormData, + JsonFormFieldProps, + JsonFormFieldState +} from '@shared/components/json-form/react/json-form.models'; + +interface ThingsboardArrayState extends JsonFormFieldState { + model: any[]; + keys: number[]; +} + +class ThingsboardArray extends React.Component { + + constructor(props) { + super(props); + this.onAppend = this.onAppend.bind(this); + this.onDelete = this.onDelete.bind(this); + const model = JsonFormUtils.selectOrSet(this.props.form.key, this.props.model) || []; + const keys: number[] = []; + for (let i = 0; i < model.length; i++) { + keys.push(i); + } + this.state = { + model, + keys + }; + } + + componentDidMount() { + if (this.props.form.startEmpty !== true && this.state.model.length === 0) { + this.onAppend(); + } + } + + onAppend() { + let empty; + if (this.props.form && this.props.form.schema && this.props.form.schema.items) { + const items = this.props.form.schema.items; + if (items.type && items.type.indexOf('object') !== -1) { + empty = {}; + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = typeof items.default !== 'undefined' ? items.default : empty; + if (empty) { + JsonFormUtils.traverseSchema(items, (prop, path) => { + if (typeof prop.default !== 'undefined') { + JsonFormUtils.selectOrSet(path, empty, prop.default); + } + }); + } + } + } else if (items.type && items.type.indexOf('array') !== -1) { + empty = []; + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = items.default || empty; + } + } else { + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = items.default || empty; + } + } + } + const newModel = this.state.model; + newModel.push(empty); + const newKeys = this.state.keys; + let key = 0; + if (newKeys.length > 0) { + key = newKeys[newKeys.length - 1] + 1; + } + newKeys.push(key); + this.setState({ + model: newModel, + keys: newKeys + } + ); + this.props.onChangeValidate(this.state.model, true); + } + + onDelete(index: number) { + const newModel = this.state.model; + newModel.splice(index, 1); + const newKeys = this.state.keys; + newKeys.splice(index, 1); + this.setState( + { + model: newModel, + keys: newKeys + } + ); + this.props.onChangeValidate(this.state.model, true); + } + + setIndex(index: number) { + return (form: JsonFormData) => { + if (form.key) { + form.key[form.key.indexOf('')] = index; + } + }; + } + + copyWithIndex(form: JsonFormData, index: number): JsonFormData { + const copy: JsonFormData = _.cloneDeep(form); + copy.arrayIndex = index; + JsonFormUtils.traverseForm(copy, this.setIndex(index)); + return copy; + } + + render() { + const arrays = []; + const fields = []; + const model = this.state.model; + const keys = this.state.keys; + const items = this.props.form.items; + for (let i = 0; i < model.length; i++ ) { + let removeButton: JSX.Element = null; + if (!this.props.form.readonly) { + const boundOnDelete = this.onDelete.bind(this, i); + removeButton = ; + } + const forms = (this.props.form.items as JsonFormData[]).map((form, index) => { + const copy = this.copyWithIndex(form, i); + return this.props.builder(copy, this.props.model, index, this.props.onChange, + this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen, + this.props.onHelpClick, this.props.mapper); + }); + arrays.push( +
  • + {removeButton} + {forms} +
  • + ); + } + let addButton: JSX.Element = null; + if (!this.props.form.readonly) { + addButton = ; + } + + return ( +
    +
    +
    {this.props.form.title}
    +
      + {arrays} +
    +
    + {addButton} +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardArray); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx new file mode 100644 index 0000000..c453146 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx @@ -0,0 +1,120 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import JsonFormUtils from './json-form-utils'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +export default ThingsboardBaseComponent => class

    + extends React.Component { + + constructor(props) { + super(props); + this.onChangeValidate = this.onChangeValidate.bind(this); + const value = this.defaultValue(); + const validationResult = JsonFormUtils.validate(this.props.form, value); + this.state = { + value, + valid: !!(validationResult.valid || !value), + error: !validationResult.valid && value ? validationResult.error.message : null + }; + } + + componentDidMount() { + if (typeof this.state.value !== 'undefined') { + this.props.onChange(this.props.form.key, this.state.value); + } + } + + onChangeValidate(e, forceUpdate?: boolean) { + let value = null; + if (this.props.form.schema.type === 'integer' || this.props.form.schema.type === 'number') { + if (e.target.value === null || e.target.value === '') { + value = undefined; + } else if (e.target.value.indexOf('.') === -1) { + value = parseInt(e.target.value, 10); + } else { + value = parseFloat(e.target.value); + } + } else if (this.props.form.schema.type === 'boolean') { + value = e.target.checked; + } else if (this.props.form.schema.type === 'date' || this.props.form.schema.type === 'array') { + value = e; + } else { // string + value = e.target.value; + } + const validationResult = JsonFormUtils.validate(this.props.form, value); + this.setState({ + value, + valid: validationResult.valid, + error: validationResult.valid ? null : validationResult.error.message + }); + this.props.onChange(this.props.form.key, value, forceUpdate); + } + + defaultValue() { + let value = JsonFormUtils.selectOrSet(this.props.form.key, this.props.model); + if (this.props.form.schema.type === 'boolean') { + if (typeof value !== 'boolean' && typeof this.props.form.default === 'boolean') { + value = this.props.form.default; + } + if (typeof value !== 'boolean' && this.props.form.schema && typeof this.props.form.schema.default === 'boolean') { + value = this.props.form.schema.default; + } + if (typeof value !== 'boolean' && + this.props.form.schema && + this.props.form.required) { + value = false; + } + } else if (this.props.form.schema.type === 'integer' || this.props.form.schema.type === 'number') { + if (typeof value !== 'number' && typeof this.props.form.default === 'number') { + value = this.props.form.default; + } + if (typeof value !== 'number' && this.props.form.schema && typeof this.props.form.schema.default === 'number') { + value = this.props.form.schema.default; + } + if (typeof value !== 'number' && this.props.form.titleMap && typeof this.props.form.titleMap[0].value === 'number') { + value = this.props.form.titleMap[0].value; + } + if (value && typeof value === 'string') { + if (value.indexOf('.') === -1) { + value = parseInt(value, 10); + } else { + value = parseFloat(value); + } + } + } else { + if (!value && isDefinedAndNotNull(this.props.form.default)) { + value = this.props.form.default; + } + if (!value && this.props.form.schema && isDefinedAndNotNull(this.props.form.schema.default)) { + value = this.props.form.schema.default; + } + if (!value && this.props.form.titleMap && isDefinedAndNotNull(this.props.form.titleMap[0].value)) { + value = this.props.form.titleMap[0].value; + } + } + return value; + } + + render() { + if (this.props.form && this.props.form.schema) { + return ; + } else { + return

    ; + } + } +}; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx new file mode 100644 index 0000000..331b07f --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx @@ -0,0 +1,45 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardBaseComponent from './json-form-base-component'; +import Checkbox from '@material-ui/core/Checkbox'; +import { JsonFormFieldProps, JsonFormFieldState } from './json-form.models.js'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; + +class ThingsboardCheckbox extends React.Component { + render() { + return ( +
    + { + this.props.onChangeValidate(e); + }} + /> + } + label={this.props.form.title} + /> +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardCheckbox); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-color.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-color.tsx new file mode 100644 index 0000000..52d0b5d --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-color.tsx @@ -0,0 +1,186 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import ThingsboardBaseComponent from './json-form-base-component'; +import reactCSS from 'reactcss'; +import * as tinycolor_ from 'tinycolor2'; +import TextField from '@material-ui/core/TextField'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import IconButton from '@material-ui/core/IconButton'; +import Clear from '@material-ui/icons/Clear'; +import Tooltip from '@material-ui/core/Tooltip'; + +const tinycolor = tinycolor_; + +interface ThingsboardColorState extends JsonFormFieldState { + color: tinycolor.ColorFormats.RGBA | null; + focused: boolean; +} + +class ThingsboardColor extends React.Component { + + constructor(props) { + super(props); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + this.onSwatchClick = this.onSwatchClick.bind(this); + this.onClear = this.onClear.bind(this); + const value = props.value ? props.value + '' : null; + const color = value != null ? tinycolor(value).toRgb() : null; + this.state = { + color, + focused: false + }; + } + + onBlur() { + this.setState({focused: false}); + } + + onFocus() { + this.setState({focused: true}); + } + + componentDidMount() { + const node = ReactDOM.findDOMNode(this); + const colContainer = $(node).children('#color-container'); + colContainer.click((event) => { + if (!this.props.form.readonly) { + this.onSwatchClick(event); + } + }); + } + + componentWillUnmount() { + const node = ReactDOM.findDOMNode(this); + const colContainer = $(node).children('#color-container'); + colContainer.off( 'click' ); + } + + onValueChanged(value: tinycolor.ColorFormats.RGBA | null) { + let color: tinycolor.Instance = null; + if (value != null) { + color = tinycolor(value); + } + this.setState({ + color: value + }); + let colorValue = ''; + if (color != null && color.getAlpha() !== 1) { + colorValue = color.toRgbString(); + } else if (color != null) { + colorValue = color.toHexString(); + } + this.props.onChangeValidate({ + target: { + value: colorValue + } + }); + } + + onSwatchClick(event) { + this.props.onColorClick(this.props.form.key, this.state.color, + (color) => { + this.onValueChanged(color); + } + ); + } + + onClear(event) { + if (event) { + event.stopPropagation(); + } + this.onValueChanged(null); + } + + render() { + + let background = 'rgba(0,0,0,0)'; + if (this.state.color != null) { + background = `rgba(${ this.state.color.r }, ${ this.state.color.g }, ${ this.state.color.b }, ${ this.state.color.a })`; + } + + const styles = reactCSS({ + default: { + color: { + background: `${ background }` + }, + swatch: { + display: 'inline-block', + marginRight: '10px', + marginTop: 'auto', + marginBottom: 'auto', + cursor: 'pointer', + opacity: `${ this.props.form.readonly ? '0.6' : '1' }` + }, + swatchText: { + width: '100%' + }, + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center' + }, + colorContainer: { + display: 'flex', + width: '100%' + } + }, + }); + + let fieldClass = 'tb-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.props.form.readonly) { + fieldClass += ' tb-readonly'; + } + if (this.state.focused) { + fieldClass += ' tb-focused'; + } + + let stringColor = ''; + if (this.state.color != null) { + const color = tinycolor(this.state.color); + stringColor = color.toRgbString(); + } + + return ( +
    +
    +
    +
    +
    + +
    + +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardColor); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-css.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-css.tsx new file mode 100644 index 0000000..232ad3a --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-css.tsx @@ -0,0 +1,40 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardAceEditor from './json-form-ace-editor'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { beautifyCss } from '@shared/models/beautify.models'; + +class ThingsboardCss extends React.Component { + + constructor(props) { + super(props); + this.onTidyCss = this.onTidyCss.bind(this); + } + + onTidyCss(css: string): Observable { + return beautifyCss(css, {indent_size: 4}); + } + + render() { + return ( + + ); + } +} + +export default ThingsboardCss; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-date.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-date.tsx new file mode 100644 index 0000000..72d1b36 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-date.tsx @@ -0,0 +1,79 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardBaseComponent from './json-form-base-component'; +import DateFnsUtils from '@date-io/date-fns'; +import { KeyboardDatePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; + +interface ThingsboardDateState extends JsonFormFieldState { + currentValue: Date | null; +} + +class ThingsboardDate extends React.Component { + + constructor(props) { + super(props); + this.onDatePicked = this.onDatePicked.bind(this); + let value: Date | null = null; + if (this.props.value && typeof this.props.value === 'number') { + value = new Date(this.props.value); + } + this.state = { + currentValue: value + }; + } + + + onDatePicked(date: Date | null) { + this.setState({ + currentValue: date + }); + this.props.onChangeValidate(date ? date.getTime() : null); + } + + render() { + + let fieldClass = 'tb-date-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.props.form.readonly) { + fieldClass += ' tb-readonly'; + } + + return ( + +
    + + +
    +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardDate); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx new file mode 100644 index 0000000..b41ace7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx @@ -0,0 +1,44 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import { + JsonFormData, + JsonFormFieldProps, + JsonFormFieldState +} from '@shared/components/json-form/react/json-form.models'; + +class ThingsboardFieldSet extends React.Component { + + render() { + const forms = (this.props.form.items as JsonFormData[]).map((form: JsonFormData, index) => { + return this.props.builder(form, this.props.model, index, this.props.onChange, + this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen, this.props.onHelpClick, this.props.mapper); + }); + + return ( +
    +
    + {this.props.form.title} +
    +
    + {forms} +
    +
    + ); + } +} + +export default ThingsboardFieldSet; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx new file mode 100644 index 0000000..28b820c --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx @@ -0,0 +1,27 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; + +class ThingsboardHelp extends React.Component { + render() { + return ( +
    + ); + } +} + +export default ThingsboardHelp; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-html.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-html.tsx new file mode 100644 index 0000000..3e1c62c --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-html.tsx @@ -0,0 +1,40 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardAceEditor from './json-form-ace-editor'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { beautifyHtml } from '@shared/models/beautify.models'; + +class ThingsboardHtml extends React.Component { + + constructor(props) { + super(props); + this.onTidyHtml = this.onTidyHtml.bind(this); + } + + onTidyHtml(html: string): Observable { + return beautifyHtml(html, {indent_size: 4}); + } + + render() { + return ( + + ); + } +} + +export default ThingsboardHtml; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-icon.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-icon.tsx new file mode 100644 index 0000000..23f6422 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-icon.tsx @@ -0,0 +1,159 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import ThingsboardBaseComponent from './json-form-base-component'; +import reactCSS from 'reactcss'; +import TextField from '@material-ui/core/TextField'; +import IconButton from '@material-ui/core/IconButton'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import Clear from '@material-ui/icons/Clear'; +import Icon from '@material-ui/core/Icon'; +import Tooltip from '@material-ui/core/Tooltip'; + +interface ThingsboardIconState extends JsonFormFieldState { + icon: string | null; + focused: boolean; +} + +class ThingsboardIcon extends React.Component { + + constructor(props) { + super(props); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + this.onIconClick = this.onIconClick.bind(this); + this.onClear = this.onClear.bind(this); + const icon = props.value ? props.value : ''; + this.state = { + icon, + focused: false + }; + } + + onBlur() { + this.setState({focused: false}); + } + + onFocus() { + this.setState({focused: true}); + } + + componentDidMount() { + const node = ReactDOM.findDOMNode(this); + const iconContainer = $(node).children('#icon-container'); + iconContainer.click((event) => { + if (!this.props.form.readonly) { + this.onIconClick(event); + } + }); + } + + componentWillUnmount() { + const node = ReactDOM.findDOMNode(this); + const iconContainer = $(node).children('#icon-container'); + iconContainer.off( 'click' ); + } + + onValueChanged(value: string | null) { + const icon = value; + this.setState({ + icon: value + }); + this.props.onChange(this.props.form.key, value); + } + + onIconClick(event) { + this.props.onIconClick(this.props.form.key, this.state.icon, + (color) => { + this.onValueChanged(color); + } + ); + } + + onClear(event) { + if (event) { + event.stopPropagation(); + } + this.onValueChanged(''); + } + + render() { + + const styles = reactCSS({ + default: { + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center' + }, + icon: { + marginRight: '10px', + marginBottom: 'auto', + cursor: 'pointer', + border: 'solid 1px rgba(0, 0, 0, .27)', + borderRadius: '0' + }, + iconContainer: { + display: 'flex', + width: '100%' + }, + iconText: { + width: '100%' + }, + }, + }); + + let fieldClass = 'tb-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.state.focused) { + fieldClass += ' tb-focused'; + } + + let pickedIcon = 'more_horiz'; + let icon = ''; + if (this.state.icon !== '') { + pickedIcon = this.state.icon; + icon = this.state.icon; + } + + return ( +
    +
    + + {pickedIcon} + + +
    + +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardIcon); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-image.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-image.tsx new file mode 100644 index 0000000..915991b --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-image.tsx @@ -0,0 +1,108 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 React from 'react'; +import Dropzone from 'react-dropzone'; +import ThingsboardBaseComponent from './json-form-base-component'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import IconButton from '@material-ui/core/IconButton'; +import Clear from '@material-ui/icons/Clear'; +import Tooltip from '@material-ui/core/Tooltip'; + +interface ThingsboardImageState extends JsonFormFieldState { + imageUrl: string; +} + +class ThingsboardImage extends React.Component { + + constructor(props) { + super(props); + this.onDrop = this.onDrop.bind(this); + this.onClear = this.onClear.bind(this); + const value = props.value ? props.value + '' : null; + this.state = { + imageUrl: value + }; + } + + onDrop(acceptedFiles: File[]) { + const reader = new FileReader(); + reader.onload = () => { + this.onValueChanged(reader.result); + }; + reader.readAsDataURL(acceptedFiles[0]); + } + + onValueChanged(value) { + this.setState({ + imageUrl: value + }); + this.props.onChangeValidate({ + target: { + value + } + }); + } + + onClear(event) { + if (event) { + event.stopPropagation(); + } + this.onValueChanged(''); + } + + render() { + + let labelClass = 'tb-label'; + if (this.props.form.required) { + labelClass += ' tb-required'; + } + if (this.props.form.readonly) { + labelClass += ' tb-readonly'; + } + + let previewComponent; + if (this.state.imageUrl) { + previewComponent = ; + } else { + previewComponent =
    No image selected
    ; + } + + return ( +
    + +
    +
    {previewComponent}
    +
    + + + +
    + + {({getRootProps, getInputProps}) => ( +
    +
    Drop an image or click to select a file to upload.
    + +
    + )} +
    +
    +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardImage); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-javascript.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-javascript.tsx new file mode 100644 index 0000000..f929461 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-javascript.tsx @@ -0,0 +1,40 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardAceEditor from './json-form-ace-editor'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { beautifyJs } from '@shared/models/beautify.models'; + +class ThingsboardJavaScript extends React.Component { + + constructor(props) { + super(props); + this.onTidyJavascript = this.onTidyJavascript.bind(this); + } + + onTidyJavascript(javascript: string): Observable { + return beautifyJs(javascript, {indent_size: 4, wrap_line_length: 60}); + } + + render() { + return ( + + ); + } +} + +export default ThingsboardJavaScript; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-json.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-json.tsx new file mode 100644 index 0000000..3fa1d25 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-json.tsx @@ -0,0 +1,40 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardAceEditor from './json-form-ace-editor'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { beautifyJs } from '@shared/models/beautify.models'; + +class ThingsboardJson extends React.Component { + + constructor(props) { + super(props); + this.onTidyJson = this.onTidyJson.bind(this); + } + + onTidyJson(json: string): Observable { + return beautifyJs(json, {indent_size: 4}); + } + + render() { + return ( + + ); + } +} + +export default ThingsboardJson; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-markdown.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-markdown.tsx new file mode 100644 index 0000000..b79f55b --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-markdown.tsx @@ -0,0 +1,33 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardAceEditor from './json-form-ace-editor'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; + +class ThingsboardMarkdown extends React.Component { + + constructor(props) { + super(props); + } + + render() { + return ( + + ); + } +} + +export default ThingsboardMarkdown; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx new file mode 100644 index 0000000..00ee218 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx @@ -0,0 +1,97 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardBaseComponent from './json-form-base-component'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import { TextField } from '@material-ui/core'; + +interface ThingsboardNumberState extends JsonFormFieldState { + focused: boolean; + lastSuccessfulValue: number; +} + +class ThingsboardNumber extends React.Component { + + constructor(props) { + super(props); + this.preValidationCheck = this.preValidationCheck.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.state = { + lastSuccessfulValue: this.props.value, + focused: false + }; + } + + isNumeric(n) { + return n === null || n === '' || !isNaN(n) && isFinite(n); + } + + onBlur() { + this.setState({focused: false}); + } + + onFocus() { + this.setState({focused: true}); + } + + preValidationCheck(e) { + if (this.isNumeric(e.target.value)) { + this.setState({ + lastSuccessfulValue: e.target.value + }); + this.props.onChangeValidate(e); + } + } + + render() { + + let fieldClass = 'tb-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.props.form.readonly) { + fieldClass += ' tb-readonly'; + } + if (this.state.focused) { + fieldClass += ' tb-focused'; + } + let value = this.state.lastSuccessfulValue; + if (typeof value !== 'undefined') { + value = Number(value); + } else { + value = null; + } + return ( +
    + +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardNumber); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx new file mode 100644 index 0000000..d6eed81 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx @@ -0,0 +1,51 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { FormLabel, Radio, RadioGroup } from '@material-ui/core'; +import FormControl from '@material-ui/core/FormControl'; +import ThingsboardBaseComponent from '@shared/components/json-form/react/json-form-base-component'; + +class ThingsboardRadios extends React.Component { + render() { + const items = this.props.form.titleMap.map((item, index) => { + return ( + } label={item.name} key={index} /> + ); + }); + + let row = false; + if (this.props.form.direction === 'row') { + row = true; + } + + return ( + + {this.props.form.title} + { + this.props.onChangeValidate(e); + }}> + {items} + + + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardRadios); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx new file mode 100644 index 0000000..c92545e --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx @@ -0,0 +1,202 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardBaseComponent from './json-form-base-component'; +import Select, { Option } from 'rc-select'; +import { + JsonFormFieldProps, + JsonFormFieldState, + KeyLabelItem +} from '@shared/components/json-form/react/json-form.models'; +import { Mode } from 'rc-select/lib/interface'; +import { deepClone } from '@core/utils'; + +interface ThingsboardRcSelectState extends JsonFormFieldState { + currentValue: KeyLabelItem | KeyLabelItem[]; + items: Array; + focused: boolean; +} + +class ThingsboardRcSelect extends React.Component { + + constructor(props) { + super(props); + this.onSelect = this.onSelect.bind(this); + this.onDeselect = this.onDeselect.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.state = { + currentValue: this.keyToCurrentValue(this.props.value, this.props.form.schema.type === 'array'), + items: this.props.form.items as KeyLabelItem[], + focused: false + }; + } + + keyToCurrentValue(key: string | string[], isArray: boolean): KeyLabelItem | KeyLabelItem[] { + let currentValue: KeyLabelItem | KeyLabelItem[] = isArray ? [] : null; + if (isArray) { + const keys = key; + if (keys) { + (keys as string[]).forEach((keyVal) => { + (currentValue as KeyLabelItem[]).push({key: keyVal, label: this.labelFromKey(keyVal)}); + }); + } + } else { + currentValue = {key: key as string, label: this.labelFromKey(key as string)}; + } + return currentValue; + } + + labelFromKey(key: string): string { + let label = key || ''; + if (key) { + for (const item of this.props.form.items) { + if (item.value === key) { + label = item.label; + break; + } + } + } + return label; + } + + arrayValues(items: KeyLabelItem[]): string[] { + const v: string[] = []; + if (items) { + items.forEach(item => { + v.push(item.key); + }); + } + return v; + } + + keyIndex(values: KeyLabelItem[], key: string): number { + let index = -1; + if (values) { + for (let i = 0; i < values.length; i++) { + if (values[i].key === key) { + index = i; + break; + } + } + } + return index; + } + + onSelect(value: KeyLabelItem, option) { + if (this.props.form.schema.type === 'array') { + const v = this.state.currentValue as KeyLabelItem[]; + v.push(this.keyToCurrentValue(value.key, false) as KeyLabelItem); + this.setState({ + currentValue: v + }); + this.props.onChangeValidate(this.arrayValues(v)); + } else { + this.setState({currentValue: this.keyToCurrentValue(value.key, false)}); + this.props.onChangeValidate({target: {value: value.key}}); + } + } + + onDeselect(value: KeyLabelItem, option) { + if (this.props.form.schema.type === 'array') { + const v = this.state.currentValue as KeyLabelItem[]; + const index = this.keyIndex(v, value.key); + if (index > -1) { + v.splice(index, 1); + } + this.setState({ + currentValue: v + }); + this.props.onChangeValidate(this.arrayValues(v)); + } + } + + onBlur() { + this.setState({ focused: false }); + } + + onFocus() { + this.setState({ focused: true }); + } + + render() { + + let options: JSX.Element[] = []; + if (this.state.items && this.state.items.length > 0) { + options = this.state.items.map((item, idx) => ( + + )); + } + + let labelClass = 'tb-label'; + if (this.props.form.required) { + labelClass += ' tb-required'; + } + if (this.props.form.readonly) { + labelClass += ' tb-readonly'; + } + if (this.state.focused) { + labelClass += ' tb-focused'; + } + let mode: Mode; + let value = this.state.currentValue; + if (this.props.form.tags || this.props.form.multiple) { + value = deepClone(value); + if (this.props.form.tags) { + mode = 'tags'; + } else if (this.props.form.multiple) { + mode = 'multiple'; + } + } + + const dropdownStyle = {...this.props.form.dropdownStyle, ...{zIndex: 100001}}; + let dropdownClassName = 'tb-rc-select-dropdown'; + if (this.props.form.dropdownClassName) { + dropdownClassName += ' ' + this.props.form.dropdownClassName; + } + + return ( +
    + + +
    {this.props.error}
    +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardRcSelect); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx new file mode 100644 index 0000000..310d5ec --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx @@ -0,0 +1,53 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import { createTheme, ThemeProvider } from '@material-ui/core/styles'; +import thingsboardTheme from './styles/thingsboardTheme'; +import ThingsboardSchemaForm from './json-form-schema-form'; +import { JsonFormProps } from './json-form.models'; + +const tbTheme = createTheme(thingsboardTheme); + +class ReactSchemaForm extends React.Component { + + static defaultProps: JsonFormProps; + + constructor(props) { + super(props); + } + + render() { + if (this.props.form.length > 0) { + return ; + } else { + return
    ; + } + } +} + +ReactSchemaForm.defaultProps = { + isFullscreen: false, + schema: {}, + form: ['*'], + groupInfoes: [], + option: { + formDefaults: { + startEmpty: true + } + } +}; + +export default ReactSchemaForm; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx new file mode 100644 index 0000000..086f4dd --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx @@ -0,0 +1,216 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import JsonFormUtils from './json-form-utils'; + +import ThingsboardArray from './json-form-array'; +import ThingsboardJavaScript from './json-form-javascript'; +import ThingsboardJson from './json-form-json'; +import ThingsboardHtml from './json-form-html'; +import ThingsboardCss from './json-form-css'; +import ThingsboardColor from './json-form-color'; +import ThingsboardRcSelect from './json-form-rc-select'; +import ThingsboardNumber from './json-form-number'; +import ThingsboardText from './json-form-text'; +import ThingsboardSelect from './json-form-select'; +import ThingsboardRadios from './json-form-radios'; +import ThingsboardDate from './json-form-date'; +import ThingsboardImage from './json-form-image'; +import ThingsboardCheckbox from './json-form-checkbox'; +import ThingsboardHelp from './json-form-help'; +import ThingsboardFieldSet from './json-form-fieldset'; +import ThingsboardIcon from './json-form-icon'; +import { + JsonFormData, + JsonFormProps, + onChangeFn, + OnColorClickFn, onHelpClickFn, + OnIconClickFn, + onToggleFullscreenFn +} from './json-form.models'; + +import _ from 'lodash'; +import * as tinycolor_ from 'tinycolor2'; +import { GroupInfo } from '@shared/models/widget.models'; +import ThingsboardMarkdown from '@shared/components/json-form/react/json-form-markdown'; +import { MouseEvent } from 'react'; + +const tinycolor = tinycolor_; + +class ThingsboardSchemaForm extends React.Component { + + private hasConditions: boolean; + private readonly mapper: {[type: string]: any}; + + constructor(props) { + super(props); + + this.mapper = { + number: ThingsboardNumber, + text: ThingsboardText, + password: ThingsboardText, + textarea: ThingsboardText, + select: ThingsboardSelect, + radios: ThingsboardRadios, + date: ThingsboardDate, + image: ThingsboardImage, + checkbox: ThingsboardCheckbox, + help: ThingsboardHelp, + array: ThingsboardArray, + javascript: ThingsboardJavaScript, + json: ThingsboardJson, + html: ThingsboardHtml, + css: ThingsboardCss, + markdown: ThingsboardMarkdown, + color: ThingsboardColor, + 'rc-select': ThingsboardRcSelect, + fieldset: ThingsboardFieldSet, + icon: ThingsboardIcon + }; + + this.onChange = this.onChange.bind(this); + this.onColorClick = this.onColorClick.bind(this); + this.onIconClick = this.onIconClick.bind(this); + this.onToggleFullscreen = this.onToggleFullscreen.bind(this); + this.onHelpClick = this.onHelpClick.bind(this); + this.hasConditions = false; + } + + onChange(key: (string | number)[], val: any, forceUpdate?: boolean) { + this.props.onModelChange(key, val, forceUpdate); + if (this.hasConditions) { + this.forceUpdate(); + } + } + + onColorClick(key: (string | number)[], val: tinycolor.ColorFormats.RGBA, + colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) { + this.props.onColorClick(key, val, colorSelectedFn); + } + + onIconClick(key: (string | number)[], val: string, + iconSelectedFn: (icon: string) => void) { + this.props.onIconClick(key, val, iconSelectedFn); + } + + onToggleFullscreen(fullscreenFinishFn?: (el: Element) => void) { + this.props.onToggleFullscreen(fullscreenFinishFn); + } + + onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) { + this.props.onHelpClick(event, helpId, helpVisibleFn, helpReadyFn); + } + + + builder(form: JsonFormData, + model: any, + index: number, + onChange: onChangeFn, + onColorClick: OnColorClickFn, + onIconClick: OnIconClickFn, + onToggleFullscreen: onToggleFullscreenFn, + onHelpClick: onHelpClickFn, + mapper: {[type: string]: any}): JSX.Element { + const type = form.type; + const Field = this.mapper[type]; + if (!Field) { + console.log('Invalid field: \"' + form.key[0] + '\"!'); + return null; + } + if (form.condition) { + this.hasConditions = true; + // tslint:disable-next-line:no-eval + if (eval(form.condition) === false) { + return null; + } + } + return ; + } + + createSchema(theForm: any[]): JSX.Element { + const merged = JsonFormUtils.merge(this.props.schema, theForm, this.props.ignore, this.props.option); + let mapper = this.mapper; + if (this.props.mapper) { + mapper = _.merge(this.mapper, this.props.mapper); + } + const forms = merged.map(function(form, index) { + return this.builder(form, this.props.model, index, this.onChange, this.onColorClick, + this.onIconClick, this.onToggleFullscreen, this.onHelpClick, mapper); + }.bind(this)); + + let formClass = 'SchemaForm'; + if (this.props.isFullscreen) { + formClass += ' SchemaFormFullscreen'; + } + + return ( +
    {forms}
    + ); + } + + render() { + if (this.props.groupInfoes && this.props.groupInfoes.length > 0) { + const content: JSX.Element[] = []; + for (const info of this.props.groupInfoes) { + const forms = this.createSchema(this.props.form[info.formIndex]); + const item = ; + content.push(item); + } + return (
    {content}
    ); + } else { + return this.createSchema(this.props.form); + } + } +} +export default ThingsboardSchemaForm; + +interface ThingsboardSchemaGroupProps { + info: GroupInfo; + forms: JSX.Element; +} + +interface ThingsboardSchemaGroupState { + showGroup: boolean; +} + +class ThingsboardSchemaGroup extends React.Component { + constructor(props) { + super(props); + this.state = { + showGroup: true + }; + } + + toogleGroup(index) { + this.setState({ + showGroup: !this.state.showGroup + }); + } + + render() { + const theCla = 'pull-right fa fa-chevron-down tb-toggle-icon' + (this.state.showGroup ? '' : ' tb-toggled'); + return (
    +
    {this.props.info.GroupTitle}
    +
    {this.props.forms}
    +
    ); + } +} diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx new file mode 100644 index 0000000..771755c --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx @@ -0,0 +1,86 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import ThingsboardBaseComponent from '@shared/components/json-form/react/json-form-base-component'; + +interface ThingsboardSelectState extends JsonFormFieldState { + currentValue: any; +} + +class ThingsboardSelect extends React.Component { + + constructor(props) { + super(props); + this.onSelected = this.onSelected.bind(this); + const possibleValue = this.getModelKey(this.props.model, this.props.form.key); + this.state = { + currentValue: this.props.model !== undefined && possibleValue ? possibleValue : this.props.form.titleMap != null ? + this.props.form.titleMap[0].value : '' + }; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.model && nextProps.form.key) { + this.setState({ + currentValue: this.getModelKey(nextProps.model, nextProps.form.key) + || (nextProps.form.titleMap != null ? nextProps.form.titleMap[0].value : '') + }); + } + } + + getModelKey(model, key) { + if (Array.isArray(key)) { + return key.reduce((cur, nxt) => (cur[nxt] || {}), model); + } else { + return model[key]; + } + } + + onSelected(event: React.ChangeEvent<{ name?: string; value: any }>) { + + this.setState({ + currentValue: event.target.value + }); + this.props.onChangeValidate(event); + } + + render() { + const menuItems = this.props.form.titleMap.map((item, idx) => ( + {item.name} + )); + + return ( + + {this.props.form.title} + + + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardSelect); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx new file mode 100644 index 0000000..fa33544 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx @@ -0,0 +1,91 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 * as React from 'react'; +import ThingsboardBaseComponent from './json-form-base-component'; +import TextField from '@material-ui/core/TextField'; +import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; + +interface ThingsboardTextState extends JsonFormFieldState { + focused: boolean; +} + +class ThingsboardText extends React.Component { + + constructor(props) { + super(props); + this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); + this.state = { + focused: false + }; + } + + onBlur() { + this.setState({focused: false}); + } + + onFocus() { + this.setState({focused: true}); + } + + render() { + + let fieldClass = 'tb-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.props.form.readonly) { + fieldClass += ' tb-readonly'; + } + if (this.state.focused) { + fieldClass += ' tb-focused'; + } + + const multiline = this.props.form.type === 'textarea'; + let rows = 1; + let rowsMax = 1; + let minHeight = 48; + if (multiline) { + rows = this.props.form.rows || 2; + rowsMax = this.props.form.rowsMax; + minHeight = 19 * rows + 48; + } + + return ( +
    + { + this.props.onChangeValidate(e); + }} + defaultValue={this.props.value} + disabled={this.props.form.readonly} + rows={rows} + rowsMax={rowsMax} + onFocus={this.onFocus} + onBlur={this.onBlur} + style={this.props.form.style || {width: '100%', minHeight: minHeight + 'px'}}/> +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardText); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts b/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts new file mode 100644 index 0000000..8c80f4e --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts @@ -0,0 +1,592 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * as tv from 'tv4'; +import ObjectPath from 'objectpath'; +import _ from 'lodash'; +import { + DefaultsFormOptions, + FormOption, + JsonFormData, + JsonSchemaData, + SchemaValidationResult +} from './json-form.models'; +import { isDefined, isEqual, isString, isUndefined } from '@core/utils'; + +function validateBySchema(schema: any, value: any): SchemaValidationResult { + return tv.validateResult(value, schema); +} + +function validate(form: any, value: any): SchemaValidationResult { + + if (!form) { + return {valid: true}; + } + const schema = form.schema; + + if (!schema) { + return {valid: true}; + } + + if (value === '') { + value = undefined; + } + + // Numbers fields will give a null value, which also means empty field + if (form.type === 'number' && value === null) { + value = undefined; + } + + if (form.type === 'number' && isNaN(parseFloat(value))) { + value = undefined; + } + const wrap: any = {type: 'object', properties: {}}; + const propName = form.key[form.key.length - 1]; + wrap.properties[propName] = schema; + + if (form.required) { + wrap.required = [propName]; + } + const valueWrap = {}; + if (typeof value !== 'undefined') { + valueWrap[propName] = value; + } + + const tv4Result: SchemaValidationResult = tv.validateResult(valueWrap, wrap); + if (tv4Result != null && !tv4Result.valid && form.validationMessage != null && typeof value !== 'undefined') { + tv4Result.error.message = form.validationMessage; + } + return tv4Result; +} + +function stripNullType(type: any): string { + if (Array.isArray(type) && type.length === 2) { + if (type[0] === 'null') { + return type[1]; + } + if (type[1] === 'null') { + return type[0]; + } + } + return type; +} + +const enumToTitleMap = (enm: string[]): { name: string, value: string }[] => { + const titleMap: { name: string, value: string }[] = []; + enm.forEach((name) => { + titleMap.push({name, value: name}); + }); + return titleMap; +}; + +const canonicalTitleMap = (titleMap: any, originalEnum?: string[]): { name: string, value: string }[] => { + if (!_.isArray(titleMap)) { + const canonical: { name: string, value: string }[] = []; + if (originalEnum) { + originalEnum.forEach((value) => { + canonical.push({name: titleMap[value], value}); + }); + } else { + for (const k of Object.keys(titleMap)) { + if (titleMap.hasOwnProperty(k)) { + canonical.push({name: k, value: titleMap[k]}); + } + } + } + return canonical; + } + return titleMap; +}; + +const stdFormObj = (name: string, schema: any, options: DefaultsFormOptions): any => { + options = options || {}; + const f: any = options.global && options.global.formDefaults ? _.cloneDeep(options.global.formDefaults) : {}; + if (options.global && options.global.supressPropertyTitles === true) { + f.title = schema.title; + } else { + f.title = schema.title || name; + } + + if (schema.description) { + f.description = schema.description; + } + if (options.required === true || schema.required === true) { + f.required = true; + } + if (schema.maxLength) { + f.maxlength = schema.maxLength; + } + if (schema.minLength) { + f.minlength = schema.minLength; + } + if (schema.readOnly || schema.readonly) { + f.readonly = true; + } + if (schema.minimum) { + f.minimum = schema.minimum + (schema.exclusiveMinimum ? 1 : 0); + } + if (schema.maximum) { + f.maximum = schema.maximum - (schema.exclusiveMaximum ? 1 : 0); + } + + // Non standard attributes (DONT USE DEPRECATED) + // If you must set stuff like this in the schema use the x-schema-form attribute + if (schema.validationMessage) { + f.validationMessage = schema.validationMessage; + } + if (schema.enumNames) { + f.titleMap = canonicalTitleMap(schema.enumNames, schema.enum); + } + f.schema = schema; + + // Ng model options doesn't play nice with undefined, might be defined + // globally though + f.ngModelOptions = f.ngModelOptions || {}; + + return f; +}; + +const text = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'string' && !schema.enum) { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'text'; + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const numberType = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'number') { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'number'; + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const integer = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'integer') { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'number'; + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const date = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'date') { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'date'; + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const checkbox = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'boolean') { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'checkbox'; + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const select = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'string' && schema.enum) { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'select'; + if (!f.titleMap) { + f.titleMap = enumToTitleMap(schema.enum); + } + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const checkboxes = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'array' && schema.items && schema.items.enum) { + const f = stdFormObj(name, schema, options); + f.key = options.path; + f.type = 'checkboxes'; + if (!f.titleMap) { + f.titleMap = enumToTitleMap(schema.items.enum); + } + options.lookup[ObjectPath.stringify(options.path)] = f; + return f; + } +}; + +const fieldset = (name: string, schema: any, options: DefaultsFormOptions): any => { + if (stripNullType(schema.type) === 'object') { + const f = stdFormObj(name, schema, options); + f.type = 'fieldset'; + f.items = []; + options.lookup[ObjectPath.stringify(options.path)] = f; + + // recurse down into properties + for (const k of Object.keys(schema.properties)) { + if (schema.properties.hasOwnProperty(k)) { + const path = options.path.slice(); + path.push(k); + if (options.ignore[ObjectPath.stringify(path)] !== true) { + const required = schema.required && schema.required.indexOf(k) !== -1; + + const def = defaultFormDefinition(k, schema.properties[k], { + path, + required: required || false, + lookup: options.lookup, + ignore: options.ignore, + global: options.global + }); + if (def) { + f.items.push(def); + } + } + } + } + return f; + } +}; + +const array = (name: string, schema: any, options: DefaultsFormOptions): any => { + + if (stripNullType(schema.type) === 'array') { + const f = stdFormObj(name, schema, options); + f.type = 'array'; + f.key = options.path; + options.lookup[ObjectPath.stringify(options.path)] = f; + + // don't do anything if items is not defined. + if (typeof schema.items !== 'undefined') { + const required = schema.required && schema.required.indexOf(options.path[options.path.length - 1]) !== -1; + + const arrPath = options.path.slice(); + arrPath.push(''); + const def = defaultFormDefinition(name, schema.items, { + path: arrPath, + required: required || false, + lookup: options.lookup, + ignore: options.ignore, + global: options.global + }); + if (def) { + f.items = [def]; + } else { + // This is the case that item only contains key value pair for rc-select multipel + f.items = schema.items; + } + } + return f; + } +}; + +const defaults: { [key: string]: ((name: string, schema: any, options: DefaultsFormOptions) => any)[] } = { + string: [select, text], + object: [fieldset], + number: [numberType], + integer: [integer], + boolean: [checkbox], + array: [checkboxes, array], + date: [date] +}; + +function defaultFormDefinition(name: string, schema: any, options: DefaultsFormOptions): any { + const rules = defaults[stripNullType(schema.type)]; + if (rules) { + let def; + for (const rule of rules) { + def = rule(name, schema, options); + if (def) { + + // Do we have form defaults in the schema under the x-schema-form-attribute? + if (def.schema['x-schema-form'] && _.isObject(def.schema['x-schema-form'])) { + def = _.extend(def, def.schema['x-schema-form']); + } + return def; + } + } + } +} + +interface DefaultsFormData { + form: any[]; + lookup: { [key: string]: any }; +} + +function getDefaults(schema: any, ignore: { [key: string]: boolean }, globalOptions: FormOption): DefaultsFormData { + const form = []; + const lookup: { [key: string]: any } = {}; + ignore = ignore || {}; + globalOptions = globalOptions || {}; + if (stripNullType(schema.type) === 'object') { + for (const k of Object.keys(schema.properties)) { + if (schema.properties.hasOwnProperty(k)) { + if (ignore[k] !== true) { + const required = schema.required && schema.required.indexOf(k) !== -1; + const def = defaultFormDefinition(k, schema.properties[k], { + path: [k], // Path to this property in bracket notation. + lookup, // Extra map to register with. Optimization for merger. + ignore, // The ignore list of paths (sans root level name) + required, // Is it required? (v4 json schema style) + global: globalOptions // Global options, including form defaults + }); + if (def) { + form.push(def); + } + } + } + } + } else { + throw new Error('Not implemented. Only type "object" allowed at root level of schema.'); + } + return {form, lookup}; +} + +const postProcessFn = (form: any[]): any[] => { + return form; +}; + +function merge(schema: any, form: any[], ignore: { [key: string]: boolean }, options: FormOption, isReadonly?: boolean): any[] { + form = form || ['*']; + options = options || {}; + isReadonly = isReadonly || schema.readonly || schema.readOnly; + const stdForm = getDefaults(schema, ignore, options); + const idx = form.indexOf('*'); + if (idx !== -1) { + form = form.slice(0, idx).concat(stdForm.form).concat(form.slice(idx + 1)); + } + const lookup = stdForm.lookup; + return postProcessFn(form.map((obj) => { + + if (typeof obj === 'string') { + obj = {key: obj}; + } + + if (obj.key) { + if (typeof obj.key === 'string') { + obj.key = ObjectPath.parse(obj.key); + } + } + + if (obj.titleMap) { + obj.titleMap = canonicalTitleMap(obj.titleMap); + } + + if (obj.itemForm) { + obj.items = []; + const str: string = ObjectPath.stringify(obj.key); + const lookupForm = lookup[str]; + lookupForm.items.forEach((item) => { + const o = _.cloneDeep(obj.itemForm); + o.key = item.key; + obj.items.push(o); + }); + } + + if (obj.key) { + const strid: string = ObjectPath.stringify(obj.key); + if (lookup[strid]) { + const schemaDefaults = lookup[strid]; + for (const k of Object.keys(schemaDefaults)) { + if (schemaDefaults.hasOwnProperty(k)) { + if (obj[k] === undefined) { + obj[k] = schemaDefaults[k]; + } + } + } + } + } + + if (isReadonly === true) { + obj.readonly = true; + } + + if (obj.items && obj.items.length > 0) { + obj.items = merge(schema, obj.items, ignore, options, obj.readonly); + } + + if (obj.tabs) { + obj.tabs.forEach((tab) => { + tab.items = merge(schema, tab.items, ignore, options, obj.readonly); + }); + } + + if (obj.type === 'checkbox' && _.isUndefined(obj.schema.default)) { + obj.schema.default = false; + } + + return obj; + })); +} + +function selectOrSet(projection: string | (string | number)[], obj: any, valueToSet?: any): any { + const numRe = /^\d+$/; + + if (!obj) { + obj = this; + } + const parts = typeof projection === 'string' ? ObjectPath.parse(projection) : projection; + + if (typeof valueToSet !== 'undefined' && parts.length === 1) { + obj[parts[0]] = valueToSet; + return obj; + } + + if (typeof valueToSet !== 'undefined' && typeof obj[parts[0]] === 'undefined') { + obj[parts[0]] = parts.length > 2 && numRe.test(parts[1]) ? [] : {}; + } + + let value = obj[parts[0]]; + for (let i = 1; i < parts.length; i++) { + if (parts[i] === '') { + return undefined; + } + if (typeof valueToSet !== 'undefined') { + if (i === parts.length - 1) { + value[parts[i]] = valueToSet; + return valueToSet; + } else { + let tmp = value[parts[i]]; + if (typeof tmp === 'undefined' || tmp === null) { + tmp = numRe.test(parts[i + 1]) ? [] : {}; + value[parts[i]] = tmp; + } + value = tmp; + } + } else if (value) { + value = value[parts[i]]; + } + } + return value; +} + +function updateValue(projection: string | (string | number)[], obj: any, valueToSet: any): boolean { + const numRe = /^\d+$/; + + if (!obj) { + obj = this; + } + + if (!obj) { + return false; + } + + const parts: string[] = isString(projection) ? ObjectPath.parse(projection) : projection; + + if (parts.length === 1) { + return setValue(obj, parts[0], valueToSet); + } + + if (isUndefined(obj[parts[0]])) { + obj[parts[0]] = parts.length > 2 && numRe.test(parts[1]) ? [] : {}; + } + + let value = obj[parts[0]]; + for (let i = 1; i < parts.length; i++) { + if (parts[i] === '') { + return false; + } + if (i === parts.length - 1) { + return setValue(value, parts[i], valueToSet); + } else { + let tmp = value[parts[i]]; + if (isUndefined(tmp) || tmp === null) { + tmp = numRe.test(parts[i + 1]) ? [] : {}; + value[parts[i]] = tmp; + } + value = tmp; + } + } + return value; +} + + +function setValue(obj: any, key: string, val: any): boolean { + let changed = false; + if (obj) { + if (isUndefined(val)) { + if (isDefined(obj[key])) { + delete obj[key]; + changed = true; + } + } else { + changed = !isEqual(obj[key], val); + obj[key] = val; + } + } + return changed; +} + +function traverseSchema(schema: JsonSchemaData, fn: (prop: any, path: string[]) => any, path?: string[], ignoreArrays?: boolean) { + ignoreArrays = typeof ignoreArrays !== 'undefined' ? ignoreArrays : true; + + path = path || []; + + const traverse = ($schema: JsonSchemaData, $fn: (prop: any, path: string[]) => any, $path: string[]) => { + $fn($schema, $path); + if ($schema.properties) { + for (const k of Object.keys($schema.properties)) { + if ($schema.properties.hasOwnProperty(k)) { + const currentPath = $path.slice(); + currentPath.push(k); + traverse($schema.properties[k], $fn, currentPath); + } + } + } + if (!ignoreArrays && $schema.items) { + const arrPath = $path.slice(); + arrPath.push(''); + traverse($schema.items, $fn, arrPath); + } + }; + + traverse(schema, fn, path || []); +} + +function traverseForm(form: JsonFormData, fn: (form: JsonFormData) => any) { + fn(form); + if (form.items) { + form.items.forEach((f) => { + traverseForm(f, fn); + }); + } + + if (form.tabs) { + form.tabs.forEach((tab) => { + tab.items.forEach((f) => { + traverseForm(f, fn); + }); + }); + } +} + + +const utils = { + validateBySchema, + validate, + merge, + updateValue, + selectOrSet, + traverseSchema, + traverseForm +}; +export default utils; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts b/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts new file mode 100644 index 0000000..c8a75bc --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts @@ -0,0 +1,142 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * as tinycolor_ from 'tinycolor2'; +import { GroupInfo } from '@shared/models/widget.models'; +import { MouseEvent } from 'react'; + +const tinycolor = tinycolor_; + +export interface SchemaValidationResult { + valid: boolean; + error?: { + message?: string; + }; +} + +export interface FormOption { + formDefaults?: { + startEmpty?: boolean; + readonly?: boolean; + }; + supressPropertyTitles?: boolean; +} + +export interface DefaultsFormOptions { + global?: FormOption; + required?: boolean; + path?: string[]; + lookup?: {[key: string]: any}; + ignore?: {[key: string]: boolean}; +} + +export type onChangeFn = (key: (string | number)[], val: any, forceUpdate?: boolean) => void; +export type OnColorClickFn = (key: (string | number)[], val: tinycolor.ColorFormats.RGBA, + colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) => void; +export type OnIconClickFn = (key: (string | number)[], val: string, + iconSelectedFn: (icon: string) => void) => void; +export type onToggleFullscreenFn = (fullscreenFinishFn?: (el: Element) => void) => void; +export type onHelpClickFn = (event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, + helpReadyFn: (ready: boolean) => void) => void; + +export interface JsonFormProps { + model?: any; + schema?: any; + form?: any; + groupInfoes?: GroupInfo[]; + isFullscreen: boolean; + ignore?: {[key: string]: boolean}; + option: FormOption; + onModelChange?: onChangeFn; + onColorClick?: OnColorClickFn; + onIconClick?: OnIconClickFn; + onToggleFullscreen?: onToggleFullscreenFn; + onHelpClick?: onHelpClickFn; + mapper?: {[type: string]: any}; +} + +export interface KeyLabelItem { + key: string; + label: string; + value?: string; +} + +export interface JsonSchemaData { + type: string; + default: any; + items?: JsonSchemaData; + properties?: any; +} + +export interface JsonFormData { + type: string; + key: (string | number)[]; + title: string; + readonly: boolean; + required: boolean; + default?: any; + condition?: string; + style?: any; + rows?: number; + rowsMax?: number; + placeholder?: string; + schema: JsonSchemaData; + titleMap: { + value: any; + name: string; + }[]; + items?: Array | Array; + tabs?: Array; + tags?: any; + helpId?: string; + startEmpty?: boolean; + [key: string]: any; +} + +export type ComponentBuilderFn = (form: JsonFormData, + model: any, + index: number, + onChange: onChangeFn, + onColorClick: OnColorClickFn, + onIconClick: OnIconClickFn, + onToggleFullscreen: onToggleFullscreenFn, + onHelpClick: onHelpClickFn, + mapper: {[type: string]: any}) => JSX.Element; + +export interface JsonFormFieldProps { + value: any; + model: any; + form: JsonFormData; + builder: ComponentBuilderFn; + mapper?: {[type: string]: any}; + onChange?: onChangeFn; + onColorClick?: OnColorClickFn; + onIconClick?: OnIconClickFn; + onChangeValidate?: (e: any, forceUpdate?: boolean) => void; + onToggleFullscreen?: onToggleFullscreenFn; + onHelpClick?: onHelpClickFn; + valid?: boolean; + error?: string; + options?: { + setSchemaDefaults?: boolean; + }; +} + +export interface JsonFormFieldState { + value?: any; + valid?: boolean; + error?: string; +} diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form.scss b/ui-ngx/src/app/shared/components/json-form/react/json-form.scss new file mode 100644 index 0000000..76e0be7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form.scss @@ -0,0 +1,361 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +$swift-ease-out-duration: .4s !default; +$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; + +$input-label-float-offset: 6px !default; +$input-label-float-scale: .75 !default; + +$previewSize: 100px !default; + +.tb-json-form { + + &.tb-fullscreen { + background: #fff; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + > div.fullscreen-form-field { + position: relative; + width: 100%; + height: 100%; + } + } + + .json-form-error { + position: relative; + bottom: -5px; + font-size: 12px; + line-height: 12px; + color: rgb(244, 67, 54); + + transition: all 450ms cubic-bezier(.23, 1, .32, 1) 0ms; + } + + .tb-container { + position: relative; + box-sizing: border-box; + padding: 10px 0; + margin-top: 32px; + } + + .tb-field { + padding-bottom: 18px; + + .MuiInputBase-multiline { + flex: 1; + flex-direction: column; + .MuiInputBase-inputMultiline { + flex: 1; + } + } + + &.tb-required { + label::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } + } + + &.tb-focused:not(.tb-readonly) { + label::after { + color: rgb(221, 44, 0); + } + } + } + + .tb-date-field { + &.tb-required { + div > div:first-child::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } + } + + &.tb-focused:not(.tb-readonly) { + div > div:first-child::after { + color: rgb(221, 44, 0); + } + } + } + + label.tb-label { + position: absolute; + right: auto; + bottom: 100%; + left: 0; + color: rgba(0, 0, 0, .54); + + 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); + } + + &.tb-required::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } + + &.tb-focused:not(.tb-readonly)::after { + color: rgb(221, 44, 0); + } + } + + .tb-head-label { + color: rgba(0, 0, 0, .54); + padding-bottom: 15px; + } + + .SchemaGroupname { + padding: 10px 20px; + background-color: #f1f1f1; + } + + .invisible { + display: none; + } + + .tb-button-toggle .tb-toggle-icon { + display: inline-block; + width: 15px; + margin: auto 0 auto auto; + background-size: 100% auto; + + transition: transform .3s, ease-in-out; + } + + .tb-button-toggle .tb-toggle-icon.tb-toggled { + transform: rotateZ(180deg); + } + + .fullscreen-form-field { + .json-form-ace-editor { + height: calc(100% - 60px); + } + } + + .json-form-ace-editor { + position: relative; + height: 100%; + border: 1px solid #c0c0c0; + + .title-panel { + position: absolute; + top: 10px; + right: 20px; + z-index: 5; + font-size: .8rem; + font-weight: 500; + + label { + padding: 4px; + color: #00acc1; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + } + + button.tidy-button { + background: rgba(220, 220, 220, .35) !important; + + span { + padding: 0 !important; + font-size: 12px !important; + } + } + button.help-button { + background: rgba(220, 220, 220, .35); + padding: 4px; + } + div.help-button-loading { + pointer-events: none; + background: #f3f3f3; + border-radius: 50%; + display: flex; + place-content: center; + align-items: center; + } + } + } + + .tb-image-select-container { + position: relative; + width: 100%; + height: $previewSize; + } + + .tb-image-preview { + width: auto; + max-width: $previewSize; + height: auto; + max-height: $previewSize; + } + + .tb-image-preview-container { + position: relative; + float: left; + width: $previewSize; + height: $previewSize; + margin-right: 12px; + vertical-align: top; + border: solid 1px; + + div { + width: 100%; + font-size: 18px; + text-align: center; + } + + div, .tb-image-preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .tb-dropzone { + outline: none; + position: relative; + height: $previewSize; + padding: 0 8px; + overflow: hidden; + vertical-align: top; + border: dashed 2px; + + div { + position: absolute; + top: 50%; + left: 50%; + width: 100%; + font-size: 24px; + text-align: center; + transform: translate(-50%, -50%); + } + } + + .tb-image-clear-container { + position: relative; + float: right; + width: 48px; + height: $previewSize; + } + + .tb-image-clear-btn { + position: absolute !important; + top: 50%; + transform: translate(0%, -50%) !important; + } + + .MuiButton-root { + text-transform: none; + } + +} + +.rc-select { + box-sizing: border-box; + display: inline-block; + position: relative; + vertical-align: middle; + color: #666; + line-height: 28px; + font-size: inherit !important; + .rc-select-selector { + outline: none; + user-select: none; + box-sizing: border-box; + display: block; + background-color: #fff; + border-radius: 6px; + } + &.rc-select-single { + &:not(.rc-select-customize-input) { + .rc-select-selector { + height: 28px; + line-height: 28px; + position: relative; + border: 1px solid #d9d9d9; + &:hover { + border-color: #23c0fa; + box-shadow: 0 0 2px rgba(45, 183, 245, 0.8); + } + .rc-select-selection-search { + .rc-select-selection-search-input { + cursor: pointer; + background: transparent; + margin-left: 10px; + } + } + .rc-select-selection-item, .rc-select-selection-placeholder { + top: 0; + left: 10px; + } + } + &.rc-select-focused { + .rc-select-selector { + border-color: #23c0fa !important; + box-shadow: 0 0 2px rgba(45, 183, 245, 0.8) !important; + } + } + } + } +} + +.rc-select-dropdown { + &.tb-rc-select-dropdown { + z-index: 100001; + background-color: white; + border: 1px solid #d9d9d9; + box-shadow: 0 0 4px #d9d9d9; + border-radius: 4px; + box-sizing: border-box; + outline: none; + + .rc-select-item { + &.rc-select-item-option { + margin: 0; + position: relative; + display: block; + padding: 7px 10px; + font-weight: normal; + color: #666; + white-space: nowrap; + &.rc-select-item-option-selected { + color: #666; + background-color: #ddd; + } + &.rc-select-item-option-active { + background-color: #5897fb; + color: white; + cursor: pointer; + } + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts b/ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts new file mode 100644 index 0000000..e819906 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts @@ -0,0 +1,62 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import indigo from '@material-ui/core/colors/indigo'; +import deeepOrange from '@material-ui/core/colors/deepOrange'; +import { ThemeOptions } from '@material-ui/core/styles'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; +import { mergeDeep } from '@core/utils'; + +const PRIMARY_COLOR = '#305680'; +const SECONDARY_COLOR = '#527dad'; +const HUE3_COLOR = '#a7c1de'; + +const tbIndigo = mergeDeep({}, indigo, { + 500: PRIMARY_COLOR, + 600: SECONDARY_COLOR, + 700: PRIMARY_COLOR, + A100: HUE3_COLOR +}); + +const thingsboardPalette: PaletteOptions = { + primary: tbIndigo, + secondary: deeepOrange, + background: { + default: '#eee' + } +}; + +export default { + typography: { + fontFamily: 'Roboto, \'Helvetica Neue\', sans-serif' + }, + palette: thingsboardPalette, +} as ThemeOptions; diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.html b/ui-ngx/src/app/shared/components/json-object-edit.component.html new file mode 100644 index 0000000..678b26f --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.html @@ -0,0 +1,45 @@ + +
    +
    + + + + + +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.scss b/ui-ngx/src/app/shared/components/json-object-edit.component.scss new file mode 100644 index 0000000..609a8ac --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.scss @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + position: relative; + + .fill-height { + height: 100%; + } +} + +.tb-json-object-toolbar { + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + margin: 0; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + background: rgba(220, 220, 220, .35); + + &:not(:last-child) { + margin-right: 4px; + } + } +} + +.tb-json-object-panel { + height: 100%; + margin-left: 15px; + border: 1px solid #c0c0c0; + + #tb-json-input { + width: 100%; + min-width: 200px; + height: 100%; + + &:not(.fill-height) { + min-height: 200px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts new file mode 100644 index 0000000..6d50762 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -0,0 +1,297 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { guid, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { getAce } from '@shared/models/ace/ace.models'; + +@Component({ + selector: 'tb-json-object-edit', + templateUrl: './json-object-edit.component.html', + styleUrls: ['./json-object-edit.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonObjectEditComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsonObjectEditComponent), + multi: true, + } + ] +}) +export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Validator, OnDestroy { + + @ViewChild('jsonEditor', {static: true}) + jsonEditorElmRef: ElementRef; + + private jsonEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + + toastTargetId = `jsonObjectEditor-${guid()}`; + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + @Input() editorStyle: { [klass: string]: any }; + + @Input() sort: (key: string, value: any) => any; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + private readonlyValue: boolean; + + get readonly(): boolean { + return this.readonlyValue; + } + + @Input() + set readonly(value: boolean) { + this.readonlyValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + modelValue: any; + + contentValue: string; + + objectValid: boolean; + + validationError: string; + + errorShowed = false; + + ignoreChange = false; + + private propagateChange = null; + + constructor(public elementRef: ElementRef, + protected store: Store, + private raf: RafService) { + } + + ngOnInit(): void { + const editorElement = this.jsonEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/json', + showGutter: true, + showPrintMargin: false, + readOnly: this.disabled || this.readonly + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.jsonEditor = ace.edit(editorElement, editorOptions); + this.jsonEditor.session.setUseWrapMode(false); + this.jsonEditor.setValue(this.contentValue ? this.contentValue : '', -1); + this.jsonEditor.setReadOnly(this.disabled || this.readonly); + this.jsonEditor.on('change', () => { + if (!this.ignoreChange) { + this.cleanupJsonErrors(); + this.updateView(); + } + }); + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.jsonEditor) { + this.jsonEditor.destroy(); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.jsonEditor.resize(); + this.jsonEditor.renderer.updateFull(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.jsonEditor) { + this.jsonEditor.setReadOnly(this.disabled || this.readonly); + } + } + + public validate(c: FormControl) { + return (this.objectValid) ? null : { + jsonParseError: { + valid: false, + }, + }; + } + + validateOnSubmit(): void { + if (!this.disabled && !this.readonly) { + this.cleanupJsonErrors(); + if (!this.objectValid) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.validationError, + type: 'error', + target: this.toastTargetId, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + this.errorShowed = true; + } + } + } + + cleanupJsonErrors(): void { + if (this.errorShowed) { + this.store.dispatch(new ActionNotificationHide( + { + target: this.toastTargetId + })); + this.errorShowed = false; + } + } + + beautifyJSON() { + if (this.jsonEditor && this.objectValid) { + const res = JSON.stringify(this.modelValue, null, 2); + this.jsonEditor.setValue(res ? res : '', -1); + this.updateView(); + } + } + + minifyJSON() { + if (this.jsonEditor && this.objectValid) { + const res = JSON.stringify(this.modelValue); + this.jsonEditor.setValue(res ? res : '', -1); + this.updateView(); + } + } + + writeValue(value: any): void { + this.modelValue = value; + this.contentValue = ''; + this.objectValid = false; + try { + if (isDefinedAndNotNull(this.modelValue)) { + this.contentValue = JSON.stringify(this.modelValue, isUndefined(this.sort) ? undefined : + (key, objectValue) => { + return this.sort(key, objectValue); + }, 2); + this.objectValid = true; + } else { + this.objectValid = !this.required; + this.validationError = 'Json object is required.'; + } + } catch (e) { + // + } + if (this.jsonEditor) { + this.ignoreChange = true; + this.jsonEditor.setValue(this.contentValue ? this.contentValue : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.jsonEditor.getValue(); + if (this.contentValue !== editorValue) { + this.contentValue = editorValue; + let data = null; + this.objectValid = false; + if (this.contentValue && this.contentValue.length > 0) { + try { + data = JSON.parse(this.contentValue); + if (!isObject(data)) { + throw new TypeError(`Value is not a valid JSON`); + } + this.objectValid = true; + this.validationError = ''; + } catch (ex) { + let errorInfo = 'Error:'; + if (ex.name) { + errorInfo += ' ' + ex.name + ':'; + } + if (ex.message) { + errorInfo += ' ' + ex.message; + } + this.validationError = errorInfo; + } + } else { + this.objectValid = !this.required; + this.validationError = this.required ? 'Json object is required.' : ''; + } + this.modelValue = data; + this.propagateChange(data); + } + } + + onFullscreen() { + if (this.jsonEditor) { + setTimeout(() => { + this.jsonEditor.resize(); + }, 0); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/json-object-view.component.html b/ui-ngx/src/app/shared/components/json-object-view.component.html new file mode 100644 index 0000000..610be35 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-view.component.html @@ -0,0 +1,22 @@ + +
    + + +
    +
    diff --git a/ui-ngx/src/app/shared/components/json-object-view.component.scss b/ui-ngx/src/app/shared/components/json-object-view.component.scss new file mode 100644 index 0000000..1a55136 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-view.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + #tb-json-view { + width: 100%; + height: 100%; + margin-bottom: 16px; + border: 1px solid #c0c0c0; + + &:not(.fill-height) { + min-height: 100px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/json-object-view.component.ts b/ui-ngx/src/app/shared/components/json-object-view.component.ts new file mode 100644 index 0000000..2e2b68f --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-view.component.ts @@ -0,0 +1,171 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { RafService } from '@core/services/raf.service'; +import { isDefinedAndNotNull, isUndefined } from '@core/utils'; +import { getAce } from '@shared/models/ace/ace.models'; + +@Component({ + selector: 'tb-json-object-view', + templateUrl: './json-object-view.component.html', + styleUrls: ['./json-object-view.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonObjectViewComponent), + multi: true + } + ] +}) +export class JsonObjectViewComponent implements OnInit, OnDestroy { + + @ViewChild('jsonViewer', {static: true}) + jsonViewerElmRef: ElementRef; + + private jsonViewer: Ace.Editor; + private viewerElement: Ace.Editor; + private propagateChange = null; + private modelValue: any; + private contentValue: string; + + @Input() label: string; + + @Input() fillHeight: boolean; + + @Input() editorStyle: { [klass: string]: any }; + + @Input() sort: (key: string, value: any) => any; + + private widthValue: boolean; + + get autoWidth(): boolean { + return this.widthValue; + } + + @Input() + set autoWidth(value: boolean) { + this.widthValue = coerceBooleanProperty(value); + } + + private heigthValue: boolean; + + get autoHeight(): boolean { + return this.heigthValue; + } + + @Input() + set autoHeight(value: boolean) { + this.heigthValue = coerceBooleanProperty(value); + } + + constructor(public elementRef: ElementRef, + protected store: Store, + private raf: RafService, + private renderer: Renderer2) { + } + + ngOnInit(): void { + this.viewerElement = this.jsonViewerElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/java', + theme: 'ace/theme/github', + showGutter: false, + showPrintMargin: false, + readOnly: true + }; + + const advancedOptions = { + enableSnippets: false, + enableBasicAutocompletion: false, + enableLiveAutocompletion: false + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.jsonViewer = ace.edit(this.viewerElement, editorOptions); + this.jsonViewer.session.setUseWrapMode(false); + this.jsonViewer.setValue(this.contentValue ? this.contentValue : '', -1); + if (this.contentValue && (this.widthValue || this.heigthValue)) { + this.updateEditorSize(this.viewerElement, this.contentValue, this.jsonViewer); + } + } + ); + } + + ngOnDestroy(): void { + if (this.jsonViewer) { + this.jsonViewer.destroy(); + } + } + + updateEditorSize(editorElement: any, content: string, editor: Ace.Editor) { + let newHeight = 200; + let newWidth = 600; + if (content && content.length > 0) { + const lines = content.split('\n'); + newHeight = 17 * lines.length + 17; + let maxLineLength = 0; + lines.forEach((row) => { + const line = row.replace(/\t/g, ' ').replace(/\n/g, ''); + const lineLength = line.length; + maxLineLength = Math.max(maxLineLength, lineLength); + }); + newWidth = 8 * maxLineLength + 16; + } + if (this.heigthValue) { + this.renderer.setStyle(editorElement, 'height', newHeight.toString() + 'px'); + } + if (this.widthValue) { + this.renderer.setStyle(editorElement, 'width', newWidth.toString() + 'px'); + } + editor.resize(); + } + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + writeValue(value: any): void { + this.modelValue = value; + this.contentValue = ''; + try { + if (isDefinedAndNotNull(this.modelValue)) { + this.contentValue = JSON.stringify(this.modelValue, isUndefined(this.sort) ? undefined : + (key, objectValue) => { + return this.sort(key, objectValue); + }, 2); + } + } catch (e) { + console.error(e); + } + if (this.jsonViewer) { + this.jsonViewer.setValue(this.contentValue ? this.contentValue : '', -1); + if (this.contentValue && (this.widthValue || this.heigthValue)) { + this.updateEditorSize(this.viewerElement, this.contentValue, this.jsonViewer); + } + } + } + +} diff --git a/ui-ngx/src/app/shared/components/kv-map.component.html b/ui-ngx/src/app/shared/components/kv-map.component.html new file mode 100644 index 0000000..fd88f3c --- /dev/null +++ b/ui-ngx/src/app/shared/components/kv-map.component.html @@ -0,0 +1,59 @@ + +
    + +
    + + + + + + + + + +
    + {{noDataText ? noDataText : 'key-val.no-data'}} +
    + +
    +
    diff --git a/ui-ngx/src/app/shared/components/kv-map.component.scss b/ui-ngx/src/app/shared/components/kv-map.component.scss new file mode 100644 index 0000000..30bd158 --- /dev/null +++ b/ui-ngx/src/app/shared/components/kv-map.component.scss @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-kv-map { + span.no-data-found { + position: relative; + display: flex; + height: 40px; + + &.disabled { + color: rgba(0, 0, 0, .38); + } + } + } +} + +:host ::ng-deep { + .mat-form-field-wrapper { + padding-bottom: 0; + } + .mat-form-field-infix { + border-top: 0; + } + .mat-form-field-underline { + bottom: 0; + } +} diff --git a/ui-ngx/src/app/shared/components/kv-map.component.ts b/ui-ngx/src/app/shared/components/kv-map.component.ts new file mode 100644 index 0000000..f800897 --- /dev/null +++ b/ui-ngx/src/app/shared/components/kv-map.component.ts @@ -0,0 +1,163 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'tb-key-val-map', + templateUrl: './kv-map.component.html', + styleUrls: ['./kv-map.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KeyValMapComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KeyValMapComponent), + multi: true, + } + ] +}) +export class KeyValMapComponent extends PageComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() disabled: boolean; + + @Input() titleText: string; + + @Input() keyPlaceholderText: string; + + @Input() valuePlaceholderText: string; + + @Input() noDataText: string; + + kvListFormGroup: FormGroup; + + private propagateChange = null; + + private valueChangeSubscription: Subscription = null; + + constructor(protected store: Store, + private fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.kvListFormGroup = this.fb.group({}); + this.kvListFormGroup.addControl('keyVals', + this.fb.array([])); + } + + keyValsFormArray(): FormArray { + return this.kvListFormGroup.get('keyVals') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.kvListFormGroup.disable({emitEvent: false}); + } else { + this.kvListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(keyValMap: {[key: string]: string}): void { + if (this.valueChangeSubscription) { + this.valueChangeSubscription.unsubscribe(); + } + const keyValsControls: Array = []; + if (keyValMap) { + for (const property of Object.keys(keyValMap)) { + if (Object.prototype.hasOwnProperty.call(keyValMap, property)) { + keyValsControls.push(this.fb.group({ + key: [property, [Validators.required]], + value: [keyValMap[property], [Validators.required]] + })); + } + } + } + this.kvListFormGroup.setControl('keyVals', this.fb.array(keyValsControls)); + this.valueChangeSubscription = this.kvListFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + if (this.disabled) { + this.kvListFormGroup.disable({emitEvent: false}); + } else { + this.kvListFormGroup.enable({emitEvent: false}); + } + } + + public removeKeyVal(index: number) { + (this.kvListFormGroup.get('keyVals') as FormArray).removeAt(index); + } + + public addKeyVal() { + const keyValsFormArray = this.kvListFormGroup.get('keyVals') as FormArray; + keyValsFormArray.push(this.fb.group({ + key: ['', [Validators.required]], + value: ['', [Validators.required]] + })); + } + + public validate(c: FormControl) { + const kvList: {key: string; value: string}[] = this.kvListFormGroup.get('keyVals').value; + let valid = true; + for (const entry of kvList) { + if (!entry.key || !entry.value) { + valid = false; + break; + } + } + return (valid) ? null : { + keyVals: { + valid: false, + }, + }; + } + + private updateModel() { + const kvList: {key: string; value: string}[] = this.kvListFormGroup.get('keyVals').value; + const keyValMap: {[key: string]: string} = {}; + kvList.forEach((entry) => { + keyValMap[entry.key] = entry.value; + }); + this.propagateChange(keyValMap); + } +} diff --git a/ui-ngx/src/app/shared/components/led-light.component.html b/ui-ngx/src/app/shared/components/led-light.component.html new file mode 100644 index 0000000..ac1346a --- /dev/null +++ b/ui-ngx/src/app/shared/components/led-light.component.html @@ -0,0 +1,18 @@ + +
    diff --git a/ui-ngx/src/app/shared/components/led-light.component.ts b/ui-ngx/src/app/shared/components/led-light.component.ts new file mode 100644 index 0000000..482a8e9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/led-light.component.ts @@ -0,0 +1,128 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { RaphaelElement, RaphaelPaper, RaphaelSet } from 'raphael'; +import * as tinycolor_ from 'tinycolor2'; + +const tinycolor = tinycolor_; + +interface CircleElement extends RaphaelElement { + theGlow?: RaphaelSet; +} + +@Component({ + selector: 'tb-led-light', + templateUrl: './led-light.component.html', + styleUrls: [] +}) +export class LedLightComponent implements OnInit, AfterViewInit, OnChanges { + + @Input() size: number; + + @Input() colorOn: string; + + @Input() colorOff: string; + + @Input() offOpacity: number; + + private enabledValue: boolean; + get enabled(): boolean { + return this.enabledValue; + } + @Input() + set enabled(value: boolean) { + this.enabledValue = coerceBooleanProperty(value); + } + + private canvasSize: number; + private radius: number; + private glowSize: number; + private glowColor: string; + + private paper: RaphaelPaper; + private circleElement: CircleElement; + + constructor(private elementRef: ElementRef) { + } + + ngOnInit(): void { + this.offOpacity = this.offOpacity || 0.4; + this.glowColor = tinycolor(this.colorOn).lighten().toHexString(); + } + + ngAfterViewInit(): void { + this.update(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'enabled' && this.circleElement) { + this.draw(); + } else if (propName === 'size') { + this.update(); + } + } + } + } + + private update() { + this.size = this.size || 50; + this.canvasSize = this.size; + this.radius = this.canvasSize / 4; + this.glowSize = this.radius / 5; + if (this.paper) { + this.paper.remove(); + } + import('raphael').then( + (raphael) => { + this.paper = raphael.default($('#canvas_container', this.elementRef.nativeElement)[0], this.canvasSize, this.canvasSize); + const center = this.canvasSize / 2; + this.circleElement = this.paper.circle(center, center, this.radius); + this.draw(); + } + ); + } + + private draw() { + if (this.enabled) { + this.circleElement.attr('fill', this.colorOn); + this.circleElement.attr('stroke', this.colorOn); + this.circleElement.attr('opacity', 1); + if (this.circleElement.theGlow) { + this.circleElement.theGlow.remove(); + } + this.circleElement.theGlow = this.circleElement.glow( + { + color: this.glowColor, + width: this.radius + this.glowSize, + opacity: 0.8, + fill: true + }); + } else { + if (this.circleElement.theGlow) { + this.circleElement.theGlow.remove(); + } + this.circleElement.attr('fill', this.colorOff); + this.circleElement.attr('stroke', this.colorOff); + this.circleElement.attr('opacity', this.offOpacity); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/logo.component.html b/ui-ngx/src/app/shared/components/logo.component.html new file mode 100644 index 0000000..30f27a7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/logo.component.html @@ -0,0 +1,19 @@ + + diff --git a/ui-ngx/src/app/shared/components/logo.component.scss b/ui-ngx/src/app/shared/components/logo.component.scss new file mode 100644 index 0000000..e737bf2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/logo.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host-context(.login-logo) { + img.tb-logo-title { + width: 280px; + height: 60px; + text-decoration: none; + cursor: pointer; + border: none; + transform: none; + + &:focus { + outline: 0; + } + } +} diff --git a/ui-ngx/src/app/shared/components/logo.component.ts b/ui-ngx/src/app/shared/components/logo.component.ts new file mode 100644 index 0000000..bfa5837 --- /dev/null +++ b/ui-ngx/src/app/shared/components/logo.component.ts @@ -0,0 +1,32 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; + +@Component({ + selector: 'tb-logo', + templateUrl: './logo.component.html', + styleUrls: ['./logo.component.scss'] +}) +export class LogoComponent { + + logo = 'assets/logo_title_white.svg'; + + gotoThingsboard(): void { + window.open('https://thingsboard.io', '_blank'); + } + +} diff --git a/ui-ngx/src/app/shared/components/markdown-editor.component.html b/ui-ngx/src/app/shared/components/markdown-editor.component.html new file mode 100644 index 0000000..d6d9090 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.html @@ -0,0 +1,52 @@ + +
    +
    + + + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/shared/components/markdown-editor.component.scss b/ui-ngx/src/app/shared/components/markdown-editor.component.scss new file mode 100644 index 0000000..1191c97 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.scss @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.markdown-content { + min-width: 400px; + &.tb-edit-mode { + .tb-markdown-view-container { + border: 1px solid #c0c0c0; + } + .tb-markdown-view { + padding: 20px; + } + &:not(.tb-fullscreen) { + padding-bottom: 15px; + .markdown-content-editor { + min-height: 200px; + max-height: 200px; + height: 200px; + } + .tb-markdown-view-container { + min-height: 200px; + max-height: 200px; + height: 200px; + } + } + } + &.tb-fullscreen { + background: #fff; + .markdown-content-editor { + height: calc(100% - 40px); + } + } + .markdown-content-editor { + position: relative; + height: 100%; + } + .tb-markdown-editor { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 1px solid #c0c0c0; + } + .tb-markdown-view-container { + overflow: auto; + height: 100%; + } + button.panel-button { + background: rgba(220, 220, 220, .35); + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + } +} diff --git a/ui-ngx/src/app/shared/components/markdown-editor.component.ts b/ui-ngx/src/app/shared/components/markdown-editor.component.ts new file mode 100644 index 0000000..4747254 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.ts @@ -0,0 +1,162 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { getAce } from '@shared/models/ace/ace.models'; + +@Component({ + selector: 'tb-markdown-editor', + templateUrl: './markdown-editor.component.html', + styleUrls: ['./markdown-editor.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkdownEditorComponent), + multi: true + } + ] +}) +export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, OnDestroy { + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() readonly: boolean; + + @Input() helpId: string; + + @ViewChild('markdownEditor', {static: true}) + markdownEditorElmRef: ElementRef; + + private markdownEditor: Ace.Editor; + + editorMode = true; + + fullscreen = false; + + markdownValue: string; + renderValue: string; + + ignoreChange = false; + + private propagateChange = null; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + constructor() { + } + + ngOnInit(): void { + if (!this.readonly) { + const editorElement = this.markdownEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/markdown', + showGutter: true, + showPrintMargin: false, + readOnly: false + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + + getAce().subscribe( + (ace) => { + this.markdownEditor = ace.edit(editorElement, editorOptions); + this.markdownEditor.session.setUseWrapMode(false); + this.markdownEditor.setValue(this.markdownValue ? this.markdownValue : '', -1); + this.markdownEditor.on('change', () => { + if (!this.ignoreChange) { + this.updateView(); + } + }); + } + ); + + } + } + + ngOnDestroy(): void { + if (this.markdownEditor) { + this.markdownEditor.destroy(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: string): void { + this.editorMode = true; + this.markdownValue = value; + this.renderValue = this.markdownValue ? this.markdownValue : ' '; + if (this.markdownEditor) { + this.ignoreChange = true; + this.markdownEditor.setValue(this.markdownValue ? this.markdownValue : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.markdownEditor.getValue(); + if (this.markdownValue !== editorValue) { + this.markdownValue = editorValue; + this.renderValue = this.markdownValue ? this.markdownValue : ' '; + this.propagateChange(this.markdownValue); + } + } + + onFullscreen() { + if (this.markdownEditor) { + setTimeout(() => { + this.markdownEditor.resize(); + }, 0); + } + } + + toggleEditMode() { + this.editorMode = !this.editorMode; + if (this.editorMode && this.markdownEditor) { + setTimeout(() => { + this.markdownEditor.resize(); + }, 0); + } + } +} diff --git a/ui-ngx/src/app/shared/components/markdown.component.html b/ui-ngx/src/app/shared/components/markdown.component.html new file mode 100644 index 0000000..5b8b9f8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown.component.html @@ -0,0 +1,26 @@ + + + +
    + {{error}} +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/markdown.component.ts b/ui-ngx/src/app/shared/components/markdown.component.ts new file mode 100644 index 0000000..a4e2437 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown.component.ts @@ -0,0 +1,206 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + ComponentFactory, + ComponentRef, ElementRef, + EventEmitter, + Inject, + Injector, + Input, OnChanges, + Output, + SimpleChanges, + Type, ViewChild, + ViewContainerRef +} from '@angular/core'; +import { HelpService } from '@core/services/help.service'; +import { MarkdownService, PrismPlugin } from 'ngx-markdown'; +import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; +import { isDefinedAndNotNull } from '@core/utils'; +import { Observable, of, ReplaySubject } from 'rxjs'; + +@Component({ + selector: 'tb-markdown', + templateUrl: './markdown.component.html' +}) +export class TbMarkdownComponent implements OnChanges { + + @ViewChild('markdownContainer', {read: ViewContainerRef, static: true}) markdownContainer: ViewContainerRef; + @ViewChild('fallbackElement', {static: true}) fallbackElement: ElementRef; + + @Input() data: string | undefined; + + @Input() context: any; + + @Input() additionalCompileModules: Type[]; + + @Input() markdownClass: string | undefined; + + @Input() style: { [klass: string]: any } = {}; + + @Input() + get lineNumbers(): boolean { return this.lineNumbersValue; } + set lineNumbers(value: boolean) { this.lineNumbersValue = coerceBooleanProperty(value); } + + @Input() + get fallbackToPlainMarkdown(): boolean { return this.fallbackToPlainMarkdownValue; } + set fallbackToPlainMarkdown(value: boolean) { this.fallbackToPlainMarkdownValue = coerceBooleanProperty(value); } + + @Output() ready = new EventEmitter(); + + private lineNumbersValue = false; + private fallbackToPlainMarkdownValue = false; + + isMarkdownReady = false; + + error = null; + + private tbMarkdownInstanceComponentRef: ComponentRef; + private tbMarkdownInstanceComponentFactory: ComponentFactory; + + constructor(private help: HelpService, + private cd: ChangeDetectorRef, + public markdownService: MarkdownService, + @Inject(SHARED_MODULE_TOKEN) private sharedModule: Type, + private dynamicComponentFactoryService: DynamicComponentFactoryService) {} + + ngOnChanges(changes: SimpleChanges): void { + if (isDefinedAndNotNull(this.data)) { + this.render(this.data); + } + } + + private render(markdown: string) { + const compiled = this.markdownService.compile(markdown, false); + let template = this.sanitizeCurlyBraces(compiled); + let markdownClass = 'tb-markdown-view'; + if (this.markdownClass) { + markdownClass += ` ${this.markdownClass}`; + } + template = `
    ${template}
    `; + this.markdownContainer.clear(); + const parent = this; + let readyObservable: Observable; + let compileModules = [this.sharedModule]; + if (this.additionalCompileModules) { + compileModules = compileModules.concat(this.additionalCompileModules); + } + this.dynamicComponentFactoryService.createDynamicComponentFactory( + class TbMarkdownInstance { + ngOnDestroy(): void { + parent.destroyMarkdownInstanceResources(); + } + }, + template, + compileModules, + true + ).subscribe((factory) => { + this.tbMarkdownInstanceComponentFactory = factory; + const injector: Injector = Injector.create({providers: [], parent: this.markdownContainer.injector}); + try { + this.tbMarkdownInstanceComponentRef = + this.markdownContainer.createComponent(this.tbMarkdownInstanceComponentFactory, 0, injector); + if (this.context) { + for (const propName of Object.keys(this.context)) { + this.tbMarkdownInstanceComponentRef.instance[propName] = this.context[propName]; + } + } + this.tbMarkdownInstanceComponentRef.instance.style = this.style; + this.handlePlugins(this.tbMarkdownInstanceComponentRef.location.nativeElement); + this.markdownService.highlight(this.tbMarkdownInstanceComponentRef.location.nativeElement); + readyObservable = this.handleImages(this.tbMarkdownInstanceComponentRef.location.nativeElement); + this.cd.detectChanges(); + this.error = null; + } catch (error) { + readyObservable = this.handleError(compiled, error); + } + readyObservable.subscribe(() => { + this.ready.emit(); + }); + }, + (error) => { + readyObservable = this.handleError(compiled, error); + this.cd.detectChanges(); + readyObservable.subscribe(() => { + this.ready.emit(); + }); + }); + } + + private handleError(template: string, error): Observable { + this.error = (error ? error + '' : 'Failed to render markdown!').replace(/\n/g, '
    '); + this.markdownContainer.clear(); + if (this.fallbackToPlainMarkdownValue) { + const element = this.fallbackElement.nativeElement; + element.innerHTML = template; + this.handlePlugins(element); + this.markdownService.highlight(element); + return this.handleImages(element); + } else { + return of(null); + } + } + + private handlePlugins(element: HTMLElement): void { + if (this.lineNumbers) { + this.setPluginClass(element, PrismPlugin.LineNumbers); + } + } + + private setPluginClass(element: HTMLElement, plugin: string | string[]): void { + const preElements = element.querySelectorAll('pre'); + for (let i = 0; i < preElements.length; i++) { + const classes = plugin instanceof Array ? plugin : [plugin]; + preElements.item(i).classList.add(...classes); + } + } + + private handleImages(element: HTMLElement): Observable { + const imgs = $('img', element); + if (imgs.length) { + let totalImages = imgs.length; + const imagesLoadedSubject = new ReplaySubject(); + imgs.each((index, img) => { + $(img).one('load error', () => { + totalImages--; + if (totalImages === 0) { + imagesLoadedSubject.next(); + imagesLoadedSubject.complete(); + } + }); + }); + return imagesLoadedSubject.asObservable(); + } else { + return of(null); + } + } + + private sanitizeCurlyBraces(template: string): string { + return template.replace(/{/g, '{').replace(/}/g, '}'); + } + + private destroyMarkdownInstanceResources() { + if (this.tbMarkdownInstanceComponentFactory) { + this.dynamicComponentFactoryService.destroyDynamicComponentFactory(this.tbMarkdownInstanceComponentFactory); + this.tbMarkdownInstanceComponentFactory = null; + } + this.tbMarkdownInstanceComponentRef = null; + } +} diff --git a/ui-ngx/src/app/shared/components/marked-options.service.ts b/ui-ngx/src/app/shared/components/marked-options.service.ts new file mode 100644 index 0000000..2735ed9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/marked-options.service.ts @@ -0,0 +1,233 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { DOCUMENT } from '@angular/common'; +import { WINDOW } from '@core/services/window.service'; +import { Tokenizer, marked } from 'marked'; +import { Clipboard } from '@angular/cdk/clipboard'; + +const copyCodeBlock = '{:copy-code}'; +const codeStyleRegex = '^{:code-style="(.*)"}\n'; +const autoBlock = '{:auto}'; +const targetBlankBlock = '{:target="_blank"}'; + +// @dynamic +@Injectable({ + providedIn: 'root' +}) +export class MarkedOptionsService extends MarkedOptions { + + renderer = new MarkedRenderer(); + headerIds = true; + gfm = true; + breaks = false; + pedantic = false; + smartLists = true; + smartypants = false; + mangle = false; + + private renderer2 = new MarkedRenderer(); + + private id = 1; + + constructor(private translate: TranslateService, + private clipboardService: Clipboard, + @Inject(WINDOW) private readonly window: Window, + @Inject(DOCUMENT) private readonly document: Document) { + super(); + // @ts-ignore + const tokenizer: Tokenizer = { + autolink(src: string, mangle: (cap: string) => string): marked.Tokens.Link { + if (src.endsWith(copyCodeBlock)) { + return undefined; + } else { + // @ts-ignore + return false; + } + }, + url(src: string, mangle: (cap: string) => string): marked.Tokens.Link { + if (src.endsWith(copyCodeBlock)) { + return undefined; + } else { + // @ts-ignore + return false; + } + } + }; + marked.use({tokenizer}); + this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => { + const codeContext = processCode(code); + if (codeContext.copyCode) { + const content = postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext); + this.id++; + return this.wrapCopyCode(this.id, content, codeContext); + } else { + return this.wrapDiv(postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext)); + } + }; + this.renderer.table = (header: string, body: string) => { + let autoLayout = false; + if (header.includes(autoBlock)) { + autoLayout = true; + header = header.replace(autoBlock, ''); + } + let table = this.renderer2.table(header, body); + if (autoLayout) { + table = table.replace(' { + const codeContext = processCode(content); + codeContext.multiline = false; + if (codeContext.copyCode) { + this.id++; + content = this.wrapCopyCode(this.id, codeContext.code, codeContext); + } + return this.renderer2.tablecell(content, flags); + }; + this.renderer.link = (href: string | null, title: string | null, text: string) => { + if (text.endsWith(targetBlankBlock)) { + text = text.substring(0, text.length - targetBlankBlock.length); + const content = this.renderer2.link(href, title, text); + return content.replace('${content}
    `; + } + + private wrapCopyCode(id: number, content: string, context: CodeContext): string { + let copyCodeButtonClass = 'clipboard-btn'; + if (context.multiline) { + copyCodeButtonClass += ' multiline'; + } + return `
    ${content}` + + `` + + `` + + `
    `; + } + + private onSelectionChange() { + const codeWrappers = $('.code-wrapper'); + codeWrappers.removeClass('noChars'); + const selectedChars = this.getSelectedText(); + if (!selectedChars) { + codeWrappers.addClass('noChars'); + } + } + + private getSelectedText(): string { + let text; + if (this.window.getSelection) { + text = this.window.getSelection().toString(); + } else if (this.document.getSelection) { + text = this.document.getSelection(); + } else if ((this.document as any).selection) { + text = (this.document as any).selection.createRange().text; + } + return text; + } + + private markdownCopyCode(id: number) { + const copyWrapper = $('#codeWrapper' + id); + if (copyWrapper.hasClass('noChars')) { + const text = decodeURIComponent($('#copyCodeId' + id).text()); + if (this.clipboardService.copy(text)) { + import('tooltipster').then( + () => { + if (!copyWrapper.hasClass('tooltipstered')) { + copyWrapper.tooltipster( + { + content: this.translate.instant('markdown.copied'), +// theme: 'tooltipster-shadow', + delay: 0, + trigger: 'custom', + triggerClose: { + click: true, + tap: true, + scroll: true, + mouseleave: true + }, + side: 'top', + distance: 12, + trackOrigin: true + } + ); + } + const tooltip = copyWrapper.tooltipster('instance'); + tooltip.open(); + }); + } + } + } +} + +interface CodeContext { + copyCode: boolean; + multiline: boolean; + codeStyle?: string; + code: string; +} + +function processCode(code: string): CodeContext { + const context: CodeContext = { + copyCode: false, + multiline: false, + code + }; + if (context.code.endsWith(copyCodeBlock)) { + context.code = context.code.substring(0, context.code.length - copyCodeBlock.length); + context.copyCode = true; + } + const codeStyleMatch = context.code.match(new RegExp(codeStyleRegex)); + if (codeStyleMatch) { + context.codeStyle = codeStyleMatch[1]; + context.code = context.code.replace(new RegExp(codeStyleRegex), ''); + } + const lineCount = context.code.trim().split('\n').length; + context.multiline = lineCount > 1; + return context; +} + +function postProcessCodeContent(content: string, context: CodeContext): string { + let replacement = '
    ', replacement);
    +}
    diff --git a/ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts b/ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts
    new file mode 100644
    index 0000000..f33280f
    --- /dev/null
    +++ b/ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts
    @@ -0,0 +1,271 @@
    +///
    +/// Copyright © 2016-2023 The Thingsboard Authors
    +///
    +/// Licensed under the Apache License, Version 2.0 (the "License");
    +/// you may not use this file except in compliance with the License.
    +/// You may obtain a copy of the License at
    +///
    +///     http://www.apache.org/licenses/LICENSE-2.0
    +///
    +/// Unless required by applicable law or agreed to in writing, software
    +/// distributed under the License is distributed on an "AS IS" BASIS,
    +/// WITHOUT 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 { AfterViewInit, Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
    +import { MatChip, MatChipList } from '@angular/material/chips';
    +import Timeout = NodeJS.Timeout;
    +
    +export interface MatChipDropEvent {
    +  from: number;
    +  to: number;
    +}
    +
    +@Directive({
    +  selector: 'mat-chip-list[tb-chip-draggable]',
    +})
    +export class MatChipDraggableDirective implements AfterViewInit {
    +
    +  @Output()
    +  chipDrop = new EventEmitter();
    +
    +  private draggableChips: Array = [];
    +
    +  constructor(private chipsList: MatChipList,
    +              private elementRef: ElementRef) {
    +  }
    +
    +  @HostListener('document:mouseup')
    +  onDocumentMouseUp() {
    +    this.draggableChips.forEach((draggableChip) => {
    +      draggableChip.preventDrag = false;
    +    });
    +  }
    +
    +  ngAfterViewInit(): void {
    +    this.configureDraggableChipList();
    +    this.chipsList.chips.changes.subscribe(() => {
    +      this.configureDraggableChipList();
    +    });
    +  }
    +
    +  private configureDraggableChipList() {
    +    const toRemove: Array = [];
    +    this.chipsList.chips.forEach((chip) => {
    +        const found = this.draggableChips.find((draggableChip) => draggableChip.chip === chip);
    +        if (!found) {
    +          this.draggableChips.push(new DraggableChip(chip,
    +            this.chipsList,
    +            this.elementRef.nativeElement,
    +            this.chipDrop));
    +        }
    +      }
    +    );
    +    this.draggableChips.forEach((draggableChip) => {
    +      const found = this.chipsList.chips.find((chip) => chip === draggableChip.chip);
    +      if (!found) {
    +        toRemove.push(draggableChip);
    +      }
    +    });
    +    toRemove.forEach((draggableChip) => {
    +      const index = this.draggableChips.indexOf(draggableChip);
    +      this.draggableChips.splice(index, 1);
    +    });
    +  }
    +}
    +
    +const draggingClassName = 'dragging';
    +const droppingClassName = 'dropping';
    +const droppingBeforeClassName = 'dropping-before';
    +const droppingAfterClassName = 'dropping-after';
    +
    +let globalDraggingChipListId = null;
    +
    +class DraggableChip {
    +
    +  private chipElement: HTMLElement;
    +  private readonly handle: HTMLElement;
    +
    +  private dragging = false;
    +  private counter = 0;
    +
    +  private dropPosition: 'after' | 'before';
    +
    +  private dropTimeout: Timeout;
    +
    +  public preventDrag = false;
    +
    +  private dropHandler = this.onDrop.bind(this);
    +  private dragOverHandler = this.onDragOver.bind(this);
    +
    +  constructor(public chip: MatChip,
    +              private chipsList: MatChipList,
    +              private chipListElement: HTMLElement,
    +              private chipDrop: EventEmitter) {
    +    this.chipElement = chip._elementRef.nativeElement;
    +    this.chipElement.setAttribute('draggable', 'true');
    +    this.handle = this.chipElement.getElementsByClassName('tb-chip-drag-handle')[0] as HTMLElement;
    +    this.chipElement.addEventListener('mousedown', this.onMouseDown.bind(this));
    +    this.chipElement.addEventListener('dragstart', this.onDragStart.bind(this));
    +    this.chipElement.addEventListener('dragend', this.onDragEnd.bind(this));
    +    this.chipElement.addEventListener('dragenter', this.onDragEnter.bind(this));
    +    this.chipElement.addEventListener('dragleave', this.onDragLeave.bind(this));
    +  }
    +
    +  private onMouseDown(event: MouseEvent) {
    +    if (event.target !== this.handle) {
    +      this.preventDrag = true;
    +    }
    +  }
    +
    +  private onDragStart(event: Event | any) {
    +    if (this.preventDrag) {
    +      event.preventDefault();
    +    } else {
    +      event.stopPropagation();
    +      this.dragging = true;
    +      globalDraggingChipListId = this.chipListElement.id;
    +      this.chipListElement.classList.add(draggingClassName);
    +      this.chipElement.classList.add(draggingClassName);
    +      event = (event as any).originalEvent || event;
    +      const dataTransfer = event.dataTransfer;
    +      dataTransfer.effectAllowed = 'copyMove';
    +      dataTransfer.dropEffect = 'move';
    +      dataTransfer.setData('text', this.index() + '');
    +      const offset = this.calculateDragImageOffset(event, this.chipElement) || {x: 0, y: 0};
    +      (event.dataTransfer as any).setDragImage( this.chipElement, offset.x, offset.y );
    +    }
    +  }
    +
    +  private onDragEnter(event: Event | any) {
    +    this.counter++;
    +    if (this.dragging) {
    +      return;
    +    }
    +    this.chipElement.removeEventListener('dragover', this.dragOverHandler);
    +    this.chipElement.removeEventListener('drop', this.dropHandler);
    +
    +    this.chipElement.addEventListener('dragover', this.dragOverHandler);
    +    this.chipElement.addEventListener('drop', this.dropHandler);
    +  }
    +
    +  private onDragLeave(event: Event | any) {
    +    this.counter--;
    +    if (this.counter <= 0) {
    +      this.counter = 0;
    +      this.chipElement.classList.remove(droppingClassName);
    +      this.chipElement.classList.remove(droppingAfterClassName);
    +      this.chipElement.classList.remove(droppingBeforeClassName);
    +    }
    +  }
    +
    +  private onDragEnd(event: Event | any) {
    +    event.stopPropagation();
    +    this.dragging = false;
    +    globalDraggingChipListId = null;
    +    this.chipListElement.classList.remove(draggingClassName);
    +    this.chipElement.classList.remove(draggingClassName);
    +  }
    +
    +  private onDragOver(event: Event | any) {
    +    if (this.dragging) {
    +      return;
    +    }
    +    event.preventDefault();
    +    if (globalDraggingChipListId !== this.chipListElement.id) {
    +      return;
    +    }
    +    const bounds = this.chipElement.getBoundingClientRect();
    +    event = (event as any).originalEvent || event;
    +    const props = {
    +      width: bounds.right - bounds.left,
    +      height: bounds.bottom - bounds.top,
    +      x: event.clientX - bounds.left,
    +      y: event.clientY - bounds.top,
    +    };
    +
    +    const horizontalOffset = props.x;
    +    const horizontalMidPoint = props.width / 2;
    +
    +    const verticalOffset = props.y;
    +    const verticalMidPoint = props.height / 2;
    +
    +    this.chipElement.classList.add(droppingClassName);
    +
    +    this.chipElement.classList.remove(droppingAfterClassName);
    +    this.chipElement.classList.remove(droppingBeforeClassName);
    +
    +    if (horizontalOffset >= horizontalMidPoint || verticalOffset >= verticalMidPoint) {
    +      this.dropPosition = 'after';
    +      this.chipElement.classList.add(droppingAfterClassName);
    +    } else {
    +      this.dropPosition = 'before';
    +      this.chipElement.classList.add(droppingBeforeClassName);
    +    }
    +
    +  }
    +
    +  private onDrop(event: Event | any) {
    +    this.counter = 0;
    +    event.preventDefault();
    +    if (globalDraggingChipListId !== this.chipListElement.id) {
    +      return;
    +    }
    +    event = (event as any).originalEvent || event;
    +    const droppedItemIndex = parseInt(event.dataTransfer.getData('text'), 10);
    +    const currentIndex = this.index();
    +    let newIndex;
    +    if (this.dropPosition === 'before') {
    +      if (droppedItemIndex < currentIndex) {
    +        newIndex = currentIndex - 1;
    +      } else {
    +        newIndex = currentIndex;
    +      }
    +    } else {
    +      if (droppedItemIndex < currentIndex) {
    +        newIndex = currentIndex;
    +      } else {
    +        newIndex = currentIndex + 1;
    +      }
    +    }
    +    if (this.dropTimeout) {
    +      clearTimeout(this.dropTimeout);
    +    }
    +    this.dropTimeout = setTimeout(() => {
    +      this.dropPosition = null;
    +
    +      this.chipElement.classList.remove(droppingClassName);
    +      this.chipElement.classList.remove(droppingAfterClassName);
    +      this.chipElement.classList.remove(droppingBeforeClassName);
    +
    +      this.chipElement.removeEventListener('drop', this.dropHandler);
    +
    +      const dropEvent: MatChipDropEvent = {
    +        from: droppedItemIndex,
    +        to: newIndex
    +      };
    +      this.chipDrop.emit(dropEvent);
    +    }, 1000 / 16);
    +  }
    +
    +  private index(): number {
    +    return this.chipsList.chips.toArray().indexOf(this.chip);
    +  }
    +
    +  private calculateDragImageOffset(event: DragEvent, dragImage: Element): { x: number, y: number } {
    +
    +    const dragImageComputedStyle = window.getComputedStyle( dragImage );
    +    const paddingTop = parseFloat( dragImageComputedStyle.paddingTop ) || 0;
    +    const paddingLeft = parseFloat( dragImageComputedStyle.paddingLeft ) || 0;
    +    const borderTop = parseFloat( dragImageComputedStyle.borderTopWidth ) || 0;
    +    const borderLeft = parseFloat( dragImageComputedStyle.borderLeftWidth ) || 0;
    +
    +    return {
    +      x: event.offsetX + paddingLeft + borderLeft,
    +      y: event.offsetY + paddingTop + borderTop
    +    };
    +  }
    +
    +}
    diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.html b/ui-ngx/src/app/shared/components/material-icon-select.component.html
    new file mode 100644
    index 0000000..fa2a649
    --- /dev/null
    +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.html
    @@ -0,0 +1,30 @@
    +
    +
    + {{materialIconFormGroup.get('icon').value}} + + {{ label }} + + + +
    diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.scss b/ui-ngx/src/app/shared/components/material-icon-select.component.scss new file mode 100644 index 0000000..21d7c4b --- /dev/null +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-icon.icon-value { + padding: 4px; + margin: 8px 4px 4px; + cursor: pointer; + border: solid 1px rgba(0, 0, 0, .27); + } +} + +:host ::ng-deep { + .mat-form-field-infix{ + width: 146px; + } +} diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts new file mode 100644 index 0000000..fe94f0a --- /dev/null +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -0,0 +1,138 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { DialogService } from '@core/services/dialog.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-material-icon-select', + templateUrl: './material-icon-select.component.html', + styleUrls: ['./material-icon-select.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MaterialIconSelectComponent), + multi: true + } + ] +}) +export class MaterialIconSelectComponent extends PageComponent implements OnInit, ControlValueAccessor { + + @Input() + label = this.translate.instant('icon.icon'); + + @Input() + disabled: boolean; + + private iconClearButtonValue: boolean; + get iconClearButton(): boolean { + return this.iconClearButtonValue; + } + @Input() + set iconClearButton(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.iconClearButtonValue !== newVal) { + this.iconClearButtonValue = newVal; + } + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + private modelValue: string; + + private propagateChange = null; + + public materialIconFormGroup: FormGroup; + + constructor(protected store: Store, + private dialogs: DialogService, + private translate: TranslateService, + private fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.materialIconFormGroup = this.fb.group({ + icon: [null, []] + }); + + this.materialIconFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.materialIconFormGroup.disable({emitEvent: false}); + } else { + this.materialIconFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + this.modelValue = value; + this.materialIconFormGroup.patchValue( + { icon: this.modelValue }, {emitEvent: false} + ); + } + + private updateModel() { + const icon: string = this.materialIconFormGroup.get('icon').value; + if (this.modelValue !== icon) { + this.modelValue = icon; + this.propagateChange(this.modelValue); + } + } + + openIconDialog() { + if (!this.disabled) { + this.dialogs.materialIconPicker(this.materialIconFormGroup.get('icon').value).subscribe( + (icon) => { + if (icon) { + this.materialIconFormGroup.patchValue( + {icon}, {emitEvent: true} + ); + } + } + ); + } + } + + clear() { + this.materialIconFormGroup.get('icon').patchValue(null, {emitEvent: true}); + } +} diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html new file mode 100644 index 0000000..1b1d981 --- /dev/null +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html @@ -0,0 +1,43 @@ + + + {{ 'rulenode.message-type' | translate }} + + + + + + + + + {{ 'rulenode.message-type-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts new file mode 100644 index 0000000..fa005d4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts @@ -0,0 +1,179 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, startWith, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MessageType, messageTypeNames } from '@shared/models/rule-node.models'; +import { objectValues } from '@core/utils'; + +@Component({ + selector: 'tb-message-type-autocomplete', + templateUrl: './message-type-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MessageTypeAutocompleteComponent), + multi: true + }] +}) +export class MessageTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + messageTypeFormGroup: FormGroup; + + modelValue: string | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('messageTypeInput', {static: true}) messageTypeInput: ElementRef; + + filteredMessageTypes: Observable>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private fb: FormBuilder) { + this.messageTypeFormGroup = this.fb.group({ + messageType: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredMessageTypes = this.messageTypeFormGroup.get('messageType').valueChanges + .pipe( + tap(value => { + this.updateView(value); + }), + startWith(''), + map(value => value ? value : ''), + mergeMap(messageType => this.fetchMessageTypes(messageType) ) + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.messageTypeFormGroup.disable({emitEvent: false}); + } else { + this.messageTypeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + this.modelValue = value; + let res: MessageType | string = null; + if (value) { + if (objectValues(MessageType).includes(value)) { + res = MessageType[value]; + } else { + res = value; + } + } + this.messageTypeFormGroup.get('messageType').patchValue(res, {emitEvent: false}); + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.messageTypeFormGroup.get('messageType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: MessageType | string | null) { + let res: string = null; + if (value) { + if (objectValues(MessageType).includes(value)) { + res = MessageType[value]; + } else { + res = value; + } + } + if (this.modelValue !== res) { + this.modelValue = res; + this.propagateChange(this.modelValue); + } + } + + displayMessageTypeFn(messageType?: MessageType | string): string | undefined { + if (messageType) { + if (objectValues(MessageType).includes(messageType)) { + return messageTypeNames.get(MessageType[messageType]); + } else { + return messageType; + } + } + return undefined; + } + + fetchMessageTypes(searchText?: string): Observable> { + this.searchText = searchText; + const result: Array = []; + messageTypeNames.forEach((value, key) => { + if (value.toUpperCase().includes(searchText.toUpperCase())) { + result.push(key); + } + }); + if (result.length) { + return of(result); + } else { + return of([searchText]); + } + } + + clear() { + this.messageTypeFormGroup.get('messageType').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.messageTypeInput.nativeElement.blur(); + this.messageTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/multiple-image-input.component.html b/ui-ngx/src/app/shared/components/multiple-image-input.component.html new file mode 100644 index 0000000..baeacac --- /dev/null +++ b/ui-ngx/src/app/shared/components/multiple-image-input.component.html @@ -0,0 +1,69 @@ + +
    + + +
    +
    +
    +
    + {{ 'image-input.images' | translate }} [{{ $index }}] +
    +
    + drag_indicator +
    +
    + +
    +
    + +
    +
    +
    +
    {{ 'image-input.no-images' | translate }}
    +
    +
    +
    + cloud_upload + image-input.drop-images-or + + +
    +
    +
    +
    +
    dashboard.maximum-upload-file-size
    +
    diff --git a/ui-ngx/src/app/shared/components/multiple-image-input.component.scss b/ui-ngx/src/app/shared/components/multiple-image-input.component.scss new file mode 100644 index 0000000..c826b6e --- /dev/null +++ b/ui-ngx/src/app/shared/components/multiple-image-input.component.scss @@ -0,0 +1,169 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../scss/constants"; + +$imagesContainerHeight: 106px !default; +$containerHeight: 120px !default; +$previewSize: 64px !default; + +.image-card { + margin-bottom: 8px; + &.image-dnd-placeholder { + height: 82px; + width: 146px; + border: 2px dashed rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + &.image-dragging { + display: none !important; + } +} + + +.image-title { + font-size: 11px; + font-weight: 400; + line-height: 14px; + color: rgba(0, 0, 0, 0.6); + padding-bottom: 4px; +} + +.image-content-container { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + height: $previewSize; +} + +.tb-image-preview { + width: auto; + max-width: $previewSize - 2px; + height: auto; + max-height: $previewSize - 2px; +} + +.tb-image-preview-container { + position: relative; + width: $previewSize; + height: $previewSize; + margin-top: -1px; + margin-bottom: -1px; + border: 1px solid rgba(0, 0, 0, 0.54); + + .tb-image-preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +.tb-image-action-container { + position: relative; + height: $previewSize - 2px; + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; +} + +:host { + + .tb-container { + margin-top: 0; + label.tb-title { + display: block; + padding-bottom: 8px; + } + } + + .tb-image-select-container { + position: relative; + width: 100%; + } + + .images-container { + padding: 12px 12px 4px; + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + flex-wrap: wrap; + margin-bottom: 8px; + &.no-images { + height: $imagesContainerHeight; + padding-bottom: 12px; + align-items: center; + justify-content: center; + } + } + + .no-images-prompt { + font-size: 18px; + color: rgba(0, 0, 0, 0.54); + } + + .file-input { + display: none; + } + + .tb-flow-drop { + position: relative; + height: $containerHeight; + overflow: hidden; + border: 2px dashed rgba(0, 0, 0, 0.2); + border-radius: 4px; + box-sizing: border-box; + + &.float-left { + float: left; + } + + .upload-label { + width: 100%; + height: 100%; + padding: 0 16px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: 16px; + color: rgba(0, 0, 0, 0.54); + text-align: center; + .mat-icon { + margin-right: 17px; + } + } + } + + .tb-hint{ + margin-top: 8px; + } +} + +:host ::ng-deep { + button.browse-file { + padding: 0; + font-size: 16px; + span.mat-button-wrapper { + display: block; + label { + display: block; + cursor: pointer; + padding: 0 16px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/multiple-image-input.component.ts b/ui-ngx/src/app/shared/components/multiple-image-input.component.ts new file mode 100644 index 0000000..270810f --- /dev/null +++ b/ui-ngx/src/app/shared/components/multiple-image-input.component.ts @@ -0,0 +1,210 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, ChangeDetectorRef, Component, forwardRef, Input, OnDestroy, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ControlValueAccessor, FormArray, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DropDirective, FlowDirective } from '@flowjs/ngx-flow'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { UtilsService } from '@core/services/utils.service'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { DndDropEvent } from 'ngx-drag-drop'; +import { isUndefined } from '@core/utils'; + +@Component({ + selector: 'tb-multiple-image-input', + templateUrl: './multiple-image-input.component.html', + styleUrls: ['./multiple-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MultipleImageInputComponent), + multi: true + } + ] +}) +export class MultipleImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { + + @Input() + label: string; + + @Input() + maxSizeByte: number; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + } + } + + @Input() + disabled: boolean; + + @Input() + inputId = this.utils.guid(); + + imageUrls: string[]; + safeImageUrls: SafeUrl[]; + + dragIndex: number; + + @ViewChild('flow', {static: true}) + flow: FlowDirective; + + @ViewChild('flowDrop', {static: true}) + flowDrop: DropDirective; + + autoUploadSubscription: Subscription; + + private propagateChange = null; + + private viewInited = false; + + constructor(protected store: Store, + private utils: UtilsService, + private sanitizer: DomSanitizer, + private dialog: DialogService, + private translate: TranslateService, + private fileSize: FileSizePipe, + private cd: ChangeDetectorRef) { + super(store); + } + + ngAfterViewInit() { + this.autoUploadSubscription = this.flow.events$.subscribe(event => { + if (event.type === 'filesAdded') { + const readers = []; + (event.event[0] as flowjs.FlowFile[]).forEach(file => { + readers.push(this.readImageUrl(file)); + }); + if (readers.length) { + Promise.all(readers).then((files) => { + files = files.filter(file => file.imageUrl != null || file.safeImageUrl != null); + this.imageUrls = this.imageUrls.concat(files.map(content => content.imageUrl)); + this.safeImageUrls = this.safeImageUrls.concat(files.map(content => content.safeImageUrl)); + this.updateModel(); + }); + } + } + }); + if (this.disabled) { + this.flowDrop.disable(); + } else { + this.flowDrop.enable(); + } + this.viewInited = true; + } + + private readImageUrl(file: flowjs.FlowFile): Promise { + return new Promise((resolve) => { + if (this.maxSizeByte && this.maxSizeByte < file.size) { + resolve({imageUrl: null, safeImageUrl: null}); + } + const reader = new FileReader(); + reader.onload = () => { + let imageUrl = null; + let safeImageUrl = null; + if (typeof reader.result === 'string' && reader.result.startsWith('data:image/')) { + imageUrl = reader.result; + if (imageUrl && imageUrl.length > 0) { + safeImageUrl = this.sanitizer.bypassSecurityTrustUrl(imageUrl); + } + } + resolve({imageUrl, safeImageUrl}); + }; + reader.onerror = () => { + resolve({imageUrl: null, safeImageUrl: null}); + }; + reader.readAsDataURL(file.file); + }); + } + + ngOnDestroy() { + this.autoUploadSubscription.unsubscribe(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.viewInited) { + if (this.disabled) { + this.flowDrop.disable(); + } else { + this.flowDrop.enable(); + } + } + } + + writeValue(value: string[]): void { + this.imageUrls = value || []; + this.safeImageUrls = this.imageUrls.map(imageUrl => this.sanitizer.bypassSecurityTrustUrl(imageUrl)); + } + + private updateModel() { + this.cd.markForCheck(); + this.propagateChange(this.imageUrls); + } + + clearImage(index: number) { + this.imageUrls.splice(index, 1); + this.safeImageUrls.splice(index, 1); + this.updateModel(); + } + + imageDragStart(index: number) { + setTimeout(() => { + this.dragIndex = index; + this.cd.markForCheck(); + }); + } + + imageDragEnd() { + this.dragIndex = -1; + this.cd.markForCheck(); + } + + imageDrop(event: DndDropEvent) { + let index = event.index; + if (isUndefined(index)) { + index = this.safeImageUrls.length; + } + moveItemInArray(this.imageUrls, this.dragIndex, index); + moveItemInArray(this.safeImageUrls, this.dragIndex, index); + this.dragIndex = -1; + this.updateModel(); + } +} diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.html b/ui-ngx/src/app/shared/components/nav-tree.component.html new file mode 100644 index 0000000..d130e1e --- /dev/null +++ b/ui-ngx/src/app/shared/components/nav-tree.component.html @@ -0,0 +1,18 @@ + +
    diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.scss b/ui-ngx/src/app/shared/components/nav-tree.component.scss new file mode 100644 index 0000000..46bf0d4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/nav-tree.component.scss @@ -0,0 +1,391 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-nav-tree-container { + padding: 15px; + font-family: Roboto, "Helvetica Neue", sans-serif; + + &.jstree-proton, &.jstree-proton-small, &.jstree-proton-large { + .jstree-node, + .jstree-icon { + background-image: url("../../../assets/jstree/tb32px.png"); + } + + .jstree-last { + background: transparent; + } + + .jstree-themeicon-custom { + background-image: none; + } + } + + &.jstree-proton { + + .jstree-themeicon-custom { + &.material-icons { + font-size: 18px; + } + } + + .jstree-anchor { + font-size: 16px; + } + + .jstree-file { + background: url("../../../assets/jstree/tb32px.png") -101px -69px no-repeat; + } + + .jstree-folder { + background: url("../../../assets/jstree/tb32px.png") -261px -5px no-repeat; + } + } + + &.jstree-proton-small { + + .jstree-themeicon-custom { + &.material-icons { + font-size: 14px; + } + } + + .jstree-anchor { + font-size: 14px; + } + + .jstree-file { + background: url("../../../assets/jstree/tb32px.png") -103px -71px no-repeat; + } + + .jstree-folder { + background: url("../../../assets/jstree/tb32px.png") -263px -7px no-repeat; + } + } + + &.jstree-proton-large { + + .jstree-themeicon-custom { + &.material-icons { + font-size: 24px; + } + } + + .jstree-anchor { + font-size: 20px; + } + + .jstree-file { + background: url("../../../assets/jstree/tb32px.png") -96px -64px no-repeat; + } + + .jstree-folder { + background: url("../../../assets/jstree/tb32px.png") -256px 0px no-repeat; + } + } + + a { + border-bottom: none; + + i.jstree-themeicon-custom { + &.tb-user-group { + &::before { + content: "account_circle"; + } + } + + &.tb-customer-group { + &::before { + content: "supervisor_account"; + } + } + + &.tb-asset-group { + &::before { + content: "domain"; + } + } + + &.tb-device-group { + &::before { + content: "devices_other"; + } + } + + &.tb-entity-view-group { + &::before { + content: "view_quilt"; + } + } + + &.tb-dashboard-group { + &::before { + content: "dashboard"; + } + } + + &.tb-customer { + &::before { + content: "supervisor_account"; + } + } + } + } +} + +#jstree-dnd { + &.jstree-proton, &.jstree-proton-small, &.jstree-proton-large { + .jstree-ok, + .jstree-er { + background-image: url("../../../assets/jstree/tb32px.png"); + } + } +} + +@media (max-width: 768px) { + .tb-nav-tree-container { + &.jstree-proton-responsive { + .jstree-node, + .jstree-icon, + .jstree-node > .jstree-ocl, + .jstree-themeicon, + .jstree-checkbox { + background-image: url("../../../assets/jstree/tb40px.png"); + background-size: 120px 240px; + } + + .jstree-container-ul { + overflow: visible; + } + + .jstree-themeicon-custom { + background-color: transparent; + background-image: none; + background-position: 0 0; + + &.material-icons { + margin: 0; + font-size: 24px; + } + } + + .jstree-node, + .jstree-leaf > .jstree-ocl { + background: 0 0; + } + + .jstree-node { + min-width: 40px; + min-height: 40px; + margin-left: 40px; + line-height: 40px; + white-space: nowrap; + background-repeat: repeat-y; + background-position: -80px 0; + } + + .jstree-last { + background: 0 0; + } + + .jstree-anchor { + height: 40px; + font-size: 1.1em; + font-weight: 700; + line-height: 40px; + text-shadow: 1px 1px #fff; + } + + .jstree-icon, + .jstree-icon:empty { + width: 40px; + height: 40px; + line-height: 40px; + } + + > { + .jstree-container-ul > .jstree-node { + margin-right: 0; + margin-left: 0; + } + } + + .jstree-ocl, + .jstree-themeicon, + .jstree-checkbox { + background-size: 120px 240px; + } + + .jstree-leaf > .jstree-ocl { + background-position: -40px -120px; + } + + .jstree-last > .jstree-ocl { + background-position: -40px -160px; + } + + .jstree-open > .jstree-ocl { + background-position: 0 0 !important; + } + + .jstree-closed > .jstree-ocl { + background-position: 0 -40px !important; + } + + .jstree-themeicon { + background-position: -40px -40px; + } + + .jstree-file { + background: url("../../../assets/jstree/tb40px.png") 0 -160px no-repeat; + background-size: 120px 240px; + } + .jstree-folder { + background: url("../../../assets/jstree/tb40px.png") -40px -40px no-repeat; + background-size: 120px 240px; + } + + .jstree-checkbox, + .jstree-checkbox:hover { + background-position: -40px -80px; + } + + &.jstree-checkbox-selection { + .jstree-clicked > .jstree-checkbox, + .jstree-clicked > .jstree-checkbox:hover { + background-position: 0 -80px; + } + } + + .jstree-checked > .jstree-checkbox, + .jstree-checked > .jstree-checkbox:hover { + background-position: 0 -80px; + } + + .jstree-anchor > .jstree-undetermined, + .jstree-anchor > .jstree-undetermined:hover { + background-position: 0 -120px; + } + + .jstree-striped { + background: 0 0; + } + + .jstree-wholerow { + height: 40px; + background: #ebebeb; + border-top: 1px solid rgba(255, 255, 255, .7); + border-bottom: 1px solid rgba(64, 64, 64, .2); + } + + .jstree-wholerow-hovered { + background: #e7f4f9; + } + + .jstree-wholerow-clicked { + background: #beebff; + } + + .jstree-children { + .jstree-last > .jstree-wholerow { + box-shadow: inset 0 -6px 3px -5px #666; + } + + .jstree-open > .jstree-wholerow { + border-top: 0; + box-shadow: inset 0 6px 3px -5px #666; + } + + .jstree-open + .jstree-open { + box-shadow: none; + } + } + + &.jstree-rtl { + .jstree-node { + margin-right: 40px; + margin-left: 0; + } + + .jstree-container-ul > .jstree-node { + margin-right: 0; + } + + .jstree-closed > .jstree-ocl { + background-position: -40px 0 !important; + } + } + } + } +} + +@media (max-width: 768px) { + #jstree-dnd { + &.jstree-dnd-responsive { + .jstree-ok, + .jstree-er { + background-image: url("../../../assets/jstree/tb40px.png"); + background-size: 120px 240px; + } + .jstree-ok { + background-position: 0 -200px; + } + .jstree-er { + background-position: -40px -200px; + } + } + } +} + +.tb-nav-tree .mat-button.tb-active { + font-weight: 500; + background-color: rgba(255, 255, 255, .15); +} + +.tb-nav-tree, +.tb-nav-tree ul { + margin-top: 0; + list-style: none; + + &:first-child { + padding: 0; + } + + li { + .mat-button { + width: 100%; + max-height: 40px; + padding: 0 16px; + margin: 0; + overflow: hidden; + line-height: 40px; + color: inherit; + text-align: left; + text-decoration: none; + text-overflow: ellipsis; + text-transform: none; + text-rendering: optimizeLegibility; + white-space: nowrap; + cursor: pointer; + border-radius: 0; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } +} + diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.ts b/ui-ngx/src/app/shared/components/nav-tree.component.ts new file mode 100644 index 0000000..03b9221 --- /dev/null +++ b/ui-ngx/src/app/shared/components/nav-tree.component.ts @@ -0,0 +1,278 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Input, NgZone, OnInit, ViewEncapsulation } from '@angular/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { deepClone } from '@core/utils'; + +export interface NavTreeNodeState { + disabled?: boolean; + opened?: boolean; + loaded?: boolean; +} + +export interface NavTreeNode { + id: string; + icon?: boolean; + text?: string; + state?: NavTreeNodeState; + children?: NavTreeNode[] | boolean; + data?: any; +} + +export interface NavTreeEditCallbacks { + selectNode?: (id: string) => void; + deselectAll?: () => void; + getNode?: (id: string) => NavTreeNode; + getParentNodeId?: (id: string) => string; + openNode?: (id: string, cb?: () => void) => void; + nodeIsOpen?: (id: string) => boolean; + nodeIsLoaded?: (id: string) => boolean; + refreshNode?: (id: string) => void; + updateNode?: (id: string, newName: string, updatedData?: any) => void; + createNode?: (parentId: string, node: NavTreeNode, pos: number | string) => void; + deleteNode?: (id: string) => void; + disableNode?: (id: string) => void; + enableNode?: (id: string) => void; + setNodeHasChildren?: (id: string, hasChildren: boolean) => void; + search?: (searchText: string) => void; + clearSearch?: () => void; +} + +export type NodesCallback = (nodes: NavTreeNode[]) => void; +export type LoadNodesCallback = (node: NavTreeNode, cb: NodesCallback) => void; +export type NodeSearchCallback = (searchText: string, node: NavTreeNode) => boolean; +export type NodeSelectedCallback = (node: NavTreeNode, event: Event) => void; +export type NodesInsertedCallback = (nodes: string[], parent: string) => void; + +@Component({ + selector: 'tb-nav-tree', + templateUrl: './nav-tree.component.html', + styleUrls: ['./nav-tree.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class NavTreeComponent implements OnInit { + + private enableSearchValue: boolean; + get enableSearch(): boolean { + return this.enableSearchValue; + } + @Input() + set enableSearch(value: boolean) { + this.enableSearchValue = coerceBooleanProperty(value); + } + + @Input() + loadNodes: LoadNodesCallback; + + @Input() + searchCallback: NodeSearchCallback; + + @Input() + onNodeSelected: NodeSelectedCallback; + + @Input() + onNodesInserted: NodesInsertedCallback; + + @Input() + editCallbacks: NavTreeEditCallbacks; + + private treeElement: JSTree; + + constructor(private elementRef: ElementRef, + private ngZone: NgZone) { + } + + ngOnInit(): void { + this.initTree(); + } + + private initTree() { + + const loadNodes: LoadNodesCallback = (node, cb) => { + const outCb = (nodes: NavTreeNode[]) => { + const copied: NavTreeNode[] = []; + if (nodes) { + nodes.forEach((n) => { + copied.push(deepClone(n, ['data'])); + }); + } + cb(copied); + }; + this.ngZone.runOutsideAngular(() => { + this.loadNodes(node, outCb); + }); + }; + + const config: JSTreeStaticDefaults = { + core: { + worker: false, + multiple: false, + check_callback: true, + themes: { name: 'proton', responsive: true }, + data: loadNodes, + error: () => { + console.error('Unexpected jstree error!'); + } + }, + plugins: [] + }; + + if (this.enableSearch) { + config.plugins.push('search'); + config.search = { + ajax: false, + fuzzy: false, + close_opened_onclear: true, + case_sensitive: false, + show_only_matches: true, + show_only_matches_children: false, + search_leaves_only: false, + search_callback: this.searchCallback + }; + } + + import('jstree').then(() => { + + this.treeElement = $('.tb-nav-tree-container', this.elementRef.nativeElement).jstree(config); + + this.treeElement.on('changed.jstree', (e: any, data) => { + const node: NavTreeNode = data.instance.get_selected(true)[0]; + if (this.onNodeSelected) { + this.ngZone.run(() => this.onNodeSelected(node, e as Event)); + } + }); + + this.treeElement.on('model.jstree', (e: any, data) => { + if (this.onNodesInserted) { + this.ngZone.run(() => this.onNodesInserted(data.nodes, data.parent)); + } + }); + + if (this.editCallbacks) { + this.editCallbacks.selectNode = id => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('deselect_all', true); + this.treeElement.jstree('select_node', node); + } + }; + this.editCallbacks.deselectAll = () => { + this.treeElement.jstree('deselect_all'); + }; + this.editCallbacks.getNode = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + return node; + }; + this.editCallbacks.getParentNodeId = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + return this.treeElement.jstree('get_parent', node); + } + }; + this.editCallbacks.openNode = (id, cb) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('open_node', node, cb); + } + }; + this.editCallbacks.nodeIsOpen = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + return this.treeElement.jstree('is_open', node); + } else { + return true; + } + }; + this.editCallbacks.nodeIsLoaded = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + return this.treeElement.jstree('is_loaded', node); + } else { + return true; + } + }; + this.editCallbacks.refreshNode = (id) => { + if (id === '#') { + this.treeElement.jstree('refresh'); + this.treeElement.jstree('redraw'); + } else { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + const opened = this.treeElement.jstree('is_open', node); + this.treeElement.jstree('refresh_node', node); + this.treeElement.jstree('redraw'); + if (node.children && opened/* && !node.children.length*/) { + this.treeElement.jstree('open_node', node); + } + } + } + }; + this.editCallbacks.updateNode = (id, newName, updatedData) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('rename_node', node, newName); + } + if (updatedData && node.data) { + Object.assign(node.data, updatedData); + } + }; + this.editCallbacks.createNode = (parentId, node, pos) => { + const parentNode: NavTreeNode = this.treeElement.jstree('get_node', parentId); + if (parentNode) { + this.treeElement.jstree('create_node', parentNode, node, pos); + } + }; + this.editCallbacks.deleteNode = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('delete_node', node); + } + }; + this.editCallbacks.disableNode = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('disable_node', node); + } + }; + this.editCallbacks.enableNode = (id) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + this.treeElement.jstree('enable_node', node); + } + }; + this.editCallbacks.setNodeHasChildren = (id, hasChildren) => { + const node: NavTreeNode = this.treeElement.jstree('get_node', id); + if (node) { + if (!node.children || (Array.isArray(node.children) && !node.children.length)) { + node.children = hasChildren; + node.state.loaded = !hasChildren; + node.state.opened = false; + this.treeElement.jstree('_node_changed', node.id); + this.treeElement.jstree('redraw'); + } + } + }; + this.editCallbacks.search = (searchText) => { + this.treeElement.jstree('search', searchText); + }; + this.editCallbacks.clearSearch = () => { + this.treeElement.jstree('clear_search'); + }; + } + }); + } +} diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html new file mode 100644 index 0000000..8e82eda --- /dev/null +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html @@ -0,0 +1,60 @@ + + + +
    + {{ displayPackageFn(otaPackageFormGroup.get('packageId').value) }} + + + + + + + +
    +
    + {{ notFoundPackage | translate }} +
    + + + {{ translate.get(notMatchingPackage, + {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + + +
    +
    +
    + + {{ requiredErrorText | translate }} + + {{ hintText | translate }} + diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.scss b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.scss new file mode 100644 index 0000000..2bf0b96 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.scss @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host{ + .mat-icon-button a { + border-bottom: none; + color: inherit; + } +} diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts new file mode 100644 index 0000000..5946154 --- /dev/null +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts @@ -0,0 +1,289 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { merge, Observable, of, Subject } from 'rxjs'; +import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; +import { EntityService } from '@core/http/entity.service'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { OtaPackageInfo, OtaUpdateTranslation, OtaUpdateType } from '@shared/models/ota-package.models'; +import { OtaPackageService } from '@core/http/ota-package.service'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction } from '@shared/models/page/sort-order'; +import { emptyPageData } from '@shared/models/page/page-data'; +import { getEntityDetailsPageURL } from '@core/utils'; + +@Component({ + selector: 'tb-ota-package-autocomplete', + templateUrl: './ota-package-autocomplete.component.html', + styleUrls: ['./ota-package-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OtaPackageAutocompleteComponent), + multi: true + }] +}) +export class OtaPackageAutocompleteComponent implements ControlValueAccessor, OnInit { + + otaPackageFormGroup: FormGroup; + + modelValue: string | EntityId | null; + + private otaUpdateType: OtaUpdateType = OtaUpdateType.FIRMWARE; + + get type(): OtaUpdateType { + return this.otaUpdateType; + } + + @Input() + set type(value ) { + this.otaUpdateType = value ? value : OtaUpdateType.FIRMWARE; + this.reset(); + } + + private deviceProfile: string; + + get deviceProfileId(): string { + return this.deviceProfile; + } + + @Input() + set deviceProfileId(value: string) { + this.deviceProfile = value; + this.reset(); + } + + @Input() + labelText: string; + + @Input() + requiredText: string; + + @Input() + useFullEntityId = false; + + @Input() + showDetailsPageLink = false; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('packageInput', {static: true}) packageInput: ElementRef; + + filteredPackages: Observable>; + + searchText = ''; + packageURL: string; + + private dirty = false; + private cleanFilteredPackages: Subject> = new Subject(); + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + public truncate: TruncatePipe, + private entityService: EntityService, + private otaPackageService: OtaPackageService, + private fb: FormBuilder) { + this.otaPackageFormGroup = this.fb.group({ + packageId: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + const getPackages = this.otaPackageFormGroup.get('packageId').valueChanges + .pipe( + debounceTime(150), + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = this.useFullEntityId ? value.id : value.id.id; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.title) : ''), + switchMap(name => this.fetchPackages(name)), + share() + ); + + this.filteredPackages = merge(this.cleanFilteredPackages, getPackages); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy() { + this.cleanFilteredPackages.complete(); + this.cleanFilteredPackages = null; + } + + getCurrentEntity(): OtaPackageInfo | null { + const currentPackage = this.otaPackageFormGroup.get('packageId').value; + if (currentPackage && typeof currentPackage !== 'string') { + return currentPackage as OtaPackageInfo; + } else { + return null; + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.otaPackageFormGroup.disable({emitEvent: false}); + } else { + this.otaPackageFormGroup.enable({emitEvent: false}); + } + } + + textIsNotEmpty(text: string): boolean { + return (text && text.length > 0); + } + + writeValue(value: string | EntityId | null): void { + this.searchText = ''; + if (value != null && value !== '') { + let packageId = ''; + if (typeof value === 'string') { + packageId = value; + } else if (value.entityType && value.id) { + packageId = value.id; + } + if (packageId !== '') { + this.entityService.getEntity(EntityType.OTA_PACKAGE, packageId, {ignoreLoading: true, ignoreErrors: true}).subscribe( + (entity) => { + this.packageURL = getEntityDetailsPageURL(entity.id.id, EntityType.OTA_PACKAGE); + this.modelValue = this.useFullEntityId ? entity.id : entity.id.id; + this.otaPackageFormGroup.get('packageId').patchValue(entity, {emitEvent: false}); + }, + () => { + this.modelValue = null; + this.otaPackageFormGroup.get('packageId').patchValue('', {emitEvent: false}); + if (value !== null) { + this.propagateChange(this.modelValue); + } + } + ); + } else { + this.modelValue = null; + this.otaPackageFormGroup.get('packageId').patchValue('', {emitEvent: false}); + this.propagateChange(null); + } + } else { + this.modelValue = null; + this.otaPackageFormGroup.get('packageId').patchValue('', {emitEvent: false}); + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.otaPackageFormGroup.get('packageId').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + reset() { + this.cleanFilteredPackages.next([]); + this.otaPackageFormGroup.get('packageId').patchValue('', {emitEvent: false}); + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayPackageFn(packageInfo?: OtaPackageInfo): string | undefined { + return packageInfo ? `${packageInfo.title} (${packageInfo.version})` : undefined; + } + + fetchPackages(searchText?: string): Observable> { + this.searchText = searchText; + const pageLink = new PageLink(50, 0, searchText, { + property: 'title', + direction: Direction.ASC + }); + return this.otaPackageService.getOtaPackagesInfoByDeviceProfileId(pageLink, this.deviceProfileId, this.type, + {ignoreLoading: true}).pipe( + catchError(() => of(emptyPageData())), + map((data) => data && data.data.length ? data.data : null) + ); + } + + clear() { + this.otaPackageFormGroup.get('packageId').patchValue(''); + setTimeout(() => { + this.packageInput.nativeElement.blur(); + this.packageInput.nativeElement.focus(); + }, 0); + } + + get placeholderText(): string { + return this.labelText || OtaUpdateTranslation.get(this.type).label; + } + + get requiredErrorText(): string { + return this.requiredText || OtaUpdateTranslation.get(this.type).required; + } + + get notFoundPackage(): string { + return OtaUpdateTranslation.get(this.type).noFound; + } + + get notMatchingPackage(): string { + return OtaUpdateTranslation.get(this.type).noMatching; + } + + get hintText(): string { + return OtaUpdateTranslation.get(this.type).hint; + } + + packageTitleText(firpackageInfomware: OtaPackageInfo): string { + return `${firpackageInfomware.title} (${firpackageInfomware.version})`; + } +} diff --git a/ui-ngx/src/app/shared/components/page.component.ts b/ui-ngx/src/app/shared/components/page.component.ts new file mode 100644 index 0000000..dbaa7c9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/page.component.ts @@ -0,0 +1,57 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Directive, OnDestroy } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Observable, Subscription } from 'rxjs'; +import { selectIsLoading } from '@core/interceptors/load.selectors'; +import { delay, share } from 'rxjs/operators'; +import { AbstractControl } from '@angular/forms'; + +@Directive() +export abstract class PageComponent implements OnDestroy { + + isLoading$: Observable; + loadingSubscription: Subscription; + disabledOnLoadFormControls: Array = []; + + protected constructor(protected store: Store) { + this.isLoading$ = this.store.pipe(delay(0), select(selectIsLoading), share()); + } + + protected registerDisableOnLoadFormControl(control: AbstractControl) { + this.disabledOnLoadFormControls.push(control); + if (!this.loadingSubscription) { + this.loadingSubscription = this.isLoading$.subscribe((isLoading) => { + for (const formControl of this.disabledOnLoadFormControls) { + if (isLoading) { + formControl.disable({emitEvent: false}); + } else { + formControl.enable({emitEvent: false}); + } + } + }); + } + } + + ngOnDestroy(): void { + if (this.loadingSubscription) { + this.loadingSubscription.unsubscribe(); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/phone-input.component.html b/ui-ngx/src/app/shared/components/phone-input.component.html new file mode 100644 index 0000000..1ca3bbd --- /dev/null +++ b/ui-ngx/src/app/shared/components/phone-input.component.html @@ -0,0 +1,49 @@ + +
    +
    +
    + {{ flagIcon }} + + + + {{country.flag}} + {{' ' + country.name + ' +' + country.dialCode }} + + +
    + + {{ label }} + + + + {{ 'phone-input.phone-input-required' | translate }} + + + {{ 'phone-input.phone-input-validation' | translate }} + + +
    +
    diff --git a/ui-ngx/src/app/shared/components/phone-input.component.scss b/ui-ngx/src/app/shared/components/phone-input.component.scss new file mode 100644 index 0000000..c941382 --- /dev/null +++ b/ui-ngx/src/app/shared/components/phone-input.component.scss @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + + .flag-loader { + position: absolute; + top: 50%; + left: 0; + transform: translate(0, -50%); + } + + .phone-input-container { + display: flex; + align-items: center; + + .phone-input { + width: 100%; + } + } + + .flags-select-container { + display: inline-block; + position: relative; + width: 50px; + height: 100%; + margin-right: 5px; + } + + .flag-container { + position: absolute; + font-size: 20px; + top: 50%; + left: 0; + transform: translate(0, -50%); + } + .country-select { + width: 45px; + height: 30px; + + .mat-select-trigger { + height: 100%; + width: 100%; + } + + .mat-select-value { + visibility: hidden; + } + } +} diff --git a/ui-ngx/src/app/shared/components/phone-input.component.ts b/ui-ngx/src/app/shared/components/phone-input.component.ts new file mode 100644 index 0000000..83d0fb1 --- /dev/null +++ b/ui-ngx/src/app/shared/components/phone-input.component.ts @@ -0,0 +1,286 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { Country, CountryData } from '@shared/models/country.models'; +import examples from 'libphonenumber-js/examples.mobile.json'; +import { Subscription } from 'rxjs'; +import { FloatLabelType, MatFormFieldAppearance } from '@angular/material/form-field/form-field'; + +@Component({ + selector: 'tb-phone-input', + templateUrl: './phone-input.component.html', + styleUrls: ['./phone-input.component.scss'], + providers: [ + CountryData, + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true + } + ] +}) +export class PhoneInputComponent implements OnInit, ControlValueAccessor, Validator { + + @Input() + disabled: boolean; + + @Input() + defaultCountry = 'US'; + + @Input() + enableFlagsSelect = true; + + @Input() + required = true; + + @Input() + floatLabel: FloatLabelType = 'auto'; + + @Input() + appearance: MatFormFieldAppearance = 'legacy'; + + @Input() + placeholder; + + @Input() + label = this.translate.instant('phone-input.phone-input-label'); + + get showFlagSelect(): boolean { + return this.enableFlagsSelect && !this.isLegacy; + } + + allCountries: Array = this.countryCodeData.allCountries; + phonePlaceholder = '+12015550123'; + flagIcon: string; + phoneFormGroup: FormGroup; + + private isLoading = true; + get isLoad(): boolean { + return this.isLoading; + } + + set isLoad(value) { + if (this.isLoading) { + this.isLoading = value; + if (this.defaultCountry) { + this.getFlagAndPhoneNumberData(this.defaultCountry); + } + if (this.phoneFormGroup && this.phoneFormGroup.get('phoneNumber').value) { + const parsedPhoneNumber = this.parsePhoneNumberFromString(this.phoneFormGroup.get('phoneNumber').value); + this.defineCountryFromNumber(parsedPhoneNumber); + } + } + } + + private isLegacy = false; + private getExampleNumber; + private parsePhoneNumberFromString; + private baseCode = 127397; + private countryCallingCode = '+'; + private modelValue: string; + private changeSubscriptions: Subscription[] = []; + private validators: ValidatorFn[] = [this.validatePhoneNumber()]; + + private propagateChange = (v: any) => { }; + + constructor(private translate: TranslateService, + private fb: FormBuilder, + private countryCodeData: CountryData) { + import('libphonenumber-js/max').then((libphonenubmer) => { + this.parsePhoneNumberFromString = libphonenubmer.parsePhoneNumberFromString; + this.getExampleNumber = libphonenubmer.getExampleNumber; + }).then(() => this.isLoad = false); + } + + ngOnInit(): void { + if (this.required) { + this.validators.push(Validators.required); + } + this.phoneFormGroup = this.fb.group({ + country: [null, []], + phoneNumber: [null, this.validators] + }); + + this.changeSubscriptions.push(this.phoneFormGroup.get('phoneNumber').valueChanges.subscribe(value => { + let parsedPhoneNumber = null; + if (value && this.parsePhoneNumberFromString) { + parsedPhoneNumber = this.parsePhoneNumberFromString(value); + this.defineCountryFromNumber(parsedPhoneNumber); + } + this.updateModel(parsedPhoneNumber); + })); + + this.changeSubscriptions.push(this.phoneFormGroup.get('country').valueChanges.subscribe(value => { + if (value) { + const code = this.countryCallingCode; + this.getFlagAndPhoneNumberData(value); + let phoneNumber = this.phoneFormGroup.get('phoneNumber').value; + if (phoneNumber) { + if (code !== '+' && code !== this.countryCallingCode && phoneNumber.includes(code)) { + phoneNumber = phoneNumber.replace(code, this.countryCallingCode); + this.phoneFormGroup.get('phoneNumber').patchValue(phoneNumber); + } + } + } + })); + } + + ngOnDestroy() { + for (const subscription of this.changeSubscriptions) { + subscription.unsubscribe(); + } + } + + focus() { + const phoneNumber = this.phoneFormGroup.get('phoneNumber'); + if (!phoneNumber.value) { + phoneNumber.patchValue(this.countryCallingCode, {emitEvent: true}); + } + } + + private getFlagAndPhoneNumberData(country) { + if (this.enableFlagsSelect) { + this.flagIcon = this.getFlagIcon(country); + } + this.getPhoneNumberData(country); + } + + private getPhoneNumberData(country): void { + if (this.getExampleNumber) { + const phoneData = this.getExampleNumber(country, examples); + this.phonePlaceholder = phoneData.number; + this.countryCallingCode = `+${this.enableFlagsSelect ? phoneData.countryCallingCode : ''}`; + } + } + + private getFlagIcon(countryCode) { + return String.fromCodePoint(...countryCode.split('').map(country => this.baseCode + country.charCodeAt(0))); + } + + private updateModelValueInFormat(parsedPhoneNumber: any) { + this.modelValue = parsedPhoneNumber.format('E.164'); + } + + validatePhoneNumber(): ValidatorFn { + return (c: FormControl) => { + const phoneNumber = c.value; + if (phoneNumber && this.parsePhoneNumberFromString) { + const parsedPhoneNumber = this.parsePhoneNumberFromString(phoneNumber); + if (!parsedPhoneNumber?.isValid() || !parsedPhoneNumber?.isPossible()) { + return { + invalidPhoneNumber: { + valid: false + } + }; + } + } + return null; + }; + } + + private defineCountryFromNumber(parsedPhoneNumber) { + const country = this.phoneFormGroup.get('country').value; + if (parsedPhoneNumber?.country && parsedPhoneNumber?.country !== country) { + this.phoneFormGroup.get('country').patchValue(parsedPhoneNumber.country, {emitEvent: true}); + } + } + + validate(): ValidationErrors | null { + const phoneNumber = this.phoneFormGroup.get('phoneNumber'); + return phoneNumber.valid || this.countryCallingCode === phoneNumber.value ? null : { + phoneFormGroup: false + }; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.phoneFormGroup.disable({emitEvent: false}); + } else { + this.phoneFormGroup.enable({emitEvent: false}); + } + } + + writeValue(phoneNumber): void { + this.modelValue = phoneNumber; + let country = this.defaultCountry; + if (this.parsePhoneNumberFromString) { + this.phoneFormGroup.get('phoneNumber').clearValidators(); + this.phoneFormGroup.get('phoneNumber').setValidators(this.validators); + if (phoneNumber) { + const parsedPhoneNumber = this.parsePhoneNumberFromString(phoneNumber); + if (parsedPhoneNumber?.isValid() && parsedPhoneNumber?.isPossible()) { + country = parsedPhoneNumber?.country || this.defaultCountry; + this.updateModelValueInFormat(parsedPhoneNumber); + this.isLegacy = false; + } else { + const validators = [Validators.maxLength(255)]; + if (this.required) { + validators.push(Validators.required); + } + this.phoneFormGroup.get('phoneNumber').setValidators(validators); + this.isLegacy = true; + } + } else { + this.isLegacy = false; + } + this.phoneFormGroup.updateValueAndValidity({emitEvent: false}); + this.getFlagAndPhoneNumberData(country); + } + this.phoneFormGroup.reset({phoneNumber, country}, {emitEvent: false}); + } + + private updateModel(parsedPhoneNumber?) { + const phoneNumber = this.phoneFormGroup.get('phoneNumber'); + if (phoneNumber.value === '+' || phoneNumber.value === this.countryCallingCode) { + this.propagateChange(null); + } else if (phoneNumber.valid) { + this.modelValue = phoneNumber.value; + if (parsedPhoneNumber) { + this.updateModelValueInFormat(parsedPhoneNumber); + } + this.propagateChange(this.modelValue); + } else { + this.propagateChange(null); + } + } +} diff --git a/ui-ngx/src/app/shared/components/popover.component.scss b/ui-ngx/src/app/shared/components/popover.component.scss new file mode 100644 index 0000000..bc06c50 --- /dev/null +++ b/ui-ngx/src/app/shared/components/popover.component.scss @@ -0,0 +1,215 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../scss/mixins'; + +$popover-arrow-width: 6; +$popover-distance: $popover-arrow-width + 4; +$zindex-popover: 1030; +$popover-bg: #fff; +$border-radius-base: 6px; +$popover-close-button-size: 30px; +$box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), +0 9px 28px 8px rgba(0, 0, 0, 0.05), 0 0 8px rgba(0, 0, 0, 0.15); + +.tb-popover { + z-index: $zindex-popover; + font-weight: normal; + white-space: normal; + text-align: left; + cursor: auto; + user-select: text; + max-width: 100vw; + max-height: 100vh; + + &::after { + position: absolute; + background: rgba(255, 255, 255, 0.01); + content: ''; + } + + &-hidden { + display: none; + } + + // Offset the popover to account for the popover arrow + &-placement-top, + &-placement-topLeft, + &-placement-topRight { + padding-bottom: $popover-distance + px; + } + + &-placement-right, + &-placement-rightTop, + &-placement-rightBottom { + padding-left: $popover-distance + px; + } + + &-placement-bottom, + &-placement-bottomLeft, + &-placement-bottomRight { + padding-top: $popover-distance + px; + } + + &-placement-left, + &-placement-leftTop, + &-placement-leftBottom { + padding-right: $popover-distance + px; + } + + &-inner { + background-color: $popover-bg; + background-clip: padding-box; + border-radius: $border-radius-base; + box-shadow: $box-shadow-base; + overflow: hidden; + position: relative; + width: 100%; + height: 100%; + } + + &-close-button { + cursor: pointer; + position: absolute; + top: 6px; + right: 6px; + padding: 0; + border: none; + text-align: center; + width: $popover-close-button-size; + height: $popover-close-button-size; + font-size: $popover-close-button-size; + color: #8e8e8e; + text-decoration: none; + font-weight: bold; + background: transparent; + z-index: 10; + &:hover { + color: #313131; + } + } + + &-content { + width: 100%; + height: 100%; + } + + &-inner-content { + padding: 12px 16px; + color: rgba(0, 0, 0, 0.85); + overflow: auto; + width: 100%; + height: 100%; + } + + // Arrows + // .popover-arrow is outer, .popover-arrow:after is inner + + &-arrow { + position: absolute; + display: block; + width: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px; + height: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px; + background: transparent; + border-style: solid; + border-width: (sqrt($popover-arrow-width * $popover-arrow-width * 2) * 0.5) + px; + transform: rotate(45deg); + z-index: 10; + } + + &-placement-top > &-content > &-arrow, + &-placement-topLeft > &-content > &-arrow, + &-placement-topRight > &-content > &-arrow { + bottom: $popover-distance - $popover-arrow-width + 2.2 + px; + border-top-color: transparent; + border-right-color: $popover-bg; + border-bottom-color: $popover-bg; + border-left-color: transparent; + box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.07); + } + &-placement-top > &-content > &-arrow { + left: 50%; + transform: translateX(-50%) rotate(45deg); + } + &-placement-topLeft > &-content > &-arrow { + left: 16px; + } + &-placement-topRight > &-content > &-arrow { + right: 16px; + } + + &-placement-right > &-content > &-arrow, + &-placement-rightTop > &-content > &-arrow, + &-placement-rightBottom > &-content > &-arrow { + left: $popover-distance - $popover-arrow-width + 2 + px; + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: $popover-bg; + border-left-color: $popover-bg; + box-shadow: -3px 3px 7px rgba(0, 0, 0, 0.07); + } + &-placement-right > &-content > &-arrow { + top: 50%; + transform: translateY(-50%) rotate(45deg); + } + &-placement-rightTop > &-content > &-arrow { + top: 12px; + } + &-placement-rightBottom > &-content > &-arrow { + bottom: 12px; + } + + &-placement-bottom > &-content > &-arrow, + &-placement-bottomLeft > &-content > &-arrow, + &-placement-bottomRight > &-content > &-arrow { + top: $popover-distance - $popover-arrow-width + 2 + px; + border-top-color: $popover-bg; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: $popover-bg; + box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.06); + } + &-placement-bottom > &-content > &-arrow { + left: 50%; + transform: translateX(-50%) rotate(45deg); + } + &-placement-bottomLeft > &-content > &-arrow { + left: 16px; + } + &-placement-bottomRight > &-content > &-arrow { + right: 16px; + } + + &-placement-left > &-content > &-arrow, + &-placement-leftTop > &-content > &-arrow, + &-placement-leftBottom > &-content > &-arrow { + right: $popover-distance - $popover-arrow-width + 2 + px; + border-top-color: $popover-bg; + border-right-color: $popover-bg; + border-bottom-color: transparent; + border-left-color: transparent; + box-shadow: 3px -3px 7px rgba(0, 0, 0, 0.07); + } + &-placement-left > &-content > &-arrow { + top: 50%; + transform: translateY(-50%) rotate(45deg); + } + &-placement-leftTop > &-content > &-arrow { + top: 12px; + } + &-placement-leftBottom > &-content > &-arrow { + bottom: 12px; + } +} diff --git a/ui-ngx/src/app/shared/components/popover.component.ts b/ui-ngx/src/app/shared/components/popover.component.ts new file mode 100644 index 0000000..e2eb71a --- /dev/null +++ b/ui-ngx/src/app/shared/components/popover.component.ts @@ -0,0 +1,637 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + Optional, + Output, + Renderer2, + SimpleChanges, + TemplateRef, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { Direction, Directionality } from '@angular/cdk/bidi'; +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + ConnectedOverlayPositionChange, + ConnectionPositionPair +} from '@angular/cdk/overlay'; +import { Subject, Subscription } from 'rxjs'; +import { + DEFAULT_POPOVER_POSITIONS, + getPlacementName, + popoverMotion, + PopoverPlacement, + POSITION_MAP, + PropertyMapping +} from '@shared/components/popover.models'; +import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; +import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils'; +import { animate, AnimationBuilder, AnimationMetadata, style } from '@angular/animations'; + +export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null; + +@Directive({ + selector: '[tb-popover]', + exportAs: 'tbPopover', + host: { + '[class.tb-popover-open]': 'visible' + } +}) +export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { + + // tslint:disable:no-input-rename + @Input('tbPopoverContent') content?: string | TemplateRef; + @Input('tbPopoverTrigger') trigger?: TbPopoverTrigger = 'hover'; + @Input('tbPopoverPlacement') placement?: string | string[] = 'top'; + @Input('tbPopoverOrigin') origin?: ElementRef; + @Input('tbPopoverVisible') visible?: boolean; + @Input('tbPopoverMouseEnterDelay') mouseEnterDelay?: number; + @Input('tbPopoverMouseLeaveDelay') mouseLeaveDelay?: number; + @Input('tbPopoverOverlayClassName') overlayClassName?: string; + @Input('tbPopoverOverlayStyle') overlayStyle?: { [klass: string]: any }; + @Input() tbPopoverBackdrop = false; + + // tslint:disable-next-line:no-output-rename + @Output('tbPopoverVisibleChange') readonly visibleChange = new EventEmitter(); + + componentFactory: ComponentFactory = this.resolver.resolveComponentFactory(TbPopoverComponent); + component?: TbPopoverComponent; + + private readonly destroy$ = new Subject(); + private readonly triggerDisposables: Array<() => void> = []; + private delayTimer?; + private internalVisible = false; + + constructor( + private elementRef: ElementRef, + private hostView: ViewContainerRef, + private resolver: ComponentFactoryResolver, + private renderer: Renderer2 + ) {} + + ngOnChanges(changes: SimpleChanges): void { + const { trigger } = changes; + + if (trigger && !trigger.isFirstChange()) { + this.registerTriggers(); + } + + if (this.component) { + this.updatePropertiesByChanges(changes); + } + } + + ngAfterViewInit(): void { + this.registerTriggers(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.clearTogglingTimer(); + this.removeTriggerListeners(); + } + + show(): void { + if (!this.component) { + this.createComponent(); + } + this.component?.show(); + } + + hide(): void { + this.component?.hide(); + } + + updatePosition(): void { + if (this.component) { + this.component.updatePosition(); + } + } + + private createComponent(): void { + const componentRef = this.hostView.createComponent(this.componentFactory); + + this.component = componentRef.instance; + + this.renderer.removeChild( + this.renderer.parentNode(this.elementRef.nativeElement), + componentRef.location.nativeElement + ); + this.component.setOverlayOrigin({ elementRef: this.origin || this.elementRef }); + + this.initProperties(); + + this.component.tbVisibleChange + .pipe(distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((visible: boolean) => { + this.internalVisible = visible; + this.visibleChange.emit(visible); + }); + } + + private registerTriggers(): void { + // When the method gets invoked, all properties has been synced to the dynamic component. + // After removing the old API, we can just check the directive's own `nzTrigger`. + const el = this.elementRef.nativeElement; + const trigger = this.trigger; + + this.removeTriggerListeners(); + + if (trigger === 'hover') { + let overlayElement: HTMLElement; + this.triggerDisposables.push( + this.renderer.listen(el, 'mouseenter', () => { + this.delayEnterLeave(true, true, this.mouseEnterDelay); + }) + ); + this.triggerDisposables.push( + this.renderer.listen(el, 'mouseleave', () => { + this.delayEnterLeave(true, false, this.mouseLeaveDelay); + if (this.component?.overlay.overlayRef && !overlayElement) { + overlayElement = this.component.overlay.overlayRef.overlayElement; + this.triggerDisposables.push( + this.renderer.listen(overlayElement, 'mouseenter', () => { + this.delayEnterLeave(false, true, this.mouseEnterDelay); + }) + ); + this.triggerDisposables.push( + this.renderer.listen(overlayElement, 'mouseleave', () => { + this.delayEnterLeave(false, false, this.mouseLeaveDelay); + }) + ); + } + }) + ); + } else if (trigger === 'focus') { + this.triggerDisposables.push(this.renderer.listen(el, 'focusin', () => this.show())); + this.triggerDisposables.push(this.renderer.listen(el, 'focusout', () => this.hide())); + } else if (trigger === 'click') { + this.triggerDisposables.push( + this.renderer.listen(el, 'click', (e: MouseEvent) => { + e.preventDefault(); + if (this.component?.visible) { + this.hide(); + } else { + this.show(); + } + }) + ); + } + // Else do nothing because user wants to control the visibility programmatically. + } + + private updatePropertiesByChanges(changes: SimpleChanges): void { + this.updatePropertiesByKeys(Object.keys(changes)); + } + + private updatePropertiesByKeys(keys?: string[]): void { + const mappingProperties: PropertyMapping = { + // common mappings + content: ['tbContent', () => this.content], + trigger: ['tbTrigger', () => this.trigger], + placement: ['tbPlacement', () => this.placement], + visible: ['tbVisible', () => this.visible], + mouseEnterDelay: ['tbMouseEnterDelay', () => this.mouseEnterDelay], + mouseLeaveDelay: ['tbMouseLeaveDelay', () => this.mouseLeaveDelay], + overlayClassName: ['tbOverlayClassName', () => this.overlayClassName], + overlayStyle: ['tbOverlayStyle', () => this.overlayStyle], + tbPopoverBackdrop: ['tbBackdrop', () => this.tbPopoverBackdrop] + }; + + (keys || Object.keys(mappingProperties).filter(key => !key.startsWith('directive'))).forEach( + (property: any) => { + if (mappingProperties[property]) { + const [name, valueFn] = mappingProperties[property]; + this.updateComponentValue(name, valueFn()); + } + } + ); + + this.component?.updateByDirective(); + } + + + private initProperties(): void { + this.updatePropertiesByKeys(); + } + + private updateComponentValue(key: string, value: any): void { + if (typeof value !== 'undefined') { + // @ts-ignore + this.component[key] = value; + } + } + + private delayEnterLeave(isOrigin: boolean, isEnter: boolean, delay: number = -1): void { + if (this.delayTimer) { + this.clearTogglingTimer(); + } else if (delay > 0) { + this.delayTimer = setTimeout(() => { + this.delayTimer = undefined; + isEnter ? this.show() : this.hide(); + }, delay * 1000); + } else { + // `isOrigin` is used due to the tooltip will not hide immediately + // (may caused by the fade-out animation). + isEnter && isOrigin ? this.show() : this.hide(); + } + } + + private removeTriggerListeners(): void { + this.triggerDisposables.forEach(dispose => dispose()); + this.triggerDisposables.length = 0; + } + + private clearTogglingTimer(): void { + if (this.delayTimer) { + clearTimeout(this.delayTimer); + this.delayTimer = undefined; + } + } +} + +@Component({ + selector: 'tb-popover', + exportAs: 'tbPopoverComponent', + animations: [popoverMotion], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + styleUrls: ['./popover.component.scss'], + template: ` + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    + ` +}) +export class TbPopoverComponent implements OnDestroy, OnInit { + + @ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay; + @ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef; + @ViewChild('popover', { static: false }) popover!: ElementRef; + + tbContent: string | TemplateRef | null = null; + tbComponentFactory: ComponentFactory | null = null; + tbComponentRef: ComponentRef | null = null; + tbComponentContext: any; + tbComponentInjector: Injector | null = null; + tbComponentStyle: { [klass: string]: any } = {}; + tbOverlayClassName!: string; + tbOverlayStyle: { [klass: string]: any } = {}; + tbPopoverInnerStyle: { [klass: string]: any } = {}; + tbBackdrop = false; + tbMouseEnterDelay?: number; + tbMouseLeaveDelay?: number; + tbHideOnClickOutside = true; + tbShowCloseButton = true; + + tbAnimationState = 'active'; + + tbVisibleChange = new Subject(); + tbAnimationDone = new Subject(); + tbComponentChange = new Subject>(); + tbDestroy = new Subject(); + + set tbVisible(value: boolean) { + const visible = value; + if (this.visible !== visible) { + this.visible = visible; + this.tbVisibleChange.next(visible); + } + } + + get tbVisible(): boolean { + return this.visible && this.tbAnimationState === 'active'; + } + + visible = false; + + set tbHidden(value: boolean) { + const hidden = value; + if (this.hidden !== hidden) { + this.hidden = hidden; + if (this.hidden) { + this.renderer.setStyle(this.popoverRoot.nativeElement, 'width', this.popoverRoot.nativeElement.offsetWidth + 'px'); + this.renderer.setStyle(this.popoverRoot.nativeElement, 'height', this.popoverRoot.nativeElement.offsetHeight + 'px'); + } else { + setTimeout(() => { + this.renderer.removeStyle(this.popoverRoot.nativeElement, 'width'); + this.renderer.removeStyle(this.popoverRoot.nativeElement, 'height'); + }); + } + this.updateStyles(); + this.cdr.markForCheck(); + } + } + + get tbHidden(): boolean { + return this.hidden; + } + + hidden = false; + lastIsIntersecting = true; + + set tbTrigger(value: TbPopoverTrigger) { + this.trigger = value; + } + + get tbTrigger(): TbPopoverTrigger { + return this.trigger; + } + + protected trigger: TbPopoverTrigger = 'hover'; + + set tbPlacement(value: PopoverPlacement | PopoverPlacement[]) { + if (typeof value === 'string') { + this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS]; + } else { + const preferredPosition = value.map(placement => POSITION_MAP[placement]); + this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS]; + } + } + + get hasBackdrop(): boolean { + return this.tbTrigger === 'click' ? this.tbBackdrop : false; + } + + preferredPlacement: PopoverPlacement = 'top'; + origin!: CdkOverlayOrigin; + public dir: Direction = 'ltr'; + classMap: { [klass: string]: any } = {}; + positions: ConnectionPositionPair[] = [...DEFAULT_POPOVER_POSITIONS]; + private parentScrollSubscription: Subscription = null; + private intersectionObserver = new IntersectionObserver((entries) => { + if (this.lastIsIntersecting !== entries[0].isIntersecting) { + this.lastIsIntersecting = entries[0].isIntersecting; + this.updateStyles(); + this.cdr.markForCheck(); + } + }, {threshold: [0.5]}); + + constructor( + public cdr: ChangeDetectorRef, + private renderer: Renderer2, + private animationBuilder: AnimationBuilder, + @Optional() private directionality: Directionality + ) {} + + ngOnInit(): void { + this.directionality.change?.pipe(takeUntil(this.tbDestroy)).subscribe((direction: Direction) => { + this.dir = direction; + this.cdr.detectChanges(); + }); + + this.dir = this.directionality.value; + } + + ngOnDestroy(): void { + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + this.parentScrollSubscription = null; + } + if (this.origin) { + const el = this.origin.elementRef.nativeElement; + this.intersectionObserver.unobserve(el); + } + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + this.tbVisibleChange.complete(); + this.tbAnimationDone.complete(); + this.tbDestroy.next(); + this.tbDestroy.complete(); + } + + closeButtonClick($event: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + this.hide(); + } + + show(): void { + if (this.tbVisible) { + return; + } + + if (!this.isEmpty()) { + this.tbVisible = true; + this.tbVisibleChange.next(true); + this.cdr.detectChanges(); + } + + if (this.origin && this.overlay && this.overlay.overlayRef) { + if (this.overlay.overlayRef.getDirection() === 'rtl') { + this.overlay.overlayRef.setDirection('ltr'); + } + const el = this.origin.elementRef.nativeElement; + this.parentScrollSubscription = onParentScrollOrWindowResize(el).subscribe(() => { + this.overlay.overlayRef.updatePosition(); + }); + this.intersectionObserver.observe(el); + } + } + + hide(): void { + if (!this.tbVisible) { + return; + } + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + this.parentScrollSubscription = null; + } + if (this.origin) { + const el = this.origin.elementRef.nativeElement; + this.intersectionObserver.unobserve(el); + } + this.tbAnimationState = 'void'; + this.cdr.detectChanges(); + this.tbAnimationDone.pipe(take(1)).subscribe(() => { + this.tbVisible = false; + this.cdr.detectChanges(); + }); + } + + updateByDirective(): void { + this.updateStyles(); + this.cdr.detectChanges(); + + Promise.resolve().then(() => { + this.updatePosition(); + this.updateVisibilityByContent(); + }); + } + + resize(width: string, height: string, animationDurationMs?: number) { + if (animationDurationMs && animationDurationMs > 0) { + const prevWidth = this.popover.nativeElement.offsetWidth; + const prevHeight = this.popover.nativeElement.offsetHeight; + const animationMetadata: AnimationMetadata[] = [style({width: prevWidth + 'px', height: prevHeight + 'px'}), + animate(animationDurationMs + 'ms', style({width, height}))]; + const factory = this.animationBuilder.build(animationMetadata); + const player = factory.create(this.popover.nativeElement); + player.play(); + const resize$ = new ResizeObserver(() => { + this.updatePosition(); + }); + resize$.observe(this.popover.nativeElement); + player.onDone(() => { + player.destroy(); + resize$.disconnect(); + this.setSize(width, height); + }); + } else { + this.setSize(width, height); + } + } + + private setSize(width: string, height: string) { + this.renderer.setStyle(this.popover.nativeElement, 'width', width); + this.renderer.setStyle(this.popover.nativeElement, 'height', height); + this.updatePosition(); + } + + updatePosition(): void { + if (this.origin && this.overlay && this.overlay.overlayRef) { + this.overlay.overlayRef.updatePosition(); + } + } + + onPositionChange(position: ConnectedOverlayPositionChange): void { + this.preferredPlacement = getPlacementName(position); + this.updateStyles(); + this.cdr.detectChanges(); + } + + updateStyles(): void { + this.classMap = { + [`tb-popover-placement-${this.preferredPlacement}`]: true, + ['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting + }; + if (this.tbOverlayClassName) { + this.classMap[this.tbOverlayClassName] = true; + } + } + + setOverlayOrigin(origin: CdkOverlayOrigin): void { + this.origin = origin; + this.cdr.markForCheck(); + } + + onClickOutside(event: MouseEvent): void { + if (this.tbHideOnClickOutside && !this.origin.elementRef.nativeElement.contains(event.target) && this.tbTrigger !== null) { + if (!this.isTopOverlay(event.target as Element)) { + this.hide(); + } + } + } + + onComponentChange(component: ComponentRef) { + this.tbComponentRef = component; + this.tbComponentChange.next(component); + } + + animationDone() { + this.tbAnimationDone.next(); + } + + private isTopOverlay(targetElement: Element): boolean { + const target = $(targetElement); + if (target.parents('.cdk-overlay-container').length) { + let targetOverlayContainerChild: JQuery; + if (target.hasClass('cdk-overlay-backdrop')) { + targetOverlayContainerChild = target; + } else { + targetOverlayContainerChild = target.parents('.cdk-overlay-pane').parent(); + } + const currentOverlayContainerChild = $(this.overlay.overlayRef.overlayElement).parent(); + return targetOverlayContainerChild.index() > currentOverlayContainerChild.index(); + } + return false; + } + + private updateVisibilityByContent(): void { + if (this.isEmpty()) { + this.hide(); + } + } + + private isEmpty(): boolean { + return (this.tbComponentFactory instanceof ComponentFactory || this.tbContent instanceof TemplateRef) + ? false : !isNotEmptyStr(this.tbContent); + } +} diff --git a/ui-ngx/src/app/shared/components/popover.models.ts b/ui-ngx/src/app/shared/components/popover.models.ts new file mode 100644 index 0000000..b9ca5e9 --- /dev/null +++ b/ui-ngx/src/app/shared/components/popover.models.ts @@ -0,0 +1,95 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { animate, AnimationTriggerMetadata, style, transition, trigger } from '@angular/animations'; +import { ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay'; +import { TbPopoverComponent } from '@shared/components/popover.component'; + +export const popoverMotion: AnimationTriggerMetadata = trigger('popoverMotion', [ + transition('void => active', [ + style({ opacity: 0, transform: 'scale(0.8)' }), + animate( + '0.2s cubic-bezier(0.08, 0.82, 0.17, 1)', + style({ + opacity: 1, + transform: 'scale(1)' + }) + ) + ]), + transition('active => void', [ + style({ opacity: 1, transform: 'scale(1)' }), + animate( + '0.2s cubic-bezier(0.78, 0.14, 0.15, 0.86)', + style({ + opacity: 0, + transform: 'scale(0.8)' + }) + ) + ]) +]); + +export const PopoverPlacements = ['top', 'topLeft', 'topRight', 'right', 'rightTop', 'rightBottom', 'bottom', 'bottomLeft', 'bottomRight', 'left', 'leftTop', 'leftBottom'] as const; +type PopoverPlacementTuple = typeof PopoverPlacements; +export type PopoverPlacement = PopoverPlacementTuple[number]; + +export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { + top: new ConnectionPositionPair({ originX: 'center', originY: 'top' }, { overlayX: 'center', overlayY: 'bottom' }), + topLeft: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }), + topRight: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'end', overlayY: 'bottom' }), + right: new ConnectionPositionPair({ originX: 'end', originY: 'center' }, { overlayX: 'start', overlayY: 'center' }), + rightTop: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'start', overlayY: 'top' }), + rightBottom: new ConnectionPositionPair( + { originX: 'end', originY: 'bottom' }, + { overlayX: 'start', overlayY: 'bottom' } + ), + bottom: new ConnectionPositionPair({ originX: 'center', originY: 'bottom' }, { overlayX: 'center', overlayY: 'top' }), + bottomLeft: new ConnectionPositionPair( + { originX: 'start', originY: 'bottom' }, + { overlayX: 'start', overlayY: 'top' } + ), + bottomRight: new ConnectionPositionPair({ originX: 'end', originY: 'bottom' }, { overlayX: 'end', overlayY: 'top' }), + left: new ConnectionPositionPair({ originX: 'start', originY: 'center' }, { overlayX: 'end', overlayY: 'center' }), + leftTop: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'end', overlayY: 'top' }), + leftBottom: new ConnectionPositionPair( + { originX: 'start', originY: 'bottom' }, + { overlayX: 'end', overlayY: 'bottom' } + ) +}; + +export const DEFAULT_POPOVER_POSITIONS = [POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left]; + +export function getPlacementName(position: ConnectedOverlayPositionChange): PopoverPlacement | undefined { + for (const placement in POSITION_MAP) { + if ( + position.connectionPair.originX === POSITION_MAP[placement].originX && + position.connectionPair.originY === POSITION_MAP[placement].originY && + position.connectionPair.overlayX === POSITION_MAP[placement].overlayX && + position.connectionPair.overlayY === POSITION_MAP[placement].overlayY + ) { + return placement as PopoverPlacement; + } + } + return undefined; +} + +export interface PropertyMapping { + [key: string]: [string, () => unknown]; +} + +export interface PopoverWithTrigger { + trigger: Element; + popoverComponent: TbPopoverComponent; +} diff --git a/ui-ngx/src/app/shared/components/popover.service.ts b/ui-ngx/src/app/shared/components/popover.service.ts new file mode 100644 index 0000000..5b5de86 --- /dev/null +++ b/ui-ngx/src/app/shared/components/popover.service.ts @@ -0,0 +1,204 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + ElementRef, + Inject, + Injectable, + Injector, + Renderer2, + Type, + ViewContainerRef +} from '@angular/core'; +import { PopoverPlacement, PopoverWithTrigger } from '@shared/components/popover.models'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { ComponentType } from '@angular/cdk/portal'; +import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens'; + +@Injectable() +export class TbPopoverService { + + private popoverWithTriggers: PopoverWithTrigger[] = []; + + componentFactory: ComponentFactory = this.resolver.resolveComponentFactory(TbPopoverComponent); + + constructor(private resolver: ComponentFactoryResolver, + @Inject(HELP_MARKDOWN_COMPONENT_TOKEN) private helpMarkdownComponent: ComponentType) { + } + + hasPopover(trigger: Element): boolean { + const res = this.findPopoverByTrigger(trigger); + return res !== null; + } + + hidePopover(trigger: Element): boolean { + const component: TbPopoverComponent = this.findPopoverByTrigger(trigger); + if (component && component.tbVisible) { + component.hide(); + return true; + } else { + return false; + } + } + + createPopoverRef(hostView: ViewContainerRef): ComponentRef { + return hostView.createComponent(this.componentFactory); + } + + displayPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, + componentType: Type, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, + injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any, + showCloseButton = true): TbPopoverComponent { + const componentRef = this.createPopoverRef(hostView); + return this.displayPopoverWithComponentRef(componentRef, trigger, renderer, componentType, preferredPlacement, hideOnClickOutside, + injector, context, overlayStyle, popoverStyle, style, showCloseButton); + } + + displayPopoverWithComponentRef(componentRef: ComponentRef, trigger: Element, renderer: Renderer2, + componentType: Type, preferredPlacement: PopoverPlacement = 'top', + hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {}, + popoverStyle: any = {}, style?: any, showCloseButton = true): TbPopoverComponent { + const component = componentRef.instance; + this.popoverWithTriggers.push({ + trigger, + popoverComponent: component + }); + renderer.removeChild( + renderer.parentNode(trigger), + componentRef.location.nativeElement + ); + const originElementRef = new ElementRef(trigger); + component.setOverlayOrigin({ elementRef: originElementRef }); + component.tbPlacement = preferredPlacement; + component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType); + component.tbComponentInjector = injector; + component.tbComponentContext = context; + component.tbOverlayStyle = overlayStyle; + component.tbPopoverInnerStyle = popoverStyle; + component.tbComponentStyle = style; + component.tbHideOnClickOutside = hideOnClickOutside; + component.tbShowCloseButton = showCloseButton; + component.tbVisibleChange.subscribe((visible: boolean) => { + if (!visible) { + componentRef.destroy(); + } + }); + component.tbDestroy.subscribe(() => { + this.removePopoverByComponent(component); + }); + component.show(); + return component; + } + + toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '', + helpContent = '', + visibleFn: (visible: boolean) => void = () => {}, + readyFn: (ready: boolean) => void = () => {}, + preferredPlacement: PopoverPlacement = 'bottom', + overlayStyle: any = {}, helpStyle: any = {}) { + if (this.hasPopover(trigger)) { + this.hidePopover(trigger); + } else { + readyFn(false); + const injector = Injector.create({ + parent: hostView.injector, providers: [] + }); + const componentRef = hostView.createComponent(this.componentFactory); + const component = componentRef.instance; + this.popoverWithTriggers.push({ + trigger, + popoverComponent: component + }); + renderer.removeChild( + renderer.parentNode(trigger), + componentRef.location.nativeElement + ); + const originElementRef = new ElementRef(trigger); + component.tbAnimationState = 'void'; + component.tbOverlayStyle = {...overlayStyle, opacity: '0' }; + component.setOverlayOrigin({ elementRef: originElementRef }); + component.tbPlacement = preferredPlacement; + component.tbComponentFactory = this.resolver.resolveComponentFactory(this.helpMarkdownComponent); + component.tbComponentInjector = injector; + component.tbComponentContext = { + helpId, + helpContent, + style: helpStyle, + visible: true + }; + component.tbHideOnClickOutside = true; + component.tbVisibleChange.subscribe((visible: boolean) => { + if (!visible) { + visibleFn(false); + componentRef.destroy(); + } + }); + component.tbDestroy.subscribe(() => { + this.removePopoverByComponent(component); + }); + const showHelpMarkdownComponent = () => { + component.tbOverlayStyle = {...component.tbOverlayStyle, opacity: '1' }; + component.tbAnimationState = 'active'; + component.updatePosition(); + readyFn(true); + setTimeout(() => { + component.updatePosition(); + }); + }; + const setupHelpMarkdownComponent = (helpMarkdownComponent: any) => { + if (helpMarkdownComponent.isMarkdownReady) { + showHelpMarkdownComponent(); + } else { + helpMarkdownComponent.markdownReady.subscribe(() => { + showHelpMarkdownComponent(); + }); + } + }; + if (component.tbComponentRef) { + setupHelpMarkdownComponent(component.tbComponentRef.instance); + } else { + component.tbComponentChange.subscribe((helpMarkdownComponentRef) => { + setupHelpMarkdownComponent(helpMarkdownComponentRef.instance); + }); + } + component.show(); + visibleFn(true); + } + } + + private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null { + const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger)); + if (res) { + return res.popoverComponent; + } else { + return null; + } + } + + private removePopoverByComponent(component: TbPopoverComponent): void { + const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component); + if (index > -1) { + this.popoverWithTriggers.splice(index, 1); + } + } + + private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean { + return element1 === element2 || element1.contains(element2) || element2.contains(element1); + } +} diff --git a/ui-ngx/src/app/shared/components/protobuf-content.component.html b/ui-ngx/src/app/shared/components/protobuf-content.component.html new file mode 100644 index 0000000..20c504d --- /dev/null +++ b/ui-ngx/src/app/shared/components/protobuf-content.component.html @@ -0,0 +1,43 @@ + +
    +
    + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/protobuf-content.component.scss b/ui-ngx/src/app/shared/components/protobuf-content.component.scss new file mode 100644 index 0000000..4ac76f7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/protobuf-content.component.scss @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + position: relative; + + .fill-height { + height: 100%; + } +} + +.tb-protobuf-content-toolbar { + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + margin: 0; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + background: rgba(220, 220, 220, .35); + &:not(:last-child) { + margin-right: 4px; + } + } +} + +.tb-protobuf-content-panel { + height: 100%; + margin-left: 15px; + border: 1px solid #c0c0c0; + + #tb-protobuf-input { + width: 100%; + min-width: 200px; + min-height: 160px; + height: 100%; + + &:not(.fill-height) { + min-height: 200px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/protobuf-content.component.ts b/ui-ngx/src/app/shared/components/protobuf-content.component.ts new file mode 100644 index 0000000..8f5454f --- /dev/null +++ b/ui-ngx/src/app/shared/components/protobuf-content.component.ts @@ -0,0 +1,196 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Ace } from 'ace-builds'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { guid } from '@core/utils'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getAce } from '@shared/models/ace/ace.models'; +import { beautifyJs } from '@shared/models/beautify.models'; + +@Component({ + selector: 'tb-protobuf-content', + templateUrl: './protobuf-content.component.html', + styleUrls: ['./protobuf-content.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ProtobufContentComponent), + multi: true + } + ] +}) +export class ProtobufContentComponent implements OnInit, ControlValueAccessor, OnDestroy { + + @ViewChild('protobufEditor', {static: true}) + protobufEditorElmRef: ElementRef; + + private protobufEditor: Ace.Editor; + private editorsResizeCaf: CancelAnimationFrame; + private editorResize$: ResizeObserver; + private ignoreChange = false; + + toastTargetId = `protobufContentEditor-${guid()}`; + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + @Input() editorStyle: {[klass: string]: any}; + + @Input() tbPlaceholder: string; + + private readonlyValue: boolean; + get readonly(): boolean { + return this.readonlyValue; + } + @Input() + set readonly(value: boolean) { + this.readonlyValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + contentBody: string; + + errorShowed = false; + + private propagateChange = null; + + constructor(public elementRef: ElementRef, + protected store: Store, + private raf: RafService) { + } + + ngOnInit(): void { + const editorElement = this.protobufEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: `ace/mode/protobuf`, + showGutter: true, + showPrintMargin: false, + readOnly: this.disabled || this.readonly, + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + getAce().subscribe( + (ace) => { + this.protobufEditor = ace.edit(editorElement, editorOptions); + this.protobufEditor.session.setUseWrapMode(true); + this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1); + this.protobufEditor.setReadOnly(this.disabled || this.readonly); + this.protobufEditor.on('change', () => { + if (!this.ignoreChange) { + this.updateView(); + } + }); + this.editorResize$ = new ResizeObserver(() => { + this.onAceEditorResize(); + }); + this.editorResize$.observe(editorElement); + } + ); + } + + ngOnDestroy(): void { + if (this.editorResize$) { + this.editorResize$.disconnect(); + } + if (this.protobufEditor) { + this.protobufEditor.destroy(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.protobufEditor) { + this.protobufEditor.setReadOnly(this.disabled || this.readonly); + } + } + + writeValue(value: string): void { + this.contentBody = value; + if (this.protobufEditor) { + this.ignoreChange = true; + this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1); + this.ignoreChange = false; + } + } + + updateView() { + const editorValue = this.protobufEditor.getValue(); + if (this.contentBody !== editorValue) { + this.contentBody = editorValue; + this.propagateChange(this.contentBody); + } + } + + beautifyProtobuf() { + beautifyJs(this.contentBody, {indent_size: 4, wrap_line_length: 60}).subscribe( + (res) => { + this.protobufEditor.setValue(res ? res : '', -1); + this.updateView(); + } + ); + } + + onFullscreen() { + if (this.protobufEditor) { + setTimeout(() => { + this.protobufEditor.resize(); + }, 0); + } + } + + private onAceEditorResize() { + if (this.editorsResizeCaf) { + this.editorsResizeCaf(); + this.editorsResizeCaf = null; + } + this.editorsResizeCaf = this.raf.raf(() => { + this.protobufEditor.resize(); + this.protobufEditor.renderer.updateFull(); + }); + } + +} diff --git a/ui-ngx/src/app/shared/components/public-api.ts b/ui-ngx/src/app/shared/components/public-api.ts new file mode 100644 index 0000000..39e4c47 --- /dev/null +++ b/ui-ngx/src/app/shared/components/public-api.ts @@ -0,0 +1,22 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './dialog/color-picker-dialog.component'; +export * from './dialog/material-icons-dialog.component'; +export * from './page.component'; +export * from './dialog.component'; +export * from './js-func.component'; +export * from './script-lang.component'; diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html new file mode 100644 index 0000000..f8d362d --- /dev/null +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html @@ -0,0 +1,58 @@ + + + + + + + + {{getDescription(queue)}} + + +
    +
    + queue.no-queues-found +
    + + + {{ translate.get('queue.no-queues-matching', + {queue: truncate.transform(searchText, true, 6, '...')}) | async }} + + +
    +
    +
    + {{ autocompleteHint | translate }} + + {{ 'queue.queue-required' | translate }} + +
    diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss new file mode 100644 index 0000000..30f2d26 --- /dev/null +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +::ng-deep { + .queue-option { + .mat-option-text { + display: inline; + } + + .queue-option-description { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +:host ::ng-deep { + .mat-form-field { + .mat-form-field-wrapper { + padding-bottom: 0px; + + .mat-form-field-underline { + position: initial !important; + display: block; + margin-top: -1px; + } + + .mat-form-field-subscript-wrapper, + .mat-form-field-ripple { + position: initial !important; + display: table; + } + + .mat-form-field-subscript-wrapper { + min-height: calc(1em + 1px); + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts new file mode 100644 index 0000000..5a863b3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts @@ -0,0 +1,215 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { EntityId } from '@shared/models/id/entity-id'; +import { BaseData } from '@shared/models/base-data'; +import { EntityService } from '@core/http/entity.service'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { QueueInfo, ServiceType } from '@shared/models/queue.models'; +import { QueueService } from '@core/http/queue.service'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction } from '@shared/models/page/sort-order'; +import { emptyPageData } from '@shared/models/page/page-data'; + +@Component({ + selector: 'tb-queue-autocomplete', + templateUrl: './queue-autocomplete.component.html', + styleUrls: ['./queue-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => QueueAutocompleteComponent), + multi: true + }] +}) +export class QueueAutocompleteComponent implements ControlValueAccessor, OnInit { + + selectQueueFormGroup: FormGroup; + + modelValue: string | null; + + @Input() + labelText: string; + + @Input() + requiredText: string; + + @Input() + autocompleteHint: string; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + queueType: ServiceType; + + @Input() + disabled: boolean; + + @ViewChild('queueInput', {static: true}) queueInput: ElementRef; + + filteredQueues: Observable>>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + public truncate: TruncatePipe, + private entityService: EntityService, + private queueService: QueueService, + private fb: FormBuilder) { + this.selectQueueFormGroup = this.fb.group({ + queueName: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredQueues = this.selectQueueFormGroup.get('queueName').valueChanges + .pipe( + debounceTime(150), + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value.name; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + distinctUntilChanged(), + switchMap(name => this.fetchQueue(name) ), + share() + ); + } + + ngAfterViewInit(): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectQueueFormGroup.disable({emitEvent: false}); + } else { + this.selectQueueFormGroup.enable({emitEvent: false}); + } + } + + textIsNotEmpty(text: string): boolean { + return (text && text.length > 0); + } + + writeValue(value: string | null): void { + this.searchText = ''; + if (value != null) { + this.queueService.getQueueByName(value, {ignoreLoading: true, ignoreErrors: true}).subscribe( + (entity) => { + this.modelValue = entity.name; + this.selectQueueFormGroup.get('queueName').patchValue(entity, {emitEvent: false}); + }, + () => { + this.modelValue = null; + this.selectQueueFormGroup.get('queueName').patchValue('', {emitEvent: false}); + if (value !== null) { + this.propagateChange(this.modelValue); + } + } + ); + } else { + this.modelValue = null; + this.selectQueueFormGroup.get('queueName').patchValue('', {emitEvent: false}); + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.selectQueueFormGroup.get('queueName').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + reset() { + this.selectQueueFormGroup.get('queueName').patchValue('', {emitEvent: false}); + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayQueueFn(queue?: BaseData): string | undefined { + return queue ? queue.name : undefined; + } + + fetchQueue(searchText?: string): Observable> { + this.searchText = searchText; + const pageLink = new PageLink(10, 0, searchText, { + property: 'name', + direction: Direction.ASC + }); + return this.queueService.getTenantQueuesByServiceType(pageLink, this.queueType, {ignoreLoading: true}).pipe( + catchError(() => of(emptyPageData())), + map(pageData => { + return pageData.data; + }) + ); + } + + getDescription(value) { + return value.additionalInfo?.description ? value.additionalInfo.description : + this.translate.instant( + 'queue.alt-description', + {submitStrategy: value.submitStrategy.type, processingStrategy: value.processingStrategy.type} + ); + } + + clear() { + this.selectQueueFormGroup.get('queueName').patchValue('', {emitEvent: true}); + setTimeout(() => { + this.queueInput.nativeElement.blur(); + this.queueInput.nativeElement.focus(); + }, 0); + } +} diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html new file mode 100644 index 0000000..0dc0d81 --- /dev/null +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html @@ -0,0 +1,45 @@ + + + + + + + + + + + {{ 'relation.relation-type-required' | translate }} + + + {{ 'relation.relation-type-max-length' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts new file mode 100644 index 0000000..ca6e63d --- /dev/null +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { BroadcastService } from '@app/core/services/broadcast.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { RelationTypes } from '@app/shared/models/relation.models'; + +@Component({ + selector: 'tb-relation-type-autocomplete', + templateUrl: './relation-type-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationTypeAutocompleteComponent), + multi: true + }] +}) +export class RelationTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + relationTypeFormGroup: FormGroup; + + modelValue: string | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('relationTypeInput', {static: true}) relationTypeInput: ElementRef; + + filteredRelationTypes: Observable>; + + searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private fb: FormBuilder) { + this.relationTypeFormGroup = this.fb.group({ + relationType: [null, this.required ? [Validators.required, Validators.maxLength(255)] : [Validators.maxLength(255)]] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + this.filteredRelationTypes = this.relationTypeFormGroup.get('relationType').valueChanges + .pipe( + tap(value => { + this.updateView(value); + }), + // startWith(''), + map(value => value ? value : ''), + mergeMap(type => this.fetchRelationTypes(type) ) + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.relationTypeFormGroup.disable({emitEvent: false}); + } else { + this.relationTypeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + this.modelValue = value; + this.relationTypeFormGroup.get('relationType').patchValue(value, {emitEvent: false}); + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.relationTypeFormGroup.get('relationType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayRelationTypeFn(relationType?: string): string | undefined { + return relationType ? relationType : undefined; + } + + fetchRelationTypes(searchText?: string, strictMatch: boolean = false): Observable> { + this.searchText = searchText; + return of(RelationTypes).pipe( + map(relationTypes => relationTypes.filter( relationType => { + if (strictMatch) { + return searchText ? relationType === searchText : false; + } else { + return searchText ? relationType.toUpperCase().startsWith(searchText.toUpperCase()) : true; + } + })) + ); + } + + clear() { + this.relationTypeFormGroup.get('relationType').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.relationTypeInput.nativeElement.blur(); + this.relationTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/script-lang.component.html b/ui-ngx/src/app/shared/components/script-lang.component.html new file mode 100644 index 0000000..f45ad2c --- /dev/null +++ b/ui-ngx/src/app/shared/components/script-lang.component.html @@ -0,0 +1,25 @@ + +
    + + {{ 'rulenode.script-lang-tbel' | translate }} + {{ 'rulenode.script-lang-java-script' | translate }} + +
    diff --git a/ui-ngx/src/app/shared/components/script-lang.component.scss b/ui-ngx/src/app/shared/components/script-lang.component.scss new file mode 100644 index 0000000..74bdf82 --- /dev/null +++ b/ui-ngx/src/app/shared/components/script-lang.component.scss @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.mat-button-toggle-group.tb-script-lang-toggle-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 18px; + .mat-button-toggle+.mat-button-toggle { + border-left: none; + } + } + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 36px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 16px; + } + } + .mat-button-toggle-button { + color: #959595; + } + .mat-button-toggle-focus-overlay { + border-radius: 16px; + margin: 2px; + } + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: #305680; + color: #fff; + border-radius: 16px; + margin-left: 2px; + margin-right: 2px; + } + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 32px; + font-size: 16px; + font-weight: 500; + } + .mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay { + opacity: .01; + } +} diff --git a/ui-ngx/src/app/shared/components/script-lang.component.ts b/ui-ngx/src/app/shared/components/script-lang.component.ts new file mode 100644 index 0000000..56aad1d --- /dev/null +++ b/ui-ngx/src/app/shared/components/script-lang.component.ts @@ -0,0 +1,87 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; + +@Component({ + selector: 'tb-script-lang', + templateUrl: './script-lang.component.html', + styleUrls: ['./script-lang.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbScriptLangComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class TbScriptLangComponent extends PageComponent implements ControlValueAccessor, OnInit { + + scriptLangFormGroup: FormGroup; + + scriptLanguage = ScriptLanguage; + + @Input() + disabled: boolean; + + private propagateChange = null; + + constructor(protected store: Store, + private fb: FormBuilder) { + super(store); + this.scriptLangFormGroup = this.fb.group({ + scriptLang: [null] + }); + } + + ngOnInit() { + this.scriptLangFormGroup.get('scriptLang').valueChanges.subscribe( + (scriptLang) => { + this.updateView(scriptLang); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.scriptLangFormGroup.disable({emitEvent: false}); + } else { + this.scriptLangFormGroup.enable({emitEvent: false}); + } + } + + writeValue(scriptLang: ScriptLanguage): void { + this.scriptLangFormGroup.get('scriptLang').patchValue(scriptLang, {emitEvent: false}); + } + + updateView(scriptLang: ScriptLanguage) { + this.propagateChange(scriptLang); + } +} diff --git a/ui-ngx/src/app/shared/components/snack-bar-component.html b/ui-ngx/src/app/shared/components/snack-bar-component.html new file mode 100644 index 0000000..090388c --- /dev/null +++ b/ui-ngx/src/app/shared/components/snack-bar-component.html @@ -0,0 +1,29 @@ + +
    +
    + +
    diff --git a/ui-ngx/src/app/shared/components/snack-bar-component.scss b/ui-ngx/src/app/shared/components/snack-bar-component.scss new file mode 100644 index 0000000..c715a10 --- /dev/null +++ b/ui-ngx/src/app/shared/components/snack-bar-component.scss @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + display: inline-block; + pointer-events: all; + &.toast-panel { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + &.left { + justify-content: flex-start; + } + &.right { + justify-content: flex-end; + } + &.top { + align-items: flex-start; + } + &.bottom { + align-items: flex-end; + } + &.h-center { + justify-content: center; + } + &.v-center { + align-items: center; + } + } + .tb-toast { + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + color: #fff; + font-size: 18px; + border-radius: 4px; + padding: 0 18px; + margin: 8px; + .toast-text { + padding: 0 6px; + width: 100%; + } + button { + margin: 6px 0 6px 12px; + } + &.info-toast { + background: #323232; + } + &.warn-toast { + background: #dc6d1b; + } + &.error-toast { + background: #800000; + } + &.success-toast { + background: #008000; + } + } +} diff --git a/ui-ngx/src/app/shared/components/socialshare-panel.component.html b/ui-ngx/src/app/shared/components/socialshare-panel.component.html new file mode 100644 index 0000000..451e61d --- /dev/null +++ b/ui-ngx/src/app/shared/components/socialshare-panel.component.html @@ -0,0 +1,53 @@ + +
    + + + + +
    diff --git a/ui-ngx/src/app/shared/components/socialshare-panel.component.scss b/ui-ngx/src/app/shared/components/socialshare-panel.component.scss new file mode 100644 index 0000000..4ef531b --- /dev/null +++ b/ui-ngx/src/app/shared/components/socialshare-panel.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + display: block; +} diff --git a/ui-ngx/src/app/shared/components/socialshare-panel.component.ts b/ui-ngx/src/app/shared/components/socialshare-panel.component.ts new file mode 100644 index 0000000..b479635 --- /dev/null +++ b/ui-ngx/src/app/shared/components/socialshare-panel.component.ts @@ -0,0 +1,53 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit } from '@angular/core'; +import { isLocalUrl } from '@core/utils'; + +@Component({ + selector: 'tb-social-share-panel', + templateUrl: './socialshare-panel.component.html', + styleUrls: ['./socialshare-panel.component.scss'] +}) +export class SocialSharePanelComponent implements OnInit { + + @Input() + shareTitle: string; + + @Input() + shareText: string; + + @Input() + shareLink: string; + + @Input() + shareHashTags: string; + + constructor() { + } + + ngOnInit(): void { + } + + isShareLinkLocal(): boolean { + if (this.shareLink && this.shareLink.length > 0) { + return isLocalUrl(this.shareLink); + } else { + return true; + } + } + +} diff --git a/ui-ngx/src/app/shared/components/tb-anchor.component.ts b/ui-ngx/src/app/shared/components/tb-anchor.component.ts new file mode 100644 index 0000000..058318f --- /dev/null +++ b/ui-ngx/src/app/shared/components/tb-anchor.component.ts @@ -0,0 +1,25 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ViewContainerRef } from '@angular/core'; + +@Component({ + selector: 'tb-anchor', + template: '' +}) +export class TbAnchorComponent { + constructor(public viewContainerRef: ViewContainerRef) { } +} diff --git a/ui-ngx/src/app/shared/components/tb-checkbox.component.html b/ui-ngx/src/app/shared/components/tb-checkbox.component.html new file mode 100644 index 0000000..cda5289 --- /dev/null +++ b/ui-ngx/src/app/shared/components/tb-checkbox.component.html @@ -0,0 +1,24 @@ + + + + diff --git a/ui-ngx/src/app/shared/components/tb-checkbox.component.ts b/ui-ngx/src/app/shared/components/tb-checkbox.component.ts new file mode 100644 index 0000000..8e335a6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/tb-checkbox.component.ts @@ -0,0 +1,74 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'tb-checkbox', + templateUrl: './tb-checkbox.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbCheckboxComponent), + multi: true + } + ] +}) +export class TbCheckboxComponent implements ControlValueAccessor { + + innerValue: boolean; + + @Input() disabled: boolean; + @Input() trueValue: any = true; + @Input() falseValue: any = false; + @Output() valueChange = new EventEmitter(); + + private propagateChange = (_: any) => {}; + + onHostChange(ev) { + this.propagateChange(ev.checked ? this.trueValue : this.falseValue); + } + + modelChange($event) { + if ($event) { + this.innerValue = true; + this.valueChange.emit(this.trueValue); + } else { + this.innerValue = false; + this.valueChange.emit(this.falseValue); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(obj: any): void { + if (obj === this.trueValue) { + this.innerValue = true; + } else { + this.innerValue = false; + } + } +} diff --git a/ui-ngx/src/app/shared/components/tb-error.component.ts b/ui-ngx/src/app/shared/components/tb-error.component.ts new file mode 100644 index 0000000..08959e2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/tb-error.component.ts @@ -0,0 +1,67 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input } from '@angular/core'; +import { animate, state, style, transition, trigger } from '@angular/animations'; + +@Component({ + selector: 'tb-error', + template: ` +
    + + {{message}} + +
    + `, + styles: [` + :host { + height: 24px; + } + `], + animations: [ + trigger('animation', [ + state('show', style({ + opacity: 1, + })), + state('hide', style({ + opacity: 0, + transform: 'translateY(-1rem)' + })), + transition('show => hide', animate('200ms ease-out')), + transition('* => show', animate('200ms ease-in')) + + ]), + ] +}) +export class TbErrorComponent { + errorValue: any; + state: any; + message; + + @Input() + set error(value) { + if (value && !this.message) { + this.message = value; + this.state = 'hide'; + setTimeout(() => { + this.state = 'show'; + }); + } else { + this.errorValue = value; + this.state = value ? 'show' : 'hide'; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.html b/ui-ngx/src/app/shared/components/time/datetime-period.component.html new file mode 100644 index 0000000..9bf0aaa --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.html @@ -0,0 +1,49 @@ + +
    +
    + + datetime.date-from + + + + + + datetime.time-from + + + + +
    +
    + + datetime.date-to + + + + + + datetime.time-to + + + + +
    +
    diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.scss b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss new file mode 100644 index 0000000..40808c0 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../scss/constants'; + +:host ::ng-deep { + .mat-form-field-wrapper { + padding-bottom: 8px; + } + .mat-form-field-underline { + bottom: 8px; + } + .mat-form-field-infix { + width: 150px; + + @media #{$mat-xs} { + width: 100%; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts new file mode 100644 index 0000000..5edb205 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts @@ -0,0 +1,138 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FixedWindow } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-datetime-period', + templateUrl: './datetime-period.component.html', + styleUrls: ['./datetime-period.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatetimePeriodComponent), + multi: true + } + ] +}) +export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { + + @Input() disabled: boolean; + + modelValue: FixedWindow; + + startDate: Date; + endDate: Date; + + endTime: any; + + maxStartDate: Date; + minEndDate: Date; + maxEndDate: Date; + + changePending = false; + + private propagateChange = null; + + constructor() { + } + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (this.changePending && this.propagateChange) { + this.changePending = false; + this.propagateChange(this.modelValue); + } + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(datePeriod: FixedWindow): void { + this.modelValue = datePeriod; + if (this.modelValue) { + this.startDate = new Date(this.modelValue.startTimeMs); + this.endDate = new Date(this.modelValue.endTimeMs); + } else { + const date = new Date(); + this.startDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() - 1, + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds()); + this.endDate = date; + this.updateView(); + } + this.updateMinMaxDates(); + } + + updateView() { + let value: FixedWindow = null; + if (this.startDate && this.endDate) { + value = { + startTimeMs: this.startDate.getTime(), + endTimeMs: this.endDate.getTime() + }; + } + this.modelValue = value; + if (!this.propagateChange) { + this.changePending = true; + } else { + this.propagateChange(this.modelValue); + } + } + + updateMinMaxDates() { + this.maxStartDate = new Date(this.endDate.getTime() - 1000); + this.minEndDate = new Date(this.startDate.getTime() + 1000); + this.maxEndDate = new Date(); + } + + onStartDateChange() { + if (this.startDate) { + if (this.startDate.getTime() > this.maxStartDate.getTime()) { + this.startDate = new Date(this.maxStartDate.getTime()); + } + this.updateMinMaxDates(); + } + this.updateView(); + } + + onEndDateChange() { + if (this.endDate) { + if (this.endDate.getTime() < this.minEndDate.getTime()) { + this.endDate = new Date(this.minEndDate.getTime()); + } else if (this.endDate.getTime() > this.maxEndDate.getTime()) { + this.endDate = new Date(this.maxEndDate.getTime()); + } + this.updateMinMaxDates(); + } + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.html b/ui-ngx/src/app/shared/components/time/datetime.component.html new file mode 100644 index 0000000..9b0f1f5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime.component.html @@ -0,0 +1,40 @@ + +
    + + {{ dateText | translate }} + + + + + + {{ timeText | translate }} + + + + +
    diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.scss b/ui-ngx/src/app/shared/components/time/datetime.component.scss new file mode 100644 index 0000000..03abc24 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-form-field-wrapper { + padding-bottom: 8px; + } + .mat-form-field-underline { + bottom: 8px; + } + .mat-form-field-infix { + width: auto; + min-width: 100px; + } + mat-form-field { + &.no-label { + .mat-form-field-infix { + border-top-width: 0.2em; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.ts b/ui-ngx/src/app/shared/components/time/datetime.component.ts new file mode 100644 index 0000000..4106e0d --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'tb-datetime', + templateUrl: './datetime.component.html', + styleUrls: ['./datetime.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatetimeComponent), + multi: true + } + ] +}) +export class DatetimeComponent implements OnInit, ControlValueAccessor { + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @Input() + dateText: string; + + @Input() + timeText: string; + + @Input() + showLabel = true; + + minDateValue: Date | null; + + @Input() + set minDate(minDate: number | null) { + this.minDateValue = minDate ? new Date(minDate) : null; + } + + maxDateValue: Date | null; + + @Input() + set maxDate(maxDate: number | null) { + this.maxDateValue = maxDate ? new Date(maxDate) : null; + } + + modelValue: number; + + date: Date; + + private propagateChange = (v: any) => { }; + + constructor() { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + ngOnInit(): void { + } + + writeValue(datetime: number | null): void { + this.modelValue = datetime; + if (this.modelValue) { + this.date = new Date(this.modelValue); + } else { + this.date = null; + } + } + + updateView(value: number | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + onDateChange() { + const value = this.date ? this.date.getTime() : null; + this.updateView(value); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html new file mode 100644 index 0000000..dde6d44 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html @@ -0,0 +1,54 @@ + +
    +
    + + +
    + + +
    + {{ this.currentTime | date:'medium'}} + {{ "widget.no-data-found" | translate}} +
    +
    + + + + + + {{speedValue}} + +
    +
    diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.scss b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.scss new file mode 100644 index 0000000..107f977 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.scss @@ -0,0 +1,131 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.trip-animation-label-container { + height: 24px; +} + +.trip-animation-container { + position: relative; + z-index: 1; + flex: 1; + width: 100%; + + #trip-animation-map { + z-index: 1; + width: 100%; + height: 100%; + + .pointsLayerMarkerIcon { + border-radius: 50%; + } + } + + .trip-animation-info-panel { + position: absolute; + top: 0; + right: 0; + z-index: 2; + pointer-events: none; + + .mat-button { + top: 0; + left: 0; + width: 32px; + min-width: 32px; + height: 32px; + min-height: 32px; + padding: 0 0 2px; + margin: 2px; + line-height: 24px; + + mat-icon { + width: 24px; + height: 24px; + + svg { + width: inherit; + height: inherit; + } + } + } + } + + .trip-animation-tooltip { + position: absolute; + top: 38px; + right: 0; + z-index: 2; + padding: 10px; + background-color: #fff; + transition: 0.3s ease-in-out; + + &-hidden { + transform: translateX(110%); + } + } +} + +.trip-animation-control-panel { + position: relative; + box-sizing: border-box; + width: 100%; + padding-bottom: 16px; + padding-left: 10px; + + mat-slider-container { + mat-slider { + min-width: 80px; + } + } + + .mat-icon-button { + width: 44px; + min-width: 44px; + height: 48px; + min-height: 48px; + margin: 0; + line-height: 28px; + + mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + + svg { + width: inherit; + height: inherit; + } + } + + mat-select { + margin: 0; + } + } + + .panel-timer { + max-width: none; + margin-top: -20px; + font-size: 12px; + font-weight: 500; + text-align: center; + } +} + +.speed-select { + width: 70px; + margin-left: 10px; + margin-top: 10px; +} diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts new file mode 100644 index 0000000..6e07782 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts @@ -0,0 +1,143 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { interval } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { HistorySelectSettings } from '@app/modules/home/components/widget/lib/maps/map-models'; + +@Component({ + selector: 'tb-history-selector', + templateUrl: './history-selector.component.html', + styleUrls: ['./history-selector.component.scss'] +}) +export class HistorySelectorComponent implements OnChanges { + + @Input() settings: HistorySelectSettings; + @Input() minTime: number; + @Input() maxTime: number; + @Input() step = 1000; + @Input() anchors = []; + @Input() useAnchors = false; + + @Output() timeUpdated: EventEmitter = new EventEmitter(); + + minTimeIndex = 0; + maxTimeIndex = 0; + speed = 1; + index = 0; + playing = false; + interval; + speeds = [1, 5, 10, 25]; + currentTime = null; + + + constructor(private cd: ChangeDetectorRef) { } + + ngOnChanges() { + this.maxTimeIndex = Math.ceil((this.maxTime - this.minTime) / this.step); + this.currentTime = this.minTime === Infinity ? null : this.minTime; + } + + play() { + this.playing = true; + if (!this.interval) { + this.interval = interval(1000 / this.speed) + .pipe( + filter(() => this.playing) + ).subscribe(() => { + this.index++; + this.currentTime = this.minTime + this.index * this.step; + if (this.index <= this.maxTimeIndex) { + this.cd.detectChanges(); + this.timeUpdated.emit(this.currentTime); + } else { + this.playing = false; + this.interval.complete(); + this.cd.detectChanges(); + } + }, err => { + console.error(err); + }, () => { + this.interval = null; + }); + } + } + + reInit() { + if (this.interval) { + this.interval.complete(); + } + if (this.playing) { + this.play(); + } + } + + pause() { + this.playing = false; + this.currentTime = this.minTime + this.index * this.step; + this.cd.detectChanges(); + this.timeUpdated.emit(this.currentTime); + } + + moveNext() { + if (this.index < this.maxTimeIndex) { + if (this.useAnchors) { + const anchorIndex = this.findIndex(this.currentTime, this.anchors) + 1; + this.index = Math.floor((this.anchors[anchorIndex] - this.minTime) / this.step); + } else { + this.index++; + } + } + this.pause(); + } + + movePrev() { + if (this.index > this.minTimeIndex) { + if (this.useAnchors) { + const anchorIndex = this.findIndex(this.currentTime, this.anchors) - 1; + this.index = Math.floor((this.anchors[anchorIndex] - this.minTime) / this.step); + } else { + this.index--; + } + } + this.pause(); + } + + findIndex(value: number, array: number[]): number { + let i = 0; + while (array[i] < value) { + i++; + } + return i; + } + + moveStart() { + this.index = this.minTimeIndex; + this.pause(); + } + + moveEnd() { + this.index = this.maxTimeIndex; + this.pause(); + } + + changeIndex(index: number) { + this.index = index; + this.currentTime = this.minTime + index * this.step; + this.timeUpdated.emit(this.currentTime); + } +} diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html new file mode 100644 index 0000000..3adc6a1 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html @@ -0,0 +1,27 @@ + +
    + + timewindow.interval + + + {{ timeIntervalTranslationMap.get(interval) | translate}} + + + +
    diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss new file mode 100644 index 0000000..ff3a9c5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../scss/constants'; + +:host { + min-width: 355px; + + @media #{$mat-xs} { + min-width: 0; + width: 100%; + } +} diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts new file mode 100644 index 0000000..78c6fe4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts @@ -0,0 +1,79 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { QuickTimeInterval, QuickTimeIntervalTranslationMap } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-quick-time-interval', + templateUrl: './quick-time-interval.component.html', + styleUrls: ['./quick-time-interval.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => QuickTimeIntervalComponent), + multi: true + } + ] +}) +export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor { + + private allIntervals = Object.values(QuickTimeInterval); + + modelValue: QuickTimeInterval; + timeIntervalTranslationMap = QuickTimeIntervalTranslationMap; + + rendered = false; + + @Input() disabled: boolean; + + @Input() onlyCurrentInterval = false; + + private propagateChange = (_: any) => {}; + + constructor() { + } + + get intervals() { + if (this.onlyCurrentInterval) { + return this.allIntervals.filter(interval => interval.startsWith('CURRENT_')); + } + return this.allIntervals; + } + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(interval: QuickTimeInterval): void { + this.modelValue = interval; + } + + onIntervalChange() { + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.html b/ui-ngx/src/app/shared/components/time/timeinterval.component.html new file mode 100644 index 0000000..92d155f --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.html @@ -0,0 +1,58 @@ + +
    +
    + + +
    +
    + +
    + + timeinterval.days + + + + timeinterval.hours + + + + timeinterval.minutes + + + + timeinterval.seconds + + +
    +
    +
    + + {{ predefinedName }} + + + {{ interval.name | translate:interval.translateParams }} + + + +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.scss b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss new file mode 100644 index 0000000..6c9cc7b --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../scss/constants'; + +:host { + min-width: 355px; + + .advanced-switch { + margin-bottom: 16px; + } + + .advanced-label { + margin: 5px 0; + } + + .hide-label { + margin-bottom: 5px; + margin-right: 5px; + } + + .interval-section { + min-height: 66px; + .interval-label { + margin-bottom: 7px; + margin-top: -1px; + } + } + + @media #{$mat-xs} { + min-width: 0; + width: 100%; + } +} + +:host ::ng-deep { + .number-input { + .mat-form-field-infix { + width: 70px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts new file mode 100644 index 0000000..b9b5fc7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -0,0 +1,302 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TimeInterval, TimeService } from '@core/services/time.service'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'tb-timeinterval', + templateUrl: './timeinterval.component.html', + styleUrls: ['./timeinterval.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeintervalComponent), + multi: true + } + ] +}) +export class TimeintervalComponent implements OnInit, ControlValueAccessor { + + minValue: number; + maxValue: number; + + @Input() + set min(min: number) { + if (typeof min !== 'undefined' && min !== this.minValue) { + this.minValue = min; + this.maxValue = Math.max(this.maxValue, this.minValue); + this.updateView(); + } + } + + @Input() + set max(max: number) { + if (typeof max !== 'undefined' && max !== this.maxValue) { + this.maxValue = max; + this.minValue = Math.min(this.minValue, this.maxValue); + this.updateView(); + } + } + + @Input() predefinedName: string; + + isEditValue = false; + + @Input() + set isEdit(val) { + this.isEditValue = coerceBooleanProperty(val); + } + + get isEdit() { + return this.isEditValue; + } + + hideFlagValue = false; + + @Input() + get hideFlag() { + return this.hideFlagValue; + } + + set hideFlag(val) { + this.hideFlagValue = val; + } + + @Output() hideFlagChange = new EventEmitter(); + + @Input() disabled: boolean; + + days = 0; + hours = 0; + mins = 1; + secs = 0; + + intervalMs = 0; + modelValue: number; + + advanced = false; + rendered = false; + + intervals: Array; + + private propagateChange = (_: any) => {}; + + constructor(private timeService: TimeService) { + } + + ngOnInit(): void { + this.boundInterval(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(intervalMs: number): void { + this.modelValue = intervalMs; + this.rendered = true; + if (typeof this.modelValue !== 'undefined') { + const min = this.timeService.boundMinInterval(this.minValue); + const max = this.timeService.boundMaxInterval(this.maxValue); + if (this.modelValue >= min && this.modelValue <= max) { + this.advanced = !this.timeService.matchesExistingInterval(this.minValue, this.maxValue, this.modelValue); + this.setIntervalMs(this.modelValue); + } else { + this.boundInterval(); + } + } + } + + setIntervalMs(intervalMs: number) { + if (!this.advanced) { + this.intervalMs = intervalMs; + } + const intervalSeconds = Math.floor(intervalMs / 1000); + this.days = Math.floor(intervalSeconds / 86400); + this.hours = Math.floor((intervalSeconds % 86400) / 3600); + this.mins = Math.floor(((intervalSeconds % 86400) % 3600) / 60); + this.secs = intervalSeconds % 60; + } + + boundInterval() { + const min = this.timeService.boundMinInterval(this.minValue); + const max = this.timeService.boundMaxInterval(this.maxValue); + this.intervals = this.timeService.getIntervals(this.minValue, this.maxValue); + if (this.rendered) { + let newIntervalMs = this.modelValue; + if (newIntervalMs < min) { + newIntervalMs = min; + } else if (newIntervalMs > max) { + newIntervalMs = max; + } + if (!this.advanced) { + newIntervalMs = this.timeService.boundToPredefinedInterval(min, max, newIntervalMs); + } + if (newIntervalMs !== this.modelValue) { + this.setIntervalMs(newIntervalMs); + this.updateView(); + } + } + } + + updateView() { + if (!this.rendered) { + return; + } + let value = null; + let intervalMs; + if (!this.advanced) { + intervalMs = this.intervalMs; + if (!intervalMs || isNaN(intervalMs)) { + intervalMs = this.calculateIntervalMs(); + } + } else { + intervalMs = this.calculateIntervalMs(); + } + if (!isNaN(intervalMs) && intervalMs > 0) { + value = intervalMs; + } + this.modelValue = value; + this.propagateChange(this.modelValue); + this.boundInterval(); + } + + calculateIntervalMs(): number { + return (this.days * 86400 + + this.hours * 3600 + + this.mins * 60 + + this.secs) * 1000; + } + + onIntervalMsChange() { + this.updateView(); + } + + onAdvancedChange() { + if (!this.advanced) { + this.intervalMs = this.calculateIntervalMs(); + } else { + let intervalMs = this.intervalMs; + if (!intervalMs || isNaN(intervalMs)) { + intervalMs = this.calculateIntervalMs(); + } + this.setIntervalMs(intervalMs); + } + this.updateView(); + } + + onHideFlagChange() { + this.hideFlagChange.emit(this.hideFlagValue); + } + + onTimeInputChange(type: string) { + switch (type) { + case 'secs': + setTimeout(() => this.onSecsChange(), 0); + break; + case 'mins': + setTimeout(() => this.onMinsChange(), 0); + break; + case 'hours': + setTimeout(() => this.onHoursChange(), 0); + break; + case 'days': + setTimeout(() => this.onDaysChange(), 0); + break; + } + } + + onSecsChange() { + if (typeof this.secs === 'undefined') { + return; + } + if (this.secs < 0) { + if ((this.days + this.hours + this.mins) > 0) { + this.secs = this.secs + 60; + this.mins--; + this.onMinsChange(); + } else { + this.secs = 0; + } + } else if (this.secs >= 60) { + this.secs = this.secs - 60; + this.mins++; + this.onMinsChange(); + } + this.updateView(); + } + + onMinsChange() { + if (typeof this.mins === 'undefined') { + return; + } + if (this.mins < 0) { + if ((this.days + this.hours) > 0) { + this.mins = this.mins + 60; + this.hours--; + this.onHoursChange(); + } else { + this.mins = 0; + } + } else if (this.mins >= 60) { + this.mins = this.mins - 60; + this.hours++; + this.onHoursChange(); + } + this.updateView(); + } + + onHoursChange() { + if (typeof this.hours === 'undefined') { + return; + } + if (this.hours < 0) { + if (this.days > 0) { + this.hours = this.hours + 24; + this.days--; + this.onDaysChange(); + } else { + this.hours = 0; + } + } else if (this.hours >= 24) { + this.hours = this.hours - 24; + this.days++; + this.onDaysChange(); + } + this.updateView(); + } + + onDaysChange() { + if (typeof this.days === 'undefined') { + return; + } + if (this.days < 0) { + this.days = 0; + } + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html new file mode 100644 index 0000000..7e4869e --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -0,0 +1,237 @@ + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + timewindow.last + +
    +
    +
    + +
    +
    + + +
    +
    + timewindow.interval + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    + + +
    + timewindow.last + +
    +
    + +
    + timewindow.time-period + +
    +
    + +
    + timewindow.interval + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + + aggregation.function + + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + + +
    +
    +
    +
    + + +
    +
    +
    + +
    + + + + + +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + + +
    +
    + + +
    +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss new file mode 100644 index 0000000..1ecf90e --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../scss/constants"; + +:host { + width: 100%; + height: 100%; + form, + fieldset { + height: 100%; + } + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 0 16px; + } + + .hide-label { + margin-bottom: 5px; + margin-right: 5px; + } + + tb-timeinterval[ng-reflect-fx-show="true"] { + margin-bottom: -16px; + } + + .limit-slider-container { + .limit-slider-value { + margin-left: 16px; + min-width: 25px; + max-width: 80px; + } + mat-form-field input[type=number] { + text-align: center; + } + } + + @media #{$mat-gt-sm} { + .history-time-input { + min-width: 364px; + } + .limit-slider-container { + > label { + margin-right: 16px; + width: min-content; + max-width: 40%; + } + } + } + +} + +:host ::ng-deep { + mat-radio-button { + display: block; + margin-bottom: 16px; + .mat-radio-label { + width: 100%; + align-items: start; + .mat-radio-label-content { + width: 100%; + } + } + } + .mat-slider-horizontal .mat-slider-thumb-label { + width: 38px; + height: 38px; + top: -46px; + right: -19px; + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts new file mode 100644 index 0000000..d0c7f88 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -0,0 +1,364 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, InjectionToken, OnInit, ViewContainerRef } from '@angular/core'; +import { + aggregationTranslations, + AggregationType, + DAY, + HistoryWindowType, + quickTimeIntervalPeriod, + RealtimeWindowType, + Timewindow, + TimewindowType +} from '@shared/models/time/time.models'; +import { OverlayRef } from '@angular/cdk/overlay'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TimeService } from '@core/services/time.service'; + +export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelData'); + +export interface TimewindowPanelData { + historyOnly: boolean; + quickIntervalOnly: boolean; + timewindow: Timewindow; + aggregation: boolean; + timezone: boolean; + isEdit: boolean; +} + +@Component({ + selector: 'tb-timewindow-panel', + templateUrl: './timewindow-panel.component.html', + styleUrls: ['./timewindow-panel.component.scss'] +}) +export class TimewindowPanelComponent extends PageComponent implements OnInit { + + historyOnly = false; + + quickIntervalOnly = false; + + aggregation = false; + + timezone = false; + + isEdit = false; + + timewindow: Timewindow; + + result: Timewindow; + + timewindowForm: FormGroup; + + historyTypes = HistoryWindowType; + + realtimeTypes = RealtimeWindowType; + + timewindowTypes = TimewindowType; + + aggregationTypes = AggregationType; + + aggregations = Object.keys(AggregationType); + + aggregationTypesTranslations = aggregationTranslations; + + constructor(@Inject(TIMEWINDOW_PANEL_DATA) public data: TimewindowPanelData, + public overlayRef: OverlayRef, + protected store: Store, + public fb: FormBuilder, + private timeService: TimeService, + public viewContainerRef: ViewContainerRef) { + super(store); + this.historyOnly = data.historyOnly; + this.quickIntervalOnly = data.quickIntervalOnly; + this.timewindow = data.timewindow; + this.aggregation = data.aggregation; + this.timezone = data.timezone; + this.isEdit = data.isEdit; + } + + ngOnInit(): void { + const hideInterval = this.timewindow.hideInterval || false; + const hideLastInterval = this.timewindow.hideLastInterval || false; + const hideQuickInterval = this.timewindow.hideQuickInterval || false; + const hideAggregation = this.timewindow.hideAggregation || false; + const hideAggInterval = this.timewindow.hideAggInterval || false; + const hideTimezone = this.timewindow.hideTimezone || false; + + this.timewindowForm = this.fb.group({ + realtime: this.fb.group( + { + realtimeType: this.fb.control({ + value: this.timewindow.realtime && typeof this.timewindow.realtime.realtimeType !== 'undefined' + ? this.timewindow.realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL, + disabled: hideInterval + }), + timewindowMs: this.fb.control({ + value: this.timewindow.realtime && typeof this.timewindow.realtime.timewindowMs !== 'undefined' + ? this.timewindow.realtime.timewindowMs : null, + disabled: hideInterval || hideLastInterval + }), + interval: [ + this.timewindow.realtime && typeof this.timewindow.realtime.interval !== 'undefined' + ? this.timewindow.realtime.interval : null + ], + quickInterval: this.fb.control({ + value: this.timewindow.realtime && typeof this.timewindow.realtime.quickInterval !== 'undefined' + ? this.timewindow.realtime.quickInterval : null, + disabled: hideInterval || hideQuickInterval + }) + } + ), + history: this.fb.group( + { + historyType: this.fb.control({ + value: this.timewindow.history && typeof this.timewindow.history.historyType !== 'undefined' + ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL, + disabled: hideInterval + }), + timewindowMs: this.fb.control({ + value: this.timewindow.history && typeof this.timewindow.history.timewindowMs !== 'undefined' + ? this.timewindow.history.timewindowMs : null, + disabled: hideInterval + }), + interval: [ + this.timewindow.history && typeof this.timewindow.history.interval !== 'undefined' + ? this.timewindow.history.interval : null + ], + fixedTimewindow: this.fb.control({ + value: this.timewindow.history && typeof this.timewindow.history.fixedTimewindow !== 'undefined' + ? this.timewindow.history.fixedTimewindow : null, + disabled: hideInterval + }), + quickInterval: this.fb.control({ + value: this.timewindow.history && typeof this.timewindow.history.quickInterval !== 'undefined' + ? this.timewindow.history.quickInterval : null, + disabled: hideInterval + }) + } + ), + aggregation: this.fb.group( + { + type: this.fb.control({ + value: this.timewindow.aggregation && typeof this.timewindow.aggregation.type !== 'undefined' + ? this.timewindow.aggregation.type : null, + disabled: hideAggregation + }), + limit: this.fb.control({ + value: this.timewindow.aggregation && typeof this.timewindow.aggregation.limit !== 'undefined' + ? this.checkLimit(this.timewindow.aggregation.limit) : null, + disabled: hideAggInterval + }, []) + } + ), + timezone: this.fb.control({ + value: this.timewindow.timezone !== 'undefined' + ? this.timewindow.timezone : null, + disabled: hideTimezone + }) + }); + this.updateValidators(); + this.timewindowForm.get('aggregation.type').valueChanges.subscribe(() => { + this.updateValidators(); + }); + } + + private checkLimit(limit?: number): number { + if (!limit || limit < this.minDatapointsLimit()) { + return this.minDatapointsLimit(); + } else if (limit > this.maxDatapointsLimit()) { + return this.maxDatapointsLimit(); + } + return limit; + } + + private updateValidators() { + const aggType = this.timewindowForm.get('aggregation.type').value; + if (aggType !== AggregationType.NONE) { + this.timewindowForm.get('aggregation.limit').clearValidators(); + } else { + this.timewindowForm.get('aggregation.limit').setValidators([Validators.min(this.minDatapointsLimit()), + Validators.max(this.maxDatapointsLimit())]); + } + this.timewindowForm.get('aggregation.limit').updateValueAndValidity({emitEvent: false}); + } + + update() { + const timewindowFormValue = this.timewindowForm.getRawValue(); + this.timewindow.realtime = { + realtimeType: timewindowFormValue.realtime.realtimeType, + timewindowMs: timewindowFormValue.realtime.timewindowMs, + quickInterval: timewindowFormValue.realtime.quickInterval, + interval: timewindowFormValue.realtime.interval + }; + this.timewindow.history = { + historyType: timewindowFormValue.history.historyType, + timewindowMs: timewindowFormValue.history.timewindowMs, + interval: timewindowFormValue.history.interval, + fixedTimewindow: timewindowFormValue.history.fixedTimewindow, + quickInterval: timewindowFormValue.history.quickInterval, + }; + if (this.aggregation) { + this.timewindow.aggregation = { + type: timewindowFormValue.aggregation.type, + limit: timewindowFormValue.aggregation.limit + }; + } + if (this.timezone) { + this.timewindow.timezone = timewindowFormValue.timezone; + } + this.result = this.timewindow; + this.overlayRef.dispose(); + } + + cancel() { + this.overlayRef.dispose(); + } + + minDatapointsLimit() { + return this.timeService.getMinDatapointsLimit(); + } + + maxDatapointsLimit() { + return this.timeService.getMaxDatapointsLimit(); + } + + minRealtimeAggInterval() { + return this.timeService.minIntervalLimit(this.currentRealtimeTimewindow()); + } + + maxRealtimeAggInterval() { + return this.timeService.maxIntervalLimit(this.currentRealtimeTimewindow()); + } + + currentRealtimeTimewindow(): number { + const timeWindowFormValue = this.timewindowForm.getRawValue(); + switch (timeWindowFormValue.realtime.realtimeType) { + case RealtimeWindowType.LAST_INTERVAL: + return timeWindowFormValue.realtime.timewindowMs; + case RealtimeWindowType.INTERVAL: + return quickTimeIntervalPeriod(timeWindowFormValue.realtime.quickInterval); + default: + return DAY; + } + } + + minHistoryAggInterval() { + return this.timeService.minIntervalLimit(this.currentHistoryTimewindow()); + } + + maxHistoryAggInterval() { + return this.timeService.maxIntervalLimit(this.currentHistoryTimewindow()); + } + + currentHistoryTimewindow() { + const timewindowFormValue = this.timewindowForm.getRawValue(); + if (timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { + return timewindowFormValue.history.timewindowMs; + } else if (timewindowFormValue.history.historyType === HistoryWindowType.INTERVAL) { + return quickTimeIntervalPeriod(timewindowFormValue.history.quickInterval); + } else if (timewindowFormValue.history.fixedTimewindow) { + return timewindowFormValue.history.fixedTimewindow.endTimeMs - + timewindowFormValue.history.fixedTimewindow.startTimeMs; + } else { + return DAY; + } + } + + onHideIntervalChanged() { + if (this.timewindow.hideInterval) { + this.timewindowForm.get('history.historyType').disable({emitEvent: false}); + this.timewindowForm.get('history.timewindowMs').disable({emitEvent: false}); + this.timewindowForm.get('history.fixedTimewindow').disable({emitEvent: false}); + this.timewindowForm.get('history.quickInterval').disable({emitEvent: false}); + this.timewindowForm.get('realtime.realtimeType').disable({emitEvent: false}); + this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); + this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); + } else { + this.timewindowForm.get('history.historyType').enable({emitEvent: false}); + this.timewindowForm.get('history.timewindowMs').enable({emitEvent: false}); + this.timewindowForm.get('history.fixedTimewindow').enable({emitEvent: false}); + this.timewindowForm.get('history.quickInterval').enable({emitEvent: false}); + this.timewindowForm.get('realtime.realtimeType').enable({emitEvent: false}); + if (!this.timewindow.hideLastInterval) { + this.timewindowForm.get('realtime.timewindowMs').enable({emitEvent: false}); + } + if (!this.timewindow.hideQuickInterval) { + this.timewindowForm.get('realtime.quickInterval').enable({emitEvent: false}); + } + } + this.timewindowForm.markAsDirty(); + } + + onHideLastIntervalChanged() { + if (this.timewindow.hideLastInterval) { + this.timewindowForm.get('realtime.timewindowMs').disable({emitEvent: false}); + if (!this.timewindow.hideQuickInterval) { + this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.INTERVAL); + } + } else { + if (!this.timewindow.hideInterval) { + this.timewindowForm.get('realtime.timewindowMs').enable({emitEvent: false}); + } + } + this.timewindowForm.markAsDirty(); + } + + onHideQuickIntervalChanged() { + if (this.timewindow.hideQuickInterval) { + this.timewindowForm.get('realtime.quickInterval').disable({emitEvent: false}); + if (!this.timewindow.hideLastInterval) { + this.timewindowForm.get('realtime.realtimeType').setValue(RealtimeWindowType.LAST_INTERVAL); + } + } else { + if (!this.timewindow.hideInterval) { + this.timewindowForm.get('realtime.quickInterval').enable({emitEvent: false}); + } + } + this.timewindowForm.markAsDirty(); + } + + onHideAggregationChanged() { + if (this.timewindow.hideAggregation) { + this.timewindowForm.get('aggregation.type').disable({emitEvent: false}); + } else { + this.timewindowForm.get('aggregation.type').enable({emitEvent: false}); + } + this.timewindowForm.markAsDirty(); + } + + onHideAggIntervalChanged() { + if (this.timewindow.hideAggInterval) { + this.timewindowForm.get('aggregation.limit').disable({emitEvent: false}); + } else { + this.timewindowForm.get('aggregation.limit').enable({emitEvent: false}); + } + this.timewindowForm.markAsDirty(); + } + + onHideTimezoneChanged() { + if (this.timewindow.hideTimezone) { + this.timewindowForm.get('timezone').disable({emitEvent: false}); + } else { + this.timewindowForm.get('timezone').enable({emitEvent: false}); + } + this.timewindowForm.markAsDirty(); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html new file mode 100644 index 0000000..4ec59dc --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -0,0 +1,46 @@ + + +
    + + + {{innerValue?.displayValue}} | {{innerValue.displayTimezoneAbbr}} + + +
    diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.scss b/ui-ngx/src/app/shared/components/time/timewindow.component.scss new file mode 100644 index 0000000..0dae335 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + min-width: 52px; + margin: 8px 0; + section.tb-timewindow { + min-height: 32px; + padding: 0 6px; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: all; + cursor: pointer; + } + + .timezone-abbr { + font-weight: 500; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts new file mode 100644 index 0000000..3922c4e --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts @@ -0,0 +1,377 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + ChangeDetectorRef, + Component, + forwardRef, + Inject, + Injector, + Input, + OnDestroy, + OnInit, + StaticProvider, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { + cloneSelectedTimewindow, + getTimezoneInfo, + HistoryWindowType, + initModelFromDefaultTimewindow, + QuickTimeIntervalTranslationMap, + RealtimeWindowType, + Timewindow, + TimewindowType +} from '@shared/models/time/time.models'; +import { DatePipe, DOCUMENT } from '@angular/common'; +import { CdkOverlayOrigin, ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { + TIMEWINDOW_PANEL_DATA, + TimewindowPanelComponent, + TimewindowPanelData +} from '@shared/components/time/timewindow-panel.component'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { WINDOW } from '@core/services/window.service'; +import { TimeService } from '@core/services/time.service'; +import { TooltipPosition } from '@angular/material/tooltip'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +// @dynamic +@Component({ + selector: 'tb-timewindow', + templateUrl: './timewindow.component.html', + styleUrls: ['./timewindow.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimewindowComponent), + multi: true + } + ] +}) +export class TimewindowComponent implements OnInit, OnDestroy, ControlValueAccessor { + + historyOnlyValue = false; + + @Input() + set historyOnly(val) { + const newHistoryOnlyValue = coerceBooleanProperty(val); + if (this.historyOnlyValue !== newHistoryOnlyValue) { + this.historyOnlyValue = newHistoryOnlyValue; + if (this.onHistoryOnlyChanged()) { + this.notifyChanged(); + } + } + } + + get historyOnly() { + return this.historyOnlyValue; + } + + alwaysDisplayTypePrefixValue = false; + + @Input() + set alwaysDisplayTypePrefix(val) { + this.alwaysDisplayTypePrefixValue = coerceBooleanProperty(val); + } + + get alwaysDisplayTypePrefix() { + return this.alwaysDisplayTypePrefixValue; + } + + quickIntervalOnlyValue = false; + + @Input() + set quickIntervalOnly(val) { + this.quickIntervalOnlyValue = coerceBooleanProperty(val); + } + + get quickIntervalOnly() { + return this.quickIntervalOnlyValue; + } + + aggregationValue = false; + + @Input() + set aggregation(val) { + this.aggregationValue = coerceBooleanProperty(val); + } + + get aggregation() { + return this.aggregationValue; + } + + timezoneValue = false; + + @Input() + set timezone(val) { + this.timezoneValue = coerceBooleanProperty(val); + } + + get timezone() { + return this.timezoneValue; + } + + isToolbarValue = false; + + @Input() + set isToolbar(val) { + this.isToolbarValue = coerceBooleanProperty(val); + } + + get isToolbar() { + return this.isToolbarValue; + } + + asButtonValue = false; + + @Input() + set asButton(val) { + this.asButtonValue = coerceBooleanProperty(val); + } + + get asButton() { + return this.asButtonValue; + } + + isEditValue = false; + + @Input() + set isEdit(val) { + this.isEditValue = coerceBooleanProperty(val); + this.timewindowDisabled = this.isTimewindowDisabled(); + } + + get isEdit() { + return this.isEditValue; + } + + @Input() + direction: 'left' | 'right' = 'left'; + + @Input() + tooltipPosition: TooltipPosition = 'above'; + + @Input() disabled: boolean; + + @ViewChild('timewindowPanelOrigin') timewindowPanelOrigin: CdkOverlayOrigin; + + innerValue: Timewindow; + + timewindowDisabled: boolean; + + private propagateChange = (_: any) => {}; + + constructor(private translate: TranslateService, + private timeService: TimeService, + private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe, + private datePipe: DatePipe, + private overlay: Overlay, + private cd: ChangeDetectorRef, + public viewContainerRef: ViewContainerRef, + public breakpointObserver: BreakpointObserver, + @Inject(DOCUMENT) private document: Document, + @Inject(WINDOW) private window: Window) { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + } + + openEditMode() { + if (this.timewindowDisabled) { + return; + } + const isGtXs = this.breakpointObserver.isMatched(MediaBreakpoints['gt-xs']); + const position = this.overlay.position(); + const config = new OverlayConfig({ + panelClass: 'tb-timewindow-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: isGtXs, + }); + if (isGtXs) { + config.minWidth = '417px'; + config.maxHeight = '550px'; + const panelHeight = 375; + const panelWidth = 417; + const el = this.timewindowPanelOrigin.elementRef.nativeElement; + const offset = el.getBoundingClientRect(); + const scrollTop = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0; + const scrollLeft = this.window.pageXOffset || this.document.documentElement.scrollLeft || this.document.body.scrollLeft || 0; + const bottomY = offset.bottom - scrollTop; + const leftX = offset.left - scrollLeft; + let originX; + let originY; + let overlayX; + let overlayY; + const wHeight = this.document.documentElement.clientHeight; + const wWidth = this.document.documentElement.clientWidth; + if (bottomY + panelHeight > wHeight) { + originY = 'top'; + overlayY = 'bottom'; + } else { + originY = 'bottom'; + overlayY = 'top'; + } + if (leftX + panelWidth > wWidth) { + originX = 'end'; + overlayX = 'end'; + } else { + originX = 'start'; + overlayX = 'start'; + } + const connectedPosition: ConnectedPosition = { + originX, + originY, + overlayX, + overlayY + }; + config.positionStrategy = position.flexibleConnectedTo(this.timewindowPanelOrigin.elementRef) + .withPositions([connectedPosition]); + } else { + config.minWidth = '100%'; + config.minHeight = '100%'; + config.positionStrategy = position.global().top('0%').left('0%') + .right('0%').bottom('0%'); + } + + const overlayRef = this.overlay.create(config); + + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const injector = this._createTimewindowPanelInjector( + overlayRef, + { + timewindow: deepClone(this.innerValue), + historyOnly: this.historyOnly, + quickIntervalOnly: this.quickIntervalOnly, + aggregation: this.aggregation, + timezone: this.timezone, + isEdit: this.isEdit + } + ); + + const componentRef = overlayRef.attach(new ComponentPortal(TimewindowPanelComponent, this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result) { + this.innerValue = componentRef.instance.result; + this.timewindowDisabled = this.isTimewindowDisabled(); + this.updateDisplayValue(); + this.notifyChanged(); + } + }); + } + + private _createTimewindowPanelInjector(overlayRef: OverlayRef, data: TimewindowPanelData): Injector { + const providers: StaticProvider[] = [ + {provide: TIMEWINDOW_PANEL_DATA, useValue: data}, + {provide: OverlayRef, useValue: overlayRef} + ]; + return Injector.create({parent: this.viewContainerRef.injector, providers}); + } + + private onHistoryOnlyChanged(): boolean { + if (this.historyOnlyValue && this.innerValue) { + if (this.innerValue.selectedTab !== TimewindowType.HISTORY) { + this.innerValue.selectedTab = TimewindowType.HISTORY; + this.updateDisplayValue(); + return true; + } + } + return false; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.timewindowDisabled = this.isTimewindowDisabled(); + } + + writeValue(obj: Timewindow): void { + this.innerValue = initModelFromDefaultTimewindow(obj, this.quickIntervalOnly, this.timeService); + this.timewindowDisabled = this.isTimewindowDisabled(); + if (this.onHistoryOnlyChanged()) { + setTimeout(() => { + this.notifyChanged(); + }); + } else { + this.updateDisplayValue(); + } + } + + notifyChanged() { + this.propagateChange(cloneSelectedTimewindow(this.innerValue)); + } + + updateDisplayValue() { + if (this.innerValue.selectedTab === TimewindowType.REALTIME && !this.historyOnly) { + this.innerValue.displayValue = this.translate.instant('timewindow.realtime') + ' - '; + if (this.innerValue.realtime.realtimeType === RealtimeWindowType.INTERVAL) { + this.innerValue.displayValue += this.translate.instant(QuickTimeIntervalTranslationMap.get(this.innerValue.realtime.quickInterval)); + } else { + this.innerValue.displayValue += this.translate.instant('timewindow.last-prefix') + ' ' + + this.millisecondsToTimeStringPipe.transform(this.innerValue.realtime.timewindowMs); + } + } else { + this.innerValue.displayValue = (!this.historyOnly || this.alwaysDisplayTypePrefix) ? (this.translate.instant('timewindow.history') + ' - ') : ''; + if (this.innerValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { + this.innerValue.displayValue += this.translate.instant('timewindow.last-prefix') + ' ' + + this.millisecondsToTimeStringPipe.transform(this.innerValue.history.timewindowMs); + } else if (this.innerValue.history.historyType === HistoryWindowType.INTERVAL) { + this.innerValue.displayValue += this.translate.instant(QuickTimeIntervalTranslationMap.get(this.innerValue.history.quickInterval)); + } else { + const startString = this.datePipe.transform(this.innerValue.history.fixedTimewindow.startTimeMs, 'yyyy-MM-dd HH:mm:ss'); + const endString = this.datePipe.transform(this.innerValue.history.fixedTimewindow.endTimeMs, 'yyyy-MM-dd HH:mm:ss'); + this.innerValue.displayValue += this.translate.instant('timewindow.period', {startTime: startString, endTime: endString}); + } + } + if (isDefinedAndNotNull(this.innerValue.timezone) && this.innerValue.timezone !== '') { + this.innerValue.displayValue += ' '; + this.innerValue.displayTimezoneAbbr = getTimezoneInfo(this.innerValue.timezone).abbr; + } else { + this.innerValue.displayTimezoneAbbr = ''; + } + this.cd.detectChanges(); + } + + hideLabel() { + return this.isToolbar && !this.breakpointObserver.isMatched(MediaBreakpoints['gt-md']); + } + + private isTimewindowDisabled(): boolean { + return this.disabled || + (!this.isEdit && (!this.innerValue || this.innerValue.hideInterval && + (!this.aggregation || this.innerValue.hideAggregation && this.innerValue.hideAggInterval))); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timezone-select.component.html b/ui-ngx/src/app/shared/components/time/timezone-select.component.html new file mode 100644 index 0000000..2ba9fd6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone-select.component.html @@ -0,0 +1,50 @@ + + + timezone.timezone + + + + + + + + + {{ translate.get('timezone.no-timezones-matching', {timezone: searchText}) | async }} + + + + + {{ 'timezone.timezone-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/time/timezone-select.component.ts b/ui-ngx/src/app/shared/components/time/timezone-select.component.ts new file mode 100644 index 0000000..6cf28ab --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timezone-select.component.ts @@ -0,0 +1,250 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AfterViewInit, Component, forwardRef, Input, NgZone, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatFormFieldAppearance } from '@angular/material/form-field/form-field'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, share, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { getDefaultTimezoneInfo, getTimezoneInfo, getTimezones, TimezoneInfo } from '@shared/models/time/time.models'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-timezone-select', + templateUrl: './timezone-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimezoneSelectComponent), + multi: true + }] +}) +export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + selectTimezoneFormGroup: FormGroup; + + modelValue: string | null; + + defaultTimezoneId: string = null; + + @Input() + appearance: MatFormFieldAppearance = 'legacy'; + + @Input() + set defaultTimezone(timezone: string) { + if (this.defaultTimezoneId !== timezone) { + this.defaultTimezoneId = timezone; + } + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + private userTimezoneByDefaultValue: boolean; + get userTimezoneByDefault(): boolean { + return this.userTimezoneByDefaultValue; + } + @Input() + set userTimezoneByDefault(value: boolean) { + this.userTimezoneByDefaultValue = coerceBooleanProperty(value); + } + + private localBrowserTimezonePlaceholderOnEmptyValue: boolean; + get localBrowserTimezonePlaceholderOnEmpty(): boolean { + return this.localBrowserTimezonePlaceholderOnEmptyValue; + } + @Input() + set localBrowserTimezonePlaceholderOnEmpty(value: boolean) { + this.localBrowserTimezonePlaceholderOnEmptyValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('timezoneInput', {static: true, read: MatAutocompleteTrigger}) timezoneInputTrigger: MatAutocompleteTrigger; + + filteredTimezones: Observable>; + + searchText = ''; + + ignoreClosePanel = false; + + private dirty = false; + + private localBrowserTimezoneInfoPlaceholder: TimezoneInfo; + + private timezones: Array; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private ngZone: NgZone, + private fb: FormBuilder) { + this.selectTimezoneFormGroup = this.fb.group({ + timezone: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredTimezones = this.selectTimezoneFormGroup.get('timezone').valueChanges + .pipe( + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value.id; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchTimezones(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectTimezoneFormGroup.disable({emitEvent: false}); + } else { + this.selectTimezoneFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + const foundTimezone = getTimezoneInfo(value, this.defaultTimezoneId, this.userTimezoneByDefaultValue); + if (foundTimezone !== null) { + this.selectTimezoneFormGroup.get('timezone').patchValue(foundTimezone, {emitEvent: false}); + if (foundTimezone.id !== value) { + setTimeout(() => { + this.updateView(foundTimezone.id); + }, 0); + } else { + this.modelValue = value; + } + } else { + this.modelValue = null; + if (this.localBrowserTimezonePlaceholderOnEmptyValue) { + this.selectTimezoneFormGroup.get('timezone').patchValue(this.getLocalBrowserTimezoneInfoPlaceholder(), {emitEvent: false}); + } else { + this.selectTimezoneFormGroup.get('timezone').patchValue('', {emitEvent: false}); + } + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.selectTimezoneFormGroup.get('timezone').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + onPanelClosed() { + if (this.ignoreClosePanel) { + this.ignoreClosePanel = false; + } else { + if (!this.modelValue) { + if (this.defaultTimezoneId || this.userTimezoneByDefaultValue) { + const defaultTimezoneInfo = getTimezoneInfo(this.defaultTimezoneId, this.defaultTimezoneId, this.userTimezoneByDefaultValue); + if (defaultTimezoneInfo !== null) { + this.ngZone.run(() => { + this.selectTimezoneFormGroup.get('timezone').reset(defaultTimezoneInfo, {emitEvent: true}); + }); + } + } else if (this.localBrowserTimezonePlaceholderOnEmptyValue) { + this.ngZone.run(() => { + this.selectTimezoneFormGroup.get('timezone').reset(this.getLocalBrowserTimezoneInfoPlaceholder(), {emitEvent: true}); + }); + } + } + } + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayTimezoneFn(timezone?: TimezoneInfo): string | undefined { + return timezone ? `${timezone.name} (${timezone.offset})` : undefined; + } + + fetchTimezones(searchText?: string): Observable> { + this.searchText = searchText; + if (searchText && searchText.length) { + return of(this.loadTimezones().filter((timezoneInfo) => + timezoneInfo.name.toLowerCase().includes(searchText.toLowerCase()))); + } + return of(this.loadTimezones()); + } + + clear() { + this.selectTimezoneFormGroup.get('timezone').patchValue('', {emitEvent: true}); + setTimeout(() => { + this.timezoneInputTrigger.openPanel(); + }, 0); + } + + private loadTimezones(): Array { + if (!this.timezones) { + this.timezones = []; + if (this.localBrowserTimezonePlaceholderOnEmptyValue) { + this.timezones.push(this.getLocalBrowserTimezoneInfoPlaceholder()); + } + this.timezones.push(...getTimezones()); + } + return this.timezones; + } + + private getLocalBrowserTimezoneInfoPlaceholder(): TimezoneInfo { + if (!this.localBrowserTimezoneInfoPlaceholder) { + this.localBrowserTimezoneInfoPlaceholder = deepClone(getDefaultTimezoneInfo()); + this.localBrowserTimezoneInfoPlaceholder.id = null; + this.localBrowserTimezoneInfoPlaceholder.name = this.translate.instant('timezone.browser-time'); + } + return this.localBrowserTimezoneInfoPlaceholder; + } +} diff --git a/ui-ngx/src/app/shared/components/toast.directive.ts b/ui-ngx/src/app/shared/components/toast.directive.ts new file mode 100644 index 0000000..5895dc4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/toast.directive.ts @@ -0,0 +1,376 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, ChangeDetectorRef, + Component, ComponentFactoryResolver, ComponentRef, + Directive, + ElementRef, HostBinding, + Inject, + Injector, + Input, + NgZone, + OnDestroy, Optional, + StaticProvider, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { MAT_SNACK_BAR_DATA, MatSnackBar, MatSnackBarConfig, MatSnackBarRef } from '@angular/material/snack-bar'; +import { NotificationMessage } from '@app/core/notification/notification.models'; +import { Subscription } from 'rxjs'; +import { NotificationService } from '@app/core/services/notification.service'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { MatButton } from '@angular/material/button'; +import Timeout = NodeJS.Timeout; + +@Directive({ + selector: '[tb-toast]' +}) +export class ToastDirective implements AfterViewInit, OnDestroy { + + @Input() + toastTarget = 'root'; + + private notificationSubscription: Subscription = null; + private hideNotificationSubscription: Subscription = null; + + private snackBarRef: MatSnackBarRef = null; + private toastComponentRef: ComponentRef; + private currentMessage: NotificationMessage = null; + + private dismissTimeout: Timeout = null; + + constructor(private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private notificationService: NotificationService, + private componentFactoryResolver: ComponentFactoryResolver, + private snackBar: MatSnackBar, + private ngZone: NgZone, + private breakpointObserver: BreakpointObserver, + private cd: ChangeDetectorRef) { + } + + ngAfterViewInit(): void { + this.notificationSubscription = this.notificationService.getNotification().subscribe( + (notificationMessage) => { + if (this.shouldDisplayMessage(notificationMessage)) { + this.currentMessage = notificationMessage; + const isGtSm = this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']); + if (isGtSm && this.toastTarget !== 'root') { + this.showToastPanel(notificationMessage); + } else { + this.showSnackBar(notificationMessage, isGtSm); + } + } + } + ); + + this.hideNotificationSubscription = this.notificationService.getHideNotification().subscribe( + (hideNotification) => { + if (hideNotification) { + const target = hideNotification.target || 'root'; + if (this.toastTarget === target) { + this.ngZone.run(() => { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + if (this.toastComponentRef) { + this.toastComponentRef.instance.actionButton._elementRef.nativeElement.click(); + } + }); + } + } + } + ); + } + + private showToastPanel(notificationMessage: NotificationMessage) { + this.ngZone.run(() => { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + if (this.toastComponentRef) { + this.viewContainerRef.detach(0); + this.toastComponentRef.destroy(); + } + let panelClass = ['tb-toast-panel', 'toast-panel']; + if (notificationMessage.panelClass) { + if (typeof notificationMessage.panelClass === 'string') { + panelClass.push(notificationMessage.panelClass); + } else if (notificationMessage.panelClass.length) { + panelClass = panelClass.concat(notificationMessage.panelClass); + } + } + const horizontalPosition = notificationMessage.horizontalPosition || 'left'; + const verticalPosition = notificationMessage.verticalPosition || 'top'; + if (horizontalPosition === 'start' || horizontalPosition === 'left') { + panelClass.push('left'); + } else if (horizontalPosition === 'end' || horizontalPosition === 'right') { + panelClass.push('right'); + } else { + panelClass.push('h-center'); + } + if (verticalPosition === 'top') { + panelClass.push('top'); + } else { + panelClass.push('bottom'); + } + + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(TbSnackBarComponent); + const data: ToastPanelData = { + notification: notificationMessage, + panelClass, + destroyToastComponent: () => { + this.viewContainerRef.detach(0); + this.toastComponentRef.destroy(); + } + }; + const providers: StaticProvider[] = [ + {provide: MAT_SNACK_BAR_DATA, useValue: data} + ]; + const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); + this.toastComponentRef = this.viewContainerRef.createComponent(componentFactory, 0, injector); + this.cd.detectChanges(); + + if (notificationMessage.duration && notificationMessage.duration > 0) { + if (this.dismissTimeout !== null) { + clearTimeout(this.dismissTimeout); + this.dismissTimeout = null; + } + this.dismissTimeout = setTimeout(() => { + if (this.toastComponentRef) { + this.toastComponentRef.instance.actionButton._elementRef.nativeElement.click(); + } + this.dismissTimeout = null; + }, notificationMessage.duration + 500); + } + this.toastComponentRef.onDestroy(() => { + if (this.dismissTimeout !== null) { + clearTimeout(this.dismissTimeout); + this.dismissTimeout = null; + } + this.toastComponentRef = null; + this.currentMessage = null; + }); + }); + } + + private showSnackBar(notificationMessage: NotificationMessage, isGtSm: boolean) { + this.ngZone.run(() => { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + const data: ToastPanelData = { + notification: notificationMessage, + parent: this.elementRef, + panelClass: [], + destroyToastComponent: () => {} + }; + const config: MatSnackBarConfig = { + horizontalPosition: notificationMessage.horizontalPosition || 'left', + verticalPosition: !isGtSm ? 'bottom' : (notificationMessage.verticalPosition || 'top'), + viewContainerRef: this.viewContainerRef, + duration: notificationMessage.duration, + panelClass: notificationMessage.panelClass, + data + }; + this.snackBarRef = this.snackBar.openFromComponent(TbSnackBarComponent, config); + if (notificationMessage.duration && notificationMessage.duration > 0 && notificationMessage.forceDismiss) { + if (this.dismissTimeout !== null) { + clearTimeout(this.dismissTimeout); + this.dismissTimeout = null; + } + this.dismissTimeout = setTimeout(() => { + if (this.snackBarRef) { + this.snackBarRef.instance.actionButton._elementRef.nativeElement.click(); + } + this.dismissTimeout = null; + }, notificationMessage.duration); + } + this.snackBarRef.afterDismissed().subscribe(() => { + if (this.dismissTimeout !== null) { + clearTimeout(this.dismissTimeout); + this.dismissTimeout = null; + } + this.snackBarRef = null; + this.currentMessage = null; + }); + }); + } + + private shouldDisplayMessage(notificationMessage: NotificationMessage): boolean { + if (notificationMessage && notificationMessage.message) { + const target = notificationMessage.target || 'root'; + if (this.toastTarget === target) { + if (!this.currentMessage || this.currentMessage.message !== notificationMessage.message + || this.currentMessage.type !== notificationMessage.type) { + return true; + } + } + } + return false; + } + + ngOnDestroy(): void { + if (this.toastComponentRef) { + this.viewContainerRef.detach(0); + this.toastComponentRef.destroy(); + } + if (this.notificationSubscription) { + this.notificationSubscription.unsubscribe(); + } + if (this.hideNotificationSubscription) { + this.hideNotificationSubscription.unsubscribe(); + } + } +} + +interface ToastPanelData { + notification: NotificationMessage; + parent?: ElementRef; + panelClass: string[]; + destroyToastComponent: () => void; +} + +import { + AnimationTriggerMetadata, + AnimationEvent, + trigger, + state, + transition, + style, + animate, +} from '@angular/animations'; +import { onParentScrollOrWindowResize } from '@core/utils'; + +export const toastAnimations: { + readonly showHideToast: AnimationTriggerMetadata; +} = { + showHideToast: trigger('showHideAnimation', [ + state('in', style({ transform: 'scale(1)', opacity: 1 })), + transition('void => opened', [style({ transform: 'scale(0)', opacity: 0 }), animate('{{ open }}ms')]), + transition( + 'opened => closing', + animate('{{ close }}ms', style({ transform: 'scale(0)', opacity: 0 })), + ), + ]), +}; + +export type ToastAnimationState = 'default' | 'opened' | 'closing'; + +@Component({ + selector: 'tb-snack-bar-component', + templateUrl: 'snack-bar-component.html', + styleUrls: ['snack-bar-component.scss'], + animations: [toastAnimations.showHideToast] +}) +export class TbSnackBarComponent implements AfterViewInit, OnDestroy { + + @ViewChild('actionButton', {static: true}) actionButton: MatButton; + + @HostBinding('class') + get panelClass(): string[] { + return this.data.panelClass; + } + + private parentEl: HTMLElement; + private snackBarContainerEl: HTMLElement; + private parentScrollSubscription: Subscription = null; + + public notification: NotificationMessage; + + animationState: ToastAnimationState; + + animationParams = { + open: 100, + close: 100 + }; + + constructor(@Inject(MAT_SNACK_BAR_DATA) + private data: ToastPanelData, + private elementRef: ElementRef, + @Optional() + private snackBarRef: MatSnackBarRef) { + this.animationState = !!this.snackBarRef ? 'default' : 'opened'; + this.notification = data.notification; + } + + ngAfterViewInit() { + if (this.snackBarRef) { + this.parentEl = this.data.parent.nativeElement; + this.snackBarContainerEl = $(this.elementRef.nativeElement).closest('snack-bar-container')[0]; + this.snackBarContainerEl.style.position = 'absolute'; + this.updateContainerRect(); + this.updatePosition(this.snackBarRef.containerInstance.snackBarConfig); + this.parentScrollSubscription = onParentScrollOrWindowResize(this.parentEl).subscribe(() => { + this.updateContainerRect(); + }); + } + } + + private updatePosition(config: MatSnackBarConfig) { + const isRtl = config.direction === 'rtl'; + const isLeft = (config.horizontalPosition === 'left' || + (config.horizontalPosition === 'start' && !isRtl) || + (config.horizontalPosition === 'end' && isRtl)); + const isRight = !isLeft && config.horizontalPosition !== 'center'; + if (isLeft) { + this.snackBarContainerEl.style.justifyContent = 'flex-start'; + } else if (isRight) { + this.snackBarContainerEl.style.justifyContent = 'flex-end'; + } else { + this.snackBarContainerEl.style.justifyContent = 'center'; + } + if (config.verticalPosition === 'top') { + this.snackBarContainerEl.style.alignItems = 'flex-start'; + } else { + this.snackBarContainerEl.style.alignItems = 'flex-end'; + } + } + + private updateContainerRect() { + const viewportOffset = this.parentEl.getBoundingClientRect(); + this.snackBarContainerEl.style.top = viewportOffset.top + 'px'; + this.snackBarContainerEl.style.left = viewportOffset.left + 'px'; + this.snackBarContainerEl.style.width = viewportOffset.width + 'px'; + this.snackBarContainerEl.style.height = viewportOffset.height + 'px'; + } + + ngOnDestroy() { + if (this.parentScrollSubscription) { + this.parentScrollSubscription.unsubscribe(); + } + } + + action(event: MouseEvent): void { + event.stopPropagation(); + if (this.snackBarRef) { + this.snackBarRef.dismissWithAction(); + } else { + this.animationState = 'closing'; + } + } + + onHideFinished(event: AnimationEvent) { + const { toState } = event; + const isFadeOut = (toState as ToastAnimationState) === 'closing'; + const itFinished = this.animationState === 'closing'; + if (isFadeOut && itFinished) { + this.data.destroyToastComponent(); + } + } +} diff --git a/ui-ngx/src/app/shared/components/tokens.ts b/ui-ngx/src/app/shared/components/tokens.ts new file mode 100644 index 0000000..0c59ace --- /dev/null +++ b/ui-ngx/src/app/shared/components/tokens.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { InjectionToken, Type } from '@angular/core'; +import { ComponentType } from '@angular/cdk/portal'; + +export const HELP_MARKDOWN_COMPONENT_TOKEN: InjectionToken> = + new InjectionToken>('HELP_MARKDOWN_COMPONENT_TOKEN'); + +export const SHARED_MODULE_TOKEN: InjectionToken> = + new InjectionToken>('SHARED_MODULE_TOKEN'); diff --git a/ui-ngx/src/app/shared/components/user-menu.component.html b/ui-ngx/src/app/shared/components/user-menu.component.html new file mode 100644 index 0000000..8ed9adb --- /dev/null +++ b/ui-ngx/src/app/shared/components/user-menu.component.html @@ -0,0 +1,45 @@ + +
    + + + +
    + + + +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/user-menu.component.scss b/ui-ngx/src/app/shared/components/user-menu.component.scss new file mode 100644 index 0000000..b0d3acb --- /dev/null +++ b/ui-ngx/src/app/shared/components/user-menu.component.scss @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + div.tb-user-info { + line-height: 1.5; + + span { + text-transform: none; + } + + span.tb-user-display-name { + font-size: .8rem; + font-weight: 300; + letter-spacing: .008em; + } + + span.tb-user-authority { + font-size: .8rem; + font-weight: 300; + letter-spacing: .005em; + opacity: .8; + } + + } + + mat-icon.tb-mini-avatar { + width: 36px; + height: 36px; + margin: auto 8px; + font-size: 36px; + cursor: default; + } +} + +.tb-user-menu-items { + min-width: 256px; +} diff --git a/ui-ngx/src/app/shared/components/user-menu.component.ts b/ui-ngx/src/app/shared/components/user-menu.component.ts new file mode 100644 index 0000000..7a37ece --- /dev/null +++ b/ui-ngx/src/app/shared/components/user-menu.component.ts @@ -0,0 +1,117 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { User } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { selectAuthUser, selectUserDetails } from '@core/auth/auth.selectors'; +import { map } from 'rxjs/operators'; +import { AuthService } from '@core/auth/auth.service'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'tb-user-menu', + templateUrl: './user-menu.component.html', + styleUrls: ['./user-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UserMenuComponent implements OnInit, OnDestroy { + + @Input() displayUserInfo: boolean; + + authorities = Authority; + + authority$ = this.store.pipe( + select(selectAuthUser), + map((authUser) => authUser ? authUser.authority : Authority.ANONYMOUS) + ); + + authorityName$ = this.store.pipe( + select(selectUserDetails), + map((user) => this.getAuthorityName(user)) + ); + + userDisplayName$ = this.store.pipe( + select(selectUserDetails), + map((user) => this.getUserDisplayName(user)) + ); + + constructor(private store: Store, + private router: Router, + private authService: AuthService) { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + } + + getAuthorityName(user: User): string { + let name = null; + if (user) { + const authority = user.authority; + switch (authority) { + case Authority.SYS_ADMIN: + name = 'user.sys-admin'; + break; + case Authority.TENANT_ADMIN: + name = 'user.tenant-admin'; + break; + case Authority.CUSTOMER_USER: + name = 'user.customer'; + break; + } + } + return name; + } + + getUserDisplayName(user: User): string { + let name = ''; + if (user) { + if ((user.firstName && user.firstName.length > 0) || + (user.lastName && user.lastName.length > 0)) { + if (user.firstName) { + name += user.firstName; + } + if (user.lastName) { + if (name.length > 0) { + name += ' '; + } + name += user.lastName; + } + } else { + name = user.email; + } + } + return name; + } + + openProfile(): void { + this.router.navigate(['profile']); + } + + openSecurity(): void { + this.router.navigate(['security']); + } + + logout(): void { + this.authService.logout(); + } + +} diff --git a/ui-ngx/src/app/shared/components/value-input.component.html b/ui-ngx/src/app/shared/components/value-input.component.html new file mode 100644 index 0000000..fd02c0b --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.html @@ -0,0 +1,79 @@ + +
    +
    + + value.type + + + + {{ valueTypes.get(valueType).name | translate }} + + + + {{ valueTypes.get(valueTypeEnum[valueType]).name | translate }} + + + + + value.string-value + + + {{ (requiredText ? requiredText : 'value.string-value-required') | translate }} + + + + value.integer-value + + + {{ (requiredText ? requiredText : 'value.integer-value-required') | translate }} + + + {{ 'value.invalid-integer-value' | translate }} + + + + value.double-value + + + {{ (requiredText ? requiredText : 'value.double-value-required') | translate }} + + +
    + + {{ (modelValue ? 'value.true' : 'value.false') | translate }} + +
    +
    + + value.json-value + + + + {{ (requiredText ? requiredText : 'value.json-value-required') | translate }} + + + {{ 'value.json-value-invalid' | translate }} + + +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/value-input.component.scss b/ui-ngx/src/app/shared/components/value-input.component.scss new file mode 100644 index 0000000..1b7270b --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + mat-form-field.tb-value-type { + .mat-form-field-infix { + padding-bottom: 1px; + } + mat-select-trigger { + .mat-icon { + vertical-align: middle; + margin-right: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts new file mode 100644 index 0000000..946d320 --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -0,0 +1,140 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms'; +import { ValueType, valueTypesMap } from '@shared/models/constants'; +import { isObject } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { + JsonObjectEditDialogComponent, + JsonObjectEditDialogData +} from '@shared/components/dialog/json-object-edit-dialog.component'; + +@Component({ + selector: 'tb-value-input', + templateUrl: './value-input.component.html', + styleUrls: ['./value-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ValueInputComponent), + multi: true + } + ] +}) +export class ValueInputComponent implements OnInit, ControlValueAccessor { + + @Input() disabled: boolean; + + @Input() requiredText: string; + + @ViewChild('inputForm', {static: true}) inputForm: NgForm; + + modelValue: any; + + valueType: ValueType; + + public valueTypeEnum = ValueType; + + valueTypeKeys = Object.keys(ValueType); + + valueTypes = valueTypesMap; + + private propagateChange = null; + + constructor( + public dialog: MatDialog, + ) { + + } + + ngOnInit(): void { + } + + openEditJSONDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: this.modelValue + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.modelValue = res; + this.inputForm.control.patchValue({value: this.modelValue}); + } + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.modelValue = value; + if (this.modelValue === true || this.modelValue === false) { + this.valueType = ValueType.BOOLEAN; + } else if (typeof this.modelValue === 'number') { + if (this.modelValue.toString().indexOf('.') === -1) { + this.valueType = ValueType.INTEGER; + } else { + this.valueType = ValueType.DOUBLE; + } + } else if (isObject(this.modelValue)) { + this.valueType = ValueType.JSON; + } else { + this.valueType = ValueType.STRING; + } + } + + updateView() { + if (this.inputForm.valid || this.valueType === ValueType.BOOLEAN) { + this.propagateChange(this.modelValue); + } else { + this.propagateChange(null); + } + } + + onValueTypeChanged() { + if (this.valueType === ValueType.BOOLEAN) { + this.modelValue = false; + } else if (this.valueType === ValueType.JSON) { + this.modelValue = {}; + this.inputForm.form.get('value').patchValue({}); + } else { + this.modelValue = null; + } + this.updateView(); + } + + onValueChanged() { + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html new file mode 100644 index 0000000..7f9c403 --- /dev/null +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html @@ -0,0 +1,49 @@ + + + {{ 'version-control.branch' | translate }} + + + + + check + + + {{ 'version-control.default' | translate }} + + + + {{ 'version-control.branch-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss new file mode 100644 index 0000000..ce72d8e --- /dev/null +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.mat-option.branch-option { + .mat-icon, .check-placeholder { + margin-right: 8px; + } + .check-placeholder { + width: 18px; + display: inline-block; + } + .mat-option-text { + width: 100%; + .default-branch { + float: right; + } + } +} diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts new file mode 100644 index 0000000..497dcbc --- /dev/null +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts @@ -0,0 +1,296 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + NgZone, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { BranchInfo } from '@shared/models/vc.models'; +import { EntitiesVersionControlService } from '@core/http/entities-version-control.service'; +import { isNotEmptyStr } from '@core/utils'; +import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; + +@Component({ + selector: 'tb-branch-autocomplete', + templateUrl: './branch-autocomplete.component.html', + styleUrls: ['./branch-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BranchAutocompleteComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None +}) +export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + branchFormGroup: FormGroup; + + modelValue: string | null; + + private requiredValue: boolean; + + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + private disabledValue: boolean; + + get disabled(): boolean { + return this.disabledValue; + } + + @Input() + set disabled(value: boolean) { + this.disabledValue = coerceBooleanProperty(value); + if (this.disabledValue) { + this.branchFormGroup.disable({emitEvent: false}); + } else { + this.branchFormGroup.enable({emitEvent: false}); + } + } + + @Input() + selectDefaultBranch = true; + + @Input() + selectionMode = false; + + @Input() + emptyPlaceholder: string; + + @ViewChild('branchAutocomplete') matAutocomplete: MatAutocomplete; + @ViewChild('branchInput', { read: MatAutocompleteTrigger, static: true }) autoCompleteTrigger: MatAutocompleteTrigger; + @ViewChild('branchInput', {static: true}) branchInput: ElementRef; + + filteredBranches: Observable>; + + defaultBranch: BranchInfo = null; + + searchText = ''; + + loading = false; + + private dirty = false; + + private clearButtonClicked = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private entitiesVersionControlService: EntitiesVersionControlService, + private fb: FormBuilder, + private zone: NgZone) { + this.branchFormGroup = this.fb.group({ + branch: [null, []] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredBranches = this.branchFormGroup.get('branch').valueChanges + .pipe( + tap((value: BranchInfo | string) => { + let modelValue: BranchInfo | null; + if (typeof value === 'string' || !value) { + if (!this.selectionMode && typeof value === 'string' && isNotEmptyStr(value)) { + modelValue = {name: value, default: false}; + } else { + modelValue = null; + } + } else { + modelValue = value; + } + if (!this.selectionMode || modelValue) { + this.updateView(modelValue); + } + }), + map(value => { + if (value) { + if (typeof value === 'string') { + return value; + } else { + return value.name; + } + } else { + return ''; + } + }), + debounceTime(150), + distinctUntilChanged(), + switchMap(name => this.fetchBranches(name)), + share() + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + isDefaultBranchSelected(): boolean { + return this.defaultBranch && this.defaultBranch.name === this.modelValue; + } + + selectDefaultBranchIfNeeded(force = false): void { + if ((this.selectDefaultBranch && !this.modelValue) || force) { + setTimeout(() => { + if (this.defaultBranch) { + this.branchFormGroup.get('branch').patchValue(this.defaultBranch, {emitEvent: false}); + this.modelValue = this.defaultBranch?.name; + this.propagateChange(this.modelValue); + } else { + this.loading = true; + this.getBranches().subscribe( + () => { + if (this.defaultBranch || force) { + this.branchFormGroup.get('branch').patchValue(this.defaultBranch, {emitEvent: false}); + this.modelValue = this.defaultBranch?.name; + this.propagateChange(this.modelValue); + this.loading = false; + } else { + this.loading = false; + } + } + ); + } + }); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + this.modelValue = value; + if (value != null) { + this.branchFormGroup.get('branch').patchValue({name: value}, {emitEvent: false}); + } else { + this.branchFormGroup.get('branch').patchValue(null, {emitEvent: false}); + this.selectDefaultBranchIfNeeded(); + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.branchFormGroup.get('branch').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + onBlur() { + if (this.clearButtonClicked) { + this.clearButtonClicked = false; + } else if (!this.matAutocomplete.isOpen) { + this.selectAvailableValue(); + } + } + + onPanelClosed() { + this.selectAvailableValue(); + } + + selectAvailableValue() { + if (this.selectionMode) { + const branch = this.branchFormGroup.get('branch').value; + this.getBranches().pipe( + map(branches => { + let foundBranch = branches.find(b => b.name === branch); + if (!foundBranch && isNotEmptyStr(this.modelValue)) { + foundBranch = branches.find(b => b.name === this.modelValue); + } + return foundBranch; + }) + ).subscribe((val) => { + if (!val && this.defaultBranch) { + val = this.defaultBranch; + } + this.zone.run(() => { + this.branchFormGroup.get('branch').patchValue(val, {emitEvent: true}); + }, 0); + }); + } + } + + updateView(value: BranchInfo | null) { + if (this.modelValue !== value?.name) { + this.modelValue = value?.name; + this.propagateChange(this.modelValue); + } + } + + displayBranchFn(branch?: BranchInfo): string | undefined { + return branch ? branch.name : undefined; + } + + private fetchBranches(searchText?: string): Observable> { + this.searchText = searchText; + return this.getBranches().pipe( + map(branches => { + let res = branches.filter(branch => { + return searchText ? branch.name.toUpperCase().startsWith(searchText.toUpperCase()) : true; + }); + if (!this.selectionMode && isNotEmptyStr(searchText) && !res.find(b => b.name === searchText)) { + res = [{name: searchText, default: false}, ...res]; + } + return res; + } + ) + ); + } + + private getBranches(): Observable> { + return this.entitiesVersionControlService.listBranches().pipe( + tap((data) => { + this.defaultBranch = data.find(branch => branch.default); + }) + ); + } + + clear() { + this.clearButtonClicked = true; + setTimeout(() => { + this.branchFormGroup.get('branch').patchValue(null, {emitEvent: true}); + this.branchInput.nativeElement.blur(); + this.branchInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html new file mode 100644 index 0000000..4790d7e --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html @@ -0,0 +1,29 @@ + +
    + search + + +
    diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss new file mode 100644 index 0000000..ab7fc61 --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.scss @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.input-wrapper { + background: hsla(0, 0%, 100%, .2); + border-radius: 8px; + color: rgb(255, 255, 255, .84); + padding: 5px 8px 5px 16px; + height: 46px; + + input { + width: 100%; + height: 100%; + padding: 0; + font-size: 20px; + outline: none; + border: none; + background-color: transparent; + color: #fff; + + &::placeholder { + color: #fff; + opacity: .6; + line-height: 26px; + } + } + + &.focus { + background: #fff; + color: rgba(0,0,0,.54); + + input { + color: #000; + opacity: .60; + } + + .close { + color: #000 !important; + opacity: .54; + } + } +} + diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts new file mode 100644 index 0000000..ff890ed --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts @@ -0,0 +1,74 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'tb-widgets-bundle-search', + templateUrl: './widgets-bundle-search.component.html', + styleUrls: ['./widgets-bundle-search.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetsBundleSearchComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None +}) +export class WidgetsBundleSearchComponent implements ControlValueAccessor { + + searchText: string; + focus = false; + + @Input() placeholder: string; + + @ViewChild('searchInput') searchInput: ElementRef; + + private propagateChange = (v: any) => { }; + + constructor() { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + writeValue(value: string | null): void { + this.searchText = value; + } + + updateSearchText(): void { + this.updateView(); + } + + private updateView() { + this.propagateChange(this.searchText); + } + + clear($event: Event): void { + $event.preventDefault(); + $event.stopPropagation(); + this.searchText = ''; + this.updateView(); + } + + toggleFocus() { + this.focus = !this.focus; + } +} diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html new file mode 100644 index 0000000..01b324e --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html @@ -0,0 +1,40 @@ + + + + + +
    + {{widgetsBundle?.title}} + widgets-bundle.system +
    +
    + +
    + {{widgetsBundle.title}} + widgets-bundle.system +
    +
    +
    +
    diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.scss b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.scss new file mode 100644 index 0000000..4f0a5c2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.scss @@ -0,0 +1,112 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../scss/constants'; + +tb-widgets-bundle-select { + mat-select { + margin: 0; + } + + .tb-bundle-item { + height: 26px; + line-height: 26px; + } +} + +.tb-widgets-bundle-select { + .tb-bundle-item { + height: 48px; + line-height: 48px; + } +} + +tb-widgets-bundle-select, +.tb-widgets-bundle-select { + .mat-select-value-text { + display: block; + width: 100%; + } + + .tb-bundle-item { + display: inline-block; + width: 100%; + + span { + display: inline-block; + vertical-align: middle; + } + + .tb-bundle-system { + float: right; + font-size: .8rem; + opacity: .8; + } + } + + mat-option { + height: auto !important; + white-space: normal !important; + } +} + +mat-toolbar { + tb-widgets-bundle-select { + .mat-form-field-wrapper { + padding-bottom: 0 !important; + .mat-form-field-infix { + background: rgba(255, 255, 255, .2); + padding: 5px 20px !important; + border: none; + + mat-select { + .mat-select-value-text { + font-size: 1.2rem; + color: #fff; + + span:first-child::after { + color: #fff; + } + } + + .mat-select-value { + vertical-align: middle; + min-height: 30px; + height: 30px; + padding: 2px 2px 1px; + .mat-select-placeholder { + color: #fff; + opacity: .8; + line-height: 26px; + } + } + } + + .mat-select.mat-select-invalid { + .mat-select-arrow { + color: #fff !important; + } + } + + @media #{$mat-xs} { + width: auto; + } + } + .mat-form-field-underline { + display: none; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts new file mode 100644 index 0000000..e1b403e --- /dev/null +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { map, share, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { WidgetService } from '@core/http/widget.service'; +import { isDefined } from '@core/utils'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; + +@Component({ + selector: 'tb-widgets-bundle-select', + templateUrl: './widgets-bundle-select.component.html', + styleUrls: ['./widgets-bundle-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetsBundleSelectComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None +}) +export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnInit, OnChanges { + + @Input() + bundlesScope: 'system' | 'tenant'; + + @Input() + selectFirstBundle: boolean; + + @Input() + selectBundleAlias: string; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + widgetsBundles$: Observable>; + + widgetsBundles: Array; + + widgetsBundle: WidgetsBundle | null; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private widgetService: WidgetService) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.widgetsBundles$ = this.getWidgetsBundles().pipe( + map((widgetsBundles) => { + const authState = getCurrentAuthState(this.store); + if (!authState.edgesSupportEnabled) { + widgetsBundles = widgetsBundles.filter(widgetsBundle => widgetsBundle.alias !== 'edge_widgets'); + } + return widgetsBundles; + }), + tap((widgetsBundles) => { + this.widgetsBundles = widgetsBundles; + if (this.selectFirstBundle) { + if (widgetsBundles.length > 0) { + if (this.widgetsBundle !== widgetsBundles[0]) { + this.widgetsBundle = widgetsBundles[0]; + this.updateView(); + } else if (isDefined(this.selectBundleAlias)) { + this.selectWidgetsBundleByAlias(this.selectBundleAlias); + } + } + } + }), + share() + ); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'selectBundleAlias') { + this.selectWidgetsBundleByAlias(this.selectBundleAlias); + } + } + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: WidgetsBundle | null): void { + this.widgetsBundle = value; + } + + widgetsBundleChanged() { + this.updateView(); + } + + isSystem(item: WidgetsBundle) { + return item && item.tenantId.id === NULL_UUID; + } + + private selectWidgetsBundleByAlias(alias: string) { + if (this.widgetsBundles && alias) { + const found = this.widgetsBundles.find((widgetsBundle) => widgetsBundle.alias === alias); + if (found && this.widgetsBundle !== found) { + this.widgetsBundle = found; + this.updateView(); + } + } else if (this.widgetsBundle) { + this.widgetsBundle = null; + this.updateView(); + } + } + + private updateView() { + this.propagateChange(this.widgetsBundle); + } + + private getWidgetsBundles(): Observable> { + let widgetsBundlesObservable: Observable>; + if (this.bundlesScope) { + if (this.bundlesScope === 'system') { + widgetsBundlesObservable = this.widgetService.getSystemWidgetsBundles(); + } else if (this.bundlesScope === 'tenant') { + widgetsBundlesObservable = this.widgetService.getTenantWidgetsBundles(); + } + } else { + widgetsBundlesObservable = this.widgetService.getAllWidgetsBundles(); + } + return widgetsBundlesObservable; + } + +} diff --git a/ui-ngx/src/app/shared/decorators/enumerable.ts b/ui-ngx/src/app/shared/decorators/enumerable.ts new file mode 100644 index 0000000..1e89ff0 --- /dev/null +++ b/ui-ngx/src/app/shared/decorators/enumerable.ts @@ -0,0 +1,25 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 function enumerable(value: boolean) { + return ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) => { + descriptor.enumerable = value; + }; +} diff --git a/ui-ngx/src/app/shared/decorators/tb-inject.ts b/ui-ngx/src/app/shared/decorators/tb-inject.ts new file mode 100644 index 0000000..6a082b9 --- /dev/null +++ b/ui-ngx/src/app/shared/decorators/tb-inject.ts @@ -0,0 +1,23 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Inject, Type } from '@angular/core'; + +export function TbInject(token: any): (target: Type, key: any, paramIndex: number) => void { + return (target: Type, key: any, paramIndex: number) => { + Inject(token)(target, key, paramIndex); + }; +} diff --git a/ui-ngx/src/app/shared/models/ace/ace.models.ts b/ui-ngx/src/app/shared/models/ace/ace.models.ts new file mode 100644 index 0000000..5b37c7c --- /dev/null +++ b/ui-ngx/src/app/shared/models/ace/ace.models.ts @@ -0,0 +1,348 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Ace } from 'ace-builds'; +import { Observable } from 'rxjs/internal/Observable'; +import { forkJoin, from, of } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; + +let aceDependenciesLoaded = false; +let aceModule: any; +let aceDiffModule: any; + +function loadAceDependencies(): Observable { + if (aceDependenciesLoaded) { + return of(null); + } else { + const aceObservables: Observable[] = []; + aceObservables.push(from(import('ace-builds/src-noconflict/ext-language_tools'))); + aceObservables.push(from(import('ace-builds/src-noconflict/ext-searchbox'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-java'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-css'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-json'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-javascript'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-text'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-html'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-c_cpp'))); + aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/css'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/json'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/javascript'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/text'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/markdown'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/html'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/c_cpp'))); + aceObservables.push(from(import('ace-builds/src-noconflict/snippets/protobuf'))); + aceObservables.push(from(import('ace-builds/src-noconflict/theme-textmate'))); + aceObservables.push(from(import('ace-builds/src-noconflict/theme-github'))); + return forkJoin(aceObservables).pipe( + tap(() => { + aceDependenciesLoaded = true; + }) + ); + } +} + +export function getAce(): Observable { + if (aceModule) { + return of(aceModule); + } else { + return from(import('ace')).pipe( + mergeMap((module) => { + return loadAceDependencies().pipe( + map(() => module) + ); + }), + tap((module) => { + aceModule = module; + }) + ); + } +} + +export function getAceDiff(): Observable { + if (aceDiffModule) { + return of(aceDiffModule); + } else { + return getAce().pipe( + mergeMap((ace) => { + return from(import('ace-diff')); + }), + tap((module) => { + aceDiffModule = module; + }) + ); + } +} + +export class Range implements Ace.Range { + + public start: Ace.Point; + public end: Ace.Point; + + constructor(startRow: number, startColumn: number, endRow: number, endColumn: number) { + this.start = { + row: startRow, + column: startColumn + }; + + this.end = { + row: endRow, + column: endColumn + }; + } + + static fromPoints(start: Ace.Point, end: Ace.Point): Ace.Range { + return new Range(start.row, start.column, end.row, end.column); + } + + clipRows(firstRow: number, lastRow: number): Ace.Range { + let end: Ace.Point; + let start: Ace.Point; + if (this.end.row > lastRow) { + end = {row: lastRow + 1, column: 0}; + } else if (this.end.row < firstRow) { + end = {row: firstRow, column: 0}; + } + + if (this.start.row > lastRow) { + start = {row: lastRow + 1, column: 0}; + } else if (this.start.row < firstRow) { + start = {row: firstRow, column: 0}; + } + return Range.fromPoints(start || this.start, end || this.end); + } + + clone(): Ace.Range { + return Range.fromPoints(this.start, this.end); + } + + collapseRows(): Ace.Range { + if (this.end.column === 0) { + return new Range(this.start.row, 0, Math.max(this.start.row, this.end.row - 1), 0); + } else { + return new Range(this.start.row, 0, this.end.row, 0); + } + } + + compare(row: number, column: number): number { + if (!this.isMultiLine()) { + if (row === this.start.row) { + return column < this.start.column ? -1 : (column > this.end.column ? 1 : 0); + } + } + + if (row < this.start.row) { + return -1; + } + + if (row > this.end.row) { + return 1; + } + + if (this.start.row === row) { + return column >= this.start.column ? 0 : -1; + } + + if (this.end.row === row) { + return column <= this.end.column ? 0 : 1; + } + + return 0; + } + + compareEnd(row: number, column: number): number { + if (this.end.row === row && this.end.column === column) { + return 1; + } else { + return this.compare(row, column); + } + } + + compareInside(row: number, column: number): number { + if (this.end.row === row && this.end.column === column) { + return 1; + } else if (this.start.row === row && this.start.column === column) { + return -1; + } else { + return this.compare(row, column); + } + } + + comparePoint(p: Ace.Point): number { + return this.compare(p.row, p.column); + } + + compareRange(range: Ace.Range): number { + let cmp: number; + const end = range.end; + const start = range.start; + + cmp = this.compare(end.row, end.column); + if (cmp === 1) { + cmp = this.compare(start.row, start.column); + if (cmp === 1) { + return 2; + } else if (cmp === 0) { + return 1; + } else { + return 0; + } + } else if (cmp === -1) { + return -2; + } else { + cmp = this.compare(start.row, start.column); + if (cmp === -1) { + return -1; + } else if (cmp === 1) { + return 42; + } else { + return 0; + } + } + } + + compareStart(row: number, column: number): number { + if (this.start.row === row && this.start.column === column) { + return -1; + } else { + return this.compare(row, column); + } + } + + contains(row: number, column: number): boolean { + return this.compare(row, column) === 0; + } + + containsRange(range: Ace.Range): boolean { + return this.comparePoint(range.start) === 0 && this.comparePoint(range.end) === 0; + } + + extend(row: number, column: number): Ace.Range { + const cmp = this.compare(row, column); + let end: Ace.Point; + let start: Ace.Point; + if (cmp === 0) { + return this; + } else if (cmp === -1) { + start = {row, column}; + } else { + end = {row, column}; + } + return Range.fromPoints(start || this.start, end || this.end); + } + + inside(row: number, column: number): boolean { + if (this.compare(row, column) === 0) { + if (this.isEnd(row, column) || this.isStart(row, column)) { + return false; + } else { + return true; + } + } + return false; + } + + insideEnd(row: number, column: number): boolean { + if (this.compare(row, column) === 0) { + if (this.isStart(row, column)) { + return false; + } else { + return true; + } + } + return false; + } + + insideStart(row: number, column: number): boolean { + if (this.compare(row, column) === 0) { + if (this.isEnd(row, column)) { + return false; + } else { + return true; + } + } + return false; + } + + intersects(range: Ace.Range): boolean { + const cmp = this.compareRange(range); + return (cmp === -1 || cmp === 0 || cmp === 1); + } + + isEmpty(): boolean { + return (this.start.row === this.end.row && this.start.column === this.end.column); + } + + isEnd(row: number, column: number): boolean { + return this.end.row === row && this.end.column === column; + } + + isEqual(range: Ace.Range): boolean { + return this.start.row === range.start.row && + this.end.row === range.end.row && + this.start.column === range.start.column && + this.end.column === range.end.column; + } + + isMultiLine(): boolean { + return (this.start.row !== this.end.row); + } + + isStart(row: number, column: number): boolean { + return this.start.row === row && this.start.column === column; + } + + moveBy(row: number, column: number): void { + this.start.row += row; + this.start.column += column; + this.end.row += row; + this.end.column += column; + } + + setEnd(row: number, column: number): void { + if (typeof row === 'object') { + this.end.column = (row as Ace.Point).column; + this.end.row = (row as Ace.Point).row; + } else { + this.end.row = row; + this.end.column = column; + } + } + + setStart(row: number, column: number): void { + if (typeof row === 'object') { + this.start.column = (row as Ace.Point).column; + this.start.row = (row as Ace.Point).row; + } else { + this.start.row = row; + this.start.column = column; + } + } + + toScreenRange(session: Ace.EditSession): Ace.Range { + const screenPosStart = session.documentToScreenPosition(this.start); + const screenPosEnd = session.documentToScreenPosition(this.end); + + return new Range( + screenPosStart.row, screenPosStart.column, + screenPosEnd.row, screenPosEnd.column + ); + } + +} diff --git a/ui-ngx/src/app/shared/models/ace/completion.models.ts b/ui-ngx/src/app/shared/models/ace/completion.models.ts new file mode 100644 index 0000000..c2b8013 --- /dev/null +++ b/ui-ngx/src/app/shared/models/ace/completion.models.ts @@ -0,0 +1,215 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Ace } from 'ace-builds'; + +export type tbMetaType = 'object' | 'function' | 'service' | 'property' | 'argument'; + +export type TbEditorCompletions = {[name: string]: TbEditorCompletion}; + +export interface FunctionArgType { + type?: string; + description?: string; +} + +export interface FunctionArg extends FunctionArgType { + name: string; + type?: string; + description?: string; + optional?: boolean; +} + +export interface TbEditorCompletion { + meta: tbMetaType; + description?: string; + type?: string; + args?: FunctionArg[]; + return?: FunctionArgType; + children?: TbEditorCompletions; +} + +interface TbEditorAceCompletion extends Ace.Completion { + isTbEditorAceCompletion: true; + snippet: string; + description?: string; + type?: string; + args?: FunctionArg[]; + return?: FunctionArgType; +} + +export class TbEditorCompleter implements Ace.Completer { + + identifierRegexps: RegExp[] = [ + /[a-zA-Z_0-9\$\-\u00A2-\u2000\u2070-\uFFFF.]/ + ]; + + constructor(private editorCompletions: TbEditorCompletions) { + } + + getCompletions(editor: Ace.Editor, session: Ace.EditSession, + position: Ace.Point, prefix: string, callback: Ace.CompleterCallback): void { + const result = this.prepareCompletions(prefix); + if (result) { + callback(null, result); + } + } + + private resolvePath(prefix: string): string[] { + if (!prefix || !prefix.length) { + return []; + } + let parts = prefix.split('.'); + if (parts.length) { + parts.pop(); + } else { + parts = []; + } + let currentCompletions = this.editorCompletions; + for (const part of parts) { + if (currentCompletions[part]) { + currentCompletions = currentCompletions[part].children; + if (!currentCompletions) { + return null; + } + } else { + return null; + } + } + return parts; + } + + private prepareCompletions(prefix: string): Ace.Completion[] { + const path = this.resolvePath(prefix); + if (path !== null) { + return this.toAceCompletionsList(this.editorCompletions, path); + } else { + return []; + } + } + + private toAceCompletionsList(completions: TbEditorCompletions, parentPath: string[]): Ace.Completion[] { + const result: Ace.Completion[] = []; + let targetCompletions = completions; + let parentPrefix = ''; + if (parentPath.length) { + parentPrefix = parentPath.join('.') + '.'; + for (const path of parentPath) { + targetCompletions = targetCompletions[path].children; + } + } + for (const key of Object.keys(targetCompletions)) { + result.push(this.toAceCompletion(key, targetCompletions[key], parentPrefix)); + } + return result; + } + + private toAceCompletion(name: string, completion: TbEditorCompletion, parentPrefix: string): Ace.Completion { + const aceCompletion: TbEditorAceCompletion = { + isTbEditorAceCompletion: true, + snippet: parentPrefix + name, + name, + caption: parentPrefix + name, + score: 100000, + value: parentPrefix + name, + meta: completion.meta, + type: completion.type, + description: completion.description, + args: completion.args, + return: completion.return + }; + return aceCompletion; + } + + getDocTooltip(completion: TbEditorAceCompletion) { + if (completion && completion.isTbEditorAceCompletion) { + return { + docHTML: this.createDocHTML(completion) + }; + } + } + + private createDocHTML(completion: TbEditorAceCompletion): string { + let title = `${completion.name}`; + if (completion.meta === 'function') { + title += '('; + if (completion.args) { + const strArgs: string[] = []; + for (const arg of completion.args) { + let strArg = `${arg.name}`; + if (arg.optional) { + strArg += '?'; + } + if (arg.type) { + strArg += `: ${arg.type}`; + } + strArgs.push(strArg); + } + title += strArgs.join(', '); + } + title += '): '; + if (completion.return) { + title += completion.return.type; + } else { + title += 'void'; + } + } else { + title += `: ${completion.type ? completion.type : completion.meta}`; + } + let html = `
    ${title}`; + if (completion.description) { + html += `
    ${completion.description}
    `; + } + if (completion.args || completion.return) { + let functionInfoBlock = '
    '; + if (completion.args) { + functionInfoBlock += '
    Parameters
    '; + let argsTable = ''; + const strArgs: string[] = []; + for (const arg of completion.args) { + let strArg = `'; + strArgs.push(strArg); + } + argsTable += strArgs.join('') + '
    ${arg.name}`; + if (arg.optional) { + strArg += ' (optional)'; + } + strArg += ''; + if (arg.type) { + strArg += `${arg.type}`; + } + strArg += ''; + if (arg.description) { + strArg += `${arg.description}`; + } + strArg += '
    '; + functionInfoBlock += argsTable; + } + if (completion.return) { + let returnStr = '
    Returns
    '; + returnStr += `
    ${completion.return.type}`; + if (completion.return.description) { + returnStr += `: ${completion.return.description}`; + } + returnStr += '
    '; + functionInfoBlock += returnStr; + } + functionInfoBlock += '
    '; + html += functionInfoBlock; + } + html += '
    '; + return html; + } +} diff --git a/ui-ngx/src/app/shared/models/ace/service-completion.models.ts b/ui-ngx/src/app/shared/models/ace/service-completion.models.ts new file mode 100644 index 0000000..c96dc0d --- /dev/null +++ b/ui-ngx/src/app/shared/models/ace/service-completion.models.ts @@ -0,0 +1,1431 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { FunctionArg, FunctionArgType, TbEditorCompletions } from '@shared/models/ace/completion.models'; + +export const entityIdHref = 'EntityId'; + +export const baseDataHref = 'Base data'; + +export const alarmDataHref = 'Alarm data'; + +export const alarmDataQueryHref = 'Alarm data query'; + +export const attributeScopeHref = 'Attribute scope'; + +export const entityTypeHref = 'EntityType'; + +export const pageDataHref = 'PageData'; + +export const deviceInfoHref = 'DeviceInfo'; + +export const assetInfoHref = 'AssetInfo'; + +export const entityViewInfoHref = 'EntityViewInfo'; + +export const entityRelationsQueryHref = 'EntityRelationsQuery'; + +export const entityRelationInfoHref = 'EntityRelationInfo'; + +export const dashboardInfoHref = 'DashboardInfo'; + +export const deviceHref = 'Device'; + +export const assetHref = 'Asset'; + +export const entityViewHref = 'entityView'; + +export const entityRelationHref = 'Entity relation'; + +export const dashboardHref = 'Dashboard'; + +export const customerHref = 'Customer'; + +export const attributeDataHref = 'Attribute Data'; + +export const timeseriesDataHref = 'Timeseries Data'; + +export const aggregationTypeHref = 'Aggregation Type'; + +export const dataSortOrderHref = 'Data Sort Order'; + +export const userHref = 'User'; + +export const entityDataHref = 'Entity data'; + +export const entityDataQueryHref = 'Entity Data Query'; + +export const deviceCredentialsHref = 'DeviceCredentials'; + +export const entityFilterHref = 'Entity filter'; + +export const entityInfoHref = 'Entity info'; + +export const aliasEntityTypeHref = 'Alias Entity Type'; + +export const aliasFilterTypeHref = 'Alias filter type'; + +export const entityAliasHref = 'Entity alias'; + +export const dataKeyTypeHref = 'Data key type'; + +export const subscriptionInfoHref = 'Subscription info'; + +export const dataSourceHref = 'Datasource'; + +export const stateParamsHref = 'State params'; + +export const aliasInfoHref = 'Alias info'; + +export const entityAliasFilterHref = 'Entity alias filter'; + +export const entityAliasFilterResultHref = 'Entity alias filter result'; + +export const importEntityDataHref = 'Import entity data'; + +export const importEntitiesResultInfoHref = 'Import entities result info'; + +export const customDialogComponentHref = 'CustomDialogComponent'; + +export const resourceInfoHref = 'Resource info'; + +export const pageLinkArg: FunctionArg = { + name: 'pageLink', + type: 'PageLink', + description: 'Page link object used to perform paginated request.' +}; + +export const requestConfigArg: FunctionArg = { + name: 'config', + type: 'RequestConfig', + description: 'HTTP request configuration.', + optional: true +}; + +export function observableReturnType(objectType: string): FunctionArgType { + return { + type: `Observable<${objectType}>`, + description: `An Observable of ${objectType} object.` + }; +} + +export function observableReturnTypeVariable(variableType: string): FunctionArgType { + return { + type: `Observable<${variableType}>`, + description: `An Observable of ${variableType} variable.` + }; +} + +export function observableVoid(): FunctionArgType { + return { + type: `Observable<void>`, + description: `An Observable.` + }; +} + +export function observableArrayReturnType(objectType: string): FunctionArgType { + return { + type: `Observable<Array<${objectType}>>`, + description: `An Observable of array of ${objectType} objects.` + }; +} + +export function observableBaseDataReturnType(): FunctionArgType { + return { + type: `Observable<${baseDataHref}<${entityIdHref}>>`, + description: `An Observable of ${baseDataHref} object.` + }; +} + +export function observableArrayBaseDataReturnType(): FunctionArgType { + return { + type: `Observable<Array<${baseDataHref}<${entityIdHref}>>>`, + description: `An Observable of array of ${baseDataHref} objects.` + }; +} + +export function observablePageDataReturnType(objectType: string): FunctionArgType { + return { + type: `Observable<${pageDataHref}<${objectType}>>`, + description: `An Observable of page result as a ${pageDataHref} holding array of ${objectType} objects.` + }; +} + +export const serviceCompletions: TbEditorCompletions = { + deviceService: { + description: 'Device Service API
    ' + + 'See DeviceService for API reference.', + meta: 'service', + type: 'DeviceService', + children: { + getTenantDeviceInfos: { + description: 'Get tenant devices', + meta: 'function', + args: [ + pageLinkArg, + { name: 'type', type: 'string', optional: true, description: 'Device type'}, + requestConfigArg + ], + return: observablePageDataReturnType(deviceInfoHref) + }, + getCustomerDeviceInfos: { + description: 'Get customer devices', + meta: 'function', + args: [ + { name: 'customerId', type: 'string', description: 'Id of the customer'}, + pageLinkArg, + { name: 'type', type: 'string', optional: true, description: 'Device type'}, + requestConfigArg + ], + return: observablePageDataReturnType(deviceInfoHref) + }, + getDevice: { + description: 'Get device by id', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableReturnType(deviceHref) + }, + getDevices: { + description: 'Get devices by ids', + meta: 'function', + args: [ + { name: 'deviceIds', type: `Array<string>`, description: 'List of device ids'}, + requestConfigArg + ], + return: observableArrayReturnType(deviceHref) + }, + getDeviceInfo: { + description: 'Get device info by id', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableReturnType(deviceInfoHref) + }, + saveDevice: { + description: 'Save device', + meta: 'function', + args: [ + { name: 'device', type: deviceHref, description: 'Device object to save'}, + requestConfigArg + ], + return: observableReturnType(deviceHref) + }, + deleteDevice: { + description: 'Delete device by id', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableVoid() + }, + getDeviceTypes: { + description: 'Get all available devices types', + meta: 'function', + args: [ + requestConfigArg + ], + return: observableArrayReturnType('EntitySubtype') + }, + getDeviceCredentials: { + description: 'Get device credentials by device id', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + { name: 'sync', type: 'boolean', description: 'Whether to execute HTTP request synchronously (false by default)', optional: true}, + requestConfigArg + ], + return: observableReturnType(deviceCredentialsHref) + }, + saveDeviceCredentials: { + description: 'Save device credentials', + meta: 'function', + args: [ + { name: 'deviceCredentials', type: deviceCredentialsHref, description: 'Device credentials object to save'}, + requestConfigArg + ], + return: observableReturnType(deviceCredentialsHref) + }, + makeDevicePublic: { + description: 'Make device public (available from public dashboard)', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableReturnType(deviceHref) + }, + assignDeviceToCustomer: { + description: 'Assign device to specific customer', + meta: 'function', + args: [ + { name: 'customerId', type: 'string', description: 'Id of the customer'}, + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableReturnType(deviceHref) + }, + unassignDeviceFromCustomer: { + description: 'Unassign device from any customer', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + requestConfigArg + ], + return: observableVoid() + }, + sendOneWayRpcCommand: { + description: 'Send one way (without response) RPC command to the device.', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + { name: 'requestBody', type: 'object', description: 'Request body to be sent to device'}, + requestConfigArg + ], + return: { + type: `Observable<any>`, + description: `A command execution Observable.` + } + }, + sendTwoWayRpcCommand: { + description: 'Sends two way (with response) RPC command to the device.', + meta: 'function', + args: [ + { name: 'deviceId', type: 'string', description: 'Id of the device'}, + { name: 'requestBody', type: 'object', description: 'Request body to be sent to device'}, + requestConfigArg + ], + return: { + type: `Observable<any>`, + description: `A command execution Observable of response body.` + } + }, + findByQuery: { + description: 'Find devices by search query', + meta: 'function', + args: [ + { name: 'query', type: 'DeviceSearchQuery', + description: 'Device search query object'}, + requestConfigArg + ], + return: observableArrayReturnType(deviceHref) + }, + findByName: { + description: 'Find device by name', + meta: 'function', + args: [ + { name: 'deviceName', type: 'string', + description: 'Search device name'}, + requestConfigArg + ], + return: observableReturnType(deviceHref) + }, + claimDevice: { + description: 'Send claim device request', + meta: 'function', + args: [ + { name: 'deviceName', type: 'string', + description: 'Claiming device name'}, + requestConfigArg + ], + return: observableReturnType('ClaimResult') + }, + unclaimDevice: { + description: 'Send un-claim device request', + meta: 'function', + args: [ + { name: 'deviceName', type: 'string', + description: 'Device name to un-claim'}, + requestConfigArg + ], + return: observableVoid() + } + } + }, + assetService: { + description: 'Asset Service API
    ' + + 'See AssetService for API reference.', + meta: 'service', + type: 'AssetService', + children: { + getTenantAssetInfos: { + description: 'Get tenant assets', + meta: 'function', + args: [ + pageLinkArg, + {name: 'type', type: 'string', optional: true, description: 'Asset type'}, + requestConfigArg + ], + return: observablePageDataReturnType(assetInfoHref) + }, + getCustomerAssetInfos: { + description: 'Get customer assets', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + pageLinkArg, + {name: 'type', type: 'string', optional: true, description: 'Asset type'}, + requestConfigArg + ], + return: observablePageDataReturnType(assetInfoHref) + }, + getAsset: { + description: 'Get asset by id', + meta: 'function', + args: [ + {name: 'assetId', type: 'string', description: 'Id of the asset'}, + requestConfigArg + ], + return: observableReturnType(assetHref) + }, + getAssets: { + description: 'Get assets by ids', + meta: 'function', + args: [ + {name: 'assetIds', type: `Array<string>`, description: 'Ids of the assets'}, + requestConfigArg + ], + return: observableArrayReturnType(assetHref) + }, + getAssetInfo: { + description: 'Get asset info by id', + meta: 'function', + args: [ + {name: 'assetId', type: 'string', description: 'Id of the assets'}, + requestConfigArg + ], + return: observableReturnType(assetInfoHref) + }, + saveAsset: { + description: 'Save asset', + meta: 'function', + args: [ + {name: 'asset', type: assetHref, description: 'Asset object to save'}, + requestConfigArg + ], + return: observableReturnType(assetHref) + }, + deleteAsset: { + description: 'Delete asset by id', + meta: 'function', + args: [ + {name: 'assetId', type: 'string', description: 'Id of the asset'}, + requestConfigArg + ], + return: observableVoid() + }, + getAssetTypes: { + description: 'Get all available assets types', + meta: 'function', + args: [ + requestConfigArg + ], + return: observableArrayReturnType('EntitySubtype') + }, + makeAssetPublic: { + description: 'Make asset public (available from public dashboard)', + meta: 'function', + args: [ + {name: 'assetId', type: 'string', description: 'Id of the asset'}, + requestConfigArg + ], + return: observableReturnType(assetHref) + }, + assignAssetToCustomer: { + description: 'Assign asset to specific customer', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + {name: 'assetId', type: 'string', description: 'Id of the asset'}, + requestConfigArg + ], + return: observableReturnType(assetHref) + }, + unassignAssetFromCustomer: { + description: 'Unassign asset from any customer', + meta: 'function', + args: [ + {name: 'assetId', type: 'string', description: 'Id of the asset'}, + requestConfigArg + ], + return: observableVoid() + }, + findByQuery: { + description: 'Find assets by search query', + meta: 'function', + args: [ + { + name: 'query', + type: 'AssetSearchQuery', + description: 'Asset search query object' + }, + requestConfigArg + ], + return: observableArrayReturnType(assetHref) + }, + findByName: { + description: 'Find asset by name', + meta: 'function', + args: [ + { + name: 'assetName', type: 'string', + description: 'Search asset name' + }, + requestConfigArg + ], + return: observableReturnType(assetHref) + }, + }, + }, + entityViewService: { + description: 'EntityView Service API
    ' + + 'See EntityViewService for API reference.', + meta: 'service', + type: 'EntityViewService', + children: { + getTenantEntityViewInfos: { + description: 'Get tenant entity view infos', + meta: 'function', + args: [ + pageLinkArg, + {name: 'type', type: 'string', optional: true, description: 'Entity view type'}, + requestConfigArg + ], + return: observablePageDataReturnType(entityViewInfoHref) + }, + getCustomerEntityViewInfos: { + description: 'Get customer entities view infos by id', + meta: 'function', + args: [ + { name: 'customerId', type: 'string', description: 'Id of the customer'}, + pageLinkArg, + { name: 'type', type: 'string', optional: true, description: 'Entity view type'}, + requestConfigArg + ], + return: observablePageDataReturnType(entityViewInfoHref) + }, + getEntityView: { + description: 'Get entity view by id', + meta: 'function', + args: [ + { name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableReturnType(entityViewHref) + }, + getEntityViewInfo: { + description: 'Get entity view info by id', + meta: 'function', + args: [ + {name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableReturnType(entityViewInfoHref) + }, + saveEntityView: { + description: 'Save entity view', + meta: 'function', + args: [ + {name: 'entityView', type: entityViewHref, description: 'Entity view object to save'}, + requestConfigArg + ], + return: observableReturnType(entityViewHref) + }, + deleteEntityView: { + description: 'Delete entity view by id', + meta: 'function', + args: [ + {name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableVoid() + }, + getEntityViewTypes: { + description: 'Get all available entity view types', + meta: 'function', + args: [ + requestConfigArg + ], + return: observableArrayReturnType('EntitySubtype') + }, + makeEntityViewPublic: { + description: 'Make entity view public (available from public dashboard)', + meta: 'function', + args: [ + {name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableReturnType(entityViewHref) + }, + assignEntityViewToCustomer: { + description: 'Assign entity view to specific customer', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + {name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableReturnType(entityViewHref) + }, + unassignEntityViewFromCustomer: { + description: 'Unassign entity view from any customer', + meta: 'function', + args: [ + {name: 'entityViewId', type: 'string', description: 'Id of the entity view'}, + requestConfigArg + ], + return: observableVoid() + }, + findByQuery: { + description: 'Find entities view by search query', + meta: 'function', + args: [ + { + name: 'query', + type: 'AssetSearchQuery', + description: 'Entity view search query object' + }, + requestConfigArg + ], + return: observableArrayReturnType(entityViewHref) + }, + } + }, + customerService: { + description: 'Customer Service API
    ' + + 'See CustomerService for API reference.', + meta: 'service', + type: 'CustomerService', + children: { + getCustomer: { + description: 'Get customer by id', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + requestConfigArg + ], + return: observableReturnType(customerHref) + }, + getCustomers: { + description: 'Get customers by ids', + meta: 'function', + args: [ + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(customerHref) + }, + saveCustomer: { + description: 'Save customer', + meta: 'function', + args: [ + {name: 'customer', type: customerHref, description: 'Customer object to save'}, + requestConfigArg + ], + return: observableReturnType(customerHref) + }, + deleteCustomer: { + description: 'Delete customer by id', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + requestConfigArg + ], + return: observableVoid() + }, + } + }, + dashboardService: { + description: 'Dashboard Service API
    ' + + 'See DashboardService for API reference.', + meta: 'service', + type: 'DashboardService', + children: { + getTenantDashboards: { + description: 'Get tenant dashboards', + meta: 'function', + args: [ + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(dashboardInfoHref) + }, + getTenantDashboardsByTenantId: { + description: 'Get dashboards by tenant id', + meta: 'function', + args: [ + {name: 'tenantId', type: 'string', description: 'Id of the tenant'}, + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(dashboardInfoHref) + }, + getCustomerDashboards: { + description: 'Get dashboards by customer id', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(dashboardInfoHref) + }, + getDashboard: { + description: 'Get dashboard by id', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + getDashboardInfo: { + description: 'Get dashboard info by id', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableReturnType(dashboardInfoHref) + }, + saveDashboard: { + description: 'Save dashboard', + meta: 'function', + args: [ + {name: 'dashboard', type: dashboardHref, description: 'Dashboard object to save'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + deleteDashboard: { + description: 'Delete dashboard by id', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableVoid() + }, + assignDashboardToCustomer: { + description: 'Assign dashboard to specific customer', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + unassignDashboardFromCustomer: { + description: 'Unassign dashboard from specific customer', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableVoid() + }, + makeDashboardPublic: { + description: 'Make dashboard public by id', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + makeDashboardPrivate: { + description: 'Make dashboard private by id', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + updateDashboardCustomers: { + description: 'Update customers assigned to dashboard by ids', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + {name: 'customerIds', type: `Array<string>`, description: 'Ids of the customers'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + addDashboardCustomers: { + description: 'Assign (Add) customers to dashboard by ids', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + {name: 'customerIds', type: `Array<string>`, description: 'Ids of the customers'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + removeDashboardCustomers: { + description: 'Unassign (Remove) customers from dashboard by ids', + meta: 'function', + args: [ + {name: 'dashboardId', type: 'string', description: 'Id of the dashboard'}, + {name: 'customerIds', type: `Array<string>`, description: 'Id of the customers'}, + requestConfigArg + ], + return: observableReturnType(dashboardHref) + }, + getPublicDashboardLink: { + description: 'Get public dashboard link', + meta: 'function', + args: [ + {name: 'dashboard', type: dashboardInfoHref, description: 'dashboard info'}, + ], + return: { + type: `string|null`, + description: `Returns dashboard url` + } + }, + getServerTimeDiff: { + description: 'Get time difference', + meta: 'function', + args: [ + ], + return: observableReturnTypeVariable('number') + }, + } + }, + userService: { + description: 'User Service API
    ' + + 'See UserService for API reference.', + meta: 'service', + type: 'UserService', + children: { + getUsers: { + description: 'Get users', + meta: 'function', + args: [ + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(userHref) + }, + getTenantAdmins: { + description: 'Get tenant admins by id', + meta: 'function', + args: [ + {name: 'tenantId', type: 'string', description: 'Id of the tenant'}, + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(userHref) + }, + getCustomerUsers: { + description: 'Get customer users by id', + meta: 'function', + args: [ + {name: 'customerId', type: 'string', description: 'Id of the customer'}, + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(userHref) + }, + getUser: { + description: 'Get user by id', + meta: 'function', + args: [ + {name: 'userId', type: 'string', description: 'Id of the user'}, + requestConfigArg + ], + return: observableReturnType(userHref) + }, + saveUser: { + description: 'Save user', + meta: 'function', + args: [ + {name: 'user', type: userHref, description: 'User object to save'}, + {name: 'sendActivationMail', type: 'boolean', description: 'Send activation email', optional: true}, + requestConfigArg + ], + return: observableReturnType(userHref) + }, + deleteUser: { + description: 'Delete user by id', + meta: 'function', + args: [ + {name: 'userId', type: 'string', description: 'Id of the user'}, + requestConfigArg + ], + return: observableVoid() + }, + setUserCredentialsEnabled: { + description: 'Set user credentials enabled by id', + meta: 'function', + args: [ + {name: 'userId', type: 'string', description: 'Id of the user'}, + {name: 'userCredentialsEnabled', type: 'boolean', description: 'User credentials enabled'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + getActivationLink: { + description: 'Get activation link by id', + meta: 'function', + args: [ + {name: 'userId', type: 'string', description: 'Id of the user'}, + requestConfigArg + ], + return: observableReturnTypeVariable('string') + }, + sendActivationEmail: { + description: 'Send activation email', + meta: 'function', + args: [ + {name: 'email', type: 'string', description: 'Email of the user'}, + requestConfigArg + ], + return: observableVoid() + }, + } + }, + entityRelationService: { + description: 'Entity Relation Service API
    ' + + 'See EntityRelationService for API reference.', + meta: 'service', + type: 'EntityRelationService', + children: { + saveRelation: { + description: 'Save relation', + meta: 'function', + args: [ + {name: 'relation', type: entityRelationHref, description: 'Relation object to save'}, + requestConfigArg + ], + return: observableReturnType(entityRelationHref) + }, + deleteRelation: { + description: 'Delete relation by ids', + meta: 'function', + args: [ + {name: 'fromId', type: entityIdHref, description: 'From-id'}, + {name: 'relationType', type: 'string', description: 'Relation type'}, + {name: 'toId', type: entityIdHref, description: 'To-id'}, + requestConfigArg + ], + return: observableVoid() + }, + deleteRelations: { + description: 'Delete relations by entity id', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Entity Id'}, + requestConfigArg + ], + return: observableVoid() + }, + getRelation: { + description: 'Get relation by ids', + meta: 'function', + args: [ + {name: 'fromId', type: entityIdHref, description: 'From-id'}, + {name: 'relationType', type: 'string', description: 'Relation type'}, + {name: 'toId', type: entityIdHref, description: 'To-id'}, + requestConfigArg + ], + return: observableReturnType(entityRelationHref) + }, + findByFrom: { + description: 'Find by from-id', + meta: 'function', + args: [ + {name: 'fromId', type: entityIdHref, description: 'From-id'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationHref) + }, + findInfoByFrom: { + description: 'Find info by from-id', + meta: 'function', + args: [ + {name: 'fromId', type: entityIdHref, description: 'From-id'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationInfoHref) + }, + findByFromAndType: { + description: 'Find by from-id and relation type', + meta: 'function', + args: [ + {name: 'fromId', type: entityIdHref, description: 'From-id'}, + {name: 'relationType', type: 'string', description: 'Relation type'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationHref) + }, + findByTo: { + description: 'Find by to-id', + meta: 'function', + args: [ + {name: 'toId', type: entityIdHref, description: 'To-id'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationHref) + }, + findInfoByTo: { + description: 'Find info by to-id', + meta: 'function', + args: [ + {name: 'toId', type: entityIdHref, description: 'To-id'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationInfoHref) + }, + findByToAndType: { + description: 'Find by to-id and relation type', + meta: 'function', + args: [ + {name: 'toId', type: entityIdHref, description: 'To-id'}, + {name: 'relationType', type: 'string', description: 'Relation type'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationHref) + }, + findByQuery: { + description: 'Find by query', + meta: 'function', + args: [ + {name: 'query', type: entityRelationsQueryHref, description: 'Entity relations query'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationHref) + }, + findInfoByQuery: { + description: 'Find info by query', + meta: 'function', + args: [ + {name: 'query', type: entityRelationsQueryHref, description: 'Entity relations query'}, + requestConfigArg + ], + return: observableArrayReturnType(entityRelationInfoHref) + }, + } + }, + attributeService: { + description: 'Attribute Service API
    ' + + 'See AttributeService for API reference.', + meta: 'service', + type: 'AttributeService', + children: { + getEntityAttributes: { + description: 'Get entity attributes by id', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'attributeScope', type: attributeScopeHref, description: 'Attribute scope'}, + {name: 'keys', type: `Array<string>`, description: 'Array of the keys'}, + requestConfigArg + ], + return: observableArrayReturnType(attributeDataHref) + }, + deleteEntityAttributes: { + description: 'Delete entity attributes by id', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'attributeScope', type: attributeScopeHref, description: 'Attribute scope'}, + {name: 'attributes', type: `array<${attributeDataHref}>`, description: 'Array of the attributes data'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + deleteEntityTimeseries: { + description: 'Delete entity timeseries by id', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'timeseries', type: `Array<${attributeDataHref}>>`, description: 'Array of the timeseries data'}, + {name: 'deleteAllDataForKeys', type: 'boolean', optional: true, description: 'Delete all data for keys'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + saveEntityAttributes: { + description: 'Save entity attributes', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'attributeScope', type: attributeScopeHref, description: 'Attribute scope'}, + {name: 'attributes', type: 'Array<${attributeDataHref}>>', description: 'Array of the attributes data'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + saveEntityTimeseries: { + description: 'Save entity timeseries', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'timeseriesScope', type: 'string', description: 'Timeseries scope'}, + {name: 'timeseries', type: `Array<attributeDataHref>`, description: 'Array of the timeseries data'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + getEntityTimeseries: { + description: 'Get entity timeseries', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'keys', type: `Array<string>`, description: 'Array of the keys'}, + {name: 'startTs', type: 'number', description: 'Start time in milliseconds'}, + {name: 'endTs', type: 'number', description: 'End time in milliseconds'}, + {name: 'limit', type: 'number', description: 'Limit of values to receive for each key'}, + {name: 'agg', type: aggregationTypeHref, description: 'Aggregation type'}, + {name: 'interval', type: 'number', description: 'Aggregation interval'}, + {name: 'orderBy', type: dataSortOrderHref, description: 'Data order by time'}, + {name: 'useStrictDataTypes', type: 'boolean', description: 'If "false" all values will be returned as strings'}, + requestConfigArg + ], + return: observableReturnTypeVariable(timeseriesDataHref) + }, + } + }, + entityService: { + description: 'Entity Service API
    ' + + 'See EntityService for API reference.', + meta: 'service', + type: 'EntityService', + children: { + getEntity: { + description: 'Get entity by id', + meta: 'function', + args: [ + {name: 'entityType', type: entityTypeHref, description: 'Entity type'}, + {name: 'entityId', type: 'string', description: 'Id of the entity'}, + requestConfigArg + ], + return: observableBaseDataReturnType() + }, + getEntities: { + description: 'Get entities by ids', + meta: 'function', + args: [ + {name: 'entityType', type: entityTypeHref, description: 'Entity type'}, + {name: 'entityIds', type: `Array<string>`, description: 'Ids of the entities'}, + requestConfigArg + ], + return: observableArrayBaseDataReturnType() + }, + getEntitiesByNameFilter: { + description: 'Get entities by name filter', + meta: 'function', + args: [ + {name: 'entityType', type: entityTypeHref, description: 'Entity type'}, + {name: 'entityNameFilter', type: 'string', description: 'Name filter for the entity'}, + {name: 'pageSize', type: 'number', description: 'Size of the page'}, + {name: 'subType', type: 'string', optional: true, description: 'Subtype'}, + requestConfigArg + ], + return: observableArrayBaseDataReturnType() + }, + findEntityDataByQuery: { + description: 'Find entity data by query', + meta: 'function', + args: [ + {name: 'query', type: entityDataQueryHref, description: 'Entity data query'}, + requestConfigArg + ], + return: observablePageDataReturnType(entityDataHref) + }, + findAlarmDataByQuery: { + description: 'Find alarm data by query', + meta: 'function', + args: [ + {name: 'query', type: alarmDataQueryHref, description: 'Alarm data query'}, + requestConfigArg + ], + return: observablePageDataReturnType(alarmDataHref) + }, + findEntityInfosByFilterAndName: { + description: 'Find entity infos by filter and name', + meta: 'function', + args: [ + {name: 'filter', type: entityFilterHref, description: 'Filter for the entities'}, + {name: 'searchText', type: 'string', description: 'Search text'}, + requestConfigArg + ], + return: observablePageDataReturnType(entityInfoHref) + }, + findSingleEntityInfoByEntityFilter: { + description: 'Find single entity infos by filter', + meta: 'function', + args: [ + {name: 'filter', type: entityFilterHref, description: 'Filter for the entity'}, + requestConfigArg + ], + return: observableReturnType(entityInfoHref) + }, + getAliasFilterTypesByEntityTypes: { + description: 'Get alias filter types by entity types', + meta: 'function', + args: [ + {name: 'entityTypes', type: `Array<${entityTypeHref}|${aliasEntityTypeHref}>`, description: 'Entity types'} + ], + return: { + type: `Array<${aliasFilterTypeHref}$gt;`, + description: `Array of ${aliasFilterTypeHref} objects` + } + }, + filterAliasByEntityTypes: { + description: 'Filter alias by entity types', + meta: 'function', + args: [ + {name: 'entityAlias', type: entityAliasHref, description: 'Alias of the entity'}, + {name: 'entityTypes', type: `Array<${entityTypeHref}|${aliasEntityTypeHref}>`, description: 'Entity types'} + ], + return: { + type: 'boolean', + description: `Returns boolean variable based on the filter` + } + }, + prepareAllowedEntityTypesList: { + description: 'Prepare allowed entity types list', + meta: 'function', + args: [ + {name: 'allowedEntityTypes', type: `Array<${entityTypeHref}|${aliasEntityTypeHref}>`, description: 'Entity types'}, + {name: 'useAliasEntityTypes', type: 'boolean', description: 'Use alias entity types'}, + ], + return: { + type: `Array<${entityTypeHref}|${aliasEntityTypeHref}>`, + description: `Returns entity types array` + } + }, + getEntityKeys: { + description: 'Get entity keys by id', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'query', type: 'string', description: 'Key name starts with'}, + {name: 'type', type: dataKeyTypeHref, description: 'Datakey type'}, + requestConfigArg + ], + return: { + type: `Observable<Array<string>>`, + description: `An Observable of array of string variables.` + } + }, + createDatasourcesFromSubscriptionsInfo: { + description: 'Create datasources from subscriptions info', + meta: 'function', + args: [ + {name: 'subscriptionsInfo', type: 'array', description: 'Subscriptions info'} + ], + return: { + type: `Array<${dataSourceHref}>`, + description: `Array of ${dataSourceHref} objects` + } + }, + createAlarmSourceFromSubscriptionInfo: { + description: 'Create alarm source from subscriptions info', + meta: 'function', + args: [ + {name: 'subscriptionInfo', type: subscriptionInfoHref, description: 'Subscription info'} + ], + return: { + type: `${dataSourceHref}`, + description: `${dataSourceHref} object` + } + }, + resolveAlias: { + description: 'Resolve alias', + meta: 'function', + args: [ + {name: 'entityAlias', type: entityAliasHref, description: 'Entity alias'}, + {name: 'stateParams', type: stateParamsHref, description: 'State params'}, + ], + return: observableReturnType(aliasInfoHref) + }, + resolveAliasFilter: { + description: 'Resolve alias filter', + meta: 'function', + args: [ + {name: 'filter', type: entityAliasFilterHref, description: 'Entity alias filter'}, + {name: 'stateParams', type: stateParamsHref, description: 'State params'}, + ], + return: observableReturnType(entityAliasFilterResultHref) + }, + checkEntityAlias: { + description: 'Check entity alias', + meta: 'function', + args: [ + {name: 'entityAlias', type: entityAliasHref, description: 'Entity alias'}, + ], + return: observableReturnTypeVariable('boolean') + }, + saveEntityParameters: { + description: 'Save entity parameters', + meta: 'function', + args: [ + {name: 'entityType', type: entityTypeHref, description: 'Entity type'}, + {name: 'entityData', type: importEntityDataHref, description: 'Entity data'}, + {name: 'update', type: 'boolean', description: 'Update'}, + requestConfigArg + ], + return: observableReturnType(importEntitiesResultInfoHref) + }, + saveEntityData: { + description: 'Save entity data', + meta: 'function', + args: [ + {name: 'entityId', type: entityIdHref, description: 'Id of the entity'}, + {name: 'entityData', type: importEntityDataHref, description: 'Entity data'}, + requestConfigArg + ], + return: observableReturnTypeVariable('any') + }, + } + }, + resourceService: { + description: 'Resource Service API
    ' + + 'See ResourceService for API reference.', + meta: 'service', + type: 'ResourceService', + children: { + getResources: { + description: 'Find resources by search text', + meta: 'function', + args: [ + pageLinkArg, + requestConfigArg + ], + return: observablePageDataReturnType(resourceInfoHref) + }, + } + }, + dialogs: { + description: 'Dialogs Service API
    ' + + 'See DialogService for API reference.', + meta: 'service', + type: 'DialogService', + children: { + confirm: { + description: 'Confirm', + meta: 'function', + args: [ + {name: 'title', type: 'string', description: 'Title'}, + {name: 'message', type: 'string', description: 'Message'}, + {name: 'cancel', type: 'string', optional: true, description: 'Cancel'}, + {name: 'ok', type: 'string', optional: true, description: 'Ok'}, + {name: 'fullscreen', type: 'boolean', optional: true, description: 'Fullscreen'}, + ], + return: observableReturnTypeVariable('boolean') + }, + alert: { + description: 'Alert', + meta: 'function', + args: [ + {name: 'title', type: 'string', description: 'Title'}, + {name: 'message', type: 'string', description: 'Message'}, + {name: 'ok', type: 'string', optional: true, description: 'Ok'}, + {name: 'fullscreen', type: 'boolean', optional: true, description: 'Fullscreen'}, + ], + return: observableReturnTypeVariable('boolean') + }, + colorPicker: { + description: 'Color picker', + meta: 'function', + args: [ + {name: 'color', type: 'string', description: 'Сolor'}, + ], + return: observableReturnTypeVariable('string') + }, + materialIconPicker: { + description: 'Material icon picker', + meta: 'function', + args: [ + {name: 'icon', type: 'string', description: 'Icon'}, + ], + return: observableReturnTypeVariable('string') + }, + forbidden: { + description: 'Forbidden', + meta: 'function', + args: [ + ], + return: observableReturnTypeVariable('boolean') + }, + todo: { + description: 'To do', + meta: 'function', + args: [ + ], + return: observableReturnTypeVariable('any') + }, + } + }, + customDialog: { + description: 'Custom Dialog Service API
    ' + + 'See CustomDialogService for API reference.', + meta: 'service', + type: 'CustomDialogService', + children: { + customDialog: { + description: 'Custom Dialog', + meta: 'function', + args: [ + {name: 'template', type: 'string', description: 'Template'}, + {name: 'controller', type: customDialogComponentHref, description: 'Controller'}, + {name: 'data', type: 'any', description: 'Data', optional: true}, + ], + return: observableReturnTypeVariable('any') + }, + } + }, + date: { + description: 'Date Pipe
    Formats a date value according to locale rules.
    ' + + 'See DatePipe for API reference.', + meta: 'service', + type: 'DatePipe' + }, + translate: { + description: 'Translate Service API
    ' + + 'See TranslateService for API reference.', + meta: 'service', + type: 'TranslateService' + }, + http: { + description: 'HTTP Client Service
    ' + + 'See HttpClient for API reference.', + meta: 'service', + type: 'HttpClient' + }, + sanitizer: { + description: 'DomSanitizer Service
    ' + + 'See DomSanitizer for API reference.', + meta: 'service', + type: 'DomSanitizer' + }, + router: { + description: 'Router Service
    ' + + 'See Router for API reference.', + meta: 'service', + type: 'Router' + } +}; diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts new file mode 100644 index 0000000..b9d44c6 --- /dev/null +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -0,0 +1,741 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models'; +import { entityIdHref, serviceCompletions } from '@shared/models/ace/service-completion.models'; + +export const timewindowCompletion: TbEditorCompletion = { + description: 'Timewindow configuration object', + meta: 'property', + type: 'Timewindow', + children: { + displayValue: { + description: 'Current timewindow display value.', + meta: 'property', + type: 'string' + }, + hideInterval: { + description: 'Whether to hide interval selection in timewindow panel.', + meta: 'property', + type: 'boolean' + }, + hideAggregation: { + description: 'Whether to hide aggregation selection in timewindow panel.', + meta: 'property', + type: 'boolean' + }, + hideAggInterval: { + description: 'Whether to hide aggregation interval selection in timewindow panel.', + meta: 'property', + type: 'boolean' + }, + selectedTab: { + description: 'Current selected timewindow type (0 - realtime, 1 - history).', + meta: 'property', + type: 'number' + }, + realtime: { + description: 'Realtime timewindow configuration object.', + meta: 'property', + type: 'IntervalWindow', + children: { + interval: { + description: 'Timewindow aggregation interval in milliseconds', + meta: 'property', + type: 'number' + }, + timewindowMs: { + description: 'Timewindow interval in milliseconds', + meta: 'property', + type: 'number' + } + } + }, + history: { + description: 'History timewindow configuration object.', + meta: 'property', + type: 'HistoryWindow', + children: { + historyType: { + description: 'History timewindow type (0 - last interval, 1 - fixed)', + meta: 'property', + type: 'number' + }, + interval: { + description: 'Timewindow aggregation interval in milliseconds', + meta: 'property', + type: 'number' + }, + timewindowMs: { + description: 'Timewindow interval in milliseconds', + meta: 'property', + type: 'number' + }, + fixedTimewindow: { + description: 'Fixed history timewindow configuration object', + meta: 'property', + type: 'FixedWindow', + children: { + startTimeMs: { + description: 'Timewindow start time in UTC milliseconds', + meta: 'property', + type: 'number' + }, + endTimeMs: { + description: 'Timewindow end time in UTC milliseconds', + meta: 'property', + type: 'number' + } + } + } + } + }, + aggregation: { + description: 'Timewindow aggregation configuration object.', + meta: 'property', + type: 'Aggregation', + children: { + interval: { + description: 'Aggregation interval in milliseconds', + meta: 'property', + type: 'number' + }, + type: { + description: 'Aggregation type', + meta: 'property', + type: 'AggregationType' + }, + limit: { + description: 'Maximum allowed datapoints when aggregation is disabled (AggregationType == \'NONE\')', + meta: 'property', + type: 'number' + } + } + } + } +}; + +export const widgetContextCompletions: TbEditorCompletions = { + ctx: { + description: 'A reference to widget context that has all necessary API
    and data used by widget instance.', + meta: 'object', + type: 'WidgetContext', + children: { + ...{ + $container: { + description: 'Container element of the widget.
    Can be used to dynamically access or modify widget DOM using jQuery API.', + meta: 'property', + type: 'jQuery Object' + }, + $scope: { + description: 'Reference to the current widget component.
    Can be used to access/modify component properties when widget is built using Angular approach.', + meta: 'property', + type: 'IDynamicWidgetComponent' + }, + width: { + description: 'Current width of widget container in pixels.', + meta: 'property', + type: 'number' + }, + height: { + description: 'Current height of widget container in pixels.', + meta: 'property', + type: 'number' + }, + isEdit: { + description: 'Indicates whether the dashboard is in in the view or editing state.', + meta: 'property', + type: 'boolean' + }, + isMobile: { + description: 'Indicates whether the dashboard view is less then 960px width (default mobile breakpoint).', + meta: 'property', + type: 'boolean' + }, + widgetConfig: { + description: 'Common widget configuration containing properties such as color (text color), backgroundColor (widget background color), etc.', + meta: 'property', + type: 'WidgetConfig', + children: { + title: { + description: 'Widget title.', + meta: 'property', + type: 'string' + }, + titleIcon: { + description: 'Widget title icon.', + meta: 'property', + type: 'string' + }, + showTitle: { + description: 'Whether to show widget title.', + meta: 'property', + type: 'boolean' + }, + showTitleIcon: { + description: 'Whether to show widget title icon.', + meta: 'property', + type: 'boolean' + }, + iconColor: { + description: 'Widget title icon color.', + meta: 'property', + type: 'string' + }, + iconSize: { + description: 'Widget title icon size.', + meta: 'property', + type: 'string' + }, + titleTooltip: { + description: 'Widget title tooltip content.', + meta: 'property', + type: 'string' + }, + dropShadow: { + description: 'Enable/disable widget card shadow.', + meta: 'property', + type: 'boolean' + }, + enableFullscreen: { + description: 'Whether to enable fullscreen button on widget.', + meta: 'property', + type: 'boolean' + }, + useDashboardTimewindow: { + description: 'Whether to use dashboard timewindow (applicable for timeseries widgets).', + meta: 'property', + type: 'boolean' + }, + displayTimewindow: { + description: 'Whether to display timewindow (applicable for timeseries widgets).', + meta: 'property', + type: 'boolean' + }, + showLegend: { + description: 'Whether to show legend.', + meta: 'property', + type: 'boolean' + }, + legendConfig: { + description: 'Legend configuration.', + meta: 'property', + type: 'LegendConfig', + children: { + position: { + description: 'Legend position. Possible values: \'top\', \'bottom\', \'left\', \'right\'', + meta: 'property', + type: 'LegendPosition', + }, + direction: { + description: 'Legend direction. Possible values: \'column\', \'row\'', + meta: 'property', + type: 'LegendDirection', + }, + showMin: { + description: 'Whether to display aggregated min values.', + meta: 'property', + type: 'boolean', + }, + showMax: { + description: 'Whether to display aggregated max values.', + meta: 'property', + type: 'boolean', + }, + showAvg: { + description: 'Whether to display aggregated average values.', + meta: 'property', + type: 'boolean', + }, + showTotal: { + description: 'Whether to display aggregated total values.', + meta: 'property', + type: 'boolean', + } + } + }, + timewindow: timewindowCompletion, + mobileHeight: { + description: 'Widget height in mobile mode.', + meta: 'property', + type: 'number' + }, + mobileOrder: { + description: 'Widget order in mobile mode.', + meta: 'property', + type: 'number' + }, + color: { + description: 'Widget text color.', + meta: 'property', + type: 'string' + }, + backgroundColor: { + description: 'Widget background color.', + meta: 'property', + type: 'string' + }, + padding: { + description: 'Widget card padding.', + meta: 'property', + type: 'string' + }, + margin: { + description: 'Widget card margin.', + meta: 'property', + type: 'string' + }, + widgetStyle: { + description: 'Widget element style object.', + meta: 'property', + type: 'object' + }, + titleStyle: { + description: 'Widget title element style object.', + meta: 'property', + type: 'object' + }, + units: { + description: 'Optional property defining units text of values displayed by widget. Useful for simple widgets like cards or gauges.', + meta: 'property', + type: 'string' + }, + decimals: { + description: 'Optional property defining how many positions should be used to display decimal part of the value number.', + meta: 'property', + type: 'number' + }, + actions: { + description: 'Map of configured widget actions.', + meta: 'property', + type: 'object' + }, + settings: { + description: 'Object holding widget settings according to widget type.', + meta: 'property', + type: 'object' + }, + alarmSource: { + description: 'Configured alarm source for alarm widget type.', + meta: 'property', + type: 'Datasource' + }, + alarmSearchStatus: { + description: 'Configured default alarm search status for alarm widget type.', + meta: 'property', + type: 'AlarmSearchStatus' + }, + alarmsPollingInterval: { + description: 'Configured alarms polling interval for alarm widget type.', + meta: 'property', + type: 'number' + }, + alarmsMaxCountLoad: { + description: 'Configured maximum alarms to load for alarm widget type.', + meta: 'property', + type: 'number' + }, + alarmsFetchSize: { + description: 'Configured alarms page size used to load alarms.', + meta: 'property', + type: 'number' + }, + datasources: { + description: 'Array of configured widget datasources.', + meta: 'property', + type: 'Array<Datasource>' + } + } + }, + settings: { + description: 'Widget settings containing widget specific properties according to the defined settings json schema', + meta: 'property', + type: 'object' + }, + datasources: { + description: 'Array of resolved widget datasources.', + meta: 'property', + type: 'Array<Datasource>' + }, + data: { + description: 'Array of latest datasources data.', + meta: 'property', + type: 'Array<DatasourceData>' + }, + timeWindow: { + description: 'Current widget timewindow (applicable for timeseries widgets).', + meta: 'property', + type: 'WidgetTimewindow' + }, + units: { + description: 'Optional property defining units text of values displayed by widget. Useful for simple widgets like cards or gauges.', + meta: 'property', + type: 'string' + }, + decimals: { + description: 'Optional property defining how many positions should be used to display decimal part of the value number.', + meta: 'property', + type: 'number' + }, + currentUser: { + description: 'Current user object.', + meta: 'property', + type: 'AuthUser', + children: { + sub: { + description: 'User subject (email).', + meta: 'property', + type: 'string' + }, + scopes: { + description: 'User security scopes.', + meta: 'property', + type: 'Array' + }, + userId: { + description: 'User id.', + meta: 'property', + type: 'string' + }, + firstName: { + description: 'User first name.', + meta: 'property', + type: 'string' + }, + lastName: { + description: 'User last name.', + meta: 'property', + type: 'string' + }, + enabled: { + description: 'Whether is user enabled.', + meta: 'property', + type: 'boolean' + }, + tenantId: { + description: 'Tenant id of the user.', + meta: 'property', + type: 'string' + }, + customerId: { + description: 'Customer id of the user (available when user belongs to specific customer).', + meta: 'property', + type: 'string' + }, + isPublic: { + description: 'Special flag indicating public user.', + meta: 'property', + type: 'boolean' + }, + authority: { + description: 'User authority. Possible values: SYS_ADMIN, TENANT_ADMIN, CUSTOMER_USER', + meta: 'property', + type: 'Authority' + } + } + }, + hideTitlePanel: { + description: 'Manages visibility of widget title panel. Useful for widget with custom title panels or different states. updateWidgetParams() function must be called after this property change.', + meta: 'property', + type: 'boolean' + }, + widgetTitle: { + description: 'If set, will override configured widget title text. updateWidgetParams() function must be called after this property change.', + meta: 'property', + type: 'string' + }, + detectChanges: { + description: 'Trigger change detection for current widget. Must be invoked when widget HTML template bindings should be updated due to widget data changes.', + meta: 'function' + }, + updateWidgetParams: { + description: 'Updates widget with runtime set properties such as widgetTitle, hideTitlePanel, etc. Must be invoked in order these properties changes take effect.', + meta: 'function' + }, + defaultSubscription: { + description: 'Default widget subscription object contains all subscription information,
    including current data, according to the widget type.', + meta: 'property', + type: 'IWidgetSubscription' + }, + timewindowFunctions: { + description: 'Object with timewindow functions used to manage widget data time frame. Can by used by Time-series or Alarm widgets.', + meta: 'property', + type: 'TimewindowFunctions', + children: { + onUpdateTimewindow: { + description: 'This function can be used to update current subscription time frame
    to historical one identified by startTimeMs and endTimeMs arguments.', + meta: 'function', + args: [ + { + name: 'startTimeMs', + description: 'Timewindow start time in UTC milliseconds', + type: 'number' + }, + { + name: 'endTimeMs', + description: 'Timewindow end time in UTC milliseconds', + type: 'number' + } + ] + }, + onResetTimewindow: { + description: 'Resets subscription time frame to default defined by widget timewindow component
    or dashboard timewindow depending on widget settings.', + meta: 'function' + } + } + }, + controlApi: { + description: 'Object that provides API functions for RPC (Control) widgets.', + meta: 'property', + type: 'RpcApi', + children: { + sendOneWayCommand: { + description: 'Sends one way (without response) RPC command to the device.', + meta: 'function', + args: [ + { + name: 'method', + description: 'RPC method name', + type: 'string' + }, + { + name: 'params', + description: 'RPC method params, custom json object', + type: 'object', + optional: true + }, + { + name: 'timeout', + description: 'Maximum delay in milliseconds to wait until response/acknowledgement is received.', + type: 'number', + optional: true + }, + { + name: 'persistent', + description: 'RPC request persistent', + type: 'boolean', + optional: true + }, + { + name: 'persistentPollingInterval', + description: 'Polling interval in milliseconds to get persistent RPC command response', + type: 'number', + optional: true + } + ], + return: { + description: 'A command execution Observable.', + type: 'Observable<any>' + } + }, + sendTwoWayCommand: { + description: 'Sends two way (with response) RPC command to the device.', + meta: 'function', + args: [ + { + name: 'method', + description: 'RPC method name', + type: 'string' + }, + { + name: 'params', + description: 'RPC method params, custom json object', + type: 'object', + optional: true + }, + { + name: 'timeout', + description: 'Maximum delay in milliseconds to wait until response/acknowledgement is received.', + type: 'number', + optional: true + }, + { + name: 'persistent', + description: 'RPC request persistent', + type: 'boolean', + optional: true + }, + { + name: 'persistentPollingInterval', + description: 'Polling interval in milliseconds to get persistent RPC command response', + type: 'number', + optional: true + } + ], + return: { + description: 'A command execution Observable of response body.', + type: 'Observable<any>' + } + } + } + }, + actionsApi: { + description: 'Set of API functions to work with user defined actions.', + meta: 'property', + type: 'WidgetActionsApi', + children: { + getActionDescriptors: { + description: 'Get list of action descriptors for provided actionSourceId.', + meta: 'function', + args: [ + { + name: 'actionSourceId', + description: 'Id of widget action source', + type: 'string' + } + ], + return: { + description: 'The list of action descriptors', + type: 'Array<WidgetActionDescriptor>' + } + }, + handleWidgetAction: { + description: 'Handle action produced by particular action source.', + meta: 'function', + args: [ + { + name: '$event', + description: 'DOM event object associated with action.', + type: 'Event' + }, + { + name: 'descriptor', + description: 'An action descriptor.', + type: 'WidgetActionDescriptor' + }, + { + name: 'entityId', + description: 'Current entity id provided by action source if available.', + type: entityIdHref, + optional: true + }, + { + name: 'entityName', + description: 'Current entity name provided by action source if available.', + type: 'string', + optional: true + } + ] + } + } + }, + stateController: { + description: 'Reference to Dashboard state controller, providing API to manage current dashboard state.', + meta: 'property', + type: 'IStateController', + children: { + openState: { + description: 'Navigate to new dashboard state.', + meta: 'function', + args: [ + { + name: 'id', + description: 'An id of the target dashboard state.', + type: 'string' + }, + { + name: 'params', + description: 'An object with state parameters to use by the new state.', + type: 'StateParams', + optional: true + }, + { + name: 'openRightLayout', + description: 'An optional boolean argument to force open right dashboard layout if present in mobile view mode.', + type: 'boolean', + optional: true + } + ] + }, + pushAndOpenState: { + description: 'Navigate to new dashboard state and adding intermediate states.', + meta: 'function', + args: [ + { + name: 'id', + description: 'An array state object of the target dashboard state.', + type: 'Array StateObject', + }, + { + name: 'openRightLayout', + description: 'An optional boolean argument to force open right dashboard layout if present in mobile view mode.', + type: 'boolean', + optional: true + } + ] + }, + updateState: { + description: 'Updates current dashboard state.', + meta: 'function', + args: [ + { + name: 'id', + description: 'An optional id of the target dashboard state to replace current state id.', + type: 'string', + optional: true + }, + { + name: 'params', + description: 'An object with state parameters to update current state parameters.', + type: 'StateParams', + optional: true + }, + { + name: 'openRightLayout', + description: 'An optional boolean argument to force open right dashboard layout if present in mobile view mode.', + type: 'boolean', + optional: true + } + ] + }, + getStateId: { + description: 'Get current dashboard state id.', + meta: 'function', + return: { + description: 'current dashboard state id.', + type: 'string' + } + }, + getStateParams: { + description: 'Get current dashboard state parameters.', + meta: 'function', + return: { + description: 'current dashboard state parameters.', + type: 'StateParams' + } + }, + getStateParamsByStateId: { + description: 'Get state parameters for particular dashboard state identified by id.', + meta: 'function', + args: [ + { + name: 'id', + description: 'An id of the target dashboard state.', + type: 'string' + } + ], + return: { + description: 'current dashboard state parameters.', + type: 'StateParams' + } + } + } + } + }, + ...serviceCompletions + } + } +}; diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts new file mode 100644 index 0000000..ed962b2 --- /dev/null +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -0,0 +1,234 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { AlarmId } from '@shared/models/id/alarm-id'; +import { EntityId } from '@shared/models/id/entity-id'; +import { TimePageLink } from '@shared/models/page/page-link'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { EntityType } from '@shared/models/entity-type.models'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { TableCellButtonActionDescriptor } from '@home/components/widget/lib/table-widget.models'; + +export enum AlarmSeverity { + CRITICAL = 'CRITICAL', + MAJOR = 'MAJOR', + MINOR = 'MINOR', + WARNING = 'WARNING', + INDETERMINATE = 'INDETERMINATE' +} + +export enum AlarmStatus { + ACTIVE_UNACK = 'ACTIVE_UNACK', + ACTIVE_ACK = 'ACTIVE_ACK', + CLEARED_UNACK = 'CLEARED_UNACK', + CLEARED_ACK = 'CLEARED_ACK' +} + +export enum AlarmSearchStatus { + ANY = 'ANY', + ACTIVE = 'ACTIVE', + CLEARED = 'CLEARED', + ACK = 'ACK', + UNACK = 'UNACK' +} + +export const alarmSeverityTranslations = new Map( + [ + [AlarmSeverity.CRITICAL, 'alarm.severity-critical'], + [AlarmSeverity.MAJOR, 'alarm.severity-major'], + [AlarmSeverity.MINOR, 'alarm.severity-minor'], + [AlarmSeverity.WARNING, 'alarm.severity-warning'], + [AlarmSeverity.INDETERMINATE, 'alarm.severity-indeterminate'] + ] +); + +export const alarmStatusTranslations = new Map( + [ + [AlarmStatus.ACTIVE_UNACK, 'alarm.display-status.ACTIVE_UNACK'], + [AlarmStatus.ACTIVE_ACK, 'alarm.display-status.ACTIVE_ACK'], + [AlarmStatus.CLEARED_UNACK, 'alarm.display-status.CLEARED_UNACK'], + [AlarmStatus.CLEARED_ACK, 'alarm.display-status.CLEARED_ACK'], + ] +); + +export const alarmSearchStatusTranslations = new Map( + [ + [AlarmSearchStatus.ANY, 'alarm.search-status.ANY'], + [AlarmSearchStatus.ACTIVE, 'alarm.search-status.ACTIVE'], + [AlarmSearchStatus.CLEARED, 'alarm.search-status.CLEARED'], + [AlarmSearchStatus.ACK, 'alarm.search-status.ACK'], + [AlarmSearchStatus.UNACK, 'alarm.search-status.UNACK'] + ] +); + +export const alarmSeverityColors = new Map( + [ + [AlarmSeverity.CRITICAL, 'red'], + [AlarmSeverity.MAJOR, 'orange'], + [AlarmSeverity.MINOR, '#ffca3d'], + [AlarmSeverity.WARNING, '#abab00'], + [AlarmSeverity.INDETERMINATE, 'green'] + ] +); + +export interface Alarm extends BaseData { + tenantId: TenantId; + customerId: CustomerId; + type: string; + originator: EntityId; + severity: AlarmSeverity; + status: AlarmStatus; + startTs: number; + endTs: number; + ackTs: number; + clearTs: number; + propagate: boolean; + details?: any; +} + +export interface AlarmInfo extends Alarm { + originatorName: string; +} + +export interface AlarmDataInfo extends AlarmInfo { + actionCellButtons?: TableCellButtonActionDescriptor[]; + hasActions?: boolean; + [key: string]: any; +} + +export const simulatedAlarm: AlarmInfo = { + id: new AlarmId(NULL_UUID), + tenantId: new TenantId(NULL_UUID), + customerId: new CustomerId(NULL_UUID), + createdTime: new Date().getTime(), + startTs: new Date().getTime(), + endTs: 0, + ackTs: 0, + clearTs: 0, + originatorName: 'Simulated', + originator: { + entityType: EntityType.DEVICE, + id: '1' + }, + type: 'TEMPERATURE', + severity: AlarmSeverity.MAJOR, + status: AlarmStatus.ACTIVE_UNACK, + details: { + message: 'Temperature is high!' + }, + propagate: false +}; + +export interface AlarmField { + keyName: string; + value: string; + name: string; + time?: boolean; +} + +export const alarmFields: {[fieldName: string]: AlarmField} = { + createdTime: { + keyName: 'createdTime', + value: 'createdTime', + name: 'alarm.created-time', + time: true + }, + startTime: { + keyName: 'startTime', + value: 'startTs', + name: 'alarm.start-time', + time: true + }, + endTime: { + keyName: 'endTime', + value: 'endTs', + name: 'alarm.end-time', + time: true + }, + ackTime: { + keyName: 'ackTime', + value: 'ackTs', + name: 'alarm.ack-time', + time: true + }, + clearTime: { + keyName: 'clearTime', + value: 'clearTs', + name: 'alarm.clear-time', + time: true + }, + originator: { + keyName: 'originator', + value: 'originatorName', + name: 'alarm.originator' + }, + originatorType: { + keyName: 'originatorType', + value: 'originator.entityType', + name: 'alarm.originator-type' + }, + type: { + keyName: 'type', + value: 'type', + name: 'alarm.type' + }, + severity: { + keyName: 'severity', + value: 'severity', + name: 'alarm.severity' + }, + status: { + keyName: 'status', + value: 'status', + name: 'alarm.status' + } +}; + +export class AlarmQuery { + + affectedEntityId: EntityId; + pageLink: TimePageLink; + searchStatus: AlarmSearchStatus; + status: AlarmStatus; + fetchOriginator: boolean; + + constructor(entityId: EntityId, pageLink: TimePageLink, + searchStatus: AlarmSearchStatus, status: AlarmStatus, + fetchOriginator: boolean) { + this.affectedEntityId = entityId; + this.pageLink = pageLink; + this.searchStatus = searchStatus; + this.status = status; + this.fetchOriginator = fetchOriginator; + } + + public toQuery(): string { + let query = `/${this.affectedEntityId.entityType}/${this.affectedEntityId.id}`; + query += this.pageLink.toQuery(); + if (this.searchStatus) { + query += `&searchStatus=${this.searchStatus}`; + } else if (this.status) { + query += `&status=${this.status}`; + } + if (typeof this.fetchOriginator !== 'undefined' && this.fetchOriginator !== null) { + query += `&fetchOriginator=${this.fetchOriginator}`; + } + return query; + } + +} diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts new file mode 100644 index 0000000..418e549 --- /dev/null +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -0,0 +1,196 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityType } from '@shared/models/entity-type.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitySearchDirection, RelationEntityTypeFilter } from '@shared/models/relation.models'; +import { EntityFilter } from '@shared/models/query/query.models'; + +export enum AliasFilterType { + singleEntity = 'singleEntity', + entityList = 'entityList', + entityName = 'entityName', + entityType = 'entityType', + stateEntity = 'stateEntity', + assetType = 'assetType', + deviceType = 'deviceType', + edgeType = 'edgeType', + entityViewType = 'entityViewType', + apiUsageState = 'apiUsageState', + relationsQuery = 'relationsQuery', + assetSearchQuery = 'assetSearchQuery', + deviceSearchQuery = 'deviceSearchQuery', + edgeSearchQuery = 'edgeSearchQuery', + entityViewSearchQuery = 'entityViewSearchQuery' +} + +export const edgeAliasFilterTypes = new Array( + AliasFilterType.edgeType, + AliasFilterType.edgeSearchQuery +); + +export const aliasFilterTypeTranslationMap = new Map( + [ + [ AliasFilterType.singleEntity, 'alias.filter-type-single-entity' ], + [ AliasFilterType.entityList, 'alias.filter-type-entity-list' ], + [ AliasFilterType.entityName, 'alias.filter-type-entity-name' ], + [ AliasFilterType.entityType, 'alias.filter-type-entity-type' ], + [ AliasFilterType.stateEntity, 'alias.filter-type-state-entity' ], + [ AliasFilterType.assetType, 'alias.filter-type-asset-type' ], + [ AliasFilterType.deviceType, 'alias.filter-type-device-type' ], + [ AliasFilterType.edgeType, 'alias.filter-type-edge-type' ], + [ AliasFilterType.entityViewType, 'alias.filter-type-entity-view-type' ], + [ AliasFilterType.apiUsageState, 'alias.filter-type-apiUsageState' ], + [ AliasFilterType.relationsQuery, 'alias.filter-type-relations-query' ], + [ AliasFilterType.assetSearchQuery, 'alias.filter-type-asset-search-query' ], + [ AliasFilterType.deviceSearchQuery, 'alias.filter-type-device-search-query' ], + [ AliasFilterType.edgeSearchQuery, 'alias.filter-type-edge-search-query' ], + [ AliasFilterType.entityViewSearchQuery, 'alias.filter-type-entity-view-search-query' ] + ] +); + +export interface SingleEntityFilter { + singleEntity?: EntityId; +} + +export interface EntityListFilter { + entityType?: EntityType; + entityList?: string[]; +} + +export interface EntityNameFilter { + entityType?: EntityType; + entityNameFilter?: string; +} + +export interface EntityTypeFilter { + entityType?: EntityType; +} + +export interface StateEntityFilter { + stateEntityParamName?: string; + defaultStateEntity?: EntityId; +} + +export interface AssetTypeFilter { + assetType?: string; + assetNameFilter?: string; +} + +export interface DeviceTypeFilter { + deviceType?: string; + deviceNameFilter?: string; +} + +export interface EdgeTypeFilter { + edgeType?: string; + edgeNameFilter?: string; +} + +export interface EntityViewFilter { + entityViewType?: string; + entityViewNameFilter?: string; +} + +export interface RelationsQueryFilter { + rootStateEntity?: boolean; + stateEntityParamName?: string; + defaultStateEntity?: EntityId; + rootEntity?: EntityId; + direction?: EntitySearchDirection; + filters?: Array; + maxLevel?: number; + fetchLastLevelOnly?: boolean; +} + +export interface EntitySearchQueryFilter { + rootStateEntity?: boolean; + stateEntityParamName?: string; + defaultStateEntity?: EntityId; + rootEntity?: EntityId; + relationType?: string; + direction?: EntitySearchDirection; + maxLevel?: number; + fetchLastLevelOnly?: boolean; +} + +// tslint:disable-next-line:no-empty-interface +export interface ApiUsageStateFilter { + +} + +export interface AssetSearchQueryFilter extends EntitySearchQueryFilter { + assetTypes?: string[]; +} + +export interface DeviceSearchQueryFilter extends EntitySearchQueryFilter { + deviceTypes?: string[]; +} + +export interface EdgeSearchQueryFilter extends EntitySearchQueryFilter { + edgeTypes?: string[]; +} + +export interface EntityViewSearchQueryFilter extends EntitySearchQueryFilter { + entityViewTypes?: string[]; +} + +export type EntityFilters = + SingleEntityFilter & + EntityListFilter & + EntityNameFilter & + EntityTypeFilter & + StateEntityFilter & + AssetTypeFilter & + DeviceTypeFilter & + EdgeTypeFilter & + EntityViewFilter & + RelationsQueryFilter & + AssetSearchQueryFilter & + DeviceSearchQueryFilter & + EntityViewSearchQueryFilter & + EntitySearchQueryFilter & + EdgeSearchQueryFilter; + +export interface EntityAliasFilter extends EntityFilters { + type?: AliasFilterType; + resolveMultiple?: boolean; +} + +export interface EntityAliasInfo { + alias: string; + filter: EntityAliasFilter; + [key: string]: any; +} + +export interface AliasesInfo { + datasourceAliases: {[datasourceIndex: number]: EntityAliasInfo}; + targetDeviceAliases: {[targetDeviceAliasIndex: number]: EntityAliasInfo}; +} + +export interface EntityAlias extends EntityAliasInfo { + id: string; +} + +export interface EntityAliases { + [id: string]: EntityAlias; +} + +export interface EntityAliasFilterResult { + stateEntity: boolean; + entityFilter: EntityFilter; + entityParamName?: string; +} diff --git a/ui-ngx/src/app/shared/models/asset.models.ts b/ui-ngx/src/app/shared/models/asset.models.ts new file mode 100644 index 0000000..628ef2d --- /dev/null +++ b/ui-ngx/src/app/shared/models/asset.models.ts @@ -0,0 +1,63 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { AssetId } from './id/asset-id'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; +import { AssetProfileId } from '@shared/models/id/asset-profile-id'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { EntityInfoData } from '@shared/models/entity.models'; + +export const TB_SERVICE_QUEUE = 'TbServiceQueue'; + +export interface AssetProfile extends BaseData, ExportableEntity { + tenantId?: TenantId; + name: string; + description?: string; + default?: boolean; + image?: string; + defaultRuleChainId?: RuleChainId; + defaultDashboardId?: DashboardId; + defaultQueueName?: string; +} + +export interface AssetProfileInfo extends EntityInfoData { + image?: string; + defaultDashboardId?: DashboardId; +} + +export interface Asset extends BaseData, ExportableEntity { + tenantId?: TenantId; + customerId?: CustomerId; + name: string; + type: string; + label: string; + assetProfileId?: AssetProfileId; + additionalInfo?: any; +} + +export interface AssetInfo extends Asset { + customerTitle: string; + customerIsPublic: boolean; + assetProfileName: string; +} + +export interface AssetSearchQuery extends EntitySearchQuery { + assetTypes: Array; +} diff --git a/ui-ngx/src/app/shared/models/audit-log.models.ts b/ui-ngx/src/app/shared/models/audit-log.models.ts new file mode 100644 index 0000000..3d9d232 --- /dev/null +++ b/ui-ngx/src/app/shared/models/audit-log.models.ts @@ -0,0 +1,120 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from './base-data'; +import { AuditLogId } from './id/audit-log-id'; +import { CustomerId } from './id/customer-id'; +import { EntityId } from './id/entity-id'; +import { UserId } from './id/user-id'; +import { TenantId } from './id/tenant-id'; + +export enum AuditLogMode { + TENANT, + ENTITY, + USER, + CUSTOMER +} + +export enum ActionType { + ADDED = 'ADDED', + DELETED = 'DELETED', + UPDATED = 'UPDATED', + ATTRIBUTES_UPDATED = 'ATTRIBUTES_UPDATED', + ATTRIBUTES_DELETED = 'ATTRIBUTES_DELETED', + RPC_CALL = 'RPC_CALL', + CREDENTIALS_UPDATED = 'CREDENTIALS_UPDATED', + ASSIGNED_TO_CUSTOMER = 'ASSIGNED_TO_CUSTOMER', + UNASSIGNED_FROM_CUSTOMER = 'UNASSIGNED_FROM_CUSTOMER', + ACTIVATED = 'ACTIVATED', + SUSPENDED = 'SUSPENDED', + CREDENTIALS_READ = 'CREDENTIALS_READ', + ATTRIBUTES_READ = 'ATTRIBUTES_READ', + RELATION_ADD_OR_UPDATE = 'RELATION_ADD_OR_UPDATE', + RELATION_DELETED = 'RELATION_DELETED', + RELATIONS_DELETED = 'RELATIONS_DELETED', + ALARM_ACK = 'ALARM_ACK', + ALARM_CLEAR = 'ALARM_CLEAR', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + LOCKOUT = 'LOCKOUT', + ASSIGNED_FROM_TENANT = 'ASSIGNED_FROM_TENANT', + ASSIGNED_TO_TENANT = 'ASSIGNED_TO_TENANT', + PROVISION_SUCCESS = 'PROVISION_SUCCESS', + PROVISION_FAILURE = 'PROVISION_FAILURE', + TIMESERIES_UPDATED = 'TIMESERIES_UPDATED', + TIMESERIES_DELETED = 'TIMESERIES_DELETED', + ASSIGNED_TO_EDGE = 'ASSIGNED_TO_EDGE', + UNASSIGNED_FROM_EDGE = 'UNASSIGNED_FROM_EDGE' +} + +export enum ActionStatus { + SUCCESS = 'SUCCESS', + FAILURE = 'FAILURE' +} + +export const actionTypeTranslations = new Map( + [ + [ActionType.ADDED, 'audit-log.type-added'], + [ActionType.DELETED, 'audit-log.type-deleted'], + [ActionType.UPDATED, 'audit-log.type-updated'], + [ActionType.ATTRIBUTES_UPDATED, 'audit-log.type-attributes-updated'], + [ActionType.ATTRIBUTES_DELETED, 'audit-log.type-attributes-deleted'], + [ActionType.RPC_CALL, 'audit-log.type-rpc-call'], + [ActionType.CREDENTIALS_UPDATED, 'audit-log.type-credentials-updated'], + [ActionType.ASSIGNED_TO_CUSTOMER, 'audit-log.type-assigned-to-customer'], + [ActionType.UNASSIGNED_FROM_CUSTOMER, 'audit-log.type-unassigned-from-customer'], + [ActionType.ACTIVATED, 'audit-log.type-activated'], + [ActionType.SUSPENDED, 'audit-log.type-suspended'], + [ActionType.CREDENTIALS_READ, 'audit-log.type-credentials-read'], + [ActionType.ATTRIBUTES_READ, 'audit-log.type-attributes-read'], + [ActionType.RELATION_ADD_OR_UPDATE, 'audit-log.type-relation-add-or-update'], + [ActionType.RELATION_DELETED, 'audit-log.type-relation-delete'], + [ActionType.RELATIONS_DELETED, 'audit-log.type-relations-delete'], + [ActionType.ALARM_ACK, 'audit-log.type-alarm-ack'], + [ActionType.ALARM_CLEAR, 'audit-log.type-alarm-clear'], + [ActionType.LOGIN, 'audit-log.type-login'], + [ActionType.LOGOUT, 'audit-log.type-logout'], + [ActionType.LOCKOUT, 'audit-log.type-lockout'], + [ActionType.ASSIGNED_FROM_TENANT, 'audit-log.type-assigned-from-tenant'], + [ActionType.ASSIGNED_TO_TENANT, 'audit-log.type-assigned-to-tenant'], + [ActionType.PROVISION_SUCCESS, 'audit-log.type-provision-success'], + [ActionType.PROVISION_FAILURE, 'audit-log.type-provision-failure'], + [ActionType.TIMESERIES_UPDATED, 'audit-log.type-timeseries-updated'], + [ActionType.TIMESERIES_DELETED, 'audit-log.type-timeseries-deleted'], + [ActionType.ASSIGNED_TO_EDGE, 'audit-log.type-assigned-to-edge'], + [ActionType.UNASSIGNED_FROM_EDGE, 'audit-log.type-unassigned-from-edge'] + ] +); + +export const actionStatusTranslations = new Map( + [ + [ActionStatus.SUCCESS, 'audit-log.status-success'], + [ActionStatus.FAILURE, 'audit-log.status-failure'], + ] +); + +export interface AuditLog extends BaseData { + tenantId: TenantId; + customerId: CustomerId; + entityId: EntityId; + entityName: string; + userId: UserId; + userName: string; + actionType: ActionType; + actionData: any; + actionStatus: ActionStatus; + actionFailureDetails: string; +} diff --git a/ui-ngx/src/app/shared/models/authority.enum.ts b/ui-ngx/src/app/shared/models/authority.enum.ts new file mode 100644 index 0000000..c736fe9 --- /dev/null +++ b/ui-ngx/src/app/shared/models/authority.enum.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 enum Authority { + SYS_ADMIN = 'SYS_ADMIN', + TENANT_ADMIN = 'TENANT_ADMIN', + CUSTOMER_USER = 'CUSTOMER_USER', + REFRESH_TOKEN = 'REFRESH_TOKEN', + ANONYMOUS = 'ANONYMOUS', + PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN' +} diff --git a/ui-ngx/src/app/shared/models/base-data.ts b/ui-ngx/src/app/shared/models/base-data.ts new file mode 100644 index 0000000..ff638a3 --- /dev/null +++ b/ui-ngx/src/app/shared/models/base-data.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { HasUUID } from '@shared/models/id/has-uuid'; +import { isDefinedAndNotNull } from '@core/utils'; + +export declare type HasId = EntityId | HasUUID; + +export interface BaseData { + createdTime?: number; + id?: T; + name?: string; + label?: string; +} + +export interface ExportableEntity { + createdTime?: number; + id?: T; + externalId?: T; +} + +export function hasIdEquals(id1: HasId, id2: HasId): boolean { + if (isDefinedAndNotNull(id1) && isDefinedAndNotNull(id2)) { + return id1.id === id2.id; + } else { + return id1 === id2; + } +} diff --git a/ui-ngx/src/app/shared/models/beautify.models.ts b/ui-ngx/src/app/shared/models/beautify.models.ts new file mode 100644 index 0000000..ad7c642 --- /dev/null +++ b/ui-ngx/src/app/shared/models/beautify.models.ts @@ -0,0 +1,79 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Observable } from 'rxjs/internal/Observable'; +import { from, of } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +let jsBeautifyModule: any; +let htmlBeautifyModule: any; +let cssBeautifyModule: any; + +function loadJsBeautify(): Observable { + if (jsBeautifyModule) { + return of(jsBeautifyModule); + } else { + return from(import('js-beautify/js/lib/beautify.js')).pipe( + tap((module) => { + jsBeautifyModule = module; + }) + ); + } +} + +function loadHtmlBeautify(): Observable { + if (htmlBeautifyModule) { + return of(htmlBeautifyModule); + } else { + return from(import('js-beautify/js/lib/beautify-html.js')).pipe( + tap((module) => { + htmlBeautifyModule = module; + }) + ); + } +} + +function loadCssBeautify(): Observable { + if (cssBeautifyModule) { + return of(cssBeautifyModule); + } else { + return from(import('js-beautify/js/lib/beautify-css.js')).pipe( + tap((module) => { + cssBeautifyModule = module; + }) + ); + } +} + +export function beautifyJs(source: string, options?: any): Observable { + return loadJsBeautify().pipe( + map((mod) => { + return mod.js_beautify(source, options); + }) + ); +} + +export function beautifyCss(source: string, options?: any): Observable { + return loadCssBeautify().pipe( + map((mod) => mod.css_beautify(source, options)) + ); +} + +export function beautifyHtml(source: string, options?: any): Observable { + return loadHtmlBeautify().pipe( + map((mod) => mod.html_beautify(source, options)) + ); +} diff --git a/ui-ngx/src/app/shared/models/component-descriptor.models.ts b/ui-ngx/src/app/shared/models/component-descriptor.models.ts new file mode 100644 index 0000000..56dd29c --- /dev/null +++ b/ui-ngx/src/app/shared/models/component-descriptor.models.ts @@ -0,0 +1,40 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { RuleNodeType } from '@shared/models/rule-node.models'; + +export enum ComponentType { + ENRICHMENT = 'ENRICHMENT', + FILTER = 'FILTER', + TRANSFORMATION = 'TRANSFORMATION', + ACTION = 'ACTION', + EXTERNAL = 'EXTERNAL', + FLOW = 'FLOW' +} + +export enum ComponentScope { + SYSTEM = 'SYSTEM', + TENANT = 'TENANT' +} + +export interface ComponentDescriptor { + type: ComponentType | RuleNodeType; + scope?: ComponentScope; + name: string; + clazz: string; + configurationDescriptor?: any; + actions?: string; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts new file mode 100644 index 0000000..bee7eba --- /dev/null +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -0,0 +1,275 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { InjectionToken } from '@angular/core'; +import { IModulesMap } from '@modules/common/modules-map.models'; +import { EntityType } from '@shared/models/entity-type.models'; + +export const Constants = { + serverErrorCode: { + general: 2, + authentication: 10, + jwtTokenExpired: 11, + tenantTrialExpired: 12, + credentialsExpired: 15, + permissionDenied: 20, + invalidArguments: 30, + badRequestParams: 31, + itemNotFound: 32, + tooManyRequests: 33, + tooManyUpdates: 34 + }, + entryPoints: { + login: '/api/auth/login', + tokenRefresh: '/api/auth/token', + nonTokenBased: '/api/noauth' + } +}; + +export const serverErrorCodesTranslations = new Map([ + [Constants.serverErrorCode.general, 'server-error.general'], + [Constants.serverErrorCode.authentication, 'server-error.authentication'], + [Constants.serverErrorCode.jwtTokenExpired, 'server-error.jwt-token-expired'], + [Constants.serverErrorCode.tenantTrialExpired, 'server-error.tenant-trial-expired'], + [Constants.serverErrorCode.credentialsExpired, 'server-error.credentials-expired'], + [Constants.serverErrorCode.permissionDenied, 'server-error.permission-denied'], + [Constants.serverErrorCode.invalidArguments, 'server-error.invalid-arguments'], + [Constants.serverErrorCode.badRequestParams, 'server-error.bad-request-params'], + [Constants.serverErrorCode.itemNotFound, 'server-error.item-not-found'], + [Constants.serverErrorCode.tooManyRequests, 'server-error.too-many-requests'], + [Constants.serverErrorCode.tooManyUpdates, 'server-error.too-many-updates'], +]); + +export const MediaBreakpoints = { + xs: 'screen and (max-width: 599px)', + sm: 'screen and (min-width: 600px) and (max-width: 959px)', + md: 'screen and (min-width: 960px) and (max-width: 1279px)', + lg: 'screen and (min-width: 1280px) and (max-width: 1919px)', + xl: 'screen and (min-width: 1920px) and (max-width: 5000px)', + 'lt-sm': 'screen and (max-width: 599px)', + 'lt-md': 'screen and (max-width: 959px)', + 'lt-lg': 'screen and (max-width: 1279px)', + 'lt-xl': 'screen and (max-width: 1919px)', + 'gt-xs': 'screen and (min-width: 600px)', + 'gt-sm': 'screen and (min-width: 960px)', + 'gt-md': 'screen and (min-width: 1280px)', + 'gt-lg': 'screen and (min-width: 1920px)', + 'gt-xl': 'screen and (min-width: 5001px)' +}; + +export const helpBaseUrl = 'https://thingsboard.io'; + +export const HelpLinks = { + linksMap: { + outgoingMailSettings: helpBaseUrl + '/docs/user-guide/ui/mail-settings', + smsProviderSettings: helpBaseUrl + '/docs/user-guide/ui/sms-provider-settings', + securitySettings: helpBaseUrl + '/docs/user-guide/ui/security-settings', + oauth2Settings: helpBaseUrl + '/docs/user-guide/oauth-2-support/', + twoFactorAuthSettings: helpBaseUrl + '/docs/', + ruleEngine: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/overview/', + ruleNodeCheckRelation: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node', + ruleNodeCheckExistenceFields: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node', + ruleNodeGpsGeofencingFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#gps-geofencing-filter-node', + ruleNodeJsFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#script-filter-node', + ruleNodeJsSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#switch-node', + ruleNodeAssetProfileSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#asset-profile-switch', + ruleNodeDeviceProfileSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#device-profile-switch', + ruleNodeCheckAlarmStatus: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-alarm-status', + ruleNodeMessageTypeFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#message-type-filter-node', + ruleNodeMessageTypeSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#message-type-switch-node', + ruleNodeOriginatorTypeFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#originator-type-filter-node', + ruleNodeOriginatorTypeSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#originator-type-switch-node', + ruleNodeOriginatorAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#originator-attributes', + ruleNodeOriginatorFields: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#originator-fields', + ruleNodeOriginatorTelemetry: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#originator-telemetry', + ruleNodeCustomerAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#customer-attributes', + ruleNodeCustomerDetails: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#customer-details', + ruleNodeDeviceAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#device-attributes', + ruleNodeRelatedAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#related-attributes', + ruleNodeTenantAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-attributes', + ruleNodeTenantDetails: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-details', + ruleNodeChangeOriginator: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/transformation-nodes/#change-originator', + ruleNodeTransformMsg: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/transformation-nodes/#script-transformation-node', + ruleNodeMsgToEmail: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/transformation-nodes/#to-email-node', + ruleNodeAssignToCustomer: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/transformation-nodes/#assign-to-customer-node', + ruleNodeUnassignFromCustomer: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/transformation-nodes/#unassign-from-customer-node', + ruleNodeClearAlarm: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#clear-alarm-node', + ruleNodeCreateAlarm: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#create-alarm-node', + ruleNodeCreateRelation: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#create-relation-node', + ruleNodeDeleteRelation: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#delete-relation-node', + ruleNodeMsgDelay: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#delay-node', + ruleNodeMsgGenerator: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#generator-node', + ruleNodeGpsGeofencingEvents: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#gps-geofencing-events-node', + ruleNodeLog: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#log-node', + ruleNodeRpcCallReply: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#rpc-call-reply-node', + ruleNodeRpcCallRequest: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#rpc-call-request-node', + ruleNodeSaveAttributes: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node', + ruleNodeSaveTimeseries: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node', + ruleNodeSaveToCustomTable: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table', + ruleNodeRuleChain: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node', + ruleNodeOutputNode: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/flow-nodes/#output-node', + ruleNodeAwsSns: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node', + ruleNodeAwsSqs: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node', + ruleNodeKafka: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#kafka-node', + ruleNodeMqtt: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#mqtt-node', + ruleNodeAzureIotHub: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#azure-iot-hub-node', + ruleNodeRabbitMq: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#rabbitmq-node', + ruleNodeRestApiCall: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#rest-api-call-node', + ruleNodeSendEmail: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#send-email-node', + ruleNodeSendSms: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/external-nodes/#send-sms-node', + ruleNodeMath: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#math-function-node', + tenants: helpBaseUrl + '/docs/user-guide/ui/tenants', + tenantProfiles: helpBaseUrl + '/docs/user-guide/ui/tenant-profiles', + customers: helpBaseUrl + '/docs/user-guide/ui/customers', + users: helpBaseUrl + '/docs/user-guide/ui/users', + devices: helpBaseUrl + '/docs/user-guide/ui/devices', + deviceProfiles: helpBaseUrl + '/docs/user-guide/ui/device-profiles', + assetProfiles: helpBaseUrl + '/docs/user-guide/ui/asset-profiles', + edges: helpBaseUrl + '/docs/user-guide/ui/edges', + assets: helpBaseUrl + '/docs/user-guide/ui/assets', + entityViews: helpBaseUrl + '/docs/user-guide/ui/entity-views', + entitiesImport: helpBaseUrl + '/docs/user-guide/bulk-provisioning', + rulechains: helpBaseUrl + '/docs/user-guide/ui/rule-chains', + resources: helpBaseUrl + '/docs/user-guide/ui/resources', + dashboards: helpBaseUrl + '/docs/user-guide/ui/dashboards', + otaUpdates: helpBaseUrl + '/docs/user-guide/ota-updates', + widgetsBundles: helpBaseUrl + '/docs/user-guide/ui/widget-library#bundles', + widgetsConfig: helpBaseUrl + '/docs/user-guide/ui/dashboards#widget-configuration', + widgetsConfigTimeseries: helpBaseUrl + '/docs/user-guide/ui/dashboards#timeseries', + widgetsConfigLatest: helpBaseUrl + '/docs/user-guide/ui/dashboards#latest', + widgetsConfigRpc: helpBaseUrl + '/docs/user-guide/ui/dashboards#rpc', + widgetsConfigAlarm: helpBaseUrl + '/docs/user-guide/ui/dashboards#alarm', + widgetsConfigStatic: helpBaseUrl + '/docs/user-guide/ui/dashboards#static', + ruleNodePushToCloud: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud', + ruleNodePushToEdge: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/action-nodes/#push-to-edge', + queue: helpBaseUrl + '/docs/user-guide/queue', + repositorySettings: helpBaseUrl + '/docs/user-guide/version-control/#git-settings-configuration', + autoCommitSettings: helpBaseUrl + '/docs/user-guide/version-control/#auto-commit', + twoFactorAuthentication: helpBaseUrl + '/docs/user-guide/two-factor-authentication' + } +}; + +export interface ValueTypeData { + name: string; + icon: string; +} + +export enum ValueType { + STRING = 'STRING', + INTEGER = 'INTEGER', + DOUBLE = 'DOUBLE', + BOOLEAN = 'BOOLEAN', + JSON = 'JSON' +} + +export enum DataType { + STRING = 'STRING', + LONG = 'LONG', + BOOLEAN = 'BOOLEAN', + DOUBLE = 'DOUBLE', + JSON = 'JSON' +} + +export const DataTypeTranslationMap = new Map([ + [DataType.STRING, 'value.string'], + [DataType.LONG, 'value.integer'], + [DataType.BOOLEAN, 'value.boolean'], + [DataType.DOUBLE, 'value.double'], + [DataType.JSON, 'value.json'] +]); + +export const valueTypesMap = new Map( + [ + [ + ValueType.STRING, + { + name: 'value.string', + icon: 'mdi:format-text' + } + ], + [ + ValueType.INTEGER, + { + name: 'value.integer', + icon: 'mdi:numeric' + } + ], + [ + ValueType.DOUBLE, + { + name: 'value.double', + icon: 'mdi:numeric' + } + ], + [ + ValueType.BOOLEAN, + { + name: 'value.boolean', + icon: 'mdi:checkbox-marked-outline' + } + ], + [ + ValueType.JSON, + { + name: 'value.json', + icon: 'mdi:code-json' + } + ] + ] +); + +export interface ContentTypeData { + name: string; + code: string; +} + +export enum ContentType { + JSON = 'JSON', + TEXT = 'TEXT', + BINARY = 'BINARY' +} + +export const contentTypesMap = new Map( + [ + [ + ContentType.JSON, + { + name: 'content-type.json', + code: 'json' + } + ], + [ + ContentType.TEXT, + { + name: 'content-type.text', + code: 'text' + } + ], + [ + ContentType.BINARY, + { + name: 'content-type.binary', + code: 'text' + } + ] + ] +); + +export const hidePageSizePixelValue = 550; +export const customTranslationsPrefix = 'custom.'; +export const i18nPrefix = 'i18n'; + +export const MODULES_MAP = new InjectionToken('ModulesMap'); diff --git a/ui-ngx/src/app/shared/models/contact-based.model.ts b/ui-ngx/src/app/shared/models/contact-based.model.ts new file mode 100644 index 0000000..d7da687 --- /dev/null +++ b/ui-ngx/src/app/shared/models/contact-based.model.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, HasId } from './base-data'; + +export interface ContactBased extends BaseData { + country: string; + state: string; + city: string; + address: string; + address2: string; + zip: string; + phone: string; + email: string; +} diff --git a/ui-ngx/src/app/shared/models/country.models.ts b/ui-ngx/src/app/shared/models/country.models.ts new file mode 100644 index 0000000..f243ba3 --- /dev/null +++ b/ui-ngx/src/app/shared/models/country.models.ts @@ -0,0 +1,520 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; + +export interface Country { + name: string; + iso2: string; + dialCode: string; + areaCodes?: string[]; + flag: string; +} + +export enum CountryISO { + Afghanistan = 'AF', + Albania = 'AL', + Algeria = 'DZ', + AmericanSamoa = 'AS', + Andorra = 'AD', + Angola = 'AO', + Anguilla = 'AI', + AntiguaAndBarbuda = 'AG', + Argentina = 'AR', + Armenia = 'AM', + Aruba = 'AW', + Australia = 'AU', + Austria = 'AT', + Azerbaijan = 'AZ', + Bahamas = 'BS', + Bahrain = 'BH', + Bangladesh = 'BD', + Barbados = 'BB', + Belarus = 'BY', + Belgium = 'BE', + Belize = 'BZ', + Benin = 'BJ', + Bermuda = 'BM', + Bhutan = 'BT', + Bolivia = 'BO', + BosniaAndHerzegovina = 'BA', + Botswana = 'BW', + Brazil = 'BR', + BritishIndianOceanTerritory = 'IO', + BritishVirginIslands = 'VG', + Brunei = 'BN', + Bulgaria = 'BG', + BurkinaFaso = 'BF', + Burundi = 'BI', + Cambodia = 'KH', + Cameroon = 'CM', + Canada = 'CA', + CapeVerde = 'CV', + CaribbeanNetherlands = 'BQ', + CaymanIslands = 'KY', + CentralAfricanRepublic = 'CF', + Chad = 'TD', + Chile = 'CL', + China = 'CN', + ChristmasIsland = 'CX', + Cocos = 'CC', + Colombia = 'CC', + Comoros = 'KM', + CongoDRCJamhuriYaKidemokrasiaYaKongo = 'CD', + CongoRepublicCongoBrazzaville = 'CG', + CookIslands = 'CK', + CostaRica = 'CR', + CôteDIvoire = 'CI', + Croatia = 'HR', + Cuba = 'CU', + Curaçao = 'CW', + Cyprus = 'CY', + CzechRepublic = 'CZ', + Denmark = 'DK', + Djibouti = 'DJ', + Dominica = 'DM', + DominicanRepublic = 'DO', + Ecuador = 'EC', + Egypt = 'EG', + ElSalvador = 'SV', + EquatorialGuinea = 'GQ', + Eritrea = 'ER', + Estonia = 'EE', + Ethiopia = 'ET', + FalklandIslands = 'FK', + FaroeIslands = 'FO', + Fiji = 'FJ', + Finland = 'FI', + France = 'FR', + FrenchGuiana = 'GF', + FrenchPolynesia = 'PF', + Gabon = 'GA', + Gambia = 'GM', + Georgia = 'GE', + Germany = 'DE', + Ghana = 'GH', + Gibraltar = 'GI', + Greece = 'GR', + Greenland = 'GL', + Grenada = 'GD', + Guadeloupe = 'GP', + Guam = 'GU', + Guatemala = 'GT', + Guernsey = 'GG', + Guinea = 'GN', + GuineaBissau = 'GW', + Guyana = 'GY', + Haiti = 'HT', + Honduras = 'HN', + HongKong = 'HK', + Hungary = 'HU', + Iceland = 'IS', + India = 'IN', + Indonesia = 'ID', + Iran = 'IR', + Iraq = 'IQ', + Ireland = 'IE', + IsleOfMan = 'IM', + Israel = 'IL', + Italy = 'IT', + Jamaica = 'JM', + Japan = 'JP', + Jersey = 'JE', + Jordan = 'JO', + Kazakhstan = 'KZ', + Kenya = 'KE', + Kiribati = 'KI', + Kosovo = 'XK', + Kuwait = 'KW', + Kyrgyzstan = 'KG', + Laos = 'LA', + Latvia = 'LV', + Lebanon = 'LB', + Lesotho = 'LS', + Liberia = 'LR', + Libya = 'LY', + Liechtenstein = 'LI', + Lithuania = 'LT', + Luxembourg = 'LU', + Macau = 'MO', + Macedonia = 'MK', + Madagascar = 'MG', + Malawi = 'MW', + Malaysia = 'MY', + Maldives = 'MV', + Mali = 'ML', + Malta = 'MT', + MarshallIslands = 'MH', + Martinique = 'MQ', + Mauritania = 'MR', + Mauritius = 'MU', + Mayotte = 'YT', + Mexico = 'MX', + Micronesia = 'FM', + Moldova = 'MD', + Monaco = 'MC', + Mongolia = 'MN', + Montenegro = 'ME', + Montserrat = 'MS', + Morocco = 'MA', + Mozambique = 'MZ', + Myanmar = 'MM', + Namibia = 'NA', + Nauru = 'NR', + Nepal = 'NP', + Netherlands = 'NL', + NewCaledonia = 'NC', + NewZealand = 'NZ', + Nicaragua = 'NI', + Niger = 'NE', + Nigeria = 'NG', + Niue = 'NU', + NorfolkIsland = 'NF', + NorthKorea = 'KP', + NorthernMarianaIslands = 'MP', + Norway = 'NO', + Oman = 'OM', + Pakistan = 'PK', + Palau = 'PW', + Palestine = 'PS', + Panama = 'PA', + PapuaNewGuinea = 'PG', + Paraguay = 'PY', + Peru = 'PE', + Philippines = 'PH', + Poland = 'PL', + Portugal = 'PT', + PuertoRico = 'PR', + Qatar = 'QA', + Réunion = 'RE', + Romania = 'RO', + Russia = 'RU', + Rwanda = 'RW', + SaintBarthélemy = 'BL', + SaintHelena = 'SH', + SaintKittsAndNevis = 'KN', + SaintLucia = 'LC', + SaintMartin = 'MF', + SaintPierreAndMiquelon = 'PM', + SaintVincentAndTheGrenadines = 'VC', + Samoa = 'WS', + SanMarino = 'SM', + SãoToméAndPríncipe = 'ST', + SaudiArabia = 'SA', + Senegal = 'SN', + Serbia = 'RS', + Seychelles = 'SC', + SierraLeone = 'SL', + Singapore = 'SG', + SintMaarten = 'SX', + Slovakia = 'SK', + Slovenia = 'SI', + SolomonIslands = 'SB', + Somalia = 'SO', + SouthAfrica = 'ZA', + SouthKorea = 'KR', + SouthSudan = 'SS', + Spain = 'ES', + SriLanka = 'LK', + Sudan = 'SD', + Suriname = 'SR', + SvalbardAndJanMayen = 'SJ', + Swaziland = 'SZ', + Sweden = 'SE', + Switzerland = 'CH', + Syria = 'SY', + Taiwan = 'TW', + Tajikistan = 'TJ', + Tanzania = 'TZ', + Thailand = 'TH', + TimorLeste = 'TL', + Togo = 'TG', + Tokelau = 'TK', + Tonga = 'TO', + TrinidadAndTobago = 'TT', + Tunisia = 'TN', + Turkey = 'TR', + Turkmenistan = 'TM', + TurksAndCaicosIslands = 'TC', + Tuvalu = 'TV', + USVirginIslands = 'VI', + Uganda = 'UG', + Ukraine = 'UA', + UnitedArabEmirates = 'AE', + UnitedKingdom = 'GB', + UnitedStates = 'US', + Uruguay = 'UY', + Uzbekistan = 'UZ', + Vanuatu = 'VU', + VaticanCity = 'VA', + Venezuela = 'VE', + Vietnam = 'VN', + WallisAndFutuna = 'WF', + WesternSahara = 'EH', + Yemen = 'YE', + Zambia = 'ZM', + Zimbabwe = 'ZW', + ÅlandIslands = 'AX', +} + +@Injectable() +export class CountryData { + public allCountries: Array = [ + {name: 'Afghanistan', iso2: CountryISO.Afghanistan, dialCode: '93', flag: '🇦🇫'}, + {name: 'Albania', iso2: CountryISO.Albania, dialCode: '355', flag: '🇦🇱'}, + {name: 'Algeria', iso2: CountryISO.Algeria, dialCode: '213', flag: '🇩🇿'}, + {name: 'American Samoa', iso2: CountryISO.AmericanSamoa, dialCode: '1', flag: '🇦🇸'}, + {name: 'Andorra', iso2: CountryISO.Andorra, dialCode: '376', flag: '🇦🇩'}, + {name: 'Angola', iso2: CountryISO.Angola, dialCode: '244', flag: '🇦🇴'}, + {name: 'Anguilla', iso2: CountryISO.Anguilla, dialCode: '1', flag: '🇦🇮'}, + {name: 'Antigua and Barbuda', iso2: CountryISO.AntiguaAndBarbuda, dialCode: '1', flag: '🇦🇬'}, + {name: 'Argentina', iso2: CountryISO.Argentina, dialCode: '54', flag: '🇦🇷'}, + {name: 'Armenia', iso2: CountryISO.Armenia, dialCode: '374', flag: '🇦🇲'}, + {name: 'Aruba', iso2: CountryISO.Aruba, dialCode: '297', flag: '🇦🇼'}, + {name: 'Australia', iso2: CountryISO.Australia, dialCode: '61', flag: '🇦🇺'}, + {name: 'Austria', iso2: CountryISO.Austria, dialCode: '43', flag: '🇦🇹'}, + {name: 'Azerbaijan', iso2: CountryISO.Azerbaijan, dialCode: '994', flag: '🇦🇿'}, + {name: 'Bahamas', iso2: CountryISO.Bahamas, dialCode: '1', flag: '🇧🇸'}, + {name: 'Bahrain', iso2: CountryISO.Bahrain, dialCode: '973', flag: '🇧🇭'}, + {name: 'Bangladesh', iso2: CountryISO.Bangladesh, dialCode: '880', flag: '🇧🇩'}, + {name: 'Barbados', iso2: CountryISO.Barbados, dialCode: '1', flag: '🇧🇧'}, + {name: 'Belarus', iso2: CountryISO.Belarus, dialCode: '375', flag: '🇧🇾'}, + {name: 'Belgium', iso2: CountryISO.Belgium, dialCode: '32', flag: '🇧🇪'}, + {name: 'Belize', iso2: CountryISO.Belize, dialCode: '501', flag: '🇧🇿'}, + {name: 'Benin', iso2: CountryISO.Benin, dialCode: '229', flag: '🇧🇯'}, + {name: 'Bermuda', iso2: CountryISO.Bermuda, dialCode: '1', flag: '🇧🇲'}, + {name: 'Bhutan', iso2: CountryISO.Bhutan, dialCode: '975', flag: '🇧🇹'}, + {name: 'Bolivia', iso2: CountryISO.Bolivia, dialCode: '591', flag: '🇧🇴'}, + {name: 'Bosnia and Herzegovina', iso2: CountryISO.BosniaAndHerzegovina, dialCode: '387', flag: '🇧🇦'}, + {name: 'Botswana', iso2: CountryISO.Botswana, dialCode: '267', flag: '🇧🇼'}, + {name: 'Brazil', iso2: CountryISO.Brazil, dialCode: '55', flag: '🇧🇷'}, + {name: 'British Indian Ocean Territory', iso2: CountryISO.BritishIndianOceanTerritory, dialCode: '246', flag: '🇮🇴'}, + {name: 'British Virgin Islands', iso2: CountryISO.BritishVirginIslands, dialCode: '1', flag: '🇻🇬'}, + {name: 'Brunei', iso2: CountryISO.Brunei, dialCode: '673', flag: '🇧🇳'}, + {name: 'Bulgaria', iso2: CountryISO.Bulgaria, dialCode: '359', flag: '🇧🇬'}, + {name: 'Burkina Faso', iso2: CountryISO.BurkinaFaso, dialCode: '226', flag: '🇧🇫'}, + {name: 'Burundi', iso2: CountryISO.Burundi, dialCode: '257', flag: '🇧🇮'}, + {name: 'Cambodia', iso2: CountryISO.Cambodia, dialCode: '855', flag: '🇰🇭'}, + {name: 'Cameroon', iso2: CountryISO.Cameroon, dialCode: '237', flag: '🇨🇲'}, + {name: 'Canada', iso2: CountryISO.Canada, dialCode: '1', flag: '🇨🇦'}, + {name: 'Cape Verde', iso2: CountryISO.CapeVerde, dialCode: '238', flag: '🇨🇻'}, + {name: 'Caribbean Netherlands', iso2: CountryISO.CaribbeanNetherlands, dialCode: '599', flag: '🇧🇶'}, + {name: 'Cayman Islands', iso2: CountryISO.CaymanIslands, dialCode: '1', flag: '🇰🇾'}, + {name: 'Central African Republic', iso2: CountryISO.CentralAfricanRepublic, dialCode: '236', flag: '🇨🇫'}, + {name: 'Chad', iso2: CountryISO.Chad, dialCode: '235', flag: '🇹🇩'}, + {name: 'Chile', iso2: CountryISO.Chile, dialCode: '56', flag: '🇨🇱'}, + {name: 'China', iso2: CountryISO.China, dialCode: '86', flag: '🇨🇳'}, + {name: 'Christmas Island', iso2: CountryISO.ChristmasIsland, dialCode: '61', flag: '🇨🇽'}, + {name: 'Cocos Islands', iso2: CountryISO.Cocos, dialCode: '61', flag: '🇨🇨'}, + {name: 'Colombia', iso2: CountryISO.Colombia, dialCode: '57', flag: '🇨🇨'}, + {name: 'Comoros', iso2: CountryISO.Comoros, dialCode: '269', flag: '🇰🇲'}, + {name: 'Congo-Kinshasa', iso2: CountryISO.CongoDRCJamhuriYaKidemokrasiaYaKongo, dialCode: '243', flag: '🇨🇩'}, + {name: 'Congo-Brazzaville', iso2: CountryISO.CongoRepublicCongoBrazzaville, dialCode: '242', flag: '🇨🇬'}, + {name: 'Cook Islands', iso2: CountryISO.CookIslands, dialCode: '682', flag: '🇨🇰'}, + {name: 'Costa Rica', iso2: CountryISO.CostaRica, dialCode: '506', flag: '🇨🇷'}, + {name: 'Côte d’Ivoire', iso2: CountryISO.CôteDIvoire, dialCode: '225', flag: '🇨🇮'}, + {name: 'Croatia', iso2: CountryISO.Croatia, dialCode: '385', flag: '🇭🇷'}, + {name: 'Cuba', iso2: CountryISO.Cuba, dialCode: '53', flag: '🇨🇺'}, + {name: 'Curaçao', iso2: CountryISO.Curaçao, dialCode: '599', flag: '🇨🇼'}, + {name: 'Cyprus', iso2: CountryISO.Cyprus, dialCode: '357', flag: '🇨🇾'}, + {name: 'Czech Republic', iso2: CountryISO.CzechRepublic, dialCode: '420', flag: '🇨🇿'}, + {name: 'Denmark', iso2: CountryISO.Denmark, dialCode: '45', flag: '🇩🇰'}, + {name: 'Djibouti', iso2: CountryISO.Djibouti, dialCode: '253', flag: '🇩🇯'}, + {name: 'Dominica', iso2: CountryISO.Dominica, dialCode: '1767', flag: '🇩🇲'}, + {name: 'Dominican Republic', iso2: CountryISO.DominicanRepublic, dialCode: '1', flag: '🇩🇴'}, + {name: 'Ecuador', iso2: CountryISO.Ecuador, dialCode: '593', flag: '🇪🇨'}, + {name: 'Egypt', iso2: CountryISO.Egypt, dialCode: '20', flag: '🇪🇬'}, + {name: 'El Salvador', iso2: CountryISO.ElSalvador, dialCode: '503', flag: '🇸🇻'}, + {name: 'Equatorial Guinea', iso2: CountryISO.EquatorialGuinea, dialCode: '240', flag: '🇬🇶'}, + {name: 'Eritrea', iso2: CountryISO.Eritrea, dialCode: '291', flag: '🇪🇷'}, + {name: 'Estonia', iso2: CountryISO.Estonia, dialCode: '372', flag: '🇪🇪'}, + {name: 'Ethiopia', iso2: CountryISO.Ethiopia, dialCode: '251', flag: '🇪🇹'}, + {name: 'Falkland Islands', iso2: CountryISO.FalklandIslands, dialCode: '500', flag: '🇫🇰'}, + {name: 'Faroe Islands', iso2: CountryISO.FaroeIslands, dialCode: '298', flag: '🇫🇴'}, + {name: 'Fiji', iso2: CountryISO.Fiji, dialCode: '679', flag: '🇫🇯'}, + {name: 'Finland', iso2: CountryISO.Finland, dialCode: '358', flag: '🇫🇮'}, + {name: 'France', iso2: CountryISO.France, dialCode: '33', flag: '🇫🇷'}, + {name: 'French Guiana', iso2: CountryISO.FrenchGuiana, dialCode: '594', flag: '🇬🇫'}, + {name: 'French Polynesia', iso2: CountryISO.FrenchPolynesia, dialCode: '689', flag: '🇵🇫'}, + {name: 'Gabon', iso2: CountryISO.Gabon, dialCode: '241', flag: '🇬🇦'}, + {name: 'Gambia', iso2: CountryISO.Gambia, dialCode: '220', flag: '🇬🇲'}, + {name: 'Georgia', iso2: CountryISO.Georgia, dialCode: '995', flag: '🇬🇪'}, + {name: 'Germany', iso2: CountryISO.Germany, dialCode: '49', flag: '🇩🇪'}, + {name: 'Ghana', iso2: CountryISO.Ghana, dialCode: '233', flag: '🇬🇭'}, + {name: 'Gibraltar', iso2: CountryISO.Gibraltar, dialCode: '350', flag: '🇬🇮'}, + {name: 'Greece', iso2: CountryISO.Greece, dialCode: '30', flag: '🇬🇷'}, + {name: 'Greenland', iso2: CountryISO.Greenland, dialCode: '299', flag: '🇬🇱'}, + {name: 'Grenada', iso2: CountryISO.Grenada, dialCode: '1', flag: '🇬🇩'}, + {name: 'Guadeloupe', iso2: CountryISO.Guadeloupe, dialCode: '590', flag: '🇬🇵'}, + {name: 'Guam', iso2: CountryISO.Guam, dialCode: '1', flag: '🇬🇺'}, + {name: 'Guatemala', iso2: CountryISO.Guatemala, dialCode: '502', flag: '🇬🇹'}, + {name: 'Guernsey', iso2: CountryISO.Guernsey, dialCode: '44', flag: '🇬🇬'}, + {name: 'Guinea', iso2: CountryISO.Guinea, dialCode: '224', flag: '🇬🇳'}, + {name: 'Guinea-Bissau', iso2: CountryISO.GuineaBissau, dialCode: '245', flag: '🇬🇼'}, + {name: 'Guyana', iso2: CountryISO.Guyana, dialCode: '592', flag: '🇬🇾'}, + {name: 'Haiti', iso2: CountryISO.Haiti, dialCode: '509', flag: '🇭🇹'}, + {name: 'Honduras', iso2: CountryISO.Honduras, dialCode: '504', flag: '🇭🇳'}, + {name: 'Hong Kong', iso2: CountryISO.HongKong, dialCode: '852', flag: '🇭🇰'}, + {name: 'Hungary', iso2: CountryISO.Hungary, dialCode: '36', flag: '🇭🇺'}, + {name: 'Iceland', iso2: CountryISO.Iceland, dialCode: '354', flag: '🇮🇸'}, + {name: 'India', iso2: CountryISO.India, dialCode: '91', flag: '🇮🇳'}, + {name: 'Indonesia', iso2: CountryISO.Indonesia, dialCode: '62', flag: '🇮🇩'}, + {name: 'Iran', iso2: CountryISO.Iran, dialCode: '98', flag: '🇮🇷'}, + {name: 'Iraq', iso2: CountryISO.Iraq, dialCode: '964', flag: '🇮🇶'}, + {name: 'Ireland', iso2: CountryISO.Ireland, dialCode: '353', flag: '🇮🇪'}, + {name: 'Isle of Man', iso2: CountryISO.IsleOfMan, dialCode: '44', flag: '🇮🇲'}, + {name: 'Israel', iso2: CountryISO.Israel, dialCode: '972', flag: '🇮🇱'}, + {name: 'Italy', iso2: CountryISO.Italy, dialCode: '39', flag: '🇮🇹'}, + {name: 'Jamaica', iso2: CountryISO.Jamaica, dialCode: '1', flag: '🇯🇲'}, + {name: 'Japan', iso2: CountryISO.Japan, dialCode: '81', flag: '🇯🇵'}, + {name: 'Jersey', iso2: CountryISO.Jersey, dialCode: '44', flag: '🇯🇪'}, + {name: 'Jordan', iso2: CountryISO.Jordan, dialCode: '962', flag: '🇯🇴'}, + {name: 'Kazakhstan', iso2: CountryISO.Kazakhstan, dialCode: '7', flag: '🇰🇿'}, + {name: 'Kenya', iso2: CountryISO.Kenya, dialCode: '254', flag: '🇰🇪'}, + {name: 'Kiribati', iso2: CountryISO.Kiribati, dialCode: '686', flag: '🇰🇮'}, + {name: 'Kosovo', iso2: CountryISO.Kosovo, dialCode: '383', flag: '🇽🇰'}, + {name: 'Kuwait', iso2: CountryISO.Kuwait, dialCode: '965', flag: '🇰🇼'}, + {name: 'Kyrgyzstan', iso2: CountryISO.Kyrgyzstan, dialCode: '996', flag: '🇰🇬'}, + {name: 'Laos', iso2: CountryISO.Laos, dialCode: '856', flag: '🇱🇦'}, + {name: 'Latvia', iso2: CountryISO.Latvia, dialCode: '371', flag: '🇱🇻'}, + {name: 'Lebanon', iso2: CountryISO.Lebanon, dialCode: '961', flag: '🇱🇧'}, + {name: 'Lesotho', iso2: CountryISO.Lesotho, dialCode: '266', flag: '🇱🇸'}, + {name: 'Liberia', iso2: CountryISO.Liberia, dialCode: '231', flag: '🇱🇷'}, + {name: 'Libya', iso2: CountryISO.Libya, dialCode: '218', flag: '🇱🇾'}, + {name: 'Liechtenstein', iso2: CountryISO.Liechtenstein, dialCode: '423', flag: '🇱🇮'}, + {name: 'Lithuania', iso2: CountryISO.Lithuania, dialCode: '370', flag: '🇱🇹'}, + {name: 'Luxembourg', iso2: CountryISO.Luxembourg, dialCode: '352', flag: '🇱🇺'}, + {name: 'Macau', iso2: CountryISO.Macau, dialCode: '853', flag: '🇲🇴'}, + {name: 'Macedonia', iso2: CountryISO.Macedonia, dialCode: '389', flag: '🇲🇰'}, + {name: 'Madagascar', iso2: CountryISO.Madagascar, dialCode: '261', flag: '🇲🇬'}, + {name: 'Malawi', iso2: CountryISO.Malawi, dialCode: '265', flag: '🇲🇼'}, + {name: 'Malaysia', iso2: CountryISO.Malaysia, dialCode: '60', flag: '🇲🇾'}, + {name: 'Maldives', iso2: CountryISO.Maldives, dialCode: '960', flag: '🇲🇻'}, + {name: 'Mali', iso2: CountryISO.Mali, dialCode: '223', flag: '🇲🇱'}, + {name: 'Malta', iso2: CountryISO.Malta, dialCode: '356', flag: '🇲🇹'}, + {name: 'Marshall Islands', iso2: CountryISO.MarshallIslands, dialCode: '692', flag: '🇲🇭'}, + {name: 'Martinique', iso2: CountryISO.Martinique, dialCode: '596', flag: '🇲🇶'}, + {name: 'Mauritania', iso2: CountryISO.Mauritania, dialCode: '222', flag: '🇲🇷'}, + {name: 'Mauritius', iso2: CountryISO.Mauritius, dialCode: '230', flag: '🇲🇺'}, + {name: 'Mayotte', iso2: CountryISO.Mayotte, dialCode: '262', flag: '🇾🇹'}, + {name: 'Mexico', iso2: CountryISO.Mexico, dialCode: '52', flag: '🇲🇽'}, + {name: 'Micronesia', iso2: CountryISO.Micronesia, dialCode: '691', flag: '🇫🇲'}, + {name: 'Moldova', iso2: CountryISO.Moldova, dialCode: '373', flag: '🇲🇩'}, + {name: 'Monaco', iso2: CountryISO.Monaco, dialCode: '377', flag: '🇲🇨'}, + {name: 'Mongolia', iso2: CountryISO.Mongolia, dialCode: '976', flag: '🇲🇳'}, + {name: 'Montenegro', iso2: CountryISO.Montenegro, dialCode: '382', flag: '🇲🇪'}, + {name: 'Montserrat', iso2: CountryISO.Montserrat, dialCode: '1', flag: '🇲🇸'}, + {name: 'Morocco', iso2: CountryISO.Morocco, dialCode: '212', flag: '🇲🇦'}, + {name: 'Mozambique', iso2: CountryISO.Mozambique, dialCode: '258', flag: '🇲🇿'}, + {name: 'Myanmar', iso2: CountryISO.Myanmar, dialCode: '95', flag: '🇲🇲'}, + {name: 'Namibia', iso2: CountryISO.Namibia, dialCode: '264', flag: '🇳🇦'}, + {name: 'Nauru', iso2: CountryISO.Nauru, dialCode: '674', flag: '🇳🇷'}, + {name: 'Nepal', iso2: CountryISO.Nepal, dialCode: '977', flag: '🇳🇵'}, + {name: 'Netherlands', iso2: CountryISO.Netherlands, dialCode: '31', flag: '🇳🇱'}, + {name: 'New Caledonia', iso2: CountryISO.NewCaledonia, dialCode: '687', flag: '🇳🇨'}, + {name: 'New Zealand', iso2: CountryISO.NewZealand, dialCode: '64', flag: '🇳🇿'}, + {name: 'Nicaragua', iso2: CountryISO.Nicaragua, dialCode: '505', flag: '🇳🇮'}, + {name: 'Niger', iso2: CountryISO.Niger, dialCode: '227', flag: '🇳🇪'}, + {name: 'Nigeria', iso2: CountryISO.Nigeria, dialCode: '234', flag: '🇳🇬'}, + {name: 'Niue', iso2: CountryISO.Niue, dialCode: '683', flag: '🇳🇺'}, + {name: 'Norfolk Island', iso2: CountryISO.NorfolkIsland, dialCode: '672', flag: '🇳🇫'}, + {name: 'North Korea', iso2: CountryISO.NorthKorea, dialCode: '850', flag: '🇰🇵'}, + {name: 'Northern Mariana Islands', iso2: CountryISO.NorthernMarianaIslands, dialCode: '1', flag: '🇲🇵'}, + {name: 'Norway', iso2: CountryISO.Norway, dialCode: '47', flag: '🇳🇴'}, + {name: 'Oman', iso2: CountryISO.Oman, dialCode: '968', flag: '🇴🇲'}, + {name: 'Pakistan', iso2: CountryISO.Pakistan, dialCode: '92', flag: '🇵🇰'}, + {name: 'Palau', iso2: CountryISO.Palau, dialCode: '680', flag: '🇵🇼'}, + {name: 'Palestine', iso2: CountryISO.Palestine, dialCode: '970', flag: '🇵🇸'}, + {name: 'Panama', iso2: CountryISO.Panama, dialCode: '507', flag: '🇵🇦'}, + {name: 'Papua New Guinea', iso2: CountryISO.PapuaNewGuinea, dialCode: '675', flag: '🇵🇬'}, + {name: 'Paraguay', iso2: CountryISO.Paraguay, dialCode: '595', flag: '🇵🇾'}, + {name: 'Peru', iso2: CountryISO.Peru, dialCode: '51', flag: '🇵🇪'}, + {name: 'Philippines', iso2: CountryISO.Philippines, dialCode: '63', flag: '🇵🇭'}, + {name: 'Poland', iso2: CountryISO.Poland, dialCode: '48', flag: '🇵🇱'}, + {name: 'Portugal', iso2: CountryISO.Portugal, dialCode: '351', flag: '🇵🇹'}, + {name: 'Puerto Rico', iso2: CountryISO.PuertoRico, dialCode: '1', flag: '🇵🇷'}, + {name: 'Qatar', iso2: CountryISO.Qatar, dialCode: '974', flag: '🇶🇦'}, + {name: 'Réunion', iso2: CountryISO.Réunion, dialCode: '262', flag: '🇷🇪'}, + {name: 'Romania', iso2: CountryISO.Romania, dialCode: '40', flag: '🇷🇴'}, + {name: 'Russia', iso2: CountryISO.Russia, dialCode: '7', flag: '🇷🇺'}, + {name: 'Rwanda', iso2: CountryISO.Rwanda, dialCode: '250', flag: '🇷🇼'}, + {name: 'Saint Barthélemy', iso2: CountryISO.SaintBarthélemy, dialCode: '590', flag: '🇧🇱'}, + {name: 'Saint Helena', iso2: CountryISO.SaintHelena, dialCode: '290', flag: '🇸🇭'}, + {name: 'Saint Kitts and Nevis', iso2: CountryISO.SaintKittsAndNevis, dialCode: '1', flag: '🇰🇳'}, + {name: 'Saint Lucia', iso2: CountryISO.SaintLucia, dialCode: '1', flag: '🇱🇨'}, + {name: 'Saint Martin', iso2: CountryISO.SaintMartin, dialCode: '590', flag: '🇲🇫'}, + {name: 'Saint Pierre and Miquelon', iso2: CountryISO.SaintPierreAndMiquelon, dialCode: '508', flag: '🇵🇲'}, + {name: 'Saint Vincent and the Grenadines', iso2: CountryISO.SaintVincentAndTheGrenadines, dialCode: '1', flag: '🇻🇨'}, + {name: 'Samoa', iso2: CountryISO.Samoa, dialCode: '685', flag: '🇼🇸'}, + {name: 'San Marino', iso2: CountryISO.SanMarino, dialCode: '378', flag: '🇸🇲'}, + {name: 'São Tomé and Príncipe', iso2: CountryISO.SãoToméAndPríncipe, dialCode: '239', flag: '🇸🇹'}, + {name: 'Saudi Arabia', iso2: CountryISO.SaudiArabia, dialCode: '966', flag: '🇸🇦'}, + {name: 'Senegal', iso2: CountryISO.Senegal, dialCode: '221', flag: '🇸🇳'}, + {name: 'Serbia', iso2: CountryISO.Serbia, dialCode: '381', flag: '🇷🇸'}, + {name: 'Seychelles', iso2: CountryISO.Seychelles, dialCode: '248', flag: '🇸🇨'}, + {name: 'Sierra Leone', iso2: CountryISO.SierraLeone, dialCode: '232', flag: '🇸🇱'}, + {name: 'Singapore', iso2: CountryISO.Singapore, dialCode: '65', flag: '🇸🇬'}, + {name: 'Sint Maarten', iso2: CountryISO.SintMaarten, dialCode: '1', flag: '🇸🇽'}, + {name: 'Slovakia', iso2: CountryISO.Slovakia, dialCode: '421', flag: '🇸🇰'}, + {name: 'Slovenia', iso2: CountryISO.Slovenia, dialCode: '386', flag: '🇸🇮'}, + {name: 'Solomon Islands', iso2: CountryISO.SolomonIslands, dialCode: '677', flag: '🇸🇧'}, + {name: 'Somalia', iso2: CountryISO.Somalia, dialCode: '252', flag: '🇸🇴'}, + {name: 'South Africa', iso2: CountryISO.SouthAfrica, dialCode: '27', flag: '🇿🇦'}, + {name: 'South Korea', iso2: CountryISO.SouthKorea, dialCode: '82', flag: '🇰🇷'}, + {name: 'South Sudan', iso2: CountryISO.SouthSudan, dialCode: '211', flag: '🇸🇸'}, + {name: 'Spain', iso2: CountryISO.Spain, dialCode: '34', flag: '🇪🇸'}, + {name: 'Sri Lanka', iso2: CountryISO.SriLanka, dialCode: '94', flag: '🇱🇰'}, + {name: 'Sudan', iso2: CountryISO.Sudan, dialCode: '249', flag: '🇸🇩'}, + {name: 'Suriname: ', iso2: CountryISO.Suriname, dialCode: '597', flag: '🇸🇷'}, + {name: 'Svalbard and Jan Mayen', iso2: CountryISO.SvalbardAndJanMayen, dialCode: '47', flag: '🇸🇯'}, + {name: 'Swaziland', iso2: CountryISO.Swaziland, dialCode: '268', flag: '🇸🇿'}, + {name: 'Sweden', iso2: CountryISO.Sweden, dialCode: '46', flag: '🇸🇪'}, + {name: 'Switzerland', iso2: CountryISO.Switzerland, dialCode: '41', flag: '🇨🇭'}, + {name: 'Syria', iso2: CountryISO.Syria, dialCode: '963', flag: '🇸🇾'}, + {name: 'Taiwan', iso2: CountryISO.Taiwan, dialCode: '886', flag: '🇹🇼'}, + {name: 'Tajikistan', iso2: CountryISO.Tajikistan, dialCode: '992', flag: '🇹🇯'}, + {name: 'Tanzania', iso2: CountryISO.Tanzania, dialCode: '255', flag: '🇹🇿'}, + {name: 'Thailand', iso2: CountryISO.Thailand, dialCode: '66', flag: '🇹🇭'}, + {name: 'Timor-Leste', iso2: CountryISO.TimorLeste, dialCode: '670', flag: '🇹🇱'}, + {name: 'Togo', iso2: CountryISO.Togo, dialCode: '228', flag: '🇹🇬'}, + {name: 'Tokelau', iso2: CountryISO.Tokelau, dialCode: '690', flag: '🇹🇰'}, + {name: 'Tonga', iso2: CountryISO.Tonga, dialCode: '676', flag: '🇹🇴'}, + {name: 'Trinidad and Tobago', iso2: CountryISO.TrinidadAndTobago, dialCode: '1', flag: '🇹🇹'}, + {name: 'Tunisia', iso2: CountryISO.Tunisia, dialCode: '216', flag: '🇹🇳'}, + {name: 'Turkey', iso2: CountryISO.Turkey, dialCode: '90', flag: '🇹🇷'}, + {name: 'Turkmenistan', iso2: CountryISO.Turkmenistan, dialCode: '993', flag: '🇹🇲'}, + {name: 'Turks and Caicos Islands', iso2: CountryISO.TurksAndCaicosIslands, dialCode: '1', flag: '🇹🇨'}, + {name: 'Tuvalu', iso2: CountryISO.Tuvalu, dialCode: '688', flag: '🇹🇻'}, + {name: 'U.S. Virgin Islands', iso2: CountryISO.USVirginIslands, dialCode: '1', flag: '🇻🇮'}, + {name: 'Uganda', iso2: CountryISO.Uganda, dialCode: '256', flag: '🇺🇬'}, + {name: 'Ukraine', iso2: CountryISO.Ukraine, dialCode: '380', flag: '🇺🇦'}, + {name: 'United Arab Emirates', iso2: CountryISO.UnitedArabEmirates, dialCode: '971', flag: '🇦🇪'}, + {name: 'United Kingdom', iso2: CountryISO.UnitedKingdom, dialCode: '44', flag: '🇬🇧'}, + {name: 'United States', iso2: CountryISO.UnitedStates, dialCode: '1', flag: '🇺🇸'}, + {name: 'Uruguay', iso2: CountryISO.Uruguay, dialCode: '598', flag: '🇺🇾'}, + {name: 'Uzbekistan', iso2: CountryISO.Uzbekistan, dialCode: '998', flag: '🇺🇿'}, + {name: 'Vanuatu', iso2: CountryISO.Vanuatu, dialCode: '678', flag: '🇻🇺'}, + {name: 'Vatican City', iso2: CountryISO.VaticanCity, dialCode: '39', flag: '🇻🇦'}, + {name: 'Venezuela', iso2: CountryISO.Venezuela, dialCode: '58', flag: '🇻🇪'}, + {name: 'Vietnam', iso2: CountryISO.Vietnam, dialCode: '84', flag: '🇻🇳'}, + {name: 'Wallis and Futuna', iso2: CountryISO.WallisAndFutuna, dialCode: '681', flag: '🇼🇫'}, + {name: 'Western Sahara', iso2: CountryISO.WesternSahara, dialCode: '212', flag: '🇪🇭'}, + {name: 'Yemen', iso2: CountryISO.Yemen, dialCode: '967', flag: '🇾🇪'}, + {name: 'Zambia', iso2: CountryISO.Zambia, dialCode: '260', flag: '🇿🇲'}, + {name: 'Zimbabwe', iso2: CountryISO.Zimbabwe, dialCode: '263', flag: '🇿🇼'}, + {name: 'Åland Islands', iso2: CountryISO.ÅlandIslands, dialCode: '358', flag: '🇦🇽'} + ]; +} diff --git a/ui-ngx/src/app/shared/models/customer.model.ts b/ui-ngx/src/app/shared/models/customer.model.ts new file mode 100644 index 0000000..d267258 --- /dev/null +++ b/ui-ngx/src/app/shared/models/customer.model.ts @@ -0,0 +1,32 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { CustomerId } from '@shared/models/id/customer-id'; +import { ContactBased } from '@shared/models/contact-based.model'; +import { TenantId } from './id/tenant-id'; +import { ExportableEntity } from '@shared/models/base-data'; + +export interface Customer extends ContactBased, ExportableEntity { + tenantId: TenantId; + title: string; + additionalInfo?: any; +} + +export interface ShortCustomerInfo { + customerId: CustomerId; + title: string; + public: boolean; +} diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts new file mode 100644 index 0000000..8548bc7 --- /dev/null +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { ShortCustomerInfo } from '@shared/models/customer.model'; +import { Widget } from './widget.models'; +import { Timewindow } from '@shared/models/time/time.models'; +import { EntityAliases } from './alias.models'; +import { Filters } from '@shared/models/query/query.models'; +import { MatDialogRef } from '@angular/material/dialog'; + +export interface DashboardInfo extends BaseData, ExportableEntity { + tenantId?: TenantId; + title?: string; + image?: string; + assignedCustomers?: Array; + mobileHide?: boolean; + mobileOrder?: number; +} + +export interface WidgetLayout { + sizeX?: number; + sizeY?: number; + desktopHide?: boolean; + mobileHide?: boolean; + mobileHeight?: number; + mobileOrder?: number; + col?: number; + row?: number; +} + +export interface WidgetLayouts { + [id: string]: WidgetLayout; +} + +export interface GridSettings { + backgroundColor?: string; + columns?: number; + margin?: number; + backgroundSizeMode?: string; + backgroundImageUrl?: string; + autoFillHeight?: boolean; + mobileAutoFillHeight?: boolean; + mobileRowHeight?: number; + layoutDimension?: LayoutDimension; + [key: string]: any; +} + +export interface DashboardLayout { + widgets: WidgetLayouts; + gridSettings: GridSettings; +} + +export interface DashboardLayoutInfo { + widgetIds?: string[]; + widgetLayouts?: WidgetLayouts; + gridSettings?: GridSettings; +} + +export interface LayoutDimension { + type?: LayoutType, + fixedWidth?: number, + fixedLayout?: DashboardLayoutId, + leftWidthPercentage?: number +} + +export declare type DashboardLayoutId = 'main' | 'right'; + +export declare type LayoutType = 'percentage' | 'fixed'; + +export declare type DashboardStateLayouts = {[key in DashboardLayoutId]?: DashboardLayout}; + +export declare type DashboardLayoutsInfo = {[key in DashboardLayoutId]?: DashboardLayoutInfo}; + +export interface DashboardState { + name: string; + root: boolean; + layouts: DashboardStateLayouts; +} + +export declare type StateControllerId = 'entity' | 'default' | string; + +export interface DashboardSettings { + stateControllerId?: StateControllerId; + showTitle?: boolean; + showDashboardsSelect?: boolean; + showEntitiesSelect?: boolean; + showFilters?: boolean; + showDashboardLogo?: boolean; + dashboardLogoUrl?: string; + showDashboardTimewindow?: boolean; + showDashboardExport?: boolean; + showUpdateDashboardImage?: boolean; + toolbarAlwaysOpen?: boolean; + hideToolbar?: boolean; + titleColor?: string; + dashboardCss?: string; +} + +export interface DashboardConfiguration { + timewindow?: Timewindow; + settings?: DashboardSettings; + widgets?: {[id: string]: Widget } | Widget[]; + states?: {[id: string]: DashboardState }; + entityAliases?: EntityAliases; + filters?: Filters; + [key: string]: any; +} + +export interface Dashboard extends DashboardInfo { + configuration?: DashboardConfiguration; + dialogRef?: MatDialogRef; +} + +export interface HomeDashboard extends Dashboard { + hideDashboardToolbar: boolean; +} + +export interface HomeDashboardInfo { + dashboardId: DashboardId; + hideDashboardToolbar: boolean; +} + +export function isPublicDashboard(dashboard: DashboardInfo): boolean { + if (dashboard && dashboard.assignedCustomers) { + return dashboard.assignedCustomers + .filter(customerInfo => customerInfo.public).length > 0; + } else { + return false; + } +} + +export function getDashboardAssignedCustomersText(dashboard: DashboardInfo): string { + if (dashboard && dashboard.assignedCustomers && dashboard.assignedCustomers.length > 0) { + return dashboard.assignedCustomers + .filter(customerInfo => !customerInfo.public) + .map(customerInfo => customerInfo.title) + .join(', '); + } else { + return ''; + } +} + +export function isCurrentPublicDashboardCustomer(dashboard: DashboardInfo, customerId: string): boolean { + if (customerId && dashboard && dashboard.assignedCustomers) { + return dashboard.assignedCustomers.filter(customerInfo => { + return customerInfo.public && customerId === customerInfo.customerId.id; + }).length > 0; + } else { + return false; + } +} diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts new file mode 100644 index 0000000..5f052d3 --- /dev/null +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -0,0 +1,837 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { DeviceId } from './id/device-id'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { DeviceCredentialsId } from '@shared/models/id/device-credentials-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; +import { DeviceProfileId } from '@shared/models/id/device-profile-id'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; +import { EntityInfoData } from '@shared/models/entity.models'; +import { FilterPredicateValue, KeyFilter } from '@shared/models/query/query.models'; +import { TimeUnit } from '@shared/models/time/time.models'; +import * as _moment from 'moment'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { OtaPackageId } from '@shared/models/id/ota-package-id'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { DataType } from '@shared/models/constants'; +import { + getDefaultProfileClientLwM2mSettingsConfig, + getDefaultProfileObserveAttrConfig, + PowerMode +} from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; + +export enum DeviceProfileType { + DEFAULT = 'DEFAULT', + SNMP = 'SNMP' +} + +export enum DeviceTransportType { + DEFAULT = 'DEFAULT', + MQTT = 'MQTT', + COAP = 'COAP', + LWM2M = 'LWM2M', + SNMP = 'SNMP' +} + +export enum TransportPayloadType { + JSON = 'JSON', + PROTOBUF = 'PROTOBUF' +} + +export enum CoapTransportDeviceType { + DEFAULT = 'DEFAULT', + EFENTO = 'EFENTO' +} + +export enum DeviceProvisionType { + DISABLED = 'DISABLED', + ALLOW_CREATE_NEW_DEVICES = 'ALLOW_CREATE_NEW_DEVICES', + CHECK_PRE_PROVISIONED_DEVICES = 'CHECK_PRE_PROVISIONED_DEVICES' +} + +export interface DeviceConfigurationFormInfo { + hasProfileConfiguration: boolean; + hasDeviceConfiguration: boolean; +} + +export const deviceProfileTypeTranslationMap = new Map( + [ + [DeviceProfileType.DEFAULT, 'device-profile.type-default'] + ] +); + +export const deviceProfileTypeConfigurationInfoMap = new Map( + [ + [ + DeviceProfileType.DEFAULT, + { + hasProfileConfiguration: false, + hasDeviceConfiguration: false, + } + ], + [ + DeviceProfileType.SNMP, + { + hasProfileConfiguration: true, + hasDeviceConfiguration: true, + } + ] + ] +); + +export const deviceTransportTypeTranslationMap = new Map( + [ + [DeviceTransportType.DEFAULT, 'device-profile.transport-type-default'], + [DeviceTransportType.MQTT, 'device-profile.transport-type-mqtt'], + [DeviceTransportType.COAP, 'device-profile.transport-type-coap'], + [DeviceTransportType.LWM2M, 'device-profile.transport-type-lwm2m'], + [DeviceTransportType.SNMP, 'device-profile.transport-type-snmp'] + ] +); + + +export const deviceProvisionTypeTranslationMap = new Map( + [ + [DeviceProvisionType.DISABLED, 'device-profile.provision-strategy-disabled'], + [DeviceProvisionType.ALLOW_CREATE_NEW_DEVICES, 'device-profile.provision-strategy-created-new'], + [DeviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES, 'device-profile.provision-strategy-check-pre-provisioned'] + ] +); + +export const deviceTransportTypeHintMap = new Map( + [ + [DeviceTransportType.DEFAULT, 'device-profile.transport-type-default-hint'], + [DeviceTransportType.MQTT, 'device-profile.transport-type-mqtt-hint'], + [DeviceTransportType.COAP, 'device-profile.transport-type-coap-hint'], + [DeviceTransportType.LWM2M, 'device-profile.transport-type-lwm2m-hint'], + [DeviceTransportType.SNMP, 'device-profile.transport-type-snmp-hint'] + ] +); + +export const transportPayloadTypeTranslationMap = new Map( + [ + [TransportPayloadType.JSON, 'device-profile.transport-device-payload-type-json'], + [TransportPayloadType.PROTOBUF, 'device-profile.transport-device-payload-type-proto'] + ] +); + +export const defaultTelemetrySchema = + 'syntax ="proto3";\n' + + 'package telemetry;\n' + + '\n' + + 'message SensorDataReading {\n' + + '\n' + + ' optional double temperature = 1;\n' + + ' optional double humidity = 2;\n' + + ' InnerObject innerObject = 3;\n' + + '\n' + + ' message InnerObject {\n' + + ' optional string key1 = 1;\n' + + ' optional bool key2 = 2;\n' + + ' optional double key3 = 3;\n' + + ' optional int32 key4 = 4;\n' + + ' optional string key5 = 5;\n' + + ' }\n' + + '}\n'; + +export const defaultAttributesSchema = + 'syntax ="proto3";\n' + + 'package attributes;\n' + + '\n' + + 'message SensorConfiguration {\n' + + ' optional string firmwareVersion = 1;\n' + + ' optional string serialNumber = 2;\n' + + '}'; + +export const defaultRpcRequestSchema = + 'syntax ="proto3";\n' + + 'package rpc;\n' + + '\n' + + 'message RpcRequestMsg {\n' + + ' optional string method = 1;\n' + + ' optional int32 requestId = 2;\n' + + ' optional string params = 3;\n' + + '}'; + +export const defaultRpcResponseSchema = + 'syntax ="proto3";\n' + + 'package rpc;\n' + + '\n' + + 'message RpcResponseMsg {\n' + + ' optional string payload = 1;\n' + + '}'; + +export const coapDeviceTypeTranslationMap = new Map( + [ + [CoapTransportDeviceType.DEFAULT, 'device-profile.coap-device-type-default'], + [CoapTransportDeviceType.EFENTO, 'device-profile.coap-device-type-efento'] + ] +); + + +export const deviceTransportTypeConfigurationInfoMap = new Map( + [ + [ + DeviceTransportType.DEFAULT, + { + hasProfileConfiguration: false, + hasDeviceConfiguration: false, + } + ], + [ + DeviceTransportType.MQTT, + { + hasProfileConfiguration: true, + hasDeviceConfiguration: false, + } + ], + [ + DeviceTransportType.LWM2M, + { + hasProfileConfiguration: true, + hasDeviceConfiguration: true, + } + ], + [ + DeviceTransportType.COAP, + { + hasProfileConfiguration: true, + hasDeviceConfiguration: true, + } + ], + [ + DeviceTransportType.SNMP, + { + hasProfileConfiguration: true, + hasDeviceConfiguration: true + } + ] + ] +); + +export interface DefaultDeviceProfileConfiguration { + [key: string]: any; +} + +export type DeviceProfileConfigurations = DefaultDeviceProfileConfiguration; + +export interface DeviceProfileConfiguration extends DeviceProfileConfigurations { + type: DeviceProfileType; +} + +export interface DefaultDeviceProfileTransportConfiguration { + [key: string]: any; +} + +export interface MqttDeviceProfileTransportConfiguration { + deviceTelemetryTopic?: string; + deviceAttributesTopic?: string; + sendAckOnValidationException?: boolean; + transportPayloadTypeConfiguration?: { + transportPayloadType?: TransportPayloadType; + enableCompatibilityWithJsonPayloadFormat?: boolean; + useJsonPayloadFormatForDefaultDownlinkTopics?: boolean; + }; + [key: string]: any; +} + +export interface CoapClientSetting { + powerMode?: PowerMode | null; + edrxCycle?: number; + pagingTransmissionWindow?: number; + psmActivityTimer?: number; +} + +export interface CoapDeviceProfileTransportConfiguration { + coapDeviceTypeConfiguration?: { + coapDeviceType?: CoapTransportDeviceType; + transportPayloadTypeConfiguration?: { + transportPayloadType?: TransportPayloadType; + [key: string]: any; + }; + }; + clientSettings?: CoapClientSetting; +} + +export interface Lwm2mDeviceProfileTransportConfiguration { + [key: string]: any; +} + +export interface SnmpDeviceProfileTransportConfiguration { + timeoutMs?: number; + retries?: number; + communicationConfigs?: SnmpCommunicationConfig[]; +} + +export enum SnmpSpecType { + TELEMETRY_QUERYING = 'TELEMETRY_QUERYING', + CLIENT_ATTRIBUTES_QUERYING = 'CLIENT_ATTRIBUTES_QUERYING', + SHARED_ATTRIBUTES_SETTING = 'SHARED_ATTRIBUTES_SETTING', + TO_DEVICE_RPC_REQUEST = 'TO_DEVICE_RPC_REQUEST' +} + +export const SnmpSpecTypeTranslationMap = new Map([ + [SnmpSpecType.TELEMETRY_QUERYING, ' Telemetry'], + [SnmpSpecType.CLIENT_ATTRIBUTES_QUERYING, 'Client attributes'], + [SnmpSpecType.SHARED_ATTRIBUTES_SETTING, 'Shared attributes'], + [SnmpSpecType.TO_DEVICE_RPC_REQUEST, 'RPC request'] +]); + +export interface SnmpCommunicationConfig { + spec: SnmpSpecType; + mappings: SnmpMapping[]; + queryingFrequencyMs?: number; +} + +export interface SnmpMapping { + oid: string; + key: string; + dataType: DataType; +} + +export type DeviceProfileTransportConfigurations = DefaultDeviceProfileTransportConfiguration & + MqttDeviceProfileTransportConfiguration & + CoapDeviceProfileTransportConfiguration & + Lwm2mDeviceProfileTransportConfiguration & + SnmpDeviceProfileTransportConfiguration; + +export interface DeviceProfileTransportConfiguration extends DeviceProfileTransportConfigurations { + type: DeviceTransportType; +} + +export interface DeviceProvisionConfiguration { + type: DeviceProvisionType; + provisionDeviceSecret?: string; + provisionDeviceKey?: string; +} + +export function createDeviceProfileConfiguration(type: DeviceProfileType): DeviceProfileConfiguration { + let configuration: DeviceProfileConfiguration = null; + if (type) { + switch (type) { + case DeviceProfileType.DEFAULT: + const defaultConfiguration: DefaultDeviceProfileConfiguration = {}; + configuration = {...defaultConfiguration, type: DeviceProfileType.DEFAULT}; + break; + } + } + return configuration; +} + +export function createDeviceConfiguration(type: DeviceProfileType): DeviceConfiguration { + let configuration: DeviceConfiguration = null; + if (type) { + switch (type) { + case DeviceProfileType.DEFAULT: + const defaultConfiguration: DefaultDeviceConfiguration = {}; + configuration = {...defaultConfiguration, type: DeviceProfileType.DEFAULT}; + break; + } + } + return configuration; +} + +export function createDeviceProfileTransportConfiguration(type: DeviceTransportType): DeviceProfileTransportConfiguration { + let transportConfiguration: DeviceProfileTransportConfiguration = null; + if (type) { + switch (type) { + case DeviceTransportType.DEFAULT: + const defaultTransportConfiguration: DefaultDeviceProfileTransportConfiguration = {}; + transportConfiguration = {...defaultTransportConfiguration, type: DeviceTransportType.DEFAULT}; + break; + case DeviceTransportType.MQTT: + const mqttTransportConfiguration: MqttDeviceProfileTransportConfiguration = { + deviceTelemetryTopic: 'v1/devices/me/telemetry', + deviceAttributesTopic: 'v1/devices/me/attributes', + sendAckOnValidationException: false, + transportPayloadTypeConfiguration: { + transportPayloadType: TransportPayloadType.JSON, + enableCompatibilityWithJsonPayloadFormat: false, + useJsonPayloadFormatForDefaultDownlinkTopics: false, + } + }; + transportConfiguration = {...mqttTransportConfiguration, type: DeviceTransportType.MQTT}; + break; + case DeviceTransportType.COAP: + const coapTransportConfiguration: CoapDeviceProfileTransportConfiguration = { + coapDeviceTypeConfiguration: { + coapDeviceType: CoapTransportDeviceType.DEFAULT, + transportPayloadTypeConfiguration: {transportPayloadType: TransportPayloadType.JSON} + }, + clientSettings: { + powerMode: PowerMode.DRX + } + }; + transportConfiguration = {...coapTransportConfiguration, type: DeviceTransportType.COAP}; + break; + case DeviceTransportType.LWM2M: + const lwm2mTransportConfiguration: Lwm2mDeviceProfileTransportConfiguration = { + observeAttr: getDefaultProfileObserveAttrConfig(), + bootstrap: [], + clientLwM2mSettings: getDefaultProfileClientLwM2mSettingsConfig() + }; + transportConfiguration = {...lwm2mTransportConfiguration, type: DeviceTransportType.LWM2M}; + break; + case DeviceTransportType.SNMP: + const snmpTransportConfiguration: SnmpDeviceProfileTransportConfiguration = { + timeoutMs: 500, + retries: 0, + communicationConfigs: null + }; + transportConfiguration = {...snmpTransportConfiguration, type: DeviceTransportType.SNMP}; + break; + } + } + return transportConfiguration; +} + +export function createDeviceTransportConfiguration(type: DeviceTransportType): DeviceTransportConfiguration { + let transportConfiguration: DeviceTransportConfiguration = null; + if (type) { + switch (type) { + case DeviceTransportType.DEFAULT: + const defaultTransportConfiguration: DefaultDeviceTransportConfiguration = {}; + transportConfiguration = {...defaultTransportConfiguration, type: DeviceTransportType.DEFAULT}; + break; + case DeviceTransportType.MQTT: + const mqttTransportConfiguration: MqttDeviceTransportConfiguration = {}; + transportConfiguration = {...mqttTransportConfiguration, type: DeviceTransportType.MQTT}; + break; + case DeviceTransportType.COAP: + const coapTransportConfiguration: CoapDeviceTransportConfiguration = { + powerMode: null + }; + transportConfiguration = {...coapTransportConfiguration, type: DeviceTransportType.COAP}; + break; + case DeviceTransportType.LWM2M: + const lwm2mTransportConfiguration: Lwm2mDeviceTransportConfiguration = { + powerMode: null + }; + transportConfiguration = {...lwm2mTransportConfiguration, type: DeviceTransportType.LWM2M}; + break; + case DeviceTransportType.SNMP: + const snmpTransportConfiguration: SnmpDeviceTransportConfiguration = { + host: 'localhost', + port: 161, + protocolVersion: SnmpDeviceProtocolVersion.V2C, + community: 'public' + }; + transportConfiguration = {...snmpTransportConfiguration, type: DeviceTransportType.SNMP}; + break; + } + } + return transportConfiguration; +} + +export enum AlarmConditionType { + SIMPLE = 'SIMPLE', + DURATION = 'DURATION', + REPEATING = 'REPEATING' +} + +export const AlarmConditionTypeTranslationMap = new Map( + [ + [AlarmConditionType.SIMPLE, 'device-profile.condition-type-simple'], + [AlarmConditionType.DURATION, 'device-profile.condition-type-duration'], + [AlarmConditionType.REPEATING, 'device-profile.condition-type-repeating'] + ] +); + +export interface AlarmConditionSpec{ + type?: AlarmConditionType; + unit?: TimeUnit; + predicate: FilterPredicateValue; +} + +export interface AlarmCondition { + condition: Array; + spec?: AlarmConditionSpec; +} + +export enum AlarmScheduleType { + ANY_TIME = 'ANY_TIME', + SPECIFIC_TIME = 'SPECIFIC_TIME', + CUSTOM = 'CUSTOM' +} + +export const AlarmScheduleTypeTranslationMap = new Map( + [ + [AlarmScheduleType.ANY_TIME, 'device-profile.schedule-any-time'], + [AlarmScheduleType.SPECIFIC_TIME, 'device-profile.schedule-specific-time'], + [AlarmScheduleType.CUSTOM, 'device-profile.schedule-custom'] + ] +); + +export interface AlarmSchedule{ + dynamicValue?: { + sourceAttribute: string, + sourceType: string; + }; + type: AlarmScheduleType; + timezone?: string; + daysOfWeek?: number[]; + startsOn?: number; + endsOn?: number; + items?: CustomTimeSchedulerItem[]; +} + +export interface CustomTimeSchedulerItem{ + enabled: boolean; + dayOfWeek: number; + startsOn: number; + endsOn: number; +} + +export interface AlarmRule { + condition: AlarmCondition; + alarmDetails?: string; + dashboardId?: DashboardId; + schedule?: AlarmSchedule; +} + +export function alarmRuleValidator(control: AbstractControl): ValidationErrors | null { + const alarmRule: AlarmRule = control.value; + return alarmRuleValid(alarmRule) ? null : {alarmRule: true}; +} + +function alarmRuleValid(alarmRule: AlarmRule): boolean { + if (!alarmRule || !alarmRule.condition || !alarmRule.condition.condition || !alarmRule.condition.condition.length) { + return false; + } + return true; +} + +export interface DeviceProfileAlarm { + id: string; + alarmType: string; + createRules: {[severity: string]: AlarmRule}; + clearRule?: AlarmRule; + propagate?: boolean; + propagateToOwner?: boolean; + propagateToTenant?: boolean; + propagateRelationTypes?: Array; +} + +export function deviceProfileAlarmValidator(control: AbstractControl): ValidationErrors | null { + const deviceProfileAlarm: DeviceProfileAlarm = control.value; + if (deviceProfileAlarm && deviceProfileAlarm.id && deviceProfileAlarm.alarmType && + deviceProfileAlarm.createRules) { + const severities = Object.keys(deviceProfileAlarm.createRules); + if (severities.length) { + let alarmRulesValid = true; + for (const severity of severities) { + const alarmRule = deviceProfileAlarm.createRules[severity]; + if (!alarmRuleValid(alarmRule)) { + alarmRulesValid = false; + break; + } + } + if (alarmRulesValid) { + if (deviceProfileAlarm.clearRule && !alarmRuleValid(deviceProfileAlarm.clearRule)) { + alarmRulesValid = false; + } + } + if (alarmRulesValid) { + return null; + } + } + } + return {deviceProfileAlarm: true}; +} + + +export interface DeviceProfileData { + configuration: DeviceProfileConfiguration; + transportConfiguration: DeviceProfileTransportConfiguration; + alarms?: Array; + provisionConfiguration?: DeviceProvisionConfiguration; +} + +export interface DeviceProfile extends BaseData, ExportableEntity { + tenantId?: TenantId; + name: string; + description?: string; + default?: boolean; + type: DeviceProfileType; + image?: string; + transportType: DeviceTransportType; + provisionType: DeviceProvisionType; + provisionDeviceKey?: string; + defaultRuleChainId?: RuleChainId; + defaultDashboardId?: DashboardId; + defaultQueueName?: string; + firmwareId?: OtaPackageId; + softwareId?: OtaPackageId; + profileData: DeviceProfileData; +} + +export interface DeviceProfileInfo extends EntityInfoData { + type: DeviceProfileType; + transportType: DeviceTransportType; + image?: string; + defaultDashboardId?: DashboardId; +} + +export interface DefaultDeviceConfiguration { + [key: string]: any; +} + +export type DeviceConfigurations = DefaultDeviceConfiguration; + +export interface DeviceConfiguration extends DeviceConfigurations { + type: DeviceProfileType; +} + +export interface DefaultDeviceTransportConfiguration { + [key: string]: any; +} + +export interface MqttDeviceTransportConfiguration { + [key: string]: any; +} + +export interface CoapDeviceTransportConfiguration { + powerMode?: PowerMode | null; + edrxCycle?: number; + pagingTransmissionWindow?: number; + psmActivityTimer?: number; +} + +export interface Lwm2mDeviceTransportConfiguration { + powerMode?: PowerMode | null; + edrxCycle?: number; + pagingTransmissionWindow?: number; + psmActivityTimer?: number; +} + +export enum SnmpDeviceProtocolVersion { + V1 = 'V1', + V2C = 'V2C', + V3 = 'V3' +} + +export enum SnmpAuthenticationProtocol { + SHA_1 = 'SHA_1', + SHA_224 = 'SHA_224', + SHA_256 = 'SHA_256', + SHA_384 = 'SHA_384', + SHA_512 = 'SHA_512', + MD5 = 'MD%' +} + +export const SnmpAuthenticationProtocolTranslationMap = new Map([ + [SnmpAuthenticationProtocol.SHA_1, 'SHA-1'], + [SnmpAuthenticationProtocol.SHA_224, 'SHA-224'], + [SnmpAuthenticationProtocol.SHA_256, 'SHA-256'], + [SnmpAuthenticationProtocol.SHA_384, 'SHA-384'], + [SnmpAuthenticationProtocol.SHA_512, 'SHA-512'], + [SnmpAuthenticationProtocol.MD5, 'MD5'] +]); + +export enum SnmpPrivacyProtocol { + DES = 'DES', + AES_128 = 'AES_128', + AES_192 = 'AES_192', + AES_256 = 'AES_256' +} + +export const SnmpPrivacyProtocolTranslationMap = new Map([ + [SnmpPrivacyProtocol.DES, 'DES'], + [SnmpPrivacyProtocol.AES_128, 'AES-128'], + [SnmpPrivacyProtocol.AES_192, 'AES-192'], + [SnmpPrivacyProtocol.AES_256, 'AES-256'], +]); + +export interface SnmpDeviceTransportConfiguration { + host?: string; + port?: number; + protocolVersion?: SnmpDeviceProtocolVersion; + community?: string; + username?: string; + securityName?: string; + contextName?: string; + authenticationProtocol?: SnmpAuthenticationProtocol; + authenticationPassphrase?: string; + privacyProtocol?: SnmpPrivacyProtocol; + privacyPassphrase?: string; + engineId?: string; +} + +export type DeviceTransportConfigurations = DefaultDeviceTransportConfiguration & + MqttDeviceTransportConfiguration & + CoapDeviceTransportConfiguration & + Lwm2mDeviceTransportConfiguration & + SnmpDeviceTransportConfiguration; + +export interface DeviceTransportConfiguration extends DeviceTransportConfigurations { + type: DeviceTransportType; +} + +export interface DeviceData { + configuration: DeviceConfiguration; + transportConfiguration: DeviceTransportConfiguration; +} + +export interface Device extends BaseData, ExportableEntity { + tenantId?: TenantId; + customerId?: CustomerId; + name: string; + type: string; + label: string; + firmwareId?: OtaPackageId; + softwareId?: OtaPackageId; + deviceProfileId?: DeviceProfileId; + deviceData?: DeviceData; + additionalInfo?: any; +} + +export interface DeviceInfo extends Device { + customerTitle: string; + customerIsPublic: boolean; + deviceProfileName: string; +} + +export enum DeviceCredentialsType { + ACCESS_TOKEN = 'ACCESS_TOKEN', + X509_CERTIFICATE = 'X509_CERTIFICATE', + MQTT_BASIC = 'MQTT_BASIC', + LWM2M_CREDENTIALS = 'LWM2M_CREDENTIALS' +} + +export const credentialTypeNames = new Map( + [ + [DeviceCredentialsType.ACCESS_TOKEN, 'Access token'], + [DeviceCredentialsType.X509_CERTIFICATE, 'X.509'], + [DeviceCredentialsType.MQTT_BASIC, 'MQTT Basic'], + [DeviceCredentialsType.LWM2M_CREDENTIALS, 'LwM2M Credentials'] + ] +); + +export const credentialTypesByTransportType = new Map( + [ + [DeviceTransportType.DEFAULT, [ + DeviceCredentialsType.ACCESS_TOKEN, DeviceCredentialsType.X509_CERTIFICATE, DeviceCredentialsType.MQTT_BASIC + ]], + [DeviceTransportType.MQTT, [ + DeviceCredentialsType.ACCESS_TOKEN, DeviceCredentialsType.X509_CERTIFICATE, DeviceCredentialsType.MQTT_BASIC + ]], + [DeviceTransportType.COAP, [DeviceCredentialsType.ACCESS_TOKEN, DeviceCredentialsType.X509_CERTIFICATE]], + [DeviceTransportType.LWM2M, [DeviceCredentialsType.LWM2M_CREDENTIALS]], + [DeviceTransportType.SNMP, [DeviceCredentialsType.ACCESS_TOKEN]] + ] +); + +export interface DeviceCredentials extends BaseData { + deviceId: DeviceId; + credentialsType: DeviceCredentialsType; + credentialsId: string; + credentialsValue: string; +} + +export interface DeviceCredentialMQTTBasic { + clientId: string; + userName: string; + password: string; +} + +export function getDeviceCredentialMQTTDefault(): DeviceCredentialMQTTBasic { + return { + clientId: '', + userName: '', + password: '' + }; +} + +export interface DeviceSearchQuery extends EntitySearchQuery { + deviceTypes: Array; +} + +export interface ClaimRequest { + secretKey: string; +} + +export enum ClaimResponse { + SUCCESS = 'SUCCESS', + FAILURE = 'FAILURE', + CLAIMED = 'CLAIMED' +} + +export interface ClaimResult { + device: Device; + response: ClaimResponse; +} + +export const dayOfWeekTranslations = new Array( + 'device-profile.schedule-day.monday', + 'device-profile.schedule-day.tuesday', + 'device-profile.schedule-day.wednesday', + 'device-profile.schedule-day.thursday', + 'device-profile.schedule-day.friday', + 'device-profile.schedule-day.saturday', + 'device-profile.schedule-day.sunday' +); + +export function getDayString(day: number): string { + switch (day) { + case 0: + return 'device-profile.schedule-day.monday'; + case 1: + return this.translate.instant('device-profile.schedule-day.tuesday'); + case 2: + return this.translate.instant('device-profile.schedule-day.wednesday'); + case 3: + return this.translate.instant('device-profile.schedule-day.thursday'); + case 4: + return this.translate.instant('device-profile.schedule-day.friday'); + case 5: + return this.translate.instant('device-profile.schedule-day.saturday'); + case 6: + return this.translate.instant('device-profile.schedule-day.sunday'); + } +} + +export function timeOfDayToUTCTimestamp(date: Date | number): number { + if (typeof date === 'number' || date === null) { + return 0; + } + return _moment.utc([1970, 0, 1, date.getHours(), date.getMinutes(), date.getSeconds(), 0]).valueOf(); +} + +export function utcTimestampToTimeOfDay(time = 0): Date { + return new Date(time + new Date(time).getTimezoneOffset() * 60 * 1000); +} + +function timeOfDayToMoment(date: Date | number): _moment.Moment { + if (typeof date === 'number' || date === null) { + return _moment([1970, 0, 1, 0, 0, 0, 0]); + } + return _moment([1970, 0, 1, date.getHours(), date.getMinutes(), 0, 0]); +} + +export function getAlarmScheduleRangeText(startsOn: Date | number, endsOn: Date | number): string { + const start = timeOfDayToMoment(startsOn); + const end = timeOfDayToMoment(endsOn); + if (start < end) { + return `${start.format('hh:mm A')}${end.format('hh:mm A')}`; + } else if (start.valueOf() === 0 && end.valueOf() === 0 || start.isSame(_moment([1970, 0])) && end.isSame(_moment([1970, 0]))) { + return '12:00 AM12:00 PM'; + } + return `12:00 AM${end.format('hh:mm A')}` + + ` and ${start.format('hh:mm A')}12:00 PM`; +} diff --git a/ui-ngx/src/app/shared/models/edge.models.ts b/ui-ngx/src/app/shared/models/edge.models.ts new file mode 100644 index 0000000..c9caa37 --- /dev/null +++ b/ui-ngx/src/app/shared/models/edge.models.ts @@ -0,0 +1,169 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { EdgeId } from '@shared/models/id/edge-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; +import { BaseEventBody } from '@shared/models/event.models'; +import { EventId } from '@shared/models/id/event-id'; + +export interface Edge extends BaseData { + tenantId?: TenantId; + customerId?: CustomerId; + name: string; + type: string; + secret: string; + routingKey: string; + label?: string; + additionalInfo?: any; + rootRuleChainId?: RuleChainId; +} + +export interface EdgeInfo extends Edge { + customerTitle: string; + customerIsPublic: boolean; +} + +export interface EdgeSearchQuery extends EntitySearchQuery { + edgeTypes: Array; +} + +export enum EdgeEventType { + DASHBOARD = 'DASHBOARD', + ASSET = 'ASSET', + DEVICE = 'DEVICE', + DEVICE_PROFILE = 'DEVICE_PROFILE', + ASSET_PROFILE = 'ASSET_PROFILE', + ENTITY_VIEW = 'ENTITY_VIEW', + ALARM = 'ALARM', + RULE_CHAIN = 'RULE_CHAIN', + RULE_CHAIN_METADATA = 'RULE_CHAIN_METADATA', + EDGE = 'EDGE', + USER = 'USER', + CUSTOMER = 'CUSTOMER', + RELATION = 'RELATION', + TENANT = 'TENANT', + WIDGETS_BUNDLE = 'WIDGETS_BUNDLE', + WIDGET_TYPE = 'WIDGET_TYPE', + ADMIN_SETTINGS = 'ADMIN_SETTINGS' +} + +export enum EdgeEventActionType { + ADDED = 'ADDED', + DELETED = 'DELETED', + UPDATED = 'UPDATED', + POST_ATTRIBUTES = 'POST_ATTRIBUTES', + ATTRIBUTES_UPDATED = 'ATTRIBUTES_UPDATED', + ATTRIBUTES_DELETED = 'ATTRIBUTES_DELETED', + TIMESERIES_UPDATED = 'TIMESERIES_UPDATED', + CREDENTIALS_UPDATED = 'CREDENTIALS_UPDATED', + ASSIGNED_TO_CUSTOMER = 'ASSIGNED_TO_CUSTOMER', + UNASSIGNED_FROM_CUSTOMER = 'UNASSIGNED_FROM_CUSTOMER', + RELATION_ADD_OR_UPDATE = 'RELATION_ADD_OR_UPDATE', + RELATION_DELETED = 'RELATION_DELETED', + RPC_CALL = 'RPC_CALL', + ALARM_ACK = 'ALARM_ACK', + ALARM_CLEAR = 'ALARM_CLEAR', + ASSIGNED_TO_EDGE = 'ASSIGNED_TO_EDGE', + UNASSIGNED_FROM_EDGE = 'UNASSIGNED_FROM_EDGE', + CREDENTIALS_REQUEST = 'CREDENTIALS_REQUEST', + ENTITY_MERGE_REQUEST = 'ENTITY_MERGE_REQUEST' +} + +export enum EdgeEventStatus { + DEPLOYED = 'DEPLOYED', + PENDING = 'PENDING' +} + +export const edgeEventTypeTranslations = new Map( + [ + [EdgeEventType.DASHBOARD, 'edge-event.type-dashboard'], + [EdgeEventType.ASSET, 'edge-event.type-asset'], + [EdgeEventType.DEVICE, 'edge-event.type-device'], + [EdgeEventType.DEVICE_PROFILE, 'edge-event.type-device-profile'], + [EdgeEventType.ASSET_PROFILE, 'edge-event.type-asset-profile'], + [EdgeEventType.ENTITY_VIEW, 'edge-event.type-entity-view'], + [EdgeEventType.ALARM, 'edge-event.type-alarm'], + [EdgeEventType.RULE_CHAIN, 'edge-event.type-rule-chain'], + [EdgeEventType.RULE_CHAIN_METADATA, 'edge-event.type-rule-chain-metadata'], + [EdgeEventType.EDGE, 'edge-event.type-edge'], + [EdgeEventType.USER, 'edge-event.type-user'], + [EdgeEventType.CUSTOMER, 'edge-event.type-customer'], + [EdgeEventType.RELATION, 'edge-event.type-relation'], + [EdgeEventType.TENANT, 'edge-event.type-tenant'], + [EdgeEventType.WIDGETS_BUNDLE, 'edge-event.type-widgets-bundle'], + [EdgeEventType.WIDGET_TYPE, 'edge-event.type-widgets-type'], + [EdgeEventType.ADMIN_SETTINGS, 'edge-event.type-admin-settings'] + ] +); + +export const edgeEventActionTypeTranslations = new Map( + [ + [EdgeEventActionType.ADDED, 'edge-event.action-type-added'], + [EdgeEventActionType.DELETED, 'edge-event.action-type-deleted'], + [EdgeEventActionType.UPDATED, 'edge-event.action-type-updated'], + [EdgeEventActionType.POST_ATTRIBUTES, 'edge-event.action-type-post-attributes'], + [EdgeEventActionType.ATTRIBUTES_UPDATED, 'edge-event.action-type-attributes-updated'], + [EdgeEventActionType.ATTRIBUTES_DELETED, 'edge-event.action-type-attributes-deleted'], + [EdgeEventActionType.TIMESERIES_UPDATED, 'edge-event.action-type-timeseries-updated'], + [EdgeEventActionType.CREDENTIALS_UPDATED, 'edge-event.action-type-credentials-updated'], + [EdgeEventActionType.ASSIGNED_TO_CUSTOMER, 'edge-event.action-type-assigned-to-customer'], + [EdgeEventActionType.UNASSIGNED_FROM_CUSTOMER, 'edge-event.action-type-unassigned-from-customer'], + [EdgeEventActionType.RELATION_ADD_OR_UPDATE, 'edge-event.action-type-relation-add-or-update'], + [EdgeEventActionType.RELATION_DELETED, 'edge-event.action-type-relation-deleted'], + [EdgeEventActionType.RPC_CALL, 'edge-event.action-type-rpc-call'], + [EdgeEventActionType.ALARM_ACK, 'edge-event.action-type-alarm-ack'], + [EdgeEventActionType.ALARM_CLEAR, 'edge-event.action-type-alarm-clear'], + [EdgeEventActionType.ASSIGNED_TO_EDGE, 'edge-event.action-type-assigned-to-edge'], + [EdgeEventActionType.UNASSIGNED_FROM_EDGE, 'edge-event.action-type-unassigned-from-edge'], + [EdgeEventActionType.CREDENTIALS_REQUEST, 'edge-event.action-type-credentials-request'], + [EdgeEventActionType.ENTITY_MERGE_REQUEST, 'edge-event.action-type-entity-merge-request'] + ] +); + +export const bodyContentEdgeEventActionTypes: EdgeEventActionType[] = [ + EdgeEventActionType.POST_ATTRIBUTES, + EdgeEventActionType.ATTRIBUTES_UPDATED, + EdgeEventActionType.ATTRIBUTES_DELETED, + EdgeEventActionType.TIMESERIES_UPDATED, + EdgeEventActionType.RPC_CALL +]; + +export const edgeEventStatusColor = new Map( + [ + [EdgeEventStatus.DEPLOYED, '#000000'], + [EdgeEventStatus.PENDING, '#9e9e9e'] + ] +); + +export interface EdgeEventBody extends BaseEventBody { + type: string; + action: string; + entityId: string; +} + +export interface EdgeEvent extends BaseData { + tenantId: TenantId; + entityId: string; + edgeId: EdgeId; + action: EdgeEventActionType; + type: EdgeEventType; + uid: string; + body: string; +} diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts new file mode 100644 index 0000000..48d2671 --- /dev/null +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -0,0 +1,464 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TenantId } from './id/tenant-id'; +import { BaseData, HasId } from '@shared/models/base-data'; + +export enum EntityType { + TENANT = 'TENANT', + TENANT_PROFILE = 'TENANT_PROFILE', + CUSTOMER = 'CUSTOMER', + USER = 'USER', + DASHBOARD = 'DASHBOARD', + ASSET = 'ASSET', + DEVICE = 'DEVICE', + DEVICE_PROFILE = 'DEVICE_PROFILE', + ASSET_PROFILE = 'ASSET_PROFILE', + ALARM = 'ALARM', + RULE_CHAIN = 'RULE_CHAIN', + RULE_NODE = 'RULE_NODE', + EDGE = 'EDGE', + ENTITY_VIEW = 'ENTITY_VIEW', + WIDGETS_BUNDLE = 'WIDGETS_BUNDLE', + WIDGET_TYPE = 'WIDGET_TYPE', + API_USAGE_STATE = 'API_USAGE_STATE', + TB_RESOURCE = 'TB_RESOURCE', + OTA_PACKAGE = 'OTA_PACKAGE', + RPC = 'RPC', + QUEUE = 'QUEUE' +} + +export enum AliasEntityType { + CURRENT_CUSTOMER = 'CURRENT_CUSTOMER', + CURRENT_TENANT = 'CURRENT_TENANT', + CURRENT_USER = 'CURRENT_USER', + CURRENT_USER_OWNER = 'CURRENT_USER_OWNER' +} + +export interface EntityTypeTranslation { + type?: string; + typePlural?: string; + list?: string; + nameStartsWith?: string; + details?: string; + add?: string; + noEntities?: string; + selectedEntities?: string; + search?: string; +} + +export interface EntityTypeResource { + helpLinkId: string; + helpLinkIdForEntity?(entity: T): string; +} + +export const entityTypeTranslations = new Map( + [ + [ + EntityType.TENANT, + { + type: 'entity.type-tenant', + typePlural: 'entity.type-tenants', + list: 'entity.list-of-tenants', + nameStartsWith: 'entity.tenant-name-starts-with', + details: 'tenant.tenant-details', + add: 'tenant.add', + noEntities: 'tenant.no-tenants-text', + search: 'tenant.search', + selectedEntities: 'tenant.selected-tenants' + } + ], + [ + EntityType.TENANT_PROFILE, + { + type: 'entity.type-tenant-profile', + typePlural: 'entity.type-tenant-profiles', + list: 'entity.list-of-tenant-profiles', + nameStartsWith: 'entity.tenant-profile-name-starts-with', + details: 'tenant-profile.tenant-profile-details', + add: 'tenant-profile.add', + noEntities: 'tenant-profile.no-tenant-profiles-text', + search: 'tenant-profile.search', + selectedEntities: 'tenant-profile.selected-tenant-profiles' + } + ], + [ + EntityType.CUSTOMER, + { + type: 'entity.type-customer', + typePlural: 'entity.type-customers', + list: 'entity.list-of-customers', + nameStartsWith: 'entity.customer-name-starts-with', + details: 'customer.customer-details', + add: 'customer.add', + noEntities: 'customer.no-customers-text', + search: 'customer.search', + selectedEntities: 'customer.selected-customers' + } + ], + [ + EntityType.USER, + { + type: 'entity.type-user', + typePlural: 'entity.type-users', + list: 'entity.list-of-users', + nameStartsWith: 'entity.user-name-starts-with', + details: 'user.user-details', + add: 'user.add', + noEntities: 'user.no-users-text', + search: 'user.search', + selectedEntities: 'user.selected-users' + } + ], + [ + EntityType.DEVICE, + { + type: 'entity.type-device', + typePlural: 'entity.type-devices', + list: 'entity.list-of-devices', + nameStartsWith: 'entity.device-name-starts-with', + details: 'device.device-details', + add: 'device.add', + noEntities: 'device.no-devices-text', + search: 'device.search', + selectedEntities: 'device.selected-devices' + } + ], + [ + EntityType.DEVICE_PROFILE, + { + type: 'entity.type-device-profile', + typePlural: 'entity.type-device-profiles', + list: 'entity.list-of-device-profiles', + nameStartsWith: 'entity.device-profile-name-starts-with', + details: 'device-profile.device-profile-details', + add: 'device-profile.add', + noEntities: 'device-profile.no-device-profiles-text', + search: 'device-profile.search', + selectedEntities: 'device-profile.selected-device-profiles' + } + ], + [ + EntityType.ASSET_PROFILE, + { + type: 'entity.type-asset-profile', + typePlural: 'entity.type-asset-profiles', + list: 'entity.list-of-asset-profiles', + nameStartsWith: 'entity.asset-profile-name-starts-with', + details: 'asset-profile.asset-profile-details', + add: 'asset-profile.add', + noEntities: 'asset-profile.no-asset-profiles-text', + search: 'asset-profile.search', + selectedEntities: 'asset-profile.selected-asset-profiles' + } + ], + [ + EntityType.ASSET, + { + type: 'entity.type-asset', + typePlural: 'entity.type-assets', + list: 'entity.list-of-assets', + nameStartsWith: 'entity.asset-name-starts-with', + details: 'asset.asset-details', + add: 'asset.add', + noEntities: 'asset.no-assets-text', + search: 'asset.search', + selectedEntities: 'asset.selected-assets' + } + ], + [ + EntityType.EDGE, + { + type: 'entity.type-edge', + typePlural: 'entity.type-edges', + list: 'entity.list-of-edges', + nameStartsWith: 'entity.edge-name-starts-with', + details: 'edge.edge-details', + add: 'edge.add', + noEntities: 'edge.no-edges-text', + search: 'edge.search', + selectedEntities: 'edge.selected-edges' + } + ], + [ + EntityType.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', + details: 'entity-view.entity-view-details', + add: 'entity-view.add', + noEntities: 'entity-view.no-entity-views-text', + search: 'entity-view.search', + selectedEntities: 'entity-view.selected-entity-views' + } + ], + [ + EntityType.RULE_CHAIN, + { + type: 'entity.type-rulechain', + typePlural: 'entity.type-rulechains', + list: 'entity.list-of-rulechains', + nameStartsWith: 'entity.rulechain-name-starts-with', + details: 'rulechain.rulechain-details', + add: 'rulechain.add', + noEntities: 'rulechain.no-rulechains-text', + search: 'rulechain.search', + selectedEntities: 'rulechain.selected-rulechains' + } + ], + [ + EntityType.RULE_NODE, + { + type: 'entity.type-rulenode', + typePlural: 'entity.type-rulenodes', + list: 'entity.list-of-rulenodes', + nameStartsWith: 'entity.rulenode-name-starts-with' + } + ], + [ + EntityType.DASHBOARD, + { + type: 'entity.type-dashboard', + typePlural: 'entity.type-dashboards', + list: 'entity.list-of-dashboards', + nameStartsWith: 'entity.dashboard-name-starts-with', + details: 'dashboard.dashboard-details', + add: 'dashboard.add', + noEntities: 'dashboard.no-dashboards-text', + search: 'dashboard.search', + selectedEntities: 'dashboard.selected-dashboards' + } + ], + [ + EntityType.ALARM, + { + type: 'entity.type-alarm', + typePlural: 'entity.type-alarms', + list: 'entity.list-of-alarms', + nameStartsWith: 'entity.alarm-name-starts-with', + details: 'dashboard.dashboard-details', + noEntities: 'alarm.no-alarms-prompt', + search: 'alarm.search', + selectedEntities: 'alarm.selected-alarms' + } + ], + [ + EntityType.API_USAGE_STATE, + { + type: 'entity.type-api-usage-state' + } + ], + [ + EntityType.WIDGETS_BUNDLE, + { + type: 'entity.type-widgets-bundle', + typePlural: 'entity.type-widgets-bundles', + list: 'entity.list-of-widgets-bundles', + details: 'widgets-bundle.widgets-bundle-details', + add: 'widgets-bundle.add', + noEntities: 'widgets-bundle.no-widgets-bundles-text', + search: 'widgets-bundle.search', + selectedEntities: 'widgets-bundle.selected-widgets-bundles' + } + ], + [ + AliasEntityType.CURRENT_CUSTOMER, + { + type: 'entity.type-current-customer', + list: 'entity.type-current-customer' + } + ], + [ + AliasEntityType.CURRENT_TENANT, + { + type: 'entity.type-current-tenant', + list: 'entity.type-current-tenant' + } + ], + [ + AliasEntityType.CURRENT_USER, + { + type: 'entity.type-current-user', + list: 'entity.type-current-user' + } + ], + [ + AliasEntityType.CURRENT_USER_OWNER, + { + type: 'entity.type-current-user-owner', + list: 'entity.type-current-user-owner' + } + ], + [ + EntityType.TB_RESOURCE, + { + type: 'entity.type-tb-resource', + details: 'resource.resource-library-details', + add: 'resource.add', + noEntities: 'resource.no-resource-text', + search: 'resource.search', + selectedEntities: 'resource.selected-resources' + } + ], + [ + EntityType.OTA_PACKAGE, + { + type: 'entity.type-ota-package', + details: 'ota-update.ota-update-details', + add: 'ota-update.add', + noEntities: 'ota-update.no-packages-text', + search: 'ota-update.search', + selectedEntities: 'ota-update.selected-package' + } + ], + [ + EntityType.QUEUE, + { + add: 'queue.add', + search: 'queue.search', + details: 'queue.details', + selectedEntities: 'queue.selected-queues' + } + ] + ] +); + +export const entityTypeResources = new Map>>( + [ + [ + EntityType.TENANT, + { + helpLinkId: 'tenants' + } + ], + [ + EntityType.TENANT_PROFILE, + { + helpLinkId: 'tenantProfiles' + } + ], + [ + EntityType.CUSTOMER, + { + helpLinkId: 'customers' + } + ], + [ + EntityType.USER, + { + helpLinkId: 'users' + } + ], + [ + EntityType.DEVICE, + { + helpLinkId: 'devices' + } + ], + [ + EntityType.DEVICE_PROFILE, + { + helpLinkId: 'deviceProfiles' + } + ], + [ + EntityType.ASSET_PROFILE, + { + helpLinkId: 'assetProfiles' + } + ], + [ + EntityType.ASSET, + { + helpLinkId: 'assets' + } + ], + [ + EntityType.EDGE, + { + helpLinkId: 'edges' + } + ], + [ + EntityType.ENTITY_VIEW, + { + helpLinkId: 'entityViews' + } + ], + [ + EntityType.RULE_CHAIN, + { + helpLinkId: 'rulechains' + } + ], + [ + EntityType.DASHBOARD, + { + helpLinkId: 'dashboards' + } + ], + [ + EntityType.WIDGETS_BUNDLE, + { + helpLinkId: 'widgetsBundles' + } + ], + [ + EntityType.TB_RESOURCE, + { + helpLinkId: 'resources' + } + ], + [ + EntityType.OTA_PACKAGE, + { + helpLinkId: 'otaUpdates' + } + ], + [ + EntityType.QUEUE, + { + helpLinkId: 'queue' + } + ] + ] +); + +export const baseDetailsPageByEntityType = new Map([ + [EntityType.TENANT, '/tenants'], + [EntityType.TENANT_PROFILE, '/tenantProfiles'], + [EntityType.CUSTOMER, '/customers'], + [EntityType.USER, '/users'], + [EntityType.DASHBOARD, '/dashboards'], + [EntityType.ASSET, '/assets'], + [EntityType.DEVICE, '/devices'], + [EntityType.DEVICE_PROFILE, '/profiles/deviceProfiles'], + [EntityType.ASSET_PROFILE, '/profiles/assetProfiles'], + [EntityType.RULE_CHAIN, '/ruleChains'], + [EntityType.EDGE, '/edgeInstances'], + [EntityType.ENTITY_VIEW, '/entityViews'], + [EntityType.TB_RESOURCE, '/settings/resources-library'], + [EntityType.OTA_PACKAGE, '/otaUpdates'], + [EntityType.QUEUE, '/settings/queues'] +]); + +export interface EntitySubtype { + tenantId: TenantId; + entityType: EntityType; + type: string; +} diff --git a/ui-ngx/src/app/shared/models/entity-view.models.ts b/ui-ngx/src/app/shared/models/entity-view.models.ts new file mode 100644 index 0000000..dd16914 --- /dev/null +++ b/ui-ngx/src/app/shared/models/entity-view.models.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { EntityViewId } from '@shared/models/id/entity-view-id'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; + +export interface AttributesEntityView { + cs: Array; + ss: Array; + sh: Array; +} + +export interface TelemetryEntityView { + timeseries: Array; + attributes: AttributesEntityView; +} + +export interface EntityView extends BaseData, ExportableEntity { + tenantId: TenantId; + customerId: CustomerId; + entityId: EntityId; + name: string; + type: string; + keys: TelemetryEntityView; + startTimeMs: number; + endTimeMs: number; + additionalInfo?: any; +} + +export interface EntityViewInfo extends EntityView { + customerTitle: string; + customerIsPublic: boolean; +} + +export interface EntityViewSearchQuery extends EntitySearchQuery { + entityViewTypes: Array; +} diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts new file mode 100644 index 0000000..548b912 --- /dev/null +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -0,0 +1,164 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityType } from '@shared/models/entity-type.models'; +import { AttributeData } from './telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { DeviceCredentialMQTTBasic } from '@shared/models/device.models'; +import { Lwm2mSecurityConfigModels } from '@shared/models/lwm2m-security-config.models'; + +export interface EntityInfo { + name?: string; + label?: string; + entityType?: EntityType; + id?: string; + entityDescription?: string; +} + +export interface EntityInfoData { + id: EntityId; + name: string; +} + +export interface ImportEntityData { + lineNumber: number; + name: string; + type: string; + label: string; + gateway: boolean; + description: string; + credential: { + accessToken?: string; + x509?: string; + mqtt?: DeviceCredentialMQTTBasic; + lwm2m?: Lwm2mSecurityConfigModels; + }; + attributes: { + server: AttributeData[], + shared: AttributeData[] + }; + timeseries: AttributeData[]; +} + +export interface EdgeImportEntityData extends ImportEntityData { + secret: string; + routingKey: string; +} + +export interface ImportEntitiesResultInfo { + create?: { + entity: number; + }; + update?: { + entity: number; + }; + error?: { + entity: number; + errors?: string; + }; +} + +export interface EntityField { + keyName: string; + value: string; + name: string; + time?: boolean; +} + +export interface EntitiesKeysByQuery { + attribute: Array; + timeseries: Array; + entityTypes: EntityType[]; +} + +export const entityFields: {[fieldName: string]: EntityField} = { + createdTime: { + keyName: 'createdTime', + name: 'entity-field.created-time', + value: 'createdTime', + time: true + }, + name: { + keyName: 'name', + name: 'entity-field.name', + value: 'name' + }, + type: { + keyName: 'type', + name: 'entity-field.type', + value: 'type' + }, + firstName: { + keyName: 'firstName', + name: 'entity-field.first-name', + value: 'firstName' + }, + lastName: { + keyName: 'lastName', + name: 'entity-field.last-name', + value: 'lastName' + }, + email: { + keyName: 'email', + name: 'entity-field.email', + value: 'email' + }, + title: { + keyName: 'title', + name: 'entity-field.title', + value: 'title' + }, + country: { + keyName: 'country', + name: 'entity-field.country', + value: 'country' + }, + state: { + keyName: 'state', + name: 'entity-field.state', + value: 'state' + }, + city: { + keyName: 'city', + name: 'entity-field.city', + value: 'city' + }, + address: { + keyName: 'address', + name: 'entity-field.address', + value: 'address' + }, + address2: { + keyName: 'address2', + name: 'entity-field.address2', + value: 'address2' + }, + zip: { + keyName: 'zip', + name: 'entity-field.zip', + value: 'zip' + }, + phone: { + keyName: 'phone', + name: 'entity-field.phone', + value: 'phone' + }, + label: { + keyName: 'label', + name: 'entity-field.label', + value: 'label' + } +}; diff --git a/ui-ngx/src/app/shared/models/error.models.ts b/ui-ngx/src/app/shared/models/error.models.ts new file mode 100644 index 0000000..794d8f6 --- /dev/null +++ b/ui-ngx/src/app/shared/models/error.models.ts @@ -0,0 +1,23 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 interface ExceptionData { + message?: string; + name?: string; + lineNumber?: number; + columnNumber?: number; +} diff --git a/ui-ngx/src/app/shared/models/event.models.ts b/ui-ngx/src/app/shared/models/event.models.ts new file mode 100644 index 0000000..9664c88 --- /dev/null +++ b/ui-ngx/src/app/shared/models/event.models.ts @@ -0,0 +1,126 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EventId } from './id/event-id'; +import { ContentType } from '@shared/models/constants'; +import { EntityType } from '@shared/models/entity-type.models'; + +export enum EventType { + ERROR = 'ERROR', + LC_EVENT = 'LC_EVENT', + STATS = 'STATS' +} + +export enum DebugEventType { + DEBUG_RULE_NODE = 'DEBUG_RULE_NODE', + DEBUG_RULE_CHAIN = 'DEBUG_RULE_CHAIN' +} + +export const eventTypeTranslations = new Map( + [ + [EventType.ERROR, 'event.type-error'], + [EventType.LC_EVENT, 'event.type-lc-event'], + [EventType.STATS, 'event.type-stats'], + [DebugEventType.DEBUG_RULE_NODE, 'event.type-debug-rule-node'], + [DebugEventType.DEBUG_RULE_CHAIN, 'event.type-debug-rule-chain'], + ] +); + +export interface BaseEventBody { + server: string; +} + +export interface ErrorEventBody extends BaseEventBody { + method: string; + error: string; +} + +export interface LcEventEventBody extends BaseEventBody { + event: string; + success: boolean; + error: string; +} + +export interface StatsEventBody extends BaseEventBody { + messagesProcessed: number; + errorsOccurred: number; +} + +export interface DebugRuleNodeEventBody extends BaseEventBody { + type: string; + entityId: string; + entityType: string; + msgId: string; + msgType: string; + relationType: string; + dataType: ContentType; + data: string; + metadata: string; + error: string; +} + +export interface DebugRuleChainEventBody extends BaseEventBody { + message: string; + error?: string; +} + +export type EventBody = ErrorEventBody & LcEventEventBody & StatsEventBody & DebugRuleNodeEventBody & DebugRuleChainEventBody; + +export interface Event extends BaseData { + tenantId: TenantId; + entityId: EntityId; + type: string; + uid: string; + body: EventBody; +} + +export interface BaseFilterEventBody { + server?: string; +} + +export interface ErrorFilterEventBody extends BaseFilterEventBody { + method?: string; + errorStr?: string; +} + +export interface LcFilterEventEventBody extends BaseFilterEventBody { + event?: string; + status?: string; + errorStr?: string; +} + +export interface StatsFilterEventBody extends BaseFilterEventBody { + messagesProcessed?: number; + errorsOccurred?: number; +} + +export interface DebugFilterRuleNodeEventBody extends BaseFilterEventBody { + msgDirectionType?: string; + entityId?: string; + entityName?: EntityType; + msgId?: string; + msgType?: string; + relationType?: string; + dataSearch?: string; + metadataSearch?: string; + isError?: boolean; + errorStr?: string; +} + +export type FilterEventBody = ErrorFilterEventBody & LcFilterEventEventBody & StatsFilterEventBody & DebugFilterRuleNodeEventBody; diff --git a/ui-ngx/src/app/shared/models/id/alarm-id.ts b/ui-ngx/src/app/shared/models/id/alarm-id.ts new file mode 100644 index 0000000..3cd21f1 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/alarm-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class AlarmId implements EntityId { + entityType = EntityType.ALARM; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/asset-id.ts b/ui-ngx/src/app/shared/models/id/asset-id.ts new file mode 100644 index 0000000..9adf209 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/asset-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class AssetId implements EntityId { + entityType = EntityType.ASSET; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/asset-profile-id.ts b/ui-ngx/src/app/shared/models/id/asset-profile-id.ts new file mode 100644 index 0000000..55d36db --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/asset-profile-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class AssetProfileId implements EntityId { + entityType = EntityType.ASSET_PROFILE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/audit-log-id.ts b/ui-ngx/src/app/shared/models/id/audit-log-id.ts new file mode 100644 index 0000000..bccaa6e --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/audit-log-id.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { HasUUID } from '@shared/models/id/has-uuid'; + +export class AuditLogId implements HasUUID { + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/customer-id.ts b/ui-ngx/src/app/shared/models/id/customer-id.ts new file mode 100644 index 0000000..8e55155 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/customer-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class CustomerId implements EntityId { + entityType = EntityType.CUSTOMER; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/dashboard-id.ts b/ui-ngx/src/app/shared/models/id/dashboard-id.ts new file mode 100644 index 0000000..a839001 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/dashboard-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class DashboardId implements EntityId { + entityType = EntityType.DASHBOARD; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/device-credentials-id.ts b/ui-ngx/src/app/shared/models/id/device-credentials-id.ts new file mode 100644 index 0000000..21b7365 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/device-credentials-id.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { HasUUID } from '@shared/models/id/has-uuid'; + +export class DeviceCredentialsId implements HasUUID { + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/device-id.ts b/ui-ngx/src/app/shared/models/id/device-id.ts new file mode 100644 index 0000000..71cc69d --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/device-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class DeviceId implements EntityId { + entityType = EntityType.DEVICE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/device-profile-id.ts b/ui-ngx/src/app/shared/models/id/device-profile-id.ts new file mode 100644 index 0000000..a959646 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/device-profile-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class DeviceProfileId implements EntityId { + entityType = EntityType.DEVICE_PROFILE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/edge-id.ts b/ui-ngx/src/app/shared/models/id/edge-id.ts new file mode 100644 index 0000000..5d26c14 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/edge-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class EdgeId implements EntityId { + entityType = EntityType.EDGE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/entity-id.ts b/ui-ngx/src/app/shared/models/id/entity-id.ts new file mode 100644 index 0000000..dc71119 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/entity-id.ts @@ -0,0 +1,31 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { HasUUID } from '@shared/models/id/has-uuid'; +import { isDefinedAndNotNull } from '@core/utils'; + +export interface EntityId extends HasUUID { + entityType: EntityType | AliasEntityType; +} + +export function entityIdEquals(entityId1: EntityId, entityId2: EntityId): boolean { + if (isDefinedAndNotNull(entityId1) && isDefinedAndNotNull(entityId2)) { + return entityId1.id === entityId2.id && entityId1.entityType === entityId2.entityType; + } else { + return entityId1 === entityId2; + } +} diff --git a/ui-ngx/src/app/shared/models/id/entity-view-id.ts b/ui-ngx/src/app/shared/models/id/entity-view-id.ts new file mode 100644 index 0000000..4ee80a8 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/entity-view-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class EntityViewId implements EntityId { + entityType = EntityType.ENTITY_VIEW; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/event-id.ts b/ui-ngx/src/app/shared/models/id/event-id.ts new file mode 100644 index 0000000..af15972 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/event-id.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { HasUUID } from '@shared/models/id/has-uuid'; + +export class EventId implements HasUUID { + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/has-uuid.ts b/ui-ngx/src/app/shared/models/id/has-uuid.ts new file mode 100644 index 0000000..e53f008 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/has-uuid.ts @@ -0,0 +1,21 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 const NULL_UUID = '13814000-1dd2-11b2-8080-808080808080'; + +export interface HasUUID { + id: string; +} diff --git a/ui-ngx/src/app/shared/models/id/ota-package-id.ts b/ui-ngx/src/app/shared/models/id/ota-package-id.ts new file mode 100644 index 0000000..1bf241e --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/ota-package-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class OtaPackageId implements EntityId { + entityType = EntityType.OTA_PACKAGE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/public-api.ts b/ui-ngx/src/app/shared/models/id/public-api.ts new file mode 100644 index 0000000..73bb920 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/public-api.ts @@ -0,0 +1,39 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './alarm-id'; +export * from './asset-id'; +export * from './audit-log-id'; +export * from './customer-id'; +export * from './dashboard-id'; +export * from './device-credentials-id'; +export * from './device-id'; +export * from './device-profile-id'; +export * from './entity-id'; +export * from './entity-view-id'; +export * from './event-id'; +export * from './has-uuid'; +export * from './ota-package-id'; +export * from './rpc-id'; +export * from './rule-chain-id'; +export * from './rule-node-id'; +export * from './tenant-id'; +export * from './tenant-profile-id'; +export * from './user-id'; +export * from './widget-type-id'; +export * from './widgets-bundle-id'; +export * from './edge-id'; +export * from './asset-id'; diff --git a/ui-ngx/src/app/shared/models/id/queue-id.ts b/ui-ngx/src/app/shared/models/id/queue-id.ts new file mode 100644 index 0000000..743583e --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/queue-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class QueueId implements EntityId { + entityType = EntityType.QUEUE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/rpc-id.ts b/ui-ngx/src/app/shared/models/id/rpc-id.ts new file mode 100644 index 0000000..7173237 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/rpc-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class RpcId implements EntityId { + entityType = EntityType.RPC; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/rule-chain-id.ts b/ui-ngx/src/app/shared/models/id/rule-chain-id.ts new file mode 100644 index 0000000..27623bd --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/rule-chain-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class RuleChainId implements EntityId { + entityType = EntityType.RULE_CHAIN; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/rule-node-id.ts b/ui-ngx/src/app/shared/models/id/rule-node-id.ts new file mode 100644 index 0000000..73d57f6 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/rule-node-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class RuleNodeId implements EntityId { + entityType = EntityType.RULE_NODE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/tb-resource-id.ts b/ui-ngx/src/app/shared/models/id/tb-resource-id.ts new file mode 100644 index 0000000..5737618 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/tb-resource-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class TbResourceId implements EntityId { + entityType = EntityType.TB_RESOURCE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/tenant-id.ts b/ui-ngx/src/app/shared/models/id/tenant-id.ts new file mode 100644 index 0000000..59864eb --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/tenant-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class TenantId implements EntityId { + entityType = EntityType.TENANT; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts b/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts new file mode 100644 index 0000000..a829b4e --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class TenantProfileId implements EntityId { + entityType = EntityType.TENANT_PROFILE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/user-id.ts b/ui-ngx/src/app/shared/models/id/user-id.ts new file mode 100644 index 0000000..59c46be --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/user-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class UserId implements EntityId { + entityType = EntityType.USER; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/widget-type-id.ts b/ui-ngx/src/app/shared/models/id/widget-type-id.ts new file mode 100644 index 0000000..298ef70 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/widget-type-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class WidgetTypeId implements EntityId { + entityType = EntityType.WIDGET_TYPE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts b/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts new file mode 100644 index 0000000..fc16726 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class WidgetsBundleId implements EntityId { + entityType = EntityType.WIDGETS_BUNDLE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/login.models.ts b/ui-ngx/src/app/shared/models/login.models.ts new file mode 100644 index 0000000..0da429f --- /dev/null +++ b/ui-ngx/src/app/shared/models/login.models.ts @@ -0,0 +1,32 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Authority } from '@shared/models/authority.enum'; + +export interface LoginRequest { + username: string; + password: string; +} + +export interface PublicLoginRequest { + publicId: string; +} + +export interface LoginResponse { + token: string; + refreshToken: string; + scope?: Authority; +} diff --git a/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts b/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts new file mode 100644 index 0000000..26cff38 --- /dev/null +++ b/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts @@ -0,0 +1,113 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 enum Lwm2mSecurityType { + PSK = 'PSK', + RPK = 'RPK', + X509 = 'X509', + NO_SEC = 'NO_SEC' +} + +export const Lwm2mSecurityTypeTranslationMap = new Map( + [ + [Lwm2mSecurityType.PSK, 'Pre-Shared Key'], + [Lwm2mSecurityType.RPK, 'Raw Public Key'], + [Lwm2mSecurityType.X509, 'X.509 Certificate'], + [Lwm2mSecurityType.NO_SEC, 'No Security'], + ] +); + +export const Lwm2mPublicKeyOrIdTooltipTranslationsMap = new Map( + [ + [Lwm2mSecurityType.PSK, 'device.lwm2m-security-config.client-publicKey-or-id-tooltip-psk'], + [Lwm2mSecurityType.RPK, 'device.lwm2m-security-config.client-publicKey-or-id-tooltip-rpk'], + [Lwm2mSecurityType.X509, 'device.lwm2m-security-config.client-publicKey-or-id-tooltip-x509'] + ] +); + +export const Lwm2mClientSecretKeyTooltipTranslationsMap = new Map( + [ + [Lwm2mSecurityType.PSK, 'device.lwm2m-security-config.client-secret-key-tooltip-psk'], + [Lwm2mSecurityType.RPK, 'device.lwm2m-security-config.client-secret-key-tooltip-prk'], + [Lwm2mSecurityType.X509, 'device.lwm2m-security-config.client-secret-key-tooltip-x509'] + ] +); + +export const Lwm2mClientKeyTooltipTranslationsMap = new Map( + [ + [Lwm2mSecurityType.PSK, 'device.lwm2m-security-config.client-secret-key-tooltip-psk'], + [Lwm2mSecurityType.RPK, 'device.lwm2m-security-config.client-secret-key-tooltip-prk'] + ] +); + +export interface ClientSecurityConfig { + securityConfigClientMode: Lwm2mSecurityType; + endpoint: string; + identity?: string; + key?: string; + cert?: string; +} + +export interface ServerSecurityConfig { + securityMode: Lwm2mSecurityType; + clientPublicKeyOrId?: string; + clientSecretKey?: string; +} + +export interface Lwm2mSecurityConfigModels { + client: ClientSecurityConfig; + bootstrap: Array; +} + + +export function getLwm2mSecurityConfigModelsDefault(): Lwm2mSecurityConfigModels { + return { + client: { + securityConfigClientMode: Lwm2mSecurityType.NO_SEC, + endpoint: '' + }, + bootstrap: [ + getDefaultServerSecurityConfig() + ] + }; +} + +export function getDefaultClientSecurityConfig(securityConfigMode: Lwm2mSecurityType, endPoint = ''): ClientSecurityConfig { + let security = { + securityConfigClientMode: securityConfigMode, + endpoint: endPoint, + identity: '', + key: '', + }; + switch (securityConfigMode) { + case Lwm2mSecurityType.X509: + security = { ...security, ...{cert: ''}}; + break; + case Lwm2mSecurityType.PSK: + security = { ...security, ...{identity: endPoint, key: ''}}; + break; + case Lwm2mSecurityType.RPK: + security = { ...security, ...{key: ''}}; + break; + } + return security; +} + +export function getDefaultServerSecurityConfig(): ServerSecurityConfig { + return { + securityMode: Lwm2mSecurityType.NO_SEC + }; +} diff --git a/ui-ngx/src/app/shared/models/material.models.ts b/ui-ngx/src/app/shared/models/material.models.ts new file mode 100644 index 0000000..ea75978 --- /dev/null +++ b/ui-ngx/src/app/shared/models/material.models.ts @@ -0,0 +1,369 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * as tinycolor_ from 'tinycolor2'; + +const tinycolor = tinycolor_; + +export interface MaterialColorItem { + value: string; + group: string; + label: string; + isDark: boolean; +} + +export const materialColorPalette: {[palette: string]: {[spectrum: string]: string}} = { + red: { + 50: '#ffebee', + 100: '#ffcdd2', + 200: '#ef9a9a', + 300: '#e57373', + 400: '#ef5350', + 500: '#f44336', + 600: '#e53935', + 700: '#d32f2f', + 800: '#c62828', + 900: '#b71c1c', + A100: '#ff8a80', + A200: '#ff5252', + A400: '#ff1744', + A700: '#d50000' + }, + pink: { + 50: '#fce4ec', + 100: '#f8bbd0', + 200: '#f48fb1', + 300: '#f06292', + 400: '#ec407a', + 500: '#e91e63', + 600: '#d81b60', + 700: '#c2185b', + 800: '#ad1457', + 900: '#880e4f', + A100: '#ff80ab', + A200: '#ff4081', + A400: '#f50057', + A700: '#c51162' + }, + purple: { + 50: '#f3e5f5', + 100: '#e1bee7', + 200: '#ce93d8', + 300: '#ba68c8', + 400: '#ab47bc', + 500: '#9c27b0', + 600: '#8e24aa', + 700: '#7b1fa2', + 800: '#6a1b9a', + 900: '#4a148c', + A100: '#ea80fc', + A200: '#e040fb', + A400: '#d500f9', + A700: '#aa00ff' + }, + 'deep-purple': { + 50: '#ede7f6', + 100: '#d1c4e9', + 200: '#b39ddb', + 300: '#9575cd', + 400: '#7e57c2', + 500: '#673ab7', + 600: '#5e35b1', + 700: '#512da8', + 800: '#4527a0', + 900: '#311b92', + A100: '#b388ff', + A200: '#7c4dff', + A400: '#651fff', + A700: '#6200ea' + }, + indigo: { + 50: '#e8eaf6', + 100: '#c5cae9', + 200: '#9fa8da', + 300: '#7986cb', + 400: '#5c6bc0', + 500: '#3f51b5', + 600: '#3949ab', + 700: '#303f9f', + 800: '#283593', + 900: '#1a237e', + A100: '#8c9eff', + A200: '#536dfe', + A400: '#3d5afe', + A700: '#304ffe' + }, + blue: { + 50: '#e3f2fd', + 100: '#bbdefb', + 200: '#90caf9', + 300: '#64b5f6', + 400: '#42a5f5', + 500: '#2196f3', + 600: '#1e88e5', + 700: '#1976d2', + 800: '#1565c0', + 900: '#0d47a1', + A100: '#82b1ff', + A200: '#448aff', + A400: '#2979ff', + A700: '#2962ff' + }, + 'light-blue': { + 50: '#e1f5fe', + 100: '#b3e5fc', + 200: '#81d4fa', + 300: '#4fc3f7', + 400: '#29b6f6', + 500: '#03a9f4', + 600: '#039be5', + 700: '#0288d1', + 800: '#0277bd', + 900: '#01579b', + A100: '#80d8ff', + A200: '#40c4ff', + A400: '#00b0ff', + A700: '#0091ea' + }, + cyan: { + 50: '#e0f7fa', + 100: '#b2ebf2', + 200: '#80deea', + 300: '#4dd0e1', + 400: '#26c6da', + 500: '#00bcd4', + 600: '#00acc1', + 700: '#0097a7', + 800: '#00838f', + 900: '#006064', + A100: '#84ffff', + A200: '#18ffff', + A400: '#00e5ff', + A700: '#00b8d4' + }, + teal: { + 50: '#e0f2f1', + 100: '#b2dfdb', + 200: '#80cbc4', + 300: '#4db6ac', + 400: '#26a69a', + 500: '#009688', + 600: '#00897b', + 700: '#00796b', + 800: '#00695c', + 900: '#004d40', + A100: '#a7ffeb', + A200: '#64ffda', + A400: '#1de9b6', + A700: '#00bfa5' + }, + green: { + 50: '#e8f5e9', + 100: '#c8e6c9', + 200: '#a5d6a7', + 300: '#81c784', + 400: '#66bb6a', + 500: '#4caf50', + 600: '#43a047', + 700: '#388e3c', + 800: '#2e7d32', + 900: '#1b5e20', + A100: '#b9f6ca', + A200: '#69f0ae', + A400: '#00e676', + A700: '#00c853' + }, + 'light-green': { + 50: '#f1f8e9', + 100: '#dcedc8', + 200: '#c5e1a5', + 300: '#aed581', + 400: '#9ccc65', + 500: '#8bc34a', + 600: '#7cb342', + 700: '#689f38', + 800: '#558b2f', + 900: '#33691e', + A100: '#ccff90', + A200: '#b2ff59', + A400: '#76ff03', + A700: '#64dd17' + }, + lime: { + 50: '#f9fbe7', + 100: '#f0f4c3', + 200: '#e6ee9c', + 300: '#dce775', + 400: '#d4e157', + 500: '#cddc39', + 600: '#c0ca33', + 700: '#afb42b', + 800: '#9e9d24', + 900: '#827717', + A100: '#f4ff81', + A200: '#eeff41', + A400: '#c6ff00', + A700: '#aeea00' + }, + yellow: { + 50: '#fffde7', + 100: '#fff9c4', + 200: '#fff59d', + 300: '#fff176', + 400: '#ffee58', + 500: '#ffeb3b', + 600: '#fdd835', + 700: '#fbc02d', + 800: '#f9a825', + 900: '#f57f17', + A100: '#ffff8d', + A200: '#ffff00', + A400: '#ffea00', + A700: '#ffd600' + }, + amber: { + 50: '#fff8e1', + 100: '#ffecb3', + 200: '#ffe082', + 300: '#ffd54f', + 400: '#ffca28', + 500: '#ffc107', + 600: '#ffb300', + 700: '#ffa000', + 800: '#ff8f00', + 900: '#ff6f00', + A100: '#ffe57f', + A200: '#ffd740', + A400: '#ffc400', + A700: '#ffab00' + }, + orange: { + 50: '#fff3e0', + 100: '#ffe0b2', + 200: '#ffcc80', + 300: '#ffb74d', + 400: '#ffa726', + 500: '#ff9800', + 600: '#fb8c00', + 700: '#f57c00', + 800: '#ef6c00', + 900: '#e65100', + A100: '#ffd180', + A200: '#ffab40', + A400: '#ff9100', + A700: '#ff6d00' + }, + 'deep-orange': { + 50: '#fbe9e7', + 100: '#ffccbc', + 200: '#ffab91', + 300: '#ff8a65', + 400: '#ff7043', + 500: '#ff5722', + 600: '#f4511e', + 700: '#e64a19', + 800: '#d84315', + 900: '#bf360c', + A100: '#ff9e80', + A200: '#ff6e40', + A400: '#ff3d00', + A700: '#dd2c00' + }, + brown: { + 50: '#efebe9', + 100: '#d7ccc8', + 200: '#bcaaa4', + 300: '#a1887f', + 400: '#8d6e63', + 500: '#795548', + 600: '#6d4c41', + 700: '#5d4037', + 800: '#4e342e', + 900: '#3e2723', + A100: '#d7ccc8', + A200: '#bcaaa4', + A400: '#8d6e63', + A700: '#5d4037' + }, + grey: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#eeeeee', + 300: '#e0e0e0', + 400: '#bdbdbd', + 500: '#9e9e9e', + 600: '#757575', + 700: '#616161', + 800: '#424242', + 900: '#212121', + A100: '#ffffff', + A200: '#000000', + A400: '#303030', + A700: '#616161' + }, + 'blue-grey': { + 50: '#eceff1', + 100: '#cfd8dc', + 200: '#b0bec5', + 300: '#90a4ae', + 400: '#78909c', + 500: '#607d8b', + 600: '#546e7a', + 700: '#455a64', + 800: '#37474f', + 900: '#263238', + A100: '#cfd8dc', + A200: '#b0bec5', + A400: '#78909c', + A700: '#455a64' + } +}; + +export const materialColors = new Array(); + +const colorPalettes = ['blue', 'green', 'red', 'amber', 'blue-grey', 'purple', 'light-green', + 'indigo', 'pink', 'yellow', 'light-blue', 'orange', 'deep-purple', 'lime', 'teal', 'brown', 'cyan', 'deep-orange', 'grey']; +const colorSpectrum = ['500', 'A700', '600', '700', '800', '900', '300', '400', 'A200', 'A400']; + +for (const key of Object.keys(materialColorPalette)) { + const value = materialColorPalette[key]; + for (const label of Object.keys(value)) { + if (colorSpectrum.indexOf(label) > -1) { + const colorValue = value[label]; + const color = tinycolor(colorValue); + const isDark = color.isDark(); + const colorItem = { + value: color.toHexString(), + group: key, + label, + isDark + }; + materialColors.push(colorItem); + } + } +} + +materialColors.sort((colorItem1, colorItem2) => { + const spectrumIndex1 = colorSpectrum.indexOf(colorItem1.label); + const spectrumIndex2 = colorSpectrum.indexOf(colorItem2.label); + let result = spectrumIndex1 - spectrumIndex2; + if (result === 0) { + const paletteIndex1 = colorPalettes.indexOf(colorItem1.group); + const paletteIndex2 = colorPalettes.indexOf(colorItem2.group); + result = paletteIndex1 - paletteIndex2; + } + return result; +}); diff --git a/ui-ngx/src/app/shared/models/oauth2.models.ts b/ui-ngx/src/app/shared/models/oauth2.models.ts new file mode 100644 index 0000000..483a955 --- /dev/null +++ b/ui-ngx/src/app/shared/models/oauth2.models.ts @@ -0,0 +1,141 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { HasUUID } from '@shared/models/id/has-uuid'; + +export interface OAuth2Info { + enabled: boolean; + oauth2ParamsInfos: OAuth2ParamsInfo[]; +} + +export interface OAuth2ParamsInfo { + clientRegistrations: OAuth2RegistrationInfo[]; + domainInfos: OAuth2DomainInfo[]; + mobileInfos: OAuth2MobileInfo[]; +} + +export interface OAuth2DomainInfo { + name: string; + scheme: DomainSchema; +} + +export interface OAuth2MobileInfo { + pkgName: string; + appSecret: string; +} + +export enum DomainSchema{ + HTTP = 'HTTP', + HTTPS = 'HTTPS', + MIXED = 'MIXED' +} + +export const domainSchemaTranslations = new Map( + [ + [DomainSchema.HTTP, 'admin.oauth2.domain-schema-http'], + [DomainSchema.HTTPS, 'admin.oauth2.domain-schema-https'], + [DomainSchema.MIXED, 'admin.oauth2.domain-schema-mixed'] + ] +); + +export enum MapperConfigType{ + BASIC = 'BASIC', + CUSTOM = 'CUSTOM', + GITHUB = 'GITHUB', + APPLE = 'APPLE' +} + +export enum TenantNameStrategy{ + DOMAIN = 'DOMAIN', + EMAIL = 'EMAIL', + CUSTOM = 'CUSTOM' +} + +export enum PlatformType { + WEB = 'WEB', + ANDROID = 'ANDROID', + IOS = 'IOS' +} + +export const platformTypeTranslations = new Map( + [ + [PlatformType.WEB, 'admin.oauth2.platform-web'], + [PlatformType.ANDROID, 'admin.oauth2.platform-android'], + [PlatformType.IOS, 'admin.oauth2.platform-ios'] + ] +); + +export interface OAuth2ClientRegistrationTemplate extends OAuth2RegistrationInfo{ + comment: string; + createdTime: number; + helpLink: string; + name: string; + providerId: string; + id: HasUUID; +} + +export interface OAuth2RegistrationInfo { + loginButtonLabel: string; + loginButtonIcon: string; + clientId: string; + clientSecret: string; + accessTokenUri: string; + authorizationUri: string; + scope: string[]; + platforms: PlatformType[]; + jwkSetUri?: string; + userInfoUri: string; + clientAuthenticationMethod: ClientAuthenticationMethod; + userNameAttributeName: string; + mapperConfig: MapperConfig; + additionalInfo: string; +} + +export enum ClientAuthenticationMethod { + BASIC = 'BASIC', + POST = 'POST' +} + +export interface MapperConfig { + allowUserCreation: boolean; + activateUser: boolean; + type: MapperConfigType; + basic?: MapperConfigBasic; + custom?: MapperConfigCustom; +} + +export interface MapperConfigBasic { + emailAttributeKey: string; + firstNameAttributeKey?: string; + lastNameAttributeKey?: string; + tenantNameStrategy: TenantNameStrategy; + tenantNamePattern?: string; + customerNamePattern?: string; + defaultDashboardName?: string; + alwaysFullScreen?: boolean; +} + +export interface MapperConfigCustom { + url: string; + username?: string; + password?: string; +} + +export interface OAuth2ClientInfo { + name: string; + icon?: string; + url: string; +} diff --git a/ui-ngx/src/app/shared/models/ota-package.models.ts b/ui-ngx/src/app/shared/models/ota-package.models.ts new file mode 100644 index 0000000..3af7b9a --- /dev/null +++ b/ui-ngx/src/app/shared/models/ota-package.models.ts @@ -0,0 +1,109 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { OtaPackageId } from '@shared/models/id/ota-package-id'; +import { DeviceProfileId } from '@shared/models/id/device-profile-id'; + +export enum ChecksumAlgorithm { + MD5 = 'MD5', + SHA256 = 'SHA256', + SHA384 = 'SHA384', + SHA512 = 'SHA512', + CRC32 = 'CRC32', + MURMUR3_32 = 'MURMUR3_32', + MURMUR3_128 = 'MURMUR3_128' +} + +export const ChecksumAlgorithmTranslationMap = new Map( + [ + [ChecksumAlgorithm.MD5, 'MD5'], + [ChecksumAlgorithm.SHA256, 'SHA-256'], + [ChecksumAlgorithm.SHA384, 'SHA-384'], + [ChecksumAlgorithm.SHA512, 'SHA-512'], + [ChecksumAlgorithm.CRC32, 'CRC-32'], + [ChecksumAlgorithm.MURMUR3_32, 'MURMUR3-32'], + [ChecksumAlgorithm.MURMUR3_128, 'MURMUR3-128'] + ] +); + +export enum OtaUpdateType { + FIRMWARE = 'FIRMWARE', + SOFTWARE = 'SOFTWARE' +} + +export const OtaUpdateTypeTranslationMap = new Map( + [ + [OtaUpdateType.FIRMWARE, 'ota-update.types.firmware'], + [OtaUpdateType.SOFTWARE, 'ota-update.types.software'] + ] +); + +export interface OtaUpdateTranslation { + label: string; + required: string; + noFound: string; + noMatching: string; + hint: string; +} + +export const OtaUpdateTranslation = new Map( + [ + [OtaUpdateType.FIRMWARE, { + label: 'ota-update.assign-firmware', + required: 'ota-update.assign-firmware-required', + noFound: 'ota-update.no-firmware-text', + noMatching: 'ota-update.no-firmware-matching', + hint: 'ota-update.chose-firmware-distributed-device' + }], + [OtaUpdateType.SOFTWARE, { + label: 'ota-update.assign-software', + required: 'ota-update.assign-software-required', + noFound: 'ota-update.no-software-text', + noMatching: 'ota-update.no-software-matching', + hint: 'ota-update.chose-software-distributed-device' + }] + ] +); + +export interface OtaPagesIds { + firmwareId?: OtaPackageId; + softwareId?: OtaPackageId; +} + +export interface OtaPackageInfo extends BaseData { + tenantId?: TenantId; + type: OtaUpdateType; + deviceProfileId?: DeviceProfileId; + title?: string; + version?: string; + tag?: string; + hasData?: boolean; + url?: string; + fileName: string; + checksum?: string; + checksumAlgorithm?: ChecksumAlgorithm; + contentType: string; + dataSize?: number; + additionalInfo?: any; + isURL?: boolean; +} + +export interface OtaPackage extends OtaPackageInfo { + file?: File; + data: string; +} diff --git a/ui-ngx/src/app/shared/models/page/page-data.ts b/ui-ngx/src/app/shared/models/page/page-data.ts new file mode 100644 index 0000000..6c14432 --- /dev/null +++ b/ui-ngx/src/app/shared/models/page/page-data.ts @@ -0,0 +1,31 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 interface PageData { + data: Array; + totalPages: number; + totalElements: number; + hasNext: boolean; +} + +export function emptyPageData(): PageData { + return { + data: [], + totalPages: 0, + totalElements: 0, + hasNext: false + } as PageData; +} diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts new file mode 100644 index 0000000..e55ac49 --- /dev/null +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -0,0 +1,193 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { getDescendantProp, isObject } from '@core/utils'; +import { SortDirection } from '@angular/material/sort'; + +export const MAX_SAFE_PAGE_SIZE = 2147483647; + +export type PageLinkSearchFunction = (entity: T, textSearch: string, searchProperty?: string) => boolean; + +export interface PageQueryParam extends Partial{ + textSearch?: string; + pageSize?: number; + page?: number; +} + +export function defaultPageLinkSearchFunction(searchProperty?: string): PageLinkSearchFunction { + return (entity, textSearch) => defaultPageLinkSearch(entity, textSearch, searchProperty); +} + +const defaultPageLinkSearch: PageLinkSearchFunction = + (entity: any, textSearch: string, searchProperty?: string) => { + if (textSearch === null || !textSearch.length) { + return true; + } + const expected = ('' + textSearch).toLowerCase(); + if (searchProperty && searchProperty.length) { + if (Object.prototype.hasOwnProperty.call(entity, searchProperty)) { + const val = entity[searchProperty]; + if (val !== null) { + if (val !== Object(val)) { + const actual = ('' + val).toLowerCase(); + if (actual.indexOf(expected) !== -1) { + return true; + } + } + } + } + } else { + for (const key of Object.keys(entity)) { + const val = entity[key]; + if (val !== null) { + if (val !== Object(val)) { + const actual = ('' + val).toLowerCase(); + if (actual.indexOf(expected) !== -1) { + return true; + } + } else if (isObject(val)) { + if (defaultPageLinkSearch(val, textSearch)) { + return true; + } + } + } + } + } + return false; + }; + +export function sortItems(item1: any, item2: any, property: string, asc: boolean): number { + const item1Value = getDescendantProp(item1, property); + const item2Value = getDescendantProp(item2, property); + let result = 0; + if (item1Value !== item2Value) { + const item1Type = typeof item1Value; + const item2Type = typeof item2Value; + if (item1Type === 'number' && item2Type === 'number') { + result = item1Value - item2Value; + } else if (item1Type === 'string' && item2Type === 'string') { + result = item1Value.localeCompare(item2Value); + } else if ((item1Type === 'boolean' && item2Type === 'boolean') || (item1Type !== item2Type)) { + if (item1Value && !item2Value) { + result = 1; + } else if (!item1Value && item2Value) { + result = -1; + } + } + } + return asc ? result : result * -1; +} + +export class PageLink { + + textSearch: string; + pageSize: number; + page: number; + sortOrder: SortOrder; + + constructor(pageSize: number, page: number = 0, textSearch: string = null, sortOrder: SortOrder = null) { + this.textSearch = textSearch; + this.pageSize = pageSize; + this.page = page; + this.sortOrder = sortOrder; + } + + public nextPageLink(): PageLink { + return new PageLink(this.pageSize, this.page + 1, this.textSearch, this.sortOrder); + } + + public toQuery(): string { + let query = `?pageSize=${this.pageSize}&page=${this.page}`; + if (this.textSearch && this.textSearch.length) { + const textSearch = encodeURIComponent(this.textSearch); + query += `&textSearch=${textSearch}`; + } + if (this.sortOrder) { + query += `&sortProperty=${this.sortOrder.property}&sortOrder=${this.sortOrder.direction}`; + } + return query; + } + + public sort(item1: any, item2: any): number { + if (this.sortOrder) { + const sortProperty = this.sortOrder.property; + const asc = this.sortOrder.direction === Direction.ASC; + return sortItems(item1, item2, sortProperty, asc); + } + return 0; + } + + public filterData(data: Array, + searchFunction: PageLinkSearchFunction = defaultPageLinkSearchFunction()): PageData { + const pageData = emptyPageData(); + pageData.data = [...data]; + if (this.textSearch && this.textSearch.length) { + pageData.data = pageData.data.filter((entity) => searchFunction(entity, this.textSearch)); + } + pageData.totalElements = pageData.data.length; + pageData.totalPages = this.pageSize === Number.POSITIVE_INFINITY ? 1 : Math.ceil(pageData.totalElements / this.pageSize); + if (this.sortOrder) { + const sortProperty = this.sortOrder.property; + const asc = this.sortOrder.direction === Direction.ASC; + pageData.data = pageData.data.sort((a, b) => sortItems(a, b, sortProperty, asc)); + } + if (this.pageSize !== Number.POSITIVE_INFINITY) { + const startIndex = this.pageSize * this.page; + pageData.data = pageData.data.slice(startIndex, startIndex + this.pageSize); + pageData.hasNext = pageData.totalElements > startIndex + pageData.data.length; + } + return pageData; + } + + public sortDirection(): SortDirection { + if (this.sortOrder) { + return (this.sortOrder.direction + '').toLowerCase() as SortDirection; + } else { + return '' as SortDirection; + } + } + +} + +export class TimePageLink extends PageLink { + + startTime: number; + endTime: number; + + constructor(pageSize: number, page: number = 0, textSearch: string = null, sortOrder: SortOrder = null, + startTime: number = null, endTime: number = null) { + super(pageSize, page, textSearch, sortOrder); + this.startTime = startTime; + this.endTime = endTime; + } + + public nextPageLink(): TimePageLink { + return new TimePageLink(this.pageSize, this.page + 1, this.textSearch, this.sortOrder, this.startTime, this.endTime); + } + + public toQuery(): string { + let query = super.toQuery(); + if (this.startTime) { + query += `&startTime=${this.startTime}`; + } + if (this.endTime) { + query += `&endTime=${this.endTime}`; + } + return query; + } +} diff --git a/ui-ngx/src/app/shared/models/page/public-api.ts b/ui-ngx/src/app/shared/models/page/public-api.ts new file mode 100644 index 0000000..3bf44ef --- /dev/null +++ b/ui-ngx/src/app/shared/models/page/public-api.ts @@ -0,0 +1,19 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './page-data'; +export * from './page-link'; +export * from './sort-order'; diff --git a/ui-ngx/src/app/shared/models/page/sort-order.ts b/ui-ngx/src/app/shared/models/page/sort-order.ts new file mode 100644 index 0000000..f21840c --- /dev/null +++ b/ui-ngx/src/app/shared/models/page/sort-order.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 interface SortOrder { + property: string; + direction: Direction; +} + +export enum Direction { + ASC = 'ASC', + DESC = 'DESC' +} + +export function sortOrderFromString(strSortOrder: string): SortOrder { + let property: string; + let direction = Direction.ASC; + if (strSortOrder.startsWith('-')) { + direction = Direction.DESC; + property = strSortOrder.substring(1); + } else { + if (strSortOrder.startsWith('+')) { + property = strSortOrder.substring(1); + } else { + property = strSortOrder; + } + } + return {property, direction}; +} diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts new file mode 100644 index 0000000..b21ad86 --- /dev/null +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -0,0 +1,53 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './id/public-api'; +export * from './page/public-api'; +export * from './telemetry/telemetry.models'; +export * from './time/time.models'; +export * from './alarm.models'; +export * from './alias.models'; +export * from './asset.models'; +export * from './audit-log.models'; +export * from './authority.enum'; +export * from './base-data'; +export * from './component-descriptor.models'; +export * from './constants'; +export * from './contact-based.model'; +export * from './customer.model'; +export * from './dashboard.models'; +export * from './device.models'; +export * from './edge.models'; +export * from './entity.models'; +export * from './entity-type.models'; +export * from './entity-view.models'; +export * from './error.models'; +export * from './event.models'; +export * from './login.models'; +export * from './material.models'; +export * from './oauth2.models'; +export * from './queue.models'; +export * from './relation.models'; +export * from './resource.models'; +export * from './rpc.models'; +export * from './rule-chain.models'; +export * from './rule-node.models'; +export * from './settings.models'; +export * from './tenant.model'; +export * from './user.model'; +export * from './widget.models'; +export * from './widgets-bundle.model'; +export * from './window-message.model'; diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts new file mode 100644 index 0000000..9c50afe --- /dev/null +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -0,0 +1,865 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { AliasFilterType, EntityFilters } from '@shared/models/alias.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { SortDirection } from '@angular/material/sort'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntityInfo } from '@shared/models/entity.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; +import { PageData } from '@shared/models/page/page-data'; +import { isDefined, isEqual } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { AlarmInfo, AlarmSearchStatus, AlarmSeverity } from '../alarm.models'; +import { Filter } from '@material-ui/icons'; +import { DatePipe } from '@angular/common'; + +export enum EntityKeyType { + ATTRIBUTE = 'ATTRIBUTE', + CLIENT_ATTRIBUTE = 'CLIENT_ATTRIBUTE', + SHARED_ATTRIBUTE = 'SHARED_ATTRIBUTE', + SERVER_ATTRIBUTE = 'SERVER_ATTRIBUTE', + TIME_SERIES = 'TIME_SERIES', + ENTITY_FIELD = 'ENTITY_FIELD', + ALARM_FIELD = 'ALARM_FIELD', + CONSTANT = 'CONSTANT', + COUNT = 'COUNT' +} + +export const entityKeyTypeTranslationMap = new Map( + [ + [EntityKeyType.ATTRIBUTE, 'filter.key-type.attribute'], + [EntityKeyType.TIME_SERIES, 'filter.key-type.timeseries'], + [EntityKeyType.ENTITY_FIELD, 'filter.key-type.entity-field'], + [EntityKeyType.CONSTANT, 'filter.key-type.constant'] + ] +); + +export function entityKeyTypeToDataKeyType(entityKeyType: EntityKeyType): DataKeyType { + switch (entityKeyType) { + case EntityKeyType.ATTRIBUTE: + case EntityKeyType.CLIENT_ATTRIBUTE: + case EntityKeyType.SHARED_ATTRIBUTE: + case EntityKeyType.SERVER_ATTRIBUTE: + return DataKeyType.attribute; + case EntityKeyType.TIME_SERIES: + return DataKeyType.timeseries; + case EntityKeyType.ENTITY_FIELD: + return DataKeyType.entityField; + case EntityKeyType.ALARM_FIELD: + return DataKeyType.alarm; + case EntityKeyType.COUNT: + return DataKeyType.count; + } +} + +export function dataKeyTypeToEntityKeyType(dataKeyType: DataKeyType): EntityKeyType { + switch (dataKeyType) { + case DataKeyType.timeseries: + return EntityKeyType.TIME_SERIES; + case DataKeyType.attribute: + return EntityKeyType.ATTRIBUTE; + case DataKeyType.function: + return EntityKeyType.ENTITY_FIELD; + case DataKeyType.alarm: + return EntityKeyType.ALARM_FIELD; + case DataKeyType.entityField: + return EntityKeyType.ENTITY_FIELD; + case DataKeyType.count: + return EntityKeyType.COUNT; + } +} + +export interface EntityKey { + type: EntityKeyType; + key: string; +} + +export function dataKeyToEntityKey(dataKey: DataKey): EntityKey { + return { + key: dataKey.name, + type: dataKeyTypeToEntityKeyType(dataKey.type) + }; +} + +export enum EntityKeyValueType { + STRING = 'STRING', + NUMERIC = 'NUMERIC', + BOOLEAN = 'BOOLEAN', + DATE_TIME = 'DATE_TIME' +} + +export interface EntityKeyValueTypeData { + name: string; + icon: string; +} + +export const entityKeyValueTypesMap = new Map( + [ + [ + EntityKeyValueType.STRING, + { + name: 'filter.value-type.string', + icon: 'mdi:format-text' + } + ], + [ + EntityKeyValueType.NUMERIC, + { + name: 'filter.value-type.numeric', + icon: 'mdi:numeric' + } + ], + [ + EntityKeyValueType.BOOLEAN, + { + name: 'filter.value-type.boolean', + icon: 'mdi:checkbox-marked-outline' + } + ], + [ + EntityKeyValueType.DATE_TIME, + { + name: 'filter.value-type.date-time', + icon: 'mdi:calendar-clock' + } + ] + ] +); + +export function entityKeyValueTypeToFilterPredicateType(valueType: EntityKeyValueType): FilterPredicateType { + switch (valueType) { + case EntityKeyValueType.STRING: + return FilterPredicateType.STRING; + case EntityKeyValueType.NUMERIC: + case EntityKeyValueType.DATE_TIME: + return FilterPredicateType.NUMERIC; + case EntityKeyValueType.BOOLEAN: + return FilterPredicateType.BOOLEAN; + } +} + +export function createDefaultFilterPredicateInfo(valueType: EntityKeyValueType, complex: boolean): KeyFilterPredicateInfo { + const predicate = createDefaultFilterPredicate(valueType, complex); + return { + keyFilterPredicate: predicate, + userInfo: createDefaultFilterPredicateUserInfo() + }; +} + +export function createDefaultFilterPredicateUserInfo(): KeyFilterPredicateUserInfo { + return { + editable: true, + label: '', + autogeneratedLabel: true, + order: 0 + }; +} + +export function createDefaultFilterPredicate(valueType: EntityKeyValueType, complex: boolean): KeyFilterPredicate { + const predicate = { + type: complex ? FilterPredicateType.COMPLEX : entityKeyValueTypeToFilterPredicateType(valueType) + } as KeyFilterPredicate; + switch (predicate.type) { + case FilterPredicateType.STRING: + predicate.operation = StringOperation.STARTS_WITH; + predicate.value = { + defaultValue: '' + }; + predicate.ignoreCase = false; + break; + case FilterPredicateType.NUMERIC: + predicate.operation = NumericOperation.EQUAL; + predicate.value = { + defaultValue: valueType === EntityKeyValueType.DATE_TIME ? Date.now() : 0 + }; + break; + case FilterPredicateType.BOOLEAN: + predicate.operation = BooleanOperation.EQUAL; + predicate.value = { + defaultValue: false + }; + break; + case FilterPredicateType.COMPLEX: + predicate.operation = ComplexOperation.AND; + predicate.predicates = []; + break; + } + return predicate; +} + +export function getDynamicSourcesForAllowUser(allow: boolean): DynamicValueSourceType[] { + const dynamicValueSourceTypes = [DynamicValueSourceType.CURRENT_TENANT, + DynamicValueSourceType.CURRENT_CUSTOMER]; + if (allow) { + dynamicValueSourceTypes.push(DynamicValueSourceType.CURRENT_USER); + } else { + dynamicValueSourceTypes.push(DynamicValueSourceType.CURRENT_DEVICE); + } + return dynamicValueSourceTypes; +} + +export enum FilterPredicateType { + STRING = 'STRING', + NUMERIC = 'NUMERIC', + BOOLEAN = 'BOOLEAN', + COMPLEX = 'COMPLEX' +} + +export enum StringOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + STARTS_WITH = 'STARTS_WITH', + ENDS_WITH = 'ENDS_WITH', + CONTAINS = 'CONTAINS', + NOT_CONTAINS = 'NOT_CONTAINS', + IN = 'IN', + NOT_IN = 'NOT_IN' +} + +export const stringOperationTranslationMap = new Map( + [ + [StringOperation.EQUAL, 'filter.operation.equal'], + [StringOperation.NOT_EQUAL, 'filter.operation.not-equal'], + [StringOperation.STARTS_WITH, 'filter.operation.starts-with'], + [StringOperation.ENDS_WITH, 'filter.operation.ends-with'], + [StringOperation.CONTAINS, 'filter.operation.contains'], + [StringOperation.NOT_CONTAINS, 'filter.operation.not-contains'], + [StringOperation.IN, 'filter.operation.in'], + [StringOperation.NOT_IN, 'filter.operation.not-in'] + ] +); + +export enum NumericOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + GREATER = 'GREATER', + LESS = 'LESS', + GREATER_OR_EQUAL = 'GREATER_OR_EQUAL', + LESS_OR_EQUAL = 'LESS_OR_EQUAL' +} + +export const numericOperationTranslationMap = new Map( + [ + [NumericOperation.EQUAL, 'filter.operation.equal'], + [NumericOperation.NOT_EQUAL, 'filter.operation.not-equal'], + [NumericOperation.GREATER, 'filter.operation.greater'], + [NumericOperation.LESS, 'filter.operation.less'], + [NumericOperation.GREATER_OR_EQUAL, 'filter.operation.greater-or-equal'], + [NumericOperation.LESS_OR_EQUAL, 'filter.operation.less-or-equal'] + ] +); + +export enum BooleanOperation { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL' +} + +export const booleanOperationTranslationMap = new Map( + [ + [BooleanOperation.EQUAL, 'filter.operation.equal'], + [BooleanOperation.NOT_EQUAL, 'filter.operation.not-equal'] + ] +); + +export enum ComplexOperation { + AND = 'AND', + OR = 'OR' +} + +export const complexOperationTranslationMap = new Map( + [ + [ComplexOperation.AND, 'filter.operation.and'], + [ComplexOperation.OR, 'filter.operation.or'] + ] +); + +export enum DynamicValueSourceType { + CURRENT_TENANT = 'CURRENT_TENANT', + CURRENT_CUSTOMER = 'CURRENT_CUSTOMER', + CURRENT_USER = 'CURRENT_USER', + CURRENT_DEVICE = 'CURRENT_DEVICE' +} + +export const dynamicValueSourceTypeTranslationMap = new Map( + [ + [DynamicValueSourceType.CURRENT_TENANT, 'filter.current-tenant'], + [DynamicValueSourceType.CURRENT_CUSTOMER, 'filter.current-customer'], + [DynamicValueSourceType.CURRENT_USER, 'filter.current-user'], + [DynamicValueSourceType.CURRENT_DEVICE, 'filter.current-device'] + ] +); + +export const inheritModeForDynamicValueSourceType = [ + DynamicValueSourceType.CURRENT_CUSTOMER, + DynamicValueSourceType.CURRENT_DEVICE]; + +export interface DynamicValue { + sourceType: DynamicValueSourceType; + sourceAttribute: string; + inherit?: boolean; +} + +export interface FilterPredicateValue { + defaultValue: T; + userValue?: T; + dynamicValue?: DynamicValue; +} + +export interface StringFilterPredicate { + type: FilterPredicateType.STRING; + operation: StringOperation; + value: FilterPredicateValue; + ignoreCase: boolean; +} + +export interface NumericFilterPredicate { + type: FilterPredicateType.NUMERIC; + operation: NumericOperation; + value: FilterPredicateValue; +} + +export interface BooleanFilterPredicate { + type: FilterPredicateType.BOOLEAN; + operation: BooleanOperation; + value: FilterPredicateValue; +} + +export interface BaseComplexFilterPredicate { + type: FilterPredicateType.COMPLEX; + operation: ComplexOperation; + predicates: Array; +} + +export type ComplexFilterPredicate = BaseComplexFilterPredicate; + +export type ComplexFilterPredicateInfo = BaseComplexFilterPredicate; + +export type KeyFilterPredicate = StringFilterPredicate | + NumericFilterPredicate | + BooleanFilterPredicate | + ComplexFilterPredicate | + ComplexFilterPredicateInfo; + +export interface KeyFilterPredicateUserInfo { + editable: boolean; + label: string; + autogeneratedLabel: boolean; + order?: number; +} + +export interface KeyFilterPredicateInfo { + keyFilterPredicate: KeyFilterPredicate; + userInfo: KeyFilterPredicateUserInfo; +} + +export interface KeyFilter { + key: EntityKey; + valueType: EntityKeyValueType; + value?: string | number | boolean; + predicate: KeyFilterPredicate; +} + +export interface KeyFilterInfo { + key: EntityKey; + valueType: EntityKeyValueType; + value?: string | number | boolean; + predicates: Array; +} + +export interface FilterInfo { + filter: string; + editable: boolean; + keyFilters: Array; +} + +export interface FiltersInfo { + datasourceFilters: {[datasourceIndex: number]: FilterInfo}; +} + +export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array): string { + const filtersText = keyFilters.map(keyFilter => + keyFilterToText(translate, datePipe, keyFilter, + keyFilters.length > 1 ? ComplexOperation.AND : undefined)); + let result: string; + if (filtersText.length > 1) { + const andText = translate.instant('filter.operation.and'); + result = filtersText.join(' ' + andText + ' '); + } else { + result = filtersText[0]; + } + return result; +} + +export function keyFilterToText(translate: TranslateService, datePipe: DatePipe, keyFilter: KeyFilter, + parentComplexOperation?: ComplexOperation): string { + const keyFilterPredicate = keyFilter.predicate; + return keyFilterPredicateToText(translate, datePipe, keyFilter, keyFilterPredicate, parentComplexOperation); +} + +export function keyFilterPredicateToText(translate: TranslateService, + datePipe: DatePipe, + keyFilter: KeyFilter, + keyFilterPredicate: KeyFilterPredicate, + parentComplexOperation?: ComplexOperation): string { + if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexPredicate = keyFilterPredicate as ComplexFilterPredicate; + const complexOperation = complexPredicate.operation; + const complexPredicatesText = + complexPredicate.predicates.map(predicate => keyFilterPredicateToText(translate, datePipe, keyFilter, predicate, complexOperation)); + if (complexPredicatesText.length > 1) { + const operationText = translate.instant(complexOperationTranslationMap.get(complexOperation)); + let result = complexPredicatesText.join(' ' + operationText + ' '); + if (complexOperation === ComplexOperation.OR && parentComplexOperation && ComplexOperation.OR !== parentComplexOperation) { + result = `(${result})`; + } + return result; + } else { + return complexPredicatesText[0]; + } + } else { + return simpleKeyFilterPredicateToText(translate, datePipe, keyFilter, keyFilterPredicate); + } +} + +function simpleKeyFilterPredicateToText(translate: TranslateService, + datePipe: DatePipe, + keyFilter: KeyFilter, + keyFilterPredicate: StringFilterPredicate | + NumericFilterPredicate | + BooleanFilterPredicate): string { + const key = keyFilter.key.key; + let operation: string; + let value: string; + const val = keyFilterPredicate.value; + const dynamicValue = !!val.dynamicValue && !!val.dynamicValue.sourceType; + if (dynamicValue) { + value = '' + + translate.instant(dynamicValueSourceTypeTranslationMap.get(val.dynamicValue.sourceType)) + ''; + value += '.' + val.dynamicValue.sourceAttribute + ''; + } + switch (keyFilterPredicate.type) { + case FilterPredicateType.STRING: + operation = translate.instant(stringOperationTranslationMap.get(keyFilterPredicate.operation)); + if (keyFilterPredicate.ignoreCase) { + operation += ' ' + translate.instant('filter.ignore-case'); + } + if (!dynamicValue) { + value = `'${keyFilterPredicate.value.defaultValue}'`; + } + break; + case FilterPredicateType.NUMERIC: + operation = translate.instant(numericOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) { + value = datePipe.transform(keyFilterPredicate.value.defaultValue, 'yyyy-MM-dd HH:mm'); + } else { + value = keyFilterPredicate.value.defaultValue + ''; + } + } + break; + case FilterPredicateType.BOOLEAN: + operation = translate.instant(booleanOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + value = translate.instant(keyFilterPredicate.value.defaultValue ? 'value.true' : 'value.false'); + } + break; + } + if (!dynamicValue) { + value = `${value}`; + } + return `${key} ${operation} ${value}`; +} + +export function keyFilterInfosToKeyFilters(keyFilterInfos: Array): Array { + if (!keyFilterInfos) { + return []; + } + const keyFilters: Array = []; + for (const keyFilterInfo of keyFilterInfos) { + const key = keyFilterInfo.key; + for (const predicate of keyFilterInfo.predicates) { + const keyFilter: KeyFilter = { + key, + valueType: keyFilterInfo.valueType, + value: keyFilterInfo.value, + predicate: keyFilterPredicateInfoToKeyFilterPredicate(predicate) + }; + keyFilters.push(keyFilter); + } + } + return keyFilters; +} + +export function keyFiltersToKeyFilterInfos(keyFilters: Array): Array { + const keyFilterInfos: Array = []; + const keyFilterInfoMap: {[infoKey: string]: KeyFilterInfo} = {}; + if (keyFilters) { + for (const keyFilter of keyFilters) { + const key = keyFilter.key; + const infoKey = key.key + key.type + keyFilter.valueType; + let keyFilterInfo = keyFilterInfoMap[infoKey]; + if (!keyFilterInfo) { + keyFilterInfo = { + key, + valueType: keyFilter.valueType, + value: keyFilter.value, + predicates: [] + }; + keyFilterInfoMap[infoKey] = keyFilterInfo; + keyFilterInfos.push(keyFilterInfo); + } + if (keyFilter.predicate) { + keyFilterInfo.predicates.push(keyFilterPredicateToKeyFilterPredicateInfo(keyFilter.predicate)); + } + } + } + return keyFilterInfos; +} + +export function filterInfoToKeyFilters(filter: FilterInfo): Array { + const keyFilterInfos = filter.keyFilters; + const keyFilters: Array = []; + for (const keyFilterInfo of keyFilterInfos) { + const key = keyFilterInfo.key; + for (const predicate of keyFilterInfo.predicates) { + const keyFilter: KeyFilter = { + key, + valueType: keyFilterInfo.valueType, + value: keyFilterInfo.value, + predicate: keyFilterPredicateInfoToKeyFilterPredicate(predicate) + }; + keyFilters.push(keyFilter); + } + } + return keyFilters; +} + +export function keyFilterPredicateInfoToKeyFilterPredicate(keyFilterPredicateInfo: KeyFilterPredicateInfo): KeyFilterPredicate { + let keyFilterPredicate = keyFilterPredicateInfo.keyFilterPredicate; + if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexInfo = keyFilterPredicate as ComplexFilterPredicateInfo; + const predicates = complexInfo.predicates.map((predicateInfo => keyFilterPredicateInfoToKeyFilterPredicate(predicateInfo))); + keyFilterPredicate = { + type: FilterPredicateType.COMPLEX, + operation: complexInfo.operation, + predicates + } as ComplexFilterPredicate; + } + return keyFilterPredicate; +} + +export function keyFilterPredicateToKeyFilterPredicateInfo(keyFilterPredicate: KeyFilterPredicate): KeyFilterPredicateInfo { + const keyFilterPredicateInfo: KeyFilterPredicateInfo = { + keyFilterPredicate: null, + userInfo: null + }; + if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexPredicate = keyFilterPredicate as ComplexFilterPredicate; + const predicateInfos = complexPredicate.predicates.map( + predicate => keyFilterPredicateToKeyFilterPredicateInfo(predicate)); + keyFilterPredicateInfo.keyFilterPredicate = { + predicates: predicateInfos, + operation: complexPredicate.operation, + type: FilterPredicateType.COMPLEX + } as ComplexFilterPredicateInfo; + } else { + keyFilterPredicateInfo.keyFilterPredicate = keyFilterPredicate; + } + return keyFilterPredicateInfo; +} + +export function isFilterEditable(filter: FilterInfo): boolean { + if (filter.editable) { + return filter.keyFilters.some(value => isKeyFilterInfoEditable(value)); + } else { + return false; + } +} + +export function isKeyFilterInfoEditable(keyFilterInfo: KeyFilterInfo): boolean { + return keyFilterInfo.predicates.some(value => isPredicateInfoEditable(value)); +} + +export function isPredicateInfoEditable(predicateInfo: KeyFilterPredicateInfo): boolean { + if (predicateInfo.keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexFilterPredicateInfo: ComplexFilterPredicateInfo = predicateInfo.keyFilterPredicate as ComplexFilterPredicateInfo; + return complexFilterPredicateInfo.predicates.some(value => isPredicateInfoEditable(value)); + } else { + return predicateInfo.userInfo.editable; + } +} + +export interface UserFilterInputInfo { + label: string; + valueType: EntityKeyValueType; + info: KeyFilterPredicateInfo; +} + +export function filterToUserFilterInfoList(filter: Filter, translate: TranslateService): Array { + const result = filter.keyFilters.map((keyFilterInfo => keyFilterInfoToUserFilterInfoList(keyFilterInfo, translate))); + let userInputs: Array = [].concat.apply([], result); + userInputs = userInputs.sort((input1, input2) => { + const order1 = isDefined(input1.info.userInfo.order) ? input1.info.userInfo.order : 0; + const order2 = isDefined(input2.info.userInfo.order) ? input2.info.userInfo.order : 0; + return order1 - order2; + }); + return userInputs; +} + +export function keyFilterInfoToUserFilterInfoList(keyFilterInfo: KeyFilterInfo, translate: TranslateService): Array { + const result = keyFilterInfo.predicates.map((predicateInfo => predicateInfoToUserFilterInfoList(keyFilterInfo.key, + keyFilterInfo.valueType, predicateInfo, translate))); + return [].concat.apply([], result); +} + +export function predicateInfoToUserFilterInfoList(key: EntityKey, + valueType: EntityKeyValueType, + predicateInfo: KeyFilterPredicateInfo, + translate: TranslateService): Array { + if (predicateInfo.keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexFilterPredicateInfo: ComplexFilterPredicateInfo = predicateInfo.keyFilterPredicate as ComplexFilterPredicateInfo; + const result = complexFilterPredicateInfo.predicates.map((predicateInfo1 => + predicateInfoToUserFilterInfoList(key, valueType, predicateInfo1, translate))); + return [].concat.apply([], result); + } else { + if (predicateInfo.userInfo.editable) { + const userInput: UserFilterInputInfo = { + info: predicateInfo, + label: predicateInfo.userInfo.label, + valueType + }; + if (predicateInfo.userInfo.autogeneratedLabel) { + userInput.label = generateUserFilterValueLabel(key.key, valueType, + predicateInfo.keyFilterPredicate.operation, translate); + } + return [userInput]; + } else { + return []; + } + } +} + +export function generateUserFilterValueLabel(key: string, valueType: EntityKeyValueType, + operation: StringOperation | BooleanOperation | NumericOperation, + translate: TranslateService) { + let label = key; + let operationTranslationKey: string; + switch (valueType) { + case EntityKeyValueType.STRING: + operationTranslationKey = stringOperationTranslationMap.get(operation as StringOperation); + break; + case EntityKeyValueType.NUMERIC: + case EntityKeyValueType.DATE_TIME: + operationTranslationKey = numericOperationTranslationMap.get(operation as NumericOperation); + break; + case EntityKeyValueType.BOOLEAN: + operationTranslationKey = booleanOperationTranslationMap.get(operation as BooleanOperation); + break; + } + label += ' ' + translate.instant(operationTranslationKey); + return label; +} + +export interface Filter extends FilterInfo { + id: string; +} + +export interface Filters { + [id: string]: Filter; +} + +export interface EntityFilter extends EntityFilters { + type?: AliasFilterType; +} + +export enum Direction { + ASC = 'ASC', + DESC = 'DESC' +} + +export interface EntityDataSortOrder { + key: EntityKey; + direction: Direction; +} + +export interface EntityDataPageLink { + pageSize: number; + page: number; + textSearch?: string; + sortOrder?: EntityDataSortOrder; + dynamic?: boolean; +} + +export interface AlarmDataPageLink extends EntityDataPageLink { + startTs?: number; + endTs?: number; + timeWindow?: number; + typeList?: Array; + statusList?: Array; + severityList?: Array; + searchPropagatedAlarms?: boolean; +} + +export function entityDataPageLinkSortDirection(pageLink: EntityDataPageLink): SortDirection { + if (pageLink.sortOrder) { + return (pageLink.sortOrder.direction + '').toLowerCase() as SortDirection; + } else { + return '' as SortDirection; + } +} + +export function createDefaultEntityDataPageLink(pageSize: number): EntityDataPageLink { + return { + pageSize, + page: 0, + sortOrder: { + key: { + type: EntityKeyType.ENTITY_FIELD, + key: 'createdTime' + }, + direction: Direction.DESC + } + }; +} + +export const singleEntityDataPageLink: EntityDataPageLink = createDefaultEntityDataPageLink(1); + +export interface EntityCountQuery { + entityFilter: EntityFilter; + keyFilters?: Array; +} + +export interface AbstractDataQuery extends EntityCountQuery { + pageLink: T; + entityFields?: Array; + latestValues?: Array; +} + +export interface EntityDataQuery extends AbstractDataQuery { +} + +export interface AlarmDataQuery extends AbstractDataQuery { + alarmFields?: Array; +} + +export interface TsValue { + ts: number; + value: string; + count?: number; +} + +export interface ComparisonTsValue { + current?: TsValue; + previous?: TsValue; +} + +export interface EntityData { + entityId: EntityId; + latest: {[entityKeyType: string]: {[key: string]: TsValue}}; + timeseries: {[key: string]: Array}; + aggLatest?: {[id: number]: ComparisonTsValue}; +} + +export interface AlarmData extends AlarmInfo { + entityId: string; + latest: {[entityKeyType: string]: {[key: string]: TsValue}}; +} + +export function entityPageDataChanged(prevPageData: PageData, nextPageData: PageData): boolean { + const prevIds = prevPageData.data.map((entityData) => entityData.entityId.id); + const nextIds = nextPageData.data.map((entityData) => entityData.entityId.id); + return !isEqual(prevIds, nextIds); +} + +export const entityInfoFields: EntityKey[] = [ + { + type: EntityKeyType.ENTITY_FIELD, + key: 'name' + }, + { + type: EntityKeyType.ENTITY_FIELD, + key: 'label' + }, + { + type: EntityKeyType.ENTITY_FIELD, + key: 'additionalInfo' + } +]; + +export function entityDataToEntityInfo(entityData: EntityData): EntityInfo { + const entityInfo: EntityInfo = { + id: entityData.entityId.id, + entityType: entityData.entityId.entityType as EntityType + }; + if (entityData.latest && entityData.latest[EntityKeyType.ENTITY_FIELD]) { + const fields = entityData.latest[EntityKeyType.ENTITY_FIELD]; + if (fields.name) { + entityInfo.name = fields.name.value; + } else { + entityInfo.name = ''; + } + if (fields.label) { + entityInfo.label = fields.label.value; + } else { + entityInfo.label = ''; + } + entityInfo.entityDescription = ''; + if (fields.additionalInfo) { + const additionalInfo = fields.additionalInfo.value; + if (additionalInfo && additionalInfo.length) { + try { + const additionalInfoJson = JSON.parse(additionalInfo); + if (additionalInfoJson && additionalInfoJson.description) { + entityInfo.entityDescription = additionalInfoJson.description; + } + } catch (e) {} + } + } + } + return entityInfo; +} + +export function updateDatasourceFromEntityInfo(datasource: Datasource, entity: EntityInfo, createFilter = false) { + datasource.entity = { + id: { + entityType: entity.entityType, + id: entity.id + } + }; + datasource.entityId = entity.id; + datasource.entityType = entity.entityType; + if (datasource.type === DatasourceType.entity || datasource.type === DatasourceType.entityCount) { + if (datasource.type === DatasourceType.entity) { + datasource.entityName = entity.name; + datasource.entityLabel = entity.label; + datasource.name = entity.name; + datasource.entityDescription = entity.entityDescription; + datasource.entity.label = entity.label; + datasource.entity.name = entity.name; + } + if (createFilter) { + datasource.entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: { + id: entity.id, + entityType: entity.entityType + } + }; + } + } +} diff --git a/ui-ngx/src/app/shared/models/queue.models.ts b/ui-ngx/src/app/shared/models/queue.models.ts new file mode 100644 index 0000000..76ee0bf --- /dev/null +++ b/ui-ngx/src/app/shared/models/queue.models.ts @@ -0,0 +1,125 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { QueueId } from '@shared/models/id/queue-id'; + +export enum ServiceType { + TB_CORE = 'TB_CORE', + TB_RULE_ENGINE = 'TB_RULE_ENGINE', + TB_TRANSPORT = 'TB_TRANSPORT', + JS_EXECUTOR = 'JS_EXECUTOR' +} + +export enum QueueSubmitStrategyTypes { + SEQUENTIAL_BY_ORIGINATOR = 'SEQUENTIAL_BY_ORIGINATOR', + SEQUENTIAL_BY_TENANT = 'SEQUENTIAL_BY_TENANT', + SEQUENTIAL = 'SEQUENTIAL', + BURST = 'BURST', + BATCH = 'BATCH' +} + +export interface QueueStrategyData { + label: string; + hint: string; +} + +export const QueueSubmitStrategyTypesMap = new Map( + [ + [QueueSubmitStrategyTypes.SEQUENTIAL_BY_ORIGINATOR, { + label: 'queue.strategies.sequential-by-originator-label', + hint: 'queue.strategies.sequential-by-originator-hint', + }], + [QueueSubmitStrategyTypes.SEQUENTIAL_BY_TENANT, { + label: 'queue.strategies.sequential-by-tenant-label', + hint: 'queue.strategies.sequential-by-tenant-hint', + }], + [QueueSubmitStrategyTypes.SEQUENTIAL, { + label: 'queue.strategies.sequential-label', + hint: 'queue.strategies.sequential-hint', + }], + [QueueSubmitStrategyTypes.BURST, { + label: 'queue.strategies.burst-label', + hint: 'queue.strategies.burst-hint', + }], + [QueueSubmitStrategyTypes.BATCH, { + label: 'queue.strategies.batch-label', + hint: 'queue.strategies.batch-hint', + }] + ]); + +export enum QueueProcessingStrategyTypes { + RETRY_FAILED_AND_TIMED_OUT = 'RETRY_FAILED_AND_TIMED_OUT', + SKIP_ALL_FAILURES = 'SKIP_ALL_FAILURES', + SKIP_ALL_FAILURES_AND_TIMED_OUT = 'SKIP_ALL_FAILURES_AND_TIMED_OUT', + RETRY_ALL = 'RETRY_ALL', + RETRY_FAILED = 'RETRY_FAILED', + RETRY_TIMED_OUT = 'RETRY_TIMED_OUT' +} + +export const QueueProcessingStrategyTypesMap = new Map( + [ + [QueueProcessingStrategyTypes.RETRY_FAILED_AND_TIMED_OUT, { + label: 'queue.strategies.retry-failed-and-timeout-label', + hint: 'queue.strategies.retry-failed-and-timeout-hint', + }], + [QueueProcessingStrategyTypes.SKIP_ALL_FAILURES, { + label: 'queue.strategies.skip-all-failures-label', + hint: 'queue.strategies.skip-all-failures-hint', + }], + [QueueProcessingStrategyTypes.SKIP_ALL_FAILURES_AND_TIMED_OUT, { + label: 'queue.strategies.skip-all-failures-and-timeouts-label', + hint: 'queue.strategies.skip-all-failures-and-timeouts-hint', + }], + [QueueProcessingStrategyTypes.RETRY_ALL, { + label: 'queue.strategies.retry-all-label', + hint: 'queue.strategies.retry-all-hint', + }], + [QueueProcessingStrategyTypes.RETRY_FAILED, { + label: 'queue.strategies.retry-failed-label', + hint: 'queue.strategies.retry-failed-hint', + }], + [QueueProcessingStrategyTypes.RETRY_TIMED_OUT, { + label: 'queue.strategies.retry-timeout-label', + hint: 'queue.strategies.retry-timeout-hint', + }] + ]); + +export interface QueueInfo extends BaseData { + generatedId?: string; + name: string; + packProcessingTimeout: number; + partitions: number; + consumerPerPartition: boolean; + pollInterval: number; + processingStrategy: { + type: QueueProcessingStrategyTypes, + retries: number, + failurePercentage: number, + pauseBetweenRetries: number, + maxPauseBetweenRetries: number + }; + submitStrategy: { + type: QueueSubmitStrategyTypes, + batchSize: number, + }; + tenantId?: TenantId; + topic: string; + additionalInfo: { + description?: string; + }; +} diff --git a/ui-ngx/src/app/shared/models/relation.models.ts b/ui-ngx/src/app/shared/models/relation.models.ts new file mode 100644 index 0000000..c51c606 --- /dev/null +++ b/ui-ngx/src/app/shared/models/relation.models.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export const CONTAINS_TYPE = 'Contains'; +export const MANAGES_TYPE = 'Manages'; + +export const RelationTypes = [ + CONTAINS_TYPE, + MANAGES_TYPE +]; + +export enum RelationTypeGroup { + COMMON = 'COMMON', + ALARM = 'ALARM', + DASHBOARD = 'DASHBOARD', + RULE_CHAIN = 'RULE_CHAIN', + RULE_NODE = 'RULE_NODE', +} + +export enum EntitySearchDirection { + FROM = 'FROM', + TO = 'TO' +} + +export const entitySearchDirectionTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'relation.search-direction.FROM'], + [EntitySearchDirection.TO, 'relation.search-direction.TO'], + ] +); + +export const directionTypeTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'relation.direction-type.FROM'], + [EntitySearchDirection.TO, 'relation.direction-type.TO'], + ] +); + +export interface RelationEntityTypeFilter { + relationType: string; + entityTypes: Array; +} + +export interface RelationsSearchParameters { + rootId: string; + rootType: EntityType; + direction: EntitySearchDirection; + relationTypeGroup?: RelationTypeGroup; + maxLevel?: number; + fetchLastLevelOnly?: boolean; +} + +export interface EntityRelationsQuery { + parameters: RelationsSearchParameters; + filters: Array; +} + +export interface EntitySearchQuery { + parameters: RelationsSearchParameters; + relationType: string; +} + +export interface EntityRelation { + from: EntityId; + to: EntityId; + type: string; + typeGroup: RelationTypeGroup; + additionalInfo?: any; +} + +export interface EntityRelationInfo extends EntityRelation { + fromName: string; + toEntityTypeName?: string; + toName: string; + fromEntityTypeName?: string; + entityURL?: string; +} diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts new file mode 100644 index 0000000..871bf14 --- /dev/null +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -0,0 +1,66 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { TbResourceId } from '@shared/models/id/tb-resource-id'; + +export enum ResourceType { + LWM2M_MODEL = 'LWM2M_MODEL', + PKCS_12 = 'PKCS_12', + JKS = 'JKS' +} + +export const ResourceTypeMIMETypes = new Map( + [ + [ResourceType.LWM2M_MODEL, 'application/xml,text/xml'], + [ResourceType.PKCS_12, 'application/x-pkcs12'], + [ResourceType.JKS, 'application/x-java-keystore'] + ] +); + +export const ResourceTypeExtension = new Map( + [ + [ResourceType.LWM2M_MODEL, 'xml'], + [ResourceType.PKCS_12, 'p12,pfx'], + [ResourceType.JKS, 'jks'] + ] +); + +export const ResourceTypeTranslationMap = new Map( + [ + [ResourceType.LWM2M_MODEL, 'LWM2M model'], + [ResourceType.PKCS_12, 'PKCS #12'], + [ResourceType.JKS, 'JKS'] + ] +); + +export interface ResourceInfo extends BaseData { + tenantId?: TenantId; + resourceKey?: string; + title?: string; + resourceType: ResourceType; +} + +export interface Resource extends ResourceInfo { + data: string; + fileName: string; +} + +export interface Resources extends ResourceInfo { + data: Array; + fileName: Array; +} diff --git a/ui-ngx/src/app/shared/models/rpc.models.ts b/ui-ngx/src/app/shared/models/rpc.models.ts new file mode 100644 index 0000000..cbdda59 --- /dev/null +++ b/ui-ngx/src/app/shared/models/rpc.models.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TenantId } from '@shared/models/id/tenant-id'; +import { RpcId } from '@shared/models/id/rpc-id'; +import { DeviceId } from '@shared/models/id/device-id'; +import { TableCellButtonActionDescriptor } from '@home/components/widget/lib/table-widget.models'; + +export enum RpcStatus { + QUEUED = 'QUEUED', + DELIVERED = 'DELIVERED', + SUCCESSFUL = 'SUCCESSFUL', + TIMEOUT = 'TIMEOUT', + FAILED = 'FAILED', + SENT = 'SENT', + EXPIRED = 'EXPIRED' +} + +export const rpcStatusColors = new Map( + [ + [RpcStatus.QUEUED, 'black'], + [RpcStatus.DELIVERED, 'green'], + [RpcStatus.SUCCESSFUL, 'green'], + [RpcStatus.TIMEOUT, 'orange'], + [RpcStatus.FAILED, 'red'], + [RpcStatus.SENT, 'green'], + [RpcStatus.EXPIRED, 'red'] + ] +); + +export const rpcStatusTranslation = new Map( + [ + [RpcStatus.QUEUED, 'widgets.persistent-table.rpc-status.QUEUED'], + [RpcStatus.DELIVERED, 'widgets.persistent-table.rpc-status.DELIVERED'], + [RpcStatus.SUCCESSFUL, 'widgets.persistent-table.rpc-status.SUCCESSFUL'], + [RpcStatus.TIMEOUT, 'widgets.persistent-table.rpc-status.TIMEOUT'], + [RpcStatus.FAILED, 'widgets.persistent-table.rpc-status.FAILED'], + [RpcStatus.SENT, 'widgets.persistent-table.rpc-status.SENT'], + [RpcStatus.EXPIRED, 'widgets.persistent-table.rpc-status.EXPIRED'] + ] +); + +export interface PersistentRpc { + id: RpcId; + createdTime: number; + expirationTime: number; + status: RpcStatus; + response: any; + request: { + id: string; + oneway: boolean; + body: { + method: string; + params: string; + }; + retries: null | number; + }; + deviceId: DeviceId; + tenantId: TenantId; + additionalInfo?: string; +} + +export interface PersistentRpcData extends PersistentRpc { + actionCellButtons?: TableCellButtonActionDescriptor[]; + hasActions?: boolean; +} + +export interface RequestData { + method?: string; + oneWayElseTwoWay?: boolean; + persistentPollingInterval?: number; + retries?: number; + params?: object; + additionalInfo?: object; +} diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts new file mode 100644 index 0000000..20086ca --- /dev/null +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -0,0 +1,90 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; +import { RuleNodeId } from '@shared/models/id/rule-node-id'; +import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; +import { ComponentType } from '@shared/models/component-descriptor.models'; + +export interface RuleChain extends BaseData, ExportableEntity { + tenantId: TenantId; + name: string; + firstRuleNodeId: RuleNodeId; + root: boolean; + debugMode: boolean; + type: string; + configuration?: any; + additionalInfo?: any; + isDefault?: boolean; +} + +export interface RuleChainMetaData { + ruleChainId: RuleChainId; + firstNodeIndex?: number; + nodes: Array; + connections: Array; +} + +export interface RuleChainImport { + ruleChain: RuleChain; + metadata: RuleChainMetaData; +} + +export interface NodeConnectionInfo { + fromIndex: number; + toIndex: number; + type: string; +} + +export const ruleNodeTypeComponentTypes: ComponentType[] = + [ + ComponentType.FILTER, + ComponentType.ENRICHMENT, + ComponentType.TRANSFORMATION, + ComponentType.ACTION, + ComponentType.EXTERNAL, + ComponentType.FLOW + ]; + +export const unknownNodeComponent: RuleNodeComponentDescriptor = { + type: RuleNodeType.UNKNOWN, + name: 'unknown', + clazz: 'tb.internal.Unknown', + configurationDescriptor: { + nodeDefinition: { + description: '', + details: '', + inEnabled: true, + outEnabled: true, + relationTypes: [], + customRelations: false, + defaultConfiguration: {} + } + } +}; + +export const inputNodeComponent: RuleNodeComponentDescriptor = { + type: RuleNodeType.INPUT, + name: 'Input', + clazz: 'tb.internal.Input' +}; + +export enum RuleChainType { + CORE = 'CORE', + EDGE = 'EDGE' +} diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts new file mode 100644 index 0000000..f6d8ca5 --- /dev/null +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -0,0 +1,488 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; +import { RuleNodeId } from '@shared/models/id/rule-node-id'; +import { ComponentDescriptor } from '@shared/models/component-descriptor.models'; +import { FcEdge, FcNode } from 'ngx-flowchart'; +import { Observable } from 'rxjs'; +import { PageComponent } from '@shared/components/page.component'; +import { AfterViewInit, EventEmitter, Inject, OnInit, Directive } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, FormGroup } from '@angular/forms'; +import { RuleChainType } from '@shared/models/rule-chain.models'; + +export interface RuleNodeConfiguration { + [key: string]: any; +} + +export interface RuleNode extends BaseData { + ruleChainId?: RuleChainId; + type: string; + name: string; + debugMode: boolean; + configuration: RuleNodeConfiguration; + additionalInfo?: any; +} + +export interface LinkLabel { + name: string; + value: string; +} + +export interface RuleNodeDefinition { + description: string; + details: string; + inEnabled: boolean; + outEnabled: boolean; + relationTypes: string[]; + customRelations: boolean; + ruleChainNode?: boolean; + defaultConfiguration: RuleNodeConfiguration; + icon?: string; + iconUrl?: string; + docUrl?: string; + uiResources?: string[]; + uiResourceLoadError?: string; + configDirective?: string; +} + +export interface RuleNodeConfigurationDescriptor { + nodeDefinition: RuleNodeDefinition; +} + +export interface IRuleNodeConfigurationComponent { + ruleNodeId: string; + ruleChainId: string; + ruleChainType: RuleChainType; + configuration: RuleNodeConfiguration; + configurationChanged: Observable; + validate(); + [key: string]: any; +} + +@Directive() +// tslint:disable-next-line:directive-class-suffix +export abstract class RuleNodeConfigurationComponent extends PageComponent implements + IRuleNodeConfigurationComponent, OnInit, AfterViewInit { + + ruleNodeId: string; + + ruleChainId: string; + + ruleChainType: RuleChainType; + + configurationValue: RuleNodeConfiguration; + + private configurationSet = false; + + set configuration(value: RuleNodeConfiguration) { + this.configurationValue = value; + if (!this.configurationSet) { + this.configurationSet = true; + this.setupConfiguration(value); + } else { + this.updateConfiguration(value); + } + } + + get configuration(): RuleNodeConfiguration { + return this.configurationValue; + } + + configurationChangedEmiter = new EventEmitter(); + configurationChanged = this.configurationChangedEmiter.asObservable(); + + protected constructor(@Inject(Store) protected store: Store) { + super(store); + } + + ngOnInit() {} + + ngAfterViewInit(): void { + setTimeout(() => { + if (!this.validateConfig()) { + this.configurationChangedEmiter.emit(null); + } + }, 0); + } + + validate() { + this.onValidate(); + } + + protected setupConfiguration(configuration: RuleNodeConfiguration) { + this.onConfigurationSet(this.prepareInputConfig(configuration)); + this.updateValidators(false); + for (const trigger of this.validatorTriggers()) { + const path = trigger.split('.'); + let control: AbstractControl = this.configForm(); + for (const part of path) { + control = control.get(part); + } + control.valueChanges.subscribe(() => { + this.updateValidators(true, trigger); + }); + } + this.configForm().valueChanges.subscribe((updated: RuleNodeConfiguration) => { + this.onConfigurationChanged(updated); + }); + } + + protected updateConfiguration(configuration: RuleNodeConfiguration) { + this.configForm().reset(this.prepareInputConfig(configuration), {emitEvent: false}); + this.updateValidators(false); + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + } + + protected validatorTriggers(): string[] { + return []; + } + + protected onConfigurationChanged(updated: RuleNodeConfiguration) { + this.configurationValue = updated; + if (this.validateConfig()) { + this.configurationChangedEmiter.emit(this.prepareOutputConfig(updated)); + } else { + this.configurationChangedEmiter.emit(null); + } + } + + protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + return configuration; + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + return configuration; + } + + protected validateConfig(): boolean { + return this.configForm().valid; + } + + protected onValidate() {} + + protected abstract configForm(): FormGroup; + + protected abstract onConfigurationSet(configuration: RuleNodeConfiguration); + +} + + +export enum RuleNodeType { + FILTER = 'FILTER', + ENRICHMENT = 'ENRICHMENT', + TRANSFORMATION = 'TRANSFORMATION', + ACTION = 'ACTION', + EXTERNAL = 'EXTERNAL', + FLOW = 'FLOW', + UNKNOWN = 'UNKNOWN', + INPUT = 'INPUT' +} + +export const ruleNodeTypesLibrary = [ + RuleNodeType.FILTER, + RuleNodeType.ENRICHMENT, + RuleNodeType.TRANSFORMATION, + RuleNodeType.ACTION, + RuleNodeType.EXTERNAL, + RuleNodeType.FLOW, +]; + +export interface RuleNodeTypeDescriptor { + value: RuleNodeType; + name: string; + details: string; + nodeClass: string; + icon: string; + special?: boolean; +} + +export const ruleNodeTypeDescriptors = new Map( + [ + [ + RuleNodeType.FILTER, + { + value: RuleNodeType.FILTER, + name: 'rulenode.type-filter', + details: 'rulenode.type-filter-details', + nodeClass: 'tb-filter-type', + icon: 'filter_list' + } + ], + [ + RuleNodeType.ENRICHMENT, + { + value: RuleNodeType.ENRICHMENT, + name: 'rulenode.type-enrichment', + details: 'rulenode.type-enrichment-details', + nodeClass: 'tb-enrichment-type', + icon: 'playlist_add' + } + ], + [ + RuleNodeType.TRANSFORMATION, + { + value: RuleNodeType.TRANSFORMATION, + name: 'rulenode.type-transformation', + details: 'rulenode.type-transformation-details', + nodeClass: 'tb-transformation-type', + icon: 'transform' + } + ], + [ + RuleNodeType.ACTION, + { + value: RuleNodeType.ACTION, + name: 'rulenode.type-action', + details: 'rulenode.type-action-details', + nodeClass: 'tb-action-type', + icon: 'flash_on' + } + ], + [ + RuleNodeType.EXTERNAL, + { + value: RuleNodeType.EXTERNAL, + name: 'rulenode.type-external', + details: 'rulenode.type-external-details', + nodeClass: 'tb-external-type', + icon: 'cloud_upload' + } + ], + [ + RuleNodeType.FLOW, + { + value: RuleNodeType.FLOW, + name: 'rulenode.type-flow', + details: 'rulenode.type-flow-details', + nodeClass: 'tb-flow-type', + icon: 'settings_ethernet' + } + ], + [ + RuleNodeType.INPUT, + { + value: RuleNodeType.INPUT, + name: 'rulenode.type-input', + details: 'rulenode.type-input-details', + nodeClass: 'tb-input-type', + icon: 'input', + special: true + } + ], + [ + RuleNodeType.UNKNOWN, + { + value: RuleNodeType.UNKNOWN, + name: 'rulenode.type-unknown', + details: 'rulenode.type-unknown-details', + nodeClass: 'tb-unknown-type', + icon: 'help_outline' + } + ] + ] +); + +export interface RuleNodeComponentDescriptor extends ComponentDescriptor { + type: RuleNodeType; + configurationDescriptor?: RuleNodeConfigurationDescriptor; +} + +export interface FcRuleNodeType extends FcNode { + component?: RuleNodeComponentDescriptor; + nodeClass?: string; + icon?: string; + iconUrl?: string; +} + +export interface FcRuleNode extends FcRuleNodeType { + ruleNodeId?: RuleNodeId; + additionalInfo?: any; + configuration?: RuleNodeConfiguration; + debugMode?: boolean; + error?: string; + highlighted?: boolean; + componentClazz?: string; + ruleChainType?: RuleChainType; +} + +export interface FcRuleEdge extends FcEdge { + labels?: string[]; +} + +export enum ScriptLanguage { + JS = 'JS', + TBEL = 'TBEL' +} + +export interface TestScriptInputParams { + script: string; + scriptType: string; + argNames: string[]; + msg: string; + metadata: {[key: string]: string}; + msgType: string; +} + +export interface TestScriptResult { + output: string; + error: string; +} + +export enum MessageType { + POST_ATTRIBUTES_REQUEST = 'POST_ATTRIBUTES_REQUEST', + POST_TELEMETRY_REQUEST = 'POST_TELEMETRY_REQUEST', + TO_SERVER_RPC_REQUEST = 'TO_SERVER_RPC_REQUEST', + RPC_CALL_FROM_SERVER_TO_DEVICE = 'RPC_CALL_FROM_SERVER_TO_DEVICE', + RPC_QUEUED = 'RPC_QUEUED', + RPC_SENT = 'RPC_SENT', + RPC_DELIVERED = 'RPC_DELIVERED', + RPC_SUCCESSFUL = 'RPC_SUCCESSFUL', + RPC_TIMEOUT = 'RPC_TIMEOUT', + RPC_EXPIRED = 'RPC_EXPIRED', + RPC_FAILED = 'RPC_FAILED', + RPC_DELETED = 'RPC_DELETED', + ACTIVITY_EVENT = 'ACTIVITY_EVENT', + INACTIVITY_EVENT = 'INACTIVITY_EVENT', + CONNECT_EVENT = 'CONNECT_EVENT', + DISCONNECT_EVENT = 'DISCONNECT_EVENT', + ENTITY_CREATED = 'ENTITY_CREATED', + ENTITY_UPDATED = 'ENTITY_UPDATED', + ENTITY_DELETED = 'ENTITY_DELETED', + ENTITY_ASSIGNED = 'ENTITY_ASSIGNED', + ENTITY_UNASSIGNED = 'ENTITY_UNASSIGNED', + ATTRIBUTES_UPDATED = 'ATTRIBUTES_UPDATED', + ATTRIBUTES_DELETED = 'ATTRIBUTES_DELETED', + ALARM_ACKNOWLEDGED = 'ALARM_ACKNOWLEDGED', + ALARM_CLEARED = 'ALARM_CLEARED', + ENTITY_ASSIGNED_FROM_TENANT = 'ENTITY_ASSIGNED_FROM_TENANT', + ENTITY_ASSIGNED_TO_TENANT = 'ENTITY_ASSIGNED_TO_TENANT', + TIMESERIES_UPDATED = 'TIMESERIES_UPDATED', + TIMESERIES_DELETED = 'TIMESERIES_DELETED' +} + +export const messageTypeNames = new Map( + [ + [MessageType.POST_ATTRIBUTES_REQUEST, 'Post attributes'], + [MessageType.POST_TELEMETRY_REQUEST, 'Post telemetry'], + [MessageType.TO_SERVER_RPC_REQUEST, 'RPC Request from Device'], + [MessageType.RPC_CALL_FROM_SERVER_TO_DEVICE, 'RPC Request to Device'], + [MessageType.RPC_QUEUED, 'RPC Queued'], + [MessageType.RPC_SENT, 'RPC Sent'], + [MessageType.RPC_DELIVERED, 'RPC Delivered'], + [MessageType.RPC_SUCCESSFUL, 'RPC Successful'], + [MessageType.RPC_TIMEOUT, 'RPC Timeout'], + [MessageType.RPC_EXPIRED, 'RPC Expired'], + [MessageType.RPC_FAILED, 'RPC Failed'], + [MessageType.RPC_DELETED, 'RPC Deleted'], + [MessageType.ACTIVITY_EVENT, 'Activity Event'], + [MessageType.INACTIVITY_EVENT, 'Inactivity Event'], + [MessageType.CONNECT_EVENT, 'Connect Event'], + [MessageType.DISCONNECT_EVENT, 'Disconnect Event'], + [MessageType.ENTITY_CREATED, 'Entity Created'], + [MessageType.ENTITY_UPDATED, 'Entity Updated'], + [MessageType.ENTITY_DELETED, 'Entity Deleted'], + [MessageType.ENTITY_ASSIGNED, 'Entity Assigned'], + [MessageType.ENTITY_UNASSIGNED, 'Entity Unassigned'], + [MessageType.ATTRIBUTES_UPDATED, 'Attributes Updated'], + [MessageType.ATTRIBUTES_DELETED, 'Attributes Deleted'], + [MessageType.ALARM_ACKNOWLEDGED, 'Alarm Acknowledged'], + [MessageType.ALARM_CLEARED, 'Alarm Cleared'], + [MessageType.ENTITY_ASSIGNED_FROM_TENANT, 'Entity Assigned From Tenant'], + [MessageType.ENTITY_ASSIGNED_TO_TENANT, 'Entity Assigned To Tenant'], + [MessageType.TIMESERIES_UPDATED, 'Timeseries Updated'], + [MessageType.TIMESERIES_DELETED, 'Timeseries Deleted'] + ] +); + +export const ruleChainNodeClazz = 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode'; +export const outputNodeClazz = 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode'; + +const ruleNodeClazzHelpLinkMap = { + 'org.thingsboard.rule.engine.filter.TbCheckRelationNode': 'ruleNodeCheckRelation', + 'org.thingsboard.rule.engine.filter.TbCheckMessageNode': 'ruleNodeCheckExistenceFields', + 'org.thingsboard.rule.engine.geo.TbGpsGeofencingFilterNode': 'ruleNodeGpsGeofencingFilter', + 'org.thingsboard.rule.engine.filter.TbJsFilterNode': 'ruleNodeJsFilter', + 'org.thingsboard.rule.engine.filter.TbJsSwitchNode': 'ruleNodeJsSwitch', + 'org.thingsboard.rule.engine.filter.TbAssetTypeSwitchNode': 'ruleNodeAssetProfileSwitch', + 'org.thingsboard.rule.engine.filter.TbDeviceTypeSwitchNode': 'ruleNodeDeviceProfileSwitch', + 'org.thingsboard.rule.engine.filter.TbCheckAlarmStatusNode': 'ruleNodeCheckAlarmStatus', + 'org.thingsboard.rule.engine.filter.TbMsgTypeFilterNode': 'ruleNodeMessageTypeFilter', + 'org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode': 'ruleNodeMessageTypeSwitch', + 'org.thingsboard.rule.engine.filter.TbOriginatorTypeFilterNode': 'ruleNodeOriginatorTypeFilter', + 'org.thingsboard.rule.engine.filter.TbOriginatorTypeSwitchNode': 'ruleNodeOriginatorTypeSwitch', + 'org.thingsboard.rule.engine.metadata.TbGetAttributesNode': 'ruleNodeOriginatorAttributes', + 'org.thingsboard.rule.engine.metadata.TbGetOriginatorFieldsNode': 'ruleNodeOriginatorFields', + 'org.thingsboard.rule.engine.metadata.TbGetTelemetryNode': 'ruleNodeOriginatorTelemetry', + 'org.thingsboard.rule.engine.metadata.TbGetCustomerAttributeNode': 'ruleNodeCustomerAttributes', + 'org.thingsboard.rule.engine.metadata.TbGetCustomerDetailsNode': 'ruleNodeCustomerDetails', + 'org.thingsboard.rule.engine.metadata.TbGetDeviceAttrNode': 'ruleNodeDeviceAttributes', + 'org.thingsboard.rule.engine.metadata.TbGetRelatedAttributeNode': 'ruleNodeRelatedAttributes', + 'org.thingsboard.rule.engine.metadata.TbGetTenantAttributeNode': 'ruleNodeTenantAttributes', + 'org.thingsboard.rule.engine.metadata.TbGetTenantDetailsNode': 'ruleNodeTenantDetails', + 'org.thingsboard.rule.engine.transform.TbChangeOriginatorNode': 'ruleNodeChangeOriginator', + 'org.thingsboard.rule.engine.transform.TbTransformMsgNode': 'ruleNodeTransformMsg', + 'org.thingsboard.rule.engine.mail.TbMsgToEmailNode': 'ruleNodeMsgToEmail', + 'org.thingsboard.rule.engine.action.TbAssignToCustomerNode': 'ruleNodeAssignToCustomer', + 'org.thingsboard.rule.engine.action.TbUnassignFromCustomerNode': 'ruleNodeUnassignFromCustomer', + 'org.thingsboard.rule.engine.action.TbClearAlarmNode': 'ruleNodeClearAlarm', + 'org.thingsboard.rule.engine.action.TbCreateAlarmNode': 'ruleNodeCreateAlarm', + 'org.thingsboard.rule.engine.action.TbCreateRelationNode': 'ruleNodeCreateRelation', + 'org.thingsboard.rule.engine.action.TbDeleteRelationNode': 'ruleNodeDeleteRelation', + 'org.thingsboard.rule.engine.delay.TbMsgDelayNode': 'ruleNodeMsgDelay', + 'org.thingsboard.rule.engine.debug.TbMsgGeneratorNode': 'ruleNodeMsgGenerator', + 'org.thingsboard.rule.engine.geo.TbGpsGeofencingActionNode': 'ruleNodeGpsGeofencingEvents', + 'org.thingsboard.rule.engine.action.TbLogNode': 'ruleNodeLog', + 'org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode': 'ruleNodeRpcCallReply', + 'org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode': 'ruleNodeRpcCallRequest', + 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode': 'ruleNodeSaveAttributes', + 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode': 'ruleNodeSaveTimeseries', + 'org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode': 'ruleNodeSaveToCustomTable', + 'org.thingsboard.rule.engine.aws.sns.TbSnsNode': 'ruleNodeAwsSns', + 'org.thingsboard.rule.engine.aws.sqs.TbSqsNode': 'ruleNodeAwsSqs', + 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', + 'org.thingsboard.rule.engine.mqtt.TbMqttNode': 'ruleNodeMqtt', + 'org.thingsboard.rule.engine.mqtt.azure.TbAzureIotHubNode': 'ruleNodeAzureIotHub', + 'org.thingsboard.rule.engine.rabbitmq.TbRabbitMqNode': 'ruleNodeRabbitMq', + 'org.thingsboard.rule.engine.rest.TbRestApiCallNode': 'ruleNodeRestApiCall', + 'org.thingsboard.rule.engine.mail.TbSendEmailNode': 'ruleNodeSendEmail', + 'org.thingsboard.rule.engine.sms.TbSendSmsNode': 'ruleNodeSendSms', + 'org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode': 'ruleNodePushToCloud', + 'org.thingsboard.rule.engine.edge.TbMsgPushToEdgeNode': 'ruleNodePushToEdge', + 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode': 'ruleNodeRuleChain', + 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode': 'ruleNodeOutputNode', + 'org.thingsboard.rule.engine.math.TbMathNode': 'ruleNodeMath', +}; + +export function getRuleNodeHelpLink(component: RuleNodeComponentDescriptor): string { + if (component) { + if (component.configurationDescriptor && + component.configurationDescriptor.nodeDefinition && + component.configurationDescriptor.nodeDefinition.docUrl) { + return component.configurationDescriptor.nodeDefinition.docUrl; + } else if (component.clazz) { + if (ruleNodeClazzHelpLinkMap[component.clazz]) { + return ruleNodeClazzHelpLinkMap[component.clazz]; + } + } + } + return 'ruleEngine'; +} diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts new file mode 100644 index 0000000..a673e0c --- /dev/null +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -0,0 +1,439 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ValidatorFn } from '@angular/forms'; +import { isNotEmptyStr, isNumber } from '@core/utils'; +import { VersionCreateConfig } from '@shared/models/vc.models'; + +export const smtpPortPattern: RegExp = /^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; + +export interface AdminSettings { + key: string; + jsonValue: T; +} + +export declare type SmtpProtocol = 'smtp' | 'smtps'; + +export interface MailServerSettings { + showChangePassword: boolean; + mailFrom: string; + smtpProtocol: SmtpProtocol; + smtpHost: string; + smtpPort: number; + timeout: number; + enableTls: boolean; + username: string; + changePassword?: boolean; + password?: string; + enableProxy: boolean; + proxyHost: string; + proxyPort: number; + proxyUser: string; + proxyPassword: string; +} + +export interface GeneralSettings { + baseUrl: string; +} + +export interface UserPasswordPolicy { + minimumLength: number; + minimumUppercaseLetters: number; + minimumLowercaseLetters: number; + minimumDigits: number; + minimumSpecialCharacters: number; + passwordExpirationPeriodDays: number; + allowWhitespaces: boolean; +} + +export interface SecuritySettings { + passwordPolicy: UserPasswordPolicy; +} + +export interface JwtSettings { + tokenIssuer: string; + tokenSigningKey: string; + tokenExpirationTime: number; + refreshTokenExpTime: number; +} + +export interface UpdateMessage { + message: string; + updateAvailable: boolean; +} + +export const phoneNumberPattern = /^\+[1-9]\d{1,14}$/; +export const phoneNumberPatternTwilio = /^\+[1-9]\d{1,14}$|^(MG|PN).*$/; + +export enum SmsProviderType { + AWS_SNS = 'AWS_SNS', + TWILIO = 'TWILIO', + SMPP = 'SMPP' +} + +export const smsProviderTypeTranslationMap = new Map( + [ + [SmsProviderType.AWS_SNS, 'admin.sms-provider-type-aws-sns'], + [SmsProviderType.TWILIO, 'admin.sms-provider-type-twilio'], + [SmsProviderType.SMPP, 'admin.sms-provider-type-smpp'] + ] +); + +export interface AwsSnsSmsProviderConfiguration { + accessKeyId?: string; + secretAccessKey?: string; + region?: string; +} + +export interface TwilioSmsProviderConfiguration { + accountSid?: string; + accountToken?: string; + numberFrom?: string; +} + +export interface SmppSmsProviderConfiguration { + protocolVersion: number; + host: string; + port: number; + systemId: string; + password: string; + systemType?: string; + bindType?: string; + serviceType?: string; + sourceAddress?: string; + sourceTon?: number; + sourceNpi?: number; + destinationTon?: number; + destinationNpi?: number; + addressRange?: string; + codingScheme?: number; +} + +export const smppVersions = [ + {value: 3.3}, + {value: 3.4} +]; + +export enum BindTypes { + TX = 'TX', + RX = 'RX', + TRX = 'TRX' +} + +export const bindTypesTranslationMap = new Map([ + [BindTypes.TX, 'admin.smpp-provider.bind-type-tx'], + [BindTypes.RX, 'admin.smpp-provider.bind-type-rx'], + [BindTypes.TRX, 'admin.smpp-provider.bind-type-trx'] +]); + +export enum TypeOfNumber { + Unknown = 'Unknown', + International = 'International', + National = 'National', + NetworkSpecific = 'NetworkSpecific', + SubscriberNumber = 'SubscriberNumber', + Alphanumeric = 'Alphanumeric', + Abbreviated = 'Abbreviated' +} + +export interface TypeDescriptor { + name: string; + value: number; +} + +export const typeOfNumberMap = new Map([ + [TypeOfNumber.Unknown, { + name: 'admin.smpp-provider.ton-unknown', + value: 0 + }], + [TypeOfNumber.International, { + name: 'admin.smpp-provider.ton-international', + value: 1 + }], + [TypeOfNumber.National, { + name: 'admin.smpp-provider.ton-national', + value: 2 + }], + [TypeOfNumber.NetworkSpecific, { + name: 'admin.smpp-provider.ton-network-specific', + value: 3 + }], + [TypeOfNumber.SubscriberNumber, { + name: 'admin.smpp-provider.ton-subscriber-number', + value: 4 + }], + [TypeOfNumber.Alphanumeric, { + name: 'admin.smpp-provider.ton-alphanumeric', + value: 5 + }], + [TypeOfNumber.Abbreviated, { + name: 'admin.smpp-provider.ton-abbreviated', + value: 6 + }], +]); + +export enum NumberingPlanIdentification { + Unknown = 'Unknown', + ISDN = 'ISDN', + DataNumberingPlan = 'DataNumberingPlan', + TelexNumberingPlan = 'TelexNumberingPlan', + LandMobile = 'LandMobile', + NationalNumberingPlan = 'NationalNumberingPlan', + PrivateNumberingPlan = 'PrivateNumberingPlan', + ERMESNumberingPlan = 'ERMESNumberingPlan', + Internet = 'Internet', + WAPClientId = 'WAPClientId', +} + +export const numberingPlanIdentificationMap = new Map([ + [NumberingPlanIdentification.Unknown, { + name: 'admin.smpp-provider.npi-unknown', + value: 0 + }], + [NumberingPlanIdentification.ISDN, { + name: 'admin.smpp-provider.npi-isdn', + value: 1 + }], + [NumberingPlanIdentification.DataNumberingPlan, { + name: 'admin.smpp-provider.npi-data-numbering-plan', + value: 3 + }], + [NumberingPlanIdentification.TelexNumberingPlan, { + name: 'admin.smpp-provider.npi-telex-numbering-plan', + value: 4 + }], + [NumberingPlanIdentification.LandMobile, { + name: 'admin.smpp-provider.npi-land-mobile', + value: 5 + }], + [NumberingPlanIdentification.NationalNumberingPlan, { + name: 'admin.smpp-provider.npi-national-numbering-plan', + value: 8 + }], + [NumberingPlanIdentification.PrivateNumberingPlan, { + name: 'admin.smpp-provider.npi-private-numbering-plan', + value: 9 + }], + [NumberingPlanIdentification.ERMESNumberingPlan, { + name: 'admin.smpp-provider.npi-ermes-numbering-plan', + value: 10 + }], + [NumberingPlanIdentification.Internet, { + name: 'admin.smpp-provider.npi-internet', + value: 13 + }], + [NumberingPlanIdentification.WAPClientId, { + name: 'admin.smpp-provider.npi-wap-client-id', + value: 18 + }], +]); + +export enum CodingSchemes { + SMSC = 'SMSC', + IA5 = 'IA5', + OctetUnspecified2 = 'OctetUnspecified2', + Latin1 = 'Latin1', + OctetUnspecified4 = 'OctetUnspecified4', + JIS = 'JIS', + Cyrillic = 'Cyrillic', + LatinHebrew = 'LatinHebrew', + UCS2UTF16 = 'UCS2UTF16', + PictogramEncoding = 'PictogramEncoding', + MusicCodes = 'MusicCodes', + ExtendedKanjiJIS = 'ExtendedKanjiJIS', + KoreanGraphicCharacterSet = 'KoreanGraphicCharacterSet', +} + +export const codingSchemesMap = new Map([ + [CodingSchemes.SMSC, { + name: 'admin.smpp-provider.scheme-smsc', + value: 0 + }], + [CodingSchemes.IA5, { + name: 'admin.smpp-provider.scheme-ia5', + value: 1 + }], + [CodingSchemes.OctetUnspecified2, { + name: 'admin.smpp-provider.scheme-octet-unspecified-2', + value: 2 + }], + [CodingSchemes.Latin1, { + name: 'admin.smpp-provider.scheme-latin-1', + value: 3 + }], + [CodingSchemes.OctetUnspecified4, { + name: 'admin.smpp-provider.scheme-octet-unspecified-4', + value: 4 + }], + [CodingSchemes.JIS, { + name: 'admin.smpp-provider.scheme-jis', + value: 5 + }], + [CodingSchemes.Cyrillic, { + name: 'admin.smpp-provider.scheme-cyrillic', + value: 6 + }], + [CodingSchemes.LatinHebrew, { + name: 'admin.smpp-provider.scheme-latin-hebrew', + value: 7 + }], + [CodingSchemes.UCS2UTF16, { + name: 'admin.smpp-provider.scheme-ucs-utf', + value: 8 + }], + [CodingSchemes.PictogramEncoding, { + name: 'admin.smpp-provider.scheme-pictogram-encoding', + value: 9 + }], + [CodingSchemes.MusicCodes, { + name: 'admin.smpp-provider.scheme-music-codes', + value: 10 + }], + [CodingSchemes.ExtendedKanjiJIS, { + name: 'admin.smpp-provider.scheme-extended-kanji-jis', + value: 13 + }], + [CodingSchemes.KoreanGraphicCharacterSet, { + name: 'admin.smpp-provider.scheme-korean-graphic-character-set', + value: 14 + }], +]); + +export type SmsProviderConfigurations = + Partial & AwsSnsSmsProviderConfiguration & TwilioSmsProviderConfiguration; + +export interface SmsProviderConfiguration extends SmsProviderConfigurations { + type: SmsProviderType; +} + +export function smsProviderConfigurationValidator(required: boolean): ValidatorFn { + return control => { + const configuration: SmsProviderConfiguration = control.value; + let errors = null; + if (required) { + let valid = false; + if (configuration && configuration.type) { + switch (configuration.type) { + case SmsProviderType.AWS_SNS: + const awsSnsConfiguration: AwsSnsSmsProviderConfiguration = configuration; + valid = isNotEmptyStr(awsSnsConfiguration.accessKeyId) && isNotEmptyStr(awsSnsConfiguration.secretAccessKey) + && isNotEmptyStr(awsSnsConfiguration.region); + break; + case SmsProviderType.TWILIO: + const twilioConfiguration: TwilioSmsProviderConfiguration = configuration; + valid = isNotEmptyStr(twilioConfiguration.numberFrom) && isNotEmptyStr(twilioConfiguration.accountSid) + && isNotEmptyStr(twilioConfiguration.accountToken); + break; + case SmsProviderType.SMPP: + const smppConfiguration = configuration as SmppSmsProviderConfiguration; + valid = isNotEmptyStr(smppConfiguration.host) && isNumber(smppConfiguration.port) + && isNotEmptyStr(smppConfiguration.systemId) && isNotEmptyStr(smppConfiguration.password); + break; + } + } + if (!valid) { + errors = { + invalid: true + }; + } + } + return errors; + }; +} + +export interface TestSmsRequest { + providerConfiguration: SmsProviderConfiguration; + numberTo: string; + message: string; +} + +export function createSmsProviderConfiguration(type: SmsProviderType): SmsProviderConfiguration { + let smsProviderConfiguration: SmsProviderConfiguration; + if (type) { + switch (type) { + case SmsProviderType.AWS_SNS: + const awsSnsSmsProviderConfiguration: AwsSnsSmsProviderConfiguration = { + accessKeyId: '', + secretAccessKey: '', + region: 'us-east-1' + }; + smsProviderConfiguration = {...awsSnsSmsProviderConfiguration, type: SmsProviderType.AWS_SNS}; + break; + case SmsProviderType.TWILIO: + const twilioSmsProviderConfiguration: TwilioSmsProviderConfiguration = { + numberFrom: '', + accountSid: '', + accountToken: '' + }; + smsProviderConfiguration = {...twilioSmsProviderConfiguration, type: SmsProviderType.TWILIO}; + break; + case SmsProviderType.SMPP: + const smppSmsProviderConfiguration: SmppSmsProviderConfiguration = { + protocolVersion: 3.3, + host: '', + port: null, + systemId: '', + password: '', + systemType: '', + bindType: 'TX', + serviceType: '', + sourceAddress: '', + sourceTon: 5, + sourceNpi: 0, + destinationTon: 5, + destinationNpi: 0, + addressRange: '', + codingScheme: 0 + }; + smsProviderConfiguration = {...smppSmsProviderConfiguration, type: SmsProviderType.SMPP}; + break; + } + } + return smsProviderConfiguration; +} + +export enum RepositoryAuthMethod { + USERNAME_PASSWORD = 'USERNAME_PASSWORD', + PRIVATE_KEY = 'PRIVATE_KEY' +} + +export const repositoryAuthMethodTranslationMap = new Map([ + [RepositoryAuthMethod.USERNAME_PASSWORD, 'admin.auth-method-username-password'], + [RepositoryAuthMethod.PRIVATE_KEY, 'admin.auth-method-private-key'] +]); + +export interface RepositorySettings { + repositoryUri: string; + defaultBranch: string; + showMergeCommits: boolean; + authMethod: RepositoryAuthMethod; + username: string; + password: string; + privateKeyFileName: string; + privateKey: string; + privateKeyPassword: string; +} + +export interface RepositorySettingsInfo { + configured: boolean; + readOnly: boolean; +} + +export interface AutoVersionCreateConfig extends VersionCreateConfig { + branch: string; +} + +export type AutoCommitSettings = {[entityType: string]: AutoVersionCreateConfig}; diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts new file mode 100644 index 0000000..6e0f205 --- /dev/null +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -0,0 +1,718 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityType } from '@shared/models/entity-type.models'; +import { AggregationType } from '../time/time.models'; +import { Observable, ReplaySubject, Subject } from 'rxjs'; +import { EntityId } from '@shared/models/id/entity-id'; +import { map } from 'rxjs/operators'; +import { NgZone } from '@angular/core'; +import { + AlarmData, + AlarmDataQuery, EntityCountQuery, + EntityData, + EntityDataQuery, + EntityKey, + TsValue +} from '@shared/models/query/query.models'; +import { PageData } from '@shared/models/page/page-data'; +import { alarmFields } from '@shared/models/alarm.models'; +import { entityFields } from '@shared/models/entity.models'; +import { isUndefined } from '@core/utils'; + +export enum DataKeyType { + timeseries = 'timeseries', + attribute = 'attribute', + function = 'function', + alarm = 'alarm', + entityField = 'entityField', + count = 'count' +} + +export enum LatestTelemetry { + LATEST_TELEMETRY = 'LATEST_TELEMETRY' +} + +export enum AttributeScope { + CLIENT_SCOPE = 'CLIENT_SCOPE', + SERVER_SCOPE = 'SERVER_SCOPE', + SHARED_SCOPE = 'SHARED_SCOPE' +} + +export enum TelemetryFeature { + ATTRIBUTES = 'ATTRIBUTES', + TIMESERIES = 'TIMESERIES' +} + +export type TelemetryType = LatestTelemetry | AttributeScope; + +export function toTelemetryType(val: string): TelemetryType { + if (LatestTelemetry[val]) { + return LatestTelemetry[val]; + } else { + return AttributeScope[val]; + } +} + +export const telemetryTypeTranslations = new Map( + [ + [LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-latest-telemetry'], + [AttributeScope.CLIENT_SCOPE, 'attribute.scope-client'], + [AttributeScope.SERVER_SCOPE, 'attribute.scope-server'], + [AttributeScope.SHARED_SCOPE, 'attribute.scope-shared'] + ] +); + +export const isClientSideTelemetryType = new Map( + [ + [LatestTelemetry.LATEST_TELEMETRY, true], + [AttributeScope.CLIENT_SCOPE, true], + [AttributeScope.SERVER_SCOPE, false], + [AttributeScope.SHARED_SCOPE, false] + ] +); + +export interface AttributeData { + lastUpdateTs?: number; + key: string; + value: any; +} + +export interface TimeseriesData { + [key: string]: Array; +} + +export enum DataSortOrder { + ASC = 'ASC', + DESC = 'DESC' +} + +export interface WebsocketCmd { + cmdId: number; +} + +export interface TelemetryPluginCmd extends WebsocketCmd { + keys: string; +} + +export abstract class SubscriptionCmd implements TelemetryPluginCmd { + cmdId: number; + keys: string; + entityType: EntityType; + entityId: string; + scope?: AttributeScope; + unsubscribe: boolean; + abstract getType(): TelemetryFeature; +} + +export class AttributesSubscriptionCmd extends SubscriptionCmd { + getType() { + return TelemetryFeature.ATTRIBUTES; + } +} + +export class TimeseriesSubscriptionCmd extends SubscriptionCmd { + startTs: number; + timeWindow: number; + interval: number; + limit: number; + agg: AggregationType; + + getType() { + return TelemetryFeature.TIMESERIES; + } +} + +export class GetHistoryCmd implements TelemetryPluginCmd { + cmdId: number; + keys: string; + entityType: EntityType; + entityId: string; + startTs: number; + endTs: number; + interval: number; + limit: number; + agg: AggregationType; +} + +export interface EntityHistoryCmd { + keys: Array; + startTs: number; + endTs: number; + interval: number; + limit: number; + agg: AggregationType; + fetchLatestPreviousPoint?: boolean; +} + +export interface LatestValueCmd { + keys: Array; +} + +export interface TimeSeriesCmd { + keys: Array; + startTs: number; + timeWindow: number; + interval: number; + limit: number; + agg: AggregationType; + fetchLatestPreviousPoint?: boolean; +} + +export interface AggKey { + id: number; + key: string; + agg: AggregationType; + previousStartTs?: number; + previousEndTs?: number; + previousValueOnly?: boolean; +} + +export interface AggEntityHistoryCmd { + keys: Array; + startTs: number; + endTs: number; +} + +export interface AggTimeSeriesCmd { + keys: Array; + startTs: number; + timeWindow: number; +} + +export class EntityDataCmd implements WebsocketCmd { + cmdId: number; + query?: EntityDataQuery; + historyCmd?: EntityHistoryCmd; + latestCmd?: LatestValueCmd; + tsCmd?: TimeSeriesCmd; + aggHistoryCmd?: AggEntityHistoryCmd; + aggTsCmd?: AggTimeSeriesCmd; + + public isEmpty(): boolean { + return !this.query && !this.historyCmd && !this.latestCmd && !this.tsCmd && !this.aggTsCmd && !this.aggHistoryCmd; + } +} + +export class EntityCountCmd implements WebsocketCmd { + cmdId: number; + query?: EntityCountQuery; +} + +export class AlarmDataCmd implements WebsocketCmd { + cmdId: number; + query?: AlarmDataQuery; + + public isEmpty(): boolean { + return !this.query; + } +} + +export class EntityDataUnsubscribeCmd implements WebsocketCmd { + cmdId: number; +} + +export class EntityCountUnsubscribeCmd implements WebsocketCmd { + cmdId: number; +} + +export class AlarmDataUnsubscribeCmd implements WebsocketCmd { + cmdId: number; +} + +export class TelemetryPluginCmdsWrapper { + + constructor() { + this.attrSubCmds = []; + this.tsSubCmds = []; + this.historyCmds = []; + this.entityDataCmds = []; + this.entityDataUnsubscribeCmds = []; + this.alarmDataCmds = []; + this.alarmDataUnsubscribeCmds = []; + this.entityCountCmds = []; + this.entityCountUnsubscribeCmds = []; + } + attrSubCmds: Array; + tsSubCmds: Array; + historyCmds: Array; + entityDataCmds: Array; + entityDataUnsubscribeCmds: Array; + alarmDataCmds: Array; + alarmDataUnsubscribeCmds: Array; + entityCountCmds: Array; + entityCountUnsubscribeCmds: Array; + + private static popCmds(cmds: Array, leftCount: number): Array { + const toPublish = Math.min(cmds.length, leftCount); + if (toPublish > 0) { + return cmds.splice(0, toPublish); + } else { + return []; + } + } + + public hasCommands(): boolean { + return this.tsSubCmds.length > 0 || + this.historyCmds.length > 0 || + this.attrSubCmds.length > 0 || + this.entityDataCmds.length > 0 || + this.entityDataUnsubscribeCmds.length > 0 || + this.alarmDataCmds.length > 0 || + this.alarmDataUnsubscribeCmds.length > 0 || + this.entityCountCmds.length > 0 || + this.entityCountUnsubscribeCmds.length > 0; + } + + public clear() { + this.attrSubCmds.length = 0; + this.tsSubCmds.length = 0; + this.historyCmds.length = 0; + this.entityDataCmds.length = 0; + this.entityDataUnsubscribeCmds.length = 0; + this.alarmDataCmds.length = 0; + this.alarmDataUnsubscribeCmds.length = 0; + this.entityCountCmds.length = 0; + this.entityCountUnsubscribeCmds.length = 0; + } + + public preparePublishCommands(maxCommands: number): TelemetryPluginCmdsWrapper { + const preparedWrapper = new TelemetryPluginCmdsWrapper(); + let leftCount = maxCommands; + preparedWrapper.tsSubCmds = TelemetryPluginCmdsWrapper.popCmds(this.tsSubCmds, leftCount); + leftCount -= preparedWrapper.tsSubCmds.length; + preparedWrapper.historyCmds = TelemetryPluginCmdsWrapper.popCmds(this.historyCmds, leftCount); + leftCount -= preparedWrapper.historyCmds.length; + preparedWrapper.attrSubCmds = TelemetryPluginCmdsWrapper.popCmds(this.attrSubCmds, leftCount); + leftCount -= preparedWrapper.attrSubCmds.length; + preparedWrapper.entityDataCmds = TelemetryPluginCmdsWrapper.popCmds(this.entityDataCmds, leftCount); + leftCount -= preparedWrapper.entityDataCmds.length; + preparedWrapper.entityDataUnsubscribeCmds = TelemetryPluginCmdsWrapper.popCmds(this.entityDataUnsubscribeCmds, leftCount); + leftCount -= preparedWrapper.entityDataUnsubscribeCmds.length; + preparedWrapper.alarmDataCmds = TelemetryPluginCmdsWrapper.popCmds(this.alarmDataCmds, leftCount); + leftCount -= preparedWrapper.alarmDataCmds.length; + preparedWrapper.alarmDataUnsubscribeCmds = TelemetryPluginCmdsWrapper.popCmds(this.alarmDataUnsubscribeCmds, leftCount); + leftCount -= preparedWrapper.alarmDataUnsubscribeCmds.length; + preparedWrapper.entityCountCmds = TelemetryPluginCmdsWrapper.popCmds(this.entityCountCmds, leftCount); + leftCount -= preparedWrapper.entityCountCmds.length; + preparedWrapper.entityCountUnsubscribeCmds = TelemetryPluginCmdsWrapper.popCmds(this.entityCountUnsubscribeCmds, leftCount); + return preparedWrapper; + } +} + +export interface SubscriptionData { + [key: string]: [number, any, number?][]; +} + +export interface IndexedSubscriptionData { + [id: number]: [number, any, number?][]; +} + +export interface SubscriptionDataHolder { + data: SubscriptionData; +} + +export interface SubscriptionUpdateMsg extends SubscriptionDataHolder { + subscriptionId: number; + errorCode: number; + errorMsg: string; +} + +export enum CmdUpdateType { + ENTITY_DATA = 'ENTITY_DATA', + ALARM_DATA = 'ALARM_DATA', + COUNT_DATA = 'COUNT_DATA' +} + +export interface CmdUpdateMsg { + cmdId: number; + errorCode: number; + errorMsg: string; + cmdUpdateType: CmdUpdateType; +} + +export interface DataUpdateMsg extends CmdUpdateMsg { + data?: PageData; + update?: Array; +} + +export interface EntityDataUpdateMsg extends DataUpdateMsg { + cmdUpdateType: CmdUpdateType.ENTITY_DATA; +} + +export interface AlarmDataUpdateMsg extends DataUpdateMsg { + cmdUpdateType: CmdUpdateType.ALARM_DATA; + allowedEntities: number; + totalEntities: number; +} + +export interface EntityCountUpdateMsg extends CmdUpdateMsg { + cmdUpdateType: CmdUpdateType.COUNT_DATA; + count: number; +} + +export type WebsocketDataMsg = AlarmDataUpdateMsg | EntityDataUpdateMsg | EntityCountUpdateMsg | SubscriptionUpdateMsg; + +export function isEntityDataUpdateMsg(message: WebsocketDataMsg): message is EntityDataUpdateMsg { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.ENTITY_DATA; +} + +export function isAlarmDataUpdateMsg(message: WebsocketDataMsg): message is AlarmDataUpdateMsg { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.ALARM_DATA; +} + +export function isEntityCountUpdateMsg(message: WebsocketDataMsg): message is EntityCountUpdateMsg { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.COUNT_DATA; +} + +export class SubscriptionUpdate implements SubscriptionUpdateMsg { + subscriptionId: number; + errorCode: number; + errorMsg: string; + data: SubscriptionData; + + constructor(msg: SubscriptionUpdateMsg) { + this.subscriptionId = msg.subscriptionId; + this.errorCode = msg.errorCode; + this.errorMsg = msg.errorMsg; + this.data = msg.data; + } + + public prepareData(keys: string[]) { + if (!this.data) { + this.data = {}; + } + if (keys) { + keys.forEach((key) => { + if (!this.data[key]) { + this.data[key] = []; + } + }); + } + } + + public updateAttributeData(origData: Array): Array { + for (const key of Object.keys(this.data)) { + const keyData = this.data[key]; + if (keyData.length) { + const existing = origData.find((data) => data.key === key); + if (existing) { + existing.lastUpdateTs = keyData[0][0]; + existing.value = keyData[0][1]; + } else { + origData.push( + { + key, + lastUpdateTs: keyData[0][0], + value: keyData[0][1] + } + ); + } + } + } + return origData; + } +} + +export class CmdUpdate implements CmdUpdateMsg { + cmdId: number; + errorCode: number; + errorMsg: string; + cmdUpdateType: CmdUpdateType; + + constructor(msg: CmdUpdateMsg) { + this.cmdId = msg.cmdId; + this.errorCode = msg.errorCode; + this.errorMsg = msg.errorMsg; + this.cmdUpdateType = msg.cmdUpdateType; + } +} + +export class DataUpdate extends CmdUpdate implements DataUpdateMsg { + data?: PageData; + update?: Array; + + constructor(msg: DataUpdateMsg) { + super(msg); + this.data = msg.data; + this.update = msg.update; + } +} + +export class EntityDataUpdate extends DataUpdate { + constructor(msg: EntityDataUpdateMsg) { + super(msg); + } + + private static processEntityData(data: Array, tsOffset: number) { + for (const entityData of data) { + if (entityData.timeseries) { + for (const key of Object.keys(entityData.timeseries)) { + const tsValues = entityData.timeseries[key]; + for (const tsValue of tsValues) { + if (tsValue.ts) { + tsValue.ts += tsOffset; + } + } + } + } + if (entityData.latest) { + for (const entityKeyType of Object.keys(entityData.latest)) { + const keyTypeValues = entityData.latest[entityKeyType]; + for (const key of Object.keys(keyTypeValues)) { + const tsValue = keyTypeValues[key]; + if (tsValue.ts) { + tsValue.ts += tsOffset; + } + if (key === entityFields.createdTime.keyName && tsValue.value) { + tsValue.value = (Number(tsValue.value) + tsOffset) + ''; + } + } + } + } + } + } + + public prepareData(tsOffset: number) { + if (this.data) { + EntityDataUpdate.processEntityData(this.data.data, tsOffset); + } + if (this.update) { + EntityDataUpdate.processEntityData(this.update, tsOffset); + } + } +} + +export class AlarmDataUpdate extends DataUpdate { + + constructor(msg: AlarmDataUpdateMsg) { + super(msg); + this.allowedEntities = msg.allowedEntities; + this.totalEntities = msg.totalEntities; + } + allowedEntities: number; + totalEntities: number; + + private static processAlarmData(data: Array, tsOffset: number) { + for (const alarmData of data) { + alarmData.createdTime += tsOffset; + if (alarmData.ackTs) { + alarmData.ackTs += tsOffset; + } + if (alarmData.clearTs) { + alarmData.clearTs += tsOffset; + } + if (alarmData.endTs) { + alarmData.endTs += tsOffset; + } + if (alarmData.latest) { + for (const entityKeyType of Object.keys(alarmData.latest)) { + const keyTypeValues = alarmData.latest[entityKeyType]; + for (const key of Object.keys(keyTypeValues)) { + const tsValue = keyTypeValues[key]; + if (tsValue.ts) { + tsValue.ts += tsOffset; + } + if (key in [entityFields.createdTime.keyName, + alarmFields.startTime.keyName, + alarmFields.endTime.keyName, + alarmFields.ackTime.keyName, + alarmFields.clearTime.keyName] && tsValue.value) { + tsValue.value = (Number(tsValue.value) + tsOffset) + ''; + } + } + } + } + } + } + + public prepareData(tsOffset: number) { + if (this.data) { + AlarmDataUpdate.processAlarmData(this.data.data, tsOffset); + } + if (this.update) { + AlarmDataUpdate.processAlarmData(this.update, tsOffset); + } + } +} + +export class EntityCountUpdate extends CmdUpdate { + count: number; + + constructor(msg: EntityCountUpdateMsg) { + super(msg); + this.count = msg.count; + } +} + +export interface TelemetryService { + subscribe(subscriber: TelemetrySubscriber); + update(subscriber: TelemetrySubscriber); + unsubscribe(subscriber: TelemetrySubscriber); +} + +export class TelemetrySubscriber { + + private dataSubject = new ReplaySubject(1); + private entityDataSubject = new ReplaySubject(1); + private alarmDataSubject = new ReplaySubject(1); + private entityCountSubject = new ReplaySubject(1); + private reconnectSubject = new Subject(); + + private tsOffset = undefined; + + public subscriptionCommands: Array; + + public data$ = this.dataSubject.asObservable(); + public entityData$ = this.entityDataSubject.asObservable(); + public alarmData$ = this.alarmDataSubject.asObservable(); + public entityCount$ = this.entityCountSubject.asObservable(); + public reconnect$ = this.reconnectSubject.asObservable(); + + public static createEntityAttributesSubscription(telemetryService: TelemetryService, + entityId: EntityId, attributeScope: TelemetryType, + zone: NgZone, keys: string[] = null): TelemetrySubscriber { + let subscriptionCommand: SubscriptionCmd; + if (attributeScope === LatestTelemetry.LATEST_TELEMETRY) { + subscriptionCommand = new TimeseriesSubscriptionCmd(); + } else { + subscriptionCommand = new AttributesSubscriptionCmd(); + } + subscriptionCommand.entityType = entityId.entityType as EntityType; + subscriptionCommand.entityId = entityId.id; + subscriptionCommand.scope = attributeScope as AttributeScope; + if (keys) { + subscriptionCommand.keys = keys.join(','); + } + const subscriber = new TelemetrySubscriber(telemetryService, zone); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + constructor(private telemetryService: TelemetryService, private zone?: NgZone) { + this.subscriptionCommands = []; + } + + public subscribe() { + this.telemetryService.subscribe(this); + } + + public update() { + this.telemetryService.update(this); + } + + public unsubscribe() { + this.telemetryService.unsubscribe(this); + this.complete(); + } + + public complete() { + this.dataSubject.complete(); + this.entityDataSubject.complete(); + this.alarmDataSubject.complete(); + this.entityCountSubject.complete(); + this.reconnectSubject.complete(); + } + + public setTsOffset(tsOffset: number): boolean { + if (this.tsOffset !== tsOffset) { + const changed = !isUndefined(this.tsOffset); + this.tsOffset = tsOffset; + return changed; + } else { + return false; + } + } + + public onData(message: SubscriptionUpdate) { + const cmdId = message.subscriptionId; + let keys: string[]; + const cmd = this.subscriptionCommands.find((command) => command.cmdId === cmdId); + if (cmd) { + const telemetryPluginCmd = cmd as TelemetryPluginCmd; + if (telemetryPluginCmd.keys && telemetryPluginCmd.keys.length) { + keys = telemetryPluginCmd.keys.split(','); + } + } + message.prepareData(keys); + if (this.zone) { + this.zone.run( + () => { + this.dataSubject.next(message); + } + ); + } else { + this.dataSubject.next(message); + } + } + + public onEntityData(message: EntityDataUpdate) { + if (this.tsOffset) { + message.prepareData(this.tsOffset); + } + if (this.zone) { + this.zone.run( + () => { + this.entityDataSubject.next(message); + } + ); + } else { + this.entityDataSubject.next(message); + } + } + + public onAlarmData(message: AlarmDataUpdate) { + if (this.tsOffset) { + message.prepareData(this.tsOffset); + } + if (this.zone) { + this.zone.run( + () => { + this.alarmDataSubject.next(message); + } + ); + } else { + this.alarmDataSubject.next(message); + } + } + + public onEntityCount(message: EntityCountUpdate) { + if (this.zone) { + this.zone.run( + () => { + this.entityCountSubject.next(message); + } + ); + } else { + this.entityCountSubject.next(message); + } + } + + public onReconnected() { + this.reconnectSubject.next(); + } + + public attributeData$(): Observable> { + const attributeData = new Array(); + return this.data$.pipe( + map((message) => message.updateAttributeData(attributeData)) + ); + } +} diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts new file mode 100644 index 0000000..990852e --- /dev/null +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -0,0 +1,153 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ContactBased } from '@shared/models/contact-based.model'; +import { TenantId } from './id/tenant-id'; +import { TenantProfileId } from '@shared/models/id/tenant-profile-id'; +import { BaseData } from '@shared/models/base-data'; +import { QueueInfo } from '@shared/models/queue.models'; + +export enum TenantProfileType { + DEFAULT = 'DEFAULT' +} + +export interface DefaultTenantProfileConfiguration { + maxDevices: number; + maxAssets: number; + maxCustomers: number; + maxUsers: number; + maxDashboards: number; + maxRuleChains: number; + maxResourcesInBytes: number; + maxOtaPackagesInBytes: number; + + transportTenantMsgRateLimit?: string; + transportTenantTelemetryMsgRateLimit?: string; + transportTenantTelemetryDataPointsRateLimit?: string; + transportDeviceMsgRateLimit?: string; + transportDeviceTelemetryMsgRateLimit?: string; + transportDeviceTelemetryDataPointsRateLimit?: string; + + tenantEntityExportRateLimit?: string; + tenantEntityImportRateLimit?: string; + + maxTransportMessages: number; + maxTransportDataPoints: number; + maxREExecutions: number; + maxJSExecutions: number; + maxDPStorageDays: number; + maxRuleNodeExecutionsPerMessage: number; + maxEmails: number; + maxSms: number; + maxCreatedAlarms: number; + + tenantServerRestLimitsConfiguration: string; + customerServerRestLimitsConfiguration: string; + + maxWsSessionsPerTenant: number; + maxWsSessionsPerCustomer: number; + maxWsSessionsPerRegularUser: number; + maxWsSessionsPerPublicUser: number; + wsMsgQueueLimitPerSession: number; + maxWsSubscriptionsPerTenant: number; + maxWsSubscriptionsPerCustomer: number; + maxWsSubscriptionsPerRegularUser: number; + maxWsSubscriptionsPerPublicUser: number; + wsUpdatesPerSessionRateLimit: string; + + cassandraQueryTenantRateLimitsConfiguration: string; + + defaultStorageTtlDays: number; + alarmsTtlDays: number; + rpcTtlDays: number; +} + +export type TenantProfileConfigurations = DefaultTenantProfileConfiguration; + +export interface TenantProfileConfiguration extends TenantProfileConfigurations { + type: TenantProfileType; +} + +export function createTenantProfileConfiguration(type: TenantProfileType): TenantProfileConfiguration { + let configuration: TenantProfileConfiguration = null; + if (type) { + switch (type) { + case TenantProfileType.DEFAULT: + const defaultConfiguration: DefaultTenantProfileConfiguration = { + maxDevices: 0, + maxAssets: 0, + maxCustomers: 0, + maxUsers: 0, + maxDashboards: 0, + maxRuleChains: 0, + maxResourcesInBytes: 0, + maxOtaPackagesInBytes: 0, + maxTransportMessages: 0, + maxTransportDataPoints: 0, + maxREExecutions: 0, + maxJSExecutions: 0, + maxDPStorageDays: 0, + maxRuleNodeExecutionsPerMessage: 0, + maxEmails: 0, + maxSms: 0, + maxCreatedAlarms: 0, + tenantServerRestLimitsConfiguration: '', + customerServerRestLimitsConfiguration: '', + maxWsSessionsPerTenant: 0, + maxWsSessionsPerCustomer: 0, + maxWsSessionsPerRegularUser: 0, + maxWsSessionsPerPublicUser: 0, + wsMsgQueueLimitPerSession: 0, + maxWsSubscriptionsPerTenant: 0, + maxWsSubscriptionsPerCustomer: 0, + maxWsSubscriptionsPerRegularUser: 0, + maxWsSubscriptionsPerPublicUser: 0, + wsUpdatesPerSessionRateLimit: '', + cassandraQueryTenantRateLimitsConfiguration: '', + defaultStorageTtlDays: 0, + alarmsTtlDays: 0, + rpcTtlDays: 0, + }; + configuration = {...defaultConfiguration, type: TenantProfileType.DEFAULT}; + break; + } + } + return configuration; +} + +export interface TenantProfileData { + configuration: TenantProfileConfiguration; + queueConfiguration?: Array; +} + +export interface TenantProfile extends BaseData { + name: string; + description?: string; + default?: boolean; + isolatedTbRuleEngine?: boolean; + profileData?: TenantProfileData; +} + +export interface Tenant extends ContactBased { + title: string; + region: string; + tenantProfileId: TenantProfileId; + additionalInfo?: any; +} + +export interface TenantInfo extends Tenant { + tenantProfileName: string; +} diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts new file mode 100644 index 0000000..6c04aef --- /dev/null +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -0,0 +1,928 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { TimeService } from '@core/services/time.service'; +import { deepClone, isDefined, isNumeric, isUndefined } from '@app/core/utils'; +import * as moment_ from 'moment'; +import * as momentTz from 'moment-timezone'; + +const moment = moment_; + +export const SECOND = 1000; +export const MINUTE = 60 * SECOND; +export const HOUR = 60 * MINUTE; +export const DAY = 24 * HOUR; +export const WEEK = 7 * DAY; +export const YEAR = DAY * 365; + +export type ComparisonDuration = moment_.unitOfTime.DurationConstructor | 'previousInterval' | 'customInterval'; + +export enum TimewindowType { + REALTIME, + HISTORY +} + +export enum RealtimeWindowType { + LAST_INTERVAL, + INTERVAL +} + +export enum HistoryWindowType { + LAST_INTERVAL, + FIXED, + INTERVAL +} + +export interface IntervalWindow { + interval?: number; + timewindowMs?: number; + quickInterval?: QuickTimeInterval; +} + +export interface RealtimeWindow extends IntervalWindow{ + realtimeType?: RealtimeWindowType; +} + +export interface FixedWindow { + startTimeMs: number; + endTimeMs: number; +} + +export interface HistoryWindow extends IntervalWindow { + historyType?: HistoryWindowType; + fixedTimewindow?: FixedWindow; +} + +export enum AggregationType { + MIN = 'MIN', + MAX = 'MAX', + AVG = 'AVG', + SUM = 'SUM', + COUNT = 'COUNT', + NONE = 'NONE' +} + +export const aggregationTranslations = new Map( + [ + [AggregationType.MIN, 'aggregation.min'], + [AggregationType.MAX, 'aggregation.max'], + [AggregationType.AVG, 'aggregation.avg'], + [AggregationType.SUM, 'aggregation.sum'], + [AggregationType.COUNT, 'aggregation.count'], + [AggregationType.NONE, 'aggregation.none'], + ] +); + +export interface Aggregation { + interval?: number; + type: AggregationType; + limit: number; +} + +export interface Timewindow { + displayValue?: string; + displayTimezoneAbbr?: string; + hideInterval?: boolean; + hideQuickInterval?: boolean; + hideLastInterval?: boolean; + hideAggregation?: boolean; + hideAggInterval?: boolean; + hideTimezone?: boolean; + selectedTab?: TimewindowType; + realtime?: RealtimeWindow; + history?: HistoryWindow; + aggregation?: Aggregation; + timezone?: string; +} + +export interface SubscriptionAggregation extends Aggregation { + interval?: number; + timeWindow?: number; + stateData?: boolean; +} + +export interface SubscriptionTimewindow { + startTs?: number; + quickInterval?: QuickTimeInterval; + timezone?: string; + tsOffset?: number; + realtimeWindowMs?: number; + fixedWindow?: FixedWindow; + aggregation?: SubscriptionAggregation; + timeForComparison?: ComparisonDuration; +} + +export interface WidgetTimewindow { + minTime?: number; + maxTime?: number; + interval?: number; + timezone?: string; + stDiff?: number; +} + +export enum QuickTimeInterval { + YESTERDAY = 'YESTERDAY', + DAY_BEFORE_YESTERDAY = 'DAY_BEFORE_YESTERDAY', + THIS_DAY_LAST_WEEK = 'THIS_DAY_LAST_WEEK', + PREVIOUS_WEEK = 'PREVIOUS_WEEK', + PREVIOUS_WEEK_ISO = 'PREVIOUS_WEEK_ISO', + PREVIOUS_MONTH = 'PREVIOUS_MONTH', + PREVIOUS_YEAR = 'PREVIOUS_YEAR', + CURRENT_HOUR = 'CURRENT_HOUR', + CURRENT_DAY = 'CURRENT_DAY', + CURRENT_DAY_SO_FAR = 'CURRENT_DAY_SO_FAR', + CURRENT_WEEK = 'CURRENT_WEEK', + CURRENT_WEEK_ISO = 'CURRENT_WEEK_ISO', + CURRENT_WEEK_SO_FAR = 'CURRENT_WEEK_SO_FAR', + CURRENT_WEEK_ISO_SO_FAR = 'CURRENT_WEEK_ISO_SO_FAR', + CURRENT_MONTH = 'CURRENT_MONTH', + CURRENT_MONTH_SO_FAR = 'CURRENT_MONTH_SO_FAR', + CURRENT_YEAR = 'CURRENT_YEAR', + CURRENT_YEAR_SO_FAR = 'CURRENT_YEAR_SO_FAR' +} + +export const QuickTimeIntervalTranslationMap = new Map([ + [QuickTimeInterval.YESTERDAY, 'timeinterval.predefined.yesterday'], + [QuickTimeInterval.DAY_BEFORE_YESTERDAY, 'timeinterval.predefined.day-before-yesterday'], + [QuickTimeInterval.THIS_DAY_LAST_WEEK, 'timeinterval.predefined.this-day-last-week'], + [QuickTimeInterval.PREVIOUS_WEEK, 'timeinterval.predefined.previous-week'], + [QuickTimeInterval.PREVIOUS_WEEK_ISO, 'timeinterval.predefined.previous-week-iso'], + [QuickTimeInterval.PREVIOUS_MONTH, 'timeinterval.predefined.previous-month'], + [QuickTimeInterval.PREVIOUS_YEAR, 'timeinterval.predefined.previous-year'], + [QuickTimeInterval.CURRENT_HOUR, 'timeinterval.predefined.current-hour'], + [QuickTimeInterval.CURRENT_DAY, 'timeinterval.predefined.current-day'], + [QuickTimeInterval.CURRENT_DAY_SO_FAR, 'timeinterval.predefined.current-day-so-far'], + [QuickTimeInterval.CURRENT_WEEK, 'timeinterval.predefined.current-week'], + [QuickTimeInterval.CURRENT_WEEK_ISO, 'timeinterval.predefined.current-week-iso'], + [QuickTimeInterval.CURRENT_WEEK_SO_FAR, 'timeinterval.predefined.current-week-so-far'], + [QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR, 'timeinterval.predefined.current-week-iso-so-far'], + [QuickTimeInterval.CURRENT_MONTH, 'timeinterval.predefined.current-month'], + [QuickTimeInterval.CURRENT_MONTH_SO_FAR, 'timeinterval.predefined.current-month-so-far'], + [QuickTimeInterval.CURRENT_YEAR, 'timeinterval.predefined.current-year'], + [QuickTimeInterval.CURRENT_YEAR_SO_FAR, 'timeinterval.predefined.current-year-so-far'] +]); + +export function historyInterval(timewindowMs: number): Timewindow { + return { + selectedTab: TimewindowType.HISTORY, + history: { + historyType: HistoryWindowType.LAST_INTERVAL, + timewindowMs + } + }; +} + +export function defaultTimewindow(timeService: TimeService): Timewindow { + const currentTime = moment().valueOf(); + return { + displayValue: '', + hideInterval: false, + hideLastInterval: false, + hideQuickInterval: false, + hideAggregation: false, + hideAggInterval: false, + hideTimezone: false, + selectedTab: TimewindowType.REALTIME, + realtime: { + realtimeType: RealtimeWindowType.LAST_INTERVAL, + interval: SECOND, + timewindowMs: MINUTE, + quickInterval: QuickTimeInterval.CURRENT_DAY + }, + history: { + historyType: HistoryWindowType.LAST_INTERVAL, + interval: SECOND, + timewindowMs: MINUTE, + fixedTimewindow: { + startTimeMs: currentTime - DAY, + endTimeMs: currentTime + }, + quickInterval: QuickTimeInterval.CURRENT_DAY + }, + aggregation: { + type: AggregationType.AVG, + limit: Math.floor(timeService.getMaxDatapointsLimit() / 2) + } + }; +} + +function getTimewindowType(timewindow: Timewindow): TimewindowType { + if (isUndefined(timewindow.selectedTab)) { + return isDefined(timewindow.realtime) ? TimewindowType.REALTIME : TimewindowType.HISTORY; + } else { + return timewindow.selectedTab; + } +} + +export function initModelFromDefaultTimewindow(value: Timewindow, quickIntervalOnly: boolean, timeService: TimeService): Timewindow { + const model = defaultTimewindow(timeService); + if (value) { + model.hideInterval = value.hideInterval; + model.hideLastInterval = value.hideLastInterval; + model.hideQuickInterval = value.hideQuickInterval; + model.hideAggregation = value.hideAggregation; + model.hideAggInterval = value.hideAggInterval; + model.hideTimezone = value.hideTimezone; + model.selectedTab = getTimewindowType(value); + if (model.selectedTab === TimewindowType.REALTIME) { + if (isDefined(value.realtime.interval)) { + model.realtime.interval = value.realtime.interval; + } + if (isUndefined(value.realtime.realtimeType)) { + if (isDefined(value.realtime.quickInterval)) { + model.realtime.realtimeType = RealtimeWindowType.INTERVAL; + } else { + model.realtime.realtimeType = RealtimeWindowType.LAST_INTERVAL; + } + } else { + model.realtime.realtimeType = value.realtime.realtimeType; + } + if (model.realtime.realtimeType === RealtimeWindowType.INTERVAL) { + model.realtime.quickInterval = value.realtime.quickInterval; + } else { + model.realtime.timewindowMs = value.realtime.timewindowMs; + } + } else { + if (isDefined(value.history.interval)) { + model.history.interval = value.history.interval; + } + if (isUndefined(value.history.historyType)) { + if (isDefined(value.history.timewindowMs)) { + model.history.historyType = HistoryWindowType.LAST_INTERVAL; + } else if (isDefined(value.history.quickInterval)) { + model.history.historyType = HistoryWindowType.INTERVAL; + } else { + model.history.historyType = HistoryWindowType.FIXED; + } + } else { + model.history.historyType = value.history.historyType; + } + if (model.history.historyType === HistoryWindowType.LAST_INTERVAL) { + model.history.timewindowMs = value.history.timewindowMs; + } else if (model.history.historyType === HistoryWindowType.INTERVAL) { + model.history.quickInterval = value.history.quickInterval; + } else { + model.history.fixedTimewindow.startTimeMs = value.history.fixedTimewindow.startTimeMs; + model.history.fixedTimewindow.endTimeMs = value.history.fixedTimewindow.endTimeMs; + } + } + if (value.aggregation) { + if (value.aggregation.type) { + model.aggregation.type = value.aggregation.type; + } + model.aggregation.limit = value.aggregation.limit || Math.floor(timeService.getMaxDatapointsLimit() / 2); + } + model.timezone = value.timezone; + } + if (quickIntervalOnly) { + model.realtime.realtimeType = RealtimeWindowType.INTERVAL; + } + return model; +} + +export function toHistoryTimewindow(timewindow: Timewindow, startTimeMs: number, endTimeMs: number, + interval: number, timeService: TimeService): Timewindow { + if (timewindow.history) { + interval = isDefined(interval) ? interval : timewindow.history.interval; + } else if (timewindow.realtime) { + interval = timewindow.realtime.interval; + } else { + interval = 0; + } + let aggType: AggregationType; + let limit: number; + if (timewindow.aggregation) { + aggType = timewindow.aggregation.type || AggregationType.AVG; + limit = timewindow.aggregation.limit || timeService.getMaxDatapointsLimit(); + } else { + aggType = AggregationType.AVG; + limit = timeService.getMaxDatapointsLimit(); + } + return { + hideInterval: timewindow.hideInterval || false, + hideLastInterval: timewindow.hideLastInterval || false, + hideQuickInterval: timewindow.hideQuickInterval || false, + hideAggregation: timewindow.hideAggregation || false, + hideAggInterval: timewindow.hideAggInterval || false, + hideTimezone: timewindow.hideTimezone || false, + selectedTab: TimewindowType.HISTORY, + history: { + historyType: HistoryWindowType.FIXED, + fixedTimewindow: { + startTimeMs, + endTimeMs + }, + interval: timeService.boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, AggregationType.AVG) + }, + aggregation: { + type: aggType, + limit + }, + timezone: timewindow.timezone + }; +} + +export function timewindowTypeChanged(newTimewindow: Timewindow, oldTimewindow: Timewindow): boolean { + if (!newTimewindow || !oldTimewindow) { + return false; + } + const newType = getTimewindowType(newTimewindow); + const oldType = getTimewindowType(oldTimewindow); + return newType !== oldType; +} + +export function calculateTsOffset(timezone?: string): number { + if (timezone) { + const tz = getTimezone(timezone); + const localOffset = moment().utcOffset(); + return (tz.utcOffset() - localOffset) * 60 * 1000; + } else { + return 0; + } +} + +export function isHistoryTypeTimewindow(timewindow: Timewindow): boolean { + return getTimewindowType(timewindow) === TimewindowType.HISTORY; +} + +export function createSubscriptionTimewindow(timewindow: Timewindow, stDiff: number, stateData: boolean, + timeService: TimeService): SubscriptionTimewindow { + const subscriptionTimewindow: SubscriptionTimewindow = { + fixedWindow: null, + realtimeWindowMs: null, + aggregation: { + interval: SECOND, + limit: timeService.getMaxDatapointsLimit(), + type: AggregationType.AVG + }, + timezone: timewindow.timezone, + tsOffset: calculateTsOffset(timewindow.timezone) + }; + let aggTimewindow; + if (stateData) { + subscriptionTimewindow.aggregation.type = AggregationType.NONE; + subscriptionTimewindow.aggregation.stateData = true; + } + if (isDefined(timewindow.aggregation) && !stateData) { + subscriptionTimewindow.aggregation = { + type: timewindow.aggregation.type || AggregationType.AVG, + limit: timewindow.aggregation.limit || timeService.getMaxDatapointsLimit() + }; + } + const selectedTab = getTimewindowType(timewindow); + if (selectedTab === TimewindowType.REALTIME) { + let realtimeType = timewindow.realtime.realtimeType; + if (isUndefined(realtimeType)) { + if (isDefined(timewindow.realtime.quickInterval)) { + realtimeType = RealtimeWindowType.INTERVAL; + } else { + realtimeType = RealtimeWindowType.LAST_INTERVAL; + } + } + if (realtimeType === RealtimeWindowType.INTERVAL) { + subscriptionTimewindow.realtimeWindowMs = + getSubscriptionRealtimeWindowFromTimeInterval(timewindow.realtime.quickInterval, timewindow.timezone); + subscriptionTimewindow.quickInterval = timewindow.realtime.quickInterval; + const currentDate = getCurrentTime(timewindow.timezone); + subscriptionTimewindow.startTs = calculateIntervalStartTime(timewindow.realtime.quickInterval, currentDate).valueOf(); + } else { + subscriptionTimewindow.realtimeWindowMs = timewindow.realtime.timewindowMs; + const currentDate = getCurrentTime(timewindow.timezone); + subscriptionTimewindow.startTs = currentDate.valueOf() + stDiff - subscriptionTimewindow.realtimeWindowMs; + } + subscriptionTimewindow.aggregation.interval = + timeService.boundIntervalToTimewindow(subscriptionTimewindow.realtimeWindowMs, timewindow.realtime.interval, + subscriptionTimewindow.aggregation.type); + aggTimewindow = subscriptionTimewindow.realtimeWindowMs; + if (realtimeType !== RealtimeWindowType.INTERVAL) { + const startDiff = subscriptionTimewindow.startTs % subscriptionTimewindow.aggregation.interval; + if (startDiff) { + subscriptionTimewindow.startTs -= startDiff; + aggTimewindow += subscriptionTimewindow.aggregation.interval; + } + } + } else { + let historyType = timewindow.history.historyType; + if (isUndefined(historyType)) { + if (isDefined(timewindow.history.timewindowMs)) { + historyType = HistoryWindowType.LAST_INTERVAL; + } else if (isDefined(timewindow.history.quickInterval)) { + historyType = HistoryWindowType.INTERVAL; + } else { + historyType = HistoryWindowType.FIXED; + } + } + if (historyType === HistoryWindowType.LAST_INTERVAL) { + const currentDate = getCurrentTime(timewindow.timezone); + const currentTime = currentDate.valueOf(); + subscriptionTimewindow.fixedWindow = { + startTimeMs: currentTime - timewindow.history.timewindowMs, + endTimeMs: currentTime + }; + aggTimewindow = timewindow.history.timewindowMs; + } else if (historyType === HistoryWindowType.INTERVAL) { + const startEndTime = calculateIntervalStartEndTime(timewindow.history.quickInterval, timewindow.timezone); + subscriptionTimewindow.fixedWindow = { + startTimeMs: startEndTime[0], + endTimeMs: startEndTime[1] + }; + aggTimewindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + subscriptionTimewindow.quickInterval = timewindow.history.quickInterval; + } else { + subscriptionTimewindow.fixedWindow = { + startTimeMs: timewindow.history.fixedTimewindow.startTimeMs - subscriptionTimewindow.tsOffset, + endTimeMs: timewindow.history.fixedTimewindow.endTimeMs - subscriptionTimewindow.tsOffset + }; + aggTimewindow = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + } + subscriptionTimewindow.startTs = subscriptionTimewindow.fixedWindow.startTimeMs; + subscriptionTimewindow.aggregation.interval = + timeService.boundIntervalToTimewindow(aggTimewindow, timewindow.history.interval, subscriptionTimewindow.aggregation.type); + } + const aggregation = subscriptionTimewindow.aggregation; + aggregation.timeWindow = aggTimewindow; + if (aggregation.type !== AggregationType.NONE) { + aggregation.limit = Math.ceil(aggTimewindow / subscriptionTimewindow.aggregation.interval); + } + return subscriptionTimewindow; +} + +function getSubscriptionRealtimeWindowFromTimeInterval(interval: QuickTimeInterval, tz?: string): number { + let currentDate; + switch (interval) { + case QuickTimeInterval.CURRENT_HOUR: + return HOUR; + case QuickTimeInterval.CURRENT_DAY: + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + return DAY; + case QuickTimeInterval.CURRENT_WEEK: + case QuickTimeInterval.CURRENT_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + return WEEK; + case QuickTimeInterval.CURRENT_MONTH: + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + currentDate = getCurrentTime(tz); + return currentDate.endOf('month').diff(currentDate.clone().startOf('month')); + case QuickTimeInterval.CURRENT_YEAR: + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + currentDate = getCurrentTime(tz); + return currentDate.endOf('year').diff(currentDate.clone().startOf('year')); + } +} + +export function calculateIntervalStartEndTime(interval: QuickTimeInterval, tz?: string): [number, number] { + const startEndTs: [number, number] = [0, 0]; + const currentDate = getCurrentTime(tz); + const startDate = calculateIntervalStartTime(interval, currentDate); + startEndTs[0] = startDate.valueOf(); + const endDate = calculateIntervalEndTime(interval, startDate, tz); + startEndTs[1] = endDate.valueOf(); + return startEndTs; +} + +export function calculateIntervalStartTime(interval: QuickTimeInterval, currentDate: moment_.Moment): moment_.Moment { + switch (interval) { + case QuickTimeInterval.YESTERDAY: + currentDate.subtract(1, 'days'); + return currentDate.startOf('day'); + case QuickTimeInterval.DAY_BEFORE_YESTERDAY: + currentDate.subtract(2, 'days'); + return currentDate.startOf('day'); + case QuickTimeInterval.THIS_DAY_LAST_WEEK: + currentDate.subtract(1, 'weeks'); + return currentDate.startOf('day'); + case QuickTimeInterval.PREVIOUS_WEEK: + currentDate.subtract(1, 'weeks'); + return currentDate.startOf('week'); + case QuickTimeInterval.PREVIOUS_WEEK_ISO: + currentDate.subtract(1, 'weeks'); + return currentDate.startOf('isoWeek'); + case QuickTimeInterval.PREVIOUS_MONTH: + currentDate.subtract(1, 'months'); + return currentDate.startOf('month'); + case QuickTimeInterval.PREVIOUS_YEAR: + currentDate.subtract(1, 'years'); + return currentDate.startOf('year'); + case QuickTimeInterval.CURRENT_HOUR: + return currentDate.startOf('hour'); + case QuickTimeInterval.CURRENT_DAY: + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + return currentDate.startOf('day'); + case QuickTimeInterval.CURRENT_WEEK: + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + return currentDate.startOf('week'); + case QuickTimeInterval.CURRENT_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + return currentDate.startOf('isoWeek'); + case QuickTimeInterval.CURRENT_MONTH: + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + return currentDate.startOf('month'); + case QuickTimeInterval.CURRENT_YEAR: + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + return currentDate.startOf('year'); + } +} + +export function calculateIntervalEndTime(interval: QuickTimeInterval, startDate: moment_.Moment, tz?: string): number { + switch (interval) { + case QuickTimeInterval.YESTERDAY: + case QuickTimeInterval.DAY_BEFORE_YESTERDAY: + case QuickTimeInterval.THIS_DAY_LAST_WEEK: + case QuickTimeInterval.CURRENT_DAY: + return startDate.add(1, 'day').valueOf(); + case QuickTimeInterval.PREVIOUS_WEEK: + case QuickTimeInterval.PREVIOUS_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK: + case QuickTimeInterval.CURRENT_WEEK_ISO: + return startDate.add(1, 'week').valueOf(); + case QuickTimeInterval.PREVIOUS_MONTH: + case QuickTimeInterval.CURRENT_MONTH: + return startDate.add(1, 'month').valueOf(); + case QuickTimeInterval.PREVIOUS_YEAR: + case QuickTimeInterval.CURRENT_YEAR: + return startDate.add(1, 'year').valueOf(); + case QuickTimeInterval.CURRENT_HOUR: + return startDate.add(1, 'hour').valueOf(); + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + return getCurrentTime(tz).valueOf(); + } +} + +export function quickTimeIntervalPeriod(interval: QuickTimeInterval): number { + switch (interval) { + case QuickTimeInterval.CURRENT_HOUR: + return HOUR; + case QuickTimeInterval.YESTERDAY: + case QuickTimeInterval.DAY_BEFORE_YESTERDAY: + case QuickTimeInterval.THIS_DAY_LAST_WEEK: + case QuickTimeInterval.CURRENT_DAY: + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + return DAY; + case QuickTimeInterval.PREVIOUS_WEEK: + case QuickTimeInterval.PREVIOUS_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK: + case QuickTimeInterval.CURRENT_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + return WEEK; + case QuickTimeInterval.PREVIOUS_MONTH: + case QuickTimeInterval.CURRENT_MONTH: + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + return DAY * 30; + case QuickTimeInterval.PREVIOUS_YEAR: + case QuickTimeInterval.CURRENT_YEAR: + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + return YEAR; + } +} + +export function calculateIntervalComparisonStartTime(interval: QuickTimeInterval, + startDate: moment_.Moment): moment_.Moment { + switch (interval) { + case QuickTimeInterval.YESTERDAY: + case QuickTimeInterval.DAY_BEFORE_YESTERDAY: + case QuickTimeInterval.CURRENT_DAY: + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + startDate.subtract(1, 'days'); + return startDate.startOf('day'); + case QuickTimeInterval.THIS_DAY_LAST_WEEK: + startDate.subtract(1, 'weeks'); + return startDate.startOf('day'); + case QuickTimeInterval.PREVIOUS_WEEK: + case QuickTimeInterval.CURRENT_WEEK: + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + startDate.subtract(1, 'weeks'); + return startDate.startOf('week'); + case QuickTimeInterval.PREVIOUS_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK_ISO: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + startDate.subtract(1, 'weeks'); + return startDate.startOf('isoWeek'); + case QuickTimeInterval.PREVIOUS_MONTH: + case QuickTimeInterval.CURRENT_MONTH: + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + startDate.subtract(1, 'months'); + return startDate.startOf('month'); + case QuickTimeInterval.PREVIOUS_YEAR: + case QuickTimeInterval.CURRENT_YEAR: + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + startDate.subtract(1, 'years'); + return startDate.startOf('year'); + case QuickTimeInterval.CURRENT_HOUR: + startDate.subtract(1, 'hour'); + return startDate.startOf('hour'); + } +} + +export function calculateIntervalComparisonEndTime(interval: QuickTimeInterval, + comparisonStartDate: moment_.Moment, + endDate: moment_.Moment): number { + switch (interval) { + case QuickTimeInterval.CURRENT_DAY_SO_FAR: + return endDate.subtract(1, 'days').valueOf(); + case QuickTimeInterval.CURRENT_WEEK_SO_FAR: + case QuickTimeInterval.CURRENT_WEEK_ISO_SO_FAR: + return endDate.subtract(1, 'week').valueOf(); + case QuickTimeInterval.CURRENT_MONTH_SO_FAR: + return endDate.subtract(1, 'month').valueOf(); + case QuickTimeInterval.CURRENT_YEAR_SO_FAR: + return endDate.subtract(1, 'year').valueOf(); + default: + return calculateIntervalEndTime(interval, comparisonStartDate); + } +} + +export function createTimewindowForComparison(subscriptionTimewindow: SubscriptionTimewindow, + timeUnit: ComparisonDuration, customIntervalValue: number): SubscriptionTimewindow { + const timewindowForComparison: SubscriptionTimewindow = { + fixedWindow: null, + realtimeWindowMs: null, + aggregation: subscriptionTimewindow.aggregation, + tsOffset: subscriptionTimewindow.tsOffset + }; + + if (subscriptionTimewindow.fixedWindow) { + let startTimeMs; + let endTimeMs; + if (timeUnit === 'previousInterval') { + if (subscriptionTimewindow.quickInterval) { + const startDate = moment(subscriptionTimewindow.fixedWindow.startTimeMs); + const endDate = moment(subscriptionTimewindow.fixedWindow.endTimeMs); + if (subscriptionTimewindow.timezone) { + startDate.tz(subscriptionTimewindow.timezone); + endDate.tz(subscriptionTimewindow.timezone); + } + const comparisonStartDate = calculateIntervalComparisonStartTime(subscriptionTimewindow.quickInterval, startDate); + startTimeMs = comparisonStartDate.valueOf(); + endTimeMs = calculateIntervalComparisonEndTime(subscriptionTimewindow.quickInterval, comparisonStartDate, endDate); + } else { + const timeInterval = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + endTimeMs = subscriptionTimewindow.fixedWindow.startTimeMs; + startTimeMs = endTimeMs - timeInterval; + } + } else if (timeUnit === 'customInterval') { + if (isNumeric(customIntervalValue) && isFinite(customIntervalValue) && customIntervalValue > 0) { + const timeInterval = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + endTimeMs = subscriptionTimewindow.fixedWindow.endTimeMs - Math.round(customIntervalValue); + startTimeMs = endTimeMs - timeInterval; + } else { + endTimeMs = subscriptionTimewindow.fixedWindow.endTimeMs; + startTimeMs = subscriptionTimewindow.fixedWindow.startTimeMs; + } + } else { + const timeInterval = subscriptionTimewindow.fixedWindow.endTimeMs - subscriptionTimewindow.fixedWindow.startTimeMs; + endTimeMs = moment(subscriptionTimewindow.fixedWindow.endTimeMs).subtract(1, timeUnit).valueOf(); + startTimeMs = endTimeMs - timeInterval; + } + timewindowForComparison.startTs = startTimeMs; + timewindowForComparison.fixedWindow = { + startTimeMs: timewindowForComparison.startTs, + endTimeMs + }; + } + + return timewindowForComparison; +} + +export function cloneSelectedTimewindow(timewindow: Timewindow): Timewindow { + const cloned: Timewindow = {}; + cloned.hideInterval = timewindow.hideInterval || false; + cloned.hideLastInterval = timewindow.hideLastInterval || false; + cloned.hideQuickInterval = timewindow.hideQuickInterval || false; + cloned.hideAggregation = timewindow.hideAggregation || false; + cloned.hideAggInterval = timewindow.hideAggInterval || false; + cloned.hideTimezone = timewindow.hideTimezone || false; + if (isDefined(timewindow.selectedTab)) { + cloned.selectedTab = timewindow.selectedTab; + if (timewindow.selectedTab === TimewindowType.REALTIME) { + cloned.realtime = deepClone(timewindow.realtime); + } else if (timewindow.selectedTab === TimewindowType.HISTORY) { + cloned.history = deepClone(timewindow.history); + } + } + cloned.aggregation = deepClone(timewindow.aggregation); + cloned.timezone = timewindow.timezone; + return cloned; +} + +export interface TimeInterval { + name: string; + translateParams: {[key: string]: any}; + value: number; +} + +export const defaultTimeIntervals = new Array( + { + name: 'timeinterval.seconds-interval', + translateParams: {seconds: 1}, + value: SECOND + }, + { + name: 'timeinterval.seconds-interval', + translateParams: {seconds: 5}, + value: 5 * SECOND + }, + { + name: 'timeinterval.seconds-interval', + translateParams: {seconds: 10}, + value: 10 * SECOND + }, + { + name: 'timeinterval.seconds-interval', + translateParams: {seconds: 15}, + value: 15 * SECOND + }, + { + name: 'timeinterval.seconds-interval', + translateParams: {seconds: 30}, + value: 30 * SECOND + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 1}, + value: MINUTE + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 2}, + value: 2 * MINUTE + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 5}, + value: 5 * MINUTE + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 10}, + value: 10 * MINUTE + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 15}, + value: 15 * MINUTE + }, + { + name: 'timeinterval.minutes-interval', + translateParams: {minutes: 30}, + value: 30 * MINUTE + }, + { + name: 'timeinterval.hours-interval', + translateParams: {hours: 1}, + value: HOUR + }, + { + name: 'timeinterval.hours-interval', + translateParams: {hours: 2}, + value: 2 * HOUR + }, + { + name: 'timeinterval.hours-interval', + translateParams: {hours: 5}, + value: 5 * HOUR + }, + { + name: 'timeinterval.hours-interval', + translateParams: {hours: 10}, + value: 10 * HOUR + }, + { + name: 'timeinterval.hours-interval', + translateParams: {hours: 12}, + value: 12 * HOUR + }, + { + name: 'timeinterval.days-interval', + translateParams: {days: 1}, + value: DAY + }, + { + name: 'timeinterval.days-interval', + translateParams: {days: 7}, + value: 7 * DAY + }, + { + name: 'timeinterval.days-interval', + translateParams: {days: 30}, + value: 30 * DAY + } +); + +export enum TimeUnit { + SECONDS = 'SECONDS', + MINUTES = 'MINUTES', + HOURS = 'HOURS', + DAYS = 'DAYS' +} + +export enum TimeUnitMilli { + MILLISECONDS = 'MILLISECONDS' +} + +export type FullTimeUnit = TimeUnit | TimeUnitMilli; + +export const timeUnitTranslationMap = new Map( + [ + [TimeUnitMilli.MILLISECONDS, 'timeunit.milliseconds'], + [TimeUnit.SECONDS, 'timeunit.seconds'], + [TimeUnit.MINUTES, 'timeunit.minutes'], + [TimeUnit.HOURS, 'timeunit.hours'], + [TimeUnit.DAYS, 'timeunit.days'] + ] +); + +export interface TimezoneInfo { + id: string; + name: string; + offset: string; + nOffset: number; + abbr: string; +} + +let timezones: TimezoneInfo[] = null; +let defaultTimezone: string = null; + +export function getTimezones(): TimezoneInfo[] { + if (!timezones) { + timezones = momentTz.tz.names().map((zoneName) => { + const tz = momentTz.tz(zoneName); + return { + id: zoneName, + name: zoneName.replace(/_/g, ' '), + offset: `UTC${tz.format('Z')}`, + nOffset: tz.utcOffset(), + abbr: tz.zoneAbbr() + }; + }); + } + return timezones; +} + +export function getTimezoneInfo(timezoneId: string, defaultTimezoneId?: string, userTimezoneByDefault?: boolean): TimezoneInfo { + const timezoneList = getTimezones(); + let foundTimezone = timezoneId ? timezoneList.find(timezoneInfo => timezoneInfo.id === timezoneId) : null; + if (!foundTimezone) { + if (userTimezoneByDefault) { + const userTimezone = getDefaultTimezone(); + foundTimezone = timezoneList.find(timezoneInfo => timezoneInfo.id === userTimezone); + } else if (defaultTimezoneId) { + foundTimezone = timezoneList.find(timezoneInfo => timezoneInfo.id === defaultTimezoneId); + } + } + return foundTimezone; +} + +export function getDefaultTimezoneInfo(): TimezoneInfo { + const userTimezone = getDefaultTimezone(); + return getTimezoneInfo(userTimezone); +} + +export function getDefaultTimezone(): string { + if (!defaultTimezone) { + defaultTimezone = momentTz.tz.guess(); + } + return defaultTimezone; +} + +export function getCurrentTime(tz?: string): moment_.Moment { + if (tz) { + return moment().tz(tz); + } else { + return moment(); + } +} + +export function getTime(ts: number, tz?: string): moment_.Moment { + if (tz) { + return moment(ts).tz(tz); + } else { + return moment(ts); + } +} + +export function getTimezone(tz: string): moment_.Moment { + return moment.tz(tz); +} + +export function getCurrentTimeForComparison(timeForComparison: moment_.unitOfTime.DurationConstructor, tz?: string): moment_.Moment { + return getCurrentTime(tz).subtract(1, timeForComparison); +} diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts new file mode 100644 index 0000000..3969845 --- /dev/null +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -0,0 +1,185 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 interface TwoFactorAuthSettings { + maxVerificationFailuresBeforeUserLockout: number; + providers: Array; + totalAllowedTimeForVerification: number; + useSystemTwoFactorAuthSettings: boolean; + verificationCodeCheckRateLimit: string; + minVerificationCodeSendPeriod: number; +} + +export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{ + providers: Array; + verificationCodeCheckRateLimitEnable: boolean; + verificationCodeCheckRateLimitNumber: number; + verificationCodeCheckRateLimitTime: number; +} + +export type TwoFactorAuthProviderConfig = Partial; + +export type TwoFactorAuthProviderConfigForm = Partial & TwoFactorAuthProviderFormConfig; + +export interface TotpTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + issuerName: string; +} + +export interface SmsTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + smsVerificationMessageTemplate: string; + verificationCodeLifetime: number; +} + +export interface EmailTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + verificationCodeLifetime: number; +} + +export interface TwoFactorAuthProviderFormConfig { + enable: boolean; +} + +export enum TwoFactorAuthProviderType{ + TOTP = 'TOTP', + SMS = 'SMS', + EMAIL = 'EMAIL', + BACKUP_CODE = 'BACKUP_CODE' +} + +interface GeneralTwoFactorAuthAccountConfig { + providerType: TwoFactorAuthProviderType; + useByDefault: boolean; +} + +export interface TotpTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + authUrl: string; +} + +export interface SmsTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + phoneNumber: string; +} + +export interface EmailTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + email: string; +} + +export interface BackupCodeTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + codesLeft: number; + codes?: Array; +} + +export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig | + EmailTwoFactorAuthAccountConfig | BackupCodeTwoFactorAuthAccountConfig; + +export interface AccountTwoFaSettings { + configs: AccountTwoFaSettingProviders; +} + +export type AccountTwoFaSettingProviders = { + [key in TwoFactorAuthProviderType]?: TwoFactorAuthAccountConfig; +}; + +export interface TwoFaProviderInfo { + type: TwoFactorAuthProviderType; + default: boolean; + contact?: string; + minVerificationCodeSendPeriod?: number; +} + +export interface TwoFactorAuthProviderData { + name: string; + description: string; + activatedHint: string; +} + +export interface TwoFactorAuthProviderLoginData extends Omit { + icon: string; + placeholder: string; +} + +export const twoFactorAuthProvidersData = new Map( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'security.2fa.provider.totp', + description: 'security.2fa.provider.totp-description', + activatedHint: 'security.2fa.provider.totp-hint' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'security.2fa.provider.sms', + description: 'security.2fa.provider.sms-description', + activatedHint: 'security.2fa.provider.sms-hint' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'security.2fa.provider.email', + description: 'security.2fa.provider.email-description', + activatedHint: 'security.2fa.provider.email-hint' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'security.2fa.provider.backup_code', + description: 'security.2fa.provider.backup-code-description', + activatedHint: 'security.2fa.provider.backup-code-hint' + } + ] + ] +); + +export const twoFactorAuthProvidersLoginData = new Map( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'security.2fa.provider.totp', + description: 'login.totp-auth-description', + placeholder: 'login.totp-auth-placeholder', + icon: 'mdi:cellphone-key' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'security.2fa.provider.sms', + description: 'login.sms-auth-description', + placeholder: 'login.sms-auth-placeholder', + icon: 'mdi:message-reply-text-outline' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'security.2fa.provider.email', + description: 'login.email-auth-description', + placeholder: 'login.email-auth-placeholder', + icon: 'mdi:email-outline' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'security.2fa.provider.backup_code', + description: 'login.backup-code-auth-description', + placeholder: 'login.backup-code-auth-placeholder', + icon: 'mdi:lock-outline' + } + ] + ] +); diff --git a/ui-ngx/src/app/shared/models/user.model.ts b/ui-ngx/src/app/shared/models/user.model.ts new file mode 100644 index 0000000..73fe32f --- /dev/null +++ b/ui-ngx/src/app/shared/models/user.model.ts @@ -0,0 +1,56 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from './base-data'; +import { UserId } from './id/user-id'; +import { CustomerId } from './id/customer-id'; +import { Authority } from './authority.enum'; +import { TenantId } from './id/tenant-id'; + +export interface User extends BaseData { + tenantId: TenantId; + customerId: CustomerId; + email: string; + authority: Authority; + firstName: string; + lastName: string; + additionalInfo: any; +} + +export enum ActivationMethod { + DISPLAY_ACTIVATION_LINK = 'DISPLAY_ACTIVATION_LINK', + SEND_ACTIVATION_MAIL = 'SEND_ACTIVATION_MAIL' +} + +export const activationMethodTranslations = new Map( + [ + [ActivationMethod.DISPLAY_ACTIVATION_LINK, 'user.display-activation-link'], + [ActivationMethod.SEND_ACTIVATION_MAIL, 'user.send-activation-mail'] + ] +); + +export interface AuthUser { + sub: string; + scopes: string[]; + userId: string; + firstName: string; + lastName: string; + enabled: boolean; + tenantId: string; + customerId: string; + isPublic: boolean; + authority: Authority; +} diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts new file mode 100644 index 0000000..155a9c8 --- /dev/null +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -0,0 +1,244 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; +import { ExportableEntity } from '@shared/models/base-data'; +import { EntityRelation } from '@shared/models/relation.models'; +import { Device, DeviceCredentials } from '@shared/models/device.models'; +import { RuleChain, RuleChainMetaData } from '@shared/models/rule-chain.models'; + +export const exportableEntityTypes: Array = [ + EntityType.ASSET, + EntityType.DEVICE, + EntityType.ENTITY_VIEW, + EntityType.DASHBOARD, + EntityType.CUSTOMER, + EntityType.DEVICE_PROFILE, + EntityType.ASSET_PROFILE, + EntityType.RULE_CHAIN, + EntityType.WIDGETS_BUNDLE +]; + +export interface VersionCreateConfig { + saveRelations: boolean; + saveAttributes: boolean; + saveCredentials: boolean; +} + +export enum VersionCreateRequestType { + SINGLE_ENTITY = 'SINGLE_ENTITY', + COMPLEX = 'COMPLEX' +} + +export interface VersionCreateRequest { + versionName: string; + branch: string; + type: VersionCreateRequestType; +} + +export interface SingleEntityVersionCreateRequest extends VersionCreateRequest { + entityId: EntityId; + config: VersionCreateConfig; + type: VersionCreateRequestType.SINGLE_ENTITY; +} + +export enum SyncStrategy { + MERGE = 'MERGE', + OVERWRITE = 'OVERWRITE' +} + +export const syncStrategyTranslationMap = new Map( + [ + [SyncStrategy.MERGE, 'version-control.sync-strategy-merge'], + [SyncStrategy.OVERWRITE, 'version-control.sync-strategy-overwrite'] + ] +); + +export const syncStrategyHintMap = new Map( + [ + [SyncStrategy.MERGE, 'version-control.sync-strategy-merge-hint'], + [SyncStrategy.OVERWRITE, 'version-control.sync-strategy-overwrite-hint'] + ] +); + +export interface EntityTypeVersionCreateConfig extends VersionCreateConfig { + syncStrategy: SyncStrategy; + entityIds: string[]; + allEntities: boolean; +} + +export interface ComplexVersionCreateRequest extends VersionCreateRequest { + syncStrategy: SyncStrategy; + entityTypes: {[entityType: string]: EntityTypeVersionCreateConfig}; + type: VersionCreateRequestType.COMPLEX; +} + +export function createDefaultEntityTypesVersionCreate(): {[entityType: string]: EntityTypeVersionCreateConfig} { + const res: {[entityType: string]: EntityTypeVersionCreateConfig} = {}; + for (const entityType of exportableEntityTypes) { + res[entityType] = { + syncStrategy: null, + saveAttributes: true, + saveRelations: true, + saveCredentials: true, + allEntities: true, + entityIds: [] + }; + } + return res; +} + +export interface VersionLoadConfig { + loadRelations: boolean; + loadAttributes: boolean; + loadCredentials: boolean; +} + +export enum VersionLoadRequestType { + SINGLE_ENTITY = 'SINGLE_ENTITY', + ENTITY_TYPE = 'ENTITY_TYPE' +} + +export interface VersionLoadRequest { + versionId: string; + type: VersionLoadRequestType; +} + +export interface SingleEntityVersionLoadRequest extends VersionLoadRequest { + externalEntityId: EntityId; + config: VersionLoadConfig; + type: VersionLoadRequestType.SINGLE_ENTITY; +} + +export interface EntityTypeVersionLoadConfig extends VersionLoadConfig { + removeOtherEntities: boolean; + findExistingEntityByName: boolean; +} + +export interface EntityTypeVersionLoadRequest extends VersionLoadRequest { + entityTypes: {[entityType: string]: EntityTypeVersionLoadConfig}; + type: VersionLoadRequestType.ENTITY_TYPE; +} + +export function createDefaultEntityTypesVersionLoad(): {[entityType: string]: EntityTypeVersionLoadConfig} { + const res: {[entityType: string]: EntityTypeVersionLoadConfig} = {}; + for (const entityType of exportableEntityTypes) { + res[entityType] = { + loadAttributes: true, + loadRelations: true, + loadCredentials: true, + removeOtherEntities: false, + findExistingEntityByName: true + }; + } + return res; +} + +export interface BranchInfo { + name: string; + default: boolean; +} + +export interface EntityVersion { + timestamp: number; + id: string; + name: string; + author: string; +} + +export interface VersionCreationResult { + version: EntityVersion; + added: number; + modified: number; + removed: number; + error: string; + done: boolean; +} + +export interface EntityTypeLoadResult { + entityType: EntityType; + created: number; + updated: number; + deleted: number; +} + +export enum EntityLoadErrorType { + DEVICE_CREDENTIALS_CONFLICT = 'DEVICE_CREDENTIALS_CONFLICT', + MISSING_REFERENCED_ENTITY = 'MISSING_REFERENCED_ENTITY', + RUNTIME = 'RUNTIME' +} + +export const entityLoadErrorTranslationMap = new Map( + [ + [EntityLoadErrorType.DEVICE_CREDENTIALS_CONFLICT, 'version-control.device-credentials-conflict'], + [EntityLoadErrorType.MISSING_REFERENCED_ENTITY, 'version-control.missing-referenced-entity'], + [EntityLoadErrorType.RUNTIME, 'version-control.runtime-failed'] + ] +); + +export interface EntityLoadError { + type: EntityLoadErrorType; + source: EntityId; + target: EntityId; + message?: string; +} + +export interface VersionLoadResult { + result: Array; + error: EntityLoadError; + done: boolean; +} + +export interface AttributeExportData { + key: string; + lastUpdateTs: number; + booleanValue: boolean; + strValue: string; + longValue: number; + doubleValue: number; + jsonValue: string; +} + +export interface EntityExportData> { + entity: E; + entityType: EntityType; + relations: Array; + attributes: {[key: string]: Array}; +} + +export interface DeviceExportData extends EntityExportData { + credentials: DeviceCredentials; +} + +export interface RuleChainExportData extends EntityExportData { + metaData: RuleChainMetaData; +} + +export interface EntityDataDiff { + currentVersion: EntityExportData; + otherVersion: EntityExportData; +} + +export function entityExportDataToJsonString(data: EntityExportData): string { + return JSON.stringify(data, null, 4); +} + +export interface EntityDataInfo { + hasRelations: boolean; + hasAttributes: boolean; + hasCredentials: boolean; +} diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts new file mode 100644 index 0000000..20b5757 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -0,0 +1,830 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { WidgetTypeId } from '@shared/models/id/widget-type-id'; +import { AggregationType, ComparisonDuration, Timewindow } from '@shared/models/time/time.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AlarmSearchStatus, AlarmSeverity } from '@shared/models/alarm.models'; +import { DataKeyType } from './telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import * as moment_ from 'moment'; +import { EntityDataPageLink, EntityFilter, KeyFilter } from '@shared/models/query/query.models'; +import { PopoverPlacement } from '@shared/components/popover.models'; +import { PageComponent } from '@shared/components/page.component'; +import { AfterViewInit, Directive, EventEmitter, Inject, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, FormGroup } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { isEmptyStr } from '@core/utils'; + +export enum widgetType { + timeseries = 'timeseries', + latest = 'latest', + rpc = 'rpc', + alarm = 'alarm', + static = 'static' +} + +export interface WidgetTypeTemplate { + bundleAlias: string; + alias: string; +} + +export interface WidgetTypeData { + name: string; + icon: string; + isMdiIcon?: boolean; + configHelpLinkId: string; + template: WidgetTypeTemplate; +} + +export const widgetTypesData = new Map( + [ + [ + widgetType.timeseries, + { + name: 'widget.timeseries', + icon: 'timeline', + configHelpLinkId: 'widgetsConfigTimeseries', + template: { + bundleAlias: 'charts', + alias: 'basic_timeseries' + } + } + ], + [ + widgetType.latest, + { + name: 'widget.latest', + icon: 'track_changes', + configHelpLinkId: 'widgetsConfigLatest', + template: { + bundleAlias: 'cards', + alias: 'attributes_card' + } + } + ], + [ + widgetType.rpc, + { + name: 'widget.rpc', + icon: 'mdi:developer-board', + configHelpLinkId: 'widgetsConfigRpc', + isMdiIcon: true, + template: { + bundleAlias: 'gpio_widgets', + alias: 'basic_gpio_control' + } + } + ], + [ + widgetType.alarm, + { + name: 'widget.alarm', + icon: 'error', + configHelpLinkId: 'widgetsConfigAlarm', + template: { + bundleAlias: 'alarm_widgets', + alias: 'alarms_table' + } + } + ], + [ + widgetType.static, + { + name: 'widget.static', + icon: 'font_download', + configHelpLinkId: 'widgetsConfigStatic', + template: { + bundleAlias: 'cards', + alias: 'html_card' + } + } + ] + ] +); + +export interface WidgetResource { + url: string; + isModule?: boolean; +} + +export interface WidgetActionSource { + name: string; + value: string; + multiple: boolean; + hasShowCondition?: boolean; +} + +export const widgetActionSources: {[acionSourceId: string]: WidgetActionSource} = { + headerButton: + { + name: 'widget-action.header-button', + value: 'headerButton', + multiple: true, + hasShowCondition: true + } +}; + +export interface WidgetTypeDescriptor { + type: widgetType; + resources: Array; + templateHtml: string; + templateCss: string; + controllerScript: string; + settingsSchema?: string | any; + dataKeySettingsSchema?: string | any; + latestDataKeySettingsSchema?: string | any; + settingsDirective?: string; + dataKeySettingsDirective?: string; + latestDataKeySettingsDirective?: string; + defaultConfig: string; + sizeX: number; + sizeY: number; +} + +export interface WidgetTypeParameters { + useCustomDatasources?: boolean; + maxDatasources?: number; + maxDataKeys?: number; + datasourcesOptional?: boolean; + dataKeysOptional?: boolean; + stateData?: boolean; + hasDataPageLink?: boolean; + singleEntity?: boolean; + hasAdditionalLatestDataKeys?: boolean; + warnOnPageDataOverflow?: boolean; + ignoreDataUpdateOnIntervalTick?: boolean; + processNoDataByWidget?: boolean; +} + +export interface WidgetControllerDescriptor { + widgetTypeFunction?: any; + settingsSchema?: string | any; + dataKeySettingsSchema?: string | any; + latestDataKeySettingsSchema?: string | any; + typeParameters?: WidgetTypeParameters; + actionSources?: {[actionSourceId: string]: WidgetActionSource}; +} + +export interface BaseWidgetType extends BaseData { + tenantId: TenantId; + bundleAlias: string; + alias: string; + name: string; +} + +export interface WidgetType extends BaseWidgetType { + descriptor: WidgetTypeDescriptor; +} + +export interface WidgetTypeInfo extends BaseWidgetType { + image: string; + description: string; + widgetType: widgetType; +} + +export interface WidgetTypeDetails extends WidgetType { + image: string; + description: string; +} + +export enum LegendDirection { + column = 'column', + row = 'row' +} + +export const legendDirectionTranslationMap = new Map( + [ + [ LegendDirection.column, 'direction.column' ], + [ LegendDirection.row, 'direction.row' ] + ] +); + +export enum LegendPosition { + top = 'top', + bottom = 'bottom', + left = 'left', + right = 'right' +} + +export const legendPositionTranslationMap = new Map( + [ + [ LegendPosition.top, 'position.top' ], + [ LegendPosition.bottom, 'position.bottom' ], + [ LegendPosition.left, 'position.left' ], + [ LegendPosition.right, 'position.right' ] + ] +); + +export interface LegendConfig { + position: LegendPosition; + direction?: LegendDirection; + sortDataKeys: boolean; + showMin: boolean; + showMax: boolean; + showAvg: boolean; + showTotal: boolean; + showLatest: boolean; +} + +export function defaultLegendConfig(wType: widgetType): LegendConfig { + return { + direction: LegendDirection.column, + position: LegendPosition.bottom, + sortDataKeys: false, + showMin: false, + showMax: false, + showAvg: wType === widgetType.timeseries, + showTotal: false, + showLatest: false + }; +} + +export enum ComparisonResultType { + PREVIOUS_VALUE = 'PREVIOUS_VALUE', + DELTA_ABSOLUTE = 'DELTA_ABSOLUTE', + DELTA_PERCENT = 'DELTA_PERCENT' +} + +export const comparisonResultTypeTranslationMap = new Map( + [ + [ComparisonResultType.PREVIOUS_VALUE, 'datakey.delta-calculation-result-previous-value'], + [ComparisonResultType.DELTA_ABSOLUTE, 'datakey.delta-calculation-result-delta-absolute'], + [ComparisonResultType.DELTA_PERCENT, 'datakey.delta-calculation-result-delta-percent'] + ] +); + +export interface KeyInfo { + name: string; + aggregationType?: AggregationType; + comparisonEnabled?: boolean; + timeForComparison?: ComparisonDuration; + comparisonCustomIntervalValue?: number; + comparisonResultType?: ComparisonResultType; + label?: string; + color?: string; + funcBody?: string; + postFuncBody?: string; + units?: string; + decimals?: number; +} + +export const dataKeyAggregationTypeHintTranslationMap = new Map( + [ + [AggregationType.MIN, 'datakey.aggregation-type-min-hint'], + [AggregationType.MAX, 'datakey.aggregation-type-max-hint'], + [AggregationType.AVG, 'datakey.aggregation-type-avg-hint'], + [AggregationType.SUM, 'datakey.aggregation-type-sum-hint'], + [AggregationType.COUNT, 'datakey.aggregation-type-count-hint'], + [AggregationType.NONE, 'datakey.aggregation-type-none-hint'], + ] +); + + +export interface DataKey extends KeyInfo { + type: DataKeyType; + pattern?: string; + settings?: any; + usePostProcessing?: boolean; + hidden?: boolean; + inLegend?: boolean; + isAdditional?: boolean; + origDataKeyIndex?: number; + _hash?: number; +} + +export enum DatasourceType { + function = 'function', + entity = 'entity', + entityCount = 'entityCount' +} + +export const datasourceTypeTranslationMap = new Map( + [ + [ DatasourceType.function, 'function.function' ], + [ DatasourceType.entity, 'entity.entity' ], + [ DatasourceType.entityCount, 'entity.entities-count' ] + ] +); + +export interface Datasource { + type?: DatasourceType | any; + name?: string; + aliasName?: string; + dataKeys?: Array; + latestDataKeys?: Array; + entityType?: EntityType; + entityId?: string; + entityName?: string; + entityAliasId?: string; + filterId?: string; + unresolvedStateEntity?: boolean; + dataReceived?: boolean; + entity?: BaseData; + entityLabel?: string; + entityDescription?: string; + generated?: boolean; + isAdditional?: boolean; + origDatasourceIndex?: number; + pageLink?: EntityDataPageLink; + keyFilters?: Array; + entityFilter?: EntityFilter; + dataKeyStartIndex?: number; + latestDataKeyStartIndex?: number; + [key: string]: any; +} + +export function datasourcesHasAggregation(datasources?: Array): boolean { + if (datasources) { + const foundDatasource = datasources.find(datasource => { + const found = datasource.dataKeys && datasource.dataKeys.find(key => key.type === DataKeyType.timeseries && + key.aggregationType && key.aggregationType !== AggregationType.NONE); + return !!found; + }); + if (foundDatasource) { + return true; + } + } + return false; +} + +export function datasourcesHasOnlyComparisonAggregation(datasources?: Array): boolean { + if (!datasourcesHasAggregation(datasources)) { + return false; + } + if (datasources) { + const foundDatasource = datasources.find(datasource => { + const found = datasource.dataKeys && datasource.dataKeys.find(key => key.type === DataKeyType.timeseries && + key.aggregationType && key.aggregationType !== AggregationType.NONE && !key.comparisonEnabled); + return !!found; + }); + if (foundDatasource) { + return false; + } + } + return true; +} + +export interface FormattedData { + $datasource: Datasource; + entityName: string; + deviceName: string; + entityId: string; + entityType: EntityType; + entityLabel: string; + entityDescription: string; + aliasName: string; + dsIndex: number; + dsName: string; + deviceType: string; + [key: string]: any; +} + +export interface ReplaceInfo { + variable: string; + valDec?: number; + dataKeyName: string; +} + +export type DataSet = [number, any][]; + +export interface DataSetHolder { + data: DataSet; +} + +export interface DatasourceData extends DataSetHolder { + datasource: Datasource; + dataKey: DataKey; +} + +export interface LegendKey { + dataKey: DataKey; + dataIndex: number; +} + +export interface LegendKeyData { + min: string; + max: string; + avg: string; + total: string; + latest: string; + hidden: boolean; +} + +export interface LegendData { + keys: Array; + data: Array; +} + +export enum WidgetActionType { + openDashboardState = 'openDashboardState', + updateDashboardState = 'updateDashboardState', + openDashboard = 'openDashboard', + custom = 'custom', + customPretty = 'customPretty', + mobileAction = 'mobileAction' +} + +export enum WidgetMobileActionType { + takePictureFromGallery = 'takePictureFromGallery', + takePhoto = 'takePhoto', + mapDirection = 'mapDirection', + mapLocation = 'mapLocation', + scanQrCode = 'scanQrCode', + makePhoneCall = 'makePhoneCall', + getLocation = 'getLocation', + takeScreenshot = 'takeScreenshot' +} + +export const widgetActionTypeTranslationMap = new Map( + [ + [ WidgetActionType.openDashboardState, 'widget-action.open-dashboard-state' ], + [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ], + [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ], + [ WidgetActionType.custom, 'widget-action.custom' ], + [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ], + [ WidgetActionType.mobileAction, 'widget-action.mobile-action' ] + ] +); + +export const widgetMobileActionTypeTranslationMap = new Map( + [ + [ WidgetMobileActionType.takePictureFromGallery, 'widget-action.mobile.take-picture-from-gallery' ], + [ WidgetMobileActionType.takePhoto, 'widget-action.mobile.take-photo' ], + [ WidgetMobileActionType.mapDirection, 'widget-action.mobile.map-direction' ], + [ WidgetMobileActionType.mapLocation, 'widget-action.mobile.map-location' ], + [ WidgetMobileActionType.scanQrCode, 'widget-action.mobile.scan-qr-code' ], + [ WidgetMobileActionType.makePhoneCall, 'widget-action.mobile.make-phone-call' ], + [ WidgetMobileActionType.getLocation, 'widget-action.mobile.get-location' ], + [ WidgetMobileActionType.takeScreenshot, 'widget-action.mobile.take-screenshot' ] + ] +); + +export interface MobileLaunchResult { + launched: boolean; +} + +export interface MobileImageResult { + imageUrl: string; +} + +export interface MobileQrCodeResult { + code: string; + format: string; +} + +export interface MobileLocationResult { + latitude: number; + longitude: number; +} + +export type MobileActionResult = MobileLaunchResult & + MobileImageResult & + MobileQrCodeResult & + MobileLocationResult; + +export interface WidgetMobileActionResult { + result?: T; + hasResult: boolean; + error?: string; + hasError: boolean; +} + +export interface ProcessImageDescriptor { + processImageFunction: string; +} + +export interface ProcessLaunchResultDescriptor { + processLaunchResultFunction?: string; +} + +export interface LaunchMapDescriptor extends ProcessLaunchResultDescriptor { + getLocationFunction: string; +} + +export interface ScanQrCodeDescriptor { + processQrCodeFunction: string; +} + +export interface MakePhoneCallDescriptor extends ProcessLaunchResultDescriptor { + getPhoneNumberFunction: string; +} + +export interface GetLocationDescriptor { + processLocationFunction: string; +} + +export type WidgetMobileActionDescriptors = ProcessImageDescriptor & + LaunchMapDescriptor & + ScanQrCodeDescriptor & + MakePhoneCallDescriptor & + GetLocationDescriptor; + +export interface WidgetMobileActionDescriptor extends WidgetMobileActionDescriptors { + type: WidgetMobileActionType; + handleErrorFunction?: string; + handleEmptyResultFunction?: string; +} + +export interface CustomActionDescriptor { + customFunction?: string; + customResources?: Array; + customHtml?: string; + customCss?: string; +} + +export interface WidgetActionDescriptor extends CustomActionDescriptor { + id: string; + name: string; + icon: string; + displayName?: string; + type: WidgetActionType; + targetDashboardId?: string; + targetDashboardStateId?: string; + openRightLayout?: boolean; + openNewBrowserTab?: boolean; + openInPopover?: boolean; + popoverHideDashboardToolbar?: boolean; + popoverPreferredPlacement?: PopoverPlacement; + popoverHideOnClickOutside?: boolean; + popoverWidth?: string; + popoverHeight?: string; + popoverStyle?: { [klass: string]: any }; + openInSeparateDialog?: boolean; + dialogTitle?: string; + dialogHideDashboardToolbar?: boolean; + dialogWidth?: number; + dialogHeight?: number; + setEntityId?: boolean; + stateEntityParamName?: string; + mobileAction?: WidgetMobileActionDescriptor; + useShowWidgetActionFunction?: boolean; + showWidgetActionFunction?: string; +} + +export interface WidgetComparisonSettings { + comparisonEnabled?: boolean; + timeForComparison?: moment_.unitOfTime.DurationConstructor; + comparisonCustomIntervalValue?: number; +} + +export interface WidgetSettings { + [key: string]: any; +} + +export interface WidgetConfig { + title?: string; + titleIcon?: string; + showTitle?: boolean; + showTitleIcon?: boolean; + iconColor?: string; + iconSize?: string; + titleTooltip?: string; + dropShadow?: boolean; + enableFullscreen?: boolean; + useDashboardTimewindow?: boolean; + displayTimewindow?: boolean; + showLegend?: boolean; + legendConfig?: LegendConfig; + timewindow?: Timewindow; + desktopHide?: boolean; + mobileHide?: boolean; + mobileHeight?: number; + mobileOrder?: number; + color?: string; + backgroundColor?: string; + padding?: string; + margin?: string; + widgetStyle?: {[klass: string]: any}; + widgetCss?: string; + titleStyle?: {[klass: string]: any}; + units?: string; + decimals?: number; + noDataDisplayMessage?: string; + pageSize?: number; + actions?: {[actionSourceId: string]: Array}; + settings?: WidgetSettings; + alarmSource?: Datasource; + alarmStatusList?: AlarmSearchStatus[]; + alarmSeverityList?: AlarmSeverity[]; + alarmTypeList?: string[]; + searchPropagatedAlarms?: boolean; + datasources?: Array; + targetDeviceAliasIds?: Array; + [key: string]: any; +} + +export interface Widget extends WidgetInfo{ + typeId?: WidgetTypeId; + sizeX: number; + sizeY: number; + row: number; + col: number; + config: WidgetConfig; +} + +export interface WidgetInfo { + id?: string; + isSystemType: boolean; + bundleAlias: string; + typeAlias: string; + type: widgetType; + title: string; + image?: string; + description?: string; +} + +export interface GroupInfo { + formIndex: number; + GroupTitle: string; +} + +export interface JsonSchema { + type: string; + title?: string; + properties: {[key: string]: any}; + required?: string[]; +} + +export interface JsonSettingsSchema { + schema?: JsonSchema; + form?: any[]; + groupInfoes?: GroupInfo[]; +} + +export interface WidgetPosition { + row: number; + column: number; +} + +export interface WidgetSize { + sizeX: number; + sizeY: number; +} + +export interface IWidgetSettingsComponent { + aliasController: IAliasController; + dashboard: Dashboard; + widget: Widget; + functionScopeVariables: string[]; + settings: WidgetSettings; + settingsChanged: Observable; + validate(); + [key: string]: any; +} + +function removeEmptyWidgetSettings(settings: WidgetSettings): WidgetSettings { + if (settings) { + const keys = Object.keys(settings); + for (const key of keys) { + const val = settings[key]; + if (val === null || isEmptyStr(val)) { + delete settings[key]; + } + } + } + return settings; +} + +@Directive() +// tslint:disable-next-line:directive-class-suffix +export abstract class WidgetSettingsComponent extends PageComponent implements + IWidgetSettingsComponent, OnInit, AfterViewInit { + + aliasController: IAliasController; + + dashboard: Dashboard; + + widget: Widget; + + functionScopeVariables: string[]; + + settingsValue: WidgetSettings; + + private settingsSet = false; + + set settings(value: WidgetSettings) { + if (!value) { + this.settingsValue = this.defaultSettings(); + } else { + this.settingsValue = {...this.defaultSettings(), ...value}; + } + if (!this.settingsSet) { + this.settingsSet = true; + this.setupSettings(this.settingsValue); + } else { + this.updateSettings(this.settingsValue); + } + } + + get settings(): WidgetSettings { + return this.settingsValue; + } + + settingsChangedEmitter = new EventEmitter(); + settingsChanged = this.settingsChangedEmitter.asObservable(); + + protected constructor(@Inject(Store) protected store: Store) { + super(store); + } + + ngOnInit() {} + + ngAfterViewInit(): void { + setTimeout(() => { + if (!this.validateSettings()) { + this.settingsChangedEmitter.emit(null); + } + }, 0); + } + + validate() { + this.onValidate(); + } + + protected setupSettings(settings: WidgetSettings) { + this.onSettingsSet(this.prepareInputSettings(settings)); + this.updateValidators(false); + for (const trigger of this.validatorTriggers()) { + const path = trigger.split('.'); + let control: AbstractControl = this.settingsForm(); + for (const part of path) { + control = control.get(part); + } + control.valueChanges.subscribe(() => { + this.updateValidators(true, trigger); + }); + } + this.settingsForm().valueChanges.subscribe((updated: any) => { + this.onSettingsChanged(this.prepareOutputSettings(updated)); + }); + } + + protected updateSettings(settings: WidgetSettings) { + settings = this.prepareInputSettings(settings); + this.settingsForm().reset(settings, {emitEvent: false}); + this.doUpdateSettings(this.settingsForm(), settings); + this.updateValidators(false); + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + } + + protected validatorTriggers(): string[] { + return []; + } + + protected onSettingsChanged(updated: WidgetSettings) { + this.settingsValue = removeEmptyWidgetSettings(updated); + if (this.validateSettings()) { + this.settingsChangedEmitter.emit(this.settingsValue); + } else { + this.settingsChangedEmitter.emit(null); + } + } + + protected doUpdateSettings(settingsForm: FormGroup, settings: WidgetSettings) { + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return settings; + } + + protected prepareOutputSettings(settings: any): WidgetSettings { + return settings; + } + + protected validateSettings(): boolean { + return this.settingsForm().valid; + } + + protected onValidate() {} + + protected abstract settingsForm(): FormGroup; + + protected abstract onSettingsSet(settings: WidgetSettings); + + protected defaultSettings(): WidgetSettings { + return {}; + } + +} diff --git a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts new file mode 100644 index 0000000..c7eb999 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts @@ -0,0 +1,27 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { WidgetsBundleId } from '@shared/models/id/widgets-bundle-id'; + +export interface WidgetsBundle extends BaseData, ExportableEntity { + tenantId: TenantId; + alias: string; + title: string; + image: string; + description: string; +} diff --git a/ui-ngx/src/app/shared/models/window-message.model.ts b/ui-ngx/src/app/shared/models/window-message.model.ts new file mode 100644 index 0000000..f720f26 --- /dev/null +++ b/ui-ngx/src/app/shared/models/window-message.model.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 type WindowMessageType = 'widgetException' | 'widgetEditModeInited' | 'widgetEditUpdated' | 'openDashboardMessage' | 'reloadUserMessage' | 'toggleDashboardLayout'; + +export interface WindowMessage { + type: WindowMessageType; + data?: any; +} + +export interface OpenDashboardMessage { + dashboardId: string; + state?: string; + hideToolbar?: boolean; + embedded?: boolean; +} + +export interface ReloadUserMessage { + accessToken: string; + refreshToken: string; +} diff --git a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts new file mode 100644 index 0000000..befd9a7 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts @@ -0,0 +1,27 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'enumToArray' +}) +export class EnumToArrayPipe implements PipeTransform { + transform(data: object): string[] { + const keys = Object.keys(data); + return keys.slice(keys.length / 2); + } +} diff --git a/ui-ngx/src/app/shared/pipe/file-size.pipe.ts b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts new file mode 100644 index 0000000..dc67cd2 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts @@ -0,0 +1,56 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; + +type unit = 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB'; +type unitPrecisionMap = { + [u in unit]: number; +}; + +const defaultPrecisionMap: unitPrecisionMap = { + bytes: 0, + KB: 1, + MB: 1, + GB: 1, + TB: 2, + PB: 2 +}; + +@Pipe({ name: 'fileSize' }) +export class FileSizePipe implements PipeTransform { + private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + + transform(bytes: number = 0, precision: number | unitPrecisionMap = defaultPrecisionMap): string { + if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) { + return '?'; + } + + let unitIndex = 0; + + while (bytes >= 1024) { + bytes /= 1024; + unitIndex++; + } + + const unitSymbol = this.units[unitIndex]; + + if (typeof precision === 'number') { + return `${bytes.toFixed(+precision)} ${unitSymbol}`; + } + return `${bytes.toFixed(precision[unitSymbol])} ${unitSymbol}`; + } +} diff --git a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts new file mode 100644 index 0000000..0f8595f --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'highlight' }) +export class HighlightPipe implements PipeTransform { + transform(text: string, search): string { + const pattern = search + .replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + const regex = new RegExp('^' + pattern, 'i'); + + return search ? text.replace(regex, match => `${match}`) : text; + } +} diff --git a/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts new file mode 100644 index 0000000..1210435 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Inject, Pipe, PipeTransform } from '@angular/core'; +import { WINDOW } from '@core/services/window.service'; + +// @dynamic +@Pipe({ + name: 'keyboardShortcut' +}) +export class KeyboardShortcutPipe implements PipeTransform { + + constructor(@Inject(WINDOW) private window: Window) {} + + transform(value: string): string { + if (!value) { + return; + } + const keys = value.split('-'); + const isOSX = /Mac OS X/.test(this.window.navigator.userAgent); + + const seperator = (!isOSX || keys.length > 2) ? '+' : ''; + + const abbreviations = { + M: isOSX ? '⌘' : 'Ctrl', + A: isOSX ? 'Option' : 'Alt', + S: 'Shift' + }; + + return keys.map((key, index) => { + const last = index === keys.length - 1; + return last ? key : abbreviations[key]; + }).join(seperator); + } + +} diff --git a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts new file mode 100644 index 0000000..df185fe --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts @@ -0,0 +1,76 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; + +@Pipe({ + name: 'milliSecondsToTimeString' +}) +export class MillisecondsToTimeStringPipe implements PipeTransform { + + constructor(private translate: TranslateService) { + } + + transform(millseconds: number, shortFormat = false): string { + let seconds = Math.floor(millseconds / 1000); + const days = Math.floor(seconds / 86400); + let hours = Math.floor((seconds % 86400) / 3600); + let minutes = Math.floor(((seconds % 86400) % 3600) / 60); + seconds = seconds % 60; + let timeString = ''; + if (shortFormat) { + if (days > 0) { + timeString += this.translate.instant('timewindow.short.days', {days}); + } + if (hours > 0) { + timeString += this.translate.instant('timewindow.short.hours', {hours}); + } + if (minutes > 0) { + timeString += this.translate.instant('timewindow.short.minutes', {minutes}); + } + if (seconds > 0) { + timeString += this.translate.instant('timewindow.short.seconds', {seconds}); + } + if (!timeString.length) { + timeString += this.translate.instant('timewindow.short.seconds', {seconds: 0}); + } + } else { + if (days > 0) { + timeString += this.translate.instant('timewindow.days', {days}); + } + if (hours > 0) { + if (timeString.length === 0 && hours === 1) { + hours = 0; + } + timeString += this.translate.instant('timewindow.hours', {hours}); + } + if (minutes > 0) { + if (timeString.length === 0 && minutes === 1) { + minutes = 0; + } + timeString += this.translate.instant('timewindow.minutes', {minutes}); + } + if (seconds > 0) { + if (timeString.length === 0 && seconds === 1) { + seconds = 0; + } + timeString += this.translate.instant('timewindow.seconds', {seconds}); + } + } + return timeString; + } +} diff --git a/ui-ngx/src/app/shared/pipe/nospace.pipe.ts b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts new file mode 100644 index 0000000..155f648 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'nospace' +}) +export class NospacePipe implements PipeTransform { + + transform(value: string, args?: any): string { + return (!value) ? '' : value.replace(/ /g, ''); + } + +} diff --git a/ui-ngx/src/app/shared/pipe/public-api.ts b/ui-ngx/src/app/shared/pipe/public-api.ts new file mode 100644 index 0000000..5e5e53b --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/public-api.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './enum-to-array.pipe'; +export * from './highlight.pipe'; +export * from './keyboard-shortcut.pipe'; +export * from './milliseconds-to-time-string.pipe'; +export * from './nospace.pipe'; +export * from './truncate.pipe'; +export * from './file-size.pipe'; +export * from './selectable-columns.pipe'; diff --git a/ui-ngx/src/app/shared/pipe/safe.pipe.ts b/ui-ngx/src/app/shared/pipe/safe.pipe.ts new file mode 100644 index 0000000..22cf9e4 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/safe.pipe.ts @@ -0,0 +1,37 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser'; + +@Pipe({ + name: 'safe' +}) +export class SafePipe implements PipeTransform { + + constructor(protected sanitizer: DomSanitizer) {} + + public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl { + switch (type) { + case 'html': return this.sanitizer.bypassSecurityTrustHtml(value); + case 'style': return this.sanitizer.bypassSecurityTrustStyle(value); + case 'script': return this.sanitizer.bypassSecurityTrustScript(value); + case 'url': return this.sanitizer.bypassSecurityTrustUrl(value); + case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value); + default: throw new Error(`Invalid safe type specified: ${type}`); + } + } +} diff --git a/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts new file mode 100644 index 0000000..e71ba26 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts @@ -0,0 +1,25 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; +import { DisplayColumn } from '@home/components/widget/lib/table-widget.models'; + +@Pipe({ name: 'selectableColumns' }) +export class SelectableColumnsPipe implements PipeTransform { + transform(allColumns: DisplayColumn[]): DisplayColumn[] { + return allColumns.filter(column => column.selectable); + } +} diff --git a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts new file mode 100644 index 0000000..edf55e1 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts @@ -0,0 +1,30 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; +import { isNumber, isObject } from '@core/utils'; + +@Pipe({name: 'tbJson'}) +export class TbJsonPipe implements PipeTransform { + transform(value: any): string { + if (isObject(value)) { + return JSON.stringify(value); + } else if (isNumber(value)) { + return value.toString(); + } + return value; + } +} diff --git a/ui-ngx/src/app/shared/pipe/truncate.pipe.ts b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts new file mode 100644 index 0000000..db2d0cc --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Pipe, PipeTransform } from '@angular/core'; +import { isString } from '@core/utils'; + +@Pipe({ name: 'truncate' }) +export class TruncatePipe implements PipeTransform { + transform(text: string, wordwise: boolean, max: any, tail: string): string { + if (!text) { return ''; } + if (isString(max)) { + max = parseInt(max, 10); + } + if (!max) { return text; } + if (text.length <= max) { return text; } + + text = text.substr(0, max); + if (wordwise) { + let lastspace = text.lastIndexOf(' '); + if (lastspace !== -1) { + if (text.charAt(lastspace - 1) === '.' || text.charAt(lastspace - 1) === ',') { + lastspace = lastspace - 1; + } + text = text.substr(0, lastspace); + } + } + return text + (tail || ' …'); + } +} diff --git a/ui-ngx/src/app/shared/public-api.ts b/ui-ngx/src/app/shared/public-api.ts new file mode 100644 index 0000000..f63b26e --- /dev/null +++ b/ui-ngx/src/app/shared/public-api.ts @@ -0,0 +1,20 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 * from './components/public-api'; +export * from './models/public-api'; +export * from './pipe/public-api'; +export * from './shared.module'; diff --git a/ui-ngx/src/app/shared/services/custom-paginator-intl.ts b/ui-ngx/src/app/shared/services/custom-paginator-intl.ts new file mode 100644 index 0000000..fa52a99 --- /dev/null +++ b/ui-ngx/src/app/shared/services/custom-paginator-intl.ts @@ -0,0 +1,40 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { MatPaginatorIntl } from '@angular/material/paginator'; +import { Subject } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable() +export class CustomPaginatorIntl implements MatPaginatorIntl { + constructor(private translate: TranslateService) {} + changes = new Subject(); + + firstPageLabel = this.translate.instant('paginator.first-page-label'); + itemsPerPageLabel = this.translate.instant('paginator.items-per-page'); + lastPageLabel = this.translate.instant('paginator.last-page-label'); + + nextPageLabel = this.translate.instant('paginator.next-page-label'); + previousPageLabel = this.translate.instant('paginator.previous-page-label'); + separator = this.translate.instant('paginator.items-per-page-separator'); + + getRangeLabel(page: number, pageSize: number, length: number): string { + const startNumber = page * pageSize + 1; + const endNumber = pageSize * (page + 1); + return `${startNumber} – ${endNumber > length ? length : endNumber} ${this.separator} ${length}`; + } +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts new file mode 100644 index 0000000..5c5d24f --- /dev/null +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -0,0 +1,506 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule, SecurityContext } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { FooterComponent } from '@shared/components/footer.component'; +import { LogoComponent } from '@shared/components/logo.component'; +import { TbSnackBarComponent, ToastDirective } from '@shared/components/toast.directive'; +import { BreadcrumbComponent } from '@shared/components/breadcrumb.component'; +import { FlowInjectionToken, NgxFlowModule } from '@flowjs/ngx-flow'; +import { NgxFlowchartModule } from 'ngx-flowchart'; +import Flow from '@flowjs/flow.js'; + +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatSliderModule } from '@angular/material/slider'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSortModule } from '@angular/material/sort'; +import { MatStepperModule } from '@angular/material/stepper'; +import { MatTableModule } from '@angular/material/table'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatListModule } from '@angular/material/list'; +import { DatetimeAdapter, MatDatetimepickerModule, MatNativeDatetimeModule } from '@mat-datetimepicker/core'; +import { NgxDaterangepickerMd } from 'ngx-daterangepicker-material'; +import { GridsterModule } from 'angular-gridster2'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { ShareModule as ShareButtonsModule } from 'ngx-sharebuttons'; +import { HotkeyModule } from 'angular2-hotkeys'; +import { ColorPickerModule } from 'ngx-color-picker'; +import { NgxHmCarouselModule } from 'ngx-hm-carousel'; +import { UserMenuComponent } from '@shared/components/user-menu.component'; +import { NospacePipe } from '@shared/pipe/nospace.pipe'; +import { TranslateModule } from '@ngx-translate/core'; +import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; +import { HelpComponent } from '@shared/components/help.component'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { TimewindowComponent } from '@shared/components/time/timewindow.component'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { TimewindowPanelComponent } from '@shared/components/time/timewindow-panel.component'; +import { TimeintervalComponent } from '@shared/components/time/timeinterval.component'; +import { DatetimePeriodComponent } from '@shared/components/time/datetime-period.component'; +import { EnumToArrayPipe } from '@shared/pipe/enum-to-array.pipe'; +import { ClipboardModule } from 'ngx-clipboard'; +import { ValueInputComponent } from '@shared/components/value-input.component'; +import { MarkdownModule, MarkedOptions } from 'ngx-markdown'; +import { MarkdownEditorComponent } from '@shared/components/markdown-editor.component'; +import { FullscreenDirective } from '@shared/components/fullscreen.directive'; +import { HighlightPipe } from '@shared/pipe/highlight.pipe'; +import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component'; +import { EntitySubTypeAutocompleteComponent } from '@shared/components/entity/entity-subtype-autocomplete.component'; +import { EntitySubTypeSelectComponent } from '@shared/components/entity/entity-subtype-select.component'; +import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; +import { EntityListComponent } from '@shared/components/entity/entity-list.component'; +import { EntityTypeSelectComponent } from '@shared/components/entity/entity-type-select.component'; +import { EntitySelectComponent } from '@shared/components/entity/entity-select.component'; +import { DatetimeComponent } from '@shared/components/time/datetime.component'; +import { EntityKeysListComponent } from '@shared/components/entity/entity-keys-list.component'; +import { SocialSharePanelComponent } from '@shared/components/socialshare-panel.component'; +import { RelationTypeAutocompleteComponent } from '@shared/components/relation/relation-type-autocomplete.component'; +import { EntityListSelectComponent } from '@shared/components/entity/entity-list-select.component'; +import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component'; +import { JsonObjectViewComponent, } from '@shared/components/json-object-view.component'; +import { FooterFabButtonsComponent } from '@shared/components/footer-fab-buttons.component'; +import { CircularProgressDirective } from '@shared/components/circular-progress.directive'; +import { + FabActionsDirective, + FabToolbarComponent, + FabTriggerDirective +} from '@shared/components/fab-toolbar.component'; +import { DashboardSelectPanelComponent } from '@shared/components/dashboard-select-panel.component'; +import { DashboardSelectComponent } from '@shared/components/dashboard-select.component'; +import { WidgetsBundleSelectComponent } from '@shared/components/widgets-bundle-select.component'; +import { KeyboardShortcutPipe } from '@shared/pipe/keyboard-shortcut.pipe'; +import { TbErrorComponent } from '@shared/components/tb-error.component'; +import { EntityTypeListComponent } from '@shared/components/entity/entity-type-list.component'; +import { EntitySubTypeListComponent } from '@shared/components/entity/entity-subtype-list.component'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { TbJsonPipe } from '@shared/pipe/tbJson.pipe'; +import { ColorPickerDialogComponent } from '@shared/components/dialog/color-picker-dialog.component'; +import { MatChipDraggableDirective } from '@shared/components/mat-chip-draggable.directive'; +import { ColorInputComponent } from '@shared/components/color-input.component'; +import { JsFuncComponent } from '@shared/components/js-func.component'; +import { JsonFormComponent } from '@shared/components/json-form/json-form.component'; +import { ConfirmDialogComponent } from '@shared/components/dialog/confirm-dialog.component'; +import { AlertDialogComponent } from '@shared/components/dialog/alert-dialog.component'; +import { TodoDialogComponent } from '@shared/components/dialog/todo-dialog.component'; +import { MaterialIconsDialogComponent } from '@shared/components/dialog/material-icons-dialog.component'; +import { MaterialIconSelectComponent } from '@shared/components/material-icon-select.component'; +import { ImageInputComponent } from '@shared/components/image-input.component'; +import { FileInputComponent } from '@shared/components/file-input.component'; +import { NodeScriptTestDialogComponent } from '@shared/components/dialog/node-script-test-dialog.component'; +import { MessageTypeAutocompleteComponent } from '@shared/components/message-type-autocomplete.component'; +import { JsonContentComponent } from '@shared/components/json-content.component'; +import { KeyValMapComponent } from '@shared/components/kv-map.component'; +import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component'; +import { TbHotkeysDirective } from '@shared/components/hotkeys.directive'; +import { NavTreeComponent } from '@shared/components/nav-tree.component'; +import { LedLightComponent } from '@shared/components/led-light.component'; +import { TbJsonToStringDirective } from '@shared/components/directives/tb-json-to-string.directive'; +import { JsonObjectEditDialogComponent } from '@shared/components/dialog/json-object-edit-dialog.component'; +import { HistorySelectorComponent } from '@shared/components/time/history-selector/history-selector.component'; +import { EntityGatewaySelectComponent } from '@shared/components/entity/entity-gateway-select.component'; +import { DndModule } from 'ngx-drag-drop'; +import { QueueAutocompleteComponent } from '@shared/components/queue/queue-autocomplete.component'; +import { ContactComponent } from '@shared/components/contact.component'; +import { TimezoneSelectComponent } from '@shared/components/time/timezone-select.component'; +import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { WidgetsBundleSearchComponent } from '@shared/components/widgets-bundle-search.component'; +import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; +import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; +import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/ota-package-autocomplete.component'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; +import { CopyButtonComponent } from '@shared/components/button/copy-button.component'; +import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component'; +import { HelpPopupComponent } from '@shared/components/help-popup.component'; +import { TbPopoverComponent, TbPopoverDirective } from '@shared/components/popover.component'; +import { TbStringTemplateOutletDirective } from '@shared/components/directives/sring-template-outlet.directive'; +import { TbComponentOutletDirective } from '@shared/components/directives/component-outlet.directive'; +import { HelpMarkdownComponent } from '@shared/components/help-markdown.component'; +import { MarkedOptionsService } from '@shared/components/marked-options.service'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens'; +import { TbMarkdownComponent } from '@shared/components/markdown.component'; +import { ProtobufContentComponent } from '@shared/components/protobuf-content.component'; +import { CssComponent } from '@shared/components/css.component'; +import { HtmlComponent } from '@shared/components/html.component'; +import { SafePipe } from '@shared/pipe/safe.pipe'; +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { MultipleImageInputComponent } from '@shared/components/multiple-image-input.component'; +import { BranchAutocompleteComponent } from '@shared/components/vc/branch-autocomplete.component'; +import { PhoneInputComponent } from '@shared/components/phone-input.component'; +import { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter'; +import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; +import { TbScriptLangComponent } from '@shared/components/script-lang.component'; + +export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { + return markedOptionsService; +} + +@NgModule({ + providers: [ + DatePipe, + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe, + TruncatePipe, + TbJsonPipe, + FileSizePipe, + SafePipe, + { + provide: FlowInjectionToken, + useValue: Flow + }, + { + provide: MAT_DATE_LOCALE, + useValue: 'en-GB' + }, + { provide: DatetimeAdapter, useClass: CustomDateAdapter }, + { provide: HELP_MARKDOWN_COMPONENT_TOKEN, useValue: HelpMarkdownComponent }, + { provide: SHARED_MODULE_TOKEN, useValue: SharedModule }, + { provide: MatPaginatorIntl, useClass: CustomPaginatorIntl }, + TbPopoverService + ], + declarations: [ + FooterComponent, + LogoComponent, + FooterFabButtonsComponent, + ToastDirective, + FullscreenDirective, + CircularProgressDirective, + MatChipDraggableDirective, + TbHotkeysDirective, + TbAnchorComponent, + TbPopoverComponent, + TbStringTemplateOutletDirective, + TbComponentOutletDirective, + TbPopoverDirective, + TbMarkdownComponent, + HelpComponent, + HelpMarkdownComponent, + HelpPopupComponent, + TbCheckboxComponent, + TbSnackBarComponent, + TbErrorComponent, + TbCheatSheetComponent, + BreadcrumbComponent, + UserMenuComponent, + TimewindowComponent, + TimewindowPanelComponent, + TimeintervalComponent, + QuickTimeIntervalComponent, + DashboardSelectComponent, + DashboardSelectPanelComponent, + DatetimePeriodComponent, + DatetimeComponent, + TimezoneSelectComponent, + ValueInputComponent, + DashboardAutocompleteComponent, + EntitySubTypeAutocompleteComponent, + EntitySubTypeSelectComponent, + EntitySubTypeListComponent, + EntityAutocompleteComponent, + EntityListComponent, + EntityTypeSelectComponent, + EntitySelectComponent, + EntityKeysListComponent, + EntityListSelectComponent, + EntityTypeListComponent, + QueueAutocompleteComponent, + RelationTypeAutocompleteComponent, + SocialSharePanelComponent, + JsonObjectEditComponent, + JsonObjectViewComponent, + JsonContentComponent, + JsFuncComponent, + CssComponent, + HtmlComponent, + FabTriggerDirective, + FabActionsDirective, + FabToolbarComponent, + WidgetsBundleSelectComponent, + ConfirmDialogComponent, + AlertDialogComponent, + TodoDialogComponent, + ColorPickerDialogComponent, + MaterialIconsDialogComponent, + ColorInputComponent, + MaterialIconSelectComponent, + NodeScriptTestDialogComponent, + JsonFormComponent, + ImageInputComponent, + MultipleImageInputComponent, + FileInputComponent, + MessageTypeAutocompleteComponent, + KeyValMapComponent, + NavTreeComponent, + LedLightComponent, + MarkdownEditorComponent, + NospacePipe, + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe, + TruncatePipe, + TbJsonPipe, + FileSizePipe, + SafePipe, + SelectableColumnsPipe, + KeyboardShortcutPipe, + TbJsonToStringDirective, + JsonObjectEditDialogComponent, + HistorySelectorComponent, + EntityGatewaySelectComponent, + ContactComponent, + OtaPackageAutocompleteComponent, + WidgetsBundleSearchComponent, + CopyButtonComponent, + TogglePasswordComponent, + ProtobufContentComponent, + BranchAutocompleteComponent, + PhoneInputComponent, + TbScriptLangComponent + ], + imports: [ + CommonModule, + RouterModule, + TranslateModule, + MatButtonModule, + MatButtonToggleModule, + MatCheckboxModule, + MatIconModule, + MatCardModule, + MatProgressBarModule, + MatInputModule, + MatSnackBarModule, + MatSidenavModule, + MatToolbarModule, + MatMenuModule, + MatGridListModule, + MatDialogModule, + MatSelectModule, + MatTooltipModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + MatTabsModule, + MatRadioModule, + MatSlideToggleModule, + MatDatepickerModule, + MatNativeDatetimeModule, + MatDatetimepickerModule, + NgxDaterangepickerMd.forRoot(), + MatSliderModule, + MatExpansionModule, + MatStepperModule, + MatAutocompleteModule, + MatChipsModule, + MatListModule, + DragDropModule, + GridsterModule, + ClipboardModule, + FlexLayoutModule.withConfig({addFlexToParent: false}), + FormsModule, + ReactiveFormsModule, + OverlayModule, + ShareButtonsModule, + HotkeyModule, + ColorPickerModule, + NgxHmCarouselModule, + DndModule, + NgxFlowModule, + NgxFlowchartModule, + // ngx-markdown + MarkdownModule.forRoot({ + sanitize: SecurityContext.NONE, + markedOptions: { + provide: MarkedOptions, + useFactory: MarkedOptionsFactory, + deps: [MarkedOptionsService] + } + }) + ], + exports: [ + FooterComponent, + LogoComponent, + FooterFabButtonsComponent, + ToastDirective, + FullscreenDirective, + CircularProgressDirective, + MatChipDraggableDirective, + TbHotkeysDirective, + TbAnchorComponent, + TbStringTemplateOutletDirective, + TbComponentOutletDirective, + TbPopoverDirective, + TbMarkdownComponent, + HelpComponent, + HelpMarkdownComponent, + HelpPopupComponent, + TbCheckboxComponent, + TbErrorComponent, + TbCheatSheetComponent, + BreadcrumbComponent, + UserMenuComponent, + TimewindowComponent, + TimewindowPanelComponent, + TimeintervalComponent, + QuickTimeIntervalComponent, + DashboardSelectComponent, + DatetimePeriodComponent, + DatetimeComponent, + TimezoneSelectComponent, + DashboardAutocompleteComponent, + EntitySubTypeAutocompleteComponent, + EntitySubTypeSelectComponent, + EntitySubTypeListComponent, + EntityAutocompleteComponent, + EntityListComponent, + EntityTypeSelectComponent, + EntitySelectComponent, + EntityKeysListComponent, + EntityListSelectComponent, + EntityTypeListComponent, + QueueAutocompleteComponent, + RelationTypeAutocompleteComponent, + SocialSharePanelComponent, + JsonObjectEditComponent, + JsonObjectViewComponent, + JsonContentComponent, + JsFuncComponent, + CssComponent, + HtmlComponent, + FabTriggerDirective, + FabActionsDirective, + FabToolbarComponent, + WidgetsBundleSelectComponent, + ValueInputComponent, + MatButtonModule, + MatButtonToggleModule, + MatCheckboxModule, + MatIconModule, + MatCardModule, + MatProgressBarModule, + MatInputModule, + MatSnackBarModule, + MatSidenavModule, + MatToolbarModule, + MatMenuModule, + MatGridListModule, + MatDialogModule, + MatSelectModule, + MatTooltipModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatProgressSpinnerModule, + MatDividerModule, + MatTabsModule, + MatRadioModule, + MatSlideToggleModule, + MatDatepickerModule, + MatNativeDatetimeModule, + MatDatetimepickerModule, + NgxDaterangepickerMd, + MatSliderModule, + MatExpansionModule, + MatStepperModule, + MatAutocompleteModule, + MatChipsModule, + MatListModule, + DragDropModule, + GridsterModule, + ClipboardModule, + FlexLayoutModule, + FormsModule, + ReactiveFormsModule, + OverlayModule, + ShareButtonsModule, + HotkeyModule, + ColorPickerModule, + NgxHmCarouselModule, + DndModule, + NgxFlowchartModule, + MarkdownModule, + ConfirmDialogComponent, + AlertDialogComponent, + TodoDialogComponent, + ColorPickerDialogComponent, + MaterialIconsDialogComponent, + ColorInputComponent, + MaterialIconSelectComponent, + NodeScriptTestDialogComponent, + JsonFormComponent, + ImageInputComponent, + MultipleImageInputComponent, + FileInputComponent, + MessageTypeAutocompleteComponent, + KeyValMapComponent, + NavTreeComponent, + LedLightComponent, + MarkdownEditorComponent, + NospacePipe, + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe, + TruncatePipe, + TbJsonPipe, + KeyboardShortcutPipe, + FileSizePipe, + SafePipe, + SelectableColumnsPipe, + RouterModule, + TranslateModule, + JsonObjectEditDialogComponent, + HistorySelectorComponent, + EntityGatewaySelectComponent, + ContactComponent, + OtaPackageAutocompleteComponent, + WidgetsBundleSearchComponent, + CopyButtonComponent, + TogglePasswordComponent, + ProtobufContentComponent, + BranchAutocompleteComponent, + PhoneInputComponent, + TbScriptLangComponent + ] +}) +export class SharedModule { } diff --git a/ui-ngx/src/assets/copy-code-icon.svg b/ui-ngx/src/assets/copy-code-icon.svg new file mode 100644 index 0000000..8283b85 --- /dev/null +++ b/ui-ngx/src/assets/copy-code-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/fonts/MaterialIcons-Regular.ttf b/ui-ngx/src/assets/fonts/MaterialIcons-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..be4be29c8664ae8199bd0fa6c8df9e8e140d354f GIT binary patch literal 337868 zcmb@v2Y3}#*SEiB=Iq%+Z%Kgk(3Bzyf>JElP_Ro;5d{P*h$xB`#fG4$h=7P8mTd(BP+pZEFR|Mgwhmvi0u&FtA_?X}kInKS1E#1oMk zGC~3wa_-sZbT*Zwn~0Z0Y5KXBT-;5Pr84^w`&G~F*5l0N%l14hazhJ|f_fKsJHB=0 zp$+}}5R@f<(tMhb$%euDiW>^HjcmOJh)x@^xCBEefC zHRlbwwST|rkIsEcB&n}R&W*S9yL(79$GepJCs03oaKBrwODk(TLd3^j@7^J|4!z^> zNNLUXC3?KvHV>Qp*rdwLmD$GIRZ;!%c?EI|WydBCxO&l*UbfULKdQBLI-R6tT*B=1 z=kOys&VTu#t{)fsa`;fZ9XsQwnnB#3m*}W`SpIffKN_}}$$1>&Cam=f&4d9=j5pu%e!xgs0rAJ$q zEyP8v^uilnq7vJEWl{OIC+RY+eA}1mGO>I+kjsR~#`P0N)iIJP&C1I|wnQ$JesYIg zC%4N@Yz>kw(qC?s!7@~iqmS$42Dy{cejM)^*BVMaJ)SMC@cRk;K3O`)C3nPnJ1wr` zXzTcBwsc_oKgY;EY7|$aEu)Yfs|HsJxTl=#Zdx+c<+kb=H$Xs-6wW}Rl=W_H` zEET|Kz7RQB9Uk z(gW=jt8TabXK$bkq*QCUn$2ZIyfyQmGCit2XuBR9)#I*?_Sl^E6XK(~*MOQ@tKyZ+ zeslH|x2=68<0IFrZflKB{MHfFhEB}8hunn@j9XTeYA-sPt2Z#-Yfd!vxcj+^UIxZ! zO^N%|R?XPbQS`@oLVMA&PSTz7PU6!}%PPj#aoo0+IZIA0Ki*t&{yD1s>C4qQ(n3db zeN@cL)pEV+v5MCOyyspzeh;Wo{!*Xm9FDwnoG5Oo*6U0?>bS8^q&%6UmEsz@HmFS# zI6JecIYAz0PhV25Ztv*d6KJ8HcQr=Pt}P`!Xsxw076Xa9+R!8JTl>E&-qK@gyL(-Z zCd>J8J?%l;>uj}7+wwiN?#Pe&zQP|mTQ_T$WjeNQ&6JPh(MQBJb@mlMIMcorNQ?v3cwp?NQ5gd}oETC|PQY zMnwCm7uV3^I*%*S%(%q$ND+0kmkNt7i0x6Ej<&+J)*f{&8}lCa^sLZO%HZ zd83h5PiY@LZ2m4)HNJg6mJWHTx^d)H)r-mAm#@=JuU8owY}7uGshD zTFwp~uemggd)E=P^vFxI&YeL$xU##tI+nK4+39#)SXW$M75%GSdc@7&m1z< zxHkG*YrAq+s&(B65|1I&Q9oBfD~>qt4P?I)*K9qmkxb?~>AX5HR_(i290ga}T-wAf zbTnt3D_6fe?{-49Z$0kLauR~Huf6JdnoM1_Lyzcht*icbedrq2i5|6;*6+kBIxt=bI&t^Yg?+W42PK-{6?Ujr zc-NKvzx-V>1829h*_G*bCoEPwwM6Hx+ZAzkwX{UbbgnvEUEQ>m`Y(#hbj$UnTW
    ?Ybv9RcK)s_@ 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 G{l5W@A9e8n literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/fonts/material-icons.css b/ui-ngx/src/assets/fonts/material-icons.css new file mode 100644 index 0000000..3809ea3 --- /dev/null +++ b/ui-ngx/src/assets/fonts/material-icons.css @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), + local('MaterialIcons-Regular'), + url(MaterialIcons-Regular.ttf) format('truetype'); +} + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; /* Preferred icon size */ + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + + /* Support for all WebKit browsers. */ + -webkit-font-smoothing: antialiased; + /* Support for Safari and Chrome. */ + text-rendering: optimizeLegibility; + + /* Support for Firefox. */ + -moz-osx-font-smoothing: grayscale; + + /* Support for IE. */ + font-feature-settings: 'liga'; +} diff --git a/ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md b/ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md new file mode 100644 index 0000000..7090ae6 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/alarm_custom_schedule_format.md @@ -0,0 +1,79 @@ +#### Custom schedule format + +An attribute with a dynamic value for a custom schedule format must have JSON in the following format: + +```javascript +{ + "timezone": "Europe/Kiev", + "items": [ + { + "dayOfWeek": 1, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 2, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 3, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 4, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 5, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 6, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 7, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + } + ] +} +``` + +
      +
    • +timezone: this value is used to designate the timezone you are using. +
    • +
    • +items: the array of values representing the days on which the schedule will be active. +
    • +
    + +One array item contains such fields: +
      +
    • +dayOfWeek: this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +enabled: this boolean value, used to designate that the specified day in the schedule will be enabled. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. diff --git a/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md b/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md new file mode 100644 index 0000000..cbf6b3a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/alarm_specific_schedule_format.md @@ -0,0 +1,31 @@ +#### Specific schedule format + +An attribute with a dynamic value for a specific schedule format must have JSON in the following format: + +```javascript +{ + "daysOfWeek": [ + 2, + 4 + ], + "endsOn": 0, + "startsOn": 0, + "timezone": "Europe/Kiev" +} +``` + +
      +
    • +timezone: this value is used to designate the timezone you are using. +
    • +
    • +daysOfWeek: this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. diff --git a/ui-ngx/src/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md new file mode 100644 index 0000000..eed5cb2 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md @@ -0,0 +1,69 @@ +#### Clear alarm details builder function + +
    +
    + +*function Details(msg, metadata, msgType): any* + +JavaScript function generating **Alarm Details** object to update existing one. Used for storing additional parameters inside Alarm.
    +For example you can save attribute name/value pair from Original Message payload or Metadata. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Should return the object presenting **Alarm Details**. + +Current Alarm Details can be accessed via `metadata.prevAlarmDetails`.
    +**Note** that `metadata.prevAlarmDetails` is a raw String field, and it needs to be converted into object using this construction: + +```javascript +var details = {}; +if (metadata.prevAlarmDetails) { + details = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + delete metadata.prevAlarmDetails; + //now metadata is the same as it comes IN this rule node +} +{:copy-code} +``` + +
    + +##### Examples + +
      +
    • +Take count property from previous Alarm and increment it.
      +Also put temperature attribute from inbound Message payload into Alarm details: +
    • +
    + +```javascript +var details = {temperature: msg.temperature, count: 1}; + +if (metadata.prevAlarmDetails) { + var prevDetails = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + delete metadata.prevAlarmDetails; + if (prevDetails.count) { + details.count = prevDetails.count + 1; + } +} + +return details; +{:copy-code} +``` + +
    + +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/). + +You can see the real life example, where this node is used, in the next tutorial: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md b/ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md new file mode 100644 index 0000000..296e5b7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md @@ -0,0 +1,11 @@ +
    + +Enable 'debug mode' for your rule node to see the messages that arrive in near real-time. +See Debugging for more information. \ No newline at end of file diff --git a/ui-ngx/src/assets/help/en_US/rulenode/create_alarm_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/create_alarm_node_script_fn.md new file mode 100644 index 0000000..36bdd21 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/create_alarm_node_script_fn.md @@ -0,0 +1,70 @@ +#### Create alarm details builder function + +
    +
    + +*function Details(msg, metadata, msgType): any* + +JavaScript function generating **Alarm Details** object. Used for storing additional parameters inside Alarm.
    +For example you can save attribute name/value pair from Original Message payload or Metadata. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Should return the object presenting **Alarm Details**. + +**Optional:** previous Alarm Details can be accessed via `metadata.prevAlarmDetails`.
    +If previous Alarm does not exist, this field will not be present in Metadata. **Note** that `metadata.prevAlarmDetails`
    +is a raw String field, and it needs to be converted into object using this construction: + +```javascript +var details = {}; +if (metadata.prevAlarmDetails != null) { + details = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + metadata.remove('prevAlarmDetails'); + //now metadata is the same as it comes IN this rule node +} +{:copy-code} +``` + +
    + +##### Examples + +
      +
    • +Take count property from previous Alarm and increment it.
      +Also put temperature attribute from inbound Message payload into Alarm details: +
    • +
    + +```javascript +var details = {temperature: msg.temperature, count: 1}; + +if (metadata.prevAlarmDetails != null) { + var prevDetails = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + metadata.remove('prevAlarmDetails'); + if (prevDetails.count != null) { + details.count = prevDetails.count + 1; + } +} + +return details; +{:copy-code} +``` + +
    + +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/). + +You can see the real life example, where this node is used, in the next tutorial: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/filter_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/filter_node_script_fn.md new file mode 100644 index 0000000..8625908 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/filter_node_script_fn.md @@ -0,0 +1,84 @@ +#### Filter message function + +
    +
    + +*function Filter(msg, metadata, msgType): boolean* + +JavaScript function defines a boolean expression based on the incoming Message and Metadata. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Must return a `boolean` value. If `true` - routes Message to subsequent rule nodes that are related via **True** link, +otherwise sends Message to rule nodes related via **False** link. +Uses 'Failure' link in case of any failures to evaluate the expression. + +
    + +##### Examples + +* Forward all messages with `temperature` value greater than `20` to the **True** link and all other messages to the **False** link. + Assumes that incoming messages always contain the 'temperature' field: + +```javascript +return msg.temperature > 20; +{:copy-code} +``` + + +Example of the rule chain configuration: + +![image](${helpBaseUrl}/help/images/rulenode/examples/filter-node.png) + +* Same as above, but checks that the message has 'temperature' field to **avoid failures** on unexpected messages: + +```javascript +return typeof msg.temperature !== 'undefined' && msg.temperature > 20; +{:copy-code} +``` + +* Forward all messages with type `ATTRIBUTES_UPDATED` to the **True** chain and all other messages to the **False** chain: + +```javascript +if (msgType === 'ATTRIBUTES_UPDATED') { + return true; +} else { + return false; +} +{:copy-code} +``` + +
      +
    • Send message to the True chain if the following conditions are met.
      Message type is POST_TELEMETRY_REQUEST and
      +(device type is vehicle and humidity value is greater than 50 or
      +device type is controller and temperature value is greater than 20 and humidity value is greater than 60).
      +Otherwise send message to the False chain: +
    • +
    + +```javascript +if (msgType === 'POST_TELEMETRY_REQUEST') { + if (metadata.deviceType === 'vehicle') { + return msg.humidity > 50; + } else if (metadata.deviceType === 'controller') { + return msg.temperature > 20 && msg.humidity > 60; + } +} +return false; +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in those tutorials: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/#node-a-filter-script) +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-filter-script-node) + +
    +
    + diff --git a/ui-ngx/src/assets/help/en_US/rulenode/generator_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/generator_node_script_fn.md new file mode 100644 index 0000000..ac8b6ab --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/generator_node_script_fn.md @@ -0,0 +1,112 @@ +#### Message generator function + +
    +
    + +*function Generate(prevMsg, prevMetadata, prevMsgType): {msg: object, metadata: object, msgType: string}* + +JavaScript function generating new Message using previous Message payload, Metadata and Message type as input arguments. + +**Parameters:** + +
      +
    • prevMsg: {[key: string]: any} - is a previously generated Message payload key/value object. +
    • +
    • prevMetadata: {[key: string]: string} - is a previously generated Message metadata key/value object. +
    • +
    • prevMsgType: string - is a previously generated string Message type. See MessageType enum for common used values. +
    • +
    + +**Returns:** + +Should return the object with the following structure: + +```javascript +{ + msg: {[key: string]: any}, + metadata: {[key: string]: string}, + msgType: string +} +``` + +All fields in resulting object are mandatory. + +
    + +##### Examples + +* Generate message of type `POST_TELEMETRY_REQUEST` with random `temperature` value from `18` to `32`: + +```javascript +var temperature = 18 + Math.random() * (32 - 18); +// Round to at most 2 decimal places (optional) +temperature = Math.round( temperature * 100 ) / 100; +var msg = { temperature: temperature }; +return { msg: msg, metadata: {}, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + + +
      +
    • +Generate message of type POST_TELEMETRY_REQUEST with temp value 42, +humidity value 77
      +and metadata with field data having value 40: +
    • +
    + +```javascript +var msg = { temp: 42, humidity: 77 }; +var metadata = { data: 40 }; +return { msg: msg, metadata: metadata, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + +
      +
    • +Generate message of type POST_TELEMETRY_REQUEST with temperature value
      +increasing and decreasing linearly in the range from 18 to 32: +
    • +
    + +```javascript +var lower = 18; +var upper = 32; +var isDecrement = 'false'; +var temperature = lower; + +// Get previous values + +if (typeof prevMetadata !== 'undefined' && + typeof prevMetadata.isDecrement !== 'undefined') { + isDecrement = prevMetadata.isDecrement; +} +if (typeof prevMsg !== 'undefined' && + typeof prevMsg.temperature !== 'undefined') { + temperature = prevMsg.temperature; +} + +if (isDecrement === 'true') { + temperature--; + if (temperature <= lower) { + isDecrement = 'false'; + temperature = lower; + } +} else { + temperature++; + if (temperature >= upper) { + isDecrement = 'true'; + temperature = upper; + } +} + +var msg = { temperature: temperature }; +var metadata = { isDecrement: isDecrement }; + +return { msg: msg, metadata: metadata, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/log_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/log_node_script_fn.md new file mode 100644 index 0000000..ababc52 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/log_node_script_fn.md @@ -0,0 +1,37 @@ +#### Message to string function + +
    +
    + +*function toString(msg, metadata, msgType): string* + +JavaScript function transforming incoming Message to String for further logging to the server log file. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Should return `string` value used for logging to the server log file. + +
    + +##### Examples + +* Create string message containing incoming message and incoming metadata values: + +```javascript +return 'Incoming message:\n' + JSON.stringify(msg) + + '\nIncoming metadata:\n' + JSON.stringify(metadata); +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in this tutorial: + +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#log-unknown-request) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md new file mode 100644 index 0000000..a31eae7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md @@ -0,0 +1,101 @@ +#### Switch message function + +
    +
    + +*function Switch(msg, metadata, msgType): string[]* + +JavaScript function computing **an array of Link names** to forward the incoming Message. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Should return an array of `string` values presenting **link names** that the Rule Engine should use to further route the incoming Message.
    +If the result is an empty array - message will not be routed to any Node and will be immediately +acknowledged. + +
    + +##### Examples + +
      +
    • +Forward all messages with temperature value greater than 30 to the 'High temperature' chain,
      +with temperature value lower than 20 to the 'Low temperature' chain and all other messages
      +to the 'Other' chain: +
    • +
    + +```javascript +if (msg.temperature > 30) { + return ['High temperature']; +} else if (msg.temperature < 20) { + return ['Low temperature']; +} else { + return ['Other']; +} +{:copy-code} +``` + +Example of the rule chain configuration: + +![image](${helpBaseUrl}/help/images/rulenode/examples/switch-node.png) + +
      +
    • + For messages with type POST_TELEMETRY_REQUEST: +
        +
      • + if temperature value lower than 18 forward to the 'Low temperature telemetry' chain, +
      • +
      • + otherwise to the 'Normal temperature telemetry' chain. +
      • +
      + For messages with type POST_ATTRIBUTES_REQUEST:
      +
        +
      • + if currentState value is IDLE forward to the 'Idle State' and 'Update State Attribute' chains, +
      • +
      • + if currentState value is RUNNING forward to the 'Running State' and 'Update State Attribute' chains, +
      • +
      • + otherwise to the 'Unknown State' chain. +
      • +
      + For all other message types - discard the message (do not route to any Node). +
    • +
    + +```javascript +if (msgType === 'POST_TELEMETRY_REQUEST') { + if (msg.temperature < 18) { + return ['Low Temperature Telemetry']; + } else { + return ['Normal Temperature Telemetry']; + } +} else if (msgType === 'POST_ATTRIBUTES_REQUEST') { + if (msg.currentState === 'IDLE') { + return ['Idle State', 'Update State Attribute']; + } else if (msg.currentState === 'RUNNING') { + return ['Running State', 'Update State Attribute']; + } else { + return ['Unknown State']; + } +} +return []; +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in this tutorial: + +- [Data function based on telemetry from 2 devices{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/clear_alarm_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/clear_alarm_node_script_fn.md new file mode 100644 index 0000000..311086e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/clear_alarm_node_script_fn.md @@ -0,0 +1,69 @@ +#### Clear alarm details builder function + +
    +
    + +*function Details(msg, metadata, msgType): any* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function generating **Alarm Details** object to update existing one. Used for storing additional parameters inside Alarm.
    +For example you can save attribute name/value pair from Original Message payload or Metadata. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Should return the object presenting **Alarm Details**. + +Current Alarm Details can be accessed via `metadata.prevAlarmDetails`.
    +**Note** that `metadata.prevAlarmDetails` is a raw String field, and it needs to be converted into object using this construction: + +```javascript +var details = {}; +if (metadata.prevAlarmDetails != null) { + details = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + metadata.remove('prevAlarmDetails'); + //now metadata is the same as it comes IN this rule node +} +{:copy-code} +``` + +
    + +##### Examples + +
      +
    • +Take count property from previous Alarm and increment it.
      +Also put temperature attribute from inbound Message payload into Alarm details: +
    • +
    + +```javascript +var details = {temperature: msg.temperature, count: 1}; + +if (metadata.prevAlarmDetails != null) { + var prevDetails = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + metadata.remove('prevAlarmDetails'); + if (prevDetails.count != null) { + details.count = prevDetails.count + 1; + } +} + +return details; +{:copy-code} +``` + +
    + +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/). + +You can see the real life example, where this node is used, in the next tutorial: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md new file mode 100644 index 0000000..296e5b7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md @@ -0,0 +1,11 @@ +
      +
    • msg: {[key: string]: any} - is a Message payload key/value object. +
    • +
    • metadata: {[key: string]: string} - is a Message metadata key/value map, where both keys and values are strings. +
    • +
    • msgType: string - is a string containing Message type. See MessageType enum for common used values. +
    • +
    + +Enable 'debug mode' for your rule node to see the messages that arrive in near real-time. +See Debugging for more information. \ No newline at end of file diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/create_alarm_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/create_alarm_node_script_fn.md new file mode 100644 index 0000000..afc9051 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/create_alarm_node_script_fn.md @@ -0,0 +1,69 @@ +#### Create alarm details builder function + +
    +
    + +*function Details(msg, metadata, msgType): any* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function generating **Alarm Details** object. Used for storing additional parameters inside Alarm.
    +For example you can save attribute name/value pair from Original Message payload or Metadata. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Should return the object presenting **Alarm Details**. + +**Optional:** previous Alarm Details can be accessed via `metadata.prevAlarmDetails`.
    +If previous Alarm does not exist, this field will not be present in Metadata. **Note** that `metadata.prevAlarmDetails`
    +is a raw String field, and it needs to be converted into object using this construction: + +```javascript +var details = {}; +if (metadata.prevAlarmDetails) { + // remove prevAlarmDetails from metadata + delete metadata.prevAlarmDetails; + details = JSON.parse(metadata.prevAlarmDetails); +} +{:copy-code} +``` + +
    + +##### Examples + +
      +
    • +Take count property from previous Alarm and increment it.
      +Also put temperature attribute from inbound Message payload into Alarm details: +
    • +
    + +```javascript +var details = {temperature: msg.temperature, count: 1}; + +if (metadata.prevAlarmDetails) { + var prevDetails = JSON.parse(metadata.prevAlarmDetails); + // remove prevAlarmDetails from metadata + delete metadata.prevAlarmDetails; + if (prevDetails.count) { + details.count = prevDetails.count + 1; + } +} + +return details; +{:copy-code} +``` + +
    + +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/). + +You can see the real life example, where this node is used, in the next tutorial: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/filter_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/filter_node_script_fn.md new file mode 100644 index 0000000..4435aca --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/filter_node_script_fn.md @@ -0,0 +1,84 @@ +#### Filter message function + +
    +
    + +*function Filter(msg, metadata, msgType): boolean* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function defines a boolean expression based on the incoming Message and Metadata. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Must return a `boolean` value. If `true` - routes Message to subsequent rule nodes that are related via **True** link, +otherwise sends Message to rule nodes related via **False** link. +Uses 'Failure' link in case of any failures to evaluate the expression. + +
    + +##### Examples + +* Forward all messages with `temperature` value greater than `20` to the **True** link and all other messages to the **False** link. + Assumes that incoming messages always contain the 'temperature' field: + +```javascript +return msg.temperature > 20; +{:copy-code} +``` + + +Example of the rule chain configuration: + +![image](${helpBaseUrl}/help/images/rulenode/examples/filter-node.png) + +* Same as above, but checks that the message has 'temperature' field to **avoid failures** on unexpected messages: + +```javascript +return msg.temperature != null && msg.temperature > 20; +{:copy-code} +``` + +* Forward all messages with type `ATTRIBUTES_UPDATED` to the **True** chain and all other messages to the **False** chain: + +```javascript +if (msgType == 'ATTRIBUTES_UPDATED') { + return true; +} else { + return false; +} +{:copy-code} +``` + +
      +
    • Send message to the True chain if the following conditions are met.
      Message type is POST_TELEMETRY_REQUEST and
      +(device type is vehicle and humidity value is greater than 50 or
      +device type is controller and temperature value is greater than 20 and humidity value is greater than 60).
      +Otherwise send message to the False chain: +
    • +
    + +```javascript +if (msgType == 'POST_TELEMETRY_REQUEST') { + if (metadata.deviceType == 'vehicle') { + return msg.humidity > 50; + } else if (metadata.deviceType == 'controller') { + return msg.temperature > 20 && msg.humidity > 60; + } +} +return false; +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in those tutorials: + +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/#node-a-filter-script) +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-filter-script-node) + +
    +
    + diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/generator_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/generator_node_script_fn.md new file mode 100644 index 0000000..7618ac4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/generator_node_script_fn.md @@ -0,0 +1,110 @@ +#### Message generator function + +
    +
    + +*function Generate(prevMsg, prevMetadata, prevMsgType): {msg: object, metadata: object, msgType: string}* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function generating new Message using previous Message payload, Metadata and Message type as input arguments. + +**Parameters:** + +
      +
    • prevMsg: {[key: string]: any} - is a previously generated Message payload key/value object. +
    • +
    • prevMetadata: {[key: string]: string} - is a previously generated Message metadata key/value object. +
    • +
    • prevMsgType: string - is a previously generated string Message type. See MessageType enum for common used values. +
    • +
    + +**Returns:** + +Should return the object with the following structure: + +```javascript +{ + msg: {[key: string]: any}, + metadata: {[key: string]: string}, + msgType: string +} +``` + +All fields in resulting object are mandatory. + +
    + +##### Examples + +* Generate message of type `POST_TELEMETRY_REQUEST` with random `temperature` value from `18` to `32`: + +```javascript +var temperature = 18 + Math.random() * (32 - 18); +// Round to at most 2 decimal places (optional) +temperature = Math.round( temperature * 100 ) / 100; +var msg = { temperature: temperature }; +return { msg: msg, metadata: {}, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + + +
      +
    • +Generate message of type POST_TELEMETRY_REQUEST with temp value 42, +humidity value 77
      +and metadata with field data having value 40: +
    • +
    + +```javascript +var msg = { temp: 42, humidity: 77 }; +var metadata = { data: 40 }; +return { msg: msg, metadata: metadata, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + +
      +
    • +Generate message of type POST_TELEMETRY_REQUEST with temperature value
      +increasing and decreasing linearly in the range from 18 to 32: +
    • +
    + +```javascript +var lower = 18; +var upper = 32; +var isDecrement = 'false'; +var temperature = lower; + +// Get previous values + +if (prevMetadata != null && prevMetadata.isDecrement != null) { + isDecrement = prevMetadata.isDecrement; +} +if (prevMsg != null && prevMsg.temperature != null) { + temperature = prevMsg.temperature; +} + +if (isDecrement == 'true') { + temperature--; + if (temperature <= lower) { + isDecrement = 'false'; + temperature = lower; + } +} else { + temperature++; + if (temperature >= upper) { + isDecrement = 'true'; + temperature = upper; + } +} + +var msg = { temperature: temperature }; +var metadata = { isDecrement: isDecrement }; + +return { msg: msg, metadata: metadata, msgType: "POST_TELEMETRY_REQUEST" }; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/log_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/log_node_script_fn.md new file mode 100644 index 0000000..bab38ad --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/log_node_script_fn.md @@ -0,0 +1,37 @@ +#### Message to string function + +
    +
    + +*function toString(msg, metadata, msgType): string* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function transforming incoming Message to String for further logging to the server log file. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Should return `string` value used for logging to the server log file. + +
    + +##### Examples + +* Create string message containing incoming message and incoming metadata values: + +```javascript +return 'Incoming message:\n' + JSON.stringify(msg) + + '\nIncoming metadata:\n' + JSON.stringify(metadata); +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in this tutorial: + +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#log-unknown-request) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md new file mode 100644 index 0000000..7fd3b74 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md @@ -0,0 +1,101 @@ +#### Switch message function + +
    +
    + +*function Switch(msg, metadata, msgType): string[]* + +[TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function computing **an array of Link names** to forward the incoming Message. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Should return an array of `string` values presenting **link names** that the Rule Engine should use to further route the incoming Message.
    +If the result is an empty array - message will not be routed to any Node and will be immediately +acknowledged. + +
    + +##### Examples + +
      +
    • +Forward all messages with temperature value greater than 30 to the 'High temperature' chain,
      +with temperature value lower than 20 to the 'Low temperature' chain and all other messages
      +to the 'Other' chain: +
    • +
    + +```javascript +if (msg.temperature > 30) { + return ['High temperature']; +} else if (msg.temperature < 20) { + return ['Low temperature']; +} else { + return ['Other']; +} +{:copy-code} +``` + +Example of the rule chain configuration: + +![image](${helpBaseUrl}/help/images/rulenode/examples/switch-node.png) + +
      +
    • + For messages with type POST_TELEMETRY_REQUEST: +
        +
      • + if temperature value lower than 18 forward to the 'Low temperature telemetry' chain, +
      • +
      • + otherwise to the 'Normal temperature telemetry' chain. +
      • +
      + For messages with type POST_ATTRIBUTES_REQUEST:
      +
        +
      • + if currentState value is IDLE forward to the 'Idle State' and 'Update State Attribute' chains, +
      • +
      • + if currentState value is RUNNING forward to the 'Running State' and 'Update State Attribute' chains, +
      • +
      • + otherwise to the 'Unknown State' chain. +
      • +
      + For all other message types - discard the message (do not route to any Node). +
    • +
    + +```javascript +if (msgType == 'POST_TELEMETRY_REQUEST') { + if (msg.temperature < 18) { + return ['Low Temperature Telemetry']; + } else { + return ['Normal Temperature Telemetry']; + } +} else if (msgType == 'POST_ATTRIBUTES_REQUEST') { + if (msg.currentState == 'IDLE') { + return ['Idle State', 'Update State Attribute']; + } else if (msg.currentState == 'RUNNING') { + return ['Running State', 'Update State Attribute']; + } else { + return ['Unknown State']; + } +} +return []; +{:copy-code} +``` + +
    + +You can see real life example, how to use this node in this tutorial: + +- [Data function based on telemetry from 2 devices{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/tbel/transformation_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/tbel/transformation_node_script_fn.md new file mode 100644 index 0000000..44ada19 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/tbel/transformation_node_script_fn.md @@ -0,0 +1,89 @@ +#### Transform message function + +
    +
    + +*function Transform(msg, metadata, msgType): {msg: object, metadata: object, msgType: string}* + +The [TBEL{:target="_blank"}](${siteBaseUrl}/docs/user-guide/tbel/) function to transform input Message payload, Metadata and/or Message type to the output message. + +**Parameters:** + +{% include rulenode/tbel/common_node_script_args %} + +**Returns:** + +Should return the object with the following structure: + +```javascript +{ + msg?: {[key: string]: any}, + metadata?: {[key: string]: string}, + msgType?: string +} +``` + +All fields in resulting object are optional and will be taken from original message if not specified. + +
    + +##### Examples + +* Add sum of two fields ('a' and 'b') as a new field ('sum') of existing message: + +```javascript +if(msg.a != null && msg.b != null){ + msg.sum = msg.a + msg.b; +} +return {msg: msg}; +``` + +* Transform value of the 'temperature' field from °F to °C: + +```javascript +msg.temperature = toFixed((msg.temperature - 32) * 5 / 9, 1); +return {msg: msg}; +``` + +* Replace the incoming message with the new message that contains only one field - count of properties in the original message: + +```javascript +var newMsg = { + count: msg.size() +}; +return {msg: newMsg}; +``` + +
      +
    • Change message type to CUSTOM_UPDATE,
      add additional attribute version into payload with value v1.1,
      change sensorType attribute value in Metadata to roomTemp:
    • +
    + +```javascript +var newType = "CUSTOM_UPDATE"; +msg.version = "v1.1"; +metadata.sensorType = "roomTemp" +return {msg: msg, metadata: metadata, msgType: newType}; +{:copy-code} +``` + +* Replace the incoming message with **two** new messages that contain only one field - sum or difference of properties in the original message: + +```javascript +var sum = msg.a + msg.b; +var diff = msg.a - msg.b; + +return [ + {msg: {sum: sum}, metadata: metadata, msgType: msgType}, + {msg: {difference: diff}, metadata: metadata, msgType: msgType} + ]; +``` + +
    + +You can see real life example, how to use this node in those tutorials: + +- [Transform incoming telemetry{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/) +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/rulenode/transformation_node_script_fn.md b/ui-ngx/src/assets/help/en_US/rulenode/transformation_node_script_fn.md new file mode 100644 index 0000000..e7f054f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/transformation_node_script_fn.md @@ -0,0 +1,89 @@ +#### Transform message function + +
    +
    + +*function Transform(msg, metadata, msgType): {msg: object, metadata: object, msgType: string}* + +The JavaScript function to transform input Message payload, Metadata and/or Message type to the output message. + +**Parameters:** + +{% include rulenode/common_node_script_args %} + +**Returns:** + +Should return the object with the following structure: + +```javascript +{ + msg?: {[key: string]: any}, + metadata?: {[key: string]: string}, + msgType?: string +} +``` + +All fields in resulting object are optional and will be taken from original message if not specified. + +
    + +##### Examples + +* Add sum of two fields ('a' and 'b') as a new field ('sum') of existing message: + +```javascript +if(typeof msg.a !== "undefined" && typeof msg.b !== "undefined"){ + msg.sum = msg.a + msg.b; +} +return {msg: msg}; +``` + +* Transform value of the 'temperature' field from °F to °C: + +```javascript +msg.temperature = (msg.temperature - 32) * 5 / 9; +return {msg: msg}; +``` + +* Replace the incoming message with the new message that contains only one field - count of properties in the original message: + +```javascript +var newMsg = { + count: Object.keys(msg).length +}; +return {msg: newMsg}; +``` + +
      +
    • Change message type to CUSTOM_UPDATE,
      add additional attribute version into payload with value v1.1,
      change sensorType attribute value in Metadata to roomTemp:
    • +
    + +```javascript +var newType = "CUSTOM_UPDATE"; +msg.version = "v1.1"; +metadata.sensorType = "roomTemp" +return {msg: msg, metadata: metadata, msgType: newType}; +{:copy-code} +``` + +* Replace the incoming message with **two** new messages that contain only one field - sum or difference of properties in the original message: + +```javascript +var sum = msg.a + msg.b; +var diff = msg.a - msg.b; + +return [ + {msg: {sum: sum}, metadata: metadata, msgType: msgType}, + {msg: {difference: diff}, metadata: metadata, msgType: msgType} + ]; +``` + +
    + +You can see real life example, how to use this node in those tutorials: + +- [Transform incoming telemetry{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/) +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node) + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md b/ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md new file mode 100644 index 0000000..c8f1b50 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/custom_action_args.md @@ -0,0 +1,19 @@ +
  • $event: MouseEvent - The MouseEvent object. Usually a result of a mouse click event. +
  • +
  • widgetContext: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
  • +
  • entityId: string - An optional string id of the target entity. +
  • +
  • entityName: string - An optional string name of the target entity. +
  • +
  • additionalParams: {[key: string]: any} - An optional key/value object holding additional entity parameters. + + +
  • +
  • entityLabel: string - An optional string label of the target entity. +
  • diff --git a/ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md new file mode 100644 index 0000000..2bd1438 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md @@ -0,0 +1,81 @@ +#### Custom action function + +
    +
    + +*function ($event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +A JavaScript function performing custom action. + +**Parameters:** + +
      + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md b/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md new file mode 100644 index 0000000..d13af3f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md @@ -0,0 +1,53 @@ +#### Additional params object + +
    +
    + +additionalParams: {[key: string]: any} + +An optional key/value object holding additional entity parameters depending on widget type and action source: + +
      +
    • Entities table widget (On row click or Action cell button) - additionalParams: { entity: EntityData }: +
        +
      • entity: EntityData - An + EntityData object + presenting basic entity properties (ex. id, entityName) and
        provides access to other entity attributes/timeseries declared in widget datasource configuration. +
      • +
      +
    • +
    • Alarms table widget (On row click or Action cell button) - additionalParams: { alarm: AlarmDataInfo }: +
        +
      • alarm: AlarmDataInfo - An + AlarmDataInfo object + presenting basic alarm properties (ex. type, severity, originator, etc.) and
        provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration. +
      • +
      +
    • +
    • Timeseries table widget (On row click or Action cell button) - additionalParams: TimeseriesRow: +
        +
      • additionalParams: TimeseriesRow - A + TimeseriesRow object + presenting formattedTs (a string value of formatted timestamp) and
        timeseries values for each column declared in widget datasource configuration. +
      • +
      +
    • +
    • Entities hierarchy widget (On node selected) - additionalParams: { nodeCtx: HierarchyNodeContext }: +
        +
      • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
        (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
      • +
      +
    • +
    • Pie - Flot widget (On slice click) - additionalParams: TbFlotPlotItem: +
        +
      • additionalParams: TbFlotPlotItem - A + TbFlotPlotItem object + containing series field with information about datasource and
        data key of clicked pie slice. +
      • +
      +
    • +
    • All other widgets - does not provide additionalParams value. +
    • +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md new file mode 100644 index 0000000..e82eab1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md @@ -0,0 +1,151 @@ +#### Custom action (with HTML template) function + +
    +
    + +*function ($event, widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel): void* + +A JavaScript function performing custom action with defined HTML template to render dialog. + +**Parameters:** + +
      +
    • $event: MouseEvent - The MouseEvent object. Usually a result of a mouse click event. +
    • +
    • widgetContext: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • entityId: string - An optional string id of the target entity. +
    • +
    • entityName: string - An optional string name of the target entity. +
    • +
    • htmlTemplate: string - An optional HTML template string defined in HTML tab.
      Used to render custom dialog (see Examples for more details). +
    • +
    • additionalParams: {[key: string]: any} - An optional key/value object holding additional entity parameters. + + +
    • +
    • entityLabel: string - An optional string label of the target entity. +
    • +
    + +
    + +##### Examples + +###### Display dialog to create a device or an asset + +
    + +
    +
    + +
    + +
    +
    + +###### Display dialog to edit a device or an asset + +
    + +
    +
    + +
    + +
    +
    + +###### Display dialog to created new user + +
    + +
    +
    + +
    + +
    +
    + +###### Display dialog to add/edit image in entity attribute + +
    + +
    +
    + +
    + +
    +
    + +###### Display dialog to clone device + +
    + +
    +
    + +
    + +
    +
    + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md new file mode 100644 index 0000000..567e093 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md @@ -0,0 +1,164 @@ +#### HTML template of dialog to create a device or an asset + +```html +{:code-style="max-height: 400px;"} +
    + +

    Add entity

    + + +
    + + +
    +
    +
    + + Entity Name + + + Entity name is required. + + + + Entity Label + + +
    +
    + + + +
    +
    +
    + + Latitude + + + + Longitude + + +
    +
    + + Address + + + + Owner + + +
    +
    + + Integer Value + + + Invalid integer value. + + +
    + + + + +
    +
    +
    +
    +
    Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md new file mode 100644 index 0000000..5dda1b5 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md @@ -0,0 +1,136 @@ +#### Function displaying dialog to create a device or an asset + +```javascript +{:code-style="max-height: 400px;"} +let $injector = widgetContext.$scope.$injector; +let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); +let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); + +openAddEntityDialog(); + +function openAddEntityDialog() { + customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe(); +} + +function AddEntityDialogController(instance) { + let vm = instance; + + vm.allowedEntityTypes = ['ASSET', 'DEVICE']; + vm.entitySearchDirection = { + from: "FROM", + to: "TO" + } + + vm.addEntityFormGroup = vm.fb.group({ + entityName: ['', [vm.validators.required]], + entityType: ['DEVICE'], + entityLabel: [null], + type: ['', [vm.validators.required]], + attributes: vm.fb.group({ + latitude: [null], + longitude: [null], + address: [null], + owner: [null], + number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], + booleanValue: [null] + }), + relations: vm.fb.array([]) + }); + + vm.cancel = function () { + vm.dialogRef.close(null); + }; + + vm.relations = function () { + return vm.addEntityFormGroup.get('relations'); + }; + + vm.addRelation = function () { + vm.relations().push(vm.fb.group({ + relatedEntity: [null, [vm.validators.required]], + relationType: [null, [vm.validators.required]], + direction: [null, [vm.validators.required]] + })); + }; + + vm.removeRelation = function (index) { + vm.relations().removeAt(index); + vm.relations().markAsDirty(); + }; + + vm.save = function () { + vm.addEntityFormGroup.markAsPristine(); + saveEntityObservable().subscribe( + function (entity) { + widgetContext.rxjs.forkJoin([ + saveAttributes(entity.id), + saveRelations(entity.id) + ]).subscribe( + function () { + widgetContext.updateAliases(); + vm.dialogRef.close(null); + } + ); + } + ); + }; + + function saveEntityObservable() { + const formValues = vm.addEntityFormGroup.value; + let entity = { + name: formValues.entityName, + type: formValues.type, + label: formValues.entityLabel + }; + if (formValues.entityType == 'ASSET') { + return assetService.saveAsset(entity); + } else if (formValues.entityType == 'DEVICE') { + return deviceService.saveDevice(entity); + } + } + + function saveAttributes(entityId) { + let attributes = vm.addEntityFormGroup.get('attributes').value; + let attributesArray = []; + for (let key in attributes) { + if (attributes[key] !== null) { + attributesArray.push({key: key, value: attributes[key]}); + } + } + if (attributesArray.length > 0) { + return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); + } + return widgetContext.rxjs.of([]); + } + + function saveRelations(entityId) { + let relations = vm.addEntityFormGroup.get('relations').value; + let tasks = []; + for (let i = 0; i < relations.length; i++) { + let relation = { + type: relations[i].relationType, + typeGroup: 'COMMON' + }; + if (relations[i].direction == 'FROM') { + relation.to = relations[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = relations[i].relatedEntity; + } + tasks.push(entityRelationService.saveRelation(relation)); + } + if (tasks.length > 0) { + return widgetContext.rxjs.forkJoin(tasks); + } + return widgetContext.rxjs.of([]); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md new file mode 100644 index 0000000..4e3cd0c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md @@ -0,0 +1,196 @@ +#### HTML template of dialog to edit a device or an asset + +```html +{:code-style="max-height: 400px;"} +
    + +

    Edit

    + + +
    + + +
    +
    +
    + + Entity Name + + + + Entity Label + + +
    +
    + + Entity Type + + + + Type + + +
    +
    +
    + + Latitude + + + + Longitude + + +
    +
    + + Address + + + + Owner + + +
    +
    + + Integer Value + + + Invalid integer value. + + +
    + + + + +
    +
    +
    +
    +
    Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    New Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md new file mode 100644 index 0000000..b2a6a63 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md @@ -0,0 +1,224 @@ +#### Function displaying dialog to edit a device or an asset + +```javascript +{:code-style="max-height: 400px;"} +let $injector = widgetContext.$scope.$injector; +let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); +let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); +let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); + +openEditEntityDialog(); + +function openEditEntityDialog() { + customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe(); +} + +function EditEntityDialogController(instance) { + let vm = instance; + + vm.entityName = entityName; + vm.entityType = entityId.entityType; + vm.entitySearchDirection = { + from: "FROM", + to: "TO" + }; + vm.attributes = {}; + vm.oldRelationsData = []; + vm.relationsToDelete = []; + vm.entity = {}; + + vm.editEntityFormGroup = vm.fb.group({ + entityName: ['', [vm.validators.required]], + entityType: [null], + entityLabel: [null], + type: ['', [vm.validators.required]], + attributes: vm.fb.group({ + latitude: [null], + longitude: [null], + address: [null], + owner: [null], + number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], + booleanValue: [false] + }), + oldRelations: vm.fb.array([]), + relations: vm.fb.array([]) + }); + + getEntityInfo(); + + vm.cancel = function() { + vm.dialogRef.close(null); + }; + + vm.relations = function() { + return vm.editEntityFormGroup.get('relations'); + }; + + vm.oldRelations = function() { + return vm.editEntityFormGroup.get('oldRelations'); + }; + + vm.addRelation = function() { + vm.relations().push(vm.fb.group({ + relatedEntity: [null, [vm.validators.required]], + relationType: [null, [vm.validators.required]], + direction: [null, [vm.validators.required]] + })); + }; + + function addOldRelation() { + vm.oldRelations().push(vm.fb.group({ + relatedEntity: [{value: null, disabled: true}, [vm.validators.required]], + relationType: [{value: null, disabled: true}, [vm.validators.required]], + direction: [{value: null, disabled: true}, [vm.validators.required]] + })); + } + + vm.removeRelation = function(index) { + vm.relations().removeAt(index); + vm.relations().markAsDirty(); + }; + + vm.removeOldRelation = function(index) { + vm.oldRelations().removeAt(index); + vm.relationsToDelete.push(vm.oldRelationsData[index]); + vm.oldRelations().markAsDirty(); + }; + + vm.save = function() { + vm.editEntityFormGroup.markAsPristine(); + widgetContext.rxjs.forkJoin([ + saveAttributes(entityId), + saveRelations(entityId), + saveEntity() + ]).subscribe( + function () { + widgetContext.updateAliases(); + vm.dialogRef.close(null); + } + ); + }; + + function getEntityAttributes(attributes) { + for (var i = 0; i < attributes.length; i++) { + vm.attributes[attributes[i].key] = attributes[i].value; + } + } + + function getEntityRelations(relations) { + let relationsFrom = relations[0]; + let relationsTo = relations[1]; + for (let i=0; i < relationsFrom.length; i++) { + let relation = { + direction: 'FROM', + relationType: relationsFrom[i].type, + relatedEntity: relationsFrom[i].to + }; + vm.oldRelationsData.push(relation); + addOldRelation(); + } + for (let i=0; i < relationsTo.length; i++) { + let relation = { + direction: 'TO', + relationType: relationsTo[i].type, + relatedEntity: relationsTo[i].from + }; + vm.oldRelationsData.push(relation); + addOldRelation(); + } + } + + function getEntityInfo() { + widgetContext.rxjs.forkJoin([ + entityRelationService.findInfoByFrom(entityId), + entityRelationService.findInfoByTo(entityId), + attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE'), + entityService.getEntity(entityId.entityType, entityId.id) + ]).subscribe( + function (data) { + getEntityRelations(data.slice(0,2)); + getEntityAttributes(data[2]); + vm.entity = data[3]; + vm.editEntityFormGroup.patchValue({ + entityName: vm.entity.name, + entityType: vm.entityType, + entityLabel: vm.entity.label, + type: vm.entity.type, + attributes: vm.attributes, + oldRelations: vm.oldRelationsData + }, {emitEvent: false}); + } + ); + } + + function saveEntity() { + const formValues = vm.editEntityFormGroup.value; + if (vm.entity.label !== formValues.entityLabel){ + vm.entity.label = formValues.entityLabel; + if (formValues.entityType == 'ASSET') { + return assetService.saveAsset(vm.entity); + } else if (formValues.entityType == 'DEVICE') { + return deviceService.saveDevice(vm.entity); + } + } + return widgetContext.rxjs.of([]); + } + + function saveAttributes(entityId) { + let attributes = vm.editEntityFormGroup.get('attributes').value; + let attributesArray = []; + for (let key in attributes) { + if (attributes[key] !== vm.attributes[key]) { + attributesArray.push({key: key, value: attributes[key]}); + } + } + if (attributesArray.length > 0) { + return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); + } + return widgetContext.rxjs.of([]); + } + + function saveRelations(entityId) { + let relations = vm.editEntityFormGroup.get('relations').value; + let tasks = []; + for(let i=0; i < relations.length; i++) { + let relation = { + type: relations[i].relationType, + typeGroup: 'COMMON' + }; + if (relations[i].direction == 'FROM') { + relation.to = relations[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = relations[i].relatedEntity; + } + tasks.push(entityRelationService.saveRelation(relation)); + } + for (let i=0; i < vm.relationsToDelete.length; i++) { + let relation = { + type: vm.relationsToDelete[i].relationType + }; + if (vm.relationsToDelete[i].direction == 'FROM') { + relation.to = vm.relationsToDelete[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = vm.relationsToDelete[i].relatedEntity; + } + tasks.push(entityRelationService.deleteRelation(relation.from, relation.type, relation.to)); + } + if (tasks.length > 0) { + return widgetContext.rxjs.forkJoin(tasks); + } + return widgetContext.rxjs.of([]); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_back_first_and_open_state.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_back_first_and_open_state.md new file mode 100644 index 0000000..c6e8e33 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_back_first_and_open_state.md @@ -0,0 +1,27 @@ +#### Function go back to the first state, after this go to the target state + +```javascript +{:code-style="max-height: 400px;"} +var stateIndex = widgetContext.stateController.getStateIndex(); +while (stateIndex > 0) { + stateIndex -= 1; + backToPrevState(stateIndex); +} +openDashboardState('devices'); + +function backToPrevState(stateIndex) { + widgetContext.stateController.navigatePrevState(stateIndex); +} + +function openDashboardState(statedId) { + var currentState = widgetContext.stateController.getStateId(); + if (currentState !== statedId) { + var params = {}; + widgetContext.stateController.updateState(statedId, params, false); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_copy_access_token.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_copy_access_token.md new file mode 100644 index 0000000..975883f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_copy_access_token.md @@ -0,0 +1,44 @@ +#### Function copy device access token to buffer + +```javascript +{:code-style="max-height: 400px;"} +var $injector = widgetContext.$scope.$injector; +var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +var $translate = $injector.get(widgetContext.servicesMap.get('translate')); +var $scope = widgetContext.$scope; +if (entityId.id && entityId.entityType === 'DEVICE') { + deviceService.getDeviceCredentials(entityId.id, true).subscribe( + (deviceCredentials) => { + var credentialsId = deviceCredentials.credentialsId; + if (copyToClipboard(credentialsId)) { + $scope.showSuccessToast($translate.instant('device.accessTokenCopiedMessage'), 750, "top", "left"); + } + } + ); +} + +function copyToClipboard(text) { + if (window.clipboardData && window.clipboardData.setData) { + return window.clipboardData.setData("Text", text); + } + else if (document.queryCommandSupported && document.queryCommandSupported("copy")) { + var textarea = document.createElement("textarea"); + textarea.textContent = text; + textarea.style.position = "fixed"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } + catch (ex) { + console.warn("Copy to clipboard failed.", ex); + return false; + } + document.body.removeChild(textarea); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_delete_device_confirm.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_delete_device_confirm.md new file mode 100644 index 0000000..83ba86c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_delete_device_confirm.md @@ -0,0 +1,33 @@ +#### Function delete device after confirmation + +```javascript +var $injector = widgetContext.$scope.$injector; +var dialogs = $injector.get(widgetContext.servicesMap.get('dialogs')); +var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); + +openDeleteDeviceDialog(); + +function openDeleteDeviceDialog() { + var title = 'Are you sure you want to delete the device ' + entityName + '?'; + var content = 'Be careful, after the confirmation, the device and all related data will become unrecoverable!'; + dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe( + function(result) { + if (result) { + deleteDevice(); + } + } + ); +} + +function deleteDevice() { + deviceService.deleteDevice(entityId.id).subscribe( + function() { + widgetContext.updateAliases(); + } + ); +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_display_alert.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_display_alert.md new file mode 100644 index 0000000..bf6ed2b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_display_alert.md @@ -0,0 +1,35 @@ +#### Function display alert dialog with entity information + +```javascript +{:code-style="max-height: 400px;"} +var title; +var content; +if (entityName) { + title = entityName + ' details'; + content = 'Entity name: ' + entityName; + if (additionalParams && additionalParams.entity) { + var entity = additionalParams.entity; + if (entity.id) { + content += '
    Entity type: ' + entity.id.entityType; + } + if (!isNaN(entity.temperature) && entity.temperature !== '') { + content += '
    Temperature: ' + entity.temperature + ' °C'; + } + } +} else { + title = 'No entity information available'; + content = 'No entity information available'; +} + +showAlertDialog(title, content); + +function showAlertDialog(title, content) { + setTimeout(function() { + widgetContext.dialogs.alert(title, content).subscribe(); + }, 100); +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_open_state_save_parameters.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_open_state_save_parameters.md new file mode 100644 index 0000000..18bd3f5 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_open_state_save_parameters.md @@ -0,0 +1,34 @@ +#### Function open state conditionally with saving particular state parameters + +```javascript +{:code-style="max-height: 400px;"} +var entitySubType; +var $injector = widgetContext.$scope.$injector; +$injector.get(widgetContext.servicesMap.get('entityService')).getEntity(entityId.entityType, entityId.id) + .subscribe(function(data) { + entitySubType = data.type; + if (entitySubType == 'energy meter') { + openDashboardStates('energy_meter_details_view'); + } else if (entitySubType == 'thermometer') { + openDashboardStates('thermometer_details_view'); + } + }); + +function openDashboardStates(statedId) { + var stateParams = widgetContext.stateController.getStateParams(); + var params = { + entityId: entityId, + entityName: entityName + }; + + if (stateParams.city) { + params.city = stateParams.city; + } + + widgetContext.stateController.openState(statedId, params, false); +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_return_previous_state.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_return_previous_state.md new file mode 100644 index 0000000..77f65ed --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_action/custom_action_return_previous_state.md @@ -0,0 +1,18 @@ +#### Function return to the previous state + +```javascript +{:code-style="max-height: 400px;"} +let stateIndex = widgetContext.stateController.getStateIndex(); +if (stateIndex > 0) { + stateIndex -= 1; + backToPrevState(stateIndex); +} + +function backToPrevState(stateIndex) { + widgetContext.stateController.navigatePrevState(stateIndex); +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_html.md new file mode 100644 index 0000000..7f93d62 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_html.md @@ -0,0 +1,46 @@ +#### HTML template of dialog to clone device + +```html +{:code-style="max-height: 400px;"} +
    + +

    Clone device: {{ deviceName }}

    + + +
    + + +
    +
    + + Clone device name + + + Clone device name is required + + +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_js.md new file mode 100644 index 0000000..2ee9160 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_clone_device_js.md @@ -0,0 +1,56 @@ +#### Function displaying dialog to clone device + +```javascript +{:code-style="max-height: 400px;"} +const $injector = widgetContext.$scope.$injector; +const customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +const attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +const deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +const rxjs = widgetContext.rxjs; + +openCloneDeviceDialog(); + +function openCloneDeviceDialog() { + customDialog.customDialog(htmlTemplate, CloneDeviceDialogController).subscribe(); +} + +function CloneDeviceDialogController(instance) { + let vm = instance; + vm.deviceName = entityName; + + vm.cloneDeviceFormGroup = vm.fb.group({ + cloneName: ['', [vm.validators.required]] + }); + + vm.save = function() { + deviceService.getDevice(entityId.id).pipe( + rxjs.mergeMap((origDevice) => { + let cloneDevice = { + name: vm.cloneDeviceFormGroup.get('cloneName').value, + type: origDevice.type + }; + return deviceService.saveDevice(cloneDevice).pipe( + rxjs.mergeMap((newDevice) => { + return attributeService.getEntityAttributes(origDevice.id, 'SERVER_SCOPE').pipe( + rxjs.mergeMap((origAttributes) => { + return attributeService.saveEntityAttributes(newDevice.id, 'SERVER_SCOPE', origAttributes); + }) + ); + }) + ); + }) + ).subscribe(() => { + widgetContext.updateAliases(); + vm.dialogRef.close(null); + }); + }; + + vm.cancel = function() { + vm.dialogRef.close(null); + }; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_html.md new file mode 100644 index 0000000..567e093 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_html.md @@ -0,0 +1,164 @@ +#### HTML template of dialog to create a device or an asset + +```html +{:code-style="max-height: 400px;"} +
    + +

    Add entity

    + + +
    + + +
    +
    +
    + + Entity Name + + + Entity name is required. + + + + Entity Label + + +
    +
    + + + +
    +
    +
    + + Latitude + + + + Longitude + + +
    +
    + + Address + + + + Owner + + +
    +
    + + Integer Value + + + Invalid integer value. + + +
    + + + + +
    +
    +
    +
    +
    Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_js.md new file mode 100644 index 0000000..5dda1b5 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_dialog_js.md @@ -0,0 +1,136 @@ +#### Function displaying dialog to create a device or an asset + +```javascript +{:code-style="max-height: 400px;"} +let $injector = widgetContext.$scope.$injector; +let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); +let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); + +openAddEntityDialog(); + +function openAddEntityDialog() { + customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe(); +} + +function AddEntityDialogController(instance) { + let vm = instance; + + vm.allowedEntityTypes = ['ASSET', 'DEVICE']; + vm.entitySearchDirection = { + from: "FROM", + to: "TO" + } + + vm.addEntityFormGroup = vm.fb.group({ + entityName: ['', [vm.validators.required]], + entityType: ['DEVICE'], + entityLabel: [null], + type: ['', [vm.validators.required]], + attributes: vm.fb.group({ + latitude: [null], + longitude: [null], + address: [null], + owner: [null], + number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], + booleanValue: [null] + }), + relations: vm.fb.array([]) + }); + + vm.cancel = function () { + vm.dialogRef.close(null); + }; + + vm.relations = function () { + return vm.addEntityFormGroup.get('relations'); + }; + + vm.addRelation = function () { + vm.relations().push(vm.fb.group({ + relatedEntity: [null, [vm.validators.required]], + relationType: [null, [vm.validators.required]], + direction: [null, [vm.validators.required]] + })); + }; + + vm.removeRelation = function (index) { + vm.relations().removeAt(index); + vm.relations().markAsDirty(); + }; + + vm.save = function () { + vm.addEntityFormGroup.markAsPristine(); + saveEntityObservable().subscribe( + function (entity) { + widgetContext.rxjs.forkJoin([ + saveAttributes(entity.id), + saveRelations(entity.id) + ]).subscribe( + function () { + widgetContext.updateAliases(); + vm.dialogRef.close(null); + } + ); + } + ); + }; + + function saveEntityObservable() { + const formValues = vm.addEntityFormGroup.value; + let entity = { + name: formValues.entityName, + type: formValues.type, + label: formValues.entityLabel + }; + if (formValues.entityType == 'ASSET') { + return assetService.saveAsset(entity); + } else if (formValues.entityType == 'DEVICE') { + return deviceService.saveDevice(entity); + } + } + + function saveAttributes(entityId) { + let attributes = vm.addEntityFormGroup.get('attributes').value; + let attributesArray = []; + for (let key in attributes) { + if (attributes[key] !== null) { + attributesArray.push({key: key, value: attributes[key]}); + } + } + if (attributesArray.length > 0) { + return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); + } + return widgetContext.rxjs.of([]); + } + + function saveRelations(entityId) { + let relations = vm.addEntityFormGroup.get('relations').value; + let tasks = []; + for (let i = 0; i < relations.length; i++) { + let relation = { + type: relations[i].relationType, + typeGroup: 'COMMON' + }; + if (relations[i].direction == 'FROM') { + relation.to = relations[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = relations[i].relatedEntity; + } + tasks.push(entityRelationService.saveRelation(relation)); + } + if (tasks.length > 0) { + return widgetContext.rxjs.forkJoin(tasks); + } + return widgetContext.rxjs.of([]); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_html.md new file mode 100644 index 0000000..c523258 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_html.md @@ -0,0 +1,81 @@ +#### HTML template of dialog to create new user + +```html +{:code-style="max-height: 400px;"} +
    + +

    Add new User

    + + +
    + + +
    +
    +
    +
    + + Email + + + Email is required + + + Invalid email format + + +
    +
    + + First Name + + +
    +
    + + Last Name + + +
    +
    + + User activation method + + + {{activationMethod.name}} + + + Please choose activation method + e.g. Send activation email + +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md new file mode 100644 index 0000000..22738c5 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md @@ -0,0 +1,137 @@ +#### Function displaying dialog to create new user + +```javascript +{:code-style="max-height: 400px;"} +const $injector = widgetContext.$scope.$injector; +const customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +const userService = $injector.get(widgetContext.servicesMap.get('userService')); +const $scope = widgetContext.$scope; +const rxjs = widgetContext.rxjs; + +openAddUserDialog(); + +function openAddUserDialog() { + customDialog.customDialog(htmlTemplate, AddUserDialogController).subscribe(); +} + +function AddUserDialogController(instance) { + let vm = instance; + + vm.currentUser = widgetContext.currentUser; + + vm.addEntityFormGroup = vm.fb.group({ + email: ['', [vm.validators.required, vm.validators.pattern(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\_\-0-9]+\.)+[a-zA-Z]{2,}))$/)]], + firstName: [''], + lastName: ['', ], + userActivationMethod: ['', [vm.validators.required]] + }); + + vm.activationMethods = [ + { + value: 'displayActivationLink', + name: 'Display activation link' + }, + { + value: 'sendActivationMail', + name: 'Send activation email' + } + ]; + + vm.cancel = function() { + vm.dialogRef.close(null); + }; + + vm.save = function() { + let formObj = vm.addEntityFormGroup.getRawValue(); + let attributes = []; + let sendActivationMail = false; + let newUser = { + email: formObj.email, + firstName: formObj.firstName, + lastName: formObj.lastName, + authority: 'TENANT_ADMIN' + }; + + if (formObj.userActivationMethod === 'sendActivationMail') { + sendActivationMail = true; + } + + userService.saveUser(newUser, sendActivationMail).pipe( + rxjs.mergeMap((user) => { + let activationObs; + if (sendActivationMail) { + activationObs = rxjs.of(null); + } else { + activationObs = userService.getActivationLink(user.id.id); + } + return activationObs.pipe( + rxjs.mergeMap((activationLink) => { + return activationLink ? customDialog.customDialog(activationLinkDialogTemplate, ActivationLinkDialogController, {"activationLink": activationLink}) : rxjs.of(null); + }) + ); + }) + ).subscribe(() => { + vm.dialogRef.close(null); + }); + }; +} + +function ActivationLinkDialogController(instance) { + let vm = instance; + + vm.activationLink = vm.data.activationLink; + + vm.onActivationLinkCopied = () => { + $scope.showSuccessToast("User activation link has been copied to clipboard", 1200, "bottom", "left", "activationLinkDialogContent"); + }; + + vm.close = () => { + vm.dialogRef.close(null); + }; +} + +let activationLinkDialogTemplate = `
    + +

    user.activation-link

    + + +
    + + +
    +
    +
    + +
    +
    {{ activationLink }}
    + +
    +
    +
    +
    + +
    +
    `; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_html.md new file mode 100644 index 0000000..4e3cd0c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_html.md @@ -0,0 +1,196 @@ +#### HTML template of dialog to edit a device or an asset + +```html +{:code-style="max-height: 400px;"} +
    + +

    Edit

    + + +
    + + +
    +
    +
    + + Entity Name + + + + Entity Label + + +
    +
    + + Entity Type + + + + Type + + +
    +
    +
    + + Latitude + + + + Longitude + + +
    +
    + + Address + + + + Owner + + +
    +
    + + Integer Value + + + Invalid integer value. + + +
    + + + + +
    +
    +
    +
    +
    Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    New Relations
    +
    +
    +
    +
    +
    + + Direction + + + + + + + Relation direction is required. + + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_js.md new file mode 100644 index 0000000..b2a6a63 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_dialog_js.md @@ -0,0 +1,224 @@ +#### Function displaying dialog to edit a device or an asset + +```javascript +{:code-style="max-height: 400px;"} +let $injector = widgetContext.$scope.$injector; +let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); +let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); +let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); + +openEditEntityDialog(); + +function openEditEntityDialog() { + customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe(); +} + +function EditEntityDialogController(instance) { + let vm = instance; + + vm.entityName = entityName; + vm.entityType = entityId.entityType; + vm.entitySearchDirection = { + from: "FROM", + to: "TO" + }; + vm.attributes = {}; + vm.oldRelationsData = []; + vm.relationsToDelete = []; + vm.entity = {}; + + vm.editEntityFormGroup = vm.fb.group({ + entityName: ['', [vm.validators.required]], + entityType: [null], + entityLabel: [null], + type: ['', [vm.validators.required]], + attributes: vm.fb.group({ + latitude: [null], + longitude: [null], + address: [null], + owner: [null], + number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], + booleanValue: [false] + }), + oldRelations: vm.fb.array([]), + relations: vm.fb.array([]) + }); + + getEntityInfo(); + + vm.cancel = function() { + vm.dialogRef.close(null); + }; + + vm.relations = function() { + return vm.editEntityFormGroup.get('relations'); + }; + + vm.oldRelations = function() { + return vm.editEntityFormGroup.get('oldRelations'); + }; + + vm.addRelation = function() { + vm.relations().push(vm.fb.group({ + relatedEntity: [null, [vm.validators.required]], + relationType: [null, [vm.validators.required]], + direction: [null, [vm.validators.required]] + })); + }; + + function addOldRelation() { + vm.oldRelations().push(vm.fb.group({ + relatedEntity: [{value: null, disabled: true}, [vm.validators.required]], + relationType: [{value: null, disabled: true}, [vm.validators.required]], + direction: [{value: null, disabled: true}, [vm.validators.required]] + })); + } + + vm.removeRelation = function(index) { + vm.relations().removeAt(index); + vm.relations().markAsDirty(); + }; + + vm.removeOldRelation = function(index) { + vm.oldRelations().removeAt(index); + vm.relationsToDelete.push(vm.oldRelationsData[index]); + vm.oldRelations().markAsDirty(); + }; + + vm.save = function() { + vm.editEntityFormGroup.markAsPristine(); + widgetContext.rxjs.forkJoin([ + saveAttributes(entityId), + saveRelations(entityId), + saveEntity() + ]).subscribe( + function () { + widgetContext.updateAliases(); + vm.dialogRef.close(null); + } + ); + }; + + function getEntityAttributes(attributes) { + for (var i = 0; i < attributes.length; i++) { + vm.attributes[attributes[i].key] = attributes[i].value; + } + } + + function getEntityRelations(relations) { + let relationsFrom = relations[0]; + let relationsTo = relations[1]; + for (let i=0; i < relationsFrom.length; i++) { + let relation = { + direction: 'FROM', + relationType: relationsFrom[i].type, + relatedEntity: relationsFrom[i].to + }; + vm.oldRelationsData.push(relation); + addOldRelation(); + } + for (let i=0; i < relationsTo.length; i++) { + let relation = { + direction: 'TO', + relationType: relationsTo[i].type, + relatedEntity: relationsTo[i].from + }; + vm.oldRelationsData.push(relation); + addOldRelation(); + } + } + + function getEntityInfo() { + widgetContext.rxjs.forkJoin([ + entityRelationService.findInfoByFrom(entityId), + entityRelationService.findInfoByTo(entityId), + attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE'), + entityService.getEntity(entityId.entityType, entityId.id) + ]).subscribe( + function (data) { + getEntityRelations(data.slice(0,2)); + getEntityAttributes(data[2]); + vm.entity = data[3]; + vm.editEntityFormGroup.patchValue({ + entityName: vm.entity.name, + entityType: vm.entityType, + entityLabel: vm.entity.label, + type: vm.entity.type, + attributes: vm.attributes, + oldRelations: vm.oldRelationsData + }, {emitEvent: false}); + } + ); + } + + function saveEntity() { + const formValues = vm.editEntityFormGroup.value; + if (vm.entity.label !== formValues.entityLabel){ + vm.entity.label = formValues.entityLabel; + if (formValues.entityType == 'ASSET') { + return assetService.saveAsset(vm.entity); + } else if (formValues.entityType == 'DEVICE') { + return deviceService.saveDevice(vm.entity); + } + } + return widgetContext.rxjs.of([]); + } + + function saveAttributes(entityId) { + let attributes = vm.editEntityFormGroup.get('attributes').value; + let attributesArray = []; + for (let key in attributes) { + if (attributes[key] !== vm.attributes[key]) { + attributesArray.push({key: key, value: attributes[key]}); + } + } + if (attributesArray.length > 0) { + return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); + } + return widgetContext.rxjs.of([]); + } + + function saveRelations(entityId) { + let relations = vm.editEntityFormGroup.get('relations').value; + let tasks = []; + for(let i=0; i < relations.length; i++) { + let relation = { + type: relations[i].relationType, + typeGroup: 'COMMON' + }; + if (relations[i].direction == 'FROM') { + relation.to = relations[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = relations[i].relatedEntity; + } + tasks.push(entityRelationService.saveRelation(relation)); + } + for (let i=0; i < vm.relationsToDelete.length; i++) { + let relation = { + type: vm.relationsToDelete[i].relationType + }; + if (vm.relationsToDelete[i].direction == 'FROM') { + relation.to = vm.relationsToDelete[i].relatedEntity; + relation.from = entityId; + } else { + relation.to = entityId; + relation.from = vm.relationsToDelete[i].relatedEntity; + } + tasks.push(entityRelationService.deleteRelation(relation.from, relation.type, relation.to)); + } + if (tasks.length > 0) { + return widgetContext.rxjs.forkJoin(tasks); + } + return widgetContext.rxjs.of([]); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_html.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_html.md new file mode 100644 index 0000000..3f2eff1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_html.md @@ -0,0 +1,42 @@ +#### HTML template of dialog to add/edit image in entity attribute + +```html +{:code-style="max-height: 400px;"} +
    + +

    Edit {{entityName}} image

    + + +
    + + +
    +
    +
    + +
    +
    +
    + + +
    +
    +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_js.md new file mode 100644 index 0000000..21acb89 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_edit_image_js.md @@ -0,0 +1,85 @@ +#### Function displaying dialog to add/edit image in entity attribute + +```javascript +{:code-style="max-height: 400px;"} +let $injector = widgetContext.$scope.$injector; +let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); +let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); +let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); +let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); + +openAddEntityDialog(); + +function openAddEntityDialog() { + customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe(() => {}); +} + +function AddEntityDialogController(instance) { + let vm = instance; + + vm.entityName = entityName; + + vm.attributes = {}; + + vm.editEntity = vm.fb.group({ + attributes: vm.fb.group({ + image: [null] + }) + }); + + getEntityInfo(); + + vm.cancel = function() { + vm.dialogRef.close(null); + }; + + vm.save = function() { + vm.loading = true; + saveAttributes(entityId).subscribe( + () => { + vm.dialogRef.close(null); + }, () =>{ + vm.loading = false; + } + ); + }; + + function getEntityAttributes(attributes) { + for (var i = 0; i < attributes.length; i++) { + vm.attributes[attributes[i].key] = attributes[i].value; + } + } + + function getEntityInfo() { + vm.loading = true; + attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE').subscribe( + function (data) { + getEntityAttributes(data); + + vm.editEntity.patchValue({ + attributes: vm.attributes + }, {emitEvent: false}); + vm.loading = false; + } + ); + } + + function saveAttributes(entityId) { + let attributes = vm.editEntity.get('attributes').value; + let attributesArray = []; + for (let key in attributes) { + if (attributes[key] !== vm.attributes[key]) { + attributesArray.push({key: key, value: attributes[key]}); + } + } + if (attributesArray.length > 0) { + return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); + } + return widgetContext.rxjs.of([]); + } +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md new file mode 100644 index 0000000..c504ac1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_location_fn.md @@ -0,0 +1,49 @@ +#### Get location function + +
    +
    + +*function getLocation($event, widgetContext, entityId, entityName, additionalParams, entityLabel): [number, number] | Observable<[number, number]>* + +A JavaScript function that should return location as array of two numbers (latitude, longitude) for further processing by mobile action.
    +Usually location can be obtained from entity attributes/telemetry. + +**Parameters:** + +
      + {% include widget/action/custom_action_args %} +
    + +**Returns:** + +Latitude and longitude as array of two numbers or Observable of array of two numbers. For example ```[37.689, -122.433]```. + +
    + +##### Examples + +* Return location from entity attributes: + +```javascript +return getLocationFromEntityAttributes(); + +function getLocationFromEntityAttributes() { + if (entityId) { + return widgetContext.attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE', ['latitude', 'longitude']) + .pipe(widgetContext.rxjs + .map(function(attributeData) { + var res = [0,0]; + if (attributeData && attributeData.length === 2) { + res[0] = attributeData.filter(function (data) { return data.key === 'latitude'})[0].value; + res[1] = attributeData.filter(function (data) { return data.key === 'longitude'})[0].value; + } + return res; + } + ) + ); + } else { + return [0,0]; + } +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md new file mode 100644 index 0000000..752d9a3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_get_phone_number_fn.md @@ -0,0 +1,48 @@ +#### Get phone number function + +
    +
    + +*function getPhoneNumber($event, widgetContext, entityId, entityName, additionalParams, entityLabel): number | string | Observable<number> | Observable<string>* + +A JavaScript function that should return phone number for further processing by mobile action.
    +Usually phone number can be obtained from entity attributes/telemetry. + +**Parameters:** + +
      + {% include widget/action/custom_action_args %} +
    + +**Returns:** + +String or numeric value of phone number or Observable of string or numeric value. For example ```123456789```. + +
    + +##### Examples + +* Return phone number from entity attributes: + +```javascript +return getPhoneNumberFromEntityAttributes(); + +function getPhoneNumberFromEntityAttributes() { + if (entityId) { + return widgetContext.attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE', ['phone']) + .pipe(widgetContext.rxjs + .map(function(attributeData) { + var res = 0; + if (attributeData && attributeData.length === 1) { + res = attributeData[0].value; + } + return res; + } + ) + ); + } else { + return 0; + } +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md new file mode 100644 index 0000000..9722c3e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_empty_result_fn.md @@ -0,0 +1,31 @@ +#### Handle empty result function + +
    +
    + +*function handleEmptyResult($event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +An optional JavaScript function to handle empty result.
    Usually this happens when user cancels the action (for ex. by pressing phone back button). + +**Parameters:** + +
      + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Display alert dialog with canceled action message: + +```javascript +showEmptyResultDialog('Action was canceled!'); + +function showEmptyResultDialog(message) { + setTimeout(function() { + widgetContext.dialogs.alert('Empty result', message).subscribe(); + }, 100); +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md new file mode 100644 index 0000000..01bf76c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_handle_error_fn.md @@ -0,0 +1,33 @@ +#### Handle error function + +
    +
    + +*function handleError(error, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +An optional JavaScript function to handle error occurred while mobile action execution. + +**Parameters:** + +
      +
    • error: string - error message. +
    • + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Display alert dialog with error message: + +```javascript +showErrorDialog('Failed to perform action', error); + +function showErrorDialog(title, error) { + setTimeout(function() { + widgetContext.dialogs.alert(title, error).subscribe(); + }, 100); +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md new file mode 100644 index 0000000..5759571 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_image_fn.md @@ -0,0 +1,92 @@ +#### Process image function + +
    +
    + +*function processImage(imageUrl, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +A JavaScript function to process image obtained as a result of mobile action (take photo, take image from gallery, etc.). + +**Parameters:** + +
      +
    • imageUrl: string - An image URL in base64 data format. +
    • + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Store image url data to entity attribute: + +```javascript +saveEntityImageAttribute('image', imageUrl); + +function saveEntityImageAttribute(attributeName, imageUrl) { + if (entityId) { + let attributes = [{ + key: attributeName, value: imageUrl + }]; + widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe( + function() { + widgetContext.showSuccessToast('Image attribute saved!'); + }, + function(error) { + widgetContext.dialogs.alert('Image attribute save failed', JSON.stringify(error)); + } + ); + } +} +{:copy-code} +``` + +* Display dialog with obtained image: + +```javascript +showImageDialog('Image', imageUrl); + +function showImageDialog(title, imageUrl) { + setTimeout(function() { + widgetContext.customDialog.customDialog(imageDialogTemplate, ImageDialogController, {imageUrl: imageUrl, title: title}).subscribe(); + }, 100); +} + +var imageDialogTemplate = + '
    ' + + '
    ' + + '' + + '

    {{title}}

    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    '; + +function ImageDialogController(instance) { + let vm = instance; + vm.title = vm.data.title; + vm.imageUrl = vm.data.imageUrl; + vm.close = function () + { + vm.dialogRef.close(null); + } +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md new file mode 100644 index 0000000..ddcab07 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_launch_result_fn.md @@ -0,0 +1,33 @@ +#### Process launch result function + +
    +
    + +*function processLaunchResult(launched, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +An optional JavaScript function to process result of attempt to launch external mobile application (for ex. map application or phone call application). + +**Parameters:** + +
      +
    • launched: boolean - boolean value indicating if the external application was successfully launched. + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Display alert dialog with external application launch status: + +```javascript +showLaunchStatusDialog('Application', launched); + +function showLaunchStatusDialog(title, status) { + setTimeout(function() { + widgetContext.dialogs.alert(title, status ? 'Successfully launched' : 'Failed to launch').subscribe(); + }, 100); +} + +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md new file mode 100644 index 0000000..445053b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_location_fn.md @@ -0,0 +1,59 @@ +#### Process location function + +
    +
    + +*function processLocation(latitude, longitude, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +A JavaScript function to process current location of the phone. + +**Parameters:** + +
      +
    • latitude: number - phone location latitude. +
    • +
    • longitude: number - phone location longitude. +
    • + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Display alert dialog with location data: + +```javascript +showLocationDialog('Location', latitude, longitude); + +function showLocationDialog(title, latitude, longitude) { + setTimeout(function() { + widgetContext.dialogs.alert(title, 'Latitude: '+latitude+'
    Longitude: ' + longitude).subscribe(); + }, 100); +} +{:copy-code} +``` + +* Store phone location to entity attributes: + +```javascript +saveEntityLocationAttributes('latitude', 'longitude', latitude, longitude); + +function saveEntityLocationAttributes(latitudeAttributeName, longitudeAttributeName, latitude, longitude) { + if (entityId) { + let attributes = [ + { key: latitudeAttributeName, value: latitude }, + { key: longitudeAttributeName, value: longitude } + ]; + widgetContext.attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributes).subscribe( + function() { + widgetContext.showSuccessToast('Location attributes saved!'); + }, + function(error) { + widgetContext.dialogs.alert('Location attributes save failed', JSON.stringify(error)); + } + ); + } +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md new file mode 100644 index 0000000..bb663f9 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/mobile_process_qr_code_fn.md @@ -0,0 +1,76 @@ +#### Process QR code function + +
    +
    + +*function processQrCode(code, format, $event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* + +A JavaScript function to process result of barcode scanning. + +**Parameters:** + +
      +
    • code: string - A string value of scanned barcode. +
    • +
    • format: string - barcode format. See BarcodeFormat enum for possible values. +
    • + {% include widget/action/custom_action_args %} +
    + +
    + +##### Examples + +* Display alert dialog with scanned barcode: + +```javascript +showQrCodeDialog('Bar Code', code, format); + +function showQrCodeDialog(title, code, format) { + setTimeout(function() { + widgetContext.dialogs.alert(title, 'Code: ['+code+']
    Format: ' + format).subscribe(); + }, 100); +} +{:copy-code} +``` + +* Parse code as a device claiming info (in this case ```{deviceName: string, secretKey: string}```)
    and then claim device (see [Claiming devices{:target="_blank"}](${siteBaseUrl}/docs/user-guide/claiming-devices/) for details): + +```javascript +var $scope = widgetContext.$scope; +var $injector = $scope.$injector; +var $translate = $injector.get(widgetContext.servicesMap.get('translate')); +var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); +var deviceNotFound = $translate.instant('widgets.input-widgets.claim-not-found'); +var failedClaimDevice = $translate.instant('widgets.input-widgets.claim-failed'); +var claimDeviceInfo = JSON.parse(code); +var deviceName = claimDeviceInfo.deviceName; +var secretKey = claimDeviceInfo.secretKey; +var claimRequest = { + secretKey: secretKey +}; +deviceService.claimDevice(deviceName, claimRequest, { ignoreErrors: true }).subscribe( + function (data) { + widgetContext.showSuccessToast('Device \'' + deviceName + '\' successfully claimed!'); + widgetContext.updateAliases(); + }, + function (error) { + if(error.status == 404) { + widgetContext.showErrorToast(deviceNotFound); + } else { + if (error.status !== 400 && error.error && error.error.message) { + showDialog('Failed to claim device', error.error.message); + } else { + widgetContext.showErrorToast(failedClaimDevice); + } + } + } +); + +function showDialog(title, error) { + setTimeout(function() { + widgetContext.dialogs.alert(title, error).subscribe(); + }, 100); +} +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md new file mode 100644 index 0000000..459a71e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md @@ -0,0 +1,48 @@ +#### Show cell button action function + +
    +
    + +*function (widgetContext, data): boolean* + +A JavaScript function evaluating whether to display particular table cell action. + +**Parameters:** + +
      +
    • widgetContext: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • data: FormattedData - A FormattedData object of specific table row.
      + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +`true` if cell action should be displayed, `false` otherwise. + +
    + +##### Examples + +* Display action only for customer users: + +```javascript +return widgetContext.currentUser.authority === 'CUSTOMER_USER'; +{:copy-code} +``` + +* Display action only if the entity in the row is device and has type `thermostat`: + +```javascript +return data && data.entityType === 'DEVICE' && data.Type === 'thermostat'; +{:copy-code} +``` + +* Display action only if the entity in the row has `temperature` latest timeseries or attribute value greater than 25: + +```javascript +return data && data.temperature > 25; +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md b/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md new file mode 100644 index 0000000..01bcc0e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md @@ -0,0 +1,41 @@ +#### Show widget header action function + +
    +
    + +*function (widgetContext, data): boolean* + +A JavaScript function evaluating whether to display particular widget header action. + +**Parameters:** + +
      +
    • widgetContext: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • data: FormattedData[] - An array of FormattedData objects.
      + Each object represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +`true` if header action should be displayed, `false` otherwise. + +
    + +##### Examples + +* Display action only for customer users: + +```javascript +return widgetContext.currentUser.authority === 'CUSTOMER_USER'; +{:copy-code} +``` + +* Display action only if the first entity is device and has type `thermostat`: + +```javascript +return data[0] && data[0].entityType === 'DEVICE' && data[0].Type === 'thermostat'; +{:copy-code} +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md b/ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md new file mode 100644 index 0000000..b73ec7b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md @@ -0,0 +1,62 @@ +#### Data generation function + +
    +
    + +*function (time, prevValue): any* + +A JavaScript function generating datapoint values. + +**Parameters:** + +
      +
    • time: number - timestamp in milliseconds of the current datapoint. +
    • +
    • prevValue: primitive (number/string/boolean) - A previous datapoint value. +
    • +
    + +**Returns:** + +A primitive type (number, string or boolean) presenting newly generated datapoint value. + +
    + +##### Examples + +* Generate data with sine function: + +```javascript +return Math.sin(time/5000); +{:copy-code} +``` + +* Generate true/false sequence: + +```javascript +if (!prevValue) { + return true; +} else { + return false; +} +{:copy-code} +``` + +* Generate repeating sequence of predefined values (for ex. latitude): + +```javascript +var lats = [37.7696499, + 37.7699074, + 37.7699536, + 37.7697242, + 37.7695189, + 37.7696889]; + +var index = Math.floor((time/3 % 14000) / 1000); + +return lats[index]; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md b/ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md new file mode 100644 index 0000000..fc49997 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md @@ -0,0 +1,86 @@ +#### Data post-processing function + +
    +
    + +*function (time, value, prevValue, timePrev, prevOrigValue): any* + +A JavaScript function doing post-processing on telemetry data. + +**Parameters:** + +
      +
    • time: number - timestamp in milliseconds of the current datapoint. +
    • +
    • value: primitive (number/string/boolean) - A value of the current datapoint. +
    • +
    • prevValue: primitive (number/string/boolean) - A value of the previous datapoint after applied post-processing. +
    • +
    • timePrev: number - timestamp in milliseconds of the previous datapoint value. +
    • +
    • prevOrigValue: primitive (number/string/boolean) - An original value of the previous datapoint. +
    • +
    + +**Returns:** + +A primitive type (number, string or boolean) presenting the new datapoint value. + +
    + +##### Examples + +* Multiply all datapoint values by 10: + +```javascript +return value * 10; +{:copy-code} +``` + +* Round all datapoint values to whole numbers: + +```javascript +return Math.round(value); +{:copy-code} +``` + +* Get relative difference between data points: + +```javascript +if (prevOrigValue) { + return (value - prevOrigValue) / prevOrigValue; +} else { + return 0; +} +{:copy-code} +``` +* Formatting data to time format + +```javascript +if (value) { + return moment(value).format("DD/MM/YYYY HH:mm:ss"); +} +return ''; +{:copy-code} +``` + +* Creates line-breaks for 0 values, when used in line chart + +```javascript +if (value === 0) { + return null; +} else { + return value; +} +{:copy-code} +``` + +* Display data point of the HTML value card under the condition + +```javascript +return value ? '
    Temperature: '+value+' °C
    ' : ''; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/alarm_widget.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/alarm_widget.md new file mode 100644 index 0000000..f3ba4c0 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/alarm_widget.md @@ -0,0 +1,147 @@ +#### Sample Alarm widget + +
    +
    + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen and then click the “Create new widget type” button.
    +Click the **Alarm Widget** button on the **Select widget type** popup.
    +The **Widget Editor** will be opened, pre-populated with the content of the default **Alarm** template widget. + + - Replace content of the CSS tab in "Resources" section with the following one: + +```css +.my-alarm-table th { + text-align: left; +} +{:copy-code} +``` + + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html +
    +
    My first Alarm widget.
    + + + + + + + + + + + +
    {{dataKey.label}}
    + {{getAlarmValue(alarm, dataKey)}} +
    +
    +{:copy-code} +``` + + - Put the following JSON content inside the "Settings schema" tab of **Settings schema section**: + +```json +{ + "schema": { + "type": "object", + "title": "AlarmTableSettings", + "properties": { + "alarmSeverityColorFunction": { + "title": "Alarm severity color function: f(severity)", + "type": "string", + "default": "if(severity == 'CRITICAL') {return 'red';} else if (severity == 'MAJOR') {return 'orange';} else return 'green'; " + } + }, + "required": [] + }, + "form": [ + { + "key": "alarmSeverityColorFunction", + "type": "javascript" + } + ] +} +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +self.onInit = function() { + var pageLink = self.ctx.pageLink(); + + pageLink.typeList = self.ctx.widgetConfig.alarmTypeList; + pageLink.statusList = self.ctx.widgetConfig.alarmStatusList; + pageLink.severityList = self.ctx.widgetConfig.alarmSeverityList; + pageLink.searchPropagatedAlarms = self.ctx.widgetConfig.searchPropagatedAlarms; + + self.ctx.defaultSubscription.subscribeForAlarms(pageLink, null); + self.ctx.$scope.alarmSource = self.ctx.defaultSubscription.alarmSource; + + var alarmSeverityColorFunctionBody = self.ctx.settings.alarmSeverityColorFunction; + if (typeof alarmSeverityColorFunctionBody === 'undefined' || !alarmSeverityColorFunctionBody.length) { + alarmSeverityColorFunctionBody = "if(severity == 'CRITICAL') {return 'red';} else if (severity == 'MAJOR') {return 'orange';} else return 'green';"; + } + + var alarmSeverityColorFunction = null; + try { + alarmSeverityColorFunction = new Function('severity', alarmSeverityColorFunctionBody); + } catch (e) { + alarmSeverityColorFunction = null; + } + + self.ctx.$scope.getAlarmValue = function(alarm, dataKey) { + var alarmKey = dataKey.name; + if (alarmKey === 'originator') { + alarmKey = 'originatorName'; + } + var value = alarm[alarmKey]; + if (alarmKey === 'createdTime') { + return self.ctx.date.transform(value, 'yyyy-MM-dd HH:mm:ss'); + } else { + return value; + } + } + + self.ctx.$scope.getAlarmCellStyle = function(alarm, dataKey) { + var alarmKey = dataKey.name; + if (alarmKey === 'severity' && alarmSeverityColorFunction) { + var severity = alarm[alarmKey]; + var color = alarmSeverityColorFunction(severity); + return { + color: color + }; + } + return {}; + } +} + +self.onDataUpdated = function() { + self.ctx.$scope.alarms = self.ctx.defaultSubscription.alarms.data; + self.ctx.detectChanges(); +} +{:copy-code} +``` + + - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/alarm-widget-sample.png) + +In this example, the **alarmSource** and **alarms** properties of are assigned to **$scope** and become accessible within HTML template. + +Inside the HTML, a special [***ngFor**{:target="_blank"}](https://angular.io/api/common/NgForOf) structural angular directive is used in order to iterate over available alarm **dataKeys** of **alarmSource** and render corresponding columns. + +The table rows are rendered by iterating over **alarms** array and corresponding cells rendered by iterating over **dataKeys**. + +The function **getAlarmValue** is fetching alarm value and formatting **createdTime** alarm property using a [DatePipe{:target="_blank"}](https://angular.io/api/common/DatePipe) angular pipe accessible via **date** property of **ctx**. + +The function **getAlarmCellStyle** is used to assign custom cell styles for each alarm cell.
    In this example, we introduced new settings property called **alarmSeverityColorFunction** that contains function body returning color depending on alarm severity. + +Inside the **getAlarmCellStyle** function there is corresponding invocation of **alarmSeverityColorFunction** with severity value in order to get color for alarm severity cell. + +Note that in this code **onDataUpdated** function is implemented in order to update **alarms** property with latest alarms from subscription and invoke change detection using **detectChanges()** function. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_latest_values_example.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_latest_values_example.md new file mode 100644 index 0000000..dcb9293 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_latest_values_example.md @@ -0,0 +1,67 @@ +#### Latest Values widget Example with gauge.js library + +
    +
    + +In this example, **Latest Values** gauge widget will be created using external [gauge.js{:target="_blank"}](http://bernii.github.io/gauge.js/) library. + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen, then click the “Create new widget type” button.
    +Click the **Latest Values** button on the **Select widget type** popup.
    +The **Widget Editor** will be opened, pre-populated with the content of default **Latest Values** template widget. + + - Open **Resources** tab and click "Add" then insert the following link: + +``` +https://bernii.github.io/gauge.js/dist/gauge.min.js +{:copy-code} +``` + + - Clear content of the CSS tab of "Resources" section. + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html + +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +var canvasElement; +var gauge; + +self.onInit = function() { + canvasElement = $('#my-gauge', self.ctx.$container)[0]; + gauge = new Gauge(canvasElement); + gauge.minValue = -1000; + gauge.maxValue = 1000; + gauge.animationSpeed = 16; + self.onResize(); +} + +self.onResize = function() { + canvasElement.width = self.ctx.width; + canvasElement.height = self.ctx.height; + gauge.update(true); + gauge.render(); +} + +self.onDataUpdated = function() { + if (self.ctx.defaultSubscription.data[0].data.length) { + var value = self.ctx.defaultSubscription.data[0].data[0][1]; + gauge.set(value); + } +} +{:copy-code} +``` + + - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/external-js-widget-sample.png) + +In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section. + +The value displayed was obtained from **data** property for the first dataKey. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_timeseries_example.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_timeseries_example.md new file mode 100644 index 0000000..6d66e15 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/ext_timeseries_example.md @@ -0,0 +1,112 @@ +#### Time-Series widget Example with Chart.js library + +
    +
    + +In this example, **Time-Series** line chart widget will be created using external [Chart.js{:target="_blank"}](https://www.chartjs.org/) library. + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen, then click the “Create new widget type” button.
    +Click the **Time-Series** button on the **Select widget type** popup.
    +The **Widget Editor** will be opened, pre-populated with the content of default **Time-Series** template widget. + + - Open **Resources** tab and click "Add" then insert the following link: + +``` +https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js +{:copy-code} +``` + + - Clear content of the CSS tab of "Resources" section. + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html + +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +var myChart; + +self.onInit = function() { + + var chartData = { + datasets: [] + }; + + for (var i=0; i < self.ctx.data.length; i++) { + var dataKey = self.ctx.data[i].dataKey; + var dataset = { + label: dataKey.label, + data: [], + borderColor: dataKey.color, + fill: false + }; + chartData.datasets.push(dataset); + } + + var options = { + maintainAspectRatio: false, + legend: { + display: false + }, + scales: { + xAxes: [{ + type: 'time', + ticks: { + maxRotation: 0, + autoSkipPadding: 30 + } + }] + } + }; + + var canvasElement = $('#myChart', self.ctx.$container)[0]; + var canvasCtx = canvasElement.getContext('2d'); + myChart = new Chart(canvasCtx, { + type: 'line', + data: chartData, + options: options + }); + self.onResize(); +} + +self.onResize = function() { + myChart.resize(); +} + +self.onDataUpdated = function() { + for (var i = 0; i < self.ctx.data.length; i++) { + var datasourceData = self.ctx.data[i]; + var dataSet = datasourceData.data; + myChart.data.datasets[i].data.length = 0; + var data = myChart.data.datasets[i].data; + for (var d = 0; d < dataSet.length; d++) { + var tsValuePair = dataSet[d]; + var ts = tsValuePair[0]; + var value = tsValuePair[1]; + data.push({t: ts, y: value}); + } + } + myChart.options.scales.xAxes[0].ticks.min = self.ctx.timeWindow.minTime; + myChart.options.scales.xAxes[0].ticks.max = self.ctx.timeWindow.maxTime; + myChart.update(); +} +{:copy-code} +``` + + - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/external-js-timeseries-widget-sample.png) + +In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section. + +Initially chart datasets prepared using configured dataKeys from **data** property of **ctx**. + +In the **onDataUpdated** function datasources data converted to Chart.js line chart format and pushed to chart datasets. + +Please note that xAxis (time axis) is limited to current timewindow bounds obtained from **timeWindow** property of **ctx**. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/latest_values_widget.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/latest_values_widget.md new file mode 100644 index 0000000..afe4f6a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/latest_values_widget.md @@ -0,0 +1,47 @@ +#### Sample Latest Values widget + +
    +
    + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen and then click the “Create new widget type” button.
    +Click the **Latest Values** button on the **Select widget type** popup.
    +The **Widget Editor** will open, pre-populated with the content of the default **Latest Values** template widget. + + - Clear content of the CSS tab of "Resources" section. + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html +
    +
    My first latest values widget.
    +
    +
    {{dataKeyData.dataKey.label}}:
    +
    {{(dataKeyData.data[0] && dataKeyData.data[0][0]) | date : 'yyyy-MM-dd HH:mm:ss' }}
    +
    {{dataKeyData.data[0] && dataKeyData.data[0][1]}}
    +
    +
    +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript + self.onInit = function() { + self.ctx.$scope.data = self.ctx.defaultSubscription.data; + } + + self.onDataUpdated = function() { + self.ctx.detectChanges(); + } +{:copy-code} +``` + + - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/latest-values-widget-sample.png) + +In this example, the **data** property of is assigned to the **$scope** and becomes accessible within the HTML template. + +Inside the HTML, a special [***ngFor**{:target="_blank"}](https://angular.io/api/common/NgForOf) structural angular directive is used in order to iterate over available dataKeys & datapoints then render latest values with their corresponding timestamps. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/rpc_widget.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/rpc_widget.md new file mode 100644 index 0000000..74bcca2 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/rpc_widget.md @@ -0,0 +1,187 @@ +#### Sample RPC (Control) widget + +
    +
    + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen and then click the “Create new widget type” button.
    +Click the **Control Widget** button on the **Select widget type** popup.
    +The **Widget Editor** will open, pre-populated with default **Control** template widget content. + + - Clear content of the CSS tab of "Resources" section. + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html +
    +
    + + RPC method + + + RPC method name is required. + + + + RPC params + + + RPC params is required. + + + +
    + +
    +
    +
    +
    +
    +{:copy-code} +``` + + - Put the following JSON content inside the "Settings schema" tab of **Settings schema section**: + +```json +{ + "schema": { + "type": "object", + "title": "Settings", + "properties": { + "oneWayElseTwoWay": { + "title": "Is One Way Command", + "type": "boolean", + "default": true + }, + "requestTimeout": { + "title": "RPC request timeout", + "type": "number", + "default": 500 + } + }, + "required": [] + }, + "form": [ + "oneWayElseTwoWay", + "requestTimeout" + ] +} +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +self.onInit = function() { + + self.ctx.$scope.sendCommand = function() { + var rpcMethod = self.ctx.$scope.rpcMethod; + var rpcParams = self.ctx.$scope.rpcParams; + var timeout = self.ctx.settings.requestTimeout; + var oneWayElseTwoWay = self.ctx.settings.oneWayElseTwoWay ? true : false; + + var commandObservable; + if (oneWayElseTwoWay) { + commandObservable = self.ctx.controlApi.sendOneWayCommand(rpcMethod, rpcParams, timeout); + } else { + commandObservable = self.ctx.controlApi.sendTwoWayCommand(rpcMethod, rpcParams, timeout); + } + commandObservable.subscribe( + function (response) { + if (oneWayElseTwoWay) { + self.ctx.$scope.rpcCommandResponse = "Command was successfully received by device.
    No response body because of one way command mode."; + } else { + self.ctx.$scope.rpcCommandResponse = "Response from device:
    "; + self.ctx.$scope.rpcCommandResponse += JSON.stringify(response, undefined, 2); + } + self.ctx.detectChanges(); + }, + function (rejection) { + self.ctx.$scope.rpcCommandResponse = "Failed to send command to the device:
    " + self.ctx.$scope.rpcCommandResponse += "Status: " + rejection.status + "
    "; + self.ctx.$scope.rpcCommandResponse += "Status text: '" + rejection.statusText + "'"; + self.ctx.detectChanges(); + } + + ); + } + +} +{:copy-code} +``` + + - Fill **Widget title** field with widget type name, for ex. "My first control widget". + - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. + - Click dashboard edit button on the preview section to change the size of the resulting widget. Then click dashboard apply button. The final widget should look like the image below. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample.png) + +- Click the **Save** button on the **Widget Editor Toolbar** to save widget type. + +To test how this widget performs RPC commands, we will need to place it in a dashboard then bind it to a device working with RPC commands. To do this, perform the following steps: + +- Login as Tenant administrator. +- Navigate to **Devices** and create new device with some name, for ex. "My RPC Device". +- Open device details and click "Copy Access Token" button to copy device access token to clipboard. +- Download [mqtt-js-rpc-from-server.sh{:target="_blank"}](${siteBaseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.sh) and [mqtt-js-rpc-from-server.js{:target="_blank"}](${siteBaseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.js). Place these files in a folder. + Edit **mqtt-js-rpc-from-server.sh** - replace **$ACCESS_TOKEN** with your device access token from the clipboard. And install mqtt client library. +- Run **mqtt-js-rpc-from-server.sh** script. You should see a "connected" message in the console. +- Navigate to **Dashboards** and create a new dashboard with some name, for ex. "My first control dashboard". Open this dashboard. +- Click dashboard "edit" button. In the dashboard edit mode, click the "Entity aliases" button located on the dashboard toolbar. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/dashboard-toolbar-entity-aliases.png) + +- Inside **Entity aliases** popup click "Add alias". +- Fill "Alias name" field, for ex. "My RPC Device Alias". +- Select "Entity list" in "Filter type" field. +- Choose "Device" in "Type" field. +- Select your device in "Entity list" field. In this example "My RPC Device". + +![image](${helpBaseUrl}/help/images/widget/editor/examples/add-rpc-device-alias.png) + +- Click "Add" and then "Save" in **Entity aliases**. +- Click dashboard "+" button then click "Create new widget" button. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/dashboard-create-new-widget-button.png) + +- Then select **Widget Bundle** where your RPC widget was saved. Select "Control widget" tab. +- Click your widget. In this example, "My first control widget". +- From **Add Widget** popup, select your device alias in **Target device** section. In this example "My RPC Device Alias". +- Click **Add**. Your Control widget will appear in the dashboard. Click dashboard **Apply changes** button to save dashboard and leave editing mode. +- Fill **RPC method** field with RPC method name. For ex. "TestMethod". +- Fill **RPC params** field with RPC params. For ex. "{ param1: "value1" }". +- Click **Send RPC command** button. You should see the following response in the widget. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-one-way.png) + +The following output should be printed in the device console: + +```bash + request.topic: v1/devices/me/rpc/request/0 + request.body: {"method":"TestMethod","params":"{ param1: \"value1\" }"} +``` + +In order to test "Two way" RPC command mode, we need to change the corresponding widget settings property. To do this, perform the following steps: + +- Click dashboard "edit" button. In dashboard edit mode, click **Edit widget** button located in the header of Control widget. +- In the widget details, view select "Advanced" tab and uncheck "Is One Way Command" checkbox. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-settings.png) + +- Click **Apply changes** button on the widget details header. Close details and click dashboard **Apply changes** button. +- Fill widget fields with RPC method name and params like in previous steps. + Click **Send RPC command** button. You should see the following response in the widget. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-two-way.png) + +- stop **mqtt-js-rpc-from-server.sh** script. + Click **Send RPC command** button. You should see the following response in the widget. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-timeout.png) + +In this example, **controlApi** is used to send RPC commands. Additionally, custom widget settings were introduced in order to configure RPC command mode and RPC request timeout. + +The response from the device is handled by **commandObservable**. It has success and failed callbacks with corresponding response, or rejection objects containing information about request execution result. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/static_widget.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/static_widget.md new file mode 100644 index 0000000..7e73b63 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/static_widget.md @@ -0,0 +1,72 @@ +#### Sample Static widget + +
    +
    + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen and then click the “Create new widget type” button.
    +Click the **Static Widget** button on the **Select widget type** popup.
    +The **Widget Editor** will be opened pre-populated with the content of default **Static** template widget. + + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html +
    +

    My first static widget.

    + +
    +{:copy-code} +``` + + - Put the following JSON content inside the "Settings schema" tab of **Settings schema section**: + +```json +{ + "schema": { + "type": "object", + "title": "Settings", + "properties": { + "alertContent": { + "title": "Alert content", + "type": "string", + "default": "Content derived from alertContent property of widget settings." + } + } + }, + "form": [ + "alertContent" + ] +} +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +self.onInit = function() { + + self.ctx.$scope.showAlert = function() { + var alertContent = self.ctx.settings.alertContent; + if (!alertContent) { + alertContent = "Content derived from alertContent property of widget settings."; + } + window.alert(alertContent); + }; + +} +{:copy-code} +``` + + - Click the **Run** button on the **Widget Editor Toolbar** to see the resulting **Widget preview** section. + +![image](${helpBaseUrl}/help/images/widget/editor/examples/static-widget-sample.png) + +This is just a static HTML widget. There is no subscription data and no special widget API was used. + +Only custom **showAlert** function was implemented showing an alert with the content of **alertContent** property of widget settings. + +You can switch to dashboard edit mode in **Widget preview** section and change value of **alertContent** by changing widget settings in the "Advanced" tab of widget details. + +Then you can see that the new alert content is displayed. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/examples/timeseries_widget.md b/ui-ngx/src/assets/help/en_US/widget/editor/examples/timeseries_widget.md new file mode 100644 index 0000000..66cb83b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/examples/timeseries_widget.md @@ -0,0 +1,93 @@ +#### Sample Time-Series widget + +
    +
    + +In the **Widgets Bundle** view, click the big “+” button at the bottom-right part of the screen, then click the “Create new widget type” button.
    +Click the **Time-Series** button on the **Select widget type** popup.
    +The **Widget Editor** will open, pre-populated with default **Time-Series** template widget content. + + - Replace content of the CSS tab in "Resources" section with the following one: + +```css +.my-data-table th { + text-align: left; +} +{:copy-code} +``` + + - Put the following HTML code inside the HTML tab of "Resources" section: + +```html + + + + + + + + + + + + + + + +
    Timestamp{{dataKeyData.dataKey.label}}
    {{data[0] | date : 'yyyy-MM-dd HH:mm:ss'}}{{dataKeyData.data[$dataIndex] && dataKeyData.data[$dataIndex][1]}}
    +
    +
    +{:copy-code} +``` + + - Put the following JavaScript code inside the "JavaScript" section: + +```javascript +self.onInit = function() { + self.ctx.widgetTitle = 'My first Time-Series widget'; + self.ctx.$scope.datasources = self.ctx.defaultSubscription.datasources; + self.ctx.$scope.data = self.ctx.defaultSubscription.data; + + self.ctx.$scope.datasourceData = []; + + var currentDatasource = null; + var currentDatasourceIndex = -1; + + for (var i=0;i **datasources** and **data** properties are assigned to **$scope** and become accessible within the HTML template. + +The **$scope.datasourceData** property is introduced to map datasource specific dataKeys data by datasource index for flexible access within the HTML template. + +Inside the HTML, a special [***ngFor**{:target="_blank"}](https://angular.io/api/common/NgForOf) structural angular directive is used in order to iterate over available datasources and render corresponding tabs. + +Inside each tab, the table is rendered using dataKeys obtained from **datasourceData** scope property accessed by datasource index.
    + +Each table renders columns by iterating over all **dataKeyData** objects and renders all available datapoints by iterating over **data** array of each **dataKeyData** to render timestamps and values. + +Note that in this code, **onDataUpdated** function is implemented with a call to **detectChanges** function necessary to perform new change detection cycle when new data is received. + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_action_sources_object.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_action_sources_object.md new file mode 100644 index 0000000..16025b6 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_action_sources_object.md @@ -0,0 +1,17 @@ +#### Action sources object + +
    +
    + +Map describing available widget action sources ([WidgetActionSource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L121)) to which user actions can be assigned. It has the following structure: + +```javascript + return { + 'headerButton': { // Action source Id (unique action source identificator) + name: 'widget-action.header-button', // Display name of action source, used in widget settings ('Actions' tab). + value: 'headerButton', // Action source Id + multiple: true // Boolean property indicating if this action source supports multiple action definitions + // (for ex. multiple buttons in one cell, or only one action can by assigned on table row click.) + } + }; +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_existing_code.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_existing_code.md new file mode 100644 index 0000000..9058507 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_existing_code.md @@ -0,0 +1,62 @@ +#### Using existing JavaScript/Typescript code + +
    +
    + +Another approach of creating widgets is to use existing bundled JavaScript/Typescript code. + +In this case, you can create own TypeScript class or Angular component and bundle it into the ThingsBoard UI code. + +In order to make this code accessible within the widget, you need to register corresponding Angular module or inject TypeScript class to a global variable (for ex. window object). + +Some ThingsBoard widgets already use this approach. Take a look at the [widget-component-service.ts{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts#L140) +or [widget-components.module.ts{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts#L50).
    +Here you can find how some bundled classes or components are registered for later use in ThingsBoard widgets. + +For example "Timeseries - Flot" widget (from "Charts" Widgets Bundle) uses [**TbFlot**{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.ts#L73) TypeScript class which is injected as window property inside **widget-component-service.ts**: + +```typescript +... + +const widgetModulesTasks: Observable[] = []; +... + +widgetModulesTasks.push(from(import('@home/components/widget/lib/flot-widget')).pipe( + tap((mod) => { + (window as any).TbFlot = mod.TbFlot; + })) +); +... + +``` + +Another example is "Timeseries table" widget (from "Cards" Widgets Bundle) that uses Angular component [**tb-timeseries-table-widget**{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L107)
    which is registered as dependency of **WidgetComponentsModule** Angular module inside **widget-components.module.ts**. +Thereby this component becomes available for use inside the widget template HTML. + +```typescript +... + +import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component'; + +... + +@NgModule({ + declarations: + [ +... + TimeseriesTableWidgetComponent, +... + ], +... + exports: [ +... + TimeseriesTableWidgetComponent, +... + ], +... +}) +export class WidgetComponentsModule { } +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_fn.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_fn.md new file mode 100644 index 0000000..f301e40 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_fn.md @@ -0,0 +1,135 @@ +#### Widget type JavaScript code + +
    +
    + +All widget related JavaScript code according to the [Widget API{:target="_blank"}](${siteBaseUrl}/docs/user-guide/contribution/widgets-development/#basic-widget-api). +The built-in variable **self** is a reference to the widget instance.
    +Each widget function should be defined as a property of the **self** variable. +**self** variable has property **ctx** of type [WidgetContext{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107) - a reference to widget context that has all necessary API and data used by widget instance. + +In order to implement a new widget, the following JavaScript functions should be defined *(Note: each function is optional and can be implemented according to widget specific behaviour):* + +|{:auto} **Function** | **Description** | +|------------------------------------|----------------------------------------------------------------------------------------| +| ``` onInit() ``` | The first function which is called when widget is ready for initialization. Should be used to prepare widget DOM, process widget settings and initial subscription information. | +| ``` onDataUpdated() ``` | Called when the new data is available from the widget subscription. Latest data can be accessed from the object of widget context (**ctx**). | +| ``` onResize() ``` | Called when widget container is resized. Latest width and height can be obtained from widget context (**ctx**). | +| ``` onEditModeChanged() ``` | Called when dashboard editing mode is changed. Latest mode is handled by isEdit property of **ctx**. | +| ``` onMobileModeChanged() ``` | Called when dashboard view width crosses mobile breakpoint. Latest state is handled by isMobile property of **ctx**. | +| ``` onDestroy() ``` | Called when widget element is destroyed. Should be used to cleanup all resources if necessary. | +| ``` getSettingsSchema() ``` | Optional function returning widget settings schema json as alternative to **Settings schema** of settings section. | +| ``` getDataKeySettingsSchema() ``` | Optional function returning particular data key settings schema json as alternative to **Data key settings schema** tab of settings section. | +| ``` typeParameters() ``` | Returns [WidgetTypeParameters{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L151) object describing widget datasource parameters. See | | +| ``` actionSources() ``` | Returns map describing available widget action sources ([WidgetActionSource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L121)) used to define user actions. See | + +
    + +##### Creating simple widgets + +The tutorials below show how to create minimal widgets of each type. +In order to minimize the amount of code, the Angular framework will be used, on which ThingsBoard UI is actually based. +By the way, you can always use pure JavaScript or jQuery API in your widget code. + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +##### Integrating existing code to create widget definition + +Below are some examples demonstrating how external JavaScript libraries or existing code can be reused/integrated to create new widgets. + +###### Using external JavaScript library + +
    + +
    +
    + +
    + +
    +
    + +###### Using existing JavaScript/Typescript code + +
    + +
    +
    + +
    + +##### Widget code debugging tips + +The most simple method of debugging is Web console output. +Just place [**console.log(...)**{:target="_blank"}](https://developer.mozilla.org/en-US/docs/Web/API/Console/log) function inside any part of widget JavaScript code. +Then click **Run** button to restart widget code and observe debug information in the Web console. + +Another and most effective method of debugging is to invoke browser debugger. +Put [**debugger;**{:target="_blank"}](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) statement into the place of widget code you are interested in and then click **Run** button to restart widget code. +Browser debugger (if enabled) will automatically pause code execution at the debugger statement and you will be able to analyze script execution using browser debugging tools. + +
    + +##### Further reading + +For more information read [Widgets Development Guide{:target="_blank"}](${siteBaseUrl}/docs/user-guide/contribution/widgets-development). + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_markdown_pattern.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_markdown_pattern.md new file mode 100644 index 0000000..fd4ab60 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_markdown_pattern.md @@ -0,0 +1,68 @@ +#### Markdown pattern + +
    +
    + +The Markdown template displays the value of the first found key in the entities in the entity alias. + +
    +
    + +#### Examples + +Use # to create a Markdown header. The number of characters # specifies the type of header: # - h1, ## - h2, ### - h3, etc. + +```markdown + ###### Markdown/HTML card +{:copy-code} +``` + ###### Markdown/HTML card + +
    +
    + +Use - character to create list item. You can create nested lists separating them with tabs in the pattern: + + ```markdown + - Element 1 + - Element 2 + - Element 2.1 + - Element 2.2 + -Element 3 +{:copy-code} + ``` +- Element 1 +- Element 2 + - Element 2.1 + - Element 2.2 +- Element 3 + +
    +
    + +Use * character to choose style: + + ```markdown + - *Element 1* + - **Element 2** + - ***Element 3*** +{:copy-code} + ``` +- *Element 1* +- **Element 2** +- ***Element 3*** + +
    +
    + +Use ${} to add some value from your key: + ```markdown + - **Element 1**: ${key1Name} + - **Element 1**: ${key2Name} + - **Element 1**: ${key3Name} +{:copy-code} + ``` + - **Element 1**: key1Value + - **Element 2**: key2Value + - **Element 3**: key3Value + diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_subscription_object.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_subscription_object.md new file mode 100644 index 0000000..87988e8 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_subscription_object.md @@ -0,0 +1,113 @@ +#### Subscription object + +
    +
    + +The widget subscription object is instance of [IWidgetSubscription{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/core/api/widget-api.models.ts#L264") and contains all subscription information, including current data, according to the [widget type{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#widget-types). + +Depending on widget type, subscription object provides different data structures. +For [Latest values{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#latest-values) and [Time-series{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#time-series) widget types, it provides the following properties: + +- **datasources** - array of datasources (Array<[Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)>) used by this subscription, using the following structure: + +```javascript + datasources = [ + { // datasource + type: 'entity',// type of the datasource. Can be "function" or "entity" + name: 'name', // name of the datasource (in case of "entity" usually Entity name) + aliasName: 'aliasName', // name of the alias used to resolve this particular datasource Entity + entityName: 'entityName', // name of the Entity used as datasource + entityType: 'DEVICE', // datasource Entity type (for ex. "DEVICE", "ASSET", "TENANT", etc.) + entityId: '943b8cd0-576a-11e7-824c-0b1cb331ec92', // entity identificator presented as string uuid. + dataKeys: [ // array of keys (Array) (attributes or timeseries) of the entity used to fetch data + { // dataKey + name: 'name', // the name of the particular entity attribute/timeseries + type: 'timeseries', // type of the dataKey. Can be "timeseries", "attribute" or "function" + label: 'Sin', // label of the dataKey. Used as display value (for ex. in the widget legend section) + color: '#ffffff', // color of the key. Can be used by widget to set color of the key data (for ex. lines in line chart or segments in the pie chart). + funcBody: "", // only applicable for datasource with type "function" and "function" key type. Defines body of the function to generate simulated data. + settings: {} // dataKey specific settings with structure according to the defined Data key settings json schema. See "Settings schema section". + }, + //... + ] + }, + //... + ] +``` + +- **data** - array of latest data (Array<[DatasourceData{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L310)>) received in scope of this subscription, using the following structure: + +```javascript + data = [ + { + datasource: {}, // datasource object of this data. See datasource structure above. + dataKey: {}, // dataKey for which the data is held. See dataKey structure above. + data: [ // array of data points + [ // data point + 1498150092317, // unix timestamp of datapoint in milliseconds + 1, // value, can be either string, numeric or boolean + ], + //... + ] + }, + //... + ] +``` + +For [Alarm widget{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#alarm-widget) type it provides the following properties: + +- **alarmSource** - ([Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)) information about entity for which alarms are fetched, using the following structure: + +```javascript + alarmSource = { + type: 'entity',// type of the alarm source. Can be "function" or "entity" + name: 'name', // name of the alarm source (in case of "entity" usually Entity name) + aliasName: 'aliasName', // name of the alias used to resolve this particular alarm source Entity + entityName: 'entityName', // name of the Entity used as alarm source + entityType: 'DEVICE', // alarm source Entity type (for ex. "DEVICE", "ASSET", "TENANT", etc.) + entityId: '943b8cd0-576a-11e7-824c-0b1cb331ec92', // entity identificator presented as string uuid. + dataKeys: [ // array of keys indicating alarm fields used to display alarms data + { // dataKey + name: 'name', // the name of the particular alarm field + type: 'alarm', // type of the dataKey. Only "alarm" in this case. + label: 'Severity', // label of the dataKey. Used as display value (for ex. as a column title in the Alarms table) + color: '#ffffff', // color of the key. Can be used by widget to set color of the key data. + settings: {} // dataKey specific settings with structure according to the defined Data key settings json schema. See "Settings schema section". + }, + //... + ] + } +``` + +- **alarms** - array of alarms (Array<[Alarm{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/alarm.models.ts#L89)>) received in scope of this subscription, using the following structure: + +```javascript + alarms = [ + { // alarm + id: { // alarm id + entityType: "ALARM", + id: "943b8cd0-576a-11e7-824c-0b1cb331ec92" + }, + createdTime: 1498150092317, // Alarm created time (unix timestamp) + startTs: 1498150092316, // Alarm started time (unix timestamp) + endTs: 1498563899065, // Alarm end time (unix timestamp) + ackTs: 0, // Time of alarm acknowledgment (unix timestamp) + clearTs: 0, // Time of alarm clear (unix timestamp) + originator: { // Originator - id of entity produced this alarm + entityType: "ASSET", + id: "ceb16a30-4142-11e7-8b30-d5d66714ea5a" + }, + originatorName: "Originator Name", // Name of originator entity + type: "Temperature", // Type of the alarm + severity: "CRITICAL", // Severity of the alarm ("CRITICAL", "MAJOR", "MINOR", "WARNING", "INDETERMINATE") + status: "ACTIVE_UNACK", // Status of the alarm + // ("ACTIVE_UNACK" - active unacknowledged, + // "ACTIVE_ACK" - active acknowledged, + // "CLEARED_UNACK" - cleared unacknowledged, + // "CLEARED_ACK" - cleared acknowledged) + details: {} // Alarm details object derived from alarm details json. + } + ] +``` + +For [RPC{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#rpc-control-widget) or [Static{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#static) widget types, subscription object is optional and does not contain necessary information. diff --git a/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_type_parameters_object.md b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_type_parameters_object.md new file mode 100644 index 0000000..09ff8cc --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/editor/widget_js_type_parameters_object.md @@ -0,0 +1,14 @@ +#### Type parameters object + +
    +
    + +Object [WidgetTypeParameters{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L151) describing widget datasource parameters. It has the following properties: + +```javascript + return { + maxDatasources: -1, // Maximum allowed datasources for this widget, -1 - unlimited + maxDataKeys: -1, //Maximum allowed data keys for this widget, -1 - unlimited + dataKeysOptional: false //Whether this widget can be configured with datasources without data keys + } +``` diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_content_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_content_fn.md new file mode 100644 index 0000000..e757bed --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_content_fn.md @@ -0,0 +1,52 @@ +#### Cell content function + +
    +
    + +*function (value, alarm, ctx): string* + +A JavaScript function used to compute alarm cell content HTML depending on alarm field value. + +**Parameters:** + +
      +
    • value: any - An alarm field value displayed in the cell. +
    • +
    • alarm: AlarmDataInfo - An + AlarmDataInfo object + presenting basic alarm properties (ex. type, severity, originator, etc.) and
      provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return string value presenting cell content HTML. + +
    + +##### Examples + +* Format alarm start time using date/time pattern: + +```javascript +var startTime = value; +return startTime ? ctx.date.transform(startTime, 'yyyy-MM-dd HH:mm:ss') : ''; +{:copy-code} +``` + +* Styled cell content for originator alarm field: + +```javascript +var originator = value; +return '
    ' + originator + '
    '; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_style_fn.md new file mode 100644 index 0000000..2c6ab6d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/cell_style_fn.md @@ -0,0 +1,62 @@ +#### Cell style function + +
    +
    + +*function (value, alarm, ctx): {[key: string]: string}* + +A JavaScript function used to compute alarm cell style depending on alarm field value. + +**Parameters:** + +
      +
    • value: any - An alarm field value displayed in the cell. +
    • +
    • alarm: AlarmDataInfo - An + AlarmDataInfo object + presenting basic alarm properties (ex. type, severity, originator, etc.) and
      provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set color depending on alarm severity: + +```javascript +var severity = value; +var color = 'black'; +switch (severity) { + case 'CRITICAL': + color = 'red'; + break; + case 'MAJOR': + color = 'orange'; + break; + case 'MINOR': + color = '#ffca3d'; + break; + case 'WARNING': + color = '#abab00'; + break; + case 'INDETERMINATE': + color = 'green'; + break; +} +return { + fontWeight: 'bold', + color: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/alarm/row_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/row_style_fn.md new file mode 100644 index 0000000..e65a72f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/alarm/row_style_fn.md @@ -0,0 +1,59 @@ +#### Row style function + +
    +
    + +*function (alarm, ctx): {[key: string]: string}* + +A JavaScript function used to compute alarm row style depending on alarm value. + +**Parameters:** + +
      +
    • alarm: AlarmDataInfo - An + AlarmDataInfo object + presenting basic alarm properties (ex. type, severity, originator, etc.) and
      provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set row background color depending on alarm severity: + +```javascript +var severity = alarm.severity; +var color = '#fff'; +switch (severity) { + case 'CRITICAL': + color = 'red'; + break; + case 'MAJOR': + color = 'orange'; + break; + case 'MINOR': + color = '#ffca3d'; + break; + case 'WARNING': + color = '#abab00'; + break; + case 'INDETERMINATE': + color = 'green'; + break; +} +return { + backgroundColor: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_disabled_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_disabled_fn.md new file mode 100644 index 0000000..7c74ccc --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_disabled_fn.md @@ -0,0 +1,43 @@ +#### Node disabled function + +
    +
    + +*function (nodeCtx): boolean* + +A JavaScript function evaluating whether current node should be disabled (not selectable). + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +`true` if node should be disabled (not selectable), `false` otherwise. + +
    + +##### Examples + +* Disable current node according to the value of example `nodeDisabled` attribute: + +```javascript +var data = nodeCtx.data; +if (data.hasOwnProperty('nodeDisabled') && data['nodeDisabled'] !== null) { + return data['nodeDisabled'] === 'true'; +} else { + return false; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_has_children_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_has_children_fn.md new file mode 100644 index 0000000..9e1888d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_has_children_fn.md @@ -0,0 +1,50 @@ +#### Node has children function + +
    +
    + +*function (nodeCtx): boolean* + +A JavaScript function evaluating whether current node has children (whether it can be expanded). + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +`true` if node should have children, `false` otherwise. + +
    + +##### Examples + +* Restrict entities hierarchy expansion up to third level: + +```javascript +return nodeCtx.level <= 2; +{:copy-code} +``` + +* Restrict entities expansion according to the value of example `nodeHasChildren` attribute: + +```javascript +var data = nodeCtx.data; +if (data.hasOwnProperty('nodeHasChildren') && data['nodeHasChildren'] !== null) { + return data['nodeHasChildren'] === 'true'; +} else { + return true; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_icon_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_icon_fn.md new file mode 100644 index 0000000..2da8c79 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_icon_fn.md @@ -0,0 +1,56 @@ +#### Node icon function + +
    +
    + +*function (nodeCtx): {iconUrl?: string, materialIcon?: string} | 'default'* + +A JavaScript function used to compute node icon info. + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +Should return node icon info object with the following structure: + +```typescript +{ + iconUrl?: string, + materialIcon?: string +} +``` +Resulting object should contain either `materialIcon` or `iconUrl` property.
    +Where: + - `materialIcon` - name of the material icon to be used from the [Material Icons Library{:target="_blank"}](https://material.io/tools/icons); + - `iconUrl` - url of the external image to be used as node icon. + +Function can return `default` string value. In this case default icons according to entity type will be used. + +
    + +##### Examples + +* Use external image for devices which name starts with `Test` and use default icons for the rest of entities: + +```javascript +var entity = nodeCtx.entity; +if (entity.id.entityType === 'DEVICE' && entity.name.startsWith('Test')) { + return {iconUrl: 'https://avatars1.githubusercontent.com/u/14793288?v=4&s=117'}; +} else { + return 'default'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_opened_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_opened_fn.md new file mode 100644 index 0000000..08c9607 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_opened_fn.md @@ -0,0 +1,38 @@ +#### Node opened by default function + +
    +
    + +*function (nodeCtx): boolean* + +A JavaScript function evaluating whether current node should be opened (expanded) when it first loaded. + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +`true` if node should be opened (expanded), `false` otherwise. + +
    + +##### Examples + +* Open by default nodes up to third level: + +```javascript +return nodeCtx.level <= 2; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_relation_query_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_relation_query_fn.md new file mode 100644 index 0000000..2120fe4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_relation_query_fn.md @@ -0,0 +1,52 @@ +#### Node relations query function + +
    +
    + +*function (nodeCtx): [EntityRelationsQuery{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/dda61383933cac9aa6821a77ff9b19291e69db9f/ui-ngx/src/app/shared/models/relation.models.ts#L69) | 'default'* + +A JavaScript function used to compute child nodes relations query for current node. + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +Should return [EntityRelationsQuery{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/dda61383933cac9aa6821a77ff9b19291e69db9f/ui-ngx/src/app/shared/models/relation.models.ts#L69) for current node used to fetch entity children.
    +Function can return `default` string value. In this case default relations query will be used. + +
    + +##### Examples + +* Fetch child entities having relations of type `Contains` from the current entity: + +```javascript +var entity = nodeCtx.entity; +var query = { + parameters: { + rootId: entity.id.id, + rootType: entity.id.entityType, + direction: "FROM", + maxLevel: 1 + }, + filters: [{ + relationType: "Contains", + entityTypes: [] + }] +}; +return query; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_text_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_text_fn.md new file mode 100644 index 0000000..5ce79b7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/node_text_fn.md @@ -0,0 +1,44 @@ +#### Node text function + +
    +
    + +*function (nodeCtx): string* + +A JavaScript function used to compute text or HTML code for the current node. + +**Parameters:** + +
      +
    • widgetCtx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    • nodeCtx: HierarchyNodeContext - An + HierarchyNodeContext object + containing entity field holding basic entity properties
      (ex. id, name, label) and data field holding other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +Should return string value presenting text or HTML for the current node. + +
    + +##### Examples + +* Display entity name and optionally temperature value if it is present in entity attributes/timeseries: + +```javascript +var data = nodeCtx.data; +var entity = nodeCtx.entity; +var text = entity.name; +if (data.hasOwnProperty('temperature') && data['temperature'] !== null) { + text += " "+ data['temperature'] +" °C"; +} +return text; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/nodes_sort_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/nodes_sort_fn.md new file mode 100644 index 0000000..2c2375f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entities_hierarchy/nodes_sort_fn.md @@ -0,0 +1,47 @@ +#### Nodes sort function + +
    +
    + +*function (nodeCtx1, nodeCtx2): number* + +A JavaScript function used to compare nodes of the same level when sorting. + +**Parameters:** + + + +**Returns:** + +Should return integer value presenting nodes comparison result: +- **less than 0** - sort `nodeCtx1` to an index lower than `nodeCtx2`; +- **0** - leave `nodeCtx1` and `nodeCtx2` unchanged with respect to each other; +- **greater than 0** - sort `nodeCtx2` to an index lower than `nodeCtx1`; + +
    + +##### Examples + +* Sort entities first by entity type in alphabetical order then by entity name in alphabetical order: + +```javascript +var result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType); +if (result === 0) { + result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name); +} +return result; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_content_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_content_fn.md new file mode 100644 index 0000000..e2dd12e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_content_fn.md @@ -0,0 +1,143 @@ +#### Cell content function + +
    +
    + +*function (value, entity, ctx): string* + +A JavaScript function used to compute entity cell content HTML depending on entity field value. + +**Parameters:** + +
      +
    • value: any - An entity field value displayed in the cell. +
    • +
    • entity: EntityData - An + EntityData object + presenting basic entity properties (ex. id, entityName) and
      provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return string value presenting cell content HTML. + +
    + +##### Examples + +* Format entity created time using date/time pattern: + +```javascript +var createdTime = value; +return createdTime ? ctx.date.transform(createdTime, 'yyyy-MM-dd HH:mm:ss') : ''; +{:copy-code} +``` + +* Styled cell content for device type field: + +```javascript +var deviceType = value; +var color = '#fff'; +switch (deviceType) { + case 'thermostat': + color = 'orange'; + break; + case 'default': + color = '#abab00'; + break; +} +return '
    ' + deviceType + '
    '; +{:copy-code} +``` + +* Colored circles instead of boolean value: + +```javascript +var color; +var active = value; +if (active == 'true') { // all key values here are strings + color = '#27AE60'; +} else { + color = '#EB5757'; +} +return ''; +{:copy-code} +``` + +* Decimal value format (1196 => 1,196.0): + +```javascript +var value = value / 1; +function numberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} +return value ? numberWithCommas(value.toFixed(1)) : ''; +{:copy-code} +``` + +* Show device status and icon this : + +```javascript +{:code-style="max-height: 200px; max-width: 850px;"} +function getIcon(value) { + if (value == 'QUEUED') { + return '' + + '' + + '' + + '' + + ''; + } + if (value == 'UPDATED' ) { + return '' + + 'update' + + ''; + } + return ''; +} +function capitalize (s) { + if (typeof s !== 'string') return ''; + return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); +} +var status = value; +return getIcon(status) + '' + capitalize(status) + ''; +{:copy-code} +``` + +* Display device attribute value on progress bar: + +```javascript +{:code-style="max-height: 200px; max-width: 850px;"} +var progress = value; +if (value !== '') { + return `` + + `` + + ``; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_style_fn.md new file mode 100644 index 0000000..442fca3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entity/cell_style_fn.md @@ -0,0 +1,62 @@ +#### Cell style function + +
    +
    + +*function (value, entity, ctx): {[key: string]: string}* + +A JavaScript function used to compute entity cell style depending on entity field value. + +**Parameters:** + +
      +
    • value: any - An entity field value displayed in the cell. +
    • +
    • entity: EntityData - An + EntityData object + presenting basic entity properties (ex. id, entityName) and
      provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set color and font-weight table cell content: + +```javascript +return { + color:'rgb(0, 132, 214)', + fontWeight: 600 +} +{:copy-code} +``` + +* Set color depending on device temperature value: + +```javascript +var temperature = value; +var color = 'black'; +if (temperature) { + if (temperature > 25) { + color = 'red'; + } else { + color = 'green'; + } +} +return { + fontWeight: 'bold', + color: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/entity/row_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/entity/row_style_fn.md new file mode 100644 index 0000000..4867cb9 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/entity/row_style_fn.md @@ -0,0 +1,60 @@ +#### Row style function + +
    +
    + +*function (entity, ctx): {[key: string]: string}* + +A JavaScript function used to compute entity row style depending on entity value. + +**Parameters:** + +
      +
    • entity: EntityData - An + EntityData object + presenting basic entity properties (ex. id, entityName) and
      provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set color and font-weight table row: + +```javascript +return { + color:'rgb(0, 132, 214)', + fontWeight: 600 +} +{:copy-code} +``` + +* Set row background color depending on device type: + +```javascript +var deviceType = entity.Type; +var color = '#fff'; +switch (deviceType) { + case 'thermostat': + color = 'orange'; + break; + case 'default': + color = '#abab00'; + break; +} +return { + backgroundColor: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/flot/point_shape_format_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/flot/point_shape_format_fn.md new file mode 100644 index 0000000..c36f7e3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/flot/point_shape_format_fn.md @@ -0,0 +1,111 @@ +#### Point shape draw function + +
    +
    + +*function (ctx, x, y, radius, shadow): void* + +A JavaScript function used to draw custom shapes for chart points when `Custom function` for point shape is selected. + +**Parameters:** + +
      +
    • + ctx: CanvasRenderingContext2D - A canvas drawing context. +
    • +
    • + x number - point center X coordinate. +
    • +
    • + y number - point center Y coordinate. +
    • +
    • + radius number - point radius. +
    • +
    • + shadow boolean - whether to draw shadow. +
    • +
    + +
    + +##### Examples + +* Draw square: + +```javascript +var size = radius * Math.sqrt(Math.PI) / 2; +ctx.rect(x - size, y - size, size + size, size + size); +{:copy-code} +``` + +* Draw circle: + +```javascript +ctx.moveTo(x + radius, y); +ctx.arc(x, y, radius, 0, shadow ? Math.PI : Math.PI * 2, false); +{:copy-code} +``` + +* Draw diamond: + +```javascript +var size = radius * Math.sqrt(Math.PI / 2); +ctx.moveTo(x - size, y); +ctx.lineTo(x, y - size); +ctx.lineTo(x + size, y); +ctx.lineTo(x, y + size); +ctx.lineTo(x - size, y); +ctx.lineTo(x, y - size); +{:copy-code} +``` + +* Draw triangle: + +```javascript +var size = radius * Math.sqrt(2 * Math.PI / Math.sin(Math.PI / 3)); +var height = size * Math.sin(Math.PI / 3); +ctx.moveTo(x - size / 2, y + height / 2); +ctx.lineTo(x + size / 2, y + height / 2); +if (!shadow) { + ctx.lineTo(x, y - height / 2); + ctx.lineTo(x - size / 2, y + height / 2); + ctx.lineTo(x + size / 2, y + height / 2); +} +{:copy-code} +``` + +* Draw cross: + +```javascript +var size = radius * Math.sqrt(Math.PI) / 2; +ctx.moveTo(x - size, y - size); +ctx.lineTo(x + size, y + size); +ctx.moveTo(x - size, y + size); +ctx.lineTo(x + size, y - size); +{:copy-code} +``` + +* Draw ellipse: + +```javascript +if (!shadow) { + ctx.moveTo(x + radius, y); + ctx.arc(x, y, radius, 0, Math.PI * 2, false); +} +{:copy-code} +``` + +* Draw plus: + +```javascript +var size = radius * Math.sqrt(Math.PI / 2); +ctx.moveTo(x - size, y); +ctx.lineTo(x + size, y); +ctx.moveTo(x, y + size); +ctx.lineTo(x, y - size); +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/flot/ticks_formatter_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/flot/ticks_formatter_fn.md new file mode 100644 index 0000000..12de0f7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/flot/ticks_formatter_fn.md @@ -0,0 +1,104 @@ +#### Ticks formatter function + +
    +
    + +*function (value): string* + +A JavaScript function used to format Y axis ticks. + +**Parameters:** + +
      +
    • value: number - A tick value that should be formatted. +
    • +
    + +**Returns:** + +A string presenting the formatted value to be displayed as Y axis tick. + +
    + +##### Examples + +* Display ticks as is: + +```javascript +return value; +{:copy-code} +``` + +* Present ticks in Amperage (A) units and two decimal places: + +```javascript +return value.toFixed(2) + ' A'; +{:copy-code} +``` + +* Disable ticks: + +```javascript +return ''; +{:copy-code} +``` + +* Present ticks in decimal format (1196 => 1,196.0): + +```javascript +var value = value / 1; +function numberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} +return value ? numberWithCommas(value.toFixed(1)) : ''; +{:copy-code} +``` + +
      +
    • +To present axis ticks for true / false or 1 / 0 data.
      +Display On when value > 0 and <= 1,
      +Off when value = 0,
      +disable for all other values.
      +Note: To avoid duplicates among Y axis ticks it is recommended to set Steps size between ticks to 1: +
    • +
    + +```javascript +if (value > 0 && value <= 1) { + return 'On'; +} else if (value === 0) { + return 'Off'; +} else { + return ''; +} +{:copy-code} +``` + +
      +
    • +To present axis ticks for state or level data.
      +Display High when value >= 2,
      +Medium when value >= 1 and < 2,
      +Low when value >= 0 and < 1,
      +disable for all other values.
      +Note: To avoid duplicates among Y axis ticks it is recommended to set Steps size between ticks to 1
      +or other suitable value depending on your case: +
    • +
    + +```javascript +if (value >= 2) { + return 'High'; +} else if (value >= 1) { + return 'Medium'; +} else if (value >= 0) { + return 'Low'; +} else { + return ''; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/flot/tooltip_value_format_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/flot/tooltip_value_format_fn.md new file mode 100644 index 0000000..bffbfe4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/flot/tooltip_value_format_fn.md @@ -0,0 +1,51 @@ +#### Tooltip value format function + +
    +
    + +*function (value): string* + +A JavaScript function used to format datapoint value to be shown on the chart tooltip. + +**Parameters:** + +
      +
    • value: primitive (number/string/boolean) - A value of the datapoint that should be formatted. +
    • +
    + +**Returns:** + +A string representing the formatted value. + +
    + +##### Examples + +* Present the datapoint value in tooltip in Celsius (°C) units: + +```javascript +return value + ' °C'; +{:copy-code} +``` + +* Present the datapoint value in tooltip in Amperage (A) units and two decimal places: + +```javascript +return value.toFixed(2) + ' A'; +{:copy-code} +``` + +* Present the datapoint value in decimal format (1196 => 1,196.0): + +```javascript +var value = value / 1; +function numberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} +return value ? numberWithCommas(value.toFixed(1)) : ''; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md new file mode 100644 index 0000000..ba58fa3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md @@ -0,0 +1,65 @@ +#### Clustering marker function + +
    +
    + +*function (data, childCount): string* + +A JavaScript function used to compute clustering marker color. + +**Parameters:** + +
      +
    • data: FormattedData[] + - the array of total markers contained within each cluster.
      + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • childCount: number - the total number of markers contained within that cluster +
    • +
    + +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +##### Examples + +
      +
    • +Calculate color depending on temperature telemetry value: +
    • + + +```javascript +let customColor; +for (let markerData of data) { + if (markerData.temperature > 40) { + customColor = 'red' + } +} +return customColor ? customColor : 'green'; +{:copy-code} +``` + +
    • +Calculate color depending on childCount: +
    • + +```javascript +if (childCount < 10) { + return 'green'; +} else if (childCount < 100) { + return 'yellow'; +} else { + return 'red'; +} +{:copy-code} +``` + +
    +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md new file mode 100644 index 0000000..64e994f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md @@ -0,0 +1,42 @@ +#### Marker color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the marker. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', amount = percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md new file mode 100644 index 0000000..eb3e6f9 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md @@ -0,0 +1,40 @@ +#### Marker label function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code of the marker label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the marker label. + +
    + +##### Examples + +* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}, ${energy:2} kWt'; + } else if (deviceType == "thermometer") { + return '${entityName}, ${temperature:2} °C'; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md new file mode 100644 index 0000000..7a4f24f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md @@ -0,0 +1,10 @@ +
  • data: FormattedData - A FormattedData object associated with marker or data point of the route.
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • dsData: FormattedData[] - All available data associated with markers or routes data points as array of FormattedData objects
    + resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
    + and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • dsIndex number - index of the current marker data or route data point in dsData array.
    + Note: The data argument is equivalent to dsData[dsIndex] expression. +
  • diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md new file mode 100644 index 0000000..565de01 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md @@ -0,0 +1,62 @@ +#### Marker image function + +
    +
    + +*function (data, images, dsData, dsIndex): {url: string, size: number}* + +A JavaScript function used to compute marker image. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return marker image data having the following structure: + +```typescript +{ + url: string, + size: number +} +``` + +- *url* - marker image url; +- *size* - marker image size; + +In case no data is returned, default marker image will be used. + +
    + +##### Examples + +
      +
    • +Calculate image url depending on temperature telemetry value for thermometer device type.
      +Let's assume 4 images are defined in Marker images section. Each image corresponds to particular temperature level: +
    • +
    + +```javascript +var type = data['Type']; +if (type == 'thermometer') { + var res = { + url: images[0], + size: 40 + } + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120; + var index = Math.min(3, Math.floor(4 * percent)); + res.url = images[index]; + } + return res; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md new file mode 100644 index 0000000..056e47d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md @@ -0,0 +1,42 @@ +#### Path color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path. + +In case no data is returned, color value from **Path color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', amount = percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md new file mode 100644 index 0000000..30f5e0f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md @@ -0,0 +1,43 @@ +#### Path point color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path point. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path point. + +In case no data is returned, color value from **Point color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', amount = percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md new file mode 100644 index 0000000..736bd72 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md @@ -0,0 +1,42 @@ +#### Polygon color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the polygon. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the polygon. + +In case no data is returned, color value from **Polygon color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `thermostat` device type: + +```javascript +var type = data['Type']; +if (type == 'thermostat') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', amount = percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md new file mode 100644 index 0000000..86de41c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md @@ -0,0 +1,40 @@ +#### Polygon tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the polygon tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `energy meter` or `thermostat` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermostat") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md new file mode 100644 index 0000000..5687e4f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md @@ -0,0 +1,65 @@ +#### Position conversion function + +
    +
    + +*function (origXPos, origYPos, data, dsData, dsIndex, aspect): {x: number, y: number}* + +A JavaScript function used to convert original relative x, y coordinates of the marker. + +**Parameters:** + +
      +
    • origXPos: number - original relative x coordinate as double from 0 to 1.
    • +
    • origYPos: number - original relative y coordinate as double from 0 to 1.
    • + {% include widget/lib/map/map_fn_args %} +
    • aspect: number - image map aspect ratio.
    • +
    + +**Returns:** + +Should return position data having the following structure: + +```typescript +{ + x: number, + y: number +} +``` + +- *x* - new relative x coordinate as double from 0 to 1; +- *y* - new relative y coordinate as double from 0 to 1; + +
    + +##### Examples + +* Scale the coordinates to half the original: + +```javascript +return {x: origXPos / 2, y: origYPos / 2}; +{:copy-code} +``` + +* Detect markers with same positions and place them with minimum overlap: + +```javascript +var xPos = data.xPos; +var yPos = data.yPos; +var locationGroup = dsData.filter((item) => item.xPos === xPos && item.yPos === yPos); +if (locationGroup.length > 1) { + const count = locationGroup.length; + const index = locationGroup.indexOf(data); + const radius = 0.035; + const angle = (360 / count) * index - 45; + const x = xPos + radius * Math.sin(angle*Math.PI/180) / aspect; + const y = yPos + radius * Math.cos(angle*Math.PI/180); + return {x: x, y: y}; +} else { + return {x: xPos, y: yPos}; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md new file mode 100644 index 0000000..d4d97d9 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md @@ -0,0 +1,40 @@ +#### Marker tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the marker, point or polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `thermostat` device type: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermometer") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md new file mode 100644 index 0000000..e938532 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md @@ -0,0 +1,34 @@ +#### Point as anchor function + +
    +
    + +*function (data, dsData, dsIndex): boolean* + +A JavaScript function evaluating whether to use trip point as time anchor used in time selector. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +`true` if the point should be decided as anchor, `false` otherwise. + +In case no data is returned, the point is not used as anchor. + +
    + +##### Examples + +* Make anchors with 5 seconds step interval: + +```javascript +return data.time % 5000 < 1000; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/markdown/markdown_text_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/markdown/markdown_text_fn.md new file mode 100644 index 0000000..c11f412 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/markdown/markdown_text_fn.md @@ -0,0 +1,61 @@ +#### Markdown text function + +
    +
    + +*function (data, ctx): string* + +A JavaScript function used to calculate markdown or HTML content. + +**Parameters:** + +
      +
    • data: FormattedData[] - An array of FormattedData objects resolved from configured datasources.
      + Each object represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return string value presenting markdown or HTML content. + +
    + +##### Examples + +* Display markdown with first entity name information: + +```javascript +return '# Some title\n - Entity name: ' + data[0]['entityName']; +{:copy-code} +``` + +
      +
    • +Display greetings for currently logged-in user.
      +Let's assume widget has first datasource configured using Current User Single entity alias
      +and has data keys for firstName, lastName and name entity fields: +
    • +
    + +```javascript +var userEntity = data[0]; +var userName; +if (userEntity.firstName || userEntity['First name']) { + userName = userEntity.firstName || userEntity['First name']; +} else if (userEntity.lastName || userEntity['Last name']) { + userName = userEntity.lastName || userEntity['Last name']; +} else if (userEntity.name || userEntity['Name']) { + userName = userEntity.name || userEntity['Name']; +} + +var welcomeText = 'Hi, ' + userName + '!\n\n'; +return welcomeText; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/qrcode/qrcode_text_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/qrcode/qrcode_text_fn.md new file mode 100644 index 0000000..2d7f40b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/qrcode/qrcode_text_fn.md @@ -0,0 +1,55 @@ +#### QR code text function + +
    +
    + +*function (data): string* + +A JavaScript function used to calculate text to be displayed as QR code. + +**Parameters:** + +
      +
    • data: FormattedData[] - An array of FormattedData objects resolved from configured datasources.
      + Each object represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • +
    + +**Returns:** + +Should return string value presenting text to be displayed as QR code. + +
    + +##### Examples + +* Prepare QR code text from name of the first entity if present: + +```javascript +return data[0] ? data[0]['entityName'] : ''; +{:copy-code} +``` + +
      +
    • +Prepare QR code text to use as device claiming info (in this case {deviceName: string, secretKey: string}).
      +Let's assume device has claimingData attribute with string JSON value containing secretKey field
      +(see Claiming devices): +
    • +
    + +```javascript +var entityData = data[0]; +if (entityData) { + return JSON.stringify({ + deviceName: entityData.entityName, + secretKey: JSON.parse(entityData.claimingData).secretKey + }); +} else { + return ''; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/rpc/convert_value_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/convert_value_fn.md new file mode 100644 index 0000000..816fa10 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/convert_value_fn.md @@ -0,0 +1,40 @@ +#### Convert value function + +
    +
    + +*function (value): any* + +A JavaScript function converting target on/off state of the control widget to payload of the RPC set value command. + +**Parameters:** + +
      +
    • value: boolean - value indicating target on/off state of the control widget. +
    • +
    + +**Returns:** + +Payload object or primitive to be used by the RPC set value command. + +
    + +##### Examples + +* Use original target value as payload: + +```javascript +return value; +{:copy-code} +``` + +* Create json payload with `enabled` boolean property: + +```javascript +return { enabled: value }; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_gpio_status_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_gpio_status_fn.md new file mode 100644 index 0000000..417283a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_gpio_status_fn.md @@ -0,0 +1,35 @@ +#### Parse GPIO status function + +
    +
    + +*function (body, pin): boolean* + +A JavaScript function evaluating enabled/disable state of GPIO pin from the response of GPIO status request. + +**Parameters:** + +
      +
    • body: any - response body of the GPIO status request. +
    • +
    • pin: number - number of the GPIO pin. +
    • +
    + +**Returns:** + +`true` if GPIO pin should be enabled, `false` otherwise. + +
    + +##### Examples + +* Detect status of the pin assuming response body is array of boolean pins states: + +```javascript +return body[pin] === true; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_value_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_value_fn.md new file mode 100644 index 0000000..c2479de --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/rpc/parse_value_fn.md @@ -0,0 +1,41 @@ +#### Parse value function + +
    +
    + +*function (data): boolean* + +A JavaScript function converting attribute/timeseries value or value of the RPC command response to boolean value. + +**Parameters:** + +
      +
    • data: any - attribute/timeseries value or value of the RPC command response. +
    • +
    + +**Returns:** + +`true` if control widget should be switched on, `false` otherwise. + +
    + +##### Examples + +* Switch on control widget for any positive value: + +```javascript +return data ? true : false; +{:copy-code} +``` + +* Parse control widget state from json payload having `enabled` boolean property: + +```javascript +var payload = typeof data === 'string' ? JSON.parse(data) : data; +return payload && payload.enabled ? true : false; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_content_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_content_fn.md new file mode 100644 index 0000000..0fe3b8b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_content_fn.md @@ -0,0 +1,53 @@ +#### Cell content function + +
    +
    + +*function (value, rowData, ctx): string* + +A JavaScript function used to compute timeseries cell content HTML depending on timeseries field value. + +**Parameters:** + +
      +
    • value: any - An entity field value displayed in the cell. +
    • +
    • rowData: TimeseriesRow - A + TimeseriesRow object + presenting formattedTs (a string value of formatted timestamp) and
      timeseries values for each column declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return string value presenting cell content HTML. + +
    + +##### Examples + +* Styled cell content for temperature field: + +```javascript +var temperature = value; +var color = '#fff'; +if (temperature) { + if (temperature > 25) { + color = 'red'; + } else { + color = 'green'; + } +} +return '
    ' + temperature + '
    '; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_style_fn.md new file mode 100644 index 0000000..202423f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/cell_style_fn.md @@ -0,0 +1,52 @@ +#### Cell style function + +
    +
    + +*function (value, rowData, ctx): {[key: string]: string}* + +A JavaScript function used to compute timeseries cell style depending on timeseries field value. + +**Parameters:** + +
      +
    • value: any - An timeseries field value displayed in the cell. +
    • +
    • rowData: TimeseriesRow - A + TimeseriesRow object + presenting formattedTs (a string value of formatted timestamp) and
      timeseries values for each column declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set color depending on temperature value: + +```javascript +var temperature = value; +var color = 'black'; +if (temperature) { + if (temperature > 25) { + color = 'red'; + } else { + color = 'green'; + } +} +return { + fontWeight: 'bold', + color: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/row_style_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/row_style_fn.md new file mode 100644 index 0000000..fd1ea7c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/timeseries/row_style_fn.md @@ -0,0 +1,49 @@ +#### Row style function + +
    +
    + +*function (rowData, ctx): {[key: string]: string}* + +A JavaScript function used to compute timeseries row style depending on row value. + +**Parameters:** + +
      +
    • rowData: TimeseriesRow - A + TimeseriesRow object + presenting formattedTs (a string value of formatted timestamp) and
      timeseries values for each column declared in widget datasource configuration. +
    • +
    • ctx: WidgetContext - A reference to WidgetContext that has all necessary API + and data used by widget instance. +
    • +
    + +**Returns:** + +Should return key/value object presenting style attributes. + +
    + +##### Examples + +* Set row background color depending on temperature value: + +```javascript +var temperature = rowData.temperature; +var color = '#fff'; +if (temperature) { + if (temperature > 25) { + color = 'red'; + } else { + color = 'green'; + } +} +return { + backgroundColor: color +}; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/images/rulenode/examples/filter-node.png b/ui-ngx/src/assets/help/images/rulenode/examples/filter-node.png new file mode 100644 index 0000000000000000000000000000000000000000..178df7e314ef08bac2e6a476cf121e772de4d42d GIT binary patch literal 21939 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 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/rulenode/examples/switch-node.png b/ui-ngx/src/assets/help/images/rulenode/examples/switch-node.png new file mode 100644 index 0000000000000000000000000000000000000000..88739a72e5a146fae6c3d959e921b1b3ef542c37 GIT binary patch literal 37494 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 R&WMD;0w{rgH+&=V{|B7HfR+FN literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/add-rpc-device-alias.png b/ui-ngx/src/assets/help/images/widget/editor/examples/add-rpc-device-alias.png new file mode 100644 index 0000000000000000000000000000000000000000..6d7e3c22ac723c7446ef3314014b06473ee1c569 GIT binary patch literal 16840 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 Z`yWO&CNvI!(D?uW002ovPDHLkV1kSFtgQe5 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-one-way.png b/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-one-way.png new file mode 100644 index 0000000000000000000000000000000000000000..579e4edbc57e2d972c9570c9e08bf3cd8be9070f GIT binary patch literal 10978 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 MtG}t1efQ!20ONPLumAu6 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-two-way.png b/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-response-two-way.png new file mode 100644 index 0000000000000000000000000000000000000000..c7761074d00744ca336b7cf83389af545e1e6c88 GIT binary patch literal 10430 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 U{phTQH6k2&85QYrNfZD71B&?6XaE2J literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-settings.png b/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..32870de750587f6cbec83500812e836653bfc7b0 GIT binary patch literal 4241 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 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample.png b/ui-ngx/src/assets/help/images/widget/editor/examples/control-widget-sample.png new file mode 100644 index 0000000000000000000000000000000000000000..8aa35f755c76cf8f35badeaa89f9c4d0bfc4089b GIT binary patch literal 20147 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 be~9=UatO7grs{yd$p}DN2Ab6mY-9cdm&T%C literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/dashboard-toolbar-entity-aliases.png b/ui-ngx/src/assets/help/images/widget/editor/examples/dashboard-toolbar-entity-aliases.png new file mode 100644 index 0000000000000000000000000000000000000000..32deda06cf2787fec6475b8da4c0dc7b2cab0cde GIT binary patch literal 4592 zcmc&&`8$+v)SpUaX+lVhifq{$OU4@6Wywy+FxfL>--$}boy%K%}!WL@w1>~JW^EIid&@a@1vleH63#N-#?r;t* z+h-~Bm4;de+-|Vl0QcNhk`q*8qEY$#(&i;M?Rn50+Sfmkw6D2KVdbG)HbtKe!&X(N zR*Br1$U1%0|_Yw`}7lW*I7(pZQO?lrzPr zhtqC*|Ay*-`O-*aJm%d1hJoo0)u~Qsi_GE86-44#m?}eie0N{nqEaG zGm%vbB|*MK<)_s13+_j`2yoF&8SxKHa|Si9EgCdqCLB&oJo@Zqv@YEPP@*ZJwVa`JLgeyzBbA*2^6TEl;CboasXwByrgN9!hoc9;knjP-i-nUcl=b%1 z$dyq2%FPekhR z+eo^?Z+o*e^tYR}m)OW&LhHBsp$Zg&<=s zs^h!CpZSYj^1%p^%O&%RlmyI2OSivM(Qh#u-MCv`RZ9!$)qA$alTMcxXmK59Xec4k ztia~ITx6Ll*xOdf-px3Te631>(mRAP5^de4f_hHeicUUu={ofzp;{(B&8DMZGsffv zXgb}*5q3Ql_pFr*Gt}{>lauXzazSI4tgf&r+TjCmpt{br!^Wgs5VV95L%yFoY1u_P z9QP}BKu+vcZhFW=L+KDV8f8qx+AfsAUTMM%NDAFb6A#2kw|ZWSz4MH)L#x&$#u*sb zz`AU(TaYH`K0cwHA7z`~V)1LMNHv{KL;=kY=dsGn%7km{(=ym=?l`wR0tJLr&ULOt zC|!Z{KD28M1Z|%^u2nIO!#lQKUx}8P_Pu0WftLz2(v_cw|H&IMa7Bd(p3P z72Y^BOLQ_1ntH)k7X)B^o};V)S6WxVI$=c3au8}NnBrE_-llEMTT6`(-W8NFMQKg1 zlJ&gKUHR&pf>@=!_E37CfwAf5kq!cS3h~lZ>#%jYA`@(n&d`fOe9wz~4XtE7jsyN* z%Fj)YJU`CFms<2)Pi7KhC6+KLrp{F3KBNcasA4lg5dY;;ZSw+H%-TtAj6HF%^WIqZ?_ZcOok+IM0qaR#o|HS?KA|nd~g#1=>lo5Zw zZ;IM*euB6!8s)mi9dOknYybRfRt&CB?`CF;@rT)`pMWUwM%hC5 zwbW|;(t)2eLg<5)1r*$kdiwfzM@~N^*4g9OcWb%$@L_3xgSK>`Gn zM&?b^G^l^R^n;{op=4zeY938|7#X-J9B;0Ao5Z5$n9(ZNs%hprG1+ckGp{py-ng+A z?@jvX_#LXfT6BCQKG{tdlfA^7?Ur>=JA~~Nm#-EoJWiJ{xRxt!e@SP5l2_6C!V41r zB(2fyuJ*BW-MNoBkoXDIcEiw@Y-}$%~oRUZ4;*`Lrq^wcD`tfy|9`3{qFfK z!%7~M2xuJ=vMj^!s9<~E9r0WFjazz`C@TxGtrBnKXs?A{4EmloNv~SA1!*JSEG}J6 z?N7@#%bKae~VH0!;bJ=dvY^?ymRkOnG7^Jii)&g$ajOnNrHzhtqUW6d$P=ro<2Q z%Fn;S^$dkF>}3w4KX|V^{0_97xoP^XAp)p;n(HKe7`DyNraX880|h&~v1ZO&6%i%a zj`ex-(R*z*v<>EE(V3U*7S?n)P~m30PGaxqK;74HhW8%|x}?F$Ej@MWu>}MQF>dh@ z;_jILkCw{TY!^5?bd)G#wZsc?u(pmJQUaY>sZ@B~$FkHjfR=8j0U4}V->N4_mpg`= zce}ICdzs9=O32Q?mY7}H-m9Z48#{k|_v>AKnU1uFcS+*SgQw%{w2`SD!DqpK;}AU$ zR>vqE6gsGtQKn`uA)JyJzL9PGb{`arb7Du_UK;55B+StM=E_0UGs|Ts;rr*msKRMA zgWGUz`C>Z|qQjiKv2RBc&T`mDjj_+r#5d2XcrR^dUd!SucAo0(3k#i%2`0 ztLCxL6??4x+&)0}$0oK(Y&ns6Ucz*aCk@$d}|xe3yydcgPE&RNC*#x~JoW{fvO z-TRMQgSo&+ofwI>0?q$QvN-{zOu%$3UMG1ui(zVQ3PSJg|GrU!||7v-qw^WFD;C!#ou;Be>h znjFVg1A43Hy*%=>>+65O#Tyt>IpO42UJce2wDi>Rva?HGRSXv#3pNZDlpY`dm^Y_6 z5QVHW=&k}fU!q*l`)sHD9b+I^WVT2u0XjNMPr6_SODiqM@2i(RU}FZk&!AF+k3QC^ z1o;`1W!p-q2c0lTr!IDWLHSoY;~a*%8??VE+Jn+zB8~@7HfH>7g*F@=q2>5-h3^A# zXmnaAGC}lU?0Xa)N4i<4&qLpt=krS-tP)s(0gSyRy6)HOlT2E2&J~t6m(qAYVGz{)qN?=AF5t$FDH#366ys!>Dka?$mwunObpCaHlg?|TRDOy1We@8ffW z&HA_QS|zTN))ERKf3A?sK)JX=i1u+8Do_rK>?AZK`Mak2#nTg%`prPK%tWk;F6m?y zYctR^y~z z88CW@rb`KQYq1c*_j|5^uy!WU%>PRRc5v{##OU8Hb0`vQnRzRp3BOYsqnO7XNvmwt z7|K`jhwaSyC-_{BspJkXA6?15d<{|URXjJ|qxFKp{$HIG>h9Z2^OCxM3nVu-bI01o zP$7VL#;W5$EW1zn(TjUd^*jhLWKs=w9LZlOgJWfDS8K*SHOyJL&wCgz0PgN z((u>ZwD7>eVRx)MUzkvV!mBbHszRc%63f^pMX*7`N4X(RA{9q)SR(#cQ+ua9po+RI zVi^4L;#R+E>~ctitc?AKL>~NgQlvr_o_~tb{|CiTMcV%O%?AL$%YzM=8ub9418|2R z4v+TM#>U35SmSwGBP%NcfpB!R=f?FO@DqtC*o+SH%wXYB2iUcyxF8VxZ|@fs70KQ` z_Y$bw%j|wbGb8Fh=8D&qpDJ_!I4!6JNi__q{xapxsPV+gDt2b}OqTYwi@ST+-l`c% zOlaO$UO`^IySrQQmVu)qKb#f-57b7;ii>+DFLWe;wm|XTOf*L3=3|qSTp>Nh@ZX7k zPwTudLTCUG#T*EUOiuWFF!WXHah{LA|Hs^w;^N}<^>x3uuU@^P1|j1yt?t3W$w_}H zqQ-rS3gEKyFEid!M-#h}1)*<$1Z;Fv1J%A5`uX`G5ah|pNougYz44~Pr*l!abMx}X z$Hus>M(GLg($06@4u1-TLM<(WP7WnhnD`zAD`t(3jtUxN{rnf;u}iC+cx{K*#&n}r z;w?&OQ%6S!I_#*6?B7{U69np*mVEG_Xn3WduyA~Qyr-v!BG}-yfE;p3>2e6znBJp= zZb3p1H@}z{ih;oavRqb0`@bSC$Hm1NV@B-6|Lh&Djh7ss{++1t5c$wuRaI3`AY3zX zI{PbvK(LC<78Vw^Q=?YowEPRSB-Km%8YeZ9P-r1x_F&DY!`&-adw zg5O2J(i8&LPxdD~9_Lm3_@MyfVFaBAfYvR(aaF;@Z%pEmj*gD69?rJMv0D{Ej;`$O z?S*DMzLzRkIk&Ss0K9ZTg(*2XIk+*4J9>5Xd6N)fcz76#Ew8T^ey^Qv5V+XQmY^vm zC%3W{m}4yTuM28zAtNikY+Q?ri(3$`Teq^ZvhIn9#1X`U8B~~@DuQ0TXr!j$=#9R< z{x7>yYT2nZr9-$>S!ro%uCA^=y_X6t8DH@oKr1(gogP&<59kS)1O-v388a|2pq86R zbkNXfr#2pWqNcF0kOPS+EQ|nBJNcjoIm7|+m~M!v`J(g9%El()ZFbwVDwDc}1-mj? zAXfbjDlKjE_*5WSh?Rb-A;>2KXeKq4U03`@=;@K4ni@57 z&V;3dnec<-(};+OYca&>#t;z^k^B0LK;ReSO903u7_=Tq^*I%6+i{(c_5w(q0WQSh zd^^LBss7a7QXW9*+*TEj`O-Onof?h_uzAa3w}q_{0KmJdJteaN0HCH$@Hqgr1;kX_ tO4R>p{Ws14>X-ni2LGP|*`yO?Ub`&Ep0qd1)R!EqnCAhmg!QCOSk+aDCJoSC` zo_ed!{;_J;%&hL2X<1kI^n@zPOQ5{PdkX@AP^2V9l|djF4iE?$0Rb9lVWmCx0fEZK zq(p^OU6+nm-M*;K{^mH}Tk$Z@lD(Ccghn)kkL+Dl|FA4w5b~sLskHv5t8}hqCQL>z zyFqw)UfbZ#w$eyvQW(=(san_yR`fS4g&-LYf*-v}+DOOR4#YS}3IUM<{dVsTeq^P& zne}dkr&(c)>yBXa!c|Zd`1QZ1xnGYWUH3XQVU;YS(!-=`s;853=}~zG1%ci;FWzGBLgGzk31IHF!#s=?K4&+OlEXd?I$Z*hp-7E4e zKcNOjMm`9iFTAdnd?{zIq@ke!%*L%qJaBf`zje{p#yeN=%9Q4)$|%MVr@XuzCV4zA zcM`0llV*s+%L{B-Psp4de-;x~IALfsW~8_mKQS5_@CtM8jlw4!S!Y5X;W$q8a|ECX zgIwFdB6V(Z@>{+@`uvfa+7I|wS>Q}QP@p3a1a=s;R1iN$=DrH7NK|y1?c^tAks@_Z z*z0~9iSm5T8h4qoZJcq+;F|(o3&A+qh?>w#Zx0nEZlpdL)K+-+rODsPn~_6{2<1hBI}h>IQ=PN`w^;rbkYJR2>nm?GSHfy z9S=-jo+jA7D1X+H@2@lELH30V!eo*T`-wA8^m|~E8OjfQjyCpXFMWM|d91$W=($Zz z$^N#0s!Y=CDCS1f4>Kkm?R)Yk4flGA1Ivy8d&$X57?L1sC9<-zy2SwWLomt}0?-#1 z7bPCLcec0xR|^CdPHxGydw-;)3|4de8+M|otZHz2%KL^REmAHWAl4J&9CCSiDN8l| z%bO~GFd-?a-&o`yli-J|I8{x}&%T_lW+o;du&{1TC3XJo)+{Jui78-UXh``(L0;b0 zS2&sX!rR;1p9xTjFo6L^qWC!SMbnn`fbxI&w6LB{B^9@7>(Z>7zW=I*U=ng|`-idP za?^1svIwNZ zHN8gF%O^12rBJ}u0k-7#YS+D8WK;m>LnaQUSNSsMzV>1K=W_mg{@3(>?fAcrodl_9 zzWIpK{Yeu6(ENb_boziTc&o|R&BXcl-2bvL;bvsKMFqH(P^-82e7KV#n-PHLn=VE6 z$0NsMP6uY@b*8x<1raGH%c@(H<+O`MJU`gnG$G{Op?yrG%cp-ey$%v`{n zJ88Jn@C1YVI`JFkx*N(;V0Jl7hIH*;U*KOAe`eh?5!sw?E~T0*5+m|~a48P0<#`y7 zos4WrAlRvu%rRVdFx%NTK|BH+;9pq!WSmlQ#23T?Sissl9qwR6QVOX<<_f3w>rai$ zxk?7_(<+V*eD20=WGw*N#{4q7}D*$M0>L57_tspGRtISxc5ij zygGs28r$ez{WuD#IEsJ?C%w4*?M`I#2(V+wJ^gA&%HwnqpvAqyqN2fNIXOa!J0|>F z+4Vqo48h!Tmx-v%9;@86rY=C}b6irKqx4a=1RnhE7ty>9aZbH?b~zb}cl20*y%7w` zNX0sLXDyV-eeKt+AvLM*7#mhHpf{XbI>aK;UDHj$OM`e-zr9 z=5*|w^z47bVpAl^rm%=jvtdn2`Fj1+=O~^sG2n~K9XpcAxBMwW#N)@mb?%2WZG1;U zy;tM*tIBzGX~4RH2WodJyVaV{%M&n8p5))Ro;&vOGO2Th?Ii2vagVf3`Xl<>KXd$# z{pMn`!=7o9p7WwpGi;IgJP2*(Jt{d39`jM10p3XJBV_&@wtQSz8kPz{_Xn|)}F)=X-2?=*Y zigzjeUJpH?@26$DHqRgLuk2b53S-G7I-Vbnkgyrs?swC)+yMznAmDQ=I0=UUa;(qI z%{}Upq%f*oY+)=eDWRpK!)G;%pdVH#lr1hRQ&sy>An~fmRahsg=GB^S-@bkH_L&dd z{OY>e;#ArB+(IGso=P^E*Uer=Mn*_T=u$0h)U0-qmq@`K5zte>gJ5tP^&r$PdJZ!* z)-^T?+{~!hHu4Zfr^yyxzom+&!3wX={%duLzxPfQAMKtF_D9C;+)Sl@u4#Ffhoervb)E z9kzT@QBoq^ad>sFs^-+X8a8!wKfSP^sj8ab*=DocaJ?13Ak`TS5Fv4i*$4kQfu8ZF zlu2xRx?K)3tZQhf(5fxW%{4k+sFstHo1L94DPh!Uuzvh|0Y6|4?4x6{>kqzJn}w>) z`@JmqY>OqE##MFTJ7qqWj&Iv!`1?C-t=VYJqIEo#93H1FfiJ#Y+r>_j!R@^#xp+h} zmjhKe;DiC~jY_!K>KC|MaahIL-P>Dtn%7&!iX!53KV502X9j#_o-axU!0vzS_df&u zA2SUOZ$z5FFhpt#oU=AbhdokEd_|1pc!X9tquQei318{4YUuhL$b8;@<&b&Bv;a3s zn|^ZU4|AG!F0HFIOEIgrDvcjNR5d0*??aT=Bv{aGZSFmkowwy*HaXVZ#9D_NG|f-{ z*gGC0ETX>!Y1wp{zgP6CFKkj<(E8t=rVe^s43%x;oO|?giMlmB*N;KGSjZR{N}W|q zcqr71G_9RUvyTMV^tC8vu!TKyRJ!A>ufQB<@~029->-0{j)lp|i}P-@k4Y5oLX-5} zEw=h1vBFIcXNmz0168?Q?TUqC-FtQb7$Jq#yhXQysF5nnj$;vy$N6~?4_jC;ne|V# zE*8r_mN{3d*pcGIK93iG1yvv=G3a2S2Kzi68LWHVt;!@Z+E`m72X|lXjd$**T4lQa z2?d4)B4HDO^SI*M17GN*(4i4*s&i9zWM6@a_!CJ$)6JX?9W8AO<4TobH)rR)TzKyv z=hkt)yOo=xc?|&B{`e7z%PO9@L!(lts;*vA;vOSsE-(3vYLq%Vah|z(z4OXZq zg_NbWHSaHHyr+AAlcb49bvDj`pXhvxif<& z&S#6YAKk{yR8*z`i(IjF0b-a-wXE@?l9J^U^#uL#EC3_s=8`$;M%;3OntnJ@bKh+< z)&VWAw!||)NP$Ofy`N8%?l8u6)^;UNk7Wf0Yj%1+X_;rR17TWWVd3GSrJ5Qb84mBq zzcAai@>^yR3Vu zm);D~Rm!e$f8C1Y?0CE!=FhV03Bh1BA3L6&bEy1v>jCwvF;z_Np9HP1<5dH{0tQJqi`p-$AXgj*dehAD3WuJPsH>+_p zSAzIcTVPkz8-?!HxB}^azACt%e+h5dceC>V*(MHC#D^-YcFcNk0F6J>U}MY~Q4H(6 zZS_d}(P}1AC3p0!M|(Z`>~EmDgTU~Qe~1=!i+4B^Jm<13#@bh@D*jAWv<+Phrh;2X zJ5p>msxG!pw77mB)-D?73nol8IQ&L#xmv5VgB<-7Zr=P%3*J7rIN(B@;!ecB;P4?q7-t=X zjm;gFTf5u&Ensv2V0m)9jCYCjS~W(!Z>6NwYWUB8!BA3Crg7To*MVcl_vo0IvbY=y zhj$Lw#jgs7+*F``O^~{mbTD-AVNQO>$0+x&cg)f-{V1HaaUjX`X=(k!$KL){)j3%u@p9Yieq0+!qmR z>Nb=>19+>F64XchJ-t}>iEvo(JZof#M_BMP+A{?W) zx^K7tJu{}E#uNPqkc3XK$ie4deIh%{8D0%;Vo#OuA2%@cG)+|<1 zG%mld4@Pb-!Mnb_{ppvLvnh+gk7!s>9bfpLwQ_FyL{ZaAHWbs;Bt!Z>3!qtVgPk9Rb`3Aa)0;iEg!(+Nsvhh*N7<0%!Trlw9!O_|gKflXL$ z)9PtSNy&C8f0lxpTH|s#^$^shmXeavo-8I$3?kiFd-cKu! zA7y<)7DTGT3583?c$A&Ib@Wzi9~LW1oX2=|{-!2ABDn4b;*M^=Gp(+cPFc-;q==p% z`r{jIcFB2J(!IsSs4O#1EZ+xSPKDOUI`Aj86WNpmTs5RD(^jE-j|C41Kq;spq7Iep zE256BfQj@Mqz~GUnd}?l&~C%X!yRVk1X$X)cg|b}hx}y}!>Tdjns~60sqecFA_~lm zNynV3k{~g!Y70fJ*u*9MqAY4_Tfw-7eJV^dNl)6RgcuqHL_m`lp6*L%ae&P2@5klH ziRw}BJ9;Th5YIc7k4#{m>q9dX;L4KfKc8CAxRPR7PL%lkGNR&c0_)j7>q-godsSQf zB_G;fml;Ho?5<5;GxmA;sq~_hcY2LW|CZaNOe`;6xX;xjLSk75K@D0Eg8y}V!H1#! zWVFc;X`VL-%tv6%BmPX%M-^Ugj$eAm6-5n4z4*oC}2Z2k`JgV&fBGgwdkQawTXg#j-^)czGH z#FmMvv_sIG=yL0+Fb+J4%CRw^mJt#OCjHnt6D)`7kcDiMn2{Wc3W?rul4%EDKQXtXvpGThXuNAnFwAj&?|?o5uA+Bo8I* zA9oK~K@LoAG5bM?2Srf|tOyYPuWIX0soFifBa8Hss`%T=d&x|5Pv*RM&23 z{p8oNlRCyU|9rK*D<2rQ*v)yWdecAtPFON-l-*Ks?p}nI(u2dqK@^i7*GM!^Mc
    1pRV**QK!JH4b(igz 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 i<}b*D%^i5%JvIMu*pvj+62w(cm59{Un)=l literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/external-js-widget-sample.png b/ui-ngx/src/assets/help/images/widget/editor/examples/external-js-widget-sample.png new file mode 100644 index 0000000000000000000000000000000000000000..1c82113ac3123abed46b06db816929072a9d9b0e GIT binary patch literal 9426 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 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/help/images/widget/editor/examples/timeseries-widget-sample.png b/ui-ngx/src/assets/help/images/widget/editor/examples/timeseries-widget-sample.png new file mode 100644 index 0000000000000000000000000000000000000000..f9cb92baf7bbeeab9fa8f026674d2ac926f5c7d1 GIT binary patch literal 28945 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 qyhU;@2o`9qN1M&PON= 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 iV&_@$q2+YC87j9T{b5}Intenta iniciar sessió amb un altre usuari si encara vols accedir a aquesta ubicació.", + "refresh-token-expired": "La sessió ha expirat", + "refresh-token-failed": "No és pot actualitzar la sessió", + "permission-denied": "Permís Denegat", + "permission-denied-text": "No tens suficients drets per fer aquesta operació!" + }, + "action": { + "activate": "Activar", + "suspend": "Suspendre", + "save": "Guardar", + "saveAs": "Guardar com", + "cancel": "Cancel·lar", + "ok": "OK", + "delete": "Eliminar", + "add": "Afegir", + "yes": "Sí", + "no": "No", + "update": "Actualitzar", + "remove": "Eliminar", + "select": "Seleccionar", + "search": "Cercar", + "clear-search": "Eliminar la recerca", + "assign": "Assignar", + "unassign": "Anul·lar l'assignació", + "share": "Compartir", + "make-private": "Fer privat", + "make-public": "Fer públic", + "apply": "Aplicar", + "apply-changes": "Aplicar canvis", + "edit-mode": "Mode Edició", + "enter-edit-mode": "Mode Edició", + "decline-changes": "Desfer canvis", + "open": "Obrir", + "close": "Tancar", + "back": "Darrere", + "run": "Executar", + "sign-in": "Entrar!", + "edit": "Editar", + "view": "Veure", + "create": "Crear", + "drag": "Arrossegar", + "refresh": "Actualitzar", + "undo": "Desfer", + "copy": "Copiar", + "paste": "Pegar", + "copy-reference": "Copiar referència", + "paste-reference": "Pegar referència", + "import": "Importar", + "export": "Exportar", + "share-via": "Compartir via {{provider}}", + "move": "Moure", + "continue": "Continuar", + "discard-changes": "Cancelar canvis", + "download": "Descarregar", + "next-with-label": "Següent: {{label}}", + "read-more": "Llegir més", + "hide": "Ocultar", + "done": "Acabat", + "print": "Imprimir", + "restore": "Restaurar", + "confirm": "Confirmar" + }, + "aggregation": { + "aggregation": "Agrupació", + "function": "Funció d'Agrupació", + "limit": "Valors Max", + "group-interval": "Interval d'agrupació", + "min": "Min", + "max": "Max", + "avg": "Mitjana", + "sum": "Suma", + "count": "Contar", + "none": "Ningú" + }, + "admin": { + "general": "General", + "general-settings": "Configuració general", + "home-settings": "Configuració d'inici", + "outgoing-mail": "Servidor de correu", + "outgoing-mail-settings": "Configuració del servidor de correu de sortida", + "system-settings": "Sistema", + "test-mail-sent": "Correu de prova enviat correctament!", + "base-url": "Base d'URL", + "base-url-required": "Base d'URL requerida.", + "prohibit-different-url": "Prohibir l'ús de hostname a capçaleres de sol·licitud del client", + "prohibit-different-url-hint": "Aquest ajust ha de ser activat en entorns de producció. Pot ocasionar errors de seguretat si està desactivat", + "mail-from": "Correu de", + "mail-from-required": "Cal correu electrònic", + "smtp-protocol": "Protocol SMTP", + "smtp-host": "Host SMTP", + "smtp-host-required": "Host SMTP requerit.", + "smtp-port": "Port SMTP", + "smtp-port-required": "Has d'ingresar un Port SMTP.", + "smtp-port-invalid": "No sembla un Port SMTP vàlid.", + "timeout-msec": "Temps d'espera (ms)", + "timeout-required": "Temps d'espera requerit.", + "timeout-invalid": "No sembla un temps d'espera vàlid.", + "enable-tls": "Habilitar TLS", + "tls-version": "Versió TLS", + "enable-proxy": "Habilitar proxy", + "proxy-host": "Host proxy", + "proxy-host-required": "Es necessita port Proxy.", + "proxy-port": "Port proxy", + "proxy-port-required": "Es necessita port Proxy.", + "proxy-port-range": "El port proxy ha d'estar amb un rang d'1 a 655535.", + "proxy-user": "Usuari proxy", + "proxy-password": "Contrasenya proxy", + "change-password": "Canvia la contrasenya", + "send-test-mail": "Enviar correu de prova", + "use-system-mail-settings": "Utilitzar la configuració del servidor de correu del sistema.", + "mail-templates": "Plantilles de correu.", + "mail-template-settings": "Configuració de plantilles de correu.", + "use-system-mail-template-settings": "Utilitza les plantilles de correu del sistema", + "mail-template": { + "mail-template": "Plantilles del correu", + "test": "Missatge de prova de correu", + "activation": "Missatge d'activació de compte", + "account-activated": "Missatge de compte activat", + "account-lockout": "Missatge de bloqueig del compte", + "reset-password": "Missatge per a restabliment de contrasenya", + "password-was-reset": "Missatge de contrasenya restablerta", + "user-activated": "Missatge activat per l'usuari", + "user-registered": "Missatge registrat de l'usuari", + "api-usage-state-enabled": "Estat d'ús de l'API activat", + "api-usage-state-warning": "Avís d'estat d'ús de l'API", + "api-usage-state-disabled": "Estat d'ús de l'API desactivat" + }, + "mail-subject": "Assumpte del correu", + "mail-body": "Cos del correu", + "sms-provider": "Proveïdor SMS", + "sms-provider-settings": "Configuració proveïdor SMS", + "use-system-sms-settings": "Utilitza la configuració del proveïdor de SMS del sistema", + "sms-provider-type": "Tipus de proveïdor SMS", + "sms-provider-type-required": "Es requereix proveïdor SMS.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "ID de clau d'accés AWS", + "aws-access-key-id-required": "Es requereix ID de clau d'accés AWS", + "aws-secret-access-key": "Clau d'accés secret AWS", + "aws-secret-access-key-required": "Es requereix Clau d'accés secret AWS", + "aws-region": "Regió AWS", + "aws-region-required": "Es requereix regió AWS", + "number-from": "Núm. de telèfon Origen", + "number-from-required": "Es requereix Núm. de telèfon origen.", + "number-to": "Núm. de telèfon de destí", + "number-to-required": "Es requereix Núm. de telèfon de destí.", + "phone-number-hint": "Núm. de telèfon en format E.164, ex. +3435550123", + "phone-number-hint-twilio": "Núm. de telèfon en format E.164/SID del número de telèfon/SID del servei de missatgeria, ex. +3435550123/PNXXX/MGXXX", + "phone-number-pattern": "Núm. Invàlid. Ha d'estar en format E.164, ex. +3435550123.", + "phone-number-pattern-twilio": "El número de telèfon no és vàlid. Hauria d'estar en el format E.164/SIF/Servei de Missatgeria del Número de telèfon, ex. +3435550123/PNXXX/MGXXX.", + "sms-message": "Missatge SMS", + "sms-message-required": "Es requereix missatge SMS.", + "sms-message-max-length": "Els SMS no poden ser més llargs de 1600 caràcters", + "twilio-account-sid": "SID de compte Twilio", + "twilio-account-sid-required": "Es requereix SID de compte Twilio", + "twilio-account-token": "Token de compte Twilio", + "twilio-account-token-required": "Es requereix Token Twilio", + "send-test-sms": "Enviar SMS de prova", + "test-sms-sent": "SMS enviat amb èxit!", + "security-settings": "Configuracions de seguretat", + "password-policy": "Política de contrasenyes", + "minimum-password-length": "Longitud mínima de contrasenya", + "minimum-password-length-required": "Es requereix una longitud mínima de contrasenya", + "minimum-password-length-range": "La longitud mínima de la contrasenya ha d'estar en un rang de 5 a 50", + "minimum-uppercase-letters": "Nom mínim de lletres majúscules", + "minimum-uppercase-letters-range": "El nom mínim de lletres majúscules no pot ser negatiu", + "minimum-lowercase-letters": "Nom mínim de lletres minúscules", + "minimum-lowercase-letters-range": "El nom mínim de lletres minúscules no pot ser negatiu", + "minimum-digits": "Nom mínim de dígits", + "minimum-digits-range": "El nom mínim de dígits no pot ser negatiu", + "minimum-special-characters": "Nom mínim de caràcters especials.", + "minimum-special-characters-range": "El nom mínim de caràcters especials no pot ser negatiu.", + "password-expiration-period-days": "Període de caducitat de contrasenya en dies", + "password-expiration-period-days-range": "El període de caducitat de la contrasenya en dies no pot ser negatiu", + "password-reuse-frequency-days": "Freqüència de reutilització de contrasenya en dies", + "password-reuse-frequency-days-range": "La freqüència de reutilització de contrasenya en dies no pot ser negativa", + "allow-whitespace": "Permet espais en blanc", + "general-policy": "Política general", + "max-failed-login-attempts": "Nom màxim d'intents fallits d'inici de sessió, abans que el compte estigui bloquejat", + "minimum-max-failed-login-attempts-range": "El nom màxim d'intents fallits d'inici de sessió no pot ser negatiu", + "user-lockout-notification-email": "En cas de bloqueig del compte de l'usuari, enviï una notificació per correu electrònic", + "domain-name": "Nom de domini", + "domain-name-unique": "El nom de domini i protocol ha de ser únic.", + "domain-name-max-length": "El nom del domini ha de ser inferior a 256", + "error-verification-url": "Un nom de domini no ha de contenir símbols '/' i ':'. Exemple: thingsboard.io", + "oauth2": { + "access-token-uri": "URI Access token", + "access-token-uri-required": "Es requereix URI Access token.", + "activate-user": "Activar usuari", + "add-domain": "Afegir domini", + "delete-domain": "Esborrar domini", + "add-provider": "Afegir proveïdor", + "delete-provider": "Esborrar proveïdor", + "allow-user-creation": "Permetre creació d'usuari", + "always-fullscreen": "Sempre pantalla completa", + "authorization-uri": "URI Autorizació", + "authorization-uri-required": "Es requereix l'autorització d'URI.", + "client-authentication-method": "Mètode d'autenticació", + "client-id": "ID Client", + "client-id-required": "Es requereix ID Client.", + "client-id-max-length": "L'identificador del client ha de ser inferior a 256", + "client-secret": "Secret de Client", + "client-secret-required": "Es requereix Secret de Client.", + "client-secret-max-length": "El secret del client hauria de ser inferior a 2049", + "custom-setting": "Configuració personalitzats", + "customer-name-pattern": "Patró nomeni de client", + "customer-name-pattern-max-length": "El patró del nom del client hauria de ser inferior a 256", + "parent-customer-name-pattern": "Patró del nom del client pare", + "user-groups-name-pattern": "Patró de nom de grups d'usuaris", + "default-dashboard-name": "Nom de panell per defecte", + "default-dashboard-name-max-length": "El nom del tauler predeterminat hauria de ser inferior a 256", + "delete-domain-text": "Atenció, després de la confirmació el domini i totes les dades del proveïdor no estaran disponibles.", + "delete-domain-title": "Eliminar els configuració del domini '{{domainName}}'?", + "delete-registration-text": "Atenció, després de la confirmació les dades del proveïdor no estaran disponibles.", + "delete-registration-title": "Eliminar el proveïdor '{{name}}'?", + "email-attribute-key": "Clau d'atribut de correu electrònic", + "email-attribute-key-required": "Cal la clau de l'atribut de correu electrònic.", + "email-attribute-key-max-length": "La clau de l'atribut de correu electrònic ha de ser inferior a 32", + "first-name-attribute-key": "Clau de atributs de nom", + "first-name-attribute-key-max-length": "La clau de l'atribut de nom ha de ser inferior a 32", + "general": "General", + "jwk-set-uri": "URI web key JSON", + "last-name-attribute-key": "Clau d'atributs de cognom", + "last-name-attribute-key-max-length": "La clau de l'atribut del cognom ha de ser inferior a 32", + "login-button-icon": "Icona del botó d'inici de sessió", + "login-button-label": "Etiqueta de proveïdor", + "login-button-label-placeholder": "Inicia la sessió amb $(Provider label)", + "login-button-label-required": "Clau d'etiqueta requerida.", + "login-provider": "Proveïdor d'inici de sessió", + "mapper": "Cartografiador", + "new-domain": "Nou domini", + "oauth2": "OAuth2", + "password-max-length": "La contrasenya ha de ser inferior a 256", + "redirect-uri-template": "Plantilla de redirecció URI", + "copy-redirect-uri": "Copiar URI de redirecció", + "registration-id": "ID de registre", + "registration-id-required": "Es requereix ID de registre.", + "registration-id-unique": "L'ID de registre ha de ser únic en el sistema.", + "scope": "Abast", + "scope-required": "Es requereix abast.", + "tenant-name-pattern": "Patró de nom de propietari", + "tenant-name-pattern-required": "Es requereix patró del nom del propietari.", + "tenant-name-pattern-max-length": "El patró de nom de l'inquilí pot ser inferior a 256", + "tenant-name-strategy": "Estratègia de Nom de Propietari", + "type": "Tipus de cartografiador", + "uri-pattern-error": "Format de URI invàlid.", + "url": "URL", + "url-pattern": "Format URL invàlid.", + "url-required": "Es requereix URL.", + "url-max-length": "L'URL ha de ser inferior a 256", + "user-info-uri": "URI Informació de l'usuari", + "user-info-uri-required": "Es requereix URI de la informació usuari.", + "username-max-length": "El nom d'usuari ha de ser inferior a 256", + "user-name-attribute-name": "Clau d'atributs de nom d'usuari", + "user-name-attribute-name-required": "Es requereix clau d'atributs de nom d'usuari", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Activar configuració OAuth2", + "domains": "Dominis", + "mobile-apps": "Aplicacions mòbils", + "no-mobile-apps": "No s'ha configurat cap aplicació", + "mobile-package": "Paquet d'aplicació", + "mobile-package-placeholder": "Ex.: my.example.app", + "mobile-package-hint": "Per a Android: el vostre propi identificador únic d'aplicació. Per a iOS: identificador de paquet de producte", + "mobile-package-unique": "El paquet d'aplicació ha de ser únic.", + "mobile-app-secret": "Secret de l'aplicació", + "invalid-mobile-app-secret": "El secret de l'aplicació ha de contenir només caràcters alfanumèrics i ha de tenir entre 16 i 2048 caràcters de longitud.", + "copy-mobile-app-secret": "Copia el secret de l'aplicació", + "add-mobile-app": "Afegeix una aplicació", + "delete-mobile-app": "Suprimeix la informació de l'aplicació", + "providers": "Proveïdors", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Totes les plataformes", + "allowed-platforms": "Plataformes permeses" + }, + "smpp-provider": { + "smpp-version": "Versión SMPP", + "smpp-host": "Host SMPP", + "smpp-host-required": "Host SMPP requerido", + "smpp-port": "Puerto SMPP", + "smpp-port-required": "Puerto SMPP requerido", + "system-id": "System ID", + "system-id-required": "System ID requerido", + "password": "Contraseña", + "password-required": "Contraseña requerida", + "type-settings": "Configuració de tipo", + "source-settings": "Configuració de origen", + "destination-settings": "Configuració de destino", + "additional-settings": "Configuració adicionales", + "system-type": "Tipo de sistema", + "bind-type": "Tipo de enlace", + "service-type": "Tipo de servicio", + "source-address": "Dirección de origen", + "source-ton": "Origen TON", + "source-npi": "Origen NPI", + "destination-ton": "Destino TON (Tipo de número)", + "destination-npi": "Destino NPI (Numbering Plan Identification)", + "address-range": "Rango de dirección", + "coding-scheme": "Esquema de codificación", + "bind-type-tx": "Transmisor", + "bind-type-rx": "Receptor", + "bind-type-trx": "Transceptor", + "ton-unknown": "Desconocido", + "ton-international": "Internacional", + "ton-national": "Nacional", + "ton-network-specific": "Específico de red", + "ton-subscriber-number": "Número de suscriptor", + "ton-alphanumeric": "Alfanumérico", + "ton-abbreviated": "Abreviado", + "npi-unknown": "0 - Desconocido", + "npi-isdn": "1 - RDSI/Teléfono plan numérico (E163/E164)", + "npi-data-numbering-plan": "3 - Datos plan numérico (X.121)", + "npi-telex-numbering-plan": "4 - Telex plan numérico (F.69)", + "npi-land-mobile": "6 - Línea Móvil (E.212)", + "npi-national-numbering-plan": "8 - Nacional", + "npi-private-numbering-plan": "9 - Privado", + "npi-ermes-numbering-plan": "10 - ERMES (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (a definir por WAP Forum)", + "scheme-smsc": "0 - Alfabeto por defecto SMSC (ASCII para códigos cortos y largos y GSM para gratuitos)", + "scheme-ia5": "1 - IA5 (ASCII para códigos cortos y largos, Latin 9 para gratuitos (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octetos sin especificar (binario 8-bit)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octetos sin especificar (binario 8-bit)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Ciríllico (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebreo (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Codificación por Pictograma", + "scheme-music-codes": "10 - Códigos musicales (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Kanji extendido JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Set de carácteres Koreanos (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Seleccionar nom de cua", + "queue-name": "Nom", + "queue-name-required": "Cal el nom de cua!", + "queues": "Cues", + "queue-partitions": "Particions", + "queue-submit-strategy": "Estratègia d'enviaments", + "queue-processing-strategy": "Estratègia de processament", + "queue-configuration": "Configuració Cua", + "repository-settings": "Ajustos del repositori", + "repository-url": "URL del repositori", + "repository-url-required": "Es requereix la URL del repositori.", + "default-branch": "Nom de branca per defecte", + "authentication-settings": "Ajustos d'autenticació", + "auth-method": "Mètode d'autenticació", + "auth-method-username-password": "Usuari / Contrasenya", + "auth-method-private-key": "Clau privada", + "password-access-token": "Contrasenya / Tóken d'accés", + "change-password-access-token": "Canviar contrasenya / Tóken d'accés", + "private-key": "Clau privada", + "drop-private-key-file-or": "Arrossegar i deixar anar un fitxer de clau privada o", + "passphrase": "Frase de contrasenya", + "enter-passphrase": "Entrar frase de contrasenya", + "change-passphrase": "Canviar frase de contrasenya", + "check-access": "Verificar accés", + "check-repository-access-success": "Accés al repositori verificat satisfactòriament!", + "delete-repository-settings-title": "Estàs segur d'esborrar els ajustos del repositori?", + "delete-repository-settings-text": "Atenció, després de la confirmació els ajustos del repositori seran eliminats i la característica de control de versions no estarà disponible.", + "auto-commit-settings": "Ajustis Acte-publicar", + "auto-commit-entities": "Entitats Acte-publicar", + "no-auto-commit-entities-prompt": "No hi ha entitats configurades per a acte-publicar", + "delete-auto-commit-settings-title": "Estàs segur d'esborrar els ajustos d'acte-publicar?", + "delete-auto-commit-settings-text": "Atenció, després de la confirmació els ajustos d'acte-publicar seran esborrats i la característica d'acte-publicar es desactivarà per a totes les entitats.", + "2fa": { + "2fa": "Autenticació de dos factors (2FA)", + "available-providers": "Proveïdors disponibles", + "issuer-name": "Nom d'emissor", + "issuer-name-required": "Cal el nom d'emissor.", + "max-verification-failures-before-user-lockout": "Màxim de fallades de verificació abans de bloquejar compte", + "max-verification-failures-before-user-lockout-pattern": "Màxim de fallades ha de ser un nombre enter positiu.", + "number-of-checking-attempts": "Nombre d'intents de verificació", + "number-of-checking-attempts-pattern": "Nombre d'intents ha de ser un nombre enter positiu.", + "number-of-checking-attempts-required": "Cal el nombre d'intents.", + "number-of-codes": "Nombre de codis", + "number-of-codes-pattern": "Nombre de codis ha de ser un nombre enter positiu.", + "number-of-codes-required": "Cal el nombre de codis.", + "provider": "Proveïdor", + "retry-verification-code-period": "reintents de codi de verificació (seg)", + "retry-verification-code-period-pattern": "El període mínim és de 5 seg", + "retry-verification-code-period-required": "Cal reintent.", + "total-allowed-time-for-verification": "Total de temps permès per a verificació (seg)", + "total-allowed-time-for-verification-pattern": "El mínim del total de temps permès és de 60 seg", + "total-allowed-time-for-verification-required": "Cal total del temps.", + "use-system-two-factor-auth-settings": "Usar ajustos 2FA del sistema", + "verification-code-check-rate-limit": "Límit de revisió mèdica del codi de verificació", + "verification-code-lifetime": "Temps de vida del codi de verificació (seg)", + "verification-code-lifetime-pattern": "Temps de vida del codi de verificació ha de ser un nombre enter positiu.", + "verification-code-lifetime-required": "Cal el temps de vida.", + "verification-message-template": "Plantilla del missatge de verificació", + "verification-limitations": "Límits de verificació", + "verification-message-template-pattern": "El missatge de verificació ha de contenir el patró: ${code}", + "verification-message-template-required": "Cal plantilla de missatge de verificació.", + "within-time": "Rang de temps (seg)", + "within-time-pattern": "Temps ha de ser un nombre enter positiu.", + "within-time-required": "Cal el temps." + } + }, + "alarm": { + "alarm": "Alarma", + "alarms": "Alarmes", + "select-alarm": "Seleccionar alarma", + "no-alarms-matching": "No s'han trobat alarmes coincidents amb '{{entity}}' .", + "alarm-required": "Cal alarma", + "alarm-status": "Estat d'alarma", + "alarm-status-list": "Llista d'estats d'Alarmes", + "any-status": "Qualsevol estat", + "search-status": { + "ANY": "Totes", + "ACTIVE": "Actives", + "CLEARED": "Borrades", + "ACK": "Reconegudes", + "UNACK": "Ignorades" + }, + "display-status": { + "ACTIVE_UNACK": "Activa no reconeguda", + "ACTIVE_ACK": "Activa reconeguda", + "CLEARED_UNACK": "Normalitzada no reconeguda", + "CLEARED_ACK": "Normalitzada reconeguda" + }, + "no-alarms-prompt": "No és van trobar alarmes", + "created-time": "Hora de creació", + "type": "Tipus", + "severity": "Gravetat", + "originator": "Origen", + "originator-type": "Tipus d'origen", + "details": "Detalls", + "status": "Estat", + "alarm-details": "Detalls Alarma", + "start-time": "Hora d'inici", + "end-time": "Hora fi", + "ack-time": "Hora de reconeixement", + "clear-time": "Hora de normalització", + "alarm-severity-list": "Llista de gravetat d'alarmes", + "any-severity": "Qualsevol gravetat", + "severity-critical": "Crítica", + "severity-major": "Major", + "severity-minor": "Menor", + "severity-warning": "Perill", + "severity-indeterminate": "Indeterminada", + "acknowledge": "Reconèixer", + "clear": "Normalitzar", + "search": "Buscar alarmes", + "selected-alarms": "{ count, plural, 1 {1 alarma} other {# alarmas} } seleccionades", + "no-data": "Sense dades que mostrar", + "polling-interval": "Cicle de refresc d'alarmes (seg)", + "polling-interval-required": "Es requereix un cicle de refresc vàlid.", + "min-polling-interval-message": "El cicle ha de ser almenys d'1 segon.", + "aknowledge-alarms-title": "Reconèixer { count, plural, 1 {1 alarma} other {# alarmas} }", + "aknowledge-alarms-text": "Aquestes segur de reconèixer { count, plural, 1 {1 alarma} other {# alarmas} }?", + "aknowledge-alarm-title": "Reconèixer Alarma", + "aknowledge-alarm-text": "Estàs segur de reconèixer Alarma?", + "clear-alarms-title": "Normalitzar { count, plural, 1 {1 alarma} other {# alarmas} }", + "clear-alarms-text": "Netejar { count, plural, 1 {1 alarma} other {# alarmas} }?", + "clear-alarm-title": "Netejar Alarma", + "clear-alarm-text": "Netejar Alarma?", + "alarm-status-filter": "Filtre d'estats d'Alarmes", + "alarm-filter": "Filtre d'Alarmes", + "max-count-load": "Nom màxim d'alarmes a carregar (0 - ilimitado)", + "max-count-load-required": "Es requereix nom màxim d'alarmes.", + "max-count-load-error-min": "El valor mínim és 0.", + "fetch-size": "Mida de cerca (Fetch)", + "fetch-size-required": "Es requereix grandària de cerca.", + "fetch-size-error-min": "El valor mínim és 10.", + "alarm-type-list": "Llista de tipus d'alarma", + "any-type": "Qualsevol tipus", + "search-propagated-alarms": "Buscar alarmes propagades" + }, + "alias": { + "add": "Afegir àlies", + "edit": "Editar àlies", + "name": "Nom Àlies", + "name-required": "Es requereix nom d'àlies", + "duplicate-alias": "Ja existeix aquest nom d'àlies.", + "filter-type-single-entity": "Única entitat", + "filter-type-entity-group": "Entitats de grup", + "filter-type-entity-list": "Llista d'entitats", + "filter-type-entity-name": "Nom d'entitat", + "filter-type-entity-type": "Tipus d'entitat", + "filter-type-entity-group-list": "Llista d'entitats de grup", + "filter-type-entity-group-name": "Nom d'entitat de grup", + "filter-type-entities-by-group-name": "Entitats per nom de grup", + "filter-type-state-entity": "Entitat des de l'estat del panell", + "filter-type-state-entity-description": "Entitat presa dels paràmetres de panell", + "filter-type-state-entity-owner": "Propietari de l'entitat des de l'estat del tauler", + "filter-type-state-entity-owner-description": "Propietari de l'entitat presa dels paràmetres d'estat del tauler de control", + "filter-type-asset-type": "Tipus d'actiu", + "filter-type-asset-type-description": "Actius del tipus '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Actius del tipus '{{assetType}}' i el nom del qual comenci per '{{prefix}}'", + "filter-type-device-type": "Tipus de dispositiu", + "filter-type-device-type-description": "Dispositius de tipus '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispositius de tipus '{{deviceType}}' i el nom del qual comenci per '{{prefix}}'", + "filter-type-entity-view-type": "Tipus de vista d'entitat", + "filter-type-entity-view-type-description": "Vistes d'entitat del tipus '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Vistes d'entitat del tipus '{{entityView}}' i el nom del qual comenci per '{{prefix}}'", + "filter-type-edge-type": "Tipus de vora", + "filter-type-edge-type-description": "Vores del tipus '{{edgeType}}'", + "filter-type-edge-type-and-name-description": "Vores del tipus '{{edgeType}}' i el nom del qual comenci per '{{prefix}}'", + "filter-type-relations-query": "Consulta de relacions", + "filter-type-relations-query-description": "{{entities}} que tenen {{relationType}} relació {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Consultar cerca de vora", + "filter-type-edge-search-query-description": "Vores amb tipus {{edgeTypes}} que tenen {{relationType}} relació {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Cerca d'actius", + "filter-type-asset-search-query-description": "Actius amb tipus {{assetTypes}} que tenen {{relationType}} relació {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Cerca de dispositius", + "filter-type-device-search-query-description": "Dispositius amb tipus {{deviceTypes}} que tenen {{relationType}} relació {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Consulta de cerca de vista d'entitat", + "filter-type-entity-view-search-query-description": "Vistes d'entitat amb tipus {{entityViewTypes}} que tenen tipus de relació {{relationType}} amb direcció {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Ús d'API", + "entity-filter": "Filtre per entitat", + "resolve-multiple": "Prendre com a múltiples entitats", + "filter-type": "Filtre per tipus", + "filter-type-required": "Es requereix filtre per tipus.", + "entity-filter-no-entity-matched": "No s'han trobat entitats amb el filtre especificat.", + "no-entity-filter-specified": "No hi ha filtre d'entitats especificat", + "root-state-entity": "Utilitzar estat de panell com a arrel", + "group-state-entity": "Utilitzar l'entitat del panell d'estats com a entitat de grup", + "group-state-entity-owner": "Usa l'entitat de l'estat del tauler com a propietari del grup d'entitats", + "last-level-relation": "Buscar només la relació d'últim nivell", + "root-entity": "Entitat arrel", + "state-entity-parameter-name": "Nom de paràmetre d'entitat d'estat", + "default-state-entity": "Entitat d'estat predeterminada", + "default-state-entity-group": "Grup d'entitats d'estat predeterminat", + "default-entity-parameter-name": "Per defecte", + "max-relation-level": "Nivell máx de relació", + "unlimited-level": "Nivell il·limitat", + "state-entity": "Entitat estat del panell", + "entities-of-group-state-entity": "Entitats del grup d'entitats del panell d'estat", + "all-entities": "Totes les entitats", + "any-relation": "qualsevol", + "type-assigned-to-edge": "Assignat a vora", + "type-unassigned-from-edge": "Sense assignar des de vores" + }, + "asset": { + "asset": "Actiu", + "assets": "Actius", + "management": "Gestió d'Actius", + "view-assets": "Veure actius", + "add": "Afegir Actiu", + "asset-type-max-length": "El tipus d'actiu ha de ser inferior a 256", + "assign-to-customer": "Assignar al client", + "assign-asset-to-customer": "Assignar Actiu(s) A Client", + "assign-asset-to-customer-text": "Selecciona els actius a assignar al client", + "no-assets-text": "No és van trobar actius", + "assign-to-customer-text": "Selecciona el client al qual assignar els actius", + "public": "Públic", + "assignedToCustomer": "Assignat al client", + "make-public": "Fer actiu públic", + "make-private": "Fer actiu privat", + "unassign-from-customer": "Cancel·lar l'assignació d'actiu del client", + "delete": "Esborrar actiu", + "asset-public": "L'actiu és públic", + "asset-type": "Tipus d'actiu", + "asset-type-required": "Es requereix tipus d'actiu.", + "select-asset-type": "Selecciona tipus d'actiu", + "enter-asset-type": "Entrar tipus d'actiu", + "any-asset": "Qualsevol actiu", + "no-asset-types-matching": "No s'han trobat actius coincidint amb '{{entitySubtype}}' .", + "asset-type-list-empty": "No hi ha cap mena d'actiu seleccionat.", + "asset-types": "Tipus d'actiu", + "name": "Nom", + "name-required": "Cal nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "label-max-length": "L'etiqueta ha de ser inferior a 256", + "description": "Descripció", + "type": "Tipus", + "type-required": "Cal tipus.", + "details": "Detalls", + "events": "Esdeveniments", + "add-asset-text": "Afegir nou actiu", + "asset-details": "Detalls d'actiu", + "assign-assets": "Assignar activus", + "assign-assets-text": "Assignar { count, plural, 1 {1 activo} other {# activos} } a client", + "delete-assets": "Esborrar actius", + "unassign-assets": "Cancel·lar assignació d'actiu", + "unassign-assets-action-title": "Cancel·lar assignació de { count, plural, 1 {1 activo} other {# activos} } del client", + "assign-new-asset": "Assignar nou actiu", + "delete-asset-title": "Eliminar l'actiu '{{assetName}}'?", + "delete-asset-text": "Atenció, després de la confirmació l'actiu i les seves dades seran esborrades i irrecuperables.", + "delete-assets-title": "Eliminar els actius { count, plural, 1 {1 activo} other {# activos} }?", + "delete-assets-action-title": "Esborrar { count, plural, 1 {1 activo} other {# activos} }", + "delete-assets-text": "Atenció, després de la confirmació tots els actius seleccionats i les seves dades seran esborrades i irrecuperables.", + "make-public-asset-title": "Fer l'actiu '{{assetName}}' públic?", + "make-public-asset-text": "Després de la confirmació, l'actiu i les seves dades és faran públics i accessibles per uns altres.", + "make-private-asset-title": "Fer l'actiu '{{assetName}}' privat?", + "make-private-asset-text": "Després de la confirmació, l'actiu i les seves dades és faran privats i accessibles per uns altres.", + "unassign-asset-title": "Cancel·lar l'assignació de l'actiu '{{assetName}}'?", + "unassign-asset-text": "Després de la confirmació, l'actiu serà desassignat i no serà accessible pel client.", + "unassign-asset": "Cancel·lar assignació d'actiu", + "unassign-assets-title": "Cancel·lar les assignacions { count, plural, 1 {1 activo} other {# activos} }?", + "unassign-assets-text": "Després de la confirmació tots els actius seleccionats seran desassignats i no seran accessibles pel client.", + "copyId": "Copiar ID d'actiu", + "idCopiedMessage": "L'ID ha estat copiat al porta-retalls", + "select-asset": "Seleccionar actiu", + "no-assets-matching": "No s'han trobat actius que coincideixin amb '{{entity}}' .", + "asset-required": "Cal nom d'actiu", + "name-starts-with": "El nom de l'actiu que comenci amb", + "help-text": "Utilitzeu «%» segons la necessitat: «%asset.name.contains%», «%asset.name.ends», «asset.starts.with».", + "selected-assets": "{ count, plural, 1 {1 activo} other {# activos} } seleccionats", + "search": "Buscar actius", + "select-group-to-add": "Seleccionar grup objectiu per a afegir actius seleccionats", + "select-group-to-move": "Seleccionar grup objectiu per a moure actius seleccionats", + "remove-assets-from-group": "Està segur que desitja eliminar { count, plural, 1 {1 asset} other {# assets} } del grup '{{entityGroup}}'?", + "group": "Grup d'actius", + "list-of-groups": "{ count, plural, 1 {One asset group} other {List of # asset groups} }", + "group-name-starts-with": "Grups d'actius els noms dels quals comencen amb '{{prefix}}'", + "import": "Importar actius", + "asset-file": "Arxiu de l'actiu", + "label": "Etiqueta", + "assign-asset-to-edge": "Assignar actiu(s) a la vora", + "unassign-asset-from-edge": "Anul·lar actiu de vora", + "unassign-asset-from-edge-title": "Està segur que desitja desassignar l'actiu '{{assetName}}'?", + "unassign-asset-from-edge-text": "Després de la confirmació, l'actiu no serà assignat i la vora no podrà accedir a ell", + "unassign-assets-from-edge-title": "Està segur que desitja desassignar {count, plural, 1 {1 activo} other {# activos} }?", + "unassign-assets-from-edge-text": "Després de la confirmació, tots els actius seleccionats quedaran sense assignar i la vora no podrà accedir a ells.", + "assign-asset-to-edge-text": "Si us plau, seleccioni els actius per a assignar a la vora", + "unassign-assets-from-edge-action-title": "Anul·lar assignació {count, plural, 1 {1 activo} other {# activos} } des de la vora" + }, + "attribute": { + "attributes": "Atributs", + "latest-telemetry": "Última telemetria", + "attributes-scope": "Abast dels atributs del dispositiu", + "scope-latest-telemetry": "Última telemetria", + "scope-client": "Atributs del Client", + "scope-server": "Atributs del Servidor", + "scope-shared": "Atributs Compartits", + "add": "Afegir atribut", + "add-attribute-prompt": "Si us plau afegir atribut", + "key": "Clau", + "key-max-length": "La clau ha de ser inferior a 256", + "last-update-time": "Hora d'última actualització", + "key-required": "Clau de l'atribut requerida.", + "value": "Valor", + "value-required": "Cal valor de l'atribut.", + "delete-attributes-title": "Eliminar { count, plural, 1 {1 atributo} other {# atributos} }?", + "delete-attributes-text": "Atenció, després de la confirmació l'atribut serà eliminat, i la informació relacionada serà irrecuperable.", + "delete-attributes": "Esborrar atribut", + "enter-attribute-value": "Ingressar valor de l'atribut", + "show-on-widget": "Mostrar en Widget", + "widget-mode": "Widget", + "next-widget": "Widget següent", + "prev-widget": "Widget anterior", + "add-to-dashboard": "Afegir al Panell", + "add-widget-to-dashboard": "Afegir widget al Panell", + "selected-attributes": "{ count, plural, 1 {1 atributo} other {# atributos} } seleccionats", + "selected-telemetry": "{ count, plural, 1 {1 telemetría} other {# telemetrías} } seleccionades", + "no-attributes-text": "No és va trobar cap atribut", + "no-telemetry-text": "No és va trobar cap telemetria" + }, + "api-usage": { + "api-usage": "Ús de API", + "alarm": "Alarma", + "alarms-created": "Alarmes creades", + "alarms-created-daily-activity": "Alarmes creades activitat diària", + "alarms-created-hourly-activity": "Alarmes creades per hora", + "alarms-created-monthly-activity": "Alarmes creades activitat mensual", + "data-points": "Punts de dades", + "data-points-storage-days": "Dies d'enregistrament de punts de dades", + "email": "Correu electrònic", + "email-messages": "Missatges de correu electrònic", + "email-messages-daily-activity": "Activitat diaria de correus", + "email-messages-monthly-activity": "Activitat mensual de correus", + "exceptions": "Excepcions", + "executions": "Execucions", + "javascript": "JavaScript", + "javascript-executions": "Execucions JavaScript", + "javascript-functions": "Funcions JavaScript", + "javascript-functions-daily-activity": "Activitat diària de funcions JavaScript", + "javascript-functions-hourly-activity": "Activitat horària de funcions JavaScript", + "javascript-functions-monthly-activity": "Activitat mensual de funcions JavaScript", + "latest-error": "Últim error", + "messages": "Missatges", + "notifications": "Notificacions", + "notifications-email-sms": "Notificacions (Email/SMS)", + "notifications-hourly-activity": "Notificacions per hora", + "permanent-failures": "${entityName} Fallades permanents", + "permanent-timeouts": "${entityName} Temps d'espera permanentes", + "processing-failures": "${entityName} Fallades de processament", + "processing-failures-and-timeouts": "Fallades de processament i temps d'espera", + "processing-timeouts": "${entityName} Temps d'espera de processament", + "queue-stats": "Estadístiques de cues", + "rule-chain": "Cadena de regles", + "rule-engine": "Motor de regles", + "rule-engine-daily-activity": "Activitat diària de motor de regles", + "rule-engine-executions": "Execucions de motor de regles", + "rule-engine-hourly-activity": "Activitat horària de motor de regles", + "rule-engine-monthly-activity": "Activitat mensual de motor de regles", + "rule-engine-statistics": "Estadístiques del motor de regles", + "rule-node": "Node de regles", + "sms": "SMS", + "sms-messages": "Missatges SMS", + "sms-messages-daily-activity": "Activitat diària de missatges SMS", + "sms-messages-monthly-activity": "Activitat mensual de missatges SMS", + "successful": "${entityName} Exitós", + "telemetry": "Telemetria", + "telemetry-persistence": "Persistència de telemetria", + "telemetry-persistence-daily-activity": "Activitat diaria de persistencia de telemetria", + "telemetry-persistence-hourly-activity": "Activitat horària de persistencia de telemetria", + "telemetry-persistence-monthly-activity": "Activitat mensual de persistencia de telemetria", + "transport": "Transport", + "transport-daily-activity": "Activitat diaria de transport", + "transport-data-points": "Punts de dades de transport", + "transport-hourly-activity": "Activitat horària de transport", + "transport-messages": "Missatges de transport", + "transport-monthly-activity": "Activitat mensual de transport", + "view-details": "Veure detalls", + "view-statistics": "Veure estadístiques", + "email-messages-hourly-activity": "Activitat horària de Correus electrònics", + "sms-messages-hourly-activity": "Activitat horària de missatges SMS" + }, + "audit-log": { + "audit": "Auditoria", + "audit-logs": "Registre d'Auditoria", + "timestamp": "Marca horària", + "entity-type": "Tipus Entitat", + "entity-name": "Nom Entitat", + "user": "Usuari", + "type": "Tipus", + "status": "Estat", + "details": "Detalls", + "type-added": "Afegir", + "type-deleted": "Esborrat", + "type-updated": "Actualitzat", + "type-attributes-updated": "Atributs actualizats", + "type-attributes-deleted": "Atributs esborrats", + "type-rpc-call": "Trucada RPC", + "type-credentials-updated": "Credencials actualizades", + "type-assigned-to-customer": "Assignat al Client", + "type-unassigned-from-customer": "Designat al Client", + "type-assigned-to-edge": "Assignat a la vora", + "type-unassigned-from-edge": "Sense assignar de la vora", + "type-activated": "Activat", + "type-suspended": "Suspès", + "type-credentials-read": "Lectura de credencials", + "type-attributes-read": "Lectura d'atributs", + "type-added-to-entity-group": "Agregat al grup", + "type-removed-from-entity-group": "Eliminat del grup", + "type-relation-add-or-update": "Relació actualitzada", + "type-relation-delete": "Relació esborrada", + "type-relations-delete": "Esborrades totes les relacions", + "type-alarm-ack": "Alarma Acusada", + "type-alarm-clear": "Alarma Netejada", + "type-rest-api-rule-engine-call": "Trucada a l'API REST del motor de regles", + "type-made-public": "Fet públic", + "type-made-private": "Fet privat", + "type-login": "Iniciar sessió", + "type-logout": "Tancar sessió", + "type-lockout": "Tancament per bloqueig", + "status-success": "Èxit", + "status-failure": "Fallada", + "audit-log-details": "Detall del registre d'auditoria", + "no-audit-logs-prompt": "No és van trobar registres", + "action-data": "Dades d'acció", + "failure-details": "Detalls del error", + "search": "Buscar registres d'auditoria", + "clear-search": "Esborrar cerca", + "type-assigned-from-tenant": "Assignat des de l'administrador", + "type-assigned-to-tenant": "Assignat a l'administrador", + "type-provision-success": "Dispositiu aprovisionat", + "type-provision-failure": "Aprovisionament fallit", + "type-timeseries-updated": "Telemetria actualitzada", + "type-timeseries-deleted": "Telemetria esborrada", + "type-owner-changed": "S'ha canviat el propietari" + }, + "confirm-on-exit": { + "message": "Tens canvis sense guardar. Abandonar la pàgina?", + "html-message": "Tens canvis sense guardar.
    Abandonar la pàgina?", + "title": "Canvis sense guardar" + }, + "contact": { + "country": "País", + "city": "Ciutat", + "state": "Estat / Província", + "postal-code": "Códig Postal", + "postal-code-invalid": "Format de codi postal invàlid.", + "address": "Direcció", + "address2": "Direcció 2", + "phone": "Telèfon", + "email": "Correu electrònic", + "no-address": "Sense Direcció", + "state-max-length": "La longitud de l'estat ha de ser inferior a 256", + "phone-max-length": "El número de telèfon hauria de ser inferior a 256", + "city-max-length": "La ciutat especificada ha de ser inferior a 256" + }, + "common": { + "username": "Usuari", + "password": "Contrasenya", + "enter-username": "Introdueix el nom d'usuari.", + "enter-password": "Introdueix la contrasenya", + "enter-search": "Introdueix cerca", + "created-time": "Data de creació", + "loading": "Carregant...", + "proceed": "Continua", + "open-details-page": "Obre la pàgina de detalls" + }, + "converter": { + "converter": "Convertidor de dades", + "converters": "Convertidors de dades", + "select-converter": "Seleccionar convertidor de dades", + "no-converters-matching": "Convertidors de dades que coincideixin amb '{{entity}}' no van ser trobats.", + "converter-required": "Cal convertidor de dades", + "delete": "Eliminar convertidor", + "management": "Gestió de convertidors de dades", + "add-converter-text": "Afegir nou convertidor de dades", + "no-converters-text": "Convertidors de dades no trobats", + "selected-converters": "{ count, plural, 1 {1 data converter} other {# data converters} } seleccionats", + "delete-converter-title": "Està segur que desitja eliminar el convertidor de dades '{{converterName}}'?", + "delete-converter-text": "Anar amb compte, després de la confirmació, el convertidor de dades i totes les dades relacionades és tornaran irrecuperables.", + "delete-converters-title": "Està segur que desitja eliminar { count, plural, 1 {1 data converter} other {# data converters} }?", + "delete-converters-action-title": "Eliminar { count, plural, 1 {1 data converter} other {# data converters} }", + "delete-converters-text": "Anar amb compte, després de la confirmació s'eliminaran tots els convertidors de dades seleccionades i totes les dades relacionades és tornaran irrecuperables.", + "events": "Esdeveniments", + "add": "Afegeix convertidor de dades", + "search": "Cerca convertidors de dades", + "converter-details": "Detalls del convertidor de dades", + "details": "Detalls", + "copyId": "Copiar ID del convertidor", + "idCopiedMessage": "ID del convertidor ha estat copiada al porta-retalls", + "debug-mode": "Mode de depuració", + "created-time": "Hora de creació", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "description": "Descripció", + "decoder": "Descodificador", + "encoder": "Codificador", + "test-decoder-fuction": "Prova funció del descodificador", + "test-encoder-fuction": "Prova funció del codificador", + "decoder-input-params": "Paràmetres d'entrada del descodificador", + "encoder-input-params": "Paràmetres d'entrada del codificador", + "payload": "Càrrega útil", + "payload-content-type": "Tipus de contingut de la càrrega útil", + "payload-content": "Contingut de la càrrega útil", + "message": "Missatge", + "message-type": "Tipus de missatge", + "message-type-required": "Cal el tipus de missatge", + "test": "Prova", + "metadata": "Metadades", + "metadata-required": "Les entrades de metadades no poden estar buides.", + "integration-metadata": "Metadades d'integració", + "integration-metadata-required": "Les entrades de metadades d'integració no poden estar buides.", + "output": "Sortida", + "import": "Importar convertidor", + "export": "Exportar convertidor", + "export-failed-error": "No és pot exportar convertidor: {{error}}", + "create-new-converter": "Crear nou convertidor", + "converter-file": "Arxiu del convertidor", + "invalid-converter-file-error": "No és pot importar el convertidor: Estructura del convertidor de dades invàlida.", + "type": "Tipus", + "type-required": "Cal el tipus", + "type-uplink": "Enllaç ascendent", + "type-downlink": "Enllaç descendent" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binari (Base64)" + }, + "customer": { + "customer": "Client", + "customers": "Clients", + "management": "Gestió de Clients", + "dashboard": "Panell del Client", + "dashboards": "Panells del Client", + "devices": "Dispositius del client", + "entity-views": "Vistes d'Entitat del client", + "assets": "Actius del Client", + "public-dashboards": "Panells Públics", + "public-devices": "Dispositius Públics", + "public-assets": "Actius Públics", + "public-entity-views": "Vistes d'Entitat Públiques", + "add": "Afegir client", + "delete": "Eliminar client", + "manage-customer-user-groups": "Gestionar grups d'usuaris de clients", + "manage-customer-groups": "Gestionar grups de clients", + "manage-customer-device-groups": "Gestioneu grups de dispositius de clients", + "manage-customer-asset-groups": "Gestionar grups d'actius de clients", + "manage-customer-entity-view-groups": "Gestioneu els grups de visualització d'entitats de clients", + "manage-customer-edge-groups": "Gestionar grups de clients perifèrics", + "manage-customer-dashboard-groups": "Gestioneu grups de panells de clients", + "manage-customer-users": "Gestionar d'usuaris del client", + "manage-customers": "Gestionar clients", + "manage-customer-devices": "Gestionar dels dispositius del client", + "manage-customer-entity-views": "Gestioneu les visualitzacions de l'entitat del client", + "manage-customer-dashboards": "Gestionar els panells del client", + "manage-public-devices": "Gestionar dispositius públics", + "manage-public-dashboards": "Gestionar panells públics", + "manage-customer-assets": "Gestionar actius del client", + "manage-public-assets": "Gestionar actius públics", + "add-customer-text": "Afegir nou client", + "no-customers-text": "No s'han trobat clients", + "customer-details": "Detalls del client", + "delete-customer-title": "Eliminar el client '{{customerTitle}}'?", + "delete-customer-text": "Atenció, després de la confirmació el client serà eliminat i tota la informació relacionada serà irrecuperable.", + "delete-customers-title": "Eliminar { count, plural, 1 {1 cliente} other {# clientes} }?", + "delete-customers-action-title": "Esborrar { count, plural, 1 {1 cliente} other {# clientes} }", + "delete-customers-text": "Atenció, després de la confirmació tots els clients seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "manage-user-groups": "Gestionar grups d'usuaris", + "manage-asset-groups": "Gestionar grups d'actius", + "manage-device-groups": "Gestionar grups de dispositius", + "manage-dashboard-groups": "Gestionar grups de panells", + "manage-entity-view-groups": "Gestioneu grups de visualitzacions d'entitats", + "manage-edge-groups": "Gestionar grups edge", + "manage-users": "Gestionar usuaris", + "manage-assets": "Gestionar actius", + "manage-devices": "Gestionar dispositius", + "manage-dashboards": "Gestionar panells", + "title": "Títol client", + "title-required": "Cal el títol del client.", + "title-max-length": "El títol ha de ser inferior a 256", + "description": "Descripció", + "details": "Detalls", + "events": "Esdeveniments", + "copyId": "Copiar ID del client", + "idCopiedMessage": "El ID del client s'ha copiat al porta-retalls", + "select-customer": "Seleccionar Client", + "no-customers-matching": "No s'han trobat clients que coincideixin amb '{{entity}}' .", + "customer-required": "Cal client", + "selected-customers": "{ count, plural, 1 {1 cliente} other {# clientes} } seleccionats", + "search": "Buscar clients", + "select-group-to-add": "Seleccionar el grup objectiu per afegir clients seleccionats", + "select-group-to-move": "Seleccionar el grup abkectiu per moure clients seleccionats", + "remove-customers-from-group": "Esteu segur que voleu eliminar { count, plural, 1 {1 customer} other {# customers} } del grup '{{entityGroup}}'?", + "group": "Grup clients", + "list-of-groups": "{ count, plural, 1 {One customer group} other {List of # customer groups} }", + "group-name-starts-with": "Grups de clients els quals començen amb '{{prefix}}'", + "select-default-customer": "Seleccionar un client per defecte", + "default-customer": "Client per defecte", + "default-customer-required": "El client per defecte és necessari per depurar el tauler de control al nivell de l'arrendatari", + "allow-white-labeling": "Permet l'etiqueta blanca", + "edges": "Vores del client", + "manage-edges": "Gestionar les vores", + "public-edges": "Vores públiques", + "manage-customer-edges": "Administrar vores dels clients", + "manage-public-edges": "Administrar vores públiques" + }, + "customers-hierarchy": { + "customers-hierarchy": "Jerarquia dels clients", + "open-nav-tree": "Obre l'arbre de navegació", + "return-to-top-level": "Torna al nivell superior" + }, + "custom-menu": { + "custom-menu": "Menú personalitzat", + "custom-menu-hint": "Definiu el menú personalitzat JSON a continuació. Aquest JSON conté una llista d'elements de menú personalitzats." + }, + "custom-translation": { + "custom-translation": "Traducció personalitzada", + "translation-map": "Mapa de traducció", + "key": "Clau de traducció", + "import": "Importa la traducció", + "export": "Exporta la traducció", + "export-data": "Exporta les dades de traducció", + "import-data": "Importa les dades de traducció", + "translation-file": "Fitxer de traducció", + "invalid-translation-file-error": "No s'ha pogut importar el fitxer de traducció: l'estructura de dades de traducció no és vàlida.", + "custom-translation-hint": "Definiu el JSON de traducció personalitzat a continuació. Aquest JSON sobreescriurà la traducció predeterminada. Feu clic a «Baixa el fitxer locale» per a obtenir la traducció existent. També podeu utilitzar el fitxer baixat com a referència a les parelles disponibles de traducció clau-valor.", + "download-locale-file": "Baixa el fitxer locale" + }, + "datetime": { + "date-from": "Data des de", + "time-from": "Hora des de", + "date-to": "Data fins a", + "time-to": "Hora fins a" + }, + "dashboard": { + "dashboard": "Panell", + "dashboards": "Panells", + "management": "Gestió de Panells", + "view-dashboards": "Veure Panells", + "add": "Afegir Panell", + "assign-dashboard-to-customer": "Assignar panell(s) al client", + "assign-dashboard-to-customer-text": "Per favor, selecciona algún panell per assignar al Client.", + "assign-dashboard-to-edge-title": "Assignar panell(s) a Vora", + "assign-to-customer-text": "Si us plau, seleccioni algun client per a assignar el(els) panell(s).", + "assign-to-customer": "Assignar al client", + "unassign-from-customer": "Desassignar del client", + "make-public": "Fer panell públic", + "make-private": "Fer panell privat", + "manage-assigned-customers": "Administrar clients assignats", + "assigned-customers": "Clients assignats", + "assign-to-customers": "Assignar Panell / Panells a Clients", + "assign-to-customers-text": "Selecciona els clients per assignar els panells", + "unassign-from-customers": "Desassignar Panell / Panells de clients", + "unassign-from-customers-text": "Selecciona els clients per desassignar els panells", + "no-dashboards-text": "Cap panell trobat", + "no-widgets": "Cap widget configurat", + "add-widget": "Afegir nou widget", + "title": "Títol", + "image": "Imatge del tauler", + "mobile-app-settings": "Paràmetres de l'aplicació mòbil", + "mobile-order": "Ordre del tauler en una aplicació mòbil", + "mobile-hide": "Amaga el tauler a l'aplicació mòbil", + "update-image": "Actualitza la imatge del tauler", + "take-screenshot": "Pren una captura de pantalla", + "select-widget-title": "Seleccionar widget", + "select-widget-value": "{{title}}: seleccionar widget", + "select-widget-subtitle": "Llista de tipus de widgets disponibles", + "delete": "Eliminar panell", + "title-required": "Cal títol.", + "title-max-length": "El títol ha de ser inferior a 256", + "description": "Descripció", + "details": "Detalls", + "dashboard-details": "Detalls del panell", + "add-dashboard-text": "Afegir nou panell", + "assign-dashboards": "Assignar panells", + "assign-new-dashboard": "Assignar nou panell", + "assign-dashboards-text": "Assignar { count, plural, 1 {1 panel} other {# paneles} } al client", + "unassign-dashboards-action-text": "Desassignar { count, plural, 1 {1 panel} other {# paneles} } als clients", + "delete-dashboards": "Eliminar panells", + "unassign-dashboards": "Desassignar panells", + "unassign-dashboards-action-title": "Desassignar { count, plural, 1 {1 paneles} other {# paneles} } del client", + "delete-dashboard-title": "Eliminar el panell '{{dashboardTitle}}'?", + "delete-dashboard-text": "Atenció, el panell seleccionat serà eliminat i la informació relacionada serà irrecuperable.", + "delete-dashboards-title": "Eliminar { count, plural, 1 {1 panel} other {# paneles} }?", + "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 panel} other {# paneles} }", + "delete-dashboards-text": "Atenció, els panells seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "unassign-dashboard-title": "Desassignar el panell '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Després de la confirmació, el panell serà desassignat i no podrà ser accessible pel client.", + "unassign-dashboard": "Desassignar panell", + "unassign-dashboards-title": "Desassignar { count, plural, 1 {1 panel} other {# paneles} }?", + "unassign-dashboards-text": "Atenció, després de la confirmació els panells seleccionats seran desassignats i no podran ser accessibles pel client.", + "public-dashboard-title": "El panell ara és públic", + "public-dashboard-text": "El teu panell {{dashboardTitle}} ara és públic i podrà ser accedit des de: aquí:", + "public-dashboard-notice": "Nota: No oblidis fer públics els dispositius relacionats per accedir a les seves dades.", + "public-dashboard-link": "Enllaç públic del tauler", + "public-dashboard-link-text": "El vostre tauler públic {{dashboardTitle}} és accessible a través del proper públic link:", + "public-dashboard-link-notice": "Note: No oblideu fer públics els dispositius, actius i entitats relacionats per a accedir a les seves dades.", + "make-private-dashboard-title": "Fer el panell '{{dashboardTitle}}' privat?", + "make-private-dashboard-text": "Després de la confirmació, el panell serà privat i no podrà ser accessible per uns altres.", + "make-private-dashboard": "Fer panell privat", + "socialshare-text": "'{{dashboardTitle}}' impulsat per ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' impulsat per ThingsBoard", + "select-dashboard": "Seleccionar panell", + "no-dashboards-matching": "Panell '{{entity}}' no trobat.", + "dashboard-required": "Cal panell.", + "select-existing": "Seleccionar panells existentes", + "create-new": "Crear nou panell", + "new-dashboard-title": "Nou títol", + "open-dashboard": "Obrir panell", + "set-background": "Definir fons", + "background-color": "Color de fons", + "background-image": "Imatge de fons", + "background-size-mode": "Manera grandària de fons", + "no-image": "No s'ha seleccionat cap imatge", + "empty-image": "Sense imatge", + "drop-image": "Deixi anar una imatge o faci clic per seleccionar un arxiu per carregar.", + "maximum-upload-file-size": "Mida màxima del fitxer de pujada: {{ size }}", + "cannot-upload-file": "No s'ha pogut pujar el fitxer", + "settings": "Configuració", + "layout-settings": "Paràmetres de la disposició", + "columns-count": "Número de columnes", + "columns-count-required": "Cal número de columnes.", + "min-columns-count-message": "Sol es permet un número mínim de 10 columnes.", + "max-columns-count-message": "Sol es permet un número màxim de 1000 columnes.", + "widgets-margins": "Marge entre widgets", + "margin-required": "Cal valor de marge.", + "min-margin-message": "0 és el valor de marge mínim permès.", + "max-margin-message": "50 és el valor de marge màxim permès.", + "horizontal-margin": "Marge horitzontal", + "horizontal-margin-required": "Cal marge horitzontal.", + "min-horizontal-margin-message": "Sol es permet marge horitzontal mínim de 0.", + "max-horizontal-margin-message": "Sol es permet marge horitzontal màxim de 50.", + "vertical-margin": "Marge vertical", + "vertical-margin-required": "Cal marge vertical.", + "min-vertical-margin-message": "Sol es permet marge vertical mínim de 0.", + "max-vertical-margin-message": "Sol es permet marge vertical màxim de 50.", + "autofill-height": "Alçada disseny autoomplert", + "mobile-layout": "Configuració de disseny mòbil", + "mobile-row-height": "Alçada de la fila per a mòbils, px", + "mobile-row-height-required": "Cal alçada de fila.", + "min-mobile-row-height-message": "Només es permeten 5 píxels com alçada mínima de fila (móvil).", + "max-mobile-row-height-message": "Només es permeten 200 píxels com alçada màxima de fila (móvil).", + "title-settings": "Paràmetres del títol", + "display-title": "Mostrar títol del panell", + "title-color": "Color del títol", + "toolbar-settings": "Paràmetres de la barra d'eines", + "hide-toolbar": "Amaga la barra d'eines", + "toolbar-always-open": "Mantenir la barra d'eines oberta", + "display-dashboards-selection": "Mostrar selecció de panells", + "display-entities-selection": "Mostrar selecció d'entitats", + "display-filters": "Mostrar filtres", + "display-dashboard-timewindow": "Mostrar finestra del temps", + "display-dashboard-export": "Mostrar exportar", + "display-update-dashboard-image": "Mostra la imatge del tauler d'actualització", + "dashboard-logo-settings": "Configuració del logotip del tauler de control", + "display-dashboard-logo": "Mostra el logotip en mode de pantalla completa del tauler", + "dashboard-logo-image": "Imatge del logotip del tauler de control", + "advanced-settings": "Configuració avançada", + "dashboard-css": "Tauler CSS", + "import": "Importar panell", + "export": "Exportar panell", + "export-failed-error": "Impossible exportar panell: {{error}}", + "export-pdf": "Exportar com PDF", + "export-png": "Exportar com PNG", + "export-jpg": "Exportar com JPEG", + "export-json-config": "Exportar configuració JSON", + "download-dashboard-progress": "Generant panell {{reportType}} ...", + "create-new-dashboard": "Crear nou panell", + "dashboard-file": "Arxivar panell", + "invalid-dashboard-file-error": "Impossible importar panell: Estructura de dades invàlides.", + "dashboard-import-missing-aliases-title": "Configurar àlies utilitzats pel panell importat", + "create-new-widget": "Crear nou widget", + "import-widget": "Importar widget", + "widget-file": "Arxiu de widget", + "invalid-widget-file-error": "Impossible importar widget: Estructura de dades invàlida.", + "widget-import-missing-aliases-title": "Configurar àlies utilitzats pel widget", + "open-toolbar": "Obrir toolbar del panell", + "close-toolbar": "Tancar toolbar", + "configuration-error": "Error de configuració", + "alias-resolution-error-title": "Error de configuració d'àlies del panell", + "invalid-aliases-config": "No es pot trobar cap dispositiu que coincideixi amb alguns dels àlies de filtre.
    Posi's en contacte amb el seu administrador per resoldre aquest problema.", + "select-devices": "Seleccionar dispositius", + "assignedToCustomer": "Assignat al client", + "assignedToCustomers": "Assignat als clients", + "public": "Públic", + "public-link": "Enllaç públic", + "copy-public-link": "Copiar enllaç públic", + "public-link-copied-message": "El link públic del panell s'ha copiat al porta-retalls", + "manage-states": "Administrar estats de panells", + "states": "Estats de panells", + "search-states": "Buscar estats de panells", + "selected-states": "{ count, plural, 1 {1 estado panel} other {# estat paneles} } seleccionats", + "edit-state": "Editar estat panell", + "delete-state": "Esborrar estat panell", + "add-state": "Afegir estat panell", + "no-states-text": "No s'han trobat estats", + "state": "Estat de panell", + "state-name": "Nom", + "state-name-required": "Cal nom de l'estat.", + "state-id": "ID Estat", + "state-id-required": "Cal el ID de l'estat.", + "state-id-exists": "Ja existeix un ID de l'estat.", + "is-root-state": "Estat arrel(Root)", + "delete-state-title": "Esborrar estat del panell", + "delete-state-text": "Eliminar l'estat del panell amb nom: '{{stateName}}'?", + "show-details": "Mostrar detalls", + "hide-details": "Ocultar detalls", + "select-state": "Seleccionar estat destí (target state)", + "state-controller": "Controlador d'estats", + "selected-dashboards": "{ count, plural, 1 {1 panel} other {# paneles} } seleccionats", + "search": "Buscar panells", + "home-dashboard": "Tauler d'inici", + "home-dashboard-hide-toolbar": "Amaga la barra d'eines del tauler d'inici", + "select-group-to-add": "Seleccioneu el grup de destinació per a afegir els panells seleccionats", + "select-group-to-move": "Seleccioneu el grup de destinació per a moure els panells seleccionats", + "remove-dashboards-from-group": "Esteu segur que voleu eliminar { count, plural, 1 {1 dashboard} other {# dashboards} } del grup '{{entityGroup}}'?", + "group": "Grup de penells", + "list-of-groups": "{ count, plural, 1 {One dashboard group} other {List of # dashboard groups} }", + "group-name-starts-with": "Grups de tauler amb noms que comencen per '{{prefix}}'", + "unassign-dashboard-from-edge-text": "Després de la confirmació, el tauler no serà assignat i la vora no podrà accedir a ell", + "unassign-dashboards-from-edge-title": "Esteu segur que voleu desassignar { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-from-edge-text": "Després de la confirmació, és anul·larà l'assignació de tots els panells seleccionats i no seran accessibles per de vora", + "assign-dashboard-to-edge": "Assignar panell(és) a la vora", + "assign-dashboard-to-edge-text": "Si us plau selecciona els panells per assignar a la vora", + "non-existent-dashboard-state-error": "Estat del tauler amb ID \"{{ stateId }}\" no s'ha trobat" + }, + "datakey": { + "settings": "Configuració", + "advanced": "Avançat", + "label": "Etiqueta", + "color": "Color", + "units": "Símbol especial per mostrar juntament amb el valor", + "decimals": "Nombre de dígits després de la coma", + "data-generation-func": "Funció de generació de dades", + "use-data-post-processing-func": "Utilitzar funció de postprocessament de dades", + "configuration": "Configuració de clau de dades", + "timeseries": "Sèrie de temps", + "attributes": "Atributs", + "entity-field": "Camp d'entitat", + "alarm": "Camps d'alarma", + "timeseries-required": "Sèries del temps del dispositiu requerit.", + "timeseries-or-attributes-required": "Sèries del temps/*Atributs requerits.", + "alarm-fields-timeseries-or-attributes-required": "Es requereixen camps d'alarma o sèries del temps/atributs.", + "maximum-timeseries-or-attributes": "Màxim { count, plural, 1 {1 timeseries/atributo és permès.} other {# timeseries/atributos són permitidos} }", + "alarm-fields-required": "Cal camps d'alarma", + "function-types": "Tipus de funcions", + "function-types-required": "Cal tipus de funcions.", + "alarm-keys": "Claus d'alarmes", + "alarm-key": "Clau d'alarma", + "alarm-key-functions": "Funcions de les claus d'alarmes", + "alarm-key-function": "Funció de clau d'alarma", + "latest-keys": "Últimes claus", + "latest-key": "Última clau", + "latest-key-functions": "Funcions d'últimes claus", + "latest-key-function": "Funció d'última clau", + "timeseries-keys": "Claus de sèries de temps", + "timeseries-key": "Clau de sèries de temps", + "timeseries-key-functions": "Funcions de sèries de temps", + "timeseries-key-function": "Funció de sèrie de temps", + "maximum-function-types": "Màxim { count, plural, 1 {1 tipo de función está permitida.} other {# tipos de funciones están permitidos} }", + "time-description": "Hora del valor actual", + "value-description": "El valor actual", + "prev-value-description": "resultat de la crida anterior de la funció", + "time-prev-description": "hora del valor previ", + "prev-orig-value-description": "valor original previ" + }, + "datasource": { + "type": "Tipus de font de dades", + "name": "Nom", + "label": "Etiqueta", + "add-datasource-prompt": "Si us plau, agrega una font de dades" + }, + "details": { + "details": "Detalls", + "edit-mode": "Manera d'edició", + "edit-json": "Editar JSON", + "toggle-edit-mode": "Anar a Manera d'Edició" + }, + "device": { + "device": "Dispositiu", + "device-required": "Cal dispositiu.", + "devices": "Dispositius", + "management": "Gestió de Dispositius", + "view-devices": "Veure Dispositius", + "device-alias": "Àlies de dispositiu", + "device-type-max-length": "El tipus de dispositiu ha de ser inferior a 256", + "aliases": "Àlies de dispositius", + "no-alias-matching": "'{{alias}}' no trobat.", + "no-aliases-found": "Cap àlies trobat.", + "no-key-matching": "'{{key}}' no trobat.", + "no-keys-found": "Ninguna clau trobada.", + "create-new-alias": "Crear nou àlies!", + "create-new-key": "Crear nova clau!", + "duplicate-alias-error": "Àlies duplicat '{{alias}}'.
    L'àlies dels dispositius han de ser únics dins del panell.", + "configure-alias": "Configurar àlies '{{alias}}'", + "no-devices-matching": "No s'ha trobat dispositiu '{{entity}}'", + "alias": "Àlies", + "alias-required": "Cal àlies de dispositiu.", + "remove-alias": "Eliminar àlies", + "add-alias": "Afegir àlies", + "name-starts-with": "Nom comenci amb", + "help-text": "Useu «%» segons la necessitat: «%device.name.contains%», «%device.name.ends», «device.starts.with».", + "device-list": "Llista de dispositius", + "use-device-name-filter": "Utilitzar filtre", + "device-list-empty": "Cap dispositiu seleccionat.", + "device-name-filter-required": "Cal nom del filtre.", + "device-name-filter-no-device-matched": "Cap dispositiu trobat que comenci amb '{{device}}'.", + "add": "Afegir dispositiu", + "assign-to-customer": "Assignar a client", + "assign-device-to-customer": "Assignar dispositiu(s) a Client", + "assign-device-to-customer-text": "Si us plau, seleccioni els dispositius que seran assignats al client", + "assign-device-to-edge-title": "Assignar Dispositiu(s) a Vora", + "assign-device-to-edge-text": "Selecciona els dispositius a assignar a la Vora", + "make-public": "Fer dispositiu públic", + "make-private": "Fer dispositiu privat", + "no-devices-text": "Cap dispositiu trobat", + "assign-to-customer-text": "Si us plau, seleccioni el client per assignar el(els) dispositiu(s)", + "device-details": "Detalls del dispositiu", + "add-device-text": "Afegir nou dispositiu", + "credentials": "Credencials", + "manage-credentials": "Gestionar credencials", + "delete": "Eliminar dispositiu", + "assign-devices": "Assignar dispositiu", + "assign-devices-text": "Assignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } al client", + "delete-devices": "Eliminar dispositiu", + "unassign-from-customer": "Desassignar el client", + "unassign-devices": "Desassignar dispositius", + "unassign-devices-action-title": "Desassignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } del client", + "unassign-device-from-edge-title": "Està segur que vol desassignar el dispositiu '{{deviceName}}'?", + "unassign-device-from-edge-text": "Després de la confirmació, el dispositiu no serà assignat i la vora no podrà accedir a ell", + "unassign-devices-from-edge": "Desassigna els dispositius de la vora", + "assign-new-device": "Assignar nou dispositiu", + "make-public-device-title": "Fer el dispositiu '{{deviceName}}' públic?", + "make-public-device-text": "Després de la confirmació, el dispositiu i la informació relacionada seran públics i podrà ser accessible per uns altres.", + "make-private-device-title": "Fer el dispositiu '{{deviceName}}' privat?", + "make-private-device-text": "Després de la confirmació, el dispositiu i la informació relacionada seran privats i no podrà ser accessible per uns altres.", + "view-credentials": "Veure credencials", + "delete-device-title": "Eliminar el dispositiu '{{deviceName}}'?", + "delete-device-text": "Atenció, després de la confirmació els dispositius seran eliminats i la informació relacionada serà irrecuperable.", + "delete-devices-title": "Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "delete-devices-action-title": "Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }", + "delete-devices-text": "Atenció, després de la confirmació els dispositius seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "unassign-device-title": "Desassignar el dispositiu '{{deviceName}}'?", + "unassign-device-text": "Després de la confirmació, el dispositiu serà desasignado i no podrà ser accessible pel client.", + "unassign-device": "Desassignar dispositiu", + "unassign-devices-title": "Desassignar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-text": "Després de la confirmació, els dispositius seleccionats seran desasignados i no podran ser accedits pel client.", + "device-credentials": "Credencials del dispositiu", + "loading-device-credentials": "S'estan carregant les credencials del dispositiu..", + "credentials-type": "Tipus de credencial", + "access-token": "Tóken d'accés", + "access-token-required": "Cal access token.", + "access-token-invalid": "Access token ha de tenir entre 1 a 32 caràcters.", + "certificate-pem-format": "Certificat en format PEM", + "certificate-pem-format-required": "Cal certificat.", + "lwm2m-security-config": { + "identity": "Identitat del client", + "identity-required": "Cal identitat del client.", + "identity-tooltip": "L'identificador PSK és un identificador PSK arbitrari de fins a 128 bytes, tal com es descriu a l'estàndard [RFC7925].\nL'identificador PSK S'HA DE convertir primer en una cadena de caràcters i després codificar-se en octets mitjançant UTF-8.", + "client-key": "Clau del client", + "client-key-required": "Cal clau del client.", + "client-key-tooltip-prk": "La clau pública o l'identificador RPK ha d'estar en l'estàndard [RFC7250] i codificat en format Base64!", + "client-key-tooltip-psk": "La clau PSK ha de tenir el format estàndard [RFC4279] i HexDec: 32, 64, 128 caràcters!", + "endpoint": "Nom del client del punt final", + "endpoint-required": "Cal nom del client del punt final.", + "client-public-key": "Clau pública del client", + "client-public-key-hint": "Si la clau pública del client està buida, s'utilitzarà el certificat de confiança", + "client-public-key-tooltip": "La clau pública X509 ha d'estar en format X509v3 codificat amb DER i suportar exclusivament l'algoritme EC i després codificada en format Base64!", + "mode": "Mode de configuració de seguretat", + "client-tab": "Configuració de seguretat del client", + "client-certificate": "Certificat de client", + "bootstrap-tab": "Client Bootstrap", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "client-publicKey-or-id": "Clau pública o identificació del client", + "client-publicKey-or-id-required": "Cal Clau pública o identificació del client.", + "client-publicKey-or-id-tooltip-psk": "L'identificador PSK és un identificador PSK arbitrari de fins a 128 bytes, tal com es descriu a l'estàndard [RFC7925].\nL'identificador PSK S'HA DE convertir primer en una cadena de caràcters i després codificar-se en octets mitjançant UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "La clau pública o l'identificador RPK ha d'estar en l'estàndard [RFC7250] i codificat en format Base64!", + "client-publicKey-or-id-tooltip-x509": "La clau pública X509 ha d'estar en format X509v3 codificat amb DER i suportar exclusivament l'algorisme EC i després codificar-se en format Base64", + "client-secret-key": "Clau secreta del client", + "client-secret-key-required": "Cal clau secreta del client.", + "client-secret-key-tooltip-psk": "La clau PSK ha de tenir el format estàndard [RFC4279] i HexDec: 32, 64, 128 caràcters!", + "client-secret-key-tooltip-prk": "La clau secreta RPK ha d'estar en format PKCS_8 (codificació DER, estàndard [RFC5958]) i després codificada en format Base64!", + "client-secret-key-tooltip-x509": "La clau secreta X509 ha d'estar en format PKCS_8 (codificació DER, estàndard [RFC5958]) i després codificada en format Base64!" + }, + "client-id": "ID Client", + "client-id-pattern": "Conté caràcter invàlid.", + "user-name": "Nom Usuari", + "user-name-required": "Cal nom d'usuari.", + "client-id-or-user-name-necessary": "L'ID Client i/o el Nom d'usuari són necessaris", + "password": "Contrasenya", + "secret": "Secreta", + "secret-required": "Cal secreta.", + "device-type": "Tipus de dispositiu", + "device-type-required": "Cal tipus de dispositiu.", + "select-device-type": "Seleccionar tipus de dispositiu", + "enter-device-type": "Entrar tipus de dispositiu", + "any-device": "Cualquier dispositiu", + "no-device-types-matching": "No hi ha cap tipus de dispositiu que coincideixin amb '{{entitySubtype}}' .", + "device-type-list-empty": "No hi ha cap tipus de dispositiu seleccionats.", + "device-types": "Tipus de dispositiu", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "label-max-length": "L'etiqueta ha de ser inferior a 256", + "description": "Descripció", + "label": "Etiqueta", + "events": "Esdeveniments", + "details": "Detalls", + "copyId": "Copiar ID", + "copyAccessToken": "Copiar access token", + "copy-mqtt-authentication": "Copiar credencials MQTT", + "idCopiedMessage": "Id del dispositiu copiat al porta-retalls", + "accessTokenCopiedMessage": "Access token del dispositiu copiat al porta-retalls", + "mqtt-authentication-copied-message": "Les dades d'autenticació MQTT s'han copiat al porta-retalls", + "assignedToCustomer": "Assignat al client", + "unable-delete-device-alias-title": "Impossible eliminar àlies del dispositiu", + "unable-delete-device-alias-text": "Àlies '{{deviceAlias}}' no pot ser eliminat. Esta siendo usado per el(els) widget(s):
    {{widgetsList}}", + "is-gateway": "És gateway", + "overwrite-activity-time": "Sobreescriure hora d'activitat per al dispositiu connectat", + "public": "Públic", + "device-public": "El dispositiu és públic", + "select-device": "Seleccionar dispositiu", + "selected-devices": "{ count, plural, 1 {1 dispositivo} other {# dispositivos} } seleccionats", + "search": "Buscar dispositius", + "select-group-to-add": "Seleccionar grup objetiu per afegir dispositius seleccionats", + "select-group-to-move": "Seleccionar grup objetiu per moure dispositius seleccionats", + "remove-devices-from-group": "Està segur de que desitja eliminar { count, plural, 1 {1 device} other {# devices} } del grup '{{entityGroup}}'?", + "group": "Grup de dispositius", + "list-of-groups": "{ count, plural, 1 {One device group} other {List of # device groups} }", + "group-name-starts-with": "Grup de dispositius els quals comencen amb '{{prefix}}'", + "import": "Importar dispositiu", + "device-file": "Arxiu de dispositiu", + "device-configuration": "Configuració del dispositiu", + "transport-configuration": "Configuració del transport", + "wizard": { + "device-wizard": "Assistent de dispositiu", + "device-details": "Detalls del dispositiu", + "new-device-profile": "Crear un nou perfil de dispositiu", + "existing-device-profile": "Seleccionar un perfil existent", + "specific-configuration": "Configuració específica", + "customer-to-assign-device": "Client al que assignar el dispositiu", + "add-credentials": "Afegir credencial" + }, + "unassign-devices-from-edge-title": "Està segur de que desitja desassignar {count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-from-edge-text": "Després de la confirmació, tots els dispositius seleccionats quedaran sense assignar i la vora no podrà accedir a ells." + }, + "device-profile": { + "device-profile": "Perfil de dispositiu", + "device-profiles": "Perfils de dispositiu", + "all-device-profiles": "Tots", + "add": "Afegir perfil de dispositiu", + "edit": "Editar perfil de dispositiu", + "device-profile-details": "Detalls de perfil de dispositiu", + "no-device-profiles-text": "No s'han trobat perfils", + "search": "Buscar perfils", + "selected-device-profiles": "{ count, plural, 1 {1 perfil} other {# perfiles} } seleccionats", + "no-device-profiles-matching": "No existeix perfil que coincideixi amb '{{entity}}'.", + "device-profile-required": "Cal perfil de dispositiu", + "idCopiedMessage": "S'ha copiat el ID de perfil al porta-retalls", + "set-default": "Fer perfil per defecte", + "delete": "Esborrar perfil de dispositiu", + "copyId": "Copiar ID de perfil", + "name-max-length": "El nom ha de ser inferior a 256", + "new-device-profile-name": "Nom del perfil", + "new-device-profile-name-required": "Cal nom de perfil.", + "name": "Nom", + "name-required": "Cal nom.", + "type": "Tipus de perfil", + "type-required": "Cal tipus de perfil.", + "type-default": "Per defecte", + "image": "Imatge del perfil del dispositiu", + "transport-type": "Tipus de transport", + "transport-type-required": "Cal tipus de transport.", + "transport-type-default": "Per defecte", + "transport-type-default-hint": "Suporta transports per MQTT bàsic, HTTP i CoAP", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Activa configuracions avançats de transport MQTT", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Activa la configuració avançada de transport CoAP", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Transport LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Especifiqueu la configuració de transport SNMP", + "description": "Descripció", + "default": "Defecte", + "profile-configuration": "Configuració de perfil", + "transport-configuration": "Configuració de transport", + "default-rule-chain": "Cadena de regles per defecte", + "mobile-dashboard": "Tauler de control mòbil", + "mobile-dashboard-hint": "Utilitzat per l'aplicació mòbil com a tauler de detalls del dispositiu", + "select-queue-hint": "Selecciona des de el desplegable o afegir un nom personalitzat.", + "delete-device-profile-title": "Eliminar el perfil '{{deviceProfileName}}'?", + "delete-device-profile-text": "Atenció, després de la confirmació el perfil i totes les vostres dades seran esborrades i irrecuperables.", + "delete-device-profiles-title": "EEliminar { count, plural, 1 {1 perfil} other {# perfiles} }?", + "delete-device-profiles-text": "Atenció, després de la confirmació els perfils seleccionats i totes les vostres dades seran esborrades i irrecuperables.", + "set-default-device-profile-title": "Establir el perfil '{{deviceProfileName}}' com perfil per defecte?", + "set-default-device-profile-text": "Després de la confirmació, el perfil serà marcat com a defecte i serà utilitzat per tots els nous dispositius que no tinguin perfil especificat.", + "no-device-profiles-found": "No s'han trobar perfils.", + "create-new-device-profile": "Crear un nou perfil!", + "mqtt-device-topic-filters": "Filtres de topic MQTT", + "mqtt-device-topic-filters-unique": "Els filtres de topic de dispositiu MQTT han de ser únics.", + "mqtt-device-payload-type": "Payload de dispositiu MQTT", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Activa la compatibilitat amb altres formats de càrrega útil.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Quan està habilitada, la plataforma utilitzarà un format de càrrega útil Protobuf de manera predeterminada. Si l'anàlisi falla, la plataforma intentarà utilitzar el format de càrrega útil JSON. Útil per a la compatibilitat enrere durant les actualitzacions de firmware. Per exemple, la versió inicial del firmware utilitza Json, mentre que la nova versió utilitza Protobuf. Durant el procés d'actualització del microprogramari per a la flota de dispositius, cal que admeti Protobuf i JSON simultàniament. El mode de compatibilitat introdueix una lleugera degradació del rendiment, per la qual cosa es recomana desactivar aquest mode un cop s'actualitzin tots els dispositius.", + "mqtt-use-json-format-for-default-downlink-topics": "Utilitzeu el format Json per als temes d'enllaç descendent predeterminat", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Quan estigui habilitada, la plataforma utilitzarà el format de càrrega útil Json per enviar atributs i RPC mitjançant els temes següents: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes , v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. Aquesta configuració no afecta les subscripcions d'atributs i rpc enviades amb temes nous (v2): v2/a/res/$request_id, v2/a, v2/r /req/$request_id, v2/r/res/$request_id. On $request_id és un identificador de sol·licitud d'enter.", + "mqtt-send-ack-on-validation-exception": "Enviar PUBACK en error de validació a la publicació (PUBLICAR)", + "mqtt-send-ack-on-validation-exception-hint": "Per defecte, la plataforma tancarà la sessió MQTT en cas d'error de validació del missatge. Quan estigui activat, la plataforma enviarà un reconeixement de publicació en lloc de tancar la sessió.", + "snmp-add-mapping": "Afegeix mapeig SNMP", + "snmp-mapping-not-configured": "No s'ha configurat cap mapeig per a OID a sèries temporals/telemetria", + "snmp-timseries-or-attribute-name": "Sèries temporals/nom d'atribut per al mapeig", + "snmp-timseries-or-attribute-type": "Sèries temporals/tipus d'atribut per al mapeig", + "snmp-method-pdu-type-get-request": "Obteniu la sol·licitud", + "snmp-method-pdu-type-get-next-request": "Obteniu la següent sol·licitud", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Cal tipus de Payload.", + "coap-device-type": "Tipus de dispositiu CoAP", + "coap-device-payload-type": "Càrrega útil del dispositiu CoAP", + "coap-device-type-required": "Cal tipus de dispositiu CoAP.", + "coap-device-type-default": "Per defecte", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Se suporten els wildcards únics [+] i multinivell [#].", + "telemetry-topic-filter": "Filtre de topic en telemetria", + "telemetry-topic-filter-required": "Cal filtre de topic (telemetria).", + "attributes-topic-filter": "Filtre de topic en atributs", + "attributes-topic-filter-required": "Cal filtre de topic (atributs).", + "telemetry-proto-schema": "Proto esquema de telemetria", + "telemetry-proto-schema-required": "Cal proto esquema de telemetria.", + "attributes-proto-schema": "Proto esquema de atributs", + "attributes-proto-schema-required": "Cal proto esquema de atributs.", + "rpc-response-proto-schema": "RPC resposta proto esquema", + "rpc-response-proto-schema-required": "Cal RPC resposta proto esquema.", + "rpc-response-topic-filter": "Filtre de topic de respuesta RPC", + "rpc-response-topic-filter-required": "Cal fitro de respuesta RPC.", + "rpc-request-proto-schema": "RPC petició proto esquema", + "rpc-request-proto-schema-required": "Cal RPC petició proto esquema.", + "rpc-request-proto-schema-hint": "RPC petició el missatge ha de tenir sempre camps: mètode de cadena = 1; int32 requestId = 2; i paràmetres = 3 de qualsevol tipus de dades.", + "not-valid-pattern-topic-filter": "No és un patró de filtre vàlid", + "not-valid-single-character": "Ús invàlid de wildcard únic", + "not-valid-multi-character": "Ús invàlid de wildcard multi-nivell", + "single-level-wildcards-hint": "[+] és adequat per qualsevol nivell. Ej.: v1/devices/+/telemetry o +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] pot reemplaçar el mateix filtre i ha de ser l'últim símbol del topic. Ej.: # o v1/devices/me/#.", + "alarm-rules": "Regles d'alarma", + "alarm-rules-with-count": "Regles d'alarma ({{count}})", + "no-alarm-rules": "No hi ha cap regla d'alarma configurada", + "add-alarm-rule": "Afegir regla d'alarma", + "edit-alarm-rule": "Editar regla d'alarma", + "alarm-type": "Tipus d'alarma", + "alarm-type-required": "Cal tipus d'alarma.", + "alarm-type-unique": "El tipus d'alarma, ha de ser únic dins de les regles d'alarma del perfil de dispositiu.", + "alarm-type-max-length": "El tipus d'alarma ha de ser inferior a 256", + "create-alarm-pattern": "Crear alarma {{alarmType}}", + "create-alarm-rules": "Crear regles d'alarma", + "no-create-alarm-rules": "No hi ha condicions de creació d'alarma configurades", + "add-create-alarm-rule-prompt": "Per favor, afegiu una regla d'alarma", + "clear-alarm-rule": "Esborrar regla d'alarma", + "no-clear-alarm-rule": "No hi ha condicions de esborrat d'alarma configurades", + "add-create-alarm-rule": "Afegir crear condició (activar alarma)", + "add-clear-alarm-rule": "Afegir esborrar condició (limpiar alarma)", + "select-alarm-severity": "Seleccioneu severitat d'alarma", + "alarm-severity-required": "Cal especificar severitat d'alarma.", + "condition-duration": "Duració de condició", + "condition-duration-value": "Valor de duració", + "condition-duration-time-unit": "Unitat del temps", + "condition-duration-value-range": "El valor ha d'estar en un rang des de 1 a 2147483647.", + "condition-duration-value-pattern": "El valor de duració ha de ser un número enter.", + "condition-duration-value-required": "Cal valor de duració.", + "condition-duration-time-unit-required": "Cal una unidad del temps.", + "advanced-settings": "Configuració avançada", + "alarm-rule-details": "Detalls", + "alarm-rule-details-hint": "Suggeriment: utilitzeu ${keyName} per substituir els valors de les claus d'atribut o de telemetria que s'utilitzen en condicions de regla d'alarma.", + "add-alarm-rule-details": "Afegir detalls", + "alarm-rule-mobile-dashboard": "Tauler de control mòbil", + "alarm-rule-mobile-dashboard-hint": "Utilitzat per l'aplicació mòbil com a quadre de comandament de detalls d'alarma", + "alarm-rule-no-mobile-dashboard": "No s'ha seleccionat cap tauler", + "propagate-alarm": "Propagar alarma", + "alarm-rule-relation-types-list": "Tipus de relació per propagar", + "alarm-rule-relation-types-list-hint": "Si no está seleccionat 'propagar relacions', les alarmes seran propagadas sense filtrar per relació.", + "propagate-alarm-to-owner": "Propaga l'alarma al propietari de l'entitat (Customer or Tenant)", + "propagate-alarm-to-owner-hierarchy": "Propaga l'alarma a la jerarquia dels propietaris de l'entitat", + "propagate-alarm-to-tenant": "Propaga l'alarma al llogater", + "alarm-details": "Detalls d'alarma", + "alarm-rule-condition": "Condicions de regla d'alarma", + "enter-alarm-rule-condition-prompt": "Per favor, afegir una condició d'alarma", + "edit-alarm-rule-condition": "Editar condició d'alarma", + "device-provisioning": "Aprovisionament de dispositius", + "provision-strategy": "Estrategia d'aprovisionament", + "provision-strategy-required": "Cal estrategia d'aprovisionament.", + "provision-strategy-disabled": "Desactivat", + "provision-strategy-created-new": "Permetre crear nous dispositius", + "provision-strategy-check-pre-provisioned": "Revisar dispositius pre-aprovisionats", + "provision-device-key": "Clau d'aprovisionament", + "provision-device-key-required": "Cal clau d'aprovisionament.", + "copy-provision-key": "Copiar clau d'aprovisionament", + "provision-key-copied-message": "La clau d'aprovisionament s'ha copiat al porta-retalls", + "provision-device-secret": "Secret d'aprovisionament", + "provision-device-secret-required": "Cal secret d'aprovisionament.", + "copy-provision-secret": "Copiar secret d'aprovisionament", + "provision-secret-copied-message": "S'ha copiat el secret d'aprovisionament al porta-retalls", + "condition": "Condició", + "condition-type": "Tipus de condició", + "condition-type-simple": "Simple", + "condition-type-duration": "Duració", + "condition-during": "Durant {{during}}", + "condition-during-dynamic": "Durant \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Repetitiva", + "condition-type-required": "Cal tipus de condició.", + "condition-repeating-value": "Nº de esdeveniments", + "condition-repeating-value-range": "El Nº de esdeveniments ha d'estar en un rang de 1 to 2147483647.", + "condition-repeating-value-pattern": "Nº de esdeveniments ha de ser un número enter.", + "condition-repeating-value-required": "Cal Nº de esdeveniments.", + "condition-repeat-times": "Repetició { count, plural, 1 {1 vez} other {# veces} }", + "condition-repeat-times-dynamic": "Repeats \"{ attribute }\" ({ count, plural, 1 {1 time} other {# times} })", + "schedule-type": "Tipus d'horari", + "schedule-type-required": "Cal tipus d'horari.", + "schedule": "Horari", + "edit-schedule": "Editar horari d'alarma", + "schedule-any-time": "Sempre actiu", + "schedule-specific-time": "Actiu en una hora específica", + "schedule-custom": "Personalitzat", + "schedule-day": { + "monday": "Dilluns", + "tuesday": "Dimarts", + "wednesday": "Dimecres", + "thursday": "Dijous", + "friday": "Divendres", + "saturday": "Dissabte", + "sunday": "Diumenge" + }, + "schedule-days": "Dies", + "schedule-time": "Hora", + "schedule-time-from": "De", + "schedule-time-to": "Fins a", + "schedule-days-of-week-required": "Ha de ser seleccionat almenys un dia de la setmana.", + "create-device-profile": "Crea un perfil nou de dispositiu", + "import": "Importa el perfil del dispositiu", + "export": "Exporta el perfil del dispositiu", + "export-failed-error": "No s'ha pogut exportar el perfil del dispositiu: {{error}}", + "device-profile-file": "Fitxer de perfil del dispositiu", + "invalid-device-profile-file-error": "No s'ha pogut importar el perfil del dispositiu: l'estructura de dades del perfil del dispositiu no és vàlida.", + "power-saving-mode": "Mode d'estalvi d'energia", + "power-saving-mode-type": { + "default": "Utilitza el mode d'estalvi d'energia del perfil del dispositiu", + "psm": "Mode d'estalvi d'energia (PSM)", + "drx": "Recepció contínua (DRX)", + "edrx": "Recepció discontinu ampliada (eDRX)" + }, + "edrx-cycle": "Cicle eDRX", + "edrx-cycle-required": "Cal cicle eDRX.", + "edrx-cycle-pattern": "El cicle eDRX ha de ser un nombre enter positiu.", + "edrx-cycle-min": "El nombre mínim de cicles eDRX és {{ min }} segons.", + "paging-transmission-window": "Paging Transmission Window", + "paging-transmission-window-required": "Cal finestra de transmissió de paginació.", + "paging-transmission-window-pattern": "La finestra de transmissió de paginació ha de ser un nombre enter positiu.", + "paging-transmission-window-min": "El nombre mínim de finestra de transmissió de paginació és {{ min }} segons.", + "psm-activity-timer": "Temporitzador d'activitats PSM", + "psm-activity-timer-required": "Cal temporitzador d'activitats PSM.", + "psm-activity-timer-pattern": "El temporitzador d'activitat PSM ha de ser un nombre enter positiu.", + "psm-activity-timer-min": "El nombre mínim de temporitzadors d'activitat PSM és {{ min }} segons.", + "lwm2m": { + "object-list": "Llista d'objectes", + "object-list-empty": "No s'ha seleccionat cap objecte.", + "no-objects-found": "No s'ha trobat cap objecte.", + "no-objects-matching": "No hi ha cap objecte que coincideixi '{{object}}' els van trobar.", + "model-tab": "Model LWM2M", + "add-new-instances": "Afegeix noves instàncies", + "instances-list": "Llista d'instàncies", + "instances-list-required": "Cal llista d'instàncies.", + "instance-id-pattern": "L'identificador de la instància ha de ser un nombre enter positiu.", + "instance-id-max": "Valor màxim d'identificador de la instància {{max}}.", + "instance": "Instància", + "resource-label": "#ID Nom del recurs", + "observe-label": "Observa", + "attribute-label": "Atribut", + "telemetry-label": "Telemetria", + "edit-observe-select": "Per editar, seleccioneu la telemetria o l'atribut", + "edit-attributes-select": "Per editar els atributs, seleccioneu la telemetria o l'atribut", + "no-attributes-set": "No s'han establert atributs", + "key-name": "Nom clau", + "key-name-required": "Cal nom clau", + "attribute-name": "Atribut de nom", + "attribute-name-required": "Cal atribut de nom.", + "attribute-value": "Valor de l'atribut", + "attribute-value-required": "Cal valor de l'atribut.", + "attribute-value-pattern": "El valor de l'atribut ha de ser un nombre enter positiu.", + "edit-attributes": "Edita els atributs: {{ name }}", + "view-attributes": "Mostra els atributs: {{ name }}", + "add-attribute": "Afegeix un atribut", + "edit-attribute": "Edita l'atribut", + "view-attribute": "Veure l'atribut", + "remove-attribute": "Eliminar l'atribut", + "delete-server-text": "Aneu amb compte, després de la confirmació, la configuració del servidor es tornarà irrecuperable.", + "delete-server-title": "Esteu segur que voleu suprimir el servidor?", + "mode": "Mode de configuració de seguretat", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Servidor Bootstrap (ShortId...)", + "lwm2m-server-legend": "Servidor LwM2M (ShortId...)", + "server": "Servidor", + "short-id": "Identificador curt del servidor", + "short-id-tooltip": "Identificador curt del servidor. S'utilitza com a enllaç a la instància d'objecte del servidor associada.\nAquest identificador identifica de manera única cada servidor LwM2M configurat per al client LwM2M.\nEl recurs S'HA d'establir quan el recurs del servidor Bootstrap té un valor 'fals'.\nEls valors ID:0 i ID :65535 NO S'HAN d'utilitzar per identificar el servidor LwM2M.", + "short-id-required": "Cal identificador curt del servidor.", + "short-id-range": "L'identificador de servidor curt hauria d'estar entre 1 i 65534.", + "short-id-pattern": "L'identificador de servidor curt ha de ser un nombre enter positiu.", + "lifetime": "Vida útil del registre del client", + "lifetime-required": "Cal vida útil del registre del client.", + "lifetime-pattern": "La vida útil del registre del client ha de ser un nombre enter positiu.", + "default-min-period": "Període mínim entre dues notificacions (s)", + "default-min-period-tooltip": "El valor predeterminat que el client LwM2M hauria d'utilitzar durant el període mínim d'una observació si no s'inclou aquest paràmetre en una observació..", + "default-min-period-required": "Cal període mínim.", + "default-min-period-pattern": "El període mínim ha de ser un nombre enter positiu.", + "notification-storing": "Emmagatzematge de notificacions quan està desactivat o fora de línia", + "binding": "Enquadernació", + "binding-type": { + "u": "U: Es pot contactar amb el client mitjançant l'enllaç UDP en qualsevol moment.", + "m": "M: Es pot contactar amb el client mitjançant l'enllaç MQTT en qualsevol moment.", + "h": "H: El client és accessible mitjançant l'enllaç HTTP en qualsevol moment.", + "t": "T: Es pot contactar amb el client mitjançant l'enllaç TCP en qualsevol moment.", + "s": "S: Es pot contactar amb el client mitjançant l'enllaç SMS en qualsevol moment.", + "n": "N: El client HA d'enviar la resposta a aquesta sol·licitud mitjançant l'enllaç no IP (s'admet des de LWM2M 1.1).", + "uq": "UQ: Connexió UDP en mode de cua (no s'admet des de LWM2M 1.1)", + "uqs": "UQS: les connexions UDP i SMS actives; UDP en mode de cua, SMS en mode estàndard (no s'admet des de LWM2M 1.1)", + "tq": "TQ: Connexió TCP en mode de cua (no s'admet des de LWM2M 1.1)", + "tqs": "TQS: les connexions TCP i SMS actives; TCP en mode de cua, SMS en mode estàndard (no s'admet des de LWM2M 1.1)", + "sq": "SQ: Connexió d'SMS en mode de cua (no s'admet des de LWM2M 1.1)" + }, + "binding-tooltip": "Aquesta és la llista del recurs\"enllaç\" de l'objecte del servidor LwM2M - /1/x/7.\nIndica els modes d'enllaç admesos al client LwM2M.\nAquest valor HA de ser el mateix que el valor de 'Supported'. Enllaç i modes” a l'objecte del dispositiu (/3/0/16).\nTot i que s'admeten diversos transports, només es pot utilitzar una vinculació de transport durant tota la sessió de transport.\nPer exemple, quan s'admeten UDP i SMS. , el client LwM2M i el servidor LwM2M poden triar comunicar-se mitjançant UDP o SMS durant tota la sessió de transport.", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "include-bootstrap-server": "Inclou actualitzacions del servidor Bootstrap", + "bootstrap-update-title": "Ja heu configurat el servidor Bootstrap. Esteu segur que voleu excloure les actualitzacions?", + "bootstrap-update-text": "Aneu amb compte, després de la confirmació, les dades de configuració del servidor Bootstrap seran irrecuperables.", + "server-host": "Host", + "server-host-required": "Cal host.", + "server-port": "Port", + "server-port-required": "Cal port.", + "server-port-pattern": "El port ha de ser un nombre enter positiu.", + "server-port-range": "El port ha d'estar en un rang d'1 a 65535.", + "server-public-key": "Clau pública del servidor", + "server-public-key-required": "Cal clau pública del servidor.", + "client-hold-off-time": "Temps d'espera", + "client-hold-off-time-required": "cal temps d'espera.", + "client-hold-off-time-pattern": "El temps d'espera ha de ser un enter positiu.", + "client-hold-off-time-tooltip": "El client manté el temps d'espera per al seu ús només amb un servidor Bootstrap", + "account-after-timeout": "Compte després del temps d'espera", + "account-after-timeout-required": "El compte després del temps d'espera és obligatori.", + "account-after-timeout-pattern": "El compte després del temps d'espera ha de ser un enter positiu.", + "account-after-timeout-tooltip": "Compte de servidor d'arrencada després del valor de temps d'espera donat per aquest recurs.", + "server-type": "Tipus de servidor", + "add-new-server-title": "Afegeix una nova configuració del servidor", + "add-server-config": "Afegeix la configuració del servidor", + "add-lwm2m-server-config": "Afegeix el servidor LwM2M", + "no-config-servers": "No s'ha configurat cap servidor", + "others-tab": "Altres configuracions", + "client-strategy": "Estratègia del client a l'hora de connectar-se", + "client-strategy-label": "Estratègia", + "client-strategy-only-observe": "Observeu només la sol·licitud al client després de la connexió inicial", + "client-strategy-read-all": "Llegiu tots els recursos i observeu la sol·licitud al client després del registre", + "fw-update": "Actualització del firmware", + "fw-update-strategy": "Estratègia d'actualització del firmware", + "fw-update-strategy-data": "Envieu l'actualització del microprogramari com a fitxer binari mitjançant l'objecte 19 i el recurs 0 (dades)", + "fw-update-strategy-package": "Envieu l'actualització del microprogramari com a fitxer binari mitjançant l'objecte 5 i el recurs 0 (paquet)", + "fw-update-strategy-package-uri": "Genera automàticament un URL CoAP únic per descarregar el paquet i enviar l'actualització del microprogramari com a Objecte 5 i Recurs 1 (URI del paquet)", + "sw-update": "Actualització de software", + "sw-update-strategy": "Estratègia d'actualització de programari", + "sw-update-strategy-package": "Envieu el fitxer binari mitjançant l'objecte 9 i el recurs 2 (paquet)", + "sw-update-strategy-package-uri": "Genera automàticament un URL CoAP únic per descarregar el paquet i impulsar l'actualització del programari mitjançant Objecte 9 i Recurs 3 (URI del paquet)", + "fw-update-resource": "Recurs CoAP d'actualització del firmware", + "fw-update-resource-required": "Cal recurs CoAP d'actualització del firmware.", + "sw-update-resource": "Recurs CoAP d'actualització de programari", + "sw-update-resource-required": "Cal Recurs CoAP d'actualització de programari.", + "config-json-tab": "Dispositiu de perfil de configuració Json", + "attributes-name": { + "min-period": "Període mínim", + "max-period": "Període màxim", + "greater-than": "Més gran que", + "less-than": "Menys que", + "step": "Pas", + "min-evaluation-period": "Període mínim d'avaluació", + "max-evaluation-period": "Període màxim d'avaluació" + }, + "composite-operations-support": "Admet operacions compostes de lectura/escriptura/observació" + }, + "snmp": { + "add-communication-config": "Afegeix la configuració de comunicació", + "add-mapping": "Afegeix mapeig", + "authentication-passphrase": "Frase de contrasenya d'autenticació", + "authentication-passphrase-required": "Cal frase de contrasenya d'autenticació.", + "authentication-protocol": "Protocol d'autenticació", + "authentication-protocol-required": "Cal protocol d'autenticació.", + "communication-configs": "Configuracions de comunicació", + "community": "Cadena comunitària", + "community-required": "Cal cadena comunitària.", + "context-name": "Nom del context", + "data-key": "Clau de dades", + "data-key-required": "Cal clau de dades.", + "data-type": "Tipus de dades", + "data-type-required": "Cal tipus de dades.", + "engine-id": "ID del motor", + "host": "Host", + "host-required": "Cal host.", + "oid": "OID", + "oid-pattern": "Format OID no vàlid.", + "oid-required": "Cal OID.", + "please-add-communication-config": "Si us plau, afegiu la configuració de comunicació", + "please-add-mapping-config": "Si us plau, afegiu la configuració de mapes", + "port": "Port", + "port-format": "Format de port no vàlid.", + "port-required": "Cal port.", + "privacy-passphrase": "Frase de contrasenya de privadesa", + "privacy-passphrase-required": "Cal frase de contrasenya de privadesa.", + "privacy-protocol": "Protocol de privadesa", + "privacy-protocol-required": "Cal protocol de privadesa.", + "protocol-version": "Versió del protocol", + "protocol-version-required": "Cal versió del protocol.", + "querying-frequency": "Freqüència de consulta, ms", + "querying-frequency-invalid-format": "La freqüència de consulta ha de ser un nombre enter positiu.", + "querying-frequency-required": "Cal freqüència de consulta.", + "retries": "Reintents", + "retries-invalid-format": "Els reintents han de ser un nombre enter positiu.", + "retries-required": "Cal reintents.", + "scope": "Àmbit", + "scope-required": "Cal àmbit.", + "security-name": "Nom de seguretat", + "security-name-required": "Cal nom de seguretat.", + "timeout-ms": "Temps d'espera, ms", + "timeout-ms-invalid-format": "El temps d'espera ha de ser un nombre enter positiu.", + "timeout-ms-required": "Cal temps d'espera.", + "user-name": "Nom d'usuari", + "user-name-required": "Cal nom d'usuari." + } + }, + "dialog": { + "close": "Tancar diàleg" + }, + "direction": { + "column": "Columna", + "row": "Fila" + }, + "edge": { + "edge": "Vora", + "edge-instances": "Instàncies de Vora", + "edge-file": "Arxiu de vora", + "name-max-length": "El nom ha de ser inferior a 256", + "label-max-length": "L'etiqueta ha de ser inferior a 256", + "type-max-length": "El tipus ha de ser inferior a 256", + "management": "Gestió de vores", + "no-edges-matching": "No s'han trobar vores que coincideixin amb '{{entity}}'", + "add": "Afegir vora", + "no-edges-text": "No s'han trobar vores", + "edge-details": "Detalls del vora", + "add-edge-text": "Afegir nova vora", + "delete": "Eliminar vora", + "delete-edge-title": "Està segur de que desitja eliminar la vora '{{edgeName}}'?", + "delete-edge-text": "Aneu amb compte, després de la confirmació, la vora i totes les dades relacionades seran irrecuperables", + "delete-edges-title": "Està segur de que desitja edge {count, plural, 1 {1 borde} other {# bordes} }?", + "delete-edges-text": "Aneu amb compte, després de la confirmació s'eliminaran totes les vores seleccionades i totes les dades relacionades seran irrecuperables", + "name": "Nom", + "name-starts-with": "El nom de la vora comença per", + "name-required": "Cal nom", + "edge-license-key": "Clau de llicència Edge", + "edge-license-key-required": "Necessiteu la clau de llicència Edge", + "edge-license-key-max-length": "La clau de llicència Edge ha de ser inferior a 31", + "edge-license-key-hint": "Per obtenir la seva llicència, aneu a la pàgina de preus i seleccioneu la millor opció de llicència si escau.", + "cloud-endpoint": "Punt final del núvol", + "cloud-endpoint-required": "Cal punt final del núvol", + "cloud-endpoint-max-length": "Cloud Endpoint hauria de ser inferior a 256", + "cloud-endpoint-hint": "Edge requereix l'accés HTTP (s) a la nube (ThingsBoard CE / PE) per verificar la clau de llicència. Especifiqueu l'URL de la núvol a la que Edge pot connectar-se.", + "description": "Descripció", + "details": "Detalls", + "events": "Esdeveniments", + "copy-id": "Copiar ID de vora", + "id-copied-message": "El ID de vora s'ha copiat al porta-retalls", + "sync": "Sinc Edge", + "edge-required": "Cal Edge", + "edge-type": "Tipus de vora", + "edge-type-required": "Cal tipus de vora.", + "event-action": "Informació de l'entitat", + "entity-id": "ID d'entitat", + "select-edge-type": "Seleccionar tipus de vora", + "assign-to-customer": "Assignar al client", + "assign-to-customer-text": "Selecciona el client per assignar les vores", + "assign-edge-to-customer": "Assignar vora(s) al client", + "assign-edge-to-customer-text": "Selecciona les vores per assignar al client", + "assignedToCustomer": "Asignada a la client", + "edge-public": "Edge és pública", + "assigned-to-customer": "Assignat al client", + "unassign-from-customer": "Anul·lar assignació del client", + "unassign-edge-title": "Està segur de que desitja desassignar la vora '{{edgeName}}'?", + "unassign-edge-text": "Després de la confirmació, la vora quedará sense assignar i el client no podrà accedir a ell", + "unassign-edges-title": "Està segur de que desitja anul·lar l'assignació de {count, plural, 1 {1 borde} other {# bordes} }?", + "unassign-edges-text": "Després de la confirmació de totes les vores seleccionades, s'anul·larà l'assignació i el client no podrà accedir a ells.", + "make-public": "Fer públic la vora", + "make-public-edge-title": "Estás segur de que quieres fer públic el edge '{{edgeName}}'?", + "make-public-edge-text": "Després de la confirmació, la vora i totes les seves dades seran públiques i accessibles per altres", + "make-private": "Fer que edge sigui privat", + "public": "Públic", + "make-private-edge-title": "Està segur de que desitja que la vora '{{edgeName}}' sigui privat?", + "make-private-edge-text": "Després de la confirmació, la vora i totes les seves dades seran privades i altres no podrán accedir a ells", + "import": "Importar vora", + "label": "Etiqueta", + "load-entity-error": "Entitat no trobada. No s'ha pogut carregar la informació", + "assign-new-edge": "Assignar nova vora", + "unassign-from-edge": "Anul·lar assignació de vora", + "edge-key": "Clau de vora", + "copy-edge-key": "Copiar clau de vora", + "edge-key-copied-message": "La clau de vora s'ha copiat al porta-retalls", + "edge-secret": "Vora secret", + "copy-edge-secret": "Copiar vora secret", + "edge-secret-copied-message": "El secret de vora s'ha copiat al porta-retalls", + "edge-assets": "Gestionar actius de vores", + "edge-devices": "Gestionar dispositius de vora", + "edge-entity-views": "Gestionar vistes d'entitat de vora", + "edge-dashboards": "Administrar panells de vora", + "edge-rulechains": "Cadenes de regles de vora", + "assets": "Actius de vora", + "devices": "Dispositius de vora", + "entity-views": "Vistes d'entitat de vora", + "dashboard": "Panell de control Edge", + "dashboards": "Panells de vora", + "rulechain-templates": "Plantilles, de cadena de regles", + "rulechains": "Cadenes de regla de vora", + "search": "Vores de cerca", + "selected-edges": "{count, plural, 1 {1 borde} other {# bordes} } seleccionades", + "any-edge": "Qualsevol vora", + "no-edge-types-matching": "No s'han trobar tipus de aristas que coincideixin amb '{{entitySubtype}}'.", + "edge-type-list-empty": "No s'ha seleccionat cap tipus de vora.", + "edge-types": "Tipus de vores", + "enter-edge-type": "Introduïu el tipus de vora", + "deployed": "Desplegada", + "pending": "Pendent", + "downlinks": "Enllaços descendents", + "no-downlinks-prompt": "No s'han trobar enllaços descendents", + "sync-process-started-successfully": "El procés de sincronització és va iniciar correctament!", + "missing-related-rule-chains-title": "A la vora li falten cadenes de regles relacionades", + "missing-related-rule-chains-text": "Assignat a la (es) cadena (es) de regles de vora utilitza nodes de regles que reenvien missatges a cadenes de regles que no están assignades a aquest vora.

    Llista de cadenes de regles que manca:
    {{missingRuleChains}}", + "widget-datasource-error": "Aquest widget només admet la font de dades de l'entitat EDGE", + "assign-to-edge": "Assignar la vora", + "assign-to-edge-title": "Assignar grup (s) d'entitat a Edge", + "manage-edge-user-groups": "Administrar grups d'usuaris perimetrals", + "manage-edge-asset-groups": "Administrar grups d'actius perimetrals", + "manage-edge-device-groups": "Administrar grups de dispositius perimetrals", + "manage-edge-entity-view-groups": "Administrar grups de vistes d'entitats perimetrals", + "manage-edge-dashboard-groups": "Administrar grups de panells de vora", + "manage-edge-rule-chains": "Administrar cadenes de regles de vora", + "manage-edge-scheduler-events": "Administrar esdeveniments del programador de vora", + "select-group-to-add": "Selecciona el grup de destí per afegir les vores seleccionades", + "select-group-to-move": "Selecciona el grup de destí per moure les vores seleccionades", + "remove-edges-from-group": "Està segur de que desitja eliminar {count, plural, 1 {1 borde} other {# bordes} } del grup '{entityGroup}'?", + "group": "Grup de vores", + "list-of-groups": "{count, plural, 1 {Un grupo de bodre} other {Lista de # grupos de bordes} }", + "group-name-starts-with": "Grups de vora els quals comencen amb '{{prefix}}'", + "unassign-entity-group-from-edge-title": "Està segur de que desitja anul·lar l'assignació del grup d'entitat '{{entityGroupName}}'?", + "unassign-entity-group-from-edge-text": "Després de la confirmació, s'anul·larà l'assignació del grup d'entitats i no es podrà accedir a ell des de la vora.", + "unassign-entity-group-from-edge": "Desassignar grup d'entitats des de la vora", + "unassign-entity-groups-from-edge-title": "Està segur de que desitja anul·lar l'assignació de {{count}} grups d'entitats?", + "unassign-entity-groups-from-edge-text": "Després de la confirmació, els grups d'entitats seran desassignat i no seran accessibles des de la vora.", + "unassign-entity-groups-from-edge": "Anul·lar l'assignació de grups d'entitats des de la vora", + "unassign-scheduler-events-from-edge": "Anul·lar l'assignació de esdeveniments del programador des de la vora", + "unassign-scheduler-event-from-edge-title": "Està segur de que desitja anul·lar l'assignació del esdeveniment del programador '{{schedulerEventName}}'?", + "unassign-scheduler-event-from-edge-text": "Després de la confirmació, s'anul·larà l'assignació del esdeveniment del programador i la vora no podrà accedir a ell.", + "unassign-scheduler-events-from-edge-title": "Està segur de que desitja anul·lar l'assignació {{count}} esdeveniments del programador?", + "unassign-scheduler-events-from-edge-text": "Després de la confirmació, tots els esdeveniments del programador seleccionat seran desassignats i no seran accessibles per la vora.", + "manage-user-groups": "Administrar grups d'usuaris", + "manage-asset-groups": "Administrar grups d'actius", + "manage-device-groups": "Administrar grups de dispositius", + "manage-dashboard-groups": "Administrar grups de panells", + "manage-entity-view-groups": "Administrar grups de vista d'entitat", + "manage-scheduler-events": "Administrar esdeveniments del planificador", + "manage-rulechains": "Administrar cadenes de regles", + "assign-scheduler-event-to-edge-title": "Assignar esdeveniments del programador a Edge", + "assign-scheduler-event-to-edge-text": "Selecciona els esdeveniments del programador per assignar a la vora", + "assign-entity-groups-to-edge-text": "Selecciona els grups d'entitats per assignar a la vora" + }, + "edge-event": { + "type-dashboard": "Panell", + "type-asset": "Actiu", + "type-device": "Dispositiu", + "type-device-profile": "Perfil del dispositiu", + "type-entity-view": "Vista d'entitat", + "type-alarm": "Alarma", + "type-entity-group": "Grup d'Entitats", + "type-rule-chain": "Cadena de regles", + "type-rule-chain-metadata": "Metadades de la cadena de regles", + "type-edge": "Edge", + "type-user": "Usuari", + "type-customer": "Client", + "type-relation": "Relació", + "type-widgets-bundle": "Paquet de widgets", + "type-widgets-type": "Tipus de widgets", + "type-admin-settings": "Configuració d'administrador", + "type-scheduler-event": "Agenda d'esdeveniments", + "type-white-labeling": "Etiquetat blanc", + "type-login-white-labeling": "Inici de sessió Etiqueta blanca", + "type-custom-translation": "Traducció personalitzada", + "type-role": "Rol", + "type-group-permission": "Permís de grup", + "action-type-added": "Afegit", + "action-type-deleted": "S'ha suprimit", + "action-type-updated": "Actualitzat", + "action-type-post-attributes": "Atributs de publicació", + "action-type-attributes-updated": "Atributs actualitzats", + "action-type-attributes-deleted": "Atributs suprimits", + "action-type-timeseries-updated": "Sèries temporals actualitzades", + "action-type-credentials-updated": "Credencials actualitzades", + "action-type-assigned-to-customer": "Assignat al client", + "action-type-unassigned-from-customer": "No assignat del client", + "action-type-relation-add-or-update": "Afegir o actualitzar la relació", + "action-type-relation-deleted": "S'ha eliminat la relació", + "action-type-rpc-call": "Trucada RPC", + "action-type-alarm-ack": "Ack d'alarma", + "action-type-alarm-clear": "Alarma esborrada", + "action-type-assigned-to-edge": "Assignat a Edge", + "action-type-unassigned-from-edge": "Sense assignar des d'Edge", + "action-type-credentials-request": "Sol·licitud de credencials", + "action-type-entity-merge-request": "Sol·licitud de fusió d'entitats", + "action-type-added-to-entity-group": "S'ha afegit al grup d'entitats", + "action-type-removed-from-entity-group": "S'ha eliminat del grup d'entitats", + "action-type-change-owner": "Canvia de propietari", + "action-type-relations-deleted": "S'han suprimit les relacions" + }, + "error": { + "unable-to-connect": "Impossible connectar amb el servidor! Per favor, revisi la seva connexió a internet.", + "unhandled-error-code": "Codi d'error no controlat: {{errorCode}}", + "unknown-error": "Error desconegut" + }, + "entity": { + "entity": "Entitat", + "entities": "Entitats", + "entities-count": "Les entitats compten", + "aliases": "Àlies d'entitat", + "entity-alias": "Àlies d'entitat", + "unable-delete-entity-alias-title": "No ha estat possible eliminar l'àlies d'entitat", + "unable-delete-entity-alias-text": "L'àlies d'entitat '{{entityAlias}}' no pot ser eliminat, ja que s'està usant per als següents widgets:
    {{widgetsList}}", + "duplicate-alias-error": "Trobat un àlies duplicat '{{alias}}'.
    Els àlies d'entitat han de ser únics per a cada panell.", + "missing-entity-filter-error": "Falta el filtre per l'àlies '{{alias}}'.", + "configure-alias": "Configurar àlies '{{alias}}' ", + "alias": "Àlies", + "alias-required": "Cal àlies d'entitat.", + "remove-alias": "Eliminar àlies d'entitat", + "add-alias": "Afegir àlies d'entitat", + "entity-list": "Llista d'entitats", + "entity-type": "Tipus d'entitat", + "entity-types": "Tipus d'entitats", + "entity-type-list": "Llista de tipus d'entitat", + "any-entity": "Qualsevol entitat", + "enter-entity-type": "Introduir tipus d'entitat", + "no-entities-matching": "No s'han trobat entitats que coincideixin amb '{{entity}}' .", + "no-entity-types-matching": "No s'han trobat tipus d'entitat que coincideixin amb '{{entityType}}' .", + "name-starts-with": "Nom comença amb", + "help-text": "Utilitzeu '%' segons les necessitats: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Utilitza filtre", + "entity-list-empty": "No hi ha entitats seleccionades.", + "entity-type-list-empty": "No hi ha tipus d'entitat seleccionats.", + "entity-name-filter-required": "Cal filtre de nom d'entitat.", + "entity-name-filter-no-entity-matched": "No hi ha entitats que comencen amb '{{entity}}' .", + "all-subtypes": "Tots", + "select-entities": "Seleccionar entitats", + "no-aliases-found": "No s'han trobat àlies.", + "no-alias-matching": "'{{alias}}' no trobat.", + "create-new-alias": "Crear nou àlies!", + "key": "Clau", + "key-name": "Nom de clau", + "no-keys-found": "No s'han trobat claus.", + "no-key-matching": "'{{key}}' no trobada.", + "create-new-key": "Crear nova clau!", + "type": "Tipus", + "type-required": "Cal tipus d'entitat.", + "type-device": "Dispositiu", + "type-devices": "Dispositius", + "list-of-devices": "{ count, plural, 1 {Un dispositivo} other {Lista de # Dispositivos} }", + "device-name-starts-with": "Dispositius els quals comencen per '{{prefix}}'", + "type-device-profile": "Perfil de dispositiu", + "type-device-profiles": "Perfils de dispositiu", + "list-of-device-profiles": "{ count, plural, 1 {un perfil} other {Lista de # perfiles} }", + "device-profile-name-starts-with": "Perfils els quals comenci per '{{prefix}}'", + "type-asset": "Actiu", + "type-assets": "Actius", + "list-of-assets": "{ count, plural, 1 {Un activo} other {Lista de # activos} }", + "asset-name-starts-with": "Actius els quals comencen per '{{prefix}}'", + "type-entity-view": "Vista Entitat", + "type-entity-views": "Vistes Entitats", + "list-of-entity-views": "{ count, plural, 1 {Una vista de entidad} other {Lista de # Vistas de Entitats} }", + "entity-view-name-starts-with": "Vistes de Entitats els quals comencen per '{{prefix}}'", + "type-rule": "Regla", + "type-rules": "Regles", + "list-of-rules": "{ count, plural, 1 {Una regla} other {Lista de # regles} }", + "rule-name-starts-with": "Regles les quals comencen per '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, 1 {Un plugin} other {Lista de # plugins} }", + "plugin-name-starts-with": "Plugins els quals comencen per '{{prefix}}'", + "type-tenant": "Propietari", + "type-tenants": "Propietaris", + "list-of-tenants": "{ count, plural, 1 {Un propietario} other {Lista de # propietarios} }", + "tenant-name-starts-with": "Propietaris els quals comencen per '{{prefix}}'", + "type-tenant-profile": "Perfil de Propietari", + "type-tenant-profiles": "Perfils de propietari", + "list-of-tenant-profiles": "{ count, plural, 1 {Un perfil de propietario} other {Lista de # perfils de propietario} }", + "tenant-profile-name-starts-with": "Pefils de propietari els quals comencen per '{{prefix}}'", + "type-customer": "Client", + "type-customers": "Clients", + "list-of-customers": "{ count, plural, 1 {Un cliente} other {Lista de # clientes} }", + "customer-name-starts-with": "Clients els quals comencen per '{{prefix}}'", + "type-user": "Usuari", + "type-users": "Usuaris", + "list-of-users": "{ count, plural, 1 {Un usuario} other {Lista de # usuaris} }", + "user-name-starts-with": "Usuaris els quals comencen per '{{prefix}}'", + "type-dashboard": "Panell", + "type-dashboards": "Panells", + "list-of-dashboards": "{ count, plural, 1 {Un panel} other {Lista de # paneles} }", + "dashboard-name-starts-with": "Panells els quals comencen per '{{prefix}}'", + "type-alarm": "Alarma", + "type-alarms": "Alarmes", + "list-of-alarms": "{ count, plural, 1 {Una alarma} other {Lista de # alarmas} }", + "alarm-name-starts-with": "Alarmes les quals comencen amb '{{prefix}}'", + "type-rulechain": "Cadena de regles", + "type-rulechains": "Cadenes de regles", + "list-of-rulechains": "{ count, plural, 1 {Una cadena de regles} other {Lista de # cadenes de regles} }", + "rulechain-name-starts-with": "Cadenes de regles les quals comencen amb '{{prefix}}'", + "type-scheduler-event": "Planificador d'esdeveniment", + "type-scheduler-events": "Planificador d'esdeveniment", + "list-of-scheduler-events": "{ count, plural, 1 {One scheduler event} other {List of # scheduler events} }", + "scheduler-event-name-starts-with": "Planificador d'esdeveniment els quals comencen amb '{{prefix}}'", + "type-blob-entity": "Entitat blob", + "type-blob-entities": "Entitats blob", + "list-of-blob-entities": "{ count, plural, 1 {One blob entity} other {List of # blob entities} }", + "blob-entity-name-starts-with": "Entitats blob els quals comencen amb '{{prefix}}'", + "type-rulenode": "Node de regles", + "type-rulenodes": "Nodes de regles", + "list-of-rulenodes": "{ count, plural, 1 {Un nodo de regles} other {Lista de # nodes de regles} }", + "rulenode-name-starts-with": "Nodes de regles els quals comencen amb '{{prefix}}'", + "type-current-customer": "Client Actual", + "type-current-tenant": "Propietari Actual", + "type-current-user": "Usuari Actual", + "type-current-user-owner": "Usuari Propietari Actual", + "search": "Buscar entitats", + "selected-entities": "{ count, plural, 1 {1 entitat} other {# entitats} } seleccionades", + "entity-name": "Nom d'entitat", + "entity-label": "Etiqueta d'entitat", + "details": "Detalls d'entitat", + "no-entities-prompt": "No s'han trobat entitats", + "no-data": "No hi ha dades que mostrar", + "columns-to-display": "Columnes a Mostrar", + "type-api-usage-state": "Estat d'ús de la API", + "type-entity-group": "Grup d'entitats", + "type-converter": "Convertidor de dades", + "type-converters": "Convertidors de dades", + "list-of-converters": "{ count, plural, 1 {One data converter} other {List of # data converters} }", + "converter-name-starts-with": "Convertidors de dades els quals comencen amb '{{prefix}}'", + "type-integration": "Integració", + "type-integrations": "Integracions", + "list-of-integrations": "{ count, plural, 1 {One integration} other {List of # integrations} }", + "integration-name-starts-with": "Integracions les quals comencen amb '{{prefix}}'", + "type-role": "Rol", + "type-roles": "Rols", + "list-of-roles": "{ count, plural, 1 {One role} other {List of # roles} }", + "role-name-starts-with": "Rols els noms dels quals comencen per '{{prefix}}'", + "type-group-permission": "Permís de grup", + "type-edge": "Vora", + "type-edges": "Vores", + "list-of-edges": "{count, plural, 1 {Un borde} other {Lista de # bordes} }", + "edge-name-starts-with": "Vores les quals comencen amb '{{prefijo}}'", + "type-tb-resource": "Recurs", + "type-ota-package": "Paquet OTA" + }, + "entity-group": { + "entity-group": "Grup d'entitats", + "details": "Detalls", + "columns": "Columnes", + "add-column": "Afegir columna", + "column-value": "Valor", + "column-value-required": "Cal valor de la columna.", + "column-title": "Títol", + "default-sort-order": "Orde de clasificació predeterminat", + "default-sort-order-required": "Cal orde de clasificació predeterminat.", + "hide-in-mobile-view": "Móvil ocult", + "use-cell-style-function": "Utilizar funció d'estil de cel·la", + "use-cell-content-function": "Utilizar funció de contingut de cel·la", + "edit-column": "Editar columna", + "column-details": "Detalls de la columna", + "actions": "Accions", + "settings": "Configuració", + "search": "Cerca grups d'entitats", + "delete": "Eliminar grup d'entitats", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "description": "Descripció", + "add": "Afegir grup d'entitats", + "open-entity-group": "Grup d'entitats obert", + "add-entity-group-text": "Afegir nou grup d'entitats", + "no-entity-groups-text": "Grups d'entitats no trobats", + "entity-group-details": "Detalls del grup d'entitats", + "selected-entity-groups": "{ count, plural, 1 {1 entity group} other {# entity groups} } seleccionat", + "delete-entity-groups": "Eliminar grups d'entitats", + "delete-entity-group-title": "Està segur de que desitja eliminar el grup d'entitats '{{entityGroupName}}'?", + "delete-entity-group-text": "Aneu amb compte, després de la confirmació, el grup d'entitats i totes les dades relacionades seran irrecuperables.", + "delete-entity-groups-title": "Està segur de que desitja eliminar { count, plural, 1 {1 entity group} other {# entity groups} }?", + "delete-entity-groups-action-title": "Eliminar { count, plural, 1 {1 entity group} other {# entity groups} }", + "delete-entity-groups-text": "Aneu amb compte, després de la confirmació s'eliminaran tots els grups d'entitats seleccionats i totes les dades relacionades seran irrecuperables.", + "device-groups": "Grups de dispositius", + "asset-groups": "Grups d'actius", + "customer-groups": "Grups de clients", + "device-group": "Grup de dispositius", + "asset-group": "Grup d'actius", + "user-group": "Grup d'usuaris", + "user-groups": "Grups d'usuaris", + "customer-group": "Grup de clients", + "entity-view-groups": "Grups de vista d'entitat", + "entity-view-group": "Grup de vista d'entitat", + "edge-groups": "Grups de vores", + "edge-group": "Grup de vora", + "dashboard-groups": "Grups de panells", + "dashboard-group": "Grup de quadres de comandament", + "fetch-more": "Portar més", + "column-type": { + "column-type": "Tipus de columna", + "client-attribute": "Atribut del client", + "shared-attribute": "Atribut compartit", + "server-attribute": "Atribut del servidor", + "timeseries": "Series temporals", + "entity-field": "Camp d'entitat" + }, + "column-type-required": "Cal el tipus de columna.", + "entity-field": { + "created-time": "Temps de creació", + "name": "Nom", + "type": "Tipus", + "device_profile": "Perfil del dispositiu", + "assigned_customer": "Client Assignat", + "authority": "Autoritat", + "first_name": "Nom", + "last_name": "Cognom", + "email": "Correu electrònic", + "title": "Títol", + "country": "País", + "state": "Estat", + "city": "Ciutat", + "address": "Direcció", + "address2": "Direcció 2", + "zip": "Codi postal", + "phone": "Telèfon", + "label": "Etiqueta" + }, + "sort-order": { + "asc": "Ascendent", + "desc": "Descendent", + "none": "Ningú" + }, + "details-mode": { + "on-row-click": "Clic en la fila", + "on-action-button-click": "Clic en el botó de detalls", + "disabled": "Inhabilitat" + }, + "change-owner": "Change owner", + "select-target-owner": "Seleccioneu el propietari objectiu", + "no-owners-matching": "No hi ha cap propietari que coincideixi '{{owner}}' els van trobar.", + "target-owner-required": "Cal propietari objectiu.", + "confirm-change-owner-title": "Esteu segur que voleu canviar de propietari per { count, plural, 1 {1 selected entity} other {# selected entities} }?", + "confirm-change-owner-text": "Aneu amb compte, després de la confirmació, totes les entitats seleccionades s'eliminaran del propietari actual i es col·locaran al grup 'Tots' del propietari objectiu..", + "add-to-group": "Afegir al grup", + "move-to-group": "Moure al grup", + "select-entity-group": "Seleccionar grup d'entitats", + "no-entity-groups-matching": "Grups d'entitats que coincideixin amb '{{entityGroup}}' no han estat trobats.", + "target-entity-group-required": "Cal grup d'entitats objetiu.", + "select-user-group": "Seleccioneu el grup d'usuaris", + "no-user-groups-matching": "No hi ha cap grup d'usuaris que coincideixi '{{entityGroup}}' els van trobar.", + "target-user-group-required": "Cal grup d'usuaris objectiu.", + "remove-from-group": "Eliminar des de grup", + "group-table-title": "Títol de panell de grup", + "enable-search": "Habilitar cerca d'entitats", + "enable-add": "Habilitar agregació d'entitats", + "enable-delete": "Habilitar eliminació d'entitats", + "enable-selection": "Habilitar selecció d'entitats", + "enable-group-transfer": "Habilitar accions de transferencia de grup", + "display-pagination": "Mostrar paginació", + "default-page-size": "Mida de pàgina predeterminat", + "enable-assignment-actions": "Habilitar accions de assignació", + "enable-credentials-management": "Habilitar gestió de credencials", + "enable-login-as-user": "Activa l'inici de sessió com a acció de l'usuari", + "enable-users-management": "Habilitar gestió d'usuaris", + "enable-customers-management": "Habilitar la gestió de clients", + "enable-assets-management": "Habilitar gestió d'actius", + "enable-devices-management": "Habilitar gestió de dispositius", + "enable-entity-views-management": "Habilitar la gestió de vistes d'entitats", + "enable-edges-management": "Habilitar la gestió de vores", + "enable-dashboards-management": "Habilitar gestió de panells", + "enable-scheduler-events-management": "Habilitar la gestió de esdeveniments del programador", + "open-details-on": "Obrir detalls de l'entitat en", + "select-existing": "Seleccionar grup d'entitats existent", + "create-new": "Crear nou grup d'entitats", + "new-entity-group-name": "Nou nom del grup d'entitats", + "entity-group-list": "Llista de grup d'entitats", + "entity-group-list-empty": "Grups d'entitats no seleccionats.", + "name-starts-with": "El nom de l'entitat de grup comença amb", + "entity-group-name-filter-required": "Cal el nom de filtre per entitat de grup.", + "roles": "Rols", + "permissions": "Permissions", + "public": "Públic", + "entity-group-public": "El grup d'entitats és públic", + "make-public": "Fer públic el grup d'entitats", + "make-private": "Fes que el grup d'entitats sigui privat", + "make-public-entity-group-title": "Esteu segur que voleu fer el grup d'entitats '{{entityGroupName}}' públic?", + "make-public-entity-group-text": "Després de la confirmació, el grup d'entitats i totes les seves entitats es faran públiques i es faran accessibles per altres.", + "make-private-entity-group-title": "Esteu segur que voleu fer el grup d'entitats '{{entityGroupName}}' private?", + "make-private-entity-group-text": "Després de la confirmació, el grup d'entitats i totes les seves entitats es convertiran en privats i no seran accessibles per altres.", + "share": "Comparteix el grup d'entitats", + "copyId": "Copia l'identificador del grup d'entitats", + "idCopiedMessage": "L'identificador del grup d'entitats s'ha copiat al porta-retalls", + "entity-group-name": "Nom del grup d'entitats", + "all-users": "Tots els usuaris" + }, + "entity-field": { + "created-time": "Hora de creació", + "name": "Nom", + "type": "Tipus", + "first-name": "Nom", + "last-name": "Cognom", + "email": "Correu electrònic", + "title": "Títol", + "country": "País", + "state": "Estat", + "city": "Ciudad", + "address": "Direcció", + "address2": "Direcció 2", + "zip": "Códi postal", + "phone": "Telèfon", + "label": "Etiqueta" + }, + "entity-view": { + "entity-view": "Vista d'entitat", + "entity-view-required": "Cal vista d'entitat.", + "entity-views": "Vistes d'entitat", + "management": "Gestió de vistes d'entitat", + "view-entity-views": "Veure vista d'entitat", + "entity-view-alias": "Àlies de vista d'entitat", + "aliases": "Àlies de vista d'entitat", + "no-alias-matching": "'{{alias}}' no trobat.", + "no-aliases-found": "No s'han trobar àlies.", + "no-key-matching": "'{{key}}' no trobada.", + "no-keys-found": "No s'han trobar claus.", + "create-new-alias": "¡Crear un nou!", + "create-new-key": "¡Crear una nova!", + "duplicate-alias-error": "Àlies duplicat'{{alias}}'.
    Els àlies de Entity View han de ser els únics en el panell.", + "configure-alias": "Configurar àlies '{{alias}}'", + "no-entity-views-matching": "No s'han trobar vistes que coincideixin amb '{{entity}}'.", + "public": "Públic", + "alias": "Àlies", + "alias-required": "Cal àlies de vista d'entitat.", + "remove-alias": "Esborrar àlies de la vista d'entitat", + "add-alias": "Afegir àlies a la vista d'entitat", + "name-starts-with": "Nom de vista d'entitat comença amb", + "help-text": "Utilitzeu '%' segons les necessitats: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Llista de vistes d'entitat", + "use-entity-view-name-filter": "Utilitzar el filtre", + "entity-view-list-empty": "No hi ha vistes d'entitat seleccionades.", + "entity-view-name-filter-required": "Cal nom del filtre de vista d'entitat.", + "entity-view-name-filter-no-entity-view-matched": "No s'han trobar vistes d'entitat que comencen amb '{{entityView}}'.", + "add": "Afegir vista d'entitat", + "entity-view-public": "Vista d'entitat és pública", + "assign-to-customer": "Assignar a client", + "assign-entity-view-to-customer": "Assignar vista d'entitat a client", + "assign-entity-view-to-customer-text": "Per favor, selecciona les vistes d'entitat per assignar al client", + "no-entity-views-text": "No s'han trobar vistas d'entitat", + "assign-to-customer-text": "Per favor, selecciona el client per assignar la vista d'entitat", + "entity-view-details": "Detalls de la vista d'entitat", + "add-entity-view-text": "Afegir nova vista d'entitat", + "delete": "Esborrar vista d'entitat", + "assign-entity-views": "Assignar vistes d'entitat", + "assign-entity-views-text": "Assignar { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } a client", + "delete-entity-views": "Esborrar vistes d'entitat", + "make-public": "Fer pública la vista d'entitat", + "make-private": "Fer que la vista d'entitat sigui privada", + "unassign-from-customer": "Anul·lar assignació a client", + "unassign-entity-views": "Anul·lar assignació de vistes d'entitat", + "unassign-entity-views-action-title": "Anul·lar assignació { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } al client", + "assign-new-entity-view": "Assignar nova vista d'entitat", + "delete-entity-view-title": "Esteu segur que voleu esborrar la vista entitat '{{entityViewName}}'?", + "delete-entity-view-text": "Compte! Després de la confirmació, la vista de l'entitat i totes les dades relacionades seran irrecuperables.", + "delete-entity-views-title": "Esteu segur que voleu esborrar les vistes d'entitat { count, plural, 1 {1 entityView} other {# entityViews} }?", + "delete-entity-views-action-title": "Esborrar { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }", + "delete-entity-views-text": "Compte! Després de la confirmació, totes les vistes d'entitats seleccionades s'eliminaran i totes les dades relacionades seran irrecuperables.", + "make-public-entity-view-title": "Està segur de que desitja que la vista d'entitat '{{entityViewName}}' sigui pública?", + "make-public-entity-view-text": "Després de la confirmació, la vista de l'entitat i totes les dades seran públiques i accessibles per altres.", + "make-private-entity-view-title": "Està segur de que desitja que la vista d'entitat '{{entityViewName}}' sigui privada?", + "make-private-entity-view-text": "Després de la confirmació, la vista de l'entitat i totes les dades seran privats i no seran accessibles per altres.", + "unassign-entity-view-title": "Esteu segur que voleu anul·lar l'assignació de la vista d'entitat '{{entityViewName}}'?", + "unassign-entity-view-text": "Després de la confirmació, la vista de l'entitat quedarà sense assignar i el client no hi podrà accedir.", + "unassign-entity-view": "Anul·lar assignació de la vista d'entitat", + "unassign-entity-views-title": "Esteu segur que voleu anul·lar l'assignació de { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }?", + "unassign-entity-views-text": "Després de la confirmació, totes les vistes d'entitats seleccionades quedaran sense assignar i el client no podrà accedir-hi.", + "entity-view-type": "Tipus de vista d'entitat", + "entity-view-type-required": "Cal tipus de vista d'entitat.", + "select-entity-view-type": "Selecciona el tipus de vista d'entitat", + "enter-entity-view-type": "Teclegeu el tipus de vista d'entitat", + "any-entity-view": "Qualsevol vista d'entitat", + "no-entity-view-types-matching": "No s'han trobar tipus de vista d'entitat que coincideixin amb '{{entitySubtype}}'.", + "entity-view-type-list-empty": "No hi ha tipus de vista d'entitat seleccionada.", + "entity-view-types": "Tipus de vista d'entitat", + "created-time": "Data de creació", + "name": "Nom", + "name-required": "Cal nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "type-max-length": "El tipus de visualització d'entitat ha de ser inferior a 256", + "description": "Descripció", + "events": "Esdeveniments", + "details": "Detalls", + "copyId": "Copiar el Id de la vista d'entitat", + "idCopiedMessage": "El Id de la vista d'entitat s'ha copiat al porta-retalls", + "assignedToCustomer": "Assignat a client", + "unable-entity-view-device-alias-title": "No es pot eliminar l'àlies de vista d'entitat", + "unable-entity-view-device-alias-text": "L'àlies del dispositiu '{{entityViewAlias}}' no es pot borrar perquè està sent usat pel widget(s):
    {{widgetsList}}", + "select-entity-view": "Seleccionar vista d'entitat", + "start-date": "Data d'inici", + "start-ts": "Temps d'inici", + "end-date": "Data de finalització", + "end-ts": "Temps de finalització", + "date-limits": "Limits de data", + "client-attributes": "Atributs de client", + "shared-attributes": "Atributs compartits", + "server-attributes": "Atributs de servidor", + "timeseries": "Series temporals", + "client-attributes-placeholder": "Atributs de client", + "shared-attributes-placeholder": "Atributs compartits", + "server-attributes-placeholder": "Atributs de servidor", + "timeseries-placeholder": "Series temporals", + "target-entity": "Entitat objetiu", + "attributes-propagation": "Propagació de atributs", + "attributes-propagation-hint": "La vista d'entitat copiarà automàticament els atributs especificats de l'entitat de destí cada vegada que guardi o actualitzi esta vista d'entitat. Per razones de rendimiento, els atributs d'entitat objetiu no es propagan a la vista d'entitat en cada cambio d'atribut. Puede habilitar la propagación automàtica configurando el node de la regla \"copiar a la vista\" en la seva cadena de regles i vincular els missatges \"Atributs de la publicación\" i \"Atributs actualizados\" al nou node de la regla.", + "timeseries-data": "Dades de series temporals", + "timeseries-data-hint": "Configura les claus de les dades de les series temporals de l'entitat de destí que seran accessibles per la vista de l'entitat. Les dades d'aquesta sèrie temporal són de només lectura.", + "selected-entity-views": "{ count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } seleccionades", + "search": "Buscar vistes d'entitat", + "select-group-to-add": "Seleccioneu el grup objectiu per afegir visualitzacions d'entitat seleccionades", + "select-group-to-move": "Seleccioneu el grup objectiu per moure les vistes d'entitat seleccionades", + "remove-entity-views-from-group": "Esteu segur que voleu eliminar-lo { count, plural, 1 {1 entity view} other {# entity views} } del grup '{{entityGroup}}'?", + "group": "Grup de vistes d'entitat", + "list-of-groups": "{ count, plural, 1 {One entity view group} other {List of # entity view groups} }", + "group-name-starts-with": "Grups de visualització d'entitats els noms dels quals comencen per '{{prefix}}'", + "assign-entity-view-to-edge": "Assignar vista (es) d'entitat a vora", + "assign-entity-view-to-edge-text": "Selecciona les vistes d'entitat per assignar a la vora", + "unassign-entity-view-from-edge-title": "Està segur de que desitja anul·lar l'assignació de la vista d'entitat '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Després de la confirmació, la vista d'entitat quedará sense assignar i la vora no podrà accedir a ella", + "unassign-entity-views-from-edge-action-title": "Anul·lar assignació {count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } del vora", + "unassign-entity-view-from-edge": "Anul·lar assignació de vista d'entitat", + "unassign-entity-views-from-edge-title": "Està segur de que desitja desassignar {count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }?", + "unassign-entity-views-from-edge-text": "Després de la confirmació, totes les vistas d'entitat seleccionades no seran assignades i la vora no podrà accedir a ellas" + }, + "event": { + "events": "Esdeveniments", + "event-type": "Tipus d'esdeveniment", + "events-filter": "Filtre d'esdeveniments", + "clean-events": "Esborrar esdeveniments", + "type-error": "Error", + "type-lc-event": "Cicle de vida de l'esdeveniment", + "type-stats": "Estadístiques", + "type-debug-converter": "Depurar", + "type-debug-integration": "Depurar", + "type-debug-rule-node": "Depuració", + "type-debug-rule-chain": "Depuració", + "no-events-prompt": "Cap esdeveniment trobat.", + "error": "Error", + "alarm": "Alarma", + "event-time": "Hora de l'esdeveniment", + "server": "Servidor", + "body": "Cos", + "method": "Mètode", + "type": "Tipus", + "in": "Dins", + "out": "Fora", + "metadata": "Metadades", + "message": "Missatge", + "entity": "Entitat", + "message-id": "Id Missatge", + "message-type": "Tipus Missatge", + "data-type": "Tipus de Dades", + "relation-type": "Tipus de relació", + "data": "Dades", + "event": "Esdeveniment", + "status": "Estat", + "success": "Èxit", + "failed": "Fallada", + "messages-processed": "Missatges processats", + "min-messages-processed": "Mínim missatges processats", + "errors-occurred": "Van passar errors", + "min-errors-occurred": "S'han produït errors mínims", + "min-value": "S'han produït errors mínims.", + "all-events": "Tots", + "has-error": "Té error", + "entity-id": "Identificador de l'entitat", + "entity-type": "Tipus d'entitat", + "clear-filter": "Esborra el filtre", + "clear-request-title": "Esborra tots els esdeveniments", + "clear-request-text": "Esteu segur que voleu esborrar tots els esdeveniments?", + "type-edge-event": "Enllaç descendent" + }, + "extension": { + "extensions": "Extensions", + "selected-extensions": "{ count, plural, 1 {1 extensión} other {# extensiones} } seleccionades", + "type": "Tipus", + "key": "Clau", + "value": "Valor", + "id": "ID", + "extension-id": "ID d'extensió", + "extension-type": "Tipus d'extensió", + "transformer-json": "JSON *", + "unique-id-required": "El id d'extensió ja existeix.", + "delete": "Esborrar Extensió", + "add": "Afegir Extensió", + "edit": "Editar Extensió", + "view": "Mostra l'extensió", + "delete-extension-title": "Eliminar la extensió '{{extensionId}}'?", + "delete-extension-text": "Atenció, després de la confirmació, l'extensió i els seus dades seran esborrades i irrecuperables.", + "delete-extensions-title": "Eliminar les extensions { count, plural, 1 {1 extensión} other {# extensiones} }?", + "delete-extensions-text": "Atenció, després de la confirmació totes les extensions seleccionades i els seus dades seran esborrats i irrecuperables.", + "converters": "Convertidors", + "converter-id": "Id convertidor", + "configuration": "Configuració", + "converter-configurations": "Configuració de convertidor", + "token": "Tóken de seguretat", + "add-converter": "Afegir convertidor", + "add-config": "Afegir configuració de convertidor", + "device-name-expression": "Expressió del nom de dispositiu", + "device-type-expression": "Expressió del tipus de dispositiu", + "custom": "Personalitzat", + "to-double": "Per a duplicar", + "transformer": "Transformador", + "json-required": "Cal el JSON del transformador.", + "json-parse": "No ha estat possible analitzar el JSON del transformador.", + "attributes": "Atributs", + "add-attribute": "Afegir Atribut", + "add-map": "Afegir element de mapejat", + "timeseries": "Series del temps", + "add-timeseries": "Afegir series del temps", + "field-required": "Cal Camp", + "brokers": "Corredors", + "add-broker": "Afegir corredor", + "host": "Host", + "port": "Port", + "port-range": "El port ha d'estar en un rang de 1 a 65535.", + "ssl": "SSL", + "credentials": "Credencials", + "username": "Usuari", + "password": "Contrasenya", + "retry-interval": "Interval de reintent en mil·lisegons", + "sas": "Signatura d'accés compartit", + "anonymous": "Anònim", + "basic": "Bàsic", + "pem": "PEM", + "ca-cert": "Arxiu de certificat CA *", + "private-key": "Arxiu de clau privat *", + "cert": "Arxiu certificat *", + "no-file": "Cap arxiu seleccionat.", + "drop-file": "Colocar un arxiu o fer clic per seleccionar un arxiu per carregar .", + "mapping": "Mapeig", + "topic-filter": "Filtre de tema", + "converter-type": "Tipus de convertidor", + "converter-json": "Json", + "json-name-expression": "Expressió json per nom del dispositiu", + "topic-name-expression": "Expressió temàtica per nom del dispositiu", + "json-type-expression": "Expressió json per tipus de dispositiu", + "topic-type-expression": "Expressió temàtica per tipus de dispositiu", + "attribute-key-expression": "Expressió per clau d'atribut", + "attr-json-key-expression": "Expressió json per clau d'atribut", + "attr-topic-key-expression": "Expressió temàtica per clau d'atribut", + "request-id-expression": "Expressió per sol·licitud d'ID", + "request-id-json-expression": "Expressió json per sol·licitud d'ID", + "request-id-topic-expression": "Expressió temàtica per sol·licitud d'ID", + "response-topic-expression": "Expressió temàtica per respuesta", + "value-expression": "Expressió per valor", + "topic": "Tema", + "timeout": "Temps d'espera en mil·lisegons", + "converter-json-required": "Cal convertidor json.", + "converter-json-parse": "No es pot analitzar el convertidor json.", + "filter-expression": "Expressió per filtre", + "connect-requests": "Sol·licituds de connexió", + "add-connect-request": "Afegir solicituts de connexió", + "disconnect-requests": "Sol·licituds de desconexió", + "add-disconnect-request": "Afegir sol·licitud de desconexió", + "attribute-requests": "Sol·licituds d'atribut", + "add-attribute-request": "Afegir solicituts d'atribut", + "attribute-updates": "Actualizaciones d'atribut", + "add-attribute-update": "Afegir actualitzacions d'atribut", + "server-side-rpc": "RPC costat servidor", + "add-server-side-rpc-request": "Afegir sol·licitud RPC costat servidor", + "device-name-filter": "Filtre de nom de dispositiu", + "attribute-filter": "Filtre d'atribut", + "method-filter": "Filtre de mètode", + "request-topic-expression": "Expressió temàtica per sol·licitud", + "response-timeout": "Temps d'espera de respuesta en mil·lisegons", + "topic-expression": "Expressió temàtica", + "client-scope": "Alcance del client", + "add-device": "Afegir dispositiu", + "opc-server": "Servidors", + "opc-add-server": "Afegir servidor", + "opc-add-server-prompt": "Per favor afegir servidor", + "opc-application-name": "Nom d'aplicació", + "opc-application-uri": "Aplicació URI", + "opc-scan-period-in-seconds": "Període d'exploració en segons", + "opc-security": "Seguretat", + "opc-identity": "Identitat", + "opc-keystore": "Magatzem de claus", + "opc-type": "Tipus", + "opc-keystore-type": "Tipus", + "opc-keystore-location": "Ubicació *", + "opc-keystore-password": "Contrasenya", + "opc-keystore-alias": "Àlies", + "opc-keystore-key-password": "Clau de contrasenya", + "opc-device-node-pattern": "Patró de node de dispositiu", + "opc-device-name-pattern": "Patró de nom de dispositiu", + "modbus-server": "Servidors/esclaus", + "modbus-add-server": "Afegir servidor/esclau", + "modbus-add-server-prompt": "Per favor afegir servidor/esclau", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Reconexió automàtica", + "modbus-rtu-over-tcp": "RTU sobre TCP", + "modbus-port-name": "Nom del port serial", + "modbus-encoding": "Codificació", + "modbus-parity": "Paritat", + "modbus-baudrate": "Taxa de bauds", + "modbus-databits": "Bits de dades", + "modbus-stopbits": "Bits de parada", + "modbus-databits-range": "Bits de dades han d'estar en un rang entre 7 i 8.", + "modbus-stopbits-range": "Bits de parada han d'estar en un rang entre 1 a 2.", + "modbus-unit-id": "ID de unidad", + "modbus-unit-id-range": "ID de unidad ha d'estar en un rang entre 1 a 247.", + "modbus-device-name": "Nom del dispositiu", + "modbus-poll-period": "Període de sondeig (ms)", + "modbus-attributes-poll-period": "Atributs del període de sondeig (ms)", + "modbus-timeseries-poll-period": "Període de sondeig de les series temporals (ms)", + "modbus-poll-period-range": "El període de sondeig ha de ser una valor positiu.", + "modbus-tag": "Etiqueta", + "modbus-function": "Funció", + "modbus-register-address": "Direcció del registre", + "modbus-register-address-range": "Direcció del registre ha d'estar en un rang entre 0 i 65535.", + "modbus-register-bit-index": "Índex de bit", + "modbus-register-bit-index-range": "Índex de bit ha d'estar en un rang entre 0 i 15.", + "modbus-register-count": "Contador del registre", + "modbus-register-count-range": "Contador del registre ha de ser un valor positiu.", + "modbus-byte-order": "Orde del byte", + "sync": { + "status": "Estat", + "sync": "Sincronitzat", + "not-sync": "No Sincronitzat", + "last-sync-time": "Hora d'última sincronització", + "not-available": "No disponible" + }, + "export-extensions-configuration": "Exportar configuració d'extensions", + "import-extensions-configuration": "Importar configuració d'extensions", + "import-extensions": "Importar extensions", + "import-extension": "Importar extensió", + "export-extension": "Exportar extensió", + "file": "Fitxer d'extensions", + "invalid-file-error": "Fitxer d'extensions invàlid", + "text": "TEXT", + "json": "JSON", + "binary": "BINARY", + "hex": "HEX" + }, + "filter": { + "add": "Afegir filtre", + "edit": "Editar filtre", + "name": "Nom de filtre", + "name-required": "Cal nom de filtre.", + "duplicate-filter": "Ja existeix un filtre amb el mateix nom.", + "filters": "Filtres", + "unable-delete-filter-title": "Error esborrant filtre", + "unable-delete-filter-text": "El filtre '{{filter}}' no pot ser borrat pel fet que està sent usat actualment pels següents widgets:
    {{widgetsList}}", + "duplicate-filter-error": "S'ha trobat un filtre duplicat '{{filter}}'.
    Els filtres han de ser únics en el panell.", + "missing-key-filters-error": "No s'ha trobar la clau de filtres per el filtre '{{filter}}'.", + "filter": "Filtre", + "editable": "Editable", + "no-filters-found": "No s'han trobar filtres.", + "no-filter-text": "No s'ha especificat filtre", + "add-filter-prompt": "Per favor, añadir filtre", + "no-filter-matching": "'{{filter}}' no trobat.", + "create-new-filter": "Crear un filtre nou!", + "filter-required": "Cal filtre.", + "operation": { + "operation": "Operació", + "equal": "igual", + "not-equal": "no igual", + "starts-with": "comença amb", + "ends-with": "acaba amb", + "contains": "conté", + "not-contains": "no conté", + "greater": "més gran que", + "less": "menor que", + "greater-or-equal": "més gran o igual", + "less-or-equal": "menor o igual", + "and": "i", + "or": "o", + "in": "dins de ", + "not-in": "no dins" + }, + "ignore-case": "Ignorar majús/minus", + "value": "Valor", + "remove-filter": "Esborrar filtre", + "preview": "Vista previa de filtre", + "no-filters": "No hi ha filtres configurats", + "add-filter": "Afegir filtre", + "add-complex-filter": "Afegir filtre complex", + "add-complex": "Afegir filtre complex", + "complex-filter": "Filtre complex", + "edit-complex-filter": "Editar filtre complex", + "edit-filter-user-params": "Editar paràmetres d'usuari del filtre", + "filter-user-params": "Filtre de paràmetres d'usuari (predicado)", + "user-parameters": "Paràmetres d'usuari", + "display-label": "Etiqueta a mostrar", + "autogenerated-label": "Auto generar etiqueta", + "order-priority": "Prioritat orden de campos", + "key-filter": "Filtres (clau)", + "key-filters": "Filtres (claus)", + "key-name": "Nom de clau", + "key-name-required": "Cal nom de clau.", + "key-type": { + "key-type": "Tipus de clau", + "attribute": "Atribut", + "timeseries": "Sèries temporals", + "entity-field": "Camp d'entitat", + "constant": "Constant" + }, + "value-type": { + "value-type": "Tipus de valor", + "string": "Cadena", + "numeric": "Numèric", + "boolean": "Booleà", + "date-time": "Data/Hora" + }, + "value-type-required": "Cal tipus de valor.", + "key-value-type-change-title": "Canviar el tipus de valor de la clau?", + "key-value-type-change-message": "Si tu confirmes el nou tipus, tots els filtres s'esborraran.", + "no-key-filters": "No hi ha filtres claus configurats", + "add-key-filter": "Afegir filtre clau", + "remove-key-filter": "Esborrar filtre clau", + "edit-key-filter": "Editar filtre clau", + "date": "Data", + "time": "Hora", + "current-tenant": "Admin actual", + "current-customer": "Client actual", + "current-user": "Usuari actual", + "current-device": "Dispositiu actual", + "default-value": "Valor per defecte", + "dynamic-source-type": "Tipus d'origen dinàmic", + "dynamic-value": "Valor dinàmic", + "no-dynamic-value": "Sense valor dinàmic", + "source-attribute": "Atribut d'origen", + "switch-to-dynamic-value": "Canviar a valor dinàmic", + "switch-to-default-value": "Canviar a valor per defecte", + "inherit-owner": "Heretar del propietari", + "source-attribute-not-set": "Si l'atribut d'origen no està definit" + }, + "fullscreen": { + "expand": "Expandir a Pantalla Completa", + "exit": "Sortir de Pantalla Completa", + "toggle": "Canviar el modo de Pantalla Completa", + "fullscreen": "Pantalla Completa" + }, + "function": { + "function": "Funció" + }, + "gateway": { + "add-entry": "Afegir configuració", + "connector-add": "Afegir conector", + "connector-enabled": "Activar conector", + "connector-name": "Nom conector", + "connector-name-required": "Cal nom conector.", + "connector-type": "Tipus conector", + "connector-type-required": "Cal tipus conector.", + "connectors": "Configuració de conectors", + "create-new-gateway": "Crear un gateway nou", + "create-new-gateway-text": "Crear un nou gateway amb el nom: '{{gatewayName}}'?", + "delete": "Esborrar configuració", + "download-tip": "Descarregar fitxer de configuració", + "gateway": "Gateway", + "gateway-exists": "Ja existeix un dispositiu amb el mateix nom.", + "gateway-name": "Nom de Gateway", + "gateway-name-required": "Cal un nom de gateway.", + "gateway-saved": "Configuració de gateway gravada satisfactòriament.", + "json-parse": "JSON no vàlid.", + "json-required": "El camp no pot ser buit.", + "no-connectors": "No hi ha conectors", + "no-data": "No hi ha configuracions", + "no-gateway-found": "No s'ha trobat cap gateway.", + "no-gateway-matching": " '{{item}}' no trobat.", + "path-logs": "Ruta als fitxers de log", + "path-logs-required": "Cal ruta.", + "remote": "Configuració remota", + "remote-logging-level": "Nivel de logging", + "remove-entry": "Esborrar configuració", + "save-tip": "Gravar fitxer de configuració", + "security-type": "Tipus de seguretat", + "security-types": { + "access-token": "Token d'accés", + "tls": "TLS" + }, + "storage": "Grabació", + "storage-max-file-records": "Número màxim de registres en fitxer", + "storage-max-files": "Número màxim de fitxers", + "storage-max-files-min": "El número mínim és 1.", + "storage-max-files-pattern": "Número no vàlid.", + "storage-max-files-required": "Cal número.", + "storage-max-records": "Màxim de registres en el magatzem", + "storage-max-records-min": "El número mínim és 1.", + "storage-max-records-pattern": "Número no vàlid.", + "storage-max-records-required": "Cal número.", + "storage-pack-size": "Mida màxim de esdeveniments", + "storage-pack-size-min": "El número mínim és 1.", + "storage-pack-size-pattern": "Número no vàlid.", + "storage-pack-size-required": "Cal número.", + "storage-path": "Ruta de magatzem", + "storage-path-required": "Cal ruta de magatzem.", + "storage-type": "Tipus de magatzem", + "storage-types": { + "file-storage": "Magatzem fitxer", + "memory-storage": "Magatzem en memoria" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "Host ThingsBoard", + "thingsboard-host-required": "Cal Host.", + "thingsboard-port": "Port ThingsBoard", + "thingsboard-port-max": "El port màxim és 65535.", + "thingsboard-port-min": "El port mínim és 1.", + "thingsboard-port-pattern": "Port no vàlid.", + "thingsboard-port-required": "Cal port.", + "tidy": "Endreçat", + "tidy-tip": "Endreçat JSON", + "title-connectors-json": "Configuració conector {{typeName}}", + "tls-path-ca-certificate": "Ruta al certificat CA al gateway", + "tls-path-client-certificate": "Ruta al certificat client al gateway", + "tls-path-private-key": "Ruta a la clau privada al gateway", + "toggle-fullscreen": "Pantalla completa fullscreen", + "transformer-json-config": "Configuració JSON*", + "update-config": "Afegir/actualizar configuració JSON" + }, + "grid": { + "delete-item-title": "Vols eliminar aquest item?", + "delete-item-text": "Atenció, després de la confirmació l'item serà eliminat i la informació relacionada serà irrecuperable.", + "delete-items-title": "Vols eliminar { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Eliminar { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Atenció, després de la confirmació els items seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "add-item-text": "Afegir nou item", + "no-items-text": "Cap item trobat", + "item-details": "Detalls del item", + "delete-item": "Esborrar Item", + "delete-items": "Esborrar Items", + "scroll-to-top": "Anar cap amunt" + }, + "help": { + "goto-help-page": "Anar a la pàgina d'ajuda", + "show-help": "Mostra ajuda" + }, + "home": { + "home": "Principal", + "profile": "Perfil", + "logout": "Sortir", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Obrir menú d'usuari" + }, + "file-input": { + "browse-file": "Navegar fitxer", + "browse-files": "Navegar fitxers" + }, + "image-input": { + "drop-image-or": "Arrossegar i deixar anar imatges o", + "drop-images-or": "Arrossegar i deixar anar imatges o", + "no-images": "No hi ha imatges seleccionades", + "images": "imatges" + }, + "import": { + "no-file": "Cap arxiu seleccionat", + "drop-file": "Deixeu anar un arxiu JSON o feu clic per seleccionar un arxiu per carregar.", + "drop-json-file-or": "Sol un fitxer JSON o", + "drop-csv-file": "Deixeu anar un fitxer CSV o feu clic per seleccionar un fitxer per carregar.", + "drop-file-csv": "Suelte un arxiu CSV o haga clic per seleccionar un arxiu per carregar.", + "column-value": "Valor", + "column-title": "Títol", + "column-example": "Dades Sortird'exemple", + "column-key": "Clau d'atribut/telemetria", + "credentials": "Credencials", + "csv-delimiter": "Delimitador CSV", + "csv-first-line-header": "La primera línia conté noms de columna.", + "csv-update-data": "Actualizar atributs/telemetria", + "details": "Detalls", + "import-csv-number-columns-error": "Un arxiu ha de contener almenys dos columnes", + "import-csv-invalid-format-error": "Formato de arxiu invàlid. Línea: '{{line}}'", + "column-type": { + "name": "Nom", + "type": "Tipus", + "label": "Etiqueta", + "column-type": "Tipus de columna", + "client-attribute": "Atribut de client", + "shared-attribute": "Atribut compartit", + "server-attribute": "Atribut de servidor", + "timeseries": "Series del temps", + "entity-field": "Camp d'entitat", + "access-token": "Token d'accés", + "x509": "X.509", + "mqtt": { + "client-id": "ID de client MQTT", + "user-name": "Nom d'usuari MQTT", + "password": "Contrasenya MQTT" + }, + "lwm2m": { + "client-endpoint": "Nom del client del punt final LwM2M", + "security-config-mode": "Mode de configuració de seguretat LwM2M", + "client-identity": "Identitat del client LwM2M", + "client-key": "Clau de client LwM2M", + "client-cert": "Clau pública del client LwM2M", + "bootstrap-server-security-mode": "Mode de seguretat del servidor d'arrencada LwM2M", + "bootstrap-server-secret-key": "Clau secreta del servidor d'arrencada LwM2M", + "bootstrap-server-public-key-id": "Clau pública o identificador del servidor d'arrencada LwM2M", + "lwm2m-server-security-mode": "Mode de seguretat del servidor LwM2M", + "lwm2m-server-secret-key": "Clau secreta del servidor LwM2M", + "lwm2m-server-public-key-id": "ID o clau pública del servidor LwM2M" + }, + "isgateway": "Es Gateway", + "activity-time-from-gateway-device": "Data d'activitat des del dispositiu gateway", + "description": "Descripció", + "edge-license-key": "Clau de llicència", + "cloud-endpoint": "Punt final del núvol", + "routing-key": "Clau Edge", + "secret": "Edge secret" + }, + "stepper-text": { + "select-file": "Selecciona un arxiu", + "configuration": "Importar configuració", + "column-type": "Seleccionar tipus de columnes", + "creat-entities": "Creant noves entitats", + "done": "Fet" + }, + "message": { + "create-entities": "Es van crear{{count}} noves entitats correctament.", + "update-entities": "{{count}} entitats s'han actualitzat correctament.", + "error-entities": "S'ha produit un error en crear {{count}} entitats." + } + }, + "integration": { + "integration": "Integració", + "integrations": "Integracions", + "select-integration": "Seleccionar integració", + "no-integrations-matching": "Integracions que coincideixin amb '{{entity}}' no han estat trobades.", + "integration-required": "Cal integració", + "delete": "Eliminar integració", + "management": "Gestió d'integracions", + "add-integration-text": "Afegir nova integració", + "no-integrations-text": "Integracions no trobades", + "selected-integrations": "{ count, plural, 1 {1 integration} other {# integrations} } seleccionades", + "delete-integration-title": "Està segur de que desitja eliminar la integració '{{integrationName}}'?", + "delete-integration-text": "Aneu amb compte, després de la confirmació, la integració i totes les dades relacionades seran irrecuperables.", + "delete-integrations-title": "Està segur de que desitja eliminar { count, plural, 1 {1 integration} other {# integrations} }?", + "delete-integrations-action-title": "Eliminar { count, plural, 1 {1 integration} other {# integrations} }", + "delete-integrations-text": "Aneu amb compte, després de la confirmació s'eliminaran totes les integracions seleccionades i totes les dades relacionades seran irrecuperables.", + "events": "Esdeveniments", + "enabled": "Habilitat", + "allow-create-devices-or-assets": "Permet crear dispositius o actius", + "add": "Afegir Integració", + "search": "Cerca integracions", + "integration-details": "Detalls d'Integració", + "details": "Detalls", + "copyId": "Copiar ID d'integració", + "idCopiedMessage": "ID d'integració ha estat copiada al porta-retalls", + "debug-mode": "Manera de depuració", + "enable-security": "Habilitar seguretat", + "enable-security-new": "Habiliteu la seguretat per les actualitzacions automàtiques de tokens", + "headers-filter": "Filtre d'encapçalats", + "header": "Encapçalats", + "no-headers-filter": "Cap filtre d'encapçalats", + "downlink-url": "URL d'enllaç descendent", + "downlink-url-required": "Cal URL d'enllaç descendent", + "create-loriot-output": "Creeu la sortida de l'aplicació Loriot", + "send-downlink": "Envia l'enllaç descendent", + "server": "Servidor", + "server-required": "Cal servidor", + "domain": "Domini", + "app-id": "ID de l'aplicació", + "app-id-required": "Cal ID de l'aplicació", + "app-token": "Token d'accés a l'aplicació", + "app-token-required": "Cal token d'accés a l'aplicació", + "email": "Correu electrònic", + "email-required": "Cal correu electrònic", + "application-uri": "Aplicació URI", + "as-id": "ID AS", + "as-id-required": "Cal ID AS.", + "as-key": "Clau AS", + "as-key-required": "Cal clau AS.", + "client-id-new": "Identificació del client", + "client-id-new-required": "Cal ID de client (inici de sessió)", + "client-secret": "Contrasenya del client", + "client-secret-required": "Cal el secret del client (contrasenya).", + "max-time-diff-in-seconds": "Màxima diferencia del temps (segons)", + "max-time-diff-in-seconds-required": "Diferencia màxima del temps és requerida.", + "created-time": "Temps creat", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "description": "Descripció", + "base-url": "URL base", + "base-url-required": "Cal URL base", + "security-key": "Clau de seguretat", + "http-endpoint": "URL del punt final HTTP", + "replace-no-content-to-ok": "Substitueix l'estat de resposta de 'Sense contingut' per 'D'acord'", + "copy-http-endpoint-url": "Copiar URL del punt final HTTP", + "http-endpoint-url-copied-message": "L'URL del punt final HTTP ha estat copiada al porta-retalls", + "host": "Host", + "host-required": "Cal Host.", + "host-type": "Tipus de Host", + "host-type-required": "Cal tipus de Host.", + "api-version": "Utilitzeu l'API v3", + "custom-host": "Host personalitzat", + "custom-host-required": "Cal Host personalitzat.", + "port": "Port", + "port-required": "Cal Port.", + "port-range": "El port ha d'estar en un rang entre 1 i 65535.", + "connect-timeout": "Temps d'espera de connexió (seg)", + "connect-timeout-required": "Cal temps d'espera de connexió.", + "connect-timeout-range": "Temps d'espera de connexió ha d'estar en un rang entre 1 i 200.", + "client-id": "ID del client", + "client-id-required": "Cal Client ID.", + "client-id-range": "L'identificador de client ha de tenir entre 1 i 23 caràcters. [MQTT-3.1.3-5]", + "client-id-pattern": "L'identificador de client ha de constar de números, lletres majúscules i minúscules. [MQTT-3.1.3-5]", + "client-id-hint": "Pista: opcional. Deixeu en blanc l'identificador de client generat automàticament. Aneu amb compte quan especifiqueu l'identificador de client. La majoria dels corredors de MQTT no permetran connexions múltiples amb el mateix identificador de client. Per connectar-se a aquests corredors, el vostre identificador de client mqtt ha de ser únic.", + "max-bytes-in-message": "Màxim bytes al missatge", + "max-bytes-in-message-range": "El màxim de bytes del missatge hauria d'estar en un interval d'1 a 256000000.", + "device-id": "ID del dispositiu", + "device-id-required": "Cal ID del dispositiu.", + "device-id-range": "L'identificador del dispositiu ha d'estar en un interval d'entre 1 i 65535 caràcters.", + "device-id-pattern": "L'identificador del dispositiu ha de constar de números, lletres majúscules i minúscules. [MQTT-3.1.3-5]", + "group-id": "ID del grup", + "group-id-required": "Cal ID del grup.", + "topics": "Temes", + "topics-required": "Cal temes.", + "routing-keys": "Claus d'encaminament", + "routing-keys-required": "Cal claus d'encaminament.", + "queues": "Cues", + "queues-required": "Cal el nom de la cua.", + "durable": "Durable", + "exclusive": "Exclusiu", + "autoDelete": "Supressió automàtica", + "exchange-name": "Nom d'intercanvi", + "exchange-name-required": "Cal nom d'intercanvi.", + "downlink-topic": "Tema d'enllaç descendent", + "connection-timeout": "Temps d'espera de connexió, ms", + "connection-timeout-min": "Valor de temps d'espera de connexió no vàlid.", + "handshake-timeout": "Temps d'espera de l'encaixada, ms", + "handshake-timeout-min": "El valor del temps d'espera de la connexió de mans no és vàlid.", + "virtual-host": "Amfitrió virtual", + "rabbit-mq-poll-interval": "Interval d'enquesta, ms", + "rabbit-mq-poll-interval-min": "El valor de l'interval d'enquesta no és vàlid.", + "application-server-url": "URL del servidor d'aplicacions", + "application-server-url-required": "Cal URL del servidor d'aplicacions.", + "application-server-api-token": "Token de l'API del servidor d'aplicacions", + "application-server-api-token-required": "Cal Token de l'API del servidor d'aplicacions.", + "bootstrap-servers": "Servidors d'arrencada", + "bootstrap-servers-required": "Cal servidors d'arrencada.", + "poll-interval": "Interval d'enquesta", + "poll-interval-required": "Cal interval d'enquesta.", + "auto-create-topics": "Creació automàtica de temes", + "clean-session": "Netejar sessió", + "enable-ssl": "Habilitar SSL", + "credentials": "Credencials", + "credentials-type": "Tipus de credencials", + "credentials-type-required": "Cal el tipus de credencials.", + "username": "Nom d'usuari", + "username-required": "Cal el nom d'usuari.", + "password": "Contrasenya", + "password-required": "Cal contrasenya és requerid.", + "azure-ca-cert": "Fitxer de certificat CA", + "ca-cert": "Arxiu de certificat CA *", + "private-key": "Arxiu de clau privat *", + "private-key-password": "Clau de contrasenya privada", + "cert": "Arxiu de certificat *", + "no-file": "Cap arxiu seleccionat.", + "drop-file": "Colocar un arxiu o fer clic per seleccionar un arxiu per carregar .", + "check-connection": "Comproveu la connexió", + "check-success": "Connexió establerta.", + "topic-filters": "Filtres de tema", + "remove-topic-filter": "Eliminar filtres de tema", + "add-topic-filter": "Afegir filtres de tema", + "add-topic-filter-prompt": "Per favor afegir filtres de tema", + "topic": "Tema", + "mqtt-qos": "QoS", + "mqtt-qos-at-most-once": "Com a màxim una vegada", + "mqtt-qos-at-least-once": "Almenys una vegada", + "mqtt-qos-exactly-once": "Exactamente una vegada", + "downlink-topic-pattern": "Patró de tema del enllaç descendent", + "downlink-topic-pattern-required": "Cal patró de tema del enllaç descendent.", + "aws-access-key-id": "Id. de la clau d'accés AWS", + "aws-secret-access-key": "Clau d'accés secreta d'AWS", + "aws-region": "Regió", + "aws-iot-endpoint": "Extrem AWS IoT", + "aws-iot-endpoint-required": "Cal extrem AWS IoT.", + "aws-iot-credentials": "Credencials AWS IoT", + "aws-sqs-polling-period-in-seconds": "Període de votació en segons", + "aws-sqs-queue-url": "URL de la cua SQS", + "aws-sqs-queue-url-required": "Cal URL de la cua SQS", + "aws-sqs-access-key-id-required": "Cal Id. de la clau d'accés", + "aws-sqs-secret-access-key-required": "Cal clau d'accés secreta", + "application-credentials": "Credencials d'aplicació", + "api-key": "Clau API", + "api-key-required": "Cal clau API.", + "api-key-format": "Format de clau d'API no vàlid.", + "auth-token": "Token d'Autenticació", + "auth-token-required": "Cal Token d'Autenticació.", + "region": "Regió", + "region-required": "Cal regió.", + "application-id": "ID d'aplicació", + "application-id-required": "Cal ID de Aplicació.", + "access-key": "Clau de Accés", + "access-key-required": "Cal clau de Accés.", + "connection-parameters": "Paràmetres de connexió", + "service-bus-namespace-name": "Espai de Noms del Bus de Serveis", + "service-bus-namespace-name-required": "Cal nom de l'espai de Noms del Bus de Serveis.", + "connection-string": "Cadena de connexió", + "consumer-group": "Grup de consumidors", + "connection-string-required": "Cal cadena de connexió!", + "event-hub-name": "Nom del Concentrador de Esdeveniments", + "event-hub-name-required": "Cal nom del Concentrador de Esdeveniments.", + "event-iot-hub-name-required": "El nom del concentrador Iot és necessari per a l'enllaç descendent", + "sas-key-name": "Nom de clau SAS", + "sas-key-name-required": "Cal nom de Clau SAS.", + "sas-key": "Clau SAS", + "sas-key-required": "Cal clau SAS.", + "iot-hub-name": "Nom del Concentrador IoT (Cal per enllaç descendent)", + "hostname": "Hostname", + "hostname-required": "Cal hostname", + "integration-clazz": "Classe d'integració", + "integration-clazz-required": "Classe d'integració", + "integration-configuration": "Configuració JSON d'integració", + "metadata": "Metadades", + "type": "Tipus", + "type-required": "Cal el tipus.", + "uplink-converter": "Convertidor de dades del enllaç ascendent", + "uplink-converter-required": "Cal convertidor de dades del enllaç ascendent.", + "downlink-converter": "Convertidor de dades del enllaç descendent", + "type-http": "HTTP", + "type-ocean-connect": "OceanConnect", + "type-sigfox": "SigFox", + "type-thingpark": "ThingPark", + "type-loriot": "Loriot", + "type-thingpark-enterprise": "ThingParkEnterprise", + "type-tmobile-iot-cdp": "iotcreators.com (T-Mobile – IoT CDP)", + "type-mqtt": "MQTT", + "type-aws-iot": "AWS IoT", + "type-aws-sqs": "AWS SQS", + "type-aws-kinesis": "AWS Kinesis", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ttn": "The Things Stack Community", + "type-tti": "The Things Stack Industries", + "type-chirpstack": "ChirpStack", + "type-azure-event-hub": "Azure Event Hub", + "type-azure-iot-hub": "Azure IoT Hub", + "type-opc-ua": "OPC-UA", + "type-custom": "Custom", + "type-coap": "CoAP", + "type-udp": "UDP", + "type-tcp": "TCP", + "type-kafka": "Kafka", + "type-rabbitmq": "RabbitMQ", + "type-pubsub": "Pub/Sub", + "opc-ua-application-name": "Nom d'aplicació", + "opc-ua-application-uri": "Aplicació URI", + "opc-ua-scan-period-in-seconds": "Període d'exploració en segons", + "opc-ua-scan-period-in-seconds-required": "Cal període d'exploració", + "opc-ua-timeout": "Temps d'espera en mil·lisegons", + "opc-ua-timeout-required": "Cal temps d'espera", + "opc-ua-security": "Seguretat", + "opc-ua-security-required": "Cal seguretat", + "opc-ua-identity": "Identitat", + "opc-ua-identity-required": "Cal identitat", + "opc-ua-keystore": "Repositori", + "add-opc-ua-keystore-prompt": "Per favor afegir arxiu del repositori", + "opc-ua-keystore-required": "Cal repositori", + "opc-ua-type": "Tipus", + "opc-ua-keystore-type": "Tipus", + "opc-ua-keystore-type-required": "Cal tipus", + "opc-ua-keystore-location": "Ubicació *", + "opc-ua-keystore-password": "Contrasenya", + "opc-ua-keystore-password-required": "Cal contrasenya", + "opc-ua-keystore-alias": "Àlies", + "opc-ua-keystore-alias-required": "Cal Àlies", + "opc-ua-keystore-key-password": "Clau de contrasenya", + "opc-ua-keystore-key-password-required": "Cal clau de contrasenya", + "opc-ua-mapping": "Mapeig", + "add-opc-ua-mapping-prompt": "Per favor afegir mapatge", + "opc-ua-mapping-type": "Tipus de mapatge", + "opc-ua-mapping-type-required": "Cal Tipus de mapatge", + "opc-ua-device-node-pattern": "Patró de Node del Dispositiu", + "opc-ua-device-node-pattern-required": "Cal Patró de Node del Dispositiu", + "opc-ua-namespace": "Namespace", + "opc-ua-add-map": "Afegir element de mapatge", + "kinesis-stream-name": "Nom del flux", + "kinesis-stream-name-required": "Cal nom del flux", + "kinesis-region": "Regió", + "kinesis-region-required": "Cal Regió", + "kinesis-access-key-id": "Id. de la clau d'accés", + "kinesis-access-key-id-required": "Cal Id. de la clau d'accés", + "kinesis-secret-access-key": "Clau d'accés secreta", + "kinesis-secret-access-key-required": "Cal clau d'accés secreta", + "kinesis-use-consumers-with-enhanced-fan-out": "Utilitzeu consumidors amb Fan-Out millorat", + "kinesis-use-credentials-from-instance-metadata": "Utilitzeu les credencials de l'Amazon EC2 Instance Metadata Service", + "kinesis-application-name": "Nom de l'aplicació (by default equals Stream name)", + "kinesis-initial-position-in-stream": "Posició inicial al corrent", + "kinesis-initial-position-in-stream-required": "Cal posició inicial al corrent", + "kinesis-max-records": "Màxim registres", + "kinesis-max-records-required": "Cal màxim registres", + "kinesis-max-records-length-range": "La longitud màxima dels registres hauria d'estar entre 1 i 10.000", + "kinesis-request-timeout": "Sol·licita el temps d'espera en segons", + "kinesis-request-timeout-required": "Cal el temps d'espera de la sol·licitud", + "other-properties": "Altres propietats", + "subscription-tags": "Etiquetes de subscripció", + "remove-subscription-tag": "Eliminar etiqueta de subscripció", + "add-subscription-tag": "Afegir etiqueta de suscripció", + "add-subscription-tag-prompt": "Per favor afegir etiqueta de suscripció", + "key": "Clau", + "path": "Trajecte", + "required": "Requerit", + "integration-key": "Clau d'integració", + "copy-integration-key": "Copiar clau d'integració", + "integration-key-copied-message": "La clau d'integració s'ha copiat al porta-retalls", + "integration-secret": "Secret d'integració", + "copy-integration-secret": "Copiar Secret d'integració", + "integration-secret-copied-message": "El secret d'integració s'ha copiat al porta-retalls", + "execute-remotely": "Executar a distància", + "handler-configuration": "Configuració del gestor", + "handler-configuration-type": "Tipus de comerciant", + "so-broadcast": "Habilita la difusió: la integració acceptarà paquets d'adreces de difusió", + "so-keepalive-option": "Habiliteu l'enviament de missatges de manteniment en els sòcols orientats a la connexió", + "so-reuse-addr": "Enllaçar el procés a un port", + "tcp-no-delay": "Força un sòcol a enviar les dades sense emmagatzemar la memòria intermèdia (desactiva l'algoritme de memòria intermèdia de Nagle)", + "fail-fast": "L'excepció llançada tan bon punt el descodificador noti que la longitud del fotograma superarà la mida màxima", + "strip-delimiter": "Delimitador de franges", + "length-field-offset": "Desplaçament de camp de longitud", + "length-field-offset-required": "Cal desplaçament de camp de longitud.", + "length-field-offset-range": "El desplaçament del camp de longitud ha d'estar en un interval de 0 a 8.", + "length-field-length": "Longitud Longitud del camp", + "length-field-length-required": "La longitud del camp és necessària.", + "length-field-length-range": "La longitud del camp ha d'estar en un interval de 0 a 8.", + "length-adjustment": "Ajust de longitud (el valor de compensació que cal afegir al valor del camp de longitud)", + "length-adjustment-required": "Cal un ajust de longitud.", + "length-adjustment-range": "L'ajust de longitud ha d'estar en un rang de 0 a 8.", + "byte-order": "Ordre de bytes del camp de longitud", + "initial-bytes-to-strip": "Nombre de primers bytes per eliminar del marc descodificat", + "initial-bytes-to-strip-required": "Cal el nombre de primers bytes per treure de la trama descodificada.", + "initial-bytes-to-strip-range": "El nombre de primers bytes per eliminar del marc descodificat hauria d'estar en un interval de 0 a 8.", + "so-backlog-option": "Nombre màxim de connexions pendents a la presa", + "so-backlog-option-required": "Cal el nombre màxim de connexions pendents al sòcol.", + "so-backlog-option-range": "El nombre màxim de connexions pendents a l'endoll hauria d'estar entre 1 i 65535.", + "so-rcv-buf": "Mida de la memòria intermèdia per al sòcol d'entrada (en KB)", + "so-rcv-buf-required": "La mida de la memòria intermèdia per al sòcol d'entrada (en KB) és necessària.", + "so-rcv-buf-range": "La mida de la memòria intermèdia per al sòcol d'entrada (en KB) hauria d'estar en un interval d'1 a 65535.", + "so-snd-buf": "Mida de la memòria intermèdia per al sòcol de sortida (en KB)", + "so-snd-buf-required": "Cal la mida de la memòria intermèdia per al sòcol de sortida (en KB).", + "so-snd-buf-range": "La mida de la memòria intermèdia per al sòcol de sortida (en KB) hauria d'estar en un interval d'1 a 65535.", + "charset-name": "Nom del conjunt de caràcters", + "charset-name-required": "Cal nom del conjunt de caràcters.", + "message-separator": "Separador de missatges", + "message-separator-required": "Cal separador de missatges.", + "character-sequence": "Seqüència de caràcters", + "character-sequence-required": "Cal seqüència de caràcters.", + "max-frame-length": "Longitud màxima del fotograma (en bytes)", + "max-frame-length-required": "Cal longitud màxima del fotograma (en bytes).", + "max-frame-length-range": "La longitud màxima del fotograma (en bytes) hauria d'estar en un interval d'1 a 65535.", + "handler-type": "Tipus de comerciant", + "message-size": "Mida del missatge", + "message-size-required": "La mida del missatge és necessària.", + "type-apache-pulsar": "Apache Pulsar", + "service-url": "URL del servei", + "service-url-required": "Cal URL del servei.", + "subscription-name": "Nom de la subscripció", + "subscription-name-required": "Cal nom de la subscripció.", + "max-num-messages": "Nombre màxim de missatges", + "max-num-messages-required": "Cal nombre màxim de missatges.", + "max-num-bytes": "Nombre màxim de bytes", + "max-num-bytes-required": "Cal nombre màxim de bytes.", + "timeout-in-ms": "Temps d'espera en mil·lisegons", + "timeout-in-ms-required": "Temps d'espera en mil·lisegons.", + "user-id": "ID d'usuari", + "user-id-required": "Cal ID d'usuari.", + "token": "Token", + "token-required": "Cal Token.", + "project-id": "ID del projecte", + "project-id-required": "Cal ID del projecte.", + "subscription-id": "Identificador de subscripció", + "subscription-id-required": "Cal identificador de subscripció.", + "service-account-key": "Fitxer de claus del compte de servei", + "service-account-key-required": "Cal fitxer de claus del compte de servei.", + "tcp": { + "system-line-separator": "Separador de línies del sistema", + "nul-delimiter": "Delimitador nul", + "byte-order-little-endian": "Petit Endian", + "byte-order-big-endian": "Big Endian" + }, + "cache-size": "Mida de la memòria cau", + "cache-time-to-live": "Temps de la memòria cau per viure en minuts", + "min-cache-size": "La mida de la memòria cau no pot ser inferior a 0", + "min-cache-time-to-live": "El temps de vida de la memòria cau no pot ser inferior a 0", + "max-cache-time-to-live": "El temps de la memòria cau en directe no és vàlid, seleccioneu entre 0 i 525600", + "coap-security-mode": "Mode de seguretat", + "coap-security-mode-required": "Cal el mode de seguretat CoAP", + "coap-security-mode-no-secure": "NO SEGUR", + "coap-security-mode-dtls": "DTLS", + "coap-security-mode-mixed": "MIXTES", + "coap-endpoint": "URL del punt final CoAP", + "coap-endpoint-url-copied-message": "L'URL del punt final CoAP s'ha copiat al porta-retalls", + "copy-coap-endpoint-url": "Copia l'URL del punt final CoAP", + "copy-coap-dtls-endpoint-url": "Copia l'URL del punt final de CoAP DTLS", + "coap-dtls-base-url": "URL base DTLS", + "coap-dtls-base-url-required": "Cal URL base DTLS", + "coap-dtls-endpoint": "URL del punt final de CoAP DTLS", + "coap-dtls-endpoint-url-copied-message": "L'URL del punt final de CoAP DTLS s'ha copiat al porta-retalls", + "type-ffb": "FFB" + }, + "item": { + "selected": "Seleccionat" + }, + "js-func": { + "no-return-error": "La funció ha de retornar un valor!", + "return-type-mismatch": "La funció ha de retornar un valor de tipus: '{{type}}'!", + "tidy": "Endreçat", + "mini": "Mini" + }, + "key-val": { + "key": "Clau", + "value": "Valor", + "remove-entry": "Esborrar entrada", + "add-entry": "Afegir entrada", + "no-data": "Sense dades" + }, + "layout": { + "layout": "Disseny", + "manage": "Administrar Dissenys", + "settings": "Configuració de disseny", + "color": "Color", + "main": "Principal", + "right": "Dret", + "select": "Seleccionar Disseny de destí" + }, + "legend": { + "direction": "Direcció de la llegenda", + "position": "Posició de la llegenda", + "sort-legend": "Ordenar claus en llegenda", + "show-max": "Mostrar valor màxim", + "show-min": "Mostrar valor mínim", + "show-avg": "Mostrar valor mitjà", + "show-total": "Mostrar valor total", + "settings": "Configuració de la llegenda", + "min": "mínim", + "max": "màxim", + "avg": "mitjana", + "total": "total", + "comparison-time-ago": { + "previousInterval": "(interval anterior)", + "customInterval": "(interval personalitzat)", + "days": "(Fa un dia)", + "weeks": "(Fa una setmana)", + "months": "(Fa un mes)", + "years": "(Fa un any)" + } + }, + "login": { + "login": "Entrar", + "request-password-reset": "Sol·licitar restablir contrasenya", + "reset-password": "Restablir contrasenya", + "create-password": "Crear contrasenya", + "passwords-mismatch-error": "¡Les contraseñas introduïdes han de ser iguals!", + "password-again": "Repetiu la contrasenya de nou", + "sign-in": "Per favor, inicieu sessió", + "username": "Nom d'usuari (correu electrònic)", + "remember-me": "Recordar-me", + "forgot-password": "Ha oblidat la contrasenya?", + "password-reset": "Restablir contrasenya", + "expired-password-reset-message": "Les teves credencials han expirat! Si us plau, crea una nova contrasenya.", + "new-password": "Nova contrasenya", + "new-password-again": "Repetiu la nova contrasenya", + "password-link-sent-message": "S'ha enviat el enllaç de restabliment de contrasenya amb èxit!", + "email": "Correu electrònic", + "no-account": "No té un compte?", + "create-account": "Crear un compte", + "login-with": "Iniciar sessió amb {{name}}", + "or": "o", + "error": "Error d'inici de sessió", + "verify-your-identity": "Verificar identitat", + "select-way-to-verify": "Seleccioneu el mode de verificació", + "resend-code": "Reenviar codi", + "resend-code-wait": "Reenviar codi a { time, plural, 1 {1 segundo} other {# segundos} }", + "try-another-way": "Altres modes de verificació", + "totp-auth-description": "Si us plau, introduïu el codi de seguretat de la vostra aplicació d'autenticació.", + "totp-auth-placeholder": "Codi", + "sms-auth-description": "S'ha enviat un codi de verificació al vostre número: {{contact}} proporcionat.", + "sms-auth-placeholder": "Codi SMS", + "email-auth-description": "S'ha enviat un codi de verificació del correu electrònic {{contact}} proporcionat.", + "email-auth-placeholder": "Codi correu electrònic", + "backup-code-auth-description": "Por favor, introduce el código de backup.", + "backup-code-auth-placeholder": "Codi de Backup" + }, + "signup": { + "firstname": "Nom", + "lastname": "Cognom", + "email": "Correu electrònic", + "signup": "Registra't", + "create-password": "Crea una contrassenya", + "repeat-password": "Repetiu la vostra contrasenya", + "have-account": "Ja tens un compte?", + "signin": "Inicia sessió", + "no-captcha-message": "Heu de confirmar que no sou un robot", + "password-length-message": "La vostra contrasenya ha de tenir almenys 6 caràcters", + "email-verification": "Correu electrònic verificació", + "email-verification-message": "S'ha enviat un correu electrònic amb les dades de verificació a l'adreça electrònica especificada.
    Seguiu les instruccions proporcionades al correu electrònic per completar el procediment d'inscripció.
    Nota: si fa temps que no consulteu el correu electrònic, si us plau comproveu la vostra carpeta 'spam' o proveu de tornar a enviar el correu electrònic fent clic al botó 'Torna a enviar'.", + "account-activation-title": "Activació del compte", + "account-activated": "El compte s'ha activat correctament!", + "account-activated-text": "Enhorabona!
    El vostre compte s'ha activat.", + "resend": "Reenviar", + "inactive-user-exists-title": "L'usuari inactiu ja existeix", + "inactive-user-exists-text": "Ja hi ha un usuari registrat amb una adreça electrònica no verificada.
    Feu clic al botó 'Torna a enviar' si voleu tornar a enviar el correu electrònic de verificació..", + "activating-account": "S'està activant el compte...", + "activating-account-text": "El teu compte s'està activant. Si us plau, espereu...", + "accept-privacy-policy": "Acceptar la política de privadesa", + "accept": "Acceptar", + "privacy-policy": "Política de privacitat", + "accept-privacy-policy-message": "Heu d'acceptar la nostra Política de privadesa", + "recaptcha-title": "reCAPTCHA", + "terms-of-use": "Condicions d'ús", + "accept-terms-of-use-message": "Heu d'acceptar les nostres Condicions d'ús" + }, + "ota-update": { + "add": "Afegeix el paquet", + "assign-firmware": "Firmware assignat", + "assign-firmware-required": "Cal firmware assignat", + "assign-software": "Programari assignat", + "assign-software-required": "Cal programari assignat", + "auto-generate-checksum": "Suma de comprovació generada automàticament", + "cant-applied-group-all": "No es pot sol·licitar per al grup Tots", + "checksum": "Suma de control", + "checksum-hint": "Si la suma de comprovació està buida, es generarà automàticament", + "checksum-algorithm": "Algorisme de suma de control", + "checksum-copied-message": "La suma de comprovació del paquet s'ha copiat al porta-retalls", + "change-firmware": "El canvi del firmware pot provocar l'actualització { count, plural, 1 {1 device} other {# devices} }.", + "change-software": "El canvi del programari pot provocar l'actualització { count, plural, 1 {1 device} other {# devices} }.", + "chose-compatible-device-profile": "El paquet penjat només estarà disponible per als dispositius amb el perfil escollit.", + "chose-firmware-distributed-device": "Trieu el firmware que es distribuirà als dispositius", + "chose-software-distributed-device": "Trieu el programari que es distribuirà als dispositius", + "content-type": "Tipus de contingut", + "copy-checksum": "Copia la suma de verificació", + "copy-direct-url": "Copia l'URL directe", + "copyId": "Copia l'identificador del paquet", + "copied": "Copiat!", + "delete": "Esborra el paquet", + "delete-ota-update-text": "Aneu amb compte, després de la confirmació, l'actualització OTA serà irrecuperable.", + "delete-ota-update-title": "Confirmes que vols suprimir l'actualització de l'OTA '{{title}}'?", + "delete-ota-updates-text": "Aneu amb compte, després de la confirmació, totes les actualitzacions OTA seleccionades s'eliminaran.", + "delete-ota-updates-title": "Esteu segur que voleu suprimir { count, plural, 1 {1 OTA update} other {# OTA updates} }?", + "description": "Descripció", + "direct-url": "URL directe", + "direct-url-copied-message": "L'URL directe del paquet s'ha copiat al porta-retalls", + "direct-url-required": "Cal un URL directe", + "download": "Descarrega el paquet", + "drop-file": "Deixeu anar un fitxer de paquet o feu clic per seleccionar un fitxer per carregar.", + "file-name": "Nom de l'arxiu", + "file-size": "Mida de l'arxiu", + "file-size-bytes": "Mida del fitxer en bytes", + "idCopiedMessage": "L'identificador del paquet s'ha copiat al porta-retalls", + "no-firmware-matching": "No coincideix cap paquet d'actualització de firmware OTA compatible '{{entity}}' els van trobar.", + "no-firmware-text": "No s'ha subministrat cap paquet d'actualització de firmware OTA compatible.", + "no-packages-text": "No s'han trobat paquets", + "no-software-matching": "No coincideix cap paquet d'actualització de programari OTA compatible '{{entity}}' els van trobar.", + "no-software-text": "No s'ha subministrat cap paquet d'actualització de programari OTA compatible.", + "ota-update": "Actualització OTA", + "ota-update-details": "Detalls actualització OTA", + "ota-updates": "Actualitzacions de l'OTA", + "package-type": "Tipus de paquet", + "packages-repository": "Repositori de paquets", + "search": "Cerca paquets", + "selected-package": "{ count, plural, 1 {1 package} other {# packages} } seleccionat", + "title": "Títol", + "title-required": "Cal títol.", + "title-max-length": "El títol ha de ser inferior a 256", + "types": { + "firmware": "Firmware", + "software": "Programari" + }, + "upload-binary-file": "Carregueu un fitxer binari", + "use-external-url": "Utilitzeu un URL extern", + "version": "Versió", + "version-required": "Cal versió.", + "version-tag": "Etiqueta de versió", + "version-tag-hint": "L'etiqueta personalitzada hauria de coincidir amb la versió del paquet informada pel vostre dispositiu.", + "version-max-length": "La versió hauria de ser inferior a 256", + "warning-after-save-no-edit": "Un cop carregat el paquet, no podreu modificar el títol, la versió, el perfil del dispositiu i el tipus de paquet." + }, + "position": { + "top": "Superior", + "bottom": "Inferior", + "left": "Esquerra", + "right": "Dreta" + }, + "profile": { + "profile": "Perfil", + "last-login-time": "Darrer accés", + "change-password": "Canviar contrasenya", + "current-password": "Contrasenya actual", + "copy-jwt-token": "Copia el testimoni JWT", + "jwt-token": "Tóken JWT", + "valid-till": "Vàlid per a {{expirationData}}", + "tokenCopiedSuccessMessage": "El testimoni JWT s'ha copiat al porta-retalls", + "tokenCopiedWarnMessage": "El testimoni JWT ha caducat! Si us plau, actualitzeu la pàgina." + }, + "security": { + "security": "Seguretat", + "2fa": { + "2fa": "Autenticació de doble factor (2FA)", + "2fa-description": "L'autenticació de dos factors protegeix el vostre compte de l'accés no autoritzat. Tot el que heu de fer és introduir un codi de seguretat en iniciar sessió.", + "authenticate-with": "Pots autenticarte amb:", + "disable-2fa-provider-text": "Desactivar {{name}} farà el teu compte menys segur", + "disable-2fa-provider-title": "Estàs segur de desactivar {{name}}?", + "get-new-code": "Obtenir un nou codi", + "main-2fa-method": "Usar com a mètode d'autenticació de doble factor principal", + "dialog": { + "activation-step-description-email": "La propera vegada que facis log-in, caldrà introduir el codi de seguretat que s'enviarà a la teva adreça de correu electrònic.", + "activation-step-description-sms": "La propera vegada que feu log-in, caldrà introduir el codi de seguretat que s'enviarà al vostre número de telèfon.", + "activation-step-description-totp": "La propera vegada que facis log-in, caldrà introduir el codi de seguretat de doble factor.", + "activation-step-label": "Activació", + "backup-code-description": "Imprimeix els codis i guarda'ls en un lloc segur, es necessitaran per fer login al teu compte. Pots utilitzar cada codi de còpia de seguretat només una vegada.", + "backup-code-warn": "Quan surtis d'aquesta pàgina, aquests codis no es tornaran a mostrar. Desa'ls en un lloc segur usant les opcions a continuació.", + "download-txt": "Descarregar (txt)", + "email-step-description": "Introdueix el teu correu electrònic per utilitzar com a autenticador.", + "email-step-label": "Correu electrònic", + "enable-email-title": "Activar autenticador per correu electrònic", + "enable-sms-title": "Activar autenticador per SMS", + "enable-totp-title": "Activar autenticador per app", + "enter-verification-code": "Entra el codi de 6-dígits", + "get-backup-code-title": "Obtenir còpia de seguretat", + "next": "Següent", + "scan-qr-code": "Escaneja el codi QR amb la teva aplicació d'autenticació", + "send-code": "Enviar codi", + "sms-step-description": "Introdueix el teu número de telèfon per utilitzar com a autenticador.", + "sms-step-label": "Número de telèfon", + "success": "Èxit!", + "totp-step-description-install": "Podeu instal·lar l'aplicació Google Authenticator, Authy, o Duo.", + "totp-step-description-open": "Obre l'aplicació d'autenticació al telèfon mòbil.", + "totp-step-label": "Obtenir app", + "verification-code": "Codi de 6-dígits", + "verification-code-invalid": "Formát de codi invàlid", + "verification-code-incorrect": "El codi de verificació és incorrecte", + "verification-code-many-request": "Massa sol·licituds, revisa el codi de verificació", + "verification-step-description": "Introdueix el codi de 6-dígits que acabem d'enviar a {{address}}", + "verification-step-label": "Verificació" + }, + "provider": { + "email": "Correu electrònic", + "email-description": "Usar un codi de seguretat enviat al teu email per autenticar-te.", + "email-hint": "Els codis d'autenticació s'han enviat per correu electrònic a {{ info }}", + "sms": "SMS", + "sms-description": "Usar el teu telèfon per autenticar-te. Enviarem un codi de seguretat via SMS.", + "sms-hint": "Els codis d'autenticació s'han enviat per missatge de text SMS a {{ info }}", + "totp": "App d'autenticació", + "totp-description": "Utilitzeu aplicacions com Google Authenticator, Authy o Duo al vostre telèfon per autenticar-vos. Es generarà un codi de seguretat per iniciar sessió.", + "totp-hint": "L'aplicació d'autenticació s'ha configurat al vostre compte", + "backup_code": "Codi Backup", + "backup-code-description": "Aquests codis de seguretat imprimibles són d'un sol ús, et permeten identificar quan no tinguis el telèfon a mà, útil quan es viatja.", + "backup-code-hint": "{{ info }} codis d'un sol ús actius en aquest moment" + } + }, + "password-requirement": { + "at-least": "Al menys:", + "character": "{ count, plural, 1 {1 caracter} other {# caracteres} }", + "digit": "{ count, plural, 1 {1 dígito} other {# dígitos} }", + "incorrect-password-try-again": "Contrasenya incorrecta. Prova una altra vegada", + "lowercase-letter": "{ count, plural, 1 {1 minúscula} other {# mínusculas} }", + "new-passwords-not-match": "Les contrasenyes no coincideixen", + "password-should-not-contain-spaces": "La contrasenya no pot contenir espais", + "password-not-meet-requirements": "La contrasenya no reuneix els requisits necessaris", + "password-requirements": "Requisits de contrasenya", + "password-should-difference": "La nova contrasenya ha de ser diferent de l'actual", + "special-character": "{ count, plural, 1 {1 caracter especial} other {# caracteres especiales} }", + "uppercase-letter": "{ count, plural, 1 {1 mayúscula} other {# mayúsculas} }" + } + }, + "relation": { + "relations": "Relacions", + "direction": "Direcció", + "search-direction": { + "FROM": "Des de", + "TO": "Cap a" + }, + "direction-type": { + "FROM": "des de", + "TO": "cap a" + }, + "from-relations": "Relacions salientes (outbound)", + "to-relations": "Relacions entrantes (inbound)", + "selected-relations": "{ count, plural, 1 {1 relación} other {# relaciones} } seleccionades", + "type": "Tipus", + "to-entity-type": "Cap a tipus d'entitat", + "to-entity-name": "Cap a nom d'entitat", + "from-entity-type": "Des de tipus d'entitat", + "from-entity-name": "Des de nom d'entitat", + "to-entity": "Cap a entitat", + "from-entity": "Des d'entitat", + "delete": "Esborrar relació", + "relation-type": "Tipus de relació", + "relation-type-required": "Cal tipus de relació.", + "relation-type-max-length": "El tipus de relació ha de ser inferior a 256", + "any-relation-type": "Qualsevol tipus", + "add": "Afegir relació", + "edit": "Editar relació", + "view": "Veure relació", + "delete-to-relation-title": "Vols eliminar la relació amb l'entitat '{{entityName}}'?", + "delete-to-relation-text": "Atenció, després de la confirmació l'entitat '{{entityName}}' no estarà relacionada amb l'entitat actual.", + "delete-to-relations-title": "Vols eliminar { count, plural, 1 {1 relación} other {# relaciones} }?", + "delete-to-relations-text": "Atenció, després de la confirmació totes les relacions seleccionades s'eliminaran i les seves entitats corresponents no estaran relacionades amb l'entitat actual.", + "delete-from-relation-title": "Vols eliminar la relació amb l'entitat '{{entityName}}'?", + "delete-from-relation-text": "Atenció, després de la confirmació l'entitat actual no estarà relacionada amb l'entitat '{{entityName}}'.", + "delete-from-relations-title": "Vols eliminar { count, plural, 1 {1 relación} other {# relaciones} }?", + "delete-from-relations-text": "Atenció, després de la confirmació totes les relacions seleccionades s'eliminaran i les seves entitats corresponents no estaran relacionades amb les entitats corresponents.", + "remove-relation-filter": "Treure filtre de relació", + "add-relation-filter": "Afegir filtre de relació", + "any-relation": "Qualsevol relació", + "relation-filters": "Filtre de relació", + "additional-info": "Informació adicional (JSON)", + "invalid-additional-info": "Error al analitzar el fitxer JSON de informació adicional.", + "no-relations-text": "No s'han trobar relacions" + }, + "resource": { + "add": "Afegeix un recurs", + "copyId": "Copia l'identificador del recurs", + "delete": "Suprimeix el recurs", + "delete-resource-text": "Aneu amb compte, després de la confirmació el recurs es tornarà irrecuperable.", + "delete-resource-title": "Esteu segur que voleu suprimir el recurs '{{resourceTitle}}'?", + "delete-resources-action-title": "Suprimeix { count, plural, 1 {1 resource} other {# resources} }", + "delete-resources-text": "Tingueu en compte que els recursos seleccionats, encara que s'utilitzin en perfils de dispositius, se suprimiran.", + "delete-resources-title": "Esteu segur que voleu suprimir { count, plural, 1 {1 resource} other {# resources} }?", + "download": "Descarrega el recurs", + "drop-file": "Deixeu anar un fitxer de recursos o feu clic per seleccionar un fitxer per carregar.", + "drop-resource-file-or": "Arrossegar i deixar anar un fitxer o", + "empty": "El recurs està buit", + "file-name": "Nom de l'arxiu", + "idCopiedMessage": "L'identificador del recurs s'ha copiat al porta-retalls", + "no-resource-matching": "No hi ha coincidència de recursos '{{widgetsBundle}}' els van trobar.", + "no-resource-text": "No s'han trobat recursos", + "open-widgets-bundle": "Obre el paquet de ginys", + "resource": "Recurs", + "resource-library-details": "Detalls de recurs", + "resource-type": "Tipus de recurs", + "resources-library": "Biblioteca de recursos", + "search": "Cerca recursos", + "selected-resources": "{ count, plural, 1 {1 resource} other {# resources} } seleccionat", + "system": "Sistema", + "title": "Títol", + "title-required": "Cal títol.", + "title-max-length": "El títol ha de ser inferior a 256" + }, + "rulechain": { + "rulechain": "Cadena de Regla", + "rulechains": "Cadenes de Regles", + "root": "Arrel", + "delete": "Esborrar cadena de regles", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "description": "Descripció", + "add": "Afegir Cadena", + "set-root": "Fer la cadena de regles arrel", + "set-root-rulechain-title": "Voleu fer la cadena de regles '{{ruleChainName}}' de tipus arrel?", + "set-root-rulechain-text": "Després de la confirmació, la cadena de regles es tornarà arrel i manejarà tots els missatges de transport entrants.", + "delete-rulechain-title": "Vols eliminar la cadena de regles '{{ruleChainName}}'?", + "delete-rulechain-text": "Atenció, després de la confirmació la cadena de regles i totes les dades seran irrecuperables.", + "delete-rulechains-title": "Està segur que vols eliminar { count, plural, 1 {1 cadena de regles} other {# cadenes de regles} }?", + "delete-rulechains-action-title": "Eliminar { count, plural, 1 {1 cadena de regles} other {# cadenes de regles} }", + "delete-rulechains-text": "Atenció, després de la confirmació totes les cadena de regles seleccionades i totes les dades seran irrecuperables.", + "add-rulechain-text": "Afegir nova cadena de regles", + "no-rulechains-text": "Cadenes de regles no trobades", + "rulechain-details": "Detalls de la cadena de regles", + "details": "Detalls", + "events": "Esdeveniments", + "system": "Sistema", + "import": "Importar cadena de regles", + "export": "Exportar cadena de regles", + "export-failed-error": "No es pot exportar la cadena de regles: {{error}}", + "create-new-rulechain": "Crear nova cadena de regles", + "rulechain-file": "Fitxer de cadena de regles", + "invalid-rulechain-file-error": "No es pot importar la cadena de regles: Estructura de dades de la cadena de regles invàlida.", + "copyId": "Copiar ID de la cadena de regles", + "idCopiedMessage": "ID de la cadena de regles ha estat copiada al porta-retalls", + "select-rulechain": "Seleccionar cadena de regles", + "no-rulechains-matching": "No s'han trobar cadenes de regles que coincideixin amb '{{entity}}' .", + "rulechain-required": "Cal cadena de regles", + "management": "Gestió de regles", + "debug-mode": "Manera de Depuració", + "search": "Buscar cadenes de regles", + "selected-rulechains": "{ count, plural, 1 {1 cadena de regles} other {# cadenes de regles} } seleccionades", + "open-rulechain": "Obrir cadena de regles", + "edge-template-root": "Arrel de plantilla", + "assign-to-edge": "Assignar a Edge", + "edge-rulechain": "Cadena de regla de vora", + "unassign-rulechain-from-edge-text": "Després de la confirmació, la cadena de regles quedará sense assignar i la vora no podrà accedir a ella", + "unassign-rulechains-from-edge-title": "Esteu segur que voleu anul·lar l'assignació { count, plural, 1 {1 rulechain} other {# rulechains} }?", + "unassign-rulechains-from-edge-text": "Després de la confirmació, totes les cadenes de regles seleccionades quedaran sense assignar i la vora no podrà accedir a ellas", + "assign-rulechain-to-edge-title": "Assignar cadena (s) de regles a vora", + "assign-rulechain-to-edge-text": "Selecciona les cadenes de regles per assignar a la vora", + "set-edge-template-root-rulechain": "Fer arrel de plantilla de vora de cadena de regles", + "set-edge-tscheduler-eventemplate-root-rulechain": "Feu una cadena de regles com a arrel de plantilla de vora", + "set-edge-template-root-rulechain-title": "Està segur de que desitja que la cadena de regles '{{ruleChainName}}' sigui l'arrel de la plantilla de vora?", + "set-edge-template-root-rulechain-text": "Després de la confirmació, la cadena de regles es convertirá en l'arrel de la plantilla de vora i serà la cadena de regles arrel per les vores recent creats.", + "invalid-rulechain-type-error": "No es pot importar la cadena de regles: Tipus de cadena de regles no vàlid. El tipus esperat és {{expectedRuleChainType}}", + "set-auto-assign-to-edge": "Assignar cadena de regles a les vores en la creació", + "set-auto-assign-to-edge-title": "Està segur de que desitja assignar automàticament la cadena de regles de vora '{{ruleChainName}}' a les vores en la creació?", + "set-auto-assign-to-edge-text": "Després de la confirmació, la cadena de regles de vora s'assignarà automàticament a les vores en la creació.", + "unset-auto-assign-to-edge": "Desmarcar assignar cadena de regles a les vores en la creació", + "unset-auto-assign-to-edge-title": "Està segur de que desitja anul·lar l'assignació de la cadena de regles de vora '{{ruleChainName}}' a les vores en la creació?", + "unset-auto-assign-to-edge-text": "Després de la confirmació, la cadena de regles de vora ja no s'assignarà automàticament a les vores en la creació.", + "unassign-rulechain-title": "Està segur de que desitja desassignar la cadena de regles '{{ruleChainName}}'?", + "unassign-rulechains": "Anul·lar assignació de cadenes de regles", + "assign-rulechains": "Assignar cadenes de regles", + "delete-rulechains": "Eliminar cadenes de regles", + "unassign-rulechain": "Anul·lar assignació de cadena de regles", + "unassign-rulechains-from-edge-action-title": "Anul·lar assignació {count, plural, 1 {1 cadena de regles} other {# cadenes de regles} } des vores", + "assign-new-rulechain": "Assignar nova cadena de regles" + }, + "rulenode": { + "details": "Detalls", + "events": "Esdeveniments", + "search": "Buscar nodes", + "open-node-library": "Obrir llibreria de nodes", + "add": "Afegir node de regles", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "type": "Tipus", + "description": "Descripció", + "delete": "Eliminar node de regles", + "select-all-objects": "Seleccionar tots els nodes i conexions", + "deselect-all-objects": "Desfer selecció de tots els nodes i conexions", + "delete-selected-objects": "Eliminar nodes i conexions seleccionats", + "delete-selected": "Eliminar seleccionat", + "create-nested-rulechain": "Crea una cadena de regles imbricades", + "select-all": "Seleccionar tots", + "copy-selected": "Copiar seleccionat", + "deselect-all": "Desfer selecció de tots", + "rulenode-details": "Detalls del node de regles", + "debug-mode": "Manera Depuració", + "configuration": "Configuració", + "link": "Enllaç", + "link-details": "Detalls del enllaç del node de regles", + "add-link": "Afegir enllaç", + "link-label": "Etiqueta del enllaç", + "link-label-required": "Cal l'etiqueta del enllaç.", + "custom-link-label": "Etiqueta del enllaç personalitzada", + "custom-link-label-required": "Cal l'etiqueta del enllaç personalitzat.", + "link-labels": "Etiquetes del enllaç", + "link-labels-required": "Cal les etiquetes del enllaç.", + "no-link-labels-found": "Etiquetes d'enllaços no trobades", + "no-link-label-matching": "'{{label}}' no trobada.", + "create-new-link-label": "Crear una nova!", + "type-filter": "Filtre", + "type-filter-details": "Filtrar missatges entrantes amb les condicions configurades", + "type-enrichment": "Enriquiment", + "type-enrichment-details": "Afegir informació adicional en missatges de metadades", + "type-transformation": "Transformació", + "type-transformation-details": "Canviar carga útil del Missatge i Metadades", + "type-action": "Acció", + "type-action-details": "Ejecutar acció especial", + "type-analytics": "Analítica", + "type-analytics-details": "Ejecutar análisis de dades transmesos o persistents", + "type-external": "Extern", + "type-external-details": "Interactuar amb sistemas externs", + "type-rule-chain": "Cadena de regles", + "type-rule-chain-details": "Reenviar els missatges entrantes a la cadena de regles especificada", + "type-flow": "Flow", + "type-flow-details": "Organitza el flow de missatges", + "type-input": "Entrada", + "type-input-details": "Entrada lògica de la Cadena de Regles, reenviar els missatges entrants al següent node de regla relacionat.", + "type-unknown": "Desconegut", + "type-unknown-details": "Regla de node no resolta", + "directive-is-not-loaded": "La directiva de configuració definida '{{directiveName}}' no está disponible.", + "ui-resources-load-error": "Error al carregar els recursos de configuració UI.", + "invalid-target-rulechain": "No es pot resoldre la cadena de regles objetiu!", + "test-script-function": "Prova Script de funció", + "message": "Missatge", + "message-type": "Tipus de missatge", + "select-message-type": "Seleccionar tipus de missatge", + "message-type-required": "Cal e tipus de missatge", + "metadata": "Metadades", + "metadata-required": "Les entrades de metadades no poden estar buides.", + "output": "Sortida", + "test": "Test", + "help": "Ajuda", + "reset-debug-mode": "Restablir el mode de depuració a tots els nodes" + }, + "role": { + "role": "Rol", + "roles": "Rols", + "management": "Gestió de rols", + "view-roles": "Veure rols", + "no-roles-matching": "No coincideixen els rols '{{entity}}' els van trobar.", + "role-list": "Llista de rols", + "add": "Afegeix un rol", + "view": "Mostra el rol", + "search": "Cerca rols", + "selected-roles": "{ count, plural, 1 {1 role} other {# roles} } seleccionat", + "no-roles-text": "No s'han trobat rols", + "role-details": "Detalls del rol", + "add-role-text": "Afegeix un paper nou", + "delete": "Suprimeix la funció", + "delete-roles": "Suprimir rols", + "delete-role-title": "Esteu segur que voleu suprimir la funció '{{roleName}}'?", + "delete-role-text": "Aneu amb compte, després de la confirmació, la funció i totes les dades relacionades es tornaran irrecuperables.", + "delete-roles-title": "Esteu segur que voleu suprimir { count, plural, 1 {1 role} other {# roles} }?", + "delete-roles-action-title": "Suprimeix { count, plural, 1 {1 role} other {# roles} }", + "delete-roles-text": "Aneu amb compte, després de la confirmació, tots els rols seleccionats s'eliminaran i totes les dades relacionades seran irrecuperables.", + "role-type": "Tipus de rol", + "role-type-required": "Cal tipus de rol.", + "select-role-type": "Seleccioneu el tipus de rol", + "enter-role-type": "Introduïu el tipus de rol", + "any-role": "Qualsevol paper", + "no-role-types-matching": "No coincideix cap tipus de rol '{{entitySubtype}}' els van trobar.", + "role-type-list-empty": "No s'ha seleccionat cap tipus de rol.", + "role-types": "Tipus de rols", + "created-time": "Temps creat", + "name": "Nom", + "name-required": "Cal el nom.", + "name-max-length": "El nom ha de ser inferior a 256", + "description": "Descripció", + "events": "Esdeveniments", + "details": "Detalls", + "copyId": "Copia l'identificador del rol", + "idCopiedMessage": "L'identificador de funció s'ha copiat al porta-retalls", + "permissions": "Permisos", + "role-required": " Cal rol", + "roles-required": "Cal rols", + "display-type": { + "GENERIC": "Genèric", + "GROUP": "Grup" + } + }, + "group-permission": { + "user-group-roles": "Rols del grup d'usuaris", + "entity-group-permissions": "Permisos del grup d'entitats", + "role-type": "Tipus de rol", + "role-name": "Nom del rol", + "group-type": "Tipus de grup", + "group-name": "Nom del grup", + "group-owner": "Propietari del grup", + "user-group-name": "Nom del grup d'usuaris", + "user-group-owner": "Propietari del grup d'usuaris", + "edit": "Edita els permisos", + "delete": "Suprimeix els permisos", + "selected-group-permissions": "{ count, plural, 1 {1 group permission} other {# group permissions} } seleccionat", + "delete-group-permission-title": "Esteu segur que voleu suprimir el permís del grup '{{roleName}}'?", + "delete-group-permission-text": "Aneu amb compte, després de la confirmació, el permís del grup i totes les dades relacionades es tornaran irrecuperables.", + "delete-group-permission": "Suprimeix el permís de grup", + "delete-group-permissions-title": "Esteu segur que voleu suprimir { count, plural, 1 {1 group permission} other {# group permission} }?", + "delete-group-permissions-text": "Aneu amb compte, després de la confirmació s'eliminaran tots els permisos de grup seleccionats i els usuaris corresponents perdran l'accés als recursos especificats.", + "delete-group-permissions": "Suprimeix els permisos del grup", + "add-group-permission": "Afegeix permís de grup", + "edit-group-permission": "Edita el permís del grup", + "entity-group": "Grup d'entitats", + "user-group": "Grup d'usuaris", + "no-owners-matching": "No hi ha cap propietari que coincideixi '{{owner}}' els van trobar.", + "target-owner-required": "El propietari del grup d'entitats és obligatori.", + "target-user-group-owner-required": "El propietari del grup d'usuaris és obligatori.", + "no-group-permissions-text": "No s'han trobat permisos de grup" + }, + "permission": { + "permissions-required": "S'ha d'especificar almenys una entrada de permís.", + "remove-permission": "Elimina l'entrada de permís", + "add-permission": "Afegeix una entrada de permís", + "other": "Altres", + "resource": { + "resource": "Recurs", + "select-resource": "Seleccioneu el recurs", + "resource-required": "Cal un recurs", + "no-resources-matching": "No hi ha recursos coincidents '{{resource}}' els van trobar.", + "display-type": { + "ALL": "Tots", + "PROFILE": "Perfil", + "ADMIN_SETTINGS": "Configuració d'administrador", + "ALARM": "Alarma", + "DEVICE": "Dispositiu", + "DEVICE_PROFILE": "Perfil del dispositiu", + "ASSET": "Actiu", + "CUSTOMER": "Client", + "DASHBOARD": "Panell", + "ENTITY_VIEW": "Vista d'entitat", + "EDGE": "Edge", + "TENANT": "Llogater", + "TENANT_PROFILE": "Perfil del llogater", + "RULE_CHAIN": "Cadena de regles", + "USER": "Usuari", + "WIDGETS_BUNDLE": "Paquet de widgets", + "WIDGET_TYPE": "Tipus de widget", + "CONVERTER": "Convertidor", + "INTEGRATION": "Integració", + "SCHEDULER_EVENT": "Agenda d'esdeveniments", + "BLOB_ENTITY": "Entitat Blob", + "CUSTOMER_GROUP": "Grup de clients", + "DEVICE_GROUP": "Grup de dispositius", + "ASSET_GROUP": "Grup d'actius", + "USER_GROUP": "Grup d'usuaris", + "ENTITY_VIEW_GROUP": "Grup de visualització d'entitats", + "DASHBOARD_GROUP": "Grup de Dashboard", + "ROLE": "Rol", + "GROUP_PERMISSION": "Permís de grup", + "WHITE_LABELING": "Etiquetat blanc", + "AUDIT_LOG": "Registre d'auditoria", + "API_USAGE_STATE": "Estat d'ús de l'API", + "TB_RESOURCE": "Recurs", + "EDGE_GROUP": "Grup Edge", + "OTA_PACKAGE": "Paquet Ota" + } + }, + "operation": { + "operation": "Operació", + "operations": "Operacions", + "operations-required": "S'ha d'especificar almenys una operació.", + "enter-operation": "Entra en funcionament", + "no-operations-matching": "No coincideix cap operació '{{operation}}' els van trobar.", + "display-type": { + "ALL": "Tots", + "CREATE": "Crear", + "READ": "Llegeix", + "WRITE": "Escriu", + "DELETE": "Suprimeix", + "ASSIGN_TO_CUSTOMER": "Assign to Customer", + "UNASSIGN_FROM_CUSTOMER": "Cancel·la l'assignació del client", + "RPC_CALL": "Trucada RPC", + "READ_CREDENTIALS": "Llegeix credencials", + "WRITE_CREDENTIALS": "Escriu credencials", + "READ_ATTRIBUTES": "Llegeix els atributs", + "WRITE_ATTRIBUTES": "Escriu atributs", + "READ_TELEMETRY": "Llegeix Telemetria", + "WRITE_TELEMETRY": "Escriu telemetria", + "CLAIM_DEVICES": "Dispositius de reclamació", + "IMPERSONATE": "Suplantar la identitat", + "CHANGE_OWNER": "Canvia de propietari", + "ADD_TO_GROUP": "Afegeix al grup", + "REMOVE_FROM_GROUP": "Elimina del grup", + "SHARE_GROUP": "Comparteix el grup", + "ASSIGN_TO_TENANT": "Assigna a l'arrendatari" + } + } + }, + "scheduler": { + "scheduler": "Planificador", + "scheduler-event": "Planificador d'esdeveniment", + "select-scheduler-event": "Seleccionar planificador d'esdeveniment", + "no-scheduler-events-matching": "Planificadors d'esdeveniments que coincideixin amb '{{entity}}' no van ser trobats.", + "scheduler-event-required": "Cal planificador d'esdeveniment", + "management": "Gestió del pla", + "scheduler-events": "Planificador d'esdeveniment", + "add-scheduler-event": "Afegir planificador d'esdeveniment", + "search-scheduler-events": "Buscar planificador d'esdeveniment", + "created-time": "Temps de creació", + "name": "Nom", + "name-required": "Cal nom", + "name-max-length": "El nom ha de ser inferior a 256.", + "type": "Tipus", + "created_customer": "Creat pel client", + "edit-scheduler-event": "Editar planificador d'esdeveniment", + "view-scheduler-event": "Mostra l'esdeveniment del programador", + "delete-scheduler-event": "Eliminar planificador d'esdeveniment", + "no-scheduler-events": "Planificador d'esdeveniment no trobat", + "selected-scheduler-events": "{ count, plural, 1 {1 planificador de esdeveniments} other {# planificadors de esdeveniments} } seleccionat", + "delete-scheduler-event-title": "Està segur que desitja eliminar el planificador d'esdeveniments '{{schedulerEventName}}'?", + "delete-scheduler-event-text": "Anar amb compte, després de la confirmació, el planificador d'esdeveniments i totes les dades relacionades és tornaran irrecuperables.", + "delete-scheduler-events-title": "Està segur que desitja eliminar { count, plural, 1 {1 planificador de esdeveniments} other {# planificadors de esdeveniments} }?", + "delete-scheduler-events-text": "Anar amb compte, després de la confirmació s'eliminaran totes els planificadors d'esdeveniments seleccionats i totes les dades relacionades és tornaran irrecuperables.", + "create": "Crear planificador d'esdeveniment", + "edit": "Editar planificador d'esdeveniment", + "view": "Mostra l'esdeveniment del programador", + "configuration": "Configuració", + "schedule": "Planificar", + "start-time": "Hora d'inici", + "repeat": "Repetició", + "repeats": "Repeticions", + "daily": "Diarament", + "weekly": "Setmanalment", + "monthly": "Mensualment", + "yearly": "Anual", + "timer": "Basat en temporitzador", + "repeats-required": "Repeticions són requerides.", + "repeat-on": "Repetir en", + "repeat-every": "Repeteix cada", + "ends-on": "Acabar en", + "sunday-label": "Dg", + "monday-label": "Dl", + "tuesday-label": "Dm", + "wednesday-label": "Dc", + "thursday-label": "Dj", + "friday-label": "Dv", + "saturday-label": "Ds", + "repeat-on-sunday": "Repetir el Diumenge", + "repeat-on-monday": "Repetir el Dilluns", + "repeat-on-tuesday": "Repetir el Dimarts", + "repeat-on-wednesday": "Repetir el Dimecres", + "repeat-on-thursday": "Repetir el Dijous", + "repeat-on-friday": "Repetir el Divendres", + "repeat-on-saturday": "Repetir el Dissabte", + "event-type": "Tipus d'esdeveniments", + "select-event-type": "Seleccionar tipus d'esdeveniment", + "event-type-required": "Tipus d'esdeveniment és requerit.", + "event-type-max-length": "El tipus d'esdeveniment ha de ser inferior a 256.", + "list-mode": "Vista de la llista", + "calendar-mode": "Vista del calendari", + "calendar-view-type": "Tipus de vista del calendari", + "month": "Mes", + "week": "Setmana", + "day": "Dia", + "agenda-week": "Agenda Setmanal", + "agenda-day": "Agenda Diaria", + "list-year": "Llista Anual", + "list-month": "Llista Mensual", + "list-week": "Llista Setmanal", + "list-day": "Llista Diaria", + "today": "Avui", + "navigate-before": "Navegar Abans de", + "navigate-next": "Navegar després de", + "starting-from": "Inician des de", + "until": "fins a", + "on": "encesa", + "sunday": "Diumenge", + "monday": "Dilluns", + "tuesday": "Dimarts", + "wednesday": "Dimecres", + "thursday": "Dijous", + "friday": "Divendres", + "saturday": "Dissabte", + "originator": "Creador", + "single-entity": "Entitat única", + "group-of-entities": "Grup d'entitats", + "entities-group-owner": "Propietari del grup d'entitats", + "single-device": "Dispositiu únic", + "group-of-devices": "Grup de dispositius", + "devices-group-owner": "Propietari del grup de dispositius", + "message-body": "Cos del missatge", + "target": "Objectiu", + "rpc-method": "Mètode", + "rpc-method-required": "Cal Mètode", + "rpc-method-white-space": "No és permet l'espai en blanc.", + "rpc-params": "Paràmetres", + "select-dashboard-state": "Seleccionar estat del panell", + "hours": "Hores", + "minutes": "Minuts", + "seconds": "Segons", + "time-interval-required": "Cal un interval de temps", + "time-unit-required": "Es requereix una unitat de temps", + "every-hour": "cada { count, plural, 1 {hour} other {# hours} }", + "every-minute": "cada { count, plural, 1 {minute} other {# minutes} }", + "every-second": "cada { count, plural, 1 {second} other {# seconds} }", + "invalid-time": "Hora no vàlida", + "assigned_customer": "Client assignat" + }, + "report": { + "report-config": "Configuració de l'informe", + "email-config": "Configuració del correu electrònic", + "dashboard-state-param": "Valor del parámetro d'estat del panell", + "base-url": "URL base", + "base-url-required": "Cal URL base.", + "use-dashboard-timewindow": "Utilitzar la finestra del temps del panell", + "timewindow": "Finestra del temps", + "name-pattern": "Report de patró de nom", + "name-pattern-required": "Cal el report de patró de nom.", + "type": "Tipus d'informe", + "use-current-user-credentials": "Utilizar credencials d'usuari actuals", + "customer-user-credentials": "Credencials d'usuari client", + "customer-user-credentials-required": "Cal credencials d'usuari client", + "generate-test-report": "Generar informe de proves", + "send-email": "Enviar correu electrònic", + "from": "Des de", + "from-required": "Cal des de.", + "to": "Per", + "to-required": "Cal per.", + "cc": "Amb copia", + "bcc": "Amb copia oculta", + "subject": "Assumpte", + "subject-required": "Cal assumpte.", + "body": "Cos", + "body-required": "Cal cos." + }, + "blob-entity": { + "blob-entity": "Entitat blob", + "select-blob-entity": "Seleccionar entitat blob", + "no-blob-entities-matching": "Entitats blob que coincideixin amb '{{entity}}' no han estat trobades.", + "blob-entity-required": "Cal entitat blob.", + "files": "Fitxers", + "search": "Buscar Fitxers", + "clear-search": "Esborrar cerca", + "no-blob-entities-prompt": "Fitxers no trobats", + "report": "Report", + "created-time": "Temps de creació", + "name": "Nom", + "type": "Tipus", + "created_customer": "Creat pel client", + "selected-blob-entities": "{ count, plural, 1 {1 file} other {# files} } seleccionat", + "download-blob-entity": "Descarregar arxiu", + "delete-blob-entity": "Eliminar arxiu", + "delete-blob-entity-title": "Està segur de que desitja eliminar l'arxiu '{{blobEntityName}}'?", + "delete-blob-entity-text": "Aneu amb compte, després de la confirmació, l'arxiu de dades es tornara irrecuperable.", + "delete-blob-entities-title": "Esteu segur que voleu suprimir { count, plural, 1 {1 file} other {# files} }?", + "delete-blob-entities-text": "Aneu amb compte, després de la confirmació, tots els fitxers seleccionats s'eliminaran i totes les dades relacionades es tornaran irrecuperables.", + "assigned_customer": "Client assignat" + }, + "timezone": { + "timezone": "Zona Horària", + "select-timezone": "Seleccionar zona horària", + "no-timezones-matching": "No hi ha zones horàries que coincideixin amb '{{timezone}}'.", + "timezone-required": "Cal zona horària.", + "browser-time": "Hora del navegador" + }, + "queue": { + "queue-name": "Cua", + "no-queues-matching": "No es van trobar cues que coincideixin amb '{{queue}}'.", + "select_name": "Seleccioneu el nom de la cua", + "name": "Nom Cua", + "name_required": "Cal especificar el nom de cua", + "name-unique": "El nom de cua ja existeix!", + "queue-required": "Cal cua!", + "topic-required": "Cal topic cua!", + "poll-interval-required": "Cal interval d'obtenció!", + "poll-interval-min-value": "L'interval no ha de ser inferior a 1", + "partitions-required": "Cal particions!", + "partitions-min-value": "El valor de partició no ha de ser inferior a 1", + "pack-processing-timeout-required": "Temps d'espera del processament", + "pack-processing-timeout-min-value": "El temps d'espera de processament no pot ser inferior a 1", + "batch-size-required": "Cal grandària del lot!", + "batch-size-min-value": "El valor de mida de lot no pot ser inferior a 1", + "retries-required": "Cal Reintents!", + "retries-min-value": "El valor dels reintents no pot ser negatiu", + "failure-percentage-required": "Cal percentatge de fallades!", + "failure-percentage-min-value": "Percentatge de fallades no pot ser inferior a 0", + "failure-percentage-max-value": "Percentatge de fallades no pot ser més gran de 100", + "pause-between-retries-required": "Cal pausa entre reintents!", + "pause-between-retries-min-value": "Pausa mínima entre reintents no pot ser inferior a 1", + "max-pause-between-retries-required": "Cal pausa màxima entre reintents!", + "max-pause-between-retries-min-value": "Pausa màxima entre reintents no pot ser inferior a 1", + "submit-strategy-type-required": "Cal estratègia d'enviament!", + "processing-strategy-type-required": "Cal estratègia de processament!", + "queues": "Cues", + "selected-queues": "{ count, plural, 1 {1 cola} other {# colas} } seleccionades", + "delete-queue-title": "Estàs segur d'esborrar la cua '{{queueName}}'?", + "delete-queues-title": "Estàs segur d'esborrar { count, plural, 1 {1 cola} other {# colas} }?", + "delete-queue-text": "Atenció, després de la confirmació la cua i totes les seves dades relacionades seran irrecuperables.", + "delete-queues-text": "Atenció, després de la confirmació totes les cues s'esborraran i no seran accessibles.", + "search": "Buscar cua", + "add": "Afegir cua", + "details": "Detalls cua", + "topic": "Tema", + "submit-strategy": "Estratègia d'enviament", + "processing-strategy": "Estratègia de processament", + "poll-interval": "Interval d'obtenció", + "partitions": "Particions", + "consumer-per-partition": "Consumidors per partició", + "consumer-per-partition-hint": "Activar consumidors separats per a cada partició", + "processing-timeout": "Timeout de processament, ms", + "batch-size": "Grandària de lot", + "retries": "Reintents (0 - ilimitados)", + "failure-percentage": "Percentatge de fallades", + "pause-between-retries": "Pausa entre reintents", + "max-pause-between-retries": "Pausa màxima entre reintents", + "delete": "Borrar cua", + "copyId": "Copiar Id de la cua", + "idCopiedMessage": "La Id de la cua s'ha copiat al porta-retalls", + "description": "Descripció", + "description-hint": "Aquest text es mostrarà a la descripció de la cua, en lloc de l'estratègia seleccionada", + "alt-description": "Estratègia d'enviament: {{submitStrategy}}, Estratègia de processament: {{processingStrategy}}", + "strategies": { + "sequential-by-originator-label": "Seqüencial per iniciador", + "sequential-by-originator-hint": "El nou missatge per exemple dispositiu A no s'enviarà fins que el missatge anterior del dispositiu A sigui admès/processat", + "sequential-by-tenant-label": "Seqüencial per propietari", + "sequential-by-tenant-hint": "El missatge nou, per exemple Propietari A, no s'enviarà fins que el missatge anterior del Propietari A sigui admès/processat", + "sequential-label": "Seqüencial", + "sequential-hint": "El missatge nou no s'enviarà fins que el missatge anterior sigui admès/processat", + "burst-label": "Ràfega (Burst)", + "burst-hint": "Tots els missatges s'enviaran cap a les cadenes de regles a l'ordre que arribin", + "batch-label": "Lots (Batch)", + "batch-hint": "El nou lot, no s'enviarà fins que l'anterior lot sigui admès/processat", + "skip-all-failures-label": "Skip to fallades", + "skip-all-failures-hint": "Ignorar tots els errors", + "skip-all-failures-and-timeouts-label": "Skip to fallades i temps morts", + "skip-all-failures-and-timeouts-hint": "Ignorar tots els errors i temps morts", + "retry-all-label": "Reintentar tots", + "retry-all-hint": "Reintentar tots els missatges del lot de processament", + "retry-failed-label": "Reintentar fallits", + "retry-failed-hint": "Reintentar tots els missatges fallits del lot de processament", + "retry-timeout-label": "Timeout de reintents", + "retry-timeout-hint": "Reintentar tots els missatges que donin timeout del lot de processament", + "retry-failed-and-timeout-label": "Reintentar fallits i timeouts", + "retry-failed-and-timeout-hint": "Reintentar tots els missatges fallits i que donin timeout del lot de processament" + } + }, + "tenant": { + "tenant": "Propietari", + "tenants": "Propietaris", + "management": "Gestió de Propietaris", + "add": "Afegir propietari", + "admins": "Admins", + "manage-tenant-admins": "Gestionar administradors del propietari", + "delete": "Eliminar propietari", + "add-tenant-text": "Afegir nou propietari", + "no-tenants-text": "Cap propietari trobat", + "tenant-details": "Detalls del propietari", + "title-max-length": "El títol ha de ser inferior a 256", + "delete-tenant-title": "Vols eliminar el propietari '{{tenantTitle}}'?", + "delete-tenant-text": "Atenció, després de la confirmació el propietari serà eliminat i la informació relacionada serà irrecuperable.", + "delete-tenants-title": "Vols eliminar { count, plural, 1 {1 propietario} other {# propietarios} }?", + "delete-tenants-action-title": "Eliminar { count, plural, 1 {1 propietario} other {# propietarios} }", + "delete-tenants-text": "Atenció, després de la confirmació els propietaris seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "title": "Títol", + "title-required": "Cal títol.", + "description": "Descripció", + "details": "Detalls", + "events": "Esdeveniments", + "copyId": "Copiar ID de propietari", + "idCopiedMessage": "El ID de propietari s'ha copiat al porta-retalls", + "select-tenant": "Seleccionar propietari", + "no-tenants-matching": "No hi ha propietaris que coincideixin amb '{{entity}}' .", + "tenant-required": "Propietari", + "selected-tenants": "{ count, plural, 1 {1 propietario} other {# propietarios} } seleccionats", + "search": "Buscar propietaris", + "allow-white-labeling": "Permet l'etiqueta blanca", + "allow-customer-white-labeling": "Permet l'etiqueta blanca del client", + "isolated-tb-core": "Processant en contenidor aïllat", + "isolated-tb-rule-engine": "Processant en contenidor Motor de Regles aïllat", + "isolated-tb-core-details": "Requereix microserveis separats per propietari aïllat", + "isolated-tb-rule-engine-details": "Requereix microserveis separats per propietari aïllat" + }, + "tenant-profile": { + "tenant-profile": "Perfil de propietari", + "tenant-profiles": "Perfils de propietarios", + "add": "Afegir perfil de propietari", + "edit": "Editar perfil de propietari", + "tenant-profile-details": "Detalls perfil de propietari", + "no-tenant-profiles-text": "No s'han trobar perfils de propietari", + "name-max-length": "El nom ha de ser inferior a 256", + "search": "Buscar perfils de propietari", + "selected-tenant-profiles": "{ count, plural, 1 {1 perfil de propietario} other {# perfils de propietario} } seleccionats", + "no-tenant-profiles-matching": "No s'han trobat perfils del propietari que coincideixin amb '{{entity}}'.", + "tenant-profile-required": "Cal perfil de propietari", + "idCopiedMessage": "El ID de perfil de propietari s'ha copiat al porta-retalls", + "set-default": "Fer perfil propietari per defecte", + "delete": "Esborrar perfil", + "copyId": "Copiar ID de perfil", + "name": "Nom", + "name-required": "Cal nom.", + "data": "Dades de perfil", + "profile-configuration": "Configuració de perfil", + "description": "Descripció", + "default": "Defecte", + "delete-tenant-profile-title": "Eliminar el perfil propietari '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Atenció, després de la confirmació, el perfil de propietari serà borrat i la seva informació relacionada serà irrecuperable.", + "delete-tenant-profiles-title": "Eliminar { count, plural, 1 {1 perfil propietario} other {# perfils propietarios} }?", + "delete-tenant-profiles-text": "Atenció, després de la confirmació, els perfils seleccionats s'eliminaran i la seva informació relacionada serà irrecuperable.", + "set-default-tenant-profile-title": "Vols fer el perfil propietari '{{tenantProfileName}}' per defecte?", + "set-default-tenant-profile-text": "Després de la confirmació, el perfil propietari serà marcat per defecte i serà utilitzat pels nous perfils propietaris que no tinguin perfil específic.", + "no-tenant-profiles-found": "No s'han trobar perfils de propietari.", + "create-new-tenant-profile": "Crear un nou perfil!", + "create-tenant-profile": "Crea un perfil d'inquilí nou", + "import": "Importa el perfil d'inquilí", + "export": "Exporta el perfil d'inquilí", + "export-failed-error": "No es pot exportar el perfil d'inquilí: {{error}}", + "tenant-profile-file": "Fitxer de perfil de llogater", + "invalid-tenant-profile-file-error": "No es pot importar el perfil d'inquilí: l'estructura de dades del perfil d'inquilí no és vàlida.", + "maximum-devices": "Nº Màxim de dispositius (0 - sense límit)", + "maximum-devices-required": "Cal Nº Màxim de dispositius.", + "maximum-devices-range": "Nº Màxim de dispositius no pot ser negatiu", + "maximum-assets": "Nº Màxim d'actius (0 - sense límits)", + "maximum-assets-required": "Cal Nº Màxim d'actius.", + "maximum-assets-range": "Nº Màxim d'actius no pot ser negatiu", + "maximum-customers": "Nº Màxim de clients (0 - sense límit)", + "maximum-customers-required": "Cal Nº Màxim de clients.", + "maximum-customers-range": "Nº Màxim de clients no pot ser negatiu", + "maximum-users": "Nº Màxim d'usuaris (0 - sense límit)", + "maximum-users-required": "Cal Nº Màxim d'usuaris.", + "maximum-users-range": "Nº Màxim d'usuaris no pot ser negatiu", + "maximum-dashboards": "Nº Màxim de panells (0 - sense límit)", + "maximum-dashboards-required": "Cal Nº Màxim de panells.", + "maximum-dashboards-range": "Nº Màxim de panells no pot ser negatiu", + "maximum-rule-chains": "Nº Màxim de cadenes de regles (0 - sense límit)", + "maximum-rule-chains-required": "Cal Nº Màxim de cadenes de regles.", + "maximum-rule-chains-range": "Nº Màxim de cadenes de regles no pot ser negatiu", + "maximum-integrations": "Nombre màxim d'integracions (0 - il·limitat)", + "maximum-integrations-required": "Cal nombre màxim d'integracions.", + "maximum-integrations-range": "Nombre màxim d'integracions no pot ser negatiu", + "maximum-converters": "Nombre màxim de convertidors (0 - il·limitat)", + "maximum-converters-required": "Cal nombre màxim de convertidors.", + "maximum-converters-range": "Nombre màxim de convertidors no pot ser negatiu", + "maximum-scheduler-events": "Nombre màxim d'esdeveniments del programador (0 - il·limitat)", + "maximum-scheduler-events-required": "Cal nombre màxim d'esdeveniments del programador.", + "maximum-scheduler-events-range": "Nombre màxim d'esdeveniments del programador no pot ser negatiu", + "maximum-resources-sum-data-size": "Suma màxima de la mida dels fitxers de recursos en bytes (0 - il·limitat)", + "maximum-resources-sum-data-size-required": "Cal suma màxima de la mida dels fitxers de recursos.", + "maximum-resources-sum-data-size-range": "Suma màxima de la mida dels fitxers de recursos no pot ser negatiu", + "maximum-ota-packages-sum-data-size": "Suma màxima de la mida dels fitxers del paquet ota en bytes (0 - il·limitat)", + "maximum-ota-package-sum-data-size-required": "Cal suma màxima de la mida dels fitxers del paquet ota.", + "maximum-ota-package-sum-data-size-range": "Suma màxima de la mida dels fitxers del paquet ota no pot ser negatiu", + "transport-tenant-msg-rate-limit": "Taxa de missatges de transport per propietari.", + "transport-tenant-telemetry-msg-rate-limit": "Taxa de missatges de telemetria per propietari.", + "transport-tenant-telemetry-data-points-rate-limit": "Taxa de punts de dades per propietari.", + "transport-device-msg-rate-limit": "Taxa de missatges de dispositiu.", + "transport-device-telemetry-msg-rate-limit": "Taxa de missatges de telemetria de dispositiu.", + "transport-device-telemetry-data-points-rate-limit": "Taxa de punts de dades de telemetria de dispositiu.", + "max-transport-messages": "Nº Màxim de missatges de transport (0 - sense límit)", + "max-transport-messages-required": "Cal Nº Màxim de missatges de transport.", + "max-transport-messages-range": "Nº Màxim de missatges de transport no pot ser negatiu", + "max-transport-data-points": "Nº Màxim de punts de dades transport (0 - sense límit)", + "max-transport-data-points-required": "Cal Nº Màxim de punts de dades transport.", + "max-transport-data-points-range": "Nº Màxim de punts de dades transport no pot ser negatiu", + "max-r-e-executions": "Nº Màxim de execucions de motor de regles (0 - sense límit)", + "max-r-e-executions-required": "Cal Nº Màxim de execucions de motor de regles.", + "max-r-e-executions-range": "Nº Màxim de execucions de motor de regles no pot ser negatiu", + "max-j-s-executions": "Nº Màxim de execucions JavaScript (0 - sense límit)", + "max-j-s-executions-required": "Cal Nº Màxim de execucions JavaScript.", + "max-j-s-executions-range": "Nº Màxim de execucions JavaScript no pot ser negatiu", + "max-d-p-storage-days": "Nº Màxim de dies a gravar en punts de dades (0 - sense límit)", + "max-d-p-storage-days-required": "Cal Nº Màxim de dies.", + "max-d-p-storage-days-range": "Nº Màxim de dies no pot ser negatiu", + "default-storage-ttl-days": "Dies per defecte gravats TTL (0 - sense límit)", + "default-storage-ttl-days-required": "Cal dies per defecte TTL.", + "default-storage-ttl-days-range": "Dies per defecte TTL no pot ser negatiu", + "alarms-ttl-days": "Alarmes TTL dies (0 - il·limitat)", + "alarms-ttl-days-required": "Cal alarmes TTL dies", + "alarms-ttl-days-days-range": "Alarmes TTL dies no pot ser negatiu", + "rpc-ttl-days": "RPC TTL days (0 - il·limitat)", + "rpc-ttl-days-required": "Cal RPC TTL dies", + "rpc-ttl-days-days-range": "RPC TTL days no pot ser negatiu", + "max-rule-node-executions-per-message": "Nº Màxim de execucions (cadena de regles) per missatge (0 - sense límit)", + "max-rule-node-executions-per-message-required": "Cal Nº Màxim de execucions per missatge.", + "max-rule-node-executions-per-message-range": "Nº Màxim de execucions per missatge no pot ser negatiu", + "max-correus electrònics": "Nº Màxim de correus electrònics (0 - sense límit)", + "max-correus electrònics-required": "Cal Nº Màxim de correus electrònics.", + "max-correus electrònics-range": "Nº Màxim de correus electrònics no pot ser negatiu", + "max-sms": "Nº Màxim de missatges SMS (0 - sense límit)", + "max-sms-required": "Cal Nº Màxim de missatges SMS.", + "max-sms-range": "Nº Màxim de missatges SMS no pot ser negatiu", + "max-created-alarms": "Nombre màxim d'alarmes creades (0 - il·limitat)", + "max-created-alarms-required": "Cal nombre màxim d'alarmes creades.", + "max-created-alarms-range": "Nombre màxim d'alarmes creades no pot ser negatiu", + "no-queue": "No s'ha configurat cap cua", + "add-queue": "Afegeix cua", + "queues-with-count": "Cues ({{count}})", + "tenant-rest-limits": "Límit de tarifa per a les sol·licituds REST per a llogaters", + "customer-rest-limits": "Límit de tarifa per a sol·licituds REST per al client", + "incorrect-pattern-for-rate-limits": "El format és parells separats per comes de capacitat i punt (in seconds) amb dos punts entre, p. 100:1,2000:60", + "too-small-value-zero": "El valor ha de ser més gran que 0", + "too-small-value-one": "El valor ha de ser més gran que 1", + "cassandra-tenant-limits-configuration": "Límit de taxa de consultes de Cassandra per al llogater", + "ws-limit-max-sessions-per-tenant": "Nombre màxim de sessions de WS per inquilí", + "ws-limit-max-sessions-per-customer": "Nombre màxim de sessions de WS per client", + "ws-limit-max-sessions-per-public-user": "Nombre màxim de sessions de WS per usuari públic", + "ws-limit-queue-per-session": "Mida màxima de la cua de missatges de WS per sessió", + "ws-limit-max-subscriptions-per-tenant": "Nombre màxim de subscripcions de WS per inquilí", + "ws-limit-max-subscriptions-per-customer": "Nombre màxim de subscripcions de WS per client", + "ws-limit-max-subscriptions-per-regular-user": "Nombre màxim de subscripcions de WS per usuari habitual", + "ws-limit-max-subscriptions-per-public-user": "Nombre màxim de subscripcions de WS per usuari públic", + "ws-limit-updates-per-session": "Límit de tarifa per a les actualitzacions de WS per sessió" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 segundo} other {# segons} }", + "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 {# dies} }", + "days": "Dies", + "hours": "Hores", + "minutes": "Minuts", + "seconds": "Segons", + "advanced": "Avançat", + "predefined": { + "yesterday": "Ahir", + "day-before-yesterday": "Abans d'ahir", + "this-day-last-week": "Aquest dia de la setmana passada", + "previous-week": "Setmana anterior (dg - ds)", + "previous-week-iso": "Setmana anterior (dl-dg)", + "previous-month": "Mes anterior", + "previous-year": "Any anterior", + "current-hour": "Hora actual", + "current-day": "Dia actual", + "current-day-so-far": "Dia actual fins ara", + "current-week": "Setmana actual (dg - ds)", + "current-week-iso": "Setmana actual (dl-dg)", + "current-week-so-far": "La setmana actual fins ara (dg - ds)", + "current-week-iso-so-far": "La setmana actual fins al moment (dl - dg)", + "current-month": "Aquest mes", + "current-month-so-far": "Mes actual fins ara", + "current-year": "Aquest any", + "current-year-so-far": "Any en curs fins ara" + } + }, + "timeunit": { + "milliseconds": "Mil·lisegons", + "seconds": "Segons", + "minutes": "Minuts", + "hours": "Hores", + "days": "Dies" + }, + "timewindow": { + "days": "{ days, plural, 1 { día } other {# dies } }", + "hours": "{ hours, plural, 0 { horas } 1 {1 hora } other {# horas } }", + "minutes": "{ minutes, plural, 0 { minutos } 1 {1 minuto } other {# minutos } }", + "seconds": "{ seconds, plural, 0 { segons } 1 {1 segundo } other {# segons } }", + "realtime": "Temps-real", + "history": "Històric", + "last-prefix": "últim(s)", + "period": "Des de {{ startTime }} fins a {{ endTime }}", + "edit": "Editar finestra del temps", + "date-range": "Rang de dates", + "last": "Últims(s)", + "time-period": "Període del temps", + "hide": "Amagar", + "interval": "Interval" + }, + "user": { + "user": "Usuari", + "users": "Usuaris", + "management": "Gestió d'usuaris", + "customer-users": "Usuaris del Client", + "tenant-admins": "Admins propietaris", + "sys-admin": "Administrador del Sistema", + "tenant-admin": "Administrador del Propietari", + "customer": "Client", + "anonymous": "Anònim", + "add": "Afegir usuari", + "delete": "Eliminar usuari", + "add-user-text": "Afegir nou usuari", + "no-users-text": "Ningun usuari trobat", + "user-details": "Detalls de l'usuari", + "delete-users": "Eliminar usuaris", + "delete-user-title": "Eliminar l'usuari '{{userEmail}}'?", + "delete-user-text": "Atenció, després de la confirmació l'usuari seleccionar serà eliminat i la informació relacionada serà irrecuperable.", + "delete-users-title": "Eliminar { count, plural, 1 {1 usuario} other {# usuaris} }?", + "delete-users-action-title": "Esborrar { count, plural, 1 {1 usuario} other {# usuaris} }", + "delete-users-text": "Atenció, després de la confirmació dels usuaris seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "activation-email-sent-message": "Correu d'activació enviat amb èxit!", + "resend-activation": "Reenviar activació", + "email": "Correu", + "email-required": "Cal correu.", + "invalid-email-format": "Format del correu no vàlid.", + "first-name": "Nom", + "last-name": "Cognom", + "description": "Descripció", + "default-dashboard": "Panell per defecte", + "always-fullscreen": "Sempre en pantalla completa", + "select-user": "Seleccionar usuari", + "no-users-matching": "No s'han trobat usuaris coincidint amb '{{entity}}' .", + "user-required": "Cal usuari", + "activation-method": "Mètode d'activació", + "display-activation-link": "Mostrar l'enllaç d'activació", + "send-activation-mail": "Enviar correu d'activació", + "activation-link": "Enllaç d'activació d'usuari", + "activation-link-text": "Per activar l'usuari, utilitza el següent enllaç: Activar Usuari :", + "copy-activation-link": "Copiar enllaç d'activació", + "activation-link-copied-message": "L'enllaç d'activació s'ha copiat al porta-retalls", + "selected-users": "{ count, plural, 1 {1 usuario} other {# usuaris} } seleccionats", + "search": "Buscar usuaris", + "details": "Detalls", + "login-as-tenant-admin": "Iniciar sessió com a Administrador Propietari", + "login-as-customer-user": "Iniciar sessió com a Usuari Client", + "select-group-to-add": "Seleccioneu el grup objectiu per afegir usuaris seleccionats", + "select-group-to-move": "Seleccioneu el grup objectiu per moure els usuaris seleccionats", + "remove-users-from-group": "Esteu segur que voleu eliminar-lo { count, plural, 1 {1 user} other {# users} } del grup '{{entityGroup}}'?", + "group": "Grup d'usuaris", + "list-of-groups": "{ count, plural, 1 {One user group} other {List of # user groups} }", + "group-name-starts-with": "Grups d'usuaris els noms dels quals comencen per '{{prefix}}'", + "disable-account": "Deshabilitar compte d'usuari", + "enable-account": "Habilitar compte d'usuari", + "enable-account-message": "El compte d'usuari s'ha habilitat correctament!", + "disable-account-message": "El compte d'usuari s'ha deshabilitat correctament!!", + "copyId": "Copia l'ID de l'usuari", + "idCopiedMessage": "L'identificador d'usuari s'ha copiat al porta-retalls" + }, + "value": { + "type": "Tipus de valor", + "string": "Cadena de text", + "string-value": "Valor de cadena de text", + "string-value-required": "Cal valor de cadena de text", + "integer": "Número enter", + "integer-value": "Valor del número enter", + "integer-value-required": "Cal valor enter", + "invalid-integer-value": "Valor de enter invàlid", + "double": "Número decimal", + "double-value": "Valor número decimal", + "double-value-required": "Cal valor número decimal", + "boolean": "Booleà", + "boolean-value": "Valor booleà", + "false": "Fals", + "true": "Verdader", + "long": "Número llarg", + "json": "JSON", + "json-value": "Valor JSON", + "json-value-invalid": "El valor JSON té un format invàlid", + "json-value-required": "Cal valor JSON" + }, + "version-control": { + "version-control": "Control de versió", + "management": "Administrador de versions", + "branch": "Branca", + "default": "Per defecte", + "select-branch": "Seleccionar branca", + "branch-required": "Cal branca", + "create-entity-version": "Versió creació important", + "version-name": "Nom de versió", + "version-name-required": "Cal nom de versió", + "author": "Autor", + "export-relations": "Exportar relacions", + "export-attributes": "Exportar atributs", + "export-credentials": "Exportar credencials", + "entity-versions": "Versions d'entitat", + "versions": "Versions", + "created-time": "Hora de creació", + "version-id": "ID de versió", + "no-entity-versions-text": "No s'han trobat versions d'entitat", + "no-versions-text": "No s'han trobat versions", + "copy-full-version-id": "Copiar el ID de versió", + "create-version": "Crear versió", + "nothing-to-commit": "No hi ha canvis a publicar", + "restore-version": "Restaurar versió", + "restore-entity-from-version": "Restaurar entitat des de versió '{{versionName}}'", + "load-relations": "Cargar relacions", + "load-attributes": "Cargar atributs", + "load-credentials": "Cargar credencials", + "diff-entity-with-version": "Diff de la versió de entitat '{{versionName}}'", + "previous-difference": "Anterior diferència", + "next-difference": "Següent diferència", + "current": "Actual", + "differences": "{ count, plural, 1 {1 diferencia} other {# diferencias} }", + "create-entities-version": "Crear versió d'entitats", + "default-sync-strategy": "Estratègia de sincronització per defecte", + "sync-strategy-merge": "Fusionar (Merge)", + "sync-strategy-overwrite": "Sobreescriure", + "entities-to-export": "Entitats a exportar", + "entities-to-restore": "Entitats a restaurar", + "sync-strategy": "Estratègia de sincronizació", + "all-entities": "Totes les entitats", + "no-entities-to-export-prompt": "Si us plau, especifica les entitats a exportar", + "no-entities-to-restore-prompt": "Si us plau, especifica les entitats a restaurar", + "add-entity-type": "Afegir tipus d'entitat", + "remove-all": "Borrar tot", + "version-create-result": "{ added, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } afegides.
    { modified, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } modificades.
    { removed, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } esborrades.", + "remove-other-entities": "Esborrar altres entitats", + "find-existing-entity-by-name": "Buscar entitat existent per nom", + "restore-entities-from-version": "Restaurar entitats des de la versió '{{versionName}}'", + "no-entities-restored": "No es van restaurar entitats", + "created": "{{created}} creades", + "updated": "{{updated}} actualitzades", + "deleted": "{{deleted}} esborrades", + "remove-other-entities-confirm-text": "Atenció! Aquesta acció esborrarà permanentment todas les entitats actuals
    no presents a la versió a restaurar.

    Escriu eliminar altres entitats per confirmar.", + "auto-commit-to-branch": "autopublicar a la branca {{ branch }}", + "default-create-entity-version-name": "{{entityName}} actualizació", + "sync-strategy-merge-hint": "Crea o actualitza les entitats seleccionades al repositori. Les altres entitats no seran modificades.", + "sync-strategy-overwrite-hint": "Crea o actualitza les entitats seleccionades al repositori. Les altres entitats seran esborrades.", + "device-credentials-conflict": "Fallada en carregar el dispositiu amb ID extern {{entityId}}
    pel fet que les mateixes credencials estan ja presents a la base de dades per a un altre dispositiu.
    Si us plau, considereu desactivar l'ajustament carregar credencials al formulari de restauració.", + "missing-referenced-entity": "Fallada en carregar {{sourceEntityTypeName}} amb ID extern {{sourceEntityId}}
    perquè fa referència al tipus d'entitat {{targetEntityTypeName}} amb el ID {{targetEntityId}}." + }, + "widget": { + "widget-library": "Bibloteca de Widgets", + "widget-bundle": "Paquets de Widgets", + "all-bundles": "Tots els paquets", + "select-widgets-bundle": "Seleccionar paquet de widgets", + "management": "Gestió de Widgets", + "editor": "Editor de widgets", + "widget-type-not-found": "Problema al carregar la configuració del widget.
    Probablement associat\n El tipus de widget ha estat eliminat.", + "widget-type-load-error": "El widget no ha estat carregat degut a aquests errors:", + "remove": "Eliminar widget", + "edit": "Editar widget", + "remove-widget-title": "Eliminar el widget '{{widgetTitle}}'?", + "remove-widget-text": "Atenció, després de la confirmació el widget serà eliminat i tota la informació relacionada serà irrecuperable.", + "timeseries": "Sèries del temps", + "search-data": "Buscar dades", + "no-data-found": "No s'han trobat dades", + "latest": "Últims valors", + "rpc": "Widget de control", + "alarm": "Widget d'alarma", + "static": "Widget estàtic", + "select-widget-type": "Seleccionar tipus de widget", + "missing-widget-title-error": "El titul del widget ha de ser especificat!", + "widget-saved": "Widget guardat", + "unable-to-save-widget-error": "Impossible guardar widget! Té errors!", + "save": "Guardar widget", + "saveAs": "Guardar widget com", + "save-widget-type-as": "Guardar tipus de widget com", + "save-widget-type-as-text": "Per favor, introduïu un nou títol i/o seleccioneu un paquet de destinació.", + "toggle-fullscreen": "Canviar a pantalla completa", + "run": "Ejecutar widget", + "title": "Títol", + "title-required": "Cal títol.", + "type": "Tipus", + "resources": "Recursos", + "resource-url": "URL JavaScript/CSS", + "resource-is-module": "És mòdul", + "remove-resource": "Eliminar recurs", + "add-resource": "Afegir recurs", + "html": "HTML", + "tidy": "Endreçat", + "css": "CSS", + "settings-schema": "Esquema de configuració", + "datakey-settings-schema": "Esquema de configuració de clau de dades", + "latest-datakey-settings-schema": "Esquema de darrers valors", + "widget-settings": "Configuració Widget", + "description": "Descripció", + "image-preview": "Vista prèvia de la imatge", + "settings-form-selector": "Selector de formulari de configuració", + "data-key-settings-form-selector": "Selector de formulari de configuració claus de dades", + "latest-data-key-settings-form-selector": "Selector formulari dels darrers valors", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Eliminar el tipus del widget '{{widgetName}}'?", + "remove-widget-type-text": "Atenció, després de la confirmació el tipus serà eliminat i la informació relacionada serà irrecuperable.", + "remove-widget-type": "Eliminar tipus de widget.", + "add-widget-type": "Afegir nou tipus de widget", + "widget-type-load-failed-error": "Error al carregar el tipus de widget!", + "widget-template-load-failed-error": "Error al carregar la plantilla del widget!", + "add": "Afegir Widget", + "undo": "Desfer canvis", + "export": "Exportar widget", + "export-data": "Exportar dades del widget", + "export-to-csv": "Exportar dades a CSV...", + "export-to-excel": "Exportar dades a XLS...", + "export-to-excel-xlsx": "Exportar dades a XLSX...", + "no-data": "No hi ha dades per mostrar en widget", + "data-overflow": "El widget mostra {{count}} de {{total}} entitats", + "alarm-data-overflow": "El widget mostra alarmes per {{allowedEntities}} entitats (màxim permès) de {{totalEntities}} entitats", + "search": "Cerca widget", + "filter": "Tipus de filtre Widget", + "loading-widgets": "S'estan carregant els widgets..." + }, + "widget-action": { + "header-button": "Botó d'encapçalats widget", + "open-dashboard-state": "Navegar a un nou estat de panell", + "update-dashboard-state": "Actualizar l'estat del panell actual", + "open-dashboard": "Navegar a un altre panell", + "custom": "Acció personalitzada", + "custom-pretty": "Acció personalitzada (amb plantilla HTML)", + "mobile-action": "Acció mòbil", + "target-dashboard-state": "Estat de panell de destí", + "target-dashboard-state-required": "Cal estat de panell de destí", + "set-entity-from-widget": "Establir entitat des de widget", + "target-dashboard": "Panell de destí", + "open-right-layout": "Obrir disseny de panell (Dret)(vista móvil)", + "state-display-type": "Opció de visualització de l'estat del tauler", + "open-normal": "Normal", + "open-in-separate-dialog": "Obrir en un diàleg separat", + "open-in-popover": "Obre en popover", + "dialog-title": "Títol del diàleg", + "dialog-hide-dashboard-toolbar": "Ocultar barra d'eines en el diàleg", + "dialog-width": "Ample de diàleg en percentatge relatiu a l'ample del viewport", + "dialog-height": "Alt de diàleg en percentatge relatiu a l'alt del viewport", + "dialog-size-range-error": "La grandària del diàleg ha de ser entre un rang de 1 a 100", + "popover-preferred-placement": "Col·locació popover preferida", + "popover-placement-top": "Superior", + "popover-placement-topLeft": "Superior esquerra", + "popover-placement-topRight": "Superior dret", + "popover-placement-right": "Dret", + "popover-placement-rightTop": "Dret superior", + "popover-placement-rightBottom": "Dret inferior", + "popover-placement-bottom": "Bottom", + "popover-placement-bottomLeft": "Bottom esquerra", + "popover-placement-bottomRight": "Bottom dret", + "popover-placement-left": "Esquerra", + "popover-placement-leftTop": "Esquerra superior", + "popover-placement-leftBottom": "Esquerra inferior", + "popover-hide-on-click-outside": "Amaga la finestra emergent al clic exterior", + "popover-hide-dashboard-toolbar": "Amaga la barra d'eines del tauler a la finestra emergent", + "popover-width": "Amplada emergent en unitats del navegador (p. ex. 100 píxels, 25 vw)", + "popover-height": "Alçada emergent en unitats del navegador (p. ex. 100px, 25vh)", + "popover-style": "Estil popover", + "open-new-browser-tab": "Obrir en una nova pestanya", + "mobile": { + "action-type": "Tipus d'acció mòbil", + "action-type-required": "Cal el tipus d'acció mòbil", + "take-picture-from-gallery": "Fer una foto de la galeria", + "take-photo": "Fer foto", + "map-direction": "Obriu les indicacions del mapa", + "map-location": "Obre la ubicació del mapa", + "scan-qr-code": "Escaneja el codi QR", + "make-phone-call": "Fes una trucada telefònica", + "get-location": "Obteniu la ubicació del telèfon", + "take-screenshot": "Fes una captura de pantalla" + } + }, + "widgets-bundle": { + "current": "Paquet actual", + "widgets-bundles": "Paquet de Widgets", + "add": "Afegir paquet de widgets", + "delete": "Eliminar paquet de widgets", + "title": "Títol", + "title-required": "Cal títol.", + "title-max-length": "El títol ha de ser inferior a 256", + "description": "Descripció", + "image-preview": "Vista prèvia de la imatge", + "add-widgets-bundle-text": "Afegir nou paquet de widgets", + "no-widgets-bundles-text": "Cap paquet de widgets trobat", + "empty": "Paquet de widgets buit.", + "details": "Detalls", + "widgets-bundle-details": "Detalls del paquet de Widgets", + "delete-widgets-bundle-title": "Eliminar el paquet de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Atenció, després de la confirmació tots els paquets seleccionats seran eliminats i la seva informació relacionada serà irrecuperable.", + "delete-widgets-bundles-title": "Eliminar { count, plural, 1 {1 paquet de widgets} other {# paquetes de widgets} }?", + "delete-widgets-bundles-action-title": "Eliminar { count, plural, 1 {1 paquet de widgets} other {# paquetes de widgets} }", + "delete-widgets-bundles-text": "Atenció, després de la confirmació tots els paquets seleccionats seran eliminats i la informació relacionada serà irrecuperable.", + "no-widgets-bundles-matching": "Cap paquet '{{widgetsBundle}}' trobat.", + "widgets-bundle-required": "Cal paquet de widget.", + "system": "Widget de Sistema", + "import": "Importar paquet de widgets", + "export": "Exportar paquet de widgets", + "export-failed-error": "Impossible exportar paquet de widgets: {{error}}", + "create-new-widgets-bundle": "Crear nou paquet de widgets", + "widgets-bundle-file": "Arxiu de paquet de widgets", + "invalid-widgets-bundle-file-error": "Impossible importar paquet de widgets: Estructura de dades invàlida.", + "search": "Buscar paquet de widgets", + "selected-widgets-bundles": "{ count, plural, 1 {1 paquet de widgets} other {# paquetes de widgets} } seleccionats", + "open-widgets-bundle": "Obrir paquet de widgets", + "loading-widgets-bundles": "S'estan carregant paquets de widgets..." + }, + "widget-config": { + "data": "Dades", + "settings": "Configuració", + "advanced": "Avançat", + "title": "Títol", + "title-tooltip": "Tooltip Títol", + "general-settings": "Configuració generals", + "display-title": "Mostrar títol", + "drop-shadow": "Ombra", + "enable-fullscreen": "Habilitar pantalla completa", + "enable-data-export": "Habilitar exportació de dades", + "background-color": "Color de fons", + "text-color": "Color del text", + "padding": "Farciment", + "margin": "Marge", + "widget-style": "Estil de widget", + "widget-css": "Widget CSS", + "title-style": "Estil de títol", + "mobile-mode-settings": "Configuració móvil.", + "order": "Orde", + "height": "Alçada", + "mobile-hide": "Amaga el widget en mode mòbil", + "units": "Caràcter especial a mostrar al següent valor", + "decimals": "Números de dígits després de la coma", + "timewindow": "Finestra del temps", + "use-dashboard-timewindow": "Utilitzar finestra del temps del panell", + "display-timewindow": "Mostrar finestra del temps", + "legend": "Llegenda", + "display-legend": "Mostrar llegenda", + "datasources": "Set de dades", + "maximum-datasources": "Un màxim de { count, plural, 1 {1 set de dades és permès.} other {# set de dades són permitidos} }", + "datasource-type": "Tipus", + "datasource-parameters": "Paràmetres", + "remove-datasource": "Eliminar set de dades", + "add-datasource": "Afegir set de dades", + "target-device": "Dispositiu destí", + "alarm-source": "Origen de alarmes", + "actions": "Accions", + "action": "Acció", + "add-action": "Afegir acció", + "search-actions": "Buscar accions", + "no-actions-text": "No s'han trobar accions", + "action-source": "Origen de acció", + "action-source-required": "Cal origen de acció.", + "action-name": "Nom", + "action-name-required": "Cal nom de acció.", + "action-name-not-unique": "Existe una acció amb el mateix nom.
    El nom d'acció ha de ser únic dins de la mateixa font d'acció (origen).", + "action-icon": "Icona", + "show-hide-action-using-function": "Mostra/amaga l'acció mitjançant la funció", + "action-type": "Tipus", + "action-type-required": "Cal tipus d'acció.", + "edit-action": "Editar acció", + "delete-action": "Esborrar acció", + "delete-action-title": "Esborrar acció de widget", + "delete-action-text": "Eliminar l'acció de widget amb el nom '{{actionName}}'?", + "title-icon": "Icona del títol", + "display-icon": "Mostrar icona del títol", + "icon-color": "Color de l'icona", + "icon-size": "Mida de l'icona", + "advanced-settings": "Configuració avançada", + "data-settings": "Configuració de dades", + "no-data-display-message": "\"No hi ha dades per mostrar\" missatge alternatiu", + "data-page-size": "Nombre màxim d'entitats per font de dades", + "settings-component-not-found": "Component de configuració no trobat per al selector '{{selector}}'" + }, + "widget-type": { + "import": "Importar tipus de widget", + "export": "Exportar tipus de widget", + "export-failed-error": "Impossible exportar tipus de widget: {{error}}", + "create-new-widget-type": "Crear nou tipus de widget", + "widget-type-file": "Arxiu de tipus de widget", + "invalid-widget-type-file-error": "No es pot importar tipus de widget: Estructura de dades del tipus de widget és invàlida." + }, + "solution-template": { + "solution-template": "Plantilla de solució", + "solution-templates": "Plantilles de solució", + "management": "Gestionar plantilles de solució", + "details": "Detalls", + "install": "Instal·lar", + "level": "Nivell", + "install-title": "La plantilla de solució s'ha instal·lat correctament", + "install-failed-title": "La instal·lació de la plantilla de solució ha fallat", + "instructions": "Instruccions", + "goto-main-dashboard": "Vés al tauler principal", + "delete": "Suprimeix", + "delete-solution-title": "Esteu segur que voleu suprimir la solució '{{solutionTitle}}'?", + "delete-solution-text": "Aneu amb compte, després de la confirmació, la solució i totes les dades relacionades es tornaran irrecuperables.", + "installing": "Instal·lació de la plantilla de solució..." + }, + "markdown": { + "edit": "Edita", + "preview": "Vista prèvia", + "copy-code": "Feu clic per copiar", + "copied": "Copiat!" + }, + "white-labeling": { + "white-labeling": "Etiquetatge Blanc", + "login-white-labeling": "Iniciar sessió de Etiquetado Blanco", + "preview": "Avanç", + "app-title": "Títol de l'aplicació", + "favicon": "Icona del lloc WEB", + "favicon-description": "Imatge *.ico, *.gif o *.png amb grandària màxima {{kbSize}} KBytes.", + "favicon-size-error": "Imatge del lloc web és molt gran. Mesura màxima permesa de la imatge del lloc web {{kbSize}} KBytes.", + "favicon-type-error": "Formato de arxiu de imagen de lloc WEB invàlid. Solamente es aceptan imágenes ICO, GIF o PNG.", + "drop-favicon-image": "Colocar la imagen del icona de un lloc WEB o haga clic per seleccionar un arxiu per carregar .", + "no-favicon-image": "Icona no seleccionat", + "logo": "Logo", + "logo-description": "Alguna imagen amb grandària màxima {{kbSize}} KBytes.", + "logo-size-error": "L'imatge del logo és molt gran. Mida màxima permesa de la imatge del logo {{kbSize}} KBytes.", + "logo-type-error": "Format darxiu del logotip invàlid. Només és accepten imatges.", + "drop-logo-image": "Colocar una imagen del logotipo o fes clic per seleccionar un arxiu per carregar .", + "no-logo-image": "Logo no seleccionat", + "logo-height": "Alçada del logo, píxel", + "primary-palette": "Paleta primària", + "accent-palette": "Paleta d'accents", + "customize-palette": "Personalitzar", + "advanced-css": "CSS avançat", + "edit-palette": "Editar paleta", + "save-palette": "Guardar paleta", + "primary-background": "Color de fons primari", + "secondary-background": "Color de fons secundari", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Color de fons de la pàgina", + "dark-foreground": "Primer pla fosc", + "domain-name": "Nom del domini", + "base-url": "Base URL", + "base-url-required": "Cal la base URL.", + "prohibit-different-url": "Prohibir l'ús del nom d'amfitrió de les capçaleres de la sol·licitud del client", + "prohibit-different-url-hint": "Aquesta configuració s'hauria d'habilitar per als entorns de producció. Pot causar problemes de seguretat quan està desactivat", + "help-link-base-url": "URL base dels enllaços d'ajuda", + "ui-help-base-url": "URL de la base d'ajuda de la IU", + "enable-help-links": "Activa els enllaços d'ajuda", + "error-verification-url": "Un nom de domini no ha de contenir els símbols '/' i ':'. Exemple: thingsboard.io", + "show-platform-name-version": "Mostra el nom i la versió de la plataforma", + "platform-name": "Nom de la plataforma", + "platform-version": "Versió de plataforma", + "version-mask": "{{name}} v.{{version}}", + "position": { + "label": "Nom de la plataforma i posició de la versió", + "under-logo": "Sota el logotip", + "bottom": "Part inferior del formulari d'inici de sessió" + } + }, + "widgets": { + "chart": { + "common-settings": "Configuració comuna", + "enable-stacking-mode": "Activar mode d'apilament (stacking)", + "line-shadow-size": "Grandària d'ombra (línia)", + "display-smooth-lines": "Mostra línies suaus (curvas)", + "default-bar-width": "Ample de barra per defecte per a dades no agregades (millisegundos)", + "bar-alignment": "Alineació de barres", + "bar-alignment-left": "Esquerra", + "bar-alignment-right": "Dreta", + "bar-alignment-center": "Centre", + "default-font-size": "Grandaria de la font per defecte", + "default-font-color": "Color de la font per defecte", + "thresholds-line-width": "Ample de línia per defecte per a tots els llindars", + "tooltip-settings": "Ajustaments de suggeriments (tooltip)", + "show-tooltip": "Mostra suggeriments", + "hover-individual-points": "Passa el cursor sobre punts individuals", + "show-cumulative-values": "Mostrar valors acumulats en mode apilat", + "hide-zero-false-values": "Amagar valors zero/false en suggeriments", + "tooltip-value-format-function": "Funció de formateig de valors en suggeriments (tooltip)", + "grid-settings": "Configuració de la graella", + "show-vertical-lines": "Mostrar línies verticales", + "show-horizontal-lines": "Mostrar línies horizontales", + "grid-outline-border-width": "Ample de quadrícula/contorn a px", + "primary-color": "Color primari", + "background-color": "Color de fons", + "ticks-color": "Color de ticks", + "xaxis-settings": "Configuració eix X", + "axis-title": "Títol d'eix", + "xaxis-tick-labels-settings": "Configuració d'etiquetes en ticks eix X", + "show-tick-labels": "Mostrar etiquetes en ticks", + "yaxis-settings": "Configuració eix Y", + "min-scale-value": "Valor mínim en l'escala", + "max-scale-value": "Valor máxim en l'escala", + "yaxis-tick-labels-settings": "Configuració 'etiquetes en ticks eix Y", + "tick-step-size": "Grandaria de pas entre ticks", + "number-of-decimals": "Número de decimals", + "ticks-formatter-function": "Funció de formateig de ticks", + "comparison-settings": "Configuració de comparació", + "enable-comparison": "Activar comparació", + "time-for-comparison": "Període de comparació", + "time-for-comparison-previous-interval": "Interval anterior (per defecte)", + "time-for-comparison-days": "Fa un dia", + "time-for-comparison-weeks": "Fa una setmana", + "time-for-comparison-months": "Fa un mes", + "time-for-comparison-years": "Fa un any", + "time-for-comparison-custom-interval": "Interval personalitzat", + "custom-interval-value": "Valor d'interval personalitzat (ms)", + "comparison-x-axis-settings": "Configuració comparació de l'eix X", + "axis-position": "Posició eix", + "axis-position-top": "Superior (por defecto)", + "axis-position-bottom": "Inferior", + "custom-legend-settings": "Configuració llegenda", + "enable-custom-legend": "Activar llegenda personalitzada (permet poder utilitzar valors d'atributs/series temporals a les etiquetes)", + "key-name": "Nom clau", + "key-name-required": "Cal nom clau", + "key-type": "Tipus clau", + "key-type-attribute": "Atribut", + "key-type-timeseries": "Sèries temporals", + "label-keys-list": "Llista de claus per utilitzar a etiquetes", + "no-label-keys": "No hi ha claus configurades", + "add-label-key": "Afegir nova clau", + "line-width": "Ample de línia", + "color": "Color", + "data-is-hidden-by-default": "Ocultar dades per defecte", + "disable-data-hiding": "Desactivar ocultació de dades", + "remove-from-legend": "Treure clau de la llegenda", + "exclude-from-stacking": "Excloure del mode apilat(disponible en el mode \"Apilat\")", + "line-settings": "Configuració de línia", + "show-line": "Mostrar línia", + "fill-line": "Omplir línia", + "points-settings": "Configuració de punts", + "show-points": "Mostrar punts", + "points-line-width": "Ample de línia en punts", + "points-radius": "Radi dels punts", + "point-shape": "Forma del punt", + "point-shape-circle": "Cercle", + "point-shape-cross": "Creu", + "point-shape-diamond": "Diamant", + "point-shape-square": "Quadrat", + "point-shape-triangle": "Triangle", + "point-shape-custom": "Funció personalitzada", + "point-shape-draw-function": "Funció de forma del punt", + "show-separate-axis": "Mostrar eix separat", + "axis-position-left": "Esquerra", + "axis-position-right": "Dreta", + "thresholds": "Llindars", + "no-thresholds": "No hi ha llindars configurats", + "add-threshold": "Afegir nou llindar", + "show-values-for-comparison": "Mostrar valors històrics per la seva comparació", + "comparison-values-label": "Etiqueta de valors històrics", + "threshold-settings": "Configuració de llindars", + "use-as-threshold": "Usar valor de clau com a llindar", + "threshold-line-width": "Ample de línia (per a llindar)", + "threshold-color": "Color llindar", + "common-pie-settings": "Configuració comunes diagrama de sectors", + "radius": "Radi", + "inner-radius": "Radi interior", + "tilt": "Inclinació", + "stroke-settings": "Configuració de traç", + "width-pixels": "Ample (pixels)", + "show-labels": "Mostrar etiquetes", + "animation-settings": "Configuració de animació", + "animated-pie": "Activar animació (experimental)", + "border-settings": "Configuració de vores", + "border-width": "Ancho de la vora", + "border-color": "Color de la vora", + "legend-settings": "Configuració de llegenda", + "display-legend": "Mostrar llegenda", + "labels-font-color": "Color de text en llegenda" + }, + "dashboard-state": { + "dashboard-state-settings": "Configuració estat del panell", + "dashboard-state": "Id del estat del panell", + "autofill-state-layout": "Autoremplenar alçada per defecte (obtinguda de l'estat)", + "default-margin": "Marge entre widgets per defecte", + "default-background-color": "Color de fons per defecte", + "sync-parent-state-params": "Sincronitzar paràmetres d'estat amb el panell pare" + }, + "date-range-navigator": { + "date-range-picker-settings": "Ajustaments del selector de dates", + "hide-date-range-picker": "Esmagar el selector de dades", + "picker-one-panel": "Selector de dates per a un panell", + "picker-auto-confirm": "Auto-confirmar al selector de dates", + "picker-show-template": "Mostra plantilla de selector de dates", + "first-day-of-week": "Primer dia de la setmana", + "interval-settings": "Paràmetres d'interval", + "hide-interval": "Ocultar interval", + "initial-interval": "Interval inicial", + "interval-hour": "Hora", + "interval-day": "Dia", + "interval-week": "Setmana", + "interval-two-weeks": "2 setmanes", + "interval-month": "Mes", + "interval-three-months": "3 mesos", + "interval-six-months": "6 mesos", + "step-settings": "Paràmetres de pas", + "hide-step-size": "Amagar mida de pas", + "initial-step-size": "Mida de pas inicial", + "hide-labels": "Ocultar etiquetes", + "use-session-storage": "Utilitzar emmagatzematge de sessió", + "localizationMap": { + "Sun": "Dg.", + "Mon": "Dl.", + "Tue": "Dm.", + "Wed": "Dc.", + "Thu": "Dj.", + "Fri": "Dv.", + "Sat": "Ds.", + "Jan": "Gen.", + "Feb": "Feb.", + "Mar": "Mar.", + "Apr": "Abr.", + "May": "Mai.", + "Jun": "Jun.", + "Jul": "Jul.", + "Aug": "Ago.", + "Sep": "Sept.", + "Oct": "Oct.", + "Nov": "Nov.", + "Dec": "Des.", + "January": "Gener", + "February": "Febrer", + "March": "Març", + "April": "Abril", + "June": "Juny", + "July": "Juliol", + "August": "Agost", + "September": "Setembre", + "October": "Octubre", + "November": "Novembre", + "December": "Desembre", + "Custom Date Range": "Interval de dates personalitzat", + "Date Range Template": "Plantilla de rang de dates", + "Today": "Avui", + "Yesterday": "Ahir", + "This Week": "Aquesta setmana", + "Last Week": "La setmana passada", + "This Month": "Aquest mes", + "Last Month": "El mes passat", + "Year": "Any", + "This Year": "Aquest any", + "Last Year": "Últim", + "Date picker": "Selector de data", + "Hour": "Hora", + "Day": "Dia", + "Week": "Setmana", + "2 weeks": "2 Setmanes", + "Month": "Mes", + "3 months": "3 Mesos", + "6 months": "6 Mesos", + "Custom interval": "Interval personalitzat", + "Interval": "Interval", + "Step size": "Nombre de passos", + "Ok": "Ok" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Configuració de dades de jerarquia", + "relations-query-function": "Funció d'obtenció de relacions", + "has-children-function": "El node té una funció filla", + "node-state-settings": "Paràmetres estat node", + "node-opened-function": "Funció per defecte en obrir node", + "node-disabled-function": "Funció amb node desactivat", + "display-settings": "Mostrar ajustaments", + "node-icon-function": "Funció d'icona node", + "node-text-function": "Funció de text node", + "sort-settings": "Paràmetres d'ordenació", + "nodes-sort-function": "Funció d'ordenació" + }, + "edge": { + "display-default-title": "Mostrar títol per defecte" + }, + "gateway": { + "general-settings": "Ajustaments generals", + "widget-title": "Títol del widget", + "default-archive-file-name": "Nom del fitxer per defecte", + "device-type-for-new-gateway": "Tipus de dispositiu per a nou gateway", + "messages-settings": "Paràmetres de missatges", + "save-config-success-message": "Missatge d'èxit enregistrant la configuració", + "device-name-exists-message": "Missatge de text quan el nom del dispositiu ja existeixi", + "gateway-title": "Formulari Gateway", + "read-only": "Només lectura", + "events-title": "Títol del formulari d'esdeveniments", + "events-filter": "Filtre d'esdeveniments", + "event-key-contains": "La clau d'esdeveniment conté..." + }, + "gauge": { + "default-color": "Color per defecte", + "radial-gauge-settings": "Paràmetres d'indicador radial", + "ticks-settings": "Ajustaments de ticks", + "min-value": "Valor mínim", + "max-value": "Valor màxim", + "start-ticks-angle": "Angle d'inici ticks", + "ticks-angle": "Angle Ticks", + "major-ticks-count": "Nombre de ticks principals", + "major-ticks-color": "Color Núm. de ticks principals", + "minor-ticks-count": "Nombre de ticks secundaris", + "minor-ticks-color": "Color Núm. de ticks secundaris", + "tick-numbers-font": "Font del tick", + "unit-title-settings": "Paràmetres d'unitat (títol)", + "show-unit-title": "Mostra unitats (títol)", + "unit-title": "Títol d'unitat", + "title-font": "Font de text del títol", + "units-settings": "Ajustaments d'unitats", + "units-font": "Font de text de les unitats", + "value-box-settings": "Paràmetres de valor", + "show-value-box": "Mostrar valor", + "value-int": "Nombre de dígits per a la part sencera del valor", + "value-font": "Font de text del valor", + "value-box-rect-stroke-color": "Color del traç del rectangle (valor)", + "value-box-rect-stroke-color-end": "Color del traç del rectangle (valor) - gradient final", + "value-box-background-color": "Color de fons del valor", + "value-box-shadow-color": "Color d'ombra del valor", + "plate-settings": "Ajustaments de la placa", + "show-plate-border": "Mostra vora de la placa", + "plate-color": "Color de la placa", + "needle-settings": "Paràmetres de l'agulla", + "needle-circle-size": "Mida del cercle de l'agulla", + "needle-color": "Color de l'agulla", + "needle-color-end": "Color de l'agulla - gradient final", + "needle-color-shadow-up": "Color d'ombreig de la meitat superior de l'agulla", + "needle-color-shadow-down": "Color d'ombra de l'agulla", + "highlights-settings": "Ajustaments de ressalt", + "highlights-width": "Amplada del ressalt", + "highlights": "Resalts", + "highlight-from": "De", + "highlight-to": "A", + "highlight-color": "Color", + "no-highlights": "No hi ha ressalts configurats", + "add-highlight": "Afegir ressalt", + "animation-settings": "Paràmetres d'animació", + "enable-animation": "Activar animació", + "animation-duration": "Duració d'animació", + "animation-rule": "Regla d'animació", + "animation-linear": "Lineal", + "animation-quad": "Quad", + "animation-quint": "Quint", + "animation-cycle": "Cicle (cycle)", + "animation-bounce": "Rebot (bounce)", + "animation-elastic": "Elastic", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "Paràmetres d'indicador lineal", + "bar-stroke-width": "Amplada del traç", + "bar-stroke-color": "Color del traç", + "bar-background-color": "Color de fons de l'indicador", + "bar-background-color-end": "Color de fons de l'indicador - gradient final", + "progress-bar-color": "Color de la barra de progrés", + "progress-bar-color-end": "Color de la barra de progrés - gradient final", + "major-ticks-names": "Nom de ticks principals", + "show-stroke-ticks": "Mostrar traç de ticks", + "major-ticks-font": "Font de ticks principals", + "border-color": "Color de la vora", + "border-width": "Amplada de la vora", + "needle-circle-color": "Color del cercle de l'agulla", + "animation-target": "Destinació de l'animació", + "animation-target-needle": "Agulla", + "animation-target-plate": "Placa", + "common-settings": "Ajustaments comuns de l'indicador", + "gauge-type": "Tipus d'indicador", + "gauge-type-arc": "Arc", + "gauge-type-donut": "Donut", + "gauge-type-horizontal-bar": "Barra horitzontal", + "gauge-type-vertical-bar": "Barra vertical", + "donut-start-angle": "Angle d'inici", + "bar-settings": "Paràmetres de barra indicadora", + "relative-bar-width": "Amplada relativa", + "neon-glow-brightness": "Efecte brillantor de neó, (0-100), 0 - desactiva efecte", + "stripes-thickness": "Grossor de les ratlles, 0 - sense ratlles", + "rounded-line-cap": "Mostra tapa de línia arrodonida", + "bar-color-settings": "Paràmetres de color de barra", + "use-precise-level-color-values": "Usar nivells precisos de color", + "bar-colors": "Colors de la barra, del més baix al més alt", + "color": "Color", + "no-bar-colors": "No hi ha colors configurats", + "add-bar-color": "Afegir color", + "from": "De", + "to": "A", + "fixed-level-colors": "Colors de barra usant valors límit", + "gauge-title-settings": "Paràmetres de títol de l'indicador", + "show-gauge-title": "Mostra títol d'indicador", + "gauge-title": "Títol d'indicador", + "gauge-title-font": "Font del títol de l'indicador", + "unit-title-and-timestamp-settings": "Paràmetres del títol d'unitats i timestamp", + "show-timestamp": "Mostrar valor timestamp", + "timestamp-format": "Format timestamp", + "label-font": "Font de l'etiqueta que es mostra sota el valor", + "value-settings": "Paràmetres del valor", + "show-value": "Mostra text del valor", + "min-max-settings": "Etiqueta mínim/màxim", + "show-min-max": "Mostra valors mínims i màxims", + "min-max-font": "Font dels valors mínim i màxim", + "show-ticks": "Mostrar ticks", + "tick-width": "Amplada de tick", + "tick-color": "Color de tick", + "tick-values": "Valors de tick", + "no-tick-values": "No hi ha valors configurats", + "add-tick-value": "Afegir valor de tick" + }, + "gpi": { + "pin": "Pin", + "label": "Etiqueta", + "row": "Fila", + "column": "Columna", + "color": "Color", + "panell-settings": "Paràmetres de panell", + "background-color": "Color de fons", + "gpio-switches": "switches GPIO", + "no-gpio-switches": "No hi ha switches GPIO configurats", + "add-gpio-switch": "Afegeix switch GPIO", + "gpio-status-request": "Sol·licitud estat GPIO", + "method-name": "Nom del mètode RPC", + "method-body": "Cos del mètode RPC", + "gpio-status-change-request": "Sol·licitud de canvi d'estat GPIO", + "parse-gpio-status-function": "Funció de parsejat d'estat GPIO", + "gpio-leds": "Leds GPIO", + "no-gpio-leds": "No hi ha Leds GPIO configurats", + "add-gpio-led": "Afegir led GPIO" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "El paràmetre d'atribut no es pot utilitzar aquest widget", + "blocked-location": "La funció de geolocalització està bloquejada al vostre navegador", + "claim-device": "Reclamar dispositiu", + "claim-failed": "Error al reclamar dispositiu!", + "claim-not-found": "Dispositiu no trobat!", + "claim-successful": "Dispositiu reclamat correctament!", + "date": "Data", + "device-name": "Nom del dispositiu", + "device-name-required": "Cal nom de dispositiu", + "discard-changes": "Descartar els canvis", + "entity-attribute-required": "Cal atribut d'entitat", + "entity-coordinate-required": "Cal tots dos camps (latitud i longitud)", + "entity-timeseries-required": "Cal la sèrie del temps de l'entitat", + "get-location": "Obtenir localització actual", + "invalid-date": "Data invàlida", + "latitude": "Latitud", + "longitude": "Longitud", + "min-value-error": "El valor mínim és {{value}}", + "max-value-error": "El valor màxim és {{value}}", + "not-allowed-entity": "La entitat seleccionada no pot tenir atributs compartits", + "no-attribute-selected": "No s'ha seleccionat cap atribut", + "no-datakey-selected": "No s'ha seleccionar cap clau de dades", + "no-coordinate-specified": "No s'ha especificat la clau per latitud/longitud", + "no-entity-selected": "Ninguna entitat seleccionada", + "no-image": "Sense imatge", + "no-support-geolocation": "El teu navegador no suporta geolocalització", + "no-support-web-camera": "No hi ha càmera web compatible", + "enable-https-use-widget": "Per favor, activa HTTPS per poder utilitzar aquest widget", + "no-found-your-camera": "No és possible trobar la càmera", + "no-permission-camera": "Permís denegat per l'usuari / Aquesta pàgina no té permisos per utilitzar la càmera", + "no-timeseries-selected": "No hi ha series del temps seleccionades", + "secret-key": "Clau", + "secret-key-required": "Cal clau", + "switch-attribute-value": "Canviar el valor de l'atribut d'entitat", + "switch-camera": "Canviar de càmera", + "switch-timeseries-value": "Canviar el valor de la sèrie del temps de l'entitat", + "take-photo": "Prendre foto", + "time": "Temps", + "timeseries-not-allowed": "El paràmetre Sèries temporals no es pot utilitzar en aquest widget", + "update-failed": "Actualizació fallida", + "update-successful": "Actualizació exitosa", + "update-attribute": "Actualizar atribut", + "update-timeseries": "Actualizar series del temps", + "general-settings": "Ajustaments Generals", + "widget-title": "Títol del giny", + "claim-button-label": "Etiqueta del botó de reclamar", + "show-secret-key-field": "Mostrar el camp 'Clave Secreta'", + "labels-settings": "Paràmetres d'etiquetes", + "show-labels": "Mostrar etiquetes", + "device-name-label": "Etiqueta per al camp d'entrada 'Nom de dispositiu'", + "secret-key-label": "Etiqueta per al camp d'entrés 'Clave Secreta'", + "messages-settings": "Paràmetres de missatges", + "claim-device-success-message": "Missatge a mostrar quan el dispositiu s'hagi reclamat ok", + "claim-device-not-found-message": "Missatge a mostrar quan el dispositiu no estigui", + "claim-device-failed-message": "Missatge a mostrar quan es produeixi un error reclamant el dispositiu", + "claim-device-name-required-message": "Missatge d'error a mostrar amb 'Nom de dispositiu requerit'", + "claim-device-secret-key-required-message": "Missatge d'error a mostrar amb 'Clave Secreta requerida'", + "show-label": "Mostrar etiqueta", + "label": "Etiqueta", + "required": "Requerit", + "required-error-message": "Error a mostrar amb camp 'Requerit'", + "show-result-message": "Missatge per mostrar resultat", + "integer-field-settings": "Paràmetres de camps sencers", + "min-value": "Valor Mínim", + "max-value": "Valor Màxim", + "double-field-settings": "Paràmetres de camps tipus double", + "text-field-settings": "Paràmetres de camp tipus text", + "min-length": "Longitud mínima", + "max-length": "Longitud màxima", + "checkbox-settings": "Paràmetres de camp tipus checkbox", + "true-label": "Etiqueta checked", + "false-label": "Etiqueta unchecked", + "image-input-settings": "Paràmetres de camp tipus d'imatge", + "display-preview": "Mostrar previsualització", + "display-clear-button": "Mostrar botó d'esborrar", + "display-apply-button": "Mostrar botó d'aplicar", + "display-discard-button": "Mostrar botó de descartar", + "datetime-field-settings": "Paràmetres de camp tipus Data/Hora", + "display-time-input": "Mostra l'entrada d'hora", + "latitude-key-name": "Nom clau latitud", + "longitude-key-name": "Nom clau longitud", + "show-get-location-button": "Mostrar botó 'Obtenir localització actual'", + "use-high-accuracy": "Usar alta precisió", + "location-fields-settings": "Paràmetres de camps de localització", + "latitude-label": "Etiqueta per a latitud", + "longitude-label": "Etiqueta per a longitud", + "input-fields-alignment": "Alineat de camps d'entrada", + "input-fields-alignment-column": "Columna (per defecte)", + "input-fields-alignment-row": "Fila", + "latitude-field-required": "Camp latitud requerit", + "longitude-field-required": "Camp longitud requerit", + "attribute-settings": "Paràmetres d'atributs", + "widget-mode": "Mode del giny", + "widget-mode-update-attribute": "Actualitzar atribut", + "widget-mode-update-timeseries": "Actualitzar timeseries", + "attribute-scope": "Abast d'atributs", + "attribute-scope-server": "Atributs de servidor", + "attribute-scope-shared": "Atributs compartits", + "value-required": "Valor requerit", + "image-settings": "Paràmetres d'imatge", + "image-format": "Format d'imatge", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Qualitat d'imatge que utilitza compressió amb pèrdua com jpeg i webp", + "max-image-width": "Màxim ample d'imatge", + "max-image-height": "Màxim alt d'imatge", + "action-buttons": "Botons d'acció", + "show-action-buttons": "Mostra botons d'acció", + "update-all-values": "Actualitzar tots els valors, no només els modificats", + "save-button-label": "Etiqueta de botó 'GRAVAR'", + "reset-button-label": "Etiqueta de botó 'DESFER'", + "group-settings": "Paràmetres de grup", + "show-group-title": "Mostrar títol per al grup de camps relacionats amb diferents entitats", + "group-title": "Títol del grup", + "fields-alignment": "Alineat de camps", + "fields-alignment-row": "Fila (per defecte)", + "fields-alignment-column": "Columna", + "fields-in-row": "Nombre de camps a la fila", + "option-value": "Valor (escriu 'null' per crear una opció buida)", + "option-label": "Etiqueta", + "hide-input-field": "Amagar camp d'entrada", + "datakey-type": "Tipus de clau de dades", + "datakey-type-server": "Atribut de servidor (per defecte)", + "datakey-type-shared": "Atribut compartit", + "datakey-type-timeseries": "Timeseries", + "datakey-value-type": "Tipus de valor de la clau de dades", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Enter", + "datakey-value-type-boolean-checkbox": "Boolean (Checkbox)", + "datakey-value-type-boolean-switch": "Boolean (Switch)", + "datakey-value-type-date-time": "Data & Hora", + "datakey-value-type-date": "Data", + "datakey-value-type-time": "Hora", + "datakey-value-type-select": "Selecció", + "value-is-required": "Valor requerit", + "ability-to-edit-attribute": "Possibilitat d'editar atribut", + "ability-to-edit-attribute-editable": "Editable (per defecte)", + "ability-to-edit-attribute-disabled": "Desactivat", + "ability-to-edit-attribute-readonly": "Només lectura", + "disable-on-datakey-name": "Desactivar quan una altra clau de dades sigui false (especifiqueu el nom de clau de dades)", + "slide-toggle-settings": "Configuració del botó lliscant", + "slide-toggle-label-position": "Posició d'etiqueta", + "slide-toggle-label-position-after": "Després", + "slide-toggle-label-position-before": "Abans", + "select-options": "Seleccionar opcions", + "no-select-options": "No hi ha opcions configurades", + "add-select-option": "Afegir opció", + "numeric-field-settings": "Paràmetres de camps numèrics", + "step-interval": "Passos entre valors (interval)", + "error-messages": "Missatges d'erro", + "min-value-error-message": "Missatge d'error de 'Valor Mínim'", + "max-value-error-message": "Missatge d'error de 'Valor Màxim'", + "invalid-date-error-message": "Missatge d'error de 'Data Invàlida'", + "icon-settings": "Configuració d'icones", + "use-custom-icon": "Usar icona personalitzada", + "input-cell-icon": "Icona a mostrar abans del camp d'entrada", + "value-conversion-settings": "Paràmetres de conversió de valor", + "get-value-settings": "Paràmetres d'obtenció de valors", + "use-get-value-function": "Fer servir getValue", + "get-value-function": "Funció getValue", + "set-value-settings": "Paràmetres d'establiment de valors", + "use-set-value-function": "Usar funció setValue", + "set-value-function": "Funció setValue" + }, + "qr-code": { + "use-qr-code-text-function": "Utilitzar funció de text QR", + "qr-code-text-pattern": "Patró del codi QR (per ex. '${entityName} | ${keyName} - text addicional.')", + "qr-code-text-pattern-required": "Es requereix patró del codi QR.", + "qr-code-text-function": "Funció del codi QR" + }, + "label-widget": { + "label-pattern": "Patró", + "label-pattern-hint": "Ajuda: ex. 'Text ${keyName} unitats.' o ${#<key index>} ; unitats'", + "label-pattern-required": "Es requereix patró", + "label-position": "Posició (Percentatge relatiu al fons)", + "x-pos": "X", + "i-pos": "Y", + "background-color": "Color de fons", + "font-settings": "Paràmetres de font", + "background-image": "Imatge de fons", + "labels": "Etiquetes", + "no-labels": "No hi ha etiquetes configurades", + "add-label": "Afegir etiqueta" + }, + "navigation": { + "title": "Títol", + "navigation-path": "Ruta de navegació", + "filter-type": "Tipus de filtre", + "filter-type-all": "Tots els objectes", + "filter-type-include": "Incloure objectes", + "filter-type-exclude": "Excloure objectes", + "items": "Objectes", + "enter-urls-to-filter": "Especificar URLs a filtrar..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Tipus de missatge", + "method": "Mètode", + "params": "Paràmetres", + "created-time": "Temps creat", + "expiration-time": "Temps de caducitat", + "retries": "Reintents", + "status": "Estat", + "filter": "Filtre", + "refresh": "Actualització", + "add": "Cal afegeix RPC ", + "details": "Detalls", + "delete": "Suprimeix", + "delete-request-title": "Cal suprimir RPC persistent", + "delete-request-text": "Esteu segur que voleu suprimir la sol·licitud?", + "details-title": "Detalls RPC ID: ", + "additional-info": "Informació addicional", + "response": "Resposta", + "any-status": "Qualsevol estat", + "rpc-status-list": "Llista d'estats RPC", + "no-request-prompt": "No hi ha cap sol·licitud per mostrar", + "send-request": "Enviar sol·licitud", + "add-title": "Crea una sol·licitud RPC persistent", + "method-error": "Cal mètode.", + "timeout-error": "El valor mínim de temps d'espera és 5000 (5 segons).", + "white-space-error": "No es permet l'espai en blanc.", + "rpc-status": { + "QUEUED": "A LA CUA", + "SENT": "ENVIAT", + "DELIVERED": "LLIURAT", + "SUCCESSFUL": "ÈXIT", + "TIMEOUT": "TEMPS D'EXPLORACIÓ", + "EXPIRED": "CADUCAT", + "FAILED": "FALLADA" + }, + "rpc-search-status-all": "TOTS", + "message-types": { + "false": "Bidireccional", + "true": "Una direcció" + }, + "general-settings": "Ajustaments Generals", + "enable-filter": "Activar filtre", + "enable-sticky-header": "Mostrar encapçalat mentre es fa scroll", + "enable-sticky-action": "Mostra columna d'accions mentre es fa scroll", + "display-request-details": "Mostrar detalls de petició", + "allow-send-request": "Permetre enviar petició RPC", + "allow-delete-request": "Permetre esborrar petició", + "columns-settings": "Paràmetres de columnes", + "display-columns": "Columnes per mostrar", + "column": "Columna", + "no-columns-found": "No s'han trobat columnes", + "no-columns-matching": "'{{column}}' no trobada." + }, + "rpc": { + "value-settings": "Paràmetres de valor", + "initial-value": "Valor inicial", + "retrieve-value-settings": "Obtenir ajustaments valors on/off", + "retrieve-value-method": "Obtenir valor usant mètode", + "retrieve-value-method-none": "No obtenir", + "retrieve-value-method-rpc": "Mètode per a anomenada RPC d'obtenció de valor", + "retrieve-value-method-attribute": "Subscriure per atribut", + "retrieve-value-method-timeseries": "Subscriure per timeseries", + "attribute-value-key": "Clau d'atribut", + "timeseries-value-key": "Clau de timeseries", + "get-value-method": "Mètode per obtenir valor via RPC", + "parse-value-function": "Funció de parseig de valor", + "update-value-settings": "Paràmetres d'actualització de valor", + "set-value-method": "Mètode per establir valor via RPC", + "convert-value-function": "Funció de conversió", + "rpc-settings": "Paràmetres RPC", + "request-timeout": "Timeout de petició RPC (ms)", + "persistent-rpc-settings": "Paràmetres de RPC persistent", + "request-persistent": "Petició RPC persistent", + "persistent-polling-interval": "Interval de sondeig (ms) per obtenir resposta de l'ordre RPC persistent", + "common-settings": "Ajustaments comuns", + "switch-title": "Títol del switch", + "show-on-off-labels": "Mostrar etiquetes on/off", + "slide-toggle-label": "Etiqueta d'interruptor lliscant", + "label-position": "Posició d'etiqueta", + "label-position-before": "Abans", + "label-position-after": "Després", + "slider-color": "Color del botó lliscant", + "slider-color-primary": "Primari", + "slider-color-accent": "Accent", + "slider-color-warn": "Avís", + "button-style": "Estil de botó", + "button-raised": "Botó aixecat", + "button-primary": "Color primari", + "button-background-color": "Color de fons", + "button-text-color": "Color de text", + "widget-title": "Títol del giny", + "button-label": "Etiqueta del botó", + "device-attribute-scope": "Abast d'atributs del dispositiu", + "server-attribute": "Atributs de servidor", + "shared-attribute": "Atributs compartits", + "device-attribute-parameters": "Paràmetres d'atributs del dispositiu", + "is-one-way-command": "És una ordre d'una via (one way)", + "rpc-method": "Mètode RPC", + "rpc-method-params": "Paràmetres mètode RPC", + "show-rpc-error": "Mostra errors d'execució de RPC", + "led-title": "Títol LED", + "led-color": "Color LED", + "check-status-settings": "Paràmetres de comprovació d'estat", + "perform-rpc-status-check": "Realitzar comprovació del dispositiu RPC", + "retrieve-led-status-value-method": "Obtenir estat del led usant mètode", + "led-status-value-attribute": "Atribut del dispositiu que conté el valor de l'estat del led", + "led-status-value-timeseries": "Timeseries del dispositiu que conté el valor de l'estat del led", + "check-status-method": "Mètode RPC per revisar l'estat del dispositiu", + "parse-led-status-value-function": "Funció de parseig per a l'estat del led", + "knob-title": "Títol de comandament", + "min-value": "Valor mínim", + "max-value": "Valor màxim" + }, + "maps": { + "select-entity": "Seleccioneu l'entitat", + "select-entity-hint": "Consell: després de la selecció, feu clic al mapa per establir la posició", + "tooltips": { + "placeMarker": "Feu clic per col·locar-lo '{{entityName}}' entitat", + "firstVertex": "Polígon per '{{entityName}}': feu clic per col·locar el primer punt", + "firstVertex-cut": "Click to place first point", + "continueLine": "Polígon per '{{entityName}}': feu clic per continuar dibuixant", + "continueLine-cut": "Feu clic per continuar dibuixant", + "finishLine": "Feu clic a qualsevol marcador existent per acabar", + "finishPoly": "Polígon per '{{entityName}}': feu clic al primer marcador per acabar i desar", + "finishPoly-cut": "Feu clic al primer marcador per acabar i desar", + "finishRect": "Polígon per '{{entityName}}': feu clic per acabar i desar", + "startCircle": "Cercle per '{{entityName}}': feu clic per col·locar el centre del cercle", + "finishCircle": "Cercle per '{{entityName}}': feu clic per acabar el cercle", + "placeCircleMarker": "Feu clic per col·locar el marcador de cercle" + }, + "actions": { + "finish": "Acabar", + "cancel": "Cancel·lar", + "removeLastVertex": "Elimina l'últim punt" + }, + "buttonTitles": { + "drawMarkerButton": "Entitat de lloc", + "drawPolyButton": "Crea un polígon", + "drawLineButton": "Crear polilínia", + "drawCircleButton": "Crea un cercle", + "drawRectButton": "Crea un rectangle", + "editButton": "Mode d'edició", + "dragButton": "Mode arrossegar i deixar anar", + "cutButton": "Retalla l'àrea del polígon", + "deleteButton": "Eliminar", + "drawCircleMarkerButton": "Crea un marcador de cercle", + "rotateButton": "Gira el polígon" + }, + "map-provider-settings": "Paràmetres proveïdor de mapes", + "map-provider": "Proveïdor de mapes", + "map-provider-google": "Google maps", + "map-provider-openstreet": "OpenStreet maps", + "map-provider-here": "HERE maps", + "map-provider-image": "Mapa d'imatge", + "map-provider-tencent": "Tencent maps", + "openstreet-provider": "Proveïdor OpenStreet map", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Default)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Usar proveïdor personalitzat", + "custom-provider-tile-url": "URL de proveïdor personalitzat", + "google-maps-api-key": "API Key Google Maps", + "default-map-type": "Tipus de mapa per defecte", + "google-map-type-roadmap": "Carretera", + "google-map-type-satelite": "Satelite", + "google-map-type-hybrid": "Híbrid", + "google-map-type-terrain": "Terreny", + "map-layer": "Capa de mapa", + "here-map-normal-day": "HERE.normalDay (Defecte)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Credencials", + "here-app-id": "HERE app id", + "here-app-code": "HERE app code", + "tencent-maps-api-key": "API Key Tencent Maps", + "tencent-map-type-roadmap": "Carretera", + "tencent-map-type-satelite": "Satelite", + "tencent-map-type-hybrid": "Híbrid", + "image-map-background": "Fons de mapa d'imatge", + "image-map-background-from-entity-attribute": "Obtenir imatge de fons des d'un atribut de l'entitat", + "image-url-source-entity-alias": "Àlies importants per a URL d'imatge", + "image-url-source-entity-attribute": "Atribut d'entitat per a URL d'imatge", + "common-map-settings": "Paràmetres comuns de mapes", + "x-pos-key-name": "Clau per a posició X", + "y-pos-key-name": "Clau per a posició I", + "latitude-key-name": "Clau per a latitud", + "longitude-key-name": "Clau per a longitud", + "default-map-zoom-level": "Nivell de zoom per defecte (0 - 20)", + "default-map-center-position": "Posició central del mapa per defecte (0,0)", + "disable-scroll-zooming": "Desactivar zoom a scroll", + "disable-zoom-control-buttons": "Desactivar botons de zoom", + "fit-map-bounds": "Ajustar els límits del mapa per cobrir tots els marcadors", + "use-default-map-center-position": "Usar posició central per defecte", + "entities-limit": "Límit d'entitats a carregar", + "markers-settings": "Paràmetres dels marcadors", + "marker-offset-x": "Offset X relatiu a la posició multiplicada per l'amplada del marcador", + "marker-offset-y": "Offset I relatiu a la posició multiplicat per l'alt del marcador", + "position-function": "Funció de conversió de posició, ha de retornar coordenades x,i com a valor double de 0 a 1", + "draggable-marker": "Marcador arrossegable", + "label": "Etiqueta", + "show-label": "Mostrar etiqueta", + "use-label-function": "Usar funció d'etiqueta", + "label-pattern": "Etiqueta (Exemples de patró: '${entityName}', '${entityName}: (Text ${keyName} unitats.)' )", + "label-function": "Funció d'etiqueta", + "tooltip": "Suggeriments (tooltip)", + "show-tooltip": "Mostrar suggeriments", + "show-tooltip-action": "Acció per mostrar els suggeriments", + "show-tooltip-action-click": "Mostrar suggeriments tooltip a click (per defecte)", + "show-tooltip-action-hover": "Mostrar suggeriments on hover", + "auto-close-tooltips": "Auto-tancar suggeriments", + "use-tooltip-function": "Funció de suggeriments", + "tooltip-pattern": "Suggeriment (per ex. 'Text ${keyName} unitats.' o Text Link')", + "tooltip-function": "Funció de suggeriment", + "tooltip-offset-x": "Offset X relatiu a l'àncora del marcador multiplicat per l'amplada", + "tooltip-offset-y": "Offset I relatiu a l'àncora del marcador multiplicat per l'alçada", + "color": "Color", + "use-color-function": "Utilitzar funció de color", + "color-function": "Funció de color", + "marker-image": "Imatge de marcador", + "use-marker-image-function": "Usar funció d'imatge de marcador", + "custom-marker-image": "Imatge de marcador personalitzada", + "custom-marker-image-size": "Mida d'imatge personalitzada (px)", + "marker-image-function": "Funció d'imatge de marcador", + "marker-images": "Imatges de marcador", + "polygon-settings": "Paràmetres de polígon", + "show-polygon": "Mostrar polígon", + "polygon-key-name": "Clau del polígon", + "enable-polygon-edit": "Polígon editabli", + "polygon-label": "Etiqueta del polígon", + "show-polygon-label": "Mostra etiqueta del polígon", + "use-polygon-label-function": "Utilitzar funcions d'etiqueta al polígon", + "polygon-label-pattern": "Etiqueta del polígon (Exemples de patró: '${entityName}', '${entityName}: (Text ${keyName} unitats.)' )", + "polygon-label-function": "Funció d'etiqueta del polígon", + "polygon-tooltip": "Suggeriment polígon", + "show-polygon-tooltip": "Mostrar suggeriments", + "auto-close-polygon-tooltips": "Auto-tancar suggeriments polígon", + "use-polygon-tooltip-function": "Utilitzar funció de suggeriments al polígon", + "polygon-tooltip-pattern": "Suggeriments (per ex. 'Text ${keyName} unitats.' o Text Link')", + "polygon-tooltip-function": "Funció de suggeriment de polígon", + "polygon-color": "Color de polígon", + "polygon-opacity": "Opacitat de polígon", + "use-polygon-color-function": "Utilitzar funció de color al polígon", + "polygon-color-function": "Funció de color de polígon", + "polygon-stroke": "Traç de polígon", + "stroke-color": "Color de traç", + "stroke-opacity": "Opacitat de traç", + "stroke-weight": "Pes de traç", + "use-polygon-stroke-color-function": "Utilitzar funció de color de traç al polígon", + "polygon-stroke-color-function": "Funció de color de traç", + "circle-settings": "Ajustaments de cercle", + "show-circle": "Mostrar cercle", + "circle-key-name": "Clau de cercle", + "enable-circle-edit": "Activar edició de cercle", + "circle-label": "Etiqueta de cercle", + "show-circle-label": "Mostra etiqueta de cercle", + "use-circle-label-function": "Utilitzar funció per a l'etiqueta de cercle", + "circle-label-pattern": "Etiqueta de cercle (Exemples patró: '${entityName}', '${entityName}: (Text ${keyName} unitats.)' )", + "circle-label-function": "Funció d'etiqueta de cercle", + "cercle-tooltip": "Suggeriments de cercle", + "show-circle-tooltip": "Mostrar suggeriments de cercle", + "auto-close-circle-tooltips": "Auto-tancar suggeriments", + "use-circle-tooltip-function": "Utilitzar funció de suggeriments en cercle", + "circle-tooltip-pattern": "Suggeriment (per ex. 'Text ${keyName} unitats.' o Text Link')", + "circle-tooltip-function": "Funció de suggeriment cercle", + "cercle-fill-color": "Color de farciment cercle", + "circle-fill-color-opacity": "Opacitat color de farciment", + "use-circle-fill-color-function": "Fer servir funció de color de farciment", + "circle-fill-color-function": "Funció de color de farciment", + "circle-stroke": "Traç del cercle", + "use-circle-stroke-color-function": "Fer servir funció de color de traç", + "circle-stroke-color-function": "Funció de color de traç", + "markers-clustering-settings": "Paràmetres d'agrupació de marcadors", + "use-map-markers-clustering": "Usar agrupació de marcadors al mapa", + "zoom-on-cluster-click": "Zoom quan es faci clic en un grup", + "max-cluster-zoom": "Nivell màxim de zoom en què un marcador pot ser part d'un grup (0 - 18)", + "max-cluster-radius-pixels": "Ràdio màxim que un grup cobreix en píxels", + "cluster-zoom-animation": "Mostrar animació en marcadors quan es faci zoom", + "show-markers-bounds-on-cluster-mouse-over": "Mostrar els límits dels marcadors quan el ratolí passi per sobre d'un grup", + "spiderfy-max-zoom-level": "Spiderfy al màxim nivell de zoom (per veure tots els marcadors)", + "load-optimization": "Optimització de càrrega", + "cluster-chunked-loading": "Fer servir fragments per afegir marcadors perquè la pàgina no es congeli", + "cluster-markers-lazy-load": "Utilitzeu lazy load en afegir marcadors", + "editor-settings": "Paràmetres de l'editor", + "enable-snapping": "Habilitar ajustament a altres vèrtexs per dibuixar amb precisió", + "init-draggable-mode": "Inicialitzar mapa en mode arrossegament", + "hide-all-edit-buttons": "Amagar tots els botons d'edició", + "hide-draw-buttons": "Ocultar botons de dibuix", + "hide-edit-buttons": "Amagar botons d'edició", + "hide-remove-button": "Amagar botons d'esborrat", + "route-map-settings": "Paràmetres de mapa de ruta", + "trip-animation-settings": "Paràmetres d'animació de ruta", + "normalization-step": "Passos de normalització (ms)", + "tooltip-background-color": "Color de fons del suggeriment", + "tooltip-font-color": "Color de font dels suggeriments", + "tooltip-opacity": "Opacitat dels suggeriments (0-1)", + "auto-close-tooltip": "Auto-tancar suggeriments", + "rotation-angle": "Angle de rotació addicional per al marcador (graus)", + "path-settings": "Paràmetres de ruta", + "path-color": "Color de ruta", + "use-path-color-function": "Utilitzar funció de color de ruta", + "path-color-function": "Funció de color de ruta", + "path-decorator": "Decorador de ruta", + "use-path-decorator": "Utilitzar decorador de ruta", + "decorator-symbol": "Símbol del decorador", + "decorator-symbol-arrow-head": "Fletxa", + "decorator-symbol-dash": "Estrella", + "decorator-symbol-size": "Mida del decorador (px)", + "use-path-decorator-custom-color": "Usar color personalitzat al decorador", + "decorator-custom-color": "Color personalitzat del decorador", + "decorator-offset": "Offset decorador", + "end-decorator-offset": "Offset final del decorador", + "decorator-repeat": "Repetició del decorador", + "points-settings": "Ajustaments de punts", + "show-points": "Mostrar punts", + "point-color": "Color de punts", + "point-size": "Mida de punts (px)", + "use-point-color-function": "Utilitzar funció de color de punts", + "point-color-function": "Funció de color de punts", + "use-point-as-anchor": "Usar punt com àncora", + "point-as-anchor-function": "Funció de punt com a àncora", + "independent-point-tooltip": "Suggeriment independent en punt" + }, + "markdown": { + "use-markdown-text-function": "Fer servir markdown/HTML", + "markdown-text-function": "Funció de valor Markdown/HTML", + "markdown-text-pattern": "Patró de Markdown/HTML (markdown o HTML amb variables, per ex. '${entityName} o ${keyName} - text.')", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label-position": "Posició etiqueta", + "label-position-left": "Esquerra", + "label-position-top": "Superior" + }, + "table": { + "common-table-settings": "Ajustaments comuns en taules", + "enable-search": "Activar cerca", + "enable-sticky-header": "Mostrar sempre la capçalera", + "enable-sticky-action": "Mostra sempre la columna d'accions", + "hidden-cell-button-display-mode": "Visualització de botons d'acció oculta", + "show-empty-space-hidden-action": "Mostrar espai buit en lloc de cel·la oculta", + "dont-reserve-space-hidden-action": "No reserveu espai per als botons en cel·la oculta", + "display-timestamp": "Mostra columna timestamp", + "display-milliseconds": "Mostrar mil·lisegons", + "display-pagination": "Mostrar pàgines", + "default-page-size": "Mida de pàgina per defecte", + "use-entity-label-tab-name": "Usar etiqueta important al nom de la taula", + "hide-empty-lines": "Ocultar línies buides", + "row-style": "Estil de fila", + "use-row-style-function": "Utilitzar funció d'estil de fila", + "row-style-function": "Funció d'estil de fila", + "cell-style": "Estil de cel·la", + "use-cell-style-function": "Utilitzar funció d'estil de cel·la", + "cell-style-function": "Funció d'estil de cel·la", + "cell-content": "Contingut de cel·la", + "use-cell-content-function": "Utilitzar funció de contingut de cel·la", + "cell-content-function": "Funció de contingut de cel·la", + "show-latest-data-column": "Mostra columna de darreres dades", + "latest-data-column-order": "Ordre de columna d'últimes dades", + "entities-table-title": "Títol de taula d'entitats", + "enable-select-column-display": "Activar possibilitat de seleccionar columnes a mostrar", + "display-entity-name": "Mostra columna de nom important", + "entity-name-column-title": "Títol de columna en nom d'entitat", + "display-entity-label": "Mostra columna d'etiqueta important", + "entity-label-column-title": "Títol de columna a l'etiqueta important", + "display-entity-type": "Mostra columna de tipus important", + "default-sort-order": "Ordenació per defecte", + "column-width": "Amplada de columna (px o %)", + "default-column-visibility": "Visibilitat per defecte a columna", + "column-visibility-visible": "Visible", + "column-visibility-hidden": "Oculta", + "column-selection-to-display": "Selecció de columnes a 'Columnes a Mostra'", + "column-selection-to-display-enabled": "Activada", + "column-selection-to-display-disabled": "Desactivada", + "alarms-table-title": "Títol de taula d'alarmes", + "enable-alarms-selection": "Activar selecció d'alarmes", + "enable-alarms-search": "Activar cerca d'alarmes", + "enable-alarm-filter": "Activar filtre d'alarmes", + "display-alarm-details": "Mostra detalls d'alarma", + "allow-alarms-ack": "Permetre reconeixement d'alarmes", + "allow-alarms-clear": "Permetre esborrat d'alarmes" + }, + "value-source": { + "value-source": "Origen valor", + "predefined-value": "Valor predefinit", + "entity-attribute": "Valor pres d'un atribut important", + "value": "Valor", + "source-entity-alias": "Àlies entitat d'origen", + "source-entity-attribute": "Atribut entitat d'origen" + }, + "widget-font": { + "font-family": "Família (font family)", + "size": "Mida", + "relative-font-size": "Mida font relativa (percentatge)", + "font-style": "Estil", + "font-style-normal": "Normal", + "font-style-italic": "Cursiva", + "font-style-oblique": "Subratllada", + "font-weight": "Pes", + "font-weight-normal": "Normal", + "font-weight-bold": "Negreta", + "font-weight-bolder": "Negreta+", + "font-weight-lighter": "Lighter", + "color": "Color", + "shadow-color": "Color ombra" + }, + "subscription": { + "entity-limit-text": "Tanmateix, podeu actualitzar el vostre pla de subscripció per augmentar els vostres límits.", + "upgrade-your-plan": "Actualitza el pla de subscripció", + "white-labeling-feature": "Característica d'etiqueta blanca", + "white-labeling-text-full": "Canvia la marca de la interfície web de la plataforma ThingsBoard amb el logotip i l'esquema de colors de la teva empresa o producte en 2 minuts.

    Elimineu 'Powered By' al peu de pàgina del tauler.
    No cal codificar ni reiniciar el servei. Permet als vostres clients també marcar en blanc la seva interfície.", + "enable-white-labeling": "Activa ara la funció d'etiqueta blanca actualitzant el teu pla de subscripció!", + "read-more": "Read more", + "white-labeling-video-text": "Vegeu el vídeo tutorial a continuació per veure com funciona aquesta funció!" + }, + "subscription-error": { + "title": "Incompliment de la subscripció", + "warning-title": "Avís de subscripció", + "upgrade-subscription-plan": "Si us plau, actualitzeu el vostre pla de subscripció", + "upgrade-subscription-plan-to-install-solution-template": "Per tal d'instal·lar {{solutionTemplateName}} solució, heu d'actualitzar la vostra subscripció almenys al {{planName}} pla!", + "limit-reached": { + "device-count": "Heu arribat al màxim de dispositius ({{value}}) permisos per al vostre pla de subscripció!", + "asset-count": "Heu assolit el màxim d'actius ({{value}}) permisos per al vostre pla de subscripció!" + } + } + }, + "icon": { + "icon": "Icona", + "select-icon": "Seleccionar icones", + "material-icons": "Icones material-disseny", + "show-all": "Mostrar totes les icones" + }, + "phone-input": { + "phone-input-label": "Número de telèfon", + "phone-input-required": "Cal número de telèfon", + "phone-input-validation": "El número és invàlid o erroni", + "phone-input-pattern": "Número invàlid. Ha de cumplir el format E.164, ej. {{phoneNumber}}", + "phone-input-hint": "Número en el format E.164, ej. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Acció botó de cel·la", + "row-click": "En clic de fila", + "polygon-click": "Clic al polígon", + "marker-click": "En clic de marcador", + "circle-click": "Al cercle clic", + "tooltip-tag-action": "Acció de l'etiqueta Tooltip", + "node-selected": "Clic en el node seleccionat", + "element-click": "Clic en el element HTML", + "pie-slice-click": "Clic en la porció", + "row-double-click": "Doble clic en la fila" + } + }, + "language": { + "language": "Llenguatge" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json new file mode 100644 index 0000000..a194bf3 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json @@ -0,0 +1,3101 @@ +{ + "access": { + "unauthorized": "Neautorizováno", + "unauthorized-access": "Neautorizovaný přístup", + "unauthorized-access-text": "Musíte se zaregistrovat, abyste měli přístup k tomuto zdroji!", + "access-forbidden": "Přístup zakázán", + "access-forbidden-text": "Pro přístup do tohoto umístění nemáte přístupová oprávnění!
    Pokud si stále přejete získat přístup do tohoto umístění, zkuste se zaregistrovat pod jiným uživatelem.", + "refresh-token-expired": "Relace vypršela", + "refresh-token-failed": "Nemohu aktualizovat relaci", + "permission-denied": "Oprávnění zamítnuto", + "permission-denied-text": "Pro provedení této operace nemáte oprávnění!" + }, + "action": { + "activate": "Aktivovat", + "suspend": "Deaktivovat", + "save": "Uložit", + "saveAs": "Uložit jako", + "cancel": "Storno", + "ok": "OK", + "delete": "Smazat", + "add": "Přidat", + "yes": "Ano", + "no": "Ne", + "update": "Aktualizovat", + "remove": "Odstranit", + "select": "Vybrat", + "search": "Vyhledat", + "clear-search": "Zrušit hledání", + "assign": "Přiřadit", + "unassign": "Odebrat", + "share": "Sdílet", + "make-private": "Učinit soukromým", + "apply": "Použít", + "apply-changes": "Uložit změny", + "edit-mode": "Režim editace", + "enter-edit-mode": "Vstoupit do režimu editace", + "decline-changes": "Zahodit změny", + "close": "Zavřít", + "back": "Zpět", + "run": "Spustit", + "sign-in": "Zaregistrovat!", + "edit": "Editovat", + "view": "Zobrazit", + "create": "Vytvořit", + "drag": "Táhnout", + "refresh": "Obnovit", + "undo": "Vrátit", + "copy": "Kopírovat", + "paste": "Vložit", + "copy-reference": "Kopírovat referenci", + "paste-reference": "Vložit referenci", + "import": "Importovat", + "export": "Exportovat", + "share-via": "Sdílet přes {{provider}}", + "continue": "Pokračovat", + "discard-changes": "Zahodit změny", + "download": "Stáhnout", + "next-with-label": "Další: {{label}}", + "read-more": "Zobrazit více", + "hide": "Skrýt", + "done": "Hotovo" + }, + "aggregation": { + "aggregation": "Agregace", + "function": "Funkce pro agregaci dat", + "limit": "Maximální hodnoty", + "group-interval": "Interval seskupení", + "min": "Min", + "max": "Max", + "avg": "Průměr", + "sum": "Suma", + "count": "Počet", + "none": "Žádná" + }, + "admin": { + "general": "Obecné", + "general-settings": "Obecná nastavení", + "home-settings": "Nastavení", + "outgoing-mail": "Odchozí email", + "outgoing-mail-settings": "Nastavení odchozího emailu", + "system-settings": "Systémová nastavení", + "test-mail-sent": "Testovací zpráva byla úspěšně odeslána!", + "base-url": "Základní URL", + "base-url-required": "Hodnota Základní URL je povinná.", + "prohibit-different-url": "Zakázat použití názvu hosta z hlaviček požadavku klienta", + "prohibit-different-url-hint": "Toto nastavení by mělo být povoleno v produkčních prostředích. Pokud je zakázáno, může způsobit bezpečnostní problémy", + "mail-from": "Email od", + "mail-from-required": "Hodnota Email od je povinná.", + "smtp-protocol": "SMTP protokol", + "smtp-host": "SMTP host", + "smtp-host-required": "Hodnota SMTP host je povinná.", + "smtp-port": "SMTP port", + "smtp-port-required": "Musíte zadat smtp port.", + "smtp-port-invalid": "Tohle nevypadá jako platný smtp port.", + "timeout-msec": "Časový limit (msec)", + "timeout-required": "Hodnota Časový limit je povinná.", + "timeout-invalid": "Tohle nevypadá jako platný časový limit.", + "enable-tls": "Povolit TLS", + "tls-version": "Verze TLS", + "enable-proxy": "Povolit proxy", + "proxy-host": "Host proxy", + "proxy-host-required": "Host proxy je povinný.", + "proxy-port": "Port proxy", + "proxy-port-required": "Port proxy je povinný.", + "proxy-port-range": "Port proxy by měl být v rozsahu od 1 do 65535.", + "proxy-user": "Uživatel proxy", + "proxy-password": "Heslo proxy", + "send-test-mail": "Odeslat testovací zprávu", + "sms-provider": "Poskytovatel SMS", + "sms-provider-settings": "Nastavení poskytovatele SMS", + "sms-provider-type": "Typ poskytovatele SMS", + "sms-provider-type-required": "Typ poskytovatele SMS je povinný.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID je povinný", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key je povinný", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region je povinný", + "number-from": "Telefonní číslo odesílatele", + "number-from-required": "Telefonní číslo Odesílatele je povinné.", + "number-to": "Telefonní číslo příjemce", + "number-to-required": "Telefonní číslo příjemce je povinné.", + "phone-number-hint": "Telefonní číslo ve formátu E.164, např. +19995550123", + "phone-number-hint-twilio": "Telefonní číslo ve formátu E.164/SID telefonního čísla/SID služby zpráv, např. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Neplatné telefonní číslo. Mělo by odpovídat formátu E.164, např. +19995550123.", + "phone-number-pattern-twilio": "Neplatné telefonní číslo. Mělo by odpovídat formátu E.164/SID telefonního čísla/SID služby zpráv, např. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS zpráva", + "sms-message-required": "SMS zpráva je povinná.", + "sms-message-max-length": "SMS zpráva nemůže být delší než 1600 znaků", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID je povinné", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token je povinný", + "send-test-sms": "Odeslat testovací SMS", + "test-sms-sent": "Testovací SMS úspěšně odeslána!", + "security-settings": "Bezpečnostní nastavení", + "password-policy": "Politika hesel", + "minimum-password-length": "Minimální délka hesla", + "minimum-password-length-required": "Minimální délka hesla je povinná", + "minimum-password-length-range": "Minimální délka hesla by měla být v rozsahu od 5 do 50", + "minimum-uppercase-letters": "Minimální počet velkých písmen", + "minimum-uppercase-letters-range": "Minimální počet velkých písmen nemůže být záporný", + "minimum-lowercase-letters": "Minimální počet malých písmen", + "minimum-lowercase-letters-range": "Minimální počet malých písmen nemůže být záporný", + "minimum-digits": "Minimální počet číslic", + "minimum-digits-range": "Minimální počet číslic nemůže být záporný ", + "minimum-special-characters": "Minimální počet speciálních znaků", + "minimum-special-characters-range": "Minimální počet speciálních znaků nemůže být záporný", + "password-expiration-period-days": "Lhůta expirace hesla ve dnech", + "password-expiration-period-days-range": "Lhůta expirace hesla ve dnech nemůže být záporná", + "password-reuse-frequency-days": "Frekvence opětovného použití hesla ve dnech", + "password-reuse-frequency-days-range": "Frekvence opětovného použití hesla ve dnech nemůže být záporná", + "general-policy": "Obecná politika", + "max-failed-login-attempts": "Maximální počet neúspěšných pokusů o přihlášení před zablokováním účtu", + "minimum-max-failed-login-attempts-range": "Maximální počet neúspěšných pokusů o přihlášení před zablokováním účtu nemůže být záporný", + "user-lockout-notification-email": "V případě zablokování uživatelského účtu odeslat upozornění na email", + "domain-name": "Doménové jméno", + "domain-name-unique": "Doménové jméno a protokol musí být unikátní.", + "error-verification-url": "Doménové jméno by nemělo obsahovat symbol '/' ani ':'. Příklad: thingsboard.io", + "oauth2": { + "access-token-uri": "URI přístupového tokenu", + "access-token-uri-required": "URI přístupového tokenu je povinné.", + "activate-user": "Aktivovat uživatele", + "add-domain": "Přidat doménu", + "delete-domain": "Smazat doménu", + "add-provider": "Přidat poskytovatele", + "delete-provider": "Smazat poskytovatele", + "allow-user-creation": "Povolit vytvoření uživatele", + "always-fullscreen": "Vždy v režimu celé obrazovky", + "authorization-uri": "Autorizační URI", + "authorization-uri-required": "Autorizační URI je povinné.", + "client-authentication-method": "Metoda autentizace klienta", + "client-id": "ID klienta", + "client-id-required": "ID klienta je povinné.", + "client-secret": "Heslo klienta", + "client-secret-required": "Heslo klienta je povinné.", + "custom-setting": "Vlastní nastavení", + "customer-name-pattern": "Vzor názvu zákazníka", + "default-dashboard-name": "Název defaultního dashboardu", + "delete-domain-text": "Budťe opatrní, protože po potvrzení nebudou doména ani žádná data poskytovatele dostupné.", + "delete-domain-title": "Jste si jisti, že chcete smazat nastavení domény '{{domainName}}'?", + "delete-registration-text": "Buďte opatrní, protože po potvrzení nebudou data poskytovatele dostupná.", + "delete-registration-title": "Jste si jisti, že chcete smazat poskytovatele '{{name}}'?", + "email-attribute-key": "Atribut klíče email", + "email-attribute-key-required": "Atribut klíče email je povinný.", + "first-name-attribute-key": "Atribut klíče jméno", + "general": "Obecné", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Atribut klíče příjmení", + "login-button-icon": "Ikona tlačítka přihlášení", + "login-button-label": "Označení poskytovatele", + "login-button-label-placeholder": "Přihlásit se přes $(Provider label)", + "login-button-label-required": "Označení je povinné.", + "login-provider": "Poskytovatel přihlášení", + "mapper": "Mapper", + "new-domain": "Nová doména", + "oauth2": "OAuth2", + "redirect-uri-template": "Šablona URI přesměrování", + "copy-redirect-uri": "Zkopírovat URI přesměrování", + "registration-id": "ID registrace", + "registration-id-required": "ID registrace je povinné.", + "registration-id-unique": "Id registrace musí být v systému unikátní.", + "scope": "Rozsah", + "scope-required": "Rozsah je povinný.", + "tenant-name-pattern": "Vzor názvu tenanta", + "tenant-name-pattern-required": "Vzor názvu tenanta je povinný.", + "tenant-name-strategy": "Strategie názvu tenanta", + "type": "Typ mapperu", + "uri-pattern-error": "Neplatný formát URI.", + "url": "URL", + "url-pattern": "Neplatný formát URL.", + "url-required": "URL je povinná.", + "user-info-uri": "User info URI", + "user-info-uri-required": "User info URI je povinné.", + "user-name-attribute-name": "Atribut klíče název uživatele", + "user-name-attribute-name-required": "Atribut klíče název uživatele je povinný", + "protocol": "Protokol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Povolit nastavení OAuth2", + "domains": "Domény", + "mobile-apps": "Mobilní aplikace", + "no-mobile-apps": "Žádné aplikace nejsou nakonfigurovány", + "mobile-package": "Aplikační balíček", + "mobile-package-placeholder": "Např.: my.example.app", + "mobile-package-hint": "Pro Android: vaše vlastní unikátní Aplikační ID. Pro iOS: Identifikátor produktu.", + "mobile-package-unique": "Aplikační balíček musí být unikátní.", + "mobile-app-secret": "Aplikační heslo", + "invalid-mobile-app-secret": "Aplikační heslo musí obsahovat pouze číslice a písmena a musí mít délku mezi 16 a 2048 znaky.", + "copy-mobile-app-secret": "Kopírovat aplikační heslo", + "add-mobile-app": "Přidat aplikaci", + "delete-mobile-app": "Smazat informace o aplikaci", + "providers": "Poskytovatelé", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Všechny platformy", + "allowed-platforms": "Povolené platformy" + } + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmy", + "select-alarm": "Vybrat alarm", + "no-alarms-matching": "Žádné alarmy odpovídající '{{entity}}' nebyly nalezeny.", + "alarm-required": "Alarm je povinný", + "alarm-status": "Stav alarmu", + "alarm-status-list": "Seznam stavů alarmu", + "any-status": "Všechny stavy", + "search-status": { + "ANY": "Všechny", + "ACTIVE": "Aktivní", + "CLEARED": "Odstraněné", + "ACK": "Přijaté", + "UNACK": "Nepřijaté" + }, + "display-status": { + "ACTIVE_UNACK": "Aktivní nepřijaté", + "ACTIVE_ACK": "Aktivní přijaté", + "CLEARED_UNACK": "Odstraněné nepřijaté", + "CLEARED_ACK": "Odstraněné přijaté" + }, + "no-alarms-prompt": "Žádné alarmy nebyly nalezeny", + "created-time": "Datum vytvoření", + "type": "Typ", + "severity": "Závažnost", + "originator": "Původce", + "originator-type": "Typ původce", + "details": "Detail", + "status": "Stav", + "alarm-details": "Detail alarmu", + "start-time": "Datum zahájení", + "end-time": "Datum ukončení", + "ack-time": "Datum přijetí", + "clear-time": "Datum vyřešení", + "alarm-severity-list": "Seznam závažností alarmu", + "any-severity": "Všechny závažnosti", + "severity-critical": "Kritická", + "severity-major": "Vysoká", + "severity-minor": "Nízká", + "severity-warning": "Varování", + "severity-indeterminate": "Střední", + "acknowledge": "Přijmout", + "clear": "Vyřešit", + "search": "Vyhledat alarmy", + "selected-alarms": "Vybráno { count, plural, 1 {1 alarmů} other {# alarmů} }", + "no-data": "Nejsou zde žádná data", + "polling-interval": "Interval frekvence příjmu alarmů (vteřin)", + "polling-interval-required": "Interval frekvence příjmu alarmů je povinný.", + "min-polling-interval-message": "Minimální povolený interval frekvence příjmu alarmů je 1 vteřina.", + "aknowledge-alarms-title": "Přijmout { count, plural, 1 {1 alarm} other {# alarmů} }", + "aknowledge-alarms-text": "Jste si jisti že chcete přijmout { count, plural, 1 {1 alarm} other {# alarmů} }?", + "aknowledge-alarm-title": "Přijmout alarm", + "aknowledge-alarm-text": "Jste si jisti, že chcete přijmout alarm?", + "clear-alarms-title": "Odstranit { count, plural, 1 {1 alarm} other {# alarmů} }", + "clear-alarms-text": "Jste si jisti, že chcete odstranit { count, plural, 1 {1 alarm} other {# alarmů} }?", + "clear-alarm-title": "Odstranit alarm", + "clear-alarm-text": "Jste si jisti, že chcete alarm odstranit?", + "alarm-status-filter": "Filtr stavu alarmu", + "alarm-filter": "Filtr alarmu", + "max-count-load": "Maximální počet nahraných alarmů (0 - neomezeně)", + "max-count-load-required": "Maximální počet nahraných alarmů je povinný.", + "max-count-load-error-min": "Minimální hodnota je 0.", + "fetch-size": "Velikost dávky", + "fetch-size-required": "Velikost dávky je povinná.", + "fetch-size-error-min": "Minimální hodnota je 10.", + "alarm-type-list": "Seznam typů alarmu", + "any-type": "Všechny typy", + "search-propagated-alarms": "Vyhledat zpropagované alarmy" + }, + "alias": { + "add": "Přidat alias", + "edit": "Editovat alias", + "name": "Název aliasu", + "name-required": "Název aliasu je povinný", + "duplicate-alias": "Alias s identickým názvem již existuje.", + "filter-type-single-entity": "Jedna entita", + "filter-type-entity-list": "Seznam entit", + "filter-type-entity-name": "Název entity", + "filter-type-entity-type": "Typ entity", + "filter-type-state-entity": "Entita ze stavu dashboardu", + "filter-type-state-entity-description": "Entita převzata z parametrů stavu dashboardu", + "filter-type-asset-type": "Typ aktiva", + "filter-type-asset-type-description": "Aktiva typu '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Aktiva typu '{{assetType}}' s názvem začínajícím '{{prefix}}'", + "filter-type-device-type": "Typ zařízení", + "filter-type-device-type-description": "Zařízení typu '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Zařízení typu '{{deviceType}}' s názvem začínajícím '{{prefix}}'", + "filter-type-entity-view-type": "Typ entitního pohledu", + "filter-type-entity-view-type-description": "Entitní pohledy typu '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Entitní pohledy typu '{{entityView}}' s názvem začínajícím '{{prefix}}'", + "filter-type-edge-type": "Typ edge", + "filter-type-edge-type-description": "Edge typu '{{edgeType}}'", + "filter-type-edge-type-and-name-description": "Edge typu '{{edgeType}}' s názvem začínajícím '{{prefix}}'", + "filter-type-relations-query": "Dotaz na vztahy", + "filter-type-relations-query-description": "{{entities}} se {{relationType}} vztahem {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Dotaz na vyhledání aktiva", + "filter-type-asset-search-query-description": "Aktiva typů {{assetTypes}} se {{relationType}} vztahem {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Dotaz na vyhledání zařízení", + "filter-type-device-search-query-description": "Zařízení typů {{deviceTypes}} se {{relationType}} vztahem {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Dotaz na vyhledání zobrazení entity", + "filter-type-entity-view-search-query-description": "Entitní pohledy typů {{entityViewTypes}} se {{relationType}} vztahem {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Stav využití Api", + "filter-type-edge-search-query": "Dotaz na vyhledání edge", + "filter-type-edge-search-query-description": "Edge typu {{edgeTypes}}, které mají {{relationType}} vztah {{direction}} {{rootEntity}}", + "entity-filter": "Filtr entity", + "resolve-multiple": "Použít jako více entit", + "filter-type": "Typ filtru", + "filter-type-required": "Typ filtru je povinný.", + "entity-filter-no-entity-matched": "Žádné entity odpovídající specifikovanému filtru nebyly nalezeny.", + "no-entity-filter-specified": "Nebyl specifikován žádný filtr entit", + "root-state-entity": "Použít stav entity dashboard jako kořenovou", + "last-level-relation": "Nahrát pouze vztah poslední úrovně", + "root-entity": "Kořenová entita", + "state-entity-parameter-name": "Název parametru stavu entity", + "default-state-entity": "Defaultní stav entity", + "default-entity-parameter-name": "Defaultně", + "max-relation-level": "Maximální úroveň vazeb", + "unlimited-level": "Neomezená úroveň", + "state-entity": "Dashboard stav entity", + "all-entities": "Všechny entity", + "any-relation": "všechny" + }, + "asset": { + "asset": "Aktivum", + "assets": "Aktiva", + "management": "Správa aktiv", + "view-assets": "Zobrazit aktiva", + "add": "Přidat aktivum", + "assign-to-customer": "Přiřadit zákazníkovi", + "assign-asset-to-customer": "Přiřadit aktiva zákazníkovi", + "assign-asset-to-customer-text": "Prosím vyberte aktiva, která mají být přiřazena zákazníkovi", + "no-assets-text": "Žádná aktiva nebyla nalezena", + "assign-to-customer-text": "Prosím vyberte zákazníka, kterému mají být aktiva přiřazena", + "public": "Veřejné", + "assignedToCustomer": "Přiřazeno zákazníkovi", + "make-public": "Zveřejnit aktivum", + "make-private": "Učinit aktivum neveřejným", + "unassign-from-customer": "Odebrat aktivum zákazníkovi", + "delete": "Smazat aktivum", + "asset-public": "Aktivum je veřejné", + "asset-type": "Typ aktiva", + "asset-type-required": "Typ aktiva je povinný.", + "select-asset-type": "Vyberte typ aktiva", + "enter-asset-type": "Zadejte typ aktiva", + "any-asset": "Všechna aktiva", + "no-asset-types-matching": "Žádné typy aktiv odpovídající '{{entitySubtype}}' nebyly nalezeny.", + "asset-type-list-empty": "Žádné typy aktiv nebyly vybrány.", + "asset-types": "Typy aktiv", + "name": "Název", + "name-required": "Název je povinný.", + "description": "Popis", + "type": "Typ", + "type-required": "Typ je povinný.", + "details": "Detail", + "events": "Události", + "add-asset-text": "Přidat nové aktivum", + "asset-details": "Detail aktiva", + "assign-assets": "Přiřadit aktiva", + "assign-assets-text": "Přiřadit { count, plural, 1 {1 aktivum} other {# aktiva} } zákazníkovi", + "assign-asset-to-edge-title": "Přiřadit aktivum(a) k edge", + "assign-asset-to-edge-text":"Zvolte prosím aktiva, která mají být přiřazena k edge", + "delete-assets": "Smazat aktiva", + "unassign-assets": "Odebrat aktiva", + "unassign-assets-action-title": "Odebrat { count, plural, 1 {1 aktivum} other {# aktiva} } zákazníkovi", + "assign-new-asset": "Přiřadit nové aktivum", + "delete-asset-title": "Jste si jisti, že chcete smazat aktivum '{{assetName}}'?", + "delete-asset-text": "Buďte opatrní, protože po potvrzení nebude možné aktivum ani žádná související data obnovit.", + "delete-assets-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 aktivum} other {# aktiva} }?", + "delete-assets-action-title": "Smazat { count, plural, 1 {1 aktivum} other {# aktiva} }", + "delete-assets-text": "Buďte opatrní, protože po potvrzení budou všechna vybraná aktiva odstraněna a žádná související data nebude možné obnovit.", + "make-public-asset-title": "Jste si jisti, že chcete aktivum '{{assetName}}' zveřejnit?", + "make-public-asset-text": "Po potvrzení se aktivum a všechna související data stanou veřejnými a dostupnými pro ostatní.", + "make-private-asset-title": "Jste si jisti, že chcete aktivum '{{assetName}}' učinit neveřejným?", + "make-private-asset-text": "Po potvrzení se aktivum a všechna související data stanou neveřejnými a nedostupnými pro ostatní.", + "unassign-asset-title": "Jste si jisti, že chcete odebrat aktivum '{{assetName}}'?", + "unassign-asset-text": "Po potvrzení bude aktivum odebráno a nebude pro zákazníka dostupné.", + "unassign-asset": "Odebrat aktivum", + "unassign-assets-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 aktivum} other {# aktiva} }?", + "unassign-assets-text": "Po potvrzení budou všechna vybraná aktiva odebrána a nebudou pro zákazníka dostupná.", + "unassign-assets-from-edge": "Odebrat aktiva edge", + "copyId": "Kopírovat Id aktiva", + "idCopiedMessage": "Id aktiva bylo zkopírováno do schránky", + "select-asset": "Vybrat aktivum", + "no-assets-matching": "Žádná aktiva odpovídající '{{entity}}' nebyla nalezena.", + "asset-required": "Aktivum je povinné", + "name-starts-with": "Název aktiva začíná", + "help-text": "Použijte '%' dle potřeby: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "import": "Importovat aktiva", + "asset-file": "Soubor aktiva", + "label": "Označení", + "search": "Vyhledat aktiva", + "assign-asset-to-edge": "Přiřadit aktivum(a) k edge", + "unassign-asset-from-edge": "Odebrat aktiva", + "unassign-asset-from-edge-title": "Jste si jisti, že chcete odebrat aktivum '{{assetName}}'?", + "unassign-asset-from-edge-text": "Po potvrzení bude aktivum odebráno a nebude pro edge dostupné.", + "unassign-assets-from-edge-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 aktivum} other {# aktiva} }?", + "unassign-assets-from-edge-text": "Po potvrzení budou všechna aktiva odebrána a nebudou pro edge dostupná.", + "selected-assets": "Vybráno { count, plural, 1 {1 aktiv} other {# aktiv} }" + }, + "attribute": { + "attributes": "Atributy", + "latest-telemetry": "Poslední telemetrie", + "attributes-scope": "Rozsah atributů entity", + "scope-latest-telemetry": "Poslední telemetrie", + "scope-client": "Atributy klienta", + "scope-server": "Atributy serveru", + "scope-shared": "Sdílené atributy", + "add": "Přidat atribut", + "key": "Klíč", + "last-update-time": "Čas poslední aktualizace", + "key-required": "atribut klíč je povinný.", + "value": "Hodnota", + "value-required": "Atribut hodnota je povinný.", + "delete-attributes-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 atribut} other {# atributů} }?", + "delete-attributes-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané atributy odstraněny.", + "delete-attributes": "Smazat atributy", + "enter-attribute-value": "Zadejte hodnotu atributu", + "show-on-widget": "Zobrazit ve widgetu", + "widget-mode": "Režim widgetu", + "next-widget": "Další widget", + "prev-widget": "Předchozí widget", + "add-to-dashboard": "Přidat na dashboard", + "add-widget-to-dashboard": "Přidat widget na dashboard", + "selected-attributes": "Vybráno { count, plural, 1 {1 atributů} other {# atributů} }", + "selected-telemetry": "Vybráno { count, plural, 1 {1 jednotek telemetrie} other {# jednotek telemetrie} }", + "no-attributes-text": "Žádné atributy nebyly nalezeny", + "no-telemetry-text": "Žádná telemetrie nebyla nalezena" + }, + "api-usage": { + "api-usage": "Využití Api", + "alarm": "Alarm", + "alarms-created": "Vytvořené alarmy", + "alarms-created-daily-activity": "Denní aktivita vytvořených alarmů", + "alarms-created-hourly-activity": "Hodinová aktivita vytvořených alarmů", + "alarms-created-monthly-activity": "Měsíční aktivita vytvořených alarmů", + "data-points": "Datové body", + "data-points-storage-days": "Dny uložení datových bodů", + "email": "Email", + "email-messages": "Emailové zprávy", + "email-messages-daily-activity": "Denní aktivita emailových zpráv", + "email-messages-monthly-activity": "Měsíční aktivita emailových zpráv", + "exceptions": "Výjimky", + "executions": "Zpracování", + "javascript": "JavaScript", + "javascript-executions": "JavaScript výjimky", + "javascript-functions": "JavaScript funkce", + "javascript-functions-daily-activity": "Denní aktivita JavaScript funkcí", + "javascript-functions-hourly-activity": "Hodinová aktivita JavaScript funkcí", + "javascript-functions-monthly-activity": "Měsíční aktivita JavaScript funkcí", + "latest-error": "Poslední chyba", + "messages": "Zprávy", + "notifications": "Notifikace", + "notifications-email-sms": "Notifikace (Email/SMS)", + "notifications-hourly-activity": "Hodinová aktivita notifikací", + "permanent-failures": "${entityName} permanentní chyby", + "permanent-timeouts": "${entityName} permanentní timeouty", + "processing-failures": "${entityName} chyby zpracování", + "processing-failures-and-timeouts": "Chyby a timeouty zpracování", + "processing-timeouts": "${entityName} timeouty zpracování", + "queue-stats": "Statistiky fronty", + "rule-chain": "Řetěz pravidel", + "rule-engine": "Engine pro zpracování pravidel", + "rule-engine-daily-activity": "Denní aktivita enginu pro zpracování pravidel", + "rule-engine-executions": "Zpracování Enginu pro zpracování pravidel", + "rule-engine-hourly-activity": "Hodinová aktivita enginu pro zpracování pravidel", + "rule-engine-monthly-activity": "Měsíční aktivita enginu pro zpracování pravidel", + "rule-engine-statistics": "Statistiky enginu pro zpracování pravidel", + "rule-node": "Uzel pravidla", + "sms": "SMS", + "sms-messages": "SMS zprávy", + "sms-messages-daily-activity": "Denní aktivita SMS zpráv", + "sms-messages-monthly-activity": "Měsíční aktivita SMS zpráv", + "successful": "${entityName} úspěšnost", + "telemetry": "Telemetrie", + "telemetry-persistence": "Uložení telemetrie", + "telemetry-persistence-daily-activity": "Denní aktivita uložení telemetrie", + "telemetry-persistence-hourly-activity": "Hodinová aktivita uložení telemetrie", + "telemetry-persistence-monthly-activity": "Měsíční aktivita uložení telemetrie", + "transport": "Přenos", + "transport-daily-activity": "Denní aktivita přenosu", + "transport-data-points": "Datové body přenosu", + "transport-hourly-activity": "Hodinová aktivita přenosu", + "transport-messages": "Zprávy přenosu", + "transport-monthly-activity": "Měsíční aktivita přenosu", + "view-details": "Zobrazit detail", + "view-statistics": "Zobrazit statistiky" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Záznamy auditu", + "timestamp": "Časová značka", + "entity-type": "Typ entity", + "entity-name": "Název entity", + "user": "Uživatel", + "type": "Typ", + "status": "Stav", + "details": "Detail", + "type-added": "Přidáno", + "type-deleted": "Smazáno", + "type-updated": "Aktualizováno", + "type-attributes-updated": "Atributy aktualizovány", + "type-attributes-deleted": "Atributy smazány", + "type-rpc-call": "RPC volání", + "type-credentials-updated": "Přístupové údaje aktualizovány", + "type-assigned-to-customer": "Přiřazeno zákazníkovi", + "type-unassigned-from-customer": "Odebráno zákazníkovi", + "type-assigned-to-edge": "Přiřazeno edge", + "type-unassigned-from-edge": "Odebráno edge", + "type-activated": "Aktivováno", + "type-suspended": "Deaktivováno", + "type-credentials-read": "Zobrazení přístupových údajů", + "type-attributes-read": "Zobrazení atributů", + "type-relation-add-or-update": "Vztah aktualizován", + "type-relation-delete": "Vztah smazán", + "type-relations-delete": "Všechny vztahy smazány", + "type-alarm-ack": "Přijato", + "type-alarm-clear": "Odstraněno", + "type-login": "Přihlášení", + "type-logout": "Odhlášení", + "type-lockout": "Zablokování", + "status-success": "Úspěch", + "status-failure": "Chyba", + "audit-log-details": "Detail záznamu auditu", + "no-audit-logs-prompt": "Žádné záznamy nebyly nalezeny", + "action-data": "Data akce", + "failure-details": "Detail chyby", + "search": "Prohledat záznamy auditu", + "clear-search": "Vymazat vyhledávání", + "type-assigned-from-tenant": "Odebráno tenantovi", + "type-assigned-to-tenant": "Přiřazeno tenantovi", + "type-provision-success": "Zřízení zařízení", + "type-provision-failure": "Selhání zřízení zařízení", + "type-timeseries-updated": "Aktualizace telemetrie", + "type-timeseries-deleted": "Smazání telemetrie" + }, + "confirm-on-exit": { + "message": "Některé změny nebyly uloženy. Jste si jisti, že chcete tuto stránku opustit?", + "html-message": "Některé změny nebyly uloženy.
    Jste si jisti, že chcete tuto stránku opustit?", + "title": "Neuložené změny" + }, + "contact": { + "country": "Stát", + "city": "Město", + "state": "Region", + "postal-code": "PSČ", + "postal-code-invalid": "Formát PSČ neplatný.", + "address": "Adresa", + "address2": "Adresa 2", + "phone": "Telefon", + "email": "Email", + "no-address": "Žádná adresa" + }, + "common": { + "username": "Uživatelské jméno", + "password": "Heslo", + "enter-username": "Zadejte uživatelské jméno", + "enter-password": "Zadejte heslo", + "enter-search": "Zadejte hledaný řetězec", + "created-time": "Datum vytvoření", + "loading": "Načítám...", + "proceed": "Pokračovat" + }, + "content-type": { + "json": "JSON", + "text": "Text", + "binary": "Binární (Base64)" + }, + "customer": { + "customer": "Zákazník", + "customers": "Zákazníci", + "management": "Správa zákazníků", + "dashboard": "Dashboard zákazníka", + "dashboards": "Dashboardy zákazníka", + "devices": "Zařízení zákazníka", + "entity-views": "Entitní pohledy zákazníka", + "assets": "Aktiva zákazníka", + "public-dashboards": "Veřejné dashboardy", + "public-devices": "Veřejná zařízení", + "public-assets": "Veřejná aktiva", + "public-edges": "Veřejné edge", + "public-entity-views": "Veřejné entitní pohledy", + "add": "Přidat zákazníka", + "delete": "Smazat zákazníka", + "manage-customer-users": "Spravovat uživatele zákazníka", + "manage-customer-devices": "Spravovat zařízení zákazníka", + "manage-customer-dashboards": "Spravovat dashboardy zákazníka", + "manage-public-devices": "Spravovat veřejná zařízení", + "manage-public-dashboards": "Spravovat veřejné dashboardy", + "manage-customer-assets": "Spravovat aktiva zákazníka", + "manage-public-assets": "Spravovat veřejná aktiva", + "manage-customer-edges": "Spravovat veřejné edge", + "manage-public-edges": "Spravovat edge zákazníka", + "add-customer-text": "Přidat nového zákazníka", + "no-customers-text": "Žádní zákazníci nebyli nalezeni", + "customer-details": "Detail zákazníka", + "delete-customer-title": "Jste si jisti, že chcete smazat zákazníka '{{customerTitle}}'?", + "delete-customer-text": "Buďte opatrní, protože po potvrzení nebude možné zákazníka ani žádná související data obnovit.", + "delete-customers-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 zákazníka} other {# zákazníků} }?", + "delete-customers-action-title": "Smazat { count, plural, 1 {1 zákazníka} other {# zákazníků} }", + "delete-customers-text": "Buďte opatrní, protože po potvrzení budou všichni vybraní zákazníci odstraněni a žádná související data nebude možné obnovit.", + "manage-users": "Spravovat uživatele", + "manage-assets": "Spravovat aktiva", + "manage-devices": "Spravovat zařízení", + "manage-dashboards": "Spravovat dashboardy", + "title": "Název", + "title-required": "Název je povinný.", + "description": "Popis", + "details": "Detail", + "events": "Události", + "copyId": "Kopírovat Id zákazníka", + "idCopiedMessage": "Id zákazníka bylo zkopírováno do schránky", + "select-customer": "Vybrat zákazníka", + "no-customers-matching": "Žádní zákazníci odpovídající '{{entity}}' nebyli nalezeni.", + "customer-required": "Zákazník je povinný", + "select-default-customer": "Vybrat defaultního zákazníka", + "default-customer": "Defaultní zákazník", + "default-customer-required": "Aby bylo možné ladit dashboard na úrovni tenanta, je nutné zadat defaultního zákazníka.", + "search": "Vyhledat zákazníky", + "selected-customers": "Vybráno { count, plural, 1 {1 zákazníků} other {# zákazníků} }", + "edges": "Edge instance zákazníka", + "manage-edges": "Spravovat edge" + }, + "datetime": { + "date-from": "Datum od", + "time-from": "Čas od", + "date-to": "Datum do", + "time-to": "Čas do" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboardy", + "management": "Správa dashboardů", + "view-dashboards": "Zobrazit dashboardy", + "add": "Přidat dashboard", + "assign-dashboard-to-customer": "Přiřadit dashboard(y) zákazníkovi", + "assign-dashboard-to-customer-text": "Prosím vyberte dashboardy, které mají být přiřazeny zákazníkovi", + "assign-dashboard-to-edge-title": "Přiřadit dashboard(y) k edge", + "assign-to-customer-text": "Prosím vyberete zákazníka, kterému má být dashboard(y) přiřazen", + "assign-to-customer": "Přiřadit zákazníkovi", + "unassign-from-customer": "Odebrat zákazníkovi", + "make-public": "Zveřejnit dashboard", + "make-private": "Učinit dashboard neveřejným", + "manage-assigned-customers": "Spravovat přiřazené zákazníky", + "assigned-customers": "Přiřazení zákazníci", + "assign-to-customers": "Přiřadit dashboard(y) zákazníkovi", + "assign-to-customers-text": "Prosím vyberte zákazníky, kterým má být dashboard(y) přiřazen(y)", + "unassign-from-customers": "Odebrat dashboard(y) zákazníkům", + "unassign-from-customers-text": "Prosím vyberte zákazníky, kterým má být dashboard(y) odebrán(y)", + "no-dashboards-text": "Žádné dashboardy nebyly nalezeny", + "no-widgets": "Nejsou nastaveny žádné widgety", + "add-widget": "Přidat nový widget", + "title": "Název", + "image": "Obrázek dashboardu", + "update-image": "Aktualizovat obrázek dashboardu", + "take-screenshot": "Snímek obrazovky", + "select-widget-title": "Vybrat widget", + "select-widget-value": "{{title}}: vybrat widget", + "select-widget-subtitle": "Seznam dostupných typů widgetu", + "delete": "Smazat dashboard", + "title-required": "Název je povinný.", + "description": "Popis", + "details": "Detail", + "dashboard-details": "Detail dashboardu", + "add-dashboard-text": "Přidat nový dashboard", + "assign-dashboards": "Přiřadit dashboardy", + "assign-new-dashboard": "Přiřadit nový dashboard", + "assign-dashboards-text": "Přiřadit { count, plural, 1 {1 dashboard} other {# dashboardů} } zákazníkům", + "unassign-dashboards-action-text": "Odebrat { count, plural, 1 {1 dashboard} other {# dashboardů} } zákazníkům", + "delete-dashboards": "Smazat dashboardy", + "unassign-dashboards": "Odebrat dashboardy", + "unassign-dashboards-action-title": "Odebrat { count, plural, 1 {1 dashboard} other {# dashboardů} } zákazníkovi", + "delete-dashboard-title": "Jste si jisti, že chcete odstranit dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Buďte opatrní, protože po potvrzení nebude možné dashboard ani žádná související data obnovit.", + "delete-dashboards-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 dashboard} other {# dashboardů} }?", + "delete-dashboards-action-title": "Smazat { count, plural, 1 {1 dashboard} other {# dashboardů} }", + "delete-dashboards-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané dashboardy smazány a žádná související data nebude možné obnovit.", + "unassign-dashboard-title": "Jste si jistí, že chcete odebrat dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Po potvrzení bude dashboard odebrán a nebude pro zákazníka dostupný.", + "unassign-dashboard": "Odebrat dashboard", + "unassign-dashboards-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 dashboard} other {# dashboardů} }?", + "unassign-dashboards-text": "Po potvrzení budou všechny vybrané dashboardy odebrány a nebudou pro zákazníka dostupné.", + "public-dashboard-title": "Dashboard je nyní veřejný", + "public-dashboard-text": "Váš dashboard {{dashboardTitle}} je nyní veřejný a dostupný prostřednictvím následujícího veřejného odkazu:", + "public-dashboard-notice": "Poznámka: Nezapomeňte zveřejnit také příslušná zařízení, aby bylo možné přistupovat k jejich datům.", + "make-private-dashboard-title": "Jste si jisti, že chcete dashboard '{{dashboardTitle}}' zneveřejnit?", + "make-private-dashboard-text": "Po potvrzení bude dashboard neveřejný a nebude pro ostatní dostupný.", + "make-private-dashboard": "Učinit dashboard neveřejným", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Vybrat dashboard", + "no-dashboards-matching": "Žádné dashboardy odpovídající '{{entity}}' nebyly nalezeny.", + "dashboard-required": "Dashboard je povinný.", + "select-existing": "Vybrat existující dashboard", + "create-new": "Vytvořit nový dashboard", + "new-dashboard-title": "Název nového dashboardu", + "open-dashboard": "Otevřít dashboard", + "set-background": "Nastavit pozadí", + "background-color": "Barva pozadí", + "background-image": "Obrázek pozadí", + "background-size-mode": "Režim velikosti pozadí", + "no-image": "Žádný obrázek nebyl vybrán", + "drop-image": "Přetáhněte sem obrázek nebo klikněte pro výběr souboru pro nahrání.", + "empty-image": "Žádný obrázek", + "maximum-upload-file-size": "Maximální velikost souboru: {{ size }}", + "cannot-upload-file": "Soubor nelze nahrát", + "settings": "Nastavení", + "layout-settings": "Nastavení rozložení", + "columns-count": "Počet sloupců", + "columns-count-required": "Počet sloupců je povinný.", + "min-columns-count-message": "Minimální povolený počet sloupců je 10.", + "max-columns-count-message": "Maximální povolený počet sloupců je 1000.", + "widgets-margins": "Okraj mezi widgety", + "margin-required": "Hodnota okraje je povinná.", + "min-margin-message": "Pouze 0 je povolena jako minimální hodnota okraje.", + "max-margin-message": "Pouze 50 je povoleno jako maximální hodnota okraje.", + "horizontal-margin": "Horizontální okraj", + "horizontal-margin-required": "Hodnota horizontálního okraje je povinná.", + "min-horizontal-margin-message": "Minimální povolená hodnota horizontálního okraje je 0.", + "max-horizontal-margin-message": "Maximální povolená hodnota horizontálního okraje je 50.", + "vertical-margin": "Vertikální okraj", + "vertical-margin-required": "Hodnota vertikálního okraje je povinná.", + "min-vertical-margin-message": "Minimální povolená hodnota vertikálního okraje je 0.", + "max-vertical-margin-message": "Maximální povolená hodnota vertikálního okraje je 50.", + "autofill-height": "Automaticky vyplnit na výšku rozmístění", + "mobile-layout": "Nastavení rozmístění pro mobilní zařízení", + "mobile-row-height": "Výška řádku pro mobilní zařízení, px", + "mobile-row-height-required": "Hodnota výšku řádku pro mobilní zařízení je povinná.", + "min-mobile-row-height-message": "Minimální povolená hodnota výšku řádku pro mobilní zařízení je 5 pixelů.", + "max-mobile-row-height-message": "Maximální povolená hodnota výšku řádku pro mobilní zařízení je 200 pixelů.", + "title-settings": "Nastavení názvu", + "display-title": "Zobrazit název dashboardu", + "title-color": "Barva názvu", + "toolbar-settings": "Nastavení nástrojové lišty", + "hide-toolbar": "Skrýt nástrojovou lištu", + "toolbar-always-open": "Ponechat nástrojovou lištu otevřenou", + "display-dashboards-selection": "Zobrazit výběr dashboardů", + "display-entities-selection": "Zobrazit výběr entit", + "display-filters": "Zobrazit filtry", + "display-dashboard-timewindow": "Zobrazit časové okno", + "display-dashboard-export": "Zobrazit export", + "display-update-dashboard-image": "Zobrazit aktualizovat obrázek dashbaordu", + "dashboard-logo-settings": "NAstavení loga dashboardu", + "display-dashboard-logo": "Zobrazit logo dashboardu v režimu celé obrazovky", + "dashboard-logo-image": "Obrázek loga dashboardu", + "import": "Importovat dashboard", + "export": "Exportovat dashboard", + "export-failed-error": "Dashboard nebylo možné exportovat: {{error}}", + "create-new-dashboard": "Vytvořit nový dashboard", + "dashboard-file": "Soubor dashboardu", + "invalid-dashboard-file-error": "Dashboard nebylo možné importovat: Neplatná datová struktura dashboardu.", + "dashboard-import-missing-aliases-title": "Konfigurovat aliasy používané importovaným dashboardem", + "create-new-widget": "Přidat nový widget", + "import-widget": "Importovat widget", + "widget-file": "Soubor widgetu", + "invalid-widget-file-error": "Widget nebylo možné importovat: Neplatná datová struktura widgetu.", + "widget-import-missing-aliases-title": "Konfigurovat aliasy používané importovaným widgetem", + "open-toolbar": "Otevřít nástrojovou lištu dashboardu", + "close-toolbar": "Zavřít nástrojovou lištu", + "configuration-error": "Chyba konfigurace", + "alias-resolution-error-title": "Chyba konfigurace aliasů dashboardu", + "invalid-aliases-config": "Nebylo možné nalézt žádná zařízení odpovídající některému z aliasů ve filtru.
    Pro vyřešení tohoto problému prosím kontaktujte vašeho administrátora.", + "select-devices": "Vybrat zařízení", + "assignedToCustomer": "Přiřazeno zákazníkovi", + "assignedToCustomers": "Přiřazeno zákazníkům", + "public": "Veřejné", + "public-link": "Veřejný odkaz", + "copy-public-link": "Kopírovat veřejný odkaz", + "public-link-copied-message": "Veřejný odkaz na dashboard byl zkopírován do schránky", + "manage-states": "Spravovat stavy dashboardu", + "states": "Stavy dashboardu", + "search-states": "Vyhledat stavy dashboardu", + "selected-states": "Vybráno { count, plural, 1 {1 stavů dashboardu} other {# stavů dashboardu} }", + "edit-state": "Editovat stav dashboardu", + "delete-state": "Smazat stav dashboardu", + "add-state": "Přidat stav dashboardu", + "no-states-text": "Žádné No states found", + "state": "Stav dashboardu", + "state-name": "Název", + "state-name-required": "Název stavu dashboardu je povinný.", + "state-id": "Id stavu", + "state-id-required": "Id stavu dashboardu je povinné.", + "state-id-exists": "Stav dashboardu s identickým Id již existuje.", + "is-root-state": "Základní stav", + "delete-state-title": "Smazat stav dashboardu", + "delete-state-text": "Jste si jisti, že chcete odstranit stav dashboardu s názvem '{{stateName}}'?", + "show-details": "Zobrazit detaily", + "hide-details": "Skrýt detaily", + "select-state": "Vybrat cílový stav", + "state-controller": "Kontrolér stavu", + "search": "Vyhledat dashboardy", + "selected-dashboards": "Vybráno { count, plural, 1 {1 dashboardů} other {# dashboardů} }", + "home-dashboard": " Výchozí dashboard", + "home-dashboard-hide-toolbar": "Skrýt nástrojovou lištu na výhochozím dashboardu", + "unassign-dashboard-from-edge-text": "Po potvrzení bude dashboard odebrán a nebude pro edge dostupný.", + "unassign-dashboards-from-edge-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 dashboard} other {# dashboardů} }?", + "unassign-dashboards-from-edge-text": "Po potvrzení budou všechny vybrané dashboardy odebrány a nebudou pro edge dostupné.", + "assign-dashboard-to-edge": "Přiřadit dashboard(y) k edge", + "assign-dashboard-to-edge-text": "Zvolte prosím dashboardy, které mají být přiřazeny k edge" + }, + "datakey": { + "settings": "Nastavení", + "advanced": "Rozšířené", + "label": "Označení", + "color": "Barva", + "units": "Speciální symbol, který bude zobrazen vedle hodnoty", + "decimals": "Počet číslic za desetinnou čárkou", + "data-generation-func": "Funkce pro generování dat", + "use-data-post-processing-func": "Použít funkci pro následné zpracování", + "configuration": "Konfigurace datového klíče", + "timeseries": "Časové řady", + "attributes": "Atributy", + "entity-field": "Pole entity", + "alarm": "Pole alarmu", + "timeseries-required": "Časové řady entity jsou povinné.", + "timeseries-or-attributes-required": "Časové řady / atributy entity jsou povinné.", + "alarm-fields-timeseries-or-attributes-required": "Pole alarmu nebo časové řady / atributy jsou povinné.", + "maximum-timeseries-or-attributes": "Maximálně { count, plural, 1 {1 časová řada/atribut je povolena.} other {# časových řad/atributů je povoleno} }", + "alarm-fields-required": "Pole alarmu jsou povinná.", + "function-types": "Typy funkcí", + "function-types-required": "Typy funkcí jsou povinné.", + "maximum-function-types": "Maximálně { count, plural, 1 {1 typ funkce je povolen.} other {# typů funkce je povoleno} }", + "time-description": "Časová značka aktuální hodnoty;", + "value-description": "Aktuální hodnota;", + "prev-value-description": "Výsledek předchozího volání funkce;", + "time-prev-description": "Časová značka předchozí hodnoty;", + "prev-orig-value-description": "Původní předchozí hodnota;" + }, + "datasource": { + "type": "Typ datového zdroje", + "name": "Název", + "label": "Označení", + "add-datasource-prompt": "Přidejte prosím datový zdroj" + }, + "details": { + "details": "Detail", + "edit-mode": "Režim editace", + "edit-json": "Editovat JSON", + "toggle-edit-mode": "Přepnout do režimu editace" + }, + "device": { + "device": "Zařízení", + "device-required": "Zařízení je povinné.", + "devices": "Zařízení", + "management": "Správa zařízení", + "view-devices": "Zobrazit zařízení", + "device-alias": "Alias zařízení", + "aliases": "Aliasy zařízení", + "no-alias-matching": "'{{alias}}' nenalezen.", + "no-aliases-found": "Žádné aliasy nebyly nalezeny.", + "no-key-matching": "'{{key}}' nenalezen.", + "no-keys-found": "Žádné klíče nebyly nalezeny.", + "create-new-alias": "Vytvořit nový!", + "create-new-key": "Vytvořit nový!", + "duplicate-alias-error": "Byl nalezen duplicitní alias '{{alias}}'.
    Aliasy zařízení musí být v rámci dashboardu unikátní.", + "configure-alias": "Konfigurovat '{{alias}}' alias", + "no-devices-matching": "Žádná zařízení odpovídající '{{entity}}' nebyla nalezena.", + "alias": "Alias", + "alias-required": "Alias zařízení je povinný.", + "remove-alias": "Odebrat alias zařízení", + "add-alias": "Přidat alias zařízení", + "name-starts-with": "Název zařízení začíná", + "help-text": "Použijte '%' dle potřeby: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "device-list": "Seznam zařízení", + "use-device-name-filter": "Použít filtr", + "device-list-empty": "Nebyla vybrána žádná zařízení.", + "device-name-filter-required": "Název filtru zařízení je povinný.", + "device-name-filter-no-device-matched": "Žádná zařízení začínající '{{device}}' nebyla nalezena.", + "add": "Přidat zařízení", + "assign-to-customer": "Přiřadit zákazníkovi", + "assign-device-to-customer": "Přiřadit zařízení zákazníkovi", + "assign-device-to-customer-text": "Vyberte prosím zařízení, která mají být přiřazena zákazníkovi", + "assign-device-to-edge-title": "Přiřadit zařízení k edge", + "assign-device-to-edge-text":"Zvolte prosím zařízení, která mají být přiřazena k edge", + "make-public": "Zveřejnit zařízení", + "make-private": "Učinit zařízení neveřejným", + "no-devices-text": "Žádná zařízení nebyla nalezena", + "assign-to-customer-text": "Vyberte prosím zákazníka, který má být přiřazen zařízení(m)", + "device-details": "Detail zařízení", + "add-device-text": "Přidat nové zařízení", + "credentials": "Přístupové údaje", + "manage-credentials": "Spravovat přístupové údaje", + "delete": "Smazat zařízení", + "assign-devices": "Přiřadit zařízení", + "assign-devices-text": "Přiřadit { count, plural, 1 {1 zařízení} other {# zařízení} } zákazníkovi", + "delete-devices": "Smazat zařízení", + "unassign-from-customer": "Odebrat zákazníkovi", + "unassign-devices": "Odebrat zařízení", + "unassign-devices-action-title": "Odebrat { count, plural, 1 {1 zařízení} other {# zařízení} } zákazníkovi", + "unassign-device-from-edge-title": "Jste si jisti, že chcete odebrat zařízení '{{deviceName}}'?", + "unassign-device-from-edge-text": "Po potvrzení bude zařízení odebráno a nebude pro edge dostupné.", + "unassign-devices-from-edge": "Odebrat zařízení edge", + "assign-new-device": "Přiřadit nové zařízení", + "make-public-device-title": "Jste si jisti, že chcete zařízení '{{deviceName}}' zveřejnit?", + "make-public-device-text": "Po potvrzení bude zařízení a všechna jeho data veřejná a dostupná pro ostatní.", + "make-private-device-title": "Jste si jisti, že chcete zařízení '{{deviceName}}' učinit neveřejným?", + "make-private-device-text": "Po potvrzení budou zařízení a všechna jeho data neveřejné a nedostupné pro ostatní.", + "view-credentials": "Zobrazit přístupové údaje", + "delete-device-title": "Jste si jisti, že chcete smazat zařízení '{{deviceName}}'?", + "delete-device-text": "Buďte opatrní, protože po potvrzení nebude možné zařízení ani žádná související data obnovit.", + "delete-devices-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 zařízení} other {# zařízení} }?", + "delete-devices-action-title": "Smazat { count, plural, 1 {1 zařízení} other {# zařízení} }", + "delete-devices-text": "Buďte opatrní, protože po potvrzení budou vybraná zařízení odstraněna a žádná související data nebude možné obnovit.", + "unassign-device-title": "Jste si jisti, že chcete odebrat zařízení '{{deviceName}}'?", + "unassign-device-text": "Po potvrzení bude zařízení odebráno a nebude pro zákazníka dostupné.", + "unassign-device": "Odebrat zařízení", + "unassign-devices-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 zařízení} other {# zařízení} }?", + "unassign-devices-text": "Po potvrzení budou všechna vybraná zařízení odebrána a nebudou pro zákazníka dostupná.", + "device-credentials": "Přístupové údaje zařízení", + "loading-device-credentials": "Nahrávám přístupové údaje zařízení...", + "credentials-type": "Typ přístupových údajů", + "access-token": "Přístupový token", + "access-token-required": "Přístupový token je povinný.", + "access-token-invalid": "Délka přístupového tokenu musí být od 1 do 32 znaků.", + "lwm2m-security-config": { + "identity": "Identita klienta", + "identity-required": "Identita klienta je povinná.", + "client-key": "Klíč klienta", + "client-key-required": "Klíč klienta je povinný.", + "endpoint": "Název endpointu klienta", + "endpoint-required": "Název endpointu klienta je povinný.", + "mode": "Režim konfigurace bezpečnosti", + "client-tab": "Konfigurace bezpečnosti klienta", + "client-certificate": "Certifikát klienta", + "bootstrap-tab": "Bootstrap klient", + "bootstrap-server": "Bootstrap server", + "lwm2m-server": "LwM2M server", + "client-publicKey-or-id": "Veřejný klíč nebo Id klienta", + "client-publicKey-or-id-required": "Veřejný klíč nebo Id klienta jsou povinné.", + "client-secret-key": "Tajný klíč klienta", + "client-secret-key-required": "Tajný klíč klienta je povinný.", + "client-public-key": "Veřejný klíč klienta", + "client-public-key-hint": "Jestliže je veřejný klíč klienta prázdný, bude použit důvěryhodný certifikát" + }, + "client-id": "ID klienta", + "client-id-pattern": "Obsahuje neplatné znaky.", + "user-name": "Název uživatele", + "user-name-required": "Název uživatele je povinný.", + "client-id-or-user-name-necessary": "ID klienta a/nebo název uživatele jsou povinné", + "password": "Heslo", + "secret": "Heslo", + "secret-required": "Heslo je povinné.", + "device-type": "Typ zařízení", + "device-type-required": "Typ zařízení je povinný.", + "select-device-type": "Vybrat typ zařízení", + "enter-device-type": "Zadejte typ zařízení", + "any-device": "Všechna zařízení", + "no-device-types-matching": "Žádné typy zařízení odpovídající '{{entitySubtype}}' nebyly nalezeny.", + "device-type-list-empty": "Nebyl vybrán typ zařízení.", + "device-types": "Typy zařízení", + "name": "Název", + "name-required": "Název je povinný.", + "description": "Popis", + "label": "Označení", + "events": "Události", + "details": "Detail", + "copyId": "Kopírovat Id zařízení", + "copyAccessToken": "Kopírovat přístupový token", + "copy-mqtt-authentication": "Kopírovat přístupové údaje MQTT", + "idCopiedMessage": "Id zařízení bylo zkopírováno do schránky", + "accessTokenCopiedMessage": "Přístupový token zařízení byl zkopírován do schránky", + "mqtt-authentication-copied-message": "MQTT autentizace zařízení byla zkopírována do schránky", + "assignedToCustomer": "Přiřazeno zákazníkovi", + "unable-delete-device-alias-title": "Nebylo možné smazat alias zařízení", + "unable-delete-device-alias-text": "Alias zařízení '{{deviceAlias}}' nelze smazat, protože je používán následujícími widgety:
    {{widgetsList}}", + "is-gateway": "Je bránou", + "overwrite-activity-time": "Přepsat čas aktivity připojeného zařízení", + "public": "Veřejné", + "device-public": "Zařízení je veřejné", + "select-device": "Vybrat zařízení", + "import": "Importovat zařízení", + "device-file": "Soubor zařízení", + "search": "Vyhledat zařízení", + "selected-devices": "Vybráno { count, plural, 1 {1 zařízení} other {# zařízení} }", + "device-configuration": "Konfigurace zařízení", + "transport-configuration": "Konfigurace přenosu", + "wizard": { + "device-wizard": "Průvodce zařízením", + "device-details": "Detail zařízení", + "new-device-profile": "Vytvořit nový profil zařízení", + "existing-device-profile": "Vybrat existující profil zařízení", + "specific-configuration": "Specifická konfigurace", + "customer-to-assign-device": "Přiřadit zařízení zákazníkovi", + "add-credentials": "Přidat přístupový údaj" + }, + "unassign-devices-from-edge-title": "Jste se jisti, že chcete odebrat { count, plural, 1 {1 zařízení} other {# zařízení} }?", + "unassign-devices-from-edge-text": "Po potvrzení budou všechna vybraná zařízení odebrána a nebudou pro edge dostupná." + }, + "device-profile": { + "device-profile": "Profil zařízení", + "device-profiles": "Profily zařízení", + "all-device-profiles": "Všechny", + "add": "Přidat profil zařízení", + "edit": "Editovat profil zařízení", + "device-profile-details": "Detail profilu zařízení", + "no-device-profiles-text": "Žádné profily zařízení nebyly nalezeny", + "search": "Vyhledat profily zařízení", + "selected-device-profiles": "Vybráno { count, plural, 1 {1 profil zařízení} other {# profilů zařízení} }", + "no-device-profiles-matching": "Žádný profil zařízení odpovídající '{{entity}}' nebyl nalezen.", + "device-profile-required": "Profil zařízení je povinný", + "idCopiedMessage": "Id profilu zařízení bylo zkopírováno do schránky", + "set-default": "Učinit profil zařízení defaultním", + "delete": "Smazat profil zařízení", + "copyId": "Kopírovat Id profilu zařízení", + "new-device-profile-name": "Název profilu zařízení", + "new-device-profile-name-required": "Název profilu zařízení je povinný.", + "name": "Název", + "name-required": "Název je povinný.", + "type": "Typ profilu", + "type-required": "Typ profilu je povinný.", + "type-default": "Defaultní", + "image": "Obrázek profilu zařízení", + "transport-type": "Typ přenosu", + "transport-type-required": "Typ přenosu je povinný.", + "transport-type-default": "Defaultní", + "transport-type-default-hint": "Podporuje základní MQTT, HTTP and CoAP přenos", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Umožňuje pokročilé nastavení MQTT přenosu", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Umožňuje pokročilé nastavení CoAP přenosu", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Typ transportu LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Specifikace konfigurace SNMP přenosu", + "description": "Popis", + "default": "Defaultní", + "profile-configuration": "Konfigurace profilu", + "transport-configuration": "Konfigurace přenosu", + "default-rule-chain": "Defaultní řetěz pravidel", + "mobile-dashboard": "Mobilní dashboard", + "mobile-dashboard-hint": "Používán mobilní aplikací jako dashboard detailu zařízení", + "select-queue-hint": "Vyberte z rozbalovacího seznamu nebo přidejte vlastní název.", + "delete-device-profile-title": "Jste si jisti, že chcete smazat profil zařízení '{{deviceProfileName}}'?", + "delete-device-profile-text": "Buďte opatrní, protože po potvrzení nebude možné profil zařízení ani žádná související data obnovit.", + "delete-device-profiles-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 profil zařízení} other {# profilů zařízení} }?", + "delete-device-profiles-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané profily zařízení odstraněny a žádná související data nebude možné obnovit.", + "set-default-device-profile-title": "Jste si jisti, že chcete profil zařízení '{{deviceProfileName}}' učinit defaultním?", + "set-default-device-profile-text": "Po potvrzení bude profil zařízení označen jako defaultní a bude použit pro nová zařízení bez specifikovaného profilu.", + "no-device-profiles-found": "Žádné profily zařízení nebyly nalezeny.", + "create-new-device-profile": "Vytvořit nový!", + "mqtt-device-topic-filters": "Filtry MQTT fronty zařízení", + "mqtt-device-topic-filters-unique": "Filtry MQTT fronty zařízení musí být unikátní.", + "mqtt-device-payload-type": "MQTT zpráva zařízení", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "snmp-add-mapping": "Přidat SNMP mapování", + "snmp-mapping-not-configured": "Není konfigurováno žádné mapování OID vůči časovým řadám/telemetrii", + "snmp-timseries-or-attribute-name": "Název časových řad/atributu pro mapování", + "snmp-timseries-or-attribute-type": "Typ časových řad/atributu pro mapování", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Typ zprávy je povinný.", + "coap-device-type": "Typ CoAP zařízení", + "coap-device-payload-type": "Typ zprávy CoAP zařízení", + "coap-device-type-required": "Typ zprávy CoAP zařízení je povinný.", + "coap-device-type-default": "Defaultní", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Jsou podporovány jednoúrovňové [+] a víceúrovňové [#] zástupné znaky.", + "telemetry-topic-filter": "Filtr fronty telemetrie", + "telemetry-topic-filter-required": "Filtr fronty telemetrie je povinný.", + "attributes-topic-filter": "Filtr atributů fronty", + "attributes-topic-filter-required": "Filtr atributů fronty je povinný.", + "telemetry-proto-schema": "Proto schéma telemetrie", + "telemetry-proto-schema-required": "Proto schéma telemetrie je povinné.", + "attributes-proto-schema": "Atributy proto schémata", + "attributes-proto-schema-required": "Atributy proto schémata jsou povinné.", + "rpc-response-proto-schema": "RPC odpověď proto schémata", + "rpc-response-proto-schema-required": "RPC odpověď proto schémata je povinná.", + "rpc-response-topic-filter": "Filtr fronty RPC odpovědi", + "rpc-response-topic-filter-required": "Filtr fronty RPC odpovědi je povinný.", + "rpc-request-proto-schema": "RPC požadavek proto schémata", + "rpc-request-proto-schema-required": "RPC požadavek proto schémataje povinný.", + "rpc-request-proto-schema-hint": "Zpráva RPC požadavku by měla vždy obsahovat pole: string method = 1; int32 requestId = 2; a params = 3 jakéhokoli datového typu.", + "not-valid-pattern-topic-filter": "Neplatný vzor filtru fronty", + "not-valid-single-character": "Neplatné použití jednoúrovňového zástupného znaku", + "not-valid-multi-character": "Neplatné použití víceúrovňového zástupného znaku", + "single-level-wildcards-hint": "[+] je vhodný pro jakoukoli úroveň filtru fronty. Př.: v1/devices/+/telemetry or +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] může nahradit filtr fronty a může se jednat o poslední symbol fronty. Př.: # or v1/devices/me/#.", + "alarm-rules": "Pravidla alarmu", + "alarm-rules-with-count": "Pravidla alarmu ({{count}})", + "no-alarm-rules": "Žádná pravidla alarmu nejsou konfigurována", + "add-alarm-rule": "Přidat pravidlo alarmu", + "edit-alarm-rule": "Editovat pravidlo alarmu", + "alarm-type": "Typ alarmu", + "alarm-type-required": "Typ alarmu je povinný.", + "alarm-type-unique": "Typ alarmu musí být v rámci pravidel alarmu profilu zařízení unikátní.", + "create-alarm-pattern": "Vytvořit {{alarmType}} alarm", + "create-alarm-rules": "Vytvořit pravidla alarmu", + "no-create-alarm-rules": "Nejsou konfigurovány žádné podmínky vytvoření", + "add-create-alarm-rule-prompt": "Přidejte prosím pravidlo vytvoření alarmu", + "clear-alarm-rule": "Pravidlo zrušení alarmu", + "no-clear-alarm-rule": "Není konfigurována žádná podmínka zrušení", + "add-create-alarm-rule": "Přidat podmínku vytvoření", + "add-clear-alarm-rule": "Přidat podmínku zrušení", + "select-alarm-severity": "Vybrat závažnost alarmu", + "alarm-severity-required": "Závažnost alarmu je povinná.", + "condition-duration": "Doba trvání podmínky", + "condition-duration-value": "Hodnota doby trvání", + "condition-duration-time-unit": "Jednotka času", + "condition-duration-value-range": "Hodnota doby trvání musí být v rozsahu od 1 do 2147483647.", + "condition-duration-value-pattern": "Doba trvání musí být celé číslo.", + "condition-duration-value-required": "Doba trvání je povinná.", + "condition-duration-time-unit-required": "Jednotka času je povinná.", + "advanced-settings": "Pokročilá nastavení", + "alarm-rule-details": "Detail", + "add-alarm-rule-details": "Přidat detail", + "alarm-rule-mobile-dashboard": "Mobilní dashboard", + "alarm-rule-mobile-dashboard-hint": "Používán mobilní aplikací jako dashboard detailu alarmu", + "alarm-rule-no-mobile-dashboard": "Žádný dashboard nebyl vybrán", + "propagate-alarm": "Propagovat alarm", + "alarm-rule-relation-types-list": "Typy vztahů ke zpropagování", + "alarm-rule-relation-types-list-hint": "Pokud nejsou vybrány žádné typy vztahů, alarmy budou propagovány bez filtru typu vztahu.", + "alarm-details": "Detail alarmu", + "alarm-rule-condition": "Podmínka pravidla alarmu", + "enter-alarm-rule-condition-prompt": "Přidejte prosím podmínku pravidla alarmu", + "edit-alarm-rule-condition": "Editovat podmínku pravidla alarmu", + "device-provisioning": "Zřízení zařízení", + "provision-strategy": "Strategie zřízení", + "provision-strategy-required": "Strategie zřízení je povinná.", + "provision-strategy-disabled": "Zakázáno", + "provision-strategy-created-new": "Povolit vytváření nových zařízení", + "provision-strategy-check-pre-provisioned": "Zkontrolovat předvytvořená zařízení", + "provision-device-key": "Klíč pro zřízení zařízení", + "provision-device-key-required": "Klíč pro zřízení zařízení je povinný.", + "copy-provision-key": "Kopírovat klíč pro zřízení", + "provision-key-copied-message": "Klíč pro zřízení byl zkopírován do schránky", + "provision-device-secret": "Heslo pro zřízení zařízení", + "provision-device-secret-required": "Heslo pro zřízení zařízení je povinné.", + "copy-provision-secret": "Kopírovat heslo pro zřízení", + "provision-secret-copied-message": "Heslo pro zřízení zařízení bylo zkopírováno do schránky", + "condition": "Podmínka", + "condition-type": "Typ podmínky", + "condition-type-simple": "Jednoduchá", + "condition-type-duration": "Doba trvání", + "condition-during": "V průběhu {{during}}", + "condition-during-dynamic": "V průběhu \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Opakování", + "condition-type-required": "Typ podmínky je povinný.", + "condition-repeating-value": "Počet událostí", + "condition-repeating-value-range": "Počet událostí musí být v rozsahu od 1 do 2147483647.", + "condition-repeating-value-pattern": "Počet událostí musí být celé číslo.", + "condition-repeating-value-required": "Počet událostí je povinný.", + "condition-repeat-times": "Opakování { count, plural, 1 {1 krát} other {# krát} }", + "condition-repeat-times-dynamic": "Opakování \"{ attribute }\" ({ count, plural, 1 {1 krát} other {# krát} })", + "schedule-type": "Typ plánovače", + "schedule-type-required": "Typ plánovače je povinný.", + "schedule": "Časový plán", + "edit-schedule": "Editovat časový plán alarmu", + "schedule-any-time": "Aktivní neustále", + "schedule-specific-time": "Aktivní v konkrétním čase", + "schedule-custom": "Vlastní", + "schedule-day": { + "monday": "Pondělí", + "tuesday": "Úterý", + "wednesday": "Středa", + "thursday": "Čtvrtek", + "friday": "Pátek", + "saturday": "Sobota", + "sunday": "Neděle" + }, + "schedule-days": "Dny", + "schedule-time": "Čas", + "schedule-time-from": "Od", + "schedule-time-to": "Do", + "schedule-days-of-week-required": "Musí být vybrán minimálně jeden den v týdnu.", + "create-device-profile": "Vytvořit nový profil zařízení", + "import": "Importovat profil zařízení", + "export": "Exportovat profil zařízení", + "export-failed-error": "Profil zařízení nebylo možné exportovat: {{error}}", + "device-profile-file": "Soubor profilu zařízení", + "invalid-device-profile-file-error": "Profil zařízení nebylo možné importovat: Neplatná datová struktura profilu zařízení.", + "power-saving-mode": "Úsporný režim", + "power-saving-mode-type": { + "default": "Použít profil zařízení úsporného režimu", + "psm": "Úsporný režim (PSM)", + "drx": "Přerušovaný přenos (DRX)", + "edrx": "Rozšířený přerušovaný přenos (eDRX)" + }, + "edrx-cycle": "eDRX cyklus", + "edrx-cycle-required": "eDRX cyklus je povinný.", + "edrx-cycle-pattern": "eDRX cyklus musí být kladné číslo.", + "lwm2m": { + "object-list": "SEznam objektů", + "object-list-empty": "Žádné objekty nebyly vybrány.", + "no-objects-matching": "Žádné objekty odpovídající '{{object}}' nebyly nalezeny.", + "model-tab": "LWM2M model", + "add-new-instances": "Přidat nové instance", + "instances-list": "Seznam instancí", + "instances-list-required": "Seznam instancí je povinný", + "instance-id-pattern": "Instance číslo musí být kladné číslo.", + "instance-id-max": "Maximální instance číslo hodnota {{max}}.", + "instance": "Instance", + "resource-label": "Název zdroje #ID", + "observe-label": "Pozorování", + "attribute-label": "Atribut", + "telemetry-label": "Telemetrie", + "edit-observe-select": "Pro editaci pozorování vyberte telemetrii nebo atribut", + "edit-attributes-select": "Pro editaci atributů vyberte telemetrii nebo atribut", + "no-attributes-set": "Žádné atributy nebyly nastaveny", + "key-name": "Název klíče", + "key-name-required": "Název klíče je povinný", + "attribute-name": "Název atributu", + "attribute-name-required": "Název atributu je povinný.", + "attribute-value": "Hodnota atributu", + "attribute-value-required": "Hodnota atributu je povinná.", + "attribute-value-pattern": "Atribut musí být kladné celé číslo.", + "edit-attributes": "Editovat atributy: {{ name }}", + "view-attributes": "Zobrazit atributy: {{ name }}", + "add-attribute": "Přidat atribut", + "edit-attribute": "Editovat atribut", + "view-attribute": "Zobrazit atribut", + "remove-attribute": "Odebrat atribut", + "mode": "Režim konfigurace bezpečnosti", + "short-id": "Krátké ID", + "short-id-required": "Krátké ID je povinné.", + "short-id-range": "Krátké ID by mělo být v rozsahu od 1 do 65534.", + "short-id-pattern": "Krátké ID musí být kladné celé číslo.", + "lifetime": "Doba platnosti registrace klienta", + "lifetime-required": "Doba platnosti registrace klienta je povinná.", + "lifetime-pattern": "Doba platnosti registrace klienta musí být kladné celé číslo.", + "default-min-period": "Minimální interval mezi dvěma notifikacemi (s)", + "default-min-period-required": "Minimální interval je povinný.", + "default-min-period-pattern": "Minimální interval musí být kladné celé číslo.", + "notification-storing": "Ukládání notifikací v případě nedostupnosti", + "binding": "Binding", + "bootstrap-tab": "Bootstrap", + "server-host": "Host", + "server-host-required": "Host je povinný.", + "server-port": "Port", + "server-port-required": "Port je povinný.", + "server-port-pattern": "Port musí být kladné celé číslo.", + "server-port-range": "Port by měl být v rozsahu od 1 do 65535.", + "server-public-key": "Veřejný klíč serveru", + "server-public-key-required": "Veřejný klíč serveru je povinný.", + "client-hold-off-time": "Čas odložení", + "client-hold-off-time-required": "Čas odloženíje povinný.", + "client-hold-off-time-pattern": "Čas odložení musí být kladné celé číslo.", + "client-hold-off-time-tooltip": "Čas odložení pouze pro použití s Bootstrap-Serverem", + "account-after-timeout": "Účet po timeoutu", + "account-after-timeout-required": "Účet po timeoutu je povinný.", + "account-after-timeout-pattern": "Účet po timeoutu musí být kladné celé číslo.", + "account-after-timeout-tooltip": "Bootstrap-Server účet po timeoutu, který udává tento zdroj.", + "others-tab": "Ostatní nastavení", + "client-strategy": "Strategie klienta při připojování", + "client-strategy-label": "Strategie", + "client-strategy-only-observe": "Klientovi je odeslán pouze observe požadavek po úvodním spojení", + "client-strategy-read-all": "Načti všechny zdroje a observer požadavky na klienta po registraci", + "fw-update": "Aktualizace firmware", + "fw-update-strategy": "Strategie aktualizace firmware", + "fw-update-strategy-data": "Odeslat (push) aktualizaci firmware jako binární soubor pomocí Object 19 a Resource 0 (Data)", + "fw-update-strategy-package": "Odeslat (push) aktualizace firmware jako binární soubor pomocí Object 5 a Resource 0 (Balíček)", + "fw-update-strategy-package-uri": "Automaticky vygenerovat unikátní CoAP URL pro stažení balíčku a odeslání (push) aktualizace firmware jako Object 5 a Resource 1 (URI balíčku)", + "sw-update": "Aktualizace software", + "sw-update-strategy": "Strategie aktualizace software", + "sw-update-strategy-package": "Odeslat (push) binární soubor pomocí Object 9 a Resource 2 (Balíček)", + "sw-update-strategy-package-uri": "Automaticky vygenerovat unikátní CoAP URL pro stažení balíčku a odesální (push) aktualizace software pomocí Object 9 a Resource 3 (URI balíčku)", + "fw-update-resource": "Zdroj aktualizace firmware CoAP", + "fw-update-resource-required": "Zdroj aktualizace firmware CoAP je povinný.", + "sw-update-resource": "Zdroje aktualizace software CoAP", + "sw-update-resource-required": "Zdroje aktualizace software CoAP je poinný.", + "config-json-tab": "Json konfigurace profilu zařízení", + "attributes-name": { + "min-period": "Minimální interval", + "max-period": "Maximální interval", + "greater-than": "Větší než", + "less-than": "Menší než", + "step": "Krok", + "min-evaluation-period": "Minimální interval evaluace", + "max-evaluation-period": "Maximální interval evaluace" + }, + "composite-operations-support": "Podporuje kompozitní Read/Write/Observe operace" + }, + "snmp": { + "add-communication-config": "Přidat konfiguraci komunikace", + "add-mapping": "Přidat mapování", + "authentication-passphrase": "Autentizační passphrase", + "authentication-passphrase-required": "Autentizační passphrase je povinná.", + "authentication-protocol": "Autentizační protokol", + "authentication-protocol-required": "Autentizační protokol je povinný.", + "communication-configs": "Konfigurace komunikace", + "community": "Řetězec komunity", + "community-required": "Řetězec komunity je povinný.", + "context-name": "Název kontextu", + "data-key": "Datový klíč", + "data-key-required": "Datový klíč je povinný.", + "data-type": "Datový typ", + "data-type-required": "Datový typ je povinný.", + "engine-id": "ID enginu", + "host": "Host", + "host-required": "Host je povinný.", + "oid": "OID", + "oid-pattern": "Neplatný formát OID.", + "oid-required": "OID je povinné.", + "please-add-communication-config": "Přidejte prosím konfiguraci komunikace", + "please-add-mapping-config": "Přidejte prosím konfiguraci mapování", + "port": "Port", + "port-format": "Neplatný formát portu.", + "port-required": "Port je povinný.", + "privacy-passphrase": "Privacy passphrase", + "privacy-passphrase-required": "Privacy passphrase je povinná.", + "privacy-protocol": "Privacy protokol", + "privacy-protocol-required": "Privacy protkol je povinný.", + "protocol-version": "Verze protokolu", + "protocol-version-required": "Verze protokolu je povinná.", + "querying-frequency": "Frekvence dotazování, ms", + "querying-frequency-invalid-format": "Frekvence dotazování musí být kladné celé číslo.", + "querying-frequency-required": "Frekvence dotazování je povinná.", + "retries": "Počet pokusů", + "retries-invalid-format": "Počet pokusů musí být kladné celé číslo.", + "retries-required": "Počet pokusů je povinný.", + "scope": "Scope", + "scope-required": "Scope je povinný.", + "security-name": "Název zabezpečení", + "security-name-required": "Název zabezpečení je povinný.", + "timeout-ms": "Timeout, ms", + "timeout-ms-invalid-format": "Timeout musí být kladné celé číslo.", + "timeout-ms-required": "Timeout je povinný.", + "user-name": "Uživatelské jméno", + "user-name-required": "Uživatelské jméno je povinné." + } + }, + "dialog": { + "close": "Zavřít dialog" + }, + "direction": { + "column": "Sloupec", + "row": "Řádek" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Instance edge", + "edge-file": "Soubor edge", + "management": "Správa edge", + "no-edges-matching": "Žádné edge odpovídající '{{entity}}' nebyly nelezeny.", + "add": "Přidat edge", + "no-edges-text": "Žádné edge nebyly nalezeny", + "edge-details": "Detail edge", + "add-edge-text": "Přidat novou edge", + "delete": "Odstranit edge", + "delete-edge-title": "Jste si jisti, že chcete odstranit edge '{{edgeName}}'?", + "delete-edge-text": "Buďte opatrní, protože po potvrzení odstranění nebude možné edge ani žádná související data obnovit.", + "delete-edges-title": "Jste si jisti, že chcete odstranit { count, plural, 1 {1 edge} other {# edge} }?", + "delete-edges-text": "Buďte opatrní, protože po potvrzení odstranění budou všechny vybrané edge odstraněny a žádná související data nebude možné obnovit.", + "name": "Název", + "name-starts-with": "Název edge začíná", + "name-required": "Název je povinný.", + "description": "Popis", + "details": "Detail", + "events": "Události", + "copy-id": "Zkopírovat Id edge", + "id-copied-message": "Id edge bylo zkopírováno do schránky", + "sync": "Synchronizovat Edge", + "edge-required": "Edge je povinná", + "edge-type": "Typ edge", + "edge-type-required": "Typ edge je povinný.", + "event-action": "Akce události", + "entity-id": "ID entity", + "select-edge-type": "Vyberte typ edge", + "assign-to-customer": "Přiřadit zákazníkovi", + "assign-to-customer-text": "Zvolte prosím zákazníka, kterému mají být edge přiřazeny", + "assign-edge-to-customer": "Přiřadt edge zákazníkovi", + "assign-edge-to-customer-text": "Zvolte prosím edge, které mají být přiřazeny zákazníkovi", + "assignedToCustomer": "Přiřazeno zákazníkovi", + "edge-public": "Edge je veřejná", + "assigned-to-customer": "Přiřazeno: {{customerTitle}}", + "unassign-from-customer": "Odebrat zákazníkovi", + "unassign-edge-title": "Jste si jisti, že chcete odebrat edge '{{edgeName}}'?", + "unassign-edge-text": "Po potvrzení bude edge odebrána a nebude pro zákazníka dostupná.", + "unassign-edges-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 edge} other {# edge} }?", + "unassign-edges-text": "Po potvrzení budou všechny vybrané edge odebrány a nebudou pro zákazníka dostupné.", + "make-public": "Zveřejnit edge", + "make-public-edge-title": "Jste si jisti, že chcete edge '{{edgeName}}' zveřejnit?", + "make-public-edge-text": "Po potvrzení budou edge a všechna její data veřejné a dostupné pro ostatní.", + "make-private": "Zneveřejnit edge", + "public": "Veřejná", + "make-private-edge-title": "Jste si jisti, že chcete edge '{{edgeName}}' zneveřejnit?", + "make-private-edge-text": "Po potvrzení budou edge a všechna její data soukromé a nebudou dostupné pro ostatní.", + "import": "Importovat edge", + "label": "Označení", + "load-entity-error": "Nahrání dat selhalo. Entita byla odstraněna.", + "assign-new-edge": "Přiřadit novou edge", + "unassign-from-edge": "Odebrat edge", + "edge-key": "Klíč edge", + "copy-edge-key": "Kopírovat klíč edge", + "edge-key-copied-message": "Klíč edge byl zkopírován do schránky", + "edge-secret": "Edge secret", + "copy-edge-secret": "Kopírovat edge secret", + "edge-secret-copied-message": "Edge secret byl zkopírován do schránky", + "edge-assets": "Aktiva edge", + "edge-devices": "Zařízení edge", + "edge-entity-views": "Entitní pohledy edge", + "edge-dashboards": "Dashboardy edge", + "edge-rulechains": "Řetězy pravidel edge", + "assets": "Aktiva edge", + "devices": "Zařízení edge", + "entity-views": "Entitní pohledy edge", + "dashboard": "Dashboard edge", + "dashboards": "Dashboardy edge", + "rulechain-templates": "Šablony řetězů pravidel", + "rulechains": "Řetězy pravidel", + "search": "Vyhledat edge", + "selected-edges": "{ count, plural, 1 {1 edge} other {# edge} } vybráno", + "any-edge": "Všechny edge", + "no-edge-types-matching": "Žádné edge odpovídající '{{entitySubtype}}' nebyly nalezeny.", + "edge-type-list-empty": "Nebyly zvoleny žádné edge.", + "edge-types": "Typy edge", + "enter-edge-type": "Zadejte typ edge", + "deployed": "Nasazeno", + "pending": "Čeká", + "downlinks": "Downlinks", + "no-downlinks-prompt": "Žádné downlinks nebyly nalezeny", + "sync-process-started-successfully": "Proces synchronizace úspěšně zahájen!", + "missing-related-rule-chains-title": "Edge chybí související řetěz(y) pravidel", + "missing-related-rule-chains-text": "Přiřazeno řetězu(ům) pravidel edge využívajícím uzly pravidel, které předávají zprávy řetězům pravidel, které nejsou k této edge přiřazeny.

    Seznam chybějících řetězů pravidel:
    {{missingRuleChains}}", + "widget-datasource-error": "Tento widget podporuje pouze datový zdroj entity EDGE" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Aktivum", + "type-device": "Zařízení", + "type-device-profile": "Profil zařízení", + "type-entity-view": "Entitní pohled", + "type-alarm": "Alarm", + "type-rule-chain": "Řetěz pravidel", + "type-rule-chain-metadata": "Metadata řetězu pravidel", + "type-edge": "Edge", + "type-user": "Uživatel", + "type-customer": "Zákazník", + "type-relation": "Vztah", + "type-widgets-bundle": "Kategorie widgetů", + "type-widgets-type": "Typ widgetů", + "type-admin-settings": "Administrátorská nastavení", + "action-type-added": "Přidáno", + "action-type-deleted": "Smazáno", + "action-type-updated": "Editováno", + "action-type-post-attributes": "Atributy příspěvku", + "action-type-attributes-updated": "Atributy editovány", + "action-type-attributes-deleted": "Atributy smazány", + "action-type-timeseries-updated": "Časové řady editovány", + "action-type-credentials-updated": "Přístupové údaje editovány", + "action-type-assigned-to-customer": "Přiřazeno zákazníkovi", + "action-type-unassigned-from-customer": "Odebráno zákazníkovi", + "action-type-relation-add-or-update": "Přidání nebo editace vztahu", + "action-type-relation-deleted": "Odstranění vztahu", + "action-type-rpc-call": "RPC volání", + "action-type-alarm-ack": "Přijetí alarmu", + "action-type-alarm-clear": "Zrušení alarmu", + "action-type-assigned-to-edge": "Přiřazeno k edge", + "action-type-unassigned-from-edge": "Odebrání k edge", + "action-type-credentials-request": "Požadavek na přístupové údaje", + "action-type-entity-merge-request": "Požadavek na sloučení entit" + }, + "error": { + "unable-to-connect": "Nebylo možné se připojit k serveru! Zkontrolujte internetové připojení.", + "unhandled-error-code": "Neošetřený chybový kód: {{errorCode}}", + "unknown-error": "Neznámá chyba" + }, + "entity": { + "entity": "Entita", + "entities": "Entity", + "entities-count": "Počet entit", + "aliases": "Entitní aliasy", + "entity-alias": "Alias entity", + "unable-delete-entity-alias-title": "Alias entity nebylo možné smazat", + "unable-delete-entity-alias-text": "Alias entity '{{entityAlias}}' nelze smazat, protože je používán následujícími widgety:
    {{widgetsList}}", + "duplicate-alias-error": "Nalezen duplicitní alias '{{alias}}'.
    Aliasy entit musí být v rámci dashboardu unikátní.", + "missing-entity-filter-error": "Ve filtru chybí alias '{{alias}}'.", + "configure-alias": "Konfigurovat '{{alias}}' alias", + "alias": "Alias", + "alias-required": "Alias entity je povinný", + "remove-alias": "Odebrat alias entity", + "add-alias": "Přidat alias entity", + "entity-list": "Seznam entit", + "entity-type": "Typ entity", + "entity-types": "Typy entit", + "entity-type-list": "Seznam typů entit", + "any-entity": "Všechny entity", + "enter-entity-type": "Zadat typ entity", + "no-entities-matching": "Žádné entity odpovídající '{{entity}}' nebyly nalezeny.", + "no-entity-types-matching": "Žádné entity odpovídající '{{entityType}}' nebyly nalezeny.", + "name-starts-with": "Název začíná", + "help-text": "Použijte '%' dle potřeby: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Použít filtr", + "entity-list-empty": "Žádné entity nebyly nalezeny.", + "entity-type-list-empty": "Nebyl vybrán žádný typ entity.", + "entity-name-filter-required": "Filtr názvu entity je povinný.", + "entity-name-filter-no-entity-matched": "Žádné entity začínající '{{entity}}' nebyly nalezeny.", + "all-subtypes": "Vše", + "select-entities": "Vybrat entity", + "no-aliases-found": "Žádné aliasy nebyly nalezeny.", + "no-alias-matching": "'{{alias}}' nebyl nalezen.", + "create-new-alias": "Vytvořit nový!", + "key": "Klíč", + "key-name": "Název klíče", + "no-keys-found": "Nebyly nalezeny žádné klíče.", + "no-key-matching": "'{{key}}' nebyl nalezen.", + "create-new-key": "Vytvořit nový!", + "type": "Typ", + "type-required": "Typ entity je povinný.", + "type-device": "Zařízení", + "type-devices": "Zařízení", + "list-of-devices": "{ count, plural, 1 {Jedno zařízení} other {Seznam # zařízení} }", + "device-name-starts-with": "Zařízení, jejichž název začíná '{{prefix}}'", + "type-device-profile": "Profil zařízení", + "type-device-profiles": "Profily zařízení", + "list-of-device-profiles": "{ count, plural, 1 {Jeden profil zařízení} other {Seznam # profilů zařízení} }", + "device-profile-name-starts-with": "Profily zařízení, jejichž název začíná '{{prefix}}'", + "type-asset": "Aktivum", + "type-assets": "Aktiva", + "list-of-assets": "{ count, plural, 1 {Jedno aktivum} other {Seznam # aktiv} }", + "asset-name-starts-with": "Aktiva, jejichž název začíná '{{prefix}}'", + "type-entity-view": "Entitní pohled", + "type-entity-views": "Entitní pohledy", + "list-of-entity-views": "{ count, plural, 1 {Jeden entitní pohled} other {Seznam # entitních pohledů} }", + "entity-view-name-starts-with": "Entitní pohledy, jejichž název začíná '{{prefix}}'", + "type-rule": "Pravidlo", + "type-rules": "Pravidla", + "list-of-rules": "{ count, plural, 1 {Jedno pravidlo} other {Seznam # pravidel} }", + "rule-name-starts-with": "Pravidla, jejichž název začíná '{{prefix}}'", + "type-plugin": "Zásuvný modul", + "type-plugins": "Zásuvné moduly", + "list-of-plugins": "{ count, plural, 1 {Jeden zásuvný modul} other {Seznam # zásuvných modulů} }", + "plugin-name-starts-with": "Zásuvné moduly, jejichž název začíná '{{prefix}}'", + "type-tenant": "Tenant", + "type-tenants": "Tenanti", + "list-of-tenants": "{ count, plural, 1 {Jeden tenant} other {Seznam # tenantů} }", + "tenant-name-starts-with": "Tenanti, jejichž název začíná '{{prefix}}'", + "type-tenant-profile": "Profil tenanta", + "type-tenant-profiles": "Profily tenantů", + "list-of-tenant-profiles": "{ count, plural, 1 {Jeden profil tenanta} other {Seznam # profilů tenantů} }", + "tenant-profile-name-starts-with": "Profily tenantů, jejichž název začíná '{{prefix}}'", + "type-customer": "Zákazník", + "type-customers": "Zákazníci", + "list-of-customers": "{ count, plural, 1 {Jeden zákazník} other {Seznam # zákazníků} }", + "customer-name-starts-with": "Zákazníci, jejichž název začíná '{{prefix}}'", + "type-user": "Uživatel", + "type-users": "Uživatelé", + "list-of-users": "{ count, plural, 1 {Jeden uživatel} other {Seznam # uživatelů} }", + "user-name-starts-with": "Uživatelé, jejichž název začíná '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboardy", + "list-of-dashboards": "{ count, plural, 1 {Jeden dashboard} other {Seznam # dashboardů} }", + "dashboard-name-starts-with": "Dashboardy, jejichž název začíná '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarmy", + "list-of-alarms": "{ count, plural, 1 {Jeden alarm} other {Seznam # alarmů} }", + "alarm-name-starts-with": "Alarmy, jejichž název začíná '{{prefix}}'", + "type-rulechain": "Řetěz pravidel", + "type-rulechains": "Řetězy pravidel", + "list-of-rulechains": "{ count, plural, 1 {Jeden řetěz pravidel} other {Seznam # řetězů pravidel} }", + "rulechain-name-starts-with": "Řetězy pravidel, jejichž název začíná '{{prefix}}'", + "type-rulenode": "Uzel pravidla", + "type-rulenodes": "Uzly pravidel", + "list-of-rulenodes": "{ count, plural, 1 {Jeden uzel pravidla} other {Seznam # uzlů pravidel} }", + "rulenode-name-starts-with": "Uzly pravidel, jejichž název začíná '{{prefix}}'", + "type-current-customer": "Stávající zákazník", + "type-current-tenant": "Stávající tenant", + "type-current-user": "Stávající uživatel", + "type-current-user-owner": "Vlastník stávajícího uživatele", + "search": "Vyhledat entity", + "selected-entities": "{ count, plural, 1 {1 entita} other {# entit} } zvoleno", + "entity-name": "Název entity", + "entity-label": "Označení entity", + "details": "Detail entity", + "no-entities-prompt": "Žádné entity nebyly nalezeny", + "no-data": "Nelze zobrazit žádná data", + "columns-to-display": "Zobrazit sloupce", + "type-api-usage-state": "Stav využití API", + "type-edge": "Edge", + "type-edges": "Edge", + "list-of-edges": "{ count, plural, 1 {Jedna edge} other {Seznam # edge} }", + "edge-name-starts-with": "Edge, jejichž název začíná '{{prefix}}'", + "type-tb-resource": "Zdroj", + "type-ota-package": "OTA balíček" + }, + "entity-field": { + "created-time": "Datum vytvoření", + "name": "Název", + "type": "Typ", + "first-name": "Jméno", + "last-name": "Příjmení", + "email": "Email", + "title": "Titul", + "country": "Stát", + "state": "Region", + "city": "Město", + "address": "Adresa", + "address2": "Adresa 2", + "zip": "PSČ", + "phone": "Telefon", + "label": "Označení" + }, + "entity-view": { + "entity-view": "Entitní pohled", + "entity-view-required": "Entitní pohled je povinný.", + "entity-views": "Entitní pohledy", + "management": "Správa entitních pohledů", + "view-entity-views": "Zobrazit entitní pohledy", + "entity-view-alias": "Alias entitního pohledu", + "aliases": "Aliasy entitního pohledu", + "no-alias-matching": "'{{alias}}' nenalezen.", + "no-aliases-found": "Žádné aliasy nebyly nalezeny.", + "no-key-matching": "'{{key}}' nenalezen.", + "no-keys-found": "Žádné klíče nebyly nalezeny.", + "create-new-alias": "Vytvořit nový!", + "create-new-key": "Vytvořit nový!", + "duplicate-alias-error": "Byl nalezen duplicitní alias '{{alias}}'.
    Aliasy entitních pohledů musí být v rámci dashboardu unikátní.", + "configure-alias": "Konfigurovat '{{alias}}' alias", + "no-entity-views-matching": "Žádné entitní pohledy odpovídající '{{entity}}' nebyly nalezeny.", + "public": "Veřejný", + "alias": "Alias", + "alias-required": "Alias entitního pohledu je povinný.", + "remove-alias": "Odebrat alias entitního pohledu", + "add-alias": "Přidat alias entitního pohledu", + "name-starts-with": "Název entitního pohledu začíná", + "help-text": "Použijte '%' dle potřeby: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Seznam entitních pohledů", + "use-entity-view-name-filter": "Použít filtr", + "entity-view-list-empty": "Nebyly vybrány žádné entitní pohledy.", + "entity-view-name-filter-required": "Filtr názvu entitního pohledu je povinný.", + "entity-view-name-filter-no-entity-view-matched": "Žádné entitní pohledy začínající '{{entityView}}' nebyly nalezeny.", + "add": "Přidat entitní pohled", + "entity-view-public": "Entitní pohled je veřejný", + "assign-to-customer": "Přiřadit zákazníkovi", + "assign-entity-view-to-customer": "Přiřadit entitní pohled(y) zákazníkovi", + "assign-entity-view-to-customer-text": "Vyberte prosím entitní pohledy, které mají být přiřazeny zákazníkovi", + "assign-entity-view-to-edge-title": "Přiřadit entitní pohled(y) k edge", + "no-entity-views-text": "Žádné entitní pohledy nebyly nalezeny", + "assign-to-customer-text": "Vyberte prosím zákazníka, kterému má být entitní pohled(y) přiřazen(y)", + "entity-view-details": "Detail entitního pohledu", + "add-entity-view-text": "Přidat nový entitní pohled", + "delete": "Smazat entitní pohled", + "assign-entity-views": "Přiřadit entitní pohledy", + "assign-entity-views-text": "Přiřadit { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} } zákazníkovi", + "delete-entity-views": "Smazat entitní pohledy", + "unassign-from-customer": "Odebrat zákazníkovi", + "unassign-entity-views": "Odebrat entitní pohledy", + "unassign-entity-views-action-title": "Odebrat { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} } zákazníkovi", + "assign-new-entity-view": "Přiřadit nový entitní pohled", + "delete-entity-view-title": "Jste si jisti, že chcete smazat entitní pohled '{{entityViewName}}'?", + "delete-entity-view-text": "Buďte opatrní, protože po potvrzení nebude možné entitní pohled ani žádná související data obnovit.", + "delete-entity-views-title": "Jste si jisti, že chcete odstranit entitní pohled { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} }?", + "delete-entity-views-action-title": "Smazat { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} }", + "delete-entity-views-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané entitní pohledy smazány a žádná související data nebude možné obnovit.", + "unassign-entity-view-title": "Jste si jisti, že chcete odebrat entitní pohled '{{entityViewName}}'?", + "unassign-entity-view-text": "Po potvrzení bude entitní pohled odebrán a nebude pro zákazníka dostupný.", + "unassign-entity-view": "Odebrat entitní pohled", + "unassign-entity-views-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} }?", + "unassign-entity-views-text": "Po potvrzení budou všechny vybrané entitní pohledy odebrány a nebudou pro zákazníka dostupné.", + "entity-view-type": "Typ entitního pohledu", + "entity-view-type-required": "Typ entitního pohledu je povinný.", + "select-entity-view-type": "Vybrat typ entitního pohledu", + "enter-entity-view-type": "Zadat typ entitního pohledu", + "any-entity-view": "Všechny entitní pohledy", + "no-entity-view-types-matching": "Žádné typy entitních pohledů odpovídající '{{entitySubtype}}' nebyly nalezeny.", + "entity-view-type-list-empty": "Žádné typy entitních pohledů nebyly nalezeny.", + "entity-view-types": "Typy entitních pohledů", + "created-time": "Datum vytvoření", + "name": "Název", + "name-required": "Název je povinný.", + "description": "Popis", + "events": "Události", + "details": "Detail", + "copyId": "Kopírovat Id entitního pohledu", + "idCopiedMessage": "Id entitního pohledu bylo zkopírováno do schránky", + "assignedToCustomer": "Přiřazeno zákazníkovi", + "unable-entity-view-device-alias-title": "Alias entitního typu nebylo možné smazat", + "unable-entity-view-device-alias-text": "Alias zařízení '{{entityViewAlias}}' nelze smazat, protože je používán následujícími widgety:
    {{widgetsList}}", + "select-entity-view": "Vybrat entitní pohled", + "make-public": "Zveřejnit entitní pohled", + "make-private": "Učinit entitní pohled neveřejným", + "start-date": "Datum zahájení", + "start-ts": "Čas zahájení", + "end-date": "Datum ukončení", + "end-ts": "Čas ukončení", + "date-limits": "Omezení data", + "client-attributes": "Klientské atributy", + "shared-attributes": "Sdílené atributy", + "server-attributes": "Serverové atributy", + "timeseries": "Časové řady", + "client-attributes-placeholder": "Klientské atributy", + "shared-attributes-placeholder": "Sdílené atributy", + "server-attributes-placeholder": "Serverové atributy", + "timeseries-placeholder": "Časové řady", + "target-entity": "Cílová entita", + "attributes-propagation": "Propagace atributů", + "attributes-propagation-hint": "Entitní pohled bude automaticky kopírovat specifikované atributy z cílové entity vždy, když uložíte nebo aktualizujete tento entitní pohled. Z výkonnostních důvodů nejsou atributy cílové entity propagovány při každé změně atributu. Automatickou propagaci můžete povolit konfigurací \"copy to view\" uzlu pravidla v rámci vašeho řetězu pravidel a provázáním \"Post attributes\" a \"Attributes Updated\" zpráv na nový uzel pravidla.", + "timeseries-data": "Data časových řad", + "timeseries-data-hint": "Nakonfigurujte klíče dat časových řad cílové entity, která budou dostupná pro entitní pohled. Tato data časových řad jsou pouze pro čtení.", + "make-public-entity-view-title": "Jste si jisti, že chcete entitní pohled '{{entityViewName}}' zveřejnit?", + "make-public-entity-view-text": "Po potvrzení bude entitní pohled a všechna jeho data veřejné a dostupné pro ostatní.", + "make-private-entity-view-title": "Jste si jisti, že chcete entitní pohled '{{entityViewName}}' učinit neveřejným?", + "make-private-entity-view-text": "Po potvrzení bude entitní pohled a všechna jeho data neveřejné a nebudou dostupné pro ostatní.", + "assign-entity-view-to-edge": "Přiřadit entitní pohled(y) k edge", + "assign-entity-view-to-edge-text":"Zvolte prosím entitní pohledy, které mají být přiřazeny k edge", + "unassign-entity-view-from-edge-title": "Jste si jisti, že chcete odebrat entitní pohled '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Po potvrzení bude entitní pohled odebrán a nebude pro edge dostupný.", + "unassign-entity-views-from-edge-action-title": "Odebrat { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} } from edge", + "unassign-entity-view-from-edge": "Odebrat entitní pohled", + "unassign-entity-views-from-edge-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 entitní pohled} other {# entitních pohledů} }?", + "unassign-entity-views-from-edge-text": "Po potvrzení budou všechny vybrané entitní pohledy odebrány a nebudou pro edge dostupné." + }, + "event": { + "event-type": "Typ události", + "events-filter": "Filtr událostí", + "type-error": "Chyba", + "type-lc-event": "Událost životního cyklu", + "type-stats": "Statistika", + "type-debug-rule-node": "Ladění", + "type-debug-rule-chain": "Ladění", + "no-events-prompt": "Žádné události nebyly nalezeny", + "error": "Chyba", + "alarm": "Alarm", + "event-time": "Čas události", + "server": "Server", + "body": "Tělo", + "method": "Metoda", + "type": "Typ", + "message-id": "Id zprávy", + "message-type": "Typ zprávy", + "data-type": "Typ dat", + "relation-type": "Typ vztahu", + "metadata": "Metadata", + "data": "Data", + "event": "Událost", + "status": "Stav", + "success": "Úspěch", + "failed": "Neúspěch", + "messages-processed": "Zpracované zprávy", + "min-messages-processed": "Minimální počet zpracovaných zpráv", + "errors-occurred": "Vyskytly se chyby", + "min-errors-occurred": "Minimální počet chyb, které se vyskytly", + "min-value": "Minimální hodnota je 0.", + "all-events": "Vše", + "has-error": "S chybou", + "entity-id": "Id entity", + "entity-type": "Typ entity" + }, + "extension": { + "extensions": "Rozšíření", + "selected-extensions": "Vybráno { count, plural, 1 {1 rozšíření} other {# rozšíření} }", + "type": "Typ", + "key": "Klíč", + "value": "Hodnota", + "id": "Id", + "extension-id": "Id rozšíření", + "extension-type": "Typ rozšíření", + "transformer-json": "JSON *", + "unique-id-required": "Id stávajícího rozšíření již existuje.", + "delete": "Smazat rozšíření", + "add": "Přidat rozšíření", + "edit": "Editovat rozšíření", + "delete-extension-title": "Jste si jisti, že chcete smazat rozšíření '{{extensionId}}'?", + "delete-extension-text": "Buďte opatrní, protože po potvrzení nebude možné rozšíření ani související data obnovit.", + "delete-extensions-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 rozšíření} other {# rozšíření} }?", + "delete-extensions-text": "Buďte opatrní, protože po potvrzení budou všechna vybraná rozšíření odstraněna.", + "converters": "Převodník", + "converter-id": "Id převodníku", + "configuration": "Konfigurace", + "converter-configurations": "Konfigurace převodníku", + "token": "Bezpečnostní token", + "add-converter": "Přidat převodník", + "add-config": "Přidat konfiguraci převodníku", + "device-name-expression": "Výraz názvu zařízení", + "device-type-expression": "Výraz typu zařízení", + "custom": "Vlastní", + "to-double": "Zdvojnásobit", + "transformer": "Transformátor", + "json-required": "JSON transformátoru je povinný.", + "json-parse": "Nebylo možné parsovat JSON transformátoru.", + "attributes": "Atributy", + "add-attribute": "Přidat atributy", + "add-map": "Přidat mapovací prvek", + "timeseries": "Časové řady", + "add-timeseries": "Přidat časové řady", + "field-required": "Pole je povinné", + "brokers": "Message brokers", + "add-broker": "Přidat message brokera", + "host": "Host", + "port": "Port", + "port-range": "Port by měl být v rozsahu mezi 1 a 65535.", + "ssl": "Ssl", + "credentials": "Přístupové údaje", + "username": "Uživatelské jméno", + "password": "Heslo", + "retry-interval": "Interval pro další pokus v milisekundách", + "anonymous": "Anonymní", + "basic": "Základní", + "pem": "PEM", + "ca-cert": "Soubor CA certifikátu *", + "private-key": "Soubor privátního klíče *", + "cert": "Soubor certifikátu *", + "no-file": "Žádný soubor nebyl vybrán.", + "drop-file": "Přetáhněte sem soubor nebo klikněte pro výběr souboru pro nahrání.", + "mapping": "Mapování", + "topic-filter": "Filtr MQ fronty", + "converter-type": "Typ převodníku", + "converter-json": "JSON", + "json-name-expression": "JSON výraz názvu zařízení", + "topic-name-expression": "Výraz názvu MQ fronty", + "json-type-expression": "JSON Výraz typu zařízení", + "topic-type-expression": "Výraz MQ fronty typu zařízení", + "attribute-key-expression": "Výraz klíče atributu", + "attr-json-key-expression": "JSON výraz klíče atributu", + "attr-topic-key-expression": "Výraz MQ fronty klíče atributu", + "request-id-expression": "Výraz Id požadavku", + "request-id-json-expression": "JSON výraz Id požadavku", + "request-id-topic-expression": "Výraz MQ fronty ID požadavku", + "response-topic-expression": "Výraz fronty odpovědi", + "value-expression": "Výraz hodnoty", + "topic": " MQ fronta", + "timeout": "Timeout v milisekundách", + "converter-json-required": "JSON převodníku je povinný.", + "converter-json-parse": "JSON převodníku nebylo možné parsovat.", + "filter-expression": "Výraz filtru", + "connect-requests": "Požadavky na spojení", + "add-connect-request": "Přidat požadavek na spojení", + "disconnect-requests": "Požadavky na odpojení", + "add-disconnect-request": "Přidat požadavek na odpojení", + "attribute-requests": "Požadavky na atribut", + "add-attribute-request": "Přidat požadavek na atribut", + "attribute-updates": "Aktualizace atributu", + "add-attribute-update": "Přidat aktualizaci atributu", + "server-side-rpc": "RPC na straně serveru", + "add-server-side-rpc-request": "Přidat požadavek na RPC na straně serveru", + "device-name-filter": "Filtr názvu zařízení", + "attribute-filter": "Filtr atributu", + "method-filter": "Filtr metody", + "request-topic-expression": "Výraz požadavku na MQ frontu", + "response-timeout": "Timeout odpovědi v milisekundách", + "topic-expression": "Výraz MQ fronty", + "client-scope": "Scope klienta", + "add-device": "Přidat zařízení", + "opc-server": "Servery", + "opc-add-server": "Přidat server", + "opc-add-server-prompt": "Prosím přidejte server", + "opc-application-name": "Název aplikace", + "opc-application-uri": "URI aplikace", + "opc-scan-period-in-seconds": "Interval skenování ve vteřinách", + "opc-security": "Bezpečnost", + "opc-identity": "Identita", + "opc-keystore": "Úložiště klíčů", + "opc-type": "Typ", + "opc-keystore-type": "Typ", + "opc-keystore-location": "Umístění *", + "opc-keystore-password": "Heslo", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Heslo klíče", + "opc-device-node-pattern": "Vzor uzlu zařízení", + "opc-device-name-pattern": "Vzor názvu zařízení", + "modbus-server": "Servery/slaves", + "modbus-add-server": "Přidat server/slave", + "modbus-add-server-prompt": "Prosím přidejte server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Automaticky znovu připojit", + "modbus-rtu-over-tcp": "RTU přes TCP", + "modbus-port-name": "Název sériového portu", + "modbus-encoding": "Šifrování", + "modbus-parity": "Parita", + "modbus-baudrate": "Baud rate", + "modbus-databits": "Data bits", + "modbus-stopbits": "Stop bits", + "modbus-databits-range": "Data bits by měly být v rozsahu od 7 do 8.", + "modbus-stopbits-range": "Stop bits by měly být v rozsahu od 1 do 2.", + "modbus-unit-id": "ID jednotky", + "modbus-unit-id-range": "ID jednotky by mělo být v rozsahu od 1 do 247.", + "modbus-device-name": "Název zařízení", + "modbus-poll-period": "Interval kontroly (ms)", + "modbus-attributes-poll-period": "Interval kontroly atributů (ms)", + "modbus-timeseries-poll-period": "Interval kontroly časových řad (ms)", + "modbus-poll-period-range": "Interval kontroly by měl mít kladnou hodnotu.", + "modbus-tag": "Štítek", + "modbus-function": "Funkce", + "modbus-register-address": "Adresa registrace", + "modbus-register-address-range": "Adresa registrace by měla být v rozsahu od 0 do 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "Bit index by měl být v rozsahu od 0 do 15.", + "modbus-register-count": "Počet registrací", + "modbus-register-count-range": "Počet registrací by měl mít kladnou hodnotu.", + "modbus-byte-order": "Byte order", + "sync": { + "status": "Stav", + "sync": "Synchronizováno", + "not-sync": "Nesynchronizováno", + "last-sync-time": "Čas poslední synchronizace", + "not-available": "Nedostupné" + }, + "export-extensions-configuration": "Exportovat konfiguraci rozšíření", + "import-extensions-configuration": "Importovat konfiguraci rozšíření", + "import-extensions": "Importovat rozšíření", + "import-extension": "Importovat rozšíření", + "export-extension": "Exportovat rozšíření", + "file": "Soubor rozšíření", + "invalid-file-error": "Neplatný soubor rozšíření" + }, + "filter": { + "add": "Přidat filtr", + "edit": "Editovat filtr", + "name": "Název filtru", + "name-required": "Název filtru je povinný.", + "duplicate-filter": "Filtr s identickým názvem již existuje.", + "filters": "Filtry", + "unable-delete-filter-title": "Smazat filtr není možné", + "unable-delete-filter-text": "Filtr '{{filter}}' není možné smazat, protože je používán následujícím widgetem(y):
    {{widgetsList}}", + "duplicate-filter-error": "Nalezen duplicitní filtr '{{filter}}'.
    Filtry musí být v rámci dashboardu unikátní.", + "missing-key-filters-error": "U filtru '{{filter}}' chybí klíčové filtry.", + "filter": "Filtr", + "editable": "Editovatelné", + "no-filters-found": "Žádné filtry nebyly nalezeny.", + "no-filter-text": "Není specifikován žádný filtr", + "add-filter-prompt": "Přidejte prosím filtr", + "no-filter-matching": "'{{filter}}' nebyl nalezen.", + "create-new-filter": "Vytvořit nový!", + "filter-required": "Filtr je povinný.", + "operation": { + "operation": "Operace", + "equal": "je rovno", + "not-equal": "není rovno", + "starts-with": "začíná na", + "ends-with": "končí na", + "contains": "obsahuje", + "not-contains": "neobsahuje", + "greater": "větší než", + "less": "menší než", + "greater-or-equal": "větší nebo rovno", + "less-or-equal": "menší nebo rovno", + "and": "a", + "or": "nebo" + }, + "ignore-case": "ignorovat velikost písmen", + "value": "Hodnota", + "remove-filter": "Odebrat filtr", + "preview": "Náhled filtru", + "no-filters": "Nejsou konfigurovány žádné filtry", + "add-filter": "Přidat filtr", + "add-complex-filter": "Přidat komplexní filtr", + "add-complex": "Přidat komplex", + "complex-filter": "Komplexní filtr", + "edit-complex-filter": "Editovat komplexní filtr", + "edit-filter-user-params": "Editovat filtr predikátu parametrů uživatele", + "filter-user-params": "Filtr predikátu parametrů uživatele", + "user-parameters": "Parametry uživatele", + "display-label": "Zobrazované označení", + "autogenerated-label": "Automaticky vygenerovat označení", + "order-priority": "Priority pořadí polí", + "key-filter": "Klíčový filtr", + "key-filters": "Klíčové filtry", + "key-name": "Název klíče", + "key-name-required": "Název klíče je povinný.", + "key-type": { + "key-type": "Typ klíče", + "attribute": "Atribut", + "timeseries": "Časové řady", + "entity-field": "Pole entity", + "constant": "Konstanta" + }, + "value-type": { + "value-type": "Typ hodnoty", + "string": "Řetězec", + "numeric": "Číslo", + "boolean": "Pravdivostní hodnota", + "date-time": "Datum a čas" + }, + "value-type-required": "Typ hodnoty klíče je povinný.", + "key-value-type-change-title": "Jste si jisti, že chcete změnit typ klíče hodnoty?", + "key-value-type-change-message": "Pokud potvrdíte nový typ hodnoty, všechny zadané klíčové filtry budou odstraněny.", + "no-key-filters": "Nejsou konfigurovány žádné klíčové filtry", + "add-key-filter": "Přidat klíčový filtr", + "remove-key-filter": "Odebrat klíčový filtr", + "edit-key-filter": "Editovat klíčový filtr", + "date": "Datum", + "time": "Čas", + "current-tenant": "Stávající tenant", + "current-customer": "Stávající zákazník", + "current-user": "Stávající uživatel", + "current-device": "Stávající zařízení", + "default-value": "Defaultní hodnota", + "dynamic-source-type": "Dynamický typ zdroje", + "no-dynamic-value": "Žádná dynamická hodnota", + "source-attribute": "Atribut zdroje", + "switch-to-dynamic-value": "Přepnout na dynamickou hodnotu", + "switch-to-default-value": "Přepnout na defaultní hodnotu", + "inherit-owner": "Zdědit od vlastníka", + "source-attribute-not-set": "Jestliže zdrojový atribut není nastaven" + }, + "fullscreen": { + "expand": "Rozšířit do režimu celé obrazovky", + "exit": "Ukončit režim celé obrazovky", + "toggle": "Přepnout do režimu celé obrazovky", + "fullscreen": "Celá obrazovka" + }, + "function": { + "function": "Funkce" + }, + "gateway": { + "add-entry": "Přidat konfiguraci", + "connector-add": "Přidat nový konektor", + "connector-enabled": "Povolit konektor", + "connector-name": "Název konektoru", + "connector-name-required": "Název konektoru je povinný.", + "connector-type": "Typ konektoru", + "connector-type-required": "Typ konektoru je povinný.", + "connectors": "Konfigurace konektoru", + "create-new-gateway": "Vytvořit novou bránu", + "create-new-gateway-text": "Jste si jisti, že chcete vytvořit novou bránu s názvem: '{{gatewayName}}'?", + "delete": "Smazat konfiguraci", + "download-tip": "Stáhnout soubor konfigurace", + "gateway": "Brána", + "gateway-exists": "Zařízení se shodným názvem již existuje.", + "gateway-name": "Název brány", + "gateway-name-required": "Název brány je povinný.", + "gateway-saved": "Konfigurace brány byla úspěšně uložena.", + "json-parse": "Neplatný JSON.", + "json-required": "Pole nemůže být prázdné.", + "no-connectors": "Žádné konektory", + "no-data": "Žádné konfigurace", + "no-gateway-found": "Žádné brány nebyly nalezeny.", + "no-gateway-matching": " '{{item}}' nenalezena.", + "path-logs": "Cesta k souborům logu", + "path-logs-required": "Cesta je povinná.", + "remote": "Vzdálená konfigurace", + "remote-logging-level": "Úroveň logování", + "remove-entry": "Odstranit konfiguraci", + "save-tip": "Uložit soubor konfigurace", + "security-type": "Typ zabezpečení", + "security-types": { + "access-token": "Přístupový token", + "tls": "TLS" + }, + "storage": "Úložiště", + "storage-max-file-records": "Maximální počet záznamů v souboru", + "storage-max-files": "Maximální počet souborů", + "storage-max-files-min": "Minimální počet je 1.", + "storage-max-files-pattern": "Počet není platný.", + "storage-max-files-required": "Počet je povinný.", + "storage-max-records": "Maximální počet záznamů v úložišti", + "storage-max-records-min": "Minimální počet záznamů je 1.", + "storage-max-records-pattern": "Počet není platný.", + "storage-max-records-required": "Maximální počet záznamů je povinný.", + "storage-pack-size": "Maximální velikost souboru událostí", + "storage-pack-size-min": "Minimální počet je 1.", + "storage-pack-size-pattern": "Počet není platný.", + "storage-pack-size-required": "Maximální velikost souboru událostí je povinná.", + "storage-path": "Cesta k úložišti", + "storage-path-required": "Cesta k úložišti je povinná.", + "storage-type": "Typ úložiště", + "storage-types": { + "file-storage": "Soubor", + "memory-storage": "Paměť" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "Host ThingsBoard", + "thingsboard-host-required": "Host je povinný.", + "thingsboard-port": "Port ThingsBoard", + "thingsboard-port-max": "Maximální číslo portu je 65535.", + "thingsboard-port-min": "Minimální číslo portu je 1.", + "thingsboard-port-pattern": "Port není platný.", + "thingsboard-port-required": "Port je povinný.", + "tidy": "Uspořádat", + "tidy-tip": "Uspořádat JSON konfiguraci", + "title-connectors-json": "Konfigurace {{typeName}} konektoru", + "tls-path-ca-certificate": "Cesta k certifikátu CA brány", + "tls-path-client-certificate": "Cesta k certifikátu klienta brány", + "tls-path-private-key": "Cesta k privátnímu klíči brány", + "toggle-fullscreen": "Přepnout do režimu celé obrazovky", + "transformer-json-config": "JSON* konfigurace", + "update-config": "Přidat/editovat JSON konfiguraci" + }, + "grid": { + "delete-item-title": "Jste si jisti, že chcete smazat tuto položku?", + "delete-item-text": "Buďte opatrní, protože po potvrzení nebude možné tuto položku ani žádná související data obnovit.", + "delete-items-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 položku} other {# položek} }?", + "delete-items-action-title": "Smazat { count, plural, 1 {1 položku} other {# položek} }", + "delete-items-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané položky odstraněny a žádná související data nebude možné obnovit.", + "add-item-text": "Přidat novou položku", + "no-items-text": "Žádné položky nebyly nalezeny", + "item-details": "Detail položky", + "delete-item": "Smazat položku", + "delete-items": "Smazat položky", + "scroll-to-top": "Nahoru" + }, + "help": { + "goto-help-page": "Jít na stránku nápovědy" + }, + "home": { + "home": "Domů", + "profile": "Profil", + "logout": "Odhlásit", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Otevřít uživatelské menu" + }, + "import": { + "no-file": "Nebyl vybrán žádný soubor", + "drop-file": "Přetáhněte sem JSON soubor nebo klikněte pro výběr souboru pro nahrání.", + "drop-file-csv": "Přetáhněte sem CSV soubor nebo klikněte pro výběr souboru pro nahrání.", + "column-value": "Hodnota", + "column-title": "Název", + "column-example": "Příklad hodnoty", + "column-key": "Klíč atributu/telemetrie", + "csv-delimiter": "Oddělovač CSV", + "csv-first-line-header": "První řádek obsahuje názvy sloupců", + "csv-update-data": "Editovat atributy/telemetrii", + "import-csv-number-columns-error": "Soubor by měl obsahovat minimálně dva sloupce", + "import-csv-invalid-format-error": "Neplatný formát souboru. Řádek: '{{line}}'", + "column-type": { + "name": "Název", + "type": "Typ", + "label": "Označení", + "column-type": "Typ sloupce", + "client-attribute": "Atribut klienta", + "shared-attribute": "Sdílený atribut", + "server-attribute": "Atribut serveru", + "timeseries": "Časové řady", + "entity-field": "Pole entity", + "access-token": "Přístupový token", + "isgateway": "Je bránou", + "activity-time-from-gateway-device": "Čas aktivity ze zařízení brány", + "description": "Popis", + "routing-key": "Klíč edge", + "secret": "Edge secret" + }, + "stepper-text":{ + "select-file": "Vybrat soubor", + "configuration": "Importovat konfiguraci", + "column-type": "Vybrat typ sloupců", + "creat-entities": "Vytvořím nové entity" + }, + "message": { + "create-entities": "{{count}} nových entit bylo úspěšně vytvořeno.", + "update-entities": "{{count}} entit bylo úspěšně aktualizováno.", + "error-entities": "Při vytvoření {{count}} entit došlo k chybě." + } + }, + "item": { + "selected": "Vybráno" + }, + "js-func": { + "no-return-error": "Funkce musí vrátit hodnotu!", + "return-type-mismatch": "Funkce musí vrátit hodnotu '{{type}}' typu!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Klíč", + "value": "Hodnota", + "remove-entry": "Odstranit záznam", + "add-entry": "Přidat záznam", + "no-data": "Žádné záznamy" + }, + "layout": { + "layout": "Rozmístění", + "manage": "Spravovat rozmístění", + "settings": "Nastavení rozmístění", + "color": "Barva", + "main": "Hlavní", + "right": "Vpravo", + "select": "Vybrat cílové rozmístění" + }, + "legend": { + "direction": "Směr legendy", + "position": "Pozice legendy", + "sort-legend": "Setřídit datové klíče v legendě", + "show-max": "Zobrazit max hodnotu", + "show-min": "Zobrazit min hodnotu", + "show-avg": "Zobrazit průměrnou hodnotu", + "show-total": "Zobrazit celkovou hodnotu", + "settings": "Nastavení legendy", + "min": "min", + "max": "max", + "avg": "průměr", + "total": "celkem", + "comparison-time-ago": { + "previousInterval": "(předchozí interval)", + "days": "(před dny)", + "weeks": "(před týdny)", + "months": "(před měsíci)", + "years": "(před roky)" + } + }, + "login": { + "login": "Přihlásit se", + "request-password-reset": "Vyžádat reset hesla", + "reset-password": "Reset hesla", + "create-password": "Vytvořit heslo", + "passwords-mismatch-error": "Zadaná hesla se musí shodovat!", + "password-again": "Heslo znovu", + "sign-in": "Prosím zaregistrujte se", + "username": "Uživatelské jméno (email)", + "remember-me": "Zapamatovat si mě", + "forgot-password": "Zapomněli jste heslo?", + "password-reset": "Reset hesla", + "expired-password-reset-message": "Platnost vašich přístupových údajů vypršela! Nastavte si prosím nové heslo.", + "new-password": "Nové heslo", + "new-password-again": "Nové heslo znovu", + "password-link-sent-message": "Odkaz pro reset hesla byl úspěšně odeslán!", + "email": "Email", + "login-with": "Přihlásit se přes {{name}}", + "or": "nebo", + "error": "Chyba přihlášení" + }, + "ota-update": { + "add": "Přidat balíček", + "assign-firmware": "Přiřazený firmware", + "assign-firmware-required": "Přiřazený firmware je povinný", + "assign-software": "Přiřazený software", + "assign-software-required": "Přiřazený software je povinný", + "auto-generate-checksum": "Automaticky vygenerovat checksum", + "checksum": "Checksum", + "checksum-hint": "Jestliže je checksum prádzná, bude automaticky vygenerována", + "checksum-algorithm": "Checksum algoritmus", + "checksum-copied-message": "Checksum balíčku byla zkopírována do schránky", + "change-firmware": "Změna firmware může způsobit aktualizaci { count, plural, 1 {1 zařízení} other {# zařízení} }.", + "change-software": "Změna software může způsobit aktualizaci { count, plural, 1 {1 zařízení} other {# zařízení} }.", + "chose-compatible-device-profile": "Nahraný balíček bude dostupný pouze pro zařízení s vybraným profilem.", + "chose-firmware-distributed-device": "Vyberte firmware, který bude distribuován do zařízení", + "chose-software-distributed-device": "Vyberte software, který bude distribuován do zařízení", + "content-type": "Typ obsahu", + "copy-checksum": "Kopírovat checksum", + "copy-direct-url": "Kopírovat přímou URL", + "copyId": "Kopírovat Id balíčku", + "copied": "Zkopírováno!", + "delete": "Odstranit balíček", + "delete-ota-update-text": "Buďte opatrní, protože po potvrzení nebude možné OTA aktualizaci obnovit.", + "delete-ota-update-title": "Jste si jisti, že chcete odstranit OTA aktualizaci '{{title}}'?", + "delete-ota-updates-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané OTA aktualizace odstraněny.", + "delete-ota-updates-title": "Jste si jisti, že chcete odstranit { count, plural, 1 {1 OTA aktualizaci} other {# OTA aktualizací} }?", + "description": "Popis", + "direct-url": "Přímá URL", + "direct-url-copied-message": "Přímá URL balíčku bylo zkopírována do schránky", + "direct-url-required": "Přímá URL je povinná", + "download": "Stáhnout balíček", + "drop-file": "Přetáhněte sem soubor balíčku nebo klikněte pro výběr souboru, který má být nahrán.", + "file-name": "Název souboru", + "file-size": "Velikost souboru", + "file-size-bytes": "Velikost souboru v bajtech", + "idCopiedMessage": "Id balíčku bylo zkopírováno do schránky", + "no-firmware-matching": "Žádné kompatibilní balíčky OTA aktualizace firmware odpovídající '{{entity}}' nebyly nalezeny.", + "no-firmware-text": "Žádné kompatibilní balíčky OTA aktualizace firmware nebyly poskytnuty.", + "no-packages-text": "Žádné balíčky nebyly nalezeny", + "no-software-matching": "Žádné kompatibilní balíčky OTA aktualizace software odpovídající '{{entity}}' nebyly nalezeny.", + "no-software-text": "Žádné kompatibilní balíčky OTA aktualizace software nebyly poskytnuty.", + "ota-update": "OTA aktualizace", + "ota-update-details": "Detail OTA aktualizace", + "ota-updates": "OTA aktualizace", + "package-type": "Typ balíčku", + "packages-repository": "Repozitář balíčku", + "search": "Vyhledat balíčky", + "selected-package": "Vybráno { count, plural, 1 {1 balíčků} other {# balíčků} }", + "title": "Název", + "title-required": "Název je povinný.", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "version": "Verze", + "version-required": "Verze je povinná.", + "warning-after-save-no-edit": "Jakmile je balíček nahrán, nebudete moci změnit název, verzi, profil zařízení, ani typ balíčku." + }, + "position": { + "top": "Nahoře", + "bottom": "Dole", + "left": "Vlevo", + "right": "Vpravo" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Poslední přihlášení", + "change-password": "Změnit heslo", + "current-password": "Stávající heslo" + }, + "relation": { + "relations": "Vztahy", + "direction": "Směr", + "search-direction": { + "FROM": "Od", + "TO": "K" + }, + "direction-type": { + "FROM": "od", + "TO": "k" + }, + "from-relations": "Odchozí vztahy", + "to-relations": "Příchozí vztahy", + "selected-relations": "Vybráno { count, plural, 1 {1 vztahů} other {# vztahů} }", + "type": "Typ", + "to-entity-type": "K typ entity", + "to-entity-name": "K název entity", + "from-entity-type": "Z typ entity", + "from-entity-name": "Z název entity", + "to-entity": "K entitě", + "from-entity": "Od entity", + "delete": "Smazat vztah", + "relation-type": "Typ vztahu", + "relation-type-required": "Typ vztahu je povinný.", + "any-relation-type": "Všechny typy", + "add": "Přidat vztah", + "edit": "Editovat vztah", + "delete-to-relation-title": "Jste si jisti, že chcete smazat vztah k entitě '{{entityName}}'?", + "delete-to-relation-text": "Buďte opatrní, protože po potvrzení bude vtah entity '{{entityName}}' k aktuální entitě zrušen.", + "delete-to-relations-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 vztah} other {# vztahů} }?", + "delete-to-relations-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané vztahy odstraněny a vztah odpovídajících entit k aktuální entitě bude zrušen.", + "delete-from-relation-title": "Jste si jisti, že chcete smazat vztah z entity '{{entityName}}'?", + "delete-from-relation-text": "Buďte opatrní, protože po potvrzení bude zrušen vztah aktuální entity k entitě '{{entityName}}'.", + "delete-from-relations-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 vztah} other {# vztahů} }?", + "delete-from-relations-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané vztahy odstraněny a bude zrušen vztah aktuální entity k odpovídajícím entitám.", + "remove-relation-filter": "Odebrat filtr vztahů", + "add-relation-filter": "Přidat filtr vztahu", + "any-relation": "Všechny vztahy", + "relation-filters": "Filtry vztahů", + "additional-info": "Další info (JSON)", + "invalid-additional-info": "Další informace v JSON nebylo možné parsovat.", + "no-relations-text": "Žádné vztahy nebyly nalezeny" + }, + "resource": { + "add": "Přidat zdroje", + "copyId": "Zkopírovat Id zdroje", + "delete": "Odstranit zdroj", + "delete-resource-text": "Buďte opatrní, protože po potvrzení nebude možné zdroj obnovit.", + "delete-resource-title": "Jste si jisti, že chcete odstranit zdroj '{{resourceTitle}}'?", + "delete-resources-action-title": "Odstranit { count, plural, 1 {1 zdroj} other {# zdrojů} }", + "delete-resources-text": "Vezměte prosím na vědomí, že vybrané zdroje, i když jsou použity v profilech zařízení, budou odstraněny.", + "delete-resources-title": "Jste si jisti, že chcete odstranit { count, plural, 1 {1 zdroj} other {# zdrojů} }?", + "download": "Stáhnout zdroj", + "drop-file": "Přesuňte sem soubor zdroje nebo klikněte pro výběr souboru pro nahrání.", + "empty": "Zdroj je prázdný", + "file-name": "Název souboru", + "idCopiedMessage": "Id zdroje bylo zkopírováno do schránky", + "no-resource-matching": "Žádné zdroje odpovídající '{{widgetsBundle}}' nebyly nalezeny.", + "no-resource-text": "Žádné zdroje nebyly nalezeny", + "open-widgets-bundle": "Otevří kategorii widgetů", + "resource": "Zdroj", + "resource-library-details": "Detail zdroje", + "resource-type": "Typ zdroje", + "resources-library": "Knihovna zdrojů", + "search": "Vyhledat zdroje", + "selected-resources": "Vybrán { count, plural, 1 {1 zdroj} other {# zdrojů} }", + "system": "Systém", + "title": "Název", + "title-required": "Název je povinný." + }, + "rulechain": { + "rulechain": "Řetěz pravidel", + "rulechains": "Řetězy pravidel", + "root": "Základní", + "delete": "Smazat řetěz pravidel", + "name": "Název", + "name-required": "Název je povinný.", + "description": "Popis", + "add": "Přidat řetěz pravidel", + "set-root": "Učinit řetěz pravidel základním", + "set-root-rulechain-title": "Jste si jisti, že chcete učinit řetěz pravidel '{{ruleChainName}}' základním?", + "set-root-rulechain-text": "Po potvrzení se stane řetěz pravidel základním a bude zajišťovat zpracování všech příchozích transportních zpráv.", + "delete-rulechain-title": "Jste si jisti, že chcete smazat řetěz pravidel '{{ruleChainName}}'?", + "delete-rulechain-text": "Buďte opatrní, protože po potvrzení nebude možné řetěz pravidel ani žádná související data obnovit.", + "delete-rulechains-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 řetěz pravidel} other {# řetězů pravidel} }?", + "delete-rulechains-action-title": "Smazat { count, plural, 1 {1 řetěz pravidel} other {# řetězy pravidel} }", + "delete-rulechains-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané řetězy pravidel odstraněny a žádná související data nebude možné obnovit.", + "add-rulechain-text": "Přidat nový řetěz pravidel", + "no-rulechains-text": "Žádné řetězy pravidel nebyly nalezeny", + "rulechain-details": "Detail řetězu pravidel", + "details": "Detail", + "events": "Události", + "system": "Systém", + "import": "Importovat řetěz pravidel", + "export": "Exportovat řetěz pravidel", + "export-failed-error": "Řetěz pravidel nebylo možné smazat: {{error}}", + "create-new-rulechain": "Vytvořit nový řetěz pravidel", + "rulechain-file": "Soubor řetězu pravidel", + "invalid-rulechain-file-error": "Řetěz pravidel nebylo možné importovat: neplatná datová struktura řetězu pravidel.", + "copyId": "Kopírovat Id řetězu pravidel", + "idCopiedMessage": "Id řetězu pravidel bylo zkopírováno do schránky", + "select-rulechain": "Vybrat řetěz pravidel", + "no-rulechains-matching": "Žádné řetězy pravidel odpovídající '{{entity}}' nebyly nalezeny.", + "rulechain-required": "Řetěz pravidel je povinný", + "management": "Správa pravidel", + "debug-mode": "Režim ladění", + "search": "Vyhledat řetězy pravidel", + "selected-rulechains": "Vybráno { count, plural, 1 {1 řetězů pravidel} other {# řetězů pravidel} }", + "open-rulechain": "Otevřít řetěz pravidel", + "assign-new-rulechain": "Přiřadit nový řetěz pravidel", + "edge-template-root": "Hlavní šablona", + "assign-to-edge": "Přiřadit k edge", + "edge-rulechain": "Řetěz pravidel edge", + "unassign-rulechain-from-edge-text": "Po potvrzení bude řetěz pravidel odebrán a nebude pro edge dostupný.", + "unassign-rulechains-from-edge-title": "Jste si jisti, že chcete odebrat { count, plural, 1 {1 řetěz pravidel} other {# řetězů pravidel} }?", + "unassign-rulechains-from-edge-text": "Po potvrzení budou všechny vybrané řetězy pravidelodebrány a nedbuou pro edge dostupné.", + "assign-rulechain-to-edge-title": "Přiřadit řetěz(y) pravidel k edge", + "assign-rulechain-to-edge-text": "Vyberte prosím řetězy pravidel, které mají být přiřazeny k edge", + "set-edge-template-root-rulechain": "Učinit řetěz pravidel hlavní šablonou pro edge", + "set-edge-template-root-rulechain-title": "Jste si jisti, že chcete učinit řetěz pravidel '{{ruleChainName}}' hlavní šablonou pro edge?", + "set-edge-template-root-rulechain-text": "Po potvrzení se stane řetěz pravidel hlavní šablonou pro edge a bude hlavním řetězem pravidel pro nově vytvářené edge.", + "invalid-rulechain-type-error": "Řetěz pravidel nebylo možné importovat: Neplatný typ řetězu pravidel. Očekávaný typ je {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Přiřadit řetěz pravidel při vytvření k edge", + "set-auto-assign-to-edge-title": "Jste si jisti, že chcete přiřadit edge řetěz pravidel '{{ruleChainName}}' k edge při vytvoření?", + "set-auto-assign-to-edge-text": "Po potvrzení bude edge řetěz pravidel automaticky přiřazen k edge při vytvoření.", + "unset-auto-assign-to-edge": "Nepřiřazovat řetěz pravidel k edge při vytváření", + "unset-auto-assign-to-edge-title": "Jste si jisti, že nechcete přiřazovat edge řetěz pravidel '{{ruleChainName}}' k edge při vytváření?", + "unset-auto-assign-to-edge-text": "Po potvrzení nebude edge řetěz pravidel dále automaticky přiřazován k edge při vytváření.", + "unassign-rulechain-title": "Jste si jisti, že chcete odebrat řetěz pravidel '{{ruleChainName}}'?", + "unassign-rulechains": "Odebrat řetězy pravidel" + }, + "rulenode": { + "details": "Detail", + "events": "Události", + "search": "Vyhledat uzly", + "open-node-library": "Otevřít knihovnu uzlů", + "add": "Přidat uzel pravidla", + "name": "Název", + "name-required": "Název je povinný.", + "type": "Typ", + "description": "Popis", + "delete": "Smazat uzel pravidla", + "select-all-objects": "Vybrat všechny uzly a spojení", + "deselect-all-objects": "Zrušit výběr všech uzlů a spojení", + "delete-selected-objects": "Smazat vybrané uzly a spojení", + "delete-selected": "Smazat vybrané", + "select-all": "Vybrat vše", + "copy-selected": "Kopírovat vybrané", + "deselect-all": "Zrušit výběr všech", + "rulenode-details": "Detail uzlu pravidla", + "debug-mode": "Režim ladění", + "configuration": "Konfigurace", + "link": "Odkaz", + "link-details": "Detail odkazu uzlu pravidla", + "add-link": "Přidat odkaz", + "link-label": "Název odkazu", + "link-label-required": "Název odkazu je povinný.", + "custom-link-label": "Vlastní název odkazu", + "custom-link-label-required": "Název vlastního odkazu je povinný.", + "link-labels": "Názvy odkazu", + "link-labels-required": "Názvy odkazu jsou povinné.", + "no-link-labels-found": "Žádné názvy odkazů nebyly nalezeny", + "no-link-label-matching": "'{{label}}' nenalezen.", + "create-new-link-label": "Vytvořit nový!", + "type-filter": "Filtr", + "type-filter-details": "Filtruje příchozí zprávy na základě definovaných podmínek", + "type-enrichment": "Obohacení", + "type-enrichment-details": "Přidá doplňující informace do metadat zprávy", + "type-transformation": "Transformace", + "type-transformation-details": "Změní zprávu a metadata", + "type-action": "Akce", + "type-action-details": "Provede speciální akci", + "type-external": "Externí", + "type-external-details": "Interaguje s externím systémem", + "type-rule-chain": "Řetěz pravidel", + "type-rule-chain-details": "Předá příchozí zprávy specifikovanému řetězu pravidel", + "type-input": "Vstup", + "type-input-details": "Logický vstup řetězu pravidel, předává příchozí zprávy dalšímu navazujícímu uzlu pravidla", + "type-unknown": "Neznámý", + "type-unknown-details": "Nevyřešený uzel pravidla", + "directive-is-not-loaded": "Definovaná konfigurační direktiva '{{directiveName}}' není dostupná.", + "ui-resources-load-error": "Nepodařilo se nahrát konfigurační ui zdroje.", + "invalid-target-rulechain": "Není možné interagovat s cílovým řetězem pravidel!", + "test-script-function": "Testovat funkci skriptu", + "message": "Zpráva", + "message-type": "Typ zprávy", + "select-message-type": "Vybrat typ zprávy", + "message-type-required": "Typ zprávy je povinný", + "metadata": "Metadata", + "metadata-required": "Záznam metadat nemůže být prázdný.", + "output": "Výstup", + "test": "Test", + "help": "Nápověda", + "reset-debug-mode": "Resetovat režim ladění na všech uzlech" + }, + "timezone": { + "timezone": "Časová zóna", + "select-timezone": "Vyberte časovou zónu", + "no-timezones-matching": "žádné časové zóny odpovídající '{{timezone}}' nebyly nalezeny.", + "timezone-required": "Časová zóna je povinná.", + "browser-time": "Čas prohlížeče" + }, + "queue": { + "select_name": "Vybrat název fronty", + "name": "Název fronty", + "name_required": "Název fronty je povinný!" + }, + "tenant": { + "tenant": "Tenant", + "tenants": "Tenanti", + "management": "Správa tenantů", + "add": "Přidat tenanta", + "admins": "Administrátoři", + "manage-tenant-admins": "Spravovat administrátory tenanta", + "delete": "Smazat tenanta", + "add-tenant-text": "Přidat nového tenanta", + "no-tenants-text": "Žádní tenanti nebyli nalezeni", + "tenant-details": "Detail tenanta", + "delete-tenant-title": "Jste si jisti, že chcete smazat tenanta '{{tenantTitle}}'?", + "delete-tenant-text": "Buďte opatrní, protože po potvrzení nebude možné tenanta ani žádná související data obnovit.", + "delete-tenants-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 tenanta} other {# tenantů} }?", + "delete-tenants-action-title": "Smazat { count, plural, 1 {1 tenanta} other {# tenantů} }", + "delete-tenants-text": "Buďte opatrní, protože po potvrzení budou všichni vybraní tenanti odstraněni a žádná související data nebude možné obnovit.", + "title": "Název", + "title-required": "Název je povinný.", + "description": "Popis", + "details": "Detail", + "events": "Události", + "copyId": "Kopírovat Id tenanta", + "idCopiedMessage": "Id tenanta bylo zkopírováno do schránky", + "select-tenant": "Vybrat tenanta", + "no-tenants-matching": "Žádní tenanti odpovídající '{{entity}}' nebyli nalezeni.", + "tenant-required": "Tenant je povinný", + "search": "Vyhledat tenanty", + "selected-tenants": "Vybráno { count, plural, 1 {1 tenantů} other {# tenantů} }", + "isolated-tb-rule-engine": "Zpracování v izolovaném kontejneru ThingsBoard Rule Engine", + "isolated-tb-rule-engine-details": "Vyžaduje samostatnou mikroslužbu(y) pro každého izolovaného tenanta" + }, + "tenant-profile": { + "tenant-profile": "Profil tenanta", + "tenant-profiles": "Profily tenantů", + "add": "Přidat profil tenanta", + "edit": "Editovat profil tenanta", + "tenant-profile-details": "Detail profilu tenanta", + "no-tenant-profiles-text": "Nebyly nalezeny žádné profily tenantů", + "search": "Vyhledat profily tenantů", + "selected-tenant-profiles": "Vybráno { count, plural, 1 {1 profilů tenantů} other {# profilů tenantů} }", + "no-tenant-profiles-matching": "Žádné profily tenantů odpovídající '{{entity}}' nebyly nalezeny.", + "tenant-profile-required": "Profil tenanta je povinný", + "idCopiedMessage": "Id profilu tenanta bylo zkopírováno do schránky", + "set-default": "Učinit profil tenanta defaultním", + "delete": "Smazat profil tenanta", + "copyId": "Kopírovat Id profilu tenanta", + "name": "Název", + "name-required": "Název je povinný.", + "data": "Data profilu", + "profile-configuration": "Konfigurace profilu", + "description": "Popis", + "default": "Defaultní", + "delete-tenant-profile-title": "Jste si jisti, že chcete smazat profil tenanta '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Buďte opatrní, protože po potvrzení nebude možné profil tenanta ani žádná související data obnovit.", + "delete-tenant-profiles-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 profil tenanta} other {# profilů tenanta} }?", + "delete-tenant-profiles-text": "Buďte opatrní, protože po potvrzení budou všechny vybrané profily tenantů odstraněny a žádná související data nebude možné obnovit.", + "set-default-tenant-profile-title": "Jste si jisti, že chcete učinit profil tenanta '{{tenantProfileName}}' defaultním?", + "set-default-tenant-profile-text": "Po potvrzení bude profil tenanta označen jako defaultní a bude použit pro nové tenanty bez specifikovaného profilu.", + "no-tenant-profiles-found": "Nebyly nalezeny žádné profily tenantů.", + "create-new-tenant-profile": "Vytvořit nový!", + "create-tenant-profile": "Vytvořit nový profil tenanta", + "import": "Importovat profil tenanta", + "export": "Exportovat profil tenanta", + "export-failed-error": "Profil tenanta nebylo možné exportovat: {{error}}", + "tenant-profile-file": "Soubor profilu tenanta", + "invalid-tenant-profile-file-error": "Profil tenanta nebylo možné importovat: Neplatný datová struktura profilu tenanta.", + "maximum-devices": "Maximální počet zařízení (0 - neomezeno)", + "maximum-devices-required": "Maximální počet zařízení je povinný.", + "maximum-devices-range": "Minimální počet zařízení nemůže být záporný", + "maximum-assets": "Maximální počet aktiv (0 - neomezeno)", + "maximum-assets-required": "Maximální počet aktiv je povinný.", + "maximum-assets-range": "Maximální počet aktiv nemůže být záporný", + "maximum-customers": "Maximální počet zákazníků (0 - neomezeno)", + "maximum-customers-required": "Maximální počet zákazníkůje povinný.", + "maximum-customers-range": "Maximální počet zákazníků nemůže být záporný", + "maximum-users": "Maximální počet uživatelů (0 - neomezeno)", + "maximum-users-required": "Maximální počet uživatelů je povinný.", + "maximum-users-range": "Maximální počet uživatelů nemůže být záporný", + "maximum-dashboards": "Maximální počet dashboardů (0 - neomezeno)", + "maximum-dashboards-required": "Maximální počet dashboardů je povinný.", + "maximum-dashboards-range": "Maximální počet dashboardů nemůže být záporný", + "maximum-rule-chains": "Maximální počet řetězů pravidel (0 - neomezeno)", + "maximum-rule-chains-required": "Maximální počet řetězů pravidel je povinný.", + "maximum-rule-chains-range": "Maximální počet řetězů pravidel nemůže být záporný", + "maximum-resources-sum-data-size": "Maximální součet velikosti souborů zdrojů v bajtech (0 - neomezeno)", + "maximum-resources-sum-data-size-required": "Maximální součet velikosti souborů zdrojů je povinný.", + "maximum-resources-sum-data-size-range": "Maximální součet velikosti souborů zdrojů nemůže být záporný", + "maximum-ota-packages-sum-data-size": "Maximální součet velikosti souborů ota balíčků v bajtech (0 - neomezeno)", + "maximum-ota-package-sum-data-size-required": "Maximální součet velikosti souborů ota balíčků je povinný.", + "maximum-ota-package-sum-data-size-range": "Maximální součet velikosti souborů ota balíčků nemůže být záporný", + "transport-tenant-msg-rate-limit": "Limit přenosu zpráv tenanta.", + "transport-tenant-telemetry-msg-rate-limit": "Limit přenosu zpráv telemetrie tenanta.", + "transport-tenant-telemetry-data-points-rate-limit": "Limit přenosu datových bodů telemetrie tenanta.", + "transport-device-msg-rate-limit": "Limit přenosu zpráv zařízení.", + "transport-device-telemetry-msg-rate-limit": "Limit přenosu zpráv zařízení telemetrie tenanta.", + "transport-device-telemetry-data-points-rate-limit": "Limit přenosu datových bodů zařízení telemetrie tenanta.", + "max-transport-messages": "Maximální počet zpráv přenosu (0 - neomezeno)", + "max-transport-messages-required": "Maximální počet zpráv přenosu je povinný.", + "max-transport-messages-range": "Maximální počet zpráv přenosu nemůže být záporný", + "max-transport-data-points": "Maximální počet datových bodů přenosu (0 - neomezeno)", + "max-transport-data-points-required": "Maximální počet datových bodů přenosu je povinný.", + "max-transport-data-points-range": "Maximální počet datových bodů přenosu nemůže být záporný", + "max-r-e-executions": "Maximální počet zpracování enginu pro zpracování pravidel (0 - neomezeno)", + "max-r-e-executions-required": "Maximální počet zpracování enginu pro zpracování pravidel je povinný.", + "max-r-e-executions-range": "Maximální počet zpracování enginu pro zpracování pravidel nemůže být záporný", + "max-j-s-executions": "Maximální počet JavaScript zpracování (0 - neomezeno)", + "max-j-s-executions-required": "Maximální počet JavaScript zpracování je povinný.", + "max-j-s-executions-range": "Maximální počet JavaScript zpracování nemůže být záporný", + "max-d-p-storage-days": "Maximální počet dnů uložení datových bodů (0 - neomezeno)", + "max-d-p-storage-days-required": "Maximální počet dnů uložení datových bodů je povinný.", + "max-d-p-storage-days-range": "Maximální počet dnů uložení datových bodů nemůže být záporný", + "default-storage-ttl-days": "Defaultní počet dnů TTL úložiště (0 - neomezeno)", + "default-storage-ttl-days-required": "Defaultní počet dnů TTL úložiště je povinný.", + "default-storage-ttl-days-range": "Defaultní počet dnů TTL úložiště nemůže být záporný", + "alarms-ttl-days": "Počet dnů TTL alarmů (0 - neomezeno)", + "alarms-ttl-days-required": "Počet dnů TTL alarmů je povinný", + "alarms-ttl-days-days-range": "Počet dnů TTL alarmů nemůže být záporný", + "rpc-ttl-days": "Počet dnů TTL RPC (0 - neomezeno)", + "rpc-ttl-days-required": "Počet dnů TTL RPC je povinný", + "rpc-ttl-days-days-range": "Počet dnů TTL RPC nemůže být záporný", + "max-rule-node-executions-per-message": "Maximální počet zpracování uzlů pravidel na zprávu (0 - neomezeno)", + "max-rule-node-executions-per-message-required": "Maximální počet zpracování uzlů pravidel na zprávu je povinný.", + "max-rule-node-executions-per-message-range": "Maximální počet zpracování uzlů pravidel na zprávu nemůže být záporný", + "max-emails": "Maximální počet odeslaných emailů (0 - neomezeno)", + "max-emails-required": "Maximální počet odeslaných emailů je povinný.", + "max-emails-range": "Maximální počet odeslaných emailů nemůže být záporný", + "max-sms": "Maximální počet odeslaných SMS (0 - neomezeno)", + "max-sms-required": "Maximální počet odeslaných SMS je povinný.", + "max-sms-range": "Maximální počet odeslaných SMS nemůže být záporný", + "max-created-alarms": "Maximální počet vytvořených alarmů (0 - neomezeno)", + "max-created-alarms-required": "Maximální počet vytvořených alarmů je povinný.", + "max-created-alarms-range": "Maximální počet vytvořených alarmů nemůže být záporný" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 vteřina} other {# vteřin} }", + "minutes-interval": "{ minutes, plural, 1 {1 minuta} other {# minut} }", + "hours-interval": "{ hours, plural, 1 {1 hodina} other {# hodin} }", + "days-interval": "{ days, plural, 1 {1 den} other {# dnů} }", + "days": "Dny", + "hours": "Hodiny", + "minutes": "Minuty", + "seconds": "Vteřiny", + "advanced": "Rozšířené", + "predefined": { + "yesterday": "Včera", + "day-before-yesterday": "Předevčírem", + "this-day-last-week": "Před týdnem", + "previous-week": "Minulý týden (Ne - So)", + "previous-week-iso": "Minulý týden (Po - Ne)", + "previous-month": "Minulý měsíc", + "previous-year": "Minulý rok", + "current-hour": "Tato hodina", + "current-day": "Tento den", + "current-day-so-far": "Do dneška", + "current-week": "Tento týden (Ne - So)", + "current-week-iso": "Tento týden (Po - Ne)", + "current-week-so-far": "Do tohoto týdne (Ne - So)", + "current-week-iso-so-far": "Do tohoto týdne (Po - Ne)", + "current-month": "Tento měsíc", + "current-month-so-far": "Do tohoto měsíce", + "current-year": "Tento rok", + "current-year-so-far": "Do tohoto roku" + } + }, + "timeunit": { + "seconds": "Vteřiny", + "minutes": "Minuty", + "hours": "Hodiny", + "days": "Dny" + }, + "timewindow": { + "days": "{ days, plural, 1 { den } other {# dnů } }", + "hours": "{ hours, plural, 0 { hodina } 1 {1 hodina } other {# hodin } }", + "minutes": "{ minutes, plural, 0 { minuta } 1 {1 minuta } other {# minut } }", + "seconds": "{ seconds, plural, 0 { vteřina } 1 {1 vteřina } other {# vteřin } }", + "realtime": "V reálném čase", + "history": "Historie", + "last-prefix": "poslední", + "period": "od {{ startTime }} do {{ endTime }}", + "edit": "Editovat časové okno", + "date-range": "Rozsah data", + "last": "Poslední", + "time-period": "Časový interval", + "hide": "Skrýt", + "interval": "Interval" + }, + "user": { + "user": "Uživatel", + "users": "Uživatelé", + "customer-users": "Uživatelé zákazníka", + "tenant-admins": "Administrátoři tenanta", + "sys-admin": "Systémový administrátor", + "tenant-admin": "Administrátor tenanta", + "customer": "Zákazník", + "anonymous": "Anonymní", + "add": "Přidat uživatele", + "delete": "Smazat uživatele", + "add-user-text": "Přidat nového uživatele", + "no-users-text": "Žádní uživatelé nebyli nalezeni", + "user-details": "Detail uživatele", + "delete-user-title": "Jste si jisti, že chcete smazat uživatele '{{userEmail}}'?", + "delete-user-text": "Buďte opatrní, protože po potvrzení nebude možné uživatele ani žádná související data obnovit.", + "delete-users-title": "Jste si jisti, že chcete smazat { count, plural, 1 {1 uživatele} other {# uživatele} }?", + "delete-users-action-title": "Smazat { count, plural, 1 {1 uživatele} other {# uživatele} }", + "delete-users-text": "Buďte opatrní, protože po potvrzení budou všichni vybraní uživatelé odstraněni a žádná související data nebude možné obnovit.", + "activation-email-sent-message": "Aktivační email byl úspěšně odeslán!", + "resend-activation": "Znovu poslat aktivační email", + "email": "Email", + "email-required": "Email je povinný.", + "invalid-email-format": "Neplatný formát emailu.", + "first-name": "Jméno", + "last-name": "Příjmení", + "description": "Popis", + "default-dashboard": "Defaultní dashboard", + "always-fullscreen": "Zobrazení vždy na celé obrazovce", + "select-user": "Vybrat uživatele", + "no-users-matching": "Žádní uživatelé odpovídající '{{entity}}' nebyli nalezeni.", + "user-required": "Uživatel je povinný", + "activation-method": "Metoda aktivace", + "display-activation-link": "Zobrazit aktivační odkaz", + "send-activation-mail": "Odeslat aktivační email", + "activation-link": "Aktivační odkaz uživatele", + "activation-link-text": "Pro aktivaci uživatele použijte následující aktivační odkaz :", + "copy-activation-link": "Kopírovat aktivační odkaz", + "activation-link-copied-message": "Aktivační odkaz uživatele byl zkopírován do schránky", + "details": "Detail", + "login-as-tenant-admin": "Přihlásit se jako administrátor tenanta", + "login-as-customer-user": "Přihlásit se jako uživatel zákazníka", + "search": "Vyhledat uživatele", + "selected-users": "Vybráno { count, plural, 1 {1 uživatelů} other {# uživatelů} }", + "disable-account": "Zakázat uživatelský účet", + "enable-account": "Povolit uživatelský účet", + "enable-account-message": "Uživatelský účet byl úspěšně povolen!", + "disable-account-message": "Uživatelský účet byl úspěšně zakázán!" + }, + "value": { + "type": "Typ hodnoty", + "string": "Řetězec", + "string-value": "Hodnota řetězce", + "string-value-required": "Hodnota řetězce je povinná", + "integer": "Celé číslo", + "integer-value": "Hodnota celého čísla", + "integer-value-required": "Hodnota celého čísla je povinná", + "invalid-integer-value": "Neplatná hodnota celého čísla", + "double": "Číslo s plovoucí řádovou s dvojitou přesností", + "double-value": "Hodnota čísla s plovoucí řádovou řádkou", + "double-value-required": "Hodnota čísla s plovoucí řádovou čárkou je povinná", + "boolean": "Pravdivostní hodnota", + "boolean-value": "Hodnota pravdivostní hodnoty", + "false": "Nepravda", + "true": "Pravda", + "long": "Vysoké celé číslo", + "json": "JSON", + "json-value": "Hodnota JSON", + "json-value-invalid": "Hodnota JSON má neplatný formát", + "json-value-required": "Hodnota JSON je povinná." + }, + "widget": { + "widget-library": "Knihovna widgetů", + "widget-bundle": "Kategorie widgetů", + "all-bundles": "Všechny kategorie", + "select-widgets-bundle": "Vybrat kategorii widgetů", + "management": "Správa widgetů", + "editor": "Editor widgetů", + "widget-type-not-found": "Problém s nahráním konfigurace widgetu.
    Pravděpodobně byl asociovaný\n typ widgetu odstraněn.", + "widget-type-load-error": "Widget nebyl nahrán z důvodu následujících chyb:", + "remove": "Odebrat widget", + "edit": "Editovat widget", + "remove-widget-title": "Jste si jisti, že chcete odebrat widget '{{widgetTitle}}'?", + "remove-widget-text": "Po potvrzení nebude možné widget ani žádná související data obnovit.", + "timeseries": "Časové řady", + "search-data": "Vyhledat data", + "no-data-found": "Žádná data nebyla nalezena", + "latest": "Poslední hodnoty", + "rpc": "Ovládací widget", + "alarm": "Widgety alarmu", + "static": "Statické widgety", + "select-widget-type": "Vybrat typ widgetu", + "missing-widget-title-error": "Název widgetu musí být specifikován!", + "widget-saved": "Widget uložen", + "unable-to-save-widget-error": "Widget nebylo možné uložit! Widget obsahuje chyby!", + "save": "Uložit widget", + "saveAs": "Uložit widget jako", + "save-widget-type-as": "Uložit typ widgetu jako", + "save-widget-type-as-text": "Zadejte prosím název nového widgetu a/nebo vyberte cílovou kategorii widgetů", + "toggle-fullscreen": "Přepnout na celou obrazovku", + "run": "Spustit widget", + "title": "Název widgetu", + "title-required": "Název widgetu je povinný.", + "type": "Typ widgetu", + "resources": "Zdroje", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "Je modulem", + "remove-resource": "Odebrat zdroj", + "add-resource": "Přidat zdroj", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Schéma nastavení", + "datakey-settings-schema": "Schéma nastavení datového klíče", + "widget-settings": "Nastavení widgetu", + "description": "Popis", + "image-preview": "Náhled obrázku", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Jste si jisti, že chcete odebrat typ widgetu '{{widgetName}}'?", + "remove-widget-type-text": "Po potvrzení nebude možné typ widgetu ani žádná související data obnovit.", + "remove-widget-type": "Odebrat typ widgetu", + "add-widget-type": "Přidat nový typ widgetu", + "widget-type-load-failed-error": "Nahrání typu widgetu selhalo!", + "widget-template-load-failed-error": "Nahrání šablony widgetu selhalo!", + "add": "Přidat widget", + "undo": "Vrátit změny widgetu", + "export": "Exportovat widget", + "no-data": "Nejsou k dispozici žádná data pro zobrazení ve widgetu", + "data-overflow": "Widget zobrazuje {{count}} z {{total}} entit", + "alarm-data-overflow": "Widget zobrazuje alarmy {{allowedEntities}} (maxima možných) entit z {{totalEntities}} entit", + "search": "Vyhledat widget", + "filter": "Filtr typu widgetu", + "loading-widgets": "Nahrávám widgety..." + }, + "widget-action": { + "header-button": "Tlačítko hlavičky widgetu", + "open-dashboard-state": "Přejít k novému stavu dashboardu", + "update-dashboard-state": "Aktualizovat stávající stav dashboardu", + "open-dashboard": "Přejít k jinému dashboardu", + "custom": "Vlastní akce", + "custom-pretty": "Vlastní akce (s HTML šablonou)", + "mobile-action": "Akce v mobilní aplikaci", + "target-dashboard-state": "Cílový stav dashboardu", + "target-dashboard-state-required": "Cílový stav dashboardu je povinný", + "set-entity-from-widget": "Nastavit entitu z widgetu", + "target-dashboard": "Cílový dashboard", + "open-right-layout": "Otevřít rozmístění dashboardu vpravo (mobilní zobrazení)", + "open-in-separate-dialog": "Otevřít v samostatném okně", + "dialog-title": "Název okna", + "dialog-hide-dashboard-toolbar": "Skrýt v okně nástrojovou lištu dashboardu", + "dialog-width": "Šířka okna v procentech vzhledem k šířce obrazovky", + "dialog-height": "Výška okna v procentech vzhledem k výšce obrazovky", + "dialog-size-range-error": "Hodnota procentuální velikosti musí být v rozsahu od 1 do 100.", + "open-new-browser-tab": "Otevřít na nové záložce prohlížeče", + "mobile": { + "action-type": "Typ akce v mobilní aplikaci", + "action-type-required": "Typ akce v mobilní aplikaci je povinný", + "take-picture-from-gallery": "Nahrát obrázek z Take picture from gallery", + "take-photo": "Pořídit fotografii", + "map-direction": "Otevřít směry v mapách", + "map-location": "Otevířt umístění v mapách", + "scan-qr-code": "Naskenovat QR kód", + "make-phone-call": "Zavolat", + "get-location": "Zjistit polohu telefonu", + "take-screenshot": "Pořídit snímek obrazovky" + } + }, + "widgets-bundle": { + "current": "Vybraná kategorie", + "widgets-bundles": "Kategorie widgetů", + "add": "Přidat kategorii widgetů", + "delete": "Smazat kategorii widgetů", + "title": "Název", + "title-required": "Název je povinný.", + "description": "Popis", + "image-preview": "Náhled obrázku", + "add-widgets-bundle-text": "Přidat novou kategorii widgetů", + "no-widgets-bundles-text": "Žádné kategorie widgetů nebyly nalezeny", + "empty": "Kategorie widgetů je prázdná", + "details": "Detail", + "widgets-bundle-details": "Detail kategorie widgetů", + "delete-widgets-bundle-title": "Jste si jisti, že chcete smazat kategorii widgetů '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Buďte opatrní, po potvrzení nebude možné kategorii widgetů ani žádná související data obnovit.", + "delete-widgets-bundles-title": "Jste si jisti, že chcete odstranit { count, plural, 1 {1 kategorii widgetů} other {# kategorií widgetů} }?", + "delete-widgets-bundles-action-title": "Smazat { count, plural, 1 {1 kategorii widgetů} other {# kategorií widgetů} }", + "delete-widgets-bundles-text": "Buďte opatrní, po potvrzení budou všechny vybrané kategorie widgetů odstraněny a žádná související data nebude možné obnovit.", + "no-widgets-bundles-matching": "Žádné kategorie widgetů odpovídající '{{widgetsBundle}}' nebyly nalezeny.", + "widgets-bundle-required": "Kategorie widgetů je povinná.", + "system": "Systém", + "import": "Importovat kategorii widgetů", + "export": "Exportovat kategorii widgetů", + "export-failed-error": "Kategorii widgetů nebylo možné exportovat: {{error}}", + "create-new-widgets-bundle": "Vytvořit novou kategorii widgetů", + "widgets-bundle-file": "Soubor kategorie widgetů", + "invalid-widgets-bundle-file-error": "Kategorii widgetů nebylo možné importovat: Neplatná datová struktura kategorie widgetů.", + "search": "Vyhledat kategorie widgetů", + "selected-widgets-bundles": "Vybráno { count, plural, 1 {1 kategorií widgetů} other {# kategorií widgetů} }", + "open-widgets-bundle": "Otevřít kategorii widgetů", + "loading-widgets-bundles": "Nahrávám kategorie widgetů..." + }, + "widget-config": { + "data": "Data", + "settings": "Nastavení", + "advanced": "Rozšířené", + "title": "Název", + "title-tooltip": "Popisek názvu", + "general-settings": "Obecná nastavení", + "display-title": "Zobrazovaný název", + "drop-shadow": "Stín", + "enable-fullscreen": "Povolit zobrazení na celé obrazovce", + "background-color": "Barva pozadí", + "text-color": "Barva textu", + "padding": "Šířka vnitřního okraje", + "margin": "Okraj", + "widget-style": "Styl widgetu", + "title-style": "Název stylu", + "mobile-mode-settings": "Nastavení mobilního režimu", + "order": "Pořadí", + "height": "Výška", + "units": "Speciální symbol zobrazovaný za hodnotou", + "decimals": "Počet číslic za desetinnou čárkou", + "timewindow": "Časové okno", + "use-dashboard-timewindow": "Použít časové okno dashboardu", + "display-timewindow": "Zobrazit časové okno", + "display-legend": "Zobrazit legendu", + "datasources": "Datové zdroje", + "maximum-datasources": "Maximum { count, plural, 1 {1 datový zdroj je povolen.} other {# datových zdrojů je povoleno} }", + "datasource-type": "Typ", + "datasource-parameters": "Parametry", + "remove-datasource": "Odebrat datový zdroj", + "add-datasource": "Přidat datový zdroj", + "target-device": "Cílové zařízení", + "alarm-source": "Zdroj alarmu", + "actions": "Akce", + "action": "Akce", + "add-action": "Přidat akci", + "search-actions": "Vyhledat akce", + "no-actions-text": "Žádné akce nebyly nalezeny", + "action-source": "Zdroj akce", + "action-source-required": "Zdroj akce je povinný.", + "action-name": "Název", + "action-name-required": "Název akce je povinný.", + "action-name-not-unique": "Jiná akce s identickým názvem již existuje.
    Název akce by měl být v rámci zdroje akce unikátní.", + "action-icon": "Ikona", + "action-type": "Typ", + "action-type-required": "Typ akce je povinný.", + "edit-action": "Editovat akci", + "delete-action": "Smazat akci", + "delete-action-title": "Smazat akci widgetu", + "delete-action-text": "Jste si jisti, že chcete smazat akci widgetu s názvem '{{actionName}}'?", + "display-icon": "Zobrazit název ikony", + "icon-color": "Barva ikony", + "icon-size": "Velikost ikony" + }, + "widget-type": { + "import": "Importovat typ widgetu", + "export": "Exportovat typ widgetu", + "export-failed-error": "Typ widgetu nebylo možné exportovat: {{error}}", + "create-new-widget-type": "Vytvořit nový typ widgetu", + "widget-type-file": "Soubor typu widgetu", + "invalid-widget-type-file-error": "Typ widgetu nebylo možné importovat: Neplatná datová struktura typu widgetu." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Ne", + "Mon": "Po", + "Tue": "Út", + "Wed": "St", + "Thu": "Čt", + "Fri": "Pá", + "Sat": "So", + "Jan": "Led", + "Feb": "Úno", + "Mar": "Bře", + "Apr": "Dub", + "May": "Květen", + "Jun": "Čvn", + "Jul": "Čvc", + "Aug": "Srp", + "Sep": "Zář", + "Oct": "Říj", + "Nov": "Lis", + "Dec": "Pro", + "January": "Leden", + "February": "Únor", + "March": "Březen", + "April": "Duben", + "June": "Červen", + "July": "Červenec", + "August": "Srpen", + "September": "Září", + "October": "Říjen", + "November": "Listopad", + "December": "Prosinec", + "Custom Date Range": "Vlastní rozsah data", + "Date Range Template": "Šablona rozsahu data", + "Today": "Dnes", + "Yesterday": "Včera", + "This Week": "Tento týden", + "Last Week": "Minulý týden", + "This Month": "Tento měsíc", + "Last Month": "Minulý měsíc", + "Year": "Rok", + "This Year": "Tento rok", + "Last Year": "Minulý rok", + "Date picker": "Výběr data", + "Hour": "Hodina", + "Day": "Den", + "Week": "Týden", + "2 weeks": "2 týdny", + "Month": "Měsíc", + "3 months": "3 měsíce", + "6 months": "6 měsíců", + "Custom interval": "Vlastní interval", + "Interval": "Interval", + "Step size": "Velikost kroku", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Parametr atributu nelze v tomto widgetu použít", + "blocked-location": "Geolokace je ve vašem prohlížeči zakázána", + "claim-device": "Získat zařízení", + "claim-failed": "Nepodařilo se získat zařízení!", + "claim-not-found": "Zařízení nenalezeno!", + "claim-successful": "Zařízení bylo úspěšně získáno!", + "date": "Datum", + "device-name": "Název zařízení", + "device-name-required": "Název zařízení je povinný", + "discard-changes": "Zahodit změny", + "entity-attribute-required": "Atribut entity je povinný", + "entity-coordinate-required": "Obě pole, zeměpisná šířka i zeměpisná délka, jsou povinná", + "entity-timeseries-required": "Časové řady entity jsou povinné", + "get-location": "Získat aktuální polohu", + "invalid-date": "Neplatné datum", + "latitude": "Zeměpisná šířka", + "longitude": "Zeměpisná délka", + "min-value-error": "Minimální hodnota je {{value}}", + "max-value-error": "Maximální hodnota je {{value}}", + "not-allowed-entity": "Vybraná entita nemůže mít sdílené atributy", + "no-attribute-selected": "Není vybrán žádný atribut", + "no-datakey-selected": "Není vybrán žádný datový klíč", + "no-coordinate-specified": "Datový klíč pro zeměpisnou šířku/délku nebyl specifikován", + "no-entity-selected": "Není vybrána žádná entita", + "no-image": "Žádný obrázek", + "no-support-geolocation": "Váš prohlížeč nepodporuje geolokaci", + "no-support-web-camera": "Váš prohlížeč nepodporuje kamery", + "enable-https-use-widget": "Prosím povolte HTTPS abyste mohli používat tento widget", + "no-found-your-camera": "Nelze nalézt vaši kameru", + "no-permission-camera": "Přístup byl zakázán uživatelem / Tato stránka nemá oprávnění použít kameru", + "no-timeseries-selected": "Nejsou vybrány žádné časové řady", + "secret-key": "Tajný klíč", + "secret-key-required": "Tajný klíč je povinný", + "switch-attribute-value": "Přepnout hodnotu atributu entity", + "switch-camera": "Přepnout kameru", + "switch-timeseries-value": "Přepnout hodnotu časové řady entity", + "take-photo": "Vyfotit", + "time": "Čas", + "timeseries-not-allowed": "Časové řady nelze v tomto widgetu použít", + "update-failed": "Aktualizace selhala", + "update-successful": "Aktualizace úspěšná", + "update-attribute": "Editovat atribut", + "update-timeseries": "Editovat časové řady", + "value": "Hodnota" + } + }, + "icon": { + "icon": "Ikona", + "select-icon": "Vybrat ikonu", + "material-icons": "Ikony Material", + "show-all": "Zobrazit všechny ikony" + }, + "custom": { + "widget-action": { + "action-cell-button": "Tlačítko buňky akce", + "row-click": "Při kliknutí na řádek", + "polygon-click": "Při kliknutí na polygon", + "marker-click": "Při kliknutí na značku", + "tooltip-tag-action": "Akce štítku nápovědy", + "node-selected": "Při výběru uzlu", + "element-click": "Při kliknutí na prvek HTML", + "pie-slice-click": "Při kliknutí na oblast grafu", + "row-double-click": "Při dvoj kliknutí na řádek" + } + }, + "language": { + "language": "Jazyk" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json new file mode 100644 index 0000000..6200b26 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -0,0 +1,4002 @@ +{ + "access": { + "unauthorized": "Uautoriseret", + "unauthorized-access": "Uautoriseret adgang", + "unauthorized-access-text": "Du skal logge ind for at få adgang til denne ressource!", + "access-forbidden": "Adgang forbudt", + "access-forbidden-text": "Du har ikke adgangsrettigheder til denne placering!
    Prøv at logge ind med en anden bruger, hvis du stadig ønsker at få adgang til denne placering.", + "refresh-token-expired": "Sessionen er udløbet", + "refresh-token-failed": "Kunne ikke opdatere session", + "permission-denied": "Tilladelse nægtet", + "permission-denied-text": "Du har ikke tilladelse til at udføre denne handling!" + }, + "action": { + "activate": "Aktivér", + "suspend": "Udsæt", + "save": "Gem", + "saveAs": "Gem som", + "cancel": "Annuller", + "ok": "Okay", + "delete": "Slet", + "add": "Tilføj", + "yes": "Ja", + "no": "Nej", + "update": "Opdatering", + "remove": "Fjern", + "search": "Søg", + "clear-search": "Ryd søgning", + "assign": "Tildel", + "unassign": "Fjern tildeling", + "share": "Del", + "make-private": "Gør privat", + "make-public": "Gør offentlig", + "apply": "Anvend", + "apply-changes": "Anvend ændringer", + "edit-mode": "Redigeringstilstand", + "enter-edit-mode": "Gå til redigeringstilstand", + "decline-changes": "Afvis ændringer", + "open": "Åbn", + "close": "Tæt", + "back": "Tilbage", + "run": "Kør", + "sign-in": "Log på!", + "edit": "Rediger", + "view": "Vis", + "create": "Opret", + "drag": "Træk", + "refresh": "Genopfrisk", + "undo": "Fortryd", + "copy": "Kopiér", + "paste": "Sæt ind", + "copy-reference": "Kopiér reference", + "paste-reference": "Indsæt reference", + "import": "Importér", + "export": "Eksportér", + "share-via": "", + "move": "Flyt", + "select": "Vælg", + "continue": "Fortsæt", + "discard-changes": "Kassér ændringer", + "download": "Download", + "next-with-label": "", + "read-more": "Læs mere", + "hide": "Skjul" + }, + "aggregation": { + "aggregation": "Opsamling", + "function": "Dataopsamlingsfunktion", + "limit": "Maks.-værdier", + "group-interval": "Grupperingsinterval", + "min": "Min", + "max": "Maks", + "avg": "Gennemsnit", + "sum": "I alt", + "count": "Sammentælling", + "none": "Ingen" + }, + "admin": { + "general": "Generelt", + "general-settings": "Generelle indstillinger", + "home-settings": "Hjem-indstillinger", + "outgoing-mail": "Mailserver", + "outgoing-mail-settings": "Udgående mailserverindstillinger", + "system-settings": "Systemindstillinger", + "test-mail-sent": "Test-e-mail blev sendt!", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL er påkrævet.", + "prohibit-different-url": "Det er ikke tilladt at bruge værtsnavn fra overskrifterne for klientanmodninger", + "prohibit-different-url-hint": "Denne indstilling skal aktiveres for produktionsmiljøer. Kan forårsage sikkerhedsproblemer ved deaktivering", + "mail-from": "Afsender", + "mail-from-required": "Afsender er påkrævet.", + "smtp-protocol": "SMTP-protokol", + "smtp-host": "SMTP-vært", + "smtp-host-required": "SMTP-vært er påkrævet.", + "smtp-port": "SMTP-port", + "smtp-port-required": "Du skal angive en smtp-port.", + "smtp-port-invalid": "Det ligner ikke en gyldig smtp-port.", + "timeout-msec": "Timeout (msek.)", + "timeout-required": "Timeout er påkrævet.", + "timeout-invalid": "Det ser ikke ud til at være en gyldig timeout.", + "enable-tls": "Aktivér TLS", + "tls-version": "TLS-version", + "enable-proxy": "Aktivér proxy", + "proxy-host": "Proxy-vært", + "proxy-host-required": "Proxy-vært er påkrævet.", + "proxy-port": "Proxy-port", + "proxy-port-required": "Proxy-port er påkrævet.", + "proxy-port-range": "Proxy-porten skal ligge i området fra 1 til 65535.", + "proxy-user": "Proxy-bruger", + "proxy-password": "Proxy-adgangskode", + "send-test-mail": "Send testmail", + "use-system-mail-settings": "Brug systemets mailserverindstillinger", + "mail-templates": "Mailskabeloner", + "mail-template-settings": "Indstillinger for mailskabeloner", + "use-system-mail-template-settings": "Brug systemmailskabeloner", + "mail-template": { + "mail-template": "Mailskabelon", + "test": "Test-e-mailmeddelelse", + "activation": "Meddelelse om aktivering af konto", + "account-activated": "Meddelelse om aktiveret konto", + "account-lockout": "Meddelelse om kontospærring", + "reset-password": "Meddelelse om nulstilling af adgangskode", + "password-was-reset": "Meddelelse om nulstillet adgangskode", + "user-activated": "Brugeraktiveret-meddelelse", + "user-registered": "Brugerregistreret-meddelelse", + "api-usage-state-enabled": "Api-brugstilstand aktiveret", + "api-usage-state-warning": "Advarsel om Api-brugstilstand", + "api-usage-state-disabled": "Api-brugstilstand deaktiveret" + }, + "mail-subject": "Mailens emne", + "mail-body": "Mailens brødtekst", + "sms-provider": "SMS-udbyder", + "sms-provider-settings": "Indstillinger for SMS-udbyder", + "use-system-sms-settings": "Anvend systemets indstillinger for SMS-udbyder", + "sms-provider-type": "SMS-udbydertype", + "sms-provider-type-required": "SMS-udbydertype er påkrævet.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "aws-access-key-id": "AWS-adgangsnøgle-id", + "aws-access-key-id-required": "AWS-adgangsnøgle-id er påkrævet", + "aws-secret-access-key": "AWS hemmelig adgangsnøgle", + "aws-secret-access-key-required": "AWS hemmelig adgangsnøgle er påkrævet", + "aws-region": "AWS-region", + "aws-region-required": "AWS-region er påkrævet", + "number-from": "Telefonnummer fra", + "number-from-required": "Telefonnummer fra er påkrævet.", + "number-to": "Telefonnummer til", + "number-to-required": "Telefonnummer til er påkrævet.", + "phone-number-hint": "Telefonnummer i E.164-format, f.eks. +19995550123", + "phone-number-hint-twilio": "Telefonnummer i E.164-format/Telefonnummers SID/Meddelelsesservice SID, f.eks. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Ugyldigt telefonnummer. Skal være i E.164-format, f.eks. +19995550123.", + "phone-number-pattern-twilio": "Ugyldigt telefonnummer. Skal være i E.164-format/Telefonnummers SID/Meddelelsesservice SID, f.eks. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS-besked", + "sms-message-required": "SMS-besked er påkrævet.", + "sms-message-max-length": "SMS-beskeden må ikke være længere end 1600 tegn", + "twilio-account-sid": "Twilio-konto SID", + "twilio-account-sid-required": "Twilio-konto SID er påkrævet", + "twilio-account-token": "Twilio-konto-token", + "twilio-account-token-required": "Twilio-konto-token er påkrævet", + "send-test-sms": "Send test-SMS", + "test-sms-sent": "Test-SMS'en blev sendt!", + "security-settings": "Sikkerhedsindstillinger", + "password-policy": "Adgangskodepolitik", + "minimum-password-length": "Min. adgangskodelængde", + "minimum-password-length-required": "Min. adgangskodelængde er påkrævet", + "minimum-password-length-range": "Min. adgangskodelængde skal være mellem 5 og 50", + "minimum-uppercase-letters": "Min. antal store bogstaver", + "minimum-uppercase-letters-range": "Min. antal store bogstaver kan ikke være negativt", + "minimum-lowercase-letters": "Min. antal små bogstaver", + "minimum-lowercase-letters-range": "Min. antal små bogstaver kan ikke være negativt", + "minimum-digits": "Min. antal cifre", + "minimum-digits-range": "Minimum antal cifre kan ikke være negativt", + "minimum-special-characters": "Minimum antal specialtegn", + "minimum-special-characters-range": "Minimum antal specialtegn kan ikke være negativt", + "password-expiration-period-days": "Adgangskodens udløbsperiode i dage", + "password-expiration-period-days-range": "Adgangskodens udløbsperiode i dage kan ikke være negativ", + "password-reuse-frequency-days": "Hyppighed af genbrug af adgangskode i dage", + "password-reuse-frequency-days-range": "Hyppigheden af genbrug af adgangskode i dage kan ikke være negativ", + "general-policy": "Generelle retningslinjer", + "max-failed-login-attempts": "Maks. antal mislykkede loginforsøg, før kontoen spærres", + "minimum-max-failed-login-attempts-range": "Maks. antal mislykkede loginforsøg kan ikke være negativt", + "user-lockout-notification-email": "Hvis brugerkontoen spærres, sendes en meddelelse til e-mail", + "domain-name": "Domænenavn", + "domain-name-unique": "Domænenavn og protokol skal være entydige.", + "error-verification-url": "Et domænenavn må ikke indeholde symbolerne '/' og ':'. Eksempel: thingsboard.io", + "oauth2": { + "access-token-uri": "Adgangstoken URI", + "access-token-uri-required": "Adgangstoken URI er påkrævet.", + "activate-user": "Aktivér bruger", + "add-domain": "Tilføj domæne", + "delete-domain": "Slet domæne", + "add-provider": "Tilføj udbyder", + "delete-provider": "Slet udbyder", + "allow-user-creation": "Tillad brugeroprettelse", + "always-fullscreen": "Altid fuld skærm", + "authorization-uri": "Godkendelses-URI", + "authorization-uri-required": "Godkendelses-URI er påkrævet.", + "client-authentication-method": "Klientgodkendelsesmetode", + "client-id": "Klient-id", + "client-id-required": "Klient-id er påkrævet.", + "client-secret": "Kunde hemmelig", + "client-secret-required": "Kunde hemmelig er påkrævet.", + "custom-setting": "Brugerdefinerede indstillinger", + "customer-name-pattern": "Kundenavnsmønster", + "parent-customer-name-pattern": "Overordnet kundenavnsmønster", + "user-groups-name-pattern": "Mønster for brugergruppenavn", + "default-dashboard-name": "Standard dashboardnavn", + "delete-domain-text": "Vær forsigtig. Efter bekræftelsen vil et domæne og alle udbyderdata være utilgængelige.", + "delete-domain-title": "", + "delete-registration-text": "Vær forsigtig. Efter bekræftelsen vil udbyderdata være utilgængelige.", + "delete-registration-title": "", + "email-attribute-key": "E-mailattributnøgle", + "email-attribute-key-required": "E-mailattributnøgle er påkrævet.", + "first-name-attribute-key": "Fornavn attributnøgle", + "general": "Generelt", + "jwk-set-uri": "JSON webnøgle-URI", + "last-name-attribute-key": "Efternavn attributnøgle", + "login-button-icon": "Logon-knapikon", + "login-button-label": "Udbyder etiket", + "login-button-label-placeholder": "Log ind med $(Udbyder etiket)", + "login-button-label-required": "Etiket er påkrævet.", + "login-provider": "Log ind udbyder", + "mapper": "Kortlægger", + "new-domain": "Nyt domæne", + "oauth2": "OAuth2", + "redirect-uri-template": "Omdiriger URI-skabelon", + "copy-redirect-uri": "Kopiér omdirigering af URI", + "registration-id": "Registrerings-id", + "registration-id-required": "Registrerings-id er påkrævet.", + "registration-id-unique": "Registrerings-id skal være entydigt for systemet.", + "scope": "Omfang", + "scope-required": "Omfang er påkrævet.", + "tenant-name-pattern": "Mønster for lejernavn", + "tenant-name-pattern-required": "Mønster for lejernavn er påkrævet.", + "tenant-name-strategy": "Strategi for lejernavn", + "type": "Kortlæggertype", + "uri-pattern-error": "Ugyldigt URI-format.", + "url": "URL", + "url-pattern": "Ugyldigt URL-format.", + "url-required": "URL er påkrævet.", + "user-info-uri": "Brugerinfo URI", + "user-info-uri-required": "Brugerinfo URI er påkrævet.", + "user-name-attribute-name": "Attributnøgle for brugernavn", + "user-name-attribute-name-required": "Attributnøgle for brugernavn er påkrævet", + "protocol": "Protokol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Aktivér OAuth2-indstillinger" + } + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmer", + "select-alarm": "Vælg alarm", + "no-alarms-matching": "", + "alarm-required": "Alarm er påkrævet", + "alarm-status": "Alarmstatus", + "alarm-status-list": "Alarmstatusliste", + "any-status": "Enhver status", + "search-status": { + "ANY": "Enhver", + "ACTIVE": "Aktiv", + "CLEARED": "Ryddet", + "ACK": "Kvitteret", + "UNACK": "Ikke kvitteret" + }, + "display-status": { + "ACTIVE_UNACK": "Aktiv Ikke-kvitteret", + "ACTIVE_ACK": "Aktiv Kvitteret", + "CLEARED_UNACK": "Ryddet Ikke-kvitteret", + "CLEARED_ACK": "Ryddet Kvitteret" + }, + "no-alarms-prompt": "Ingen alarmer fundet", + "created-time": "Oprettelsestidspunkt", + "type": "Type", + "severity": "Alvorsgrad", + "originator": "Ophavsmand", + "originator-type": "Ophavsmandtype", + "details": "Oplysninger", + "status": "Status", + "alarm-details": "Alarmoplysninger", + "start-time": "Starttid", + "end-time": "Sluttid", + "ack-time": "Tidspunkt for Kvitteret", + "clear-time": "Tidspunkt for Ryddet", + "alarm-severity-list": "Liste over alvorsgrad for alamer", + "any-severity": "Enhver alvorsgrad", + "severity-critical": "Kritisk", + "severity-major": "Stor", + "severity-minor": "Mindre", + "severity-warning": "Advarsel", + "severity-indeterminate": "Ubestemt", + "acknowledge": "Kvittér", + "clear": "Klar", + "search": "Søg efter alarmer", + "selected-alarms": "", + "no-data": "Ingen data at vise", + "polling-interval": "Alarmer undersøgelsesinterval (sek.)", + "polling-interval-required": "Alarmer undersøgelsesinterval er påkrævet.", + "min-polling-interval-message": "Mindst 1 sek. undersøgelsesinterval er tilladt.", + "aknowledge-alarms-title": "", + "aknowledge-alarms-text": "", + "aknowledge-alarm-title": "Kvittér for alarm", + "aknowledge-alarm-text": "Er du sikker på, du ønsker at kvittere for alarm?", + "clear-alarms-title": "", + "clear-alarms-text": "", + "clear-alarm-title": "Ryd alarm", + "clear-alarm-text": "Er du sikker på, at du vil slette Alarm?", + "alarm-status-filter": "Filter for alarmstatus", + "alarm-filter": "Alarmfilter", + "max-count-load": "Maks. antal alarmer, der skal indlæses (0 – ubegrænset)", + "max-count-load-required": "Maks. antal alarmer, der skal indlæses, er påkrævet.", + "max-count-load-error-min": "Minimumværdien er 0.", + "fetch-size": "Hent størrelse", + "fetch-size-required": "Hent størrelse er påkrævet.", + "fetch-size-error-min": "Minimumværdien er 10.", + "alarm-type-list": "Liste over alarmtyper", + "any-type": "Enhver type", + "search-propagated-alarms": "Søg efter overførte alarmer" + }, + "alias": { + "add": "Tilføj alias", + "edit": "Rediger alias", + "name": "Aliasnavn", + "name-required": "Aliasnavn er påkrævet", + "duplicate-alias": "Alias med samme navn findes allerede.", + "filter-type-single-entity": "Enkelt entitet", + "filter-type-entity-group": "Gruppeentiteter", + "filter-type-entity-list": "Entitetsliste", + "filter-type-entity-name": "Entitetsnavn", + "filter-type-entity-type": "Entitetstype", + "filter-type-entity-group-list": "Entitetsgruppeliste", + "filter-type-entity-group-name": "Entitetsgruppenavn", + "filter-type-entities-by-group-name": "Entiteter efter gruppenavn", + "filter-type-state-entity": "Entitet fra dashboardtilstand", + "filter-type-state-entity-description": "Entitet taget fra dashboardtilstandsparametre", + "filter-type-state-entity-owner": "Ejer af entitet fra dashboardtilstand", + "filter-type-state-entity-owner-description": "Ejer af entitet taget fra dashboardtilstandsparametre", + "filter-type-asset-type": "Aktivtype", + "filter-type-asset-type-description": "", + "filter-type-asset-type-and-name-description": "", + "filter-type-device-type": "Enhedstype", + "filter-type-device-type-description": "", + "filter-type-device-type-and-name-description": "", + "filter-type-entity-view-type": "Entitetsvisningstype", + "filter-type-entity-view-type-description": "", + "filter-type-entity-view-type-and-name-description": "", + "filter-type-relations-query": "Relationsforespørgsel", + "filter-type-relations-query-description": "", + "filter-type-asset-search-query": "Aktivsøgningsforespørgsel", + "filter-type-asset-search-query-description": "", + "filter-type-device-search-query": "Enhedssøgningsforespørgsel", + "filter-type-device-search-query-description": "", + "filter-type-entity-view-search-query": "Entitetsvisning for søgningsforespørgsel", + "filter-type-entity-view-search-query-description": "", + "filter-type-apiUsageState": "Api-brugstilstand", + "entity-filter": "Entitetsfilter", + "resolve-multiple": "Løs som flere entiteter", + "filter-type": "Filtertype", + "filter-type-required": "Filtertype er påkrævet.", + "entity-filter-no-entity-matched": "Der blev ikke fundet nogen entiteter, der matcher det angivne filter.", + "no-entity-filter-specified": "Intet entitetsfilter angivet", + "root-state-entity": "Brug dashboardtilstandsentitet som rod", + "group-state-entity": "Brug dashboardtilstandsentitet som entitetsgruppe", + "group-state-entity-owner": "Brug dashboardtilstandsentitet som entitetsgruppeejer", + "last-level-relation": "Hent kun sidste niveaurelation", + "root-entity": "Rodentitet", + "state-entity-parameter-name": "Parameternavn for tilstandsentitet", + "default-state-entity": "Standard tilstandsentitet", + "default-state-entity-group": "Standard tilstandsentitetsgruppe", + "default-entity-parameter-name": "Som standard", + "max-relation-level": "Maks. niveaurelation", + "unlimited-level": "Ubegrænset niveau", + "state-entity": "Dashboardtilstandsentitet", + "entities-of-group-state-entity": "Entiteter fra dashboardtilstandsentitetsgruppe", + "all-entities": "Alle entiteter", + "any-relation": "enhver" + }, + "asset": { + "asset": "Aktiv", + "assets": "Aktiver", + "management": "Styring af aktiver", + "view-assets": "Vis aktiver", + "add": "Tilføj aktiv", + "assign-to-customer": "Tildel til kunde", + "assign-asset-to-customer": "Tildel aktiv(er) til kunde", + "assign-asset-to-customer-text": "Vælg de aktiver, der skal tildeles til kunden", + "no-assets-text": "Ingen aktiver fundet", + "assign-to-customer-text": "Vælg den kunde, der skal tildeles aktivet/aktiverne", + "public": "Offentlig", + "assignedToCustomer": "Tildelt til kunde", + "make-public": "Gør aktiv offentligt", + "make-private": "Gør aktiv privat", + "unassign-from-customer": "Fjern tildeling fra kunde", + "delete": "Slet aktiv", + "asset-public": "Aktiv er offentligt", + "asset-type": "Aktivtype", + "asset-type-required": "Aktivtype er påkrævet.", + "select-asset-type": "Vælg aktivtype", + "enter-asset-type": "Indtast aktivtype", + "any-asset": "Ethvert aktiv", + "no-asset-types-matching": "", + "asset-type-list-empty": "Ingen aktivtyper valgt.", + "asset-types": "Aktivtyper", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "type": "Type", + "type-required": "Type er påkrævet.", + "details": "Oplysninger", + "events": "Begivenheder", + "add-asset-text": "Tilføj nyt aktiv", + "asset-details": "Oplysninger om aktiv", + "assign-assets": "Tildel aktiver", + "assign-assets-text": "", + "delete-assets": "Slet aktiver", + "unassign-assets": "Fjern tildeling af aktiver", + "unassign-assets-action-title": "", + "assign-new-asset": "Tildel nyt aktiv", + "delete-asset-title": "", + "delete-asset-text": "Vær forsigtig. Efter bekræftelsen vil aktivet og alle relaterede data være uoprettelige.", + "delete-assets-title": "", + "delete-assets-action-title": "", + "delete-assets-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte aktiver blive fjernet, og alle relaterede data vil være uoprettelige.", + "make-public-asset-title": "", + "make-public-asset-text": "Efter bekræftelsen vil aktivet og alle dets data blive gjort offentlige og tilgængelige for andre.", + "make-private-asset-title": "", + "make-private-asset-text": "Efter bekræftelsen vil aktivet og alle dets data blive gjort private og vil ikke være tilgængelige for andre.", + "unassign-asset-title": "", + "unassign-asset-text": "Efter bekræftelsen fjernes tildelingen af aktivet og vil ikke være tilgængeligt for kunden.", + "unassign-asset": "Fjern tildeling af aktiv", + "unassign-assets-title": "", + "unassign-assets-text": "Efter bekræftelsen vil alle valgte aktiver få fjernet tildelingen og ikke være tilgængelige for kunden.", + "copyId": "Kopiér aktiv-id", + "idCopiedMessage": "Aktiv-id er blevet kopieret til udklipsholder", + "select-asset": "Vælg aktiv", + "no-assets-matching": "", + "asset-required": "Aktiv er påkrævet", + "name-starts-with": "Aktivnavn starter med", + "selected-assets": "", + "search": "Søg efter aktiver", + "select-group-to-add": "Vælg målgruppe for at tilføje valgte aktiver", + "select-group-to-move": "Vælg målgruppe for at flytte valgte aktiver", + "remove-assets-from-group": "", + "group": "Aktivgruppe", + "list-of-groups": "", + "group-name-starts-with": "", + "import": "Importér aktiver", + "asset-file": "Aktivfil", + "label": "Mærkning" + }, + "attribute": { + "attributes": "Attributter", + "latest-telemetry": "Seneste telemetri", + "attributes-scope": "Omfang af entitetsattributter", + "scope-latest-telemetry": "Seneste telemetri", + "scope-client": "Klientattributter", + "scope-server": "Serverattributter", + "scope-shared": "Delte attributter", + "add": "Tilføj attribut", + "add-attribute-prompt": "Tilføj venligst attribut", + "key": "Nøgle", + "last-update-time": "Seneste opdateringstid", + "key-required": "Attributnøgle er påkrævet.", + "value": "Værdi", + "value-required": "Attributværdi er påkrævet.", + "delete-attributes-title": "", + "delete-attributes-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte attributter blive fjernet.", + "delete-attributes": "Slet attributter", + "enter-attribute-value": "Indtast attributværdi", + "show-on-widget": "Vis på widget", + "widget-mode": "Widget-tilstand", + "next-widget": "Næste widget", + "prev-widget": "Forrige widget", + "add-to-dashboard": "Føj til dashboard", + "add-widget-to-dashboard": "Føj widget til dashboard", + "selected-attributes": "", + "selected-telemetry": "", + "no-attributes-text": "Ingen attributter fundet", + "no-telemetry-text": "Ingen telemetri fundet" + }, + "api-usage": { + "api-usage": "Api-brug", + "data-points": "Datapunkter", + "data-points-storage-days": "Lagringsdage for datapunkter", + "email": "E-mail", + "email-messages": "E-mailbeskeder", + "email-messages-daily-activity": "Daglig aktivitet for e-mailbeskeder", + "email-messages-hourly-activity": "Timeaktivitet for e-mailbeskeder", + "email-messages-monthly-activity": "Månedlig aktivitet for e-mailbeskeder", + "exceptions": "Undtagelser", + "executions": "Udførelser", + "javascript": "JavaScript", + "javascript-executions": "JavaScript-udførelser", + "javascript-functions": "JavaScript-funktioner", + "javascript-functions-daily-activity": "JavaScript-funktioners daglige aktivitet", + "javascript-functions-hourly-activity": "JavaScript-funktioners timeaktivitet", + "javascript-functions-monthly-activity": "JavaScript-funktioners månedlige aktivitet", + "latest-error": "Seneste fejl", + "messages": "Beskeder", + "permanent-failures": "${entityName} Permanente fejl", + "permanent-timeouts": "${entityName} Permanente timeouts", + "processing-failures": "${entityName} Behandling af fejl", + "processing-failures-and-timeouts": "Behandling af fejl og timeouts", + "processing-timeouts": "${entityName} Behandling af timeouts", + "queue-stats": "Køstatistikker", + "rule-chain": "Regelkæde", + "rule-engine": "Regelprogram", + "rule-engine-daily-activity": "Regelmotors daglige aktivitet", + "rule-engine-executions": "Regelmotorudførelser", + "rule-engine-hourly-activity": "Regelmotors timeaktivitet", + "rule-engine-monthly-activity": "Regelmotors månedlige aktivitet", + "rule-engine-statistics": "Statistik for regelmotor", + "rule-node": "Regelknude", + "sms": "SMS", + "sms-messages": "SMS-beskeder", + "sms-messages-daily-activity": "Daglig aktivitet for SMS-beskeder", + "sms-messages-hourly-activity": "Timeaktivitet for SMS-beskeder", + "sms-messages-monthly-activity": "Månedlig aktivitet for SMS-beskeder", + "successful": "${entityName} Vellykket", + "telemetry": "Telemetri", + "telemetry-persistence": "Telemetri-vedholdenhed", + "telemetry-persistence-daily-activity": "Daglig aktivitet for telemetri-vedholdenhed", + "telemetry-persistence-hourly-activity": "Timeaktivitet for telemetri-vedholdenhed", + "telemetry-persistence-monthly-activity": "Månedlig aktivitet for telemetri-vedholdenhed", + "transport": "Transport", + "transport-daily-activity": "Daglig aktivitet for transport", + "transport-data-points": "Transportdatapunkter", + "transport-hourly-activity": "Timeaktivitet for transport", + "transport-messages": "Transportmeddelelser", + "transport-monthly-activity": "Månedlig aktivitet for transport", + "view-details": "Vis oplysninger", + "view-statistics": "Vis statistik" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Auditlogs", + "timestamp": "Tidsstempel", + "entity-type": "Entitetstype", + "entity-name": "Entitetsnavn", + "user": "Bruger", + "type": "Type", + "status": "Status", + "details": "Oplysninger", + "type-added": "Tilføjet", + "type-deleted": "Slettet", + "type-updated": "Opdateret", + "type-attributes-updated": "Attributter opdateret", + "type-attributes-deleted": "Attributter slettet", + "type-rpc-call": "RPC-opkald", + "type-credentials-updated": "Brugeroplysninger opdateret", + "type-assigned-to-customer": "Tildelt til kunde", + "type-unassigned-from-customer": "Tildeling fjernet fra kunde", + "type-activated": "Aktiveret", + "type-suspended": "Indstillet", + "type-credentials-read": "Brugeroplysninger læst", + "type-attributes-read": "Attributter læst", + "type-added-to-entity-group": "Tilføjet til gruppe", + "type-removed-from-entity-group": "Fjernet fra gruppe", + "type-relation-add-or-update": "Relation opdateret", + "type-relation-delete": "Relation slettet", + "type-relations-delete": "Alle relationer slettet", + "type-alarm-ack": "Kvitteret", + "type-alarm-clear": "Ryddet", + "type-rest-api-rule-engine-call": "Regelmotor REST API-opkald", + "type-made-public": "Gjort offentligt", + "type-made-private": "Gjort privat", + "type-login": "Log på", + "type-logout": "Log af", + "type-lockout": "Spærring", + "status-success": "Succes", + "status-failure": "Fejl", + "audit-log-details": "Oplysninger om auditlog", + "no-audit-logs-prompt": "Ingen logs fundet", + "action-data": "Handlingsdata", + "failure-details": "Fejloplysninger", + "search": "Søg efter auditlogfiler", + "clear-search": "Ryd søgning", + "type-assigned-from-tenant": "Tildelt fra lejer", + "type-assigned-to-tenant": "Tildelt til lejer", + "type-provision-success": "Enhed klargjort", + "type-provision-failure": "Enhed klargjort mislykkedes", + "type-timeseries-updated": "Telemetri opdateret", + "type-timeseries-deleted": "Telemetri slettet", + "type-owner-changed": "Ejer ændret" + }, + "confirm-on-exit": { + "message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil forlade denne side?", + "html-message": "Du har ikke-gemte ændringer.
    Er du sikker på, at du vil forlade denne side?", + "title": "Ugemte ændringer" + }, + "contact": { + "country": "Land", + "city": "By", + "state": "Region", + "postal-code": "Postnummer", + "postal-code-invalid": "Ugyldigt postnummerformat.", + "address": "Adresse", + "address2": "Adresse 2", + "phone": "Telefon", + "email": "E-mail", + "no-address": "Ingen adresse" + }, + "common": { + "username": "Brugernavn", + "password": "Adgangskode", + "enter-username": "Indtast brugernavn", + "enter-password": "Indtast adgangskode", + "enter-search": "Indtast søgning", + "created-time": "Oprettelsestidspunkt", + "loading": "Indlæser..." + }, + "converter": { + "converter": "Dataomformer", + "converters": "Dataomformere", + "select-converter": "Vælg dataomformer", + "no-converters-matching": "", + "converter-required": "Dataomformer er påkrævet", + "delete": "Slet omformer", + "management": "Styring af dataomformere", + "add-converter-text": "Tilføj ny dataomformer", + "no-converters-text": "Ingen dataomformere fundet", + "selected-converters": "", + "delete-converter-title": "", + "delete-converter-text": "Vær forsigtig. Efter bekræftelsen vil dataomformeren og alle relaterede data være uoprettelige.", + "delete-converters-title": "", + "delete-converters-action-title": "", + "delete-converters-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte dataomformere blive fjernet, og alle relaterede data vil være uoprettelige.", + "events": "Begivenheder", + "add": "Tilføj dataomformer", + "search": "Søg efter dataomformere", + "converter-details": "Oplysninger om dataomformer", + "details": "Oplysninger", + "copyId": "Kopiér omformer-id", + "idCopiedMessage": "Omformer-id er blevet kopieret til udklipsholder", + "debug-mode": "Debug-tilstand", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "decoder": "Dekoder", + "encoder": "Indkoder", + "test-decoder-fuction": "Test af dekoderfunktion", + "test-encoder-fuction": "Test af indkoderfunktion", + "decoder-input-params": "Parametre for dekoderindgang", + "encoder-input-params": "Parametre for indkoderindgang", + "payload": "Data", + "payload-content-type": "Dataindholdstype", + "payload-content": "Dataindhold", + "message": "Meddelelse", + "message-type": "Meddelelsestype", + "message-type-required": "Meddelelsestype er påkrævet", + "test": "Test", + "metadata": "Metadata", + "metadata-required": "Metadataposter må ikke være tomme.", + "integration-metadata": "Integrationsmetadata", + "integration-metadata-required": "Integrationsmetadataposter må ikke være tomme.", + "output": "Output", + "import": "Importér omformer", + "export": "Eksportér omformer", + "export-failed-error": "", + "create-new-converter": "Opret ny omformer", + "converter-file": "Omformerfil", + "invalid-converter-file-error": "Omformeren kunne ikke importeres: Ugyldig omformerdatastruktur.", + "type": "Type", + "type-required": "Type er påkrævet.", + "type-uplink": "Uplink", + "type-downlink": "Downlink" + }, + "content-type": { + "json": "Json", + "text": "Tekst", + "binary": "Binær (Base64)" + }, + "customer": { + "customer": "Kunde", + "customers": "Kunder", + "management": "Kundeadministration", + "dashboard": "Kundens dashboard", + "dashboards": "Kundens dashboards", + "devices": "Kundeenheder", + "entity-views": "Kundeentitetsvisninger", + "assets": "Kundeaktiver", + "public-dashboards": "Offentlige dashboards", + "public-devices": "Offentlige enheder", + "public-assets": "Offentlige aktiver", + "public-entity-views": "Visninger af offentlig entitet", + "add": "Tilføj kunde", + "delete": "Slet kunde", + "manage-customer-user-groups": "Administrer kundebrugergrupper", + "manage-customer-groups": "Administrer kundegrupper", + "manage-customer-device-groups": "Administrer kundeenhedsgrupper", + "manage-customer-asset-groups": "Administrer kundeaktivgrupper", + "manage-customer-entity-view-groups": "Administrer visningsgrupper for kundeentitet", + "manage-customer-dashboard-groups": "Administrer kundedashboardgrupper", + "manage-customer-users": "Administrer kundebrugere", + "manage-customers": "Administrer kunder", + "manage-customer-devices": "Administrer kundeenheder", + "manage-customer-entity-views": "Administrer visninger af kundeentitet", + "manage-customer-dashboards": "Administrer kundedashboards", + "manage-public-devices": "Administrer offentlige enheder", + "manage-public-dashboards": "Administrer offentlige dashboards", + "manage-customer-assets": "Administrer kundeaktiver", + "manage-public-assets": "Administrer offentlige aktiver", + "add-customer-text": "Tilføj ny kunde", + "no-customers-text": "Ingen kunder fundet", + "customer-details": "Kundeinformation", + "delete-customer-title": "", + "delete-customer-text": "Vær forsigtig. Efter bekræftelsen vil kunden og alle relaterede data være uoprettelige.", + "delete-customers-title": "", + "delete-customers-action-title": "", + "delete-customers-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte kunder blive fjernet, og alle relaterede data vil være uoprettelige.", + "manage-user-groups": "Administrer brugergrupper", + "manage-asset-groups": "Administrer aktivgrupper", + "manage-device-groups": "Administrer enhedsgrupper", + "manage-dashboard-groups": "Administrer dashboardgrupper", + "manage-entity-view-groups": "Administrer entitetsvisningsgrupper", + "manage-users": "Administrer brugere", + "manage-assets": "Administrer aktiver", + "manage-devices": "Administrer enheder", + "manage-dashboards": "Administrer dashboards", + "title": "Titel", + "title-required": "Titel er påkrævet.", + "description": "Beskrivelse", + "details": "Oplysninger", + "events": "Begivenheder", + "copyId": "Kopiér kunde-id", + "idCopiedMessage": "Kunde-id er blevet kopieret til udklipsholder", + "select-customer": "Vælg kunde", + "no-customers-matching": "", + "customer-required": "Kunde er påkrævet", + "selected-customers": "", + "search": "Søg efter kunder", + "select-group-to-add": "Vælg målgruppe for at tilføje udvalgte kunder", + "select-group-to-move": "Vælg målgruppe for at flytte udvalgte kunder", + "remove-customers-from-group": "", + "group": "Kundegruppe", + "list-of-groups": "", + "group-name-starts-with": "", + "select-default-customer": "Vælg standardkunde", + "default-customer": "Standardkunde", + "default-customer-required": "Standardkunde er påkrævet for at debugge dashboard på lejerniveau", + "allow-white-labeling": "Tillad hvid mærkning" + }, + "customers-hierarchy": { + "customers-hierarchy": "Kundehierarki", + "open-nav-tree": "Åbn navigationstræ", + "return-to-top-level": "Tilbage til øverste niveau" + }, + "custom-menu": { + "custom-menu": "Brugerdefineret menu", + "custom-menu-hint": "Definer brugerdefineret menu JSON nedenfor. Denne JSON indeholder en liste over brugerdefinerede menupunkter." + }, + "custom-translation": { + "custom-translation": "Brugerdefineret oversættelse", + "translation-map": "Oversættelsestilknytning", + "key": "Oversættelsesnøgle", + "import": "Importér oversættelse", + "export": "Eksportér oversættelse", + "export-data": "Eksportér oversættelsesdata", + "import-data": "Importér oversættelsesdata", + "translation-file": "Oversættelsesfil", + "invalid-translation-file-error": "Kunne ikke importere oversættelsesfil: Ugyldig oversættelsesdatastruktur.", + "custom-translation-hint": "Definer brugerdefineret oversættelse JSON nedenfor. Denne JSON overskriver standardoversættelsen. Klik på 'Download lokalfil' for at hente eksisterende oversættelse. Du kan også bruge den downloadede fil som reference til tilgængelige nøgleværdipar for oversættelse.", + "download-locale-file": "Download lokalfil" + }, + "datetime": { + "date-from": "Dato fra", + "time-from": "Tidspunkt fra", + "date-to": "Dato til", + "time-to": "Tidspunkt til" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Dashboardadministration", + "view-dashboards": "Vis dashboards", + "add": "Tilføj dashboard", + "assign-dashboard-to-customer": "Tildel dashboard(s) til kunde", + "assign-dashboard-to-customer-text": "Vælg de dashboards, der skal tildeles til kunden", + "assign-to-customer-text": "Vælg den kunde, der skal tildeles dashboard(s)", + "assign-to-customer": "Tildel til kunde", + "unassign-from-customer": "Fjern tildeling fra kunde", + "make-public": "Gør dashboard offentligt", + "make-private": "Gør dashboard privat", + "manage-assigned-customers": "Administrer tildelte kunder", + "assigned-customers": "Tildelte kunder", + "assign-to-customers": "Tildel dashboard(s) til kunder", + "assign-to-customers-text": "Vælg de kunder, der skal tildeles dashboard(s)", + "unassign-from-customers": "Fjern tildeling af dashboard(s) fra kunder", + "unassign-from-customers-text": "Vælg de kunder, hvor tildeling skal fjernes fra dashboard(s)", + "no-dashboards-text": "Ingen dashboards fundet", + "no-widgets": "Ingen widgets konfigureret", + "add-widget": "Tilføj ny widget", + "title": "Titel", + "select-widget-title": "Vælg widget", + "select-widget-value": "", + "select-widget-subtitle": "Liste over tilgængelige widget-typer", + "delete": "Slet dashboard", + "title-required": "Titel er påkrævet.", + "description": "Beskrivelse", + "details": "Oplysninger", + "dashboard-details": "Oplysninger om dashboard", + "add-dashboard-text": "Tilføj nyt dashboard", + "assign-dashboards": "Tildel dashboards", + "assign-new-dashboard": "Tildel nyt dashboard", + "assign-dashboards-text": "", + "unassign-dashboards-action-text": "", + "delete-dashboards": "Slet dashboards", + "unassign-dashboards": "Fjern tildeling af dashboards", + "unassign-dashboards-action-title": "", + "delete-dashboard-title": "", + "delete-dashboard-text": "Vær forsigtig. Efter bekræftelsen vil dashboardet og alle relaterede data være uoprettelige.", + "delete-dashboards-title": "", + "delete-dashboards-action-title": "", + "delete-dashboards-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte dashboards blive fjernet, og alle relaterede data vil være uoprettelige.", + "unassign-dashboard-title": "", + "unassign-dashboard-text": "Efter bekræftelsen vil dashboardets tildeling blive fjernet og vil ikke være tilgængelig for kunden.", + "unassign-dashboard": "Fjern tildeling af dashboard", + "unassign-dashboards-title": "", + "unassign-dashboards-text": "Efter bekræftelsen vil tildelingen af alle valgte dashboards blive fjernet og vil ikke være tilgængelige for kunden.", + "public-dashboard-title": "Dashboard er nu offentligt", + "public-dashboard-text": "", + "public-dashboard-notice": "Bemærk: Glem ikke at gøre relaterede enheder offentlige for at få adgang til deres data.", + "public-dashboard-link": "Link til offentligt dashboard", + "public-dashboard-link-text": "", + "public-dashboard-link-notice": "Bemærk: Glem ikke at gøre relaterede enheder, aktiver og entitetsvisninger offentlige for at få adgang til deres data.", + "make-private-dashboard-title": "", + "make-private-dashboard-text": "Efter bekræftelsen vil dashboardet blive gjort privat og vil ikke være tilgængeligt for andre.", + "make-private-dashboard": "Gør dashboard privat", + "socialshare-text": "", + "socialshare-title": "", + "select-dashboard": "Vælg dashboard", + "no-dashboards-matching": "", + "dashboard-required": "Dashboard er påkrævet.", + "select-existing": "Vælg eksisterende dashboard", + "create-new": "Opret nyt dashboard", + "new-dashboard-title": "Ny dashboardtitel", + "open-dashboard": "Åbn dashboard", + "set-background": "Angiv baggrund", + "background-color": "Baggrundsfarve", + "background-image": "Baggrundsbillede", + "background-size-mode": "Baggrundsstørrelsestilstand", + "no-image": "Intet billede valgt", + "drop-image": "Træk og slip et billede, eller klik for at vælge en fil, der skal uploades.", + "maximum-upload-file-size": "", + "cannot-upload-file": "Filen kan ikke uploades", + "settings": "Indstillinger", + "columns-count": "Kolonneantal", + "columns-count-required": "Kolonneantal er påkrævet.", + "min-columns-count-message": "Kun 10 minimum kolonneantal er tilladt.", + "max-columns-count-message": "Kun 1000 maksimum kolonneantal er tilladt.", + "widgets-margins": "Margin mellem widgets", + "margin-required": "Marginværdi er påkrævet.", + "min-margin-message": "Kun 0 er tilladt som minimummarginværdi.", + "max-margin-message": "Kun 50 er tilladt som maksimummarginværdi.", + "horizontal-margin": "Horisontal margin", + "horizontal-margin-required": "Horisontal marginværdi er påkrævet.", + "min-horizontal-margin-message": "Kun 0 er tilladt som horisontal minimummarginværdi.", + "max-horizontal-margin-message": "Kun 50 er tilladt som horisontal maksimummarginværdi.", + "vertical-margin": "Vertikal margin", + "vertical-margin-required": "Vertikal marginværdi er påkrævet.", + "min-vertical-margin-message": "Kun 0 er tilladt som vertikal minimummarginværdi.", + "max-vertical-margin-message": "Kun 50 er tilladt som vertikal maksimummarginværdi.", + "autofill-height": "Autoudfyld layouthøjde", + "mobile-layout": "Mobile layoutindstillinger", + "mobile-row-height": "Mobil rækkehøjde, px", + "mobile-row-height-required": "Mobil rækkehøjdeværdi er påkrævet.", + "min-mobile-row-height-message": "Kun 5 pixel er tilladt som minimumværdi for mobilrækkehøjde.", + "max-mobile-row-height-message": "Kun 200 pixel er tilladt som maksimumværdi for mobilrækkehøjde.", + "display-title": "Vis dashboardtitel", + "toolbar-always-open": "Hold værktøjslinjen åben", + "title-color": "Titelfarve", + "display-dashboards-selection": "Vis valg af dashboards", + "display-entities-selection": "Vis valg af entiteter", + "display-filters": "Vis filtre", + "display-dashboard-timewindow": "Vis tidsvindue", + "display-dashboard-export": "Vis eksport", + "import": "Importér dashboard", + "export": "Eksportér dashboard", + "export-failed-error": "", + "export-pdf": "Eksportér som PDF", + "export-png": "Eksportér som PNG", + "export-jpg": "Eksportér som JPEG", + "export-json-config": "Eksportér JSON-konfiguration", + "download-dashboard-progress": "", + "create-new-dashboard": "Opret nyt dashboard", + "dashboard-file": "Dashboardfil", + "invalid-dashboard-file-error": "Kunne ikke importere dashboard: Ugyldig dashboarddatastruktur.", + "dashboard-import-missing-aliases-title": "Konfigurer aliasser anvendt af importeret dashboard", + "create-new-widget": "Opret ny widget", + "import-widget": "Importér widget", + "widget-file": "Widget-fil", + "invalid-widget-file-error": "Kan ikke importere widget: Ugyldig widget-datastruktur.", + "widget-import-missing-aliases-title": "Konfigurer aliasser, der bruges af importeret widget", + "open-toolbar": "Skjul dashboardets værktøjslinje", + "close-toolbar": "Luk værktøjslinje", + "configuration-error": "Konfigurationsfejl", + "alias-resolution-error-title": "Konfigurationsfejl i dashboardaliasser", + "invalid-aliases-config": "Kunne ikke finde nogen enheder, der matcher nogle af aliasfiltrene.
    Kontakt din administrator for at løse dette problem.", + "select-devices": "Vælg enheder", + "assignedToCustomer": "Tildelt til kunde", + "assignedToCustomers": "Tildelt til kunder", + "public": "Offentlig", + "public-link": "Offentligt link", + "copy-public-link": "Kopiér offentligt link", + "public-link-copied-message": "Dashboardets offentilige link er blevet kopieret til udklipsholderen", + "manage-states": "Administrer dashboardtilstande", + "states": "Dashboardtilstande", + "search-states": "Søg efter dashboardtilstande", + "selected-states": "", + "edit-state": "Rediger dashboardtilstand", + "delete-state": "Slet dashboardtilstand", + "add-state": "Tilføj dashboardtilstand", + "no-states-text": "Ingen tilstande fundet", + "state": "Dashboardtilstand", + "state-name": "Navn", + "state-name-required": "Dashboardtilstandsnavn er påkrævet.", + "state-id": "Tilstands-id", + "state-id-required": "Dashboardtilstands-id er påkrævet.", + "state-id-exists": "Dashboardtilstand med samme id findes allerede.", + "is-root-state": "Rodtilstand", + "delete-state-title": "Slet dashboardtilstand", + "delete-state-text": "", + "show-details": "Vis oplysninger", + "hide-details": "Skjul oplysninger", + "select-state": "Vælg måltilstand", + "state-controller": "Tilstandscontroller", + "selected-dashboards": "", + "search": "Søg efter dashboards", + "home-dashboard": "Startside", + "home-dashboard-hide-toolbar": "Skjul startsidens værktøjslinje", + "select-group-to-add": "Vælg målgruppe for at tilføje valgte dashboards", + "select-group-to-move": "Vælg målgruppe for at flytte valgte dashboards", + "remove-dashboards-from-group": "", + "group": "Gruppe af dashboards", + "list-of-groups": "", + "group-name-starts-with": "" + }, + "datakey": { + "settings": "Indstillinger", + "advanced": "Fremskreden", + "label": "Mærkning", + "color": "Farve", + "units": "Specielt symbol, der vises ved siden af værdi", + "decimals": "Antal cifre efter flydende punkt", + "data-generation-func": "Datagenereringsfunktion", + "use-data-post-processing-func": "Brug dataefterbehandlingsfunktion", + "configuration": "Konfiguration af datanøgle", + "timeseries": "Tidsserier", + "attributes": "Attributter", + "entity-field": "Entitetsfelt", + "alarm": "Alarmfelter", + "timeseries-required": "Entitets tidsserier er påkrævet.", + "timeseries-or-attributes-required": "Entitets tidsserier/attributter er påkrævet.", + "alarm-fields-timeseries-or-attributes-required": "Alarmfelter eller entitets tidsserier/attributter er påkrævet.", + "maximum-timeseries-or-attributes": "", + "alarm-fields-required": "Alarmfelter er påkrævet.", + "function-types": "Funktionstyper", + "function-types-required": "Funktionstyper er påkrævet.", + "maximum-function-types": "", + "time-description": "tidsstempel for den aktuelle værdi", + "value-description": "den aktuelle værdi", + "prev-value-description": "resultat af forrige funktionskald", + "time-prev-description": "tidsstempel for den forrige værdi", + "prev-orig-value-description": "oprindelig tidligere værdi" + }, + "datasource": { + "type": "Datakildetype", + "name": "Navn", + "label": "Mærkning", + "add-datasource-prompt": "Tilføj datakilde" + }, + "details": { + "details": "Oplysninger", + "edit-mode": "Redigeringstilstand", + "edit-json": "Rediger JSON", + "toggle-edit-mode": "Skift redigeringstilstand" + }, + "device": { + "device": "Enhed", + "device-required": "Enhed er påkrævet.", + "devices": "Enheder", + "management": "Enhedsadministration", + "view-devices": "Vis enheder", + "device-alias": "Enhedsalias", + "aliases": "Enhedsaliasser", + "no-alias-matching": "", + "no-aliases-found": "Ingen aliasser fundet.", + "no-key-matching": "", + "no-keys-found": "Ingen nøgler fundet.", + "create-new-alias": "Opret en ny!", + "create-new-key": "Opret en ny!", + "duplicate-alias-error": "", + "configure-alias": "", + "no-devices-matching": "", + "alias": "Alias", + "alias-required": "Enhedsalias er påkrævet.", + "remove-alias": "Fjern enhedsalias", + "add-alias": "Tilføj enhedsalias", + "name-starts-with": "Enhedens navn starter med", + "device-list": "Enhedsliste", + "use-device-name-filter": "Anvend filter", + "device-list-empty": "Ingen enheder valgt.", + "device-name-filter-required": "Enhedsnavnfilter er påkrævet.", + "device-name-filter-no-device-matched": "", + "add": "Tilføj enhed", + "assign-to-customer": "Tildel til kunde", + "assign-device-to-customer": "Tildel enhed(er) til kunde", + "assign-device-to-customer-text": "Vælg de enheder, der skal tildeles kunden", + "make-public": "Gør enheden offentlig", + "make-private": "Gør enheden privat", + "no-devices-text": "Ingen enheder fundet", + "assign-to-customer-text": "Vælg den kunde, der skal tilknyttes enheden/enhederne", + "device-details": "Enhedsoplysninger", + "add-device-text": "Tilføj ny enhed", + "credentials": "Brugeroplysninger", + "manage-credentials": "Administrer brugeroplysninger", + "delete": "Slet enhed", + "assign-devices": "Tildel enheder", + "assign-devices-text": "", + "delete-devices": "Slet enheder", + "unassign-from-customer": "Fjern tildeling fra kunde", + "unassign-devices": "Fjern tildeling af enheder", + "unassign-devices-action-title": "", + "assign-new-device": "Tildel ny enhed", + "make-public-device-title": "", + "make-public-device-text": "Efter bekræftelsen vil enheden og alle dens data blive gjort offentlige og tilgængelige for andre.", + "make-private-device-title": "", + "make-private-device-text": "Efter bekræftelsen vil enheden og alle dens data blive gjort private og vil ikke være tilgængelige for andre.", + "view-credentials": "Vis brugeroplysninger", + "delete-device-title": "", + "delete-device-text": "Vær forsigtig. Efter bekræftelsen vil enheden og alle relaterede data være uoprettelige.", + "delete-devices-title": "", + "delete-devices-action-title": "", + "delete-devices-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte enheder blive fjernet, og alle relaterede data vil være uoprettelige.", + "unassign-device-title": "", + "unassign-device-text": "Efter bekræftelsen vil enhedens tildeling blive fjernet og vil ikke være tilgængelig for kunden.", + "unassign-device": "Fjern tildeling af enhed", + "unassign-devices-title": "", + "unassign-devices-text": "Efter bekræftelsen vil tildelingen af alle valgte enheder blive fjernet og vil ikke være tilgængelige for kunden.", + "device-credentials": "Enhedsbrugeroplysninger", + "credentials-type": "Type af brugeroplysninger", + "access-token": "Adgangstoken", + "access-token-required": "Adgangstoken er påkrævet.", + "access-token-invalid": "Adgangstokenlængden skal være fra 1 til 20 tegn.", + "rsa-key": "RSA offentlig nøgle", + "rsa-key-required": "RSA offentlig nøgle er påkrævet.", + "client-id": "Klient-id", + "client-id-pattern": "Indeholder ugyldigt tegn.", + "user-name": "Brugernavn", + "user-name-required": "Brugernavn er påkrævet.", + "client-id-or-user-name-necessary": "Klient-id og/eller brugernavn er nødvendige", + "password": "Adgangskode", + "secret": "Hemmelig", + "secret-required": "Hemmelig er påkrævet.", + "device-type": "Enhedstype", + "device-type-required": "Enhedstype er påkrævet.", + "select-device-type": "Vælg enhedstype", + "enter-device-type": "Indtast enhedstype", + "any-device": "Enhver enhed", + "no-device-types-matching": "", + "device-type-list-empty": "Ingen enhedstyper valgt.", + "device-types": "Enhedstyper", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "label": "Mærkning", + "events": "Begivenheder", + "details": "Oplysninger", + "copyId": "Kopiér enheds-id", + "copyAccessToken": "Kopiér adgangstoken", + "copy-mqtt-authentication": "Kopiér MQTT-brugeroplysninger", + "idCopiedMessage": "Enheds-id er blevet kopieret til udklipsholder", + "accessTokenCopiedMessage": "Enhedsadgangstoken er blevet kopieret til udklipsholder", + "mqtt-authentication-copied-message": "Enhedens MQTT-godkendelse er blevet kopieret til udklipsholder", + "assignedToCustomer": "Tildelt til kunde", + "unable-delete-device-alias-title": "Kan ikke slette enhedsalias", + "unable-delete-device-alias-text": "", + "is-gateway": "Er gateway", + "overwrite-activity-time": "Overskriv aktivitetstid for tilsluttet enhed", + "public": "Offentlig", + "device-public": "Enheden er offentlig", + "select-device": "Vælg enhed", + "selected-devices": "", + "search": "Søg efter enheder", + "select-group-to-add": "Vælg målgruppe for at tilføje valgte enheder", + "select-group-to-move": "Vælg målgruppe for at flytte valgte enheder", + "remove-devices-from-group": "", + "group": "Gruppe af enheder", + "list-of-groups": "", + "group-name-starts-with": "", + "import": "Importér enhed", + "device-file": "Enhedsfil", + "device-configuration": "Enhedskonfiguration", + "transport-configuration": "Transportkonfiguration", + "wizard": { + "device-wizard": "Enhedsguide", + "device-details": "Enhedsoplysninger", + "new-device-profile": "Opret ny enhedsprofil", + "existing-device-profile": "Vælg eksisterende enhedsprofil", + "specific-configuration": "Specifik konfiguration", + "customer-to-assign-device": "Kunden skal tildele enheden", + "add-credential": "Tilføj brugeroplysninger" + } + }, + "device-profile": { + "device-profile": "Enhedsprofil", + "device-profiles": "Enhedsprofiler", + "all-device-profiles": "Alle", + "add": "Tilføj enhedsprofil", + "edit": "Rediger enhedsprofil", + "device-profile-details": "Oplysninger om enhedsprofil", + "no-device-profiles-text": "Ingen enhedsprofiler fundet", + "search": "Søg efter enhedsprofiler", + "selected-device-profiles": "", + "no-device-profiles-matching": "", + "device-profile-required": "Enhedsprofil er påkrævet", + "idCopiedMessage": "Enhedsprofil-id er blevet kopieret til udklipsholder", + "set-default": "Gør enhedsprofil standard", + "delete": "Slet enhedsprofil", + "copyId": "Kopiér enhedsprofil-id", + "new-device-profile-name": "Enhedsprofilnavn", + "new-device-profile-name-required": "Enhedsprofilnavn er påkrævet.", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "type": "Profiltype", + "type-required": "Profiltype er påkrævet.", + "type-default": "Standard", + "transport-type": "Transporttype", + "transport-type-required": "Transporttype er påkrævet.", + "transport-type-default": "Standard", + "transport-type-default-hint": "Understøtter grundlæggende MQTT-, HTTP- og CoAP-transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Aktiverer avancerede MQTT-transportindstillinger", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M-transporttype", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Aktiverer avancerede CoAP-transportindstillinger", + "description": "Beskrivelse", + "default": "Standard", + "profile-configuration": "Profilkonfiguration", + "transport-configuration": "Transportkonfiguration", + "default-rule-chain": "Standardregelkæde", + "select-queue-hint": "Vælg fra en rulleliste, eller tilføj et brugerdefineret navn.", + "delete-device-profile-title": "", + "delete-device-profile-text": "Vær forsigtig. Efter bekræftelsen vil enhedsprofilen og alle relaterede data være uoprettelige.", + "delete-device-profiles-title": "", + "delete-device-profiles-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte enhedsprofiler blive fjernet, og alle relaterede data vil være uoprettelige.", + "set-default-device-profile-title": "", + "set-default-device-profile-text": "Efter bekræftelsen vil enhedsprofilen blive markeret som standard og vil blive brugt til nye enheder, hvor der ikke er angivet nogen profil.", + "no-device-profiles-found": "Ingen enhedsprofiler fundet.", + "create-new-device-profile": "Opret en ny!", + "mqtt-device-topic-filters": "MQTT-enhedsemnefiltre", + "mqtt-device-topic-filters-unique": "MQTT-enhedsemnefiltre skal være unikke.", + "mqtt-device-payload-type": "MQTT-enhedsdata", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Datatype er påkrævet.", + "coap-device-type": "CoAP-enhedstype", + "coap-device-payload-type": "CoAP-enhedsdata", + "coap-device-type-required": "CoAP-enhedstype er påkrævet.", + "coap-device-type-default": "Standard", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Enkelt [+] jokertegn og [#] jokertegn på flere niveauer understøttes.", + "telemetry-topic-filter": "Telemetriemnefilter", + "telemetry-topic-filter-required": "Telemetriemnefilter er påkrævet.", + "attributes-topic-filter": "Attributemnefilter", + "attributes-topic-filter-required": "Attributemnefilter er påkrævet.", + "telemetry-proto-schema": "Telemetri-protoskema", + "telemetry-proto-schema-required": "Telemetri-protoskema er påkrævet.", + "attributes-proto-schema": "Protoskema for attributter", + "attributes-proto-schema-required": "Protoskema for attributter er påkrævet.", + "rpc-response-topic-filter": "RPC-svaremnefilter", + "rpc-response-topic-filter-required": "RPC-svaremnefilter er påkrævet.", + "not-valid-pattern-topic-filter": "Ikke gyldigt mønsteremnefilter", + "not-valid-single-character": "Ugyldig brug af jokertegn i et enkelt niveau", + "not-valid-multi-character": "Ugyldig brug af jokertegn i flere niveauer", + "single-level-wildcards-hint": "[+] er velegnet til ethvert emnefilterniveau. Eks.: v1/enheder/+/telemetri eller +/enheder/+/attributter.", + "multi-level-wildcards-hint": "[#] kan selv erstatte emnefilteret og skal være det sidste symbol i emnet. Eks.: # eller v1/enheder/me/#.", + "alarm-rules": "Alarmregler", + "alarm-rules-with-count": "", + "no-alarm-rules": "Ingen alarmregler konfigureret", + "add-alarm-rule": "Tilføj alarmregel", + "edit-alarm-rule": "Rediger alarmregel", + "alarm-type": "Alarmtype", + "alarm-type-required": "Alarmtype er påkrævet.", + "alarm-type-unique": "Alarmtypen skal være unik inden for alarmreglerne for enhedsprofilen.", + "create-alarm-pattern": "", + "create-alarm-rules": "Opret alarmregler", + "no-create-alarm-rules": "Ingen opret betingelser konfigureret", + "add-create-alarm-rule-prompt": "Tilføj opret alarmregel", + "clear-alarm-rule": "Ryd alarmregel", + "no-clear-alarm-rule": "Ingen klar betingelse konfigureret", + "add-create-alarm-rule": "Tilføj opret betingelse", + "add-clear-alarm-rule": "Tilføj ryd tilstand", + "select-alarm-severity": "Vælg alarmens alvorsgrad", + "alarm-severity-required": "Alarmens alvorsgrad er påkrævet.", + "condition-duration": "Varighed af betingelse", + "condition-duration-value": "Varighedsværdi", + "condition-duration-time-unit": "Tidsenhed", + "condition-duration-value-range": "Varighedsværdien skal ligge i området fra 1 til 2147483647.", + "condition-duration-value-pattern": "Varighedsværdien skal være heltal.", + "condition-duration-value-required": "Varighedsværdi er påkrævet.", + "condition-duration-time-unit-required": "Tidsenhed er påkrævet.", + "advanced-settings": "Avancerede indstillinger", + "alarm-rule-details": "Oplysninger", + "add-alarm-rule-details": "Tilføj oplysninger", + "propagate-alarm": "Overfør alarm", + "alarm-rule-relation-types-list": "Relationstyper, der skal overføres", + "alarm-rule-relation-types-list-hint": "Hvis der ikke vælges Overfør relationstyper vil alarmer blive overført uden filtrering på relationstype.", + "alarm-details": "Alarmoplysninger", + "alarm-rule-condition": "Alarmregelbetingelse", + "enter-alarm-rule-condition-prompt": "Tilføj alarmregelbetingelse", + "edit-alarm-rule-condition": "Rediger alarmregelbetingelse", + "device-provisioning": "Klargøring af enheden", + "provision-strategy": "Strategi for klargøring", + "provision-strategy-required": "Strategi for klargøring er påkrævet.", + "provision-strategy-disabled": "Deaktiveret", + "provision-strategy-created-new": "Tillad oprettelse af nye enheder", + "provision-strategy-check-pre-provisioned": "Kontrollér, om der er forhåndsklargjorte enheder", + "provision-device-key": "Klargøringsenhedsnøgle", + "provision-device-key-required": "Klargøringsenhedsnøgle er påkrævet.", + "copy-provision-key": "Kopiér klargøringsnøgle", + "provision-key-copied-message": "Klargøringsnøgle er blevet kopieret til udklipsholder", + "provision-device-secret": "Klargøringsenhed hemmelig", + "provision-device-secret-required": "Klargøringsenhed hemmelig er påkrævet.", + "copy-provision-secret": "Kopiér klargøring hemmelig", + "provision-secret-copied-message": "Klargøring hemmelig er blevet kopieret til udklipsholder", + "condition": "Betingelse", + "condition-type": "Betingelsestype", + "condition-type-simple": "Enkel", + "condition-type-duration": "Varighed", + "condition-during": "", + "condition-type-repeating": "Gentager", + "condition-type-required": "Betingelsesnavn er påkrævet.", + "condition-repeating-value": "Antal begivenheder", + "condition-repeating-value-range": "Antallet af begivenheder skal ligge i intervallet fra 1 til 2147483647.", + "condition-repeating-value-pattern": "Antal begivenheder skal være heltal.", + "condition-repeating-value-required": "Antal begivenheder er påkrævet.", + "condition-repeat-times": "", + "schedule-type": "Planlægningstype", + "schedule-type-required": "Planlægningstype er påkrævet.", + "schedule": "Tidsplan", + "edit-schedule": "Rediger alarmtidsplan", + "schedule-any-time": "Aktiv hele tiden", + "schedule-specific-time": "Aktiv på et bestemt tidspunkt", + "schedule-custom": "Brugerdefineret", + "schedule-day": { + "monday": "Mandag", + "tuesday": "Tirsdag", + "wednesday": "Onsdag", + "thursday": "Torsdag", + "friday": "Fredag", + "saturday": "Lørdag", + "sunday": "Søndag" + }, + "schedule-days": "Dage", + "schedule-time": "Tid", + "schedule-time-from": "Fra", + "schedule-time-to": "Til", + "schedule-days-of-week-required": "Der skal vælges mindst én ugedag.", + "create-device-profile": "Opret ny enhedsprofil", + "import": "Importér enhedsprofil", + "export": "Eksportér enhedsprofil", + "export-failed-error": "", + "device-profile-file": "Fil for enhedsprofil", + "invalid-device-profile-file-error": "Kan ikke importere enhedsprofil: Ugyldig datastruktur for enhedsprofil." + }, + "dialog": { + "close": "Tæt" + }, + "direction": { + "column": "Kolonne", + "row": "Række" + }, + "error": { + "unable-to-connect": "Kan ikke oprette forbindelse til serveren! Kontrollér din internetforbindelse.", + "unhandled-error-code": "", + "unknown-error": "Ukendt fejl" + }, + "entity": { + "entity": "Entitet", + "entities": "Entiteter", + "entities-count": "Antal entiteter", + "aliases": "Entitetsaliasser", + "entity-alias": "Entitetsalias", + "unable-delete-entity-alias-title": "Entitetsalias kunne ikke slettes", + "unable-delete-entity-alias-text": "", + "duplicate-alias-error": "", + "missing-entity-filter-error": "", + "configure-alias": "", + "alias": "Alias", + "alias-required": "Entitetsalias er påkrævet.", + "remove-alias": "Fjern entitetsalias", + "add-alias": "Tilføj entitetsalias", + "entity-list": "Entitetsliste", + "entity-type": "Entitetstype", + "entity-types": "Entitetstyper", + "entity-type-list": "Liste over entitetstype", + "any-entity": "Enhver entitet", + "enter-entity-type": "Indtast entitetstype", + "no-entities-matching": "", + "no-entity-types-matching": "", + "name-starts-with": "Navnet begynder med", + "use-entity-name-filter": "Anvend filter", + "entity-list-empty": "Ingen entiteter valgt.", + "entity-type-list-empty": "Ingen entitetstyper valgt.", + "entity-name-filter-required": "Entitetsnavnfilter er påkrævet.", + "entity-name-filter-no-entity-matched": "", + "all-subtypes": "Alle", + "select-entities": "Vælg entiteter", + "no-aliases-found": "Ingen aliasser fundet.", + "no-alias-matching": "", + "create-new-alias": "Opret en ny!", + "key": "Nøgle", + "key-name": "Nøglenavn", + "no-keys-found": "Ingen nøgler fundet.", + "no-key-matching": "", + "create-new-key": "Opret en ny!", + "type": "Type", + "type-required": "Entitetstype er påkrævet.", + "type-device": "Enhed", + "type-devices": "Enheder", + "list-of-devices": "", + "device-name-starts-with": "", + "type-device-profile": "Enhedsprofil", + "type-device-profiles": "Enhedsprofiler", + "list-of-device-profiles": "", + "device-profile-name-starts-with": "", + "type-asset": "Aktiv", + "type-assets": "Aktiver", + "list-of-assets": "", + "asset-name-starts-with": "", + "type-entity-view": "Entitetsvisning", + "type-entity-views": "Entitetsvisninger", + "list-of-entity-views": "", + "entity-view-name-starts-with": "", + "type-rule": "Regel", + "type-rules": "Regler", + "list-of-rules": "", + "rule-name-starts-with": "", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "", + "plugin-name-starts-with": "", + "type-tenant": "Lejer", + "type-tenants": "Lejere", + "list-of-tenants": "", + "tenant-name-starts-with": "", + "type-tenant-profile": "Lejerprofil", + "type-tenant-profiles": "Lejerprofiler", + "list-of-tenant-profiles": "", + "tenant-profile-name-starts-with": "", + "type-customer": "Kunde", + "type-customers": "Kunder", + "list-of-customers": "", + "customer-name-starts-with": "", + "type-user": "Bruger", + "type-users": "Brugere", + "list-of-users": "", + "user-name-starts-with": "", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboards", + "list-of-dashboards": "", + "dashboard-name-starts-with": "", + "type-alarm": "Alarm", + "type-alarms": "Alarmer", + "list-of-alarms": "", + "alarm-name-starts-with": "", + "type-rulechain": "Regelkæde", + "type-rulechains": "Regelkæder", + "list-of-rulechains": "", + "rulechain-name-starts-with": "", + "type-scheduler-event": "Planlægningsbegivenhed", + "type-scheduler-events": "Planlægningsbegivenheder", + "list-of-scheduler-events": "", + "scheduler-event-name-starts-with": "", + "type-blob-entity": "Blob-entitet", + "type-blob-entities": "Blob-entiteter", + "list-of-blob-entities": "", + "blob-entity-name-starts-with": "", + "type-rulenode": "Regelknude", + "type-rulenodes": "Regelknuder", + "list-of-rulenodes": "", + "rulenode-name-starts-with": "", + "type-current-customer": "Nuværende kunde", + "type-current-tenant": "Nuværende lejer", + "type-current-user": "Nuværende bruger", + "type-current-user-owner": "Nuværende brugerejer", + "search": "Søg efter entiteter", + "selected-entities": "", + "entity-name": "Entitetsnavn", + "entity-label": "Entitetsetiket", + "details": "Entitetsoplysninger", + "no-entities-prompt": "Ingen entiteter fundet", + "no-data": "Ingen data at vise", + "columns-to-display": "Kolonner, der skal vises", + "type-api-usage-state": "Api-brugstilstand", + "type-entity-group": "Entitetsgruppe", + "type-converter": "Dataomformer", + "type-converters": "Dataomformere", + "list-of-converters": "", + "converter-name-starts-with": "", + "type-integration": "Integration", + "type-integrations": "Integrationer", + "list-of-integrations": "", + "integration-name-starts-with": "", + "type-role": "Rolle", + "type-roles": "Roller", + "list-of-roles": "", + "role-name-starts-with": "", + "type-group-permission": "Gruppetilladelse" + }, + "entity-group": { + "entity-group": "Entitetsgruppe", + "details": "Oplysninger", + "columns": "Kolonner", + "add-column": "Tilføj kolonne", + "column-value": "Værdi", + "column-value-required": "Kolonneværdi er påkrævet.", + "column-title": "Titel", + "default-sort-order": "Standardsorteringsrækkefølge", + "default-sort-order-required": "Standardsorteringsrækkefølge er påkrævet.", + "hide-in-mobile-view": "Mobil skjult", + "use-cell-style-function": "Brug celletypefunktion", + "use-cell-content-function": "Brug celleindholdsfunktion", + "edit-column": "Rediger kolonne", + "column-details": "Kolonneoplysninger", + "actions": "Handlinger", + "settings": "Indstillinger", + "search": "Søg entitetsgrupper", + "delete": "Slet entitetsgruppe", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "add": "Tilføj entitetsgruppe", + "open-entity-group": "Åbn entitetsgruppe", + "add-entity-group-text": "Tilføj ny entitetsgruppe", + "no-entity-groups-text": "Ingen entitetsgrupper fundet", + "entity-group-details": "Oplysninger om entitetsgruppe", + "selected-entity-groups": "", + "delete-entity-groups": "Slet entitetsgrupper", + "delete-entity-group-title": "", + "delete-entity-group-text": "Vær forsigtig. Efter bekræftelsen vil entitetsgruppen og alle relaterede data være uoprettelige.", + "delete-entity-groups-title": "", + "delete-entity-groups-action-title": "", + "delete-entity-groups-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte entitetsgrupper blive fjernet, og alle relaterede data vil være uoprettelige.", + "device-groups": "Enhedsgrupper", + "asset-groups": "Aktivgrupper", + "customer-groups": "Kundegrupper", + "device-group": "Enhedsgruppe", + "asset-group": "Aktivgruppe", + "user-group": "Brugergruppe", + "user-groups": "Brugergrupper", + "customer-group": "Kundegruppe", + "entity-view-groups": "Entitetsvisningsgrupper", + "entity-view-group": "Entitetsvisningsgruppe", + "dashboard-groups": "Dashboardgrupper", + "dashboard-group": "Dashboardgruppe", + "fetch-more": "Hent mere", + "column-type": { + "column-type": "Kolonnetype", + "client-attribute": "Klientattribut", + "shared-attribute": "Delt attribut", + "server-attribute": "Serverattribut", + "timeseries": "Tidsserier", + "entity-field": "Entitetsfelt" + }, + "column-type-required": "Kolonnetype er påkrævet.", + "entity-field": { + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "type": "Type", + "device_profile": "Enhedsprofil", + "assigned_customer": "Tildelt kunde", + "authority": "Autoritet", + "first_name": "Fornavn", + "last_name": "Efternavn", + "email": "E-mail", + "title": "Titel", + "country": "Land", + "state": "Region", + "city": "By", + "address": "Adresse", + "address2": "Adresse 2", + "zip": "Postnummer", + "phone": "Telefon", + "label": "Mærkning" + }, + "sort-order": { + "asc": "Stigende", + "desc": "Faldende", + "none": "Ingen" + }, + "details-mode": { + "on-row-click": "Ved klik på række", + "on-action-button-click": "Ved klik på knappen Oplysninger", + "disabled": "Deaktiveret" + }, + "change-owner": "Skift ejer", + "select-target-owner": "Vælg målejer", + "no-owners-matching": "", + "target-owner-required": "Målejer er påkrævet.", + "confirm-change-owner-title": "", + "confirm-change-owner-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte enheder blive fjernet fra den aktuelle ejer og blive placeret i gruppen 'Alle' for målejeren.", + "add-to-group": "Tilføj til gruppe", + "move-to-group": "Flyt til gruppe", + "select-entity-group": "Vælg entitetsgruppe", + "no-entity-groups-matching": "", + "target-entity-group-required": "Målentitetsgruppe er påkrævet.", + "select-user-group": "Vælg brugergruppe", + "no-user-groups-matching": "", + "target-user-group-required": "Målbrugergruppe er påkrævet.", + "remove-from-group": "Fjern fra gruppe", + "group-table-title": "Gruppetabeltitel", + "enable-search": "Aktivér entitetssøgning", + "enable-add": "Aktivér tilføjelse af entiteter", + "enable-delete": "Aktivér sletning af entiteter", + "enable-selection": "Aktivér entitetsvalg", + "enable-group-transfer": "Aktivér gruppeoverførselshandlinger", + "display-pagination": "Vis sidenummerering", + "default-page-size": "Standard sidestørrelse", + "enable-assignment-actions": "Aktivér tildelingshandlinger", + "enable-credentials-management": "Aktivér administration af brugeroplysninger", + "enable-login-as-user": "Aktivér login som brugerhandling", + "enable-users-management": "Aktivér administration af brugere", + "enable-customers-management": "Aktivér administration af kunder", + "enable-assets-management": "Aktivér administration af aktiver", + "enable-devices-management": "Aktivér administration af enheder", + "enable-entity-views-management": "Aktivér administration af entitetsvisninger", + "enable-dashboards-management": "Aktivér administration af dashboards", + "open-details-on": "Åbn entitetsoplysninger på", + "select-existing": "Vælg eksisterende entitetsgruppe", + "create-new": "Opret ny entitetsgruppe", + "new-entity-group-name": "Nyt entitetsgruppenavn", + "entity-group-list": "Entitetsgruppeliste", + "entity-group-list-empty": "Ingen entitetsgrupper valgt.", + "name-starts-with": "Entitetsgruppenavn starter med", + "entity-group-name-filter-required": "Entitetsgruppenavnfilter er påkrævet.", + "roles": "Roller", + "permissions": "Tilladelser", + "public": "Offentlig", + "entity-group-public": "Entitetsgruppe er offentlig", + "make-public": "Gør entitetsgruppen offentlig", + "make-private": "Gør entitetsgruppen privat", + "make-public-entity-group-title": "", + "make-public-entity-group-text": "Efter bekræftelsen vil entitetsgruppen og alle dens entiteter blive gjort offentlige og tilgængelige for andre.", + "make-private-entity-group-title": "", + "make-private-entity-group-text": "Efter bekræftelsen vil entitetsgruppen og alle dens entiteter blive gjort private og vil ikke være tilgængelige for andre.", + "share": "Del entitetsgruppe", + "copyId": "Kopiér entitetsgruppe-id", + "idCopiedMessage": "Entitetsgruppe-id er blevet kopieret til udklipsholder", + "entity-group-name": "Entitetsgruppenavn", + "all-users": "Alle brugere" + }, + "entity-field": { + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "type": "Type", + "first-name": "Fornavn", + "last-name": "Efternavn", + "email": "E-mail", + "title": "Titel", + "country": "Land", + "state": "Region", + "city": "By", + "address": "Adresse", + "address2": "Adresse 2", + "zip": "Postnummer", + "phone": "Telefon", + "label": "Mærkning" + }, + "entity-view": { + "entity-view": "Entitetsvisning", + "entity-view-required": "Entitetsvisning er påkrævet.", + "entity-views": "Entitetsvisninger", + "management": "Administration af entitetsvisning", + "view-entity-views": "Vis entitetsvisninger", + "entity-view-alias": "Alias for entitetsvisning", + "aliases": "Aliasser for entitetsvisning", + "no-alias-matching": "", + "no-aliases-found": "Ingen aliasser fundet.", + "no-key-matching": "", + "no-keys-found": "Ingen nøgler fundet.", + "create-new-alias": "Opret en ny!", + "create-new-key": "Opret en ny!", + "duplicate-alias-error": "", + "configure-alias": "", + "no-entity-views-matching": "", + "public": "Offentlig", + "alias": "Alias", + "alias-required": "Alias for entitetsvisning er påkrævet.", + "remove-alias": "Fjern alias for entitetsvisning", + "add-alias": "Tilføj alias for entitetsvisning", + "name-starts-with": "Entitetsvisningsnavn starter med", + "entity-view-list": "Liste over entitetsvisning", + "use-entity-view-name-filter": "Anvend filter", + "entity-view-list-empty": "Ingen entitetsvisninger valgt.", + "entity-view-name-filter-required": "Entitetsvisningsnavnfilter er påkrævet.", + "entity-view-name-filter-no-entity-view-matched": "", + "add": "Tilføj entitetsvisning", + "entity-view-public": "Entitetsvisning er offentlig", + "assign-to-customer": "Tildel til kunde", + "assign-entity-view-to-customer": "Tildel entitetsvisning(er) til kunde", + "assign-entity-view-to-customer-text": "Vælg de entitetsvisninger, der skal tildeles til kunden", + "no-entity-views-text": "Ingen entitetsvisninger fundet", + "assign-to-customer-text": "Vælg den kunde, der skal tildeles entitetsvisning(er)", + "entity-view-details": "Oplysninger for entitetsvisning", + "add-entity-view-text": "Tilføj ny entitetsvisning", + "delete": "Slet entitetsvisning", + "assign-entity-views": "Tildel entitetsvisninger", + "assign-entity-views-text": "", + "delete-entity-views": "Slet entitetsvisninger", + "make-public": "Gør entitetsvisning offentlig", + "make-private": "Gør entitetsvisning privat", + "unassign-from-customer": "Fjern tildeling fra kunde", + "unassign-entity-views": "Fjern tildeling af entitetsvisninger", + "unassign-entity-views-action-title": "", + "assign-new-entity-view": "Tildel ny entitetsvisning", + "delete-entity-view-title": "", + "delete-entity-view-text": "Vær forsigtig. Efter bekræftelsen vil entitetsvisningen og alle relaterede data være uoprettelige.", + "delete-entity-views-title": "", + "delete-entity-views-action-title": "", + "delete-entity-views-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte entitetsvisninger blive fjernet, og alle relaterede data vil være uoprettelige.", + "make-public-entity-view-title": "", + "make-public-entity-view-text": "Efter bekræftelsen vil entitetsvisningen og alle dens data blive gjort offentlige og tilgængelige for andre.", + "make-private-entity-view-title": "", + "make-private-entity-view-text": "Efter bekræftelsen vil entitetsvisningen og alle dens data blive gjort private og vil ikke være tilgængelige for andre.", + "unassign-entity-view-title": "", + "unassign-entity-view-text": "Efter bekræftelsen vil entitetsvisningens tildeling blive fjernet og vil ikke være tilgængelig for kunden.", + "unassign-entity-view": "Fjern tildeling af entitetsvisning", + "unassign-entity-views-title": "", + "unassign-entity-views-text": "Efter bekræftelsen vil tildelingen af alle valgte entitetsvisninger blive fjernet og vil ikke være tilgængelige for kunden.", + "entity-view-type": "Entitetsvisningstype", + "entity-view-type-required": "Entitetsvisningstype er påkrævet.", + "select-entity-view-type": "Vælg entitetsvisningstype", + "enter-entity-view-type": "Indtast entitetsvisningstype", + "any-entity-view": "Enhver entitetsvisning", + "no-entity-view-types-matching": "", + "entity-view-type-list-empty": "Der er ikke valgt nogen entitetsvisningstyper.", + "entity-view-types": "Entitetsvisningstyper", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "events": "Begivenheder", + "details": "Oplysninger", + "copyId": "Kopiér entitetsvisnings-id", + "idCopiedMessage": "Entitetsvisnings-id er blevet kopieret til udklipsholder", + "assignedToCustomer": "Tildelt til kunde", + "unable-entity-view-device-alias-title": "Kan ikke slette entitetsvisningsalias", + "unable-entity-view-device-alias-text": "", + "select-entity-view": "Vælg entitetsvisning", + "start-date": "Startdato", + "start-ts": "Starttid", + "end-date": "Slutdato", + "end-ts": "Sluttid", + "date-limits": "Datofrister", + "client-attributes": "Klientattributter", + "shared-attributes": "Delte attributter", + "server-attributes": "Serverattributter", + "timeseries": "Tidsserier", + "client-attributes-placeholder": "Klientattributter", + "shared-attributes-placeholder": "Delte attributter", + "server-attributes-placeholder": "Serverattributter", + "timeseries-placeholder": "Tidsserier", + "target-entity": "Målentitet", + "attributes-propagation": "Overførsel af attributter", + "attributes-propagation-hint": "Entitetsvisning vil automatisk kopiere specificerede attributter fra målentitet, hver gang du gemmer eller opdaterer denne entitetsvisning. Af ydelsesmæssige årsager overføres målenhedsattributter ikke til entitetsvisning ved hver attributændring. Du kan aktivere automatisk overførsel ved at konfigurere regelknuden \"Kopiér til visning\" i din regelkæde og knytte meddelelserne \"Postattributter\" og \"Attributter opdateret\" til den nye regelknude.", + "timeseries-data": "Tidsseriedata", + "timeseries-data-hint": "Konfigurer tidsseriedatanøgler for målentiteten, der vil være tilgængelige for entitetsvisningen. Disse tidsseriedata er skrivebeskyttede.", + "selected-entity-views": "", + "search": "Søg efter entitetsvisninger", + "select-group-to-add": "Vælg målgruppe for at tilføje valgte entitetsvisninger", + "select-group-to-move": "Vælg målgruppe for at flytte valgte entitetsvisninger", + "remove-entity-views-from-group": "", + "group": "Gruppe af entitetsvisninger", + "list-of-groups": "", + "group-name-starts-with": "" + }, + "event": { + "events": "Begivenheder", + "event-type": "Begivenhedstype", + "type-error": "Fejl", + "type-lc-event": "Livscyklusbegivenhed", + "type-rw-event": "Rådata", + "type-stats": "Statistikker", + "type-debug-converter": "Debug", + "type-debug-integration": "Debug", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "Ingen begivenheder fundet", + "error": "Fejl", + "alarm": "Alarm", + "event-time": "Begivenhedstidspunkt", + "server": "Server", + "body": "Tekst", + "method": "Metode", + "type": "Type", + "in": "Ind", + "out": "Ud", + "metadata": "Metadata", + "message": "Meddelelse", + "entity": "Entitet", + "message-id": "Meddelelses-id", + "message-type": "Meddelelsestype", + "data-type": "Datatype", + "relation-type": "Relationstype", + "data": "Data", + "event": "Begivenhed", + "status": "Status", + "success": "Succes", + "failed": "Mislykket", + "messages-processed": "Behandlede meddelelser", + "errors-occurred": "Der opstod fejl", + "uuid": "UUID" + }, + "extension": { + "extensions": "Udvidelser", + "selected-extensions": "", + "type": "Type", + "key": "Nøgle", + "value": "Værdi", + "id": "Id", + "extension-id": "Udvidelses-id", + "extension-type": "Udvidelsestype", + "transformer-json": "JSON *", + "unique-id-required": "Aktuelt udvidelses-id findes allerede.", + "delete": "Slet udvidelse", + "add": "Tilføj udvidelse", + "edit": "Rediger udvidelse", + "view": "Vis udvidelse", + "delete-extension-title": "", + "delete-extension-text": "Vær forsigtig. Efter bekræftelsen vil udvidelsen og alle relaterede data være uoprettelige.", + "delete-extensions-title": "", + "delete-extensions-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte udvidelser blive fjernet.", + "converters": "Omformere", + "converter-id": "Omformer-id", + "configuration": "Konfiguration", + "converter-configurations": "Omformerkonfigurationer", + "token": "Sikkerhedstoken", + "add-converter": "Tilføj omformer", + "add-config": "Tilføj omformerkonfiguration", + "device-name-expression": "Enhedsnavnudtryk", + "device-type-expression": "Enhedstypeudtryk", + "custom": "Brugerdefineret", + "to-double": "Til dobbelt", + "transformer": "Transformer", + "json-required": "Transformer-json er påkrævet.", + "json-parse": "Kan ikke parse transformer-json.", + "attributes": "Attributter", + "add-attribute": "Tilføj attribut", + "add-map": "Tilføj tilknytningselement", + "timeseries": "Tidsserier", + "add-timeseries": "Tilføj tidsserier", + "field-required": "Felt er påkrævet", + "brokers": "Mæglere", + "add-broker": "Tilføj mægler", + "host": "Vært", + "port": "Port", + "port-range": "Porten skal ligge i området fra 1 til 65535.", + "ssl": "Ssl", + "credentials": "Brugeroplysninger", + "username": "Brugernavn", + "password": "Adgangskode", + "retry-interval": "Forsøg interval igen i millisekunder", + "sas": "Signatur for delt adgang", + "anonymous": "Anonym", + "basic": "Basis", + "pem": "PEM", + "ca-cert": "CA-certifikatfil *", + "private-key": "Privat nøglefil *", + "cert": "Certifikatfil *", + "no-file": "Ingen fil valgt.", + "drop-file": "Træk og slip en fil, eller klik for at vælge en fil, der skal uploades.", + "mapping": "Tilknytning", + "topic-filter": "Emnefilter", + "converter-type": "Omformertype", + "converter-json": "Json", + "json-name-expression": "Udtryk for enhedsnavn json", + "topic-name-expression": "Udtryk for enhedsnavnemne", + "json-type-expression": "Udtryk for enhedstype json", + "topic-type-expression": "Udtryk for enhedstypeemne", + "attribute-key-expression": "Udtryk for attributnøgle", + "attr-json-key-expression": "Udtryk for attributnøgle json", + "attr-topic-key-expression": "Udtryk for attributnøgleemne", + "request-id-expression": "Udtryk for anmodnings-id", + "request-id-json-expression": "Udtryk for anmodnings-id json", + "request-id-topic-expression": "Udtryk for anmodnings-id emne", + "response-topic-expression": "Udtryk for svaremne", + "value-expression": "Udtryk for værdi", + "topic": "Emne", + "timeout": "Timeout i millisekunder", + "converter-json-required": "Omformer-json er påkrævet.", + "converter-json-parse": "Omformeren json kunne ikke parses.", + "filter-expression": "Udtryk for filter", + "connect-requests": "Tilslutningsanmodninger", + "add-connect-request": "Tilføj tilslutningsanmodning", + "disconnect-requests": "Afbryd anmodninger", + "add-disconnect-request": "Tilføj afbrydelsesanmodning", + "attribute-requests": "Attributanmodninger", + "add-attribute-request": "Tilføj attributanmodning", + "attribute-updates": "Attributopdateringer", + "add-attribute-update": "Tilføj attributopdatering", + "server-side-rpc": "Serverside RPC", + "add-server-side-rpc-request": "Tilføj serverside RPC-anmodning", + "device-name-filter": "Enhedsnavnfilter", + "attribute-filter": "Attributfilter", + "method-filter": "Metodefilter", + "request-topic-expression": "Anmod om udtryk for emne", + "response-timeout": "Svartimeout i millisekunder", + "topic-expression": "Udtryk for emne", + "client-scope": "Klientomfang", + "add-device": "Tilføj enhed", + "opc-server": "Servere", + "opc-add-server": "Tilføj server", + "opc-add-server-prompt": "Tilføj venligst server", + "opc-application-name": "Applikationsnavn", + "opc-application-uri": "Applikation uri", + "opc-scan-period-in-seconds": "Scanningsperiode i sekunder", + "opc-security": "Sikkerhed", + "opc-identity": "Identitet", + "opc-keystore": "Keystore", + "opc-type": "Type", + "opc-keystore-type": "Type", + "opc-keystore-location": "Placering *", + "opc-keystore-password": "Adgangskode", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Nøgleadgangskode", + "opc-device-node-pattern": "Mønster for enhedsknude", + "opc-device-name-pattern": "Mønster for enhedsnavn", + "modbus-server": "Servere/slaver", + "modbus-add-server": "Tilføj server/slave", + "modbus-add-server-prompt": "Tilføj venligst server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Automatisk gentilslutning", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "Serielt portnavn", + "modbus-encoding": "Kodning", + "modbus-parity": "Paritet", + "modbus-baudrate": "Baudrate", + "modbus-databits": "Databits", + "modbus-stopbits": "Stopbits", + "modbus-databits-range": "Databits skal ligge i området fra 7 til 8.", + "modbus-stopbits-range": "Stopbits skal ligge i området fra 1 til 2.", + "modbus-unit-id": "Enheds-id", + "modbus-unit-id-range": "Enheds-id skal ligge i området fra 1 til 247.", + "modbus-device-name": "Enhedsnavn", + "modbus-poll-period": "Undersøgelsesperiode (ms)", + "modbus-attributes-poll-period": "Attributundersøgelsesperiode (ms)", + "modbus-timeseries-poll-period": "Tidsserieundersøgelsesperiode (ms)", + "modbus-poll-period-range": "Undersøgelsesperioden skal have positiv værdi.", + "modbus-tag": "Tag", + "modbus-function": "Funktion", + "modbus-register-address": "Registeradresse", + "modbus-register-address-range": "Registeradresse skal ligge i området fra 0 til 65535.", + "modbus-register-bit-index": "Bitindeks", + "modbus-register-bit-index-range": "Bitindeks skal ligge i området fra 0 til 15.", + "modbus-register-count": "Registeroptælling", + "modbus-register-count-range": "Registeroptælling skal være en positiv værdi.", + "modbus-byte-order": "Byterækkefølge", + "sync": { + "status": "Status", + "sync": "Synkroniseret", + "not-sync": "Ikke synkroniseret", + "last-sync-time": "Sidste synkroniseringstidspunkt", + "not-available": "Ikke til rådighed" + }, + "export-extensions-configuration": "Konfiguration af eksportér udvidelser", + "import-extensions-configuration": "Konfiguration af importér udvidelser", + "import-extensions": "Importér udvidelser", + "import-extension": "Importér udvidelse", + "export-extension": "Eksportér udvidelse", + "file": "Fil for udvidelser", + "invalid-file-error": "Ugyldig udvidelsesfil", + "text": "TEKST", + "json": "JSON", + "binary": "BINÆR", + "hex": "HEX" + }, + "filter": { + "add": "Tilføj filter", + "edit": "Rediger filter", + "name": "Filternavn", + "name-required": "Filternavn er påkrævet.", + "duplicate-filter": "Filter med samme navn findes allerede.", + "filters": "Filtre", + "unable-delete-filter-title": "Filteret kunne ikke slettes", + "unable-delete-filter-text": "", + "duplicate-filter-error": "", + "missing-key-filters-error": "", + "filter": "Filter", + "editable": "Redigerbar", + "no-filters-found": "Ingen filtre fundet.", + "no-filter-text": "Intet filter angivet", + "add-filter-prompt": "Tilføj venligst filter", + "no-filter-matching": "", + "create-new-filter": "Opret en ny!", + "filter-required": "Filter er påkrævet.", + "operation": { + "operation": "Driftsopgave", + "equal": "lig med", + "not-equal": "ikke lig med", + "starts-with": "starter med", + "ends-with": "slutter med", + "contains": "indeholder", + "not-contains": "indeholder ikke", + "greater": "større end", + "less": "mindre end", + "greater-or-equal": "større end eller lig med", + "less-or-equal": "mindre end eller lig med", + "and": "og", + "or": "eller" + }, + "ignore-case": "ignorer versalfølsomhed", + "value": "Værdi", + "remove-filter": "Fjern filter", + "preview": "Visning af filter", + "no-filters": "Ingen filtre konfigureret", + "add-filter": "Tilføj filter", + "add-complex-filter": "Tilføj komplekst filter", + "add-complex": "Tilføj kompleks", + "complex-filter": "Komplekst filter", + "edit-complex-filter": "Rediger komplekst filter", + "edit-filter-user-params": "Rediger filterprædikat for brugerparametre", + "filter-user-params": "Filtrerprædikat for brugerparametre", + "user-parameters": "Brugerparametre", + "display-label": "Etiket, der skal vises", + "autogenerated-label": "Generer automatisk etiket", + "order-priority": "Prioritet af feltrækkefølge", + "key-filter": "Nøglefilter", + "key-filters": "Nøglefiltre", + "key-name": "Nøglenavn", + "key-name-required": "Nøglenavn er påkrævet.", + "key-type": { + "key-type": "Nøgletype", + "attribute": "Attribut", + "timeseries": "Tidsserier", + "entity-field": "Entitetsfelt", + "constant": "Konstant" + }, + "value-type": { + "value-type": "Værditype", + "string": "Streng", + "numeric": "Numerisk", + "boolean": "Boolesk", + "date-time": "Datotid" + }, + "value-type-required": "Nøgleværditype er påkrævet.", + "key-value-type-change-title": "Er du sikker på, at du vil ændre nøgleværditypen?", + "key-value-type-change-message": "Hvis du bekræfter ny værditype, fjernes alle indtastede nøglefiltre.", + "no-key-filters": "Ingen nøglefiltre konfigureret", + "add-key-filter": "Tilføj nøglefilter", + "remove-key-filter": "Fjern nøglefilter", + "edit-key-filter": "Rediger nøglefilter", + "date": "Dato", + "time": "Tid", + "current-tenant": "Nuværende lejer", + "current-customer": "Nuværende kunde", + "current-user": "Nuværende bruger", + "current-device": "Nuværende enhed", + "default-value": "Standardværdi", + "dynamic-source-type": "Dynamisk kildetype", + "no-dynamic-value": "Ingen dynamisk værdi", + "source-attribute": "Kildeattribut", + "switch-to-dynamic-value": "Skift til dynamisk værdi", + "switch-to-default-value": "Skift til standardværdi", + "inherit-owner": "Nedarv fra ejer", + "source-attribute-not-set": "Hvis kildeattributten ikke er angivet" + }, + "fullscreen": { + "expand": "Udvid til fuld skærm", + "exit": "Afslut fuldskærm", + "toggle": "Skift mellem fuldskærmstilstand", + "fullscreen": "Fuld skærm" + }, + "function": { + "function": "Funktion" + }, + "gateway": { + "add-entry": "Tilføj konfiguration", + "connector-add": "Tilføj ny stikforbindelse", + "connector-enabled": "Aktivér stikforbindelse", + "connector-name": "Navn på stikforbindelse", + "connector-name-required": "Navn på stikforbindelse er påkrævet.", + "connector-type": "Stikforbindelsestype", + "connector-type-required": "Stikforbindelsestype er påkrævet.", + "connectors": "Konfiguration af stikforbindelser", + "create-new-gateway": "Opret en ny gateway", + "create-new-gateway-text": "", + "delete": "Slet konfiguration", + "download-tip": "Download konfigurationsfil", + "gateway": "Gateway", + "gateway-exists": "Enhed med samme navn findes allerede.", + "gateway-name": "Gateway-navn", + "gateway-name-required": "Gateway-navn er påkrævet.", + "gateway-saved": "Gateway-konfigurationen blev gemt.", + "json-parse": "Ikke gyldig JSON.", + "json-required": "Feltet må ikke være tomt.", + "no-connectors": "Ingen stikforbindelser", + "no-data": "Ingen konfigurationer", + "no-gateway-found": "Ingen gateway fundet.", + "no-gateway-matching": "", + "path-logs": "Sti til logfiler", + "path-logs-required": "Sti er påkrævet.", + "remote": "Fjernkonfiguration", + "remote-logging-level": "Logføringsniveau", + "remove-entry": "Fjern konfiguration", + "save-tip": "Gem konfigurationsfil", + "security-type": "Sikkerhedstype", + "security-types": { + "access-token": "Adgangstoken", + "tls": "TLS" + }, + "storage": "Lagring", + "storage-max-file-records": "Maks. antal poster i fil", + "storage-max-files": "Maks. antal filer", + "storage-max-files-min": "Min. antal er 1.", + "storage-max-files-pattern": "Antal er ikke gyldigt.", + "storage-max-files-required": "Antal er påkrævet.", + "storage-max-records": "Maks. antal poster i lagring", + "storage-max-records-min": "Min. antal poster er 1.", + "storage-max-records-pattern": "Antal er ikke gyldigt.", + "storage-max-records-required": "Maks. antal poster er påkrævet.", + "storage-pack-size": "Maks. antal pakkestørrelse for begivenhed", + "storage-pack-size-min": "Min. antal er 1.", + "storage-pack-size-pattern": "Antal er ikke gyldigt.", + "storage-pack-size-required": "Maks. antal pakkestørrelse for begivenhed er påkrævet.", + "storage-path": "Lagringssti", + "storage-path-required": "Lagringssti er påkrævet.", + "storage-type": "Lagringstype", + "storage-types": { + "file-storage": "Lagring af filter", + "memory-storage": "Lagring af hukommelse" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard-vært", + "thingsboard-host-required": "Vært er påkrævet.", + "thingsboard-port": "ThingsBoard-port", + "thingsboard-port-max": "Maks. portnummer er 65535.", + "thingsboard-port-min": "Min. portnummer er 1.", + "thingsboard-port-pattern": "Port er ikke gyldig.", + "thingsboard-port-required": "Port er påkrævet.", + "tidy": "Tidy", + "tidy-tip": "Tidy konfig. JSON", + "title-connectors-json": "", + "tls-path-ca-certificate": "Sti til CA-certifikat på gateway", + "tls-path-client-certificate": "Sti til klientcertifikat på gateway", + "tls-path-private-key": "Sti til privat nøgle på gateway", + "toggle-fullscreen": "Skift til fuld skærm", + "transformer-json-config": "Konfiguration JSON*", + "update-config": "Tilføj/opdater konfiguration JSON" + }, + "grid": { + "delete-item-title": "Er du sikker på, du ønsker at slette dette element?", + "delete-item-text": "Vær forsigtig. Efter bekræftelsen vil dette element og alle relaterede data være uoprettelige.", + "delete-items-title": "", + "delete-items-action-title": "", + "delete-items-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte elementer blive fjernet, og alle relaterede data vil være uoprettelige.", + "add-item-text": "Tilføj nyt element", + "no-items-text": "Ingen elementer fundet", + "item-details": "Oplysninger om element", + "delete-item": "Slet element", + "delete-items": "Slet elementer", + "scroll-to-top": "Gå til toppen" + }, + "help": { + "goto-help-page": "Gå til hjælpesiden" + }, + "home": { + "home": "Hjem", + "profile": "Profil", + "logout": "Log ud", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Åbn brugermenu" + }, + "import": { + "no-file": "Ingen fil valgt", + "drop-file": "Træk og slip en JSON-fil, eller klik for at vælge en fil, der skal uploades.", + "drop-csv-file": "Træk og slip en CSV-fil, eller klik for at vælge en fil, der skal uploades.", + "drop-file-csv": "Træk og slip en CSV-fil, eller klik for at vælge en fil, der skal uploades.", + "column-value": "Værdi", + "column-title": "Titel", + "column-example": "Eksempel på værdidata", + "column-key": "Attribut-/telemetrinøgle", + "csv-delimiter": "CSV-skilletegn", + "csv-first-line-header": "Første linje indeholder kolonnenavne", + "csv-update-data": "Opdater attributter/telemetri", + "import-csv-number-columns-error": "En fil skal indeholde mindst to kolonner", + "import-csv-invalid-format-error": "", + "column-type": { + "name": "Navn", + "type": "Type", + "label": "Mærkning", + "column-type": "Kolonnetype", + "client-attribute": "Klientattribut", + "shared-attribute": "Delt attribut", + "server-attribute": "Serverattribut", + "timeseries": "Tidsserier", + "entity-field": "Entitetsfelt", + "access-token": "Adgangstoken", + "isgateway": "Er gateway", + "activity-time-from-gateway-device": "Aktivitetstid fra gateway-enhed", + "description": "Beskrivelse" + }, + "stepper-text": { + "select-file": "Vælg en fil", + "configuration": "Importér konfiguration", + "column-type": "Vælg kolonnetype", + "creat-entities": "Oprettelse af nye entiteter", + "done": "Udført" + }, + "message": { + "create-entities": "", + "update-entities": "", + "error-entities": "" + } + }, + "integration": { + "integration": "Integration", + "integrations": "Integrationer", + "select-integration": "Vælg integration", + "no-integrations-matching": "", + "integration-required": "Integration er påkrævet", + "delete": "Slet integration", + "management": "Integrationsstyring", + "add-integration-text": "Tilføj ny integration", + "no-integrations-text": "Ingen integrationer fundet", + "selected-integrations": "", + "delete-integration-title": "", + "delete-integration-text": "Vær forsigtig. Efter bekræftelsen vil integrationen og alle relaterede data være uoprettelige.", + "delete-integrations-title": "", + "delete-integrations-action-title": "", + "delete-integrations-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte integrationer blive fjernet, og alle relaterede data vil være uoprettelige.", + "events": "Begivenheder", + "enabled": "Aktiveret", + "allow-create-devices-or-assets": "Tillad oprettelse af enheder eller aktiver", + "add": "Tilføj integration", + "search": "Søg efter integrationer", + "integration-details": "Integrationsoplysninger", + "details": "Oplysninger", + "copyId": "Kopiér integrations-id", + "idCopiedMessage": "Integrations-id er blevet kopieret til udklipsholder", + "debug-mode": "Debug-tilstand", + "enable-security": "Aktivér sikkerhed", + "enable-security-new": "Aktivér sikkerhed for automatiske tokenopdateringer", + "headers-filter": "Filter for overskrifter", + "header": "Overskrift", + "no-headers-filter": "Intet filter for overskrifter", + "downlink-url": "Downlink URL", + "downlink-url-required": "Downlink URL er påkrævet", + "create-loriot-output": "Opret Loriot-applikationsoutput", + "send-downlink": "Send downlink", + "server": "Server", + "server-required": "Server er påkrævet", + "app-id": "Applikations-id", + "app-id-required": "Applikations-id er påkrævet", + "app-token": "Applikationsadgangstoken", + "app-token-required": "Applikationsadgangstoken er påkrævet", + "email": "E-mail", + "email-required": "E-mail er påkrævet", + "application-uri": "Applikation URI", + "as-id": "AS-id", + "as-id-required": "AS-id er påkrævet.", + "as-key": "AS-nøgle", + "as-key-required": "AS-nøgle er påkrævet.", + "client-id-new": "Klient-id", + "client-id-new-required": "Klient-id (login) er påkrævet (login).", + "client-secret": "Klient hemmelig", + "client-secret-required": "Klient hemmelig (adgangskode) er påkrævet (adgangskode).", + "max-time-diff-in-seconds": "Maksimal tidsforskel (sekunder)", + "max-time-diff-in-seconds-required": "Maksimal tidsforskel er påkrævet.", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL er påkrævet", + "security-key": "Sikkerhedsnøgle", + "http-endpoint": "HTTP-slutpunkts-URL", + "replace-no-content-to-ok": "Erstat svarstatus fra 'Intet indhold' til 'OK'", + "copy-http-endpoint-url": "Kopiér HTTP-slutpunkts-URL", + "http-endpoint-url-copied-message": "HTTP-slutpunkts-URL er blevet kopieret til udklipsholder", + "host": "Vært", + "host-required": "Vært er påkrævet.", + "host-type": "Værtstype", + "host-type-required": "Værtstype er påkrævet.", + "custom-host": "Brugerdefineret vært", + "custom-host-required": "Brugerdefineret vært er påkrævet.", + "port": "Port", + "port-required": "Port er påkrævet.", + "port-range": "Porten skal ligge i området fra 1 til 65535.", + "connect-timeout": "Forbindelsestimeout (sek.)", + "connect-timeout-required": "Forbindelsestimeout er påkrævet.", + "connect-timeout-range": "Forbindelsestimeout skal ligge i området fra 1 til 200.", + "client-id": "Klient-id", + "client-id-required": "Klient-id er påkrævet.", + "client-id-range": "Klient-id skal ligge i et interval fra 1 til 23 tegn. [MQTT-3.1.3-5]", + "client-id-pattern": "Klient-id skal bestå af tal og store og små bogstaver. [MQTT-3.1.3-5]", + "max-bytes-in-message": "Maksimum bytes i meddelelse", + "max-bytes-in-message-range": "Maksimum bytes i meddelelsen skal ligge i området fra 1 til 256000000.", + "device-id": "Enheds-id", + "device-id-required": "Enheds-id er påkrævet.", + "device-id-range": "Enheds-id'et skal ligge i området fra 1 til 65535 tegn.", + "device-id-pattern": "Enheds-id'et skal bestå af tal og store og små bogstaver. [MQTT-3.1.3-5]", + "group-id": "Gruppe-id", + "group-id-required": "Gruppe-id er påkrævet.", + "topics": "Emner", + "topics-required": "Emne er påkrævet.", + "routing-keys": "Routing-nøgler", + "routing-keys-required": "Routing-nøgle er påkrævet.", + "queues": "Køer", + "queues-required": "Kønavn er påkrævet.", + "exchange-name": "Udskiftningsnavn", + "exchange-name-required": "Udskiftningsnavn er påkrævet.", + "downlink-topic": "Downlink-emne", + "connection-timeout": "Forbindelsestimeout, ms", + "connection-timeout-min": "Ugyldig forbindelsestimeoutværdi.", + "handshake-timeout": "Handshake-timeout, ms", + "handshake-timeout-min": "Ugyldig handshake-timeoutværdi.", + "virtual-host": "Virtuel vært", + "rabbit-mq-poll-interval": "Undersøgelsesinterval, ms", + "rabbit-mq-poll-interval-min": "Ugyldig undersøgelsesintervalværdi.", + "application-server-url": "Applikationsserver URL", + "application-server-api-token": "Applikationsserver API-token", + "application-server-api-token-required": "Applikationsserver API-token er påkrævet.", + "bootstrap-servers": "Bootstrap-servere", + "bootstrap-servers-required": "Bootstrap-servere er påkrævet.", + "poll-interval": "Undersøgelsesinterval", + "poll-interval-required": "Undersøgelsesinterval er påkrævet.", + "auto-create-topics": "Opret emner automatisk", + "clean-session": "Ryd session", + "enable-ssl": "Aktivér SSL", + "credentials": "Brugeroplysninger", + "credentials-type": "Type af brugeroplysninger", + "credentials-type-required": "Type af brugeroplysninger er påkrævet.", + "username": "Brugernavn", + "username-required": "Brugernavn er påkrævet.", + "password": "Adgangskode", + "password-required": "Brugernavn er påkrævet.", + "azure-ca-cert": "CA-certifikatfil", + "ca-cert": "CA-certifikatfil *", + "private-key": "Privat nøglefil *", + "private-key-password": "Adgangskode til privat nøgle", + "cert": "Certifikatfil *", + "no-file": "Ingen fil valgt.", + "drop-file": "Træk og slip en fil, eller klik for at vælge en fil, der skal uploades.", + "check-connection": "Kontrollér forbindelsen", + "check-success": "Forbindelse oprettet.", + "topic-filters": "Emnefiltre", + "remove-topic-filter": "Fjern emnefilter", + "add-topic-filter": "Tilføj emnefilter", + "add-topic-filter-prompt": "Tilføj venligst emnefilter", + "topic": "Emne", + "mqtt-qos": "QoS", + "mqtt-qos-at-most-once": "Højst én gang", + "mqtt-qos-at-least-once": "Mindst én gang", + "mqtt-qos-exactly-once": "Præcis én gang", + "downlink-topic-pattern": "Mønster for downlink-emne", + "downlink-topic-pattern-required": "Mønster for downlink-emne er påkrævet.", + "aws-access-key-id": "AWS-adgangsnøgle-id", + "aws-secret-access-key": "AWS hemmelig adgangsnøgle", + "aws-region": "Region", + "aws-iot-endpoint": "AWS IoT-slutpunkt", + "aws-iot-endpoint-required": "AWS IoT-slutpunkt er påkrævet.", + "aws-iot-credentials": "AWS IoT-brugeroplysninger", + "aws-sqs-polling-period-in-seconds": "Undersøgelsesperiode i sekunder", + "aws-sqs-queue-url": "SQS Kø URL", + "aws-sqs-queue-url-required": "SQS Kø URL er påkrævet", + "aws-sqs-access-key-id-required": "Adgangsnøgle-id er påkrævet", + "aws-sqs-secret-access-key-required": "Hemmelig adgangsnøgle er påkrævet", + "application-credentials": "Applikationsbrugeroplysninger", + "api-key": "API-nøgle", + "api-key-required": "API-nøgle er påkrævet.", + "api-key-format": "Ugyldigt API-nøgleformat.", + "auth-token": "Godkendelsestoken", + "auth-token-required": "Godkendelsestoken er påkrævet.", + "region": "Region", + "region-required": "Region er påkrævet.", + "application-id": "Applikations-id", + "application-id-required": "Applikations-id er påkrævet.", + "access-key": "Adgangsnøgle", + "access-key-required": "Adgangsnøgle er påkrævet.", + "connection-parameters": "Tilslutningsparametre", + "service-bus-namespace-name": "Namespace-navn for servicebus", + "service-bus-namespace-name-required": "Namespace-navn for servicebus er påkrævet.", + "connection-string": "Tilslutningsstreng", + "connection-string-required": "Tilslutningsstreng påkrævet!", + "event-hub-name": "Navn på begivenhedshub", + "event-hub-name-required": "Navn på begivenhedshub er påkrævet.", + "event-iot-hub-name-required": "Iot-hubnavn er påkrævet for downlink", + "sas-key-name": "SAS-nøglenavn", + "sas-key-name-required": "SAS-nøglenavn er påkrævet.", + "sas-key": "SAS-nøgle", + "sas-key-required": "SAS-nøgle er påkrævet.", + "iot-hub-name": "IoT-hubnavn (påkrævet for downlink)", + "hostname": "Værtsnavn", + "hostname-required": "Værtsnavn er påkrævet", + "integration-clazz": "Integrationsklasse", + "integration-clazz-required": "Integrationsklasse er påkrævet", + "integration-configuration": "Konfiguration af integration JSON", + "metadata": "Metadata", + "type": "Type", + "type-required": "Type er påkrævet.", + "uplink-converter": "Uplink-dataomformer", + "uplink-converter-required": "Uplink-dataomformer er påkrævet.", + "downlink-converter": "Downlink-dataomformer", + "type-http": "HTTP", + "type-ocean-connect": "OceanConnect", + "type-sigfox": "SigFox", + "type-thingpark": "ThingPark", + "type-loriot": "Loriot", + "type-thingpark-enterprise": "ThingParkEnterprise", + "type-tmobile-iot-cdp": "T-Mobile – IoT CDP", + "type-mqtt": "MQTT", + "type-aws-iot": "AWS IoT", + "type-aws-sqs": "AWS SQS", + "type-aws-kinesis": "AWS-kinese", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ttn": "TheThingsNetwork", + "type-tti": "The Things Stack", + "type-chirpstack": "ChirpStack", + "type-azure-event-hub": "Azure-begivenhedshub", + "type-azure-iot-hub": "Azure IoT-hub", + "type-opc-ua": "OPC-UA", + "type-custom": "Brugerdefineret", + "type-udp": "UDP", + "type-tcp": "TCP", + "type-kafka": "Kafka", + "type-rabbitmq": "RabbitMQ", + "type-pubsub": "Pub/Sub", + "opc-ua-application-name": "Applikationsnavn", + "opc-ua-application-uri": "Applikation uri", + "opc-ua-scan-period-in-seconds": "Scanningsperiode i sekunder", + "opc-ua-scan-period-in-seconds-required": "Scanningsperiode er påkrævet", + "opc-ua-timeout": "Timeout i millisekunder", + "opc-ua-timeout-required": "Timeout er påkrævet", + "opc-ua-security": "Sikkerhed", + "opc-ua-security-required": "Sikkerhed er påkrævet", + "opc-ua-identity": "Identitet", + "opc-ua-identity-required": "Identitet er påkrævet", + "opc-ua-keystore": "Keystore", + "add-opc-ua-keystore-prompt": "Tilføj venligst keystore-fil", + "opc-ua-keystore-required": "Keystore er påkrævet", + "opc-ua-type": "Type", + "opc-ua-keystore-type": "Type", + "opc-ua-keystore-type-required": "Type er påkrævet", + "opc-ua-keystore-location": "Placering *", + "opc-ua-keystore-password": "Adgangskode", + "opc-ua-keystore-password-required": "Adgangskode er påkrævet", + "opc-ua-keystore-alias": "Alias", + "opc-ua-keystore-alias-required": "Alias er påkrævet", + "opc-ua-keystore-key-password": "Nøgleadgangskode", + "opc-ua-keystore-key-password-required": "Adgangskode er påkrævet", + "opc-ua-mapping": "Tilknytning", + "add-opc-ua-mapping-prompt": "Tilføj tilknytning", + "opc-ua-mapping-type": "Tilknytningstype", + "opc-ua-mapping-type-required": "Tilknytningstype er påkrævet", + "opc-ua-device-node-pattern": "Enhedsknudemønster", + "opc-ua-device-node-pattern-required": "Enhedsknudemønster er påkrævet", + "opc-ua-namespace": "Namespace", + "opc-ua-add-map": "Tilføj tilknytningselement", + "kinesis-stream-name": "Streamnavn", + "kinesis-stream-name-required": "Streamnavn er påkrævet", + "kinesis-region": "Region", + "kinesis-region-required": "Region er påkrævet", + "kinesis-access-key-id": "Adgangsnøgle-id", + "kinesis-access-key-id-required": "Adgangsnøgle-id er påkrævet", + "kinesis-secret-access-key": "Hemmelig adgangsnøgle", + "kinesis-secret-access-key-required": "Hemmelig adgangsnøgle er påkrævet", + "kinesis-use-consumers-with-enhanced-fan-out": "Anvend forbrugere med forbedret Fan-Out", + "kinesis-use-credentials-from-instance-metadata": "Anvend brugeroplysninger fra Amazon EC2 Instance Metadata Service", + "kinesis-application-name": "Applikationsnavn (som standard lig med Streamnavn)", + "kinesis-initial-position-in-stream": "Startposition i stream", + "kinesis-initial-position-in-stream-required": "Startposition i stream påkrævet", + "kinesis-max-records": "Maks. poster", + "kinesis-max-records-required": "Maks. poster er påkrævet", + "kinesis-max-records-length-range": "Maks. længde for poster skal ligge i et område fra 1 til 10000", + "kinesis-request-timeout": "Anmodningstimeout i sekunder", + "kinesis-request-timeout-required": "Anmodningstimeout er påkrævet", + "other-properties": "Andre egenskaber", + "subscription-tags": "Abonnementstags", + "remove-subscription-tag": "Fjern abonnementstags", + "add-subscription-tag": "Tilføj abonnementstags", + "add-subscription-tag-prompt": "Tilføj venligst abonnementstags", + "key": "Nøgle", + "path": "Sti", + "required": "Påkrævet", + "integration-key": "Integrationsnøgle", + "copy-integration-key": "Kopiér integrationsnøgle", + "integration-key-copied-message": "Integrationsnøgle er blevet kopieret til udklipsholder", + "integration-secret": "Integration hemmelig", + "copy-integration-secret": "Kopiér integration hemmelig", + "integration-secret-copied-message": "Integration hemmelig er kopieret til udklipsholder", + "execute-remotely": "Fjernbetjent udførelse", + "handler-configuration": "Handler-konfiguration", + "handler-configuration-type": "Handler-type", + "so-broadcast": "Aktivér broadcast – integration accepterer adressepakker for broadcast", + "so-keepalive-option": "Muliggør afsendelse af keepalive-meddelelser på tilslutningsorienterede stik", + "so-reuse-addr": "Bind processen til en port", + "tcp-no-delay": "Tvinger et stik til at sende data uden buffer (deaktiver Nagle's bufferalgoritme)", + "fail-fast": "Undtagelse, når dekoderen bemærker, at rammelængden vil overstige maksimumstørrelse", + "strip-delimiter": "Fjern skilletegn", + "length-field-offset": "Feltforskydning for længde", + "length-field-offset-required": "Feltforskydning for længde er påkrævet.", + "length-field-offset-range": "Feltforskydning for længde skal ligge i et område fra 0 til 8.", + "length-field-length": "Længdefeltlængde", + "length-field-length-required": "Længdefeltlængde er påkrævet.", + "length-field-length-range": "Længdefeltlængde skal ligge i et område fra 0 til 8.", + "length-adjustment": "Længdejustering (kompenseringsværdien, der skal lægges til værdien af længdefeltet)", + "length-adjustment-required": "Længdejustering er påkrævet.", + "length-adjustment-range": "Længdejustering skal ligge i et område fra 0 til 8.", + "byte-order": "Byterækkefølge for længdefeltet", + "initial-bytes-to-strip": "Antal første bytes, der skal fjernes fra den afkodede ramme", + "initial-bytes-to-strip-required": "Antal første bytes, der skal fjernes fra den afkodede ramme, er påkrævet.", + "initial-bytes-to-strip-range": "Antal første bytes, der skal fjernes fra den afkodede ramme, skal ligge i området fra 0 til 8.", + "so-backlog-option": "Maks. antal ventende tilslutninger på stikket", + "so-backlog-option-required": "Maks. antal ventende tilslutninger på stikket er påkrævet.", + "so-backlog-option-range": "Maks. antal ventende tilslutninger på stikket skal ligge i området fra 1 til 65535.", + "so-rcv-buf": "Bufferens størrelse til indgående stik (i KB)", + "so-rcv-buf-required": "Bufferens størrelse til indgående stik (i KB) er påkrævet.", + "so-rcv-buf-range": "Bufferens størrelse til indgående stik (i KB) skal ligge i området fra 1 til 65535.", + "so-snd-buf": "Bufferens størrelse til udgående stik (i KB)", + "so-snd-buf-required": "Bufferens størrelse til udgående stik (i KB) er påkrævet.", + "so-snd-buf-range": "Bufferens størrelse til udgående stik (i KB) skal ligge i et område fra 1 til 65535.", + "charset-name": "Tegnsætnavn", + "charset-name-required": "Tegnsætnavn er påkrævet.", + "message-separator": "Meddelelsesseparator", + "message-separator-required": "Meddelelsesseparator er påkrævet.", + "character-sequence": "Tegnsekvens", + "character-sequence-required": "Tegnsekvens er påkrævet.", + "max-frame-length": "Maks. rammelængde (i bytes)", + "max-frame-length-required": "Maks. rammelængde (i bytes) er påkrævet.", + "max-frame-length-range": "Maks. rammelængde (i byte) skal ligge i området fra 1 til 65535.", + "handler-type": "Handler-type", + "message-size": "Meddelelsesstørrelse", + "message-size-required": "Meddelelsesstørrelse er påkrævet.", + "type-apache-pulsar": "Apache Pulsar", + "service-url": "Service-URL", + "service-url-required": "Service-URL er påkrævet.", + "subscription-name": "Abonnementsnavn", + "subscription-name-required": "Abonnementsnavn er påkrævet.", + "max-num-messages": "Maks. antal meddelelser", + "max-num-messages-required": "Maks. antal meddelelser er påkrævet.", + "max-num-bytes": "Maks. antal bytes", + "max-num-bytes-required": "Maks. antal bytes påkrævet.", + "timeout-in-ms": "Timeout i millisekunder", + "timeout-in-ms-required": "Timeout i millisekunder er påkrævet.", + "user-id": "Bruger-id", + "user-id-required": "Bruger-id er påkrævet.", + "token": "Token", + "token-required": "Token er påkrævet.", + "project-id": "Projekt-id", + "project-id-required": "Projekt-id er påkrævet.", + "subscription-id": "Abonnements-id", + "subscription-id-required": "Abonnements-id er påkrævet.", + "service-account-key": "Nøglefil til servicekonto", + "service-account-key-required": "Nøglefil til servicekonto er påkrævet.", + "tcp": { + "system-line-separator": "Systemlinjeseparator", + "nul-delimiter": "Nul-skilletegn", + "byte-order-little-endian": "Little Endian", + "byte-order-big-endian": "Big Endian" + }, + "cache-size": "Cachestørrelse", + "cache-time-to-live": "Cache time-to-live i minutter", + "min-cache-size": "Cachestørrelse kan ikke være lavere 0", + "min-cache-time-to-live": "Cache time-to-live kan ikke være lavere 0", + "max-cache-time-to-live": "Ugyldigt cache time-to-live, vælg mellem 0 og 525600" + }, + "item": { + "selected": "Valgt" + }, + "js-func": { + "no-return-error": "Funktionen skal returnere værdi!", + "return-type-mismatch": "", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Nøgle", + "value": "Værdi", + "remove-entry": "Fjern post", + "add-entry": "Tilføj post", + "no-data": "Ingen poster" + }, + "layout": { + "layout": "Layout", + "manage": "Administrer layouts", + "settings": "Layoutindstillinger", + "color": "Farve", + "main": "Hoved", + "right": "Højre", + "select": "Vælg mållayout" + }, + "legend": { + "direction": "Signaturforklaringens retning", + "position": "Signaturforklaringens placering", + "sort-legend": "Sortér datanøgler i signaturforklaring", + "show-max": "Vis maks. værdi", + "show-min": "Vis min. værdi", + "show-avg": "Vis gennemsnitsværdi", + "show-total": "Vis samlet værdi", + "settings": "Indstillinger for signaturforklaring", + "min": "min.", + "max": "maks.", + "avg": "gns.", + "total": "i alt", + "comparison-time-ago": { + "previousInterval": "(tidligere interval)", + "days": "(dag siden)", + "weeks": "(uge siden)", + "months": "(måned siden)", + "years": "(år siden)" + } + }, + "login": { + "login": "Log på", + "request-password-reset": "Anmod om nulstilling af adgangskode", + "reset-password": "Nulstil adgangskode", + "create-password": "Opret adgangskode", + "passwords-mismatch-error": "De indtastede adgangskoder skal være ens!", + "password-again": "Gentag adgangskode", + "sign-in": "Log på", + "username": "Brugernavn (e-mail)", + "remember-me": "Husk mig", + "forgot-password": "Glemt adgangskode?", + "password-reset": "Nulstilling af adgangskode", + "expired-password-reset-message": "Dine brugeroplysninger er udløbet! Opret venligst en ny adgangskode.", + "new-password": "Ny adgangskode", + "new-password-again": "Gentag ny adgangskode", + "password-link-sent-message": "Link til nulstilling af adgangskode blev sendt!", + "email": "E-mail", + "no-account": "Har du ikke en konto?", + "create-account": "Opret en konto", + "login-with": "Log ind med din e-mail", + "or": "eller", + "error": "Login-fejl" + }, + "signup": { + "firstname": "Fornavn", + "lastname": "Efternavn", + "email": "E-mail", + "signup": "Tilmeld", + "create-password": "Opret en adgangskode", + "repeat-password": "Gentag din adgangskode", + "have-account": "Har du allerede en konto?", + "signin": "Log på", + "no-captcha-message": "Du skal bekræfte, at du ikke er en robot", + "password-length-message": "Din adgangskode skal bestå af mindst 6 tegn", + "email-verification": "E-mailbekræftelse", + "email-verification-message": "Der er sendt en e-mail med bekræftelsesoplysninger til den angivne e-mailadresse.
    Følg instruktionerne i e-mailen for at fuldføre din tilmeldingsprocedure.
    Bemærk: Hvis du ikke har set e-mailen efter et stykke tid, skal du tjekke din 'spam'-mappe eller forsøge at sende e-mailen igen ved at klikke på knappen 'Send igen'.", + "account-activation-title": "Aktivering af konto", + "account-activated": "Kontoen er aktiveret!", + "account-activated-text": "Tillykke!
    Din konto er blevet aktiveret.", + "resend": "Send igen", + "inactive-user-exists-title": "Inaktiv bruger findes allerede", + "inactive-user-exists-text": "Der er allerede registreret en bruger med en ikke-bekræftet e-mailadresse.
    Klik på knappen \"Send igen\", hvis du ønsker at sende bekræftelses-e-mailen igen.", + "activating-account": "Aktiverer konto...", + "activating-account-text": "Din konto er i øjeblikket ved at blive aktiveret. Vent venligst...", + "accept-privacy-policy": "Accepter fortrolighedspolitik", + "accept": "Accepter", + "privacy-policy": "Fortrolighedspolitik" + }, + "position": { + "top": "Top", + "bottom": "Bund", + "left": "Venstre", + "right": "Højre" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Sidste login", + "change-password": "Skift adgangskode", + "current-password": "Nuværende adgangskode" + }, + "relation": { + "relations": "Relationer", + "direction": "Retning", + "search-direction": { + "FROM": "Fra", + "TO": "Til" + }, + "direction-type": { + "FROM": "fra", + "TO": "til" + }, + "from-relations": "Udgående relationer", + "to-relations": "Indgående relationer", + "selected-relations": "", + "type": "Type", + "to-entity-type": "Til entitetstype", + "to-entity-name": "Til entitetsnavn", + "from-entity-type": "Fra entitetstype", + "from-entity-name": "Fra entitetsnavn", + "to-entity": "Til entitet", + "from-entity": "Fra entitet", + "delete": "Slet relation", + "relation-type": "Relationstype", + "relation-type-required": "Relationstype er påkrævet.", + "any-relation-type": "Enhver type", + "add": "Tilføj relation", + "edit": "Rediger relation", + "delete-to-relation-title": "", + "delete-to-relation-text": "", + "delete-to-relations-title": "", + "delete-to-relations-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte relationer blive fjernet, og tilsvarende entiteter vil ikke være relaterede til den aktuelle entitet.", + "delete-from-relation-title": "", + "delete-from-relation-text": "", + "delete-from-relations-title": "", + "delete-from-relations-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte relationer blive fjernet, og den aktuelle entitet vil ikke være relateret til de tilsvarende entiteter.", + "remove-relation-filter": "Fjern relationsfilter", + "add-relation-filter": "Tilføj relationsfilter", + "any-relation": "Enhver relation", + "relation-filters": "Relationsfiltre", + "additional-info": "Yderligere info (JSON)", + "invalid-additional-info": "Kan ikke parse yderligere info-json.", + "no-relations-text": "Ingen relationer fundet" + }, + "rulechain": { + "rulechain": "Regelkæde", + "rulechains": "Regelkæder", + "root": "Rod", + "delete": "Slet regelkæde", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "add": "Tilføj regelkæde", + "set-root": "Lav regelkæde for rod", + "set-root-rulechain-title": "", + "set-root-rulechain-text": "Efter bekræftelsen vil regelkæden være rod og håndtere alle indgående transportmeddelelser.", + "delete-rulechain-title": "", + "delete-rulechain-text": "Vær forsigtig. Efter bekræftelsen vil regelkæden og alle relaterede data være uoprettelige.", + "delete-rulechains-title": "", + "delete-rulechains-action-title": "", + "delete-rulechains-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte regelkæder blive fjernet, og alle relaterede data vil være uoprettelige.", + "add-rulechain-text": "Tilføj ny regelkæde", + "no-rulechains-text": "Ingen regelkæder fundet", + "rulechain-details": "Oplysninger om regelkæde", + "details": "Oplysninger", + "events": "Begivenheder", + "system": "System", + "import": "Importér regelkæde", + "export": "Eksportér regelkæde", + "export-failed-error": "", + "create-new-rulechain": "Opret ny regelkæde", + "rulechain-file": "Regelkædefil", + "invalid-rulechain-file-error": "Regelkæden kunne ikke importeres: Ugyldig datastruktur for regelkæde.", + "copyId": "Kopiér regelkæde-id", + "idCopiedMessage": "Regelkæde-id er blevet kopieret til udklipsholder", + "select-rulechain": "Vælg regelkæde", + "no-rulechains-matching": "", + "rulechain-required": "Regelkæde er påkrævet", + "management": "Regelstyring", + "debug-mode": "Debug-tilstand", + "search": "Søg efter regelkæder", + "selected-rulechains": "", + "open-rulechain": "Åbn regelkæde" + }, + "rulenode": { + "details": "Oplysninger", + "events": "Begivenheder", + "search": "Søg efter knuder", + "open-node-library": "Åbn knudebibliotek", + "add": "Tilføj regelknude", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "type": "Type", + "description": "Beskrivelse", + "delete": "Slet regelknude", + "select-all-objects": "Vælg alle knuder og tilslutninger", + "deselect-all-objects": "Fravælg alle knuder og tilslutninger", + "delete-selected-objects": "Slet valgte knuder og tilslutninger", + "delete-selected": "Slet valgte", + "select-all": "Vælg alle", + "copy-selected": "Kopiér valgte", + "deselect-all": "Fravælg alle", + "rulenode-details": "Oplysninger om regelknude", + "debug-mode": "Debug-tilstand", + "configuration": "Konfiguration", + "link": "Link", + "link-details": "Oplysninger om regelknudelink", + "add-link": "Tilføj link", + "link-label": "Link-etiket", + "link-label-required": "Link-etiket er påkrævet.", + "custom-link-label": "Brugerdefineret link-etiket", + "custom-link-label-required": "Brugerdefineret link-etiket er påkrævet.", + "link-labels": "Link-etiketter", + "link-labels-required": "Link-etiketter er påkrævet.", + "no-link-labels-found": "Ingen link-etiketter fundet", + "no-link-label-matching": "", + "create-new-link-label": "Opret en ny!", + "type-filter": "Filter", + "type-filter-details": "Filtrer indgående meddelelser med konfigurerede betingelser", + "type-enrichment": "Berigelse", + "type-enrichment-details": "Tilføj yderligere oplysninger i meddelelsesmetadata", + "type-transformation": "Omdannelse", + "type-transformation-details": "Skift meddelelsesdata og metadata", + "type-action": "Handling", + "type-action-details": "Gennemfør ekstrahandling", + "type-analytics": "Analytik", + "type-analytics-details": "Udfør analyse af streamede eller vedvarende data", + "type-external": "Ekstern", + "type-external-details": "Interagerer med eksternt system", + "type-rule-chain": "Regelkæde", + "type-rule-chain-details": "Videresender indgående meddelelser til specificeret regelkæde", + "type-input": "Input", + "type-input-details": "Logisk input af regelkæde, videresender indgående meddelelser til næste relaterede regelknude", + "type-unknown": "Ukendt", + "type-unknown-details": "Uløst regelknude", + "directive-is-not-loaded": "", + "ui-resources-load-error": "Kunne ikke indlæse konfigurations-UI-ressourcer.", + "invalid-target-rulechain": "Kan ikke løse målregelkæde!", + "test-script-function": "Testscriptfunktion", + "message": "Meddelelse", + "message-type": "Meddelelsestype", + "select-message-type": "Vælg meddelelsestype", + "message-type-required": "Meddelelsestype er påkrævet", + "metadata": "Metadata", + "metadata-required": "Metadataposter må ikke være tomme.", + "output": "Output", + "test": "Test", + "help": "Hjælp", + "reset-debug-mode": "Nulstil debug-tilstand i alle knuder" + }, + "role": { + "role": "Rolle", + "roles": "Roller", + "management": "Rollestyring", + "view-roles": "Vis roller", + "no-roles-matching": "", + "role-list": "Rolleliste", + "add": "Tilføj rolle", + "view": "Vis rolle", + "search": "Søg efter roller", + "selected-roles": "", + "no-roles-text": "Ingen roller fundet", + "role-details": "Rolleoplysninger", + "add-role-text": "Tilføj ny rolle", + "delete": "Slet rolle", + "delete-roles": "Slet roller", + "delete-role-title": "", + "delete-role-text": "Vær forsigtig. Efter bekræftelsen vil rollen og alle relaterede data være uoprettelige.", + "delete-roles-title": "", + "delete-roles-action-title": "", + "delete-roles-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte roller blive fjernet, og alle relaterede data vil være uoprettelige.", + "role-type": "Rolletype", + "role-type-required": "Rolletype er påkrævet.", + "select-role-type": "Vælg rolletype", + "enter-role-type": "Indtast rolletype", + "any-role": "Enhver rolle", + "no-role-types-matching": "", + "role-type-list-empty": "Ingen rolletyper valgt.", + "role-types": "Rolletyper", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "description": "Beskrivelse", + "events": "Begivenheder", + "details": "Oplysninger", + "copyId": "Kopiér rolle-id", + "idCopiedMessage": "Rolle-id er blevet kopieret til udklipsholder", + "permissions": "Tilladelser", + "role-required": "Rolle påkrævet", + "roles-required": "Roller påkrævet", + "display-type": { + "GENERIC": "Generisk", + "GROUP": "Gruppe" + } + }, + "group-permission": { + "user-group-roles": "Brugergrupperoller", + "entity-group-permissions": "Tilladelser for entitetsgruppe", + "role-type": "Rolletype", + "role-name": "Rollenavn", + "group-type": "Gruppetype", + "group-name": "Gruppenavn", + "group-owner": "Gruppeejer", + "user-group-name": "Brugergruppenavn", + "user-group-owner": "Brugergruppeejer", + "edit": "Rediger tilladelser", + "delete": "Slet tilladelser", + "selected-group-permissions": "", + "delete-group-permission-title": "", + "delete-group-permission-text": "Vær forsigtig. Efter bekræftelsen vil gruppetilladelsen og alle relaterede data være uoprettelige.", + "delete-group-permission": "Slet gruppetilladelse", + "delete-group-permissions-title": "", + "delete-group-permissions-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte gruppetilladelser blive fjernet, og tilsvarende brugere vil miste adgang til specificerede ressourcer.", + "delete-group-permissions": "Slet gruppetilladelser", + "add-group-permission": "Tilføj gruppetilladelse", + "edit-group-permission": "Rediger gruppetilladelse", + "entity-group": "Entitetsgruppe", + "user-group": "Brugergruppe", + "no-owners-matching": "", + "target-owner-required": "Entitetsgruppeejer er påkrævet.", + "target-user-group-owner-required": "Brugergruppeejer er påkrævet.", + "no-group-permissions-text": "Ingen gruppetilladelser fundet" + }, + "permission": { + "permissions-required": "Mindst én tilladelsespost skal angives.", + "remove-permission": "Fjern tilladelsespost", + "add-permission": "Tilføj tilladelsespost", + "other": "Andet", + "resource": { + "resource": "Ressource", + "select-resource": "Vælg ressource", + "resource-required": "Ressource er påkrævet", + "no-resources-matching": "", + "display-type": { + "ALL": "Alle", + "PROFILE": "Profil", + "ADMIN_SETTINGS": "Admin-indstillinger", + "ALARM": "Alarm", + "DEVICE": "Enhed", + "DEVICE_PROFILE": "Enhedsprofil", + "ASSET": "Aktiv", + "CUSTOMER": "Kunde", + "DASHBOARD": "Dashboard", + "ENTITY_VIEW": "Entitetsvisning", + "TENANT": "Lejer", + "TENANT_PROFILE": "Lejerprofil", + "RULE_CHAIN": "Regelkæde", + "USER": "Bruger", + "WIDGETS_BUNDLE": "Widgets-bundt", + "WIDGET_TYPE": "Widget-type", + "CONVERTER": "Omformer", + "INTEGRATION": "Integration", + "SCHEDULER_EVENT": "Planlægningsbegivenheder", + "BLOB_ENTITY": "Blob-entitet", + "CUSTOMER_GROUP": "Kundegruppe", + "DEVICE_GROUP": "Enhedsgruppe", + "ASSET_GROUP": "Aktivgruppe", + "USER_GROUP": "Brugergruppe", + "ENTITY_VIEW_GROUP": "Entitetsvisningsgruppe", + "DASHBOARD_GROUP": "Dashboardgruppe", + "ROLE": "Rolle", + "GROUP_PERMISSION": "Gruppetilladelse", + "WHITE_LABELING": "Hvid mærkning", + "AUDIT_LOG": "Auditlog", + "API_USAGE_STATE": "API-brugstilstand" + } + }, + "operation": { + "operation": "Driftsopgave", + "operations": "Driftsopgaver", + "operations-required": "Der skal angives mindst én driftsopgave.", + "enter-operation": "Indtast driftsopgave.", + "no-operations-matching": "", + "display-type": { + "ALL": "Alle", + "CREATE": "Opret", + "READ": "Læs", + "WRITE": "Skriv", + "DELETE": "Slet", + "ASSIGN_TO_CUSTOMER": "Tildel til kunde", + "UNASSIGN_FROM_CUSTOMER": "Fjern tildeling fra kunde", + "RPC_CALL": "RPC-opkald", + "READ_CREDENTIALS": "Læs brugeroplysninger", + "WRITE_CREDENTIALS": "Skriv brugeroplysninger", + "READ_ATTRIBUTES": "Læs attributter", + "WRITE_ATTRIBUTES": "Skriv attributter", + "READ_TELEMETRY": "Læs telemetri", + "WRITE_TELEMETRY": "Skriv telemetri", + "CLAIM_DEVICES": "Gør krav på enheder", + "IMPERSONATE": "Efterlign", + "CHANGE_OWNER": "Skift ejer", + "ADD_TO_GROUP": "Tilføj til gruppe", + "REMOVE_FROM_GROUP": "Fjern fra gruppe", + "SHARE_GROUP": "Del gruppe", + "ASSIGN_TO_TENANT": "Tildel til lejer" + } + } + }, + "scheduler": { + "scheduler": "Planlægger", + "scheduler-event": "Planlægningsbegivenhed", + "select-scheduler-event": "Vælg planlægningsbegivenhed", + "no-scheduler-events-matching": "", + "scheduler-event-required": "Planlægningsbegivenhed er påkrævet", + "management": "Tidsplansstyring", + "scheduler-events": "Planlægningsbegivenheder", + "add-scheduler-event": "Tilfjøj planlægningsbegivenhed", + "search-scheduler-events": "Søg efter planlægningsbegivenheder", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "type": "Type", + "created_customer": "Oprettet af kunde", + "edit-scheduler-event": "Rediger planlægningsbegivenhed", + "view-scheduler-event": "Vis planlægningsbegivenhed", + "delete-scheduler-event": "Slet planlægningsbegivenhed", + "no-scheduler-events": "Ingen planlægningsbegivenheder fundet", + "selected-scheduler-events": "", + "delete-scheduler-event-title": "", + "delete-scheduler-event-text": "Vær forsigtig. Efter bekræftelsen vil planlægningsbegivenheden og alle relaterede data være uoprettelige.", + "delete-scheduler-events-title": "", + "delete-scheduler-events-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte planlægningsbegivenheder blive fjernet, og alle relaterede data vil være uoprettelige.", + "create": "Opret planlægningsbegivenhed", + "edit": "Rediger planlægningsbegivenhed", + "view": "Vis planlægningsbegivenhed", + "name-required": "Navn er påkrævet.", + "configuration": "Konfiguration", + "schedule": "Tidsplan", + "start-time": "Starttid", + "repeat": "Gentag", + "repeats": "Gentagelser", + "daily": "Daglig", + "weekly": "Ugentlig", + "monthly": "Månedlig", + "yearly": "Årlig", + "timer": "Timerbaseret", + "repeats-required": "Gentagelser er påkrævet.", + "repeat-on": "Gentag den", + "repeat-every": "Gentag hver", + "ends-on": "Slutter den", + "sunday-label": "L", + "monday-label": "M", + "tuesday-label": "T", + "wednesday-label": "O", + "thursday-label": "T", + "friday-label": "F", + "saturday-label": "L", + "repeat-on-sunday": "Gentag på søndag", + "repeat-on-monday": "Gentag på mandag", + "repeat-on-tuesday": "Gentag på tirsdag", + "repeat-on-wednesday": "Gentag på onsdag", + "repeat-on-thursday": "Gentag på torsdag", + "repeat-on-friday": "Gentag på fredag", + "repeat-on-saturday": "Gentag på lørdag", + "event-type": "Begivenhedstype", + "select-event-type": "Vælg begivenhedstype", + "event-type-required": "Begivenhedstype er påkrævet.", + "list-mode": "Listevisning", + "calendar-mode": "Kalendervisning", + "calendar-view-type": "Kalendervisningstype", + "month": "Måned", + "week": "Uge", + "day": "Dag", + "agenda-week": "Program for uge", + "agenda-day": "Program for dag", + "list-year": "Liste for år", + "list-month": "Liste for måned", + "list-week": "Liste for uge", + "list-day": "Liste for dag", + "today": "I dag", + "navigate-before": "Naviger før", + "navigate-next": "Naviger næste", + "starting-from": "Starter fra", + "until": "til og med", + "on": "den", + "sunday": "Søndag", + "monday": "Mandag", + "tuesday": "Tirsdag", + "wednesday": "Onsdag", + "thursday": "Torsdag", + "friday": "Fredag", + "saturday": "Lørdag", + "originator": "Ophavsmand", + "single-entity": "Enkelt entitet", + "group-of-entities": "Gruppe af entiteter", + "entities-group-owner": "Ejer af gruppe af entiteter", + "single-device": "Enkelt enhed", + "group-of-devices": "Gruppe af enheder", + "devices-group-owner": "Ejer af gruppe af enheder", + "message-body": "Meddelelsestekst", + "target": "Mål", + "rpc-method": "Metode", + "rpc-method-required": "Metode er påkrævet", + "rpc-params": "Parametre", + "select-dashboard-state": "Vælg dashboardtilstand", + "hours": "Timer", + "minutes": "Minutter", + "seconds": "Sekunder", + "time-interval-required": "Tidsinterval er påkrævet", + "time-unit-required": "Tidsenhed er påkrævet", + "every-hour": "", + "every-minute": "", + "every-second": "" + }, + "report": { + "report-config": "Rapportkonfiguration", + "email-config": "E-mailkonfiguration", + "dashboard-state-param": "Parameterværdi for dashboardtilstand", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL er påkrævet.", + "use-dashboard-timewindow": "Brug dashboardtidsvindue", + "timewindow": "Tidsvindue", + "name-pattern": "Rapportnavnsmønster", + "name-pattern-required": "Rapportnavnsmønster er påkrævet", + "type": "Rapporttype", + "use-current-user-credentials": "Brug aktuelle brugeroplysninger", + "customer-user-credentials": "Kundens brugeroplysninger", + "customer-user-credentials-required": "Kundens brugeroplysninger er påkrævet", + "generate-test-report": "Generer testrapport", + "send-email": "Send e-mail", + "from": "Fra", + "from-required": "Fra er påkrævet.", + "to": "Til", + "to-required": "Til er påkrævet.", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Emne", + "subject-required": "Emne er påkrævet.", + "body": "Tekst", + "body-required": "Tekst er påkrævet." + }, + "blob-entity": { + "blob-entity": "Blob-entitet", + "select-blob-entity": "Vælg blob-entitet", + "no-blob-entities-matching": "", + "blob-entity-required": "Blob-entitet er påkrævet", + "files": "Filer", + "search": "Søg efter filer", + "clear-search": "Ryd søgning", + "no-blob-entities-prompt": "Ingen filer fundet", + "report": "Rapport", + "created-time": "Oprettelsestidspunkt", + "name": "Navn", + "type": "Type", + "created_customer": "Oprettet af kunde", + "selected-blob-entities": "", + "download-blob-entity": "Download fil", + "delete-blob-entity": "Slet fil", + "delete-blob-entity-title": "", + "delete-blob-entity-text": "Vær forsigtig. Efter bekræftelsen vil fildata være uoprettelige.", + "delete-blob-entities-title": "", + "delete-blob-entities-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte filer blive fjernet, og alle relaterede data vil være uoprettelige." + }, + "timezone": { + "timezone": "Tidszone", + "select-timezone": "Vælg tidszone", + "no-timezones-matching": "", + "timezone-required": "Tidszone er påkrævet.", + "browser-time": "Browsertid" + }, + "queue": { + "select_name": "Vælg kønavn", + "name": "Kønavn", + "name_required": "Kønavn er påkrævet." + }, + "tenant": { + "tenant": "Lejer", + "tenants": "Lejere", + "management": "Administration af lejer", + "add": "Tilføj lejer", + "admins": "Admin.", + "manage-tenant-admins": "Administrer lejeradmin.", + "delete": "Slet lejer", + "add-tenant-text": "Tilføj ny lejer", + "no-tenants-text": "Ingen lejere fundet", + "tenant-details": "Lejeroplysninger", + "delete-tenant-title": "", + "delete-tenant-text": "Vær forsigtig. Efter bekræftelsen vil lejeren og alle relaterede data være uoprettelige.", + "delete-tenants-title": "", + "delete-tenants-action-title": "", + "delete-tenants-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte lejere blive fjernet, og alle relaterede data vil være uoprettelige.", + "title": "Titel", + "title-required": "Titel er påkrævet.", + "description": "Beskrivelse", + "details": "Oplysninger", + "events": "Begivenheder", + "copyId": "Kopiér lejer-id", + "idCopiedMessage": "Lejer-id er blevet kopieret til udklipsholder", + "select-tenant": "Vælg lejer", + "no-tenants-matching": "", + "tenant-required": "Lejer er påkrævet", + "selected-tenants": "", + "search": "Søg efter lejere", + "allow-white-labeling": "Tillad hvid mærkning", + "allow-customer-white-labeling": "Tillad hvid mærkning af kunder", + "isolated-tb-core": "Behandling i isoleret ThingsBoard Core-beholder", + "isolated-tb-rule-engine": "Behandling i isoleret ThingsBoard regelmotor-beholder", + "isolated-tb-core-details": "Kræver separat(e) microserviceydelse(r) pr. isoleret lejer", + "isolated-tb-rule-engine-details": "Kræver separat(e) microserviceydelse(r) pr. isoleret lejer" + }, + "tenant-profile": { + "tenant-profile": "Lejerprofil", + "tenant-profiles": "Lejerprofiler", + "add": "Tilføj lejerprofil", + "edit": "Rediger lejerprofil", + "tenant-profile-details": "Oplysninger om lejerprofil", + "no-tenant-profiles-text": "Ingen lejerprofiler fundet", + "search": "Søg efter lejerprofiler", + "selected-tenant-profiles": "", + "no-tenant-profiles-matching": "", + "tenant-profile-required": "Lejerprofil er påkrævet", + "idCopiedMessage": "Lejerprofil-id er blevet kopieret til udklipsholder", + "set-default": "Gør lejerprofil til standard", + "delete": "Slet lejerprofil", + "copyId": "Kopiér lejerprofil-id", + "name": "Navn", + "name-required": "Navn er påkrævet.", + "data": "Profildata", + "profile-configuration": "Profilkonfiguration", + "description": "Beskrivelse", + "default": "Standard", + "delete-tenant-profile-title": "", + "delete-tenant-profile-text": "Vær forsigtig. Efter bekræftelsen vil lejerprofilen og alle relaterede data være uoprettelige.", + "delete-tenant-profiles-title": "", + "delete-tenant-profiles-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte lejerprofiler blive fjernet, og alle relaterede data vil være uoprettelige.", + "set-default-tenant-profile-title": "", + "set-default-tenant-profile-text": "Efter bekræftelsen vil lejerprofilen blive markeret som standard og vil blive brugt til nye lejere, hvor der ikke er angivet nogen profil.", + "no-tenant-profiles-found": "Ingen lejerprofiler fundet.", + "create-new-tenant-profile": "Opret en ny!", + "maximum-devices": "Maks. antal enheder (0 – ubegrænset)", + "maximum-devices-required": "Maks. antal enheder er påkrævet.", + "maximum-devices-range": "Min. antal enheder kan ikke være negativt", + "maximum-assets": "Maks. antal aktiver (0 – ubegrænset)", + "maximum-assets-required": "Maks. antal aktiver er påkrævet.", + "maximum-assets-range": "Maks. antal aktiver kan ikke være negativt", + "maximum-customers": "Maks. antal kunder (0 – ubegrænset)", + "maximum-customers-required": "Maks. antal kunder er påkrævet.", + "maximum-customers-range": "Maks. antal kunder kan ikke være negativt", + "maximum-users": "Maks. antal brugere (0 – ubegrænset)", + "maximum-users-required": "Maks. antal brugere er påkrævet.", + "maximum-users-range": "Maks. antal brugere kan ikke være negativt", + "maximum-dashboards": "Maks. antal dashboards (0 – ubegrænset)", + "maximum-dashboards-required": "Maks. antal dashboards er påkrævet.", + "maximum-dashboards-range": "Maks. antal dashboards kan ikke være negativt", + "maximum-rule-chains": "Maks. antal regelkæder (0 – ubegrænset)", + "maximum-rule-chains-required": "Maks. antal regelkæder er påkrævet.", + "maximum-rule-chains-range": "Maks. antal regelkæder kan ikke være negativt", + "maximum-integrations": "Maks. antal integrationer (0 – ubegrænset)", + "maximum-integrations-required": "Maks. antal integrationer er påkrævet.", + "maximum-integrations-range": "Maks. antal integrationer kan ikke være negativt", + "maximum-converters": "Maks. antal omformere (0 – ubegrænset)", + "maximum-converters-required": "Maks. antal omformere er påkrævet.", + "maximum-converters-range": "Maks. antal omformere kan ikke være negativt", + "maximum-scheduler-events": "Maks. antal planlægningsbegivenheder (0 – ubegrænset)", + "maximum-scheduler-events-required": "Maks. antal planlægningsbegivenheder er påkrævet.", + "maximum-scheduler-events-range": "Maks. antal planlægningsbegivenheder kan ikke være negativt", + "transport-tenant-msg-rate-limit": "Hastighedsgrænse for transport af lejermeddelelser.", + "transport-tenant-telemetry-msg-rate-limit": "Hastighedsgrænse for transport af lejertelemetrimeddelelser.", + "transport-tenant-telemetry-data-points-rate-limit": "Hastighedsgrænse for transport af lejertelemetridatapunkter.", + "transport-device-msg-rate-limit": "Hastighedsgrænse for transport af enhedsmeddelelser.", + "transport-device-telemetry-msg-rate-limit": "Hastighedsgrænse for transport af enhedstelemetrimeddelelser.", + "transport-device-telemetry-data-points-rate-limit": "Hastighedsgrænse for transport af enhedstelemetridatapunkter.", + "max-transport-messages": "Maks. antal transportmeddelelser (0 – ubegrænset)", + "max-transport-messages-required": "Maks. antal transportmeddelelser er påkrævet.", + "max-transport-messages-range": "Maks. antal transportmeddelelser kan ikke være negativt", + "max-transport-data-points": "Maks. antal transportdatapunkter (0 – ubegrænset)", + "max-transport-data-points-required": "Maks. antal transportdatapunkter er påkrævet.", + "max-transport-data-points-range": "Maks. antal transportdatapunkter kan ikke være negativt", + "max-r-e-executions": "Maks. antal regelmotor-udførelser (0 – ubegrænset)", + "max-r-e-executions-required": "Maks. antal regelmotor-udførelser er påkrævet.", + "max-r-e-executions-range": "Maks. antal regelmotor-udførelser kan ikke være negativt", + "max-j-s-executions": "Maks. antal JavaScript-udførelser (0 – ubegrænset)", + "max-j-s-executions-required": "Maks. antal JavaScript-udførelser er påkrævet.", + "max-j-s-executions-range": "Maks. antal JavaScript-udførelser kan ikke være negativt", + "max-d-p-storage-days": "Maks. antal lagringsdage for datapunkter (0 – ubegrænset)", + "max-d-p-storage-days-required": "Maks. antal lagringsdage for datapunkter er påkrævet.", + "max-d-p-storage-days-range": "Maks. antal lagringsdage for datapunkter kan ikke være negativt", + "default-storage-ttl-days": "Standard lagringsdage for TTL (0 – ubegrænset)", + "default-storage-ttl-days-required": "Standard lagringsdage for TTL er påkrævet.", + "default-storage-ttl-days-range": "Standard lagringsdage for TTL kan ikke være negative", + "max-rule-node-executions-per-message": "Maks. antal regelknudeudførelser pr. meddelelse (0 – ubegrænset)", + "max-rule-node-executions-per-message-required": "Maks. antal regelknudeudførelser pr. meddelelse er påkrævet.", + "max-rule-node-executions-per-message-range": "Maks. antal regelknudeudførelser pr. meddelelse kan ikke være negativt", + "max-emails": "Maks. antal sendte e-mails (0 – ubegrænset)", + "max-emails-required": "Maks. antal sendte e-mails er påkrævet.", + "max-emails-range": "Maks. antal sendte e-mails kan ikke være negativt", + "max-sms": "Maks. antal sendte SMS'er (0 – ubegrænset)", + "max-sms-required": "Maks. antal sendte SMS'er er påkrævet.", + "max-sms-range": "Maks. antal sendte SMS'er kan ikke være negativt" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 sekund} other {# sekunder} }", + "minutes-interval": "{ minutes, plural, 1 {1 minut} other {# minutter} }", + "hours-interval": "{ hours, plural, 1 {1 time} other {# timer} }", + "days-interval": "{ days, plural, 1 {1 dag} other {# dage} }", + "days": "Dage", + "hours": "Timer", + "minutes": "Minutter", + "seconds": "Sekunder", + "advanced": "Fremskreden", + "predefined": { + "yesterday": "I går", + "day-before-yesterday": "Dagen før i går", + "this-day-last-week": "Denne dag i sidste uge", + "previous-week": "Forrige uge (søn – lør)", + "previous-week-iso": "Forrige uge (man – søn)", + "previous-month": "Forrige måned", + "previous-year": "Forrige år", + "current-hour": "Aktuel time", + "current-day": "Aktuel dag", + "current-day-so-far": "Aktuel dag indtil nu", + "current-week": "Aktuel uge (søn – lør)", + "current-week-iso": "Aktuel uge (man – søn)", + "current-week-so-far": "Aktuel uge indtil videre (søn – lør)", + "current-week-iso-so-far": "Aktuel uge indtil videre (man– søn)", + "current-month": "Aktuel måned", + "current-month-so-far": "Aktuel måned indtil nu", + "current-year": "Aktuelt år", + "current-year-so-far": "Aktuelt år indtil nu" + } + }, + "timeunit": { + "seconds": "Sekunder", + "minutes": "Minutter", + "hours": "Timer", + "days": "Dage" + }, + "timewindow": { + "days": "{ days, plural, 1 { dag } other {# dage } }", + "hours": "{ hours, plural, 0 { time } 1 {1 time } other {# timer } }", + "minutes": "{ minutes, plural, 0 { minut } 1 {1 minut } other {# minutter } }", + "seconds": "{ seconds, plural, 0 { sekund } 1 {1 sekund } other {# sekunder } }", + "realtime": "Realtid", + "history": "Historie", + "last-prefix": "sidst", + "period": "fra {{ startTime }} til {{ endTime }}", + "edit": "Rediger tidsvindue", + "date-range": "Datointerval", + "last": "Sidst", + "time-period": "Tidsperiode", + "hide": "Skjul", + "interval": "Interval" + }, + "user": { + "user": "Bruger", + "users": "Brugere", + "management": "Brugeradministration", + "customer-users": "Kundebrugere", + "tenant-admins": "Lejeradmin.", + "sys-admin": "Systemadministrator", + "tenant-admin": "Bruger", + "customer": "Kunde", + "anonymous": "Anonym", + "add": "Tilføj bruger", + "delete": "Slet bruger", + "add-user-text": "Tilføj ny bruger", + "no-users-text": "Ingen brugere fundet", + "user-details": "Brugerdetaljer", + "delete-users": "Slet brugere", + "delete-user-title": "", + "delete-user-text": "Vær forsigtig. Efter bekræftelsen vil brugeren og alle relaterede data være uoprettelige.", + "delete-users-title": "", + "delete-users-action-title": "", + "delete-users-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte brugere blive fjernet, og alle relaterede data vil være uoprettelige.", + "activation-email-sent-message": "Aktiverings-e-mail blev sendt!", + "resend-activation": "Gensend aktivering", + "email": "E-mail", + "email-required": "E-mail er påkrævet.", + "invalid-email-format": "Ugyldigt e-mailformat.", + "first-name": "Fornavn", + "last-name": "Efternavn", + "description": "Beskrivelse", + "default-dashboard": "Standard dashboard", + "always-fullscreen": "Altid fuld skærm", + "select-user": "Vælg bruger", + "no-users-matching": "", + "user-required": "Bruger er påkrævet", + "activation-method": "Aktiveringsmetode", + "display-activation-link": "Vis aktiveringslink", + "send-activation-mail": "Send aktiverings-e-mail", + "activation-link": "Link til brugeraktivering", + "activation-link-text": "", + "copy-activation-link": "Kopiér aktiveringslink", + "activation-link-copied-message": "Brugeraktiveringslinket er blevet kopieret til udklipsholderen", + "selected-users": "", + "search": "Søg efter brugere", + "details": "Oplysninger", + "login-as-tenant-admin": "Log ind som lejeradministrator", + "login-as-customer-user": "Log ind som kundebruger", + "select-group-to-add": "Vælg målgruppe for at tilføje valgte brugere", + "select-group-to-move": "Vælg målgruppe for at flytte valgte brugere", + "remove-users-from-group": "", + "group": "Gruppe af brugere", + "list-of-groups": "", + "group-name-starts-with": "", + "disable-account": "Deaktiver brugerkonto", + "enable-account": "Aktivér brugerkonto", + "enable-account-message": "Brugerkontoen er blevet aktiveret!", + "disable-account-message": "Brugerkontoen blev deaktiveret!" + }, + "value": { + "type": "Værditype", + "string": "Streng", + "string-value": "Strengværdi", + "string-value-required": "Strengværdi er påkrævet", + "integer": "Heltal", + "integer-value": "Heltalsværdi", + "integer-value-required": "Heltalsværdi er påkrævet", + "invalid-integer-value": "Ugyldig heltalsværdi", + "double": "Dobbelt", + "double-value": "Dobbelt værdi", + "double-value-required": "Dobbelt værdi er påkrævet", + "boolean": "Boolesk", + "boolean-value": "Boolesk værdi", + "false": "FALSK", + "true": "SANDT", + "long": "Lang", + "json": "JSON", + "json-value": "JSON-værdi", + "json-value-invalid": "JSON-værdi har et ugyldigt format", + "json-value-required": "JSON-værdi er påkrævet." + }, + "widget": { + "widget-library": "Widgets-bibliotek", + "widget-bundle": "Widgets-bundt", + "all-bundles": "Alle bundter", + "select-widgets-bundle": "Vælg widgets-bundt", + "management": "Widget-administration", + "editor": "Widget-editor", + "widget-type-not-found": "Problem under indlæsning af widget-konfiguration.
    Sandsynligvis tilknyttet\r\n widget-type blev fjernet.", + "widget-type-load-error": "Widget blev ikke indlæst pga. følgende fejl:", + "remove": "Fjern widget", + "edit": "Rediger widget", + "remove-widget-title": "", + "remove-widget-text": "Efter bekræftelsen vil widgeten og alle relaterede data være uoprettelige.", + "timeseries": "Tidsserier", + "search-data": "Søg efter data", + "no-data-found": "Ingen data fundet", + "latest": "Seneste værdier", + "rpc": "Styring af widget", + "alarm": "Alarm-widget", + "static": "Statisk widget", + "select-widget-type": "Vælg widget-type", + "missing-widget-title-error": "Widget-titel skal angives!", + "widget-saved": "Widget gemt", + "unable-to-save-widget-error": "Widget kunne ikke gemmes! Widget har fejl!", + "save": "Gem widget", + "saveAs": "Gem widget som", + "save-widget-type-as": "Gem widget-type som", + "save-widget-type-as-text": "Indtast en ny widget-titel, og/eller vælg målwidget-bundt", + "toggle-fullscreen": "Skift til fuld skærm", + "run": "Kør widget", + "title": "Widget-titel", + "title-required": "Widget-titel er påkrævet.", + "type": "Widget-type", + "resources": "Ressourcer", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "Er modul", + "remove-resource": "Fjern ressource", + "add-resource": "Tilføj ressource", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Indstillingsskema", + "datakey-settings-schema": "Skema over datanøgleindstillinger", + "widget-settings": "Widget-indstillinger", + "description": "Beskrivelse", + "image-preview": "Forhåndsvisning af billede", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "", + "remove-widget-type-text": "Efter bekræftelsen vil widget-typen og alle relaterede data være uoprettelige.", + "remove-widget-type": "Fjern widget-type", + "add-widget-type": "Tilføj ny widget-type", + "widget-type-load-failed-error": "Kunne ikke indlæse widget-type!", + "widget-template-load-failed-error": "Kunne ikke indlæse widget-skabelon!", + "add": "Tilføj widget", + "undo": "Fortryd widget-ændringer", + "export": "Eksportér widget", + "export-data": "Eksportér widget-data", + "export-to-csv": "Eksportér data til CSV...", + "export-to-excel": "Eksportér data til XLS...", + "export-to-excel-xlsx": "Eksportér data til XLSX...", + "no-data": "Ingen data at vise på widget", + "data-overflow": "", + "alarm-data-overflow": "", + "search": "Søg efter widget", + "filter": "Widget-filtertype", + "loading-widgets": "Indlæser widgets..." + }, + "widget-action": { + "header-button": "Widget-overskriftsknap", + "open-dashboard-state": "Naviger til ny dashboardtilstand", + "update-dashboard-state": "Opdater aktuel dashboardtilstand", + "open-dashboard": "Naviger til et andet dashboard", + "custom": "Brugerdefineret handling", + "custom-pretty": "Brugerdefineret handling (med HTML-skabelon)", + "target-dashboard-state": "Mål-dashboardtilstand", + "target-dashboard-state-required": "Mål-dashboardtilstand er påkrævet", + "set-entity-from-widget": "Angiv entitet fra widget", + "target-dashboard": "Mål-dashboard", + "open-right-layout": "Åbn højre dashboardlayout (mobilvisning)", + "open-in-separate-dialog": "Åbn i separat dialogboks", + "dialog-title": "Dialogbokstitel", + "dialog-hide-dashboard-toolbar": "Skjul dashboardets værktøjslinje i dialogboks", + "dialog-width": "Dialogboksbredde i procent i forhold til visningsportens bredde", + "dialog-height": "Dialogbokshøjde i procent i forhold til visningsportens højde", + "dialog-size-range-error": "Dialogboksstørrelsens procentværdi skal ligge i området fra 1 til 100.", + "open-new-browser-tab": "Åbn i en ny browserfane" + }, + "widgets-bundle": { + "current": "Nuværende bundt", + "widgets-bundles": "Widgets-bundter", + "add": "Tilføj widgets-bundt", + "delete": "Slet widgets-bundt", + "title": "Titel", + "title-required": "Titel er påkrævet.", + "description": "Beskrivelse", + "image-preview": "Forhåndsvisning af billede", + "add-widgets-bundle-text": "Tilføj nye widgets-bundter", + "no-widgets-bundles-text": "Ingen widgets-bundter fundet", + "empty": "Widgets-bundt er tomt", + "details": "Oplysninger", + "widgets-bundle-details": "Oplysninger om widgets-bundt", + "delete-widgets-bundle-title": "", + "delete-widgets-bundle-text": "Vær forsigtig. Efter bekræftelsen vil widgets-bundtet og alle relaterede data være uoprettelige.", + "delete-widgets-bundles-title": "", + "delete-widgets-bundles-action-title": "", + "delete-widgets-bundles-text": "Vær forsigtig. Efter bekræftelsen vil alle valgte widgets-bundter blive fjernet, og alle relaterede data vil være uoprettelige.", + "no-widgets-bundles-matching": "", + "widgets-bundle-required": "Widgets-bundt er påkrævet.", + "system": "System", + "import": "Importér widgets-bundt", + "export": "Eksportér widgets-bundt", + "export-failed-error": "", + "create-new-widgets-bundle": "Opret nyt widgets-bundt", + "widgets-bundle-file": "Widgets-bundtfil", + "invalid-widgets-bundle-file-error": "Widgets-bundt kunne ikke importeres: Ugyldig datastruktur for widgets-bundt.", + "search": "Søg efter widget-bundter", + "selected-widgets-bundles": "", + "open-widgets-bundle": "Åbn widgets-bundt", + "loading-widgets-bundles": "Indlæser widgets-bundter..." + }, + "widget-config": { + "data": "Data", + "settings": "Indstillinger", + "advanced": "Fremskreden", + "title": "Titel", + "title-tooltip": "Værktøjstip for titel", + "general-settings": "Generelle indstillinger", + "display-title": "Vis titel", + "drop-shadow": "Slip skygge", + "enable-fullscreen": "Aktivér fuldskærm", + "enable-data-export": "Aktivér dataeksport", + "background-color": "Baggrundsfarve", + "text-color": "Tekstfarve", + "padding": "Padding", + "margin": "Margin", + "widget-style": "Widget-stil", + "title-style": "Titeltype", + "mobile-mode-settings": "Indstillinger for mobiltilstand", + "order": "Rækkefølge", + "height": "Højde", + "units": "Specielt symbol, der vises ved siden af værdi", + "decimals": "Antal cifre efter flydende punkt", + "timewindow": "Tidsvindue", + "use-dashboard-timewindow": "Brug dashboardtidsvindue", + "display-timewindow": "Vis tidsvindue", + "display-legend": "Vis siganturforklaring", + "datasources": "Datakilder", + "maximum-datasources": "", + "datasource-type": "Type", + "datasource-parameters": "Parametre", + "remove-datasource": "Fjern datakilde", + "add-datasource": "Tilføj datakilde", + "target-device": "Målenhed", + "alarm-source": "Alarmkilde", + "actions": "Handlinger", + "action": "Handling", + "add-action": "Tilføj handling", + "search-actions": "Søg efter handlinger", + "no-actions-text": "Ingen handlinger fundet", + "action-source": "Handlingskilde", + "action-source-required": "Handlingskilde er påkrævet.", + "action-name": "Navn", + "action-name-required": "Handlingsnavn er påkrævet.", + "action-name-not-unique": "Der findes allerede en anden handling med samme navn.
    Handlingsnavnet skal være unikt inden for den samme handlingskilde.", + "action-icon": "Ikon", + "action-type": "Type", + "action-type-required": "Handlingstype er påkrævet.", + "edit-action": "Rediger handling", + "delete-action": "Slet handling", + "delete-action-title": "Slet widget-handling", + "delete-action-text": "", + "display-icon": "Vis titelikon", + "icon-color": "Ikonfarve", + "icon-size": "Ikonstørrelse" + }, + "widget-type": { + "import": "Importér widget-type", + "export": "Eksportér widget-type", + "export-failed-error": "", + "create-new-widget-type": "Opret ny widget-type", + "widget-type-file": "Widget-typefil", + "invalid-widget-type-file-error": "Kan ikke importere widget-type: Ugyldig datastruktur for widget-type." + }, + "self-registration": { + "self-registration": "Selvregistrering", + "self-registration-url": "Selvregistrerings-URL", + "captcha-site-key": "reCAPTCHA site-nøgle", + "captcha-secret-key": "reCAPTCHA hemmelig nøgle", + "notification-email": "Notifikations-e-mail", + "privacy-policy-text": "Fortrolighedspolitik tekst", + "text-message-page": "Tekstmeddelelse til tilmeldingsside" + }, + "white-labeling": { + "white-labeling": "Hvid mærkning", + "login-white-labeling": "Login for hvid mærkning", + "preview": "Forhåndsvisning", + "app-title": "Applikationstitel", + "favicon": "Website-ikon", + "favicon-description": "", + "favicon-size-error": "", + "favicon-type-error": "Ugyldigt filformat for website-billede. Kun ICO-, GIF- eller PNG-billeder accepteres.", + "drop-favicon-image": "Træk og slip et website-ikonbillede, eller klik for at vælge en fil, der skal uploades.", + "no-favicon-image": "Intet ikon valgt", + "logo": "Logo", + "logo-description": "", + "logo-size-error": "", + "logo-type-error": "Ugyldigt logofilformat. Kun billeder accepteres.", + "drop-logo-image": "Træk og slip et logobillede, eller klik for at vælge en fil, der skal uploades.", + "no-logo-image": "Intet logo valgt", + "logo-height": "Logohøjde, px", + "primary-palette": "Primær palet", + "accent-palette": "Accent palet", + "customize-palette": "Tilpas", + "advanced-css": "Avanceret CSS", + "edit-palette": "Rediger palet", + "save-palette": "Gem palet", + "primary-background": "Primær baggrund", + "secondary-background": "Sekundær baggrund", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Baggrundsfarve for side", + "dark-foreground": "Mørk forgrund", + "domain-name": "Domænenavn", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL er påkrævet.", + "prohibit-different-url": "Det er ikke tilladt at bruge værtsnavn fra overskrifterne for klientanmodninger", + "prohibit-different-url-hint": "Denne indstilling skal aktiveres for produktionsmiljøer. Kan forårsage sikkerhedsproblemer ved deaktivering", + "help-link-base-url": "Basis-URL for hjælpelinks", + "enable-help-links": "Aktivér hjælpelinks", + "error-verification-url": "Et domænenavn må ikke indeholde symbolerne '/' og ':'. Eksempel: thingsboard.io", + "show-platform-name-version": "Vis platformnavn og version", + "platform-name": "Platformnavn", + "platform-version": "Platformversion", + "version-mask": "", + "position": { + "label": "Platformnavn og versionsposition", + "under-logo": "Under logoet", + "bottom": "Nederst i login-formularen" + } + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Søn", + "Mon": "Man", + "Tue": "Tir", + "Wed": "Ons", + "Thu": "Tor", + "Fri": "Fre", + "Sat": "Lør", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "Maj", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Okt", + "Nov": "Nov", + "Dec": "Dec", + "January": "Januar", + "February": "Februar", + "March": "Marts", + "April": "April", + "June": "Juni", + "July": "Juli", + "August": "August", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "December", + "Custom Date Range": "Brugerdefineret datointerval", + "Date Range Template": "Skabelon for datointerval", + "Today": "I dag", + "Yesterday": "I går", + "This Week": "Denne uge", + "Last Week": "Sidste uge", + "This Month": "Denne måned", + "Last Month": "Sidste måned", + "Year": "År", + "This Year": "I år", + "Last Year": "Sidste år", + "Date picker": "Datovælger", + "Hour": "Time", + "Day": "Dag", + "Week": "Uge", + "2 weeks": "2 uger", + "Month": "Måned", + "3 months": "3 måneder", + "6 months": "6 måneder", + "Custom interval": "Brugerdefineret interval", + "Interval": "Interval", + "Step size": "Trinstørrelse", + "Ok": "Okay" + } + }, + "input-widgets": { + "attribute-not-allowed": "Attributparameter kan ikke bruges i denne widget", + "blocked-location": "Geoplaceringen er blokeret i din browser", + "claim-device": "Gør krav på enhed", + "claim-failed": "Kunne ikke gøre krav på enheden!", + "claim-not-found": "Enhed ikke fundet!", + "claim-successful": "Enheden er blevet krævet!", + "date": "Dato", + "device-name": "Enhedsnavn", + "device-name-required": "Enhedsnavn er påkrævet", + "discard-changes": "Kassér ændringer", + "entity-attribute-required": "Entitetsattribut er påkrævet", + "entity-coordinate-required": "Både felter, breddegrad og længdegrad er påkrævet", + "entity-timeseries-required": "Tidsserie for entitet er påkrævet", + "get-location": "Få den aktuelle placering", + "invalid-date": "Ugyldig dato", + "latitude": "Breddegrad", + "longitude": "Længde", + "min-value-error": "", + "max-value-error": "", + "not-allowed-entity": "Den valgte entitet kan ikke have delte attributter", + "no-attribute-selected": "Ingen attribut er valgt", + "no-datakey-selected": "Ingen datanøgle er valgt", + "no-coordinate-specified": "Datanøgle for breddegrad/længdegrad er ikke angivet", + "no-entity-selected": "Ingen entitet valgt", + "no-image": "Intet billede", + "no-support-geolocation": "Din browser understøtter ikke geoplacering", + "no-support-web-camera": "Din browser understøtter ikke kameraer", + "enable-https-use-widget": "Aktivér HTTPS for at bruge denne widget", + "no-found-your-camera": "Kan ikke finde dit kamera", + "no-permission-camera": "Tilladelse blev nægtet af brugeren / Dette website har ikke tilladelse til at bruge kameraet", + "no-timeseries-selected": "Ingen tidsserier valgt", + "secret-key": "Hemmelig nøgle", + "secret-key-required": "Hemmelig nøgle er påkrævet", + "switch-attribute-value": "Skift entitetsattributværdi", + "switch-camera": "Skift kamera", + "switch-timeseries-value": "Skift entitetstidsserieværdi", + "take-photo": "Tag et billede", + "time": "Tid", + "timeseries-not-allowed": "Tidsserieparameter kan ikke bruges i denne widget", + "update-failed": "Opdatering mislykkedes", + "update-successful": "Opdatering lykkedes", + "update-attribute": "Opdatering", + "update-timeseries": "Opdater tidsserier", + "value": "Værdi" + } + }, + "icon": { + "icon": "Ikon", + "select-icon": "Vælg ikon", + "material-icons": "Materialeikoner", + "show-all": "Vis alle ikoner" + }, + "subscription": { + "entity-limit-text": "Du kan dog opgradere din abonnementsordning for at øge dine begrænsninger.", + "upgrade-your-plan": "Opgrader abonnementsordning", + "white-labeling-feature": "Funktion til hvid mærkning", + "white-labeling-text-full": "Rebrand ThingsBoard-platformens webinterface med dit virksomheds- eller produktlogo og farveskema på 2 minutter.

    Fjern “Powered By” i dashboardets sidefod.
    Ingen kodning eller genstart af tjeneste påkrævet. Giv også dine kunder mulighed for at hvidmærke deres interface.", + "enable-white-labeling": "Aktivér hvidmærkningsfunktionen nu ved at opgradere din abonnementsordning!", + "read-more": "Læs mere", + "white-labeling-video-text": "Se videovejledningen nedenfor for at se, hvordan denne funktion fungerer!" + }, + "subscription-error": { + "title": "Misligholdelse af abonnement", + "warning-title": "Abonnementsadvarsel", + "limit-reached": { + "device-count": "", + "asset-count": "" + }, + "feature-disabled": { + "white-labeling": "Hvidmærkningsfunktionen er ikke tilladt af din abonnementsordning!" + } + }, + "custom": { + "widget-action": { + "action-cell-button": "Handlingscelleknap", + "row-click": "Ved klik på række", + "polygon-click": "Ved klik på polygon", + "marker-click": "Ved klik på markør", + "tooltip-tag-action": "Værktøjstip for taghandling", + "node-selected": "På valgt knude", + "element-click": "Ved klik på HTML-element", + "pie-slice-click": "Ved klik på udsnit", + "row-double-click": "Ved dobbeltklik på række" + }, + "add-new-facility": { + "add-entity": "Tilføj placering", + "facility-name": "placeringsnavn", + "facility-name-required": "placeringsnavn er påkrævet.", + "title": "Tilføj ny placering" + }, + "delete-facility": { + "are-you-sure-question": "Er du sikker på, at du vil slette placering '{{facilityName}}'?", + "title": "Slet placering" + }, + "facilities": { + "set-location-and-facility-map": "Indstil placering" + }, + "general": { + "account": "Konto", + "accounts": "Konti", + "activation-link-is-sent-to-the-email-address": "Aktiveringslink er sendt til e-mailadressen: '{{email}}'", + "add-ecl": "Tilføj ECL", + "add-ecl-device": "Tilføj ECL-enhed", + "add-edit-ally-email": "Tilføj Ally™ konto", + "add-edit-entity-ally-account": "Tilføj {{ entityName }}'s Ally™ konto", + "add-home": "Tilføj Hjem", + "add-home-area": "Tilføj Hjemmeområde", + "add-new-ally-account": "Tilføj ny konto", + "add-new-location": "Tilføj ny placering", + "add-new-read-only-user": "Tilføj ny skrivebeskyttet bruger", + "add-new-read-only-user-for-facility": "Tilføj ny skrivebeskyttet bruger for '{{facilityName}}'", + "add-vacation-for-zone": "Tilføj ferie for denne zone", + "open-calendar": "Åbn kalender", + "add-zone": "Tilføj zone", + "alarm-code": "Alarmkode", + "alarm-description": "Beskrivelse", + "alarm-message": "Meddelelse", + "alarms-disabled-msg": "Alarmer er deaktiveret.", + "alarms-disabled-state": "Deaktiveret", + "alarms-enabled-msg": "Alarmer er aktiveret.", + "alarms-enabled-state": "Aktiveret", + "all-devices-in-home": "Alle enheder i hjemmet", + "ally-email": "Ally™ e-mail", + "ally-email-access-check-email": "Tjek din e-mail {{ allyEmail }} for at se den 4-cifrede kode fra Ally™ Pro", + "ally-email-access-code-label": "Indtast din adgangskode (4 cifre)", + "ally-email-account-is-missing-or-is-not-valid": "Ally™ e-mailkonto mangler eller er ikke gyldig", + "an-error-occurred-entities-try-again": "Der opstod en fejl under sletning af enhederne. Prøv igen.", + "an-error-occurred-entity-try-again": "Der opstod en fejl under sletning af enheden. Prøv igen.", + "an-error-occurred-please-try-again": "Der opstod en fejl. Prøv igen.", + "app": "App", + "app-version": "Appversion", + "area-name-is-required": "Områdenavn er påkrævet.", + "assign-existing-user-to-facility": "Tildel eksisterende bruger til placering", + "assign-user-to-facility": "Tildel bruger til '{{facilityName}}'", + "available-devices": "Tilgængelige enheder", + "battery": "Batteri", + "calendar": "Kalender", + "cant-remove-yourself": "Du kan ikke fjerne dig selv fra placeringen.", + "change-temperature": "Ændr temperatur", + "change-zone-map": "Skift zonekort", + "child-lock": "Børnesikring", + "child-lock-disabled": "Deaktiveret", + "child-lock-enabled": "Aktiveret", + "circuit": "Kreds", + "comfort": "Komfort", + "comfort-dhw-setpoint": "Sætpunkt for Komfort varmt brugsvand", + "comfort-dhw-setpoint-is-required": "Sætpunkt for Komfort varmt brugsvand er påkrævet.", + "comfort-room": "Komfort rum", + "comfort-room-setpoint": "Sætpunkt for Komfort rum", + "comfort-room-setpoint-is-required": "Sætpunkt for Komfort rum er påkrævet.", + "confirm-btn": "Bekræft", + "confirm-delete-device": "Er du sikker på, du ønsker at slette enhed '{{deviceName}}'?", + "confirm-delete-devices": "Er du sikker på, du ønsker at slette {{numberOfDevices}} enheder?", + "confirm-delete-home": "Er du sikker på, du ønsker at slette hjem '{{homeName}}'; deres zoner og enheder?", + "confirm-delete-zone": "Er du sikker på, du ønsker at slette zonen '{{zoneName}}'?", + "confirm-remove-user": "Er du sikker på, du ønsker at fjerne {{ thisUser }} fra lokation {{ locationName }}", + "constant-comfort-temperature": "Konstant komforttemperatur", + "constant-setback-temperature": "Konstant sænkningstemperatur", + "coordinates": "Koordinater", + "create-ecl-home-without-ally-account": "Opret ECL hjem uden Ally™ konto", + "create-vacation-event": "Opret feriebegivenhed", + "current-humidity": "Luftfugtighed", + "current-temp": "Aktuel temp.", + "current-temperature": "Aktuel temperatur", + "default-flow-temperature-at-minus-30": "Standard fremløbstemperatur ved -30 °C", + "default-flow-temperature-at-minus-15": "Standard fremløbstemperatur ved -15 °C", + "default-flow-temperature-at-minus-5": "Standard fremløbstemperatur ved -5 °C", + "default-flow-temperature-at-zero": "Standard fremløbstemperatur ved 0 °C", + "default-flow-temperature-at-plus-5": "Standard fremløbstemperatur ved 5 °C", + "default-flow-temperature-at-plus-15": "Standard fremløbstemperatur ved 15 °C", + "delete-account": "Slet konto", + "delete-all-devices": "Slet alle enheder", + "delete-device-from-home": "Slet enhed fra hjem", + "delete-devices": "Slet enheder", + "delete-facility-account": "Slet konto", + "delete-home": "Slet hjem", + "delete-user": "Slet bruger", + "delete-zone": "Slet zone", + "device-id": "Enheds-id", + "device-is-now-removed-from-this-zone": "Enheden er nu fjernet fra denne zone.", + "device-manual_mode-updated": "Manuel tilstand er indstillet for denne enhed.", + "device-name": "Enhedsnavn", + "device-status-updated": "Enhedsstatus opdateret!", + "device-type": "Enhedstype", + "devices-found-in-home": "Enheder fundet i {{ homeName }}", + "devices-imported": "Enheder importeret!", + "devices-in-zone": "Enheder i zone", + "devices-placed-into-zone": "{{numberOfDevices}} enhed(er) placeret i zone.", + "dhw-circuit": "Varmt brugsvandskreds", + "domestic-hot-water-circuit-menu": "Varmt brugsvand (DHW), menu for kreds", + "duplicate-devices": "Duplikerede enheder", + "duplicate-devices-list": "Du har allerede importeret enheder: {{listOfDevices}}", + "duplicate-devices-were-not-imported": "Duplikerede enheder: {{listOfDevices}} blev ikke importeret.", + "ecl-access-code": "ECL-adgangskode", + "ecl-device-added": "Din ECL-enhed {{device}} er blevet tilføjet.", + "ecl-diagram-title": "Nyt ECL-diagram", + "ecl-diagram-title-a-230-1": "Nyt ECL-diagram A230.1", + "ecl-diagram-title-a-247-1": "Nyt ECL-diagram A247.1", + "ecl-diagram-title-a-260-1": "Nyt ECL-diagram A260.1", + "ecl-diagram-title-a-266-1": "Nyt ECL-diagram A266.1", + "ecl-diagram-title-a-347-1": "Nyt ECL-diagram A347.1", + "ecl-diagram-title-a-376-1": "Nyt ECL-diagram A376.1", + "ecl-diagram-title-a-390-1": "Nyt ECL-diagram A390.1", + "ecl-heating-circuit-has-been-successfully-set": "ECL-varmekredsen er blevet indstillet.", + "ecl-menu": "ECL-menu", + "ecl-mode": "Tilstand", + "ecl-mvp-menu": "ECL MVP-menu", + "ecl-status": "Status", + "edit-facilities-access": "Rediger adgang til faciliteter", + "edit-facility-permission": "Rediger placeringstilladelse", + "edit-user": "Rediger bruger", + "edit-zone": "Rediger zone", + "eg-read-only": "f.eks. skrivebeskyttet", + "energy": "Energi", + "error-occurred": "Der opstod en fejl", + "etrv-update-temperature": "Opdater temperatur", + "facilities": "Faciliteter", + "facilities-for-entity-as-read-only-user": "Faciliteter for '{{entityName}}' som skrivebeskyttet bruger", + "facility-area-name": "placeringsområdenavn", + "facility-managers": "Facility Managers", + "facility-name-is-required": "placeringsnavn er påkrævet.", + "floor-map": "Plantegning", + "floors-in-the-room": "Gulve i rummet", + "flow-heating-curve-parameters-history": "Historik over parametre for flowvarmekurve", + "flow-temperature": "Fremløbstemperatur", + "flow-temperature-at-minus-15": "Fremløbstemperatur ved -150 °C", + "flow-temperature-at-minus-30": "Fremløbstemperatur ved -30 °C", + "flow-temperature-at-minus-5": "Fremløbstemperatur ved -5 °C", + "flow-temperature-at-plus-15": "Fremløbstemperatur ved 15 °C", + "flow-temperature-at-plus-5": "Fremløbstemperatur ved 5 °C", + "flow-temperature-at-zero": "Fremløbstemperatur ved 0 °C", + "flow-temperature-curve": "Fremløbstemperaturkurve", + "flow-temperature-values": "Fremløbstemperaturværdier", + "flow-temperature-without-optimization": "Fremløbstemperatur uden optimering", + "gateway-subdevices": "Gateway-underenheder", + "get-subdevices": "Hent underenheder", + "global-temp-set-msg": "Global temperatur er indstillet.", + "go-to-manual": "Gå til manuel tilstand", + "hardware": "Hardware", + "heating-circuit": "Varmekreds", + "heating-circuit-menu": "Menu for varmekreds", + "holiday-7-23h-comfort-temperature": "Ferie 7-23 t komforttemperatur", + "holiday-constant-comfort-temperature": "Ferie, konstant komforttemperatur", + "holiday-constant-setback-temperature": "Ferie, konstant sænkningstemperatur", + "holiday-frost-protection-standby": "Ferie, frostsikring/standby", + "home-alarms": "Hjemmealarmer", + "home-map": "Hjemmekort", + "home-map-with-zones": "Zoner", + "home-name": "Hjemmenavn", + "home-name-is-required": "Hjemmenavn er påkrævet", + "homes": "Hjem", + "homes-and-zones": "Hjem og zoner", + "homes-imported": "Hjem importeret!", + "homes-successfully-imported": "Hjem blev importeret!", + "import-ally-homes-from-the-button": "Importér Ally™ hjem fra knappen med ikonet 'hus'.", + "import-devices": "Importér enheder", + "import-devices-from-home": "Importer enheder fra hjem", + "import-homes": "Importer hjem", + "information": "Information", + "information-will-be-sent-via-email-to-this-user": "Information sendes via e-mail til denne bruger", + "invalid-temp": "Ugyldig temperaturværdi", + "link-to-ecl": "Link til ECL", + "linked-devices": "Tilknyttede enheder", + "list-of-subdevices": "Liste over underenheder", + "location-city": "Placering, by", + "location-country": "Placering, landområder", + "location-on-maps": "Placering på kort", + "location-street": "Placering, vej", + "location-street-number": "Placering, vejnummer", + "location-successfully-created": "Placeringen blev oprettet!", + "location-zip": "Placering, postnummer", + "locations": "Placeringer", + "lower-temperature": "Lavere temperatur", + "manage-home": "Administrer hjem", + "manual-operation": "Manuel drift", + "manual-temp": "Manuel temp.", + "manual-temperature": "Manuel temperatur", + "map": "Kort", + "max-flow-temperature": "Maks. Fremløbstemperatur", + "min-flow-temperature": "Min. Fremløbstemperatur", + "mode": "Tilstand", + "mode-at-home": "Hjemme", + "mode-leaving-home": "Ikke til stede", + "mode-manual": "Manuel", + "mode-pause": "Pause", + "mode-vacation": "Ferie", + "new-entities-table": "Tabel over nye enheder", + "new-led-indicator": "Ny LED-indikator", + "new-scheduler-events": "Nye planlægningsbegivenheder", + "no-devices-found": "Ingen enheder fundet.", + "no-ecl-devices-found": "Ingen ECL-enheder fundet", + "no-vacations": "Ingen planlagte begivenheder", + "number-of-devices-imported": "{{numberOfDevices}} enhed(er) importeret.", + "offline-status": "Offline", + "online": "Online", + "online-status": "Online", + "outdoor-temperature": "Udendørstemperatur", + "override-temperature": "Overstyringstemperatur", + "overview": "Oversigt", + "owner-name": "Ejerens navn", + "parent-facility-name": "Overordnet placeringsnavn", + "pi-heating-demand": "Pi Heating Demand", + "pi-heating-demand-history": "Pi Heating Demand History", + "pi-heating-demand-values-history": "Pi Heating Demand Values History", + "place-devices-in-this-zone": "Placer enheder i denne zone", + "place-into-zone": "Placer i zone", + "please-choose-user-role": "Vælg brugerrolle", + "pre-comfort": "Forkomfort", + "pre-setback": "Forsænkning", + "production-week": "Produktionsuge", + "read-only-users": "Skrivebeskyttede brugere", + "real-etrv-current-temp": "Termostattemperatur", + "refresh-device-status": "Opdater enhedsstatus", + "remove-from-zone": "Fjern fra zone", + "remove-user-from-loc": "Fjern bruger fra denne placering", + "remove-user-title": "Fjern bruger", + "resend-code": "Send kode igen", + "resend-code-msg": "Din kode er sendt! Tjek din e-mail.", + "return-temp": "Returløbstemperatur", + "room-map": "Kort over rum", + "save-comfort-dhw-setpoint": "Gem sætpunkt for 'Komfort varmt brugsvand'", + "save-saving-dhw-setpoint": "Gem sætpunktet 'Gemmer varmt brugsvand'", + "saving-dhw-setpoint": "Gemmer sætpunktet varmt brugsvand", + "saving-room": "Gemmer rum", + "saving-room-setpoint": "Gemmer sætpunkt for rum", + "saving-room-setpoint-is-required": "Gemmer sætpunkt for rum er påkrævet.", + "scheduled-operation": "Planlagt drift", + "scheduler-events": "Planlægningsbegivenheder", + "see-advanced-settings-for-details": "Se avancerede indstillinger for at få yderligere oplysninger", + "see-areas": "se områder", + "see-devices-in-home": "Se enheder i hjemmet", + "see-information": "Se oplysninger", + "selected-devices-are-successfully-linked-with-ecl-device": "Selected device(s) are successfully linked with ECL device.", + "serial-number": "Serienummer", + "set-ecl-heating-circuit": "Set ECL heating circuit", + "select-ecl-device": "Vælg ECL-enhed...", + "device-are-linked-to-ecl": "Den eller de valgte enheder er med succes forbundet med ECL-enhed.", + "unlink-all-devices": "Fjern linket til alle enheder fra ECL", + "unlink-device": "Fjern forbindelsen mellem enhed fra ECL", + "see-pi-history": "Se PI-historik", + "device-unlinked-msg": "Enheden er fjernet fra ECL", + "set-global-temp": "Indstil den globale temperatur", + "are-you-sure-unlink-all": "Er du sikker på at fjerne linket til ALLE enheder fra ECL?", + "are-you-sure-unlink-device": "Er du sikker på at fjerne linket mellem enhed {{device}} og ECL-controlleren?", + "set-temp": "Indstil temp.", + "set-temperature": "Ønsket temperatur", + "set-vacation": "Indstil ferie", + "setback": "Sænkning", + "setback-dhw-setpoint-is-required": "Gemmer sætpunktet varmt brugsvand er påkrævet.", + "software": "Software", + "software-build-no": "Softwarebuildnr.", + "something-went-wrong": "Noget gik galt!", + "subdevices-information-saved": "Gateway-underenheder gemt.", + "successfully-inserted-devices": "{{numberOfDevices}} enhed(er) blev indsat.", + "temp-required": "Temperaturværdi er påkrævet", + "temperature": "Temperatur", + "this-user": "denne bruger", + "title-homes": "Hjem", + "title-location": "Placering", + "update-area-map": "Opdater områdekort", + "update-facilities": "Opdater faciliteter", + "update-home-map": "Opdater hjemmekort", + "update-zone-map": "Opdater zonekort", + "upper-temperature": "Øvre temperatur", + "user-activation-link-has-been-copied-to-clipboard": "Brugeraktiveringslinket er blevet kopieret til udklipsholderen", + "user-removed-fm": "Bruger fjernet som Facility Manager fra denne placering.", + "user-removed-ro": "Bruger fjernet som skrivebeskyttet bruger fra denne placering.", + "user-was-successfully-assigned-to-the-facility": "Brugeren blev tildelt {{facilityName}}", + "user-with-email-already-exists": "Bruger med e-mail '{{email}}' findes allerede!", + "user-with-this-email-is-already-assigned-to-the-facility": "Bruger med denne e-mail er allerede tildelt '{{facilityName}}'.", + "user-with-this-email-was-not-found": "Bruger med denne e-mail blev ikke fundet.", + "vacation-plan": "Ferieplan", + "view-details": "Vis detaljer", + "view-device-information": "Vis enhedsoplysninger", + "view-home": "Vis hjem", + "view-information": "Vis oplysninger", + "view-users": "Brugere", + "view-zone": "Vis zone", + "warning-uppercase": "ADVARSEL", + "were-not-imported": "blev ikke importeret", + "window-state": "Vinduestilstand", + "work-state": "Arbejdstilstand", + "you-dont-have-attribute-free": "Du har ikke en attribut, der er ledig til placeringsnavn!", + "you-have-valid-ally-account": "Du har en gyldig Ally™ konto.", + "your-ally-email-account": "Din Ally™ e-mailkonto", + "your-facilities": "Dine faciliteter", + "your-home-was-successfully-deleted": "Dit hjem blev slettet.", + "zone": "zone", + "zone-have-no-devices-msg": "Der er ingen enheder placeret i zonen. Placer venligst nogle enheder.", + "zone-management": "Zonestyring", + "zone-name": "Navn", + "zone-name-is-required": "Zonenavn er påkrævet.", + "zone-temp-not-enabled": " ikke aktiveret.", + "zone-temp-sett": "Indstilling af temperaturen", + "zone-temp-sett-tooltip": "Ændr temperaturen", + "zone-temperature": "Zonetemperatur", + "zone-view": "Zonevisning" + } + }, + "language": { + "language": "Sprog" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json new file mode 100644 index 0000000..d60ed05 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -0,0 +1,1929 @@ +{ + "access": { + "unauthorized": "Nicht autorisiert", + "unauthorized-access": "Unautorisierter Zugriff", + "unauthorized-access-text": "Sie sollten sich anmelden, um Zugriff auf diese Daten zu erhalten!", + "access-forbidden": "Keine Zugangsberechtigung", + "access-forbidden-text": "Sie haben keine Zugangsberechtigung für diesen Bereich!
    Versuchen Sie sich mit einem anderen Benutzer anzumelden um Zugriff auf diesen Bereich zu bekommen.", + "refresh-token-expired": "Sitzung ist abgelaufen", + "refresh-token-failed": "Sitzung kann nicht aktualisiert werden", + "permission-denied": "Zugriff verweigert", + "permission-denied-text": "Sie haben keine Berechtigung, um diesen Vorgang auszuführen!" + }, + "action": { + "activate": "Aktivieren", + "suspend": "Unterbrechen", + "save": "Speichern", + "saveAs": "Speichern unter", + "cancel": "Abbrechen", + "ok": "OK", + "delete": "Löschen", + "add": "Hinzufügen", + "yes": "Ja", + "no": "Nein", + "update": "Aktualisieren", + "remove": "Löschen", + "select": "Auswählen", + "search": "Suche", + "clear-search": "Suchanfrage löschen", + "assign": "Zuordnen", + "unassign": "Zuordnung aufheben", + "share": "Teilen", + "make-private": "Privat machen", + "apply": "Anwenden", + "apply-changes": "Änderungen übernehmen", + "edit-mode": "Bearbeitungsmodus", + "enter-edit-mode": "Zum Bearbeitungsmodus wechseln", + "decline-changes": "Änderungen nicht übernehmen", + "close": "Schließen", + "back": "Zurück", + "run": "Ausführen", + "sign-in": "Anmelden!", + "edit": "Bearbeiten", + "view": "Ansicht", + "create": "Erstellen", + "drag": "Ziehen", + "refresh": "Aktualisieren", + "undo": "Rückgängig machen", + "copy": "Kopieren", + "paste": "Einfügen", + "copy-reference": "Zeichen kopieren", + "paste-reference": "Zeichen einfügen", + "import": "Importieren", + "export": "Exportieren", + "share-via": "Teilen mit {{provider}}", + "continue": "Fortsetzen", + "discard-changes": "Änderungen verwerfen", + "download": "Download", + "next-with-label": "Nächste: {{label}}", + "read-more": "Mehr dazu", + "hide": "Verstecken", + "done": "Erledigt", + "print": "Drucken", + "restore": "Wiederherstellen", + "confirm": "Bestätigen" + }, + "aggregation": { + "aggregation": "Aggregation", + "function": "Datenaggregationsfunktion", + "limit": "Höchstwerte", + "group-interval": "Gruppierungsintervall", + "min": "Minimal", + "max": "Maximal", + "avg": "Durchschnitt", + "sum": "Summe", + "count": "Anzahl", + "none": "kein Wert" + }, + "admin": { + "general": "Allgemein", + "general-settings": "Allgemeine Einstellungen", + "home-settings": "Home Einstellungen", + "outgoing-mail": "E-Mail Versand", + "outgoing-mail-settings": "Konfiguration des Postausgangsservers", + "system-settings": "Systemeinstellungen", + "test-mail-sent": "Test E-Mail wurde erfolgreich versendet!", + "base-url": "Basis-URL", + "base-url-required": "Basis-URL ist erforderlich.", + "prohibit-different-url": "Prohibit to use hostname from the client request headers", + "prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled", + "mail-from": "E-Mail von", + "mail-from-required": "E-Mail von ist erforderlich.", + "smtp-protocol": "SMTP Protokoll", + "smtp-host": "SMTP Host", + "smtp-host-required": "SMTP Host ist erforderlich.", + "smtp-port": "SMTP Port", + "smtp-port-required": "Sie müssen einen SMTP Port angeben.", + "smtp-port-invalid": "Das ist kein gültiger SMTP Port.", + "timeout-msec": "Wartezeit (msec)", + "timeout-required": "Wartezeit ist erforderlich.", + "timeout-invalid": "Das ist keine gültige Wartezeit.", + "enable-tls": "TLS aktivieren", + "tls-version" : "TLS-Version", + "enable-proxy": "Proxy aktivieren", + "proxy-host": "Proxy Host", + "proxy-host-required": "Proxy Host ist erforderlich.", + "proxy-port": "Proxy Port", + "proxy-port-required": "Proxy Port ist erforderlich.", + "proxy-port-range": "Proxy Port sollte im Bereich 1 bis 65535 sein.", + "proxy-user": "Proxy Benutzername", + "proxy-password": "Proxy Passwort", + "change-password": "Passwort ändern", + "send-test-mail": "Test E-Mail senden", + "sms-provider": "SMS Anbieter", + "sms-provider-settings": "SMS Anbieter Einstellungen", + "sms-provider-type": "SMS Anbieter Typ", + "sms-provider-type-required": "SMS Anbieter Typ ist erforderlich.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "security-settings": "Sicherheitseinstellungen", + "password-policy": "Kennwortrichtlinie", + "minimum-password-length": "Minimale Passwortlänge", + "minimum-password-length-required": "Minimale Passwortlänge ist erforderlich", + "minimum-password-length-range": "Die Mindestlänge des Passworts sollte zwischen 5 und 50 liegen", + "minimum-uppercase-letters": "Mindestanzahl von Großbuchstaben", + "minimum-uppercase-letters-range": "Die Mindestanzahl von Großbuchstaben kann nicht negativ sein", + "minimum-lowercase-letters": "Mindestanzahl von Kleinbuchstaben", + "minimum-lowercase-letters-range": "Die Mindestanzahl von Kleinbuchstaben kann nicht negativ sein", + "minimum-digits": "Mindestanzahl von Ziffern", + "minimum-digits-range": "Die Mindestanzahl von Ziffern kann nicht negativ sein", + "minimum-special-characters": "Mindestanzahl von Sonderzeichen", + "minimum-special-characters-range": "Die Mindestanzahl von Sonderzeichen kann nicht negativ sein", + "password-expiration-period-days": "Gültigkeitsdauer des Passworts in Tagen", + "password-expiration-period-days-range": "Die Gültigkeitsdauer des Passworts in Tagen kann nicht negativ sein", + "password-reuse-frequency-days": "Häufigkeit der Kennwortwiederverwendung in Tagen", + "password-reuse-frequency-days-range": "Die Häufigkeit der Kennwortwiederverwendung in Tagen kann nicht negativ sein", + "general-policy": "Allgemeine Politik", + "max-failed-login-attempts": "Maximale Anzahl fehlgeschlagener Anmeldeversuche, bevor das Konto gesperrt wird", + "minimum-max-failed-login-attempts-range": "Die maximale Anzahl fehlgeschlagener Anmeldeversuche kann nicht negativ sein", + "user-lockout-notification-email": "Wenn das Benutzerkonto gesperrt ist, senden Sie eine Benachrichtigung per E-Mail" + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarme", + "select-alarm": "Alarm auswählen", + "no-alarms-matching": "Keine passenden Alarme zu '{{entity}}' wurden gefunden.", + "alarm-required": "Alarm ist erforderlich", + "alarm-status": "Alarm Status", + "alarm-status-list": "Alarm Status Liste", + "any-status": "Jeder Status", + "search-status": { + "ANY": "Jeder", + "ACTIVE": "Aktiv", + "CLEARED": "Gelöscht", + "ACK": "Bestätigt", + "UNACK": "Nicht bestätigt" + }, + "display-status": { + "ACTIVE_UNACK": "Nicht bestätigt aktiv", + "ACTIVE_ACK": "Bestätigt aktiv", + "CLEARED_UNACK": "Nicht bestätigt", + "CLEARED_ACK": "Bestätigung gelöscht" + }, + "no-alarms-prompt": "Keine Alarme gefunden", + "created-time": "Erstellungszeit", + "type": "Typ", + "severity": "Schwere", + "originator": "Urheber", + "originator-type": "Urheber-Typ", + "details": "Details", + "status": "Status", + "alarm-details": "Alarm-Details", + "start-time": "Startzeit", + "end-time": "Endzeit", + "ack-time": "Bestätigungszeit", + "clear-time": "Zeit gelöscht", + "alarm-severity-list": "Alarm Schwere Liste", + "any-severity": "Jede Schwere", + "severity-critical": "Kritisch", + "severity-major": "Groß", + "severity-minor": "Klein", + "severity-warning": "Warnung", + "severity-indeterminate": "Unbestimmt", + "acknowledge": "Bestätigen", + "clear": "Löschen", + "search": "Alarme suchen", + "selected-alarms": "{ count, plural, 1 {1 Alarm} other {# Alarme} } ausgewählt", + "no-data": "Keine Daten zum Anzeigen", + "polling-interval": "Alarmabfrageintervall (sec)", + "polling-interval-required": "Alarmabfrageintervall ist erforderlich.", + "min-polling-interval-message": "Mindestens 1 sec Abrufintervall ist zulässig.", + "aknowledge-alarms-title": "{ count, plural, 1 {1 Alarm} other {# Alarme} } bestätigen", + "aknowledge-alarms-text": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Alarm} other {# Alarme} } bestätigen möchten?", + "aknowledge-alarm-title": "Alarm bestätigen", + "aknowledge-alarm-text": "Möchten Sie den Alarm wirklich bestätigen?", + "clear-alarms-title": "{ count, plural, 1 {1 Alarm} other {# Alarme} } löschen", + "clear-alarms-text": "Möchten Sie wirklich { count, plural, 1 {1 Alarm} other {# Alarme} } löschen?", + "clear-alarm-title": "Alarm löschen", + "clear-alarm-text": "Möchten Sie den Alarm wirklich löschen?", + "alarm-status-filter": "Alarm Status Filter" + }, + "alias": { + "add": "Alias hinzufügen", + "edit": "Alias bearbeiten", + "name": "Aliasname", + "name-required": "Aliasname ist erforderlich", + "duplicate-alias": "Ein Alias mit demselben Namen ist bereits vorhanden.", + "filter-type-single-entity": "Einzelne Entität", + "filter-type-entity-list": "Entitätsliste", + "filter-type-entity-name": "Entitätsname", + "filter-type-entity-type": "Entitätstyp", + "filter-type-state-entity": "Entität aus dem Dashboard Status", + "filter-type-state-entity-description": "Entität aus den Dashboard Status Parametern", + "filter-type-asset-type": "Objekttyp", + "filter-type-asset-type-description": "Objekte vom Typ '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Objekte vom Typ '{{assetType}}' und Name beginnend mit '{{prefix}}'", + "filter-type-device-type": "Gerätetyp", + "filter-type-device-type-description": "Geräte vom Typ '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Geräte vom Typ '{{deviceType}}' und Name beginnend mit '{{prefix}}'", + "filter-type-entity-view-type": "Entitätsansichtstyp", + "filter-type-entity-view-type-description": "Entitätsansichten vom Typ '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Entitätsansichten vom Typ '{{entityView}}' und Name beginnend mit '{{prefix}}'", + "filter-type-edge-type": "Randtyp", + "filter-type-edge-type-description": "Rand vom Typ '{{edgeType}}'", + "filter-type-relations-query": "Beziehungsabfrage", + "filter-type-relations-query-description": "{{entities}} mit {{relationType}} Beziehung {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Objektabfrage", + "filter-type-asset-search-query-description": "Objekte vom Typ {{assetTypes}} mit {{relationType}} Beziehung {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Geräteabfrage", + "filter-type-device-search-query-description": "Geräte vom Typ {{deviceTypes}} mit {{relationType}} Beziehung {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Entitätsansichtsabfrage", + "filter-type-entity-view-search-query-description": "Entitätsansichten vom Typ {{entityViewTypes}} mit {{relationType}} Beziehung {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Randabfrage", + "filter-type-edge-search-query-description": "Rand vom Typ {{edgeTypes}} mit {{relationType}} Beziehung {{direction}} {{rootEntity}}", + "entity-filter": "Entitätsfilter", + "resolve-multiple": "Als mehrere Entitäten auflösen", + "filter-type": "Filtertyp", + "filter-type-required": "Filtertyp ist erforderlich.", + "entity-filter-no-entity-matched": "Es wurden keine Entitäten gefunden, die dem angegebenen Filter entsprechen.", + "no-entity-filter-specified": "Es wurde kein Entitätsfilter angegeben", + "root-state-entity": "Dashboard Status Entität als Wurzel verwenden", + "root-entity": "Wurzelentität", + "state-entity-parameter-name": "Parameter-Name der Statusentität", + "default-state-entity": "Standard Statusentität", + "default-entity-parameter-name": "Standardmäßig", + "max-relation-level": "Maximale Beziehungstiefe", + "unlimited-level": "Unbegrenzte Ebenen", + "state-entity": "Dashboard Status Entität", + "all-entities": "Alle Entitäten", + "any-relation": "Jede Beziehung" + }, + "asset": { + "asset": "Objekt", + "assets": "Objekte", + "management": "Objektverwaltung", + "view-assets": "Objekte anzeigen", + "add": "Objekt hinzufügen", + "assign-to-customer": "Einem Kunden zuordnen", + "assign-asset-to-customer": "Objekte dem Kunden zuordnen", + "assign-asset-to-customer-text": "Bitte wählen Sie die Objekte aus, die dem Kunden zugeordnet werden sollen", + "no-assets-text": "Keine Objekte gefunden", + "assign-to-customer-text": "Bitte wählen Sie den Kunden aus, dem die Objekte zugeordnet werden sollen", + "public": "Öffentlich", + "assignedToCustomer": "Kunden zugeordnet", + "make-public": "Objekt öffentlich machen", + "make-private": "Objekt privat machen", + "unassign-from-customer": "Kundenzuordnung aufheben", + "delete": "Objekt löschen", + "asset-public": "Objekt ist öffentlich", + "asset-type": "Objekttyp", + "asset-type-required": "Objekttyp ist erforderlich.", + "select-asset-type": "Objekttyp auswählen", + "enter-asset-type": "Objekttyp bestätigen", + "any-asset": "Jedes Objekt", + "no-asset-types-matching": "Es wurden keine zu '{{entitySubtype}}' passenden Objekte gefunden.", + "asset-type-list-empty": "Keine Objekttypen ausgewählt.", + "asset-types": "Objekttypen", + "name": "Name", + "name-required": "Name ist erforderlich.", + "description": "Beschreibung", + "type": "Typ", + "type-required": "Typ ist erforderlich.", + "details": "Details", + "events": "Ereignisse", + "add-asset-text": "Neues Objekt hinzufügen", + "asset-details": "Objektdetails", + "assign-assets": "Objekte zuordnen", + "assign-assets-text": "Kunden { count, plural, 1 {1 Objekt} other {# Objekte} } zuordnen", + "delete-assets": "Objekte löschen", + "unassign-assets": "Objektzuordnungen aufheben", + "unassign-assets-action-title": "Kunden { count, plural, 1 {1 Objektzuordnung} other {# Objektzuordnungen} } aufheben", + "assign-new-asset": "Neues Objekt zuordnen", + "delete-asset-title": "Sind Sie sicher, dass Sie das Objekt '{{assetName}}' löschen möchten?", + "delete-asset-text": "Vorsicht, nach Bestätigung wird das Objekt und alle zugehörigen Daten nicht wiederherstellbar gelöscht.", + "delete-assets-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Objekt} other {# Objekte} } löschen möchten?", + "delete-assets-action-title": "{ count, plural, 1 {1 Objekt} other {# Objekte} } löschen", + "delete-assets-text": "Vorsicht, nach Bestätigung werden die ausgewählten Objekte und alle zugehörigen Daten nicht wiederherstellbar gelöscht", + "make-public-asset-title": "Sind Sie sicher, dass Sie das Objekt '{{assetName}}' öffentlich machen möchten?", + "make-public-asset-text": "Nach Bestätigung wird das Objekt und alle zugehörigen Daten anderen zugänglich gemacht.", + "make-private-asset-title": "Sind Sie sicher, dass Sie das Objekt '{{assetName}}' privat machen möchten?", + "make-private-asset-text": "Nach Bestätigung wird das Objekt und alle zugehörigen Daten privat und ist für andere nicht mehr zugänglich.", + "unassign-asset-title": "Sind Sie sicher, dass Sie die Zuordnung für das Objekt '{{assetName}}' aufheben möchten?", + "unassign-asset-text": "Nach Bestätigung wird die Zuordnung des Objekts aufgehoben und es ist für den Kunden nicht mehr zugänglich.", + "unassign-asset": "Zuordnung des Objekts aufheben", + "unassign-assets-title": "Möchten Sie die Zuordnung von { count, plural, 1 {1 Objekt} other {# Objekte} } aufheben?", + "unassign-assets-text": "Nach Bestätigung wird die Zuordnung der ausgewählten Objekte aufgehoben und sie sind für den Kunden nicht mehr zugänglich.", + "copyId": "Objekt-ID kopieren", + "idCopiedMessage": "Objekt-ID wurde in die Zwischenablage kopiert", + "select-asset": "Objekt auswählen", + "no-assets-matching": "Es wurden keine zu '{{entity}}' passenden Objekte gefunden.", + "asset-required": "Objekt ist erforderlich", + "name-starts-with": "Name des Objekts beginnt mit", + "label": "Bezeichnung", + "assign-asset-to-edge": "Objekte dem Rand zuordnen", + "assign-asset-to-edge-text":"Bitte wählen Sie die Objekte aus, die dem Rand zugeordnet werden sollen", + "unassign-asset-from-edge": "Objekte von Rand entfernen", + "unassign-asset-from-edge-title": "Sind Sie sicher, dass Sie die Zuordnung für das Objekt '{{assetName}}' aufheben möchten?", + "unassign-asset-from-edge-text": "Nach Bestätigung wird die Zuordnung des Objekts aufgehoben und es ist für den Kunden nicht mehr zugänglich.", + "unassign-assets-from-edge-action-title": "Rand { count, plural, 1 {1 Objektzuordnung} other {# Objektzuordnungen} } aufheben", + "unassign-assets-from-edge-title": "Sind Sie sicher, dass Sie die Zuordnung für das Objekt '{{assetName}}' wirklich aufheben möchten?", + "unassign-assets-from-edge-text": "Nach der Bestätigung werden alle ausgewählten Objekte nicht zugewiesen und sind für den Rand nicht zugänglich." + }, + "attribute": { + "attributes": "Eigenschaften", + "latest-telemetry": "Neueste Telemetrie", + "attributes-scope": "Entitätseigenschaftsbereich", + "scope-latest-telemetry": "Neueste Telemetrie", + "scope-client": "Client Eigenschaften", + "scope-server": "Server Eigenschaften", + "scope-shared": "Gemeinsame Eigenschaften", + "add": "Eigenschaft hinzufügen", + "key": "Schlüssel", + "last-update-time": "Datum der letzten Aktualisierung", + "key-required": "Eigenschaftsschlüssel ist erforderlich.", + "value": "Wert", + "value-required": "Eigenschaftswert ist erforderlich.", + "delete-attributes-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Eigenschaft} other {# Eigenschaften} } löschen möchten?", + "delete-attributes-text": "Seien Sie vorsichtig, nach der Bestätigung werden alle ausgewählten Eigenschaften entfernt.", + "delete-attributes": "Eigenschaften löschen", + "enter-attribute-value": "Geben Sie den Eigenschaftswert ein", + "show-on-widget": "Im Widget anzeigen", + "widget-mode": "Widget-Modus", + "next-widget": "Nächstes Widget", + "prev-widget": "Vorheriges Widget", + "add-to-dashboard": "Zum Dashboard hinzufügen", + "add-widget-to-dashboard": "Widget zum Dashboard hinzufügen", + "selected-attributes": "{ count, plural, 1 {1 Eigenschaft} other {# Eigenschaften} } ausgewählt", + "selected-telemetry": "{ count, plural, 1 {1 Telemetrieeinheit } other {# Telemetrieeinheiten} } ausgewählt" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Audit-Protokolle", + "timestamp": "Zeitstempel", + "entity-type": "Entitätstype", + "entity-name": "Entitätsname", + "user": "User", + "type": "Typ", + "status": "Status", + "details": "Details", + "type-added": "Hinzugefügt", + "type-deleted": "Gelöscht", + "type-updated": "Aktualisiert", + "type-attributes-updated": "Eigenschaften aktualisiert", + "type-attributes-deleted": "Eigenschaften gelöscht", + "type-rpc-call": "RPC Aufruf", + "type-credentials-updated": "Anmeldeinformationen wurden aktualisiert", + "type-assigned-to-customer": "Kunden Zuordnung", + "type-unassigned-from-customer": "Kunden Zuordnung aufgehoben", + "type-assigned-to-edge": "Rand Zuordnung", + "type-unassigned-from-edge": "Rand Zuordnung aufgehoben", + "type-activated": "Aktiviert", + "type-suspended": "Ausgesetzt", + "type-credentials-read": "Anmeldeinformationen gelesen", + "type-attributes-read": "Eigenschaften gelesen", + "type-relation-add-or-update": "Beziehung aktualisiert", + "type-relation-delete": "Beziehung gelöscht", + "type-relations-delete": "Alle Beziehungen gelöscht", + "type-alarm-ack": "Bestätigt", + "type-alarm-clear": "Gelöscht", + "type-login": "Anmeldung", + "type-logout": "Ausloggen", + "type-lockout": "Aussperrung", + "status-success": "Erfolg", + "status-failure": "Fehler", + "audit-log-details": "Audit-Protokoll Details", + "no-audit-logs-prompt": "Keine Protokolle gefunden", + "action-data": "Aktionsdaten", + "failure-details": "Fehlerdetails", + "search": "Audit-Protokolle durchsuchen", + "clear-search": "Suche leeren" + }, + "confirm-on-exit": { + "message": "Sie haben nicht gespeicherte Änderungen. Möchten Sie diese Seite wirklich verlassen?", + "html-message": "Sie haben nicht gespeicherte Änderungen.
    Möchten Sie diese Seite wirklich verlassen?", + "title": "Nicht gespeicherte Änderungen" + }, + "contact": { + "country": "Land", + "city": "Stadt", + "state": "Bundesland", + "postal-code": "Postleitzahl", + "postal-code-invalid": "Ungültiges Format der Postleitzahl.", + "address": "Adresse", + "address2": "Adresse 2", + "phone": "Telefon", + "email": "E-Mail", + "no-address": "Keine Adresse" + }, + "common": { + "username": "Benutzername", + "password": "Passwort", + "enter-username": "Benutzername eingeben", + "enter-password": "Passwort eingeben", + "enter-search": "Suche eingeben", + "created-time": "Erstellungszeit" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binär (Base64)" + }, + "customer": { + "customer": "Kunde", + "customers": "Kunden", + "management": "Kundenverwaltung", + "dashboard": "Kunden Dashboard", + "dashboards": "Kunden Dashboards", + "devices": "Kundengeräte", + "entity-views": "Kunden Entitätsansichten", + "assets": "Kundenobjekte", + "public-dashboards": "Öffentliche Dashboards", + "public-devices": "Öffentliche Geräte", + "public-assets": "Öffentliche Objekte", + "public-entity-views": "Öffentliche Entitätsansichten", + "public-edges": "Öffentliche Rand", + "add": "Kunde hinzufügen", + "delete": "Kunde löschen", + "manage-customer-users": "Kundenbenutzer verwalten", + "manage-customer-devices": "Kundengeräte verwalten", + "manage-customer-dashboards": "Kunden-Dashboards verwalten", + "manage-public-devices": "Öffentliche Geräte verwalten", + "manage-public-dashboards": "Öffentliche Dashboards verwalten", + "manage-customer-assets": "Kundenobjekte verwalten", + "manage-customer-edges": "Randobjekte verwalten", + "manage-public-assets": "Öffentliche Objekte verwalten", + "manage-public-edges": "Öffentliche Rand verwalten", + "add-customer-text": "Neuen Kunden hinzufügen", + "no-customers-text": "Keine Kunden gefunden", + "customer-details": "Kundendetails", + "delete-customer-title": "Sind Sie sicher, dass der Kunde '{{customerTitle}}' gelöscht werden soll?", + "delete-customer-text": "Vorsicht, nach Bestätigung wird der Kunde und alle zugehörigen Daten nicht wiederherstellbar gelöscht.", + "delete-customers-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Kunde} other {# Kunden} } löschen möchten?", + "delete-customers-action-title": "{ count, plural, 1 {1 Kunde} other {# Kunden} } löschen", + "delete-customers-text": "Seien Sie vorsichtig, nach der Bestätigung werden alle ausgewählten Kunden und alle zugehörigen Daten nicht wiederherstellbar gelöscht.", + "manage-users": "User verwalten", + "manage-assets": "Objekte verwalten", + "manage-devices": "Geräte verwalten", + "manage-dashboards": "Dashboards verwalten", + "title": "Titel", + "title-required": "Titel ist erforderlich.", + "description": "Beschreibung", + "details": "Details", + "events": "Ereignisse", + "copyId": "Kunden-ID kopieren", + "idCopiedMessage": "Kunden-ID wurde in die Zwischenablage kopiert", + "select-customer": "Kunden auswählen", + "no-customers-matching": "Keine Kunden für '{{entity}}' gefunden.", + "customer-required": "Kunde ist erforderlich", + "select-default-customer": "Wählen Sie den Standardkunden aus.", + "default-customer": "Standardkunde", + "edge-instances": "Kunden Rand", + "default-customer-required": "Ein Standardkunde ist erforderlich, um das Dashboard auf Mandantenebene zu testen." + }, + "datetime": { + "date-from": "Datum von", + "time-from": "Zeit von", + "date-to": "Datum bis", + "time-to": "Zeit bis" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Dashboardverwaltung", + "view-dashboards": "Dashboards anzeigen", + "add": "Dashboard hinzufügen", + "assign-dashboard-to-customer": "Dashboard(s) dem Kunden zuordnen", + "assign-dashboard-to-customer-text": "Bitte wählen Sie die Dashboards aus, die Sie dem Kunden zuordnen möchten", + "assign-to-customer-text": "Bitte wählen Sie den Kunden aus, dem die Dashboards zugeordnet werden sollen", + "assign-to-customer": "Kunden zuordnen", + "unassign-from-customer": "Zuordnung zum Kunden aufheben", + "make-public": "Dashboard öffentlich machen", + "make-private": "Dashboard privat machen", + "manage-assigned-customers": "Zugeordnete Kunden verwalten", + "assigned-customers": "Zugeordnete Kunden", + "assign-to-customers": "Dashboard(s) zu Kunden zuweisen", + "assign-to-customers-text": "Bitte wählen Sie den Kunden aus, dem Sie das Dashboard(s) zuweisen möchten", + "unassign-from-customers": "Zuordnung von Dashboard(s) zu Kunden aufheben", + "unassign-from-customers-text": "Bitte wählen Sie die Kunden aus, für die die Zuordnung von Dashboard(s) aufgehoben werden soll", + "no-dashboards-text": "Keine Dashboard(s) gefunden", + "no-widgets": "Keine Widgets konfiguriert", + "add-widget": "Neues Widget hinzufügen", + "title": "Titel", + "select-widget-title": "Widget auswählen", + "select-widget-subtitle": "Liste der verfügbaren Widget-Typen", + "delete": "Dashboard löschen", + "title-required": "Titel ist erforderlich.", + "description": "Beschreibung", + "details": "Details", + "dashboard-details": "Dashboard-Details", + "add-dashboard-text": "Neues Dashboard hinzufügen", + "assign-dashboards": "Dashboards zuweisen", + "assign-new-dashboard": "Neues Dashboard zuweisen", + "assign-dashboards-text": "Zuordnen { count, plural, 1 {1 Dashboard} other {# Dashboards} } zu Kunden", + "unassign-dashboards-action-text": "Zuordnung { count, plural, 1 {1 Dashboard} other {# Dashboards} } vom Kunden aufheben", + "delete-dashboards": "Dashboards löschen", + "unassign-dashboards": "Zuordnung von Dashboards aufheben", + "unassign-dashboards-action-title": "Zuordnung { count, plural, 1 {1 Dashboard} other {# Dashboards} } vom Kunden aufheben", + "delete-dashboard-title": "Sind Sie sicher, dass Sie das Dashboard '{{dashboardTitle}}' löschen möchten?", + "delete-dashboard-text": "Vorsicht, nach Bestätigung werden das Dashboard und alle zugehörigen Daten nicht mehr wiederhergestellt.", + "delete-dashboards-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Dashboard} other {# Dashboards} } löschen möchten?", + "delete-dashboards-action-title": "Löschen { count, plural, 1 {1 Dashboard} other {# Dashboards} }", + "delete-dashboards-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Dashboards entfernt und alle zugehörigen Daten nicht mehr wiederhergestellt.", + "unassign-dashboard-title": "Sind Sie sicher, dass Sie die Zuordnung zum Dashboard '{{dashboardTitle}}' aufheben möchten?", + "unassign-dashboard-text": "Nach der Bestätigung wird die Zuordnung des Dashboards aufgehoben und es ist für den Kunden nicht mehr zugänglich.", + "unassign-dashboard": "Zuordnung zum Kunden aufheben", + "unassign-dashboards-title": "Sind Sie sicher, dass Sie die Zuordug aufheben möchten { count, plural, 1 {1 Dashboard} other {# Dashboards} }?", + "unassign-dashboards-text": "Nach der Bestätigung wird die Zuordnung aller ausgewählten Dashboards aufgehoben und sie sind für den Kunden nicht mehr zugänglich.", + "public-dashboard-title": "Dashboard wurde veröffentlicht", + "public-dashboard-text": "Ihr Dashboard {{dashboardTitle}} ist jetzt öffentlich und über nächste Öffentlichkeit zugänglich link:", + "public-dashboard-notice": "Note: Vergessen Sie nicht, verwandte Geräte öffentlich zu machen, um auf Ihre Daten zugreifen zu können.", + "make-private-dashboard-title": "Sind Sie sicher, dass Sie das Dashboard '{{dashboardTitle}}' privatisieren möchten?", + "make-private-dashboard-text": "Nach der Bestätigung wird das Dashboard privatisiert und ist für andere nicht zugänglich.", + "make-private-dashboard": "Dashboard privatisieren", + "socialshare-text": "'{{dashboardTitle}}' Bereitgestellt vom ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' Bereitgestellt vom ThingsBoard", + "select-dashboard": "Dashboard auswählen", + "no-dashboards-matching": "Es wurden keine passenden Dashboards '{{entity}}' gefunden.", + "dashboard-required": "Dashboard ist erforderlich.", + "select-existing": "Existierendes Dashboard auswählen", + "create-new": "Neues Dashboard erstellen", + "new-dashboard-title": "Neuer Dashboard Titel", + "open-dashboard": "Dashboard öffnen", + "set-background": "Hintergrund einstellen", + "background-color": "Hintergrundfarbe", + "background-image": "Hintergrundbild", + "background-size-mode": "Hintergrundgrößenmodus", + "no-image": "Kein Bild ausgewählt", + "drop-image": "Legen Sie ein Bild ab oder klicken Sie, um eine hochzuladende Datei auszuwählen.", + "settings": "Einstellungen", + "columns-count": "Spalten zählen", + "columns-count-required": "Die Anzahl der Spalten ist erforderlich.", + "min-columns-count-message": "Es müssen mindestens 10 Spalten vorhanden sein.", + "max-columns-count-message": "Es sind maximal 100 Spalten zulässig.", + "widgets-margins": "Abstand zwischen den Widgets", + "horizontal-margin": "Horizontaler Abstand", + "horizontal-margin-required": "Horizontaler Abstandswert ist erforderlich.", + "min-horizontal-margin-message": "Der horizontale Abstandswert muss mindestens 0 betragen.", + "max-horizontal-margin-message": "Der horizontale Abstandswert beträgt maximal 50.", + "vertical-margin": "Vertikaler Abstand", + "vertical-margin-required": "Vertikaler Abstandswert ist erforderlich.", + "min-vertical-margin-message": "Der vertikale Abstandswert muss mindestens 0 betragen.", + "max-vertical-margin-message": "Der vertikale Abstandswert beträgt maximal 50.", + "autofill-height": "Layouthöhe automatisch füllen", + "mobile-layout": "Mobile Layouteinstellungen", + "mobile-row-height": "Mobile Zeilenhöhe, px", + "mobile-row-height-required": "Ein mobiler Zeilenhöhenwert ist erforderlich.", + "min-mobile-row-height-message": "Der Mindestwert für die mobile Zeilenhöhe beträgt 5 Pixel.", + "max-mobile-row-height-message": "Der Höchstwert für die mobile Zeilenhöhe beträgt 200 Pixel.", + "display-title": "Display Dashboard Titel", + "toolbar-always-open": "Werkzeugleiste geöffnet lassen", + "title-color": "Titelfarbe ", + "display-dashboards-selection": "Auswahl der Dashboards anzeigen", + "display-entities-selection": "Auswahl der Einheiten zulassen", + "display-dashboard-timewindow": "Zeitfenster anzeigen", + "display-dashboard-export": "Export anzeigen", + "import": "Dashboard importieren", + "export": "Dashboard exportieren", + "export-failed-error": "Dashboard kann nicht exportiert werden: {{error}}", + "create-new-dashboard": "Neues Dashboard erstellen", + "dashboard-file": "Dashboard-Datei", + "invalid-dashboard-file-error": "Dashboard kann nicht importiert werden: Ungültige Dashboard-Datenstruktur.", + "dashboard-import-missing-aliases-title": "Konfigurieren Sie die von importierten Dashboards verwendeten Aliasnamen", + "create-new-widget": "Neues Widget erstellen", + "import-widget": "Widget importieren", + "widget-file": "Widget-Datei", + "invalid-widget-file-error": "Widget kann nicht importiert werden: Ungültige Widget-Datenstruktur.", + "widget-import-missing-aliases-title": "Konfigurieren Sie die von importierten Widgets verwendeten Aliase", + "open-toolbar": "Dashboard-Werkzeugleiste öffnen", + "close-toolbar": "Werkzeugleiste schließen", + "configuration-error": "Konfigurationsfehler", + "alias-resolution-error-title": "Konfigurationsfehler für Dashboard-Aliasnamen", + "invalid-aliases-config": "Es konnten keine Geräte gefunden werden, die mit dem Aliase-Filter übereinstimmen.
    Bitte wenden Sie sich an Ihren Administrator, um dieses problem zu beheben.", + "select-devices": "Geräte auswählen", + "assignedToCustomer": "Dem Kunden zugewiesen", + "assignedToCustomers": "Kunden zugwiesen", + "public": "Öffentlich", + "public-link": "Öffentlicher Link", + "copy-public-link": "Öffentlichen Link kopieren", + "public-link-copied-message": "Der öffentliche Link des Dashboards wurde in die Zwischenablage kopiert", + "manage-states": "Dashboard-Status verwalten", + "states": "Dashboard-Status", + "search-states": "Dashboard-Status suchen", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } ausgewählt", + "edit-state": "Dashboard-Status bearbeiten", + "delete-state": "Dashboard-Status löschen", + "add-state": "Dashboard-Status hinzufügen", + "state": "Dashboard-Status", + "state-name": "Name", + "state-name-required": "Name des Dashboard-Status ist erforderlich.", + "state-id": "Status-Id", + "state-id-required": "Dashboard-Status-ID ist erforderlich.", + "state-id-exists": "Dashboard-Status mit derselben ID ist bereits vorhanden .", + "is-root-state": "Grundzustand", + "delete-state-title": "Dashboard-Status löschen", + "delete-state-text": "Sind Sie sicher, dass Sie den Dashboard-Status '{{stateName}}' löschen möchten?", + "show-details": "Details anzeigen", + "hide-details": "Details ausblenden", + "select-state": "Soll-Zustand auswählen", + "state-controller": "Zustandssteuerung", + "unassign-dashboard-from-edge-text": "Nach der Bestätigung wird die Zuordnung des Dashboards aufgehoben und es ist für der Rand nicht mehr zugänglich.", + "unassign-dashboards-from-edge-text": "Nach der Bestätigung wird die Zuordnung aller ausgewählten Dashboards aufgehoben und sie sind für den Rand nicht mehr zugänglich.", + "assign-dashboard-to-edge": "Dashboard(s) dem Rand zuordnen", + "assign-dashboard-to-edge-text": "Bitte wählen Sie die Dashboards aus, die Sie dem Rand zuordnen möchten" + }, + "datakey": { + "settings": "Einstellungen", + "advanced": "Erweitert", + "label": "Bezeichnung", + "color": "Farbe", + "units": "Maßeinheit die neben dem Wert angezeigt wird", + "decimals": "Anzahl der Stellen nach dem Komma", + "data-generation-func": "Daten generieren", + "use-data-post-processing-func": "Datenverarbeitungsfunktion verwenden", + "configuration": "Datenschlüsselkonfiguration", + "timeseries": "Zeitreihe", + "attributes": "Eigenschaften", + "alarm": "Alarmfelder", + "timeseries-required": "Entity-Zeitreihen sind erforderlich.", + "timeseries-or-attributes-required": "Entity-Zeitreihen/Eigenschaften sind erforderlich.", + "maximum-timeseries-or-attributes": "Maximum { count, plural, 1 {1 Zeitreihe/Eigenschaft ist erlaubt} other {# Zeitreihen/Eigenschaften sind erlaubt} }.", + "alarm-fields-required": "Alarmfelder sind erforderlich.", + "function-types": "Funktionsarten", + "function-types-required": "Funktionstypen sind erforderlich.", + "maximum-function-types": "Maximal { count, plural, 1 {1 Funktionstyp ist erlaubt} other {# Funktionstypen sind erlaubt} }.", + "time-description": "Zeitstempel des aktuellen Wertes;", + "value-description": "Der aktuelle Wert;", + "prev-value-description": "Ergebnis des vorherigen Funktionsaufrufs;", + "time-prev-description": "Zeitmarke des vorherigen Wertes;", + "prev-orig-value-description": "Ursprünglicher vorheriger Wert;" + }, + "datasource": { + "type": "Datenquellentyp", + "name": "Name", + "add-datasource-prompt": "Bitte Datenquelle hinzufügen" + }, + "details": { + "edit-mode": "Bearbeitungsmodus", + "toggle-edit-mode": "Bearbeitungsmodus umschalten" + }, + "device": { + "device": "Gerät", + "device-required": "Gerät ist erforderlich.", + "devices": "Geräte", + "management": "Geräte verwalten", + "view-devices": "Geräte anzeigen", + "device-alias": "Geräte-Alias", + "aliases": "Gerätealiasnamen", + "no-alias-matching": "'{{alias}}' nicht gefunden.", + "no-aliases-found": "Keine Aliase gefunden.", + "no-key-matching": "'{{key}}' nicht gefunden.", + "no-keys-found": "Keine Schlüssel gefunden.", + "create-new-alias": "Neues Alias erstellen!", + "create-new-key": "Neuen Schlüssel erstellen!", + "duplicate-alias-error": "Doppelter Alias gefunden '{{alias}}'.
    Gerätealiasnamen müssen innerhalb des Dashboards eindeutig sein.", + "configure-alias": "Alias '{{alias}}' konfigurieren", + "no-devices-matching": "Keine passenden Geräte '{{entity}}' gefunden.", + "alias": "Alias", + "alias-required": "Geräte-Alias ist erforderlich.", + "remove-alias": "Geräte-Alias entfernen", + "add-alias": "Geräte-Alias hinzufügen", + "name-starts-with": "Gerätename beginnt mit", + "device-list": "Geräteliste", + "use-device-name-filter": "Filter verwenden", + "device-list-empty": "Keine Geräte ausgewählt.", + "device-name-filter-required": "Der Gerätenamefilter ist erforderlich.", + "device-name-filter-no-device-matched": "Keine Geräte beginnend mit '{{device}}' gefunden.", + "add": "Gerät hinzufügen", + "assign-to-customer": "Kunden zuordnen", + "assign-device-to-customer": "Gerät(e) dem Kunden zuordnen", + "assign-device-to-customer-text": "Bitte wählen Sie die Geräte aus, die Sie dem Kunden zuordnen möchten", + "make-public": "Gerät veröffentlichen", + "make-private": "Gerät privatisieren", + "no-devices-text": "Keine Geräte gefunden", + "assign-to-customer-text": "Bitte wählen Sie einen Kunden aus, dem die Geräte zugeordnet werden sollen", + "device-details": "Gerätedetails", + "add-device-text": "Neues Gerät hinzufügen", + "credentials": "Zugangsdaten", + "manage-credentials": "Zugangsdaten verwalten", + "delete": "Gerät löschen", + "assign-devices": "Gerät zuordnen", + "assign-devices-text": "{ count, plural, 1 {1 Gerät} other {# Geräte} } dem Kunden zuordnen", + "delete-devices": "Geräte löschen", + "unassign-from-customer": "Zuordnung zum Kunden aufheben", + "unassign-devices": "Nicht zugeordnete Geräte", + "unassign-devices-action-title": "Zuordnung von { count, plural, 1 {1 Gerät} other {# Geräte} } zum Kunden aufheben", + "assign-new-device": "Neues Gerät zuordnen", + "make-public-device-title": "Sind Sie sicher, dass Sie das Gerät '{{deviceName}}' öffentlich machen möchten?", + "make-public-device-text": "Nach der Bestätigung werden das Gerät und dessen Daten öffentlich und für andere zugänglich.", + "make-private-device-title": "Sind Sie sicher, dass Sie das Gerät '{{deviceName}}' privat machen möchten?", + "make-private-device-text": "Nach der Bestätigung werden das Gerät und dessen Daten privat und sind für andere nicht mehr zugänglich.", + "view-credentials": "Zugangsdaten anzeigen", + "delete-device-title": "Möchten Sie das Gerät '{{deviceName}}' wirklich löschen?", + "delete-device-text": "Vorsicht, nach Bestätigung werden das Gerät und alle zugehörigen Daten nicht mehr wiederhergestellt.", + "delete-devices-title": "Sind Sie sicher, dass Sie löschen möchten { count, plural, 1 {1 Gerät} other {# Geräte} }?", + "delete-devices-action-title": "Löschen { count, plural, 1 {1 Gerät} other {# Geräte} }", + "delete-devices-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Geräte entfernt und alle zugehörigen Daten werden nicht mehr wiederhergestellt.", + "unassign-device-title": "Sind Sie sicher, dass Sie die Zuordnung zum Gerät '{{deviceName}}' wirklich aufheben möchten?", + "unassign-device-text": "Nach der Bestätigung ist das Gerät nicht zugeordnet und für den Kunden nicht zugänglich.", + "unassign-device": "Nicht zugeordnete Geräte", + "unassign-devices-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Gerät} other {# Geräte} } nicht mehr zuordnen möchten?", + "unassign-devices-text": "Nach der Bestätigung werden alle ausgewählten Geräte nicht zugewiesen und sind für den Kunden nicht zugänglich.", + "device-credentials": "Geräte Zugangsdaten", + "credentials-type": "Art der Zugangsdaten", + "access-token": "Zugangs-Token", + "access-token-required": "Zugangs-Token ist erforderlich.", + "access-token-invalid": "Die Länge des Zugangs-Tokens muss zwischen 1 und 32 Zeichen betragen.", + "secret": "Geheimnis", + "secret-required": "Geheimnis ist erforderlich.", + "device-type": "Gerätetyp", + "device-type-required": "Gerätetyp ist erforderlich.", + "select-device-type": "Gerätetyp auswählen", + "enter-device-type": "Gerätetyp eingeben", + "any-device": "Jedes Gerät", + "no-device-types-matching": "Keine passenden Gerätetypen '{{entitySubtype}}' gefunden.", + "device-type-list-empty": "Kein Gerätetyp ausgewählt.", + "device-types": "Gerätetypen", + "name": "Name", + "name-required": "Name ist erforderlich.", + "description": "Beschreibung", + "events": "Ereignisse", + "details": "Details", + "copyId": "Geräte-ID kopieren", + "copyAccessToken": "Zugangs-Token kopieren", + "idCopiedMessage": "Geräte-ID wurde in die Zwischenablage kopiert", + "accessTokenCopiedMessage": "Geräte-Zugangs-Token wurde in die Zwischenablage kopiert", + "assignedToCustomer": "Dem Kunden zuordnen", + "unable-delete-device-alias-title": "Geräte-Alias kann nicht gelöscht werden", + "unable-delete-device-alias-text": "Geräte-Alias '{{deviceAlias}}' kann nicht gelöscht werden, da er von den folgenden Widgets verwendet wird:
    {{widgetsList}}", + "is-gateway": "Ist ein Gateway", + "public": "Öffentlich", + "device-public": "Gerät ist öffentlich", + "select-device": "Gerät auswählen", + "assign-device-to-edge-text":"Bitte wählen Sie die Geräte aus, die Sie dem Rand zuordnen möchten", + "unassign-device-from-edge-title": "Sind Sie sicher, dass Sie die Zuordnung zum Gerät '{{deviceName}}' wirklich aufheben möchten?", + "unassign-device-from-edge-text": "Nach der Bestätigung ist das Gerät nicht zugeordnet und für den Kunden nicht zugänglich.", + "unassign-devices-from-edge-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Gerät} other {# Geräte} } nicht mehr zuordnen möchten?", + "unassign-devices-from-edge-text": "Nach der Bestätigung werden alle ausgewählten Geräte nicht zugewiesen und sind für den Rand nicht zugänglich." + }, + "dialog": { + "close": "Dialog schließen" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Kanteninstanzen", + "edge-file": "Edge-Datei", + "management": "Rand verwalten", + "no-edges-matching": "Keine passenden Rand '{{entity}}' gefunden.", + "add": "Rand hinzufügen", + "no-edges-text": "Kein Rand gefunden.", + "edge-details": "Details der Rand", + "add-edge-text": "Neue Rand hinzufügen", + "delete": "Rand löschen", + "delete-edge-title": "Möchten Sie des Rands wirklich löschen '{{edgeName}}'?", + "delete-edge-text": "Seien Sie vorsichtig, nach der Bestätigung werden der Rand und alle zugehörigen Daten nicht wiederhergestellt.", + "delete-edges-title": "Sind Sie sicher, dass Sie die Rand löschen möchten { count, plural, 1 {1 Rand} other {# Rand} }?", + "delete-edges-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Rand entfernt und alle zugehörigen Daten werden nicht wiederhergestellt.", + "name": "Name", + "name-starts-with": "Der Kantenname beginnt mit", + "name-required": "Name ist erforderlich.", + "description": "Beschreibung", + "details": "Details", + "events": "Ereignisse", + "copy-id": "Regelketten-ID kopieren", + "id-copied-message": "Regelketten-ID wurde in die Zwischenablage kopiert", + "sync": "Sync Edge", + "edge-required": "Rand ist erforderlich.", + "edge-type": "Randtyp", + "edge-type-required": "Randtyp ist erforderlich.", + "event-action": "Ereignisaktion", + "entity-id": "Entität ID", + "select-edge-type": "Randtyp auswählen", + "assign-to-customer": "Einem Kunden zuordnen", + "assign-to-customer-text": "Bitte wählen Sie den Kunden aus, dem die Rand zugeordnet werden sollen", + "assign-edge-to-customer": "Rand dem Kunden zuordnen", + "assign-edge-to-customer-text": "Bitte wählen Sie die Rand aus, die dem Kunden zugeordnet werden sollen", + "assignedToCustomer": "Dem Kunden zugewiesen", + "edge-public": "Edge ist öffentlich", + "assigned-to-customer": "Kunden Zuordnung", + "unassign-from-customer": "Kunden Zuordnung aufgehoben", + "unassign-edge-title": "Sind Sie sicher, dass Sie die Zuordnung zum Rand '{{edgeName}}' wirklich aufheben möchten?", + "unassign-edge-text": "Nach der Bestätigung ist der Rand nicht zugeordnet und für den Kunden nicht zugänglich.", + "unassign-edges-title": "Sind Sie sicher, dass Sie die Zuordnung aufheben möchten { count, plural, 1 {1 Rand} other {# Rand} }?", + "unassign-edges-text": "Nach der Bestätigung werden alle ausgewählten Kanten nicht zugewiesen und sind für den Kunden nicht zugänglich.", + "make-public": "Rand öffentlich machen", + "make-public-edge-title": "Sind Sie sicher, dass Sie der Rand '{{edgeName}}' öffentlich machen möchten?", + "make-public-edge-text": "Nach Bestätigung wird der Rabd und alle zugehörigen Daten anderen zugänglich gemacht.", + "make-private": "Rand privat machen", + "public": "Öffentlich", + "make-private-edge-title": "Sind Sie sicher, dass Sie der Rand '{{edgeName}}' privat machen möchten?", + "make-private-edge-text": "Nach der Bestätigung werden der Rand und dessen Daten privat und sind für andere nicht mehr zugänglich.", + "import": "Rand importieren", + "label": "Bezeichnung", + "load-entity-error": "Entität nicht gefunden. Fehler beim Laden der Informationen", + "assign-new-edge": "Neue Rand zuordnen", + "unassign-from-edge": "Rand zuweisen", + "edge-key": "Rand Schlüssel", + "copy-edge-key": "Rand Schlüssel kopieren", + "edge-key-copied-message": "Rand Schlüssel wurde in die Zwischenablage kopiert", + "edge-secret": "Rand Geheimnis", + "copy-edge-secret": "Rand Geheimnis kopieren", + "edge-secret-copied-message": "Rand Geheimnis wurde in die Zwischenablage kopiert", + "edge-assets": "Rand-Objekte verwalten", + "edge-devices": "Rand-Geräte verwalten", + "edge-entity-views": "Rand-Entitätsansichten verwalten", + "edge-dashboards": "Rand-Dashboards verwalten", + "edge-rulechains": "Kantenregelketten", + "assets": "Rand Objekte", + "devices": "Objekte Geräte", + "entity-views": "Objekte Entitätsansichten", + "dashboard": "Kanten-Dashboard", + "dashboards": "Rand Dashboards", + "rulechain-templates": "Regelkettenvorlagen", + "rulechains": "Rand Regelketten", + "search": "Kanten durchsuchen", + "selected-edges": "{count, plural, 1 {1 Rand} other {# Rand} } ausgewählt", + "any-edge": "Beliebige Kante", + "no-edge-types-matching": "Es wurden keine Kantentypen gefunden, die mit '{{entitySubtype}}' übereinstimmen.", + "edge-type-list-empty": "Keine Kantentypen ausgewählt.", + "edge-types": "Kantentypen", + "enter-edge-type": "Geben Sie den Kantentyp ein", + "deployed": "Bereitgestellt", + "pending": "Steht aus", + "downlinks": "Downlinks", + "no-downlinks-prompt": "Keine Downlinks gefunden", + "sync-process-started-successfully": "Synchronisierungsprozess erfolgreich gestartet!", + "missing-related-rule-chains-title": "In Edge fehlen verwandte Regelketten.", + "missing-related-rule-chains-text": "Randregelkette (n) zugewiesen Verwenden Sie Regelknoten, die Nachrichten an Regelkette (n) weiterleiten, die dieser Kante nicht zugeordnet sind.

    Liste der fehlenden Regelketten:
    {{missingRuleChains}}", + "widget-datasource-error": "Dieses Widget unterstützt nur EDGE-Entitätsdatenquellen" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Device", + "type-device-profile": "Device Profile", + "type-entity-view": "Entity View", + "type-alarm": "Alarm", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "action-type-added": "Added", + "action-type-deleted": "Deleted", + "action-type-updated": "Updated", + "action-type-post-attributes": "Post Attributes", + "action-type-attributes-updated": "Attributes Updated", + "action-type-attributes-deleted": "Attributes Deleted", + "action-type-timeseries-updated": "Timeseries Updated", + "action-type-credentials-updated": "Credentials Updated", + "action-type-assigned-to-customer": "Assigned to Customer", + "action-type-unassigned-from-customer": "Unassigned from Customer", + "action-type-relation-add-or-update": "Relation Add or Update", + "action-type-relation-deleted": "Relation Deleted", + "action-type-rpc-call": "RPC Call", + "action-type-alarm-ack": "Alarm Ack", + "action-type-alarm-clear": "Alarm Clear", + "action-type-assigned-to-edge": "Assigned to Edge", + "action-type-unassigned-from-edge": "Unassigned from Edge", + "action-type-credentials-request": "Credentials Request", + "action-type-entity-merge-request": "Entity Merge Request" + }, + "error": { + "unable-to-connect": "Es konnte keine Verbindung zum Server hergestellt werden! Bitte überprüfen Sie Ihre Internetverbindung.", + "unhandled-error-code": "Unbehandelter Fehlercode: {{errorCode}}", + "unknown-error": "Unbekannter Fehler" + }, + "entity": { + "entity": "Entität", + "entities": "Entitäten", + "aliases": "Entitäts-Aliasnamen", + "entity-alias": "Entitätsalias", + "unable-delete-entity-alias-title": "Alias der Entität kann nicht gelöscht werden", + "unable-delete-entity-alias-text": "Alias der Entität '{{entityAlias}}' kann nicht gelöscht werden, da es von den folgenden Widget(s) verwendet wird:
    {{widgetsList}}", + "duplicate-alias-error": "Doppelte Alias gefunden '{{alias}}'.
    Die Aliase der Entität müssen innerhalb des Dashboards eindeutig sein.", + "missing-entity-filter-error": "Fehlender Filter für Alias '{{alias}}'.", + "configure-alias": "Alias '{{alias}}' konfigurieren", + "alias": "Alias", + "alias-required": "Alias der Entität ist erforderlich.", + "remove-alias": "Alias der Entität entfernen", + "add-alias": "Alias der Entität erforderlich", + "entity-list": "Entitätsliste", + "entity-type": "Entitätstyp", + "entity-types": "Entitätstypen", + "entity-type-list": "Liste der Entitätstyp", + "any-entity": "Jede Entität", + "enter-entity-type": "Entitätstyp eingeben", + "no-entities-matching": "Keine passenden Entitäten für '{{entity}}' gefunden.", + "no-entity-types-matching": "Keine passende Entitätstypen für '{{entityType}}' gefunden.", + "name-starts-with": "Name beginnt mit", + "use-entity-name-filter": "Filter verwenden", + "entity-list-empty": "Keine Entitäten ausgewählt.", + "entity-type-list-empty": "Keine Entitättypen ausgewählt.", + "entity-name-filter-required": "Entitätsnamenfilter ist erforderlich.", + "entity-name-filter-no-entity-matched": "Keine Entitäten beginnend mit '{{entity}}' gefunden.", + "all-subtypes": "Alle", + "select-entities": "Entitäten auswählen", + "no-aliases-found": "Keine Aliase gefunden.", + "no-alias-matching": "'{{alias}}' nicht gefunden.", + "create-new-alias": "Erstellen Sie einen neuen Alias!", + "key": "Schlüssel", + "key-name": "Name des Schlüssels", + "no-keys-found": "Kein Schlüssel gefunden.", + "no-key-matching": "'{{key}}' nicht gefunden.", + "create-new-key": "Erstellen Sie einen neuen Schlüssel!", + "type": "Typ", + "type-required": "Typ der Entität ist erforderlich.", + "type-device": "Gerät", + "type-devices": "Geräte", + "list-of-devices": "{ count, plural, 1 {Ein Gerät} other {Liste von # Geräten} }", + "device-name-starts-with": "Geräte beginnend mit '{{prefix}}'", + "type-asset": "Objekt", + "type-assets": "Objekte", + "list-of-assets": "{ count, plural, 1 {Ein Objekt} other {Liste von # Objekten} }", + "asset-name-starts-with": "Objekte beginnend mit '{{prefix}}'", + "type-entity-view": "Entitätsansicht", + "type-entity-views": "Entitätsansichten", + "list-of-entity-views": "{ count, plural, 1 {Eine Entitätsansicht} other {Liste von # Entitätsansichten} }", + "entity-view-name-starts-with": "Entitätsansichten beginnend mit'{{prefix}}'", + "type-rule": "Regel", + "type-rules": "Regeln", + "list-of-rules": "{ count, plural, 1 {Eine Regel} other {Liste von # Regeln} }", + "rule-name-starts-with": "Regeln beginnend mit '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, 1 {Ein Plugin} other {Liste von # Plugins} }", + "plugin-name-starts-with": "Plugins beginnend mit '{{prefix}}'", + "type-tenant": "Mandant", + "type-tenants": "Mandanten", + "list-of-tenants": "{ count, plural, 1 {Ein Mandant} other {Liste von # Mandanten} }", + "tenant-name-starts-with": "Mandanten beginnend mit '{{prefix}}'", + "type-customer": "Kunde", + "type-customers": "Kunden", + "list-of-customers": "{ count, plural, 1 {Ein Kunde} other {Liste von # Kunden} }", + "customer-name-starts-with": "Kunden beginnend mit '{{prefix}}'", + "type-user": "Benutzer", + "type-users": "Benutzer", + "list-of-users": "{ count, plural, 1 {Ein Benutzer} other {Liste von # Benutzern} }", + "user-name-starts-with": "Benutzer beginnend mit '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboards", + "list-of-dashboards": "{ count, plural, 1 {Ein Dashboard} other {Liste von # Dashboards} }", + "dashboard-name-starts-with": "Dashboards beginnend mit '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarme", + "list-of-alarms": "{ count, plural, 1 {Ein Alarm} other {Liste von # Alarmen} }", + "alarm-name-starts-with": "Alarme, beginnend mit '{{prefix}}'", + "type-rulechain": "Regelkette", + "type-rulechains": "Regelketten", + "list-of-rulechains": "{ count, plural, 1 {Eine Regelkette} other {Liste von # Regelketten} }", + "rulechain-name-starts-with": "Regelketten beginnend mit '{{prefix}}'", + "type-rulenode": "Regelknoten", + "type-rulenodes": "Regelknoten", + "list-of-rulenodes": "{ count, plural, 1 {Ein Regelknoten} other {Liste von # Regelknoten} }", + "rulenode-name-starts-with": "Regelknoten beginnend mit '{{prefix}}'", + "type-edge": "Randtyp", + "type-edges": "Randtyp", + "list-of-edges": "{ count, plural, 1 {1 Rand} other {# Rand} }", + "edge-name-starts-with": "Rand beginnend mit '{{prefix}}'", + "type-current-customer": "Aktueller Kunde", + "search": "Entitäten suchen", + "selected-entities": "{ count, plural, 1 {Entität} other {# Entitäten} } ausgewählt", + "entity-name": "Entitätsname", + "details": "Entitätsdetails", + "no-entities-prompt": "Keine Entitäten gefunden", + "no-data": "Keine Daten zum Anzeigen", + "columns-to-display": "Anzuzeigende Spalten" + }, + "entity-view": { + "entity-view": "Entitätsansicht", + "entity-view-required": "Entitätsansicht ist erforderlich.", + "entity-views": "Entitätsansichten", + "management": "Entitätsansichten verwalten", + "view-entity-views": "Entitätsansichten anzeigen", + "entity-view-alias": "Entitätsansichtsalias", + "aliases": "Entitätsansichten-Aliase", + "no-alias-matching": "'{{alias}}' nicht gefunden.", + "no-aliases-found": "Keine Aliase gefunden.", + "no-key-matching": "'{{key}}' nicht gefunden.", + "no-keys-found": "Keine Schlüssel gefunden.", + "create-new-alias": "Neuen Alias erstellen!", + "create-new-key": "Neuen Schlüssel erstellen!", + "duplicate-alias-error": "Doppelter Alias gefunden '{{alias}}'.
    Aliase der Entitätsansicht müssen innerhalb des Dashboards eindeutig sein.", + "configure-alias": "Alias '{{alias}}' konfigurieren", + "no-entity-views-matching": "Keine passenden Entitätsansichten für '{{entity}}' gefunden.", + "alias": "Alias", + "alias-required": "Alias der Entitätsansicht erforderlich.", + "remove-alias": "Alias der Entitätsansicht entfernen", + "add-alias": "Alias für die Entitätsansicht hinzufügen", + "name-starts-with": "Entitätsansichtsname beginnend mit", + "entity-view-list": "Liste der Entitätsansichten", + "use-entity-view-name-filter": "Filter anwenden", + "entity-view-list-empty": "Keine der Entitätsansichten ausgewählt.", + "entity-view-name-filter-required": "Filterung nach Entitätsansichtenname erforderlich.", + "entity-view-name-filter-no-entity-view-matched": "Keine Entitätsansichten beginnend mit '{{entityView}}' wurden gefunden.", + "add": "Entitätsansicht hinzufügen", + "assign-to-customer": "Einem Kunden zuordnen", + "assign-entity-view-to-customer": "Entitätsansichten dem Kunden zuordnen", + "assign-entity-view-to-customer-text": "Bitte wählen Sie die Entitätsansichten aus, die dem Kunden zugeordnet werden sollen", + "no-entity-views-text": "Keine Entitätsansichten gefunden", + "assign-to-customer-text": "Bitte wählen Sie den Kunden aus, dem die Entitätsansichten zugeordnet werden sollen", + "entity-view-details": "Details der Entitätsansicht", + "add-entity-view-text": "Neue Entitätsansicht hinzufügen", + "delete": "Entitätsansicht löschen", + "assign-entity-views": "Entitätsansicht zuordnen", + "assign-entity-views-text": "Dem Kunden { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichten} } zuordnen", + "delete-entity-views": "Entitätsansichten löschen", + "unassign-from-customer": "Zuordnung zum Kunden aufheben", + "unassign-entity-views": "Zuordnung der Entitätsansichten aufheben", + "unassign-entity-views-action-title": "Die Zuordnung { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichten} } zum Kunden aufheben", + "assign-new-entity-view": "Neue Entitätsansicht zuordnen", + "delete-entity-view-title": "Möchten Sie die Entitätsansicht wirklich löschen '{{entityViewName}}'?", + "delete-entity-view-text": "Seien Sie vorsichtig, nach der Bestätigung werden die Entitätsansicht und alle zugehörigen Daten nicht wiederhergestellt.", + "delete-entity-views-title": "Sind Sie sicher, dass Sie die Entitätsansichten löschen möchten { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichten} }?", + "delete-entity-views-action-title": "Löschen { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichten} }", + "delete-entity-views-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Entitätsansichten entfernt und alle zugehörigen Daten werden nicht wiederhergestellt.", + "unassign-entity-view-title": "Möchten Sie die Zuordnung der Entitätsansicht '{{entityViewName}}' wirklich aufheben?", + "unassign-entity-view-text": "Nach der Bestätigung wird die Zuordnung der Entitätsansicht aufgehoben und ist für den Kunden nicht mehr zugänglich.", + "unassign-entity-view": "Zuordnung der Entitätsansicht aufheben", + "unassign-entity-views-title": "Sind Sie sicher, dass Sie die Zuordnung aufheben möchten { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichten} }?", + "unassign-entity-views-text": "Nach der Bestätigung werden die Zuordnungen der ausgewählten Entitätsansichten aufgehoben und sind für den Kunden nicht mehr zugänglich.", + "entity-view-type": "Entitätsansichtstyp", + "entity-view-type-required": "Entitätsansichtstyp ist erforderlich.", + "select-entity-view-type": "Entitätsansichtstyp auswählen", + "enter-entity-view-type": "Entitätsansichtstyp eingeben", + "any-entity-view": "Jede Entitätsansicht", + "no-entity-view-types-matching": "Es wurden keine passenden Entitätsansichtstypen für '{{entitySubtype}}' gefunden.", + "entity-view-type-list-empty": "Keine Entitätsansichtstypen ausgewählt.", + "entity-view-types": "Entitätsansichtstypen", + "name": "Name", + "name-required": "Name ist erforderlich.", + "assign-entity-view-to-edge": "Entitätsansicht dem Rand zuordnen", + "assign-entity-view-to-edge-text":"Bitte wählen Sie die Entitätsansicht aus, die dem Rand zugeordnet werden sollen", + "unassign-entity-view-from-edge-title": "Sind Sie sicher, dass Sie die Zuordnung für Entitätsansicht '{{entityViewName}}' aufheben möchten?", + "unassign-entity-view-from-edge-text": "Nach Bestätigung wird die Zuordnung des Entitätsansichts aufgehoben und es ist für den Kunden nicht mehr zugänglich.", + "unassign-entity-views-from-edge-action-title": "Rand { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichte} } aufheben", + "unassign-entity-view-from-edge": "Entitätsansichtzuordnung aufheben", + "unassign-entity-views-from-edge-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Entitätsansicht} other {# Entitätsansichte} } nicht mehr zuordnen möchten?", + "unassign-entity-views-from-edge-text": "Nach der Bestätigung werden alle ausgewählten Entitätsansicht nicht zugewiesen und sind für den Rand nicht zugänglich.", + "description": "Beschreibung", + "events": "Ereignisse", + "details": "Details", + "copyId": "Entitätsansichts-ID kopieren", + "assignedToCustomer": "Dem Kunden zuordnen", + "unable-entity-view-device-alias-title": "Alias der Entitätsansicht kann nicht gelöscht werden", + "unable-entity-view-device-alias-text": "Geräte-Alias '{{entityViewAlias}}' kann nicht gelöscht werden, da es von den folgenden widget(s):
    {{widgetsList}} verwendet wird", + "select-entity-view": "Entitätsansicht auswählen", + "make-public": "Entitätsansicht öffentlich machen", + "start-date": "Start-Datum", + "start-ts": "Start-Zeit", + "end-date": "Ende-Datum", + "end-ts": "Ende-Zeit", + "date-limits": "Datumslimits", + "client-attributes": "Client Eigenschaften", + "shared-attributes": "Gemeinsame Eigenschaften", + "server-attributes": "Server Eigenschaften", + "timeseries": "Zeitreihe", + "client-attributes-placeholder": "Client Eigenschaften", + "shared-attributes-placeholder": "Gemeinsame Eigenschaften", + "server-attributes-placeholder": "Server Eigenschaften", + "timeseries-placeholder": "Zeitreihe", + "target-entity": "Zielentität", + "attributes-propagation": "Eigenschaftsübertragung", + "attributes-propagation-hint": "Die Entitätsansicht kopiert automatisch die angegebenen Eigenschaften der Ziel-Entität, wenn Sie diese Entitätsansicht speichern oder aktualisieren. Aus Performance-Gründen werden die Attribute der Ziel-Entität nicht bei jeder Eigenschaftsänderung in die Entitätsansicht übertragen. Sie können die automatische Weitergabe aktivieren, indem Sie den Regelknoten \"copy to view\" in Ihrer Regelkette konfigurieren und die Nachrichten \"Post attributes\" und \"Attributes updated\" mit dem neuen Regelknoten verknüpfen.", + "timeseries-data": "Zeitreihendaten", + "timeseries-data-hint": "Konfigurieren Sie die Datensatzschlüssel der Zeitreihe der Zielentität, auf die die Entitätsansicht zugreifen kann. Die Daten dieser Zeitreihe sind schreibgeschützt." + }, + "event": { + "event-type": "Ereignistyp", + "type-error": "Fehler", + "type-lc-event": "Lebenszyklusereignis", + "type-stats": "Statistiken", + "type-debug-rule-node": "Fehlersuche", + "type-debug-rule-chain": "Fehlersuche", + "no-events-prompt": "Keine Ereignisse gefunden", + "error": "Fehler", + "type-edge-event": "Downlink", + "alarm": "Alarm", + "event-time": "Ereigniszeit", + "server": "Server", + "body": "Inhalt", + "method": "Methode", + "type": "Typ", + "message-id": "Nachrichten-Id", + "message-type": "Nachrichten-Typ", + "data-type": "Datentyp", + "relation-type": "Beziehungstyp", + "metadata": "Meta-Daten", + "data": "Daten", + "event": "Ereignis", + "status": "Status", + "success": "Erfolg", + "failed": "Fehlgeschlagen", + "messages-processed": "Nachrichten verarbeitet", + "errors-occurred": "Fehler aufgetreten", + "all-events": "Alle", + "entity-type": "Entitätstyp" + }, + "extension": { + "extensions": "Erweiterungen", + "selected-extensions": "{ count, plural, 1 {Erweiterung} other {# extensions} } ausgewählt", + "type": "Typ", + "key": "Schlüssel", + "value": "Wert", + "id": "ID", + "extension-id": "Erweiterungs-ID", + "extension-type": "Erweiterungstyp", + "transformer-json": "JSON *", + "unique-id-required": "Die aktuelle Erweiterungs-ID ist bereits vorhanden.", + "delete": "Erweiterung löschen", + "add": "Erweiterung hinzufügen", + "edit": "Erweiterung bearbeiten", + "delete-extension-title": "Möchten Sie die Erweiterung '{{extensionId}}' wirklich löschen?", + "delete-extension-text": "Vorsicht, nach Bestätigung werden die Erweiterung und alle zugehörigen Daten nicht wiederhergestellt.", + "delete-extensions-title": "Möchten Sie wirklich löschen? { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Vorsicht, nach der Bestätigung werden alle ausgewählten Erweiterungen entfernt.", + "converters": "Konverter", + "converter-id": "Konverter-ID", + "configuration": "Konfiguration", + "converter-configurations": "Konvertierte Konfigurationen", + "token": "Sicherheitszeichen", + "add-converter": "Konverter hinzufügen", + "add-config": "Konvertierte Konfigurationen hinzufügen", + "device-name-expression": "Angabe des Gerätenamens", + "device-type-expression": "Angabe des Gerätetyps", + "custom": "Regel", + "to-double": "Duplizieren", + "transformer": "Transformator", + "json-required": "Transformer json ist erforderlich.", + "json-parse": "Transformer json kann nicht analysiert werden.", + "attributes": "Eigenschaften", + "add-attribute": "Eigenschaften hinzufügen", + "add-map": "Mapping-Element hinzufügen", + "timeseries": "Zeitreihe", + "add-timeseries": "Zeitreihe hinzufügen", + "field-required": "Feld ist erforderlich", + "brokers": "Vermittler", + "add-broker": "Vermittler hinzufügen", + "host": "Host", + "port": "Port", + "port-range": "Der Port sollte im Bereich von 1 bis 65535 liegen.", + "ssl": "Ssl", + "credentials": "Zugangsdaten", + "username": "Benutzername", + "password": "Passwort", + "retry-interval": "Wiederholungsintervall in Millisekunden", + "anonymous": "Anonym", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "CA-Zertifikatsdatei *", + "private-key": "Privatschlüsseldatei *", + "cert": "Zertifikatsdatei *", + "no-file": "Keine Datei ausgewählt.", + "drop-file": "Legen Sie eine Datei ab oder wählen Sie eine Datei aus um diese hochzuladen.", + "mapping": "Mapping", + "topic-filter": "Themenfilter", + "converter-type": "Konvertierungstyp", + "converter-json": "Json", + "json-name-expression": "Angabe des Gerätenamens json", + "topic-name-expression": "Themenangabe des Gerätenamens", + "json-type-expression": "Angabe des Gerätenamens json", + "topic-type-expression": "Themenangabe des Gerätetyps", + "attribute-key-expression": "Angabe des Eigenschaftenschlüssels", + "attr-json-key-expression": "Angabe des Eigenschaftenschlüssels", + "attr-topic-key-expression": "Themenangabe des Eigenschaftenschlüssels", + "request-id-expression": "ID-Angabe anfordern", + "request-id-json-expression": "ID-Angabe anforern json", + "request-id-topic-expression": "Themenangabe der ID anfordern", + "response-topic-expression": "Antwort Themenangabe", + "value-expression": "Wertangabe", + "topic": "Thema", + "timeout": "Unterbrechung in Millisekunden", + "converter-json-required": "Konvertierte json ist erforderlich.", + "converter-json-parse": "Konvertierte json konnte nicht analysiert werden.", + "filter-expression": "Filterangabe", + "connect-requests": "Abfragen verbinden", + "add-connect-request": "Verbindungsabfrage hinzufügen", + "disconnect-requests": "Abfrage trennen", + "add-disconnect-request": "Trennung der Abfrage hinzufügen", + "attribute-requests": "Abfrage der Eigenschaften", + "add-attribute-request": "Abfrage der Eigenschaften hinzufügen", + "attribute-updates": "Aktualisierungen der Eigenschaften", + "add-attribute-update": "Aktualisierung der Eigenschaften hinzufügen", + "server-side-rpc": "Serverseite RPC", + "add-server-side-rpc-request": "Abfrage der Serverseite RPC hinzufügen", + "device-name-filter": "Gerätenamefilter", + "attribute-filter": "Eigenschaftenfilter", + "method-filter": "Methodenfilter", + "request-topic-expression": "Themenabgabe anfordern", + "response-timeout": "Antwortzeit in Millisekunden", + "topic-expression": "Themenangabe", + "client-scope": "Kundenumfrage", + "add-device": "Gerät hinzufügen", + "opc-server": "Servers", + "opc-add-server": "Server hinzufügen", + "opc-add-server-prompt": "Bitte einen Server hinzufügen", + "opc-application-name": "Anwendungsname", + "opc-application-uri": "Anwendung uri", + "opc-scan-period-in-seconds": "Scanzeitraum in Sekunden", + "opc-security": "Sicherheit", + "opc-identity": "Identifizierung", + "opc-keystore": "Schlüsselspeicher", + "opc-type": "Typ", + "opc-keystore-type": "Typ", + "opc-keystore-location": "Standort *", + "opc-keystore-password": "Passwort", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Schlüsselpasswort", + "opc-device-node-pattern": "Geräteknotenmuster", + "opc-device-name-pattern": "Gerätenamensmuster", + "modbus-server": "Servers/Folgegerät", + "modbus-add-server": "Server/Folgegerät hinzufügen", + "modbus-add-server-prompt": "Bitte Server/Folgegerät hinzufügen", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Verbindung automatisch wiederherstellen", + "modbus-rtu-over-tcp": "RTU über TCP", + "modbus-port-name": "Name des Seriellen Anschlusses", + "modbus-encoding": "Verschlüsselung", + "modbus-parity": "Übereinstimmung", + "modbus-baudrate": "Datenübertragungsgeschwindigkeit", + "modbus-databits": "Daten Bits", + "modbus-stopbits": "Stopp-Bits", + "modbus-databits-range": "Datenbits sollten im Bereich von 7 bis 8 liegen.", + "modbus-stopbits-range": "Stoppbits sollten im Bereich von 1 bis 2 liegen.", + "modbus-unit-id": "ID der Einheit", + "modbus-unit-id-range": "Die Einheiten-ID sollte im Bereich von 1 bis 247 liegen.", + "modbus-device-name": "Gerätename", + "modbus-poll-period": "Abfragezeitraum in Millisekunden", + "modbus-attributes-poll-period": "Abfrageintervall der Eigenschaften in Millisekunden", + "modbus-timeseries-poll-period": "Abfrageintervall der Zeitreihen in Millisekunden", + "modbus-poll-period-range": "Das Abfrageintervall sollte einen positiven Wert haben.", + "modbus-tag": "Kennzeichnung", + "modbus-function": "Funktion", + "modbus-register-address": "Registeradresse", + "modbus-register-address-range": "Die Registeradresse sollte im Bereich zwischen 0 und 65535 liegen.", + "modbus-register-bit-index": "Bitindex", + "modbus-register-bit-index-range": "Der Bitindex sollte im Bereich von 0 bis 15 liegen.", + "modbus-register-count": "Registeranzahl", + "modbus-register-count-range": "Die Registeranzahl sollten einen positiven Wert haben.", + "modbus-byte-order": "Byte-Reihenfolge", + "sync": { + "status": "Status", + "sync": "Synchronisiert", + "not-sync": "Nicht synchronisiert", + "last-sync-time": "Zeit der letzten Synchronisierung", + "not-available": "Nicht verfügbar" + }, + "export-extensions-configuration": "Erweiterungskonfiguration exportieren", + "import-extensions-configuration": "Erweiterungskonfiguration importieren", + "import-extensions": "Erweiterungen importieren", + "import-extension": "Erweiterung importieren", + "export-extension": "Erweiterung exportieren", + "file": "Erweiterungsdatei", + "invalid-file-error": "Ungültige Erweiterungsdatei" + }, + "fullscreen": { + "expand": "Auf Vollbildmodus erweitern", + "exit": "Vollbildmodus verlassen", + "toggle": "Vollbildmodus umschalten", + "fullscreen": "Vollbild" + }, + "function": { + "function": "Funktion" + }, + "grid": { + "delete-item-title": "Möchten Sie dieses Element wirklich löschen?", + "delete-item-text": "Vorsicht, nach Bestätigung wird das Element und alle zugehörigen Daten nicht wiederhergestellt.", + "delete-items-title": "Sind Sie sicher, dass Sie löschen möchten { count, plural, 1 {Symbol} other {Symbole} }?", + "delete-items-action-title": "Löschen { count, plural, 1 {Symbol} other {# Symbole} }", + "delete-items-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Elemente entfernt und alle zugehörigen Daten nicht wiederhergestellt.", + "add-item-text": "Neues Element hinzufügen", + "no-items-text": "Keine Elemente gefunden", + "item-details": "Elementdetails", + "delete-item": "Element löschen", + "delete-items": "Elemente löschen", + "scroll-to-top": "zum Seitenanfang" + }, + "help": { + "goto-help-page": "Gehen Sie zur Hilfeseite" + }, + "home": { + "home": "Startseite", + "profile": "Profil", + "logout": "Abmelden", + "menu": "Menü", + "avatar": "Benutzerbild", + "open-user-menu": "Benutzermenü öffnen" + }, + "import": { + "no-file": "Keine Datei ausgewählt", + "drop-file": "Legen Sie eine JSON-Datei ab oder wählen Sie eine Datei zum hochladen aus." + }, + "item": { + "selected": "Ausgewählt" + }, + "js-func": { + "no-return-error": "Funktion muss einen Wert zurückgeben!", + "return-type-mismatch": "Funktion muss einen Wert vom Typ '{{type}}' zurückgeben!", + "tidy": "Aufräumen" + }, + "key-val": { + "key": "Schlüssel", + "value": "Wert", + "remove-entry": "Eintrag entfernen", + "add-entry": "Eintag hinzufügen", + "no-data": "Keine Einträge" + }, + "layout": { + "layout": "Layout", + "manage": "Layouts verwalten", + "settings": "Layout-Einstellungen", + "color": "Farbe", + "main": "Hauptbereich", + "right": "Recht", + "select": "Wählen Sie das Ziellayout aus" + }, + "legend": { + "position": "Legendenposition", + "show-max": "Maximalwert anzeigen", + "show-min": "Minimalwert anzeigen", + "show-avg": "Durchschnittswert anzeigen", + "show-total": "Gesamtwert anzeigen", + "settings": "Legendeneinstellungen", + "min": "min.", + "max": "max.", + "avg": "mittelw.", + "total": "Gesamt" + }, + "login": { + "login": "Anmelden", + "request-password-reset": "Passwortzurücksetzung anfordern", + "reset-password": "Passwort zurücksetzen", + "create-password": "Passwort erstellen", + "passwords-mismatch-error": "Eingegebene Passwörter müssen identisch sein!", + "password-again": "Passwort wiederholen", + "sign-in": "Bitte anmelden", + "username": "Benutzername (E-Mail)", + "remember-me": "Login speichern", + "forgot-password": "Passwort vergessen?", + "password-reset": "Passwort zurücksetzen", + "new-password": "Neues Passwort", + "new-password-again": "Neues Passwort wiederholen", + "password-link-sent-message": "Der Link zum Zurücksetzen des Passworts wurde erfolgreich versendet!", + "email": "E-Mail", + "login-with": "Mit {{name}} anmelden", + "or": "oder" + }, + "position": { + "top": "Oben", + "bottom": "Unten", + "left": "Links", + "right": "Rechts" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Letzte Anmeldung", + "change-password": "Passwort ändern", + "current-password": "Aktuelles Passwort" + }, + "relation": { + "relations": "Beziehungen", + "direction": "Richtung", + "search-direction": { + "FROM": "Von", + "TO": "Zu" + }, + "direction-type": { + "FROM": "von", + "TO": "zu" + }, + "from-relations": "Ausgehende Verbindungen", + "to-relations": "Eingehende Verbindungen", + "selected-relations": "{ count, plural, 1 {1 Beziehung} other {# Beziehungen} } ausgewählt", + "type": "Typ", + "to-entity-type": "Zum Entitätstyp", + "to-entity-name": "Zum Entitätsnamen", + "from-entity-type": "Vom Entitätstyp", + "from-entity-name": "Vom Entitätsnamen", + "to-entity": "Zur Entität", + "from-entity": "Von Entität", + "delete": "Beziehung löschen", + "relation-type": "Art der Beziehung", + "relation-type-required": "Art der Beziehung erforderlich.", + "any-relation-type": "Jede Art", + "add": "Beziehung hinzufügen", + "edit": "Beziehung bearbeiten", + "delete-to-relation-title": "Möchten Sie die Beziehung zur Einheit'{{entityName}}' wirklich löschen?", + "delete-to-relation-text": "Vorsicht, nach Bestätigung ist die Entität '{{entityName}}' nicht mehr mit der aktuellen Entität verbunden.", + "delete-to-relations-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Beziehung} other {# Beziehungen} } wirklich löschen?", + "delete-to-relations-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Beziehungen entfernt und die entsprechenden Entitäten sind nicht mehr mit der aktuellen Entität verbunden.", + "delete-from-relation-title": "Sind Sie sicher, dass Sie die Verbindung aus der Entität '{{entityName}}' löschen möchten?", + "delete-from-relation-text": "Vorsicht, nach Bestätigung wird die aktuelle Entität '{{entityName}}' von der Entität unabhängig sein.", + "delete-from-relations-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Beziehung} other {# Beziehungen} } löschen möchten?", + "delete-from-relations-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Beziehungen entfernt und die aktuellen Entität wird nicht mehr mit den entsprechenden Entitäten verknüpft sein.", + "remove-relation-filter": "Beziehungsfilter entfernen", + "add-relation-filter": "Beziehungsfilter hinzufügen", + "any-relation": "Jede Beziehung", + "relation-filters": "Beziehungsfilter", + "additional-info": "Zusätzliche Information (JSON)", + "invalid-additional-info": "Json der Zusätzlichen Informationen konnte nicht gelesen werden." + }, + "rulechain": { + "rulechain": "Regelkette", + "rulechains": "Regelketten", + "root": "Wurzel", + "delete": "Regelkette löschen", + "name": "Name", + "name-required": "Name ist erforderlich.", + "description": "Beschreibung", + "add": "Regelkette hinzufügen", + "set-root": "Regelkette zur Wurzel machen", + "set-root-rulechain-title": "Sind Sie sicher, dass Sie die Regelkette '{{ruleChainName}}' zur Wurzel machen möchten?", + "set-root-rulechain-text": "Nach der Bestätigung wird die Regelkette zur Wurzel und bearbeitet alle eingehenden Transportnachrichten.", + "delete-rulechain-title": "Sind Sie sicher, dass Sie die Regelkette '{{ruleChainName}}' löschen möchten?", + "delete-rulechain-text": "Vorsichtig, nach Bestätigung werden die Regelkette und alle zugehörigen Daten gelöscht.", + "delete-rulechains-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Regelkette} other {# Regelketten} } löschen möchten?", + "delete-rulechains-action-title": "{ count, plural, 1 {1 Regelkette} other {# Regelketten} } löschen", + "delete-rulechains-text": "Vorsichtig, nach Bestätigung werden alle ausgewählten Regelketten entfernt und alle zugehörigen Daten werden gelöscht.", + "add-rulechain-text": "Neue Regelkette hinzufügen", + "no-rulechains-text": "Keine Regelkette gefunden", + "rulechain-details": "Regelketten-Details", + "details": "Details", + "events": "Ereignisse", + "system": "System", + "import": "Regelkette importieren", + "export": "Regelkette exportieren", + "export-failed-error": "Regelkette konnte nicht exportiert werden: {{error}}", + "create-new-rulechain": "Neue Regelkette erstellen", + "rulechain-file": "Regelkettendatei", + "invalid-rulechain-file-error": "Regelkette konnte nicht importiert werden: Ungültige Regelkettendatenstruktur.", + "copyId": "Regelketten-ID kopieren", + "idCopiedMessage": "Regelketten-ID wurde in die Zwischenablage kopiert", + "select-rulechain": "Regelkette auswählen", + "no-rulechains-matching": "Es wurden keine passenden Regelketten für '{{entity}}' gefunden.", + "rulechain-required": "Regelkette ist erforderlich", + "management": "Regelverwaltung", + "debug-mode": "Modus zur Fehlersuche", + "assign-rulechains": "Regelketten zuweisen", + "assign-new-rulechain": "Neues Regelkette zuweisen", + "delete-rulechains": "Regelketten löschen", + "unassign-rulechain": "Nicht zugeordnete Regelkette", + "unassign-rulechains": "Nicht zugeordnete Regelketten", + "unassign-rulechain-title": "Möchten Sie die Zuordnung die Regelkette '{{ruleChainTitle}}' wirklich aufheben?", + "unassign-rulechain-from-edge-text": "Nach der Bestätigung wird die Zuordnung aller ausgewählten Regelkette aufgehoben und sie sind für den Rand nicht mehr zugänglich.", + "unassign-rulechains-from-edge-action-title": "Zuordnung { count, plural, 1 {1 Regelkette} other {# Regelketten} } vom Rand aufheben", + "unassign-rulechains-from-edge-text": "Nach der Bestätigung wird die Zuordnung aller ausgewählten Regelketten aufgehoben und sie sind für den Rand nicht mehr zugänglich.", + "assign-rulechain-to-edge-title": "Regelkette(n) dem Rand zuordnen", + "assign-rulechain-to-edge-text": "Bitte wählen Sie die Regelketten aus, die Sie dem Rand zuordnen möchten", + "set-edge-template-root-rulechain": "Erstellen Sie den Stamm der Regelkettenkantenvorlage", + "set-edge-template-root-rulechain-title": "Möchten Sie die Kantenvorlage der Regelkette '{{ruleChainName}}' wirklich als Root festlegen?", + "set-edge-template-root-rulechain-text": "Nach der Bestätigung wird die Regelkette zum Stamm der Kantenvorlage und zur Stammregelkette für neu erstellte Kanten.", + "invalid-rulechain-type-error": "Regelkette konnte nicht importiert werden: Ungültige Regelkettentyp. Erwarteter Typ ist {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Weisen Sie bei der Erstellung den Kanten die Regelkette zu", + "set-auto-assign-to-edge-title": "Möchten Sie die Kantenregelkette '{{ruleChainName}}' bei der Erstellung automatisch den Kanten zuweisen?", + "set-auto-assign-to-edge-text": "Nach der Bestätigung wird die Kantenregelkette bei der Erstellung automatisch den Kanten zugewiesen.", + "unset-auto-assign-to-edge": "Deaktiviert die Zuordnung der Regelkette zu Kanten bei der Erstellung", + "unset-auto-assign-to-edge-title": "Möchten Sie die Kantenregelkette '{{ruleChainName}}' bei der Erstellung unbedingt den Kanten zuweisen?", + "unset-auto-assign-to-edge-text": "Nach der Bestätigung wird die Kantenregelkette bei der Erstellung nicht mehr automatisch den Kanten zugewiesen.", + "edge-template-root": "Vorlagenstamm", + "search": "Suchen Sie nach Regelketten", + "selected-rulechains": "{count, plural, 1 {1 Regelkette} other {# Regelketten} } ausgewählt", + "open-rulechain": "Regelkette öffnen", + "assign-to-edge": "Rand zuweisen", + "edge-rulechain": "Kantenregelkette" + }, + "rulenode": { + "details": "Details", + "events": "Ereignisse", + "search": "Knoten suchen", + "open-node-library": "Knotenbibliothek öffnen", + "add": "Neuen Regelknoten hinzufügen", + "name": "Name", + "name-required": "Name ist erforderlich.", + "type": "Typ", + "description": "Beschreibung", + "delete": "Regelknoten löschen", + "select-all-objects": "Alle Knoten und Verbindungen auswählen", + "deselect-all-objects": "Auswahl aller Knoten und Verbindungen aufheben", + "delete-selected-objects": "Ausgewählte Knoten und Verbindungen löschen", + "delete-selected": "Auswahl löschen", + "select-all": "Alle auswählen", + "copy-selected": "Auswahl kopieren", + "deselect-all": "Nichts auswählen", + "rulenode-details": "Details der Regelknoten", + "debug-mode": "Modus zur Fehlersuche", + "configuration": "Konfiguration", + "link": "Verbindung", + "link-details": "Verbindungsdetails der Regelknoten", + "add-link": "Verbindung hinzufügen", + "link-label": "Verbindungsbeschriftung", + "link-label-required": "Verbindungsbeschriftung ist erforderlich.", + "custom-link-label": "Benutzerdefinierte Verbindungsbeschriftung", + "custom-link-label-required": "Benutzerdefinierte Verbindungsbeschriftung ist erforderlich.", + "link-labels": "Verbindungsbeschriftungen", + "link-labels-required": "Verbindungsbeschriftungen sind erforderlich.", + "no-link-labels-found": "Keine Verbindungsbeschriftungen gefunden", + "no-link-label-matching": "'{{label}}' nicht gefunden.", + "create-new-link-label": "Bitte erstellen Sie eine neue Verbindungsbeschriftung!", + "type-filter": "Filter", + "type-filter-details": "Eingehende Nachrichten mit konfigurierten Bedingungen filtern", + "type-enrichment": "Anreicherung", + "type-enrichment-details": "Fügen Sie zusätzliche Informationen zu den Nachrichtenmetadaten hinzu", + "type-transformation": "Transformation", + "type-transformation-details": "Ändern Sie die Nutzerdaten und Metadaten der Nachricht", + "type-action": "Aktion", + "type-action-details": "Besondere Aktion ausführen", + "type-external": "Extern", + "type-external-details": "Interagiert mit externem System", + "type-rule-chain": "Regelkette", + "type-rule-chain-details": "Leitet eingehende Nachrichten an die angegebene Regelkette weiter", + "type-input": "Input", + "type-input-details": "Logische Eingabe der Regelkette, leitet eingehende Nachrichten an die nächste zugehörige Regelkette weiter", + "type-unknown": "Unbekannt", + "type-unknown-details": "Nicht aufgelöster Regelknoten", + "directive-is-not-loaded": "Definierte Konfigurationsanweisung '{{directiveName}}' ist nicht verfügbar.", + "ui-resources-load-error": "Fehler beim Laden der Konfigurations-UI-Ressourcen.", + "invalid-target-rulechain": "Zielregelkette kann nicht aufgelöst werden!", + "test-script-function": "Skriptfunktion testen", + "message": "Nachricht", + "message-type": "Nachrichtentyp", + "select-message-type": "Nachrichtentyp auswählen", + "message-type-required": "Nachrichtentyp ist erforderlich", + "metadata": "Metadaten", + "metadata-required": "Metadateneinträge dürfen nicht leer sein.", + "output": "Ausgabe", + "test": "Test", + "help": "Hilfe" + }, + "tenant": { + "tenant": "Mandant", + "tenants": "Mandanten", + "management": "Mandantenverwaltung", + "add": "Mandant hinzufügen", + "admins": "Administratoren", + "manage-tenant-admins": "Mandantenadministratoren verwalten", + "delete": "Mandant löschen", + "add-tenant-text": "Neuen Mandanten hinzufügen", + "no-tenants-text": "Keine Mandanten gefunden", + "tenant-details": "Mandantendetails", + "delete-tenant-title": "Möchten Sie den Mandanten '{{tenantTitle}}' wirklich löschen?", + "delete-tenant-text": "Vorsicht, nach Bestätigung werden der Mandant und alle zugehörigen Daten gelöscht.", + "delete-tenants-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Mandant} other {# Mandanten} } löschen möchten?", + "delete-tenants-action-title": "{ count, plural, 1 {1 Mandant} other {# Mandanten} } löschen", + "delete-tenants-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Mandanten entfernt und alle zugehörigen Daten werden gelöscht.", + "title": "Titel", + "title-required": "Titel ist erforderlich.", + "description": "Beschreibung", + "details": "Details", + "events": "Ereignisse", + "copyId": "Mandanten-ID kopieren", + "idCopiedMessage": "Mandanten-ID wurde in die Zwischenablage kopiert", + "select-tenant": "Mandant auswählen", + "no-tenants-matching": "Es wurden keine passenden Mandanten für '{{entity}}' gefunden.", + "tenant-required": "Mandant ist erforderlich" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 Sekunde} other {# Sekunden} }", + "minutes-interval": "{ minutes, plural, 1 {1 Minute} other {# Minuten} }", + "hours-interval": "{ hours, plural, 1 {1 Stunde} other {# Stunden} }", + "days-interval": "{ days, plural, 1 {1 Tag} other {# Tage} }", + "days": "Tage", + "hours": "Stunden", + "minutes": "Minuten", + "seconds": "Sekunden", + "advanced": "Erweitert" + }, + "timewindow": { + "days": "{ days, plural, 1 { Tag } other {# Tage } }", + "hours": "{ hours, plural, 0 { Stunde } 1 {1 Stunde } other {# Stunden } }", + "minutes": "{ minutes, plural, 0 { Minute } 1 {1 Minute } other {# Minuten } }", + "seconds": "{ seconds, plural, 0 { Sekunde } 1 {1 Sekunde } other {# Sekunden } }", + "realtime": "Echtzeit", + "history": "Historie", + "last-prefix": "letzte", + "period": "von {{ startTime }} bis {{ endTime }}", + "edit": "Zeitfenster bearbeiten", + "date-range": "Datumsbereich", + "last": "Letzte", + "time-period": "Zeitfenster" + }, + "user": { + "user": "User", + "users": "Users", + "customer-users": "Kunden Users", + "tenant-admins": "Mandanten-Administratoren", + "sys-admin": "System-Administrator", + "tenant-admin": "Mandanten-Administrator", + "customer": "Kunde", + "anonymous": "Anonym", + "add": "Benutzer hinzufügen", + "delete": "Benutzer löschen", + "add-user-text": "Neuen Benutzer hinzufügen", + "no-users-text": "Keine Benutzer gefunden", + "user-details": "Benutzer-Details", + "delete-user-title": "Möchten Sie den Benutzer '{{userEmail}}' wirklich löschen?", + "delete-user-text": "Vorsicht, nach Bestätigung werden der Benutzer und alle zugehörigen Daten gelöscht.", + "delete-users-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Benutzer} other {# Benutzer} } löschen möchten??", + "delete-users-action-title": "{ count, plural, 1 {1 Benutzer} other {# Benutzer} } löschen", + "delete-users-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Benutzer entfernt und alle zugehörigen Daten werden gelöscht.", + "activation-email-sent-message": "Aktivierungs E-Mail wurde erfolgreich gesendet!", + "resend-activation": "Aktivierung erneut senden", + "email": "E-Mail", + "email-required": "E-Mail ist erforderlich.", + "invalid-email-format": "Ungültiges E-Mail Format.", + "first-name": "Vorname", + "last-name": "Nachname", + "description": "Beschreibung", + "default-dashboard": "Standard-Dashboard", + "always-fullscreen": "Immer Vollbild", + "select-user": "Benutzer auswählen", + "no-users-matching": "Keine passenden Benutzer für '{{entity}}' gefunden.", + "user-required": "Benutzer ist erforderlich", + "activation-method": "Aktivierungsmethode", + "display-activation-link": "Aktivierungslink anzeigen", + "send-activation-mail": "Aktivierungs E-Mail senden", + "activation-link": "Link zur Benutzer-Aktivierung", + "activation-link-text": "Um den Benutzer zu aktivieren, verwenden Sie bitte folgenden Aktivierungslink:", + "copy-activation-link": "Aktivierungslink kopieren", + "activation-link-copied-message": "Der Link zur Benutzer-Aktivierung wurde in die Zwischenablage kopiert ", + "details": "Details", + "login-as-tenant-admin": "Als Mandanten-Administrator anmelden", + "login-as-customer-user": "Als Kunden-Benutzer anmelden", + "disable-account": "Benutzerkonto deaktivieren", + "enable-account": "Benutzerkonto aktivieren", + "enable-account-message": "Benutzerkonto wurde erfolgreich aktiviert!", + "disable-account-message": "Benutzerkonto wurde erfolgreich deaktiviert!" + }, + "value": { + "type": "Wertetyp", + "string": "Text", + "string-value": "Textwert", + "integer": "Ganzzahlig", + "integer-value": "Ganzzahliger Wert", + "invalid-integer-value": "Ungültiger ganzzahliger Wert", + "double": "Gleitkommazahl", + "double-value": "Gleitkomma Wert", + "boolean": "Binär", + "boolean-value": "Binärwert", + "false": "Falsch", + "true": "Wahr", + "long": "Lang" + }, + "widget": { + "widget-library": "Widget-Bibliothek", + "widget-bundle": "Widget-Paket", + "select-widgets-bundle": "Widget-Paket auswählen", + "management": "Widget Verwaltung", + "editor": "Widget Editor", + "widget-type-not-found": "Problem beim Laden der Widget-Konfiguration.
    Zugeordneter Widget-Typ wurde entfernt.", + "widget-type-load-error": "Widget wurde aufgrund der folgenden Fehler nicht geladen:", + "remove": "Widget entfernen", + "edit": "Widget bearbeiten", + "remove-widget-title": "Möchten Sie das Widget '{{widgetTitle}}' wirklich entfernen?", + "remove-widget-text": "Nach der Bestätigung werden das Widget und alle zugehörigen Daten nicht wiederhergestellt.", + "timeseries": "Zeitreihe", + "search-data": "Daten suchen", + "no-data-found": "Keine Daten gefunden", + "latest": "Neueste Werte", + "rpc": "Steuerungswidget", + "alarm": "Alarm-Widget", + "static": "Statisches Widget", + "select-widget-type": "Widget-Typ auswählen", + "missing-widget-title-error": "Widget-Titel muss angegeben werden!", + "widget-saved": "Widget gespeichert", + "unable-to-save-widget-error": "Das Widget kann nicht gespeichert werden! Fehlermeldung!", + "save": "Widget speichern", + "saveAs": "Widget speichern unter", + "save-widget-type-as": "Widget-Typ speichern unter", + "save-widget-type-as-text": "Bitte geben Sie den neuen Widget-Titel ein und/oder wählen Sie das Ziel-Widget-Paket aus", + "toggle-fullscreen": "Vollbild umschalten", + "run": "Widget ausführen", + "title": "Widget-Titel", + "title-required": "Widget-Titel ist erforderlich.", + "type": "Widget-Typ", + "resources": "Ressourcen", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Ressource entfernen", + "add-resource": "Ressource hinzufügen", + "html": "HTML", + "tidy": "Aufgeräumt", + "css": "CSS", + "settings-schema": "Einstellungsschema", + "datakey-settings-schema": "Datenschlüssel-Einstellungsschema", + "javascript": "Javascript", + "remove-widget-type-title": "Möchten Sie den Widget-Typ '{{widgetName}}' wirklich entfernen?", + "remove-widget-type-text": "Nach der Bestätigung werden der Widget-Typ und alle zugehörigen Daten nicht wiederhergestellt.", + "remove-widget-type": "Widget-Typ entfernen", + "add-widget-type": "Neuen Widget-Typ hinzufügen", + "widget-type-load-failed-error": "Widget-Typ konnte nicht geladen werden!", + "widget-template-load-failed-error": "Widget-Vorlage konnte nicht geladen werden!", + "add": "Widget hinzufügen", + "undo": "Widget-Änderungen widerrufen ", + "export": "Widget exportieren" + }, + "widget-action": { + "header-button": "Widget-Header-Schaltfläche", + "open-dashboard-state": "Zum neuen Dashboard-Status navigieren", + "update-dashboard-state": "Aktuellen Dashboard-Status aktualisieren", + "open-dashboard": "Zu einem anderen Dashboard navigieren", + "custom": "Benutzerdefinierte Aktion", + "target-dashboard-state": "Zielstatus des Dashboards", + "target-dashboard-state-required": "Der Zielstatus ist erforderlich", + "set-entity-from-widget": "Widget-Entität festlegen", + "target-dashboard": "Ziel-Dashboard", + "open-right-layout": "Das rechte Dashboard-Layout öffnen (mobile Ansicht)" + }, + "widgets-bundle": { + "current": "Aktuelles Paket", + "widgets-bundles": "Widget-Pakete", + "add": "Widget-Pakete hinzufügen", + "delete": "Widget-Pakete löschen", + "title": "Titel", + "title-required": "Titel ist erforderlich.", + "add-widgets-bundle-text": "Neues Widget-Paket hinzufügen", + "no-widgets-bundles-text": "Keine Widget-Pakete gefunden", + "empty": "Widget-Paket ist leer ", + "details": "Details", + "widgets-bundle-details": "Widget-Paket-Details", + "delete-widgets-bundle-title": "Möchten Sie das Widget-Paket '{{widgetsBundleTitle}}' wirklich löschen? ", + "delete-widgets-bundle-text": "Seien Sie vorsichtig, nach der Bestätigung werden das Widget-Paket und alle zugehörigen Daten gelöscht.", + "delete-widgets-bundles-title": "Sind Sie sicher, dass Sie { count, plural, 1 {1 Widget-Paket} other {# Widget-Pakete} } löschen möchten?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 Widgets-Paket} other {# Widget-Pakete} } löschen", + "delete-widgets-bundles-text": "Vorsicht, nach Bestätigung werden alle ausgewählten Widget-Pakete entfernt und alle zugehörigen Daten werden gelöscht.", + "no-widgets-bundles-matching": "Keine passenden Widget-Pakete '{{widgetsBundle}}' gefunden.", + "widgets-bundle-required": "Widget-Paket ist erforderlich.", + "system": "System", + "import": "Widget-Paket importieren", + "export": "Widget-Paket exportieren", + "export-failed-error": "Widget-Paket kann nicht exportiert werden: {{error}}", + "create-new-widgets-bundle": "Neues Widget-Paket erstellen", + "widgets-bundle-file": "Widget-Paket-Datei", + "invalid-widgets-bundle-file-error": "Widget-Paket kann nicht importiert werden: Ungültige Widget-Paket-Datenstruktur." + }, + "widget-config": { + "data": "Daten", + "settings": "Einstellungen", + "advanced": "Erweitert ", + "title": "Titel", + "title-tooltip": "Titel Tooltip", + "general-settings": "Allgemeine Einstellungen", + "display-title": "Titel anzeigen", + "drop-shadow": "Schlagschatten", + "enable-fullscreen": "Vollbild aktivieren", + "background-color": "Hintergrundfarbe", + "text-color": "Textfarbe", + "padding": "Pufferung", + "margin": "Rand", + "widget-style": "Widget-Stil", + "title-style": "Titel-Stil", + "mobile-mode-settings": "Einstellungen für den mobilen Modus", + "order": "Reihenfolge", + "height": "Größe", + "units": "Spezielles Symbol, das neben dem Wert angezeigt wird", + "decimals": "Anzahl der Stellen nach dem Fließkomma", + "timewindow": "Zeitfenster", + "use-dashboard-timewindow": "Dashboard-Zeitfenster verwenden", + "display-timewindow": "Zeitfenster anzeigen", + "display-legend": "Legende anzeigen", + "datasources": "Datenquellen", + "maximum-datasources": "Maximal { count, plural, 1 {1 Datenquelle ist erlaubt} other {# Datenquellen sind erlaubt} }.", + "datasource-type": "Typ", + "datasource-parameters": "Parameter", + "remove-datasource": "Datenquelle entfernen", + "add-datasource": "Datenquelle hinzufügen ", + "target-device": "Zielgerät", + "alarm-source": "Alarmquelle", + "actions": "Aktionen", + "action": "Aktion", + "add-action": "Aktion hinzufügen", + "search-actions": "Aktion suchen", + "action-source": "Aktionsquelle", + "action-source-required": "Aktionsquelle ist erforderlich.", + "action-name": "Name", + "action-name-required": "Aktionsname ist erforderlich.", + "action-name-not-unique": "Eine andere Aktion mit demselben Namen ist bereits vorhanden.
    Der Aktionsname sollte innerhalb derselben Aktionsquelle eindeutig sein.", + "action-icon": "Symbol ", + "action-type": "Art", + "action-type-required": "Aktionsart ist erforderlich.", + "edit-action": "Aktion bearbeiten", + "delete-action": "Aktion löschen", + "delete-action-title": "Widget-Aktion löschen", + "delete-action-text": "Möchten Sie die Widget-Aktion mit Namen '{{actionName}}' wirklich löschen?" + }, + "widget-type": { + "import": "Widget-Typ importieren", + "export": "Widget-Typ exportieren", + "export-failed-error": "Widget-Typ kann nicht exportiert werden: {{error}}", + "create-new-widget-type": "Neuen Widget-Typ erstellen", + "widget-type-file": "Widget-Typdatei", + "invalid-widget-type-file-error": "Widget-Typ kann nicht importiert werden: Ungültige Datenstruktur des Widget-Typs." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "So.", + "Mon": "Mo.", + "Tue": "Di.", + "Wed": "Mi.", + "Thu": "Do.", + "Fri": "Fr.", + "Sat": "Sa.", + "Jan": "Jan.", + "Feb": "Feb.", + "Mar": "März", + "Apr": "Apr.", + "May": "Mai", + "Jun": "Juni", + "Jul": "Juli", + "Aug": "Aug.", + "Sep": "Sep.", + "Oct": "Okt.", + "Nov": "Nov.", + "Dec": "Dez.", + "January": "Januar", + "February": "Februar", + "March": "März", + "April": "April", + "June": "Juni", + "July": "Juli", + "August": "August", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "Dezember", + "Custom Date Range": "Benutzerdefinierter Datumsbereich", + "Date Range Template": "Datumsbereichsvorlage", + "Today": "Heute", + "Yesterday": "Gestern", + "This Week": "Diese Woche", + "Last Week": "Letzte Woche", + "This Month": "Diesen Monat", + "Last Month": "Im vergangenen Monat", + "Year": "Jahr", + "This Year": "Dieses Jahr", + "Last Year": "Vergangenes Jahr", + "Date picker": "Datumsauswahl", + "Hour": "Stunde", + "Day": "Tag", + "Week": "Woche", + "2 weeks": "2 Wochen", + "Month": "Monat", + "3 months": "3 Monate", + "6 months": "6 Monate", + "Custom interval": "Benutzerdefiniertes Intervall", + "Interval": "Intervall", + "Step size": "Schrittlänge", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Attributparameter können in diesem Widget nicht verwendet werden", + "date": "Datum", + "discard-changes": "Änderungen verwerfen", + "entity-attribute-required": "Entitätsattribut ist erforderlich", + "entity-timeseries-required": "Zeitreihen für Entitäten sind erforderlich", + "not-allowed-entity": "Die ausgewählte Entität kann keine gemeinsamen Attribute haben", + "no-attribute-selected": "Es ist kein Attribut ausgewählt", + "no-datakey-selected": "Es ist kein Datenschlüssel ausgewählt", + "no-entity-selected": "Keine Entität ausgewählt", + "no-image": "Kein Bild", + "no-support-web-camera": "Keine unterstützte Webcam", + "no-timeseries-selected": "Keine Zeitreihen ausgewählt", + "switch-attribute-value": "Entitätsattributwert wechseln", + "switch-camera": "Kamera wechseln", + "switch-timeseries-value": "Wert für Zeitreihen von Entitäten wechseln", + "take-photo": "Foto machen", + "time": "Zeit", + "timeseries-not-allowed": "Der Timeseries-Parameter kann in diesem Widget nicht verwendet werden", + "update-failed": "Aktualisierung fehlgeschlagen", + "update-successful": "Aktualisierung erfolgreich", + "update-attribute": "Attribut aktualisieren", + "update-timeseries": "Zeitreihen aktualisieren", + "value": "Wert" + } + }, + "icon": { + "icon": "Symbol", + "select-icon": "Symbol auswählen", + "material-icons": "Material-Symbole", + "show-all": "Alle Symbole anzeigen" + }, + "custom": { + "widget-action": { + "action-cell-button": "Aktionszellenschaltfläche", + "row-click": "Klick auf Zeile", + "polygon-click": "Klick auf Polygon", + "marker-click": "Klick auf Marker", + "tooltip-tag-action": "Tooltip-Tag-Aktion", + "node-selected": "Klick auf Node", + "element-click": "Klick auf HTML element", + "pie-slice-click": "Klicken auf Slice", + "row-double-click": "Doppelklicken auf Zeile" + } + }, + "paginator" : { + "items-per-page": "Einträge pro Seite:", + "first-page-label": "Erste Seite", + "last-page-label": "Letzte Seite", + "next-page-label": "Nächste Seite", + "previous-page-label": "Vorherige Seite", + "items-per-page-separator": "von" + }, + "language": { + "language": "Sprache" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json new file mode 100644 index 0000000..b66e02b --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -0,0 +1,2606 @@ +{ + "access": { + "unauthorized": "Χωρίς δικαιώματα πρόσβασης", + "unauthorized-access": "Μη εξουσιοδοτημένη πρόσβαση", + "unauthorized-access-text": "Θα πρέπει να συνδεθείτε για να έχετε πρόσβαση σε αυτόν τον πόρο!", + "access-forbidden": "Απαγορεύεται η πρόσβαση!", + "access-forbidden-text": "Δεν έχετε δικαιώματα πρόσβασης σε αυτήν την τοποθεσία!
    Συνδεθείτε με διαφορετικό όνομα χρήστη, αν εξακολουθείτε να θέλετε να έχετε πρόσβαση σε αυτήν την τοποθεσία.", + "refresh-token-expired": "Η περίοδος χρήσης έχει λήξει", + "refresh-token-failed": "Δεν είναι δυνατή η ανανέωση της περιόδου χρήσης", + "permission-denied": "Άρνηση πρόσβασης!", + "permission-denied-text": "Δεν έχετε δικαίωμα εκτέλεσης αυτής της λειτουργίας!" + }, + "action": { + "activate": "Ενεργοποίηση", + "suspend": "Αναστολή", + "save": "Αποθήκευση", + "saveAs": "Αποθήκευση ως", + "cancel": "Ακύρωση", + "ok": "OK", + "delete": "Διαγραφή", + "add": "Προσθήκη", + "yes": "Ναι", + "no": "Όχι", + "update": "Ενημέρωση", + "remove": "Διαγραφή", + "search": "Αναζήτηση", + "clear-search": "Καθαρισμός Αναζήτησης", + "assign": "Ανάθεση", + "unassign": "Ακύρωση Ανάθεσης", + "share": "Διαμοιρασμός", + "make-private": "Ιδιωτικό", + "apply": "Εφαρμογή", + "apply-changes": "Εφααρμογή Αλλαγών", + "edit-mode": "Λειτουργία Επεξεργασίας", + "enter-edit-mode": "Έναρξη Επεξεργασίας", + "decline-changes": "Απόρριψη Αλλαγών", + "close": "Κλείσιμο", + "back": "Πίσω", + "run": "Εκτέλεση", + "sign-in": "Σύνδεση!", + "edit": "Επεξεργασία", + "view": "Επισκόπηση", + "create": "Δημιουργία", + "drag": "Drag", + "refresh": "Ανανέωση", + "undo": "Αναίρεση", + "copy": "Αντιγραφή", + "paste": "Επικόλληση", + "copy-reference": "Αντιγραφή παραπομπής", + "paste-reference": "Επικόλληση παραπομπής", + "import": "Εισαγωγή", + "export": "Εξαγωγή", + "share-via": "Διαμοίραση μέσω {{provider}}", + "move": "Μετακίνηση", + "select": "Επιλογή", + "continue": "Συνέχεια", + "done": "Ολοκληρώθηκε" + }, + "aggregation": { + "aggregation": "Συνάθροιση", + "function": "Συνάρτηση συνάθροισης δεδομένων", + "limit": "Μέγιστες τιμές", + "group-interval": "Διάστημα ομαδοποίησης", + "min": "Min", + "max": "Max", + "avg": "Μέσος Όρος", + "sum": "Άθροισμα", + "count": "Καταμέτρηση", + "none": "Κανένα" + }, + "admin": { + "general": "Γενικά", + "general-settings": "Γενικές Ρυθμίσεις", + "outgoing-mail": "Διακομιστής Αλληλογραφίας", + "outgoing-mail-settings": "Διακομιστής Εξερχόμενης Αλληλογραφίας", + "system-settings": "Ρυθμίσεις Συστήματος", + "test-mail-sent": "Το δοκιμαστικό μήνυμα στάλθηκε με επιτυχία!", + "base-url": "Βασική διεύθυνση URL", + "base-url-required": "Απαιτείται ορισμός Base URL.", + "mail-from": "Αποστολέας", + "mail-from-required": "Απαιτείται βασική διεύθυνση URL.", + "smtp-protocol": "Πρωτόκολλο SMTP", + "smtp-host": "SMTP host", + "smtp-host-required": "Απαιτείται ορισμός SMTP host.", + "smtp-port": "Θύρα SMTP", + "smtp-port-required": "Πρέπει να εισάγετε SMTP port.", + "smtp-port-invalid": "Αυτή δε φαίνεται να είναι μια έγκυρη SMTP port.", + "timeout-msec": "Timeout (msec)", + "timeout-required": "Απαιτείται τιμή Timeout.", + "timeout-invalid": "Αυτή δε φαίνεται να είναι μια έγκυρη τιμή timeout.", + "enable-tls": "Ενεργοποίηση TLS", + "tls-version": "Έκδοση TLS", + "send-test-mail": "Αποστολή δοκιμαστικού μηνύματος", + "use-system-mail-settings": "Χρήση των ρυθμίσεων διακομιστή αλληλογραφίας συστήματος", + "mail-templates": "Πρότυπα αλληλογραφίας", + "mail-template-settings": "Ρυθμίσεις προτύπων αλληλογραφίας", + "use-system-mail-template-settings": "Χρήση προτύπων ηλεκτρονικού ταχυδρομείου συστήματος", + "mail-template": { + "mail-template": "Πρότυπο αλληλογραφίας", + "test": "Δοκιμαστικό μήνυμα ηλεκτρονικού ταχυδρομείου", + "activation": "Μήνυμα ενεργοποίησης λογαριασμού", + "account-activated": "Μήνυμα επιτυχούς ενεργοποίησης λογαριασμού", + "reset-password": "Μήνυμα επαναφοράς κωδικού πρόσβασης", + "password-was-reset": "Μήνυμα επαναφοράς κωδικού πρόσβασης", + "user-activated": "Μήνυμα ενεργοποίησης χρήστη", + "user-registered": "Μήνυμα καταχώρησης χρήστη" + }, + "mail-subject": "Θέμα μηνύματος (subject)", + "mail-body": "Κυρίως μήνυμα (body)" + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarms", + "select-alarm": "Επιλογή alarm", + "no-alarms-matching": "Δεν βρέθηκαν alarms σχετικά με '{{entity}}'.", + "alarm-required": "Απαιτείται Alarm", + "alarm-status": "Κατάσταση Alarm", + "search-status": { + "ANY": "'Ολες", + "ACTIVE": "Ενεργό", + "CLEARED": "Εκκαθαρίστηκε", + "ACK": "Επιβεβαιώθηκε", + "UNACK": "Χωρίς επιβεβαίωση" + }, + "display-status": { + "ACTIVE_UNACK": "Ενεργό χωρίς επιβεβαίωση", + "ACTIVE_ACK": "Ενεργό επιβεβαιωμένο", + "CLEARED_UNACK": "Εκκαθαρίστηκε χωρίς επιβεβαίωση", + "CLEARED_ACK": "Εκκαθαρίστηκε επιβεβαιωμένο" + }, + "no-alarms-prompt": "Δεν βρέθηκαν alarm", + "created-time": "Ώρα δημιουργίας", + "type": "Τύπος", + "severity": "Βαρύτητα", + "originator": "Δημιουργός", + "originator-type": "Τύπος δημιουργού", + "details": "Λεπτομέρειες", + "status": "Κατάσταση", + "alarm-details": "Λεπτομέρειες Alarm", + "start-time": "Ώρα έναρξης", + "end-time": "Ώρα λήξης", + "ack-time": "Ώρα επιβεβαίωσης", + "clear-time": "Ώρα εκκαθάρισης", + "severity-critical": "Critical", + "severity-major": "Κρίσιμο", + "severity-minor": "Ασήμαντο", + "severity-warning": "Προειδοποίηση", + "severity-indeterminate": "Απροσδιόριστο", + "acknowledge": "Επιβεβαίωση", + "clear": "Εκκαθάριση", + "search": "Αναζήτηση alarm", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } επιλέχθηκαν", + "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση", + "polling-interval": "Διάστημα δειγματοληψίας alarm(sec)", + "polling-interval-required": "Απαιτείται ορισμός διαστήματος δειγματοληψίας alarm.", + "min-polling-interval-message": "Η ελάχιστη επιτρεπόμενη τιμή διαστήματος δειγματοληψίας alarm είναι 1 sec.", + "aknowledge-alarms-title": "Επιβεβαίωση { count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "Είστε σίγουρος ότι θέλετε να επιβεβαιώσετε { count, plural, 1 {1 alarm} other {# alarms} };", + "aknowledge-alarm-title": "Επιβεβαίωση Alarm", + "aknowledge-alarm-text": "Είστε σίγουρος ότι θέλετε να επιβεβαιώσετε το Alarm?", + "clear-alarms-title": "Εκκαθάριση { count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "Είστε σίγουρος ότι θέλετε να εκκαθαρίσετε { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Εκκαθάριση Alarm", + "clear-alarm-text": "Είστε σίγουρος ότι θέλετε να εκκαθαρίσετε το Alarm?", + "alarm-status-filter": "Φίλτρο κατάστασης Alarm" + }, + "alias": { + "add": "Προσθήκη ψευδωνύμου", + "edit": "Επεξεργασία ψευδωνύμου", + "name": "Ψευδώνυμο", + "name-required": "Απαιτείται Ψευδώνυμο", + "duplicate-alias": "Το ψευδώνυμο υπάρχει ήδη.", + "filter-type-single-entity": "Απλή Οντότητα", + "filter-type-entity-group": "Ομάδα Οντοτήτων", + "filter-type-entity-list": "Λίστα Οντοτήτων", + "filter-type-entity-name": "Όνομα Οντότητας", + "filter-type-entity-group-list": "Λίστα ομάδας Οντοτήτων", + "filter-type-entity-group-name": "Όνομα ομάδας Οντοτήτων", + "filter-type-state-entity": "Οντότητα από την κατάσταση του dashboard", + "filter-type-state-entity-description": "Οντότητα που λαμβάνεται από τις παραμέτρους κατάστασης του dashboard", + "filter-type-asset-type": "Τύπος Asset", + "filter-type-asset-type-description": "Assets του τύπου '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Assets του τύπου '{{assetType}}' με όνομα που αρχίζει από '{{prefix}}'", + "filter-type-device-type": "Τύπος Συσκευής", + "filter-type-device-type-description": "Συσκευές του τύπου '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Συσκευές του τύπου '{{deviceType}}' με όνομα που αρχίζει από '{{prefix}}'", + "filter-type-entity-view-type": "Τύπος προβολής Οντοτήτων", + "filter-type-entity-view-type-description": "Τύπος προβολής Οντοτήτων '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Τύπος προβολής Οντοτήτων '{{entityView}}' με όνομα που αρχίζει από '{{prefix}}'", + "filter-type-relations-query": "Ερώτημα Σχέσεων", + "filter-type-relations-query-description": "{{entities}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Ερώτημα αναζήτησης Asset", + "filter-type-asset-search-query-description": "Asset με τύπο {{assetTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Ερώτημα αναζήτησης Συσκευών", + "filter-type-device-search-query-description": "Συσκευές με τύπο {{deviceTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Ερώτημα αναζήτησης Όψεων Οντοτήτων", + "filter-type-entity-view-search-query-description": "Όψεις Οντοτήτων με τύπο {{entityViewTypes}} που έχουν σχέση {{relationType}} {{direction}} {{rootEntity}}", + "entity-filter": "Φίλτρο Οντοτήτων", + "resolve-multiple": "Διακανονισμός ως πολλαπλές οντότητες", + "filter-type": "Είδος Φίλτρου", + "filter-type-required": "Απαιτείται καθορισμός του Είδους Φίλτρου.", + "entity-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες που ταιριάζουν με το συγκεκριμένο φίλτρο.", + "no-entity-filter-specified": "Δεν έχει οριστεί Φίλτρο Οντοτήτων", + "root-state-entity": "Χρήση της οντότητας κατάστασης του dashboard ως ρίζα", + "group-state-entity": "Χρήση της οντότητας κατάστασης του dashboard ως ομάδας οντοτήτων", + "root-entity": "Οντότητα ρίζας", + "state-entity-parameter-name": "Όνομα παραμέτρου οντότητας κατάστασης", + "default-state-entity": "Προεπιλεγμένη οντότητα κατάστασης", + "default-state-entity-group": "Προεπιλεγμένη ομάδα καταστάσεων οντοτήτων", + "default-entity-parameter-name": "Εξ ορισμού", + "max-relation-level": "Μέγιστος βαθμός συσχέτισης", + "unlimited-level": "Απεριόριστος βαθμός", + "state-entity": "Οντότητα κατάστασης του Dashboard", + "entities-of-group-state-entity": "Οντότητες από την ομάδα οντοτήτων κατάστασης του dashboard", + "all-entities": "Όλες οι Οντότητες", + "any-relation": "Οποιαδήποτε" + }, + "asset": { + "asset": "Asset", + "assets": "Assets", + "management": "Διαχείριση Asset", + "view-assets": "Προβολή Asset", + "add": "Προσθήκη Asset", + "assign-to-customer": "Ανάθεση σε Πελάτη", + "assign-asset-to-customer": "Ανάθεση Asset(s) σε Πελάτη", + "assign-asset-to-customer-text": "Επιλέξτε τα Asset που θα αναθέσετε στον πελάτη", + "no-assets-text": "Δεν βρέθηκαν Asset", + "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να αναθέσετε το Asset(s)", + "public": "Δημόσιο", + "assignedToCustomer": "Συνδεδεμένο με πελάτη", + "make-public": "Κάνε το Asset δημόσιο", + "make-private": "Κάνε το Asset ιδιωτικό", + "unassign-from-customer": "Αποσύνδεση από τον πελάτη", + "delete": "Διαγραφή Αsset", + "asset-public": "Το Asset είναι δημόσιο", + "asset-type": "Τύπος Asset", + "asset-type-required": "Απαιτείται ορισμός είδους Asset.", + "select-asset-type": "Επιλογή τύπου Αsset", + "enter-asset-type": "Εισαγωγή τύπου Αsset", + "any-asset": "Οποιοδήποτε Αsset", + "no-asset-types-matching": "Δεν βρέθηκαν τύποι Asset που να τιαιριάζουν με '{{entitySubtype}}'.", + "asset-type-list-empty": "Δεν επιλέχθηκε τύπος Asset.", + "asset-types": "Τύποι Asset", + "name": "Όνομα", + "name-required": "Απαιτείται Όνομα.", + "description": "Περιγραφή", + "type": "Τύπος", + "type-required": "Απαιτείται Τύπος.", + "details": "Λεπτομέρειες", + "events": "Γεγονότα", + "add-asset-text": "Προσθήκη νέου Asset", + "asset-details": "Λεπτομέρειες Asset", + "assign-assets": "Ανάθεση Assets", + "assign-assets-text": "Ανάθεση { count, plural, 1 {1 asset} other {# assets} } σε πελάτη", + "delete-assets": "Διαγραφή Assets", + "unassign-assets": "Αποσύνδεση Assets", + "unassign-assets-action-title": "Αποσύνδεση { count, plural, 1 {1 asset} other {# assets} } από πελάτη", + "assign-new-asset": "Ανάθεση νέου Asset", + "delete-asset-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το Asset '{{assetName}}'?", + "delete-asset-text": "Προσοχή!, Μετά την επιβεβαίωση, το Asset και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-assets-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, 1 {1 asset} other {# assets} };", + "delete-assets-action-title": "Διαγραφή { count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "Προσοχή! Μετά την επιβεβαίωση, όλα τα επιλεγμένα Asset και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "make-public-asset-title": "Είστε βέβαιοι ότι θέλετε να κάνετε το Asset '{{assetName}}' δημόσιο;", + "make-public-asset-text": "Μετά την επιβεβαίωση, το Asset και όλα τα στοιχεία του θα είναι προσβάσιμα από άλλους.", + "make-private-asset-title": "Είστε βέβαιοι ότι θέλετε να κάνετε το Asset '{{assetName}}' ιδιωτικό;", + "make-private-asset-text": "Μετά την επιβεβαίωση, το Asset και όλα τα στοιχεία του θα είναι ιδιωτικά και μη προσβάσιμα από άλλους.", + "unassign-asset-title": "Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το Asset '{{assetName}}';", + "unassign-asset-text": "Μετά την επιβεβαίωση, το Asset θα αποσυνδεθεί και δεν θα είναι προσβάσιμο από τον πελάτη.", + "unassign-asset": "Αποσύνδεση Asset", + "unassign-assets-title": "Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε { count, plural, 1 {1 asset} other {# assets} };", + "unassign-assets-text": "Μετά την επιβεβαίωση, όλα τα επιλεγμένα Asset θα αποσυνδεθούν και δεν θα είναι προσβάσιμα από τον πελάτη.", + "copyId": "Αντιγραφή Asset Id", + "idCopiedMessage": "Το Asset Id αντιγράφηκε στο πρόχειρο", + "select-asset": "Επιλογή Asset", + "no-assets-matching": "Δεν βρέθηκαν Asset που να ταιριάζουν με '{{entity}}'.", + "asset-required": "Απαιτείται Asset", + "name-starts-with": "Όνομα Asset που ξεκινάει με", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } επιλέχθηκαν", + "search": "Αναζήτηση Asset", + "select-group-to-add": "Επιλέξτε ομάδα στόχο για να προσθέσετε επιλεγμένα Asset", + "select-group-to-move": "Επιλέξτε ομάδα στόχο για να μετακινήσετε επιλεγμένα assets", + "remove-assets-from-group": "Είστε βέβαιοι ότι θέλετε να καταργήσετε { count, plural, 1 {1 asset} other {# assets} } από την ομάδα '{entityGroup}';", + "group": "Ομάδα Asset", + "list-of-groups": "{ count, plural, 1 {One asset group} other {List of # asset groups} }", + "group-name-starts-with": "Ομάδες Asset των οποίων τα ονόματα ξεκινούν με '{{prefix}}'", + "import": "Εισαγωγή Asset", + "asset-file": "Αρχείο Asset" + }, + "attribute": { + "attributes": "Χαρακτηριστικά", + "latest-telemetry": "Τελευταία τηλεμετρία", + "attributes-scope": "Πεδίο εφαρμογής Χαρακτηριστικών Οντότητας", + "scope-latest-telemetry": "Τελευταία τηλεμετρία", + "scope-client": "Χαρακτηριστικά Client", + "scope-server": "Χαρακτηριστικά Server", + "scope-shared": "Κοινόχρηστα Χαρακτηριστικά", + "add": "Προσθήκη Χαρακτηριστικού", + "add-attribute-prompt": "Προσθέστε Χαρακτηριστικό", + "key": "Όνομα", + "last-update-time": "Ώρα τελευταίας ενημέρωσης", + "key-required": "Απαιτείται μεταβλητή χαρακτηριστικού.", + "value": "Τιμή", + "value-required": "Απαιτείται ορισμός τιμής χαρακτηριστικού.", + "delete-attributes-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, 1 {1 attribute} other {# attributes} };", + "delete-attributes-text": "Προσοχή! Μετά την επιβεβαίωση θα αφαιρεθούν όλα τα επιλεγμένα χαρακτηριστικά.", + "delete-attributes": "Διαγραφή χαρακτηριστικών", + "enter-attribute-value": "Καταχωρίστε την τιμή του χαρακτηριστικού", + "show-on-widget": "Εμφάνιση σε widget", + "widget-mode": "Είδος λειτουργίας widget", + "next-widget": "Επόμενο widget", + "prev-widget": "Προηγούμενο widget", + "add-to-dashboard": "Προσθήκη σε dashboard", + "add-widget-to-dashboard": "Προσθήκη widget στο dashboard", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } επιλέχθηκαν", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } επιλέχθηκαν" + }, + "audit-log": { + "audit": "Καταγραφή", + "audit-logs": "Ημερολόγια Καταγραφής", + "timestamp": "Ώρα", + "entity-type": "Είδος Οντότητας", + "entity-name": "Όνομα Οντότητας", + "user": "Χρήστης", + "type": "Τύπος", + "status": "Κατάσταση", + "details": "Λεπτομέρειες", + "type-added": "Προστέθηκε", + "type-deleted": "Διαγράφηκε", + "type-updated": "Ενημερώθηκε", + "type-attributes-updated": "Τα χαρακτηριστικά ενημερώθηκαν", + "type-attributes-deleted": "Τα χαρακτηριστικά διαγράφηκαν", + "type-rpc-call": "Κλήση RPC", + "type-credentials-updated": "Τα διαπιστευτήρια ενημερώθηκαν", + "type-assigned-to-customer": "Ανατέθηκε σε Πελάτη", + "type-unassigned-from-customer": "Αποσυνδέθηκε από Πελάτη", + "type-activated": "Ενεργοποιήθηκε", + "type-suspended": "Μπήκε σε αναστολή", + "type-credentials-read": "Τα διαπιστευτήρια διαβάστηκαν", + "type-attributes-read": "Τα χαρακτηριστικά διαβάστηκαν", + "type-added-to-entity-group": "Προστέθηκε στην ομάδα", + "type-removed-from-entity-group": "Αφαιρέθηκε από την ομάδα", + "type-relation-add-or-update": "Relation updated", + "type-relation-delete": "Η συσχέτιση ενημερώθηκε", + "type-relations-delete": "Όλες οι συσχετίσεις διαγράφηκαν", + "type-alarm-ack": "Επιβεβαιώθηκε", + "type-alarm-clear": "Εκκαθαρίστηκε", + "type-rest-api-rule-engine-call": "Κλήση Rule engine REST API", + "type-made-public": "Έγινε δημόσιο", + "type-made-private": "Έγινε ιδιωτικό", + "status-success": "Επιτυχία", + "status-failure": "Αποτυχία", + "audit-log-details": "΄Λεπτομέρειες καταγραφής", + "no-audit-logs-prompt": "Δεν βρέθηκαν αρχεία καταγραφής", + "action-data": "Δεδομένα ενεργειών", + "failure-details": "Λεπτομέρειες αποτυχίας", + "search": "Αναζήτηση αρχείων καταγραφής", + "clear-search": "΄Καθαρισμός αναζήτησης" + }, + "confirm-on-exit": { + "message": "Έχετε μη αποθηκευμένες αλλαγές. Είστε βέβαιοι ότι θέλετε να φύγετε από τη σελίδα;", + "html-message": "Έχετε μη αποθηκευμένες αλλαγές.
    Είστε βέβαιοι ότι θέλετε να φύγετε από τη σελίδα;", + "title": "Μη αποθηκευμένες αλλαγές" + }, + "contact": { + "country": "Χώρα", + "city": "Πόλη", + "state": "Περιφέρεια", + "postal-code": "Ταχυδρομικός Κώδικας", + "postal-code-invalid": "Μη έγκυρη μορφή ταχυδρομικού κώδικα.", + "address": "Διεύθυνση (γραμμή 1)", + "address2": "Διεύθυνση (γραμμή 2)", + "phone": "Τηλέφωνο", + "email": "Email", + "no-address": "Χωρίς διεύθυνση" + }, + "common": { + "username": "Όνομα χρήστη", + "password": "Κωδικός πρόσβασης", + "enter-username": "Εισάγετε Όνομα χρήστη", + "enter-password": "Εισάγετε Κωδικό πρόσβασης", + "enter-search": "Αναζήτηση", + "created-time": "Δημιουργήθηκε" + }, + "converter": { + "converter": "Μετατροπέας δεδομένων", + "converters": "Μετατροπείς δεδομένων", + "select-converter": "Επιλογή μετατροπέα δεδομένων", + "no-converters-matching": "Δεν βρέθηκαν μετατροπείς δεδομένων για '{{entity}}'.", + "converter-required": "Απαιτείται ορισμός μετατροπέα δεδομένων", + "delete": "Διαγραφή μετατροπέα", + "management": "Διαχείριση μετατροπέων δεδομένων", + "add-converter-text": "Προσθήκη νέου μετατροπέα δεδομένων", + "no-converters-text": "Δεν βρέθηκαν μετατροπείς δεδομένων", + "selected-converters": "{ count, plural, 1 {1 data converter} other {# data converters} } επιλέχθηκαν", + "delete-converter-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον μετατροπέα δεδομένων '{{converterName}}'?", + "delete-converter-text": "Προσέξτε, μετά την επιβεβαίωση, ο μετατροπέας δεδομένων και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-converters-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, 1 {1 data converter} other {# data converters} };", + "delete-converters-action-title": "Διαγραφή { count, plural, 1 {1 data converter} other {# data converters} }", + "delete-converters-text": "Προσοχή! Μετά την επιβεβαίωση, όλοι οι επιλεγμένοι μετατροπείς δεδομένων και όλα τα σχετικά δεδομένα θα καταστούν μη ανακτήσιμα.", + "events": "Γεγονότα", + "add": "Προσθήκη μετατροπέα δεδομένων", + "converter-details": "Λεπτομέρειες μετατροπέα δεδομένων", + "details": "Λεπτομέρειες", + "copyId": "Αντιγραφή ID μετατροπέα δεδομένων", + "idCopiedMessage": "Το ID του μετατροπέα δεδομένων αντιγράφηκε στο πρόχειρο.", + "debug-mode": "Λειτουργία εντοπισμού σφαλμάτων", + "name": "Όνομα", + "name-required": "Απαιτείται Όνομα.", + "description": "Περιγραφή", + "decoder": "Αποκωδικοποιητής", + "encoder": "Kωδικοποιητής", + "test-decoder-fuction": "Δοκιμή λειτουργίας αποκωδικοποιητή", + "test-encoder-fuction": "Δοκιμή λειτουργίας κωδικοποιητή", + "decoder-input-params": "Είσοδο παραμέτρων αποκωδικοποιητή", + "encoder-input-params": "Είσοδο παραμέτρων κωδικοποιητή", + "payload": "Φορτίο", + "payload-content-type": "Τύπος περιεχόμενου φορτίου", + "payload-content": "Περιεχόμενο φορτίο", + "message": "Μήνυμα", + "message-type": "Τύπος μηνύματος", + "message-type-required": "Απαιτείται τύπος μηνύματος", + "test": "Δοκιμή", + "metadata": "Μεταδεδομένα", + "metadata-required": "Οι καταχωρίσεις μεταδεδομένων δεν μπορούν να είναι κενές.", + "integration-metadata": "Μεταδεδομένα ενσωμάτωσης", + "integration-metadata-required": "Οι καταχωρήσεις μεταδεδομένων ενσωμάτωσης δεν μπορούν να είναι κενές.", + "output": "Έξοδος", + "import": "Εισαγωγή μετατροπέα", + "export": "Εξαγωγή μετατροπέα", + "export-failed-error": "Δεν είναι δυνατή η εξαγωγή του μετατροπέα: {{error}}", + "create-new-converter": "Δημιουργία νέου μετατροπέα", + "converter-file": "Αρχείο μετατροπέα", + "invalid-converter-file-error": "Δεν είναι δυνατή η εισαγωγή του μετατροπέα: Μη έγκυρη δομή δεδομένων μετατροπέα.", + "type": "Τύπος", + "type-required": "Απαιτείται τύπος.", + "type-uplink": "Uplink", + "type-downlink": "Downlink" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "customer": { + "customer": "Πελάτης", + "customers": "Πελάτες", + "management": "Διαχείριση Πελατών", + "dashboard": "Dashboard Πελάτη", + "dashboards": "Dashboards Πελάτη", + "devices": "Συσκευές Πελάτη", + "entity-views": "Προβολές Οντοτήτων ΠελάτηCustomer Entity Views", + "assets": "Assets Πελάτη", + "public-dashboards": "Δημόσια Dashboards", + "public-devices": "Δημόσιες Devices", + "public-assets": "Δημόσια Assets", + "public-entity-views": "Δημόσιες Προβολές Οντοτήτων", + "add": "Προσθήκη Πελάτη", + "delete": "Διαγραφή πελάτη", + "manage-customer-user-groups": "Διαχείριση ομάδων χρηστών πελατών", + "manage-customer-groups": "Διαχείριση ομάδων πελατών", + "manage-customer-device-groups": "Διαχείριση ομάδων συσκευών πελατών", + "manage-customer-asset-groups": "Διαχείριση ομάδων Asset πελατών", + "manage-customer-entity-view-groups": "Διαχείριση ομάδων προβολής οντότητας πελατών", + "manage-customer-dashboard-groups": "Διαχείριση ομάδων dashboard πελατών", + "manage-customer-users": "Διαχείριση χρηστών πελατών", + "manage-customers": "Διαχείριση πελατών", + "manage-customer-devices": "Διαχείρηση συσκευών πελατών", + "manage-customer-entity-views": "Διαχείριση προβολής οντότητας πελατών", + "manage-customer-dashboards": "Διαχείριση dashboards πελατών", + "manage-public-devices": "Διαχείριση δημόσιων συσκευών", + "manage-public-dashboards": "Διαχείριση δημόσιων συσκευών", + "manage-customer-assets": "Διαχείριση οντοτήτων πελατών", + "manage-public-assets": "Διαχείριση δημόσιων οντοτήτων", + "add-customer-text": "Προσθήκη νέου πελάτη", + "no-customers-text": "Δεν βρέθηκαν πελάτες", + "customer-details": "Λεπτομέρειες πελάτη", + "delete-customer-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον πελάτη '{{customerTitle}}'?", + "delete-customer-text": "Προσέξτε, μετά την επιβεβαίωση, ο πελάτης και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-customers-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Διαγραφή { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Προσέξτε, μετά την επιβεβαίωση, οι πελάτες και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "manage-user-groups": "Διαχείρηση ομαδών χρηστών", + "manage-asset-groups": "Διαχείρηση ομαδών οντοτήτων", + "manage-device-groups": "Διαχείρηση ομαδών συσκευών", + "manage-dashboard-groups": "Διαχείρηση ομαδών dashboards", + "manage-entity-view-groups": "Διαχείρηση ομαδών προβολής οντοτήτων", + "manage-users": "Διαχείρηση χρηστών", + "manage-assets": "Διαχείρηση οντοτήτων", + "manage-devices": "Διαχείρηση συσκευών", + "manage-dashboards": "Διαχείρηση dashboards", + "title": "Τίτλος", + "title-required": "Απαιτείται ένας τίτλος.", + "description": "Περιγραφή", + "details": "Λεπτομέρειες", + "events": "Γεγονότα", + "copyId": "Αντιγραφή ID πελάτη", + "idCopiedMessage": "Το ID του πελάτη έχει αντιγραφεί στο πρόχειρο", + "select-customer": "Επιλέξτε πελάτη", + "no-customers-matching": "Δεν βρέθηκαν πελάτες που να αντιστοιχούν σε '{{entity}}'.", + "customer-required": "Απαιτείται πελάτης", + "selected-customers": "{ count, plural, 1 {1 customer} other {# customers} } επιλέχθηκαν", + "search": "Αναζήτηση πελατών", + "select-group-to-add": "Επιλέξτε ομάδα για να προσθέσετε τους επιλεγμένους πελάτες", + "select-group-to-move": "Επιλέξτε ομάδα για να μετακινήσετε τους επιλεγμένους πελάτες", + "remove-customers-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 customer} other {# customers} } από την ομάδα '{entityGroup}'?", + "group": "Ομάδα πελατών", + "list-of-groups": "{ count, plural, 1 {One customer group} other {List of # customer groups} }", + "group-name-starts-with": "Πελάτες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "select-default-customer": "Επιλογή προεπιλεγμένου πελάτη", + "default-customer": "Προεπιλεγμένος πελάτης", + "default-customer-required": "Ο προεπιλεγμένος πελάτης είναι υποχρεωτικός για να είναι δυνατή η απασφαλμάτωση του dashboard από τον Tenant", + "allow-white-labeling": "Επιτρέψτε το White Labeling" + }, + "customers-hierarchy": { + "customers-hierarchy": "Ιεραρχία Πελατών", + "open-nav-tree": "Άνοιγμα διακλάδωσης", + "return-to-top-level": "Επιστροφή στο αρχικό επίπεδο" + }, + "custom-menu": { + "custom-menu": "Προσαρμοσμένο Μενού", + "custom-menu-hint": "Ορίστε το προσαρμοσμένο μενού (JSON) παρακάτω. Αυτό το JSON περιέχει μια λίστα με προσαρμοσμένα στοιχεία μενού." + }, + "custom-translation": { + "custom-translation": "Μεταφράσεις", + "translation-map": "Χάρτης μετάφρασης", + "key": "Κλειδί", + "import": "Εισαγωγή μετάφρασης", + "export": "Εξαγωγή μετάφρασης", + "export-data": "Εξαγωγή δεδομένων μετάφρασης", + "import-data": "Εισαγωγή δεδομένων μετάφρασης", + "translation-file": "Αρχείο μετάφρασης", + "invalid-translation-file-error": "Δεν είναι δυνατή η εισαγωγή αρχείου μετάφρασης: Μη έγκυρη δομή δεδομένων μετάφρασης.", + "custom-translation-hint": "Καθορίστε την προσαρμοσμένη μετάφραση (JSON) παρακάτω. Αυτό το JSON θα αντικαταστήσει την προεπιλεγμένη μετάφραση. Κάνετε Λήψη αρχείου γλώσσας για να λάβετε την υπάρχουσα μετάφραση. Μπορείτε επίσης να χρησιμοποιήσετε το ληφθέν αρχείο ως αναφορά στα διαθέσιμα ζεύγη κλειδιών-τιμών μετάφρασης.", + "download-locale-file": "Λήψη αρχείου γλώσσας" + }, + "datetime": { + "date-from": "Ημ/νία από", + "time-from": "Ώρα από", + "date-to": "Ημ/νία έως", + "time-to": "Ώρα έως" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Διαχείριση Dashboard", + "view-dashboards": "Προβολή Dashboards", + "add": "Προσθήκη Dashboard", + "assign-dashboard-to-customer": "Ανάθεση Dashboard(s) Σε Πελάτη", + "assign-dashboard-to-customer-text": "Παρακαλώ επιλέξτε τα dashboards που θέλετε να αναθέσετε σε πελάτη", + "assign-to-customer-text": "Παρακαλώ επιλέξτε τον πελάτη στον οποίο θέλετε να αναθέσετε το/τα dashboard(s)", + "assign-to-customer": "Ανάθεση σε πελάτη", + "unassign-from-customer": "Αφαίρεση από πελάτη", + "make-public": "Δημοσιοποιήση Dashboard", + "make-private": "Ιδιωτικοποίηση Dashboard", + "manage-assigned-customers": "Διαχείρηση ανατεθειμένων πελατών", + "assigned-customers": "Ανατεθειμένοι πελάτες", + "assign-to-customers": "Ανάθεση Dashboard(s) Σε Πελάτες", + "assign-to-customers-text": "Παρακαλώ επιλέξτε τους πελάτες στους οποίους θέλετε να αναθέσετε το/τα dashboard(s)", + "unassign-from-customers": "Μη Ανατεθειμένα Dashboard(s) Από Πελάτες", + "unassign-from-customers-text": "Παρακαλώ επιλέξτε τους πελάτες τους οποίους θέλετε να αφαιρέσετε από το/τα dashboard(s)", + "no-dashboards-text": "Δεν βρέθηκαν dashboards", + "no-widgets": "Δεν έχουν ρυθμιστεί widgets", + "add-widget": "Προσθέστε νέο widget", + "title": "Τίτλος", + "select-widget-title": "Επιλογή widget", + "select-widget-subtitle": "Λίστα με τους διαθέσιμους τύπους widget", + "delete": "Διαγραφή dashboard", + "title-required": "Απαιτείται ένας τίτλος.", + "description": "Περιγραφή", + "details": "Λεπτομέρειες", + "dashboard-details": "Λεπτομέρειες Dashboard", + "add-dashboard-text": "Προσθέστε νέο dashboard", + "assign-dashboards": "Αναθέστε dashboards", + "assign-new-dashboard": "Ανάθεση νέου dashboard", + "assign-dashboards-text": "Ανάθεση { count, plural, 1 {1 dashboard} other {# dashboards} } σε πελάτες", + "unassign-dashboards-action-text": "Μη ανατεθειμένο/α { count, plural, 1 {1 dashboard} other {# dashboards} } από πελάτες", + "delete-dashboards": "Διαγραφή dashboards", + "unassign-dashboards": "Αφαίρεση dashboards", + "unassign-dashboards-action-title": "Αφαίρεση { count, plural, 1 {1 dashboard} other {# dashboards} } από πελάτη", + "delete-dashboard-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Προσοχή, μετά την επιβεβαίωση, το dashboard και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-dashboards-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Διααγραφή { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Προσοχή, μετά την επιβεβαίωση, όλα τα επιλεγμένα dashboards και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "unassign-dashboard-title": "Είστε σίγουρου ότι θέλετε να αφαιρέσετε το dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Μετά την επιβεβαίωση το dashboard θα αφαιρεθεί και δεν θα είναι διαθέσιμο στον πελάτη.", + "unassign-dashboard": "Αφαίρεση dashboard", + "unassign-dashboards-title": "Είστε σίγουρου ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Μετά την επιβεβαίωση όλα τα επιλεγμένα dashboards θα αφαιρεθούν και δεν θα είναι διαθέσιμα στον πελάτη.", + "public-dashboard-title": "Το dashboard είναι δημόσιο", + "public-dashboard-text": "Το dashboard {{dashboardTitle}} είναι δημόσιο και διαθέσιμο μέσω του παρακάτω συνδέσμου:", + "public-dashboard-notice": "Σημείωση: Μην ξεχνάτε να κάνετε δημόσιες τις συσκευές που συσχετίζονται ώστε να είναι δυνατή η πρόσβαση στα δεδομένα τους.", + "public-dashboard-link": "Σύνδεσμος δημόσιου dashboard", + "public-dashboard-link-text": "Το δημόσιο dashboard {{dashboardTitle}} είναι πλέον διαθέσιμο μέσω μέσω του παρακάτω συνδέσμου:", + "public-dashboard-link-notice": "Σημείωση: Μην ξεχνάτε να κάνετε δημόσιες τις συσκευές, τα assets και την προβολή οντοτήτων που συσχετίζονται ώστε να είναι δυνατή η πρόσβαση στα δεδομένα τους.", + "make-private-dashboard-title": "Είστε σίγουροι ότι θέλετε να κάνετε το dashboard '{{dashboardTitle}}' ιδιωτικό", + "make-private-dashboard-text": "Μετά την επιβεβαίωση το dashboard θα γίνουν ιδιωτικά και δεν θα είναι διαθέσιμα από τρίτους.", + "make-private-dashboard": "Κάνε το dashboard ιδιωτικό", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Επιλογή dashboard", + "no-dashboards-matching": "Δεν βρέθηκαν dashboards που να αντιστοιχούν σε '{{entity}}'.", + "dashboard-required": "Απαιτείται dashboard.", + "select-existing": "Επιλέξτε ένα υπάρχον dashboard", + "create-new": "Δημιουργία νέου dashboard", + "new-dashboard-title": "Τίτλος νέου dashboard", + "open-dashboard": "Άνοιγμα dashboard", + "set-background": "Ορίστε φόντο", + "background-color": "χρώμα φόντου", + "background-image": "εικόνα φόντου", + "background-size-mode": "μέγεθος φόντου", + "no-image": "Δεν επιλέχθηκε εικόνα", + "drop-image": "Ρίξτε εδώ μια εικόνα ή κάνετε κλικ για να επιλέξετε ένα αρχείο για να ανεβεί.", + "settings": "Ρυθμίσεις", + "columns-count": "Αρίθμιση στηλών", + "columns-count-required": "Απαιτείται η αρίθμιση στηλών.", + "min-columns-count-message": "Ελάχιστος αριθμός στηλών είναι 10.", + "max-columns-count-message": "Μέγιστος αριθμός στηλών είναι 1000.", + "widgets-margins": "Περιθώριο μεταξύ widgets", + "horizontal-margin": "Οριζόντιο περιθώριο", + "horizontal-margin-required": "Απαιτείται τιμή για το οριζόντιο περιθώριο.", + "min-horizontal-margin-message": "Ελάχιστη τιμή οριζόντιου περιθωρίου είναι 0.", + "max-horizontal-margin-message": "Μέγιστη τιμή οριζόντιου περιθωρίου είναι 50.", + "vertical-margin": "Κάθετο περιθώριο", + "vertical-margin-required": "Απαιτείται τιμή για το κάθετο περιθώριο", + "min-vertical-margin-message": "Ελάχιστη τιμή καθέτου περιθωρίου είναι 0.", + "max-vertical-margin-message": "Μέγιστη τιμή καθέτου περιθωρίου είναι 50.", + "autofill-height": "Αυτόματη συμπλήρωση ύψους διάταξης", + "mobile-layout": "Ρυθμίσεις διάταξης mobile", + "mobile-row-height": "ύψος γραμμής mobile, σε px", + "mobile-row-height-required": "Απαιτείται ύψος γραμμής mobile.", + "min-mobile-row-height-message": "Ελάχιστη τιμή ύψους γραμμής mobile είναι 5.", + "max-mobile-row-height-message": "Μέγιστη τιμή ύψους γραμμής mobile είναι 200.", + "display-title": "Τίτλος εμφάνισης dashboard", + "toolbar-always-open": "Γραμμή εργαλείων πάντα ανοιχτή", + "title-color": "Χρώμα τίτλου", + "display-dashboards-selection": "Εμφάνηση επιλογής dashboards", + "display-entities-selection": "Εμφάνιση επιλογής οντοτήτων", + "display-dashboard-timewindow": "Εμφάνιση χρονικού πλαισίου", + "display-dashboard-export": "Εμφάνιση εξαγωγής", + "import": "Εισαγωγή dashboard", + "export": "Εξαγωγή dashboard", + "export-failed-error": "Δεν ήταν δυνατή η εξαγωγή dashboard: {{error}}", + "export-pdf": "Εξαγωγή ως PDF", + "export-png": "Εξαγωγή ως PNG", + "export-jpg": "Εξαγωγή ως JPEG", + "export-json-config": "Εξαγωγή ρυθμίσεις JSON", + "download-dashboard-progress": "Δημιουργία dashboard {{reportType}} ...", + "create-new-dashboard": "Δημιουργία νέου dashboard", + "dashboard-file": "Αρχείο dashboard", + "invalid-dashboard-file-error": "Δεν ήταν δυνατή η εισαγωγή dashboard: Μη έγκυρη δομή δεδομένων dashboard.", + "dashboard-import-missing-aliases-title": "Διαμορφώστε τα ψευδώνυμα που χρησιμοποιούνται από το dashboard που εισαγάγατε", + "create-new-widget": "Δημιουργία νέου widget", + "import-widget": "Εισαγωγή widget", + "widget-file": "Αρχείο widget", + "invalid-widget-file-error": "Δεν ήταν δυνατή η εισαγωγή widget: Μη έγκυρη δομή δεδομένων dashboard.", + "widget-import-missing-aliases-title": "Διαμορφώστε τα ψευδώνυμα που χρησιμοποιούνται από το widget που εισαγάγατε", + "open-toolbar": "Άνοιγμα γραμμής εργαλείων dashboard", + "close-toolbar": "Κλείσιμο γραμμής εργαλείων", + "configuration-error": "Σφάλμα ρυθμίσεων", + "alias-resolution-error-title": "Σφάλμα ρυθμίσεων ψευδωνύμων dashboard", + "invalid-aliases-config": "Δεν βρέθηκε καμία συσκευή που να ταιριάζει με κάποιο από τα φίλτρα.
    Παρακαλούμε επικοινωνήστε με τον Διαχειριστή για την επίλυση του ζητήματος.", + "select-devices": "Επιλογή συσκευών", + "assignedToCustomer": "Ανατεθειμένο σε πελάτη", + "assignedToCustomers": "Ανατεθειμένο σε πελάτες", + "public": "Δημόσιο", + "public-link": "Δημόσιος σύνδεσμος", + "copy-public-link": "Αντιγραφή δημόσιου συνδέσμου", + "public-link-copied-message": "Ο δημόσιος σύνδεσμος έχει αντιγραφεί στο πρόχειρο", + "manage-states": "Διαχείρηση κατάστασης dashboard", + "states": "Κατάσταση dashboard", + "search-states": "Αναζήτηση σε κατάσταση dashboard", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } επιλεγμένα", + "edit-state": "Επεξεργασία κατάστασης dashboard", + "delete-state": "Διαγραφή κατάστασης dashboard", + "add-state": "Προσθήκη κατάστασης dashboard", + "state": "Κατάσταση Dashboard", + "state-name": "Όνομα", + "state-name-required": "Απαιτείται όνομα κατάστασης dashboard.", + "state-id": "ID Κατάστασης", + "state-id-required": "Απαιτείται ID κατάστασης dashboard.", + "state-id-exists": "Υπάρχει ήδη κατάσταση dashboard με το ίδιο ID.", + "is-root-state": "Root state", + "delete-state-title": "Διαγραφή κατάστασης dashboard", + "delete-state-text": "Είστε σίγουροι ότι θέλετε να διαγράψετε την κατάσταση dashboard με όνομα '{{stateName}}'?", + "show-details": "Εμφάνιση λεπτομερειών", + "hide-details": "Απόκρυψη λεπτομερειών", + "select-state": "Επιλογή κατάστασης", + "state-controller": "Έλεγχος κατάστασης", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } επιλεγμένα", + "search": "Αναζήτηση dashboards", + "select-group-to-add": "Επιλογή ομάδας για να προστεθεί στα επιλεγμένα dashboards", + "select-group-to-move": "Επιλογή ομάδας για να μετακινηθεί στα επιλεγμένα dashboards", + "remove-dashboards-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 dashboard} other {# dashboards} } from group '{entityGroup}'?", + "group": "Ομάδα dashboards", + "list-of-groups": "{ count, plural, 1 {One dashboard group} other {List of # dashboard groups} }", + "group-name-starts-with": "Ομάδες dashboard των οποίων το όνομα αρχίζει από '{{prefix}}'" + }, + "datakey": { + "settings": "Ρυθμίσεις", + "advanced": "Προχωρημένες", + "label": "Ετικέτα", + "color": "Χρώμα", + "units": "Ειδικό σύμβολο που εμφανίζεται δίπλα από την τιμή", + "decimals": "Αριθμός ψηφίων μετά την υποδιαστολή", + "data-generation-func": "Λειτουργία δημιουργίας δεδομένων", + "use-data-post-processing-func": "Χρησιμοποιήστε τη λειτουργία μετα-επεξεργασίας δεδομένων", + "configuration": "Ρυθμίσεις ονομάτων δεδομένων", + "timeseries": "Χρονική σειρά", + "attributes": "Ιδιώτητες", + "alarm": "Πεδία alarm", + "timeseries-required": "Απαιτείται χρονική σειρά οντοτήτων.", + "timeseries-or-attributes-required": "Απαιτείται χρονική σειρά/ιδιώτητες οντοτήτων.", + "maximum-timeseries-or-attributes": "Μέγιστο { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "Απαιτούνται πεδία alarm.", + "function-types": "Τύποι λειτουργιών", + "function-types-required": "Απαιτούνται τύποι λειτουργιών.", + "maximum-function-types": "Μέγιστο { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "χρονικό σημείο της συγκεκριμένης τιμής", + "value-description": "η συγκεκριμένη τιμή", + "prev-value-description": "αποτέλεσμα της προηγούμενης λειτουργίας", + "time-prev-description": "χρονσικό σημείο της προηγούμενης τιμής", + "prev-orig-value-description": "αρχική προηγούμενη τιμή" + }, + "datasource": { + "type": "Τύπος πηγής δεδομένων", + "name": "Όνομα", + "add-datasource-prompt": "Παρακαλούμε προσθέστε πηγή δεδομένων" + }, + "details": { + "details": "Λεπτομέρειες", + "edit-mode": "Λειτουργία Επεξεργασίας", + "toggle-edit-mode": "Εναλλαγή λειτουργίας επεξεργασίας" + }, + "device": { + "device": "Συσκευή", + "device-required": "Απαιτείται ορισμός συσκευής.", + "devices": "Συσκευές", + "management": "Διαχείριση Συσκευών", + "view-devices": "Προβολή Συσκευών", + "device-alias": "Ψευδώνυμο συσκευής", + "aliases": "Ψευδώνυμα συσκευής", + "no-alias-matching": "Δεν βρέθηκε '{{alias}}'.", + "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", + "no-key-matching": "Δεν βρέθηκε '{{key}}'.", + "no-keys-found": "Δεν βρέθηκαν ονόματα", + "create-new-alias": "Δημιουργήστε ένα νέο ψευδώνυμο!", + "create-new-key": "Δημιουργήστε ένα νέο!", + "duplicate-alias-error": "Βρέθηκε διπλότυπο το ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα συσκευών πρέπει να είναι μοναδικά σε ένα dashboard.", + "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", + "no-devices-matching": "Δεν βρέθηκε συσκευή η οποία να αντιστοιχεί σε '{{entity}}'.", + "alias": "Ψευδώνυμο", + "alias-required": "Απαιτείται ψευδώνυμο συσκευής.", + "remove-alias": "Αφαίρεση ψευδώνυμου συσκευής", + "add-alias": "Προσθήκη ψευδώνυμου συσκευής", + "name-starts-with": "Το όνομα συσκευής αρχίζει με", + "device-list": "Λίστα συσκευών", + "use-device-name-filter": "Χρήση φίλτρου", + "device-list-empty": "Δεν επιλέχθηκαν συσκευές.", + "device-name-filter-required": "Απαιτείται φίλτρο ονόματος συσκυεής.", + "device-name-filter-no-device-matched": "Δεν βρέθηκαν συσκευές οι οποίες να αρχίζουν από '{{device}}'.", + "add": "Προσθήκη συσκευής", + "assign-to-customer": "Ανάθεση σε πελάτη", + "assign-device-to-customer": "Ανάθεση Συσκευών Σε Πελάτη", + "assign-device-to-customer-text": "Παρακαλούμε επιλέξτε συσκευές προς ανάθεση σε πελάτη", + "make-public": "Δημοσιοποίηση συσκευών", + "make-private": "Ιδιωτικοποίηση συσκευών", + "no-devices-text": "Δεν βρέθηκαν συσκευές", + "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να αναθέσετε τις συσκευές", + "device-details": "Λεπτομέρειες συσκευές", + "add-device-text": "Προσθήκη νέας συσκευής", + "credentials": "Διαπιστευτήρια", + "manage-credentials": "Διαχείρηση διαπιστευτηρίων", + "delete": "Διαγραφή συσκευής", + "assign-devices": "Ανάθεση συσκευών", + "assign-devices-text": "Ανάθεση { count, plural, 1 {1 device} other {# devices} } σε πελάτη", + "delete-devices": "Διαγραφή συσκευών", + "unassign-from-customer": "Αφαίρεση από πελάτη", + "unassign-devices": "Αφαίρεση συσκευών", + "unassign-devices-action-title": "Αφαίρεση { count, plural, 1 {1 device} other {# devices} } από πελάτη", + "assign-new-device": "Ανάθεση νέων συσκευών", + "make-public-device-title": "Είστε σίγουροι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' δημόσια;", + "make-public-device-text": "Μετά από την επιβεβαίωσή σας η συσκευή και όλα τα δεδομένα της θα είναι δημόσια και διαθέσιμα σε τρίτους.", + "make-private-device-title": "Είστε σίγουροι ότι θέλετε να κάνετε τη συσκευή '{{deviceName}}' ιδιωτική;", + "make-private-device-text": "Μετά από την επιβεβαίωσή σας η συσκευή και όλα τα δεδομένα της θα είναι ιδιωτικά και δεν θα είναι διαθέσιμα σε τριτους.", + "view-credentials": "Προβολή διαπιστευτηρίων", + "delete-device-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την συσκευή '{{deviceName}}';", + "delete-device-text": "Προσοχή, μετά την επιβεβαίωση, η συσκευή και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-devices-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 device} other {# devices} };", + "delete-devices-action-title": "Διαγραφή { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες συσκευές θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "unassign-device-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε την συσκευή '{{deviceName}}'?", + "unassign-device-text": "Μετά την επιβεβαίωση, η συσκευή θα αφαιρεθεί και δεν θα είναι προσβάσιμες από τον πελάτη.", + "unassign-device": "Αφαίρεση συσκευής", + "unassign-devices-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες συσκευές θα καταργηθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", + "device-credentials": "Πιστοποιητικά συσκευής", + "credentials-type": "Τύπος διαπιστευτηρίων", + "access-token": "Διακριτικό πρόσβασης", + "access-token-required": "Απαιτείται διακριτικό πρόσβασης.", + "access-token-invalid": "Access token length must be from 1 to 32 characters.", + "secret": "Secret", + "secret-required": "Απαιτείται secret.", + "device-type": "Τύπος συσκευής", + "device-type-required": "Απαιτείται τύπος συσκεύης.", + "select-device-type": "Επιλογή τύπου συσκευής", + "enter-device-type": "Είσοδο τύπου συσκευής", + "any-device": "Οποιαδήποτε συσκευή", + "no-device-types-matching": "Δεν βρέθηκε συσκευή η οποία να αντιστοιχεί σε '{{entitySubtype}}'.", + "device-type-list-empty": "Δεν έχουν επιλεχθεί τύποι συσκευής.", + "device-types": "Τύποι συσκευής", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "label": "Ετικέτα", + "events": "Γεγονότα", + "details": "Λεπτομέρειες", + "copyId": "Αντιγραφή ID συσκευής", + "copyAccessToken": "Αντιγραφή διακριτικού διαπιστευτηρίου", + "idCopiedMessage": "Το ID της συσκευής έχει αντιγραφεί στο πρόχειρο", + "accessTokenCopiedMessage": "Το διακριτικό διαπιστευτήριο έχει αντιγραφεί στο πρόχειρο", + "assignedToCustomer": "Ανάθεση σε πελάτη", + "unable-delete-device-alias-title": "Αδύνατο να διαγραφεί το ψευδώνυμο συσκευής", + "unable-delete-device-alias-text": "Το ψευδώνυμο συσκευής '{{deviceAlias}}' δεν μπορεί να διαγραφεί όσο εξακωλουθεί να χρησιμοποιείται από τα ακόλουθα widget(s):
    {{widgetsList}}", + "is-gateway": "είναι gateway", + "public": "Δημόσιο", + "device-public": "Η συσκευή είναι δημόσια", + "select-device": "Επιλογή συσκευής", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } επιλέχθηκαν", + "search": "Αναζήτηση συσκευών", + "select-group-to-add": "Επιλέξτε την ομάδα στην οποία θα προστεθουν οι επιλεγμένες συσκευές", + "select-group-to-move": "Επιλέξτε την ομάδα στην οποία θα μετακινηθούν οι επιλεγμένες συσκευές", + "remove-devices-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 device} other {# devices} } from group '{entityGroup}'?", + "group": "Ομάδα Συσκευών", + "list-of-groups": "{ count, plural, 1 {One device group} other {List of # device groups} }", + "group-name-starts-with": "Ομάδες συσκευών των οποίων το όνομα αρχίζει από '{{prefix}}'", + "import": "Εισαγωγή συσκευής", + "device-file": "Αρχείο συσκευής" + }, + "dialog": { + "close": "Κλείσιμο διαλόγου" + }, + "direction": { + "column": "Στήλη", + "row": "Γραμμή" + }, + "error": { + "unable-to-connect": "Αδύνατον να συνδεθεί στον διακομιστή! Παρακαλούμε ελέγξτε την σύνδεσή σας στο ίντερνετ.", + "unhandled-error-code": "Μη διορθωμένος κωδικός σφάλματος: {{errorCode}}", + "unknown-error": "Άγνωστο σφάλμα" + }, + "entity": { + "entity": "Οντότητα", + "entities": "Οντότητες", + "aliases": "Ψευδώνυμα οντότητας", + "entity-alias": "Ψευδώνυμο οντότητας", + "unable-delete-entity-alias-title": "Αδύνατο να διαγραφεί το ψευδώνυμο οντότητας", + "unable-delete-entity-alias-text": "Το ψευδώνυμο οντότητας '{{entityAlias}}' δεν μπορεί να διαγραφεί όσο εξακολουθεί να χρησιμοποιείται από τα παρακάτω widget(s):
    {{widgetsList}}", + "duplicate-alias-error": "Βρέθηκε διπλότυπο το ψευδώνυμο '{{alias}}'.
    Τα ψευδώνυμα οντοτήτων πρέπει να είναι μοναδικά σε ένα dashboard.", + "missing-entity-filter-error": "Λείπει φίλτρο από το ψευδώνυμο '{{alias}}'.", + "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", + "alias": "Ψευδώνυμο", + "alias-required": "Απαιτείται ψευδώνυμο οντότητας.", + "remove-alias": "Αφαίρεση ψευδώνυμου οντότητας", + "add-alias": "Προσθήκη ψευδώνυμου οντότητας", + "entity-list": "Λίστα οντοτήτων", + "entity-type": "Τύπος οντοτήτων", + "entity-types": "Τύποι οντοτήτων", + "entity-type-list": "Λίστα τύπων οντοτήτων", + "any-entity": "Οποιαδήποτε οντότητα", + "enter-entity-type": "Εισαγωγή τύπου οντότητας", + "no-entities-matching": "Δεν βρέθηκαν οντότητες ο οποίες να σχετίζονται με '{{entity}}'.", + "no-entity-types-matching": "Δεν βρέθηκαν τύποι οντότητας ο οποίοι να σχετίζονται με '{{entityType}}'.", + "name-starts-with": "το όνομα αρχίζει από", + "use-entity-name-filter": "χρήση φίλτρου", + "entity-list-empty": "Δεν έχουν επιλεγεί οντότητες.", + "entity-type-list-empty": "Δεν έχουν επιλεγεί τύποι οντότητας.", + "entity-name-filter-required": "Απαιτείται φίλτρο ονόματος οντότητας.", + "entity-name-filter-no-entity-matched": "Δεν βρέθηκαν οντότητες οι οποίες αρχίζουν από '{{entity}}'.", + "all-subtypes": "Όλοι", + "select-entities": "Επιλογή οντοτήτων", + "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", + "no-alias-matching": "Δεν βρέθηκε '{{alias}}'.", + "create-new-alias": "Δημιουργείστε ένα νέο!", + "key": "Κλειδί", + "key-name": "όνομα κλειδιού", + "no-keys-found": "δεν βρέθηκαν κλειδιά.", + "no-key-matching": "Δεν βρέθηκε '{{key}}'.", + "create-new-key": "Δημιουργείστε ένα νέο!", + "type": "Τύπος", + "type-required": "Απαιτείται τύπος οντότητας.", + "type-device": "Συσκευή", + "type-devices": "Συσκευές", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "Συσκευές των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-asset": "Asset", + "type-assets": "Assets", + "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", + "asset-name-starts-with": "Assets των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-entity-view": "Προβολή οντότητας", + "type-entity-views": "Προβολές οντότητας", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Προβολές οντότητας των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-rule": "Κανόνας", + "type-rules": "Κανόνες", + "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", + "rule-name-starts-with": "Κανόνες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-plugin": "Πρόσθετο", + "type-plugins": "Προσθετα", + "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", + "plugin-name-starts-with": "Πρόσθετα των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-tenant": "Μισθωτής", + "type-tenants": "Μισθωτές", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", + "tenant-name-starts-with": "Μισθωτές των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-customer": "Πελάτης", + "type-customers": "Πελάτες", + "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", + "customer-name-starts-with": "Πελάτες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-user": "Χρήστης", + "type-users": "Χρήστες", + "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", + "user-name-starts-with": "Χρήστες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboards", + "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", + "dashboard-name-starts-with": "Dashboards των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarms", + "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", + "alarm-name-starts-with": "Alarms των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-rulechain": "Αλυσίδα Κανόνων", + "type-rulechains": "Αλυσίδες Κανόνων", + "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", + "rulechain-name-starts-with": "Αλυσίδες Κανόνων των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-scheduler-event": "Προγραμματιστής", + "type-scheduler-events": "Προγραμματιστής", + "list-of-scheduler-events": "{ count, plural, 1 {One scheduler event} other {List of # scheduler events} }", + "scheduler-event-name-starts-with": "Προγραμματιστές γεγονότων των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-blob-entity": "Ογκώδη οντότητα", + "type-blob-entities": "Ογκώδεις οντότητες", + "list-of-blob-entities": "{ count, plural, 1 {One blob entity} other {List of # blob entities} }", + "blob-entity-name-starts-with": "Ογκώδεις οντότητες των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-rulenode": "Κόμβος κανόνα", + "type-rulenodes": "Κόμβοι κανόνων", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Κόμβοι κανόνων των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-current-customer": "Τρέχον Πελάτης", + "search": "Αναζήτηση Οντότητες", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } επιλεγμένα", + "entity-name": "Όνομα οντότητας", + "details": "Λεπτομέρειες οντότητας", + "no-entities-prompt": "Δεν βρέθηκαν οντότητες", + "no-data": "Δεν υπάρχουν δεδομένα προς προβολή", + "columns-to-display": "στήλες που προβάλονται", + "type-entity-group": "Ομάδα Οντότητας", + "type-converter": "Μετατροπέας Δεδομένων", + "type-converters": "Μετατροπείς Δεδομένων", + "list-of-converters": "{ count, plural, 1 {One data converter} other {List of # data converters} }", + "converter-name-starts-with": "Μετατροπείς δεδομένων των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-integration": "Ενσωμάτωση", + "type-integrations": "Ενσωματώσεις", + "list-of-integrations": "{ count, plural, 1 {One integration} other {List of # integrations} }", + "integration-name-starts-with": "Ενσωματώσεις των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-role": "Ρόλος", + "type-roles": "Ρόλοι", + "list-of-roles": "{ count, plural, 1 {One role} other {List of # roles} }", + "role-name-starts-with": "Ρόλοι των οποίων το όνομα αρχίζει από '{{prefix}}'", + "type-group-permission": "Άδεια Ομάδας" + }, + "entity-group": { + "entity-group": "Ομάδα Οντότητας", + "details": "Λεπτομέρειες", + "columns": "Στήλες", + "add-column": "Προσθήκη στήλης", + "column-value": "Τιμή", + "column-value-required": "Απαιτείται τιμή στήλης.", + "column-title": "Τίτλος", + "default-sort-order": "Προεπιλογή ταξινόμισης", + "default-sort-order-required": "Απαιτείται προεπιλογή ταξινόμισης στήλης.", + "hide-in-mobile-view": "κρυφό σε mobile", + "use-cell-style-function": "Λειτουργία χρήσης στυλ κελιού", + "use-cell-content-function": "Λειτουργία χρήσης περιεχομένου κελιού", + "edit-column": "Επεξεργασία στήλης", + "column-details": "Λεπτομέρειες στήλης", + "actions": "Ενέργιες", + "settings": "Ρυθμίσεις", + "delete": "Διαγραφή ομάδας οντοτήτων", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "add": "Προσθήκη ομάδας οντοτήτων", + "add-entity-group-text": "Προσθήκη νέας ομάδας οντοτήτων", + "no-entity-groups-text": "Δεν βρέθηκαν ομάδες οντοτήτων", + "entity-group-details": "Λεπτομέρειες ομαδών οντοτήτων", + "delete-entity-groups": "Διαγραφή ομαδών οντοτήτων", + "delete-entity-group-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την ομάδα οντοτήτων '{{entityGroupName}}'?", + "delete-entity-group-text": "Προσοχή, μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-entity-groups-title": "Είστε σίγουροι ότι θέλετε να διαγραφούν { count, plural, 1 {1 entity group} other {# entity groups} }?", + "delete-entity-groups-action-title": "Διαγραφή { count, plural, 1 {1 entity group} other {# entity groups} }", + "delete-entity-groups-text": "Προσοχή, αφού ολοκληρωθεί η επιβεβαίωση, όλες οι επιλεγμένες ομάδες οντοτήτων θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "device-groups": "Ομάδες Συσκευών", + "asset-groups": "Ομάδες Asset", + "customer-groups": "Ομάδες Πελατών", + "device-group": "Ομάδα Πελατών", + "asset-group": "Ομάδα Asset", + "user-group": "Ομάδα Χρηστών", + "user-groups": "Ομάδες Χρηστών", + "customer-group": "Ομάδα Χρηστών", + "entity-view-groups": "Ομάδες Όψεων", + "entity-view-group": "Προβολή ομαδών οντοτήτων", + "dashboard-groups": "Ομάδες Dashboard", + "dashboard-group": "Ομάδα Dashboard", + "fetch-more": "Περισσότερα", + "column-type": { + "column-type": "Τύπος στήλης", + "client-attribute": "Χαρακτηριστικά Client", + "shared-attribute": "Κοινόχρηστα Χαρακτηριστικά", + "server-attribute": "Χαρακτηριστικό Server", + "timeseries": "Χρονική σειρά", + "entity-field": "Πεδίο οντότητας" + }, + "column-type-required": "Απαιτείται τύπος στήλης.", + "entity-field": { + "created-time": "Δημιουργήθηκε", + "name": "Όνομα", + "type": "Τύπος", + "assigned_customer": "Ανατεθειμένος πελάτης", + "authority": "Εξουσιοδότηση", + "first_name": "Όνομα", + "last_name": "Επίθετο", + "email": "Email", + "title": "Τίτλος", + "country": "Χώρα", + "state": "Νομός", + "city": "Πόλη", + "address": "Διεύθυνση", + "address2": "Διεύθυνση 2", + "zip": "Τ.Κ.", + "phone": "Τηλέφωνο" + }, + "sort-order": { + "asc": "Αύξουσα", + "desc": "Φθίνουσα", + "none": "Καμία" + }, + "details-mode": { + "on-row-click": "στο κλικ στη γραμμη", + "on-action-button-click": "στο κλικ στο κουμπί λεπτομέρειες", + "disabled": "μη διαθέσιμο" + }, + "change-owner": "Αλλαγή ιδιοκτήτη", + "select-target-owner": "Επιλογή ιδιοκτήτη", + "no-owners-matching": "Δεν βρέθηκε ιδιοκτήτης που να αντιστοιχεί σε '{{owner}}'.", + "target-owner-required": "Απαιτείται επιλεγμένος ιδιοκτήτης.", + "confirm-change-owner-title": "Είστε σίγουροι ότι θέλετε να αλλάξετε ιδιοκτήτη για { count, plural, 1 {1 selected entity} other {# selected entities} }?", + "confirm-change-owner-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες οντότητες θα αφαιρεθούν από τον τρέχοντα ιδιοκτήτη και θα τοποθετηθούν στην ομάδα 'Όλα' του επιλεγμένου ιδιοκτήτη.", + "add-to-group": "Προσθήκη σε ομάδα", + "move-to-group": "Μετακίνηση σε ομάδα", + "select-entity-group": "Επιλογή ομάδας οντοτήτων", + "no-entity-groups-matching": "Δεν βρέθηκαν ομάδες οντοτήτων που να αντιστοιχούν με '{{entityGroup}}'.", + "target-entity-group-required": "Απαιτείται επιλεγμένη ομάδα οντοτήτων.", + "select-user-group": "Επιλογή ομάδας χρηστών", + "no-user-groups-matching": "Δεν βρέθηκαν ομάδες χρηστών οι οποίες να αντιστοιχούν σε '{{entityGroup}}'.", + "target-user-group-required": "Απαιτείται επιλεγμένη ομάδα χρηστών.", + "remove-from-group": "Αφαίρεση από ομάδα", + "group-table-title": "Τίτλος ομάδας πίνακα", + "enable-search": "Ενεργοποίηση αναζήτησης οντοτήτων", + "enable-add": "Ενεργοποίηση προσθήκης οντοτήτων", + "enable-delete": "Ενεργοποίηση διαγραφής οντοτήτων", + "enable-selection": "Ενεργοποίηση επιλογής οντοτήτων", + "enable-group-transfer": "Ενεργοποίηση μεταφοράς ομάδων", + "display-pagination": "Προβολή σελιδοποίησης", + "default-page-size": "Προεπιλεγμένο μέγεθος σελίδας", + "enable-assignment-actions": "Ενεργοποίηση ανάθεσης", + "enable-credentials-management": "Ενεργοποίηση διαχείρησης διαπιστευτηρίων", + "enable-login-as-user": "Ενεργοποίηση εισόδου ως χρήστη", + "enable-users-management": "Ενεργοποίηση διαχείρισης χρήστών", + "enable-customers-management": "Ενεργοποίηση διαχείρησης πελατών", + "enable-assets-management": "Ενεργοποίηση διαχείρησης assets", + "enable-devices-management": "Ενεργοποίηση διαχείρησης συσκευών", + "enable-entity-views-management": "Ενεργοποίηση διαχείρησης προβολής οντοτήτων", + "enable-dashboards-management": "Ενεργοποίηση διαχείρησης dashboard", + "open-details-on": "Άνοιγμα λεπτομερειών οντότητας σε", + "select-existing": "Ε[ιλογή υπάρχον ομάδας οντοτήτων", + "create-new": "Δημιουργία νέας ομάδας οντοτήτων", + "new-entity-group-name": "Νέο όνομα ομάδας οντοτήτων", + "entity-group-list": "Λίστα ομάδων οντοτήτων", + "entity-group-list-empty": "Δεν έχουν επιλεχθεί ομάδες οντοτήτων.", + "name-starts-with": "Το όνομα της ομάδας οντοτήτων αρχίζει από", + "entity-group-name-filter-required": "Απαιτείται φίλτρο ονόματος ομάδας οντοτήτων.", + "roles": "Ρόλοι", + "permissions": "Δικαιώματα", + "public": "Δημόσιο", + "entity-group-public": "Η ομάδα οντοτήτων είναι δημόσια", + "make-public": "Δημοσιοποίηση ομάδας οντοτήτων", + "make-private": "Ιδιωτικοποίηση ομάδας οντοτήτων", + "make-public-entity-group-title": "Είστε σίγουροι ότι θέλετε να κάνετε την ομάδα οντοτήτων '{{entityGroupName}}' δημόσια;", + "make-public-entity-group-text": "Μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλες οι οντότητές της θα δημοσιοποιηθούν και θα είναι προσβάσιμες από τρίτους.", + "make-private-entity-group-title": "Είστε σίγουροι ότι θέλετε να κάνετε την ομάδα οντοτήτων '{{entityGroupName}}' ιδιωτική?", + "make-private-entity-group-text": "Μετά την επιβεβαίωση, η ομάδα οντοτήτων και όλες οι οντότητές της θα γίνουν ιδιωτικές και δεν θα είναι προσβάσιμες από τρίτους.", + "copyId": "Αντιγραφή ID ομάδας οντοτήτων", + "idCopiedMessage": "Το ID της ομάδας οντοτήτων έχει αντιγραφεί στο πρόχειρο" + }, + "entity-field": { + "created-time": "Δημιουργήθηκε", + "name": "Όνομα", + "type": "Τύπος", + "first-name": "Όνομα", + "last-name": "Επίθετο", + "email": "Email", + "title": "Τίτλος", + "country": "Χώρα", + "state": "Νομός", + "city": "Πόλη", + "address": "Διεύθυνση", + "address2": "Διεύθυνση 2", + "zip": "Τ.Κ.", + "phone": "Τηλέφωνο" + }, + "entity-view": { + "entity-view": "Όψη Οντότητας", + "entity-view-required": "Απαιτείται προβολή οντότητας.", + "entity-views": "Όψεις Οντοτήτων", + "management": "Διαχείριση Όψεων Οντοτήτων", + "view-entity-views": "Προβολή οντοτήτων", + "entity-view-alias": "Ψευδώνυμο προβολής οντότητας", + "aliases": "Ψευδώνυμα προβολής οντότητας", + "no-alias-matching": "'Δεν βρέθηκαν {{alias}}'.", + "no-aliases-found": "Δεν βρέθηκαν ψευδώνυμα.", + "no-key-matching": "'Δεν βρέθηκαν {{key}}'.", + "no-keys-found": "Δεν βρέθηκαν ονόματα.", + "create-new-alias": "Δημιουργήστε ένα νέο!", + "create-new-key": "Δημιουργήστε ένα νέο!", + "duplicate-alias-error": "Βρέθηκε διπλότυπο '{{alias}}'.
    Τα ψευδώνυμα προβολής οντότητας πρέπει να είναι μοναδικά εντός του dashboard.", + "configure-alias": "Ρύθμιση ψευδώνυμου '{{alias}}'", + "no-entity-views-matching": "Δεν βρέθηκαν προβολές οντοτήτων οι οποίες να αντιστοιχούν σε '{{entity}}'.", + "alias": "Ψευδώνυμο", + "alias-required": "Απαιτείται ψευδώνυμο προβολής οντοτήτων.", + "remove-alias": "Αφαίρεση ψευδωνύμου προβολής οντοτήτων", + "add-alias": "Προβολή ψευδωνύμου προβολής οντοτήτων", + "name-starts-with": "Το όνομα προβολής οντότητας αρχίζει από", + "entity-view-list": "Λίστα προβολής οντότητας", + "use-entity-view-name-filter": "Χρήση φίλτρου", + "entity-view-list-empty": "Δεν έχουν επιλεχθεί προβολές οντότητας.", + "entity-view-name-filter-required": "Απαιτείται φίλτρο ονόματος προβολής οντότητας.", + "entity-view-name-filter-no-entity-view-matched": "Δεν βρέθηκαν προβολές οντότητας οι οποίες να αρχίζουν από '{{entityView}}'.", + "add": "Προσθήκη Προβολής Οντότητας", + "assign-to-customer": "Ανάθεση σε πελάτη", + "assign-entity-view-to-customer": "Ανάθεση προβολής/ών οντότητας σε πελάτη", + "assign-entity-view-to-customer-text": "Παρακαλώ επιλέξτε τις προβολές οντότητας για να αναθέσετε σε πελάτη", + "no-entity-views-text": "Δεν βρέθηκαν προβολές οντότητας", + "assign-to-customer-text": "Παρακαλώ επιλέξτε πελάτη για να ανάθεση προβολών οντότητας", + "entity-view-details": "Λεπτομέρειες προβολής οντότητας", + "add-entity-view-text": "Προσθήκη νέας προβολής οντότητας", + "delete": "Διαγραφή προβολής οντότητας", + "assign-entity-views": "Ανάθεση προβολών οντότητας", + "assign-entity-views-text": "Ανάθεση { count, plural, 1 {1 entityView} other {# entityViews} } σε πελάτη", + "delete-entity-views": "Διαγραφή προβολών οντότητας", + "make-public": "Δημοσιοποίηση προβολής οντότητας", + "make-private": "Ιδιωτικοποίηση προβολής οντότητας", + "unassign-from-customer": "Αφαίρεση από πελάτη", + "unassign-entity-views": "Αφαίρεση προβολών οντότητας", + "unassign-entity-views-action-title": "Αφαίρεση { count, plural, 1 {1 entityView} other {# entityViews} } από πελάτη", + "assign-new-entity-view": "Ανάθεση νέας προβολής οντότητας", + "delete-entity-view-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την προβολή οντότητας '{{entityViewName}}'?", + "delete-entity-view-text": "Προσοχή, μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-entity-views-title": "Είστε σίγουροι ότι θέλετε να προβάλετε την οντότητα { count, plural, 1 {1 entityView} other {# entityViews} };", + "delete-entity-views-action-title": "Διαγραφή { count, plural, 1 {1 entityView} other {# entityViews} }", + "delete-entity-views-text": "Προσοχή, μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές της οντότητας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "make-public-entity-view-title": "Είστε σίγουροι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' δημόσια?", + "make-public-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα δεδομένα της θα δημοσιοποιηθούν και θα είναι προσβάσιμα από τρίτους.", + "make-private-entity-view-title": "Είστε σίγουροι ότι θέλετε να κάνετε την προβολή οντότητας '{{entityViewName}}' ιδιωτική;", + "make-private-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας και όλα τα δεδομένα της θα γίνουν ιδιωτικά και θα δεν είναι προσβάσιμα από τρίτους.", + "unassign-entity-view-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε την προβολη οντότητας '{{entityViewName}}';", + "unassign-entity-view-text": "Μετά την επιβεβαίωση, η προβολή της οντότητας θα καταργηθεί και δεν θα είναι προσβάσιμη από τον πελάτη.", + "unassign-entity-view": "Αφαίρεση προβολής οντότητας", + "unassign-entity-views-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 entityView} other {# entityViews} };", + "unassign-entity-views-text": "Μετά την επιβεβαίωση, όλες οι επιλεγμένες προβολές οντοτήτων θα αφαιρεθούν και δεν θα είναι προσβάσιμες από τον πελάτη.", + "entity-view-type": "Τύπος Προβολής Οντότητας", + "entity-view-type-required": "Απαιτείται τύπος προβολής οντότητας.", + "select-entity-view-type": "Επιλογή τύπου προβολής οντότητας", + "enter-entity-view-type": "Εισαγωγή τύπου προβολής οντοτήτων", + "any-entity-view": "Οποιαδήποτε προβολή οντότητας", + "no-entity-view-types-matching": "Δεν βρέθηκαν τύποι προβολής οντότητας οι οποίοι να αντιστοιχούν με '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Δεν έχουν επιλεχθεί τύποι προβολής οντότητας.", + "entity-view-types": "Τύποι Προβολής Οντότητας", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "events": "Γεγονότα", + "details": "Λεπτομέρειες", + "copyId": "Αντιγραφή ID προβολής οντότητας", + "idCopiedMessage": "Το ID της προβολής οντότητας έχει αντιγραφεί στο πρόχειρο", + "assignedToCustomer": "Αναθέση σε πελάτη", + "unable-entity-view-device-alias-title": "Αδύνατον να διαγραφεί το ψευδώνυμο προβολής οντότητας", + "unable-entity-view-device-alias-text": "Το ψευδώνυμο συσκευής '{{entityViewAlias}}' δεν μπορεί να διαγραφεί όσο εξακωλουθεί να χρησιμοποιείτε από τα παρακάτω widget(s):
    {{widgetsList}}", + "select-entity-view": "Επιλογή προβολής οντότητας", + "start-date": "Ημερομηνία έναρξης", + "start-ts": "Ώρα έναρξης", + "end-date": "Ημερομηνία λήξης", + "end-ts": "Ώρα λήξης", + "date-limits": "Όρια ημερομηνίας", + "client-attributes": "Χαρακτηριστικά Client", + "shared-attributes": "Κοινόχρηστα Χαρακτηριστικά", + "server-attributes": "Χαρακτηριστικά Server", + "timeseries": "Χρονική σειρά", + "client-attributes-placeholder": "Χαρακτηριστικά Client", + "shared-attributes-placeholder": "Κοινόχρηστα Χαρακτηριστικά", + "server-attributes-placeholder": "Χαρακτηριστικά Server", + "timeseries-placeholder": "Χρονική σειρά", + "target-entity": "Στοχευμένη οντότητα", + "attributes-propagation": "Διάδοση χαρακτηριστικών", + "attributes-propagation-hint": "Η προβολή οντοτήτων θα αντιγράφει αυτόματα καθορισμένα χαρακτηριστικά από την στοχευμένη οντότητα κάθε φορά που αποθηκεύετε ή ενημερώνετε αυτήν την προβολή οντότητας. Για λόγους απόδοσης, τα χαρακτηριστικά της στοχευμένης οντότητας δεν μεταδίδονται στην προβολή οντότητας με κάθε αλλαγή χαρακτηριστικών. Μπορείτε να ενεργοποιήσετε την αυτόματη διάδοση ρυθμίζοντας τον κόμβο \"αντιγραφή για προβολή \" στην αλυσίδα κανόνων σας και συνδέοντας τα μηνύματα \"Χαρακτηριστικά Post \" και \"Ενημερωμένα Χαρακτηριστικά \" στον νέο κόμβο.", + "timeseries-data": "Δεδομένα χρονικής σειράς", + "timeseries-data-hint": "Ρυθμίστε τα δεδομένα της χρονικής σειράς της στοχευμένης οντότητας που θα είναι διαθέσιμα στην προβολή οντοτητας. Αυτά τα δεδομένα χρονικής σειράς είναι μόνο για ανάγνωση.", + "selected-entity-views": "{ count, plural, 1 {1 entity view} other {# entity views} } επιλεγμένα", + "search": "Αναζήτηση προβολών οντότητας", + "select-group-to-add": "Επιλογή ομάδας για προσθήκη των επιλεγμένων προβολών οντότητας", + "select-group-to-move": "Επιλογή ομάδας για μετακίνηση των επιλεγμένων προβολών οντότητας", + "remove-entity-views-from-group": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε { count, plural, 1 {1 entity view} other {# entity views} } from group '{entityGroup}'?", + "group": "Ομάδα προβολών οντότητας", + "list-of-groups": "{ count, plural, 1 {One entity view group} other {List of # entity view groups} }", + "group-name-starts-with": "Ομάδες προβολής οντότητας των οποίων το όνομα αρχίζει από '{{prefix}}'" + }, + "event": { + "events": "Γεγονότα", + "event-type": "Τύπος Γεγονότος", + "type-error": "Σφάλμα", + "type-lc-event": "Γεγονός κύκλου ζωής", + "type-stats": "Στατιστική", + "type-debug-converter": "Αποσφαλμάτωση", + "type-debug-integration": "Αποσφαλμάτωση", + "type-debug-rule-node": "Αποσφαλμάτωση", + "type-debug-rule-chain": "Αποσφαλμάτωση", + "no-events-prompt": "Δεν βρέθηκαν γεγονότα", + "error": "Σφάλμα", + "alarm": "Alarm", + "event-time": "Ώρα Γεγονότος", + "server": "Διακομιστής", + "body": "Σώμα (body)", + "method": "Μέθοδος", + "type": "Τύπος", + "in": "Είσοδος", + "out": "Έξοδος", + "metadata": "Μεταδεδομένα", + "message": "Μήνυμα", + "message-id": "ID Μηνύματος", + "message-type": "Τύπος Μηνύματος", + "data-type": "Τύπος Δεδομένων", + "relation-type": "Τύπος Σχέσης", + "data": "Δεδομέναα", + "event": "Γεγονός", + "status": "Κατάσταση", + "success": "Επιτυχία", + "failed": "Απέτυχε", + "messages-processed": "Επεξεργασμένα μηνύματα", + "errors-occurred": "Παρουσιάστηκαν σφάλματα", + "all-events": "Όλοι", + "entity-type": "Τύπος οντοτήτων" + }, + "extension": { + "extensions": "Επεκτάσεις", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } επιλέχθηκαν", + "type": "Τύπος", + "key": "Κλειδί", + "value": "Τιμή", + "id": "ID", + "extension-id": "ID επέκτασης", + "extension-type": "Τύπος επέκτασης", + "transformer-json": "JSON *", + "unique-id-required": "Το τρέχον ID επέκτασης υπάρχει ήδη.", + "delete": "Διαγραφή επέκτασης", + "add": "Προσθήκη επέκτασης", + "edit": "Επεξεργασία επέκτασης", + "view": "Προβολή επέκτασης", + "delete-extension-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την επέκταση '{{extensionId}}';", + "delete-extension-text": "Προσοχή, μετά την επιβεβαίωση, η επέκταση και όλα τα σχετικά δεδομένα θα διαγραφούν μόνιμα.", + "delete-extensions-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Προσέξτε, μετά την επιβεβαίωση θα αφαιρεθούν όλες οι επιλεγμένες επεκτάσεις.", + "converters": "Μετατροπείς", + "converter-id": "ID Μετατροπέα", + "configuration": "Διαμόρφωση", + "converter-configurations": "Ρυθμίσεις μετατροπέα", + "token": "Ετικέτα ασφαλείας", + "add-converter": "Προσθήκη μετατροπέα", + "add-config": "Προσθήκη ρυθμήσεων μετατροπέα", + "device-name-expression": "Έκφραση ονόματος συσκευής", + "device-type-expression": "Έκφραση τύπου συσκευής", + "custom": "Προσαρμοσμένο", + "to-double": "Διπλασιασμός", + "transformer": "Μετατροπέας", + "json-required": "Απαιτείται μετατροπέας JSON.", + "json-parse": "Αδύνατον να γίνει αναλύση του μετατροπέα JSON.", + "attributes": "Χαρακτηριστικά", + "add-attribute": "Προσθήκη χαρακτηριστικού", + "add-map": "Προσθήκη στοιχείου χαρτογράφισης", + "timeseries": "Χρονική σειρά", + "add-timeseries": "Προσθήκη χρονικής σειράς", + "field-required": "Απαιτείται το πεδίο", + "brokers": "Brokers", + "add-broker": "Προσθήκη broker", + "host": "Host", + "port": "Port", + "port-range": "Η Port πρέπει να είναι από 1 ως 65535.", + "ssl": "Ssl", + "credentials": "Διαπιστευτήρια", + "username": "Όνομα Χρήστη", + "password": "Κωδικός", + "retry-interval": "Διάστημα επανάληψης σε χιλιοστά του δευτερολέπτου", + "anonymous": "Ανώνυμα", + "basic": "Βασικά", + "pem": "PEM", + "ca-cert": "Αρχείο πιστοποιητικού CA *", + "private-key": "Αρχείο ιδιωτικού κλειδιού *", + "cert": "Αρχείο πιστοποιητικού *", + "no-file": "Δεν έχει επιλεχθεί αρχείο.", + "drop-file": "Αποθέστε ένα αρχείο ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", + "mapping": "χαρτογράφιση", + "topic-filter": "Φίλτρο θέματος", + "converter-type": "Τύπος μετατροπέα", + "converter-json": "JSON", + "json-name-expression": "Έκφραση json ονόματος συσκευής", + "topic-name-expression": "Έκφραση θέματος ονόματος συσκευής", + "json-type-expression": "Έκφραση json τύπου συσκευής", + "topic-type-expression": "Έκφραση θέματος τύπου συσκευής", + "attribute-key-expression": "Έκφραση χαρακτηριστικού κλειδιού", + "attr-json-key-expression": "Έκφραση JSON χαρακτηριστικού κλειδιού", + "attr-topic-key-expression": "Έκφραση θέματος χαρακτηριστικού κλειδιού", + "request-id-expression": "Αίτημα έκφρασης ID", + "request-id-json-expression": "Αίτημα ID έκφρασης JSON", + "request-id-topic-expression": "Αίτημα ID έκφρασης θέματος", + "response-topic-expression": "Απάντηση έκφρασης θέματος", + "value-expression": "Έκφραση τιμής", + "topic": "Θέμα", + "timeout": "Λήξη σε χιλιοστά του δευτερολέπτου", + "converter-json-required": "Απαιτείται μετατροπέας JSON.", + "converter-json-parse": "Αδύνατον να αναλυθεί ο μετατροπέας JSON.", + "filter-expression": "Έκφραση φίλτρου", + "connect-requests": "Αιτήματα σύνδεσης", + "add-connect-request": "Προσθήκη αιτήματος σύνδεσης", + "disconnect-requests": "Αιτήματα αποσύνδεσης", + "add-disconnect-request": "Προσθήκη αιτήματος αποσύνδεσης", + "attribute-requests": "Χαρακτιριστικό αιτημάτων", + "add-attribute-request": "Προσθήκη χαρακτηριστικού αιτήματος", + "attribute-updates": "Ανανεώσεις χαρακτηριστικού", + "add-attribute-update": "Προσθήκη ανανέωσης χαρακτηριστικού", + "server-side-rpc": "Server side RPC", + "add-server-side-rpc-request": "Προσθήκη αιτήματος server-side RPC", + "device-name-filter": "Φίλτρο ονόματος συσκευής", + "attribute-filter": "ίλτρο χαρακτηριστικού", + "method-filter": "Φίλτρο μεθόδου", + "request-topic-expression": "Αίτημα έκφρασης θέματος", + "response-timeout": "Λήξη απάντησης σε χιλιοστά του δευτερολέπτου", + "topic-expression": "Έκφρασου θέματος", + "client-scope": "Πεδίο εφαρμογής πελάτη", + "add-device": "Προσθήκη συσκευής", + "opc-server": "Servers", + "opc-add-server": "Προσθήκη server", + "opc-add-server-prompt": "Παρακαλούμε προσθέστε server", + "opc-application-name": "Όνομα εφαρμογής", + "opc-application-uri": "URI Εφαρμογής", + "opc-scan-period-in-seconds": "Περίοδος σάρωσης σε δευτερόλεπτα", + "opc-security": "Ασφάλεια", + "opc-identity": "Ταυτότητα", + "opc-keystore": "Keystore", + "opc-type": "Τύπος", + "opc-keystore-type": "Τύπος", + "opc-keystore-location": "τοποθεσία *", + "opc-keystore-password": "Κωδικός", + "opc-keystore-alias": "Ψευδώνυμο", + "opc-keystore-key-password": "Κωδικός Κλειδί", + "opc-device-node-pattern": "Μοτίβο κόμβου συσκευής", + "opc-device-name-pattern": "Μοτίβο ονόματος συσκευής", + "modbus-server": "Servers/slaves", + "modbus-add-server": "Προσθήκη server/slave", + "modbus-add-server-prompt": "Παρακαλούμε προσθέστε server/slave", + "modbus-transport": "Μεταφορά", + "modbus-tcp-reconnect": "Αυτόματη επανασύνδεση", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "Όνομα serial port", + "modbus-encoding": "Encoding", + "modbus-parity": "Parity", + "modbus-baudrate": "Baud rate", + "modbus-databits": "Data bits", + "modbus-stopbits": "Stop bits", + "modbus-databits-range": "Τα Data bits πρέπει να κυμαίνονται από 7 εώς 8.", + "modbus-stopbits-range": "Τα Stop bits πρέπει να κυμαίνονται από 1 εώς 2.", + "modbus-unit-id": "ID Μονάδας", + "modbus-unit-id-range": "Το ID Μονάδας πρέπει να κυμαίνεται από 1 εώς 247.", + "modbus-device-name": "Όνομα συσκευής", + "modbus-poll-period": "Poll period (ms)", + "modbus-attributes-poll-period": "Χαρακτηριστικά poll period (ms)", + "modbus-timeseries-poll-period": "Χρονική σειρά poll period (ms)", + "modbus-poll-period-range": "Το Poll period θα πρέπει να έχει θετική τιμή.", + "modbus-tag": "Ετικέτα", + "modbus-function": "Λειτουργία", + "modbus-register-address": "Καταχώριση διεύθυνσης", + "modbus-register-address-range": "η καταχωριμένη διεύθυνση πρεπει να κυμαίνεται από 0 ως 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "Το Bit index πρέπει να κυμαίνεται από 0 ως 15.", + "modbus-register-count": "Μετρητής καταχώρησης", + "modbus-register-count-range": "Ο μετρητής καταχώρησης πρεέπει να έχει θετική τιμή.", + "modbus-byte-order": "Byte order", + "sync": { + "status": "Κατάσταση", + "sync": "Σε Συγχρονισμό", + "not-sync": "Δεν Συγχρονίζεται", + "last-sync-time": "Τελευταία φορά συγχρονισμού", + "not-available": "Μη διαθέσιμο" + }, + "export-extensions-configuration": "Εξαγωγή διαμόρφωσης επεκτάσεων", + "import-extensions-configuration": "Εισαγωγή διαμόρφωσης επεκτάσεων", + "import-extensions": "Εισαγωγή επεκτάσεων", + "import-extension": "Εισαγωγή επέκτασης", + "export-extension": "Εξαγωγή επέκτασης", + "file": "Επεκτάσεις αρχείου", + "invalid-file-error": "Μη έγκυρη επέκταση αρχείου" + }, + "fullscreen": { + "expand": "Ανάπτυξη σε πλήρη οθόνη", + "exit": "Έξοδος από πλήρη οθόνη", + "toggle": "Εναλλαγή σε πλήρη οθόνη", + "fullscreen": "Πλήρης οθόνη" + }, + "function": { + "function": "Λειτουργία" + }, + "grid": { + "delete-item-title": "Είστε σίγουροι ότι θέλετε να διαγραφεί αυτό το αντικείμενο?", + "delete-item-text": "Προσοχή, μετά την επιβεβαίωση, αυτό το στοιχείο και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-items-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Διαγραφή { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Προσοχή, μετά την επιβεβαίωση, όλα τα επιλεγμένα στοιχεία θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "add-item-text": "Προσθήκη νέου αντικειμένου", + "no-items-text": "Δεν βρέθηκαν αντικείμενα", + "item-details": "λεπτομέρειες αντικείμενου", + "delete-item": "Διαγραφή Αντικειμένου", + "delete-items": "Διαγραφή Αντικειμένων", + "scroll-to-top": "Κύλιση προς τα πάνω" + }, + "help": { + "goto-help-page": "Πηγαίνετε στη σελίδα" + }, + "home": { + "home": "Αρχικη", + "profile": "Προφίλ", + "logout": "Αποσυνδέση", + "menu": "Μενού", + "avatar": "Avatar", + "open-user-menu": "Άνοιγμα μενού χρήστη" + }, + "import": { + "no-file": "Δεν έχει επιλεχθεί αρχείο", + "drop-file": "Αποθέστε ένα αρχείο JSON ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", + "drop-csv-file": "Αποθέστε ένα αρχείο CVS ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", + "drop-file-csv": "Αποθέστε ένα αρχείο CSV ή κάνετε κλικ για να επιλέξετε ένα αρχείο για ανέβασμα.", + "column-value": "Τιμή", + "column-title": "Τίτλος", + "column-example": "Παράδειγμα δεδομένων", + "column-key": "Κλειδί χαρακτηριστικού/τηλεμετρίας", + "csv-delimiter": "Οριοθέτηση CSV", + "csv-first-line-header": "Η πρώτη σειρά περιέχει ονόματα στηλών", + "csv-update-data": "Ανανέωση χαρακτηριστικών/τηλεμετρίας", + "import-csv-number-columns-error": "Ένα αρχείο θα πρέπει να περιέχει τουλάχιστον δυο στήλες", + "import-csv-invalid-format-error": "Μη έγκυρη μορφή αρχείου. Σειρά: '{{line}}'", + "column-type": { + "name": "Όνομα", + "type": "Τύπος", + "column-type": "Τύπος στήλης", + "client-attribute": "Χαρακτηριστικό Client", + "shared-attribute": "Χαρακτηριστικό Shared", + "server-attribute": "Χαρακτηριστικό Server", + "timeseries": "Χρονική σειρά", + "entity-field": "Πεδίο Οντότητας", + "access-token": "Διακριτικό πρόσβασης" + }, + "stepper-text": { + "select-file": "Επιλογή αρχείου", + "configuration": "Εισαγωγή ρύθμισης", + "column-type": "Επιλογή τύπου στήλης", + "creat-entities": "Δημιουργία νέων οντοτήτων" + }, + "message": { + "create-entities": "{{count}} νέες οντότητες δημιουργήθηκαν με επιτυχία.", + "update-entities": "{{count}} οντότητες ενημερώθηκαν με επιτυχία.", + "error-entities": "Υπήρξε κάποιο σφάλμα κατά τη δημιουργία {{count}} οντοτήτων." + } + }, + "integration": { + "integration": "Ενσωμάτωση", + "integrations": "Ενσωματώσεις", + "select-integration": "Επιλογή ενσωμάτωσης", + "no-integrations-matching": "Δεν βρέθηκαν ενσωματώσεις πο να αντιστοιχούν σε '{{entity}}'.", + "integration-required": "Απαιτείται ενσωμάτωση", + "delete": "Διαγραφή ενσωμάτωσης", + "management": "Διαχείριση Ενσωματώσεων", + "add-integration-text": "Προσθήκη νέας ενσωμάτωσης", + "no-integrations-text": "Δεν βρέθηκαν ενσωματώσεις", + "selected-integrations": "{ count, plural, 1 {1 integration} other {# integrations} } επιλέχθηκαν", + "delete-integration-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την ενσωμάτωση '{{integrationName}}';", + "delete-integration-text": "Προσοχή, μετά την επιβεβαίωση, η ενσωμάτωση και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-integrations-title": "Είστε σίγουροι ότι θέλετε { count, plural, 1 {1 integration} other {# integrations} };", + "delete-integrations-action-title": "Διαγραφή { count, plural, 1 {1 integration} other {# integrations} }", + "delete-integrations-text": "Προσοχή, αφού ολοκληρωθεί η επιβεβαίωση, όλες οι επιλεγμένες ενσωματώσεις θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "events": "Γεγονότα", + "add": "Προσθήκη Ενσωμάτωσης", + "integration-details": "Λεπτομέρειες Ενσωμάτωσης", + "details": "Λεπτομέρειες", + "copyId": "Αντιγραη ID ενσωμάτωσης", + "idCopiedMessage": "Το ID της ενσωμάτωσης έχει αντιγραφεί στο πρόχειρο.", + "debug-mode": "Λειτουργία απασφαλμάτωσης", + "enable-security": "Ενεργοποίηση ασφάλειας", + "headers-filter": "Φίλτρο Headers", + "header": "Header", + "no-headers-filter": "Όχι φίλτρο headers", + "downlink-url": "URL Λήψης", + "application-uri": "URI Εφαρμογής", + "as-id": "AS ID", + "as-id-required": "Απαιτείται AS ID.", + "as-key": "AS Key", + "as-key-required": "Απαιτείται AS Key.", + "max-time-diff-in-seconds": "Μέγιστη χρονική διαφορά (δευτερόλεπτα)", + "max-time-diff-in-seconds-required": "Απαιτείται μέγιστη χρονική διαφορά.", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "base-url": "Base URL", + "base-url-required": "Απαιτείται Base URL", + "security-key": "Κλειδί ασφάλειας", + "http-endpoint": "HTTP endpoint URL", + "copy-http-endpoint-url": "Αντιγραφή HTTP endpoint URL", + "http-endpoint-url-copied-message": "Το HTTP endpoint URL έχει αντιγραφεί στο πρόχειρο", + "host": "Host", + "host-required": "Απαιτείται Host.", + "host-type": "Τύπος Host", + "host-type-required": "Απαιτείται τύπος Host.", + "custom-host": "Προσαρμοσμένο host", + "custom-host-required": "Απαιτείται προσαρμοσμένο host.", + "port": "Port", + "port-required": "Απαιτείται Port.", + "port-range": "Η Port πρέπει να κυμαίνεται από 1 ως 65535.", + "connect-timeout": "Λήξη σύνδεσης (δευτερόλεπτα)", + "connect-timeout-required": "Απαιτείται λήξη σύνδεσης.", + "connect-timeout-range": "Η λήξη σύνδεσης πρέπει να κυμαίνεται από 1 ως 200.", + "client-id": "Client ID", + "clean-session": "Καθαρή συνεδρία session", + "enable-ssl": "Ενεργοποίηση SSL", + "credentials": "Διαπιστευτήρια", + "credentials-type": "Τύπος διαπιστευτηρίων", + "credentials-type-required": "Απαιτείται τύπος διαπιστευτηρίων.", + "username": "Όνομα Χρήστη", + "username-required": "Απαιτείται όνομα χρήστη.", + "password": "Κωδικός", + "password-required": "Απαιτείται κωδικός.", + "ca-cert": "Αρχείο πιστοποιητικού CA *", + "private-key": "Αρχείο ιδιωτικού κλειδιού *", + "private-key-password": "κωδικός ιδιωτικού κλειδιού", + "cert": "Αρχείο πιστοποιητικού *", + "no-file": "Δεν έχει επιλεχθεί αρχείο.", + "drop-file": "Αποθέστε αρχείο ή κάνετε κλικ για να επιλέξετε ένα αρχείο προς ανέβασμα.", + "topic-filters": "Φίτρο θέματος", + "remove-topic-filter": "Αφαίρεση φίλτρου θέματος", + "add-topic-filter": "προσθήκη φίλτρου θέματος", + "add-topic-filter-prompt": "παρακαλούμε προσθέστε φίλτρο θέματος", + "topic": "Θέμα", + "mqtt-qos": "QoS", + "mqtt-qos-at-most-once": "Το πολύ μια", + "mqtt-qos-at-least-once": "Τουλάχιστον μια", + "mqtt-qos-exactly-once": "Ακριβώς μια", + "downlink-topic-pattern": "Μοτίβο θέματος σύνδεσης", + "downlink-topic-pattern-required": "Απαιτείται μοτίβο θέματος σύνδεσης.", + "aws-iot-endpoint": "AWS IoT Endpoint", + "aws-iot-endpoint-required": "Απαιτείται AWS IoT Endpoint.", + "aws-iot-credentials": "AWS IoT Διαπιστευτήρια", + "application-credentials": "Διαπιστευτήρια Εφαρμογής", + "api-key": "API Key", + "api-key-required": "Απαιτείται API Key.", + "auth-token": "Τεκμίριο Ταυτοποίησης", + "auth-token-required": "Απαιτείται τεκμίριο ταυτοποίησης", + "region": "Περιοχή", + "region-required": "Απαιτείται περιοχή.", + "application-id": "ID Εφαρμογής", + "application-id-required": "Απαιτείται ID εφαρμογής.", + "access-key": "Κλειδί πρόσβασης", + "access-key-required": "Απαιτείται κλειδί πρόσβασης.", + "connection-parameters": "Παράμετροι σύνδεσης", + "service-bus-namespace-name": "Service Bus Namespace Name", + "service-bus-namespace-name-required": "Απαιτείται Service Bus Namespace Name.", + "event-hub-name": "Όνομα Event Hub", + "event-hub-name-required": "Απαιτείται όνομα Event Hub.", + "sas-key-name": "Όνομα SAS Key", + "sas-key-name-required": "Απαιτείται όνομα SAS Key.", + "sas-key": "SAS Key", + "sas-key-required": "Απαιτείται SAS Key.", + "iot-hub-name": "IoT Hub Name (απαιτείται για downlink)", + "metadata": "Μεταδεδομένα", + "type": "Τύπος", + "type-required": "Απαιτείται τύπος.", + "uplink-converter": "Μετατροπέας δεδομένων uplink", + "uplink-converter-required": "Απαιτείται μετατροπέας δεδομένων uplink.", + "downlink-converter": "Μετατροπέας δεδομένων downlink", + "type-http": "HTTP", + "type-ocean-connect": "OceanConnect", + "type-sigfox": "SigFox", + "type-thingpark": "ThingPark", + "type-tmobile-iot-cdp": "T-Mobile – IoT CDP", + "type-mqtt": "MQTT", + "type-aws-iot": "AWS IoT", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ttn": "TheThingsNetwork", + "type-azure-event-hub": "Azure Event Hub", + "type-opc-ua": "OPC-UA", + "opc-ua-application-name": "Όνομα εφαρμογής", + "opc-ua-application-uri": "URI Εφαρμογής", + "opc-ua-scan-period-in-seconds": "Περίοδος σάρωσης σε δευτερόλεπτα", + "opc-ua-scan-period-in-seconds-required": "Απαιτείται περίοδος σάρωσης", + "opc-ua-timeout": "Λήξη χρόνου σε χιλιοστά του δευτερολέπτου", + "opc-ua-timeout-required": "Απαιτείται λήξη χρόνου", + "opc-ua-security": "Ασφάλεια", + "opc-ua-security-required": "Απαιτειται ασφάλεια", + "opc-ua-identity": "Ταυτότητα", + "opc-ua-identity-required": "Απαιτείται ταυτότητα", + "opc-ua-keystore": "Keystore", + "add-opc-ua-keystore-prompt": "Παρακαλούμε προσθέστε αρχείο keystore", + "opc-ua-keystore-required": "Απαιτείται keystore", + "opc-ua-type": "Τύπος", + "opc-ua-keystore-type": "Τύπος", + "opc-ua-keystore-type-required": "Απαιτέιται τύπος", + "opc-ua-keystore-location": "Τοποθεσία *", + "opc-ua-keystore-password": "Κωδικός", + "opc-ua-keystore-password-required": "Απαιτείται κωδικός", + "opc-ua-keystore-alias": "Ψευδώνυμο", + "opc-ua-keystore-alias-required": "Απαιτείται ψευδώνυμο", + "opc-ua-keystore-key-password": "Κλειδί κωδικού", + "opc-ua-keystore-key-password-required": "Απαιτείται κλειδί κωδικού", + "opc-ua-mapping": "χαρτογράφιση", + "add-opc-ua-mapping-prompt": "Παρακαλούμε προσθέστε χαρτογράφιση", + "opc-ua-mapping-type": "Τύπος χαρτογράφισης", + "opc-ua-mapping-type-required": "Απαιτείται τύπος χαρτογράφισης", + "opc-ua-device-node-pattern": "Μοτίβο Κόμβου Συσκευής", + "opc-ua-device-node-pattern-required": "Απαιτείται Μοτίβο Κόμβου Συσκευής", + "opc-ua-namespace": "Namespace", + "opc-ua-add-map": "Προσθήκη στοιχείου χαρτογράφισης", + "subscription-tags": "Ετικέτες εγγραφής", + "remove-subscription-tag": "Αφαίρεση ετικέτας εγγραφής", + "add-subscription-tag": "Προσθήκη ετικέτας εγγραφής", + "add-subscription-tag-prompt": "Παρακαλούμε προσθέστε ετικέτα εγγραφής", + "key": "Κλειδί", + "path": "Μονοπάτι", + "required": "Απαιτείται" + }, + "item": { + "selected": "Επιλέχθηκε" + }, + "js-func": { + "no-return-error": "Η λειτουργία πρέπει να επιστρέφει τιμή!", + "return-type-mismatch": "Η λειτουργία πρέπει να επιστρέφει τιμή από '{{type}}' τύπο!", + "tidy": "Tidy" + }, + "key-val": { + "key": "Όνομα", + "value": "Τιμή", + "remove-entry": "Αφαίρεση καταχώρησης", + "add-entry": "Προσθήκη καταχωρησης", + "no-data": "Καμία καταχώρηση" + }, + "layout": { + "layout": "Διάταξη", + "manage": "Διαχείριση Διατάξεων", + "settings": "Ρυθμίσεις Διάταξης", + "color": "Χρώμα", + "main": "Κεντρικά", + "right": "Δεξιά", + "select": "Επιλογή διάταξης" + }, + "legend": { + "direction": "Κατεύθυνση Λεζάντας", + "position": "Θέση λεζάντας", + "show-max": "Προβολή μέγιστης τιμής", + "show-min": "Προβολή ελάχιστης τιμής", + "show-avg": "Προβολή μέσης τιμής", + "show-total": "Προβολή συνολικής τιμής", + "settings": "Ρυθμίσεις λεζάντας", + "min": "min", + "max": "max", + "avg": "Μ.Ο.", + "total": "Σύνολο" + }, + "login": { + "login": "Σύνδεση", + "request-password-reset": "Αίτημα επαναφοράς κωδικού πρόσβασης", + "reset-password": "Επαναφορά κωδικού πρόσβασης", + "create-password": "Δημιουργία κωδικού πρόσβασης", + "passwords-mismatch-error": "Οι καταχωρημένοι κωδικοί πρόσβασης πρέπει να είναι ίδιοι!", + "password-again": "Επανάληψη κωδικού πρόσβασης", + "sign-in": "Παρακαλώ, συνδεθείτε", + "username": "Όνομα Χρήστη (email)", + "remember-me": "Να με Θυμάσαι", + "forgot-password": "Ξεχάσατε τον κωδικό πρόσβασης;", + "password-reset": "Επαναφορά κωδικού πρόσβασης", + "new-password": "Νέος κωδικός πρόσβασης", + "new-password-again": "Επανάληψη νέου κωδικού πρόσβασης", + "password-link-sent-message": "Ο σύνδεσμος επαναφοράς κωδικού πρόσβασης στάλθηκε με επιτυχία!", + "email": "Email", + "no-account": "Δεν έχετε λογαριασμό;", + "create-account": "Δημιουργία λογαριασμού", + "login-with": "Σύνδεση μέσω {{name}}", + "or": "ή" + }, + "signup": { + "firstname": "Όνομα", + "lastname": "Επίθετο", + "email": "Email", + "signup": "Εγγραφή", + "create-password": "Δημιουργία κωδικού", + "repeat-password": "Επαναλάβετε τον κωδικό", + "have-account": "Έχετε ήδη έναν λογαριασμό;", + "signin": "Είσοδος", + "no-captcha-message": "Πρέπει να επιβεβαιώσετε ότι δεν είστε ρομπότ", + "password-length-message": "Ο κωδικός σας πρεπει να περιλαμβάνει τουλάχιστον 6 χαρακτήρες", + "email-verification": "Επιβεβαίωση email", + "email-verification-message": "Έχει σταλεί ένα email επιβεβαίωσης στην διεύθυνση email που καθορίσατε.
    Παρακαλούμε ακολουθήστε τις οδηγίες που συμπεριλαμβανονται στο email ώστε να ολοκληρωθεί η εγγραφή σας.
    Σημείωση: Αν δεν έχει εμφανιστεί σχετικά άμεσα το email, παρακαλούμε ελέγξτε στον φάκελο με την 'Ανεπιθύμητη Αλληλογραφία' (spam) ή ξαναπροσπαθείστε να στείλετε το email κάνοντας κλικ στο κουμπί 'Αποστολή εκ νέου'", + "account-activation-title": "Ενεργοποίηση λογαριασμού", + "account-activated": "Ο λογαριασμός ενεργοποιήθηκε με επιτυχία!", + "account-activated-text": "Συγχαρητήρια!
    Ο λογαριασμός σας έχει ενεργοποιηθεί.
    Τώρα μπορείται να κάνετε είσοδο στην πλατφόρμα.", + "resend": "Αποστολή εκ νέου", + "inactive-user-exists-title": "Ο ανενεργός χρήστης υπάρχει ήδη", + "inactive-user-exists-text": "Υπάρχει ήδη εγγεγραμμένος χρήστης με μη επιβεβαιωμένη διεύθυνση email.
    Click Πατήστε το κουμπί 'Επαναποστολή', αν θέλετε να στείλετε ξανά email επιβεβαίωσης.", + "activating-account": "Ενεργοποίηση λογαριασμού...", + "activating-account-text": "Ο λογαριασμός σας ενεργοποιείται αυτήν τη στιγμή. Παρακαλούμε περιμένετε...", + "accept-privacy-policy": "Αποδοχή Πολιτικής Απορρήτου", + "accept": "Αποδοχή", + "privacy-policy": "Πολιτική Απορρήτου" + }, + "position": { + "top": "Κορυφή", + "bottom": "Κάτω μέρος", + "left": "Αριστερά", + "right": "Δεξιά" + }, + "profile": { + "profile": "Προφίλ", + "change-password": "Αλλαγή κωδικού", + "current-password": "Τρέχων κωδικός" + }, + "relation": { + "relations": "Σχέσεις", + "direction": "Κατεύθυνση", + "search-direction": { + "FROM": "Από", + "TO": "Σε" + }, + "direction-type": { + "FROM": "από", + "TO": "σε" + }, + "from-relations": "Εξωτερικές σχέσεις", + "to-relations": "Εσωτερικές σχέσεις", + "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } επιλέχθηκαν", + "type": "Τύπος", + "to-entity-type": "Στον τύπο οντότητας", + "to-entity-name": "Στο όνομα οντότητας", + "from-entity-type": "Από τύπο οντότητας", + "from-entity-name": "Από όνομα οντότητας", + "to-entity": "Σε οντότητα", + "from-entity": "Από οντότητα", + "delete": "Διαγραφή σχέσης", + "relation-type": "Relation type", + "relation-type-required": "Απαιτείται τύπος σχέσης.", + "any-relation-type": "Οποιοσδήποτε τύπος", + "add": "Προσθήκη σχέσης", + "edit": "Επεξεργασία σχέσης", + "delete-to-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη σχέση με την οντότητα '{{entityName}}'?", + "delete-to-relation-text": "Προσοχή, μετά την επιβεβαίωση η οντότητα '{{entityName}}' δεν θα σχετίζεται με την τρέχουσα οντότητα.", + "delete-to-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 relation} other {# relations} };", + "delete-to-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες σχέσεις θα αφαιρεθούν και οι αντίστοιχες οντότητες δεν θα σχετίζονται με την τρέχουσα οντότητα.", + "delete-from-relation-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε σχέση από την οντότητα '{{entityName}}';", + "delete-from-relation-text": "Προσοχή, μετά την επιβεβαίωση η τρέχουσα οντότητα δεν θα σχετίζεται με την οντότητα '{{entityName}}'.", + "delete-from-relations-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 relation} other {# relations} };", + "delete-from-relations-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες σχέσεις θα αφαιρεθούν και η τρέχουσα οντότητα δεν θα σχετίζεται με τις αντίστοιχες οντότητες.", + "remove-relation-filter": "Αφαίρεση φίλτρου σχέσης", + "add-relation-filter": "Προσθήκη φίλτρου σχέσης", + "any-relation": "Οποιαδήποτε σχέση", + "relation-filters": "Φίλτρα σχέσεων", + "additional-info": "Συμπληρωματικές πληροφορίες (JSON)", + "invalid-additional-info": "Δεν είναι δυνατή η ανάλυση των πρόσθετων πληροφοριών json." + }, + "rulechain": { + "rulechain": "Αλυσίδα Κανόνων", + "rulechains": "Κανόνες", + "root": "Ρίζα", + "delete": "Διαγραφή Αλυσίδας Κανόνων", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "add": "Προσθήκη Αλυσίδας Κανόνων", + "set-root": "Δημιουργία ριζικής Αλυσίδας Κανόνων", + "set-root-rulechain-title": "Είστε σίγουροι ότι θέλετε να δημιουργήσετε τη ριζική Αλυσίδα Κανόνων '{{ruleChainName}}' ;", + "set-root-rulechain-text": "Μετά την επιβεβαίωση, η Αλυσίδα Κανόνων θα γίνει ριζική και θα χειριστεί όλα τα εισερχόμενα μηνύματα μεταφοράς.", + "delete-rulechain-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την Αλυσίδα Κανόνων '{{ruleChainName}}';", + "delete-rulechain-text": "Προσοχή, μετά την επιβεβαίωση η Αλυσίδα Κανόνων θα αφαιρεθεί και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-rulechains-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 rule chain} other {# rule chains} };", + "delete-rulechains-action-title": "Διαγραφή { count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες αλυσίδες κανόνων θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "add-rulechain-text": "Προσθήκη νέας Αλυσίδας Κανόνων", + "no-rulechains-text": "Δεν βρέθηκαν Αλυσίδες Κανόνων", + "rulechain-details": "Λεπτομέρειες Αλυσίδας Κανόνων", + "details": "Λεπομέρειες", + "events": "Γεγονότα", + "system": "Σύστημα", + "import": "Εισαγωγή Αλυσίδας Κανόνων", + "export": "Εξαγωγή Αλυσίδας Κανόνων", + "export-failed-error": "Δεν είναι δυνατή η εξαγωγή Αλυσίδας Κανόνων: {{error}}", + "create-new-rulechain": "Δημιουργία νέας Αλυσίδας Κανόνων", + "rulechain-file": "Αρχείο Αλυσίδας Κανόνων", + "invalid-rulechain-file-error": "Δεν είναι δυνατή η εισαγωγή Αλυσίδας Κανόνων: Μη έγκυρη δομή δεδομένων Αλυσίδας Κανόνων.", + "copyId": "Αντιγραφή ταυτότητας Αλυσίδας Κανόνων", + "idCopiedMessage": "Η ταυτότητα της Αλυσίδας Κανόνων έχει αντιγραφεί στο πρόχειρο", + "select-rulechain": "Επιλογή Αλυσίδας Κανόνων", + "no-rulechains-matching": "Δεν βρέθηκαν Αλυσίδες Κανόνων που να ταιριάζουν '{{entity}}'.", + "rulechain-required": "Απαιτείται Αλυσίδα Κανόνων", + "management": "Διαχείριση κανόνων", + "debug-mode": "Λειτουργία Εκσφαλμάτωσης" + }, + "rulenode": { + "details": "Λεπτομέρειες", + "events": "Γεγονότα", + "search": "Αναζήτηση κόμβων", + "open-node-library": "Άνοιγμα βιβλιοθήκης κόμβων", + "add": "Προσθήκη κανόνα κόμβου", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "type": "Τύπος", + "description": "Περιγραφή", + "delete": "Διαγραφή κανόνα κόμβου", + "select-all-objects": "Επιλογή όλων των κόμβων και συνδέσεων", + "deselect-all-objects": "Αποεπιλογή όλων των κόμβων και συνδέσεων", + "delete-selected-objects": "Διαγραφή επιλεγμένων κόμβων και συνδέσεων", + "delete-selected": "Delete selected", + "select-all": "Select all", + "copy-selected": "Επιλογή αντιγραφής", + "deselect-all": "Αποεπιλογή όλων", + "rulenode-details": "Λεπτομέρειες κανόνα κόμβου", + "debug-mode": "Λειτουργία εντοπισμού σφαλμάτων", + "configuration": "Διαμόρφωση", + "link": "Σύνδεσμος", + "link-details": "Λεπτομέρειες συνδέσμου κανόνα κόμβου", + "add-link": "Προσθήκη συνδέσμου", + "link-label": "Ετικέτα συνδέσμου", + "link-label-required": "Απαιτείται ετικετα συνδέσμου.", + "custom-link-label": "Ετικέτα προσαρμοσμένου συνδέσμου", + "custom-link-label-required": "Απαιτείται ετικέτα προσαρμοσμένου συνδέσμου.", + "link-labels": "Ετικέτες συνδέσμου", + "link-labels-required": "Απαιτούνται ετικέτες συνδέσμου.", + "no-link-labels-found": "Δεν βρέθηκαν ετικέτες συνδέσμου", + "no-link-label-matching": "Δεν βρέθηκαν '{{label}}'.", + "create-new-link-label": "Δημιουργήστε μια νέα!", + "type-filter": "Φίλτρο", + "type-filter-details": "Φιλτράρετε εισερχόμενα μηνύματα με διαμορφωμένες συνθήκες", + "type-enrichment": "Εμπλουτισμός", + "type-enrichment-details": "Προσθέστε επιπλέον πληροφορίες στα Μεταδεδομένα του μηνύματος", + "type-transformation": "Μεταμόρφωση", + "type-transformation-details": "Αλλαγή ωφέλιμου φορτίου και μεταδεδομένων μηνύματος", + "type-action": "Ενέργεια", + "type-action-details": "Εκτέλεση ειδικής ενέργειας", + "type-analytics": "Analytics", + "type-analytics-details": "Εκτελέστε ανάλυση δεδομένων που διαβιβάζονται με ροή ή συνεχίζονται", + "type-external": "Εξωτερικός", + "type-external-details": "Αλληλεπίδραση με εξωτερικό σύστημα", + "type-rule-chain": "Αλυσίδα κανόνων", + "type-rule-chain-details": "Προωθεί τα εισερχόμενα μηνύματα σε συγκεκριμένη αλυσίδα κανόνων", + "type-input": "Εισαγωγήγή", + "type-input-details": "Λογική εισαγωγή της αλυσίδας κανόνων, προωθεί τα εισερχόμενα μηνύματα στον επόμενο σχετικό κανόνα κόμβου", + "type-unknown": "Άγνωστο", + "type-unknown-details": "Ανεπεξέργαστος κανόνας κόμβου", + "directive-is-not-loaded": "Δεν είναι διαθέσιμη καθορισμένη οδηγία διαμόρφωσης '{{directiveName}}'.", + "ui-resources-load-error": "Αποτυχία φόρτωσης διαμόρφωσης πόρων ui.", + "invalid-target-rulechain": "Δεν είναι δυνατή η επίλυση της αλυσίδας κανόνα στόχου!", + "test-script-function": "Λειτουργία σεναρίου δοκιμής", + "message": "Μήνυμα", + "message-type": "Τύπος μηνύματος", + "select-message-type": "Επιλογή τύπου μηνύματος", + "message-type-required": "Απαιτείται τύπος μηνύματος", + "metadata": "Μεταδεδομένα", + "metadata-required": "Οι καταχωρίσεις μεταδεδομένων δεν μπορούν να είναι κενές.", + "output": "Απόδοση", + "test": "Τεστ", + "help": "Βοήθεια", + "reset-debug-mode": "Επαναφορά λειτουργίας εντοπισμού σφαλμάτων σε όλους τους κόμβους" + }, + "role": { + "role": "Ρόλος", + "roles": "Ρόλοι", + "management": "Διαχείριση ρόλων", + "view-roles": "Προβολή ρόλων", + "no-roles-matching": "Δεν βρέθηκαν ρόλοι που να ταιριάζουν '{{entity}}'.", + "role-list": "Λίστα ρόλων", + "add": "Προσθήκη ρόλου", + "view": "Προβολή ρόλου", + "no-roles-text": "Δεν βρέθηκαν ρόλοι", + "role-details": "Λεπτομέρειες ρόλου", + "add-role-text": "Προσθήκη νέου ρόλου", + "delete": "Διαγραφή ρόλου", + "delete-roles": "Διαγραφή ρόλων", + "delete-role-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το ρόλο '{{roleName}}';", + "delete-role-text": "Προσοχή, μετά την επιβεβαίωση ο ρόλος και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-roles-title": "Είστε σίγουροι ότι θέλετε να παίξετε { count, plural, 1 {1 role} other {# roles} };", + "delete-roles-action-title": "Διαγραφή { count, plural, 1 {1 role} other {# roles} }", + "delete-roles-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι ρόλοι θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "role-type": "΄Τύπος ρόλου", + "role-type-required": "Απαιτείται τύπος ρόλου.", + "select-role-type": "Επιλογή τύπου ρόλου", + "enter-role-type": "Εισαγωγή τύπου ρόλου", + "any-role": "Οποιοσδήποτε ρόλος", + "no-role-types-matching": "Δεν βρέθηκαν τύποι ρόλων που να ταιριάζουν '{{entitySubtype}}'.", + "role-type-list-empty": "Δεν έγινε επιλογή τύπου ρόλου.", + "role-types": "Τύποι ρόλου", + "name": "Όνομα", + "name-required": "Απαιτείται όνομα.", + "description": "Περιγραφή", + "events": "Γεγονότα", + "details": "Λεπτομέρειες", + "copyId": "Αντιγραφή ταυτότητας ρόλου", + "idCopiedMessage": "Η ταυτότητα ρόλου έχει αντιγραφεί στο πρόχειρο", + "permissions": "Άδειες", + "role-required": "Απαιτείται ρόλος", + "display-type": { + "GENERIC": "Γενικός", + "GROUP": "Ομάδα" + } + }, + "group-permission": { + "user-group-roles": "Ρόλοι ομάδας χρηστών", + "entity-group-permissions": "Άδειες ομάδας Οντοτήτων", + "role-type": "Τύπος ρόλου", + "role-name": "Όνομα ρόλου", + "group-type": "Τύπος ομάδας", + "group-name": "Όνομα ομάδας", + "group-owner": "Κάτοχος ομάδας", + "user-group-name": "Όνομα χρήστη ομάδας", + "user-group-owner": "Κάτοχος χρήστη ομάδας", + "edit": "Επεξεργασία Αδειών", + "delete": "Διαγραφή αδειών", + "selected-group-permissions": "{ count, plural, 1 {1 group permission} other {# group permissions} } επιλέχθηκαν", + "delete-group-permission-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε την άδεια ομάδας '{{roleName}}';", + "delete-group-permission-text": "Προσοχή, μετά την επιβεβαίωση η άδεια ομάδας και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-group-permission": "Διαγραφή άδειας ομάδας", + "delete-group-permissions-title": "Έίστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 group permission} other {# group permission} };", + "delete-group-permissions-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες άδειες ομάδας θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-group-permissions": "Διαγραφή αδειών ομάδας", + "add-group-permission": "Προσθήκη ομαδικής άδειας", + "edit-group-permission": "Επεξεργασία άδειας ομάδας", + "entity-group": "Ομάδα οντοτήτων", + "user-group": "Χρήστης ομάδας", + "no-owners-matching": "Δεν βρέθηκαν κάτοχοι που να ταιριάζουν '{{owner}}'.", + "target-owner-required": " Απαιτείται κάτοχος της ομάδας οντότητας.", + "target-user-group-owner-required": "Απαιτείται κάτοχος χρήστη ομάδας." + }, + "permission": { + "permissions-required": "Τουλάχιστον μία εγγραφή άδειας πρέπει να οριστεί.", + "remove-permission": "Καταργήστε την καταχώριση δικαιωμάτων", + "add-permission": "Προσθήκη καταχώρησης άδειας", + "resource": { + "resource": "Πηγή", + "select-resource": "Επιλογή πηγής", + "resource-required": "Απαιτείται πηγή", + "no-resources-matching": "Δεν βρέθηκαν πηγές που να ταιριάζουν '{{resource}}'.", + "display-type": { + "ALL": "Όλες", + "PROFILE": "Προφίλ", + "ADMIN_SETTINGS": "Ρυθμίσεις Διαχειριστή", + "ALARM": "Alarm", + "DEVICE": "Συσκευή", + "ASSET": "Asset", + "CUSTOMER": "Πελάτης", + "DASHBOARD": "Dashboard", + "ENTITY_VIEW": "Προβολή οντοτήτων", + "TENANT": "Μισθωτής", + "RULE_CHAIN": "Αλυσίδα Κανόνα", + "USER": "Χρήστης", + "WIDGETS_BUNDLE": "Δέσμη Widgets", + "WIDGET_TYPE": "Τύπος Widget", + "CONVERTER": "Μετατροπέας", + "INTEGRATION": "Ενσωμάτωση", + "SCHEDULER_EVENT": "Πρόγραμμα εκδηλώσεων", + "BLOB_ENTITY": "Δέσμη Οντότητας", + "CUSTOMER_GROUP": "Ομάδα Πελατών", + "DEVICE_GROUP": "Ομάδα Συσκευών", + "ASSET_GROUP": "Ομάδες Asset", + "USER_GROUP": "Ομάδα Χρηστών", + "ENTITY_VIEW_GROUP": "Ομάδα Οντοτήτων", + "DASHBOARD_GROUP": "Ομάδα Dashboard", + "ROLE": "Ρόλος", + "GROUP_PERMISSION": "Ομαδική Άδεια", + "WHITE_LABELING": "Εμφάνιση", + "AUDIT_LOG": "Αρχείο Ελέγχου" + } + }, + "operation": { + "operation": "Εργασία", + "operations": "Εργασίες", + "operations-required": "Τουλάχιστον μία εργασία πρέπει να καθοριστεί.", + "enter-operation": "Εισαγωγή εργασίας", + "no-operations-matching": "Δεν βρέθηκαν εργασίες που να ταιριάζουν '{{operation}}'.", + "display-type": { + "ALL": "'Ολες", + "CREATE": "Δημιουργία", + "READ": "Ανάγνωση", + "WRITE": "Εγγραφή", + "DELETE": "Διαγραφή", + "ASSIGN_TO_CUSTOMER": "Ανάθεση σε Πελάτη", + "UNASSIGN_FROM_CUSTOMER": "Αποσύνδεση από Πελάτη", + "RPC_CALL": "Κλήση RPC", + "READ_CREDENTIALS": "Ανάγνωση Διαπιστευτηρίων", + "WRITE_CREDENTIALS": "Γράψτε Διαπιστευτήρια", + "READ_ATTRIBUTES": "Ανάγνωση Χαρακτηριστικών", + "WRITE_ATTRIBUTES": "Γράψτε Χαρακτηριστικά", + "READ_TELEMETRY": "Ανάγνωση Τηλεμετρίας", + "WRITE_TELEMETRY": "Γράψτε Τηλεμετρία", + "CLAIM_DEVICES": "Αιτήματα Συσκευών", + "IMPERSONATE": "Impersonate", + "CHANGE_OWNER": "Αλλαγή Κατόχου", + "ADD_TO_GROUP": "Προσθήκη στην Ομάδα", + "REMOVE_FROM_GROUP": "Αφαίρεση από την Ομάδα" + } + } + }, + "scheduler": { + "scheduler": "Προγραμματιστής", + "scheduler-event": "Προγραμματισμένο γεγονός", + "select-scheduler-event": "Επιλέξτε προγραμματισμένα γεγονότα που να ταιριάζουν '{{entity}}'", + "scheduler-event-required": "Απαιτείται προγραμματισμένο γεγονός", + "management": "Διαχείριση Προγράμματος", + "scheduler-events": "Προγραμματισμένα γεγονότα", + "add-scheduler-event": "Προσθήκη προγραμματισμένου γεγονότος", + "search-scheduler-events": "Αναζήτηση προγραμματισμένων γεγονότων", + "created-time": "Χρόνος που δημιουργήθηκε", + "name": "Όνομα", + "type": "Τύπος", + "created_customer": "Πελάτης που δημιουργήθηκε", + "edit-scheduler-event": "Επεξεργασία προγραμματισμένου γεγονότος", + "view-scheduler-event": "Προβολή προγραμματισμένου γεγονότος", + "delete-scheduler-event": "Διαγραφή προγραμματισμένου γεγονότος", + "no-scheduler-events": "Δεν βρέθηκαν προγραμματισμένα γεγονότα", + "selected-scheduler-events": "{ count, plural, 1 {1 scheduler event} other {# scheduler events} } επιλέχθηκαν", + "delete-scheduler-event-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το προγραμματισμένο γεγονός '{{schedulerEventName}}';", + "delete-scheduler-event-text": "Προσοχή, μετά την επιβεβαίωση το προγραμματισμένο γεγονός και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-scheduler-events-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 scheduler event} other {# scheduler events} };", + "delete-scheduler-events-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα προγραμματισμένα γεγονότα θα καταργηθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "create": "Δημιουργία προγραμματισμένου γεγονότος", + "edit": "Επεξεργασία προγραμματισμένου γεγονότος", + "view": "Προβολή προγραμματισμένου γεγονότος", + "name-required": "Απαιτείται Όνομα", + "configuration": "Διαμόρφωση", + "schedule": "Πρόγραμμα", + "start-time": "Ώρα έναρξης", + "repeat": "Επανάληψη", + "repeats": "Επαναλήψεις", + "daily": "Καθημερινά", + "weekly": "Εβδομαδιαία", + "timer": "Χρονομετρητής", + "repeats-required": "Απαιτούνται επαναλήψεις.", + "repeat-on": "Επανάληψη σε", + "repeat-every": "Επανάληψη κάθε", + "ends-on": "Τελειώνει σε", + "sunday-label": "K", + "monday-label": "Δ", + "tuesday-label": "T", + "wednesday-label": "Τ", + "thursday-label": "Π", + "friday-label": "Π", + "saturday-label": "Σ", + "repeat-on-sunday": "Επανάληψη την Κυριακή", + "repeat-on-monday": "Επανάληψη τη Δευτέρα", + "repeat-on-tuesday": "Επανάληψη την Τρίτη", + "repeat-on-wednesday": "Επανάληψη την Τετάρτη", + "repeat-on-thursday": "Επανάληψη την Πέμπτη", + "repeat-on-friday": "Επανάληψη την Παρασκευή", + "repeat-on-saturday": "Επανάληψη το Σάββατο", + "event-type": "Τύπος Γεγονότος", + "select-event-type": "Επιλογή Τύπου Γεγονότος", + "event-type-required": "Απαιτείται Τύπος Γεγονότος.", + "list-mode": "Προβολή Λίστας", + "calendar-mode": "Προβολή Ημερολογίου", + "calendar-view-type": "Προβολή τύπου ημερολογίου", + "month": "Μήνας", + "week": "Εβδομάδα", + "day": "Ημέρα", + "agenda-week": "Εβδομαδιαία Ατζέντα", + "agenda-day": "Ημερήσια Ατζέντα", + "list-year": "Λίστα Έτους", + "list-month": "Λίστα Μήνα", + "list-week": "Λίστα Εβδομάδος", + "list-day": "Λίστα Ημέρας", + "today": "Σήμερα", + "navigate-before": "Πλοηγηθείτε Πριν", + "navigate-next": "Πλοηγηθείτε Μετά", + "starting-from": "Έναρξη Από", + "until": "μέχρι", + "on": "σε", + "sunday": "Κυριακή", + "monday": "Δευτέρα", + "tuesday": "Τρίτη", + "wednesday": "Τετάρτη", + "thursday": "Πέμπτη", + "friday": "Παρασκευή", + "saturday": "Σάββατο", + "originator": "Δημιουργός" , + "single-entity": "Ενιαία Οντότητα", + "group-of-entities": "Ομάδα Οντοτήτων", + "single-device": "Ενιαία Συσκευή", + "group-of-devices": "Ομάδα Συσκευών", + "message-body": "Σώμα μηνυμάτων", + "target": "Στόχος", + "rpc-method": "Μέθοδος", + "rpc-method-required": "Απαιτείται μέθοδος", + "rpc-params": "Παράμετροι", + "select-dashboard-state": "Επιλογή κατάστασης πίνακα ελέγχου", + "hours": "Ώρες", + "minutes": "Λεπτά", + "seconds": "Δευτερόλεπτα", + "time-interval-required": "Απαιτείται χρονικό διάστημα", + "time-unit-required": "Απαιτείται μονάδα ώρας" + }, + "report": { + "report-config": "Διαμόρφωση αναφοράς", + "email-config": "Διαμόρφωση email", + "dashboard-state-param": "Τιμή παραμέτρου κατάστασης πίνακα", + "base-url": "Βάση URL", + "base-url-required": "Απαιτείται Βάση URL.", + "use-dashboard-timewindow": "Χρησιμοποιήστε το χρονικό πλαίσιο του πίνακα ελέγχου", + "timewindow": "Χρονικό πλαίσιο", + "name-pattern": "Αναφορά πρότυπου ονόματος", + "name-pattern-required": "Απαιτείται αναφορά πρότυπου ονόματος", + "type": "Τύπος αναφοράς", + "use-current-user-credentials": "Χρησιμοποιήστε τα τρέχοντα διαπιστευτήρια του χρήστη", + "customer-user-credentials": "Διαπιστευτήρια του χρήστη του πελάτη", + "customer-user-credentials-required": "Απαιτούνται διαπιστευτήρια του χρήστη του πελάτη", + "generate-test-report": "Δημιουργία αναφοράς δοκιμής", + "send-email": "Αποστολή email", + "from": "Από", + "from-required": "Απαιτείται Από.", + "to": "Σε", + "to-required": "Απαιτείται Σε.", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Θέμα", + "subject-required": "Απαιτείται Θέμα.", + "body": "Σώμα", + "body-required": "Απαιτείται Σώμα." + }, + "blob-entity": { + "blob-entity": "Ογκώδης οντότητα", + "select-blob-entity": "Επιλογή ογκώδους οντότητας", + "no-blob-entities-matching": "Δεν βρέθηκαν ογκώδεις οντότητες που να ταιριάζουν '{{entity}}'.", + "blob-entity-required": "Απαιτείται ογκώδης οντότητα", + "files": "Αρχεία", + "search": "Αναζήτηση αρχείων", + "clear-search": "Εκκαθάριση αναζήτησης", + "no-blob-entities-prompt": "Δεν βρέθηκαν αρχεία", + "report": "Αναφορά", + "created-time": "Χρόνος που δημιουργήθηκε", + "name": "Όνομα", + "type": "Τύπος", + "created_customer": "Δημιουργήθηκε από Πελάτη", + "download-blob-entity": "Λήψη αρχείου", + "delete-blob-entity": "Διαγραφή αρχείου", + "delete-blob-entity-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αρχείο '{{blobEntityName}}';", + "delete-blob-entity-text": "Προσοχή, μετά την επιβεβαίωση τα δεδομένα αρχείου θα διαγραφούν οριστικά." + }, + "timezone": { + "timezone": "Ζώνη Ώρας", + "select-timezone": "Επιλογή Ζώνης Ώρας", + "no-timezones-matching": "Δεν βρέθηκαν Ζώνες ώρας που να ταιριάζουν'{{timezone}}' .", + "timezone-required": "Απαιτείται Ζώνη Ώρας." + }, + "tenant": { + "tenant": "Μισθωτής", + "tenants": "Μισθωτές", + "management": "Διαχείριση Μισθωτών", + "add": "Πρόσθεση Μισθωτή", + "admins": "Διαχειριστές", + "manage-tenant-admins": "Επεξεργασία των διαχειριστών του Μισθωτή", + "delete": "Διαγραφή Μισθωτή", + "add-tenant-text": "Πρόσθεση νέου Μισθωτή", + "no-tenants-text": "Δεν βρέθηκαν Μισθωτές", + "tenant-details": "Λεπτομέρειες Μισθωτή", + "delete-tenant-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τον Μισθωτή '{{tenantTitle}}';", + "delete-tenant-text": "Προσοχή, μετά την επιβεβαίωση ο Μισθωτής και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-tenants-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 tenant} other {# tenants} };", + "delete-tenants-action-title": "Διαγραφή { count, plural, 1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι Μισθωτές θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "title": "Τίτλος", + "title-required": "Απαιτείται Τίτλος.", + "description": "Περιγραφή", + "details": "Λεπτομέρειες", + "events": "Γεγονότα", + "copyId": "Αντιγραφή Ταυτότητας του Μισθωτή", + "idCopiedMessage": "Η Ταυτότητα του Μισθωτή έχει αντιγραφεί στο πρόχειρο", + "select-tenant": "Επιλογή Μισθωτή", + "no-tenants-matching": "Δεν βρέθηκαν Μισθωτές που να ταιριάζουν '{{entity}}'.", + "tenant-required": "Απαιτείται Μισθωτής", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenants} } επιλέχθηκαν", + "search": "Αναζήτηση Μισθωτών", + "allow-white-labeling": "Επιτρέπεται Προσαρμογή Εμφάνισης", + "allow-customer-white-labeling": "Επιτρέπεται Προσαρμογή Εμφάνισης Πελάτη" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", + "days": "Ημέρες", + "hours": "Ώρες", + "minutes": "Λεπτά", + "seconds": "Δευτερόλεπτα", + "advanced": "Προηγμένος" + }, + "timewindow": { + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "realtime": "Πραγματικός Χρόνος", + "history": "Ιστορικό", + "last-prefix": "Τελευταίος", + "period": "από {{ startTime }} σε {{ endTime }}", + "edit": "Επεξεργασία Χρονικού Πλαισίου", + "date-range": "Εύρος ημερομηνιών", + "last": "Τελευταίος", + "time-period": "Χρονική Περίοδος" + }, + "user": { + "user": "Χρήστης", + "users": "Χρήστες", + "management": "Διαχείριση Χρηστών", + "customer-users": "Χρήστες του Πελάτη", + "tenant-admins": "Διαχειριστές Μισθωτή", + "sys-admin": "Διαχειριστής Συστήματος", + "tenant-admin": "Διαχειριστής Μισθωτή", + "customer": "Πελάτης", + "anonymous": "Ανώνυμος", + "add": "Προσθήκη χρήστη", + "delete": "Διαγραφή χρήστη", + "add-user-text": "Προσθήκη νέου Χρήστη", + "no-users-text": "Δεν βρέθηκαν Χρήστες", + "user-details": "Λεπτομέρειες Χρήστη", + "delete-users": "Διαγραφή Χρηστών", + "delete-user-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε το Χρήστη '{{userEmail}}'?", + "delete-user-text": "Προσοχή, μετά την επιβεβαίωση ο Χρήστης και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-users-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 user} other {# users} };", + "delete-users-action-title": "Διαγραφή { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Προσοχή, μετά την επιβεβαίωση όλοι οι επιλεγμένοι Χρήστες θα αφαιρεθούν και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "activation-email-sent-message": "Το email ενεργοποίησης στάλθηκε με επιτυχία!", + "resend-activation": "Επανάληψη ενεργοποίησης", + "email": "Email", + "email-required": "Απαιτείται email.", + "invalid-email-format": "Μη έγκυρη μορφή email.", + "first-name": "Όνομα", + "last-name": "Επίθετο", + "description": "Περιγραφή", + "default-dashboard": "Προκαθορισμένος πίνακας ελέγχου", + "always-fullscreen": "Πάντα με πλήρη οθόνη", + "select-user": "Επιλογή Χρήστη", + "no-users-matching": "Δεν βρέθηκαν χρήστες που να ταιριάζουν '{{entity}}'.", + "user-required": "Απαιτείται Χρήστης", + "activation-method": "Μέθοδος ενεργοποίησης", + "display-activation-link": "Εμφάνιση συνδέσμου ενεργοποίησης", + "send-activation-mail": "Αποστολή mail ενεργοποίησης", + "activation-link": "Σύνδεσμος ενεργοποίησης Χρήστη", + "activation-link-text": "΄Προκειμένου να ενεργοποιήσετε το Χρήστη, χρησιμοποιήστε το εξής activation link :", + "copy-activation-link": "Αντιγραφή συνδέσμου ενεργοποίησης", + "activation-link-copied-message": "Ο σύνδεσμος ενεργοποίησης χρήστη έχει αντιγραφεί στο πρόχειρο", + "selected-users": "{ count, plural, 1 {1 user} other {# users} } επιλέχθηκαν", + "search": "Αναζήτηση Χρηστών", + "details": "Λεπτομέρειες", + "login-as-tenant-admin": "Συνδεθείτε ως Διαχειριστής Μισθωτή", + "login-as-customer-user": "Συνδεθείτε ως Χρήστης του Πελάτη", + "select-group-to-add": "Επιλέξτε την ομάδα προορισμού για να προσθέσετε επιλεγμένους Χρήστες", + "select-group-to-move": "Επιλέξτε την ομάδα προορισμού για να μετακινήσετε επιλεγμένους χρήστες", + "remove-users-from-group": "Είστε σίγουροι ότι θέλετε να καταργήσετε { count, plural, 1 {1 user} other {# users} } από την ομάδα '{entityGroup}';", + "group": "Ομάδα από Χρήστες", + "list-of-groups": "{ count, plural, 1 {One user group} other {List of # user groups} }", + "group-name-starts-with": "Ομάδες Χρηστών των οποίων τα ονόματα ξεκινούν με'{{prefix}}'" + }, + "value": { + "type": "Είδος τιμής", + "string": "Συμβολοσειρά", + "string-value": "Τιμή συμβολοσειράς", + "integer": "Ακέραιος", + "integer-value": "Ακέραια τιμή", + "invalid-integer-value": "Μη έγκυρη ακέραια τιμή", + "double": "Πραγματικός", + "double-value": "Πραγματική τιμή", + "boolean": "Λογικό", + "boolean-value": "Λογική τιμή", + "false": "Εσφαλμένος", + "true": "Αληθής", + "long": "Μακρύς" + }, + "widget": { + "widget-library": "Βιβλιοθήκη Widget", + "widget-bundle": "Δέσμη Widget", + "select-widgets-bundle": "Επιλογή δέσμης Widgets", + "management": "Διαχείριση Widget", + "editor": "Συντάκτης Widget", + "widget-type-not-found": "Πρόβλημα φόρτωσης διαμόρφωσης Widget.
    Probably associated\n widget type was removed.", + "widget-type-load-error": "Το Widget δεν φορτώθηκε λόγω των παρακάτω σφαλμάτων:", + "remove": "Αφαίρεση Widget", + "edit": "Επεξεργασία Widget", + "remove-widget-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε το Widget '{{widgetTitle}}'?", + "remove-widget-text": "Προσοχή, μετά την επιβεβαίωση το widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "timeseries": "Χρονική σειρά", + "search-data": "Αναζήτηση δεδομένων", + "no-data-found": "Δεν βρέθηκαν δεδομένα", + "latest": "Τελευταίες αξίες", + "rpc": "Έλεγχος Widget", + "alarm": "Alarm widget", + "static": "Στατικό widget", + "select-widget-type": "Επιλογή τύπου Widget", + "missing-widget-title-error": "Ο τίτλος Widget πρέπει να καθοριστεί!", + "widget-saved": "Το Widget αποθηκεύτηκε", + "unable-to-save-widget-error": "Δεν είναι δυνατή η αποθήκευση του Widget! Το Widget έχει σφάλματα!", + "save": "Αποθήκευση widget", + "saveAs": "Αποθήκευση widget ως", + "save-widget-type-as": "Αποθήκευση τύπου Widget type ως", + "save-widget-type-as-text": "Παρακαλούμε εισάγετε νέο τίτλο Widget και/ή επιλέξετε στοχευμένη δέσμη widgets", + "toggle-fullscreen": "Λειτουργεία πλήρους οθόνης", + "run": "Εκτέλεση Widget", + "title": "Τίτλος Widget", + "title-required": "Απαιτείται τίτλος Widget.", + "type": "Τύπος Widget", + "resources": "Πόροι", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Αφαίρεση πηγής", + "add-resource": "Προσθήκη πηγής", + "html": "HTML", + "tidy": "Τακτοποιημένος", + "css": "CSS", + "settings-schema": "Ρυθμίσεις σχήματος", + "datakey-settings-schema": "Πλήκτρο δεδομένων σχήματος ρυθμίσεων", + "javascript": "Javascript", + "remove-widget-type-title": "Είστε σίγουροι ότι θέλετε να αφαιρέσετε τον τύπο Widget '{{widgetName}}'?", + "remove-widget-type-text": "Προσοχή, μετά την επιβεβαίωση ο τύπος Widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "remove-widget-type": "Αφαίρεση τύπου Widget", + "add-widget-type": "Προσθήκη νέου τύπου Widget", + "widget-type-load-failed-error": "Αποτυχία φόρτωσης τύπου Widget!", + "widget-template-load-failed-error": "Αποτυχία φόρτωσης προτύπου Widget!", + "add": "Προσθήκη Widget", + "undo": "Αναίρεση αλλαγών Widget", + "export": "Εξαγωγή Widget", + "export-data": "Εξαγωγή δεδομένων Widget", + "export-to-csv": "Εξαγωγή δεδομένων σε CSV...", + "export-to-excel": "Εξαγωγή δεδομένων σε XLS...", + "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση στο Widget" + }, + "widget-action": { + "header-button": "Κουμπί κεφαλίδας στη νέα κατάσταση του πίνακα ελέγχου", + "update-dashboard-state": "Ενημέρωση της τρέχουσας κατάστασης του dashboard", + "open-dashboard": "Πλοήγηση σε άλλο dashboard", + "custom": "Προσαρμοσμένη ενέργεια", + "target-dashboard-state": "Κατάσταση προορισμού dashboard", + "target-dashboard-state-required": "Απαιτείται κατάσταση προορισμού dashboard", + "set-entity-from-widget": "Ορισμός οντότητας από Widget", + "target-dashboard": "dashboard προορισμού", + "open-right-layout": "Ανοίξτε τη δεξιά διάταξη του dashboard (προβολή κινητού)" + }, + "widgets-bundle": { + "current": "Τρέχουσα δέσμη", + "widgets-bundles": "Δέσμες Widgets", + "add": "Προσθήκη δέσμης Widgets", + "delete": "Διαγραφή δέσμης Widgets", + "title": "Τίτλος", + "title-required": "Απαιτείται τίτλος.", + "add-widgets-bundle-text": "Προσθήκη νέας δέσμης widgets", + "no-widgets-bundles-text": "Δεν βρέθηκαν δέσμες widgets", + "empty": "Η δέσμη Widgets είναι κενή", + "details": "Λεπτομέρειες", + "widgets-bundle-details": "Λεπτομέρειες δέσμης Widgets", + "delete-widgets-bundle-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη δέσμη Widgets '{{widgetsBundleTitle}}';", + "delete-widgets-bundle-text": "Προσοχή, μετά την επιβεβαίωση η δέσμη Widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "delete-widgets-bundles-title": "Είστε σίγουροι ότι θέλετε να διαγράψετε { count, plural, 1 {1 widgets bundle} other {# widgets bundles} };", + "delete-widgets-bundles-action-title": "Διαγραφή { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Προσοχή, μετά την επιβεβαίωση όλες οι επιλεγμένες δέσμες Widget και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά.", + "no-widgets-bundles-matching": "Δεν βρέθηκαν δέσμες Widgets που να ταιριάζουν'{{widgetsBundle}}'.", + "widgets-bundle-required": "Απαιτείται δέσμη Widgets.", + "system": "Σύστημα", + "import": "Εισαγωγή δέσμης Widgets", + "export": "Εξαγωγή δέσμης Widgets", + "export-failed-error": "Δεν είναι δυνατή η εξαγωγή δέσμης Widgets: {{error}}", + "create-new-widgets-bundle": "Δημιουργία νέας δέσμης Widgets", + "widgets-bundle-file": "Αρχείο δέσμης Widgets", + "invalid-widgets-bundle-file-error": "Δεν είναι δυνατή η εισαγωγή δέσμης Widgets: Μη έγκυρη δομή δεδομένων Widgets." + }, + "widget-config": { + "data": "Δεδομένα", + "settings": "Ρυθμίσεις", + "advanced": "Προηγμένος", + "title": "Τίτλος", + "general-settings": "Γενικές ρυθμίσεις", + "display-title": "Εμφάνιση τίτλου", + "drop-shadow": "Σκίαση", + "enable-fullscreen": "Ενεργοποίηση πλήρους οθόνης", + "enable-data-export": "Ενεργοποίηση εξαγωγής δεδομένων", + "background-color": "Χρώμα φόντου", + "text-color": "Χρώμα κειμένου", + "padding": "Εσωτερικό περιθώριο", + "margin": "Περιθώριο", + "widget-style": "Στυλ Widget", + "title-style": "Στυλ τίτλου", + "mobile-mode-settings": "Ρυθμίσεις λειτουργίας κινητού", + "order": "Εντολή", + "height": "Ύψος", + "units": "Ειδικό σύμβολο για εμφάνιση δίπλα στην αξία", + "decimals": "Αριθμός ψηφίων μετά το κυμαινόμενο σημείο", + "timewindow": "Timewindow", + "use-dashboard-timewindow": "Χρήση dashboard timewindow", + "display-timewindow": "Απεικόνιση timewindow", + "display-legend": "Απεικόνιση λεζάντας", + "datasources": "Πηγές δεδομένων", + "maximum-datasources": "Το μέγιστο { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", + "datasource-type": "Τύπος", + "datasource-parameters": "Παράμετροι", + "remove-datasource": "Κατάργηση της πηγής δεδομένων", + "add-datasource": "Προσθήκη πηγής δεδομένων", + "target-device": "Target device", + "alarm-source": "Πηγή Alarm", + "actions": "Ενέργειες", + "action": "ενέργεια", + "add-action": "Προσθήκη ενέργειας", + "search-actions": "Αναζήτηση ενεργειών", + "action-source": "Πηγή ενέργειας", + "action-source-required": "Απαιτείται πηγή ενέργειας.", + "action-name": "Όνομα", + "action-name-required": "Απαιτείται όνομα ενέργειας.", + "action-name-not-unique": "Μια άλλη ενέργεια με το ίδιο όνομα υπάρχει ήδη.
    Το όνομα ενέργειας πρέπει να είναι μοναδικό μέσα στην ίδια πηγή ενέργειας.", + "action-icon": "Εικονίδιο", + "action-type": "Τύπος", + "action-type-required": "Απαιτείται τύπος ενέργειας.", + "edit-action": "Επεξεργασία ενέργειας", + "delete-action": "Διαγραφή ενέργειας", + "delete-action-title": "Διαγραφή ενέργειας Widget", + "delete-action-text": "Είστε σίγουροι ότι θέλετε να διαγράψετε δράση widget με όνομα '{{actionName}}';" + }, + "widget-type": { + "import": "Εισαγωγή τύπου Widget", + "export": "Εξαγωγή τύπου Widget", + "export-failed-error": "Δεν είναι δυνατή η εξαγωγή τύπου Widget: {{error}}", + "create-new-widget-type": "Δημιουργία νέου τύπου Widget", + "widget-type-file": "Αρχείο τύπου Widget", + "invalid-widget-type-file-error": "Δεν είναι δυνατή η εισαγωγή τύπου Widget: Μη έγκυρη δομή δεδομένων τύπου Widget." + }, + "self-registration": { + "self-registration": "Αυτόματη εγγραφή", + "self-registration-url": "Αυτόματη εγγραφή URL", + "captcha-site-key": "reCAPTCHA κλειδί ιστότοπου", + "captcha-secret-key": "reCAPTCHA μυστικό κλειδί", + "notification-email": "Εmail γνωστοποίησης", + "privacy-policy-text": "Κείμενο πολιτικής απορρήτου", + "text-message-page": "Μήνυμα κειμένου για τη σελίδα εγγραφής" + }, + "white-labeling": { + "white-labeling": "Εμφάνιση", + "login-white-labeling": "Εμφάνιση Σύδεσης", + "preview": "Προεπισκόπηση", + "app-title": "Τίτλος εφαρμογής", + "favicon": "Εικονίδιο Ιστότοπου", + "favicon-description": "Εικόνα *.ico, *.gif or *.png με μέγιστο μέγεθος {{kbSize}} KBytes.", + "favicon-size-error": "Η εικόνα ιστότοπου είναι πολύ μεγάλη. Μέγιστο επιτρεπόμενο μέγεθος {{kbSize}} KBytes.", + "favicon-type-error": "Μη έγκυρη μορφή αρχείου εικόνας ιστότοπου. Μόνο εικόνες ICO, GIF ή PNG γίνονται αποδεκτές.", + "drop-favicon-image": "Σύρετε ένα εικονίδιο ιστότοπου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", + "no-favicon-image": "Δεν έχει επιλεχθεί εικονίδιο", + "logo": "Logo", + "logo-description": "Οποιαδήποτε εικόνα με μέγιστο μέγεθος {{kbSize}} KBytes.", + "logo-size-error": "Η εικόνα του λογότυπου είναι πολύ μεγάλη. Μέγιστο επιτρεπόμενο μέγεθος {{kbSize}} KBytes.", + "logo-type-error": "Μη έγκυρη μορφή αρχείου λογότυπου. Μόνο εικόνες είναι αποδεκτές.", + "drop-logo-image": "Σείρετε μια εικόνα λογότυπου ή κάντε κλικ για να επιλέξετε ένα αρχείο για μεταφόρτωση.", + "no-logo-image": "Δεν έχει επιλεγεί λογότυπο", + "logo-height": "Ύψος λογότυπου, px", + "primary-palette": "Κύρια παλέτα", + "accent-palette": "Παλέτα τονισμών", + "customize-palette": "Προσαρμογή", + "edit-palette": "Επεξεργασία παλέτας", + "save-palette": "Αποθήκευση παλέτας", + "primary-background": "Κύριο χρώμα υποβάθρου", + "secondary-background": "Δευτερεύον χρώμα υποβάθρου", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Χρώμα υποβάθρου σελίδας", + "dark-foreground": "Σκοτεινό χρώμα προσκηνίου", + "domain-name": "Όνομα Domain", + "help-link-base-url": "Base url για συνδέσμους βοηθείας", + "enable-help-links": "Ενεργοποίηση συνδέσμων βοηθείας", + "error-verification-url": "Ένα όνομα domain δεν πρέπει να περιέχει σύμβολα '/' και ':'. Παράδειγμα: thingsboard.io", + "show-platform-name-version": "Εμφάνιση ονόματος και έκδοσης πλατφόρμας", + "platform-name": "Όνομα πλατφόρμας", + "platform-version": "Έκδοση πλατφόρμας", + "version-mask": "{{name}} v.{{verion}}", + "position": { + "label": "Όνομα πλατφόρμας και θέση έκδοσης", + "under-logo": "Κάτω από το λογότυπο", + "bottom": "Στο κάτω μέρος της φόρμας σύνδεσης" + } + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Κυρ", + "Mon": "Δευ", + "Tue": "Τρι", + "Wed": "Τετ", + "Thu": "Πεμ", + "Fri": "Παρ", + "Sat": "Σαβ", + "Jan": "Ιαν", + "Feb": "Φεβ", + "Mar": "Μαρ", + "Apr": "Απρ", + "May": "Μάιος", + "Jun": "Ιουν", + "Jul": "Ιουλ", + "Aug": "Αυγ", + "Sep": "Σεπ", + "Oct": "Οκτ", + "Nov": "Νοε", + "Dec": "Δεκ", + "January": "Ιανουάριος", + "February": "Φεβρουάριος", + "March": "Μάρτιος", + "April": "Απρίλιος", + "June": "Ιούνιος", + "July": "Ιούλιος", + "August": "Αύγουστος", + "September": "Σεπτέμβριος", + "October": "Οκτώβριος", + "November": "Νοέμβριος", + "December": "Δεκέμβριος", + "Custom Date Range": "Προσαρμοσμένο εύρος ημερομηνιών", + "Date Range Template": "Πρότυπο εύρους ημερομηνιών", + "Today": "Σήμερα", + "Yesterday": "Χθες", + "This Week": "Αυτή την εβοδομάδα", + "Last Week": "Την προηγούμενη εβδομάδα", + "This Month": "Αυτόν τον μήνα", + "Last Month": "Τον προηγούμενο μήνα", + "Year": "Έτος", + "This Year": "Αυτό το χρόνο", + "Last Year": "Τον προηγούμενο χρόνο", + "Date picker": "Επιλογέας ημερομηνίας", + "Hour": "Ώρα", + "Day": "Ημέρα", + "Week": "Εβδομάδα", + "2 weeks": "2 Εβδομάδες", + "Month": "Μήνας", + "3 months": "3 Μήνες", + "6 months": "6 Μήνες", + "Custom interval": "Προσαρμοσμένο διάστημα", + "Interval": "Διάστημα", + "Step size": "Μέγεθος βήματος", + "Ok": "Ok" + } + } + }, + "icon": { + "icon": "Εικονίδιο", + "select-icon": "Επιλογή εικονιδίου ", + "material-icons": "Υλικά εικονίδια", + "show-all": "Προβολή όλων των εικονιδίων" + }, + "subscription": { + "entity-limit-text": "Ωστόσο, μπορείτε να αναβαθμίσετε το πρόγραμμα εγγραφής σας για να αυξήσετε τα όριά σας.", + "upgrade-your-plan": "Αναβάθμιση του σχεδίου συνδρομής", + "white-labeling-feature": "Εμφάνιση χαρακτηριστικού", + "white-labeling-text-full": "Αλλάξτε το διακριτικό τίτλο της πλατφόρμας με το λογότυπο της εταιρείας ή του προϊόντος σας και το συνδυασμό χρωμάτων σε 2 λεπτά.

    Αφαιρέστε το \"Powered By\" στο κάτω μέρος του dashboard.
    Δεν απαιτείται κώδικας ή επανεκκίνηση υπηρεσίας. Επιτρέψτε και στους πελάτες σας να αλλάξουν το περιβάλλον τους.", + "enable-white-labeling": "Ενεργοποίηση εμφάνισης χαρακτηριστικού τώρα αναβαθμίζοντας το σχέδιο εγγραφής σας!", + "read-more": "Διαβάστε περισσότερα", + "white-labeling-video-text": "Δείτε το εκπαιδευτικό βίντεο παρακάτω για να δείτε πώς λειτουργεί αυτό το χαρακτηριστικό!" + }, + "subscription-error": { + "title": "Παραβίαση συνδρομής", + "warning-title": "Προειδοποίηση συνδρομής", + "limit-reached": { + "device-count": "Έχετε φτάσει τις μέγιστες συσκευές ({{value}}) που επιτρέπονται από το πρόγραμμα εγγραφής σας!", + "asset-count": "Έχετε φτάσει τα μέγιστα assets ({{value}}) που επιτρέπονται από το πρόγραμμα εγγραφής σας!" + }, + "feature-disabled": { + "white-labeling": "Η εμφάνιση χαρακτηριστικού δεν επιτρέπεται από το σχέδιο εγγραφής σας!" + } + }, + "custom": { + "widget-action": { + "action-cell-button": "Κουμπί ενέργειας κελιών", + "row-click": "Κλικ στη σειρά", + "polygon-click": "Κλικ στο πολύγωνο", + "marker-click": "Κλικ στο δείκτη", + "tooltip-tag-action": "Ετικέτα εργαλείου ενέργειας", + "node-selected": "Στον επιλεγμένο κόμβο", + "element-click": "Κλικ στο στοιχείο HTML" + } + }, + "language": { + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json new file mode 100644 index 0000000..c7ce4f9 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -0,0 +1,4817 @@ +{ + "access": { + "unauthorized": "Unauthorized", + "unauthorized-access": "Unauthorized Access", + "unauthorized-access-text": "You should sign in to have access to this resource!", + "access-forbidden": "Access Forbidden", + "access-forbidden-text": "You haven't access rights to this location!
    Try to sign in with different user if you still wish to gain access to this location.", + "refresh-token-expired": "Session has expired", + "refresh-token-failed": "Unable to refresh session", + "permission-denied": "Permission Denied", + "permission-denied-text": "You don't have permission to perform this operation!" + }, + "action": { + "activate": "Activate", + "suspend": "Suspend", + "save": "Save", + "saveAs": "Save as", + "cancel": "Cancel", + "ok": "OK", + "delete": "Delete", + "add": "Add", + "yes": "Yes", + "no": "No", + "update": "Update", + "remove": "Remove", + "select": "Select", + "search": "Search", + "clear-search": "Clear search", + "assign": "Assign", + "unassign": "Unassign", + "share": "Share", + "make-private": "Make private", + "apply": "Apply", + "apply-changes": "Apply changes", + "edit-mode": "Edit mode", + "enter-edit-mode": "Enter edit mode", + "decline-changes": "Decline changes", + "close": "Close", + "back": "Back", + "run": "Run", + "sign-in": "Sign in!", + "edit": "Edit", + "view": "View", + "create": "Create", + "drag": "Drag", + "refresh": "Refresh", + "undo": "Undo", + "copy": "Copy", + "paste": "Paste", + "copy-reference": "Copy reference", + "paste-reference": "Paste reference", + "import": "Import", + "export": "Export", + "share-via": "Share via {{provider}}", + "continue": "Continue", + "discard-changes": "Discard Changes", + "download": "Download", + "next-with-label": "Next: {{label}}", + "read-more": "Read more", + "hide": "Hide", + "done": "Done", + "print": "Print", + "restore": "Restore", + "confirm": "Confirm" + }, + "aggregation": { + "aggregation": "Aggregation", + "function": "Data aggregation function", + "limit": "Max values", + "group-interval": "Grouping interval", + "min": "Min", + "max": "Max", + "avg": "Average", + "sum": "Sum", + "count": "Count", + "none": "None" + }, + "admin": { + "general": "General", + "general-settings": "General Settings", + "home-settings": "Home Settings", + "outgoing-mail": "Mail Server", + "outgoing-mail-settings": "Outgoing Mail Server Settings", + "system-settings": "System Settings", + "test-mail-sent": "Test mail was successfully sent!", + "base-url": "Base URL", + "base-url-required": "Base URL is required.", + "prohibit-different-url": "Prohibit to use hostname from the client request headers", + "prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled", + "mail-from": "Mail From", + "mail-from-required": "Mail From is required.", + "smtp-protocol": "SMTP protocol", + "smtp-host": "SMTP host", + "smtp-host-required": "SMTP host is required.", + "smtp-port": "SMTP port", + "smtp-port-required": "You must supply a smtp port.", + "smtp-port-invalid": "That doesn't look like a valid smtp port.", + "timeout-msec": "Timeout (msec)", + "timeout-required": "Timeout is required.", + "timeout-invalid": "That doesn't look like a valid timeout.", + "enable-tls": "Enable TLS", + "tls-version": "TLS version", + "enable-proxy": "Enable proxy", + "proxy-host": "Proxy host", + "proxy-host-required": "Proxy host is required.", + "proxy-port": "Proxy port", + "proxy-port-required": "Proxy port is required.", + "proxy-port-range": "Proxy port should be in a range from 1 to 65535.", + "proxy-user": "Proxy user", + "proxy-password": "Proxy password", + "change-password": "Change password", + "send-test-mail": "Send test mail", + "sms-provider": "SMS provider", + "sms-provider-settings": "SMS provider settings", + "sms-provider-type": "SMS provider type", + "sms-provider-type-required": "SMS provider type is required.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID is required", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key is required", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region is required", + "number-from": "Phone Number From", + "number-from-required": "Phone Number From is required.", + "number-to": "Phone Number To", + "number-to-required": "Phone Number To is required.", + "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", + "phone-number-hint-twilio": "Phone Number in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", + "phone-number-pattern-twilio": "Invalid phone number. Should be in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS message", + "sms-message-required": "SMS message is required.", + "sms-message-max-length": "SMS message can't be longer 1600 characters", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID is required", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token is required", + "send-test-sms": "Send test SMS", + "test-sms-sent": "Test SMS was successfully sent!", + "security-settings": "Security settings", + "password-policy": "Password policy", + "minimum-password-length": "Minimum password length", + "minimum-password-length-required": "Minimum password length is required", + "minimum-password-length-range": "Minimum password length should be in a range from 5 to 50", + "minimum-uppercase-letters": "Minimum number of uppercase letters", + "minimum-uppercase-letters-range": "Minimum number of uppercase letters can't be negative", + "minimum-lowercase-letters": "Minimum number of lowercase letters", + "minimum-lowercase-letters-range": "Minimum number of lowercase letters can't be negative", + "minimum-digits": "Minimum number of digits", + "minimum-digits-range": "Minimum number of digits can't be negative", + "minimum-special-characters": "Minimum number of special characters", + "minimum-special-characters-range": "Minimum number of special characters can't be negative", + "password-expiration-period-days": "Password expiration period in days", + "password-expiration-period-days-range": "Password expiration period in days can't be negative", + "password-reuse-frequency-days": "Password reuse frequency in days", + "password-reuse-frequency-days-range": "Password reuse frequency in days can't be negative", + "allow-whitespace": "Allow whitespace", + "general-policy": "General policy", + "max-failed-login-attempts": "Maximum number of failed login attempts, before account is locked", + "minimum-max-failed-login-attempts-range": "Maximum number of failed login attempts can't be negative", + "user-lockout-notification-email": "In case user account lockout, send notification to email", + "domain-name": "Domain name", + "domain-name-unique": "Domain name and protocol need to unique.", + "domain-name-max-length": "Domain name should be less than 256", + "error-verification-url": "A domain name shouldn't contain symbols '/' and ':'. Example: thingsboard.io", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI is required.", + "activate-user": "Activate user", + "add-domain": "Add domain", + "delete-domain": "Delete domain", + "add-provider": "Add provider", + "delete-provider": "Delete provider", + "allow-user-creation": "Allow user creation", + "always-fullscreen": "Always fullscreen", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI is required.", + "client-authentication-method": "Client authentication method", + "client-id": "Client ID", + "client-id-required": "Client ID is required.", + "client-id-max-length": "Client ID should be less than 256", + "client-secret": "Client secret", + "client-secret-required": "Client secret is required.", + "client-secret-max-length": "Client secret should be less than 2049", + "custom-setting": "Custom settings", + "customer-name-pattern": "Customer name pattern", + "customer-name-pattern-max-length": "Customer name pattern should be less than 256", + "default-dashboard-name": "Default dashboard name", + "default-dashboard-name-max-length": "Default dashboard name should be less than 256", + "delete-domain-text": "Be careful, after the confirmation a domain and all provider data will be unavailable.", + "delete-domain-title": "Are you sure you want to delete settings the domain '{{domainName}}'?", + "delete-registration-text": "Be careful, after the confirmation a provider data will be unavailable.", + "delete-registration-title": "Are you sure you want to delete the provider '{{name}}'?", + "email-attribute-key": "Email attribute key", + "email-attribute-key-required": "Email attribute key is required.", + "email-attribute-key-max-length": "Email attribute key should be less than 32", + "first-name-attribute-key": "First name attribute key", + "first-name-attribute-key-max-length": "First name attribute key should be less than 32", + "general": "General", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Last name attribute key", + "last-name-attribute-key-max-length": "Last name attribute key should be less than 32", + "login-button-icon": "Login button icon", + "login-button-label": "Provider label", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "Label is required.", + "login-provider": "Login provider", + "mapper": "Mapper", + "new-domain": "New domain", + "oauth2": "OAuth2", + "password-max-length": "Password should be less than 256", + "redirect-uri-template": "Redirect URI template", + "copy-redirect-uri": "Copy redirect URI", + "registration-id": "Registration ID", + "registration-id-required": "Registration ID is required.", + "registration-id-unique": "Registration ID need to unique for the system.", + "scope": "Scope", + "scope-required": "Scope is required.", + "tenant-name-pattern": "Tenant name pattern", + "tenant-name-pattern-required": "Tenant name pattern is required.", + "tenant-name-pattern-max-length": "Tenant name pattern ishould be less than 256", + "tenant-name-strategy": "Tenant name strategy", + "type": "Mapper type", + "uri-pattern-error": "Invalid URI format.", + "url": "URL", + "url-pattern": "Invalid URL format.", + "url-required": "URL is required.", + "url-max-length": "URL should be less than 256", + "user-info-uri": "User info URI", + "user-info-uri-required": "User info URI is required.", + "username-max-length": "User name should be less than 256", + "user-name-attribute-name": "User name attribute key", + "user-name-attribute-name-required": "User name attribute key is required", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Enable OAuth2 settings", + "domains": "Domains", + "mobile-apps": "Mobile applications", + "no-mobile-apps": "No applications configured", + "mobile-package": "Application package", + "mobile-package-placeholder": "Ex.: my.example.app", + "mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.", + "mobile-package-unique": "Application package must be unique.", + "mobile-app-secret": "Application secret", + "invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.", + "copy-mobile-app-secret": "Copy application secret", + "add-mobile-app": "Add application", + "delete-mobile-app": "Delete application info", + "providers": "Providers", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "All platforms", + "allowed-platforms": "Allowed platforms" + }, + "smpp-provider": { + "smpp-version": "SMPP version", + "smpp-host": "SMPP host", + "smpp-host-required": "SMPP host is required", + "smpp-port": "SMPP port", + "smpp-port-required": "SMPP port is required", + "system-id": "System ID", + "system-id-required": "System ID is required", + "password": "Password", + "password-required": "Password is required", + "type-settings": "Type settings", + "source-settings": "Source settings", + "destination-settings": "Destination settings", + "additional-settings": "Additional settings", + "system-type": "System type", + "bind-type": "Bind type", + "service-type": "Service type", + "source-address": "Source address", + "source-ton": "Source TON", + "source-npi": "Source NPI", + "destination-ton": "Destination TON (Type of Number)", + "destination-npi": "Destination NPI (Numbering Plan Identification)", + "address-range": "Address range", + "coding-scheme": "Coding scheme", + "bind-type-tx": "Transmitter", + "bind-type-rx": "Receiver", + "bind-type-trx": "Transciever", + "ton-unknown": "Unknown", + "ton-international": "International", + "ton-national": "National", + "ton-network-specific": "Network Specific", + "ton-subscriber-number": "Subscriber Number", + "ton-alphanumeric": "Alphanumeric", + "ton-abbreviated": "Abbreviated", + "npi-unknown": "0 - Unknown", + "npi-isdn": "1 - ISDN/telephone numbering plan (E163/E164)", + "npi-data-numbering-plan": "3 - Data numbering plan (X.121)", + "npi-telex-numbering-plan": "4 - Telex numbering plan (F.69)", + "npi-land-mobile": "6 - Land Mobile (E.212)", + "npi-national-numbering-plan": "8 - National numbering plan", + "npi-private-numbering-plan": "9 - Private numbering plan", + "npi-ermes-numbering-plan": "10 - ERMES numbering plan (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (to be defined by WAP Forum)", + "scheme-smsc": "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free)", + "scheme-ia5": "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octet Unspecified (8-bit binary)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet Unspecified (8-bit binary)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cyrillic (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebrew (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Pictogram Encoding", + "scheme-music-codes": "10 - Music Codes (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Extended Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Korean Graphic Character Set (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Select queue name", + "queue-name": "Name", + "queue-name-required": "Queue name is required!", + "queues": "Queues", + "queue-partitions": "Partitions", + "queue-submit-strategy": "Submit strategy", + "queue-processing-strategy": "Processing strategy", + "queue-configuration": "Queue configuration", + "repository-settings": "Repository settings", + "repository-url": "Repository URL", + "repository-url-required": "Repository URL is required.", + "default-branch": "Default branch name", + "repository-read-only": "Read-only", + "show-merge-commits": "Show merge commits", + "authentication-settings": "Authentication settings", + "auth-method": "Authentication method", + "auth-method-username-password": "Password / access token", + "auth-method-private-key": "Private key", + "password-access-token": "Password / access token", + "change-password-access-token": "Change password / access token", + "private-key": "Private key", + "drop-private-key-file-or": "Drag and drop a private key file or", + "passphrase": "Passphrase", + "enter-passphrase": "Enter passphrase", + "change-passphrase": "Change passphrase", + "check-access": "Check access", + "check-repository-access-success": "Repository access successfully verified!", + "delete-repository-settings-title": "Are you sure you want to delete repository settings?", + "delete-repository-settings-text": "Be careful, after the confirmation the repository settings will be removed and version control feature will be unavailable.", + "auto-commit-settings": "Auto-commit settings", + "auto-commit-entities": "Auto-commit entities", + "no-auto-commit-entities-prompt": "No entities configured for auto-commit", + "delete-auto-commit-settings-title": "Are you sure you want to delete auto-commit settings?", + "delete-auto-commit-settings-text": "Be careful, after the confirmation the auto-commit settings will be removed and auto-commit will be disabled for all entities.", + "2fa": { + "2fa": "Two-factor authentication", + "available-providers": "Available providers", + "issuer-name": "Issuer name", + "issuer-name-required": "Issuer name is required.", + "max-verification-failures-before-user-lockout": "Max verification failures before user lockout", + "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", + "number-of-checking-attempts": "Number of checking attempts", + "number-of-checking-attempts-pattern": "Number of checking attempts must be a positive integer.", + "number-of-checking-attempts-required": "Number of checking attempts is required.", + "number-of-codes": "Number of codes", + "number-of-codes-pattern": "Number of codes must be a positive integer.", + "number-of-codes-required": "Number of codes is required.", + "provider": "Provider", + "retry-verification-code-period": "Retry verification code period (sec)", + "retry-verification-code-period-pattern": "Minimal period time is 5 sec", + "retry-verification-code-period-required": "Retry verification code period is required.", + "total-allowed-time-for-verification": "Total allowed time for verification (sec)", + "total-allowed-time-for-verification-pattern": "Minimal total allowed time is 60 sec", + "total-allowed-time-for-verification-required": "Total allowed time is required.", + "use-system-two-factor-auth-settings": "Use system two factor auth settings", + "verification-code-check-rate-limit": "Verification code check rate limit", + "verification-code-lifetime": "Verification code lifetime (sec)", + "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", + "verification-code-lifetime-required": "Verification code lifetime is required.", + "verification-message-template": "Verification message template", + "verification-limitations": "Verification limitations", + "verification-message-template-pattern": "Verification message need to contains pattern: ${code}", + "verification-message-template-required": "Verification message template is required.", + "within-time": "Within time (sec)", + "within-time-pattern": "Time must be a positive integer.", + "within-time-required": "Time is required." + }, + "jwt": { + "security-settings": "JWT security settings", + "issuer-name": "Issuer name", + "issuer-name-required": "Issuer name is required.", + "signings-key": "Signing key", + "signings-key-hint": "Base64 encoded string representing at least 256 bits of data.", + "signings-key-required": "Signing key is required.", + "signings-key-min-length": "Signing key must be at least 256 bits of data.", + "signings-key-base64": "Signing key must be base64 format.", + "expiration-time": "Token expiration time (sec)", + "expiration-time-required": "Token expiration time is required.", + "expiration-time-pattern": "Token expiration time be a positive integer.", + "expiration-time-min": "Minimum time is 60 seconds (1 minute).", + "refresh-expiration-time": "Refresh token expiration time (sec)", + "refresh-expiration-time-required": "Refresh token expiration time is required.", + "refresh-expiration-time-pattern": "Refresh token expiration time be a positive integer.", + "refresh-expiration-time-min": "Minimum time is 900 seconds (15 minute).", + "refresh-expiration-time-less-token": "Refresh token time must be greater token time.", + "generate-key": "Generate key", + "info-header": "All users will be to re-logined", + "info-message": "Change of the JWT Signing Key will cause all issued tokens to be invalid. All users will need to re-login. This will also affect scripts that use Rest API/Websockets." + } + }, + "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-status-list": "Alarm status list", + "any-status": "Any status", + "search-status": { + "ANY": "Any", + "ACTIVE": "Active", + "CLEARED": "Cleared", + "ACK": "Acknowledged", + "UNACK": "Unacknowledged" + }, + "display-status": { + "ACTIVE_UNACK": "Active Unacknowledged", + "ACTIVE_ACK": "Active Acknowledged", + "CLEARED_UNACK": "Cleared Unacknowledged", + "CLEARED_ACK": "Cleared Acknowledged" + }, + "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", + "alarm-severity-list": "Alarm severity list", + "any-severity": "Any severity", + "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} }?", + "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-alarm-title": "Clear Alarm", + "clear-alarm-text": "Are you sure you want to clear Alarm?", + "alarm-status-filter": "Alarm Status Filter", + "alarm-filter": "Alarm Filter", + "max-count-load": "Maximum number of alarms to load (0 - unlimited)", + "max-count-load-required": "Maximum number of alarms to load is required.", + "max-count-load-error-min": "Minimum value is 0.", + "fetch-size": "Fetch size", + "fetch-size-required": "Fetch size is required.", + "fetch-size-error-min": "Minimum value is 10.", + "alarm-type-list": "Alarm type list", + "any-type": "Any type", + "search-propagated-alarms": "Search propagated alarms" + }, + "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-entity-type": "Entity type", + "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-entity-view-type": "Entity View type", + "filter-type-entity-view-type-description": "Entity Views of type '{{entityViewType}}'", + "filter-type-entity-view-type-and-name-description": "Entity Views of type '{{entityViewType}}' and with name starting with '{{prefix}}'", + "filter-type-edge-type": "Edge type", + "filter-type-edge-type-description": "Edges of type '{{edgeType}}'", + "filter-type-edge-type-and-name-description": "Edges of type '{{edgeType}}' 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}}", + "filter-type-apiUsageState": "Api Usage State", + "filter-type-edge-search-query": "Edge search query", + "filter-type-edge-search-query-description": "Edges with types {{edgeTypes}} 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", + "last-level-relation": "Fetch last level relation only", + "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" + }, + "asset": { + "asset": "Asset", + "assets": "Assets", + "management": "Asset management", + "view-assets": "View Assets", + "add": "Add Asset", + "asset-type-max-length": "Asset type should be less than 256", + "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.", + "name-max-length": "Name should be less than 256", + "label-max-length": "Label should be less than 256", + "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", + "assign-asset-to-edge-title": "Assign Asset(s) To Edge", + "assign-asset-to-edge-text":"Please select the assets to assign to the edge", + "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.", + "unassign-assets-from-edge": "Unassign assets from edge", + "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 expression", + "help-text": "Use '%' according to need: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "import": "Import assets", + "asset-file": "Asset file", + "label": "Label", + "search": "Search assets", + "assign-asset-to-edge": "Assign Asset(s) To Edge", + "unassign-asset-from-edge": "Unassign asset", + "unassign-asset-from-edge-title": "Are you sure you want to unassign the asset '{{assetName}}'?", + "unassign-asset-from-edge-text": "After the confirmation the asset will be unassigned and won't be accessible by the edge.", + "unassign-assets-from-edge-title": "Are you sure you want to unassign { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-from-edge-text": "After the confirmation all selected assets will be unassigned and won't be accessible by the edge.", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } selected" + }, + "attribute": { + "attributes": "Attributes", + "latest-telemetry": "Latest telemetry", + "attributes-scope": "Entity attributes scope", + "scope-latest-telemetry": "Latest telemetry", + "scope-client": "Client attributes", + "scope-server": "Server attributes", + "scope-shared": "Shared attributes", + "add": "Add attribute", + "key": "Key", + "key-max-length": "Key should be less than 256", + "last-update-time": "Last update time", + "key-required": "Attribute key is required.", + "value": "Value", + "value-required": "Attribute value is required.", + "delete-attributes-title": "Are you sure you want to delete { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Be careful, after the confirmation all selected attributes will be removed.", + "delete-attributes": "Delete attributes", + "enter-attribute-value": "Enter attribute value", + "show-on-widget": "Show on widget", + "widget-mode": "Widget mode", + "next-widget": "Next widget", + "prev-widget": "Previous widget", + "add-to-dashboard": "Add to dashboard", + "add-widget-to-dashboard": "Add widget to dashboard", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } selected", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } selected", + "no-attributes-text": "No attributes found", + "no-telemetry-text": "No telemetry found" + }, + "api-usage": { + "api-usage": "Api Usage", + "alarm": "Alarm", + "alarms-created": "Alarms created", + "alarms-created-daily-activity": "Alarms created daily activity", + "alarms-created-hourly-activity": "Alarms created hourly activity", + "alarms-created-monthly-activity": "Alarms created monthly activity", + "data-points": "Data points", + "data-points-storage-days": "Data points storage days", + "email": "Email", + "email-messages": "Email messages", + "email-messages-daily-activity": "Email messages daily activity", + "email-messages-monthly-activity": "Email messages monthly activity", + "exceptions": "Exceptions", + "executions": "Executions", + "javascript": "JavaScript", + "javascript-executions": "JavaScript executions", + "javascript-functions": "JavaScript functions", + "javascript-functions-daily-activity": "JavaScript functions daily activity", + "javascript-functions-hourly-activity": "JavaScript functions hourly activity", + "javascript-functions-monthly-activity": "JavaScript functions monthly activity", + "latest-error": "Latest Error", + "messages": "Messages", + "notifications": "Notifications", + "notifications-email-sms": "Notifications (Email/SMS)", + "notifications-hourly-activity": "Notifications hourly activity", + "permanent-failures": "${entityName} Permanent Failures", + "permanent-timeouts": "${entityName} Permanent Timeouts", + "processing-failures": "${entityName} Processing Failures", + "processing-failures-and-timeouts": "Processing Failures and Timeouts", + "processing-timeouts": "${entityName} Processing Timeouts", + "queue-stats": "Queue Stats", + "rule-chain": "Rule Chain", + "rule-engine": "Rule Engine", + "rule-engine-daily-activity": "Rule Engine daily activity", + "rule-engine-executions": "Rule Engine executions", + "rule-engine-hourly-activity": "Rule Engine hourly activity", + "rule-engine-monthly-activity": "Rule Engine monthly activity", + "rule-engine-statistics": "Rule Engine Statistics", + "rule-node": "Rule Node", + "sms": "SMS", + "sms-messages": "SMS messages", + "sms-messages-daily-activity": "SMS messages daily activity", + "sms-messages-monthly-activity": "SMS messages monthly activity", + "successful": "${entityName} Successful", + "telemetry": "Telemetry", + "telemetry-persistence": "Telemetry persistence", + "telemetry-persistence-daily-activity": "Telemetry persistence daily activity", + "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", + "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", + "transport": "Transport", + "transport-daily-activity": "Transport daily activity", + "transport-data-points": "Transport data points", + "transport-hourly-activity": "Transport hourly activity", + "transport-messages": "Transport messages", + "transport-monthly-activity": "Transport monthly activity", + "view-details": "View details", + "view-statistics": "View statistics" + }, + "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-assigned-to-edge": "Assigned to Edge", + "type-unassigned-from-edge": "Unassigned from Edge", + "type-activated": "Activated", + "type-suspended": "Suspended", + "type-credentials-read": "Credentials read", + "type-attributes-read": "Attributes read", + "type-relation-add-or-update": "Relation updated", + "type-relation-delete": "Relation deleted", + "type-relations-delete": "All relation deleted", + "type-alarm-ack": "Acknowledged", + "type-alarm-clear": "Cleared", + "type-login": "Login", + "type-logout": "Logout", + "type-lockout": "Lockout", + "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", + "type-assigned-from-tenant": "Assigned from Tenant", + "type-assigned-to-tenant": "Assigned to Tenant", + "type-provision-success": "Device provisioned", + "type-provision-failure": "Device provisioning was failed", + "type-timeseries-updated": "Telemetry updated", + "type-timeseries-deleted": "Telemetry deleted" + }, + "confirm-on-exit": { + "message": "You have unsaved changes. Are you sure you want to leave this page?", + "html-message": "You have unsaved changes.
    Are you sure you want to leave this page?", + "title": "Unsaved changes" + }, + "contact": { + "country": "Country", + "city": "City", + "state": "State / Province", + "postal-code": "Zip / Postal Code", + "postal-code-invalid": "Invalid Zip / Postal Code format.", + "address": "Address", + "address2": "Address 2", + "phone": "Phone", + "email": "Email", + "no-address": "No address", + "state-max-length": "State length should be less than 256", + "phone-max-length": "Phone number should be less than 256", + "city-max-length": "Specified city should be less than 256" + }, + "common": { + "username": "Username", + "password": "Password", + "enter-username": "Enter username", + "enter-password": "Enter password", + "enter-search": "Enter search", + "created-time": "Created time", + "loading": "Loading...", + "proceed": "Proceed", + "open-details-page": "Open details page" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "customer": { + "customer": "Customer", + "customers": "Customers", + "management": "Customer management", + "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-edges": "Public Edges", + "public-entity-views": "Public Entity Views", + "add": "Add Customer", + "delete": "Delete customer", + "manage-customer-users": "Manage customer users", + "manage-customer-devices": "Manage customer devices", + "manage-customer-dashboards": "Manage customer dashboards", + "manage-public-devices": "Manage public devices", + "manage-public-dashboards": "Manage public dashboards", + "manage-customer-assets": "Manage customer assets", + "manage-public-assets": "Manage public assets", + "manage-customer-edges": "Manage customer edges", + "manage-public-edges": "Manage customer edges", + "add-customer-text": "Add new customer", + "no-customers-text": "No customers found", + "customer-details": "Customer details", + "delete-customer-title": "Are you sure you want to delete the customer '{{customerTitle}}'?", + "delete-customer-text": "Be careful, after the confirmation the customer and all related data will become unrecoverable.", + "delete-customers-title": "Are you sure you want to delete { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Delete { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Be careful, after the confirmation all selected customers will be removed and all related data will become unrecoverable.", + "manage-users": "Manage users", + "manage-assets": "Manage assets", + "manage-devices": "Manage devices", + "manage-dashboards": "Manage dashboards", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "description": "Description", + "details": "Details", + "events": "Events", + "copyId": "Copy customer Id", + "idCopiedMessage": "Customer Id has been copied to clipboard", + "select-customer": "Select customer", + "no-customers-matching": "No customers matching '{{entity}}' were found.", + "customer-required": "Customer is required", + "select-default-customer": "Select default customer", + "default-customer": "Default customer", + "default-customer-required": "Default customer is required in order to debug dashboard on Tenant level", + "search": "Search customers", + "selected-customers": "{ count, plural, 1 {1 customer} other {# customers} } selected", + "edges": "Customer edge instances", + "manage-edges": "Manage edges" + }, + "datetime": { + "date-from": "Date from", + "time-from": "Time from", + "date-to": "Date to", + "time-to": "Time to" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Dashboard management", + "view-dashboards": "View Dashboards", + "add": "Add Dashboard", + "assign-dashboard-to-customer": "Assign Dashboard(s) To Customer", + "assign-dashboard-to-customer-text": "Please select the dashboards to assign to the customer", + "assign-dashboard-to-edge-title": "Assign Dashboard(s) To Edge", + "assign-to-customer-text": "Please select the customer to assign the dashboard(s)", + "assign-to-customer": "Assign to customer", + "unassign-from-customer": "Unassign from customer", + "make-public": "Make dashboard public", + "make-private": "Make dashboard private", + "manage-assigned-customers": "Manage assigned customers", + "assigned-customers": "Assigned customers", + "assign-to-customers": "Assign Dashboard(s) To Customers", + "assign-to-customers-text": "Please select the customers to assign the dashboard(s)", + "unassign-from-customers": "Unassign Dashboard(s) From Customers", + "unassign-from-customers-text": "Please select the customers to unassign from the dashboard(s)", + "no-dashboards-text": "No dashboards found", + "no-widgets": "No widgets configured", + "add-widget": "Add new widget", + "title": "Title", + "image": "Dashboard image", + "mobile-app-settings": "Mobile application settings", + "mobile-order": "Dashboard order in mobile application", + "mobile-hide": "Hide dashboard in mobile application", + "update-image": "Update dashboard image", + "take-screenshot": "Take screenshot", + "select-widget-title": "Select widget", + "select-widget-value": "{{title}}: select widget", + "select-widget-subtitle": "List of available widget types", + "delete": "Delete dashboard", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "description": "Description", + "details": "Details", + "dashboard-details": "Dashboard details", + "add-dashboard-text": "Add new dashboard", + "assign-dashboards": "Assign dashboards", + "assign-new-dashboard": "Assign new dashboard", + "assign-dashboards-text": "Assign { count, plural, 1 {1 dashboard} other {# dashboards} } to customers", + "unassign-dashboards-action-text": "Unassign { count, plural, 1 {1 dashboard} other {# dashboards} } from customers", + "delete-dashboards": "Delete dashboards", + "unassign-dashboards": "Unassign dashboards", + "unassign-dashboards-action-title": "Unassign { count, plural, 1 {1 dashboard} other {# dashboards} } from customer", + "delete-dashboard-title": "Are you sure you want to delete the dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Be careful, after the confirmation the dashboard and all related data will become unrecoverable.", + "delete-dashboards-title": "Are you sure you want to delete { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Delete { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Be careful, after the confirmation all selected dashboards will be removed and all related data will become unrecoverable.", + "unassign-dashboard-title": "Are you sure you want to unassign the dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "After the confirmation the dashboard will be unassigned and won't be accessible by the customer.", + "unassign-dashboard": "Unassign dashboard", + "unassign-dashboards-title": "Are you sure you want to unassign { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "After the confirmation all selected dashboards will be unassigned and won't be accessible by the customer.", + "public-dashboard-title": "Dashboard is now public", + "public-dashboard-text": "Your dashboard {{dashboardTitle}} is now public and accessible via next public link:", + "public-dashboard-notice": "Note: Do not forget to make related devices public in order to access their data.", + "make-private-dashboard-title": "Are you sure you want to make the dashboard '{{dashboardTitle}}' private?", + "make-private-dashboard-text": "After the confirmation the dashboard will be made private and won't be accessible by others.", + "make-private-dashboard": "Make dashboard private", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Select dashboard", + "no-dashboards-matching": "No dashboards matching '{{entity}}' were found.", + "dashboard-required": "Dashboard is required.", + "select-existing": "Select existing dashboard", + "create-new": "Create new dashboard", + "new-dashboard-title": "New dashboard title", + "open-dashboard": "Open dashboard", + "set-background": "Set background", + "background-color": "Background color", + "background-image": "Background image", + "background-size-mode": "Background size mode", + "no-image": "No image selected", + "empty-image": "No image", + "drop-image": "Drop an image or click to select a file to upload.", + "maximum-upload-file-size": "Maximum upload file size: {{ size }}", + "cannot-upload-file": "Cannot upload file", + "settings": "Settings", + "layout-settings": "Layout settings", + "columns-count": "Columns count", + "columns-count-required": "Columns count is required.", + "min-columns-count-message": "Only 10 minimum column count is allowed.", + "max-columns-count-message": "Only 1000 maximum column count is allowed.", + "widgets-margins": "Margin between widgets", + "margin-required": "Margin value is required.", + "min-margin-message": "Only 0 is allowed as minimum margin value.", + "max-margin-message": "Only 50 is allowed as maximum margin value.", + "horizontal-margin": "Horizontal margin", + "horizontal-margin-required": "Horizontal margin value is required.", + "min-horizontal-margin-message": "Only 0 is allowed as minimum horizontal margin value.", + "max-horizontal-margin-message": "Only 50 is allowed as maximum horizontal margin value.", + "vertical-margin": "Vertical margin", + "vertical-margin-required": "Vertical margin value is required.", + "min-vertical-margin-message": "Only 0 is allowed as minimum vertical margin value.", + "max-vertical-margin-message": "Only 50 is allowed as maximum vertical margin value.", + "autofill-height": "Auto fill layout height", + "mobile-layout": "Mobile layout settings", + "mobile-row-height": "Mobile row height, px", + "mobile-row-height-required": "Mobile row height value is required.", + "min-mobile-row-height-message": "Only 5 pixels is allowed as minimum mobile row height value.", + "max-mobile-row-height-message": "Only 200 pixels is allowed as maximum mobile row height value.", + "title-settings": "Title settings", + "display-title": "Display dashboard title", + "title-color": "Title color", + "toolbar-settings": "Toolbar settings", + "hide-toolbar": "Hide toolbar", + "toolbar-always-open": "Keep toolbar opened", + "display-dashboards-selection": "Display dashboards selection", + "display-entities-selection": "Display entities selection", + "display-filters": "Display filters", + "display-dashboard-timewindow": "Display timewindow", + "display-dashboard-export": "Display export", + "display-update-dashboard-image": "Display update dashboard image", + "dashboard-logo-settings": "Dashboard logo settings", + "display-dashboard-logo": "Display logo in dashboard fullscreen mode", + "dashboard-logo-image": "Dashboard logo image", + "advanced-settings": "Advanced settings", + "dashboard-css": "Dashboard CSS", + "import": "Import dashboard", + "export": "Export dashboard", + "export-failed-error": "Unable to export dashboard: {{error}}", + "create-new-dashboard": "Create new dashboard", + "dashboard-file": "Dashboard file", + "invalid-dashboard-file-error": "Unable to import dashboard: Invalid dashboard data structure.", + "dashboard-import-missing-aliases-title": "Configure aliases used by imported dashboard", + "create-new-widget": "Create new widget", + "import-widget": "Import widget", + "widget-file": "Widget file", + "invalid-widget-file-error": "Unable to import widget: Invalid widget data structure.", + "widget-import-missing-aliases-title": "Configure aliases used by imported widget", + "open-toolbar": "Open dashboard toolbar", + "close-toolbar": "Close toolbar", + "configuration-error": "Configuration error", + "alias-resolution-error-title": "Dashboard aliases configuration error", + "invalid-aliases-config": "Unable to find any devices matching to some of the aliases filter.
    Please contact your administrator in order to resolve this issue.", + "select-devices": "Select devices", + "assignedToCustomer": "Assigned to customer", + "assignedToCustomers": "Assigned to customers", + "public": "Public", + "copyId": "Copy dashboard id", + "idCopiedMessage": "Dashboard Id has been copied to clipboard", + "public-link": "Public link", + "copy-public-link": "Copy public link", + "public-link-copied-message": "Dashboard public link has been copied to clipboard", + "manage-states": "Manage dashboard states", + "states": "Dashboard states", + "search-states": "Search dashboard states", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } selected", + "edit-state": "Edit dashboard state", + "delete-state": "Delete dashboard state", + "add-state": "Add dashboard state", + "no-states-text": "No states found", + "state": "Dashboard state", + "state-name": "Name", + "state-name-required": "Dashboard state name is required.", + "state-id": "State Id", + "state-id-required": "Dashboard state id is required.", + "state-id-exists": "Dashboard state with the same id is already exists.", + "is-root-state": "Root state", + "delete-state-title": "Delete dashboard state", + "delete-state-text": "Are you sure you want delete dashboard state with name '{{stateName}}'?", + "show-details": "Show details", + "hide-details": "Hide details", + "select-state": "Select target state", + "state-controller": "State controller", + "search": "Search dashboards", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } selected", + "home-dashboard": "Home dashboard", + "home-dashboard-hide-toolbar": "Hide home dashboard toolbar", + "unassign-dashboard-from-edge-text": "After the confirmation the dashboard will be unassigned and won't be accessible by the edge.", + "unassign-dashboards-from-edge-title": "Are you sure you want to unassign { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-from-edge-text": "After the confirmation all selected dashboards will be unassigned and won't be accessible by the edge.", + "assign-dashboard-to-edge": "Assign Dashboard(s) To Edge", + "assign-dashboard-to-edge-text": "Please select the dashboards to assign to the edge", + "non-existent-dashboard-state-error": "Dashboard state with id \"{{ stateId }}\" is not found" + }, + "datakey": { + "settings": "Settings", + "advanced": "Advanced", + "label": "Label", + "color": "Color", + "units": "Special symbol to show next to value", + "decimals": "Number of digits after floating point", + "data-generation-func": "Data generation function", + "use-data-post-processing-func": "Use data post-processing function", + "configuration": "Data key configuration", + "timeseries": "Timeseries", + "attributes": "Attributes", + "entity-field": "Entity field", + "alarm": "Alarm fields", + "timeseries-required": "Entity timeseries are required.", + "timeseries-or-attributes-required": "Entity timeseries/attributes are required.", + "alarm-fields-timeseries-or-attributes-required": "Alarm fields or entity timeseries/attributes are required.", + "maximum-timeseries-or-attributes": "Maximum { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "Alarm fields are required.", + "function-types": "Function types", + "function-type": "Function type", + "function-types-required": "Function types are required.", + "alarm-keys": "Alarm data keys", + "alarm-key": "Alarm data key", + "alarm-key-functions": "Alarm key functions", + "alarm-key-function": "Alarm key function", + "latest-keys": "Latest data keys", + "latest-key": "Latest data key", + "latest-key-functions": "Latest key functions", + "latest-key-function": "Latest key function", + "timeseries-keys": "Timeseries data keys", + "timeseries-key": "Timeseries data key", + "timeseries-key-functions": "Timeseries key functions", + "timeseries-key-function": "Timeseries key function", + "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;", + "aggregation": "Aggregation", + "aggregation-type-hint-common": "For performance reasons, the aggregated values calculation is available only for fixed time intervals like \"current day\", \"current month\", etc, and is not available for sliding window intervals like 'last 30 minutes' or 'last 24 hours'.", + "aggregation-type-none-hint": "Take latest value.", + "aggregation-type-min-hint": "Find minimum value among data points within a selected time window.", + "aggregation-type-max-hint": "Find maximum value among data points within a selected time window.", + "aggregation-type-avg-hint": "Calculate an average value among data points within a selected time window.", + "aggregation-type-sum-hint": "Sum all values of the data points within a selected time window.", + "aggregation-type-count-hint": "Total number of the data points within a selected time window.", + "delta-calculation": "Delta calculation", + "enable-delta-calculation": "Enable delta calculation", + "enable-delta-calculation-hint": "When enabled, the data key value is calculated based on the aggregated values for a selected time window and a specified comparison period. For performance reasons, the delta calculation is available only for history time windows and not for real-time values. For example, you may calculate the delta between the energy consumption for yesterday compared to the energy consumption for the day before yesterday.", + "delta-calculation-result": "Delta calculation result", + "delta-calculation-result-previous-value": "Previous value", + "delta-calculation-result-delta-absolute": "Delta (absolute)", + "delta-calculation-result-delta-percent": "Delta (percent)" + }, + "datasource": { + "type": "Datasource type", + "name": "Name", + "label": "Label", + "add-datasource-prompt": "Please add datasource" + }, + "details": { + "details": "Details", + "edit-mode": "Edit mode", + "edit-json": "Edit JSON", + "toggle-edit-mode": "Toggle edit mode" + }, + "device": { + "device": "Device", + "device-required": "Device is required.", + "devices": "Devices", + "management": "Device management", + "view-devices": "View Devices", + "device-alias": "Device alias", + "device-type-max-length": "Device type should be less than 256", + "aliases": "Device 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}}'.
    Device aliases must be unique whithin the dashboard.", + "configure-alias": "Configure '{{alias}}' alias", + "no-devices-matching": "No devices matching '{{entity}}' were found.", + "alias": "Alias", + "alias-required": "Device alias is required.", + "remove-alias": "Remove device alias", + "add-alias": "Add device alias", + "name-starts-with": "Device name expression", + "help-text": "Use '%' according to need: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "device-list": "Device list", + "use-device-name-filter": "Use filter", + "device-list-empty": "No devices selected.", + "device-name-filter-required": "Device name filter is required.", + "device-name-filter-no-device-matched": "No devices starting with '{{device}}' were found.", + "add": "Add Device", + "assign-to-customer": "Assign to customer", + "assign-device-to-customer": "Assign Device(s) To Customer", + "assign-device-to-customer-text": "Please select the devices to assign to the customer", + "assign-device-to-edge-title": "Assign Device(s) To Edge", + "assign-device-to-edge-text":"Please select the devices to assign to the edge", + "make-public": "Make device public", + "make-private": "Make device private", + "no-devices-text": "No devices found", + "assign-to-customer-text": "Please select the customer to assign the device(s)", + "device-details": "Device details", + "add-device-text": "Add new device", + "credentials": "Credentials", + "manage-credentials": "Manage credentials", + "delete": "Delete device", + "assign-devices": "Assign devices", + "assign-devices-text": "Assign { count, plural, 1 {1 device} other {# devices} } to customer", + "delete-devices": "Delete devices", + "unassign-from-customer": "Unassign from customer", + "unassign-devices": "Unassign devices", + "unassign-devices-action-title": "Unassign { count, plural, 1 {1 device} other {# devices} } from customer", + "unassign-device-from-edge-title": "Are you sure you want to unassign the device '{{deviceName}}'?", + "unassign-device-from-edge-text": "After the confirmation the device will be unassigned and won't be accessible by the edge.", + "unassign-devices-from-edge": "Unassign devices from edge", + "assign-new-device": "Assign new device", + "make-public-device-title": "Are you sure you want to make the device '{{deviceName}}' public?", + "make-public-device-text": "After the confirmation the device and all its data will be made public and accessible by others.", + "make-private-device-title": "Are you sure you want to make the device '{{deviceName}}' private?", + "make-private-device-text": "After the confirmation the device and all its data will be made private and won't be accessible by others.", + "view-credentials": "View credentials", + "delete-device-title": "Are you sure you want to delete the device '{{deviceName}}'?", + "delete-device-text": "Be careful, after the confirmation the device and all related data will become unrecoverable.", + "delete-devices-title": "Are you sure you want to delete { count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "Delete { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Be careful, after the confirmation all selected devices will be removed and all related data will become unrecoverable.", + "unassign-device-title": "Are you sure you want to unassign the device '{{deviceName}}'?", + "unassign-device-text": "After the confirmation the device will be unassigned and won't be accessible by the customer.", + "unassign-device": "Unassign device", + "unassign-devices-title": "Are you sure you want to unassign { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "After the confirmation all selected devices will be unassigned and won't be accessible by the customer.", + "device-credentials": "Device Credentials", + "loading-device-credentials": "Loading device credentials...", + "credentials-type": "Credentials type", + "access-token": "Access token", + "access-token-required": "Access token is required.", + "access-token-invalid": "Access token length must be from 1 to 32 characters.", + "certificate-pem-format": "Certificate in PEM format", + "certificate-pem-format-required": "Certificate is required.", + "lwm2m-security-config": { + "identity": "Client Identity", + "identity-required": "Client Identity is required.", + "identity-tooltip": "The PSK identifier is an arbitrary PSK identifier up to 128 bytes, as described in the standard [RFC7925].\nThe PSK identifier MUST first be converted to a character string and then encoded into octets using UTF-8.", + "client-key": "Client Key", + "client-key-required": "Client Key is required.", + "client-key-tooltip-prk": "RPK public key or id must be in the standard [RFC7250] and encoded to Base64 format!", + "client-key-tooltip-psk": "PSK key must be in the standard [RFC4279] and HexDec format: 32, 64, 128 characters!", + "endpoint": "Endpoint Client Name", + "endpoint-required": "Endpoint Client Name is required.", + "client-public-key": "Client public key", + "client-public-key-hint": "If client public key is empty, the trusted certificate will be used", + "client-public-key-tooltip": "X509 public key must be in DER-encoded X509v3 format and support exclusively EC algorithm and then encoded to Base64 format!", + "mode": "Security config mode", + "client-tab": "Client Security Config", + "client-certificate": "Client certificate", + "bootstrap-tab": "Bootstrap Client", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "client-publicKey-or-id": "Client Public Key or Id", + "client-publicKey-or-id-required": "Client Public Key or Id is required.", + "client-publicKey-or-id-tooltip-psk": "The PSK identifier is an arbitrary PSK identifier up to 128 bytes, as described in the standard [RFC7925].\nThe PSK identifier MUST first be converted to a character string and then encoded into octets using UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "RPK public key or id must be in the standard [RFC7250] and encoded to Base64 format!", + "client-publicKey-or-id-tooltip-x509": "X509 public key must be in DER-encoded X509v3 format and support exclusively EC algorithm and then encoded to Base64 format", + "client-secret-key": "Client Secret Key", + "client-secret-key-required": "Client Secret Key is required.", + "client-secret-key-tooltip-psk": "PSK key must be in the standard [RFC4279] and HexDec format: 32, 64, 128 characters!", + "client-secret-key-tooltip-prk": "RPK secret key must be in PKCS_8 format (DER encoding, standard [RFC5958]) and then encoded to Base64 format!", + "client-secret-key-tooltip-x509": "X509 secret key must be in PKCS_8 format (DER encoding, standard [RFC5958]) and then encoded to Base64 format!" + }, + "client-id": "Client ID", + "client-id-pattern": "Contains invalid character.", + "user-name": "User Name", + "user-name-required": "User Name is required.", + "client-id-or-user-name-necessary": "Client ID and/or User Name are necessary", + "password": "Password", + "secret": "Secret", + "secret-required": "Secret is required.", + "device-type": "Device type", + "device-type-required": "Device type is required.", + "select-device-type": "Select device type", + "enter-device-type": "Enter device type", + "any-device": "Any device", + "no-device-types-matching": "No device types matching '{{entitySubtype}}' were found.", + "device-type-list-empty": "No device types selected.", + "device-types": "Device types", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "label-max-length": "Label should be less than 256", + "description": "Description", + "label": "Label", + "events": "Events", + "details": "Details", + "copyId": "Copy device Id", + "copyAccessToken": "Copy access token", + "copy-mqtt-authentication": "Copy MQTT credentials", + "idCopiedMessage": "Device Id has been copied to clipboard", + "accessTokenCopiedMessage": "Device access token has been copied to clipboard", + "mqtt-authentication-copied-message": "Device MQTT authentication has been copied to clipboard", + "assignedToCustomer": "Assigned to customer", + "unable-delete-device-alias-title": "Unable to delete device alias", + "unable-delete-device-alias-text": "Device alias '{{deviceAlias}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "is-gateway": "Is gateway", + "overwrite-activity-time": "Overwrite activity time for connected device", + "public": "Public", + "device-public": "Device is public", + "select-device": "Select device", + "import": "Import device", + "device-file": "Device file", + "search": "Search devices", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } selected", + "device-configuration": "Device configuration", + "transport-configuration": "Transport configuration", + "wizard": { + "device-wizard": "Device Wizard", + "device-details": "Device details", + "new-device-profile": "Create new device profile", + "existing-device-profile": "Select existing device profile", + "specific-configuration": "Specific configuration", + "customer-to-assign-device": "Customer to assign the device", + "add-credentials": "Add credentials" + }, + "unassign-devices-from-edge-title": "Are you sure you want to unassign { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-from-edge-text": "After the confirmation all selected devices will be unassigned and won't be accessible by the edge." + }, + "asset-profile": { + "asset-profile": "Asset profile", + "asset-profiles": "Asset profiles", + "all-asset-profiles": "All", + "add": "Add asset profile", + "edit": "Edit asset profile", + "asset-profile-details": "Asset profile details", + "no-asset-profiles-text": "No asset profiles found", + "search": "Search asset profiles", + "selected-asset-profiles": "{ count, plural, 1 {1 asset profile} other {# asset profiles} } selected", + "no-asset-profiles-matching": "No asset profile matching '{{entity}}' were found.", + "asset-profile-required": "Asset profile is required", + "idCopiedMessage": "Asset profile Id has been copied to clipboard", + "set-default": "Make asset profile default", + "delete": "Delete asset profile", + "copyId": "Copy asset profile Id", + "name-max-length": "Name should be less than 256", + "new-device-profile-name": "Asset profile name", + "new-device-profile-name-required": "Asset profile name is required.", + "name": "Name", + "name-required": "Name is required.", + "image": "Asset profile image", + "description": "Description", + "default": "Default", + "default-rule-chain": "Default rule chain", + "mobile-dashboard": "Mobile dashboard", + "mobile-dashboard-hint": "Used by mobile application as a asset details dashboard", + "select-queue-hint": "Select from a drop-down list.", + "delete-asset-profile-title": "Are you sure you want to delete the asset profile '{{assetProfileName}}'?", + "delete-asset-profile-text": "Be careful, after the confirmation the asset profile and all related data will become unrecoverable.", + "delete-asset-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 asset profile} other {# asset profiles} }?", + "delete-asset-profiles-text": "Be careful, after the confirmation all selected asset profiles will be removed and all related data will become unrecoverable.", + "set-default-asset-profile-title": "Are you sure you want to make the asset profile '{{assetProfileName}}' default?", + "set-default-asset-profile-text": "After the confirmation the asset profile will be marked as default and will be used for new assets with no profile specified.", + "no-asset-profiles-found": "No asset profiles found.", + "create-new-asset-profile": "Create a new one!", + "create-asset-profile": "Create new asset profile", + "import": "Import asset profile", + "export": "Export asset profile", + "export-failed-error": "Unable to export asset profile: {{error}}", + "asset-profile-file": "Asset profile file", + "invalid-asset-profile-file-error": "Unable to import asset profile: Invalid asset profile data structure." + }, + "device-profile": { + "device-profile": "Device profile", + "device-profiles": "Device profiles", + "all-device-profiles": "All", + "add": "Add device profile", + "edit": "Edit device profile", + "device-profile-details": "Device profile details", + "no-device-profiles-text": "No device profiles found", + "search": "Search device profiles", + "selected-device-profiles": "{ count, plural, 1 {1 device profile} other {# device profiles} } selected", + "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", + "device-profile-required": "Device profile is required", + "idCopiedMessage": "Device profile Id has been copied to clipboard", + "set-default": "Make device profile default", + "delete": "Delete device profile", + "copyId": "Copy device profile Id", + "name-max-length": "Name should be less than 256", + "new-device-profile-name": "Device profile name", + "new-device-profile-name-required": "Device profile name is required.", + "name": "Name", + "name-required": "Name is required.", + "type": "Profile type", + "type-required": "Profile type is required.", + "type-default": "Default", + "image": "Device profile image", + "transport-type": "Transport type", + "transport-type-required": "Transport type is required.", + "transport-type-default": "Default", + "transport-type-default-hint": "Supports basic MQTT, HTTP and CoAP transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Enables advanced MQTT transport settings", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Enables advanced CoAP transport settings", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M transport type", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Specify SNMP transport configuration", + "description": "Description", + "default": "Default", + "profile-configuration": "Profile configuration", + "transport-configuration": "Transport configuration", + "default-rule-chain": "Default rule chain", + "mobile-dashboard": "Mobile dashboard", + "mobile-dashboard-hint": "Used by mobile application as a device details dashboard", + "select-queue-hint": "Select from a drop-down list.", + "delete-device-profile-title": "Are you sure you want to delete the device profile '{{deviceProfileName}}'?", + "delete-device-profile-text": "Be careful, after the confirmation the device profile and all related data including associated OTA updates will become unrecoverable.", + "delete-device-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 device profile} other {# device profiles} }?", + "delete-device-profiles-text": "Be careful, after the confirmation all selected device profiles will be removed and all related data including associated OTA updates will become unrecoverable.", + "set-default-device-profile-title": "Are you sure you want to make the device profile '{{deviceProfileName}}' default?", + "set-default-device-profile-text": "After the confirmation the device profile will be marked as default and will be used for new devices with no profile specified.", + "no-device-profiles-found": "No device profiles found.", + "create-new-device-profile": "Create a new one!", + "mqtt-device-topic-filters": "MQTT device topic filters", + "mqtt-device-topic-filters-unique": "MQTT device topic filters need to be unique.", + "mqtt-device-payload-type": "MQTT device payload", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Enable compatibility with other payload formats.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "When enabled, the platform will use a Protobuf payload format by default. If parsing fails, the platform will attempt to use JSON payload format. Useful for backward compatibility during firmware updates. For example, the initial release of the firmware uses Json, while the new release uses Protobuf. During the process of firmware update for the fleet of devices, it is required to support both Protobuf and JSON simultaneously. The compatibility mode introduces slight performance degradation, so it is recommended to disable this mode once all devices are updated.", + "mqtt-use-json-format-for-default-downlink-topics": "Use Json format for default downlink topics", + "mqtt-use-json-format-for-default-downlink-topics-hint": "When enabled, the platform will use Json payload format to push attributes and RPC via the following topics: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. This setting does not impact attribute and rpc subscriptions sent using new (v2) topics: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Where $request_id is an integer request identifier.", + "mqtt-send-ack-on-validation-exception": "Send PUBACK on PUBLISH message validation failure", + "mqtt-send-ack-on-validation-exception-hint": "By default, the platform will close the MQTT session on message validation failure. When enabled, the platform will send publish acknowledgment instead of closing the session.", + "snmp-add-mapping": "Add SNMP mapping", + "snmp-mapping-not-configured": "No mapping for OID to timeseries/telemetry configured", + "snmp-timseries-or-attribute-name": "Timeseries/attribute name for mapping", + "snmp-timseries-or-attribute-type": "Timeseries/attribute type for mapping", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payload type is required.", + "coap-device-type": "CoAP device type", + "coap-device-payload-type": "CoAP device payload", + "coap-device-type-required": "CoAP device type is required.", + "coap-device-type-default": "Default", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Single [+] and multi-level [#] wildcards supported.", + "telemetry-topic-filter": "Telemetry topic filter", + "telemetry-topic-filter-required": "Telemetry topic filter is required.", + "attributes-topic-filter": "Attributes topic filter", + "attributes-topic-filter-required": "Attributes topic filter is required.", + "telemetry-proto-schema": "Telemetry proto schema", + "telemetry-proto-schema-required": "Telemetry proto schema is required.", + "attributes-proto-schema": "Attributes proto schema", + "attributes-proto-schema-required": "Attributes proto schema is required.", + "rpc-response-proto-schema": "RPC response proto schema", + "rpc-response-proto-schema-required": "RPC response proto schema is required.", + "rpc-response-topic-filter": "RPC response topic filter", + "rpc-response-topic-filter-required": "RPC response topic filter is required.", + "rpc-request-proto-schema": "RPC request proto schema", + "rpc-request-proto-schema-required": "RPC request proto schema is required.", + "rpc-request-proto-schema-hint": "RPC request message should always have fields: string method = 1; int32 requestId = 2; and params = 3 of any data type.", + "not-valid-pattern-topic-filter": "Not valid pattern topic filter", + "not-valid-single-character": "Invalid use of a single-level wildcard character", + "not-valid-multi-character": "Invalid use of a multi-level wildcard character", + "single-level-wildcards-hint": "[+] is suitable for any topic filter level. Ex.: v1/devices/+/telemetry or +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] can replace the topic filter itself and must be the last symbol of the topic. Ex.: # or v1/devices/me/#.", + "alarm-rules": "Alarm rules", + "alarm-rules-with-count": "Alarm rules ({{count}})", + "no-alarm-rules": "No alarm rules configured", + "add-alarm-rule": "Add alarm rule", + "edit-alarm-rule": "Edit alarm rule", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-unique": "Alarm type must be unique within the device profile alarm rules.", + "alarm-type-max-length": "Alarm type should be less than 256", + "create-alarm-pattern": "Create {{alarmType}} alarm", + "create-alarm-rules": "Create alarm rules", + "no-create-alarm-rules": "No create conditions configured", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "select-alarm-severity": "Select alarm severity", + "alarm-severity-required": "Alarm severity is required.", + "condition-duration": "Condition duration", + "condition-duration-value": "Duration value", + "condition-duration-time-unit": "Time unit", + "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", + "condition-duration-value-pattern": "Duration value should be integers.", + "condition-duration-value-required": "Duration value is required.", + "condition-duration-time-unit-required": "Time unit is required.", + "advanced-settings": "Advanced settings", + "alarm-rule-details": "Details", + "alarm-rule-details-hint": "Hint: use ${keyName} to substitute values of the attribute or telemetry keys that are used in alarm rule condition.", + "add-alarm-rule-details": "Add details", + "alarm-rule-mobile-dashboard": "Mobile dashboard", + "alarm-rule-mobile-dashboard-hint": "Used by mobile application as an alarm details dashboard", + "alarm-rule-no-mobile-dashboard": "No dashboard selected", + "propagate-alarm": "Propagate alarm to related entities", + "alarm-rule-relation-types-list": "Relation types to propagate", + "alarm-rule-relation-types-list-hint": "If Propagate relation types are not selected, alarms will be propagated without filtering by relation type.", + "propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)", + "propagate-alarm-to-tenant": "Propagate alarm to Tenant", + "alarm-details": "Alarm details", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "device-provisioning": "Device provisioning", + "provision-strategy": "Provision strategy", + "provision-strategy-required": "Provision strategy is required.", + "provision-strategy-disabled": "Disabled", + "provision-strategy-created-new": "Allow to create new devices", + "provision-strategy-check-pre-provisioned": "Check for pre-provisioned devices", + "provision-device-key": "Provision device key", + "provision-device-key-required": "Provision device key is required.", + "copy-provision-key": "Copy provision key", + "provision-key-copied-message": "Provision key has been copied to clipboard", + "provision-device-secret": "Provision device secret", + "provision-device-secret-required": "Provision device secret is required.", + "copy-provision-secret": "Copy provision secret", + "provision-secret-copied-message": "Provision secret has been copied to clipboard", + "condition": "Condition", + "condition-type": "Condition type", + "condition-type-simple": "Simple", + "condition-type-duration": "Duration", + "condition-during": "During {{during}}", + "condition-during-dynamic": "During \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Repeating", + "condition-type-required": "Condition type is required.", + "condition-repeating-value": "Count of events", + "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", + "condition-repeating-value-pattern": "Count of events should be integers.", + "condition-repeating-value-required": "Count of events is required.", + "condition-repeat-times": "Repeats { count, plural, 1 {1 time} other {# times} }", + "condition-repeat-times-dynamic": "Repeats \"{ attribute }\" ({ count, plural, 1 {1 time} other {# times} })", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-any-time": "Active all the time", + "schedule-specific-time": "Active at a specific time", + "schedule-custom": "Custom", + "schedule-day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "schedule-days": "Days", + "schedule-time": "Time", + "schedule-time-from": "From", + "schedule-time-to": "To", + "schedule-days-of-week-required": "At least one day of week should be selected.", + "create-device-profile": "Create new device profile", + "import": "Import device profile", + "export": "Export device profile", + "export-failed-error": "Unable to export device profile: {{error}}", + "device-profile-file": "Device profile file", + "invalid-device-profile-file-error": "Unable to import device profile: Invalid device profile data structure.", + "power-saving-mode": "Power Saving Mode", + "power-saving-mode-type": { + "default": "Use device profile power saving mode", + "psm": "Power Saving Mode (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "eDRX cycle", + "edrx-cycle-required": "eDRX cycle is required.", + "edrx-cycle-pattern": "eDRX cycle must be a positive integer.", + "edrx-cycle-min": "Minimum number of eDRX cycle is {{ min }} seconds.", + "paging-transmission-window": "Paging Transmission Window", + "paging-transmission-window-required": "Paging transmission window is required.", + "paging-transmission-window-pattern": "Paging transmission window must be a positive integer.", + "paging-transmission-window-min": "Minimum number ofpPaging transmission window is {{ min }} seconds.", + "psm-activity-timer": "PSM Activity Timer", + "psm-activity-timer-required": "PSM activity timer is required.", + "psm-activity-timer-pattern": "PSM activity timer must be a positive integer.", + "psm-activity-timer-min": "Minimum number of PSM activity timer is {{ min }} seconds.", + "lwm2m": { + "object-list": "Object list", + "object-list-empty": "No objects selected.", + "no-objects-found": "No objects found.", + "no-objects-matching": "No objects matching '{{object}}' were found.", + "model-tab": "LWM2M Model", + "add-new-instances": "Add new instances", + "instances-list": "Instances list", + "instances-list-required": "Instances list is required.", + "instance-id-pattern": "Instance id must be a positive integer.", + "instance-id-max": "Maximum instance id value {{max}}.", + "instance": "Instance", + "resource-label": "#ID Resource name", + "observe-label": "Observe", + "attribute-label": "Attribute", + "telemetry-label": "Telemetry", + "edit-observe-select": "To edit observe select telemetry or attribute", + "edit-attributes-select": "To edit attributes select telemetry or attribute", + "no-attributes-set": "No attributes set", + "key-name": "Key name", + "key-name-required": "Key name is required", + "attribute-name": "Name attribute", + "attribute-name-required": "Name attribute is required.", + "attribute-value": "Attribute value", + "attribute-value-required": "Attribute value is required.", + "attribute-value-pattern": "Attribute value must be a positive integer.", + "edit-attributes": "Edit attributes: {{ name }}", + "view-attributes": "View attributes: {{ name }}", + "add-attribute": "Add attribute", + "edit-attribute": "Edit attribute", + "view-attribute": "View attribute", + "remove-attribute": "Remove attribute", + "delete-server-text": "Be careful, after the confirmation the server configuration will become unrecoverable.", + "delete-server-title": "Are you sure you want to delete the server?", + "mode": "Security config mode", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap Server (ShortId...)", + "lwm2m-server-legend": "LwM2M Server (ShortId...)", + "server": "Server", + "short-id": "Short server ID", + "short-id-tooltip": "Server short Id. Used as link to associate server Object Instance.\nThis identifier uniquely identifies each LwM2M Server configured for the LwM2M Client.\nResource MUST be set when the Bootstrap-Server Resource has a value of 'false'.\nThe values ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server.", + "short-id-required": "Short server ID is required.", + "short-id-range": "Short server ID should be in a range from 1 to 65534.", + "short-id-pattern": "Short server ID must be a positive integer.", + "lifetime": "Client registration lifetime", + "lifetime-required": "Client registration lifetime is required.", + "lifetime-pattern": "Client registration lifetime must be a positive integer.", + "default-min-period": "Min period between two notifications (s)", + "default-min-period-tooltip": "The default value the LwM2M Client should use for the Minimum Period of an Observation in the absence of this parameter being included in an Observation.", + "default-min-period-required": "Minimum period is required.", + "default-min-period-pattern": "Minimum period must be a positive integer.", + "notification-storing": "Notification storing when disabled or offline", + "binding": "Binding", + "binding-type": { + "u": "U: Client is reachable via the UDP binding at any time.", + "m": "M: Client is reachable via the MQTT binding at any time.", + "h": "H: Client is reachable via the HTTP binding at any time.", + "t": "T: Client is reachable via the TCP binding at any time.", + "s": "S: Client is reachable via the SMS binding at any time.", + "n": "N: Client MUST send the response to such a request over the Non-IP binding (is supported since LWM2M 1.1).", + "uq": "UQ: UDP connection in queue mode (is not supported since LWM2M 1.1)", + "uqs": "UQS: both UDP and SMS connections active; UDP in queue mode, SMS in standard mode (is not supported since LWM2M 1.1)", + "tq": "TQ: TCP connection in queue mode (is not supported since LWM2M 1.1)", + "tqs": "TQS: both TCP and SMS connections active; TCP in queue mode, SMS in standard mode (is not supported since LWM2M 1.1)", + "sq": "SQ: SMS connection in queue mode (is not supported since LWM2M 1.1)" + }, + "binding-tooltip": "This is the list in the\"binding\" resource of the LwM2M server object - /1/x/7.\nIndicates the supported binding modes in the LwM2M Client.\nThis value SHOULD be the same as the value in the “Supported Binding and Modes” resource in the Device Object (/3/0/16).\nWhile multiple transports are supported, only one transport binding can be used during the entire Transport Session.\nAs an example, when UDP and SMS are both supported, the LwM2M Client and the LwM2M Server can choose to communicate either over UDP or SMS during the entire Transport Session.", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "include-bootstrap-server": "Include Bootstrap Server updates", + "bootstrap-update-title": "You already have configured Bootstrap Server. Are you sure you want to exclude the updates?", + "bootstrap-update-text": "Be careful, after the confirmation the Bootstrap Server configuration data will become unrecoverable.", + "server-host": "Host", + "server-host-required": "Host is required.", + "server-port": "Port", + "server-port-required": "Port is required.", + "server-port-pattern": "Port must be a positive integer.", + "server-port-range": "Port should be in a range from 1 to 65535.", + "server-public-key": "Server Public Key", + "server-public-key-required": "Server Public Key is required.", + "client-hold-off-time": "Hold Off Time", + "client-hold-off-time-required": "Hold Off Time is required.", + "client-hold-off-time-pattern": "Hold Off Time must be a positive integer.", + "client-hold-off-time-tooltip": "Client Hold Off Time for use with a Bootstrap-Server only", + "account-after-timeout": "Account after the timeout", + "account-after-timeout-required": "Account after the timeout is required.", + "account-after-timeout-pattern": "Account after the timeout must be a positive integer.", + "account-after-timeout-tooltip": "Bootstrap-Server Account after the timeout value given by this resource.", + "server-type": "Server type", + "add-new-server-title": "Add new server config", + "add-server-config": "Add server config", + "add-lwm2m-server-config": "Add LwM2M server", + "no-config-servers": "No servers configured", + "others-tab": "Other settings", + "client-strategy": "Client strategy when connecting", + "client-strategy-label": "Strategy", + "client-strategy-only-observe": "Only Observe Request to the client after the initial connection", + "client-strategy-read-all": "Read All Resources & Observe Request to the client after registration", + "fw-update": "Firmware update", + "fw-update-strategy": "Firmware update strategy", + "fw-update-strategy-data": "Push firmware update as binary file using Object 19 and Resource 0 (Data)", + "fw-update-strategy-package": "Push firmware update as binary file using Object 5 and Resource 0 (Package)", + "fw-update-strategy-package-uri": "Auto-generate unique CoAP URL to download the package and push firmware update as Object 5 and Resource 1 (Package URI)", + "sw-update": "Software update", + "sw-update-strategy": "Software update strategy", + "sw-update-strategy-package": "Push binary file using Object 9 and Resource 2 (Package)", + "sw-update-strategy-package-uri": "Auto-generate unique CoAP URL to download the package and push software update using Object 9 and Resource 3 (Package URI)", + "fw-update-resource": "Firmware update CoAP resource", + "fw-update-resource-required": "Firmware update CoAP resource is required.", + "sw-update-resource": "Software update CoAP resource", + "sw-update-resource-required": "Software update CoAP resource is required.", + "config-json-tab": "Json Config Profile Device", + "attributes-name": { + "min-period": "Minimum period", + "max-period": "Maximum period", + "greater-than": "Greater than", + "less-than": "Less than", + "step": "Step", + "min-evaluation-period": "Minimum evaluation period", + "max-evaluation-period": "Maximum evaluation period" + }, + "composite-operations-support": "Supports composite Read/Write/Observe operations" + }, + "snmp": { + "add-communication-config": "Add communication config", + "add-mapping": "Add mapping", + "authentication-passphrase": "Authentication passphrase", + "authentication-passphrase-required": "Authentication passphrase is required.", + "authentication-protocol": "Authentication protocol", + "authentication-protocol-required": "Authentication protocol is required.", + "communication-configs": "Communication configs", + "community": "Community string", + "community-required": "Community string is required.", + "context-name": "Context name", + "data-key": "Data key", + "data-key-required": "Data key is required.", + "data-type": "Data type", + "data-type-required": "Data type is required.", + "engine-id": "Engine ID", + "host": "Host", + "host-required": "Host is required.", + "oid": "OID", + "oid-pattern": "Invalid OID format.", + "oid-required": "OID is required.", + "please-add-communication-config": "Please add communication config", + "please-add-mapping-config": "Please add mapping config", + "port": "Port", + "port-format": "Invalid port format.", + "port-required": "Port is required.", + "privacy-passphrase": "Privacy passphrase", + "privacy-passphrase-required": "Privacy passphrase is required.", + "privacy-protocol": "Privacy protocol", + "privacy-protocol-required": "Privacy protocol is required.", + "protocol-version": "Protocol version", + "protocol-version-required": "Protocol version is required.", + "querying-frequency": "Querying frequency, ms", + "querying-frequency-invalid-format": "Querying frequency must be a positive integer.", + "querying-frequency-required": "Querying frequency is required.", + "retries": "Retries", + "retries-invalid-format": "Retries must be a positive integer.", + "retries-required": "Retries is required.", + "scope": "Scope", + "scope-required": "Scope is required.", + "security-name": "Security name", + "security-name-required": "Security name is required.", + "timeout-ms": "Timeout, ms", + "timeout-ms-invalid-format": "Timeout must be a positive integer.", + "timeout-ms-required": "Timeout is required.", + "user-name": "User name", + "user-name-required": "User name is required." + } + }, + "dialog": { + "close": "Close dialog" + }, + "direction": { + "column": "Column", + "row": "Row" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Edge instances", + "edge-file": "Edge file", + "name-max-length": "Name should be less than 256", + "label-max-length": "Label should be less than 256", + "type-max-length": "Type should be less than 256", + "management": "Edge management", + "no-edges-matching": "No edges matching '{{entity}}' were found.", + "add": "Add Edge", + "no-edges-text": "No edges found", + "edge-details": "Edge details", + "add-edge-text": "Add new edge", + "delete": "Delete edge", + "delete-edge-title": "Are you sure you want to delete the edge '{{edgeName}}'?", + "delete-edge-text": "Be careful, after the confirmation the edge and all related data will become unrecoverable.", + "delete-edges-title": "Are you sure you want to edge { count, plural, 1 {1 edge} other {# edges} }?", + "delete-edges-text": "Be careful, after the confirmation all selected edges will be removed and all related data will become unrecoverable.", + "name": "Name", + "name-starts-with": "Edge name starts with", + "name-required": "Name is required.", + "description": "Description", + "details": "Details", + "events": "Events", + "copy-id": "Copy Edge Id", + "id-copied-message": "Edge Id has been copied to clipboard", + "sync": "Sync Edge", + "edge-required": "Edge required", + "edge-type": "Edge type", + "edge-type-required": "Edge type is required.", + "event-action": "Event action", + "entity-id": "Entity ID", + "select-edge-type": "Select edge type", + "assign-to-customer": "Assign to customer", + "assign-to-customer-text": "Please select the customer to assign the edge(s)", + "assign-edge-to-customer": "Assign Edge(s) To Customer", + "assign-edge-to-customer-text": "Please select the edges to assign to the customer", + "assignedToCustomer": "Assigned to customer", + "edge-public": "Edge is public", + "assigned-to-customer": "Assigned to: {{customerTitle}}", + "unassign-from-customer": "Unassign from customer", + "unassign-edge-title": "Are you sure you want to unassign the edge '{{edgeName}}'?", + "unassign-edge-text": "After the confirmation the edge will be unassigned and won't be accessible by the customer.", + "unassign-edges-title": "Are you sure you want to unassign { count, plural, 1 {1 edge} other {# edges} }?", + "unassign-edges-text": "After the confirmation all selected edges will be unassigned and won't be accessible by the customer.", + "make-public": "Make edge public", + "make-public-edge-title": "Are you sure you want to make the edge '{{edgeName}}' public?", + "make-public-edge-text": "After the confirmation the edge and all its data will be made public and accessible by others.", + "make-private": "Make edge private", + "public": "Public", + "make-private-edge-title": "Are you sure you want to make the edge '{{edgeName}}' private?", + "make-private-edge-text": "After the confirmation the edge and all its data will be made private and won't be accessible by others.", + "import": "Import edge", + "label": "Label", + "load-entity-error": "Failed to load data. Entity has been deleted.", + "assign-new-edge": "Assign new edge", + "unassign-from-edge": "Unassign from edge", + "edge-key": "Edge key", + "copy-edge-key": "Copy Edge key", + "edge-key-copied-message": "Edge key has been copied to clipboard", + "edge-secret": "Edge secret", + "copy-edge-secret": "Copy Edge secret", + "edge-secret-copied-message": "Edge secret has been copied to clipboard", + "edge-assets": "Edge assets", + "edge-devices": "Edge devices", + "edge-entity-views": "Edge entity views", + "edge-dashboards": "Edge dashboards", + "edge-rulechains": "Edge rule chains", + "assets": "Edge assets", + "devices": "Edge devices", + "entity-views": "Edge entity views", + "dashboard": "Edge dashboard", + "dashboards": "Edge Dashboards", + "rulechain-templates": "Rule chain templates", + "rulechains": "Rule chains", + "search": "Search edges", + "selected-edges": "{ count, plural, 1 {1 edge} other {# edges} } selected", + "any-edge": "Any edge", + "no-edge-types-matching": "No edge types matching '{{entitySubtype}}' were found.", + "edge-type-list-empty": "No edge types selected.", + "edge-types": "Edge types", + "enter-edge-type": "Enter edge type", + "deployed": "Deployed", + "pending": "Pending", + "downlinks": "Downlinks", + "no-downlinks-prompt": "No downlinks found", + "sync-process-started-successfully": "Sync process started successfully!", + "missing-related-rule-chains-title": "Edge has missing related rule chain(s)", + "missing-related-rule-chains-text": "Assigned to edge rule chain(s) use rule nodes that forward message(s) to rule chain(s) that are not assigned to this edge.

    List of missing rule chain(s):
    {{missingRuleChains}}", + "widget-datasource-error": "This widget supports only EDGE entity datasource" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Device", + "type-device-profile": "Device Profile", + "type-asset-profile": "Asset Profile", + "type-entity-view": "Entity View", + "type-alarm": "Alarm", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "action-type-added": "Added", + "action-type-deleted": "Deleted", + "action-type-updated": "Updated", + "action-type-post-attributes": "Post Attributes", + "action-type-attributes-updated": "Attributes Updated", + "action-type-attributes-deleted": "Attributes Deleted", + "action-type-timeseries-updated": "Timeseries Updated", + "action-type-credentials-updated": "Credentials Updated", + "action-type-assigned-to-customer": "Assigned to Customer", + "action-type-unassigned-from-customer": "Unassigned from Customer", + "action-type-relation-add-or-update": "Relation Add or Update", + "action-type-relation-deleted": "Relation Deleted", + "action-type-rpc-call": "RPC Call", + "action-type-alarm-ack": "Alarm Ack", + "action-type-alarm-clear": "Alarm Clear", + "action-type-assigned-to-edge": "Assigned to Edge", + "action-type-unassigned-from-edge": "Unassigned from Edge", + "action-type-credentials-request": "Credentials Request", + "action-type-entity-merge-request": "Entity Merge Request" + }, + "error": { + "unable-to-connect": "Unable to connect to the server! Please check your internet connection.", + "unhandled-error-code": "Unhandled error code: {{errorCode}}", + "unknown-error": "Unknown error" + }, + "entity": { + "entity": "Entity", + "entities": "Entities", + "entities-count": "Entities count", + "aliases": "Entity aliases", + "entity-alias": "Entity alias", + "unable-delete-entity-alias-title": "Unable to delete entity alias", + "unable-delete-entity-alias-text": "Entity alias '{{entityAlias}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
    Entity aliases must be unique whithin the dashboard.", + "missing-entity-filter-error": "Filter is missing for alias '{{alias}}'.", + "configure-alias": "Configure '{{alias}}' alias", + "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 expression", + "help-text": "Use '%' according to need: '%entity_name_contains%', '%entity_name_ends', 'entity_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", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "Devices whose names start with '{{prefix}}'", + "type-device-profile": "Device profile", + "type-device-profiles": "Device profiles", + "list-of-device-profiles": "{ count, plural, 1 {One device profile} other {List of # device profiles} }", + "device-profile-name-starts-with": "Device profiles whose names start with '{{prefix}}'", + "type-asset-profile": "Asset profile", + "type-asset-profiles": "Asset profiles", + "list-of-asset-profiles": "{ count, plural, 1 {One asset profile} other {List of # asset profiles} }", + "asset-profile-name-starts-with": "Asset profiles whose names start with '{{prefix}}'", + "type-asset": "Asset", + "type-assets": "Assets", + "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", + "asset-name-starts-with": "Assets 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} }", + "rule-name-starts-with": "Rules whose names start with '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "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", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", + "tenant-name-starts-with": "Tenants whose names start with '{{prefix}}'", + "type-tenant-profile": "Tenant profile", + "type-tenant-profiles": "Tenant profiles", + "list-of-tenant-profiles": "{ count, plural, 1 {One tenant profile} other {List of # tenant profiles} }", + "tenant-profile-name-starts-with": "Tenant profiles whose names start with '{{prefix}}'", + "type-customer": "Customer", + "type-customers": "Customers", + "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", + "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", + "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", + "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", + "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-rulenode": "Rule node", + "type-rulenodes": "Rule nodes", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Rule nodes whose names start with '{{prefix}}'", + "type-current-customer": "Current Customer", + "type-current-tenant": "Current Tenant", + "type-current-user": "Current User", + "type-current-user-owner": "Current User Owner", + "type-widgets-bundle": "Widgets bundle", + "type-widgets-bundles": "Widgets bundles", + "list-of-widgets-bundles": "{ count, plural, 1 {One widgets bundle} other {List of # widget bundles} }", + "search": "Search entities", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } selected", + "entity-name": "Entity name", + "entity-label": "Entity label", + "details": "Entity details", + "no-entities-prompt": "No entities found", + "no-data": "No data to display", + "columns-to-display": "Columns to Display", + "type-api-usage-state": "Api Usage State", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, 1 {One edge} other {List of # edges} }", + "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", + "type-tb-resource": "Resource", + "type-ota-package": "OTA package" + }, + "entity-field": { + "created-time": "Created time", + "name": "Name", + "type": "Type", + "first-name": "First name", + "last-name": "Last name", + "email": "Email", + "title": "Title", + "country": "Country", + "state": "State", + "city": "City", + "address": "Address", + "address2": "Address 2", + "zip": "Zip", + "phone": "Phone", + "label": "Label" + }, + "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}}'.
    Entity View aliases must be unique within the dashboard.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "public": "Public", + "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 expression", + "help-text": "Use '%' according to need: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_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", + "entity-view-public": "Entity view is public", + "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", + "assign-entity-view-to-edge-title": "Assign Entity View(s) To Edge", + "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 entity view} other {# entity views} } to customer", + "delete-entity-views": "Delete entity views", + "unassign-from-customer": "Unassign from customer", + "unassign-entity-views": "Unassign entity views", + "unassign-entity-views-action-title": "Unassign { count, plural, 1 {1 entity view} other {# entity views} } from customer", + "assign-new-entity-view": "Assign new entity view", + "delete-entity-view-title": "Are you sure you want to delete the entity view '{{entityViewName}}'?", + "delete-entity-view-text": "Be careful, after the confirmation the entity view and all related data will become unrecoverable.", + "delete-entity-views-title": "Are you sure you want to delete { count, plural, 1 {1 entity view} other {# entity views} }?", + "delete-entity-views-action-title": "Delete { count, plural, 1 {1 entity view} other {# entity views} }", + "delete-entity-views-text": "Be careful, after the confirmation all selected entity views will be removed and all related data will become unrecoverable.", + "unassign-entity-view-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", + "unassign-entity-view-text": "After the confirmation the entity view will be unassigned and won't be accessible by the customer.", + "unassign-entity-view": "Unassign entity view", + "unassign-entity-views-title": "Are you sure you want to unassign { count, plural, 1 {1 entity view} other {# entity views} }?", + "unassign-entity-views-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the customer.", + "entity-view-type": "Entity View type", + "entity-view-type-required": "Entity View type is required.", + "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", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "type-max-length": "Entity view type should be less than 256", + "description": "Description", + "events": "Events", + "details": "Details", + "copyId": "Copy entity view Id", + "idCopiedMessage": "Entity view Id has been copied to clipboard", + "assignedToCustomer": "Assigned to customer", + "unable-entity-view-device-alias-title": "Unable to delete entity view alias", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "select-entity-view": "Select entity view", + "make-public": "Make entity view public", + "make-private": "Make entity view private", + "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.", + "search": "Search entity views", + "selected-entity-views": "{ count, plural, 1 {1 entity view} other {# entity views} } selected", + "make-public-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' public?", + "make-public-entity-view-text": "After the confirmation the entity view and all its data will be made public and accessible by others.", + "make-private-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' private?", + "make-private-entity-view-text": "After the confirmation the entity view and all its data will be made private and won't be accessible by others.", + "assign-entity-view-to-edge": "Assign Entity View(s) To Edge", + "assign-entity-view-to-edge-text":"Please select the entity views to assign to the edge", + "unassign-entity-view-from-edge-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "After the confirmation the entity view will be unassigned and won't be accessible by the edge.", + "unassign-entity-views-from-edge-action-title": "Unassign { count, plural, 1 {1 entity view} other {# entity views} } from edge", + "unassign-entity-view-from-edge": "Unassign entity view", + "unassign-entity-views-from-edge-title": "Are you sure you want to unassign { count, plural, 1 {1 entity view} other {# entity views} }?", + "unassign-entity-views-from-edge-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the edge." + }, + "event": { + "event-type": "Event type", + "events-filter": "Events Filter", + "clean-events": "Clear Events", + "type-error": "Error", + "type-lc-event": "Lifecycle event", + "type-stats": "Statistics", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "No events found", + "error": "Error", + "alarm": "Alarm", + "event-time": "Event time", + "server": "Server", + "body": "Body", + "method": "Method", + "type": "Type", + "message": "Message", + "message-id": "Message Id", + "copy-message-id": "Copy message Id", + "message-type": "Message Type", + "data-type": "Data Type", + "relation-type": "Relation Type", + "metadata": "Metadata", + "data": "Data", + "event": "Event", + "status": "Status", + "success": "Success", + "failed": "Failed", + "messages-processed": "Messages processed", + "max-messages-processed": "Maximum messages processed", + "min-messages-processed": "Minimum messages processed", + "errors-occurred": "Errors occurred", + "max-errors-occurred": "Maximum errors occurred", + "min-errors-occurred": "Minimum errors occurred", + "min-value": "Minimum value is 0.", + "all-events": "All", + "has-error": "Has error", + "entity-id": "Entity Id", + "copy-entity-id": "Copy entity Id", + "entity-type": "Entity type", + "clear-filter": "Clear Filter", + "clear-request-title": "Clear all events", + "clear-request-text": "Are you sure you want to clear all events?" + }, + "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", + "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", + "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", + "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", + "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", + "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-tcp-reconnect": "Automatically reconnect", + "modbus-rtu-over-tcp": "RTU over TCP", + "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", + "sync": { + "status": "Status", + "sync": "Sync", + "not-sync": "Not sync", + "last-sync-time": "Last sync time", + "not-available": "Not available" + }, + "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" + }, + "filter": { + "add": "Add filter", + "edit": "Edit filter", + "name": "Filter name", + "name-required": "Filter name is required.", + "duplicate-filter": "Filter with same name is already exists.", + "filters": "Filters", + "unable-delete-filter-title": "Unable to delete filter", + "unable-delete-filter-text": "Filter '{{filter}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "duplicate-filter-error": "Duplicate filter found '{{filter}}'.
    Filters must be unique within the dashboard.", + "missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.", + "filter": "Filter", + "editable": "Editable", + "no-filters-found": "No filters found.", + "no-filter-text": "No filter specified", + "add-filter-prompt": "Please add filter", + "no-filter-matching": "'{{filter}}' not found.", + "create-new-filter": "Create a new one!", + "filter-required": "Filter is required.", + "operation": { + "operation": "Operation", + "equal": "equal", + "not-equal": "not equal", + "starts-with": "starts with", + "ends-with": "ends with", + "contains": "contains", + "not-contains": "not contains", + "greater": "greater than", + "less": "less than", + "greater-or-equal": "greater or equal", + "less-or-equal": "less or equal", + "and": "and", + "or": "or", + "in": "in", + "not-in": "not in" + }, + "ignore-case": "ignore case", + "value": "Value", + "remove-filter": "Remove filter", + "preview": "Filter preview", + "no-filters": "No filters configured", + "add-filter": "Add filter", + "add-complex-filter": "Add complex filter", + "add-complex": "Add complex", + "complex-filter": "Complex filter", + "edit-complex-filter": "Edit complex filter", + "edit-filter-user-params": "Edit filter predicate user parameters", + "filter-user-params": "Filter predicate user parameters", + "user-parameters": "User parameters", + "display-label": "Label to display", + "autogenerated-label": "Auto generate label", + "order-priority": "Field order priority", + "key-filter": "Key filter", + "key-filters": "Key filters", + "key-name": "Key name", + "key-name-required": "Key name is required.", + "key-type": { + "key-type": "Key type", + "attribute": "Attribute", + "timeseries": "Timeseries", + "entity-field": "Entity field", + "constant": "Constant" + }, + "value-type": { + "value-type": "Value type", + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "date-time": "Datetime" + }, + "value-type-required": "Key value type is required.", + "key-value-type-change-title": "Are you sure you want to change key value type?", + "key-value-type-change-message": "If you confirm new value type all entered key filters will be removed.", + "no-key-filters": "No key filters configured", + "add-key-filter": "Add key filter", + "remove-key-filter": "Remove key filter", + "edit-key-filter": "Edit key filter", + "date": "Date", + "time": "Time", + "current-tenant": "Current tenant", + "current-customer": "Current customer", + "current-user": "Current user", + "current-device": "Current device", + "default-value": "Default value", + "dynamic-source-type": "Dynamic source type", + "dynamic-value": "Dynamic value", + "no-dynamic-value": "No dynamic value", + "source-attribute": "Source attribute", + "switch-to-dynamic-value": "Switch to dynamic value", + "switch-to-default-value": "Switch to default value", + "inherit-owner": "Inherit from owner", + "source-attribute-not-set": "If source attribute isn't set" + }, + "fullscreen": { + "expand": "Expand to fullscreen", + "exit": "Exit fullscreen", + "toggle": "Toggle fullscreen mode", + "fullscreen": "Fullscreen" + }, + "function": { + "function": "Function" + }, + "gateway": { + "add-entry": "Add configuration", + "connector-add": "Add new connector", + "connector-enabled": "Enable connector", + "connector-name": "Connector name", + "connector-name-required": "Connector name is required.", + "connector-type": "Connector type", + "connector-type-required": "Connector type is required.", + "connectors": "Connectors configuration", + "create-new-gateway": "Create a new gateway", + "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", + "delete": "Delete configuration", + "download-tip": "Download configuration file", + "gateway": "Gateway", + "gateway-exists": "Device with same name is already exists.", + "gateway-name": "Gateway name", + "gateway-name-required": "Gateway name is required.", + "gateway-saved": "Gateway configuration successfully saved.", + "json-parse": "Not valid JSON.", + "json-required": "Field cannot be empty.", + "no-connectors": "No connectors", + "no-data": "No configurations", + "no-gateway-found": "No gateway found.", + "no-gateway-matching": " '{{item}}' not found.", + "path-logs": "Path to log files", + "path-logs-required": "Path is required.", + "remote": "Remote configuration", + "remote-logging-level": "Logging level", + "remove-entry": "Remove configuration", + "save-tip": "Save configuration file", + "security-type": "Security type", + "security-types": { + "access-token": "Access Token", + "tls": "TLS" + }, + "storage": "Storage", + "storage-max-file-records": "Maximum records in file", + "storage-max-files": "Maximum number of files", + "storage-max-files-min": "Minimum number is 1.", + "storage-max-files-pattern": "Number is not valid.", + "storage-max-files-required": "Number is required.", + "storage-max-records": "Maximum records in storage", + "storage-max-records-min": "Minimum number of records is 1.", + "storage-max-records-pattern": "Number is not valid.", + "storage-max-records-required": "Maximum records is required.", + "storage-pack-size": "Maximum event pack size", + "storage-pack-size-min": "Minimum number is 1.", + "storage-pack-size-pattern": "Number is not valid.", + "storage-pack-size-required": "Maximum event pack size is required.", + "storage-path": "Storage path", + "storage-path-required": "Storage path is required.", + "storage-type": "Storage type", + "storage-types": { + "file-storage": "File storage", + "memory-storage": "Memory storage" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard host", + "thingsboard-host-required": "Host is required.", + "thingsboard-port": "ThingsBoard port", + "thingsboard-port-max": "Maximum port number is 65535.", + "thingsboard-port-min": "Minimum port number is 1.", + "thingsboard-port-pattern": "Port is not valid.", + "thingsboard-port-required": "Port is required.", + "tidy": "Tidy", + "tidy-tip": "Tidy config JSON", + "title-connectors-json": "Connector {{typeName}} configuration", + "tls-path-ca-certificate": "Path to CA certificate on gateway", + "tls-path-client-certificate": "Path to client certificate on gateway", + "tls-path-private-key": "Path to private key on gateway", + "toggle-fullscreen": "Toggle fullscreen", + "transformer-json-config": "Configuration JSON*", + "update-config": "Add/update configuration JSON" + }, + "grid": { + "delete-item-title": "Are you sure you want to delete this item?", + "delete-item-text": "Be careful, after the confirmation this item and all related data will become unrecoverable.", + "delete-items-title": "Are you sure you want to delete { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Delete { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Be careful, after the confirmation all selected items will be removed and all related data will become unrecoverable.", + "add-item-text": "Add new item", + "no-items-text": "No items found", + "item-details": "Item details", + "delete-item": "Delete Item", + "delete-items": "Delete Items", + "scroll-to-top": "Scroll to top" + }, + "help": { + "goto-help-page": "Go to help page", + "show-help": "Show help" + }, + "home": { + "home": "Home", + "profile": "Profile", + "logout": "Logout", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Open user menu" + }, + "file-input": { + "browse-file": "Browse file", + "browse-files": "Browse files" + }, + "image-input": { + "drop-image-or": "Drag and drop an image or", + "drop-images-or": "Drag and drop an images or", + "no-images": "No images selected", + "images": "images" + }, + "import": { + "no-file": "No file selected", + "drop-file": "Drop a JSON file or click to select a file to upload.", + "drop-json-file-or": "Drag and drop a JSON file or", + "drop-file-csv": "Drop a CSV file or click to select a file to upload.", + "drop-file-csv-or": "Drag and drop a CSV file or", + "column-value": "Value", + "column-title": "Title", + "column-example": "Example value data", + "column-key": "Attribute/telemetry key", + "credentials": "Credentials", + "csv-delimiter": "CSV delimiter", + "csv-first-line-header": "First line contains column names", + "csv-update-data": "Update attributes/telemetry", + "details": "Details", + "import-csv-number-columns-error": "A file should contain at least two columns", + "import-csv-invalid-format-error": "Invalid file format. Line: '{{line}}'", + "column-type": { + "name": "Name", + "type": "Type", + "label": "Label", + "column-type": "Column type", + "client-attribute": "Client attribute", + "shared-attribute": "Shared attribute", + "server-attribute": "Server attribute", + "timeseries": "Timeseries", + "entity-field": "Entity field", + "access-token": "Access token", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT client ID", + "user-name": "MQTT user name", + "password": "MQTT password" + }, + "lwm2m": { + "client-endpoint": "LwM2M endpoint client name", + "security-config-mode": "LwM2M security config mode", + "client-identity": "LwM2M client identity", + "client-key": "LwM2M client key", + "client-cert": "LwM2M client public key", + "bootstrap-server-security-mode": "LwM2M bootstrap server security mode", + "bootstrap-server-secret-key": "LwM2M bootstrap server secret key", + "bootstrap-server-public-key-id": "LwM2M bootstrap server public key or id", + "lwm2m-server-security-mode": "LwM2M server security mode", + "lwm2m-server-secret-key": "LwM2M server secret key", + "lwm2m-server-public-key-id": "LwM2M server public key or id" + }, + "isgateway": "Is Gateway", + "activity-time-from-gateway-device": "Activity time from gateway device", + "description": "Description", + "routing-key": "Edge key", + "secret": "Edge secret" + }, + "stepper-text":{ + "select-file": "Select a file", + "configuration": "Import configuration", + "column-type": "Select columns type", + "creat-entities": "Creating new entities" + }, + "message": { + "create-entities": "{{count}} new entities were successfully created.", + "update-entities": "{{count}} entities were successfully updated.", + "error-entities": "There was an error creating {{count}} entities." + } + }, + "item": { + "selected": "Selected" + }, + "js-func": { + "no-return-error": "Function must return value!", + "return-type-mismatch": "Function must return value of '{{type}}' type!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Key", + "value": "Value", + "remove-entry": "Remove entry", + "add-entry": "Add entry", + "no-data": "No entries" + }, + "layout": { + "layout": "Layout", + "manage": "Manage layouts", + "settings": "Layout settings", + "color": "Color", + "main": "Main", + "right": "Right", + "left": "Left", + "select": "Select target layout", + "percentage-width": "Percentage width (%)", + "fixed-width": "Fixed width (px)", + "left-width": "Left column (%)", + "right-width": "Right column (%)", + "pick-fixed-side": "Fixed side: ", + "layout-fixed-width": "Fixed width (px)", + "value-min-error": "Value must be more then {{min}}{{unit}}", + "value-max-error": "Value must be less then {{max}}{{unit}}", + "layout-fixed-width-required": "Fixed width is required", + "right-width-percentage-required": "Right percentage is required", + "left-width-percentage-required": "Left percentage is required", + "divider": "Divider", + "right-side": "Right side layout", + "left-side": "Left side layout" + }, + "legend": { + "direction": "Legend direction", + "position": "Legend position", + "sort-legend": "Sort datakeys in legend", + "show-max": "Show max value", + "show-min": "Show min value", + "show-avg": "Show average value", + "show-total": "Show total value", + "show-latest": "Show latest value", + "settings": "Legend settings", + "min": "min", + "max": "max", + "avg": "avg", + "total": "total", + "latest": "latest", + "comparison-time-ago": { + "previousInterval": "(previous interval)", + "customInterval": "(custom interval)", + "days": "(day ago)", + "weeks": "(week ago)", + "months": "(month ago)", + "years": "(year ago)" + } + }, + "login": { + "login": "Login", + "request-password-reset": "Request Password Reset", + "reset-password": "Reset Password", + "create-password": "Create Password", + "two-factor-authentication": "Two factor authentication", + "passwords-mismatch-error": "Entered passwords must be same!", + "password-again": "Password again", + "sign-in": "Please sign in", + "username": "Username (email)", + "remember-me": "Remember me", + "forgot-password": "Forgot Password?", + "password-reset": "Password reset", + "expired-password-reset-message": "Your credentials has been expired! Please create new password.", + "new-password": "New password", + "new-password-again": "Confirm new password", + "password-link-sent-message": "Reset link has been sent", + "email": "Email", + "login-with": "Login with {{name}}", + "or": "or", + "error": "Login error", + "verify-your-identity": "Verify your identity", + "select-way-to-verify": "Select a way to verify", + "resend-code": "Resend code", + "resend-code-wait": "Resend code in { time, plural, 1 {1 second} other {# seconds} }", + "try-another-way": "Try another way", + "totp-auth-description": "Please enter the security code from your authenticator app.", + "totp-auth-placeholder": "Code", + "sms-auth-description": "A security code has been sent to your phone at {{contact}}.", + "sms-auth-placeholder": "SMS code", + "email-auth-description": "A security code has been sent to your email address at {{contact}}.", + "email-auth-placeholder": "Email code", + "backup-code-auth-description": "Please enter one of your backup codes.", + "backup-code-auth-placeholder": "Backup code" + }, + "markdown": { + "edit": "Edit", + "preview": "Preview", + "copy-code": "Click to copy", + "copied": "Copied!" + }, + "ota-update": { + "add": "Add package", + "assign-firmware": "Assigned firmware", + "assign-firmware-required": "Assigned firmware is required", + "assign-software": "Assigned software", + "assign-software-required": "Assigned software is required", + "auto-generate-checksum": "Auto-generate checksum", + "checksum": "Checksum", + "checksum-hint": "If checksum is empty, it will be generated automatically", + "checksum-algorithm": "Checksum algorithm", + "checksum-copied-message": "Package checksum has been copied to clipboard", + "change-firmware": "Change of the firmware may cause update of { count, plural, 1 {1 device} other {# devices} }.", + "change-software": "Change of the software may cause update of { count, plural, 1 {1 device} other {# devices} }.", + "chose-compatible-device-profile": "The uploaded package will be available only for devices with the chosen profile.", + "chose-firmware-distributed-device": "Choose firmware that will be distributed to the devices", + "chose-software-distributed-device": "Choose software that will be distributed to the devices", + "content-type": "Content type", + "copy-checksum": "Copy checksum", + "copy-direct-url": "Copy direct URL", + "copyId": "Copy package Id", + "copied": "Copied!", + "delete": "Delete package", + "delete-ota-update-text": "Be careful, after the confirmation the OTA update will become unrecoverable.", + "delete-ota-update-title": "Are you sure you want to delete the OTA update '{{title}}'?", + "delete-ota-updates-text": "Be careful, after the confirmation all selected OTA updates will be removed.", + "delete-ota-updates-title": "Are you sure you want to delete { count, plural, 1 {1 OTA update} other {# OTA updates} }?", + "description": "Description", + "direct-url": "Direct URL", + "direct-url-copied-message": "Package direct URL has been copied to clipboard", + "direct-url-required": "Direct URL is required", + "download": "Download package", + "drop-file": "Drop a package file or click to select a file to upload.", + "drop-package-file-or": "Drag and drop a package file or", + "file-name": "File name", + "file-size": "File size", + "file-size-bytes": "File size in bytes", + "idCopiedMessage": "Package Id has been copied to clipboard", + "no-firmware-matching": "No compatible Firmware OTA Update packages matching '{{entity}}' were found.", + "no-firmware-text": "No compatible Firmware OTA Update packages provisioned.", + "no-packages-text": "No packages found", + "no-software-matching": "No compatible Software OTA Update packages matching '{{entity}}' were found.", + "no-software-text": "No compatible Software OTA Update packages provisioned.", + "ota-update": "OTA update", + "ota-update-details": "OTA update details", + "ota-updates": "OTA updates", + "package-type": "Package type", + "packages-repository": "Packages repository", + "search": "Search packages", + "selected-package": "{ count, plural, 1 {1 package} other {# packages} } selected", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Upload binary file", + "use-external-url": "Use external URL", + "version": "Version", + "version-required": "Version is required.", + "version-tag": "Version Tag", + "version-tag-hint": "Custom tag should match the package version reported by your device.", + "version-max-length": "Version should be less than 256", + "warning-after-save-no-edit": "Once the package is uploaded, you will not be able to modify title, version, device profile and package type." + }, + "position": { + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right" + }, + "profile": { + "profile": "Profile", + "last-login-time": "Last Login", + "change-password": "Change Password", + "current-password": "Current password", + "copy-jwt-token": "Copy JWT token", + "jwt-token": "JWT token", + "token-valid-till": "Token is valid till", + "tokenCopiedSuccessMessage": "JWT token has been copied to clipboard", + "tokenCopiedWarnMessage": "JWT token is expired! Please, refresh the page." + }, + "profiles": { + "profiles": "Profiles" + }, + "security": { + "security": "Security", + "2fa": { + "2fa": "Two-factor authentication", + "2fa-description": "Two-factor authentication protects your account from unauthorized access. All you have to do is enter a security code when you log in.", + "authenticate-with": "You can authenticate with:", + "disable-2fa-provider-text": "Disabling {{name}} will make your account less secure", + "disable-2fa-provider-title": "Are you sure you want to disable {{name}}?", + "get-new-code": "Get new code", + "main-2fa-method": "Use as main two-factor authentication method", + "dialog": { + "activation-step-description-email": "The next time you login in, you will be prompted to enter the security code that will be sent to your email address.", + "activation-step-description-sms": "The next time you login in, you will be prompted to enter the security code that will be sent to the phone number.", + "activation-step-description-totp": "The next time you login in, you will need to provide a two-factor authentication code.", + "activation-step-label": "Activation", + "backup-code-description": "Print out the codes so you have them handy when you need to use them to log in to your account. You can use each backup code once.", + "backup-code-warn": "Once you leave this page, these codes cannot be shown again. Store them safely using the options below.", + "download-txt": "Download (txt)", + "email-step-description": "Enter an email to use as your authenticator.", + "email-step-label": "Email", + "enable-email-title": "Enable email authenticator", + "enable-sms-title": "Enable SMS authenticator", + "enable-totp-title": "Enable authenticator app", + "enter-verification-code": "Enter the 6-digit code here", + "get-backup-code-title": "Get backup code", + "next": "Next", + "scan-qr-code": "Scan this QR code with your verification app", + "send-code": "Send code", + "sms-step-description": "Enter a phone number to use as your authenticator.", + "sms-step-label": "Phone Number", + "success": "Success!", + "totp-step-description-install": "You can install apps like Google Authenticator, Authy, or Duo.", + "totp-step-description-open": "Open the authenticator app on your mobile phone.", + "totp-step-label": "Get app", + "verification-code": "6-digit code", + "verification-code-invalid": "Invalid verification code format", + "verification-code-incorrect": "Verification code is incorrect", + "verification-code-many-request": "Too many requests check verification code", + "verification-step-description": "Enter a 6-digit code we just sent to {{address}}", + "verification-step-label": "Verification" + }, + "provider": { + "email": "Email", + "email-description": "Use a security code sent to your email address to authenticate.", + "email-hint": "Authentication codes are sent via email to {{ info }}", + "sms": "SMS", + "sms-description": "Use your phone to authenticate. We'll send you a security code via SMS message when you log in.", + "sms-hint": "Authentication codes are sent by text message to {{ info }}", + "totp": "Authenticator app", + "totp-description": "Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in.", + "totp-hint": "Authenticator app is set up for your account", + "backup_code": "Backup code", + "backup-code-description": "These printable one-time passcodes allow you to sign in when away from your phone, like when you’re traveling.", + "backup-code-hint": "{{ info }} single-use codes are active at this time" + } + }, + "password-requirement": { + "at-least": "At least:", + "character": "{ count, plural, 1 {1 character} other {# characters} }", + "digit": "{ count, plural, 1 {1 digit} other {# digits} }", + "incorrect-password-try-again": "Incorrect password. Try again", + "lowercase-letter": "{ count, plural, 1 {1 lowercase letter} other {# lowercase letters} }", + "new-passwords-not-match": "New password didn't match", + "password-should-not-contain-spaces": "Your password should not contain spaces", + "password-not-meet-requirements": "Password didn't meet requirements", + "password-requirements": "Password requirements", + "password-should-difference": "New password should be different from current", + "special-character": "{ count, plural, 1 {1 special character} other {# special characters} }", + "uppercase-letter": "{ count, plural, 1 {1 uppercase letter} other {# uppercase letters} }" + } + }, + "relation": { + "relations": "Relations", + "direction": "Direction", + "search-direction": { + "FROM": "From", + "TO": "To" + }, + "direction-type": { + "FROM": "from", + "TO": "to" + }, + "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.", + "relation-type-max-length": "Relation type should be less than 256", + "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.", + "no-relations-text": "No relations found" + }, + "resource": { + "add": "Add Resource", + "copyId": "Copy resource Id", + "delete": "Delete resource", + "delete-resource-text": "Be careful, after the confirmation the resource will become unrecoverable.", + "delete-resource-title": "Are you sure you want to delete the resource '{{resourceTitle}}'?", + "delete-resources-action-title": "Delete { count, plural, 1 {1 resource} other {# resources} }", + "delete-resources-text": "Please note that the selected resources, even if they are used in device profiles, will be deleted.", + "delete-resources-title": "Are you sure you want to delete { count, plural, 1 {1 resource} other {# resources} }?", + "download": "Download resource", + "drop-file": "Drop a resource file or click to select a file to upload.", + "drop-resource-file-or": "Drag and drop a resource file or", + "empty": "Resource is empty", + "file-name": "File name", + "idCopiedMessage": "Resource Id has been copied to clipboard", + "no-resource-matching": "No resource matching '{{widgetsBundle}}' were found.", + "no-resource-text": "No resources found", + "open-widgets-bundle": "Open widgets bundle", + "resource": "Resource", + "resource-library-details": "Resource details", + "resource-type": "Resource type", + "resources-library": "Resources library", + "search": "Search resources", + "selected-resources": "{ count, plural, 1 {1 resource} other {# resources} } selected", + "system": "System", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256" + }, + "rulechain": { + "rulechain": "Rule chain", + "rulechains": "Rule chains", + "root": "Root", + "delete": "Delete rule chain", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "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", + "search": "Search rule chains", + "selected-rulechains": "{ count, plural, 1 {1 rule chain} other {# rule chains} } selected", + "open-rulechain": "Open rule chain", + "assign-new-rulechain": "Assign new rulechain", + "edge-template-root": "Template Root", + "assign-to-edge": "Assign to Edge", + "edge-rulechain": "Edge rule chain", + "unassign-rulechain-from-edge-text": "After the confirmation the rulechain will be unassigned and won't be accessible by the edge.", + "unassign-rulechains-from-edge-title": "Are you sure you want to unassign { count, plural, 1 {1 rulechain} other {# rulechains} }?", + "unassign-rulechains-from-edge-text": "After the confirmation all selected rulechains will be unassigned and won't be accessible by the edge.", + "assign-rulechain-to-edge-title": "Assign Rule Chain(s) To Edge", + "assign-rulechain-to-edge-text": "Please select the rulechains to assign to the edge", + "set-edge-template-root-rulechain": "Make rule chain as edge template root", + "set-edge-template-root-rulechain-title": "Are you sure you want to make the rule chain '{{ruleChainName}}' edge template root?", + "set-edge-template-root-rulechain-text": "After the confirmation the rule chain will become edge template root and will be root rule chain for a newly created edges.", + "invalid-rulechain-type-error": "Unable to import rule chain: Invalid rule chain type. Expected type is {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Assign rule chain to edge(s) on creation", + "set-auto-assign-to-edge-title": "Are you sure you want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?", + "set-auto-assign-to-edge-text": "After the confirmation the edge rule chain will be automatically assigned to edge(s) on creation.", + "unset-auto-assign-to-edge": "Do not assign rule chain to edge(s) on creation", + "unset-auto-assign-to-edge-title": "Are you sure you do not want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?", + "unset-auto-assign-to-edge-text": "After the confirmation the edge rule chain will no longer be automatically assigned to edge(s) on creation.", + "unassign-rulechain-title": "Are you sure you want to unassign the rulechain '{{ruleChainName}}'?", + "unassign-rulechains": "Unassign rulechains" + }, + "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.", + "name-max-length": "Name should be less than 256", + "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", + "create-nested-rulechain": "Create nested rule chain", + "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.", + "link-labels": "Link labels", + "link-labels-required": "Link labels is required.", + "no-link-labels-found": "No link labels found", + "no-link-label-matching": "'{{label}}' not found.", + "create-new-link-label": "Create a new one!", + "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-flow": "Flow", + "type-flow-details": "Organizes message flow", + "type-input": "Input", + "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", + "type-unknown": "Unknown", + "type-unknown-details": "Unresolved 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", + "script-lang-java-script": "Java Script", + "script-lang-tbel": "TBEL", + "message": "Message", + "message-type": "Message type", + "select-message-type": "Select 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", + "reset-debug-mode": "Reset debug mode in all nodes" + }, + "timezone": { + "timezone": "Timezone", + "select-timezone": "Select timezone", + "no-timezones-matching": "No timezones matching '{{timezone}}' were found.", + "timezone-required": "Timezone is required.", + "browser-time": "Browser Time" + }, + "queue": { + "queue-name": "Queue", + "no-queues-found": "No queues found.", + "no-queues-matching": "No queues matching '{{queue}}' were found.", + "select-name": "Select queue name", + "name": "Name", + "name-required": "Queue name is required!", + "name-unique": "Queue name is not unique!", + "name-pattern": "Queue name contains a character other than ASCII alphanumerics, '.', '_' and '-'!", + "queue-required": "Queue is required!", + "topic-required": "Queue topic is required!", + "poll-interval-required": "Poll interval is required!", + "poll-interval-min-value": "Poll interval value can't be less then 1", + "partitions-required": "Partitions is required!", + "partitions-min-value": "Partitions value can't be less then 1", + "pack-processing-timeout-required": "Processing timeout is required", + "pack-processing-timeout-min-value": "Processing timeout value can't be less then 1", + "batch-size-required": "Batch size is required!", + "batch-size-min-value": "Batch size value can't be less then 1", + "retries-required": "Retries is required!", + "retries-min-value": "Retries value can't be negative", + "failure-percentage-required": "Failure percentage is required!", + "failure-percentage-min-value": "Failure percentage value can't be less then 0", + "failure-percentage-max-value": "Failure percentage value can't be more then 100", + "pause-between-retries-required": "Pause between retries is required!", + "pause-between-retries-min-value": "Pause between retries value can't be less then 1", + "max-pause-between-retries-required": "Max pause between retries is required!", + "max-pause-between-retries-min-value": "Max pause between retries value can't be less then 1", + "submit-strategy-type-required": "Submit strategy type is required!", + "processing-strategy-type-required": "Processing strategy type is required!", + "queues": "Queues", + "selected-queues": "{ count, plural, 1 {1 queue} other {# queues} } selected", + "delete-queue-title": "Are you sure you want to delete the queue '{{queueName}}'?", + "delete-queues-title": "Are you sure you want to delete { count, plural, 1 {1 queue} other {# queues} }?", + "delete-queue-text": "Be careful, after the confirmation the queue and all related data will become unrecoverable.", + "delete-queues-text": "After the confirmation all selected queues will be deleted and won't be accessible.", + "search": "Search queue", + "add" : "Add queue", + "details": "Queue details", + "topic": "Topic", + "submit-settings": "Submit settings", + "submit-strategy": "Strategy type *", + "grouping-parameter": "Grouping parameter", + "processing-settings": "Retries processing settings", + "processing-strategy": "Processing type *", + "retries-settings": "Retries settings", + "polling-settings": "Polling settings", + "batch-processing": "Batch processing", + "poll-interval": "Poll interval", + "partitions": "Partitions", + "immediate-processing": "Immediate processing", + "consumer-per-partition": "Send message poll for each consumer", + "consumer-per-partition-hint": "Enable separate consumer(s) per each partition", + "processing-timeout": "Processing within, ms", + "batch-size": "Batch size", + "retries": "Number of retries (0 – unlimited)", + "failure-percentage": "Percentage of failure messages for skipping retries", + "pause-between-retries": "Retry within, sec", + "max-pause-between-retries": "Additional retry within, sec", + "delete": "Delete queue", + "copyId": "Copy queue Id", + "idCopiedMessage": "Queue Id has been copied to clipboard", + "description": "Description", + "description-hint": "This text will be displayed in the Queue description instead of the selected strategy", + "alt-description": "Submit Strategy: {{submitStrategy}}, Processing Strategy: {{processingStrategy}}", + "strategies": { + "sequential-by-originator-label": "Sequential by originator", + "sequential-by-originator-hint": "New message for e.g. device A is not submitted until previous message for device A is acknowledged", + "sequential-by-tenant-label": "Sequential by tenant", + "sequential-by-tenant-hint": "New message for e.g tenant A is not submitted until previous message for tenant A is acknowledged", + "sequential-label": "Sequential", + "sequential-hint": "New message is not submitted until previous message is acknowledged", + "burst-label": "Burst", + "burst-hint": "All messages are submitted to the rule chains in the order they arrive", + "batch-label": "Batch", + "batch-hint": "New batch is not submitted until previous batch is acknowledged", + "skip-all-failures-label": "Skip all failures", + "skip-all-failures-hint": "Ignore all failures", + "skip-all-failures-and-timeouts-label": "Skip all failures and timeouts", + "skip-all-failures-and-timeouts-hint": "Ignore all failures and timeouts", + "retry-all-label": "Retry all", + "retry-all-hint": "Retry all messages from processing pack", + "retry-failed-label": "Retry failed", + "retry-failed-hint": "Retry all failed messages from processing pack", + "retry-timeout-label": "Retry timeout", + "retry-timeout-hint": "Retry all timed-out messages from processing pack", + "retry-failed-and-timeout-label": "Retry failed and timeout", + "retry-failed-and-timeout-hint": "Retry all failed and timed-out messages from processing pack" + } + }, + "server-error": { + "general": "General server error", + "authentication": "Authentication error", + "jwt-token-expired": "JWT token expired", + "tenant-trial-expired": "Tenant trial expired", + "credentials-expired": "Credentials expired", + "permission-denied": "Permission denied", + "invalid-arguments": "Invalid arguments", + "bad-request-params": "Bad request params", + "item-not-found": "Item not found", + "too-many-requests": "Too many requests", + "too-many-updates": "Too many updates" + }, + "tenant": { + "tenant": "Tenant", + "tenants": "Tenants", + "management": "Tenant management", + "add": "Add Tenant", + "admins": "Admins", + "manage-tenant-admins": "Manage tenant admins", + "delete": "Delete tenant", + "add-tenant-text": "Add new tenant", + "no-tenants-text": "No tenants found", + "tenant-details": "Tenant details", + "title-max-length": "Title should be less than 256", + "delete-tenant-title": "Are you sure you want to delete the tenant '{{tenantTitle}}'?", + "delete-tenant-text": "Be careful, after the confirmation the tenant and all related data will become unrecoverable.", + "delete-tenants-title": "Are you sure you want to delete { count, plural, 1 {1 tenant} other {# tenants} }?", + "delete-tenants-action-title": "Delete { count, plural, 1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "Be careful, after the confirmation all selected tenants will be removed and all related data will become unrecoverable.", + "title": "Title", + "title-required": "Title is required.", + "description": "Description", + "details": "Details", + "events": "Events", + "copyId": "Copy tenant Id", + "idCopiedMessage": "Tenant Id has been copied to clipboard", + "select-tenant": "Select tenant", + "no-tenants-matching": "No tenants matching '{{entity}}' were found.", + "tenant-required": "Tenant is required", + "search": "Search tenants", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenants} } selected", + "isolated-tb-rule-engine": "Processing in isolated ThingsBoard Rule Engine container", + "isolated-tb-rule-engine-details": "Requires separate microservice(s) per isolated Tenant" + }, + "tenant-profile": { + "tenant-profile": "Tenant profile", + "tenant-profiles": "Tenant profiles", + "add": "Add tenant profile", + "edit": "Edit tenant profile", + "tenant-profile-details": "Tenant profile details", + "no-tenant-profiles-text": "No tenant profiles found", + "name-max-length": "Name should be less than 256", + "search": "Search tenant profiles", + "selected-tenant-profiles": "{ count, plural, 1 {1 tenant profile} other {# tenant profiles} } selected", + "no-tenant-profiles-matching": "No tenant profile matching '{{entity}}' were found.", + "tenant-profile-required": "Tenant profile is required", + "idCopiedMessage": "Tenant profile Id has been copied to clipboard", + "set-default": "Make tenant profile default", + "delete": "Delete tenant profile", + "copyId": "Copy tenant profile Id", + "name": "Name", + "name-required": "Name is required.", + "data": "Profile data", + "profile-configuration": "Profile configuration", + "description": "Description", + "default": "Default", + "delete-tenant-profile-title": "Are you sure you want to delete the tenant profile '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Be careful, after the confirmation the tenant profile and all related data will become unrecoverable.", + "delete-tenant-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 tenant profile} other {# tenant profiles} }?", + "delete-tenant-profiles-text": "Be careful, after the confirmation all selected tenant profiles will be removed and all related data will become unrecoverable.", + "set-default-tenant-profile-title": "Are you sure you want to make the tenant profile '{{tenantProfileName}}' default?", + "set-default-tenant-profile-text": "After the confirmation the tenant profile will be marked as default and will be used for new tenants with no profile specified.", + "no-tenant-profiles-found": "No tenant profiles found.", + "create-new-tenant-profile": "Create a new one!", + "create-tenant-profile": "Create new tenant profile", + "import": "Import tenant profile", + "export": "Export tenant profile", + "export-failed-error": "Unable to export tenant profile: {{error}}", + "tenant-profile-file": "Tenant profile file", + "invalid-tenant-profile-file-error": "Unable to import tenant profile: Invalid tenant profile data structure.", + "advanced-settings": "Advanced settings", + "entities": "Entities", + "rule-engine": "Rule Engine", + "time-to-live": "Time-to-live", + "alarms-and-notifications": "Alarms and notifications", + "ota-files-in-bytes": "OTA files in bytes", + "ws-title": "WS", + "unlimited": "(0 - unlimited)", + "maximum-devices": "Devices maximum number", + "maximum-devices-required": "Devices maximum number is required.", + "maximum-devices-range": "Devices maximum number can't be negative", + "maximum-assets": "Assets maximum number", + "maximum-assets-required": "Assets maximum number is required.", + "maximum-assets-range": "Assets maximum number can't be negative", + "maximum-customers": "Customers maximum number", + "maximum-customers-required": "Customers maximum number is required.", + "maximum-customers-range": "Customers maximum number can't be negative", + "maximum-users": "Users maximum number", + "maximum-users-required": "Users maximum number is required.", + "maximum-users-range": "Users maximum number can't be negative", + "maximum-dashboards": "Dashboards maximum number", + "maximum-dashboards-required": "Dashboards maximum number is required.", + "maximum-dashboards-range": "Dashboards maximum number can't be negative", + "maximum-rule-chains": "Rule chains maximum number", + "maximum-rule-chains-required": "Rule chains maximum number is required.", + "maximum-rule-chains-range": "Rule chains maximum number can't be negative", + "maximum-resources-sum-data-size": "Resource files sum size", + "maximum-resources-sum-data-size-required": "Resource files sum size is required.", + "maximum-resources-sum-data-size-range": "Resource files sum size can`t be negative", + "maximum-ota-packages-sum-data-size": "OTA package files sum size", + "maximum-ota-package-sum-data-size-required": "OTA package files sum size is required.", + "maximum-ota-package-sum-data-size-range": "OTA package files sum size can`t be negative", + "transport-tenant-msg-rate-limit": "Transport tenant messages", + "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetry messages", + "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetry data points", + "transport-device-msg-rate-limit": "Transport device messages", + "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages", + "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points", + "tenant-entity-export-rate-limit": "Entity version creation", + "tenant-entity-import-rate-limit": "Entity version load", + "max-transport-messages": "Transport messages maximum number", + "max-transport-messages-required": "Transport messages maximum number is required.", + "max-transport-messages-range": "Transport messages maximum number can't be negative", + "max-transport-data-points": "Transport data points maximum number ", + "max-transport-data-points-required": "Transport data points maximum number is required.", + "max-transport-data-points-range": "Transport data points maximum number can't be negative", + "max-r-e-executions": "Rule Engine executions maximum number", + "max-r-e-executions-required": "Rule Engine executions maximum number is required.", + "max-r-e-executions-range": "Rule Engine executions maximum number can't be negative", + "max-j-s-executions": "JavaScript executions maximum number ", + "max-j-s-executions-required": "JavaScript executions maximum number is required.", + "max-j-s-executions-range": "JavaScript executions maximum number can't be negative", + "max-d-p-storage-days": "Data points storage days maximum number", + "max-d-p-storage-days-required": "Data points storage days maximum number is required.", + "max-d-p-storage-days-range": "Data points storage days maximum number can't be negative", + "default-storage-ttl-days": "Storage TTL days by default", + "default-storage-ttl-days-required": "Storage TTL days by default is required.", + "default-storage-ttl-days-range": "Storage TTL days by default can't be negative", + "alarms-ttl-days": "Alarms TTL days", + "alarms-ttl-days-required": "Alarms TTL days required", + "alarms-ttl-days-days-range": "Alarms TTL days can't be negative", + "rpc-ttl-days": "RPC TTL days", + "rpc-ttl-days-required": "RPC TTL days required", + "rpc-ttl-days-days-range": "RPC TTL days can't be negative", + "max-rule-node-executions-per-message": "Rule node per message executions maximum number", + "max-rule-node-executions-per-message-required": "MRule node per message executions maximum number is required.", + "max-rule-node-executions-per-message-range": "Rule node per message executions maximum number can't be negative", + "max-emails": "Emails sent maximum number", + "max-emails-required": "Emails sent maximum number is required.", + "max-emails-range": "Emails sent maximum number can't be negative", + "max-sms": "SMS sent maximum number", + "max-sms-required": "SMS sent maximum number is required.", + "max-sms-range": "SMS sent maximum number can't be negative", + "max-created-alarms": "Alarms created maximum number", + "max-created-alarms-required": "Alarms created maximum number is required.", + "max-created-alarms-range": "Alarms created maximum number be negative", + "no-queue": "No Queue configured", + "add-queue": "Add Queue", + "queues-with-count": "Queues ({{count}})", + "tenant-rest-limits": "REST requests for tenant", + "customer-rest-limits": "REST requests for customer", + "incorrect-pattern-for-rate-limits": "The format is comma separated pairs of capacity and period (in seconds) with a colon between, e.g. 100:1,2000:60", + "too-small-value-zero": "The value must be bigger than 0", + "too-small-value-one": "The value must be bigger than 1", + "cassandra-tenant-limits-configuration": "Cassandra query for tenant", + "ws-limit-max-sessions-per-tenant": "Sessions per tenant maximum number", + "ws-limit-max-sessions-per-customer": "Sessions per customer maximum number", + "ws-limit-max-sessions-per-regular-user": "Sessions per regular user maximum number", + "ws-limit-max-sessions-per-public-user": "Sessions per public user maximum number", + "ws-limit-queue-per-session": "Message queue per session maximum size", + "ws-limit-max-subscriptions-per-tenant": "Subscriptions per tenant maximum number", + "ws-limit-max-subscriptions-per-customer": "Subscriptions per customer maximum number", + "ws-limit-max-subscriptions-per-regular-user": "Subscriptions per regular user maximum number", + "ws-limit-max-subscriptions-per-public-user": "Subscriptions per public user maximum number", + "ws-limit-updates-per-session": "WS updates per session", + "rate-limits": { + "add-limit": "Add limit", + "advanced-settings": "Advanced settings", + "edit-limit": "Edit limit", + "but-less-than": "but less than", + "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", + "edit-transport-tenant-telemetry-msg-title": "Edit transport tenant telemetry messages rate limits", + "edit-transport-tenant-telemetry-data-points-title": "Edit transport tenant telemetry data points rate limits", + "edit-transport-device-msg-title": "Edit transport device messages rate limits", + "edit-transport-device-telemetry-msg-title": "Edit transport device telemetry messages rate limits", + "edit-transport-device-telemetry-data-points-title": "Edit transport device telemetry data points rate limits", + "edit-transport-tenant-msg-rate-limit-title": "Edit transport tenant messages rate limits", + "edit-customer-rest-limits-title": "Edit REST requests for customer rate limits", + "edit-ws-limit-updates-per-session-title": "Edit WS updates per session rate limits", + "edit-cassandra-tenant-limits-configuration-title": "Edit Cassandra query for tenant rate limits", + "edit-tenant-entity-export-rate-limit-title": "Edit entity version creation rate limits", + "edit-tenant-entity-import-rate-limit-title": "Edit entity version load rate limits", + "messages-per": "messages per", + "not-set": "Not set", + "number-of-messages": "Number of messages", + "number-of-messages-required": "Number of messages is required.", + "number-of-messages-min": "Minimum value is 1.", + "preview": "Preview", + "per-seconds": "Per seconds", + "per-seconds-required": "Time rate is required.", + "per-seconds-min": "Minimum value is 1.", + "rate-limits": "Rate limits", + "remove-limit": "Remove limit", + "transport-tenant-msg": "Transport tenant messages", + "transport-tenant-telemetry-msg": "Transport tenant telemetry messages", + "transport-tenant-telemetry-data-points": "Transport tenant telemetry data points", + "transport-device-msg": "Transport device messages", + "transport-device-telemetry-msg": "Transport device telemetry messages", + "transport-device-telemetry-data-points": "Transport device telemetry data points", + "sec": "sec" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "seconds": "Seconds", + "advanced": "Advanced", + "predefined": { + "yesterday": "Yesterday", + "day-before-yesterday": "Day before yesterday", + "this-day-last-week": "This day last week", + "previous-week": "Previous week (Sun - Sat)", + "previous-week-iso": "Previous week (Mon - Sun)", + "previous-month": "Previous month", + "previous-year": "Previous year", + "current-hour": "Current hour", + "current-day": "Current day", + "current-day-so-far": "Current day so far", + "current-week": "Current week (Sun - Sat)", + "current-week-iso": "Current week (Mon - Sun)", + "current-week-so-far": "Current week so far (Sun - Sat)", + "current-week-iso-so-far": "Current week so far (Mon - Sun)", + "current-month": "Current month", + "current-month-so-far": "Current month so far", + "current-year": "Current year", + "current-year-so-far": "Current year so far" + } + }, + "timeunit": { + "milliseconds": "Milliseconds", + "seconds": "Seconds", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days" + }, + "timewindow": { + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "short": { + "days": "{ days, plural, 1 {1 day } other {# days } }", + "hours": "{ hours, plural, 1 {1 hour } other {# hours } }", + "minutes": "{{minutes}} min ", + "seconds": "{{seconds}} sec " + }, + "realtime": "Realtime", + "history": "History", + "last-prefix": "last", + "period": "from {{ startTime }} to {{ endTime }}", + "edit": "Edit timewindow", + "date-range": "Date range", + "last": "Last", + "time-period": "Time period", + "hide": "Hide", + "interval": "Interval" + }, + "user": { + "user": "User", + "users": "Users", + "customer-users": "Customer Users", + "tenant-admins": "Tenant Admins", + "sys-admin": "System administrator", + "tenant-admin": "Tenant administrator", + "customer": "Customer", + "anonymous": "Anonymous", + "add": "Add User", + "delete": "Delete user", + "add-user-text": "Add new user", + "no-users-text": "No users found", + "user-details": "User details", + "delete-user-title": "Are you sure you want to delete the user '{{userEmail}}'?", + "delete-user-text": "Be careful, after the confirmation the user and all related data will become unrecoverable.", + "delete-users-title": "Are you sure you want to delete { count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "Delete { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Be careful, after the confirmation all selected users will be removed and all related data will become unrecoverable.", + "activation-email-sent-message": "Activation email was successfully sent!", + "resend-activation": "Resend activation", + "email": "Email", + "email-required": "Email is required.", + "invalid-email-format": "Invalid email format.", + "first-name": "First Name", + "last-name": "Last Name", + "description": "Description", + "default-dashboard": "Default dashboard", + "always-fullscreen": "Always fullscreen", + "select-user": "Select user", + "no-users-matching": "No users matching '{{entity}}' were found.", + "user-required": "User is required", + "activation-method": "Activation method", + "display-activation-link": "Display activation link", + "send-activation-mail": "Send activation mail", + "activation-link": "User activation link", + "activation-link-text": "In order to activate user use the following activation link :", + "copy-activation-link": "Copy activation link", + "activation-link-copied-message": "User activation link has been copied to clipboard", + "details": "Details", + "login-as-tenant-admin": "Login as Tenant Admin", + "login-as-customer-user": "Login as Customer User", + "search": "Search users", + "selected-users": "{ count, plural, 1 {1 user} other {# users} } selected", + "disable-account": "Disable User Account", + "enable-account": "Enable User Account", + "enable-account-message": "User account was successfully enabled!", + "disable-account-message": "User account was successfully disabled!", + "copyId": "Copy user Id", + "idCopiedMessage": "User Id has been copied to clipboard" + }, + "value": { + "type": "Value type", + "string": "String", + "string-value": "String value", + "string-value-required": "String value is required", + "integer": "Integer", + "integer-value": "Integer value", + "integer-value-required": "Integer value is required", + "invalid-integer-value": "Invalid integer value", + "double": "Double", + "double-value": "Double value", + "double-value-required": "Double value is required", + "boolean": "Boolean", + "boolean-value": "Boolean value", + "false": "False", + "true": "True", + "long": "Long", + "json": "JSON", + "json-value": "JSON value", + "json-value-invalid": "JSON value has an invalid format", + "json-value-required": "JSON value is required." + }, + "version-control": { + "version-control": "Version control", + "management": "Version control management", + "search": "Search versions", + "branch": "Branch", + "default": "Default", + "select-branch": "Select branch", + "branch-required": "Branch is required", + "create-entity-version": "Create entity version", + "version-name": "Version name", + "version-name-required": "Version name is required", + "author": "Author", + "export-relations": "Export relations", + "export-attributes": "Export attributes", + "export-credentials": "Export credentials", + "entity-versions": "Entity versions", + "versions": "Versions", + "created-time": "Created time", + "version-id": "Version ID", + "no-entity-versions-text": "No entity versions found", + "no-versions-text": "No versions found", + "copy-full-version-id": "Copy full version id", + "create-version": "Create version", + "creating-version": "Creating version... Please wait", + "nothing-to-commit": "No changes to commit", + "restore-version": "Restore version", + "restore-entity-from-version": "Restore entity from version '{{versionName}}'", + "restoring-entity-version": "Restoring entity version... Please wait", + "load-relations": "Load relations", + "load-attributes": "Load attributes", + "load-credentials": "Load credentials", + "compare-with-current": "Compare with current", + "diff-entity-with-version": "Diff with entity version '{{versionName}}'", + "previous-difference": "Previous Difference", + "next-difference": "Next Difference", + "current": "Current", + "differences": "{ count, plural, 1 {1 difference} other {# differences} }", + "create-entities-version": "Create entities version", + "default-sync-strategy": "Default sync strategy", + "sync-strategy-merge": "Merge", + "sync-strategy-overwrite": "Overwrite", + "entities-to-export": "Entities to export", + "entities-to-restore": "Entities to restore", + "sync-strategy": "Sync strategy", + "all-entities": "All entities", + "no-entities-to-export-prompt": "Please specify entities to export", + "no-entities-to-restore-prompt": "Please specify entities to restore", + "add-entity-type": "Add entity type", + "remove-all": "Remove all", + "version-create-result": "{ added, plural, 0 {No entities} 1 {1 entity} other {# entities} } added.
    { modified, plural, 0 {No entities} 1 {1 entity} other {# entities} } modified.
    { removed, plural, 0 {No entities} 1 {1 entity} other {# entities} } removed.", + "remove-other-entities": "Remove other entities", + "find-existing-entity-by-name": "Find existing entity by name", + "restore-entities-from-version": "Restore entities from version '{{versionName}}'", + "restoring-entities-from-version": "Restoring entities... Please wait", + "no-entities-restored": "No entities restored", + "created": "{{created}} created", + "updated": "{{updated}} updated", + "deleted": "{{deleted}} deleted", + "remove-other-entities-confirm-text": "Be careful! This will permanently delete all current entities
    not present in the version you want to restore.

    Please type remove other entities to confirm.", + "auto-commit-to-branch": "auto-commit to {{ branch }} branch", + "default-create-entity-version-name": "{{entityName}} update", + "sync-strategy-merge-hint": "Creates or updates selected entities in the repository. All other repository entities are not modified.", + "sync-strategy-overwrite-hint": "Creates or updates selected entities in the repository. All other repository entities are deleted.", + "device-credentials-conflict": "Failed to load the device with external id {{entityId}}
    due to the same credentials are already present in the database for another device.
    Please consider disabling the load credentials setting in the restore form.", + "missing-referenced-entity": "Failed to load the {{sourceEntityTypeName}} with external id {{sourceEntityId}}
    because it references missing {{targetEntityTypeName}} with id {{targetEntityId}}.", + "runtime-failed": "Failed: {{message}}", + "auto-commit-settings-read-only-hint": "Auto-commit feature doesn't work with enabled read-only option in Repository settings." + }, + "widget": { + "widget-library": "Widgets Library", + "widget-bundle": "Widgets Bundle", + "all-bundles": "All bundles", + "select-widgets-bundle": "Select widgets bundle", + "management": "Widget management", + "editor": "Widget Editor", + "widget-type-not-found": "Problem loading widget configuration.
    Probably associated\n widget type was removed.", + "widget-type-load-error": "Widget wasn't loaded due to the following errors:", + "remove": "Remove widget", + "edit": "Edit widget", + "remove-widget-title": "Are you sure you want to remove the widget '{{widgetTitle}}'?", + "remove-widget-text": "After the confirmation the widget and all related data will become unrecoverable.", + "timeseries": "Time series", + "search-data": "Search data", + "no-data-found": "No data found", + "latest": "Latest values", + "rpc": "Control widget", + "alarm": "Alarm widget", + "static": "Static widget", + "select-widget-type": "Select widget type", + "missing-widget-title-error": "Widget title must be specified!", + "widget-saved": "Widget saved", + "unable-to-save-widget-error": "Unable to save widget! Widget has errors!", + "save": "Save widget", + "saveAs": "Save widget as", + "save-widget-type-as": "Save widget type as", + "save-widget-type-as-text": "Please enter new widget title and/or select target widgets bundle", + "toggle-fullscreen": "Toggle fullscreen", + "run": "Run widget", + "title": "Widget title", + "title-required": "Widget title is required.", + "type": "Widget type", + "resources": "Resources", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "Is module", + "remove-resource": "Remove resource", + "add-resource": "Add resource", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Settings schema", + "datakey-settings-schema": "Data key settings schema", + "latest-datakey-settings-schema": "Latest data key settings schema", + "widget-settings": "Widget settings", + "description": "Description", + "image-preview": "Image preview", + "settings-form-selector": "Settings form selector", + "data-key-settings-form-selector": "Data key settings form selector", + "latest-data-key-settings-form-selector": "Latest data key settings form selector", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Are you sure you want to remove the widget type '{{widgetName}}'?", + "remove-widget-type-text": "After the confirmation the widget type and all related data will become unrecoverable.", + "remove-widget-type": "Remove widget type", + "add-widget-type": "Add new widget type", + "widget-type-load-failed-error": "Failed to load widget type!", + "widget-template-load-failed-error": "Failed to load widget template!", + "add": "Add Widget", + "undo": "Undo widget changes", + "export": "Export widget", + "no-data": "No data to display on widget", + "data-overflow": "Widget displays {{count}} out of {{total}} entities", + "alarm-data-overflow": "Widget displays alarms for {{allowedEntities}} (maximum allowed) entities out of {{totalEntities}} entities", + "search": "Search widget", + "filter": "Widget filter type", + "loading-widgets": "Loading widgets..." + }, + "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", + "custom-pretty": "Custom action (with HTML template)", + "mobile-action": "Mobile 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)", + "state-display-type": "Dashboard state display option", + "open-normal": "Normal", + "open-in-separate-dialog": "Open in separate dialog", + "open-in-popover": "Open in popover", + "dialog-title": "Dialog title", + "dialog-hide-dashboard-toolbar": "Hide dashboard toolbar in dialog", + "dialog-width": "Dialog width in percents relative to viewport width", + "dialog-height": "Dialog height in percents relative to viewport height", + "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.", + "popover-preferred-placement": "Preferred popover placement", + "popover-placement-top": "Top", + "popover-placement-topLeft": "Top left", + "popover-placement-topRight": "Top right", + "popover-placement-right": "Right", + "popover-placement-rightTop": "Right top", + "popover-placement-rightBottom": "Right bottom", + "popover-placement-bottom": "Bottom", + "popover-placement-bottomLeft": "Bottom left", + "popover-placement-bottomRight": "Bottom right", + "popover-placement-left": "Left", + "popover-placement-leftTop": "Left top", + "popover-placement-leftBottom": "Left bottom", + "popover-hide-on-click-outside": "Hide popover on outside click", + "popover-hide-dashboard-toolbar": "Hide dashboard toolbar in popover", + "popover-width": "Popover width in browser units (ex. 100px, 25vw)", + "popover-height": "Popover height in browser units (ex. 100px, 25vh)", + "popover-style": "Popover style", + "open-new-browser-tab": "Open in a new browser tab", + "mobile": { + "action-type": "Mobile action type", + "action-type-required": "Mobile action type is required", + "take-picture-from-gallery": "Take picture from gallery", + "take-photo": "Take photo", + "map-direction": "Open map directions", + "map-location": "Open map location", + "scan-qr-code": "Scan QR Code", + "make-phone-call": "Make phone call", + "get-location": "Get phone location", + "take-screenshot": "Take screenshot" + } + }, + "widgets-bundle": { + "current": "Current bundle", + "widgets-bundles": "Widgets Bundles", + "add": "Add Widgets Bundle", + "delete": "Delete widgets bundle", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "description": "Description", + "image-preview": "Image preview", + "add-widgets-bundle-text": "Add new widgets bundle", + "no-widgets-bundles-text": "No widgets bundles found", + "empty": "Widgets bundle is empty", + "details": "Details", + "widgets-bundle-details": "Widgets bundle details", + "delete-widgets-bundle-title": "Are you sure you want to delete the widgets bundle '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Be careful, after the confirmation the widgets bundle and all related data will become unrecoverable.", + "delete-widgets-bundles-title": "Are you sure you want to delete { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "Delete { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Be careful, after the confirmation all selected widgets bundles will be removed and all related data will become unrecoverable.", + "no-widgets-bundles-matching": "No widgets bundles matching '{{widgetsBundle}}' were found.", + "widgets-bundle-required": "Widgets bundle is required.", + "system": "System", + "import": "Import widgets bundle", + "export": "Export widgets bundle", + "export-failed-error": "Unable to export widgets bundle: {{error}}", + "create-new-widgets-bundle": "Create new widgets bundle", + "widgets-bundle-file": "Widgets bundle file", + "invalid-widgets-bundle-file-error": "Unable to import widgets bundle: Invalid widgets bundle data structure.", + "search": "Search widget bundles", + "selected-widgets-bundles": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} } selected", + "open-widgets-bundle": "Open widgets bundle", + "loading-widgets-bundles": "Loading widgets bundles..." + }, + "widget-config": { + "data": "Data", + "settings": "Settings", + "advanced": "Advanced", + "title": "Title", + "title-tooltip": "Title Tooltip", + "general-settings": "General settings", + "display-title": "Display widget title", + "drop-shadow": "Drop shadow", + "enable-fullscreen": "Enable fullscreen", + "background-color": "Background color", + "text-color": "Text color", + "padding": "Padding", + "margin": "Margin", + "widget-style": "Widget style", + "widget-css": "Widget CSS", + "title-style": "Title style", + "mobile-mode-settings": "Mobile mode", + "order": "Order", + "height": "Height", + "mobile-hide": "Hide widget in mobile mode", + "desktop-hide": "Hide widget in desktop mode", + "units": "Special symbol to show next to value", + "decimals": "Number of digits after floating point", + "timewindow": "Timewindow", + "use-dashboard-timewindow": "Use dashboard timewindow", + "display-timewindow": "Display timewindow", + "legend": "Legend", + "display-legend": "Display legend", + "datasources": "Datasources", + "maximum-datasources": "Maximum { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", + "timeseries-key-error": "At least one timeseries data key should be specified", + "datasource-type": "Type", + "datasource-parameters": "Parameters", + "remove-datasource": "Remove datasource", + "add-datasource": "Add datasource", + "target-device": "Target device", + "alarm-source": "Alarm source", + "actions": "Actions", + "action": "Action", + "add-action": "Add action", + "search-actions": "Search actions", + "no-actions-text": "No actions found", + "action-source": "Action source", + "action-source-required": "Action source is required.", + "action-name": "Name", + "action-name-required": "Action name is required.", + "action-name-not-unique": "Another action with the same name already exists.
    Action name should be unique within the same action source.", + "action-icon": "Icon", + "show-hide-action-using-function": "Show/hide action using function", + "action-type": "Type", + "action-type-required": "Action type is required.", + "edit-action": "Edit action", + "delete-action": "Delete action", + "delete-action-title": "Delete widget action", + "delete-action-text": "Are you sure you want delete widget action with name '{{actionName}}'?", + "title-icon": "Title icon", + "display-icon": "Display title icon", + "icon-color": "Icon color", + "icon-size": "Icon size", + "advanced-settings": "Advanced settings", + "data-settings": "Data settings", + "no-data-display-message": "\"No data to display\" alternative message", + "data-page-size": "Maximum entities per datasource", + "settings-component-not-found": "Settings form component not found for selector '{{selector}}'" + }, + "widget-type": { + "import": "Import widget type", + "export": "Export widget type", + "export-failed-error": "Unable to export widget type: {{error}}", + "create-new-widget-type": "Create new widget type", + "widget-type-file": "Widget type file", + "invalid-widget-type-file-error": "Unable to import widget type: Invalid widget type data structure." + }, + "widgets": { + "chart": { + "common-settings": "Common settings", + "enable-stacking-mode": "Enable stacking mode", + "line-shadow-size": "Line shadow size", + "display-smooth-lines": "Display smooth (curved) lines", + "default-bar-width": "Default bar width for non-aggregated data (milliseconds)", + "bar-alignment": "Bar alignment", + "bar-alignment-left": "Left", + "bar-alignment-right": "Right", + "bar-alignment-center": "Center", + "default-font-size": "Default font size", + "default-font-color": "Default font color", + "thresholds-line-width": "Default line width for all thresholds", + "tooltip-settings": "Tooltip settings", + "show-tooltip": "Show tooltip", + "hover-individual-points": "Hover individual points", + "show-cumulative-values": "Show cumulative values in stacking mode", + "hide-zero-false-values": "Hide zero/false values from tooltip", + "tooltip-value-format-function": "Tooltip value format function", + "grid-settings": "Grid settings", + "show-vertical-lines": "Show vertical lines", + "show-horizontal-lines": "Show horizontal lines", + "grid-outline-border-width": "Grid outline/border width (px)", + "primary-color": "Primary color", + "background-color": "Background color", + "ticks-color": "Ticks color", + "xaxis-settings": "X axis settings", + "axis-title": "Axis title", + "xaxis-tick-labels-settings": "X axis tick labels settings", + "show-tick-labels": "Show axis tick labels", + "yaxis-settings": "Y axis settings", + "min-scale-value": "Minimum value on the scale", + "max-scale-value": "Maximum value on the scale", + "yaxis-tick-labels-settings": "Y axis tick labels settings", + "tick-step-size": "Step size between ticks", + "number-of-decimals": "The number of decimals to display", + "ticks-formatter-function": "Ticks formatter function", + "comparison-settings": "Comparison settings", + "enable-comparison": "Enable comparison", + "time-for-comparison": "Comparison period", + "time-for-comparison-previous-interval": "Previous interval (default)", + "time-for-comparison-days": "Day ago", + "time-for-comparison-weeks": "Week ago", + "time-for-comparison-months": "Month ago", + "time-for-comparison-years": "Year ago", + "time-for-comparison-custom-interval": "Custom interval", + "custom-interval-value": "Custom interval value (ms)", + "comparison-x-axis-settings": "Comparison X axis settings", + "axis-position": "Axis position", + "axis-position-top": "Top (default)", + "axis-position-bottom": "Bottom", + "custom-legend-settings": "Custom legend settings", + "enable-custom-legend": "Enable custom legend (this will allow you to use attribute/timeseries values in key labels)", + "key-name": "Key name", + "key-name-required": "Key name is required", + "key-type": "Key type", + "key-type-attribute": "Attribute", + "key-type-timeseries": "Timeseries", + "label-keys-list": "Keys list to use in labels", + "no-label-keys": "No keys configured", + "add-label-key": "Add new key", + "line-width": "Line width", + "color": "Color", + "data-is-hidden-by-default": "Data is hidden by default", + "disable-data-hiding": "Disable data hiding", + "remove-from-legend": "Remove datakey from legend", + "exclude-from-stacking": "Exclude from stacking(available in \"Stacking\" mode)", + "line-settings": "Line settings", + "show-line": "Show line", + "fill-line": "Fill line", + "points-settings": "Points settings", + "show-points": "Show points", + "points-line-width": "Line width of points", + "points-radius": "Radius of points", + "point-shape": "Point shape", + "point-shape-circle": "Circle", + "point-shape-cross": "Cross", + "point-shape-diamond": "Diamond", + "point-shape-square": "Square", + "point-shape-triangle": "Triangle", + "point-shape-custom": "Custom function", + "point-shape-draw-function": "Point shape draw function", + "show-separate-axis": "Show separate axis", + "axis-position-left": "Left", + "axis-position-right": "Right", + "thresholds": "Thresholds", + "no-thresholds": "No thresholds configured", + "add-threshold": "Add new threshold", + "show-values-for-comparison": "Show historical values for comparison", + "comparison-values-label": "Historical values label", + "threshold-settings": "Threshold settings", + "use-as-threshold": "Use key value as threshold", + "threshold-line-width": "Threshold line width", + "threshold-color": "Threshold color", + "common-pie-settings": "Common pie settings", + "radius": "Radius", + "inner-radius": "Inner radius", + "tilt": "Tilt", + "stroke-settings": "Stroke settings", + "width-pixels": "Width (pixels)", + "show-labels": "Show labels", + "animation-settings": "Animation settings", + "animated-pie": "Enable pie animation (experimental)", + "border-settings": "Border settings", + "border-width": "Border width", + "border-color": "Border color", + "legend-settings": "Legend settings", + "display-legend": "Display legend", + "labels-font-color": "Labels font color" + }, + "dashboard-state": { + "dashboard-state-settings": "Dashboard state settings", + "dashboard-state": "Dashboard state id", + "autofill-state-layout": "Autofill state layout height by default", + "default-margin": "Default widgets margin", + "default-background-color": "Default background color", + "sync-parent-state-params": "Sync state params with parent dashboard" + }, + "date-range-navigator": { + "date-range-picker-settings": "Date range picker settings", + "hide-date-range-picker": "Hide date range picker", + "picker-one-panel": "Date range picker one panel", + "picker-auto-confirm": "Date range picker auto confirm", + "picker-show-template": "Date range picker show template", + "first-day-of-week": "First day of the week", + "interval-settings": "Interval settings", + "hide-interval": "Hide interval", + "initial-interval": "Initial interval", + "interval-hour": "Hour", + "interval-day": "Day", + "interval-week": "Week", + "interval-two-weeks": "2 weeks", + "interval-month": "Month", + "interval-three-months": "3 months", + "interval-six-months": "6 months", + "step-settings": "Step settings", + "hide-step-size": "Hide step size", + "initial-step-size": "Initial step size", + "hide-labels": "Hide labels", + "use-session-storage": "Use session storage", + "localizationMap": { + "Sun": "Sun", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "May", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec", + "January": "January", + "February": "February", + "March": "March", + "April": "April", + "June": "June", + "July": "July", + "August": "August", + "September": "September", + "October": "October", + "November": "November", + "December": "December", + "Custom Date Range": "Custom Date Range", + "Date Range Template": "Date Range Template", + "Today": "Today", + "Yesterday": "Yesterday", + "This Week": "This Week", + "Last Week": "Last Week", + "This Month": "This Month", + "Last Month": "Last Month", + "Year": "Year", + "This Year": "This Year", + "Last Year": "Last Year", + "Date picker": "Date picker", + "Hour": "Hour", + "Day": "Day", + "Week": "Week", + "2 weeks": "2 Weeks", + "Month": "Month", + "3 months": "3 Months", + "6 months": "6 Months", + "Custom interval": "Custom interval", + "Interval": "Interval", + "Step size": "Step size", + "Ok": "Ok" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Hierarchy data settings", + "relations-query-function": "Node relations query function", + "has-children-function": "Node has children function", + "node-state-settings": "Node state settings", + "node-opened-function": "Default node opened function", + "node-disabled-function": "Node disabled function", + "display-settings": "Display settings", + "node-icon-function": "Node icon function", + "node-text-function": "Node text function", + "sort-settings": "Sort settings", + "nodes-sort-function": "Nodes sort function" + }, + "edge": { + "display-default-title": "Display default title" + }, + "gateway": { + "general-settings": "General settings", + "widget-title": "Widget title", + "default-archive-file-name": "Default archive file name", + "device-type-for-new-gateway": "Device type for new gateway", + "messages-settings": "Messages settings", + "save-config-success-message": "Text message about successfully saved gateway configuration", + "device-name-exists-message": "Text message when device with entered name is already exists", + "gateway-title": "Gateway form", + "read-only": "Read only", + "events-title": "Gateway events form title", + "events-filter": "Events filter", + "event-key-contains": "Event key contains..." + }, + "gauge": { + "default-color": "Default color", + "radial-gauge-settings": "Radial gauge settings", + "ticks-settings": "Ticks settings", + "min-value": "Minimum value", + "max-value": "Maximum value", + "start-ticks-angle": "Start ticks angle", + "ticks-angle": "Ticks angle", + "major-ticks-count": "Major ticks count", + "major-ticks-color": "Major ticks color", + "minor-ticks-count": "Minor ticks count", + "minor-ticks-color": "Minor ticks color", + "tick-numbers-font": "Tick numbers font", + "unit-title-settings": "Unit title settings", + "show-unit-title": "Show unit title", + "unit-title": "Unit title", + "title-font": "Title text font", + "units-settings": "Units settings", + "units-font": "Units text font", + "value-box-settings": "Value box settings", + "show-value-box": "Show value box", + "value-int": "Digits count for integer part of value", + "value-font": "Value text font", + "value-box-rect-stroke-color": "Value box rectangle stroke color", + "value-box-rect-stroke-color-end": "Value box rectangle stroke color - end gradient", + "value-box-background-color": "Value box background color", + "value-box-shadow-color": "Value box shadow color", + "plate-settings": "Plate settings", + "show-plate-border": "Show plate border", + "plate-color": "Plate color", + "needle-settings": "Needle settings", + "needle-circle-size": "Needle circle size", + "needle-color": "Needle color", + "needle-color-end": "Needle color - end gradient", + "needle-color-shadow-up": "Upper half of the needle shadow color", + "needle-color-shadow-down": "Drop shadow needle color", + "highlights-settings": "Highlights settings", + "highlights-width": "Highlights width", + "highlights": "Highlights", + "highlight-from": "From", + "highlight-to": "To", + "highlight-color": "Color", + "no-highlights": "No highlights configured", + "add-highlight": "Add highlight", + "animation-settings": "Animation settings", + "enable-animation": "Enable animation", + "animation-duration": "Animation duration", + "animation-rule": "Animation rule", + "animation-linear": "Linear", + "animation-quad": "Quad", + "animation-quint": "Quint", + "animation-cycle": "Cycle", + "animation-bounce": "Bounce", + "animation-elastic": "Elastic", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "Linear gauge settings", + "bar-stroke-width": "Bar stroke width", + "bar-stroke-color": "Bar stroke color", + "bar-background-color": "Gauge bar background color", + "bar-background-color-end": "Bar background color - end gradient", + "progress-bar-color": "Progress bar color", + "progress-bar-color-end": "Progress bar color - end gradient", + "major-ticks-names": "Major ticks names", + "show-stroke-ticks": "Show ticks stroke", + "major-ticks-font": "Major ticks font", + "border-color": "Border color", + "border-width": "Border width", + "needle-circle-color": "Needle circle color", + "animation-target": "Animation target", + "animation-target-needle": "Needle", + "animation-target-plate": "Plate", + "common-settings": "Common gauge settings", + "gauge-type": "Gauge type", + "gauge-type-arc": "Arc", + "gauge-type-donut": "Donut", + "gauge-type-horizontal-bar": "Horizontal bar", + "gauge-type-vertical-bar": "Vertical bar", + "donut-start-angle": "Angle to start from", + "bar-settings": "Gauge bar settings", + "relative-bar-width": "Relative bar width", + "neon-glow-brightness": "Neon glow effect brightness, (0-100), 0 - disable effect", + "stripes-thickness": "Thickness of the stripes, 0 - no stripes", + "rounded-line-cap": "Display rounded line cap", + "bar-color-settings": "Bar color settings", + "use-precise-level-color-values": "Use precise color levels", + "bar-colors": "Bar colors, from lower to upper", + "color": "Color", + "no-bar-colors": "No bar colors configured", + "add-bar-color": "Add bar color", + "from": "From", + "to": "To", + "fixed-level-colors": "Bar colors using boundary values", + "gauge-title-settings": "Gauge title settings", + "show-gauge-title": "Show gauge title", + "gauge-title": "Gauge title", + "gauge-title-font": "Gauge title font", + "unit-title-and-timestamp-settings": "Unit title and timestamp settings", + "show-timestamp": "Show value timestamp", + "timestamp-format": "Timestamp format", + "label-font": "Font of label showing under value", + "value-settings": "Value settings", + "show-value": "Show value text", + "min-max-settings": "Minimum/maximum labels settings", + "show-min-max": "Show min and max values", + "min-max-font": "Font of minimum and maximum labels", + "show-ticks": "Show ticks", + "tick-width": "Tick width", + "tick-color": "Tick color", + "tick-values": "Tick values", + "no-tick-values": "No tick values configured", + "add-tick-value": "Add tick value" + }, + "gpio": { + "pin": "Pin", + "label": "Label", + "row": "Row", + "column": "Column", + "color": "Color", + "panel-settings": "Panel settings", + "background-color": "Background color", + "gpio-switches": "GPIO switches", + "no-gpio-switches": "No GPIO switches configured", + "add-gpio-switch": "Add GPIO switch", + "gpio-status-request": "GPIO status request", + "method-name": "Method name", + "method-body": "Method body", + "gpio-status-change-request": "GPIO status change request", + "parse-gpio-status-function": "Parse gpio status function", + "gpio-leds": "GPIO leds", + "no-gpio-leds": "No GPIO leds configured", + "add-gpio-led": "Add GPIO led" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Attribute parameter cannot be used in this widget", + "blocked-location": "Geolocation is blocked in your browser", + "claim-device": "Claim device", + "claim-failed": "Failed to claim the device!", + "claim-not-found": "Device not found!", + "claim-successful": "Device was successfully claimed!", + "date": "Date", + "device-name": "Device name", + "device-name-required": "Device name is required", + "discard-changes": "Discard changes", + "entity-attribute-required": "Entity attribute is required", + "entity-coordinate-required": "Both fields, latitude and longitude, are required", + "entity-timeseries-required": "Entity timeseries is required", + "get-location": "Get current location", + "invalid-date": "Invalid Date", + "latitude": "Latitude", + "longitude": "Longitude", + "min-value-error": "Min value is {{value}}", + "max-value-error": "Max value is {{value}}", + "not-allowed-entity": "Selected entity cannot have shared attributes", + "no-attribute-selected": "No attribute is selected", + "no-datakey-selected": "No datakey is selected", + "no-coordinate-specified": "Datakey for latitude/longitude doesn't specified", + "no-entity-selected": "No entity selected", + "no-image": "No image", + "no-support-geolocation": "Your browser doesn't support geolocation", + "no-support-web-camera": "Your browser does not support cameras", + "enable-https-use-widget": "Please enable HTTPS to use this widget", + "no-found-your-camera": "Can't find your camera", + "no-permission-camera": "Permission was denied by the user / This site doesn't have permission to use the camera", + "no-timeseries-selected": "No timeseries selected", + "secret-key": "Secret key", + "secret-key-required": "Secret key is required", + "switch-attribute-value": "Switch entity attribute value", + "switch-camera": "Switch camera", + "switch-timeseries-value": "Switch entity timeseries value", + "take-photo": "Take photo", + "time": "Time", + "timeseries-not-allowed": "Timeseries parameter cannot be used in this widget", + "update-failed": "Update failed", + "update-successful": "Update successful", + "update-attribute": "Update attribute", + "update-timeseries": "Update timeseries", + "value": "Value", + "general-settings": "General settings", + "widget-title": "Widget title", + "claim-button-label": "Claiming button label", + "show-secret-key-field": "Show 'Secret key' input field", + "labels-settings": "Labels settings", + "show-labels": "Show labels", + "device-name-label": "Label for device name input field", + "secret-key-label": "Label for secret key input field", + "messages-settings": "Messages settings", + "claim-device-success-message": "Text message of successful device claiming", + "claim-device-not-found-message": "Text message when device not found", + "claim-device-failed-message": "Text message of failed device claiming", + "claim-device-name-required-message": "'Device name required' error message", + "claim-device-secret-key-required-message": "'Secret key required' error message", + "show-label": "Show label", + "label": "Label", + "required": "Required", + "required-error-message": "'Required' error message", + "show-result-message": "Show result message", + "integer-field-settings": "Integer field settings", + "min-value": "Min value", + "max-value": "Max value", + "double-field-settings": "Double field settings", + "text-field-settings": "Text field settings", + "min-length": "Min length", + "max-length": "Max length", + "checkbox-settings": "Checkbox settings", + "true-label": "Checked label", + "false-label": "Unchecked label", + "image-input-settings": "Image input settings", + "display-preview": "Display preview", + "display-clear-button": "Display clear button", + "display-apply-button": "Display apply button", + "display-discard-button": "Display discard button", + "datetime-field-settings": "Date/time field settings", + "display-time-input": "Display time input", + "latitude-key-name": "Latitude key name", + "longitude-key-name": "Longitude key name", + "show-get-location-button": "Show button 'Get current location'", + "use-high-accuracy": "Use high accuracy", + "location-fields-settings": "Location fields settings", + "latitude-label": "Label for latitude", + "longitude-label": "Label for longitude", + "input-fields-alignment": "Input fields alignment", + "input-fields-alignment-column": "Column (default)", + "input-fields-alignment-row": "Row", + "latitude-field-required": "Latitude field required", + "longitude-field-required": "Longitude field required", + "attribute-settings": "Attribute settings", + "widget-mode": "Widget mode", + "widget-mode-update-attribute": "Update attribute", + "widget-mode-update-timeseries": "Update timeseries", + "attribute-scope": "Attribute scope", + "attribute-scope-server": "Server attribute", + "attribute-scope-shared": "Shared attribute", + "value-required": "Value required", + "image-settings": "Image settings", + "image-format": "Image format", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Image quality that use lossy compression such as jpeg and webp", + "max-image-width": "Maximum image width", + "max-image-height": "Maximum image height", + "action-buttons": "Action buttons", + "show-action-buttons": "Show action buttons", + "update-all-values": "Update all values, not only modified", + "save-button-label": "'SAVE' button label", + "reset-button-label": "'UNDO' button label", + "group-settings": "Group settings", + "show-group-title": "Show title for group of fields, related to different entities", + "group-title": "Group title", + "fields-alignment": "Fields alignment", + "fields-alignment-row": "Row (default)", + "fields-alignment-column": "Column", + "fields-in-row": "Number of fields in the row", + "option-value": "Value (write 'null' for create empty option)", + "option-label": "Label", + "hide-input-field": "Hide input field", + "datakey-type": "Datakey type", + "datakey-type-server": "Server attribute (default)", + "datakey-type-shared": "Shared attribute", + "datakey-type-timeseries": "Timeseries", + "datakey-value-type": "Datakey value type", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Integer", + "datakey-value-type-boolean-checkbox": "Boolean (Checkbox)", + "datakey-value-type-boolean-switch": "Boolean (Switch)", + "datakey-value-type-date-time": "Date & Time", + "datakey-value-type-date": "Date", + "datakey-value-type-time": "Time", + "datakey-value-type-select": "Select", + "value-is-required": "Value is required", + "ability-to-edit-attribute": "Ability to edit attribute", + "ability-to-edit-attribute-editable": "Editable (default)", + "ability-to-edit-attribute-disabled": "Disabled", + "ability-to-edit-attribute-readonly": "Read-only", + "disable-on-datakey-name": "Disable on false value of another datakey (specify datakey name)", + "slide-toggle-settings": "Slide toggle settings", + "slide-toggle-label-position": "Slide toggle label position", + "slide-toggle-label-position-after": "After", + "slide-toggle-label-position-before": "Before", + "select-options": "Select options", + "no-select-options": "No select options configured", + "add-select-option": "Add select option", + "numeric-field-settings": "Numeric field settings", + "step-interval": "Step interval between values", + "error-messages": "Error messages", + "min-value-error-message": "'Min value' error message", + "max-value-error-message": "'Max value' error message", + "invalid-date-error-message": "'Invalid date' error message", + "icon-settings": "Icon settings", + "use-custom-icon": "Use custom icon", + "input-cell-icon": "Icon to show before input cell", + "value-conversion-settings": "Value conversion settings", + "get-value-settings": "Get value settings", + "use-get-value-function": "Use getValue function", + "get-value-function": "getValue function", + "set-value-settings": "Set value settings", + "use-set-value-function": "Use setValue function", + "set-value-function": "setValue function" + }, + "invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type", + "qr-code": { + "use-qr-code-text-function": "Use QR code text function", + "qr-code-text-pattern": "QR code text pattern (for ex. '${entityName} | ${keyName} - some text.')", + "qr-code-text-pattern-hint": "QR code text pattern use the value of the first found key in the entities in the entity alias.", + "qr-code-text-pattern-required": "QR code text pattern is required.", + "qr-code-text-function": "QR code text function" + }, + "label-widget": { + "label-pattern": "Pattern", + "label-pattern-hint": "Hint: for ex. 'Text ${keyName} units.' or ${#<key index>} units'", + "label-pattern-required": "Pattern is required", + "label-position": "Position (Percentage relative to background)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Background color", + "font-settings": "Font settings", + "background-image": "Background image", + "labels": "Labels", + "no-labels": "No labels configured", + "add-label": "Add label" + }, + "navigation": { + "title": "Title", + "navigation-path": "Navigation path", + "filter-type": "Filter type", + "filter-type-all": "All items", + "filter-type-include": "Include items", + "filter-type-exclude": "Exclude items", + "items": "Items", + "enter-urls-to-filter": "Enter urls to filter..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Message type", + "method": "Method", + "params": "Params", + "created-time": "Created time", + "expiration-time": "Expiration time", + "retries": "Retries", + "status": "Status", + "filter": "Filter", + "refresh": "Refresh", + "add": "Add RPC request", + "details": "Details", + "delete": "Delete", + "delete-request-title": "Delete Persistent RPC request", + "delete-request-text": "Are you sure you want to delete request?", + "details-title": "Details RPC ID: ", + "additional-info": "Additional info", + "response": "Response", + "any-status": "Any status", + "rpc-status-list": "RPC status list", + "no-request-prompt": "No request to display", + "send-request": "Send request", + "add-title": "Create Persistent RPC request", + "method-error": "Method is required.", + "timeout-error": "Min timeout value is 5000 (5 seconds).", + "white-space-error": "White space is not allowed.", + "rpc-status": { + "QUEUED": "QUEUED", + "SENT": "SENT", + "DELIVERED": "DELIVERED", + "SUCCESSFUL": "SUCCESSFUL", + "TIMEOUT": "TIMEOUT", + "EXPIRED": "EXPIRED", + "FAILED": "FAILED" + }, + "rpc-search-status-all": "ALL", + "message-types": { + "false": "Two-way", + "true": "One-way" + }, + "general-settings": "General settings", + "enable-filter": "Enable filter", + "enable-sticky-header": "Display header while scrolling", + "enable-sticky-action": "Display actions column while scrolling", + "display-request-details": "Display request details", + "allow-send-request": "Allow send RPC request", + "allow-delete-request": "Allow delete request", + "columns-settings": "Columns settings", + "display-columns": "Columns to display", + "column": "Column", + "no-columns-found": "No columns found", + "no-columns-matching": "'{{column}}' not found." + }, + "rpc": { + "value-settings": "Value settings", + "initial-value": "Initial value", + "retrieve-value-settings": "Retrieve on/off value settings", + "retrieve-value-method": "Retrieve value using method", + "retrieve-value-method-none": "Don't retrieve", + "retrieve-value-method-rpc": "Call RPC get value method", + "retrieve-value-method-attribute": "Subscribe for attribute", + "retrieve-value-method-timeseries": "Subscribe for timeseries", + "attribute-value-key": "Attribute key", + "timeseries-value-key": "Timeseries key", + "get-value-method": "RPC get value method", + "parse-value-function": "Parse value function", + "update-value-settings": "Update value settings", + "set-value-method": "RPC set value method", + "convert-value-function": "Convert value function", + "rpc-settings": "RPC settings", + "request-timeout": "RPC request timeout (ms)", + "persistent-rpc-settings": "Persistent RPC settings", + "request-persistent": "RPC request persistent", + "persistent-polling-interval": "Polling interval (ms) to get persistent RPC command response", + "common-settings": "Common settings", + "switch-title": "Switch title", + "show-on-off-labels": "Show on/off labels", + "slide-toggle-label": "Slide toggle label", + "label-position": "Label position", + "label-position-before": "Before", + "label-position-after": "After", + "slider-color": "Slider color", + "slider-color-primary": "Primary", + "slider-color-accent": "Accent", + "slider-color-warn": "Warn", + "button-style": "Button style", + "button-raised": "Raised button", + "button-primary": "Primary color", + "button-background-color": "Button background color", + "button-text-color": "Button text color", + "widget-title": "Widget title", + "button-label": "Button label", + "device-attribute-scope": "Device attribute scope", + "server-attribute": "Server attribute", + "shared-attribute": "Shared attribute", + "device-attribute-parameters": "Device attribute parameters", + "is-one-way-command": "Is one way command", + "rpc-method": "RPC method", + "rpc-method-params": "RPC method params", + "show-rpc-error": "Show RPC command execution error", + "led-title": "LED title", + "led-color": "LED color", + "check-status-settings": "Check status settings", + "perform-rpc-status-check": "Perform RPC device status check", + "retrieve-led-status-value-method": "Retrieve led status value using method", + "led-status-value-attribute": "Device attribute containing led status value", + "led-status-value-timeseries": "Device timeseries containing led status value", + "check-status-method": "RPC check device status method", + "parse-led-status-value-function": "Parse led status value function", + "knob-title": "Knob title", + "min-value": "Minimum value", + "max-value": "Maximum value" + }, + "maps": { + "select-entity": "Select entity", + "select-entity-hint": "Hint: after selection click at the map to set position", + "tooltips": { + "placeMarker": "Click to place '{{entityName}}' entity", + "firstVertex": "Polygon for '{{entityName}}': click to place first point", + "firstVertex-cut": "Click to place first point", + "continueLine": "Polygon for '{{entityName}}': click to continue drawing", + "continueLine-cut": "Click to continue drawing", + "finishLine": "Click any existing marker to finish", + "finishPoly": "Polygon for '{{entityName}}': click first marker to finish and save", + "finishPoly-cut": "Click first marker to finish and save", + "finishRect": "Polygon for '{{entityName}}': click to finish and save", + "startCircle": "Circle for '{{entityName}}': click to place circle center", + "finishCircle": "Circle for '{{entityName}}': click to finish circle", + "placeCircleMarker": "Click to place circle marker" + }, + "actions": { + "finish": "Finish", + "cancel": "Cancel", + "removeLastVertex": "Remove last point" + }, + "buttonTitles": { + "drawMarkerButton": "Place entity", + "drawPolyButton": "Create polygon", + "drawLineButton": "Create polyline", + "drawCircleButton": "Create circle", + "drawRectButton": "Create rectangle", + "editButton": "Edit mode", + "dragButton": "Drag-drop mode", + "cutButton": "Cut polygon area", + "deleteButton": "Remove", + "drawCircleMarkerButton": "Create circle marker", + "rotateButton": "Rotate polygon" + }, + "map-provider-settings": "Map provider settings", + "map-provider": "Map provider", + "map-provider-google": "Google maps", + "map-provider-openstreet": "OpenStreet maps", + "map-provider-here": "HERE maps", + "map-provider-image": "Image map", + "map-provider-tencent": "Tencent maps", + "openstreet-provider": "OpenStreet map provider", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Default)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Use custom provider", + "custom-provider-tile-url": "Custom provider tile URL", + "google-maps-api-key": "Google Maps API Key", + "default-map-type": "Default map type", + "google-map-type-roadmap": "Roadmap", + "google-map-type-satelite": "Satellite", + "google-map-type-hybrid": "Hybrid", + "google-map-type-terrain": "Terrain", + "map-layer": "Map layer", + "here-map-normal-day": "HERE.normalDay (Default)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Credentials", + "here-app-id": "HERE app id", + "here-app-code": "HERE app code", + "tencent-maps-api-key": "Tencent Maps API Key", + "tencent-map-type-roadmap": "Roadmap", + "tencent-map-type-satelite": "Satellite", + "tencent-map-type-hybrid": "Hybrid", + "image-map-background": "Image map background", + "image-map-background-from-entity-attribute": "Take image map background from entity attribute", + "image-url-source-entity-alias": "Image URL source entity alias", + "image-url-source-entity-attribute": "Image URL source entity attribute", + "common-map-settings": "Common map settings", + "x-pos-key-name": "X position key name", + "y-pos-key-name": "Y position key name", + "latitude-key-name": "Latitude key name", + "longitude-key-name": "Longitude key name", + "default-map-zoom-level": "Default map zoom level (0 - 20)", + "default-map-center-position": "Default map center position (0,0)", + "disable-scroll-zooming": "Disable scroll zooming", + "disable-double-click-zooming": "Disable double click zooming", + "disable-zoom-control-buttons": "Disable zoom control buttons", + "fit-map-bounds": "Fit map bounds to cover all markers", + "use-default-map-center-position": "Use default map center position", + "entities-limit": "Limit of entities to load", + "markers-settings": "Markers settings", + "marker-offset-x": "Marker X offset relative to position multiplied by marker width", + "marker-offset-y": "Marker Y offset relative to position multiplied by marker height", + "position-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each", + "draggable-marker": "Draggable marker", + "label": "Label", + "show-label": "Show label", + "use-label-function": "Use label function", + "label-pattern": "Label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "label-function": "Label function", + "tooltip": "Tooltip", + "show-tooltip": "Show tooltip", + "show-tooltip-action": "Action for displaying the tooltip", + "show-tooltip-action-click": "Show tooltip on click (Default)", + "show-tooltip-action-hover": "Show tooltip on hover", + "auto-close-tooltips": "Auto-close tooltips", + "use-tooltip-function": "Use tooltip function", + "tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "tooltip-function": "Tooltip function", + "tooltip-offset-x": "Tooltip X offset relative to marker anchor multiplied by marker width", + "tooltip-offset-y": "Tooltip Y offset relative to marker anchor multiplied by marker height", + "color": "Color", + "use-color-function": "Use color function", + "color-function": "Color function", + "marker-image": "Marker image", + "use-marker-image-function": "Use marker image function", + "custom-marker-image": "Custom marker image", + "custom-marker-image-size": "Custom marker image size (px)", + "marker-image-function": "Marker image function", + "marker-images": "Marker images", + "polygon-settings": "Polygon settings", + "show-polygon": "Show polygon", + "polygon-key-name": "Polygon key name", + "enable-polygon-edit": "Enable polygon edit", + "polygon-label": "Polygon label", + "show-polygon-label": "Show polygon label", + "use-polygon-label-function": "Use polygon label function", + "polygon-label-pattern": "Polygon label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "polygon-label-function": "Polygon label function", + "polygon-tooltip": "Polygon tooltip", + "show-polygon-tooltip": "Show polygon tooltip", + "auto-close-polygon-tooltips": "Auto-close polygon tooltips", + "use-polygon-tooltip-function": "Use polygon tooltip function", + "polygon-tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "polygon-tooltip-function": "Polygon tooltip function", + "polygon-color": "Polygon color", + "polygon-opacity": "Polygon opacity", + "use-polygon-color-function": "Use polygon color function", + "polygon-color-function": "Polygon color function", + "polygon-stroke": "Polygon stroke", + "stroke-color": "Stroke color", + "stroke-opacity": "Stroke opacity", + "stroke-weight": "Stroke weight", + "use-polygon-stroke-color-function": "Use polygon stroke color function", + "polygon-stroke-color-function": "Polygon stroke color function", + "circle-settings": "Circle settings", + "show-circle": "Show circle", + "circle-key-name": "Circle key name", + "enable-circle-edit": "Enable circle edit", + "circle-label": "Circle label", + "show-circle-label": "Show circle label", + "use-circle-label-function": "Use circle label function", + "circle-label-pattern": "Circle label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "circle-label-function": "Circle label function", + "circle-tooltip": "Circle tooltip", + "show-circle-tooltip": "Show circle tooltip", + "auto-close-circle-tooltips": "Auto-close circle tooltips", + "use-circle-tooltip-function": "Use circle tooltip function", + "circle-tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "circle-tooltip-function": "Circle tooltip function", + "circle-fill-color": "Circle fill color", + "circle-fill-color-opacity": "Circle fill color opacity", + "use-circle-fill-color-function": "Use circle fill color function", + "circle-fill-color-function": "Circle fill color function", + "circle-stroke": "Circle stroke", + "use-circle-stroke-color-function": "Use circle stroke color function", + "circle-stroke-color-function": "Circle stroke color function", + "markers-clustering-settings": "Markers clustering settings", + "use-map-markers-clustering": "Use map markers clustering", + "zoom-on-cluster-click": "Zoom when clicking on a cluster", + "max-cluster-zoom": "The maximum zoom level when a marker can be part of a cluster (0 - 18)", + "max-cluster-radius-pixels": "Maximum radius that a cluster will cover in pixels", + "cluster-zoom-animation": "Show animation on markers when zooming", + "show-markers-bounds-on-cluster-mouse-over": "Show the bounds of markers when mouse over a cluster", + "spiderfy-max-zoom-level": "Spiderfy at the max zoom level (to see all cluster markers)", + "load-optimization": "Load optimization", + "cluster-chunked-loading": "Use chunks for adding markers so that the page does not freeze", + "cluster-markers-lazy-load": "Use lazy load for adding markers", + "editor-settings": "Editor settings", + "enable-snapping": "Enable snapping to other vertices for precision drawing", + "init-draggable-mode": "Initialize map in draggable mode", + "hide-all-edit-buttons": "Hide all edit control buttons", + "hide-draw-buttons": "Hide draw buttons", + "hide-edit-buttons": "Hide edit buttons", + "hide-remove-button": "Hide remove button", + "route-map-settings": "Route map settings", + "trip-animation-settings": "Trip animation settings", + "normalization-step": "Normalization data step (ms)", + "tooltip-background-color": "Tooltip background color", + "tooltip-font-color": "Tooltip font color", + "tooltip-opacity": "Tooltip opacity (0-1)", + "auto-close-tooltip": "Auto-close tooltip", + "rotation-angle": "Set additional rotation angle for marker (deg)", + "path-settings": "Path settings", + "path-color": "Path color", + "use-path-color-function": "Use path color function", + "path-color-function": "Path color function", + "path-decorator": "Path decorator", + "use-path-decorator": "Use path decorator", + "decorator-symbol": "Decorator symbol", + "decorator-symbol-arrow-head": "Arrow", + "decorator-symbol-dash": "Dash", + "decorator-symbol-size": "Decorator symbol size (px)", + "use-path-decorator-custom-color": "Use path decorator custom color", + "decorator-custom-color": "Decorator custom color", + "decorator-offset": "Decorator offset", + "end-decorator-offset": "End decorator offset", + "decorator-repeat": "Decorator repeat", + "points-settings": "Points settings", + "show-points": "Show points", + "point-color": "Point color", + "point-size": "Point size (px)", + "use-point-color-function": "Use point color function", + "point-color-function": "Point color function", + "use-point-as-anchor": "Use point as anchor", + "point-as-anchor-function": "Point as anchor function", + "independent-point-tooltip": "Independent point tooltip", + "clustering-markers": "Clustering markers", + "use-icon-create-function": "Use markers colour function", + "marker-color-function": "Marker color function" + }, + "markdown": { + "use-markdown-text-function": "Use markdown/HTML value function", + "markdown-text-function": "Markdown/HTML value function", + "markdown-text-pattern": "Markdown/HTML pattern (markdown or HTML with variables, for ex. '${entityName} or ${keyName} - some text.')", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label-position": "Label position", + "label-position-left": "Left", + "label-position-top": "Top" + }, + "table": { + "common-table-settings": "Common Table Settings", + "enable-search": "Enable search", + "enable-sticky-header": "Always display header", + "enable-sticky-action": "Always display actions column", + "hidden-cell-button-display-mode": "Hidden cell button actions display mode", + "show-empty-space-hidden-action": "Show empty space instead of hidden cell button action", + "dont-reserve-space-hidden-action": "Don't reserve space for hidden action buttons", + "display-timestamp": "Display timestamp column", + "display-milliseconds": "Display timestamp milliseconds", + "display-pagination": "Display pagination", + "default-page-size": "Default page size", + "use-entity-label-tab-name": "Use entity label in tab name", + "hide-empty-lines": "Hide empty lines", + "row-style": "Row style", + "use-row-style-function": "Use row style function", + "row-style-function": "Row style function", + "cell-style": "Cell style", + "use-cell-style-function": "Use cell style function", + "cell-style-function": "Cell style function", + "cell-content": "Cell content", + "use-cell-content-function": "Use cell content function", + "cell-content-function": "Cell content function", + "show-latest-data-column": "Show latest data column", + "latest-data-column-order": "Latest data column order", + "entities-table-title": "Entities table title", + "enable-select-column-display": "Enable select columns to display", + "display-entity-name": "Display entity name column", + "entity-name-column-title": "Entity name column title", + "display-entity-label": "Display entity label column", + "entity-label-column-title": "Entity label column title", + "display-entity-type": "Display entity type column", + "default-sort-order": "Default sort order", + "custom-title": "Custom header title", + "column-width": "Column width (px or %)", + "default-column-visibility": "Default column visibility", + "column-visibility-visible": "Visible", + "column-visibility-hidden": "Hidden", + "column-visibility-hidden-mobile": "Hidden in mobile mode", + "column-selection-to-display": "Column selection in 'Columns to Display'", + "column-selection-to-display-enabled": "Enabled", + "column-selection-to-display-disabled": "Disabled", + "alarms-table-title": "Alarms table title", + "enable-alarms-selection": "Enable alarms selection", + "enable-alarms-search": "Enable alarms search", + "enable-alarm-filter": "Enable alarm filter", + "display-alarm-details": "Display alarm details", + "allow-alarms-ack": "Allow alarms acknowledgment", + "allow-alarms-clear": "Allow alarms clear" + }, + "value-source": { + "value-source": "Value source", + "predefined-value": "Predefined value", + "entity-attribute": "Value taken from entity attribute", + "value": "Value", + "source-entity-alias": "Source entity alias", + "source-entity-attribute": "Source entity attribute" + }, + "widget-font": { + "font-family": "Font family", + "size": "Size", + "relative-font-size": "Relative font size (percents)", + "font-style": "Style", + "font-style-normal": "Normal", + "font-style-italic": "Italic", + "font-style-oblique": "Oblique", + "font-weight": "Weight", + "font-weight-normal": "Normal", + "font-weight-bold": "Bold", + "font-weight-bolder": "Bolder", + "font-weight-lighter": "Lighter", + "color": "Color", + "shadow-color": "Shadow color" + } + }, + "icon": { + "icon": "Icon", + "select-icon": "Select icon", + "material-icons": "Material icons", + "show-all": "Show all icons" + }, + "phone-input": { + "phone-input-label": "Phone number", + "phone-input-required": "Phone number is required", + "phone-input-validation": "Phone number is invalid or not possible", + "phone-input-pattern": "Invalid phone number. Should be in E.164 format, ex. {{phoneNumber}}", + "phone-input-hint": "Phone Number in E.164 format, ex. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Action cell button", + "row-click": "On row click", + "polygon-click": "On polygon click", + "marker-click": "On marker click", + "circle-click": "On circle click", + "tooltip-tag-action": "Tooltip tag action", + "node-selected": "On node selected", + "element-click": "On HTML element click", + "pie-slice-click": "On slice click", + "row-double-click": "On row double click" + } + }, + "paginator" : { + "items-per-page": "Items per page:", + "first-page-label": "First page", + "last-page-label": "Last page", + "next-page-label": "Next page", + "previous-page-label": "Previous page", + "items-per-page-separator": "of" + }, + "language": { + "language": "Language", + "locales": { + "de_DE": "Deutsch", + "fr_FR": "Français", + "zh_CN": "简体中文", + "zh_TW": "繁體中文", + "en_US": "English", + "it_IT": "Italiano", + "ko_KR": "한국어", + "ru_RU": "Русский", + "es_ES": "Español", + "ca_ES": "Catalan", + "ja_JP": "日本語", + "tr_TR": "Türkçe", + "fa_IR": "فارسي", + "uk_UA": "Українська", + "cs_CZ": "Česky", + "el_GR": "Ελληνικά", + "ro_RO": "Română", + "lv_LV": "Latviešu", + "ka_GE": "ქართული", + "pt_BR": "Português do Brasil", + "sl_SI": "Slovenščina", + "da_DK": "Dansk" + } + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json new file mode 100644 index 0000000..68305d1 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -0,0 +1,4581 @@ +{ + "access": { + "unauthorized": "No autorizado", + "unauthorized-access": "Acceso no autorizado", + "unauthorized-access-text": "Debes iniciar sesión para tener acceso a este recurso!", + "access-forbidden": "Acceso Prohibido", + "access-forbidden-text": "No tienes suficientes derechos para acceder a esta ubicación!
    Intenta iniciar sesión con otro usuario si todavía quieres acceder a esta ubicación.", + "refresh-token-expired": "La sesión ha expirado", + "refresh-token-failed": "No se puede actualizar la sesión", + "permission-denied": "Permiso Denegado", + "permission-denied-text": "No tienes suficientes derechos para realizar esta operación!" + }, + "action": { + "activate": "Activar", + "suspend": "Suspender", + "save": "Guardar", + "saveAs": "Guardar como", + "cancel": "Cancelar", + "ok": "OK", + "delete": "Borrar", + "add": "Agregar", + "yes": "Si", + "no": "No", + "update": "Actualizar", + "remove": "Eliminar", + "select": "Seleccionar", + "search": "Buscar", + "clear-search": "Borrar búsqueda", + "assign": "Asignar", + "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", + "decline-changes": "Descartar cambios", + "close": "Cerrar", + "back": "Atrás", + "run": "Ejecutar", + "sign-in": "Entrar!", + "edit": "Editar", + "view": "Ver", + "create": "Crear", + "drag": "Arrastrar", + "refresh": "Actualizar", + "undo": "Deshacer", + "copy": "Copiar", + "paste": "Pegar", + "copy-reference": "Copiar referencia", + "paste-reference": "Pegar referencia", + "import": "Importar", + "export": "Exportar", + "share-via": "Compartir vía {{provider}}", + "continue": "Continuar", + "discard-changes": "Cancelar cambios", + "download": "Descargar", + "next-with-label": "Siguiente: {{label}}", + "read-more": "Leer más", + "hide": "Ocultar", + "done": "Terminado", + "print": "Imprimir", + "restore": "Restaurar", + "confirm": "Confirmar" + }, + "aggregation": { + "aggregation": "Agrupación", + "function": "Función de Agrupación", + "limit": "Valores Max", + "group-interval": "Intervalo de agrupación", + "min": "Min", + "max": "Max", + "avg": "Promedio", + "sum": "Suma", + "count": "Contar", + "none": "Ninguno" + }, + "admin": { + "general": "General", + "general-settings": "Configuración general", + "home-settings": "Ajustes Home", + "outgoing-mail": "Servidor de correo", + "outgoing-mail-settings": "Configuración del servidor de correo de salida", + "system-settings": "Sistema", + "test-mail-sent": "Mail de prueba enviado correctamente!", + "base-url": "URL Base", + "base-url-required": "URL Base requerida.", + "prohibit-different-url": "Prohibir el uso de hostname en cabeceras de request del cliente", + "prohibit-different-url-hint": "Este ajuste debe ser activado en entornos de producción. Puede causar fallos de seguridad si está desactivado", + "mail-from": "Mail Desde", + "mail-from-required": "Mail Desde requerido.", + "smtp-protocol": "Protocolo SMTP", + "smtp-host": "Host SMTP", + "smtp-host-required": "Host SMTP 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.", + "enable-tls": "Habilitar TLS", + "tls-version": "Versión TLS", + "enable-proxy": "Habilitar proxy", + "proxy-host": "Host proxy", + "proxy-host-required": "Se requiere host Proxy.", + "proxy-port": "Puerto proxy", + "proxy-port-required": "Se requiere puerto proxy.", + "proxy-port-range": "El puerto proxy debe estar en un rango de 1 a 65535.", + "proxy-user": "Usuario proxy", + "proxy-password": "Contraseña proxy", + "change-password": "Cambiar contraseña", + "send-test-mail": "Enviar correo de prueba", + "sms-provider": "Proveedor SMS", + "sms-provider-settings": "Ajustes proveedor SMS", + "sms-provider-type": "Tipo de proveedor SMS", + "sms-provider-type-required": "Se requiere proveedor SMS.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID requerido", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key requerido", + "aws-region": "Región AWS", + "aws-region-required": "Se requere región AWS", + "number-from": "Nº de teléfono Origen", + "number-from-required": "Se requere Nº de teléfono origen.", + "number-to": "Nº de teléfono de destino", + "number-to-required": "Se requere Nº de teléfono de destino.", + "phone-number-hint": "Nº de teléfono en formato E.164, ej. +19995550123", + "phone-number-hint-twilio": "Nº de teléfono en formato E.164 SID/SID de servicio de mensajería, ej. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Nº Inválido. Debe estar en formato E.164, ej. +19995550123.", + "phone-number-pattern-twilio": "Nº Inválido. Debe estar en formato E.164 , ej. +19995550123/PNXXX/MGXXX.", + "sms-message": "Mensaje SMS", + "sms-message-required": "Se requeriere mensaje SMS.", + "sms-message-max-length": "Los SMS no pueden ser más largos de 1600 caracteres", + "twilio-account-sid": "SID de cuenta Twilio", + "twilio-account-sid-required": "Se requere SID de cuenta Twilio", + "twilio-account-token": "Token de cuenta Twilio", + "twilio-account-token-required": "Se requiere Token Twilio", + "send-test-sms": "Enviar SMS de prueba", + "test-sms-sent": "SMS enviado con éxito!", + "security-settings": "Configuraciones de seguridad", + "password-policy": "Política de contraseñas", + "minimum-password-length": "Longitud mínima de contraseña", + "minimum-password-length-required": "Se requiere una longitud mínima de contraseña", + "minimum-password-length-range": "La longitud mínima de la contraseña debe estar en un rango de 5 a 50", + "minimum-uppercase-letters": "Número mínimo de letras mayúsculas", + "minimum-uppercase-letters-range": "El número mínimo de letras mayúsculas no puede ser negativo", + "minimum-lowercase-letters": "Número mínimo de letras minúsculas", + "minimum-lowercase-letters-range": "El número mínimo de letras minúsculas no puede ser negativo", + "minimum-digits": "Número mínimo de dígitos", + "minimum-digits-range": "El número mínimo de dígitos no puede ser negativo", + "minimum-special-characters": "Número mínimo de caracteres especiales.", + "minimum-special-characters-range": "El número mínimo de caracteres especiales no puede ser negativo.", + "password-expiration-period-days": "Periodo de caducidad de contraseña en días", + "password-expiration-period-days-range": "El período de caducidad de la contraseña en días no puede ser negativo", + "password-reuse-frequency-days": "Frecuencia de reutilización de contraseña en días", + "password-reuse-frequency-days-range": "La frecuencia de reutilización de contraseña en días no puede ser negativa", + "allow-whitespace": "Permitir espacios en blanco", + "general-policy": "Política general", + "max-failed-login-attempts": "Número máximo de intentos fallidos de inicio de sesión, antes de que la cuenta esté bloqueada", + "minimum-max-failed-login-attempts-range": "El número máximo de intentos fallidos de inicio de sesión no puede ser negativo", + "user-lockout-notification-email": "En caso de bloqueo de la cuenta del usuario, envíe una notificación por correo electrónico", + "domain-name": "Nombre de dominio", + "domain-name-unique": "El nombre de dominio y protocolo debe ser único.", + "domain-name-max-length": "El nombre de dominio debe ser menor que 256", + "error-verification-url": "Un nombre de dominio no debe contener símbolos '/' y ':'. Ejemplo: thingsboard.io", + "oauth2": { + "access-token-uri": "URI Access token", + "access-token-uri-required": "Se requere URI Access token.", + "activate-user": "Activar usuario", + "add-domain": "Añadir dominio", + "delete-domain": "Borrar dominio", + "add-provider": "Añadir proveedor", + "delete-provider": "Borrar proveedor", + "allow-user-creation": "Permitir creación de usuario", + "always-fullscreen": "Siempre pantalla completa", + "authorization-uri": "URI Autorización", + "authorization-uri-required": "Se requiere URI de Autorización.", + "client-authentication-method": "Método de autenticación", + "client-id": "ID Cliente", + "client-id-required": "Se requere ID Cliente.", + "client-id-max-length": "El ID Cliente debe ser menor de 256", + "client-secret": "Secreto de Cliente", + "client-secret-required": "Se requiere Secreto de Cliente.", + "client-secret-max-length": "El secreto de cliente debe ser menor de 2049", + "custom-setting": "Ajustes personalizados", + "customer-name-pattern": "Patrón nombre de cliente", + "customer-name-pattern-max-length": "El patrón del nombre de cliente debe ser menor de 256", + "default-dashboard-name": "Nombre de panel por defecto", + "default-dashboard-name-max-length": "El nombre del panel debe ser menor de 256", + "delete-domain-text": "Atención, tras la confirmación el dominio y todos los datos del proveedor no estarán disponibles.", + "delete-domain-title": "Eliminar los ajustes del dominio '{{domainName}}'?", + "delete-registration-text": "Atención, tras la confirmación los datos del proveedor no estarán disponibles.", + "delete-registration-title": "Eliminar el proveedor '{{name}}'?", + "email-attribute-key": "Clave de atributos email", + "email-attribute-key-required": "Se requiere clave de atributos de email.", + "email-attribute-key-max-length": "La clave de atributos de email debe ser menor de 32", + "first-name-attribute-key": "Clave de atributos de nombre", + "first-name-attribute-key-max-length": "La clave de atributos de nombre debe ser menor de 32", + "general": "General", + "jwk-set-uri": "URI web key JSON", + "last-name-attribute-key": "Clave de atributos de apellido", + "last-name-attribute-key-max-length": "La clave de atributos de apellido debe ser menor de 32", + "login-button-icon": "Icono de botón login", + "login-button-label": "Etiqueta de proveedor", + "login-button-label-placeholder": "Login con $(Provider label)", + "login-button-label-required": "Clave de etiqueta requerida.", + "login-provider": "Proveedor de login", + "mapper": "Mapeador", + "new-domain": "Nuevo dominio", + "oauth2": "OAuth2", + "password-max-length": "La contraseña debe ser menor de 256", + "redirect-uri-template": "Plantilla de redirección URI", + "copy-redirect-uri": "Copiar URI de redirección", + "registration-id": "ID de registro", + "registration-id-required": "Se requiere ID de registro.", + "registration-id-unique": "El ID de registro debe ser único en el sistema.", + "scope": "Alcance", + "scope-required": "Se requiere alcance.", + "tenant-name-pattern": "Patrón de nombre de propietario", + "tenant-name-pattern-required": "Se requiere patrón de nombre de propietario.", + "tenant-name-pattern-max-length": "EL patrón de nombre de propietario debe ser menor de 256", + "tenant-name-strategy": "Estrategia de Nombre de Propietario", + "type": "Tipo de mapeador", + "uri-pattern-error": "Formato de URI inválido.", + "url": "URL", + "url-pattern": "Formato URL inválido.", + "url-required": "Se requiere URL.", + "url-max-length": "URL debe ser menor de 256", + "user-info-uri": "URI Información de usuario", + "user-info-uri-required": "Se requiere URI de información usuario.", + "username-max-length": "El usuario debe ser menor de 256", + "user-name-attribute-name": "Clave de atributos de nombre de usuario", + "user-name-attribute-name-required": "Se requiere clave de atributos de nombre de usuario", + "protocol": "Protocolo", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Activar ajustes OAuth2", + "domains": "Dominios", + "mobile-apps": "Aplicaciones móviles", + "no-mobile-apps": "No hay aplicaciones configuradas", + "mobile-package": "Paquete de aplicación", + "mobile-package-placeholder": "Ej.: mi.ejemplo.app", + "mobile-package-hint": "Para Android: El ID único de aplicación. Para iOS: Identificador Product bundle.", + "mobile-package-unique": "El paquete de aplicación debe ser único.", + "mobile-app-secret": "Secreto de aplicación", + "invalid-mobile-app-secret": "El secreto de aplicación sólo puede contener carácteres alfanuméricos y debe tener entre 16 y 2048 carácteres de longitud.", + "copy-mobile-app-secret": "Copiar secreto de aplicación", + "add-mobile-app": "Añadir aplicación", + "delete-mobile-app": "Borrar información de aplicación", + "providers": "Proveedores", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Todas las plataformas", + "allowed-platforms": "Plataformas permitidas" + }, + "smpp-provider": { + "smpp-version": "Versión SMPP", + "smpp-host": "Host SMPP", + "smpp-host-required": "Host SMPP requerido", + "smpp-port": "Puerto SMPP", + "smpp-port-required": "Puerto SMPP requerido", + "system-id": "System ID", + "system-id-required": "System ID requerido", + "password": "Contraseña", + "password-required": "Contraseña requerida", + "type-settings": "Ajustes de tipo", + "source-settings": "Ajustes de origen", + "destination-settings": "Ajustes de destino", + "additional-settings": "Ajustes adicionales", + "system-type": "Tipo de sistema", + "bind-type": "Tipo de enlace", + "service-type": "Tipo de servicio", + "source-address": "Dirección de origen", + "source-ton": "Origen TON", + "source-npi": "Origen NPI", + "destination-ton": "Destino TON (Tipo de número)", + "destination-npi": "Destino NPI (Numbering Plan Identification)", + "address-range": "Rango de dirección", + "coding-scheme": "Esquema de codificación", + "bind-type-tx": "Transmisor", + "bind-type-rx": "Receptor", + "bind-type-trx": "Transceptor", + "ton-unknown": "Desconocido", + "ton-international": "Internacional", + "ton-national": "Nacional", + "ton-network-specific": "Específico de red", + "ton-subscriber-number": "Número de suscriptor", + "ton-alphanumeric": "Alfanumérico", + "ton-abbreviated": "Abreviado", + "npi-unknown": "0 - Desconocido", + "npi-isdn": "1 - RDSI/Teléfono plan numérico (E163/E164)", + "npi-data-numbering-plan": "3 - Datos plan numérico (X.121)", + "npi-telex-numbering-plan": "4 - Telex plan numérico (F.69)", + "npi-land-mobile": "6 - Línea Móvil (E.212)", + "npi-national-numbering-plan": "8 - Nacional", + "npi-private-numbering-plan": "9 - Privado", + "npi-ermes-numbering-plan": "10 - ERMES (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (a definir por WAP Forum)", + "scheme-smsc": "0 - Alfabeto por defecto SMSC (ASCII para códigos cortos y largos y GSM para gratuitos)", + "scheme-ia5": "1 - IA5 (ASCII para códigos cortos y largos, Latin 9 para gratuitos (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octetos sin especificar (binario 8-bit)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octetos sin especificar (binario 8-bit)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Ciríllico (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebreo (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Codificación por Pictograma", + "scheme-music-codes": "10 - Códigos musicales (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Kanji extendido JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Set de carácteres Koreanos (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Seleccionar nombre de cola", + "queue-name": "Nombre", + "queue-name-required": "Nombre de cola requerido!", + "queues": "Colas", + "queue-partitions": "Particiones", + "queue-submit-strategy": "Estrategia de envíos", + "queue-processing-strategy": "Estrategia de procesamiento", + "queue-configuration": "Configuración Cola", + "repository-settings": "Ajustes del repositorio", + "repository-url": "URL del repositorio", + "repository-url-required": "Se requiere la URL del repositorio.", + "default-branch": "Nombre de rama por defecto", + "authentication-settings": "Ajustes de autenticación", + "auth-method": "Método de autenticación", + "auth-method-username-password": "Usuario / Contraseña", + "auth-method-private-key": "Clave privada", + "password-access-token": "Contraseña / Tóken de acceso", + "change-password-access-token": "Cambiar contraseña / Tóken de acceso", + "private-key": "Clave privada", + "drop-private-key-file-or": "Arrastrar y soltar un fichero de llave privada o", + "passphrase": "Frase de contraseña", + "enter-passphrase": "Entrar frase de contraseña", + "change-passphrase": "Cambiar frase de contraseña", + "check-access": "Verificar acceso", + "check-repository-access-success": "Acceso al repositorio verificado satisfactoriamente!", + "delete-repository-settings-title": "Estás seguro de borrar los ajustes del repositorio?", + "delete-repository-settings-text": "Atención, tras la confirmación los ajustes del repositorio serán eliminados y la característica de control de versiones no estará disponible.", + "auto-commit-settings": "Ajustes Auto-publicar", + "auto-commit-entities": "Entidades Auto-publicar", + "no-auto-commit-entities-prompt": "No hay entidades configuradas para auto-publicar", + "delete-auto-commit-settings-title": "Estás seguro de borrar los ajustes de auto-publicar?", + "delete-auto-commit-settings-text": "Atención, tras la confirmación los ajustes de auto-publicar serán borrados y la característica de auto-publicar se desactivará para todas las entidades.", + "2fa": { + "2fa": "Autenticación de dos factores (2FA)", + "available-providers": "Proveedores disponibles", + "issuer-name": "Nombre de emisor", + "issuer-name-required": "Nombre de emisor requerido.", + "max-verification-failures-before-user-lockout": "Máximo de fallos de verificación antes de bloquear cuenta", + "max-verification-failures-before-user-lockout-pattern": "Máximo de fallos debe ser un número entero positivo.", + "number-of-checking-attempts": "Número de intentos de verificación", + "number-of-checking-attempts-pattern": "Número de intentos debe ser un número entero positivo.", + "number-of-checking-attempts-required": "Número de intentos requerido.", + "number-of-codes": "Número de códigos", + "number-of-codes-pattern": "Número de códigos debe ser un número entero positivo.", + "number-of-codes-required": "Número de códigos requerido.", + "provider": "Proveedor", + "retry-verification-code-period": "Reintentos de código de verificación (sec)", + "retry-verification-code-period-pattern": "El período mínimo es de 5 sec", + "retry-verification-code-period-required": "Reintentos requerido.", + "total-allowed-time-for-verification": "Total de tiempo permitido para verificación (sec)", + "total-allowed-time-for-verification-pattern": "El mínimo del total de tiempo perminito es de 60 sec", + "total-allowed-time-for-verification-required": "Total de tiempo requerido.", + "use-system-two-factor-auth-settings": "Usar ajustes 2FA del sistema", + "verification-code-check-rate-limit": "Límite de chequeo del código de verificación", + "verification-code-lifetime": "Tiempo de vida del código de verificación (sec)", + "verification-code-lifetime-pattern": "Tiempo de vida del código de verificación debe ser un número entero positivo.", + "verification-code-lifetime-required": "Tiempo de vida requerido.", + "verification-message-template": "Plantilla del mensaje de verificación", + "verification-limitations": "Límites de verificación", + "verification-message-template-pattern": "El mensaje de verificación debe contener el patrón: ${code}", + "verification-message-template-required": "Plantilla de mensaje de verificación requerida.", + "within-time": "Rango de tiempo (sec)", + "within-time-pattern": "Tiempo debe ser un número entero positivo.", + "within-time-required": "Tiempo requerido." + } + }, + "alarm": { + "alarm": "Alarma", + "alarms": "Alarmas", + "select-alarm": "Seleccionar alarma", + "no-alarms-matching": "No se han encontrado alarmas coincidentes con '{{entity}}' .", + "alarm-required": "Alarma requerida", + "alarm-status": "Estado de Alarma", + "alarm-status-list": "Lista de estados de Alarmas", + "any-status": "Cualquier estado", + "search-status": { + "ANY": "Todas", + "ACTIVE": "Activas", + "CLEARED": "Borradas", + "ACK": "Reconocidas", + "UNACK": "Ignoradas" + }, + "display-status": { + "ACTIVE_UNACK": "Activa No reconocida", + "ACTIVE_ACK": "Activa Reconocida", + "CLEARED_UNACK": "Normalizada no reconocida", + "CLEARED_ACK": "Normalizada reconocida" + }, + "no-alarms-prompt": "No se encontraron alarmas", + "created-time": "Hora de creación", + "type": "Tipo", + "severity": "Gravedad", + "originator": "Origen", + "originator-type": "Tipo de origen", + "details": "Detalles", + "status": "Estado", + "alarm-details": "Detalles Alarma", + "start-time": "Hora de inicio", + "end-time": "Hora fin", + "ack-time": "Hora de reconocimiento", + "clear-time": "Hora de normalización", + "alarm-severity-list": "Lista de gravedad de alarmas", + "any-severity": "Cualquier gravedad", + "severity-critical": "Crítica", + "severity-major": "Mayor", + "severity-minor": "Menor", + "severity-warning": "Peligro", + "severity-indeterminate": "Indeterminada", + "acknowledge": "Reconocer", + "clear": "Normalizar", + "search": "Buscar alarmas", + "selected-alarms": "{ count, plural, 1 {1 alarma} other {# alarmas} } seleccionadas", + "no-data": "Sin datos que mostrar", + "polling-interval": "Ciclo de refresco de alarmas (seg)", + "polling-interval-required": "Se requiere un ciclo de refresco válido.", + "min-polling-interval-message": "El ciclo debe ser por lo menos de 1 segundo.", + "aknowledge-alarms-title": "Reconocer { count, plural, 1 {1 alarma} other {# alarmas} }", + "aknowledge-alarms-text": "Estas seguro de reconocer { count, plural, 1 {1 alarma} other {# alarmas} }?", + "aknowledge-alarm-title": "Recononcer Alarma", + "aknowledge-alarm-text": "Estas seguro de reconocer Alarma?", + "clear-alarms-title": "Normalizar { count, plural, 1 {1 alarma} other {# alarmas} }", + "clear-alarms-text": "Limpiar { count, plural, 1 {1 alarma} other {# alarmas} }?", + "clear-alarm-title": "Limpiar Alarma", + "clear-alarm-text": "Limpiar Alarma?", + "alarm-status-filter": "Filtro de estados de Alarmas", + "alarm-filter": "Filtro de Alarmas", + "max-count-load": "Número máximo de alarmas a cargar (0 - ilimitado)", + "max-count-load-required": "Se requiere número máximo de alarmas.", + "max-count-load-error-min": "El valor mínimo es 0.", + "fetch-size": "Tamaño de búsqueda (Fetch)", + "fetch-size-required": "Se requiere tamaño de búsqueda.", + "fetch-size-error-min": "El valor mínimo es 10.", + "alarm-type-list": "Lista de tipos de alarma", + "any-type": "Cualquier tipo", + "search-propagated-alarms": "Buscar alarmas propagadas" + }, + "alias": { + "add": "Añadir alias", + "edit": "Editar alias", + "name": "Nombre de Alias", + "name-required": "Se requiere nombre de alias", + "duplicate-alias": "Ya existe ese nombre de alias.", + "filter-type-single-entity": "Única entidad", + "filter-type-entity-list": "Lista de entidades", + "filter-type-entity-name": "Nombre de entidad", + "filter-type-entity-type": "Tipo de entidad", + "filter-type-state-entity": "Entidad desde estado de panel", + "filter-type-state-entity-description": "Entidad tomada de los parámetros de panel", + "filter-type-asset-type": "Tipo de activo", + "filter-type-asset-type-description": "Activos del tipo '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Activos del tipo '{{assetType}}' y cuyo nombre comience por '{{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 cuyo nombre comience por '{{prefix}}'", + "filter-type-entity-view-type": "Tipo de vista de entidad", + "filter-type-entity-view-type-description": "Vistas de entidad del tipo '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Vistas de entidad del tipo '{{entityView}}' y cuyo nombre comience por '{{prefix}}'", + "filter-type-edge-type": "Tipo de borde", + "filter-type-edge-type-description": "Bordes del tipo '{{edgeType}}'", + "filter-type-edge-type-and-name-description": "Vistas de entidad del tipo '{{entityViewType}}' y cuyo nombre comience por '{{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": "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": "Búsqueda de dispositivos", + "filter-type-device-search-query-description": "Dispositivos con tipos {{deviceTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Consulta de búsqueda de vista de entidad", + "filter-type-entity-view-search-query-description": "Vistas de entidad con tipos {{entityViewTypes}} que tienen tipo de relación {{relationType}} con dirección {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Uso de API", + "filter-type-edge-search-query": "Consultar búsqueda de borde", + "filter-type-edge-search-query-description": "Bordes con tipos {{edgeTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "entity-filter": "Filtro de entidad", + "resolve-multiple": "Resolver como múltiples entidades", + "filter-type": "Tipo de filtro", + "filter-type-required": "Se requiere tipo de filtro.", + "entity-filter-no-entity-matched": "No se encontraron entidades que coincidan con el filtro especificado.", + "no-entity-filter-specified": "No se ha especificado filtro de entidad", + "root-state-entity": "Usar entidad de estado del panel como raíz", + "last-level-relation": "Obtener sólo el último nivel de relación", + "root-entity": "Entidad raíz", + "state-entity-parameter-name": "Parámetro de estado de entidad", + "default-state-entity": "Entidad de estado por defecto", + "default-entity-parameter-name": "Por defecto", + "max-relation-level": "Máximo nivel de relación", + "unlimited-level": "Nivel ilimitado", + "state-entity": "Entidad de estado del panel", + "all-entities": "Todas las entidades", + "any-relation": "cualquiera" + }, + "asset": { + "asset": "Activo", + "assets": "Activos", + "management": "Gestión de Activos", + "view-assets": "Ver Activos", + "add": "Añadir Activo", + "asset-type-max-length": "El tipo de activo debe ser menor de 256", + "assign-to-customer": "Asignar a cliente", + "assign-asset-to-customer": "Asignar Activo(s) A Cliente", + "assign-asset-to-customer-text": "Selecciona los activos a asignar al cliente", + "no-assets-text": "No se encontraron activos", + "assign-to-customer-text": "Selecciona el cliente al que asignar los activos", + "public": "Público", + "assignedToCustomer": "Asignado a cliente", + "make-public": "Hacer activo público", + "make-private": "Hacer activo privado", + "unassign-from-customer": "Cancelar la asignación de activo del cliente", + "delete": "Borrar activo", + "asset-public": "El activo es público", + "asset-type": "Tipo de activo", + "asset-type-required": "Se requiere tipo de activo.", + "select-asset-type": "Selecciona tipo de activo", + "enter-asset-type": "Entrar tipo de activo", + "any-asset": "Cualquier activo", + "no-asset-types-matching": "No se han encontrado activos coincidiendo con '{{entitySubtype}}' .", + "asset-type-list-empty": "No hay ningun tipo de activo seleccionado.", + "asset-types": "Tipos de activo", + "name": "Nombre", + "name-required": "Nombre requerido.", + "name-max-length": "El nombre debe tener menos de 256", + "label-max-length": "La etiqueta debe tener menos de 256", + "description": "Descripción", + "type": "Tipo", + "type-required": "Tipo requerido.", + "details": "Detalles", + "events": "Eventos", + "add-asset-text": "Añadir nuevo activo", + "asset-details": "Detalles de activo", + "assign-assets": "Asignar activos", + "assign-assets-text": "Asignar { count, plural, 1 {1 activo} other {# activos} } a cliente", + "assign-asset-to-edge-title": "Asignar activo(s) al borde", + "assign-asset-to-edge-text": "Por favor, selecciona lo activos a asignar al borde", + "delete-assets": "Borrar activos", + "unassign-assets": "Cancelar asignación de activo", + "unassign-assets-action-title": "Cancelar asignación de { count, plural, 1 {1 activo} other {# activos} } del cliente", + "assign-new-asset": "Asignar nuevo activo", + "delete-asset-title": "Eliminar el activo '{{assetName}}'?", + "delete-asset-text": "Atención, tras la confirmación el activo y sus datos serán borrados e irrecuperables.", + "delete-assets-title": "Eliminar los activos { count, plural, 1 {1 activo} other {# activos} }?", + "delete-assets-action-title": "Borrar { count, plural, 1 {1 activo} other {# activos} }", + "delete-assets-text": "Atención, tras la confirmación todos los activos seleccionados y sus datos serán borrados e irrecuperables.", + "make-public-asset-title": "Hacer el activo '{{assetName}}' público?", + "make-public-asset-text": "Tras la confirmación, el activo y sus datos se harán públicos y accesibles por otros.", + "make-private-asset-title": "Hacer el activo '{{assetName}}' privado?", + "make-private-asset-text": "Tras la confirmación, el activo y sus datos se harán privados y no serán accesibles por otros.", + "unassign-asset-title": "Cancelar la asignación del activo '{{assetName}}'?", + "unassign-asset-text": "Tras la confirmación, el activo será desasignado y no será accesible por el cliente.", + "unassign-asset": "Cancelar asignación de activo", + "unassign-assets-title": "Cancelar las asignaciones { count, plural, 1 {1 activo} other {# activos} }?", + "unassign-assets-text": "Tras la confirmación todos los activos seleccionados serán desasignados y no serán accesibles por el cliente.", + "unassign-assets-from-edge": "Anular activos de borde", + "copyId": "Copiar ID de activo", + "idCopiedMessage": "El ID ha sido copiado al portapapeles", + "select-asset": "Seleccionar activo", + "no-assets-matching": "No se han encontrado activos que coincidan con '{{entity}}' .", + "asset-required": "Nombre de activo requerido", + "name-starts-with": "El nombre de activo comienza con", + "help-text": "Usar el símbolo '%' de acuerdo a las necesidades: '%nombre_activo_contiene%', '%nombre_activo_acaba_en', 'nombre_activo_comienza_con'.", + "import": "Importar activos", + "asset-file": "Archivo del activo", + "label": "Etiqueta", + "search": "Buscar activos", + "assign-asset-to-edge": "Asignar activo(s) al borde", + "unassign-asset-from-edge": "Anular activo de bodre", + "unassign-asset-from-edge-title": "¿Está seguro de que desea desasignar el activo '{{assetName}}'?", + "unassign-asset-from-edge-text": "Después de la confirmación, el activo no será asignado y el borde no podrá acceder a él", + "unassign-assets-from-edge-title": "¿Está seguro de que desea desasignar {count, plural, 1 {1 activo} other {# activos} }?", + "unassign-assets-from-edge-text": "Después de la confirmación, todos los activos seleccionados quedarán sin asignar y el borde no podrá acceder a ellos.", + "selected-assets": "{ count, plural, 1 {1 activo} other {# activos} } seleccionados" + }, + "attribute": { + "attributes": "Atributos", + "latest-telemetry": "Última telemetría", + "attributes-scope": "Alcance de los atributos del dispositivo", + "scope-latest-telemetry": "Última telemetría", + "scope-client": "Atributos de Cliente", + "scope-server": "Atributos de Servidor", + "scope-shared": "Atributos Compartidos", + "add": "Agregar atributo", + "key": "Clave", + "key-max-length": "La clave debe ser menor de 256", + "last-update-time": "Hora de última actualización", + "key-required": "Clave del atributo requerida.", + "value": "Valor", + "value-required": "Valor del atributo requerido.", + "delete-attributes-title": "¿Eliminar { count, plural, 1 {1 atributo} other {# atributos} }?", + "delete-attributes-text": "Atención, tras la confirmación el atributo será eliminado, y la información relacionada será irrecuperable.", + "delete-attributes": "Borrar atributo", + "enter-attribute-value": "Ingresar valor del atributo", + "show-on-widget": "Mostrar en Widget", + "widget-mode": "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 telemetría} other {# telemetrías} } seleccionadas", + "no-attributes-text": "No se encontró ningún atributo", + "no-telemetry-text": "No se encontró ninguna telemetría" + }, + "api-usage": { + "api-usage": "Uso de API", + "alarm": "Alarma", + "alarms-created": "Alarmas creadas", + "alarms-created-daily-activity": "Actividad diaria de Alarmas creadas", + "alarms-created-hourly-activity": "Actividad horaria de Alarmas creadas", + "alarms-created-monthly-activity": "Actividad mensual de Alarmas creadas", + "data-points": "Puntos de datos", + "data-points-storage-days": "Días de guardado de puntos de datos", + "email": "Email", + "email-messages": "Mensajes de Email", + "email-messages-daily-activity": "Actividad diaria de Emails", + "email-messages-monthly-activity": "Actividad mensual de Emails", + "exceptions": "Excepciones", + "executions": "Ejecuciones", + "javascript": "JavaScript", + "javascript-executions": "Ejecuciones JavaScript", + "javascript-functions": "Funciones JavaScript", + "javascript-functions-daily-activity": "Actividad diaria de funciones JavaScript", + "javascript-functions-hourly-activity": "Actividad horaria de funciones JavaScript", + "javascript-functions-monthly-activity": "Actividad mensual de funciones JavaScript", + "latest-error": "Último error", + "messages": "Mensajes", + "notifications": "Notificaciones", + "notifications-email-sms": "Notificaciones (Email/SMS)", + "notifications-hourly-activity": "Notificaciones actividad horaria", + "permanent-failures": "${entityName} Fallos permanentes", + "permanent-timeouts": "${entityName} Timeouts permanentes", + "processing-failures": "${entityName} Fallos de procesamiento", + "processing-failures-and-timeouts": "Fallos de procesamiento y timeouts", + "processing-timeouts": "${entityName} Timeouts de procesamiento", + "queue-stats": "Estadísticas de colas", + "rule-chain": "Cadena de reglas", + "rule-engine": "Motor de reglas", + "rule-engine-daily-activity": "Actividad diaria de motor de reglas", + "rule-engine-executions": "Ejecuciones de motor de reglas", + "rule-engine-hourly-activity": "Actividad horaria de motor de reglas", + "rule-engine-monthly-activity": "Actividad mensual de motor de reglas", + "rule-engine-statistics": "Estadisticas del motor de reglas", + "rule-node": "Nodo de reglas", + "sms": "SMS", + "sms-messages": "Mensajes SMS", + "sms-messages-daily-activity": "Actividad diaria de mensajes SMS", + "sms-messages-monthly-activity": "Actividad mensual de mensajes SMS", + "successful": "${entityName} Exitoso", + "telemetry": "Telemetría", + "telemetry-persistence": "Persistencia de telemetría", + "telemetry-persistence-daily-activity": "Actividad diaria de persistencia de telemetría", + "telemetry-persistence-hourly-activity": "Actividad horaria de persistencia de telemetría", + "telemetry-persistence-monthly-activity": "Actividad mensual de persistencia de telemetría", + "transport": "Transporte", + "transport-daily-activity": "Actividad diaria de transporte", + "transport-data-points": "Puntos de datos de transporte", + "transport-hourly-activity": "Actividad horaria de transporte", + "transport-messages": "Mensajes de transporte", + "transport-monthly-activity": "Actividad mensual de transporte", + "view-details": "Ver detalles", + "view-statistics": "Ver estadísticas" + }, + "audit-log": { + "audit": "Auditoría", + "audit-logs": "Registro Auditoría", + "timestamp": "Timestamp", + "entity-type": "Tipo Entidad", + "entity-name": "Nombre Entidad", + "user": "Usuario", + "type": "Tipo", + "status": "Estado", + "details": "Detalles", + "type-added": "Añadido", + "type-deleted": "Borrado", + "type-updated": "Actualizado", + "type-attributes-updated": "Atributos actualizados", + "type-attributes-deleted": "Atributos borrados", + "type-rpc-call": "Llamada RPC", + "type-credentials-updated": "Credenciales actualizados", + "type-assigned-to-customer": "Asignado a Cliente", + "type-unassigned-from-customer": "Deasignado a Cliente", + "type-assigned-to-edge": "Asignado a Borde", + "type-unassigned-from-edge": "Deasignado de Borde", + "type-activated": "Activado", + "type-suspended": "Suspendido", + "type-credentials-read": "Lectura de credenciales", + "type-attributes-read": "Lectura de atributos", + "type-relation-add-or-update": "Relación actualizada", + "type-relation-delete": "Relación borrada", + "type-relations-delete": "Borradas todas las relaciones", + "type-alarm-ack": "Alarma Acusada", + "type-alarm-clear": "Alarma Limpiada", + "type-login": "Inicio de sesión", + "type-logout": "Cierre de sesión", + "type-lockout": "Cierre por bloqueo", + "status-success": "Éxito", + "status-failure": "Fallo", + "audit-log-details": "Detalle del registro de auditoría", + "no-audit-logs-prompt": "No se encontraron registros", + "action-data": "Datos de acción", + "failure-details": "Detalles del error", + "search": "Buscar registros de auditoría", + "clear-search": "Borrar búsqueda", + "type-assigned-from-tenant": "Asignado desde el administrador", + "type-assigned-to-tenant": "Asignado al administrador", + "type-provision-success": "Dispositivo aprovisionado", + "type-provision-failure": "Aprovisionamiento fallido", + "type-timeseries-updated": "Telemetría actualizada", + "type-timeseries-deleted": "Telemetría borrada" + }, + "confirm-on-exit": { + "message": "Tienes cambios sin guardar. ¿Abandonar la página?", + "html-message": "Tienes cambios sin guardar.
    ¿Abandonar la página?", + "title": "Cambios sin guardar" + }, + "contact": { + "country": "País", + "city": "Ciudad", + "state": "Estado / Provincia", + "postal-code": "Código Postal", + "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", + "state-max-length": "Longitud de provincia debe ser menor que 256", + "phone-max-length": "Teléfono debe ser menor que 256", + "city-max-length": "Ciudad debe ser menor que 256" + }, + "common": { + "username": "Usuario", + "password": "Contraseña", + "enter-username": "Introduce el nombre de usuario.", + "enter-password": "Introduce la contraseña", + "enter-search": "Introduce búsqueda", + "created-time": "Fecha de creación", + "loading": "Cargando...", + "proceed": "Proceder", + "open-details-page": "Abrir detalles" + }, + "content-type": { + "json": "Json", + "text": "Texto", + "binary": "Binario (Base64)" + }, + "customer": { + "customer": "Cliente", + "customers": "Clientes", + "management": "Gestión de Clientes", + "dashboard": "Panel del Cliente", + "dashboards": "Paneles del Cliente", + "devices": "Dispositivos del cliente", + "entity-views": "Vistas de Entidad del cliente", + "assets": "Activos de Cliente", + "public-dashboards": "Paneles Públicos", + "public-devices": "Dispositivos Públicos", + "public-assets": "Activos Públicos", + "public-entity-views": "Vistas de Entidad Públicas", + "public-edges": "Bordes públicos", + "add": "Agregar cliente", + "delete": "Borrar 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-customer-edges": "Administrar bordes de clientes", + "manage-public-edges": "Administrar bordes públicos", + "manage-public-assets": "Gestionar activos públicos", + "add-customer-text": "Agregar nuevo cliente", + "no-customers-text": "No se encontraron clientes", + "customer-details": "Detalles del cliente", + "delete-customer-title": "¿Eliminar el cliente '{{customerTitle}}'?", + "delete-customer-text": "Atención, tras la confirmación el cliente será eliminado y toda la información relacionada será irrecuperable.", + "delete-customers-title": "¿Eliminar { count, plural, 1 {1 cliente} other {# clientes} }?", + "delete-customers-action-title": "Borrar { count, plural, 1 {1 cliente} other {# clientes} }", + "delete-customers-text": "Atención, tras la confirmación todos los clientes seleccionados serán eliminados y su información relacionada será irrecuperable.", + "manage-users": "Gestionar usuarios", + "manage-assets": "Gestionar activos", + "manage-devices": "Gestionar dispositivos", + "manage-dashboards": "Gestionar paneles", + "title": "Título", + "title-required": "Título requerido.", + "title-max-length": "Título debe ser menor de 256", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID de cliente", + "idCopiedMessage": "El ID de cliente se ha copiado al portapapeles", + "select-customer": "Seleccionar Cliente", + "no-customers-matching": "No se han encontrado clientes que coincidan con '{{entity}}' .", + "customer-required": "Cliente requerido", + "select-default-customer": "Seleccionar cliente por defecto", + "default-customer": "Cliente por defecto", + "default-customer-required": "Se requiere cliente por defecto para realizar debu a nivel de propietario", + "search": "Buscar clientes", + "selected-customers": "{ count, plural, 1 {1 cliente} other {# clientes} } seleccionados", + "edges": "Instancias de borde del cliente", + "manage-edges": "Administrar Bordes" + }, + "datetime": { + "date-from": "Fecha desde", + "time-from": "Hora desde", + "date-to": "Fecha hasta", + "time-to": "Hora hasta" + }, + "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-dashboard-to-edge-title": "Asignar panel(es) a Borde", + "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", + "make-public": "Hacer panel público", + "make-private": "Hacer panel privado", + "manage-assigned-customers": "Administrar clientes asignados", + "assigned-customers": "Clientes asignados", + "assign-to-customers": "Asignar Panel / Paneles a Clientes", + "assign-to-customers-text": "Selecciona los clientes para asignar los paneles", + "unassign-from-customers": "Desasignar Panel / Paneles de clientes", + "unassign-from-customers-text": "Selecciona los clientes para desasignar los paneles", + "no-dashboards-text": "Ningún panel encontrado", + "no-widgets": "Ningún widget configurado", + "add-widget": "Agregar nuevo widget", + "title": "Título", + "image": "Imagen de panel", + "mobile-app-settings": "Ajustes de aplicación móvil", + "mobile-order": "Órden de paneles en aplicación móvil", + "mobile-hide": "Ocultar panel en aplicación móvil", + "update-image": "Actualizar imagen de panel", + "take-screenshot": "Captura de pantalla", + "select-widget-title": "Seleccionar widget", + "select-widget-value": "{{title}}: seleccionar widget", + "select-widget-subtitle": "Lista de tipos de widgets disponibles", + "delete": "Eliminar panel", + "title-required": "Título requerido.", + "title-max-length": "Título debe ser menor de 256", + "description": "Descripción", + "details": "Detalles", + "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", + "unassign-dashboards-action-text": "Desasignar { count, plural, 1 {1 panel} other {# paneles} } a 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": "¿Eliminar el panel '{{dashboardTitle}}'?", + "delete-dashboard-text": "Atención, el panel seleccionado será eliminado y la información relacionada sera irrecuperable.", + "delete-dashboards-title": "¿Eliminar { count, plural, 1 {1 panel} other {# paneles} }?", + "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 panel} other {# paneles} }", + "delete-dashboards-text": "Atención, los paneles seleccionados serán eliminados y la información relacionada será irrecuperable.", + "unassign-dashboard-title": "¿Desasignar el panel '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Tras la confirmación, el panel será desasignado y no podrá ser accesible por el cliente.", + "unassign-dashboard": "Desasignar panel", + "unassign-dashboards-title": "¿Desasignar { count, plural, 1 {1 panel} other {# paneles} }?", + "unassign-dashboards-text": "Atención, tras la confirmación los paneles seleccionados serán desasignados y no podrán ser accesibles por el cliente.", + "public-dashboard-title": "El panel ahora es público", + "public-dashboard-text": "Tu panel {{dashboardTitle}} es ahora público y podrá ser accedido desde: aquí:", + "public-dashboard-notice": "Nota: No olvides hacer públicos los dispositivos relacionados para acceder a sus datos.", + "make-private-dashboard-title": "¿Hacer el panel '{{dashboardTitle}}' privado?", + "make-private-dashboard-text": "Tras la confirmación, 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", + "select-dashboard": "Seleccionar panel", + "no-dashboards-matching": "Panel '{{entity}}' no encontrado.", + "dashboard-required": "Panel requerido.", + "select-existing": "Seleccionar paneles existentes", + "create-new": "Crear nuevo panel", + "new-dashboard-title": "Nuevo título", + "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", + "empty-image": "Sin imagen", + "drop-image": "Suelte una imagen o haga clic para seleccionar un archivo para cargar.", + "maximum-upload-file-size": "Tamaño máximo de fichero: {{ size }}", + "cannot-upload-file": "No es posible subir el fichero", + "settings": "Ajustes", + "layout-settings": "Ajustes de diseño", + "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.", + "widgets-margins": "Margen entre widgets", + "margin-required": "Valor de margen requerido.", + "min-margin-message": "0 es el valor de margen mínimo permitido.", + "max-margin-message": "50 es el valor de margen máximo permitido.", + "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.", + "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.", + "autofill-height": "Altura diseño auto relleno", + "mobile-layout": "Ajustes del diseño móvil", + "mobile-row-height": "Altura de fila para móvil, px", + "mobile-row-height-required": "Altura de fila requerida.", + "min-mobile-row-height-message": "Sólo se permiten 5 píxeles como altura mínima de fila (móvil).", + "max-mobile-row-height-message": "Sólo se permiten 200 píxeles como altura máxima de fila (móvil).", + "title-settings": "Ajustes de título", + "display-title": "Mostrar título del panel", + "title-color": "Color del título", + "toolbar-settings": "Ajustes de la barra de herramientas", + "hide-toolbar": "Ocultar barra de herramientas", + "toolbar-always-open": "Mantener la barra de herramientas abierta", + "display-dashboards-selection": "Mostrar selección de paneles", + "display-entities-selection": "Mostrar selección de entidades", + "display-filters": "Mostrar filtros", + "display-dashboard-timewindow": "Mostrar ventana de tiempo", + "display-dashboard-export": "Mostrar exportar", + "display-update-dashboard-image": "Mostrar actualización de imagen", + "dashboard-logo-settings": "Ajustes del logotipo del panel", + "display-dashboard-logo": "Mostrar logotipo en pantalla completa", + "dashboard-logo-image": "Imagen logotipo", + "advanced-settings": "Ajustes avanzados", + "dashboard-css": "CSS del panel", + "import": "Importar panel", + "export": "Exportar panel", + "export-failed-error": "Imposible exportar panel: {{error}}", + "create-new-dashboard": "Crear nuevo panel", + "dashboard-file": "Archivar 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", + "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", + "configuration-error": "Error de configuración", + "alias-resolution-error-title": "Error de configuración de alias del panel", + "invalid-aliases-config": "No se puede encontrar ningún dispositivo que coincida con algunos de los alias de filtro.
    Póngase en contacto con su administrador para resolver este problema.", + "select-devices": "Seleccionar dispositivos", + "assignedToCustomer": "Asignado al cliente", + "assignedToCustomers": "Asignado a los clientes", + "public": "Público", + "copyId": "Copiar ID del panel", + "idCopiedMessage": "El ID del panel ha sido copiado al portapapeles", + "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", + "manage-states": "Administrar estados de paneles", + "states": "Estados de paneles", + "search-states": "Buscar estados de paneles", + "selected-states": "{ count, plural, 1 {1 estado panel} other {# estado paneles} } seleccionados", + "edit-state": "Editar estado panel", + "delete-state": "Borrar estado panel", + "add-state": "Añadir estado panel", + "no-states-text": "No se han encontrado estados", + "state": "Estado de panel", + "state-name": "Nombre", + "state-name-required": "Se requiere nombre del estado.", + "state-id": "ID Estado", + "state-id-required": "Se requiere el ID de estado.", + "state-id-exists": "Ya existe un ID de estado.", + "is-root-state": "Estado raiz(Root)", + "delete-state-title": "Borrar estado de panel", + "delete-state-text": "Eliminar el estado de panel con nombre: '{{stateName}}'?", + "show-details": "Mostrar detalles", + "hide-details": "Ocultar detalles", + "select-state": "Seleccionar estado destino (target state)", + "state-controller": "Controlador de estados", + "search": "Buscar paneles", + "selected-dashboards": "{count, plural, 1 {1 panel} other {# paneles} } seleccionados", + "home-dashboard": "Panel principal", + "home-dashboard-hide-toolbar": "Ocultar barra de herramientas en panel principal", + "unassign-dashboard-from-edge-text": "Después de la confirmación, el tablero no será asignado y el borde no podrá acceder a él", + "unassign-dashboards-from-edge-title": "Estas seguro de desasignar { count, plural, 1 {1 panel} other {# paneles} }?", + "unassign-dashboards-from-edge-text": "Después de la confirmación, se anulará la asignación de todos los paneles seleccionados y no serán accesibles por de borde", + "assign-dashboard-to-edge": "Asignar panel(es) al borde", + "assign-dashboard-to-edge-text": "Por favor selecciona los paneles para asignar al borde", + "non-existent-dashboard-state-error": "El panel con id de estado \"{{ stateId }}\" no se ha encontrado" + }, + "datakey": { + "settings": "Ajustes", + "advanced": "Avanzado", + "label": "Etiqueta", + "color": "Color", + "units": "Símbolo especial para mostrar junto con el valor", + "decimals": "Número de dígitos después de la coma", + "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", + "attributes": "Atributos", + "entity-field" : "Campo de entidad", + "alarm": "Campos de alarma", + "timeseries-required": "Series de tiempo del dispositivo requerido.", + "timeseries-or-attributes-required": "Series de tiempo/Atributos requeridos.", + "alarm-fields-timeseries-or-attributes-required": "Se requieren campos de alarma o series de tiempo/atributos.", + "maximum-timeseries-or-attributes": "Máximo { count, plural, 1 {1 timeseries/atributo es permitido.} other {# timeseries/atributos son permitidos} }", + "alarm-fields-required": "Campos de alarma requeridos.", + "function-types": "Tipos de funciones", + "function-type": "Tipos de función", + "function-types-required": "Tipos de funciones requerido.", + "alarm-keys": "Claves de Alarmas", + "alarm-key": "Clave de Slarma", + "alarm-key-functions": "Funciones de claves de Alarmas", + "alarm-key-function": "Función de clave de Alarma", + "latest-keys": "Últimas claves", + "latest-key": "Última clave", + "latest-key-functions": "Funciones de últimas claves", + "latest-key-function": "Función de última clave", + "timeseries-keys": "Claves de series de tiempo", + "timeseries-key": "Clave de series de tiempo", + "timeseries-key-functions": "Funciones de series de tiempo", + "timeseries-key-function": "Función de serie de tiempo", + "maximum-function-types": "Máximo { count, plural, 1 {1 tipo de función está permitida.} other {# tipos de funciones están permitidos} }", + "time-description": "hora del valor actual", + "value-description": "el valor actual", + "prev-value-description": "resultado de la llamada anterior de la función", + "time-prev-description": "hora del valor previo", + "prev-orig-value-description": "valor original previo" + }, + "datasource": { + "type": "Típo de fuente de datos", + "name": "Nombre", + "label": "Etiqueta", + "add-datasource-prompt": "Por favor, agrega una fuente de datos" + }, + "details": { + "details": "Detalles", + "edit-mode": "Modo Edición", + "edit-json": "Editar JSON", + "toggle-edit-mode": "Ir a Modo Edición" + }, + "device": { + "device": "Dispositivo", + "device-required": "Dispositivo requerido.", + "devices": "Dispositivos", + "management": "Gestión de Dispositivos", + "view-devices": "Ver Dispositivos", + "device-alias": "Alias de dispositivo", + "device-type-max-length": "El tipo de dispositivo debe ser menor de 256", + "aliases": "Alias de dispositivos", + "no-alias-matching": "'{{alias}}' no encontrado.", + "no-aliases-found": "Ningún alias encontrado.", + "no-key-matching": "'{{key}}' no encontrado.", + "no-keys-found": "Ninguna clave encontrada.", + "create-new-alias": "Crear nuevo alias!", + "create-new-key": "Crear nueva clave!", + "duplicate-alias-error": "Alias duplicado '{{alias}}'.
    El alias de los dispositivos deben ser únicos dentro del panel.", + "configure-alias": "Configurar alias '{{alias}}'", + "no-devices-matching": "No se encontró dispositivo '{{entity}}'", + "alias": "Alias", + "alias-required": "Alias de dispositivo requerido.", + "remove-alias": "Eliminar alias", + "add-alias": "Agregar alias", + "name-starts-with": "Nombre empieza con", + "help-text": "Usar '%' de acuerdo a las necesidades: '%nombre_dispositivo_contiene%', '%nombre_dispositivo_termina_en', 'nombre_dispositivo_empieza_con'.", + "device-list": "Lista de dispositivos", + "use-device-name-filter": "Usar 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", + "assign-device-to-edge-title": "Asignar Dispositivo(s) a Borde", + "assign-device-to-edge-text": "Selecciona los dispositivos a asignar al Borde", + "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-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", + "unassign-device-from-edge-title": "¿Está seguro de que desea desasignar el dispositivo '{{deviceName}}'?", + "unassign-device-from-edge-text": "Después de la confirmación, el dispositivo no será asignado y el borde no podrá acceder a él", + "unassign-devices-from-edge": "Desasignar dispositivos del Borde", + "assign-new-device": "Asignar nuevo dispositivo", + "make-public-device-title": "¿Hacer el dispositivo '{{deviceName}}' público?", + "make-public-device-text": "Tras la confirmación, el dispositivo y la información relacionada serán públicos y podrá ser accesible por otros.", + "make-private-device-title": "¿Hacer el dispositivo '{{deviceName}}' privado?", + "make-private-device-text": "Tras la confirmación, el dispositivo y la información relacionada serán privados y no podrá ser accesible por otros.", + "view-credentials": "Ver credenciales", + "delete-device-title": "¿Eliminar el dispositivo '{{deviceName}}'?", + "delete-device-text": "Atención, tras la confirmación los dispositivos serán eliminados y la información relacionada será irrecuperable.", + "delete-devices-title": "¿Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "delete-devices-action-title": "Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }", + "delete-devices-text": "Atención, tras la confirmación los dispositivos seleccionados serán eliminados y la información relacionada será irrecuperable.", + "unassign-device-title": "¿Desasignar el dispositivo '{{deviceName}}'?", + "unassign-device-text": "Tras la confirmación, el dispositivo será desasignado y no podrá ser accesible por el cliente.", + "unassign-device": "Desasignar dispositivo", + "unassign-devices-title": "¿Desasignar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-text": "Tras la confirmación, los dispositivos seleccionados serán desasignados y no podrán ser accedidos por el cliente.", + "device-credentials": "Credenciales del dispositivo", + "loading-device-credentials": "Cargando credenciales del dispositivo...", + "credentials-type": "Tipo de credenciales", + "access-token": "Tóken de acceso", + "access-token-required": "Tóken de acceso requerido.", + "access-token-invalid": "Tóken de acceso debe tener entre 1 a 32 caracteres.", + "certificate-pem-format": "Certificado en formato PEM", + "certificate-pem-format-required": "Certificado requerido.", + "lwm2m-security-config": { + "identity": "Identidad Cliente", + "identity-required": "Identidad Cliente requerida.", + "identity-tooltip": "La identidad PSK es un identificador PSK arbitrario hasta 128 bytes, como se describe en el estándar [RFC7925].\nEl identificador PSK DEBE ser convertido a un string y después codificado en octetos usando UTF-8.", + "client-key": "Clave Cliente", + "client-key-required": "Clave Cliente requerida.", + "client-key-tooltip-prk": "La clave RPK o id DEBE estar conforme al estándar [RFC7250] y codificada a un formato Base64!", + "client-key-tooltip-psk": "La clave PSK DEBE estar conforme al estándar [RFC4279] y en formato HexDec de: 32, 64 o 128 caracteres!", + "endpoint": "Nombre Endpoint Cliente", + "endpoint-required": "Nombre Endpoint requerido.", + "client-public-key": "Clave pública Cliente", + "client-public-key-hint": "Si la clave pública está vacía, se usará el certificado de confianza", + "client-public-key-tooltip": "La clave pública X509 debe estar codificada en formato DER X509v3 y soportar exclusivamente el algoritmo EC y codificada en formato Base64!", + "mode": "Modo de Seguridad", + "client-tab": "Configuración de Seguridad del cliente", + "client-certificate": "Certificado de Cliente", + "bootstrap-tab": "Cliente Bootstrap", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "client-publicKey-or-id": "Id o clave pública de cliente", + "client-publicKey-or-id-required": "Id o clave pública requerida.", + "client-publicKey-or-id-tooltip-psk": "La clave pública PSK es un identificador PSK arbitrario hasta 128 bytes, como se describe en el estándar [RFC7925].\nEl identificador PSK DEBE ser convertido a un string y después codificado en octetos usando UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "La clave pública RPK o id DEBE estar conforme al estándar [RFC7250] y codificada a un formato Base64!", + "client-publicKey-or-id-tooltip-x509": "La clave pública X509 debe estar codificada en formato DER X509v3 y soportar exclusivamente el algoritmo EC y codificada en formato Base64", + "client-secret-key": "Clave secreta de Cliente", + "client-secret-key-required": "Clave secreta requerida.", + "client-secret-key-tooltip-psk": "La clave PSK debe ser en el estándar [RFC4279] y en formato HexDec de: 32, 64 o 128 caracteres!", + "client-secret-key-tooltip-prk": "La clave RPK debe estar en formato PKCS_8 (Codificación DER, estándar [RFC5958]) y luego codificada en formato Base64!", + "client-secret-key-tooltip-x509": "La clave X509 debe estar en formato PKCS_8 (Codificación DER, estándar [RFC5958]) y luego codificada en formato Base64!" + }, + "client-id": "ID Cliente", + "client-id-pattern": "Contiene carácter inválido.", + "user-name": "Nombre Usuario", + "user-name-required": "Se requiere nombre de usuario.", + "client-id-or-user-name-necessary": "El ID Cliente y/o el Nombre de usuario son necesarios", + "password": "Contraseña", + "secret": "Secreto", + "secret-required": "Secreto requerido.", + "device-type": "Tipo de dispositivo", + "device-type-required": "Tipo de dispositivo requerido.", + "select-device-type": "Seleccionar tipo de dispositivo", + "enter-device-type": "Entrar tipo de dispositivo", + "any-device": "Cualquier dispositivo", + "no-device-types-matching": "No hay tipos de dispositivo que coincidan con '{{entitySubtype}}' .", + "device-type-list-empty": "No hay tipos de dispositivo seleccionados.", + "device-types": "Tipos de dispositivo", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "name-max-length": "El nombre debe ser menor de 256", + "label-max-length": "La etiqueta debe ser menor de 256", + "description": "Descripción", + "label": "Etiqueta", + "events": "Eventos", + "details": "Detalles", + "copyId": "Copiar ID", + "copyAccessToken": "Copiar access token", + "copy-mqtt-authentication": "Copiar credenciales MQTT", + "idCopiedMessage": "Id del dispositivo copiado al portapapeles", + "accessTokenCopiedMessage": "Access token del dispositivo copiado al portapapeles", + "mqtt-authentication-copied-message": "Los datos de autenticación MQTT se han copiado al portapapeles", + "assignedToCustomer": "Asignado al cliente", + "unable-delete-device-alias-title": "Imposible eliminar alias del dispositivo", + "unable-delete-device-alias-text": "Alias '{{deviceAlias}}' no puede ser eliminado. Esta siendo usado por el(los) widget(s):
    {{widgetsList}}", + "is-gateway": "Es gateway", + "overwrite-activity-time": "Sobreescribir hora de actividad para el dispositivo conectado", + "public": "Público", + "device-public": "El dispositivo es público", + "select-device": "Seleccionar dispositivo", + "import": "Importar dispositivo", + "device-file": "Archivo de dispositivo", + "search": "Buscar dispositivos", + "selected-devices": "{ count, plural, 1 {1 dispositivo} other {# dispositivos} } seleccionados", + "device-configuration": "Configuración del dispositivo", + "transport-configuration": "Configuración del transporte", + "wizard": { + "device-wizard": "Asistente de dispositivo", + "device-details": "Detalles del dispositivo", + "new-device-profile": "Crear un nuevo perfil de dispositivo", + "existing-device-profile": "Seleccionar un perfil existente", + "specific-configuration": "Configuración específica", + "customer-to-assign-device": "Cliente al que asignar el dispositivo", + "add-credentials": "Añadir credencial" + }, + "unassign-devices-from-edge-title": "¿Está seguro de que desea desasignar {count, plural, 1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-from-edge-text": "Después de la confirmación, todos los dispositivos seleccionados quedarán sin asignar y el borde no podrá acceder a ellos." + }, + "device-profile": { + "device-profile": "Perfil de dispositivo", + "device-profiles": "Perfiles de dispositivo", + "all-device-profiles": "Todos", + "add": "Añadir perfil de dispositivo", + "edit": "Editar perfil de dispositivo", + "device-profile-details": "Detalles de perfil de dispositivo", + "no-device-profiles-text": "No se encontraron perfiles", + "search": "Buscar perfiles", + "selected-device-profiles": "{ count, plural, 1 {1 perfil} other {# perfiles} } seleccionados", + "no-device-profiles-matching": "No existe perfil que conincida con '{{entity}}'.", + "device-profile-required": "Se requiere perfil de dispositivo", + "idCopiedMessage": "Se ha copiado el ID de perfil al portapapeles", + "set-default": "Hacer perfil por defecto", + "delete": "Borrar perfil de dispositivo", + "copyId": "Copiar ID de perfil", + "name-max-length": "El nombre debe ser menor de 256", + "new-device-profile-name": "Nombre de perfil", + "new-device-profile-name-required": "Se requiere nombre de perfil.", + "name": "Nombre", + "name-required": "Se requiere nombre.", + "type": "Tipo de perfil", + "type-required": "Se requiere tipo de perfil.", + "type-default": "Por defecto", + "image": "Imagen del perfil de dispositivo", + "transport-type": "Tipo de transporte", + "transport-type-required": "Se requiere tipo de transporte.", + "transport-type-default": "Por defecto", + "transport-type-default-hint": "Soporta transportes por MQTT básico, HTTP y CoAP", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Activa ajustes avanzados de transporte MQTT", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Activa ajustes avanzados del transporte CoAP", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Transporte LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Configuración transporte SNMP", + "description": "Descripción", + "default": "Defecto", + "profile-configuration": "Configuración de perfil", + "transport-configuration": "Configuración de transporte", + "default-rule-chain": "Cadena de reglas por defecto", + "mobile-dashboard": "Panel móvil", + "mobile-dashboard-hint": "Usado por la aplicación móvil como panel de detalle", + "select-queue-hint": "Selecciona desde el desplegable o añade un nombre personalizado.", + "delete-device-profile-title": "Eliminar el perfil '{{deviceProfileName}}'?", + "delete-device-profile-text": "Atención, tras la confirmación el perfil y todos sus datos serán borrados e irrecuperables.", + "delete-device-profiles-title": "EEliminar { count, plural, 1 {1 perfil} other {# perfiles} }?", + "delete-device-profiles-text": "Atención, tras la confirmación los perfiles seleccionados y todos sus datos serán borrados e irrecuperables.", + "set-default-device-profile-title": "Establecer el perfil '{{deviceProfileName}}' como perfil por defecto?", + "set-default-device-profile-text": "Tras la confirmación, el perfil será marcado como por defecto y será usado por todos los nuevos dispositivos que no tengan perfil especificado.", + "no-device-profiles-found": "No se encontraron perfiles.", + "create-new-device-profile": "Crear un nuevo perfil!", + "mqtt-device-topic-filters": "Filtros de topic MQTT", + "mqtt-device-topic-filters-unique": "Los filtros de topic de dispositivo MQTT deben ser únicos.", + "mqtt-device-payload-type": "Payload de dispositivo MQTT", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Activar compatibilidad con otros formatos de payload.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Si se activa, la plataforma usará un formato de payload Protobuf por defecto. Si el parseo falla, la plataforma intentará usar el formato JSON. Útil para retrocompatibilidad durante actualizaciones de firmware. Por ejemplo, si la versión inicial de firmware usa JSON y una versión posterior usa Protobuf. Durante el proceso de actualización de firmware a los dispositivos, se requiere soportar Protobuf y JSON al mismo tiempo. El modo de retrocompatibilidad introduce una pequeña degradación en el rendimiento, es recomendable desactivarlo una vez que todos los dispositivos estén actualizados.", + "mqtt-use-json-format-for-default-downlink-topics": "Usar formato JSON para los downlink", + "mqtt-use-json-format-for-default-downlink-topics-hint": "When enabled, the platform will use Json payload format to push attributes and RPC via the following topics: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. This setting does not impact attribute and rpc subscriptions sent using new (v2) topics: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Where $request_id is an integer request identifier.", + "mqtt-send-ack-on-validation-exception": "Enviar PUBACK en error de validación al publicar (PUBLISH)", + "mqtt-send-ack-on-validation-exception-hint": "By default, the platform will close the MQTT session on message validation failure. When enabled, the platform will send publish acknowledgment instead of closing the session.", + "snmp-add-mapping": "Añadir mapeado SNMP", + "snmp-mapping-not-configured": "No hay mapeado OID a series de tiempo/telemetría configurado", + "snmp-timseries-or-attribute-name": "Nombre Series de tiempo/nombre atributo para mapeado", + "snmp-timseries-or-attribute-type": "Tipo Series de tiempo/nombre atributo para mapeado", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Se requiere tipo de Payload.", + "coap-device-type": "Tipo de dispositivo CoAP", + "coap-device-payload-type": "Payload dispositivo CoAP", + "coap-device-type-required": "Se requiere tipo de dispositivo CoAP.", + "coap-device-type-default": "Por defecto", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Se soportan los wilcards únicos [+] y multi-nivel [#].", + "telemetry-topic-filter": "Filtro de topic en telemetría", + "telemetry-topic-filter-required": "Se requiere filtro de topic (telemetría).", + "attributes-topic-filter": "Filtro de topic en atributos", + "attributes-topic-filter-required": "Se requiere filtro de topic (atributos).", + "telemetry-proto-schema": "Proto esquema de telemetría", + "telemetry-proto-schema-required": "Se requiere proto esquema de telemetría.", + "attributes-proto-schema": "Proto esquema de atributos", + "attributes-proto-schema-required": "Se requiere proto esquema de atributos.", + "rpc-response-proto-schema": "Proto esquema de respuesta RPC", + "rpc-response-proto-schema-required": "Se requiere proto esquema de respuesta RPC.", + "rpc-response-topic-filter": "Filtro de topic de respuesta RPC", + "rpc-response-topic-filter-required": "Se requiere filtro de topic respuesta RPC.", + "rpc-request-proto-schema": "Proto esquema de petición RPC", + "rpc-request-proto-schema-required": "Se requiere proto esquema de petición RPC.", + "rpc-request-proto-schema-hint": "Las peticiones RPC deben contener siempre los siguientes campos: string method = 1; int32 requestId = 2; y params = 3 para cualquier tipo de datoas.", + "not-valid-pattern-topic-filter": "No es un patrón de filtro válido", + "not-valid-single-character": "Uso inválido de wildcard único", + "not-valid-multi-character": "Uso inválido de wildcard multi-nivel", + "single-level-wildcards-hint": "[+] es adecuado para cualquier nivel. Ej.: v1/devices/+/telemetry o +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] puede reemplazar el mismo filtro y debe ser el último símbolo del topic. Ej.: # o v1/devices/me/#.", + "alarm-rules": "Reglas de alarma", + "alarm-rules-with-count": "Reglas de alarma ({{count}})", + "no-alarm-rules": "No hay reglas de alarma configuradas", + "add-alarm-rule": "Añadir regla de alarma", + "edit-alarm-rule": "Editar regla de alarma", + "alarm-type": "Tipo de alarma", + "alarm-type-required": "Se requiere tipo de alarma.", + "alarm-type-unique": "El tipo de alarma, debe ser único dentro de las reglas de alarma del perfil de dispositivo.", + "alarm-type-max-length": "El tipo de alarma debe ser menor de 256", + "create-alarm-pattern": "Crear alarma {{alarmType}}", + "create-alarm-rules": "Crear reglas de alarma", + "no-create-alarm-rules": "No hay condiciones de creación de alarma configuradas", + "add-create-alarm-rule-prompt": "Por favor, añade una regla de alarma", + "clear-alarm-rule": "Borrar regla de alarma", + "no-clear-alarm-rule": "No hay condiciones de borrado de alarma configuradas", + "add-create-alarm-rule": "Añadir crear condición (activar alarma)", + "add-clear-alarm-rule": "Añair borrar condición (limpiar alarma)", + "select-alarm-severity": "Selecciona severidad de alarma", + "alarm-severity-required": "Se requiere especificar severidad de alarma.", + "condition-duration": "Duración de condición", + "condition-duration-value": "Valor de duración", + "condition-duration-time-unit": "Unidad de tiempo", + "condition-duration-value-range": "El valor debe estar en un rango desde 1 a 2147483647.", + "condition-duration-value-pattern": "El valor de duración debe ser un número entero.", + "condition-duration-value-required": "Se requiere valor de duración.", + "condition-duration-time-unit-required": "Se requiere una unidad de tiempo.", + "advanced-settings": "Ajustes avanzados", + "alarm-rule-details": "Detalles", + "alarm-rule-details-hint": "Ayuda: usa ${nombredeClave} para sustituir los valores de atributos o telemetrías usadas en la condición de la regla de alarma.", + "add-alarm-rule-details": "Añadir detalles", + "alarm-rule-mobile-dashboard": "Panel Móvil", + "alarm-rule-mobile-dashboard-hint": "Usado por la aplicación móvil como panel de detalle de alarmas", + "alarm-rule-no-mobile-dashboard": "No hay panel seleccionado", + "propagate-alarm": "Propagar alarma", + "alarm-rule-relation-types-list": "Tipos de relación para propagar", + "alarm-rule-relation-types-list-hint": "Si no está seleccionado 'propagar relaciones', las alarmas serán propagadas sin filtrar por relación.", + "propagate-alarm-to-owner": "Propagar alarma al propietario de la entidad (Cliente o Administrador)", + "propagate-alarm-to-tenant": "Propagar alarma a Administrador", + "alarm-details": "Detalles de alarma", + "alarm-rule-condition": "Condiciones de regla de alarma", + "enter-alarm-rule-condition-prompt": "Por favor, añade una condición de alarma", + "edit-alarm-rule-condition": "Editar condición de alarma", + "device-provisioning": "Aprovisionamiento de dispositivos", + "provision-strategy": "Estrategia de aprovisionamiento", + "provision-strategy-required": "Se requiere estrategia de aprovisionamiento.", + "provision-strategy-disabled": "Desactivado", + "provision-strategy-created-new": "Permitir crear nuevos dispositivos", + "provision-strategy-check-pre-provisioned": "Revisar dispositivos pre-aprovisionados", + "provision-device-key": "Clave de aprovisionamiento", + "provision-device-key-required": "Se requiere clave de aprovisionamiento.", + "copy-provision-key": "Copiar clave de aprovisionamiento", + "provision-key-copied-message": "La clave de aprovisionamiento se ha copiado al portapapeles", + "provision-device-secret": "Secreto de aprovisionamiento", + "provision-device-secret-required": "Se requiere secreto de aprovisionamiento.", + "copy-provision-secret": "Copiar secreto de aprovisionamiento", + "provision-secret-copied-message": "Se ha copiado el secreto de aprovisionamiento al portapapeles", + "condition": "Condición", + "condition-type": "Tipo de condición", + "condition-type-simple": "Simple", + "condition-type-duration": "Duración", + "condition-during": "Durante {{during}}", + "condition-during-dynamic": "Durante \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Repetitiva", + "condition-type-required": "Se requiere tipo de condición.", + "condition-repeating-value": "Nº de eventos", + "condition-repeating-value-range": "El Nº de eventos debe estar en un rango de 1 to 2147483647.", + "condition-repeating-value-pattern": "Nº de eventos debe ser un número entero.", + "condition-repeating-value-required": "Se requiere Nº de eventos.", + "condition-repeat-times": "Repetición { count, plural, 1 {1 vez} other {# veces} }", + "condition-repeat-times-dynamic": "Repetición \"{ atributo }\" ({ count, plural, 1 {1 vez} other {# veces} })", + "schedule-type": "Tipo de horario", + "schedule-type-required": "Tipo de horario requerido.", + "schedule": "Horario", + "edit-schedule": "Editar horario de alarma", + "schedule-any-time": "Siempre activo", + "schedule-specific-time": "Activo en una hora específica", + "schedule-custom": "Personalizado", + "schedule-day": { + "monday": "Lunes", + "tuesday": "Martes", + "wednesday": "Miércoles", + "thursday": "Jueves", + "friday": "Viernes", + "saturday": "Sábado", + "sunday": "Domingo" + }, + "schedule-days": "Días", + "schedule-time": "Hora", + "schedule-time-from": "Desde", + "schedule-time-to": "Hasta", + "schedule-days-of-week-required": "Seleccionar por lo menos un día de la semana.", + "create-device-profile": "Crear un nuevo perfil de dispositivo", + "import": "Importar perfil de dispositivo", + "export": "Exportar perfil de dispositivo", + "export-failed-error": "No ha sido posible exportar el perfil de dispositivo: {{error}}", + "device-profile-file": "Archivo de perfil de dispositivo", + "invalid-device-profile-file-error": "No ha sido posible importar perfil de dispositivo: Estructura de datos inválida.", + "power-saving-mode": "Modo de ahorro de energía (PSM)", + "power-saving-mode-type": { + "default": "Usar el del perfil del dispositivo", + "psm": "Power Saving Mode (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "Ciclo eDRX", + "edrx-cycle-required": "Se requiere ciclo eDRX.", + "edrx-cycle-pattern": "El ciclo eDRX debe ser un número entero positivo.", + "edrx-cycle-min": "El mínimo de ciclo eDRX es de {{ min }} segundos.", + "paging-transmission-window": "Ventana de transmisión (Paging Transmission Window)", + "paging-transmission-window-required": "Se requiere ventana de transmisión (Paging transmission window).", + "paging-transmission-window-pattern": "Ventana de transmision debe ser un número entero positivo.", + "paging-transmission-window-min": "El mínimo de ventana de transmisión es de {{ min }} segundos.", + "psm-activity-timer": "Tiempo Actividad PSM (PSM Activity Timer)", + "psm-activity-timer-required": "Se requiere el tiempo de actividad PSM.", + "psm-activity-timer-pattern": "Tiempo de actividad PSM debe ser un número entero positivo.", + "psm-activity-timer-min": "El tiempo de actividad PSM mínimo es de {{ min }} segundos.", + "lwm2m": { + "object-list": "Lista de objetos", + "object-list-empty": "No hay objetos seleccionados.", + "no-objects-found": "No se encontraron objetos.", + "no-objects-matching": "No hay objetos que coincidan con '{{object}}'.", + "model-tab": "Modelo LWM2M", + "add-new-instances": "Añadir nueva instancia", + "instances-list": "Lista de Instancias", + "instances-list-required": "Se requiere lista de instancias.", + "instance-id-pattern": "El id de instancia debe ser un número entero positivo.", + "instance-id-max": "El id máximo de instancia es {{max}}.", + "instance": "Instancia", + "resource-label": "#ID Nombre Recurso", + "observe-label": "Observar", + "attribute-label": "Atributo", + "telemetry-label": "Telemetría", + "edit-observe-select": "Para editar observar, selecciona telemetría o atributo", + "edit-attributes-select": "Para editar atributo, selecciona telemetría o atributo", + "no-attributes-set": "No hay atributos ajustados", + "key-name": "Nombre de clave", + "key-name-required": "Se requiere nombre de clave", + "attribute-name": "Nombre Atributo", + "attribute-name-required": "Se requiere nombre de atributo.", + "attribute-value": "Valor de atributo", + "attribute-value-required": "Se requiere valor de atributo.", + "attribute-value-pattern": "El valor del atributo debe ser un número entero positivo.", + "edit-attributes": "Edita atributos: {{ name }}", + "view-attributes": "Ver atributos: {{ name }}", + "add-attribute": "Añadir atributo", + "edit-attribute": "Editar atributo", + "view-attribute": "Ver atributos", + "remove-attribute": "Borrar atributos", + "delete-server-text": "Atención, tras la confirmación la configuración del servidor se borrará y será irrecuperable.", + "delete-server-title": "Estas seguro de borrar el servidor?", + "mode": "Configuración de seguridad", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Servidor Bootstrap (ShortId...)", + "lwm2m-server-legend": "Servidor LwM2M (ShortId...)", + "server": "Servidor", + "short-id": "Short server ID", + "short-id-tooltip": "Id corto del servidor. Usado como enlace para asociar las instancias de objetos del servidor.\nEste identificador sirve para identificar únicamente cada servidor LwM2M configurado para el cliente LwM2M.\nLos recursos DEBEN ser ajustados cuando el servidor Bootstrap tenga un valor de 'false'.\nLos valores ID:0 and ID:65535 NO DEBEN ser usados para identificar al servidor LwM2M.", + "short-id-required": "Se requiere Short server ID.", + "short-id-range": "Short server ID debe estar en un rango de 1 a 65534.", + "short-id-pattern": "Short server ID debe ser un número entero positivo.", + "lifetime": "Ciclo de vida registro de cliente (Registration Lifetime)", + "lifetime-required": "Se requiere ciclo de vida.", + "lifetime-pattern": "Ciclo de vida debe ser un número entero positivo.", + "default-min-period": "Periodo mínimo entre notificaciones (s)", + "default-min-period-tooltip": "Valor predeterminado que el cliente LwM2M debe usar para el período mínimo de una Observación en ausencia de éste parámetro incluido en una observación.", + "default-min-period-required": "Período mímino requerido.", + "default-min-period-pattern": "El período mínimo debe ser un número entero positivo.", + "notification-storing": "Grabado de notificaciones cuando esté desactivado u offline", + "binding": "Binding", + "binding-type": { + "u": "U: El cliente es alcanzable por UDP en cualquier momento.", + "m": "M: El cliente es alcanzable por MQTT en cualquier momento.", + "h": "H: El cliente es alcanzable por HTTP en cualquier momento.", + "t": "T: El cliente es alcanzable por TCP en cualquier momento.", + "s": "S: El cliente es alcanzable por SMS en cualquier momento.", + "n": "N: El cliente DEBE enviar las respuestas a las peticiones sobre el modo Non-IP (soportado desde la versión LWM2M 1.1).", + "uq": "UQ: Conexión UDP en modo de cola (no soportado desde la versión LWM2M 1.1)", + "uqs": "UQS: Conexiones UDP y SMS activas, UDP en modo cola, SMS en modo estándar (no soportado desde la versión LWM2M 1.1)", + "tq": "TQ: Conexión TCP en modo de cola (no soportado desde la versión LWM2M 1.1)", + "tqs": "TQS: Conexiones TCP y SMS activas, TCP en modo cola, SMS en modo estándar (no soportado desde la versión LWM2M 1.1)", + "sq": "SQ: Conexión SMS en modo de cola (no soportado desde la versión LWM2M 1.1)" + }, + "binding-tooltip": "This is the list in the\"binding\" resource of the LwM2M server object - /1/x/7.\nIndicates the supported binding modes in the LwM2M Client.\nThis value SHOULD be the same as the value in the “Supported Binding and Modes” resource in the Device Object (/3/0/16).\nWhile multiple transports are supported, only one transport binding can be used during the entire Transport Session.\nAs an example, when UDP and SMS are both supported, the LwM2M Client and the LwM2M Server can choose to communicate either over UDP or SMS during the entire Transport Session.", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "include-bootstrap-server": "Incluir actualizaciónes del servidor Bootstrap", + "bootstrap-update-title": "Ya has configurado un servidor Bootstrap. Estás seguro de que quieres excluir las actualizaciones?", + "bootstrap-update-text": "Atención, tras la confirmación la configuración del servidor Bootstrap será irrecuperable.", + "server-host": "Host", + "server-host-required": "Se requiere Host.", + "server-port": "Puerto", + "server-port-required": "Se requiere Puerto.", + "server-port-pattern": "El puerto debe ser un número entero positivo.", + "server-port-range": "El puerto debe comprender en el rango 1 a 65535.", + "server-public-key": "Clave Pública del Servidor", + "server-public-key-required": "Se requiere la Clave Pública del servidor.", + "client-hold-off-time": "Tiempo de espera (Hold Off Time)", + "client-hold-off-time-required": "Se requiere tiempo de espera.", + "client-hold-off-time-pattern": "El tiempo de espera debe ser un número entero positivo.", + "client-hold-off-time-tooltip": "El tiempo de espera se usa sólamente con un servidor Bootstrap", + "account-after-timeout": "Cuenta tras el tiempo de espera", + "account-after-timeout-required": "Se requiere Cuenta tras el tiempo de espera.", + "account-after-timeout-pattern": "Cuenta tras el tiempo de espera debe ser un número entero positivo.", + "account-after-timeout-tooltip": "Bootstrap-Server Account after the timeout value given by this resource.", + "server-type": "Tipo de servidor", + "add-new-server-title": "Añadir nueva configuración", + "add-server-config": "Añadir configuración de servidor", + "add-lwm2m-server-config": "Añadir Servidor LwM2M", + "no-config-servers": "No hay servidores configurados", + "others-tab": "Otros ajustes", + "client-strategy": "Estrategia del cliente en la conexión", + "client-strategy-label": "Estrategia", + "client-strategy-only-observe": "Realizar un request para observar al cliente tras la conexión inicial", + "client-strategy-read-all": "Leer todos los recursos y realizar un request para observar al cliente tras el registro", + "fw-update": "Actualización de Firmware", + "fw-update-strategy": "Estrategia de actualización de Firmware", + "fw-update-strategy-data": "Enviar actualización de firmware como un fichero binario usando el Objeto 19 y el Recurso 0 (Data)", + "fw-update-strategy-package": "Enviar actualización de firmware como un fichero binario usando el Objeto 5 y el Recurso 0 (Package)", + "fw-update-strategy-package-uri": "Auto-generar una URL CoAP única para la descarga del paquete y enviarla usando el Objeto 5 y el Recurso 1 (Package URI)", + "sw-update": "Actualización de Software", + "sw-update-strategy": "Estrategia de Actualización de Software", + "sw-update-strategy-package": "Enviar como un fichero binario usando el Objeto 9 y el Recurso 2 (Package)", + "sw-update-strategy-package-uri": "Auto-generar una URL CoAP única para la descarga del paquete y enviarla usando el Objeto 9 y el Recurso 3 (Package URI)", + "fw-update-resource": "Recurso CoAP Actualización de Firmware", + "fw-update-resource-required": "Se requiere el Recurso CoAP Actualización de Firmware.", + "sw-update-resource": "Recurso CoAP Actualización de Software", + "sw-update-resource-required": "Se requiere el Recurso CoAP Actualización de Software.", + "config-json-tab": "Configuracion Json Perfil de dispositivo", + "attributes-name": { + "min-period": "Período mínimo", + "max-period": "Período máximo", + "greater-than": "Mayor que", + "less-than": "Menor que", + "step": "Paso", + "min-evaluation-period": "Período mínimo de evaluación", + "max-evaluation-period": "Período máximo de evaluación" + }, + "composite-operations-support": "Soporta operaciones Lectura/Escritura/Observación Compuestas" + }, + "snmp": { + "add-communication-config": "Añadir configuración de comunicaciones", + "add-mapping": "Añadir mapeado", + "authentication-passphrase": "Frase de contraseña", + "authentication-passphrase-required": "Se requiere frase de contraseña.", + "authentication-protocol": "Protocolo de autenticación", + "authentication-protocol-required": "Se requiere Protocolo de autenticación.", + "communication-configs": "Configuracion de comunicaciones", + "community": "Community", + "community-required": "Se requiere Community.", + "context-name": "Nombre de contexto", + "data-key": "Clave de datos", + "data-key-required": "Se requiere clave de datos.", + "data-type": "Tipo de datos", + "data-type-required": "Se requiere tipo de datos.", + "engine-id": "Engine ID", + "host": "Host", + "host-required": "Se requiere Host.", + "oid": "OID", + "oid-pattern": "Formato OID inválido.", + "oid-required": "Se requiere OID.", + "please-add-communication-config": "Por favor, añadir configuración de comunicación", + "please-add-mapping-config": "Por favor, añadir configuración de mapeado", + "port": "Puerto", + "port-format": "Formato de puerto inválido.", + "port-required": "Se requiere puerto.", + "privacy-passphrase": "Frase de clave de privacidad", + "privacy-passphrase-required": "Se requiere Frase de clave de privacidad.", + "privacy-protocol": "Protocolo privacidad", + "privacy-protocol-required": "Se requiere Protocolo privacidad.", + "protocol-version": "Versión protocolo", + "protocol-version-required": "Se requiere versión protocolo.", + "querying-frequency": "Frecuencia de peticiones, ms", + "querying-frequency-invalid-format": "Frecuencia de peticiones debe ser un número entero positivo.", + "querying-frequency-required": "Se requiere frecuencia de peticiones.", + "retries": "Reintentos", + "retries-invalid-format": "Reintentos debe ser un número entero positivo.", + "retries-required": "Se requiere reintentos.", + "scope": "Alcance", + "scope-required": "Se requiere alcance.", + "security-name": "Nombre seguridad", + "security-name-required": "Se requiere Nombre seguridad.", + "timeout-ms": "Timeout, ms", + "timeout-ms-invalid-format": "Timeout debe ser un número entero positivo.", + "timeout-ms-required": "Se requiere timeout.", + "user-name": "Usuario", + "user-name-required": "Se requiere usuario." + } + }, + "dialog": { + "close": "Cerrar diálogo" + }, + "direction": { + "column": "Columna", + "row": "Fila" + }, + "edge": { + "edge": "Borde", + "edge-instances": "Instancias de Borde", + "edge-file": "Archivo de borde", + "name-max-length": "El nombre debe ser menor de 256", + "label-max-length": "La etiqueta debe ser menor de 256", + "type-max-length": "El tipo debe ser menor de 256", + "management": "Gestión de bordes", + "no-edges-matching": "No se encontraron bordes que coincidan con '{{entity}}'", + "add": "Agregar borde", + "no-edges-text": "No se encontraron bordes", + "edge-details": "Detalles del borde", + "add-edge-text": "Agregar nuevo borde", + "delete": "Eliminar borde", + "delete-edge-title": "¿Está seguro de que desea eliminar el borde '{{edgeName}}'?", + "delete-edge-text": "Tenga cuidado, después de la confirmación, el borde y todos los datos relacionados serán irrecuperables", + "delete-edges-title": "¿Está seguro de que desea edge {count, plural, 1 {1 borde} other {# bordes} }?", + "delete-edges-text": "Tenga cuidado, después de la confirmación se eliminarán todos los bordes seleccionados y todos los datos relacionados se volverán irrecuperables", + "name": "Nombre", + "name-starts-with": "Nombre de Borde comienza con", + "name-required": "Se requiere nombre", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copy-id": "Copiar ID de borde", + "id-copied-message": "El ID de borde se ha copiado al portapapeles", + "sync": "Sinc Edge", + "edge-required": "Borde Requerido", + "edge-type": "Tipo de Borde", + "edge-type-required": "El tipo de borde es requerido.", + "event-action": "Información de la entidad", + "entity-id": "ID de entidad", + "select-edge-type": "Seleccionar tipo de borde", + "assign-to-customer": "Asignar al cliente", + "assign-to-customer-text": "Seleccione el cliente para asignar los bordes", + "assign-edge-to-customer": "Asignar borde(s) al cliente", + "assign-edge-to-customer-text": "Seleccione los bordes para asignar al cliente", + "assignedToCustomer": "Asignada a la cliente", + "edge-public": "Edge es pública", + "assigned-to-customer": "Asignado al cliente", + "unassign-from-customer": "Anular asignación del cliente", + "unassign-edge-title": "¿Está seguro de que desea desasignar el borde '{{edgeName}}'?", + "unassign-edge-text": "Después de la confirmación, el borde quedará sin asignar y el cliente no podrá acceder a él", + "unassign-edges-title": "¿Está seguro de que desea anular la asignación de {count, plural, 1 {1 borde} other {# bordes} }?", + "unassign-edges-text": "Después de la confirmación de todos los bordes seleccionados, se anulará la asignación y el cliente no podrá acceder a ellos.", + "make-public": "Hacer público el borde", + "make-public-edge-title": "¿Estás seguro de que quieres hacer público el edge '{{edgeName}}'?", + "make-public-edge-text": "Después de la confirmación, el borde y todos sus datos serán públicos y accesibles para otros", + "make-private": "Hacer que edge sea privado", + "public": "Público", + "make-private-edge-title": "¿Está seguro de que desea que el borde '{{edgeName}}' sea privado?", + "make-private-edge-text": "Después de la confirmación, el borde y todos sus datos se harán privados y otros no podrán acceder a ellos", + "import": "Importar borde", + "label": "Etiqueta", + "load-entity-error": "Entidad no encontrada. No se pudo cargar la información", + "assign-new-edge": "Asignar nuevo borde", + "unassign-from-edge": "Anular asignación de borde", + "edge-key": "Clave de borde", + "copy-edge-key": "Copiar clave de borde", + "edge-key-copied-message": "La clave de borde se ha copiado al portapapeles", + "edge-secret": "Borde secreto", + "copy-edge-secret": "Copiar borde secreto", + "edge-secret-copied-message": "El secreto de borde se ha copiado al portapapeles", + "edge-assets": "Gestionar activos de bordes", + "edge-devices": "Gestionar dispositivos de borde", + "edge-entity-views": "Gestionar vistas de entidad de borde", + "edge-dashboards": "Administrar paneles de borde", + "edge-rulechains": "Cadenas de reglas de borde", + "assets": "Activos de borde", + "devices": "Dispositivos de borde", + "entity-views": "Vistas de entidad de borde", + "dashboard": "Panel de control Edge", + "dashboards": "Paneles de borde", + "rulechain-templates": "Plantillas, de cadena de reglas", + "rulechains": "Cadenas de regla de borde", + "search": "Bordes de búsqueda", + "selected-edges": "{count, plural, 1 {1 borde} other {# bordes} } seleccionadas", + "any-edge": "Cualquier bordee", + "no-edge-types-matching": "No se encontraron tipos de aristas que coincidan con '{{entitySubtype}}'.", + "edge-type-list-empty": "No se seleccionó ningún tipo de borde.", + "edge-types": "Tipos de bordes", + "enter-edge-type": "Ingrese el tipo de borde", + "deployed": "Desplegada", + "pending": "Pendiente", + "downlinks": "Enlaces descendentes", + "no-downlinks-prompt": "No se encontraron enlaces descendentes", + "sync-process-started-successfully": "¡El proceso de sincronización se inició correctamente!", + "missing-related-rule-chains-title": "Al borde le faltan cadenas de reglas relacionadas", + "missing-related-rule-chains-text": "Asignado a la (s) cadena (s) de reglas de borde usa nodos de reglas que reenvían mensajes a cadenas de reglas que no están asignadas a este borde.

    Lista de cadenas de reglas faltantes:
    {{missingRuleChains}}", + "widget-datasource-error": "Este widget solo admite la fuente de datos de la entidad EDGE" + }, + "edge-event": { + "type-dashboard": "Panel", + "type-asset": "Activo", + "type-device": "Dispositivo", + "type-device-profile": "Perfil de dispositivo", + "type-entity-view": "Vista de entidad", + "type-alarm": "Alarma", + "type-rule-chain": "Cadena de reglas", + "type-rule-chain-metadata": "Metadatos de Cadena de Reglas", + "type-edge": "Borde", + "type-user": "Usuario", + "type-customer": "Cliente", + "type-relation": "Relación", + "type-widgets-bundle": "Paquete de Widgets", + "type-widgets-type": "Tipos de Widgets", + "type-admin-settings": "Ajustes de Administración", + "action-type-added": "Añadido", + "action-type-deleted": "Borrado", + "action-type-updated": "Actualizado", + "action-type-post-attributes": "Envío de Atributos", + "action-type-attributes-updated": "Atributos Actualizados", + "action-type-attributes-deleted": "Atributos Borrados", + "action-type-timeseries-updated": "Series de tiempo Actualizadas", + "action-type-credentials-updated": "Credenciales Actualizadas", + "action-type-assigned-to-customer": "Asignado a Cliente", + "action-type-unassigned-from-customer": "Desasignado de Cliente", + "action-type-relation-add-or-update": "Añadir o Actualizar relación", + "action-type-relation-deleted": "Relación borrada", + "action-type-rpc-call": "Llamada RPC", + "action-type-alarm-ack": "ACK Alarma", + "action-type-alarm-clear": "Borrado de Alarma", + "action-type-assigned-to-edge": "Asignado a Borde", + "action-type-unassigned-from-edge": "Desasignado de Borde", + "action-type-credentials-request": "Obtención de Credenciales", + "action-type-entity-merge-request": "Unión de entidades" + }, + "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 controlado: {{errorCode}}", + "unknown-error": "Error desconocido" + }, + "entity": { + "entity": "Entidad", + "entities": "Entidades", + "entities-count": "Nº de entidades", + "aliases": "Alias de entidad", + "entity-alias": "Alias de entidad", + "unable-delete-entity-alias-title": "No ha sido posible eliminar el alias de entidad", + "unable-delete-entity-alias-text": "El alias de entidad '{{entityAlias}}' no puede ser eliminado ya que se esta usando por los siguientes widgets:
    {{widgetsList}}", + "duplicate-alias-error": "Encontrado un alias duplicado '{{alias}}'.
    Loas alias de entidad tienen que ser únicos para cada panel.", + "missing-entity-filter-error": "Falta el filtro para el alias '{{alias}}'.", + "configure-alias": "Configurar alias '{{alias}}' ", + "alias": "Alias", + "alias-required": "Alias de entidad requerido.", + "remove-alias": "Eliminar alias de entidad", + "add-alias": "Añadir alias de entidad", + "entity-list": "Lista de entidades", + "entity-type": "Tipo de entidad", + "entity-types": "Tipos de entidades", + "entity-type-list": "Lista de tipos de entidad", + "any-entity": "Cualquier entdad", + "enter-entity-type": "Introducir tipo de entidad", + "no-entities-matching": "No se han encontrado entidades que coincidan con '{{entity}}' .", + "no-entity-types-matching": "No se han encontrado tipos de entidad que coincidan con '{{entityType}}' .", + "name-starts-with": "Nombre empieza con", + "help-text": "Usar el símbolo '%' de acuerdo a las necesidades: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "Usar filtro", + "entity-list-empty": "No hay entidades seleccionadas.", + "entity-type-list-empty": "No hay tipos de entidad seleccionados.", + "entity-name-filter-required": "Filtro de nombre de entidad requerido.", + "entity-name-filter-no-entity-matched": "No hay entidades que comiencen con '{{entity}}' .", + "all-subtypes": "Todos", + "select-entities": "Seleccionar entidades", + "no-aliases-found": "No se han encontrado alias.", + "no-alias-matching": "'{{alias}}' no encontrado.", + "create-new-alias": "Crear nuevo alias!", + "key": "Clave", + "key-name": "Nombre de clave", + "no-keys-found": "No se han encontrado claves.", + "no-key-matching": "'{{key}}' no encontrada.", + "create-new-key": "Crear nueva clave!", + "type": "Tipo", + "type-required": "Tipo de entidad requerido.", + "type-device": "Dispositivo", + "type-devices": "Dispositivos", + "list-of-devices": "{ count, plural, 1 {Un dispositivo} other {Lista de # Dispositivos} }", + "device-name-starts-with": "Dispositivos cuyos nombres comiencen por '{{prefix}}'", + "type-device-profile": "Perfil de dispositivo", + "type-device-profiles": "Perfiles de dispositivo", + "list-of-device-profiles": "{ count, plural, 1 {un perfil} other {Lista de # perfiles} }", + "device-profile-name-starts-with": "Perfiles cuyo nombre empiece por '{{prefix}}'", + "type-asset": "Activo", + "type-assets": "Activos", + "list-of-assets": "{ count, plural, 1 {Un activo} other {Lista de # activos} }", + "asset-name-starts-with": "Activos cuyos nombres comiencen por '{{prefix}}'", + "type-entity-view": "Vista Entidad", + "type-entity-views": "Vistas Entidades", + "list-of-entity-views": "{ count, plural, 1 {Una vista de entidad} other {Lista de # Vistas de Entidades} }", + "entity-view-name-starts-with": "Vistas de Entidades cuyo nombre comiencen por '{{prefix}}'", + "type-rule": "Regla", + "type-rules": "Reglas", + "list-of-rules": "{ count, plural, 1 {Una regla} other {Lista de # reglas} }", + "rule-name-starts-with": "Reglas cuyos nombres comiencen por '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, 1 {Un plugin} other {Lista de # plugins} }", + "plugin-name-starts-with": "Plugins cuyos nombres comiencen por '{{prefix}}'", + "type-tenant": "Propietario", + "type-tenants": "Propietarios", + "list-of-tenants": "{ count, plural, 1 {Un propietario} other {Lista de # propietarios} }", + "tenant-name-starts-with": "Propietarios cuyo nombre comience por '{{prefix}}'", + "type-tenant-profile": "Perfil de Propietario", + "type-tenant-profiles": "Perfiles de propietario", + "list-of-tenant-profiles": "{ count, plural, 1 {Un perfil de propietario} other {Lista de # perfiles de propietario} }", + "tenant-profile-name-starts-with": "Pefiles de propietario cuyo nombre empiece por '{{prefix}}'", + "type-customer": "Cliente", + "type-customers": "Clientes", + "list-of-customers": "{ count, plural, 1 {Un cliente} other {Lista de # clientes} }", + "customer-name-starts-with": "Clientes cuyos nombres comiencen por '{{prefix}}'", + "type-user": "Usuario", + "type-users": "Usuarios", + "list-of-users": "{ count, plural, 1 {Un usuario} other {Lista de # usuarios} }", + "user-name-starts-with": "Usuarios cuyos nombres comiencen por '{{prefix}}'", + "type-dashboard": "Panel", + "type-dashboards": "Paneles", + "list-of-dashboards": "{ count, plural, 1 {Un panel} other {Lista de # paneles} }", + "dashboard-name-starts-with": "Paneles cuyos nombres comiencen por '{{prefix}}'", + "type-alarm": "Alarma", + "type-alarms": "Alarmas", + "list-of-alarms": "{ count, plural, 1 {Una alarma} other {Lista de # alarmas} }", + "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 {Una cadena de reglas} other {Lista de # cadenas de reglas} }", + "rulechain-name-starts-with": "Cadenas de reglas cuyos nombres comienzan con '{{prefix}}'", + "type-rulenode": "Nodo de reglas", + "type-rulenodes": "Nodos de reglas", + "list-of-rulenodes": "{ count, plural, 1 {Un nodo de reglas} other {Lista de # nodos de reglas} }", + "rulenode-name-starts-with": "Nodos de reglas cuyos nombres comienzan con '{{prefix}}'", + "type-current-customer": "Cliente Actual", + "type-current-tenant": "Propietario Actual", + "type-current-user": "Usuario Actual", + "type-current-user-owner": "Usuario Propietario Actual", + "type-widgets-bundle": "Paquete de widgets", + "type-widgets-bundles": "Paquetes de widgets", + "list-of-widgets-bundles": "{ count, plural, 1 {Un paquete de widget} other {Lista de # paquetes de widgets} }", + "search": "Buscar entidades", + "selected-entities": "{ count, plural, 1 {1 entidad} other {# entidades} } seleccionadas", + "entity-label": "Etiqueta de entidad", + "entity-name": "Nombre de entidad", + "details": "Detalles de entidad", + "no-entities-prompt": "No se han encontrado entidades", + "no-data": "No hay datos que mostrar", + "columns-to-display": "Columnas a Mostrar", + "type-api-usage-state": "Estado de uso de la API", + "type-edge": "Borde", + "type-edges": "Bordes", + "list-of-edges": "{count, plural, 1 {Un borde} other {Lista de # bordes} }", + "edge-name-starts-with": "Bordes cuyos nombres comienzan con '{{prefijo}}'", + "type-tb-resource": "Recurso", + "type-ota-package": "Paquete OTA" + }, + "entity-field": { + "created-time": "Hora de creación", + "name": "Nombre", + "type": "Tipo", + "first-name": "Nombre", + "last-name": "Apellido", + "email": "Correo electrónico", + "title": "Título", + "country": "País", + "state": "Estado", + "city": "Ciudad", + "address": "Dirección", + "address2": "Dirección 2", + "zip": "Código postal", + "phone": "Teléfono", + "label": "Etiqueta" + }, + "entity-view": { + "entity-view": "Vista de entidad", + "entity-view-required": "Vista de entidad es requerido.", + "entity-views": "Vistas de entidad", + "management": "Gestión de vistas de entidad", + "view-entity-views": "Ver vista de entidad", + "entity-view-alias": "Alias de vista de entidad", + "aliases": "Alias de vista de entidad", + "no-alias-matching": "'{{alias}}' no encontrado.", + "no-aliases-found": "No se encontraron alias.", + "no-key-matching": "'{{key}}' no encontrada.", + "no-keys-found": "No se encontraron claves.", + "create-new-alias": "¡Crear un nuevo!", + "create-new-key": "¡Crear una nueva!", + "duplicate-alias-error": "Alias duplicado'{{alias}}'.
    Los alias de Entity View deben ser únicos en el panel.", + "configure-alias": "Configurar alias '{{alias}}'", + "no-entity-views-matching": "No se encontraron vistas que coincidan con '{{entity}}'.", + "public": "Público", + "alias": "Alias", + "alias-required": "Alias de vista de entidad es requerido.", + "remove-alias": "Borrar alias de la vista de entidad", + "add-alias": "Añadir alias a la vista de entidad", + "name-starts-with": "Nombre de vista de entidad comienza con", + "help-text": "Usar el símbolo '%' de acuerdo a las necesidades: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Lista de vistas de entidad", + "use-entity-view-name-filter": "Usar el filtro", + "entity-view-list-empty": "No hay vistas de entidad seleccionadas.", + "entity-view-name-filter-required": "Nombre del filtro de vista de entidad es requerido.", + "entity-view-name-filter-no-entity-view-matched": "No se encontraron vistas de entidad que comiencen con '{{entityView}}'.", + "add": "Añadir vista de entidad", + "entity-view-public": "Vista de entidad es pública", + "assign-to-customer": "Asignar a cliente", + "assign-entity-view-to-customer": "Asignar vista de entidad a cliente", + "assign-entity-view-to-customer-text": "Por favor, seleccione las vistas de entidad para asignar al cliente", + "assign-entity-view-to-edge-title": "Asignar vista(s) de entidad a Borde", + "no-entity-views-text": "No se encontraron vistas de entidad", + "assign-to-customer-text": "Por favor, seleccione el cliente para asignar la vista de entidad", + "entity-view-details": "Detalles de la vista de entidad", + "add-entity-view-text": "Añadir nueva vista de entidad", + "delete": "Borrar vista de entidad", + "assign-entity-views": "Asignar vistas de entidad", + "assign-entity-views-text": "Asignar { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } a cliente", + "delete-entity-views": "Borrar vistas de entidad", + "unassign-from-customer": "Anular asignación a cliente", + "unassign-entity-views": "Anular asignación de vistas de entidad", + "unassign-entity-views-action-title": "Anular asignación { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } al cliente", + "assign-new-entity-view": "Asignar nueva vista de entidad", + "delete-entity-view-title": "¿Está seguro que quiere borrar la vista de entidad '{{entityViewName}}'?", + "delete-entity-view-text": "¡Cuidado! Tras la confirmación, la vista de la entidad y todos los datos relacionados serán irrecuperables.", + "delete-entity-views-title": "¿Está seguro que quiere borrar las vistas de entidad { count, plural, 1 {1 entityView} other {# entityViews} }?", + "delete-entity-views-action-title": "Borrar { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }", + "delete-entity-views-text": "¡Cuidado! Tras la confirmación, todas las vistas de entidades seleccionadas se eliminarán y todos los datos relacionados serán irrecuperables.", + "unassign-entity-view-title": "¿Está seguro que quiere anular la asignación de la vista de entidad '{{entityViewName}}'?", + "unassign-entity-view-text": "Tras la confirmación, la vista de la entidad quedará sin asignar y el cliente no podrá acceder a ella.", + "unassign-entity-view": "Anular asignación de la vista de entidad", + "unassign-entity-views-title": "¿Está seguro que quiere anular la asignación de { count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }?", + "unassign-entity-views-text": "Tras la confirmación, todas las vistas de entidades seleccionadas quedarán sin asignar y el cliente no podrá acceder a ellas.", + "entity-view-type": "Tipo de vista de entidad", + "entity-view-type-required": "Tipo de vista de entidad es requerido.", + "select-entity-view-type": "Seleccione el tipo de vista de entidad", + "enter-entity-view-type": "Teclee el tipo de vista de entidad", + "any-entity-view": "Cualquier vista de entidad", + "no-entity-view-types-matching": "No se encontraron tipos de vista de entidad que coincidan con '{{entitySubtype}}'.", + "entity-view-type-list-empty": "No hay tipos de vista de entidad seleccionados.", + "entity-view-types": "Tipos de vista de entidad", + "created-time": "Fecha de creación", + "name": "Nombre", + "name-required": "Nombre Requerido.", + "name-max-length": "Nombre debe ser menor de 256", + "type-max-length": "Tipo de vista de entidad debe ser menor de 256", + "description": "Descripción", + "events": "Eventos", + "details": "Detalles", + "copyId": "Copiar el Id de la vista de entidad", + "idCopiedMessage": "El Id de la vista de entidad se ha copiado al portapapeles", + "assignedToCustomer": "Asignado a cliente", + "unable-entity-view-device-alias-title": "No se puede eliminar el alias de vista de entidad", + "unable-entity-view-device-alias-text": "El alias del dispositivo '{{entityViewAlias}}' no se puede borrar porque está siendo usado por el widget(s):
    {{widgetsList}}", + "select-entity-view": "Seleccionar vista de entidad", + "make-public": "Hacer pública la vista de entidad", + "make-private": "Hacer que la vista de entidad sea privada", + "start-date": "Fecha de inicio", + "start-ts": "Tiempo de inicio", + "end-date": "Fecha de finalización", + "end-ts": "Tiempo de finalización", + "date-limits": "Limites de fecha", + "client-attributes": "Atributos de cliente", + "shared-attributes": "Atributos compartidos", + "server-attributes": "Atributos de servidor", + "timeseries": "Series temporales", + "client-attributes-placeholder": "Atributos de cliente", + "shared-attributes-placeholder": "Atributos compartidos", + "server-attributes-placeholder": "Atributos de servidor", + "timeseries-placeholder": "Series temporales", + "target-entity": "Entidad objetivo", + "attributes-propagation": "Propagación de atributos", + "attributes-propagation-hint": "La vista de entidad copiará automáticamente los atributos especificados de la entidad de destino cada vez que guarde o actualice esta vista de entidad. Por razones de rendimiento, los atributos de entidad objetivo no se propagan a la vista de entidad en cada cambio de atributo. Puede habilitar la propagación automática configurando el nodo de la regla \"copiar a la vista\" en su cadena de reglas y vincular los mensajes \"Atributos de la publicación\" y \"Atributos actualizados\" al nuevo nodo de la regla.", + "timeseries-data": "Datos de series temporales", + "timeseries-data-hint": "Configure las claves de los datos de las series temporales de la entidad de destino que serán accesibles para la vista de la entidad. Los datos de esta serie temporal son de solo lectura.", + "search": "Buscar vistas de entidad", + "selected-entity-views": "{ count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } seleccionadas", + "make-public-entity-view-title": "¿Está seguro de que desea que la vista de entidad '{{entityViewName}}' sea pública?", + "make-public-entity-view-text": "Tras la confirmación, la vista de la entidad y todos sus datos se harán públicos y accesibles para otros.", + "make-private-entity-view-title": "¿Está seguro de que desea que la vista de entidad '{{entityViewName}}' sea privada?", + "make-private-entity-view-text": "Tras la confirmación, la vista de la entidad y todos sus datos se harán privados y no serán accesibles para otros.", + "assign-entity-view-to-edge": "Asignar vista (s) de entidad a borde", + "assign-entity-view-to-edge-text": "Seleccione las vistas de entidad para asignar al borde", + "unassign-entity-view-from-edge-title": "¿Está seguro de que desea anular la asignación de la vista de entidad '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Después de la confirmación, la vista de entidad quedará sin asignar y el borde no podrá acceder a ella", + "unassign-entity-views-from-edge-action-title": "Anular asignación {count, plural, 1 {1 vista de entidad} other {# vistas de entidad} } del borde", + "unassign-entity-view-from-edge": "Anular asignación de vista de entidad", + "unassign-entity-views-from-edge-title": "¿Está seguro de que desea desasignar {count, plural, 1 {1 vista de entidad} other {# vistas de entidad} }?", + "unassign-entity-views-from-edge-text": "Después de la confirmación, todas las vistas de entidad seleccionadas no serán asignadas y el borde no podrá acceder a ellas" + }, + "event": { + "event-type": "Tipo de evento", + "events-filter": "Filtro de Eventos", + "clean-events": "Borrar Eventos", + "type-error": "Error", + "type-lc-event": "Ciclo de vida del evento", + "type-stats": "Estadísticas", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "Ningún evento encontrado.", + "error": "Error", + "alarm": "Alarma", + "event-time": "Hora del evento", + "server": "Servidor", + "body": "Cuerpo", + "method": "Método", + "type": "Tipo", + "message-id": "Id Mensaje", + "message-type": "Tipo Mensaje", + "data-type": "Tipo de Datos", + "relation-type": "Tipo de relación", + "metadata": "Metadatos", + "data": "Datos", + "event": "Evento", + "status": "Estado", + "success": "Éxito", + "failed": "Fallo", + "messages-processed": "Mensajes procesados", + "min-messages-processed": "Mínimo de mensajes procesados", + "errors-occurred": "Ocurrieron errores", + "min-errors-occurred": "Minimum errors occurred", + "min-value": "El valor mínimo es 0.", + "all-events": "Todos", + "has-error": "Tiene error", + "entity-id": "Id de Entidad", + "entity-type": "Tipo de entidad", + "clear-filter": "Limpiar Filtro", + "clear-request-title": "Borrar todos los eventos", + "clear-request-text": "Estás seguro de borrar todos los eventos?" + }, + "extension": { + "extensions": "Extensiones", + "selected-extensions": "{ count, plural, 1 {1 extensión} other {# extensiones} } 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": "El id de extensión ya existe.", + "delete": "Borrar Extensión", + "add": "Añadir Extensión", + "edit": "Editar Extensión", + "delete-extension-title": "Eliminar la extensión '{{extensionId}}'?", + "delete-extension-text": "Atención, tras la confirmación la extensión y sus datos serán borrados e irrecuperables.", + "delete-extensions-title": "Eliminar las extensiones { count, plural, 1 {1 extensión} other {# extensiones} }?", + "delete-extensions-text": "Atención, tras la confirmación todas las extensiones seleccionadas y sus datos serán borrados e irrecuperables.", + "converters": "Convertidores", + "converter-id": "Id de convertidor", + "configuration": "Configuración", + "converter-configurations": "Ajustes de convertidor", + "token": "Tóken de seguridad", + "add-converter": "Añadir convertidor", + "add-config": "Añadir configuración de convertidor", + "device-name-expression": "Expresión del nombre de dispositivo", + "device-type-expression": "Expresión del tipo de dispositivo", + "custom": "Personalizado", + "to-double": "Para duplicar", + "transformer": "Transformador", + "json-required": "Se requiere el JSON del transformador.", + "json-parse": "No ha sido posible analizar el JSON del transformador.", + "attributes": "Atributos", + "add-attribute": "Añadir Atributo", + "add-map": "Agregar elemento de mapeado", + "timeseries": "Series de tiempo", + "add-timeseries": "Añadir series de tiempo", + "field-required": "Campo requerido", + "brokers": "Brokers", + "add-broker": "Añadir broker", + "host": "Host", + "port": "Puerto", + "port-range": "El puerto debe estar en un rango de 1 a 65535.", + "ssl": "SSL", + "credentials": "Credenciales", + "username": "Usuario", + "password": "Contraseña", + "retry-interval": "Intervalo de reintento en milisegundos", + "anonymous": "Anónimo", + "basic": "Básico", + "pem": "PEM", + "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": "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 solicitud 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": "Tiempo de espera de respuesta 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": "Almacén de claves", + "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": "Clave de contraseña", + "opc-device-node-pattern": "Patrón de nodo de dispositivo", + "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-tcp-reconnect": "Reconexión automática", + "modbus-rtu-over-tcp": "RTU sobre TCP", + "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": "Estado", + "sync": "Sincronizado", + "not-sync": "No Sincronizado", + "last-sync-time": "Hora de última sincronización", + "not-available": "No disponible" + }, + "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": "Fichero de extensiones", + "invalid-file-error": "Fichero de extensiones inválido" + }, + "filter": { + "add": "Añadir filtro", + "edit": "Editar filtro", + "name": "Nombre de filtro", + "name-required": "Se requiere nombre de filtro.", + "duplicate-filter": "Ya existe un filtro con el mismo nombre.", + "filters": "Filtros", + "unable-delete-filter-title": "Error borrando filtro", + "unable-delete-filter-text": "El filtro '{{filter}}' no puede ser borrado debido a que está siendo usado actualmente por los siguientes widgets:
    {{widgetsList}}", + "duplicate-filter-error": "Se ha encontrado un filtro duplicado '{{filter}}'.
    Los filtros deben ser únicos en el panel.", + "missing-key-filters-error": "No se encontró la clave de filtros para el filtro '{{filter}}'.", + "filter": "Filtro", + "editable": "Editable", + "no-filters-found": "No se encontraron filtros.", + "no-filter-text": "No se ha especificado filtro", + "add-filter-prompt": "Por favos, añadir filtro", + "no-filter-matching": "'{{filter}}' no encontrado.", + "create-new-filter": "Crear un filtro nuevo!", + "filter-required": "Se requiere filtro.", + "operation": { + "operation": "Operación", + "equal": "igual", + "not-equal": "no igual", + "starts-with": "comienza con", + "ends-with": "acaba con", + "contains": "contiene", + "not-contains": "no contiene", + "greater": "mayor que", + "less": "menor que", + "greater-or-equal": "mayor o igual", + "less-or-equal": "menor o igual", + "and": "y", + "or": "o", + "in": "en", + "not-in": "no en" + }, + "ignore-case": "Ignorar mayús/minus", + "value": "Valor", + "remove-filter": "Borrar filtro", + "preview": "Vista previa de filtro", + "no-filters": "No hay filtros configurados", + "add-filter": "Añadir filtro", + "add-complex-filter": "Añadir filtro complejo", + "add-complex": "Agregar filtro complejo", + "complex-filter": "Filtro complejo", + "edit-complex-filter": "Editar filtro complejo", + "edit-filter-user-params": "Editar parámetros de usuario del filtro", + "filter-user-params": "Filtro de parámetros de usuario (predicado)", + "user-parameters": "Parámetros de usuario", + "display-label": "Etiqueta a mostrar", + "autogenerated-label": "Auto generar etiqueta", + "order-priority": "Prioridad orden de campos", + "key-filter": "Filtros (clave)", + "key-filters": "Filtros (claves)", + "key-name": "Nombre de clave", + "key-name-required": "Se requiere nombre de clave.", + "key-type": { + "key-type": "Tipo de clave", + "attribute": "Atributo", + "timeseries": "Timeseries", + "entity-field": "Campo de entidad", + "constant": "Constante" + }, + "value-type": { + "value-type": "Tipo de valor", + "string": "Cadena", + "numeric": "Numerico", + "boolean": "Booleano", + "date-time": "Fecha/Hora" + }, + "value-type-required": "Se requiere tipo de valor.", + "key-value-type-change-title": "Cambiar el tipo de valor de la clave?", + "key-value-type-change-message": "Si confirmas el nuevo tipo, todos los filtros se borrarán.", + "no-key-filters": "No hay filtros claves configurados", + "add-key-filter": "Añadir filtro clave", + "remove-key-filter": "Borrar filtro clave", + "edit-key-filter": "Editar filtro clave", + "date": "Fecha", + "time": "Hora", + "current-tenant": "Admin actual", + "current-customer": "Cliente actual", + "current-user": "Usuario actual", + "current-device": "Dispositivo actual", + "default-value": "Valor por defecto", + "dynamic-source-type": "Tipo de origen dinámico", + "dynamic-value": "Valor dinámico", + "no-dynamic-value": "Sin valor dinámico", + "source-attribute": "Atributo de origen", + "switch-to-dynamic-value": "Cambiar a valor dinámico", + "switch-to-default-value": "Cambiar a valor por defecto", + "inherit-owner": "Heredar de propietario", + "source-attribute-not-set": "Si los atributos de origen no están seleccionados" + }, + "fullscreen": { + "expand": "Expandir a Pantalla Completa", + "exit": "Salir de Pantalla Completa", + "toggle": "Cambiar el modo de Pantalla Completa", + "fullscreen": "Pantalla Completa" + }, + "function": { + "function": "Función" + }, + "gateway": { + "add-entry": "Añadir configuración", + "connector-add": "Añadir conector", + "connector-enabled": "Activar conector", + "connector-name": "Nombre conector", + "connector-name-required": "Se requiere nombre conector.", + "connector-type": "Tipo conector", + "connector-type-required": "Se requiere tipo conector.", + "connectors": "Configuración de conectores", + "create-new-gateway": "Crear un gateway nuevo", + "create-new-gateway-text": "Crear un nuevo gateway con el nombre: '{{gatewayName}}'?", + "delete": "Borrar configuración", + "download-tip": "Descargar fichero de configuración", + "gateway": "Gateway", + "gateway-exists": "Ya existe un dispositivo con el mismo nombre.", + "gateway-name": "Nombre de Gateway", + "gateway-name-required": "Se requiere un nombre de gateway.", + "gateway-saved": "Configuración de gateway grabada satisfactoriamente.", + "json-parse": "JSON no válido.", + "json-required": "El campo no puede estar vacío.", + "no-connectors": "No hay conectores", + "no-data": "No hay configuraciones", + "no-gateway-found": "No se ha encontrado ningún gateway.", + "no-gateway-matching": " '{{item}}' no encontrado.", + "path-logs": "Ruta a los archivos de log", + "path-logs-required": "Ruta requerida.", + "remote": "Configuración remota", + "remote-logging-level": "Nivel de logging", + "remove-entry": "Borrar configuración", + "save-tip": "Grabar fichero de configuración", + "security-type": "Tipo de seguridad", + "security-types": { + "access-token": "Token de acceso", + "tls": "TLS" + }, + "storage": "Grabación", + "storage-max-file-records": "Número máximo de registros en fichero", + "storage-max-files": "Número máximo de ficheros", + "storage-max-files-min": "El número mínimo es 1.", + "storage-max-files-pattern": "Número no válido.", + "storage-max-files-required": "Se requiere número.", + "storage-max-records": "Máximo de registros en el almacén", + "storage-max-records-min": "El número mínimo es 1.", + "storage-max-records-pattern": "Número no válido.", + "storage-max-records-required": "Se requiere número.", + "storage-pack-size": "Tamaño máximo de eventos", + "storage-pack-size-min": "El número mínimo es 1.", + "storage-pack-size-pattern": "Número no válido.", + "storage-pack-size-required": "Se requiere número.", + "storage-path": "Ruta de almacén", + "storage-path-required": "Se requiere ruta de almacén.", + "storage-type": "Tipo de almacén", + "storage-types": { + "file-storage": "Almacén fichero", + "memory-storage": "Almacén en memoria" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "Host ThingsBoard", + "thingsboard-host-required": "Se requiere Host.", + "thingsboard-port": "Puerto ThingsBoard", + "thingsboard-port-max": "El puerto máximo es 65535.", + "thingsboard-port-min": "El puerto mínimo es 1.", + "thingsboard-port-pattern": "Puerto no válido.", + "thingsboard-port-required": "Se requiere puerto.", + "tidy": "Tidy", + "tidy-tip": "Tidy JSON", + "title-connectors-json": "Configuración conector {{typeName}}", + "tls-path-ca-certificate": "Ruta al certificado CA en el gateway", + "tls-path-client-certificate": "Ruta al certificado cliente en el gateway", + "tls-path-private-key": "Ruta a la clave privada en el gateway", + "toggle-fullscreen": "Pantalla completa fullscreen", + "transformer-json-config": "Configuración JSON*", + "update-config": "Añadir/actualizar configuración JSON" + }, + "grid": { + "delete-item-title": "¿Quieres eliminar este item?", + "delete-item-text": "Atención, tras la confirmación el item será eliminado y la información relacionada será irrecuperable.", + "delete-items-title": "¿Quieres eliminar { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Eliminar { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Atención, tras la confirmación 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" + }, + "help": { + "goto-help-page": "Ir a la página de ayuda", + "show-help": "Mostrar ayuda" + }, + "home": { + "home": "Principal", + "profile": "Perfil", + "logout": "Salir", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Abrir menú de usuario" + }, + "file-input": { + "browse-file": "Navegar fichero", + "browse-files": "Navegar ficheros" + }, + "image-input": { + "drop-image-or": "Arrastrar y soltar una imagen o", + "drop-images-or": "Arrastrar y soltar imagenes o", + "no-images": "No hay imágenes seleccionadas", + "images": "imágenes" + }, + "import": { + "no-file": "Ningún archivo seleccionado", + "drop-file": "Suelte un archivo JSON o haga clic para seleccionar un archivo para cargar.", + "drop-json-file-or": "Suele un archivo JSON o", + "drop-file-csv": "Suelte un archivo CSV o haga clic para seleccionar un archivo para cargar.", + "drop-file-csv-or": "Suelte un archivo CSV o", + "column-value": "Valor", + "column-title": "Título", + "column-example": "Datos de ejemplo", + "column-key": "Clave de atributo/telemetría", + "credentials": "Credenciales", + "csv-delimiter": "Delimitador CSV", + "csv-first-line-header": "La primera línea contiene nombres de columna.", + "csv-update-data": "Actualizar atributos/telemetría", + "details": "Detalles", + "import-csv-number-columns-error": "Un archivo debe contener al menos dos columnas", + "import-csv-invalid-format-error": "Formato de archivo inválido. Línea: '{{line}}'", + "column-type": { + "name": "Nombre", + "type": "Tipo", + "label": "Etiqueta", + "column-type": "Tipo de columna", + "client-attribute": "Atributo de cliente", + "shared-attribute": "Atributo compartido", + "server-attribute": "Atributo de servidor", + "timeseries": "Series de tiempo", + "entity-field": "Campo de entidad", + "access-token": "Token de acceso", + "x509": "X.509", + "mqtt": { + "client-id": "Client ID MQTT", + "user-name": "Usuario MQTT", + "password": "Contraseña MQTT" + }, + "lwm2m": { + "client-endpoint": "Nombre endpoint cliente LwM2M", + "security-config-mode": "Configuración de seguridad LwM2M", + "client-identity": "Identidad cliente LwM2M", + "client-key": "Clave cliente LwM2M", + "client-cert": "Clave pública cliente LwM2M", + "bootstrap-server-security-mode": "Modo seguridad servidor Bootstrap LwM2M", + "bootstrap-server-secret-key": "Clave secreta servidor Bootstrap LwM2M", + "bootstrap-server-public-key-id": "Clave pública o id servidor Bootstrap LwM2M", + "lwm2m-server-security-mode": "Modo seguridad servidor LwM2M", + "lwm2m-server-secret-key": "Clave secreta servidor LwM2M", + "lwm2m-server-public-key-id": "Clave pública servidor LwM2M" + }, + "isgateway": "Es Gateway", + "activity-time-from-gateway-device": "Fecha de actividad desde el dispositivo gateway", + "description": "Descripción", + "routing-key": "Clave Edge", + "secret": "Secreto Edge" + }, + "stepper-text": { + "select-file": "Seleccione un archivo", + "configuration": "Importar configuración", + "column-type": "Seleccionar tipo de columnas", + "creat-entities": "Creando nuevas entidades" + }, + "message": { + "create-entities": "Se crearon {{count}} nuevas entidades correctamente.", + "update-entities": "{{count}} entidades se actualizaron correctamente.", + "error-entities": "Se produjo un error al crear {{count}} entidades." + } + }, + "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}}'!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Clave", + "value": "Valor", + "remove-entry": "Borrar entrada", + "add-entry": "Añadir entrada", + "no-data": "Sin datos" + }, + "layout": { + "layout": "Diseño", + "manage": "Administrar diseños", + "settings": "Ajustes de diseño", + "color": "Color", + "main": "Principal", + "right": "Derecho", + "select": "Seleccionar diseño de destino" + }, + "legend": { + "direction": "Dirección de la leyenda", + "position": "Posición de la leyenda", + "sort-legend": "Ordenar claves en 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", + "comparison-time-ago": { + "previousInterval": "(intervalo anterior)", + "customInterval": "(intervalo personalizado)", + "days": "(hace un día)", + "weeks": "(hace una semana)", + "months": "(hace un mes)", + "years": "(hace un año)" + } + }, + "login": { + "login": "Entrar", + "request-password-reset": "Solicitar restablecer contraseña", + "reset-password": "Restablecer contraseña", + "create-password": "Crear contraseña", + "two-factor-authentication": "Autenticado de dos factores", + "passwords-mismatch-error": "¡Las contraseñas introducidas deben ser iguales!", + "password-again": "Repita la contraseña de nuevo", + "sign-in": "Por favor, inicie sesión", + "username": "Nombre de usuario (correo electrónico)", + "remember-me": "Recordarme", + "forgot-password": "¿Olvidó la contraseña?", + "password-reset": "Restablecer contraseña", + "expired-password-reset-message": "Tus credenciales han expirado! Por favor, crea una nueva 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", + "login-with": "Login con {{name}}", + "or": "o", + "error": "Error de Login", + "verify-your-identity": "Verificar identidad", + "select-way-to-verify": "Selecciona el modo de verificación", + "resend-code": "Reenviar código", + "resend-code-wait": "Reenviar código en { time, plural, 1 {1 segundo} other {# segundos} }", + "try-another-way": "Otros modos de verificación", + "totp-auth-description": "Por favor, introduce el código de seguridad de tu aplicación de autenticación.", + "totp-auth-placeholder": "Código", + "sms-auth-description": "Se ha enviado un código de verificación a tu número: {{contact}} proporcionado.", + "sms-auth-placeholder": "Código SMS", + "email-auth-description": "Se ha enviado un código de verificación al email {{contact}} proporcionado.", + "email-auth-placeholder": "Código Email", + "backup-code-auth-description": "Por favor, introduce el código de backup.", + "backup-code-auth-placeholder": "Código de Backup" + }, + "markdown": { + "edit": "Editar", + "preview": "Previsualizar", + "copy-code": "Click para copiar", + "copied": "Copiado!" + }, + "ota-update": { + "add": "Añadir paquete", + "assign-firmware": "Firmware asignado", + "assign-firmware-required": "Firmware asignado requerido", + "assign-software": "Software asignado", + "assign-software-required": "Software asignado requerido", + "auto-generate-checksum": "Auto-generar checksum", + "checksum": "Checksum", + "checksum-hint": "Si el checksum está vacío, se generará automáticamente", + "checksum-algorithm": "Algoritmo checksum", + "checksum-copied-message": "El checksum del paquete se ha copiado al portapapeles", + "change-firmware": "El cambio del firmware provocará la actualización de { count, plural, 1 {1 dispositivo} other {# dispositivos} }.", + "change-software": "El cambio del software provocará la actualización de { count, plural, 1 {1 dispositivo} other {# dispositivos} }.", + "chose-compatible-device-profile": "El paquete subido, sólamente estará disponible para los dispositivos con el perfil seleccionado.", + "chose-firmware-distributed-device": "Elige el firmware que se distribuirá a los dispositivos", + "chose-software-distributed-device": "Elige el software que se distribuirá a los dispositivos", + "content-type": "Tipo de contenido", + "copy-checksum": "Copiar checksum", + "copy-direct-url": "Copiar URL directa", + "copyId": "Copiar Id de paquete", + "copied": "Copiado!", + "delete": "Borrar paquete", + "delete-ota-update-text": "Atención, tras la confirmación la actualización OTA será irrecuperable.", + "delete-ota-update-title": "Estás seguro de borrar la actualización OTA '{{title}}'?", + "delete-ota-updates-text": "Atención, tras la confirmación todas las actualizaciones OTA seleccionadas se borrarán.", + "delete-ota-updates-title": "Estás seguro de borrar { count, plural, 1 {1 Actualización OTA} other {# Actualizaciones OTA} }?", + "description": "Descripción", + "direct-url": "URL Directa", + "direct-url-copied-message": "La URL directa del paquete se ha copiado al portapapeles", + "direct-url-required": "URL Directa requerida", + "download": "Descargar paquete", + "drop-file": "Arrastra un fichero o haz click para seleccionar un fichero a subir.", + "drop-package-file-or": "Arrastrar y soltar un fichero o", + "file-name": "Nombre del fichero", + "file-size": "Tamaño del fichero", + "file-size-bytes": "Tamaño del fichero en bytes", + "idCopiedMessage": "El Id del paquete se ha copiado al portapapeles", + "no-firmware-matching": "No se ha encontrado ningún paquete de firmware OTA compatible que coincidan con '{{entity}}'.", + "no-firmware-text": "No hay aprovisionado ningún paquete de firmware OTA.", + "no-packages-text": "No se han encontrado paquetes", + "no-software-matching": "No se ha encontrado ningún paquete de software OTA compatible que coincidan con '{{entity}}'.", + "no-software-text": "No hay aprovisionado ningún paquete de software OTA.", + "ota-update": "Actualización OTA", + "ota-update-details": "Detalles de actualización OTA", + "ota-updates": "Actualizaciones OTA", + "package-type": "Tipo de paquete", + "packages-repository": "Repositorio de paquetes", + "search": "Buscar paquetes", + "selected-package": "{ count, plural, 1 {1 paquete} other {# paquetes} } selected", + "title": "Título", + "title-required": "Título requerido.", + "title-max-length": "El título debe ser menor de 256", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Subir fichero binario", + "use-external-url": "Usar una URL externa", + "version": "Versión", + "version-required": "Versión requerida.", + "version-tag": "Tag de Versión", + "version-tag-hint": "El tag de versión debe coincidir con la versión reportada por el dispositivo.", + "version-max-length": "Versión debe ser menor que 256", + "warning-after-save-no-edit": "Una vez que el paquete se haya subido, no se podrá modificar el título, versión, perfil de dispositivo y tipo de paquete." + }, + "position": { + "top": "Superior", + "bottom": "Inferior", + "left": "Izquierda", + "right": "Derecha" + }, + "profile": { + "profile": "Perfil", + "last-login-time": "Último acceso", + "change-password": "Cambiar contraseña", + "current-password": "Contraseña actual", + "copy-jwt-token": "Copiar JWT", + "jwt-token": "Tóken JWT", + "token-valid-till": "Válido hasta {{expirationData}}", + "tokenCopiedSuccessMessage": "JWT copiado al portapapeles", + "tokenCopiedWarnMessage": "JWT caducado, por favor actualiza la página." + }, + "security": { + "security": "Seguridad", + "2fa": { + "2fa": "Autenticación de doble factor (2FA)", + "2fa-description": "La autenticación de doble factor proteje tu cuenta de accesos no autorizados. Lo único que tienes que hacer es entrar un código de seguridad al hacer login.", + "authenticate-with": "Puedes autenticarte con:", + "disable-2fa-provider-text": "Desactivar {{name}} hará tu cuenta menos segura", + "disable-2fa-provider-title": "Estás seguro de desactivar {{name}}?", + "get-new-code": "Obtener un nuevo código", + "main-2fa-method": "Usar como método de autenticación de doble factor principal", + "dialog": { + "activation-step-description-email": "La próxima vez que hagas log-in, se deberá introducir el código de seguridad que se enviará a tu dirección de email.", + "activation-step-description-sms": "La próxima vez que hagas log-in, se deberá introducir el código de seguridad que se enviará a tu número de teléfono.", + "activation-step-description-totp": "La próxima vez que hagas log-in, se deberá introducir el código de seguridad de doble factor.", + "activation-step-label": "Activación", + "backup-code-description": "Imprime los códigos y guárdalos en un lugar seguro, se necesitarán para hacer login en tu cuenta. Puedes usar cada código de backup sólo una vez.", + "backup-code-warn": "Una vez que salgas de esta página, estos códigos no se volverán a mostrar. Guárdalos en un lugar seguro usando las opciones a continuación.", + "download-txt": "Descargar (txt)", + "email-step-description": "Introduce tu email para usar como autenticador.", + "email-step-label": "Email", + "enable-email-title": "Activar autenticador por email", + "enable-sms-title": "Activar autenticador por SMS", + "enable-totp-title": "Activar autenticador por app", + "enter-verification-code": "Entra el código de 6-dígitos", + "get-backup-code-title": "Obtener copia de seguridad", + "next": "Siguiente", + "scan-qr-code": "Escanea el código QR con tu aplicación de autenticación", + "send-code": "Enviar código", + "sms-step-description": "Introduce tu número de teléfono para usar como autenticador.", + "sms-step-label": "Número de teléfono", + "success": "Éxito!", + "totp-step-description-install": "Puedes instalar la aplicación Google Authenticator, Authy, o Duo.", + "totp-step-description-open": "Abre la aplicación de autenticación en tu teléfono móvil.", + "totp-step-label": "Obtener app", + "verification-code": "Código de 6-dígitos", + "verification-code-invalid": "Formáto de código inválido", + "verification-code-incorrect": "El código de verificación es incorrecto", + "verification-code-many-request": "Demasiadas solicitudes, revisa el código de verificación", + "verification-step-description": "Introduce el código de 6-dígitos que acabamos de enviar a {{address}}", + "verification-step-label": "Verificación" + }, + "provider": { + "email": "Email", + "email-description": "Usar un código de seguridad enviado a tu email para autenticarte.", + "email-hint": "Los códigos de autenticación se han enviado por email a {{ info }}", + "sms": "SMS", + "sms-description": "Usar tu teléfono para autenticarte. Enviaremos un código de seguridad vía SMS.", + "sms-hint": "Los códigos de autenticación se han enviado por mensaje de texto SMS a {{ info }}", + "totp": "App de autenticación", + "totp-description": "Usar aplicaciones como Google Authenticator, Authy, o Duo en tu teléfono para autenticarte. Se generará un código de seguridad para hacer login.", + "totp-hint": "La aplicación de autenticación se ha configurado en tu cuenta", + "backup_code": "Código Backup", + "backup-code-description": "Estos códigos de seguridad imprimibles son de un solo uso, te permiten identificarte cuando no tengas el teléfono a mano, útil cuando se viaja.", + "backup-code-hint": "{{ info }} códigos de un solo uso activos en este momento" + } + }, + "password-requirement": { + "at-least": "Al menos:", + "character": "{ count, plural, 1 {1 caracter} other {# caracteres} }", + "digit": "{ count, plural, 1 {1 dígito} other {# dígitos} }", + "incorrect-password-try-again": "Contraseña incorrecta. Prueba otra vez", + "lowercase-letter": "{ count, plural, 1 {1 minúscula} other {# mínusculas} }", + "new-passwords-not-match": "Las contraseñas no coinciden", + "password-should-not-contain-spaces": "La contraseña no puede contener espacios", + "password-not-meet-requirements": "La contraseña no reune los requisitos necesarios", + "password-requirements": "Requisitos de contraseña", + "password-should-difference": "La nueva contraseña debe ser diferente a la actual", + "special-character": "{ count, plural, 1 {1 caracter especial} other {# caracteres especiales} }", + "uppercase-letter": "{ count, plural, 1 {1 mayúscula} other {# mayúsculas} }" + } + }, + "relation": { + "relations": "Relaciones", + "direction": "Dirección", + "search-direction": { + "FROM": "Desde", + "TO": "Hacia" + }, + "direction-type": { + "FROM": "desde", + "TO": "hacia" + }, + "from-relations": "Relaciones salientes (outbound)", + "to-relations": "Relaciones entrantes (inbound)", + "selected-relations": "{ count, plural, 1 {1 relación} other {# relaciones} } seleccionadas", + "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": "Borrar relación", + "relation-type": "Tipo de relación", + "relation-type-required": "Tipo de relación requerido.", + "relation-type-max-length": "Tipo de relación debe ser menor de 256", + "any-relation-type": "Cualquier tipo", + "add": "Añadir relación", + "edit": "Editar relación", + "delete-to-relation-title": "¿Quieres eliminar la relación con la entidad '{{entityName}}'?", + "delete-to-relation-text": "Atención, tras la confirmación la entidad '{{entityName}}' no estará relacionada con la entidad actual.", + "delete-to-relations-title": "¿Quieres eliminar { count, plural, 1 {1 relación} other {# relaciones} }?", + "delete-to-relations-text": "Atención, tras la confirmación todas las relaciones seleccionadas se eliminarán y sus entidades correspondientes no estarán relacionadas con la entidad actual.", + "delete-from-relation-title": "¿Quieres eliminar la relación con la entidad '{{entityName}}'?", + "delete-from-relation-text": "Atención, tras la confirmación la entidad actual no estará relacionada con la entidad '{{entityName}}'.", + "delete-from-relations-title": "¿Quieres eliminar { count, plural, 1 {1 relación} other {# relaciones} }?", + "delete-from-relations-text": "Atención, tras la confirmación todas las relaciones seleccionadas se eliminarán y sus entidades correspondientes no estarán relacionadas con sus entidades correspondientes.", + "remove-relation-filter": "Quitar filtro de relación", + "add-relation-filter": "Añadir filtro de relación", + "any-relation": "Cualquier relación", + "relation-filters": "Filtro de relación", + "additional-info": "Información adicional (JSON)", + "invalid-additional-info": "Error al analizar el fichero JSON de información adicional.", + "no-relations-text": "No se encontraron relaciones" + }, + "resource": { + "add": "Añadir Recurso", + "copyId": "Copiar Id de recurso", + "delete": "Borrar recurso", + "delete-resource-text": "Atención, tras la confirmación el recurso será irrecuperable.", + "delete-resource-title": "Estás seguro de borrar el recurso '{{resourceTitle}}'?", + "delete-resources-action-title": "Borrar { count, plural, 1 {1 recurso} other {# recursos} }", + "delete-resources-text": "Los recursos serán borrados, incluso si están siendo usados en los perfiles de dispositivo.", + "delete-resources-title": "Estás seguro de borrar { count, plural, 1 {1 recurso} other {# recursos} }?", + "download": "Descargar recurso", + "drop-file": "Arrastra un fichero o haz click para seleccionar un fichero a subir.", + "drop-resource-file-or": "Arrastrar y soltar un fichero o", + "empty": "El recurso está vacío", + "file-name": "Nombre de fichero", + "idCopiedMessage": "El Id de recurso ha sido copiado al portapapeles", + "no-resource-matching": "No se han encontrado recursos que coincidan con '{{widgetsBundle}}'.", + "no-resource-text": "No se encontraron recursos", + "open-widgets-bundle": "Abrir paquete de widgets", + "resource": "Recurso", + "resource-library-details": "Detalles de recurso", + "resource-type": "Tipo de recurso", + "resources-library": "Librería de recursos", + "search": "Buscar recursos", + "selected-resources": "{ count, plural, 1 {1 recurso} other {# recursos} } seleccionados", + "system": "Sistema", + "title": "Título", + "title-required": "Título requerido.", + "title-max-length": "El título debe ser menor de 256" + }, + "rulechain": { + "rulechain": "Cadena de Regla", + "rulechains": "Cadenas de Reglas", + "root": "Raíz", + "delete": "Borrar cadena de reglas", + "name": "Nombre", + "name-required": "Nombre requerido.", + "name-max-length": "Nombre debe ser menor de 256", + "description": "Descripción", + "add": "Añadir Cadena", + "set-root": "Hacer la cadena de reglas Raíz", + "set-root-rulechain-title": "¿Desea hacer la cadena de reglas '{{ruleChainName}}' de tipo raíz?", + "set-root-rulechain-text": "Tras la confirmación, la cadena de reglas se volverá raíz y manejará todos los mensajes de transporte entrantes.", + "delete-rulechain-title": "¿Quieres eliminar la cadena de reglas '{{ruleChainName}}'?", + "delete-rulechain-text": "Atención, tras la confirmación la cadena de reglas y todos los datos serán irrecuperables.", + "delete-rulechains-title": "¿Está seguro que quieres eliminar { count, plural, 1 {1 cadena de reglas} other {# cadenas de reglas} }?", + "delete-rulechains-action-title": "Eliminar { count, plural, 1 {1 cadena de reglas} other {# cadenas de reglas} }", + "delete-rulechains-text": "Atención, tras la confirmación todas las cadena de reglas seleccionadas y todos sus datos serán irrecuperables.", + "add-rulechain-text": "Añadir 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": "Fichero de 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": "No se encontraron cadenas de reglas que coincidan con '{{entity}}' .", + "rulechain-required": "Cadena de reglas requerida", + "management": "Gestión de reglas", + "debug-mode": "Modo Debug", + "search": "Buscar cadenas de reglas", + "selected-rulechains": "{ count, plural, 1 {1 cadena de reglas} other {# cadenas de reglas} } seleccionadas", + "open-rulechain": "Abrir cadena de reglas", + "assign-new-rulechain": "Asignar nueva cadena de reglas", + "edge-template-root": "Raíz de plantilla", + "assign-to-edge": "Asignar a Edge", + "edge-rulechain": "Cadena de reglas de borde", + "unassign-rulechain-from-edge-text": "Después de la confirmación, la cadena de reglas quedará sin asignar y el borde no podrá acceder a ella", + "unassign-rulechains-from-edge-title": "Estás seguro de desasignar { count, plural, 1 {1 cadena de reglas} other {# cadenas de reglas} }?", + "unassign-rulechains-from-edge-text": "Después de la confirmación, todas las cadenas de reglas seleccionadas quedarán sin asignar y el borde no podrá acceder a ellas", + "assign-rulechain-to-edge-title": "Asignar cadena (s) de reglas a borde", + "assign-rulechain-to-edge-text": "Seleccione las cadenas de reglas para asignar al borde", + "set-edge-template-root-rulechain": "Hacer raíz de plantilla de borde de cadena de reglas", + "set-edge-template-root-rulechain-title": "¿Está seguro de que desea que la cadena de reglas '{{ruleChainName}}' sea la raíz de la plantilla de borde?", + "set-edge-template-root-rulechain-text": "Después de la confirmación, la cadena de reglas se convertirá en la raíz de la plantilla de borde y será la cadena de reglas raíz para los bordes recién creados.", + "invalid-rulechain-type-error": "No se puede importar la cadena de reglas: Tipo de cadena de reglas no válido. El tipo esperado es {{expectedRuleChainType}}", + "set-auto-assign-to-edge": "Asignar cadena de reglas a los bordes en la creación", + "set-auto-assign-to-edge-title": "¿Está seguro de que desea asignar automáticamente la cadena de reglas de borde '{{ruleChainName}}' a los bordes en la creación?", + "set-auto-assign-to-edge-text": "Después de la confirmación, la cadena de reglas de borde se asignará automáticamente a los bordes en la creación.", + "unset-auto-assign-to-edge": "Desmarcar asignar cadena de reglas a los bordes en la creación", + "unset-auto-assign-to-edge-title": "¿Está seguro de que desea anular la asignación de la cadena de reglas de borde '{{ruleChainName}}' a los bordes en la creación?", + "unset-auto-assign-to-edge-text": "Después de la confirmación, la cadena de reglas de borde ya no se asignará automáticamente a los bordes en la creación.", + "unassign-rulechain-title": "¿Está seguro de que desea desasignar la cadena de reglas '{{ruleChainName}}'?", + "unassign-rulechains": "Anular asignación de cadenas de reglas" + }, + "rulenode": { + "details": "Detalles", + "events": "Eventos", + "search": "Buscar nodos", + "open-node-library": "Abrir librería de nodos", + "add": "Añadir nodo de reglas", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "name-max-length": "Nombre debe ser menor de 256", + "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", + "create-nested-rulechain": "Crear cadena de reglas anidada", + "select-all": "Seleccionar todos", + "copy-selected": "Copiar seleccionado", + "deselect-all": "Deshacer selección de todos", + "rulenode-details": "Detalles del nodo de reglas", + "debug-mode": "Modo Debug", + "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-flow": "Flujo", + "type-flow-details": "Organiza el flujo de mensajes", + "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 Script de función", + "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 metadatos no pueden estar vacías.", + "output": "Salida", + "test": "Test", + "help": "Ayuda", + "reset-debug-mode": "Restablecer el modo de depuración en todos los nodos" + }, + "timezone": { + "timezone": "Zona Horaria", + "select-timezone": "Seleccionar zona horaria", + "no-timezones-matching": "No hay zonas horarias que coincidan con '{{timezone}}'.", + "timezone-required": "Se requiere zona horaria.", + "browser-time": "Hora del navegador" + }, + "queue": { + "queue-name": "Cola", + "no-queues-matching": "No se encontraron colas que coincidan con '{{queue}}'.", + "select_name": "Selecciona el nombre de la cola", + "name": "Nombre Cola", + "name_required": "Necesario especificar el nombre de cola", + "name-unique": "El nombre de cola ya existe!", + "queue-required": "Cola requerida!", + "topic-required": "Topic cola requerido!", + "poll-interval-required": "Intervalo de obtención requerido!", + "poll-interval-min-value": "El intervalo no debe ser menor de 1", + "partitions-required": "Particiones requeridas!", + "partitions-min-value": "El valor de particion no debe ser menor de 1", + "pack-processing-timeout-required": "Timeout de procesamiento", + "pack-processing-timeout-min-value": "Timeout de procesamiento no puede ser menor de 1", + "batch-size-required": "Tamaño del lote requerido!", + "batch-size-min-value": "El valor de tamaño de lote no puede ser menor de 1", + "retries-required": "Reintentos requerido!", + "retries-min-value": "El valor de reintentos no puede ser negativo", + "failure-percentage-required": "Porcentaje de fallos requerido!", + "failure-percentage-min-value": "Porcentaje de fallos no puede ser menor de 0", + "failure-percentage-max-value": "Porcentaje de fallos no puede ser mayor de 100", + "pause-between-retries-required": "Pausa entre reintentos requerido!", + "pause-between-retries-min-value": "Pausa mínima entre reintentos no puede ser menor de 1", + "max-pause-between-retries-required": "Pausa máxima entre reintentos requerido!", + "max-pause-between-retries-min-value": "Pausa máxima entre reintentos no puede ser menor de 1", + "submit-strategy-type-required": "Estrategia de envío requerida!", + "processing-strategy-type-required": "Estrategia de procesamiento requerida!", + "queues": "Colas", + "selected-queues": "{ count, plural, 1 {1 cola} other {# colas} } seleccionadas", + "delete-queue-title": "Estás seguro de borrar la cola '{{queueName}}'?", + "delete-queues-title": "Estás seguro de borrar { count, plural, 1 {1 cola} other {# colas} }?", + "delete-queue-text": "Atención, tras la confirmacion la cola y todos sus datos relacionados serán irrecuperables.", + "delete-queues-text": "Atención, tras la confirmación todas las colas se borrarán y no serán accesibles.", + "search": "Buscar cola", + "add" : "Añadir cola", + "details": "Detalles cola", + "topic": "Topic", + "submit-strategy": "Estrategia de envío", + "processing-strategy": "Estrategia de procesamiento", + "poll-interval": "Intervalo de obtención", + "partitions": "Particiones", + "consumer-per-partition": "Consumidores por partición", + "consumer-per-partition-hint": "Activar consumidores separados para cada partición", + "processing-timeout": "Timeout de procesamiento, ms", + "batch-size": "Tamaño de lote", + "retries": "Reintentos (0 - ilimitados)", + "failure-percentage": "Porcentaje de fallos", + "pause-between-retries": "Pausa entre reintentos", + "max-pause-between-retries": "Pausa máxima entre reintentos", + "delete": "Borrar cola", + "copyId": "Copiar Id de cola", + "idCopiedMessage": "La Id de cola se ha copiado al portapapeles", + "description": "Descripción", + "description-hint": "Este texto se mostrará en la descripción de la cola, en lugar de la estrategia seleccionada", + "alt-description": "Estrategia de envío: {{submitStrategy}}, Estrategia de procesamiento: {{processingStrategy}}", + "strategies": { + "sequential-by-originator-label": "Secuencial por iniciador", + "sequential-by-originator-hint": "El nuevo mensaje por ejemplo dispositivo A, no se enviará hasta que el mensaje anterior del dispositivo A sea admitido/procesado", + "sequential-by-tenant-label": "Secuencial por propietario", + "sequential-by-tenant-hint": "El nuevo mensaje, por ejemplo Propietario A, no se enviará hasta que el mensaje anterior del Propietario A sea admitido/procesado", + "sequential-label": "Secuencial", + "sequential-hint": "El nuevo mensaje no se enviará hasta que el mensaje anterior sea admitido/procesado", + "burst-label": "Ráfaga (Burst)", + "burst-hint": "Todos los mensajes se enviarán hacia las cadenas de reglas en el órden que lleguen", + "batch-label": "Lotes (Batch)", + "batch-hint": "El nuevo lote, no se enviará hasta que el anterior lote sea admitido/procesado", + "skip-all-failures-label": "Omitir todos los fallos", + "skip-all-failures-hint": "Ignorar todos los fallos", + "skip-all-failures-and-timeouts-label": "Omitir todos los fallos y timeouts", + "skip-all-failures-and-timeouts-hint": "Ignorar todos los fallos y timeouts", + "retry-all-label": "Reintentar todos", + "retry-all-hint": "Reintentar todos los mensajes del lote de procesamiento", + "retry-failed-label": "Reintentar fallidos", + "retry-failed-hint": "Reintentar todos los mensajes fallidos del lote de procesamiento", + "retry-timeout-label": "Timeout de reintentos", + "retry-timeout-hint": "Reintentar todos los mensajes que den timeout del lote de procesamiento", + "retry-failed-and-timeout-label": "Reintentar fallidos y timeouts", + "retry-failed-and-timeout-hint": "Reintentar todos los mensajes fallidos y que den timeout del lote de procesamiento" + } + }, + "tenant": { + "tenant": "Propietario", + "tenants": "Propietarios", + "management": "Gestión de Propietarios", + "add": "Agregar propietario", + "admins": "Admins", + "manage-tenant-admins": "Gestionar administradores de propietario", + "delete": "Eliminar propietario", + "add-tenant-text": "Agregar nuevo propietario", + "no-tenants-text": "Ningún propietario encontrado", + "tenant-details": "Detalles del propietario", + "title-max-length": "Título debe ser menor de 256", + "delete-tenant-title": "¿Quieres eliminar el propietario '{{tenantTitle}}'?", + "delete-tenant-text": "Atención, tras la confirmación el propietario será eliminado y la información relacionada será irrecuperable.", + "delete-tenants-title": "¿Quieres eliminar { count, plural, 1 {1 propietario} other {# propietarios} }?", + "delete-tenants-action-title": "Eliminar { count, plural, 1 {1 propietario} other {# propietarios} }", + "delete-tenants-text": "Atención, tras la confirmación los propietarios seleccionados serán eliminados y la información relacionada será irrecuperable.", + "title": "Título", + "title-required": "Título requerido.", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID de propietario", + "idCopiedMessage": "El ID de propietario se ha copiado al portapapeles", + "select-tenant": "Seleccionar propietario", + "no-tenants-matching": "No hay propietarios que coincidan con '{{entity}}' .", + "tenant-required": "Propietario requerido", + "search": "Buscar propietarios", + "selected-tenants": "{ count, plural, 1 {1 propietario} other {# propietarios} } seleccionados", + "isolated-tb-rule-engine": "Procesando en contenedor Motor de Reglas aislado", + "isolated-tb-rule-engine-details": "Requiere microservicios separados por propietario aislado" + }, + "tenant-profile": { + "tenant-profile": "Perfil de propietario", + "tenant-profiles": "Perfiles de propietarios", + "add": "Añadir perfil de propietario", + "edit": "Editar perfil de propietario", + "tenant-profile-details": "Detalles perfil de propietario", + "no-tenant-profiles-text": "No se encontraron perfiles de propietario", + "name-max-length": "El nombre debe ser menor de 256", + "search": "Buscar perfiles de propietario", + "selected-tenant-profiles": "{ count, plural, 1 {1 perfil de propietario} other {# perfiles de propietario} } seleccionados", + "no-tenant-profiles-matching": "No se han encontrado perfiles de propietario que coincidan con '{{entity}}'.", + "tenant-profile-required": "Se requiere perfil de propietario", + "idCopiedMessage": "El ID de perfil de propietario se ha copiado al portapapeles", + "set-default": "Hacer perfil propietario por defecto", + "delete": "Borrar perfil", + "copyId": "Copiar ID de perfil", + "name": "Nombre", + "name-required": "Se requiere nombre.", + "data": "Datos de perfil", + "profile-configuration": "Configuración de perfil", + "description": "Descripción", + "default": "Defecto", + "delete-tenant-profile-title": "Eliminar el perfil propietario '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Atención, tras la confirmación, el perfil de propietario será borrado y su información relacionada será irrecuperable.", + "delete-tenant-profiles-title": "Eliminar { count, plural, 1 {1 perfil propietario} other {# perfiles propietarios} }?", + "delete-tenant-profiles-text": "Atención, tras la confirmación, los perfiles seleccionados se eliminarán y su información relacionada será irrecuperable.", + "set-default-tenant-profile-title": "Quieres hacer el perfil propietario '{{tenantProfileName}}' por defecto?", + "set-default-tenant-profile-text": "Tras la confirmación, el perfil propietario será marcado por defecto y será usado por los nuevos perfiles propietarios que no tengan perfil específico.", + "no-tenant-profiles-found": "No se encontraron perfiles de propietario.", + "create-new-tenant-profile": "Crear un nuevo perfil!", + "create-tenant-profile": "Crear un nuevo perfil de propietario", + "import": "Importar perfil de propietario", + "export": "Exportar perfil de propietario", + "export-failed-error": "No se ha podido exportar el perfil de propietario: {{error}}", + "tenant-profile-file": "Archivo de perfil de propietario", + "invalid-tenant-profile-file-error": "No se ha podido importar el perfil de propietario: Estructura de datos inválida.", + "maximum-devices": "Nº Máximo de dispositivos (0 - sin límite)", + "maximum-devices-required": "Nº Máximo de dispositivos requerido.", + "maximum-devices-range": "Nº Máximo de dispositivos no puede ser negativo", + "maximum-assets": "Nº Máximo de activos (0 - sin límite)", + "maximum-assets-required": "Nº Máximo de activos requerido.", + "maximum-assets-range": "Nº Máximo de activos no puede ser negativo", + "maximum-customers": "Nº Máximo de clientes (0 - sin límite)", + "maximum-customers-required": "Nº Máximo de clientes requerido.", + "maximum-customers-range": "Nº Máximo de clientes no puede ser negativo", + "maximum-users": "Nº Máximo de usuarios (0 - sin límite)", + "maximum-users-required": "Nº Máximo de usuarios requerido.", + "maximum-users-range": "Nº Máximo de usuarios no puede ser negativo", + "maximum-dashboards": "Nº Máximo de paneles (0 - sin límite)", + "maximum-dashboards-required": "Nº Máximo de paneles requerido.", + "maximum-dashboards-range": "Nº Máximo de paneles no puede ser negativo", + "maximum-rule-chains": "Nº Máximo de cadenas de reglas (0 - sin límite)", + "maximum-rule-chains-required": "Nº Máximo de cadenas de reglas requerido.", + "maximum-rule-chains-range": "Nº Máximo de cadenas de reglas no puede ser negativo", + "maximum-resources-sum-data-size": "Tamaño máximo de ficheros de recursos en bytes (0 - sin límite)", + "maximum-resources-sum-data-size-required": "Tamaño máximo de ficheros de recursos requerido.", + "maximum-resources-sum-data-size-range": "Tamaño máximo de ficheros de recursos no puede ser negativo", + "maximum-ota-packages-sum-data-size": "Tamaño máximo de paquetes OTA en bytes (0 - sin límite)", + "maximum-ota-package-sum-data-size-required": "Tamaño máximo de paquetes OTA requerido.", + "maximum-ota-package-sum-data-size-range": "Tamaño máximo de paquetes OTA no puede ser negativo", + "transport-tenant-msg-rate-limit": "Tasa de mensajes de transporte por propietario.", + "transport-tenant-telemetry-msg-rate-limit": "Tasa de mensajes de telemetría por propietario.", + "transport-tenant-telemetry-data-points-rate-limit": "Tasa de datapoints por propietario.", + "transport-device-msg-rate-limit": "Tasa de mensajes de dispositivo.", + "transport-device-telemetry-msg-rate-limit": "Tasa de mensajes de telemetría de dispositivo.", + "transport-device-telemetry-data-points-rate-limit": "Tasa de datapoints de telemetría de dispositivo.", + "tenant-entity-export-rate-limit": "Tasa de creación de versión de entidades", + "tenant-entity-import-rate-limit": "Tasa de carga de versión de entidades", + "max-transport-messages": "Nº Máximo de mensajes de transporte (0 - sin límite)", + "max-transport-messages-required": "Nº Máximo de mensajes de transporte requerido.", + "max-transport-messages-range": "Nº Máximo de mensajes de transporte no puede ser negativo", + "max-transport-data-points": "Nº Máximo de datapoints transporte (0 - sin límite)", + "max-transport-data-points-required": "Nº Máximo de datapoints transporte requerido.", + "max-transport-data-points-range": "Nº Máximo de datapoints transporte no puede ser negativo", + "max-r-e-executions": "Nº Máximo de ejecuciones de motor de reglas (0 - sin límite)", + "max-r-e-executions-required": "Nº Máximo de ejecuciones de motor de reglas requerido.", + "max-r-e-executions-range": "Nº Máximo de ejecuciones de motor de reglas no puede ser negativo", + "max-j-s-executions": "Nº Máximo de ejecuciones JavaScript (0 - sin límite)", + "max-j-s-executions-required": "Nº Máximo de ejecuciones JavaScript requerido.", + "max-j-s-executions-range": "Nº Máximo de ejecuciones JavaScript no puede ser negativo", + "max-d-p-storage-days": "Nº Máximo de días a grabar en datapoints (0 - sin límite)", + "max-d-p-storage-days-required": "Nº Máximo de días requerido.", + "max-d-p-storage-days-range": "Nº Máximo de días no puede ser negativo", + "default-storage-ttl-days": "Días por defecto grabado TTL (0 - sin límite)", + "default-storage-ttl-days-required": "Días por defecto TTL requerido.", + "default-storage-ttl-days-range": "Días por defecto TTL no puede ser negativo", + "alarms-ttl-days": "Días de TTL alarmas (0 - sin límite)", + "alarms-ttl-days-required": "Días de TTL alarmas requerido", + "alarms-ttl-days-days-range": "Días de TTL alarmas no puede ser negativo", + "rpc-ttl-days": "Días TTL RPC (0 - sin límite)", + "rpc-ttl-days-required": "Días TTL RPC requerido", + "rpc-ttl-days-days-range": "Días TTL RPC no puede ser negativo", + "max-rule-node-executions-per-message": "Nº Máximo de ejecuciones (cadena de reglas) por mensaje (0 - sin límite)", + "max-rule-node-executions-per-message-required": "Nº Máximo de ejecuciones por mensaje requerido.", + "max-rule-node-executions-per-message-range": "Nº Máximo de ejecuciones por mensaje no puede ser negativo", + "max-emails": "Nº Máximo de emails (0 - sin límite)", + "max-emails-required": "Nº Máximo de emails requerido.", + "max-emails-range": "Nº Máximo de emails no puede ser negativo", + "max-sms": "Nº Máximo de mensajes SMS (0 - sin límite)", + "max-sms-required": "Nº Máximo de mensajes SMS requerido.", + "max-sms-range": "Nº Máximo de mensajes SMS no puede ser negativo", + "max-created-alarms": "Nº Máximo de alarmas creadas (0 - sin límite)", + "max-created-alarms-required": "Nº Máximo de alarmas creadas requerido.", + "max-created-alarms-range": "Nº Máximo de alarmas creadas no puede ser negativo", + "no-queue": "No Queue configured", + "add-queue": "Add Queue", + "queues-with-count": "Queues ({{count}})", + "tenant-rest-limits": "Rate limit for REST requests for tenant", + "customer-rest-limits": "Rate limit for REST requests for customer", + "incorrect-pattern-for-rate-limits": "The format is comma separated pairs of capacity and period (in seconds) with a colon between, e.g. 100:1,2000:60", + "too-small-value-zero": "The value must be bigger than 0", + "too-small-value-one": "The value must be bigger than 1", + "cassandra-tenant-limits-configuration": "Cassandra query rate limit for tenant", + "ws-limit-max-sessions-per-tenant": "Maximum number of WS sessions per tenant", + "ws-limit-max-sessions-per-customer": "Maximum number of WS sessions per customer", + "ws-limit-max-sessions-per-public-user": "Maximum number of WS sessions per public user", + "ws-limit-queue-per-session": "Maximum size of WS message queue per session", + "ws-limit-max-subscriptions-per-tenant": "Maximum number of WS subscriptions per tenant", + "ws-limit-max-subscriptions-per-customer": "Maximum number of WS subscriptions per customer", + "ws-limit-max-subscriptions-per-regular-user": "Maximum number of WS subscriptions per regular user", + "ws-limit-max-subscriptions-per-public-user": "Maximum number of WS subscriptions per public user", + "ws-limit-updates-per-session": "Rate limit for WS updates per session" + }, + "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} }", + "days": "Días", + "hours": "Horas", + "minutes": "Minutos", + "seconds": "Segundos", + "advanced": "Avanzado", + "predefined": { + "yesterday": "Ayer", + "day-before-yesterday": "Anteayer", + "this-day-last-week": "Hoy hace una semana", + "previous-week": "Semana anterior (Dom - Sáb)", + "previous-week-iso": "Semana anterior (Lun - Dom)", + "previous-month": "Mes anterior", + "previous-year": "Año anterior", + "current-hour": "Hora actual", + "current-day": "Día actual", + "current-day-so-far": "Día actual hasta ahora", + "current-week": "Semana actual (Dom - Sáb)", + "current-week-iso": "Semana actual (Lun - Dom)", + "current-week-so-far": "Semana actual hasta hoy (Dom - Sáb)", + "current-week-iso-so-far": "Semana actual hasta hoy (Lun - Dom)", + "current-month": "Mes actual", + "current-month-so-far": "Mes actual hasta hoy", + "current-year": "Año actual", + "current-year-so-far": "Año actual hasta ahora" + } + }, + "timeunit": { + "milliseconds": "Milisegundos", + "seconds": "Segundos", + "minutes": "Minutos", + "hours": "Horas", + "days": "Días" + }, + "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(s)", + "period": "desde {{ startTime }} hasta {{ endTime }}", + "edit": "Editar ventana de tiempo", + "date-range": "Rango de fechas", + "last": "Últimos(s)", + "time-period": "Período de tiempo", + "hide": "Ocultar", + "interval": "Intervalo" + }, + "user": { + "user": "Usuario", + "users": "Usuarios", + "customer-users": "Usuarios del Cliente", + "tenant-admins": "Admins propietarios", + "sys-admin": "Administrador del Sistema", + "tenant-admin": "Administrador Propietario", + "customer": "Cliente", + "anonymous": "Anónimo", + "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": "¿Eliminar el usuario '{{userEmail}}'?", + "delete-user-text": "Atención, tras la confirmación el usuario seleccionado será eliminado y la información relacionada será irrecuperable.", + "delete-users-title": "¿Eliminar { count, plural, 1 {1 usuario} other {# usuarios} }?", + "delete-users-action-title": "Borrar { count, plural, 1 {1 usuario} other {# usuarios} }", + "delete-users-text": "Atención, tras la confirmación los usuarios seleccionados serán eliminados y la información relacionada será irrecuperable.", + "activation-email-sent-message": "Mail de activación enviado con éxito!", + "resend-activation": "Reenviar activación", + "email": "Email", + "email-required": "Email requerido.", + "invalid-email-format": "Formato de email no válido.", + "first-name": "Nombre", + "last-name": "Apellido", + "description": "Descripción", + "default-dashboard": "Panel por defecto", + "always-fullscreen": "Siempre en pantalla completa", + "select-user": "Seleccionar usuario", + "no-users-matching": "No se han encontrado usuarios coindiendo con '{{entity}}' .", + "user-required": "Usuario requerido", + "activation-method": "Método de activación", + "display-activation-link": "Mostrar enlace de activación", + "send-activation-mail": "Enviar mail de activación", + "activation-link": "Enlace de activacion de usuario", + "activation-link-text": "Para activar el usuario, usa el siguiente enlace: Activar Usuario :", + "copy-activation-link": "Copiar enlace de activación", + "activation-link-copied-message": "El enlace de activación se ha copiado al portapapeles", + "details": "Detalles", + "login-as-tenant-admin": "Iniciar sesión como Administrador Propietario", + "login-as-customer-user": "Iniciar sesión como Usuario Cliente", + "search": "Buscar usuarios", + "selected-users": "{ count, plural, 1 {1 usuario} other {# usuarios} } seleccionados", + "disable-account": "Deshabilitar cuenta de usuario", + "enable-account": "Habilitar cuenta de usuario", + "enable-account-message": "¡La cuenta de usuario se ha habilitado correctamente!", + "disable-account-message": "¡La cuenta de usuario se deshabilitó correctamente!", + "copyId": "Copiar Id de usuario", + "idCopiedMessage": "El Id de usuario se ha copiado al portapapeles" + }, + "value": { + "type": "Tipo de valor", + "string": "Cadena de texto", + "string-value": "Valor de cadena de texto", + "string-value-required": "Se requiere valor de cadena de texto", + "integer": "Nro entero", + "integer-value": "Valor de nro entero", + "integer-value-required": "Se requiere valor entero", + "invalid-integer-value": "Valor de entero inválido", + "double": "Nro decimal", + "double-value": "Valor nro decimal", + "double-value-required": "Se requiere valor nro decimal", + "boolean": "Booleano", + "boolean-value": "Valor booleano", + "false": "Falso", + "true": "Verdadero", + "long": "Nro Largo", + "json": "JSON", + "json-value": "Valor JSON", + "json-value-invalid": "El valor JSON tiene un formato inválido", + "json-value-required": "Se requiere valor JSON" + }, + "version-control": { + "version-control": "Control de Versión", + "management": "Administrador de versiones", + "branch": "Rama", + "default": "Por defecto", + "select-branch": "Seleccionar rama", + "branch-required": "Se requiere rama", + "create-entity-version": "Versión creación de entidad", + "version-name": "Nombre de versión", + "version-name-required": "Se requiere nombre de versión", + "author": "Autor", + "export-relations": "Exportar relaciones", + "export-attributes": "Exportar atributos", + "export-credentials": "Exportar credenciales", + "entity-versions": "Versiones de entidad", + "versions": "Versiones", + "created-time": "Hora de creación", + "version-id": "ID de versión", + "no-entity-versions-text": "No se han encontrado versiones de entidad", + "no-versions-text": "No se han encontrado versiones", + "copy-full-version-id": "Copiar el ID de versión", + "create-version": "Crear versión", + "nothing-to-commit": "No hay cambios a publicar", + "restore-version": "Restaurar versión", + "restore-entity-from-version": "Restaurar entidad desde versión '{{versionName}}'", + "load-relations": "Cargar relaciones", + "load-attributes": "Cargar atributos", + "load-credentials": "Cargar credencialess", + "diff-entity-with-version": "Diff de la versión de entidad '{{versionName}}'", + "previous-difference": "Anterior diferencia", + "next-difference": "Siguiente diferencia", + "current": "Actual", + "differences": "{ count, plural, 1 {1 diferencia} other {# diferencias} }", + "create-entities-version": "Crear versión de entidades", + "default-sync-strategy": "Estrategia de sincronización por defecto", + "sync-strategy-merge": "Combinar (Merge)", + "sync-strategy-overwrite": "Sobreescribir", + "entities-to-export": "Entidades a exportar", + "entities-to-restore": "Entidades a restaurar", + "sync-strategy": "Estrategia de sincronización", + "all-entities": "Todas las entidades", + "no-entities-to-export-prompt": "Por favor, especifica las entidades a exportar", + "no-entities-to-restore-prompt": "Por favor, especifica las entidades a restaurar", + "add-entity-type": "Añadir tipo de entidad", + "remove-all": "Borrar todo", + "version-create-result": "{ added, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } añadidas.
    { modified, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } modificadas.
    { removed, plural, 0 {Ninguna entidad} 1 {1 entidad} other {# entidades} } borradas.", + "remove-other-entities": "Borrar otras entidades", + "find-existing-entity-by-name": "Buscar entidad existente por nombre", + "restore-entities-from-version": "Restaurar entidades desde la versión '{{versionName}}'", + "no-entities-restored": "No se restauraron entidades", + "created": "{{created}} creadas", + "updated": "{{updated}} actualizadas", + "deleted": "{{deleted}} borradas", + "remove-other-entities-confirm-text": "Atención! Esta acción borrará permanentemente todas las entidades actuales
    no presentes en la versión a restaurar.

    Escribe remove other entities para confirmar.", + "auto-commit-to-branch": "auto-publicar a la rama {{ branch }}", + "default-create-entity-version-name": "{{entityName}} actualización", + "sync-strategy-merge-hint": "Crea o actualiza las entidades seleccionadas en el repositorio. Las demás entidades no serán modificadas.", + "sync-strategy-overwrite-hint": "Crea o actualiza las entidades seleccionadas en el repositorio. Las demás entidades serán borradas.", + "device-credentials-conflict": "Fallo al cargar el dispositivo con ID externo {{entityId}}
    debido a que las mismas credenciales están ya presentes en la base de datos para otro dispositivo.
    Por favor, considera desactivar el ajuste cargar credenciales en el formulario de restauración.", + "missing-referenced-entity": "Fallo al cargar {{sourceEntityTypeName}} con ID externo {{sourceEntityId}}
    porque hace referencia al tipo de entidad {{targetEntityTypeName}} con el ID {{targetEntityId}}." + }, + "widget": { + "widget-library": "Bibloteca de Widgets", + "widget-bundle": "Paquetes de Widgets", + "all-bundles": "Todos los paquetes", + "select-widgets-bundle": "Seleccionar paquete de widgets", + "management": "Gestión de Widgets", + "editor": "Editor de widgets", + "widget-type-not-found": "Problema al cargar la configuración del widget.
    Probablemente asociado\n El tipo de widget fue eliminado.", + "widget-type-load-error": "El widget no pudo ser cargado debido a estos errores:", + "remove": "Eliminar widget", + "edit": "Editar widget", + "remove-widget-title": "¿Eliminar el widget '{{widgetTitle}}'?", + "remove-widget-text": "Atención, tras la confirmación el widget será eliminado y toda la información relacionada será irrecuperable..", + "timeseries": "Series de tiempo", + "search-data": "Buscar datos", + "no-data-found": "No se han encontrado datos", + "latest": "Ú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!", + "widget-saved": "Widget guardado", + "unable-to-save-widget-error": "Imposible guardar 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": "Ejecutar widget", + "title": "Título", + "title-required": "Título requerido.", + "type": "Tipo", + "resources": "Recursos", + "resource-url": "URL JavaScript/CSS", + "resource-is-module": "Es módulo", + "remove-resource": "Eliminar recurso", + "add-resource": "Agregar recurso", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Esquema de configuración", + "datakey-settings-schema": "Esquema de configuración de clave de datos", + "latest-datakey-settings-schema": "Esquema de últimos valores", + "widget-settings": "Ajustes de widget", + "description": "Descripción", + "image-preview": "Imagen previsualización", + "settings-form-selector": "Selector formulario de ajustes", + "data-key-settings-form-selector": "Selector formulario de ajustes claves de datos", + "latest-data-key-settings-form-selector": "Selector formulario de últimos valores", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "¿Eliminar el tipo del widget '{{widgetName}}'?", + "remove-widget-type-text": "Atención, tras la confirmación el tipo será eliminado y la información relacionada será irrecuperable.", + "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 la plantilla del widget!", + "add": "Agregar Widget", + "undo": "Deshacer cambios", + "export": "Exportar widget", + "no-data": "No hay datos para mostrar en widget", + "data-overflow": "El widget muestra {{count}} de {{total}} entidades", + "alarm-data-overflow": "El widget muestra alarmas para {{allowedEntities}} entidades (máximo permitido) de {{totalEntities}} entidades", + "search": "Buscar widget", + "filter": "Filtro tipo de widget", + "loading-widgets": "Cargando widgets..." + }, + "widget-action": { + "header-button": "Botón de encabezado widget", + "open-dashboard-state": "Navegar a un nuevo estado de panel", + "update-dashboard-state": "Actualizar el estado del panel actual", + "open-dashboard": "Navegar hacia otro panel", + "custom": "Acción personalizada", + "custom-pretty": "Acción personalizada (con plantilla HTML)", + "mobile-action": "Acción en dispositivo móvil", + "target-dashboard-state": "Estado de panel de destino", + "target-dashboard-state-required": "Se requiere estado de panel de destino", + "set-entity-from-widget": "Establecer entidad desde widget", + "target-dashboard": "Panel de destino", + "open-right-layout": "Abrir diseño de panel (derecho)(vista móvil)", + "state-display-type": "Opciones de display de panel", + "open-normal": "Normal", + "open-in-separate-dialog": "Abrir en un diálogo separado", + "open-in-popover": "Abrir en popover", + "dialog-title": "Título del diálogo", + "dialog-hide-dashboard-toolbar": "Ocultar barra de herramientas en el diálogo", + "dialog-width": "Ancho de diálogo en porcentaje relativo al ancho del viewport", + "dialog-height": "Alto de diálogo en porcentaje relativo al alto del viewport", + "dialog-size-range-error": "El tamaño del diálogo debe ser entre un rango de 1 a 100", + "popover-preferred-placement": "Ubicación preferida popover", + "popover-placement-top": "Superior", + "popover-placement-topLeft": "Superior izquierda", + "popover-placement-topRight": "Superior derecha", + "popover-placement-right": "Derecha", + "popover-placement-rightTop": "Derecha superior", + "popover-placement-rightBottom": "Derecha inferior", + "popover-placement-bottom": "Inferior", + "popover-placement-bottomLeft": "Inferior izquierda", + "popover-placement-bottomRight": "Inferior derecha", + "popover-placement-left": "Izquierda", + "popover-placement-leftTop": "Izquierda superior", + "popover-placement-leftBottom": "Izquierda inferior", + "popover-hide-on-click-outside": "Ocultar en click fuera del popover", + "popover-hide-dashboard-toolbar": "Ocultar caja de herramientas en popover", + "popover-width": "Ancho de popover en unidades de navegador (ej. 100px, 25vw)", + "popover-height": "Altura de popover en unidades de navegador (ej. 100px, 25vh)", + "popover-style": "Estilo de popover", + "open-new-browser-tab": "Abrir en una nueva pestaña", + "mobile": { + "action-type": "Tipo de acción móvil", + "action-type-required": "Tipo de acción móvil requerida", + "take-picture-from-gallery": "Tomar foto de galería", + "take-photo": "Tomar foto", + "map-direction": "Abrir indicaciones en mapa", + "map-location": "Abrir localización en mapa", + "scan-qr-code": "Escanear código QR", + "make-phone-call": "Hacer llamada telefónica", + "get-location": "Obtener localización del teléfono", + "take-screenshot": "Obtener captura de pantalla" + } + }, + "widgets-bundle": { + "current": "Paquete actual", + "widgets-bundles": "Paquete de Widgets", + "add": "Agregar paquete de widgets", + "delete": "Eliminar paquete de widgets", + "title": "Título", + "title-required": "Título requerido.", + "title-max-length": "El título debe ser menor de 256", + "description": "Descripción", + "image-preview": "Imagen previsualización", + "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.", + "details": "Detalles", + "widgets-bundle-details": "Detalles del paquete de Widgets", + "delete-widgets-bundle-title": "¿Eliminar el paquete de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Atención, tras la confirmación todos los paquetes seleccionados serán eliminados y su información relacionada será irrecuperable.", + "delete-widgets-bundles-title": "¿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": "Atención, tras la confirmación 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.", + "system": "Widget de Sistema", + "import": "Importar paquete de widgets", + "export": "Exportar paquete de widgets", + "export-failed-error": "Imposible 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.", + "search": "Buscar paquete de widgets", + "selected-widgets-bundles": "{ count, plural, 1 {1 paquete de widgets} other {# paquetes de widgets} } seleccionados", + "open-widgets-bundle": "Abrir paquete de widgets", + "loading-widgets-bundles": "Cargando paquete de widgets..." + }, + "widget-config": { + "data": "Datos", + "settings": "Ajustes", + "advanced": "Avanzado", + "title": "Titulo", + "title-tooltip": "Tooltip Título", + "general-settings": "Ajustes generales", + "display-title": "Mostrar titulo", + "drop-shadow": "Sombra", + "enable-fullscreen": "Habilitar pantalla completa", + "background-color": "Color de fondo", + "text-color": "Color del texto", + "padding": "Relleno", + "margin": "Margen", + "widget-style": "Estilo de widget", + "widget-css": "CSS de widget", + "title-style": "Estilo de título", + "mobile-mode-settings": "Ajustes móvil.", + "order": "Orden", + "height": "Altura", + "mobile-hide": "Ocultar widget en modo móvil", + "units": "Caracter especial a mostrar en el siguiente valor", + "decimals": "Números de dígitos después de la coma", + "timewindow": "Ventana de tiempo", + "use-dashboard-timewindow": "Usar ventana de tiempo del Panel", + "display-timewindow": "Mostrar ventana de tiempo", + "legend": "Leyenda", + "display-legend": "Mostrar leyenda", + "datasources": "Set de datos", + "maximum-datasources": "Un máximo de { count, plural, 1 {1 set de datos es permitido.} other {# set de datos son permitidos} }", + "datasource-type": "Tipo", + "datasource-parameters": "Parámetros", + "remove-datasource": "Eliminar set de datos", + "add-datasource": "Agregar set de datos", + "target-device": "Dispositivo destino", + "alarm-source": "Origen de alarmas", + "actions": "Acciones", + "action": "Acción", + "add-action": "Añadir acción", + "search-actions": "Buscar acciones", + "no-actions-text": "No se encontraron actiones", + "action-source": "Origen de acción", + "action-source-required": "Origen de acción requerido.", + "action-name": "Nombre", + "action-name-required": "Nombre de accion requerido.", + "action-name-not-unique": "Existe una acción con el mismo nombre.
    El nombre de acción debe ser único dentro de la misma fuente de acción (origen).", + "action-icon": "Icono", + "show-hide-action-using-function": "Mostrar/Ocultar acción usando función", + "action-type": "Tipo", + "action-type-required": "Tipo de acción requerido.", + "edit-action": "Editar acción", + "delete-action": "Borrar acción", + "delete-action-title": "Borrar acción de widget", + "delete-action-text": "Eliminar la acción de widget con el nombre '{{actionName}}'?", + "title-icon": "Título de icono", + "display-icon": "Mostrar título de icono", + "icon-color": "Color del icono", + "icon-size": "Tamaño del icono", + "advanced-settings": "Ajustes avanzados", + "data-settings": "Ajustes de datos", + "no-data-display-message": "\"No hay datos que mostrar\" mensaje alternativo", + "data-page-size": "Nº Máximo de entidades por origen de datos", + "settings-component-not-found": "Componente de ajustes no encontrado para el selector '{{selector}}'" + }, + "widget-type": { + "import": "Importar tipo de widget", + "export": "Exportar tipo de widget", + "export-failed-error": "Imposible exportar tipo de widget: {{error}}", + "create-new-widget-type": "Crear nuevo tipo de widget", + "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." + }, + "widgets": { + "chart": { + "common-settings": "Ajustes comunes", + "enable-stacking-mode": "Activar modo de apilamiento (stacking)", + "line-shadow-size": "Tamaño de sombra (línea)", + "display-smooth-lines": "Mostrar líneas suaves (curvas)", + "default-bar-width": "Ancho de barra por defecto para datos no agregados (millisegundos)", + "bar-alignment": "Alineación barra", + "bar-alignment-left": "Izquierda", + "bar-alignment-right": "Derecha", + "bar-alignment-center": "Centro", + "default-font-size": "Tamaño de fuente por defecto", + "default-font-color": "Color de fuente por defecto", + "thresholds-line-width": "Ancho de línea por defecto para todos los umbrales", + "tooltip-settings": "Ajustes de sugerencias (tooltip)", + "show-tooltip": "Mostrar sugerencias", + "hover-individual-points": "Hover sobre puntos individuales", + "show-cumulative-values": "Mostrar valores acumulados en modo apilado", + "hide-zero-false-values": "Ocultar valores cero/false en sugerencias", + "tooltip-value-format-function": "Función de formateo de valores en sugerencias (tooltip)", + "grid-settings": "Ajustes de cuadrícula", + "show-vertical-lines": "Mostrar líneas verticales", + "show-horizontal-lines": "Mostrar líneas horizontales", + "grid-outline-border-width": "Ancho de cuadrícula/contorno en px", + "primary-color": "Color primario", + "background-color": "Color de fondo", + "ticks-color": "Color de ticks", + "xaxis-settings": "Ajustes eje X", + "axis-title": "Título de eje", + "xaxis-tick-labels-settings": "Ajustes de etiquetas en ticks eje X", + "show-tick-labels": "Mostrar etiquetas en ticks", + "yaxis-settings": "Ajustes eje Y", + "min-scale-value": "Valor mínimo en la escala", + "max-scale-value": "Valor máximo en la escala", + "yaxis-tick-labels-settings": "Ajustes de etiquetas en ticks eje Y", + "tick-step-size": "Tamaño de paso entre ticks", + "number-of-decimals": "Número de decimales", + "ticks-formatter-function": "Función de formateo de ticks", + "comparison-settings": "Ajustes de comparación", + "enable-comparison": "Activar comparación", + "time-for-comparison": "Período de comparación", + "time-for-comparison-previous-interval": "Intervalo anterior (por defecto)", + "time-for-comparison-days": "Hace un día", + "time-for-comparison-weeks": "Hace una semana", + "time-for-comparison-months": "Hace un mes", + "time-for-comparison-years": "Hace un año", + "time-for-comparison-custom-interval": "Intervalo personalizado", + "custom-interval-value": "Valor de intervalo personalizado (ms)", + "comparison-x-axis-settings": "Ajustes comparación eje X", + "axis-position": "Posición eje", + "axis-position-top": "Superior (por defecto)", + "axis-position-bottom": "Inferior", + "custom-legend-settings": "Ajustes leyenda", + "enable-custom-legend": "Activar leyenda personalizada (habilita poder usar valores de atributos/timeseries en las etiquetas)", + "key-name": "Nombre clave", + "key-name-required": "Nombre clave requerido", + "key-type": "Tipo clave", + "key-type-attribute": "Atributo", + "key-type-timeseries": "Timeseries", + "label-keys-list": "Lista de claves para usar en etiquetas", + "no-label-keys": "No hay claves configuradas", + "add-label-key": "Añadir nueva clave", + "line-width": "Ancho de línea", + "color": "Color", + "data-is-hidden-by-default": "Ocultar datos por defecto", + "disable-data-hiding": "Desactivar ocultación de datos", + "remove-from-legend": "Quitar clave de la leyenda", + "exclude-from-stacking": "Excluir del modo apilado(disponible en el modo \"Apilado\")", + "line-settings": "Ajustes de línea", + "show-line": "Mostrar línea", + "fill-line": "Rellenar línea", + "points-settings": "Ajustes de puntos", + "show-points": "Mostrar puntos", + "points-line-width": "Ancho de línea en puntos", + "points-radius": "Radio de los puntos", + "point-shape": "Forma del punto", + "point-shape-circle": "Círculo", + "point-shape-cross": "Cruz", + "point-shape-diamond": "Diamante", + "point-shape-square": "Cuadrado", + "point-shape-triangle": "Triángulo", + "point-shape-custom": "Función personalizada", + "point-shape-draw-function": "Función de forma del punto", + "show-separate-axis": "Mostrar eje separado", + "axis-position-left": "Izquieda", + "axis-position-right": "Derecha", + "thresholds": "Umbrales", + "no-thresholds": "No hay umbrales configurados", + "add-threshold": "Añadir nuevo umbral", + "show-values-for-comparison": "Mostrar valores históricos para su comparación", + "comparison-values-label": "Etiqueta de valores históricos", + "threshold-settings": "Ajustes de umbrales", + "use-as-threshold": "Usar valor de clave como umbral", + "threshold-line-width": "Ancho de línea (para umbral)", + "threshold-color": "Color umbral", + "common-pie-settings": "Ajustes comunes diagrama de sectores", + "radius": "Radio", + "inner-radius": "Radio interior", + "tilt": "Inclinación", + "stroke-settings": "Ajustes de trazo", + "width-pixels": "Ancho (pixels)", + "show-labels": "Mostrar etiquetas", + "animation-settings": "Ajustes de animación", + "animated-pie": "Activar animación (experimental)", + "border-settings": "Ajustes de bordes", + "border-width": "Ancho de borde", + "border-color": "Color de borde", + "legend-settings": "Ajustes de leyenda", + "display-legend": "Mostrar leyenda", + "labels-font-color": "Color de texto en leyenda" + }, + "dashboard-state": { + "dashboard-state-settings": "Ajustes estado de panel", + "dashboard-state": "Id de estado panel", + "autofill-state-layout": "Auto-rellenar altura por defecto (obtenida del estado)", + "default-margin": "Márgen entre widgets por defecto", + "default-background-color": "Color de fondo por defecto", + "sync-parent-state-params": "Sincronizar parámetros de estado con el panel padre" + }, + "date-range-navigator": { + "date-range-picker-settings": "Ajustes del selector de fechas", + "hide-date-range-picker": "Ocultar el selector de fechas", + "picker-one-panel": "Selector de fechas para un panel", + "picker-auto-confirm": "Auto-confirmar en selector de fechas", + "picker-show-template": "Mostrar plantilla de selector de fechas", + "first-day-of-week": "Primer día de la semana", + "interval-settings": "Ajustes de intervalo", + "hide-interval": "Ocultar intervalo", + "initial-interval": "Intervalo inicial", + "interval-hour": "Hora", + "interval-day": "Día", + "interval-week": "Semana", + "interval-two-weeks": "2 semanas", + "interval-month": "Mes", + "interval-three-months": "3 meses", + "interval-six-months": "6 meses", + "step-settings": "Ajustes de paso", + "hide-step-size": "Ocultar tamaño de paso", + "initial-step-size": "Tamaño de paso inicial", + "hide-labels": "Ocultar etiquetas", + "use-session-storage": "Usar almacenamiento de sesión", + "localizationMap": { + "Sun": "Dom.", + "Mon": "Lun.", + "Tue": "Mar.", + "Wed": "Mié", + "Thu": "Jue.", + "Fri": "Vie.", + "Sat": "Sáb.", + "Jan": "Ene.", + "Feb": "Feb.", + "Mar": "Mar.", + "Apr": "Abr.", + "May": "May.", + "Jun": "Jun.", + "Jul": "Jul.", + "Aug": "Ago.", + "Sep": "Sept.", + "Oct": "Oct.", + "Nov": "Nov.", + "Dec": "Dic.", + "January": "Enero", + "February": "Febrero", + "March": "Marzo", + "April": "Abril", + "June": "Junio", + "July": "Julio", + "August": "Agosto", + "September": "Septiembre", + "October": "Octubre", + "November": "Noviembre", + "December": "Diciembre", + "Custom Date Range": "Intervalo de fechas personalizado", + "Date Range Template": "Plantilla de rango de fechas", + "Today": "Hoy", + "Yesterday": "Ayer", + "This Week": "Esta semana", + "Last Week": "La semana pasada", + "This Month": "Este mes", + "Last Month": "El mes pasado", + "Year": "Año", + "This Year": "Este año", + "Last Year": "Último", + "Date picker": "Selector de fecha", + "Hour": "Hora", + "Day": "Día", + "Week": "Semana", + "2 weeks": "2 Semanas", + "Month": "Mes", + "3 months": "3 Meses", + "6 months": "6 Meses", + "Custom interval": "Intervalo personalizado", + "Interval": "Intervalo", + "Step size": "Número de pasos", + "Ok": "Ok" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Ajustes de datos de jerarquía", + "relations-query-function": "Función de obtención de relaciones", + "has-children-function": "El nodo tiene una función hija", + "node-state-settings": "Ajustes estado nodo", + "node-opened-function": "Función por defecto al abrir nodo", + "node-disabled-function": "Función con nodo desactivado", + "display-settings": "Mostrar ajustes", + "node-icon-function": "Función de icono nodo", + "node-text-function": "Función de texto nodo", + "sort-settings": "Ajustes de ordenación", + "nodes-sort-function": "Función de ordenación" + }, + "edge": { + "display-default-title": "Mostrar título por defecto" + }, + "gateway": { + "general-settings": "Ajustes generales", + "widget-title": "Título del widget", + "default-archive-file-name": "Nombre del fichero por defecto", + "device-type-for-new-gateway": "Tipo de dispositivo para nuevo gateway", + "messages-settings": "Ajustes de mensajes", + "save-config-success-message": "Mensaje de éxito grabando configuración", + "device-name-exists-message": "Mensaje de texto cuando el nombre del dispositivo ya exista", + "gateway-title": "Formulario Gateway", + "read-only": "Solo lectura", + "events-title": "Título del formulario de eventos", + "events-filter": "Filtro de eventos", + "event-key-contains": "La clave de evento contiene..." + }, + "gauge": { + "default-color": "Color por defecto", + "radial-gauge-settings": "Ajustes de indicador radial", + "ticks-settings": "Ajustes de ticks", + "min-value": "Valor mínimo", + "max-value": "Valor máximo", + "start-ticks-angle": "Ángulo de inicio ticks", + "ticks-angle": "Ángulo Ticks", + "major-ticks-count": "Nº de ticks principales", + "major-ticks-color": "Color Nº de ticks principales", + "minor-ticks-count": "Nº de ticks secundarios", + "minor-ticks-color": "Color Nº de ticks secundarios", + "tick-numbers-font": "Fuente del tick", + "unit-title-settings": "Ajustes de unidad (título)", + "show-unit-title": "Mostrar unidades (título)", + "unit-title": "Título de unidad", + "title-font": "Fuente de texto del título", + "units-settings": "Ajustes de unidades", + "units-font": "Fuente de texto de las unidades", + "value-box-settings": "Ajustes de valor", + "show-value-box": "Mostrar valor", + "value-int": "Nº de dígitos para la parte entera del valor", + "value-font": "Fuente de texto del valor", + "value-box-rect-stroke-color": "Color del trazo del rectángulo (valor)", + "value-box-rect-stroke-color-end": "Color del trazo del rectángulo (valor) - gradiente final", + "value-box-background-color": "Color de fondo del valor", + "value-box-shadow-color": "Color de sombra del valor", + "plate-settings": "Ajustes de la placa", + "show-plate-border": "Mostrar borde de la placa", + "plate-color": "Color de la placa", + "needle-settings": "Ajustes de la aguja", + "needle-circle-size": "Tamaño del círculo de la aguja", + "needle-color": "Color de la aguja", + "needle-color-end": "Color de la aguja - gradiente final", + "needle-color-shadow-up": "Color de sombreado de la mitad superior de la aguja", + "needle-color-shadow-down": "Color de sombra de la aguja", + "highlights-settings": "Ajustes de resalto", + "highlights-width": "Ancho del resalto", + "highlights": "Resaltos", + "highlight-from": "De", + "highlight-to": "A", + "highlight-color": "Color", + "no-highlights": "No hay resaltos configurados", + "add-highlight": "Añadir resalto", + "animation-settings": "Ajustes de animación", + "enable-animation": "Activar animación", + "animation-duration": "Duración de animación", + "animation-rule": "Regla de animación", + "animation-linear": "Lineal", + "animation-quad": "Quad", + "animation-quint": "Quint", + "animation-cycle": "Ciclo (cycle)", + "animation-bounce": "Rebote (bounce)", + "animation-elastic": "Elastic", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "Ajustes de indicador lineal", + "bar-stroke-width": "Ancho del trazo", + "bar-stroke-color": "Color del trazo", + "bar-background-color": "Color de fondo del indicador", + "bar-background-color-end": "Color de fondo del indicador - gradiente final", + "progress-bar-color": "Color de la barra de progreso", + "progress-bar-color-end": "Color de la barra de progreso - gradiente final", + "major-ticks-names": "Nombre de ticks principales", + "show-stroke-ticks": "Mostrar trazo de ticks", + "major-ticks-font": "Fuente de ticks principales", + "border-color": "Color del borde", + "border-width": "Ancho del borde", + "needle-circle-color": "Color del círculo de la aguja", + "animation-target": "Destino de la animación", + "animation-target-needle": "Aguja", + "animation-target-plate": "Placa", + "common-settings": "Ajustes comunes del indicador", + "gauge-type": "Tipo de indicador", + "gauge-type-arc": "Arco", + "gauge-type-donut": "Donut", + "gauge-type-horizontal-bar": "Barra horizontal", + "gauge-type-vertical-bar": "Barra vertical", + "donut-start-angle": "Ángulo de inicio", + "bar-settings": "Ajustes de barra indicadora", + "relative-bar-width": "Ancho relativo", + "neon-glow-brightness": "Efecto brillo de neon, (0-100), 0 - desactiva efecto", + "stripes-thickness": "Grosor de las rayas, 0 - sin rayas", + "rounded-line-cap": "Mostrar tapa de línea redondeada", + "bar-color-settings": "Ajustes de color de barra", + "use-precise-level-color-values": "Usar niveles precisos de color", + "bar-colors": "Colores de la barra, del más bajo al más alto", + "color": "Color", + "no-bar-colors": "No hay colores configurados", + "add-bar-color": "Añadir color", + "from": "De", + "to": "A", + "fixed-level-colors": "Colores de barra usando valores límite", + "gauge-title-settings": "Ajustes de título del indicador", + "show-gauge-title": "Mostrar título de indicador", + "gauge-title": "Título de indicador", + "gauge-title-font": "Fuente del título del indicador", + "unit-title-and-timestamp-settings": "Ajustes del título de unidades y timestamp", + "show-timestamp": "Mostrar valor timestamp", + "timestamp-format": "Formato timestamp", + "label-font": "Fuente de la etiqueta que se muestra bajo el valor", + "value-settings": "Ajustes del valor", + "show-value": "Mostrar texto del valor", + "min-max-settings": "Etiqueta mínimo/máximo", + "show-min-max": "Mostrar valores mínimos y máximos", + "min-max-font": "Fuente de los valores mínimo y máximo", + "show-ticks": "Mostrar ticks", + "tick-width": "Ancho de tick", + "tick-color": "Color de tick", + "tick-values": "Valores de tick", + "no-tick-values": "No hay valores configurados", + "add-tick-value": "Añadir valor de tick" + }, + "gpio": { + "pin": "Pin", + "label": "Etiqueta", + "row": "Fila", + "column": "Columna", + "color": "Color", + "panel-settings": "Ajustes de panel", + "background-color": "Color de fondo", + "gpio-switches": "switches GPIO", + "no-gpio-switches": "No hay switches GPIO configurados", + "add-gpio-switch": "Añadir switch GPIO", + "gpio-status-request": "Solicitud estado GPIO", + "method-name": "Nombre del método RPC", + "method-body": "Cuerpo del método RPC", + "gpio-status-change-request": "Solicitud de cambio de estado GPIO", + "parse-gpio-status-function": "Función de parseado de estado GPIO", + "gpio-leds": "Leds GPIO", + "no-gpio-leds": "No hay Leds GPIO configurados", + "add-gpio-led": "Añadir led GPIO" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "El parámetro de atributo no se puede usar en este widget", + "blocked-location": "La función de geolocalización está bloqueada en tu navegador", + "claim-device": "Reclamar dispositivo", + "claim-failed": "Error al reclamar dispositivo!", + "claim-not-found": "Dispositivo no encontrado!", + "claim-successful": "Dispositivo reclamado correctamente!", + "date": "Fecha", + "device-name": "Nombre del dispositivo", + "device-name-required": "Se requere nombre de dispositivo", + "discard-changes": "Descartar los cambios", + "entity-attribute-required": "Se requiere atributo de entidad", + "entity-coordinate-required": "Se requieren ambos campos (latitud y longitud)", + "entity-timeseries-required": "Se requiere la serie de tiempo de la entidad", + "get-location": "Obtener localización actual", + "invalid-date": "Fecha inválida", + "latitude": "Latitud", + "longitude": "Longitud", + "min-value-error": "El valor mínimo es {{value}}", + "max-value-error": "El valor máximo es {{value}}", + "not-allowed-entity": "La entidad seleccionada no puede tener atributos compartidos", + "no-attribute-selected": "No se seleccionó ningún atributo", + "no-datakey-selected": "No se seleccionó ninguna clave de datos", + "no-coordinate-specified": "No se ha especificado la clave para latitud/longitud", + "no-entity-selected": "Ninguna entidad seleccionada", + "no-image": "Sin imagen", + "no-support-geolocation": "Tu navegador no soporta geolocalización", + "no-support-web-camera": "No hay cámara web compatible", + "enable-https-use-widget": "Por favor, activa HTTPS para poder usar este widget", + "no-found-your-camera": "No es posible encontrar la cámara", + "no-permission-camera": "Permiso denegado por el usuario / Esta página no tiene permisos para usar la cámara", + "no-timeseries-selected": "No hay series de tiempo seleccionadas", + "secret-key": "Clave", + "secret-key-required": "Clave requerida", + "switch-attribute-value": "Cambiar el valor del atributo de entidad", + "switch-camera": "Cambiar de cámara", + "switch-timeseries-value": "Cambiar el valor de la serie de tiempo de la entidad", + "take-photo": "Tomar foto", + "time": "Tiempo", + "timeseries-not-allowed": "El parámetro Timeseries no se puede usar en este widget", + "update-failed": "Actualización fallida", + "update-successful": "Actualización exitosa", + "update-attribute": "Actualizar atributo", + "update-timeseries": "Actualizar series de tiempo", + "value": "Valor", + "general-settings": "Ajustes Generales", + "widget-title": "Título del widget", + "claim-button-label": "Etiqueta del botón de reclamar", + "show-secret-key-field": "Mostrar el campo 'Clave Secreta'", + "labels-settings": "Ajustes de etiquetas", + "show-labels": "Mostrar etiquetas", + "device-name-label": "Etiqueta para el campo de entrada 'Nombre de Dispositivo'", + "secret-key-label": "Etiqueta para el campo de entrara 'Clave Secreta'", + "messages-settings": "Ajustes de mensajes", + "claim-device-success-message": "Mensaje a mostrar cuando el dispositivo se haya reclamado ok", + "claim-device-not-found-message": "Mensaje a mostrar cuando el dispositivo no se encuentre", + "claim-device-failed-message": "Mensaje a mostrar cuando ocurra un error reclamando el dispositivo", + "claim-device-name-required-message": "Mensaje de error a mostrar con 'Nombre de dispositivo requerido'", + "claim-device-secret-key-required-message": "Mensaje de error a mostrar con 'Clave Secreta requerida'", + "show-label": "Mostrar etiqueta", + "label": "Etiqueta", + "required": "Requerido", + "required-error-message": "Error a mostrar con campo 'Requerido'", + "show-result-message": "Mensaje para mostrar resultado", + "integer-field-settings": "Ajustes de campos tipo entero", + "min-value": "Valor Mínimo", + "max-value": "Valor Máximo", + "double-field-settings": "Ajustes de campos tipo double", + "text-field-settings": "Ajustes de campos tipo texto", + "min-length": "Longitud mínima", + "max-length": "Longitud máxima", + "checkbox-settings": "Ajustes de campos tipo checkbox", + "true-label": "Etiqueta checked", + "false-label": "Etiqueta unchecked", + "image-input-settings": "Ajustes de campos tipo entrada de imagen", + "display-preview": "Mostrar previsualización", + "display-clear-button": "Mostrar botón de borrar", + "display-apply-button": "Mostrar botón de aplicar", + "display-discard-button": "Mostrar botón de descartar", + "datetime-field-settings": "Ajustes de campos tipo Fecha/Hora", + "display-time-input": "Mostrar entrada de hora", + "latitude-key-name": "Nombre clave latitud", + "longitude-key-name": "Nombre clave longitud", + "show-get-location-button": "Mostrar botón 'Obtener localización actual'", + "use-high-accuracy": "Usar alta precisión", + "location-fields-settings": "Ajustes de campos de localización", + "latitude-label": "Etiqueta para latitud", + "longitude-label": "Etiqueta para longitud", + "input-fields-alignment": "Alineado de campos de entrada", + "input-fields-alignment-column": "Columna (por defecto)", + "input-fields-alignment-row": "Fila", + "latitude-field-required": "Campo latitud requerido", + "longitude-field-required": "Campo longitud requerido", + "attribute-settings": "Ajustes de atributos", + "widget-mode": "Modo del widget", + "widget-mode-update-attribute": "Actualizar atributo", + "widget-mode-update-timeseries": "Actualizar timeseries", + "attribute-scope": "Alcance de atributos", + "attribute-scope-server": "Atributos de servidor", + "attribute-scope-shared": "Atributos compartidos", + "value-required": "Valor requerido", + "image-settings": "Ajustes de imagen", + "image-format": "Formato de imagen", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Calidad de imagen que usa compresión con pérdida como jpeg y webp", + "max-image-width": "Máximo ancho de imagen", + "max-image-height": "Máximo alto de imagen", + "action-buttons": "Botones de acción", + "show-action-buttons": "Mostrar botones de acción", + "update-all-values": "Actualizar todos los valores, no sólo los modificados", + "save-button-label": "Etiqueta de botón 'GRABAR'", + "reset-button-label": "Etiqueta de botón 'DESHACER'", + "group-settings": "Ajustes de grupo", + "show-group-title": "Mostrar título para el grupo de campos relacionados con diferentes entidades", + "group-title": "Título del grupo", + "fields-alignment": "Alineado de campos", + "fields-alignment-row": "Fila (por defecto)", + "fields-alignment-column": "Columna", + "fields-in-row": "Número de campos en la fila", + "option-value": "Valor (escribe 'null' para crear una opción vacía)", + "option-label": "Etiqueta", + "hide-input-field": "Ocultar campo de entrada", + "datakey-type": "Tipo de clave de datos", + "datakey-type-server": "Atributo de servidor (por defecto)", + "datakey-type-shared": "Atributo compartido", + "datakey-type-timeseries": "Timeseries", + "datakey-value-type": "Tipo de valor de la clave de datos", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Entero", + "datakey-value-type-boolean-checkbox": "Boolean (Checkbox)", + "datakey-value-type-boolean-switch": "Boolean (Switch)", + "datakey-value-type-date-time": "Fecha & Hora", + "datakey-value-type-date": "Fecha", + "datakey-value-type-time": "Hora", + "datakey-value-type-select": "Selección", + "value-is-required": "Valor requerido", + "ability-to-edit-attribute": "Posibilidad de editar atributo", + "ability-to-edit-attribute-editable": "Editable (por defecto)", + "ability-to-edit-attribute-disabled": "Desactivado", + "ability-to-edit-attribute-readonly": "Sólo lectura", + "disable-on-datakey-name": "Desactivar cuando otra clave de datos sea false (especificar nombre de clave de datos)", + "slide-toggle-settings": "Ajustes de deslizador", + "slide-toggle-label-position": "Posición de etiqueta", + "slide-toggle-label-position-after": "Después", + "slide-toggle-label-position-before": "Antes", + "select-options": "Seleccionar opciones", + "no-select-options": "No hay opciones configuradas", + "add-select-option": "Añadir opción", + "numeric-field-settings": "Ajustes de campos numéricos", + "step-interval": "Pasos entre valores (intervalo)", + "error-messages": "Mensajes de erro", + "min-value-error-message": "Mensaje de error de 'Valor Mínimo'", + "max-value-error-message": "Mensaje de error de 'Valor Máximo'", + "invalid-date-error-message": "Mensaje de erro de 'Fecha Inválida'", + "icon-settings": "Ajustes de iconos", + "use-custom-icon": "Usar icono personalizado", + "input-cell-icon": "Icono a mostrar antes del campo de entrada", + "value-conversion-settings": "Ajustes de conversión de valor", + "get-value-settings": "Ajustes de obtención de valores", + "use-get-value-function": "Usar función getValue", + "get-value-function": "Función getValue", + "set-value-settings": "Ajustes de establecimiento de valores", + "use-set-value-function": "Usar función setValue", + "set-value-function": "Función setValue" + }, + "invalid-qr-code-text": "Texto de entrara inválido para el código QR. La entrada debe ser de tipo string", + "qr-code": { + "use-qr-code-text-function": "Usar función de texto QR", + "qr-code-text-pattern": "Patrón del código QR (por ej. '${entityName} | ${keyName} - texto adicional.')", + "qr-code-text-pattern-required": "Se requiere patrón del código QR.", + "qr-code-text-function": "Función del código QR" + }, + "label-widget": { + "label-pattern": "Patrón", + "label-pattern-hint": "Ayuda: por ej. 'Texto ${keyName} unidades.' o ${#<key index>} unidades'", + "label-pattern-required": "Se requiere patrón", + "label-position": "Posición (Porcentaje relativo al fondo)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Color de fondo", + "font-settings": "Ajustes de fuente", + "background-image": "Imagen de fondo", + "labels": "Etiquetas", + "no-labels": "No hay etiquetas configuradas", + "add-label": "Añadir etiqueta" + }, + "navigation": { + "title": "Título", + "navigation-path": "Ruta de navegación", + "filter-type": "Tipo de filtro", + "filter-type-all": "Todos los objetos", + "filter-type-include": "Incluir objetos", + "filter-type-exclude": "Excluir objetos", + "items": "Objetos", + "enter-urls-to-filter": "Especificar URLs a filtrar..." + }, + "persistent-table": { + "rpc-id": "ID RPC", + "message-type": "Tipo de mensaje", + "method": "Método", + "params": "Parémetros", + "created-time": "Hora de creación", + "expiration-time": "Hora de expiración", + "retries": "Reintentos", + "status": "Estado", + "filter": "Filtro", + "refresh": "Actualizar", + "add": "Añadir petición RPC", + "details": "Detalles", + "delete": "Borrar", + "delete-request-title": "Borrar petición RPC persistente", + "delete-request-text": "Estas seguro de borrar la petición?", + "details-title": "Detalles ID RPC: ", + "additional-info": "Información adicional", + "response": "Respuesta", + "any-status": "Cualquier estado", + "rpc-status-list": "Lista de estados RPC", + "no-request-prompt": "No hay peticiones a mostrar", + "send-request": "Enviar petición", + "add-title": "Crear una petición RPC persistente", + "method-error": "Se requiere método.", + "timeout-error": "El valor mínimo de timeout es 5000 (5 segundos).", + "white-space-error": "No se permiten espacios en blanco.", + "rpc-status": { + "QUEUED": "EN COLA", + "SENT": "ENVIADO", + "DELIVERED": "ENTREGADO", + "SUCCESSFUL": "CORRECTO", + "TIMEOUT": "TIMEOUT", + "EXPIRED": "EXPIRADO", + "FAILED": "FALLO" + }, + "rpc-search-status-all": "TODOS", + "message-types": { + "false": "Two-way", + "true": "One-way" + }, + "general-settings": "Ajustes Generales", + "enable-filter": "Activar filtro", + "enable-sticky-header": "Mostrar encabezado mientras se hace scroll", + "enable-sticky-action": "Mostrar columna de acciones mientras se hace scroll", + "display-request-details": "Mostrar detalles de petición", + "allow-send-request": "Permitir enviar petición RPC", + "allow-delete-request": "Permitir borrar petición", + "columns-settings": "Ajustes de columnas", + "display-columns": "Columnas a mostrar", + "column": "Columna", + "no-columns-found": "No se encontraron columnas", + "no-columns-matching": "'{{column}}' no encontrada." + }, + "rpc": { + "value-settings": "Ajustes de valor", + "initial-value": "Valor inicial", + "retrieve-value-settings": "Obtener ajustes valores on/off", + "retrieve-value-method": "Obtener valor usando método", + "retrieve-value-method-none": "No obtener", + "retrieve-value-method-rpc": "Método para llamada RPC de obtención de valor", + "retrieve-value-method-attribute": "Suscribir por atributo", + "retrieve-value-method-timeseries": "Suscribir por timeseries", + "attribute-value-key": "Clave de atributo", + "timeseries-value-key": "Clave de timeseries", + "get-value-method": "Metodo para obtener valor vía RPC", + "parse-value-function": "Función de parseo de valor", + "update-value-settings": "Ajustes de actualización de valor", + "set-value-method": "Método para establecer valor vía RPC", + "convert-value-function": "Función de conversión", + "rpc-settings": "Ajustes RPC", + "request-timeout": "Timeout de petición RPC (ms)", + "persistent-rpc-settings": "Ajustes de RPC persistente", + "request-persistent": "Petición RPC persistente", + "persistent-polling-interval": "Intervalo de sondeo (ms) para obtener respuesta del comando RPC persistente", + "common-settings": "Ajustes comunes", + "switch-title": "Título del switch", + "show-on-off-labels": "Mostrar etiquetas on/off", + "slide-toggle-label": "Etiqueta de interruptor deslizante", + "label-position": "Posición de etiqueta", + "label-position-before": "Antes", + "label-position-after": "Después", + "slider-color": "Color del deslizador", + "slider-color-primary": "Primario", + "slider-color-accent": "Acento", + "slider-color-warn": "Aviso", + "button-style": "Estilo de botón", + "button-raised": "Botón levantado", + "button-primary": "Color primario", + "button-background-color": "Color de fondo", + "button-text-color": "Color de texto", + "widget-title": "Título del widget", + "button-label": "Etiqueta del botón", + "device-attribute-scope": "Alcance de atributos del dispositivo", + "server-attribute": "Atributos de servidor", + "shared-attribute": "Atributos compartidos", + "device-attribute-parameters": "Parámetros de atributos del dispositivo", + "is-one-way-command": "Es un comando de una vía (one way)", + "rpc-method": "Método RPC", + "rpc-method-params": "Parámetros método RPC", + "show-rpc-error": "Mostrar errores de ejecución de RPC", + "led-title": "Título LED", + "led-color": "Color LED", + "check-status-settings": "Ajustes de comprobación de estado", + "perform-rpc-status-check": "Realizar comprobación del dispositivo RPC", + "retrieve-led-status-value-method": "Obtener estado del led usando método", + "led-status-value-attribute": "Atributo del dispositivo que contiene el valor del estado del led", + "led-status-value-timeseries": "Timeseries del dispositivo que contiene el valor del estado del led", + "check-status-method": "Método RPC para chequear el estado del dispositivo", + "parse-led-status-value-function": "Función de parseo para el estado del led", + "knob-title": "Título de mando", + "min-value": "Valor mínimo", + "max-value": "Valor máximo" + }, + "maps": { + "select-entity": "Seleccionar entidad", + "select-entity-hint": "Ayuda: tras la selección haz click en el mapa para establecer la posición", + "tooltips": { + "placeMarker": "Click para colocar la entidad '{{entityName}}'", + "firstVertex": "Polígono para la entidad '{{entityName}}': click para colocar el primer punto", + "firstVertex-cut": "Click para colocar el primer punto", + "continueLine": "Polígono para la entidad '{{entityName}}': click para continuar dibujando", + "continueLine-cut": "Click para continuar dibujando", + "finishLine": "Click en cualquier marcador existente para finalizar", + "finishPoly": "Polígono para la entidad '{{entityName}}': click en el primer marcador para finalizar y grabar los cambios", + "finishPoly-cut": "Click en el primer marcador para finalizar y grabar los cambios", + "finishRect": "Polígono para la entidad '{{entityName}}': click para finalizar y grabar los cambios", + "startCircle": "Círculo para la entidad '{{entityName}}': click para establecer el centro del círculo", + "finishCircle": "Círculo para la entidad '{{entityName}}': click para finalizar el círculo", + "placeCircleMarker": "Click para colocar el marcador del círculo" + }, + "actions": { + "finish": "Finalizar", + "cancel": "Cancelar", + "removeLastVertex": "Quitar último punto" + }, + "buttonTitles": { + "drawMarkerButton": "Establecer entidad", + "drawPolyButton": "Crear polígono", + "drawLineButton": "Crear polilínea", + "drawCircleButton": "Crear círculo", + "drawRectButton": "Crear rectángulo", + "editButton": "Modo de edición", + "dragButton": "Modo Arrastrar-soltar", + "cutButton": "Cortar el área del polígono", + "deleteButton": "Borrar", + "drawCircleMarkerButton": "Crear marcador de círculo", + "rotateButton": "Rotar polígono" + }, + "map-provider-settings": "Ajustes proveedor de mapas", + "map-provider": "Proveedor de mapas", + "map-provider-google": "Google maps", + "map-provider-openstreet": "OpenStreet maps", + "map-provider-here": "HERE maps", + "map-provider-image": "Mapa de imagen", + "map-provider-tencent": "Tencent maps", + "openstreet-provider": "Proveedor OpenStreet map", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Default)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Usar proveedor personalizado", + "custom-provider-tile-url": "URL de proveedor personalizado", + "google-maps-api-key": "API Key Google Maps", + "default-map-type": "Tipo de mapa por defecto", + "google-map-type-roadmap": "Carretera", + "google-map-type-satelite": "Satelite", + "google-map-type-hybrid": "Híbrido", + "google-map-type-terrain": "Terreno", + "map-layer": "Capa de mapa", + "here-map-normal-day": "HERE.normalDay (Defecto)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Credenciales", + "here-app-id": "HERE app id", + "here-app-code": "HERE app code", + "tencent-maps-api-key": "API Key Tencent Maps", + "tencent-map-type-roadmap": "Carretera", + "tencent-map-type-satelite": "Satelite", + "tencent-map-type-hybrid": "Híbrido", + "image-map-background": "Fondo de mapa de imagen", + "image-map-background-from-entity-attribute": "Obtener imagen de fondo desde un atributo de la entidad", + "image-url-source-entity-alias": "Alias de entidad para URL de imagen", + "image-url-source-entity-attribute": "Atributo de entidad para URL de imagen", + "common-map-settings": "Ajustes comunes de mapas", + "x-pos-key-name": "Clave para posición X", + "y-pos-key-name": "Clave para posición Y", + "latitude-key-name": "Clave para latitud", + "longitude-key-name": "Clave para longitud", + "default-map-zoom-level": "Nivel de zoom por defecto (0 - 20)", + "default-map-center-position": "Posición central del mapa por defecto (0,0)", + "disable-scroll-zooming": "Desactivar zoom en scroll", + "disable-zoom-control-buttons": "Desactivar botones de zoom", + "fit-map-bounds": "Ajustar los límites del mapa para cubrir todos los marcadores", + "use-default-map-center-position": "Usar posición central por defecto", + "entities-limit": "Límite de entidades a cargar", + "markers-settings": "Ajustes de los marcadores", + "marker-offset-x": "Offset X relativo a la posición multiplicado por el ancho del marcador", + "marker-offset-y": "Offset Y relativo a la posición multiplicado por el alto del marcador", + "position-function": "Función de conversión de posición, debe retornar coordenadas x,y como valor double de 0 a 1", + "draggable-marker": "Marcador arrastrable", + "label": "Etiqueta", + "show-label": "Mostrar etiqueta", + "use-label-function": "Usar función de etiqueta", + "label-pattern": "Etiqueta (Ejemplos de patrón: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "label-function": "Función de etiqueta", + "tooltip": "Sugerencias (tooltip)", + "show-tooltip": "Mostrar sugerencias", + "show-tooltip-action": "Acción para mostrar las sugerencias", + "show-tooltip-action-click": "Mostrar sugerencias tooltip en click (por defecto)", + "show-tooltip-action-hover": "Mostrar sugerencias on hover", + "auto-close-tooltips": "Auto-cerrar sugerencias", + "use-tooltip-function": "Función de sugerencias", + "tooltip-pattern": "Sugerencia (Por ej. 'Texto ${keyName} unidades.' o Texto Link')", + "tooltip-function": "Función de sugerencia", + "tooltip-offset-x": "Offset X relativo al ancla del marcador multiplicado por el ancho", + "tooltip-offset-y": "Offset Y relativo al ancla del marcador multiplicado por la altura", + "color": "Color", + "use-color-function": "Usar función de color", + "color-function": "Función de color", + "marker-image": "Imagen de marcador", + "use-marker-image-function": "Usar función de imagen de marcador", + "custom-marker-image": "Imagen de marcador personalizada", + "custom-marker-image-size": "Tamaño de imagen personalizada (px)", + "marker-image-function": "Función de imagen de marcador", + "marker-images": "Imagenes de marcador", + "polygon-settings": "Ajustes de polígono", + "show-polygon": "Mostrar polígono", + "polygon-key-name": "Clave del polígono", + "enable-polygon-edit": "Polígono editable", + "polygon-label": "Etiqueta del polígono", + "show-polygon-label": "Mostrar etiqueta del polígono", + "use-polygon-label-function": "Usar funciones de etiqueta en el polígono", + "polygon-label-pattern": "Etiqueta del polígono (Ejemplos de patrón: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "polygon-label-function": "Función de etiqueta del polígono", + "polygon-tooltip": "Sugerencia polígono", + "show-polygon-tooltip": "Mostrar sugerencias", + "auto-close-polygon-tooltips": "Auto-cerrar sugerencias polígono", + "use-polygon-tooltip-function": "Usar función de sugerencias en el polígono", + "polygon-tooltip-pattern": "Sugerencias (por ej. 'Texto ${keyName} unidades.' o Texto Link')", + "polygon-tooltip-function": "Función de sugerencia de polígono", + "polygon-color": "Color de polígono", + "polygon-opacity": "Opacidad de polígono", + "use-polygon-color-function": "Usar función de color en polígono", + "polygon-color-function": "Función de color de polígono", + "polygon-stroke": "Trazo de polígono", + "stroke-color": "Color de trazo", + "stroke-opacity": "Opacidad de trazo", + "stroke-weight": "Peso de trazo", + "use-polygon-stroke-color-function": "Usar función de color de trazo en polígono", + "polygon-stroke-color-function": "Función de color de trazo", + "circle-settings": "Ajustes de círculo", + "show-circle": "Mostrar círculo", + "circle-key-name": "Clave de círculo", + "enable-circle-edit": "Activar edición de círculo", + "circle-label": "Etiqueta de círculo", + "show-circle-label": "Mostrar etiqueta de círculo", + "use-circle-label-function": "Usar función para etiqueta de círculo", + "circle-label-pattern": "Etiqueta de círculo (Ejemplos patrón: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "circle-label-function": "Funcion de etiqueta de círculo", + "circle-tooltip": "Sugerencias de círculo", + "show-circle-tooltip": "Mostrar sugerencias de círculo", + "auto-close-circle-tooltips": "Auto-cerrar sugerencias", + "use-circle-tooltip-function": "Usar función de sugerencias en círculo", + "circle-tooltip-pattern": "Sugerencia (por ej. 'Texto ${keyName} unidades.' o Texto Link')", + "circle-tooltip-function": "Función de sugerencia círculo", + "circle-fill-color": "Color de relleno círculo", + "circle-fill-color-opacity": "Opacidad color de relleno", + "use-circle-fill-color-function": "Usar función de color de relleno", + "circle-fill-color-function": "Función de color de relleno", + "circle-stroke": "Trazo del círculo", + "use-circle-stroke-color-function": "Usar función de color de trazo", + "circle-stroke-color-function": "Función de color de trazo", + "markers-clustering-settings": "Ajustes de agrupación de marcadores", + "use-map-markers-clustering": "Usar agrupación de marcadores en mapa", + "zoom-on-cluster-click": "Zoom cuando se haga click en un grupo", + "max-cluster-zoom": "Nivel máximo de zoom en la que un marcador puede ser parte de un grupo (0 - 18)", + "max-cluster-radius-pixels": "Radio máximo que un grupo cubre en píxeles", + "cluster-zoom-animation": "Mostrar animacion en marcadores cuando se haga zoom", + "show-markers-bounds-on-cluster-mouse-over": "Mostrar los limites de los marcadores cuando el raton pase por encima de un grupo", + "spiderfy-max-zoom-level": "Spiderfy al máximo nivel de zoom (para ver todos los marcadores)", + "load-optimization": "Optimización de carga", + "cluster-chunked-loading": "Usar fragmentos para añadir marcadores para que la página no se congele", + "cluster-markers-lazy-load": "Usar lazy load al añadir marcadores", + "editor-settings": "Ajustes del editor", + "enable-snapping": "Habilitar ajuste a otros vértices para dibujar con precisión", + "init-draggable-mode": "Inicializar mapa en modo arrastre", + "hide-all-edit-buttons": "Ocultar todos los botones de edición", + "hide-draw-buttons": "Ocultar botones de dibujo", + "hide-edit-buttons": "Ocultar botones de edición", + "hide-remove-button": "Ocultar botones de borrado", + "route-map-settings": "Ajustes de mapa de ruta", + "trip-animation-settings": "Ajustes de animación de ruta", + "normalization-step": "Pasos de normalización (ms)", + "tooltip-background-color": "Color de fondo de la sugerencia", + "tooltip-font-color": "Color de fuente de las sugerencias", + "tooltip-opacity": "Opacidad de las sugerencias (0-1)", + "auto-close-tooltip": "Auto-cerrar sugerencias", + "rotation-angle": "Ángulo de rotación adicional para el marcador (grados)", + "path-settings": "Ajustes de ruta", + "path-color": "Color de ruta", + "use-path-color-function": "Usar función de color de ruta", + "path-color-function": "Función de color de ruta", + "path-decorator": "Decorador de ruta", + "use-path-decorator": "Usar decorador de ruta", + "decorator-symbol": "Símbolo del decorador", + "decorator-symbol-arrow-head": "Flecha", + "decorator-symbol-dash": "Estrella", + "decorator-symbol-size": "Tamaño del decorador (px)", + "use-path-decorator-custom-color": "Usar color personalizado en el decorador", + "decorator-custom-color": "Color personalizado del decorador", + "decorator-offset": "Offset decorador", + "end-decorator-offset": "Offset final del decorador", + "decorator-repeat": "Repetición del decorador", + "points-settings": "Ajustes de puntos", + "show-points": "Mostrar puntos", + "point-color": "Color de puntos", + "point-size": "Tamaño de puntos (px)", + "use-point-color-function": "Usar función de color de puntos", + "point-color-function": "Función de color de puntos", + "use-point-as-anchor": "Usar punto como ancla", + "point-as-anchor-function": "Función de punto como ancla", + "independent-point-tooltip": "Sugerencia independiente en punto" + }, + "markdown": { + "use-markdown-text-function": "Usar función markdown/HTML", + "markdown-text-function": "Función de valor Markdown/HTML", + "markdown-text-pattern": "Patrón de Markdown/HTML (markdown o HTML con variables, por ej. '${entityName} o ${keyName} - texto.')", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label-position": "Posición etiqueta", + "label-position-left": "Izquierda", + "label-position-top": "Superior" + }, + "table": { + "common-table-settings": "Ajustes comunes en tablas", + "enable-search": "Activar búsqueda", + "enable-sticky-header": "Mostrar siempre el encabezado", + "enable-sticky-action": "Mostrar siempre la columna de acciones", + "hidden-cell-button-display-mode": "Visualización de botones de acción oculta", + "show-empty-space-hidden-action": "Mostrar espacio vacío en lugar de celda oculta", + "dont-reserve-space-hidden-action": "No reservar espacio para los botones en celda oculta", + "display-timestamp": "Mostrar columna timestamp", + "display-milliseconds": "Mostrar milisegundos", + "display-pagination": "Mostrar páginas", + "default-page-size": "Tamaño de página por defecto", + "use-entity-label-tab-name": "Usar etiqueta de entidad en el nombre de la tabla", + "hide-empty-lines": "Ocultar líneas vacías", + "row-style": "Estilo de fila", + "use-row-style-function": "Usar función de estilo de fila", + "row-style-function": "Función de estilo de fila", + "cell-style": "Estilo de celda", + "use-cell-style-function": "Usar función de estilo de celda", + "cell-style-function": "Función de estilo de celda", + "cell-content": "Contenido de celda", + "use-cell-content-function": "Usar función de contenido de celda", + "cell-content-function": "Función de contenido de celda", + "show-latest-data-column": "Mostrar columna de últimos datos", + "latest-data-column-order": "Órden de columna de últimos datos", + "entities-table-title": "Título de tabla de entidades", + "enable-select-column-display": "Activar posibilidad de seleccionar columnas a mostrar", + "display-entity-name": "Mostrar columna de nombre de entidad", + "entity-name-column-title": "Título de columna en nombre de entidad", + "display-entity-label": "Mostrar columna de etiqueta de entidad", + "entity-label-column-title": "Título de columna en etiqueta de entidad", + "display-entity-type": "Mostrar columna de tipo de entidad", + "default-sort-order": "Ordenación por defecto", + "column-width": "Ancho de columna (px o %)", + "default-column-visibility": "Visibilidad por defecto en columna", + "column-visibility-visible": "Visible", + "column-visibility-hidden": "Oculta", + "column-selection-to-display": "Selección de columnas en 'Columnas a Mostrar'", + "column-selection-to-display-enabled": "Activada", + "column-selection-to-display-disabled": "Desactivada", + "alarms-table-title": "Título de tabla de alarmas", + "enable-alarms-selection": "Activar selección de alarmas", + "enable-alarms-search": "Activar búsqueda de alarmas", + "enable-alarm-filter": "Activar filtro de alarmas", + "display-alarm-details": "Mostrar detalles de alarma", + "allow-alarms-ack": "Permitir reconocimiento de alarmas", + "allow-alarms-clear": "Permitir borrado de alarmas" + }, + "value-source": { + "value-source": "Origen valor", + "predefined-value": "Valor predefinido", + "entity-attribute": "Valor tomado de un atributo de entidad", + "value": "Valor", + "source-entity-alias": "Alias entidad de origen", + "source-entity-attribute": "Atributo entidad de origen" + }, + "widget-font": { + "font-family": "Familia (font family)", + "size": "Tamaño", + "relative-font-size": "Tamaño fuente relativo (porcentaje)", + "font-style": "Estilo", + "font-style-normal": "Normal", + "font-style-italic": "Cursiva", + "font-style-oblique": "Subrayada", + "font-weight": "Peso", + "font-weight-normal": "Normal", + "font-weight-bold": "Negrita", + "font-weight-bolder": "Negrita+", + "font-weight-lighter": "Lighter", + "color": "Color", + "shadow-color": "Color sombra" + } + }, + "icon": { + "icon": "Icono", + "select-icon": "Seleccionar iconos", + "material-icons": "Iconos material-design", + "show-all": "Mostrar todos los iconos" + }, + "phone-input": { + "phone-input-label": "Número de teléfono", + "phone-input-required": "Número de teléfono requerido", + "phone-input-validation": "El número es inválido o erróneo", + "phone-input-pattern": "Número inválido. Debe cumplir el formato E.164, ej. {{phoneNumber}}", + "phone-input-hint": "Número en el formato E.164, ej. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Acción botón de celda", + "row-click": "En click de fila", + "polygon-click": "Clic en polígono", + "marker-click": "En click de marcador", + "circle-click": "En click de círculo", + "tooltip-tag-action": "Acción de la etiqueta Tooltip", + "node-selected": "Clic en el nodo seleccionado", + "element-click": "Clic en el elemento HTML", + "pie-slice-click": "Clic en la rebanada", + "row-double-click": "Doble clic en la fila" + } + }, + "language": { + "language": "Idioma" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-fa_IR.json b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json new file mode 100644 index 0000000..b0eef3f --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json @@ -0,0 +1,1630 @@ +{ + "access": { + "unauthorized": "غير مجاز", + "unauthorized-access": "دسترسي غير مجاز", + "unauthorized-access-text": "!شما بايد وارد شويد تا به اين منبع دسترسي پيدا کنيد", + "access-forbidden": "دسترسي ممنوع", + "access-forbidden-text": "!اگر هنوز تمايل داريد به اينجا دسترسي پيدا کنيد، تلاش کنيد با نام کاربري ديگري وارد شويد
    .شما حق دسترسي به اينجا را نداريد", + "refresh-token-expired": "اين بخش، منقضي شده است", + "refresh-token-failed": "بازيابي اين بخش ممکن نيست" + }, + "action": { + "activate": "فعال سازي", + "suspend": "معلّق", + "save": "ذخيره سازي", + "saveAs": "ذخيره سازي در", + "cancel": "لغو", + "ok": "قبول", + "delete": "حذف", + "add": "اضافه", + "yes": "بله", + "no": "خير", + "update": "به روز کردن", + "remove": "حذف", + "search": "جستجو", + "clear-search": "پاک کردن جستجو", + "assign": "تخصيص", + "unassign": "لغو تخصيص", + "share": "به اشتراک گذاري", + "make-private": "شخصي سازي", + "apply": "اعمال", + "apply-changes": "اعمال تغييرات", + "edit-mode": "حالت ويرايش", + "enter-edit-mode": "ورود به حالت ويرايش", + "decline-changes": "عدم پذيرش تغييرات", + "close": "بستن", + "back": "بازگشت", + "run": "اجرا", + "sign-in": "!ورود", + "edit": "ويرايش", + "view": "نمايش", + "create": "ايجاد", + "drag": "کشيدن", + "refresh": "بازيابي", + "undo": "برگرداندن آخرين عمل", + "copy": "رونوشت", + "paste": "الصاق رونوشت", + "copy-reference": "رونوشت مرجع", + "paste-reference": "رونوشت مرجع", + "import": "وارد کردن", + "export": "صدور", + "share-via": "{{provider}} اشتراک گذاري از طريق" + }, + "aggregation": { + "aggregation": "تجميع", + "function": "تابع تجميع داده ها", + "limit": "بيشترين مقادير", + "group-interval": "فاصله گروه بندي", + "min": "کمترين", + "max": "بيشترين", + "avg": "ميانگين", + "sum": "جمع", + "count": "شمارش", + "none": "هيچکدام" + }, + "admin": { + "general": "عمومي", + "general-settings": "تنظيمات عمومي", + "outgoing-mail": "پيام خروجي", + "outgoing-mail-settings": "تنظيمات پيام خروجي", + "system-settings": "تنظيمات سيستم", + "test-mail-sent": "!ارسال پيام آزمايشي موفقيت آميز بود", + "base-url": "مبنا URL", + "base-url-required": ".مبنا مورد نياز است URL", + "mail-from": "... پيام از", + "mail-from-required": ".پيام از ... مورد نياز است", + "smtp-protocol": "SMTP قرارداد", + "smtp-host": "SMTP ميزبان", + "smtp-host-required": ".مورد نياز است SMTP ميزبان", + "smtp-port": "SMTP درگاه", + "smtp-port-required": ".فراهم کنيد SMTP شما بايد يک درگاه", + "smtp-port-invalid": ".معتبر باشد SMTP به نظر نمي آيد يک درگاه", + "timeout-msec": "مهلت (msec)", + "timeout-required": ".مهلت مورد نياز است", + "timeout-invalid": ".مهلت، به نظر نمي آيد معتبر باشد", + "enable-tls": "TLS فعال سازي", + "tls-version": "نسخه TLS", + "send-test-mail": "ارسال پيام آزمايشي" + }, + "alarm": { + "alarm": "هشدار", + "alarms": "هشدارها", + "select-alarm": "انتخاب هشدار", + "no-alarms-matching": ".يافت نشد '{{entity}}' هيچ هشداري مطابق", + "alarm-required": "هشدار مورد نياز است", + "alarm-status": "وضعيت هشدار", + "search-status": { + "ANY": "هر", + "ACTIVE": "فعال", + "CLEARED": "پاک شده", + "ACK": "تصديق شده", + "UNACK": "تصديق نشده" + }, + "display-status": { + "ACTIVE_UNACK": "تصديق نشده فعال", + "ACTIVE_ACK": "تصديق شده فعال", + "CLEARED_UNACK": "تصديق نشده پاک شده", + "CLEARED_ACK": "تصديق شده پاک شده" + }, + "no-alarms-prompt": "هيچ هشداري يافت نشد", + "created-time": "زمان ايجاد", + "type": "نوع", + "severity": "شدت", + "originator": "مبدأ", + "originator-type": "نوع مبدأ", + "details": "جزئيات", + "status": "وضعيت", + "alarm-details": "جزئيات هشدار", + "start-time": "زمان شروع", + "end-time": "زمان پايان", + "ack-time": "زمان تصديق", + "clear-time": "زمان پاک شدن", + "severity-critical": "بحراني", + "severity-major": "مهم", + "severity-minor": "جزئي", + "severity-warning": "اخطار", + "severity-indeterminate": "نامشخص", + "acknowledge": "تصديق", + "clear": "پاک کردن", + "search": "جستجوي هشدارها", + "selected-alarms": "اننخاب شده { count, plural, 1 {1 هشدار} other {# هشدارها} }", + "no-data": "هيچ داده اي براي نمايش نيست", + "polling-interval": "هشدار دهنده فاصله نمونه برداري (sec)", + "polling-interval-required": ".هشدار دهنده فاصله نمونه برداري مورد نياز است", + "min-polling-interval-message": ".حداقل فاصله مجاز نمونه برداري، 1 ثانيه است", + "aknowledge-alarms-title": "{ count, plural, 1 {1 هشدار} other {# هشدارها} } تصديق", + "aknowledge-alarms-text": "اطمينان داريد؟ { count, plural, 1 {1 هشدار} other {# هشدارها} } آيا شما از تصديق", + "aknowledge-alarm-title": "تصديق هشدار", + "aknowledge-alarm-text": "آيا شما از تصديق هشدار اطمينان داريد؟", + "clear-alarms-title": "{ count, plural, 1 {1 هشدار} other {# هشدارها} } پاک کردن", + "clear-alarms-text": "اطمينان داريد؟ { count, plural, 1 {1 هشدار} other {# هشدارها} } آيا شما از پاک کردن", + "clear-alarm-title": "پاک کردن هشدار", + "clear-alarm-text": "آيا شما از پاک کردن هشدار اطمينان داريد؟", + "alarm-status-filter": "فيلتر وضعيت هشدار" + }, + "alias": { + "add": "افزودن نام مستعار", + "edit": "ويرايش نام مستعار", + "name": "نام مستعار", + "name-required": "نام مستعار مورد نياز است", + "duplicate-alias": ".در حال حاضر نام مستعار مشابهي وجود دارد", + "filter-type-single-entity": "موجودي تکي", + "filter-type-entity-list": "ليست موجودي", + "filter-type-entity-name": "نام موجودي", + "filter-type-state-entity": "موجودي از وضعيت داشبورد", + "filter-type-state-entity-description": "پارامترهاي موجودي گرفته شده از وضعيت داشبورد", + "filter-type-asset-type": "نوع دارايي", + "filter-type-asset-type-description": "'{{assetType}}' دارايي هاي نوع", + "filter-type-asset-type-and-name-description": ".شروع مي شود '{{prefix}}' که نامشان با '{{assetType}}' دارايي هاي نوع", + "filter-type-device-type": "نوع دستگاه", + "filter-type-device-type-description": "'{{deviceType}}' دستگاه هاي نوع", + "filter-type-device-type-and-name-description": ".شروع مي شود '{{prefix}}' که نامشان با '{{deviceType}}' دستگاه هاي نوع", + "filter-type-entity-view-type": "نوع نمايش موجودي", + "filter-type-entity-view-type-description": "'{{entityView}}' نمايش هاي موجودي نوع ", + "filter-type-entity-view-type-and-name-description": ".شروع مي شود '{{prefix}}' که نامشان با '{{entityView}}' نمايش هاي موجودي نوع", + "filter-type-relations-query": "پرس و جو درمورد ارتباطات", + "filter-type-relations-query-description": ". دارند {{direction}} {{rootEntity}} را {{relationType}} که ارتباط {{entities}}", + "filter-type-asset-search-query": "پرس و جو درمورد جستجوي دارايي", + "filter-type-asset-search-query-description": ".دارند {{direction}} {{rootEntity}} را {{relationType}} که ارتباط{{assetTypes}} دارايي ها از انواع", + "filter-type-device-search-query": "پرس و چو درمورد جستجوي دستگاه", + "filter-type-device-search-query-description": ".دارند {{direction}} {{rootEntity}} را {{relationType}} که ارتباط{{deviceTypes}} دستگاه ها از انواع", + "filter-type-entity-view-search-query": "پرس و جو درمورد جستجوي نمايش موجودي", + "filter-type-entity-view-search-query-description": ".دارند {{direction}} {{rootEntity}} را {{relationType}} که ارتباط{{entityViewTypes}} نمايش هاي موجودي از انواع", + "entity-filter": "فيلتر موجودي", + "resolve-multiple": "تصميم با توجه به موجودي هاي متعدد", + "filter-type": "نوع فيلتر", + "filter-type-required": ".نوع فيلتر مورد نياز است", + "entity-filter-no-entity-matched": ".هيچ موجودي منطبق بر فيلتر مشخص شده يافت نشد", + "no-entity-filter-specified": ".هيچ فيلتر موجودي اي تعيين نشده است", + "root-state-entity": "موجودي وضعيت داشبورد به عنوان پايه استفاده شود", + "root-entity": "موجودي پايه", + "state-entity-parameter-name": "نام پارامتر موجودي وضعيت", + "default-state-entity": "موجودي وضعيت پيش فرض", + "default-entity-parameter-name": "به صورت پيش فرض", + "max-relation-level": "بالاترين سطح ارتباط", + "unlimited-level": "سطح نامحدود", + "state-entity": "موجودي وضعيت داشبورد", + "all-entities": "تمام موجودي ها", + "any-relation": "هر" + }, + "asset": { + "asset": "دارايي", + "assets": "دارايي ها", + "management": "مديريت دارايي", + "view-assets": "نمايش دارايي ها", + "add": "افزودن دارايي", + "assign-to-customer": "تخصيص به مشتري", + "assign-asset-to-customer": "تخصيص دارايي(ها) به مشتري", + "assign-asset-to-customer-text": "لطفا دارايي ها را انتخاب کنيد تا به مشتري تخصيص يابد", + "no-assets-text": "هيچ دارايي اي يافت نشد", + "assign-to-customer-text": "لطفا مشتري را انتخاب کنيد تا دارايي(ها) تخصيص يابد", + "public": "عمومي", + "assignedToCustomer": "تخصيص يافته به مشتري", + "make-public": "عمومي سازي دارايي", + "make-private": "شخصي سازي دارايي", + "unassign-from-customer": "لغو تخصيص از مشتري", + "delete": "حذف دارايي", + "asset-public": "دارايي عمومي است", + "asset-type": "نوع دارايي", + "asset-type-required": ".نوع دارايي مورد نياز است", + "select-asset-type": "انتخاب کردن نوع دارايي", + "enter-asset-type": "وارد کردن نوع دارايي", + "any-asset": "هر دارايي", + "no-asset-types-matching": ".يافت نشد '{{entitySubtype}}' هيچ دارايي منطبق بر", + "asset-type-list-empty": ".هيچيک از انواع دارايي انتخاب نشد", + "asset-types": "انواع دارايي", + "name": "نام", + "name-required": ".نام مورد نياز است", + "description": "توصيف", + "type": "نوع", + "type-required": ".نوع مورد نياز است", + "details": "جزئيات", + "events": "رويدادها", + "add-asset-text": "افزودن دارايي جديد", + "asset-details": "جزئيات دارايي", + "assign-assets": "تخصيص دارايي ها", + "assign-assets-text": "به مشتري { count, plural, 1 {1 دارايي} other {# دارايي} } تخصيص", + "delete-assets": "حذف دارايي ها", + "unassign-assets": "لغو تخصيص دارايي ها", + "unassign-assets-action-title": "از مشتري { count, plural, 1 {1 دارايي} other {# دارايي} } لغو تخصيص", + "assign-new-asset": "تخصيص دارايي جديد", + "delete-asset-title": "مطمئنيد؟ '{{assetName}}' آيا از حذف دارايي", + "delete-asset-text": ".مراقب باشيد، پس از تأييد، دارايي و تمام داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-assets-title": "مطمئنيد؟ { count, plural, 1 {1 دارايي} other {# دارايي} } آيا از حذف", + "delete-assets-action-title": "{ count, plural, 1 {1 دارايي} other {# دارايي} } حذف", + "delete-assets-text": ".مراقب باشيد، پس از تأييد، تمام دارايي هاي انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "make-public-asset-title": "مطمئنيد؟ '{{assetName}}' آيا از عمومي سازي", + "make-public-asset-text": ".پس از تأييد، دارايي و تمامي داده هايش عمومي و قابل دسترسي براي ديگران مي شود", + "make-private-asset-title": "مطمئنيد؟ '{{assetName}}' آيا از شخصي سازي دارايي", + "make-private-asset-text": ".پس از تأييد، دارايي و تمامي داده هايش شخصي و خارج از دسترس ديگران مي شوند", + "unassign-asset-title": "مطمئنيد؟ '{{assetName}}' آيا از لغو تخصيص دارايي", + "unassign-asset-text": ".پس از تأييد، دارايي، لغو تخصيص و خارج از دسترس مشتري مي شود", + "unassign-asset": "لغو تخصيص دارايي", + "unassign-assets-title": "مطمئنيد؟ { count, plural, 1 {1 دارايي} other {# دارايي} } آيا از لغو تخصيص", + "unassign-assets-text": ".پس از تأييد، تمام دارايي هاي انتخاب شده، لغو تخصيص و خارج از دسترس مشتري مي شوند", + "copyId": "دارايي ID رونوشت از", + "idCopiedMessage": "دارايي در حافظه موقت رونوشت شد ID", + "select-asset": "انتخاب دارايي", + "no-assets-matching": ".يافت نشد '{{entity}}' هيچ دارايي منطبق بر", + "asset-required": "دارايي مود نياز است", + "name-starts-with": "نام دارايي شروع مي شود با", + "label": "برچسب" + }, + "attribute": { + "attributes": "ويژگي ها", + "latest-telemetry": "آخرين سنجش", + "attributes-scope": "حوزه ويژگي هاي موجودي", + "scope-latest-telemetry": "آخرين سنجش", + "scope-client": "ويژگي هاي مشتري", + "scope-server": "ويژگي هاي سِروِر", + "scope-shared": "ويژگي هاي مشترک", + "add": "افزودن ويژگي ها", + "key": "کليد", + "last-update-time": "آخرين زمان به روز رساني", + "key-required": ".کليد ويژگي مورد نياز است", + "value": "مقدار", + "value-required": ".مقدار ويژگي مورد نياز است", + "delete-attributes-title": "مطمئنيد؟ { count, plural, 1 {1 ويژگي} other {# ويژگي} } آيا از حذف", + "delete-attributes-text": ".مراقب باشيد، پس از تأييد، تمام ويژگي هاي انتخاب شده حذف مي گردند", + "delete-attributes": "حذف ويژگي ها", + "enter-attribute-value": "وارد کردن مقدار ويژگي", + "show-on-widget": "نمايش بر ويجت", + "widget-mode": "حالت ويجت", + "next-widget": "ويجت بعد", + "prev-widget": "ويجت قبل", + "add-to-dashboard": "افزودن به داشبورد", + "add-widget-to-dashboard": "افزودن ويجت به داشبورد", + "selected-attributes": "انتخاب شدند { count, plural, 1 {1 ويژگي} other {# ويژگي} }", + "selected-telemetry": "انتخاب شد { count, plural, 1 {1 واحد سنجش} other {# واحد سنجش} }" + }, + "audit-log": { + "audit": "بازبيني", + "audit-logs": "داده هاي ثبت شده از بازبيني", + "timestamp": "برچسب زمان", + "entity-type": "نوع موحودي", + "entity-name": "نام موجودي", + "user": "کاربر", + "type": "نوع", + "status": "وضعيت", + "details": "جزئيات", + "type-added": "اضافه شده", + "type-deleted": "حذف شده", + "type-updated": "به روز", + "type-attributes-updated": "ويژگي ها به روز شد", + "type-attributes-deleted": "ويژگي ها حذف شد", + "type-rpc-call": "RPC فراخواني", + "type-credentials-updated": "اعتبارنامه ها به روز شد", + "type-assigned-to-customer": "به مشتري تخصيص يافت", + "type-unassigned-from-customer": "از مشتري لغو تخصيص شد", + "type-activated": "فعال شد", + "type-suspended": "معلق", + "type-credentials-read": "اعتبارنامه ها خوانده شد", + "type-attributes-read": "ويژگي ها خوانده شد", + "type-relation-add-or-update": "ارتباط به روز شد", + "type-relation-delete": "ارتباط حذف شد", + "type-relations-delete": "تمام ارتباطات حذف شد", + "type-alarm-ack": "تصديق شده", + "type-alarm-clear": "پاک شده", + "status-success": "موفقيت", + "status-failure": "عدم موفقيت", + "audit-log-details": "بازبيني جزئيات ثبت داده ها", + "no-audit-logs-prompt": "هيچ داده ثبت شده اي يافت نشد", + "action-data": "داده هاي اقدام", + "failure-details": "جزئيات عدم موفقيت", + "search": "جستجوي داده هاي ثبت شده از بازبيني", + "clear-search": "پاک کردن جستجو" + }, + "confirm-on-exit": { + "message": "شما تغييراتي ذخيره نشده داريد. از ترک اين صفحه مطمئنيد؟", + "html-message": "از ترک اين صفحه مطمئنيد؟
    .شما تغييراتي ذخيره نشده داريد", + "title": "تغييرات ذخيره نشده " + }, + "contact": { + "country": "کشور", + "city": "شهر", + "state": "استان / ايالت", + "postal-code": "کد پستي", + "postal-code-invalid": ".قالب کد پستي نامعتبر است", + "address": "نشاني", + "address2": "2 نشاني", + "phone": "تلفن", + "email": "پست الکترونيک", + "no-address": "بدون آدرس" + }, + "common": { + "username": "نام کاربري", + "password": "رمز عبور", + "enter-username": "وارد کردن نام کاربري", + "enter-password": "وارد کردن رمز عبور", + "enter-search": "وارد کردن جستجو", + "created-time": "زمان ايجاد" + }, + "content-type": { + "json": "JSON", + "text": "Text", + "binary": "Binary (Base64)" + }, + "customer": { + "customer": "مشتري", + "customers": "مشتريان", + "management": "مديريت مشتري", + "dashboard": "داشبورد مشتري", + "dashboards": "داشبوردهاي مشتري", + "devices": "دستگاه هاي مشتري", + "entity-views": "نمايش موجودي مشتري", + "assets": "دارايي هاي مشتري", + "public-dashboards": "داشبوردهاي عمومي", + "public-devices": "دستگاه هاي عمومي", + "public-assets": "دارايي هاي عمومي", + "public-entity-views": "نمايش موجودي عمومي", + "add": "افزودن مشتري", + "delete": "حذف مشتري", + "manage-customer-users": "مديريت کاربرهاي مشتري", + "manage-customer-devices": "مديريت دستگاه هاي مشتري", + "manage-customer-dashboards": "مديريت داشبوردهاي مشتري", + "manage-public-devices": "مديريت دستگاه هاي عمومي", + "manage-public-dashboards": "مديريت داشبوردهاي عمومي", + "manage-customer-assets": "مديريت دارايي هاي مشتري", + "manage-public-assets": "مديريت دارايي هاي عمومي", + "add-customer-text": "افزودن مشتري جديد", + "no-customers-text": "هيچ مشتري اي يافت نشد", + "customer-details": "جزئيات اطلاعات مشتري", + "delete-customer-title": "مطمئنيد؟ '{{customerTitle}}' از حذف مشتري", + "delete-customer-text": ".مراقب باشيد، پس از تأييد، مشتري و تمامي داده هاي مربوطه، غير قابل بازيابي مي شوند", + "delete-customers-title": "مطمئنيد؟ { count, plural, 1 {1 مشتري} other {# مشتري} } از حذف", + "delete-customers-action-title": "{ count, plural, 1 {1 مشتري} other {# مشتري} } حذف", + "delete-customers-text": ".مراقب باشيد، پس از تأييد، تمام مشتريانِ انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل دسترسي مي شوند", + "manage-users": "مديريت کاربرها", + "manage-assets": "مديريت دارايي ها", + "manage-devices": "مديريت دستگاه ها", + "manage-dashboards": "مديريت داشبوردها", + "title": "عنوان", + "title-required": ".عنوان مورد نياز است", + "description": "توصيف", + "details": "جزئيات", + "events": "رويدادها", + "copyId": "مشتري ID رونوشت از", + "idCopiedMessage": "مشتري در حافظه موقت رونوشت شد ID", + "select-customer": "انتخاب مشتري", + "no-customers-matching": ".يافت نشد '{{entity}}' هيچ مشتري منطبق بر", + "customer-required": "مشتري مورد نياز است", + "select-default-customer": "انتخاب مشتري پيش فرض", + "default-customer": "مشتري پيش فرض", + "default-customer-required": "جهت عيب يابي داشبورد در سطح کاربر مياني، مشتري پيش فرض مورد نياز است" + }, + "datetime": { + "date-from": "تاريخ از", + "time-from": "زمان از", + "date-to": "تاريخ تا", + "time-to": "زمان تا" + }, + "dashboard": { + "dashboard": "داشبورد", + "dashboards": "داشبوردها", + "management": "مديريت داشبورد", + "view-dashboards": "نمايش داشبوردها", + "add": "افزودن داشبورد", + "assign-dashboard-to-customer": "تخصيص داشبورد(ها) به مشتري", + "assign-dashboard-to-customer-text": "لطفا داشبوردها را، جهت تخصيص به مشتري، انتخاب کنيد", + "assign-to-customer-text": "لطفا مشتري را، جهت تخصيص داشبورد(ها)، انتخاب کنيد", + "assign-to-customer": "تخصيص به مشتري", + "unassign-from-customer": "لغو تخصيص از مشتري", + "make-public": "عمومي سازي مشتري", + "make-private": "شخصي سازي داشبورد", + "manage-assigned-customers": "مديريت مشتريان تخصيص داده شده", + "assigned-customers": "مشتريان تخصيص داده شده", + "assign-to-customers": "تخصيص داشبورد(ها) به مشتريان", + "assign-to-customers-text": "لطفا مشتريان را، جهت تخصيص داشبورد(ها)، انتخاب کنيد", + "unassign-from-customers": "لغو تخصيص داشبوردها از مشتريان", + "unassign-from-customers-text": "لطفا مشتريان را، جهت لغو تخصيص از داشبورد(ها)، انتخاب کنيد", + "no-dashboards-text": "هيچ داشبوردي يافت نشد", + "no-widgets": "هيچ ويجتي پيکربندي نشده است", + "add-widget": "افزودن ويجت جديد", + "title": "عنوان", + "select-widget-title": "انتخاب ويجت", + "select-widget-subtitle": "ليست انواع ويجت هاي در دسترس", + "delete": "حذف داشبورد", + "title-required": ".عنوان مورد نياز است", + "description": "توصيف", + "details": "جزئيات", + "dashboard-details": "جزئيات داشبورد", + "add-dashboard-text": "افزودن داشبورد جديد", + "assign-dashboards": "تخصيص داشبوردها", + "assign-new-dashboard": "تخصيص داشبورد جديد", + "assign-dashboards-text": "به مشتريان { count, plural, 1 {1 داشبورد} other {# داشبورد} } تخصيص", + "unassign-dashboards-action-text": "از مشتريان { count, plural, 1 {1 داشبورد} other {# داشبورد} } لغو تخصيص", + "delete-dashboards": "حذف داشبوردها", + "unassign-dashboards": "لغو تخصيص داشبوردها", + "unassign-dashboards-action-title": "از مشتري { count, plural, 1 {1 داشبورد} other {# داشبورد} } لغو تخصيص", + "delete-dashboard-title": "مطمئنيد؟ '{{dashboardTitle}}' از حذف", + "delete-dashboard-text": ".مراقب باشيد، پس از تأييد، داشبورد و تمامي داده هاي مربوطه، غير قابل بازيابي مي شوند", + "delete-dashboards-title": "مطمئنيد؟ { count, plural, 1 {1 داشبورد} other {# داشبورد} } از حذف", + "delete-dashboards-action-title": "{ count, plural, 1 {1 داشبورد} other {# داشبورد} } حذف", + "delete-dashboards-text": ".مراقب باشيد، پس از تأييد، تمام داشبوردهاي انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند ", + "unassign-dashboard-title": "مطمئنيد؟ '{{dashboardTitle}}' از لغو تخصيص داشبورد", + "unassign-dashboard-text": ".پس از تأييد، داشبورد، لغو تخصيص و خارج از دسترس مشتري مي شود", + "unassign-dashboard": "لغو تخصيص داشبورد", + "unassign-dashboards-title": "مطمئنيد؟ { count, plural, 1 {1 داشبورد} other {# داشبورد} } از لغو تخصيص", + "unassign-dashboards-text": ".پس از تأييد، تمام داشبوردهاي انتخاب شده، لغو تخصيص و خارج از دسترس مشتري مي شوند", + "public-dashboard-title": "داشبورد اکنون عمومي است", + "public-dashboard-text": ":قابل دسترسي است اکنون عمومي بوده و از طريق پيوند عمومي ديگر ، {{dashboardTitle}} ،داشبورد شما", + "public-dashboard-notice": ".فراموش نکنيد براي دسترسي به داده هاي دستگاه هاي مربوطه، آنها را عمومي نماييد
    :توجه", + "make-private-dashboard-title": "مطمئنيد؟ '{{dashboardTitle}}' از شخصي سازي داشبورد", + "make-private-dashboard-text": ".پس از تأييد، داشبورد، شخصي و خارج از دسترس ديگران مي شود", + "make-private-dashboard": "شخصي سازي داشبورد", + "socialshare-text": "ThingsBoard طراحي شده توسط '{{dashboardTitle}}'", + "socialshare-title": "ThingsBoard طراحي شده توسط '{{dashboardTitle}}'", + "select-dashboard": "انتخاب داشبورد", + "no-dashboards-matching": ".يافت نشد '{{entity}}' هيچ داشبوردي منطبق بر", + "dashboard-required": ".داشبورد مورد نياز است", + "select-existing": "انتخاب داشبورد موجود", + "create-new": "ايجاد داشبورد جديد", + "new-dashboard-title": "عنوان داشبورد جديد", + "open-dashboard": "باز کردن داشبورد", + "set-background": "تنظيم پس زمينه", + "background-color": "رنگ پس زمينه", + "background-image": "تصوير پس زمينه", + "background-size-mode": "حالت اندازه پس زمينه", + "no-image": "هيچ تصويري انتخاب نشد", + "drop-image": ".جهت بارگذاري يک تصوير، آن را با موس کِشيده و رها کنيد، و يا روي آن کليک نماييد", + "settings": "تنظيمات", + "columns-count": "شمارش ستون ها", + "columns-count-required": ".شمارش ستون ها مورد نياز است", + "min-columns-count-message": ".کمترين تعداد مجاز ستون ها 10 عدد است", + "max-columns-count-message": ".بيشترين تعداد مجاز ستون ها 1000 عدد است", + "widgets-margins": "حاشيه بين ويجت ها", + "horizontal-margin": "حاشيه افقي", + "horizontal-margin-required": ".مقدار حاشيه افقي مورد نياز است", + "min-horizontal-margin-message": ".کمترين مقدار مجاز حاشيه افقي 0 است", + "max-horizontal-margin-message": ".بيشترين مقدار مجاز حاشيه افقي 50 است", + "vertical-margin": "حاشيه عمودي", + "vertical-margin-required": ".حاشيه عمودي مورد نياز است", + "min-vertical-margin-message": ".کمترين مقدار مجاز حاشيه عمودي 0 است", + "max-vertical-margin-message": ".بيشترين مقدار مجاز حاشيه افقي 50 است", + "autofill-height": "تنظيم خودکار ارتفاع چيدمان طرح", + "mobile-layout": "تنظيمات چيدمان طرح در تلفن همراه", + "mobile-row-height": "(px) ارتفاع رديف در تلفن همراه", + "mobile-row-height-required": ".مقدار ارتفاع ردبف در تلفن همراه مورد نياز است", + "min-mobile-row-height-message": ".کمترين مقدار مجاز ارتفاع رديف در تلفن همراه 5 پيکسل است", + "max-mobile-row-height-message": ".بيشترين مقدار مجاز ارتفاع رديف در تلفن همراه 200 پيکسل است", + "display-title": "نمايش عنوان داشبورد", + "toolbar-always-open": "باز نگه داشتن نوار ابزار", + "title-color": "رنگ عنوان", + "display-dashboards-selection": "نمايش انتخاب داشبوردها", + "display-entities-selection": "نمايش انتخاب موجودي ها", + "display-dashboard-timewindow": "نمايش پنجره زمان", + "display-dashboard-export": "نمايش صدور", + "import": "وارد کردن داشبورد", + "export": "صادر کردن داشبورد", + "export-failed-error": "{{error}} :صدور داشبورد ممکن نيست", + "create-new-dashboard": "ايجاد داشبورد جديد", + "dashboard-file": "پرونده داشبورد", + "invalid-dashboard-file-error": ".وارد کردن داشبورد ممکن نيست: ساختار داده داشبورد نامعتبر است", + "dashboard-import-missing-aliases-title": "پيکربندي نامهاي مستعار استفاده شده توسط داشبوردِ وارده", + "create-new-widget": "ايجاد ويجت جديد", + "import-widget": "وارد کردن ويجت", + "widget-file": "پرونده ويجت", + "invalid-widget-file-error": ".وارد کردن ويجت ممکن نيست: ساختار داده ويجت نامعتبر است", + "widget-import-missing-aliases-title": "پيکربندي نامهاي مستعار استفاده شده توسط ويجتِ وارده", + "open-toolbar": "باز کردن نوار ابزار داشبورد", + "close-toolbar": "بستن نوار ابزار", + "configuration-error": "خطاي پيکربندي", + "alias-resolution-error-title": "خطاي پيکربندي نامهاي مستعار داشبورد", + "invalid-aliases-config": ".لطفا جهت حل اين موضوع با مسئول مربوط به خود تماس بگيريد
    .يافتن دستگاهي منطبق بر فبلتر بعضي نامهاي مستعار ممکن نيست", + "select-devices": "انتخاب دستگاه ها", + "assignedToCustomer": "تخصيص يافته به مشتري", + "assignedToCustomers": "تخصيص يافته به مشتريان", + "public": "عمومي", + "public-link": "پيوند عمومي", + "copy-public-link": "رونوشت از پيوند عمومي", + "public-link-copied-message": "پيوند عمومي داشبورد در حافظه موقت رونوشت شد", + "manage-states": "مديريت وضعيت هاي داشبورد", + "states": "وضعيت هاي داشبورد", + "search-states": "جستجوي وضعيت هاي داشبورد", + "selected-states": "انتخاب شدند { count, plural, 1 {1 وضعيت داشبورد} other {# وضعيت داشبورد} }", + "edit-state": "ويرايش وضعيت داشبورد", + "delete-state": "حذف وضعيت داشبورد", + "add-state": "افزودن وضعيت داشبورد", + "state": "وضعيت داشبورد", + "state-name": "نام", + "state-name-required": ".نام وضعيت داشبورد مورد نياز است", + "state-id": "وضعيت ID", + "state-id-required": ".وضعيت داشبورد مورد نياز است ID", + "state-id-exists": ".مشابه موجود است ID در حال حاضر وضعيت داشبوردي با", + "is-root-state": "وضعيت پايه", + "delete-state-title": "حذف وضعيت داشبورد", + "delete-state-text": "مطمئنيد؟ '{{stateName}}' از حذف وضعيت داشبورد با نام", + "show-details": "نمايش جزئيات", + "hide-details": "پنهان کردن جزئيات", + "select-state": "انتخاب وضعيت هدف", + "state-controller": "کنترل کننده وضعيت" + }, + "datakey": { + "settings": "تنظيمات", + "advanced": "پيشرفته", + "label": "برچسب", + "color": "رنگ", + "units": "کارکتر خاص براي نمايش بعد از مقدار تعين شده", + "decimals": "تعداد ارقام بعد از مميّز شناور", + "data-generation-func": "تابع توليد داده", + "use-data-post-processing-func": "استفاده از تابع پس پردازش داده", + "configuration": "پيکربندي کليد داده", + "timeseries": "سري هاي زماني", + "attributes": "ويژگي ها", + "alarm": "حوزه هاي هشدار", + "timeseries-required": ".سري هاي زماني موجودي مورد نياز است", + "timeseries-or-attributes-required": ".سري هاي زماني / ويژگي هاي موجودي مورد نياز است", + "maximum-timeseries-or-attributes": "{ count, plural, 1 {.1 سري زماني / ويژگي مجاز است} other {# سري زماني / ويژگي مجازند} } بيشترين", + "alarm-fields-required": ".حوزه هاي هشدار مورد نياز است", + "function-types": "نوع توابع", + "function-types-required": ".نوع تابع مورد نياز است", + "maximum-function-types": "{ count, plural, 1 {.1 نوع تابع مجاز است} other {# نوع تابع مجازند} } بيشترين", + "time-description": "برچسب زماني مقدار فعلي؛", + "value-description": "مقدار فعلي؛", + "prev-value-description": "نتيجه ي فراخوانيِ تابع قبلي؛", + "time-prev-description": "برچسب زماني مقدار قبلي؛", + "prev-orig-value-description": "ممقدار اصلي قبلي" + }, + "datasource": { + "type": "نوع منبع داده", + "name": "نام", + "add-datasource-prompt": "لطفا منبع داده را اضافه کنيد" + }, + "details": { + "edit-mode": "حالت ويرايش", + "toggle-edit-mode": "حالت ويرايش را تغيير دهيد" + }, + "device": { + "device": "دستگاه", + "device-required": ".دستگاه مورد نياز است", + "devices": "دستگاه ها", + "management": "مديريت دستگاه", + "view-devices": "نمايش دستگاه ها", + "device-alias": "نام مستعار دستگاه", + "aliases": "نامهاي مستعار دستگاه", + "no-alias-matching": ".يافت نشد'{{alias}}'", + "no-aliases-found": ".هيچ نام مستعاري يافت نشد", + "no-key-matching": ".يافت نشد'{{key}}'", + "no-keys-found": ".هيچ کليدي يافت نشد", + "create-new-alias": "!ايجاد يک نام مستعار جديد", + "create-new-key": "!ايجاد يک کليد جديد", + "duplicate-alias-error": ".نام مستعار در داشبورد بايد يکتا باشد
    '{{alias}}'نام مستعار مشابه يافت شد", + "configure-alias": "نام مستعار '{{alias}}' پيکربندي", + "no-devices-matching": "مطابقت داشته باشد وجود ندارد '{{entity}}' هيچ دستگاهي که با ", + "alias": "نام مستعار", + "alias-required": ".نام مستعار مورد نياز است", + "remove-alias": "حذف نام مستعار دستگاه", + "add-alias": "افزودن نام مستعار دستگاه", + "name-starts-with": "اسم دستگاه شروع مي شود با", + "device-list": "ليست دستگاه ها", + "use-device-name-filter": "از فيلتر استفاده کنيد", + "device-list-empty": ".هيچ دستگاهي انتخاب نشده است", + "device-name-filter-required": ".فيلتر نام دستگاه مورد نياز است", + "device-name-filter-no-device-matched": ".شروع شود يافت نشد '{{device}}' هيچ دستگاهي که با", + "add": "افزودن دستگاه", + "assign-to-customer": "تخصيص به مشتري", + "assign-device-to-customer": "تخصيص دستگاه (ها) به مشتري", + "assign-device-to-customer-text": "لطفا دستگاه ها را انتخاب کنيد تا به مشتري تخصيص يابد", + "make-public": "عمومي سازي دستگاه", + "make-private": "شخصي سازي دستگاه", + "no-devices-text": "هيچ دستگاهي يافت نشد", + "assign-to-customer-text": "لطفا مشتري را انتخاب کنيد تا دستگاه(ها) تخصيص يابد", + "device-details": "جزئيات دستگاه", + "add-device-text": "افزودن دستگاه جديد", + "credentials": "اعتبارنامه ها", + "manage-credentials": "مديريت اعتبارنامه ها", + "delete": "حذف دستگاه", + "assign-devices": "تخصيص دستگاه ها", + "assign-devices-text": "به مشتري { count, plural, 1 {1 دستگاه} other {# دستگاه} } تخصيص", + "delete-devices": "حذف دستگاه ها", + "unassign-from-customer": "لغو تخصيص از مشتري", + "unassign-devices": "لغو تخصيص دستگاه ها", + "unassign-devices-action-title": "از مشتري { count, plural, 1 {1 دستگاه} other {# دستگاه} } لغو تخصيص", + "assign-new-device": "تخصيص دستگاه جديد", + "make-public-device-title": "مطمئنيد؟ '{{deviceName}}' از عمومي سازي دستگاه", + "make-public-device-text": ".پس از تأييد، دستگاه و تمامي داده هايش عمومي و قابل دسترسي براي ديگران مي شود", + "make-private-device-title": "مطمئنيد؟ '{{deviceName}}' از شخصي سازي دستگاه", + "make-private-device-text": ".پس از تأييد، دستگاه و تمامي داده هايش شخصي و خارج از دسترس ديگران مي شوند", + "view-credentials": "نمايش اعتبارنامه ها", + "delete-device-title": "مطمئنيد؟ '{{deviceName}}' از حذف", + "delete-device-text": ".مراقب باشيد، پس از تأييد، دستگاه و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-devices-title": "مطمئنيد؟ { count, plural, 1 {1 دستگاه} other {# دستگاه} } از حذف", + "delete-devices-action-title": "{ count, plural, 1 {1 دستگاه} other {# دستگاه} } حذف", + "delete-devices-text": ".مراقب باشيد، پس از تأييد، تمام دستگاه هاي انتخاب شده، حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "unassign-device-title": "مطمئنيد؟ '{{deviceName}}' از لغو تخصيص", + "unassign-device-text": ".پس از تأييد، دستگاه، لغو تخصيص و خارج از دسترس مشتري مي شود", + "unassign-device": "لغو تخصيص دستگاه", + "unassign-devices-title": "مطمئنيد؟ { count, plural, 1 {1 دستگاه} other {# دستگاه} } از لغو تخصيص", + "unassign-devices-text": ".پس از تأييد، تمام دستگاه هاي انتخاب شده، لغو تخصيص و خارج از دسترس مشتري مي شوند", + "device-credentials": "اعتبارنامه هاي دستگاه", + "credentials-type": "نوع اعتبارنامه ها", + "access-token": "شناسه دسترسي", + "access-token-required": ".شناسه دسترسي مورد نياز است", + "access-token-invalid": ".طول شناسه دسترسي بايد از 1 تا 32 حرف باشد", + "secret": "محرمانه", + "secret-required": ".شناسه محرمانه مورد نياز است", + "device-type": "نوع دستگاه", + "device-type-required": ".نوع دستگاه مورد نياز است", + "select-device-type": "انتخاب نوع دستگاه", + "enter-device-type": "وارد کردن نوع دستگاه", + "any-device": "هر دستگاهي", + "no-device-types-matching": ".يافت نشد '{{entitySubtype}}' هيچ نوع دستگاهي منطبق بر", + "device-type-list-empty": ".هيچ نوع دستگاهي انتخاب نشد", + "device-types": "انواع دستگاه", + "name": "نام", + "name-required": ".نام مورد نياز است", + "description": "توصيف", + "events": "رويدادها", + "details": "جزئيات", + "copyId": "دستگاه ID رونوشت از", + "copyAccessToken": "رونوشت از شناسه دسترسي", + "idCopiedMessage": ".دستگاه در حافظه موقت رونوشت شد ID", + "accessTokenCopiedMessage": ".شناسه دسترسي دستگاه در حافظه موقت رونوشت شد", + "assignedToCustomer": "تخصيص يافته به مشتري", + "unable-delete-device-alias-title": "حذف نام مستعار دستگاه ممکن نيست", + "unable-delete-device-alias-text": "
    {{widgetsList}} :را تا زمان استفاده توسط ويجت(هاي) زير نمي توان حذف کرد ، '{{deviceAlias}}' ،نام مستعار دستگاه", + "is-gateway": "درگاه است", + "public": "عمومي", + "device-public": "دستگاه عمومي است", + "select-device": "انتخاب دستگاه" + }, + "dialog": { + "close": "بستن گفتگو" + }, + "error": { + "unable-to-connect": ".اتصال به سِروِر ممکن نيست! لطفا اتصال اينترنت خود را بررسي کنيد", + "unhandled-error-code": "{{errorCode}} :کد خطاي رسيدگي نشده", + "unknown-error": "خطاي ناشناخته" + }, + "entity": { + "entity": "موجودي", + "entities": "موجودي ها", + "aliases": "نامهاي مستعار موجودي", + "entity-alias": "نام مستعار موجودي", + "unable-delete-entity-alias-title": "حذف نام مستعار موجودي ممکن نيست", + "unable-delete-entity-alias-text": "
    {{widgetsList}} :را تا زمان استفاده توسط ويجت(هاي) زير نمي توان حذف کرد ، '{{entityAlias}}' ،نام مستعار موجودي", + "duplicate-alias-error": ".نامهاي مستعار موجودي بايد در داشبورد، منحصر بفرد باشند
    .يافت شد '{{alias}}' نام مستعار تکراري", + "missing-entity-filter-error": ".مفقود است '{{alias}}' فيلتر براي نام مستعار", + "configure-alias": "'{{alias}}' پيکربندي نام مستعار", + "alias": "نام مستعار", + "alias-required": ".نام مستعار موجودي مورد نياز است", + "remove-alias": "حذف نام مستعار موجودي", + "add-alias": "افزودن نام مستعار موجودي", + "entity-list": "ليست موجودي", + "entity-type": "نوع موجودي", + "entity-types": "انواع موجودي", + "entity-type-list": "ليست نوع موجودي", + "any-entity": "هر موجودي", + "enter-entity-type": "وارد کردن نوع موجودي", + "no-entities-matching": ".يافت نشد '{{entity}}' هيچ موجودي منطبق بر", + "no-entity-types-matching": ".يافت نشد '{{entityType}}' هيچ نوع موجودي منطبق بر", + "name-starts-with": "نام شروع مي شود با", + "use-entity-name-filter": "استفاده از فيلتر", + "entity-list-empty": ".هيچ موجودي اي انتخاب نشده است", + "entity-type-list-empty": ".هيچ نوع موجودي انتخاب نشده است", + "entity-name-filter-required": ".فيلتر نام موجودي مورد نياز است", + "entity-name-filter-no-entity-matched": ".شروع شود يافت نشد '{{entity}}' هيچ موجودي که با", + "all-subtypes": "همه", + "select-entities": "انتخاب موجودي ها", + "no-aliases-found": ".هيچ نام مستعاري يافت نشد", + "no-alias-matching": ".يافت نشد '{{alias}}'", + "create-new-alias": "!ايجاد يک نام مستعار جديد", + "key": "کليد", + "key-name": "نام کليد", + "no-keys-found": ".هيچ کليدي يافت نشد", + "no-key-matching": "'.يافت نشد {{key}}'", + "create-new-key": "!ايجاد يک کليد جديد", + "type": "نوع", + "type-required": ".نوع موجودي مورد نياز است", + "type-device": "دستگاه", + "type-devices": "دستگاه ها", + "list-of-devices": "{ count, plural, 1 {يک دستگاه} other {ليست # دستگاه} }", + "device-name-starts-with": "شروع مي شود '{{prefix}}' دستگاه هايي که نامشان با", + "type-asset": "دارايي", + "type-assets": "دارايي ها", + "list-of-assets": "{ count, plural, 1 {يک دارايي} other {ليست # دارايي} }", + "asset-name-starts-with": "شروع مي شود '{{prefix}}' دارايي هايي که نامشان با", + "type-entity-view": "نمايش موجودي", + "type-entity-views": "نمايش هاي موجودي", + "list-of-entity-views": "{ count, plural, 1 {يک نمايش موجودي} other {ليست # نمايش موجودي} }", + "entity-view-name-starts-with": "شروع مي شود '{{prefix}}' نمايش هاي موجودي که نامشان با", + "type-rule": "قاعده", + "type-rules": "قواعد", + "list-of-rules": "{ count, plural, 1 {يک قاعده} other {ليست # قاعده} }", + "rule-name-starts-with": "شروع مي شود '{{prefix}}' قواعدي که نامشان با", + "type-plugin": "ابزار جانبي", + "type-plugins": "ابزارهاي جانبي", + "list-of-plugins": "{ count, plural, 1 {يک ابزار جانبي} other {ليست # ابزار جانبي} }", + "plugin-name-starts-with": "شروع مي شود '{{prefix}}' ابزارهاي جانبي که نامشان با", + "type-tenant": "کاربر", + "type-tenants": "کاربران", + "list-of-tenants": "{ count, plural, 1 {يک کاربر} other {ليست # کاربر} }", + "tenant-name-starts-with": "شروع مي شود '{{prefix}}' کاربرهايي که نامشان با", + "type-customer": "مشتري", + "type-customers": "مشتريان", + "list-of-customers": "{ count, plural, 1 {يک مشتري} other {ليست # مشتري} }", + "customer-name-starts-with": "شروع مي شود '{{prefix}}' مشترياني که نامشان با", + "type-user": "کاربر", + "type-users": "کاربران", + "list-of-users": "{ count, plural, 1 {يک کاربر} other {ليست # کاربر} }", + "user-name-starts-with": "شروع مي شود '{{prefix}}' کاربرهايي که نامشان با", + "type-dashboard": "داشبورد", + "type-dashboards": "داشبوردها", + "list-of-dashboards": "{ count, plural, 1 {يک داشبورد} other {ليست # داشبورد} }", + "dashboard-name-starts-with": "شروع مي شود '{{prefix}}' داشبوردهايي که نامشان با", + "type-alarm": "هشدار", + "type-alarms": "هشدارها", + "list-of-alarms": "{ count, plural, 1 {يک هشدار} other {ليست # هشدار} }", + "alarm-name-starts-with": "شروع مي شود '{{prefix}}' هشدارهايي که نامشان با", + "type-rulechain": "زنجيره قواعد", + "type-rulechains": "زنجيره هاي قواعد", + "list-of-rulechains": "{ count, plural, 1 {يک زنجيره قواعد} other {ليست # زنجيره قواعد} }", + "rulechain-name-starts-with": "شروع مي شود '{{prefix}}' زنجيره هاي قواعدي که نامشان با", + "type-rulenode": "گره قواعد", + "type-rulenodes": "گره هاي قواعد", + "list-of-rulenodes": "{ count, plural, 1 {يک گره قواعد} other {ليست # گره قواعد} }", + "rulenode-name-starts-with": "شروع مي شود '{{prefix}}' گره هاي قواعدي که نامشان با", + "type-current-customer": "مشتري فعلي", + "search": "جستجوي موجودي ها", + "selected-entities": "انتخاب شدند { count, plural, 1 {1 موجودي} other {# موجودي} }", + "entity-name": "نام موجودي", + "details": "جزئيات موجودي", + "no-entities-prompt": "هيچ موجودي اي يافت نشد", + "no-data": "هيچ داده اي براي نمايش نيست", + "columns-to-display": "ستون ها براي نمايش" + }, + "entity-view": { + "entity-view": "نمايش موجودي", + "entity-view-required": ".نمايش موجودي مورد نياز است", + "entity-views": "نمايش هاي موجودي", + "management": "مديريت نمايش موجودي", + "view-entity-views": "نمايش نمايش هاي موجودي", + "entity-view-alias": "نام مستعار نمايش موجودي", + "aliases": "نامهاي مستعار نمايش موجودي", + "no-alias-matching": ".يافت نشد '{{alias}}'", + "no-aliases-found": ".هيچ نام مستعاري يافت نشد", + "no-key-matching": ".يافت نشد '{{key}}'", + "no-keys-found": ".هيچ کليدي يافت نشد", + "create-new-alias": "!ايجاد نام مستعار جديد", + "create-new-key": "!ايجاد کليد جديد", + "duplicate-alias-error": ".نامهاي مستعار نمايش موجودي بايد در داشبورد، منحصر بفرد باشند
    .يافت شد '{{alias}}' نام مستعار تکراري", + "configure-alias": "'{{alias}}' پيکربندي نام مستعار", + "no-entity-views-matching": ".يافت نشد '{{entity}}' هيچ موجودي منطبق بر", + "alias": "نام مستعار", + "alias-required": ".نام مستعار نمايش موجودي مورد نياز است", + "remove-alias": "حذف نام مستعار نمايش موجودي", + "add-alias": "افزودن نام مستعار نمايش موجودي", + "name-starts-with": "نام نمايش موجودي شروع مي شود با", + "entity-view-list": "ليست نمايش موجودي", + "use-entity-view-name-filter": "استفاده از فيلتر", + "entity-view-list-empty": ".هيچ نمايش موجودي انتخاب نشد", + "entity-view-name-filter-required": ".فيلتر نام نمايش موجودي مورد نياز است", + "entity-view-name-filter-no-entity-view-matched": ".شروع شود يافت نشد '{{entityView}}' هيچ نمايش موجودي که با", + "add": "افزودن نمايش موجودي", + "assign-to-customer": "تخصيص به مشتري", + "assign-entity-view-to-customer": "تخصيص نمايش(هاي) موجودي به مشتري", + "assign-entity-view-to-customer-text": ".لطفا نمايش هاي موجودي را انتخاب کنيد تا به مشتري تخصيص يابند", + "no-entity-views-text": "هيچ نمايش موجودي يافت نشد", + "assign-to-customer-text": ".لطفا مشتري را انتخاب کنيد تا نمايش(هاي) موجودي تخصيص يابد", + "entity-view-details": "جزئيات نمايش موجودي", + "add-entity-view-text": "افزودن نمايش موجودي جديد", + "delete": "حذف نمايش موجودي", + "assign-entity-views": "تخصيص نمايش هاي موجودي", + "assign-entity-views-text": "به مشتري { count, plural, 1 {1 نمايش موجودي} other {# نمايش موجودي} } تخصيص", + "delete-entity-views": "حذف نمايش هاي موجودي", + "unassign-from-customer": "لغو تخصيص از مشتري", + "unassign-entity-views": "لغو تخصيص نمايش هاي موجودي", + "unassign-entity-views-action-title": "از مشتري { count, plural, 1 {1 نمايش موجودي} other {# نمايش موجودي} } لغو تخصيص", + "assign-new-entity-view": "تخصيص نمايش موجودي جديد", + "delete-entity-view-title": "مطمئنيد؟ '{{entityViewName}}' از حذف نمايش موجودي", + "delete-entity-view-text": ".مراقب باشيد، پس از تأييد، نمايش موجودي و تمامي داده هاي مربوطه، غير قابل بازيابي مي شوند", + "delete-entity-views-title": "مطمئنيد؟ { count, plural, 1 {1 نمايش موجودي} other {# نمايش موجودي} } از حذف نمايش موجودي", + "delete-entity-views-action-title": "{ count, plural, 1 {1 نمايش موجودي} other {# نمايش موجودي} } حذف", + "delete-entity-views-text": ".مراقب باشيد، پس از تأييد، تمام نمايش هاي موجوديِ انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "unassign-entity-view-title": "مطمئنيد؟ '{{entityViewName}}' از لغو تخصيص نمايش موجودي", + "unassign-entity-view-text": ".پس از تأييد، نمايش موجودي، لغو تخصيص و خارج از دسترس مشتري مي شود", + "unassign-entity-view": "لغو تخصيص نمايش موجودي", + "unassign-entity-views-title": "مطمئنيد؟ { count, plural, 1 {1 نمايش موجودي} other {# نمايش موجودي} } از لغو تخصيص", + "unassign-entity-views-text": ".پس از تأييد، تمام نمايش هاي موجوديِ انتخاب شده، لغو تخصيص و خارج از دسترس مشتري مي شوند", + "entity-view-type": "نوع نمايش موجودي", + "entity-view-type-required": ".نوع نمايش موجودي مورد نياز است", + "select-entity-view-type": "انتخاب نوع نمايش موجودي", + "enter-entity-view-type": "وارد کردن نوع نمايش موجودي", + "any-entity-view": "هر نمايش موجودي", + "no-entity-view-types-matching": ".يافت نشد '{{entitySubtype}}' هيچ نوع نمايش موجودي منطبق بر", + "entity-view-type-list-empty": ".هيچ نوع نمايش موجودي انتخاب نشد", + "entity-view-types": "انواع نمايش موجودي", + "name": "نام", + "name-required": ".نام مورد نياز است", + "description": "توصيف", + "events": "رويدادها", + "details": "جزئيات", + "copyId": "نمايش موجودي ID رونوشت از", + "assignedToCustomer": "تخصيص يافته به مشتري", + "unable-entity-view-device-alias-title": ".حذف نام مستعار نمايش موجودي ممکن نيست", + "unable-entity-view-device-alias-text": "
    {{widgetsList}} :را تا زمان استفاده توسط ويجت(هاي) زير نمي توان حذف کرد ، '{{entityViewAlias}}' ،نام مستعار دستگاه", + "select-entity-view": "انتخاب نمايش موجودي", + "make-public": "عمومي سازي نمايش موجودي", + "start-date": "تاريخ شروع", + "start-ts": "زمان شروع", + "end-date": "تاريخ پايان", + "end-ts": "زمان پايان", + "date-limits": "محدوده تاريخ", + "client-attributes": "ويژگي هاي مشتري", + "shared-attributes": "ويژگي هاي مشترک", + "server-attributes": "ويژگي هاي سِروِر", + "timeseries": "سري هاي زماني", + "client-attributes-placeholder": "ويژگي هاي مشتري", + "shared-attributes-placeholder": "ويژگي هاي مشترک", + "server-attributes-placeholder": "ويژگي هاي سِروِر", + "timeseries-placeholder": "سري هاي زماني", + "target-entity": "موجودي هدف", + "attributes-propagation": "انتشار ويژگي ها", + "attributes-propagation-hint": "هر بار که شما نمايش موجودي را بروز رساني يا ذخيره مي کنيد، نمايش موجودي بصور خودکار ويژگي هاي تعيين شده را از موجودي هدف کپي مي کند و به دلايل عملکردي، ويژگي هاي موجودي هدف، با هر بار تغيير ويژگي، در نمايش موجودي انتشار نمي يابند. مي توانيد با پيکربندي گره قواعد در زنجيره قواعد خود و پيوند دهي \"Post attributes\" و \"Attributes Updated\" آن به گره قواعد جديد، انتشار خودکار \"copy to view\" را ممکن سازيد ." , + "timeseries-data": "داده ي سري هاي زماني", + "timeseries-data-hint": "کليدهاي داده ي سري هاي زمانيِ موجوديِ هدف را پيکربندي کنيد تا در دسترسِ نمايش موجودي باشند. اين سري هاي زماني، فقط خواندني است" + }, + "event": { + "event-type": "نوع رويداد", + "type-error": "خطا", + "type-lc-event": "رويداد چرخه عمر", + "type-stats": "آمار", + "type-debug-rule-node": "اشکال زدايي", + "type-debug-rule-chain": "اشکال زدايي", + "no-events-prompt": "هيچ رويدادي يافت نشد", + "error": "خطا", + "alarm": "هشدار", + "event-time": "زمان رويداد", + "server": "سِروِر", + "body": "بدنه", + "method": "روش", + "type": "نوع", + "message-id": "پيام ID", + "message-type": "نوع پيام", + "data-type": "نوع داده", + "relation-type": "نوع ارتباط", + "metadata": "فرا داده", + "data": "داده", + "event": "رويداد", + "status": "وضعيت", + "success": "موفقيت", + "failed": "عدم موفقيت", + "messages-processed": "پيام پردازش شد", + "errors-occurred": "خطاها رخ دادند", + "all-events": "همه", + "entity-type": "نوع موجودي" + }, + "extension": { + "extensions": "دنباله ها", + "selected-extensions": "انتخاب شدند { count, plural, 1 {1 افزونه} other {افزونه ها #} }", + "type": "نوع", + "key": "کليد", + "value": "مقدار", + "id": "ID", + "extension-id": " افزونه ID", + "extension-type": "نوع افزونه", + "transformer-json": "JSON *", + "unique-id-required": ".افزونه فعلي موجود است ID در حال حاضر", + "delete": "حذف دنباله", + "add": "افزودن دنباله", + "edit": "ويرايش دنباله", + "delete-extension-title": "مطمئنيد؟ '{{extensionId}}' از حذف افزونه", + "delete-extension-text": ".مراقب باشيد، پس از تأييد، افزونه و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-extensions-title": "مطمئنيد؟ { count, plural, 1 {1 افزونه} other {# افزونه} } از حذف", + "delete-extensions-text": ".مراقب باشيد، پس از تأييد، تمام افزونه هاي انتخاب شده حذف مي گردند", + "converters": "مبدّل ها", + "converter-id": "مبدّل ID", + "configuration": "پيکربندي", + "converter-configurations": "پيکربندي هاي مبدّل", + "token": "نشانه امنيت", + "add-converter": "افزودن مبدّل", + "add-config": "افزودن پيکربندي مبدّل", + "device-name-expression": "عبارت نام دستگاه", + "device-type-expression": "عبارت نوع دستگاه", + "custom": "متداول", + "to-double": "دو برابر شدن", + "transformer": "مبدّل", + "json-required": ".مبدّل مورد نياز است JSON", + "json-parse": ".مبدّل ممکن نيست JSON تجزيه", + "attributes": "ويژگي ها", + "add-attribute": "افزودن ويژگي", + "add-map": "افزودن جزء نگاشت", + "timeseries": "سري هاي زماني", + "add-timeseries": "افزودن سري هاي زماني", + "field-required": "دامنه مورد نياز است", + "brokers": "واسطه ها", + "add-broker": "افزودن واسطه", + "host": "ميزبان", + "port": "درگاه", + "port-range": ".درگاه بايد در بازه اي بين 1 تا 65535 باشد", + "ssl": "Ssl", + "credentials": "اعتبارنامه ها", + "username": "نام کاربري", + "password": "رمز عبور", + "retry-interval": "بازخواني فاصله در ميلي ثانيه", + "anonymous": "بي نام", + "basic": "پايه", + "pem": "PEM", + "ca-cert": "CA پرونده گواهينامه *", + "private-key": "پرونده کليد شخصي *", + "cert": "پرونده گواهينامه *", + "no-file": ".هيچ پرونده اي انتخاب نشد", + "drop-file": ".جهت بارگذاري يک پرونده، آن را با موس کِشيده و رها کنيد، و يا روي آن کليک نماييد", + "mapping": "نگاشت", + "topic-filter": "فيلتر عنوان", + "converter-type": "نوع مبدّل", + "converter-json": "JSON", + "json-name-expression": "نام دستگاه JSON عبارت", + "topic-name-expression": "عبارت عنوان نام دستگاه", + "json-type-expression": "نوع دستگاه JSON عبارت", + "topic-type-expression": "عبارت عنوان نوع دستگاه", + "attribute-key-expression": "عبارت کليد ويژگي", + "attr-json-key-expression": "کليد ويژگي JSON عبارت", + "attr-topic-key-expression": "عبارت عنوان کليد ويژگي", + "request-id-expression": "ID درخواست عبارت", + "request-id-json-expression": "ID JSON درخواست عبارت", + "request-id-topic-expression": "ID درخواست عبارت عنوان", + "response-topic-expression": "عبارت عنوان پاسخ", + "value-expression": "عبارت مقدار", + "topic": "عنوان", + "timeout": "وقفه در ميلي ثانيه", + "converter-json-required": ".مبدّل مورد نياز است JSON", + "converter-json-parse": ".مبدّل ممکن نيست JSON تجزيه", + "filter-expression": "عبارت فيلتر", + "connect-requests": "درخواست هاي اتصال", + "add-connect-request": "افزودن درخواست اتصال", + "disconnect-requests": "درخواست هاي قطع اتصال", + "add-disconnect-request": "افزودن درخواست قطع اتصال", + "attribute-requests": "درخواست هاي ويژگي", + "add-attribute-request": "افزودن درخواست ويژگي", + "attribute-updates": "به روز رساني هاي ويژگي ", + "add-attribute-update": "افزودن به روز رساني ويژگي ", + "server-side-rpc": "سَمت سِروِر RPC", + "add-server-side-rpc-request": "سَمت سِروِر RPC افزودن درخواست", + "device-name-filter": "فيلتر نام دستگاه", + "attribute-filter": "فيلتر ويژگي ", + "method-filter": "فيلتر روش", + "request-topic-expression": "عبارت عنوان درخواست", + "response-timeout": "وقفه پاسخ در ميلي ثانيه", + "topic-expression": "بيان موضوع", + "client-scope": "حوزه مشتري", + "add-device": "افزودن دستگاه", + "opc-server": "سِروِرها", + "opc-add-server": "افزودن سِروِر", + "opc-add-server-prompt": "لطفا سِروِر را اضافه کنيد", + "opc-application-name": "نام برنامه کاربردي", + "opc-application-uri": "برنامه کاربردي URI", + "opc-scan-period-in-seconds": "دوره پويش در ثانيه", + "opc-security": "امنيت", + "opc-identity": "هويت", + "opc-keystore": "کي استور", + "opc-type": "نوع", + "opc-keystore-type": "نوع", + "opc-keystore-location": "* موقعيت مکاني", + "opc-keystore-password": "رمز عبور", + "opc-keystore-alias": "نام مستعار", + "opc-keystore-key-password": "کليد رمز عبور", + "opc-device-node-pattern": "الگوي گره دستگاه", + "opc-device-name-pattern": "الگوي نام دستگاه", + "modbus-server": "سِروِرها/جايگزين آماده به کار", + "modbus-add-server": "افزودن سِروِر/ جايگزين آماده به کار ", + "modbus-add-server-prompt": "لطفا سِروِرها/جايگزين آماده به کار را اضافه کنيد", + "modbus-transport": "انتقال", + "modbus-port-name": "نام در گاه سريال", + "modbus-encoding": "رمز گذاري", + "modbus-parity": "توازن", + "modbus-baudrate": "نرخ علامت در ثانيه", + "modbus-databits": "بيت هاي داده", + "modbus-stopbits": "بيت هاي توقف", + "modbus-databits-range": ".بيت هاي داده بايد در بازه اي بين 7 تا 8 باشند", + "modbus-stopbits-range": ".بيت هاي توقف بايد در بازه اي بين 1 تا 2 باشند", + "modbus-unit-id": "واحد ID", + "modbus-unit-id-range": ".واحد بايد در بازه اي بين 1 تا 247 باشد ID", + "modbus-device-name": "نام دستگاه", + "modbus-poll-period": "(ms) دوره نمونه برداري", + "modbus-attributes-poll-period": "(ms) دوره نمونه برداري ويژگي ها", + "modbus-timeseries-poll-period": "(ms) دوره نمونه برداري سري هاي زماني", + "modbus-poll-period-range": ".دوره نمونه برداري بايد مقداري مثبت باشد", + "modbus-tag": "برچسب", + "modbus-function": "تابع", + "modbus-register-address": "ثبت نام نشاني", + "modbus-register-address-range": ".نشاني ثبت بايد در بازه اي بين 0 تا 65535 باشد", + "modbus-register-bit-index": "شاخص بيت", + "modbus-register-bit-index-range": ".شاخص بيت بايد در بازه اي بين 0 تا 15 باشد", + "modbus-register-count": "شمارش ثبت", + "modbus-register-count-range": ".شمارش ثبت بايد مقداري مثبت باشد", + "modbus-byte-order": "ترتيب بايت", + + "sync": { + "status": "وضعيت", + "sync": "همگام", + "not-sync": "غير همگام", + "last-sync-time": "آخرين زمان همگام سازي", + "not-available": "خارج از دسترس" + }, + + "export-extensions-configuration": "صدور پيکربندي افزونه ها", + "import-extensions-configuration": "وارد کردن پيکربندي افزونه ها", + "import-extensions": "وارد کردن افزونه ها", + "import-extension": "وارد کردن افزونه", + "export-extension": "صدور افزونه", + "file": "پرونده افزونه ها", + "invalid-file-error": "پرونده افزونه نامعتبر است" + }, + "fullscreen": { + "expand": "بسط به حالت تمام صفحه", + "exit": "خروج از حالت تمام صفحه", + "toggle": "تغيير حالت تمام صفحه", + "fullscreen": "حالت تمام صفحه" + }, + "function": { + "function": "تابع" + }, + "grid": { + "delete-item-title": "از حذف اين مورد مطمئنيد؟", + "delete-item-text": ".مراقب باشيد، پس از تأييد، اين مورد و تمامي داده هاي مربوطه غيرقابل بازيابي مي شوند", + "delete-items-title": "مطمئنيد؟ { count, plural, 1 {1 مورد} other {# مورد} } از حذف", + "delete-items-action-title": "{ count, plural, 1 {1 مورد} other {# مورد} } حذف", + "delete-items-text": ".مراقب باشيد، پس از تأييد، تمام مواردِ انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "add-item-text": "افزودن مورد جديد", + "no-items-text": "هيچ موردي يافت نشد", + "item-details": "جزئيات مورد", + "delete-item": "حذف مورد", + "delete-items": "حذف موارد", + "scroll-to-top": "پيمايش به بالا" + }, + "help": { + "goto-help-page": "رفتن به صفحه کمک" + }, + "home": { + "home": "خانه", + "profile": "پرونده شخصي", + "logout": "خروج", + "menu": "فهرست انتخاب", + "avatar": "آواتار", + "open-user-menu": "بازکردن فهرست انتخاب کاربر" + }, + "import": { + "no-file": "هيچ پرونده اي انتخاب نشد", + "drop-file": ".آن را با موس کِشيده و رها کنيد، و يا روي آن کليک نماييد ،JSON جهت بارگذاري يک پرونده" + }, + "item": { + "selected": "انتخاب شده" + }, + "js-func": { + "no-return-error": "!تابع بايد مقدار را برگرداند", + "return-type-mismatch": "!را برگرداند '{{type}}' تابع بايد مقدار نوع", + "tidy": "مرتب" + }, + "key-val": { + "key": "کليد", + "value": "مقدار", + "remove-entry": "حذف ورودي", + "add-entry": "افزودن ورودي", + "no-data": "هيچ ورودي وجود ندارد" + }, + "layout": { + "layout": "طرح بندي", + "manage": "مديريت طرح بندي ها", + "settings": "تنظيمات طرح بندي", + "color": "رنگ", + "main": "اصلي", + "right": "راست", + "select": "انتخاب طرح بندي هدف" + }, + "legend": { + "position": "محل فهرست علائم", + "show-max": "نمايش بيشترين مقدار", + "show-min": "نمايش کمترين مقدار", + "show-avg": "نمايش مقدار ميانگين", + "show-total": "نمايش مقدار مجموع", + "settings": "تنظيمات فهرست علائم", + "min": "کمترين", + "max": "بيشترين", + "avg": "ميانگين", + "total": "مجموع" + }, + "login": { + "login": "ورود", + "request-password-reset": "درخواست بازنشاني رمز عبور", + "reset-password": "بازنشاني رمز عبور", + "create-password": "ايجاد رمز عبور", + "passwords-mismatch-error": "!رمزهاي عبور وارد شده بايد مشابه باشند", + "password-again": "رمز عبور دوباره", + "sign-in": "لطفا وارد شويد", + "username": "(نام کاربري (پست الکترونيک", + "remember-me": "مرا به خاطر داشته باش", + "forgot-password": "رمز عبور را فراموش کرده ايد؟", + "password-reset": "بازنشاني رمز عبور", + "new-password": "رمز عبور جديد", + "new-password-again": "رمز عبور جديد دوباره", + "password-link-sent-message": "!پيوند بازنشاني رمز عبور با موفقيت ارسال شد", + "email": "پست الکترونيک" + }, + "position": { + "top": "بالا", + "bottom": "پايين", + "left": "چپ", + "right": "راست" + }, + "profile": { + "profile": "پرونده شخصي", + "change-password": "تغيير رمز عبور", + "current-password": "رمز عبور فعلي" + }, + "relation": { + "relations": "ارتباطات", + "direction": "جهت", + "search-direction": { + "FROM": "از", + "TO": "به" + }, + "direction-type": { + "FROM": "از", + "TO": "به" + }, + "from-relations": "ارتباطات خارج از محدوده", + "to-relations": "ارتباطات داخل محدوده", + "selected-relations": "انتخاب شدند { count, plural, 1 {1 ارتباط} other {ارتباط #} }", + "type": "نوع", + "to-entity-type": "به نوع موجودي", + "to-entity-name": "به نام موجودي", + "from-entity-type": "از نوع موجودي", + "from-entity-name": "از نام موجودي", + "to-entity": "به موجودي", + "from-entity": "از موجودي", + "delete": "حذف ارتباط", + "relation-type": "نوع ارتباط", + "relation-type-required": ".نوع ارتباط مورد نياز است", + "any-relation-type": "هر نوع", + "add": "افزودن ارتباط", + "edit": "ويرايش ارتباط", + "delete-to-relation-title": "مطمئنيد؟ '{{entityName}}' از حذف ارتباط با موجودي", + "delete-to-relation-text": ".غيرمرتبط با موجودي فعلي مي شود '{{entityName}}' مراقب باشيد، پس از تأييد، موجودي", + "delete-to-relations-title": "مطمئنيد؟ { count, plural, 1 {1 ارتباط} other {# ارتباط} } از حذف", + "delete-to-relations-text": ".مراقب باشيد، پس از تأييد، تمام روابطِ انتخاب شده حذف، و موجودي هاي مربوطه، غيرمرتبط با موجودي فعلي مي شوند", + "delete-from-relation-title": "مطمئنيد؟ '{{entityName}}' از حذف ارتباط از موجودي", + "delete-from-relation-text": ".مي شود '{{entityName}}' مراقب باشيد، پس از تأييد، موجودي فعلي، غيرمرتبط از جانب موجودي", + "delete-from-relations-title": "مطمئنيد؟ { count, plural, 1 {1 ارتباط} other {# ارتباط} } از حذف", + "delete-from-relations-text": ".مراقب باشيد، پس از تأييد، تمام روابطِ انتخاب شده حذف، و موجودي فعلي، غيرمرتبط از جانب موجودي هاي مربوطه مي شود", + "remove-relation-filter": "حذف فيلتر ارتباط", + "add-relation-filter": "افزودن فيلتر ارتباط", + "any-relation": "هر ارتباط", + "relation-filters": "فيلترهاي ارتباط", + "additional-info": "(JSON) اطلاعات تکميلي", + "invalid-additional-info": "اطلاعات تکميلي ممکن نيست JSON تجزيه" + }, + "rulechain": { + "rulechain": "زنجيره قواعد", + "rulechains": "زنجيره هاي قواعد", + "root": "پايه", + "delete": "حذف زنجيره قواعد", + "name": "نام", + "name-required": ".نام مورد نياز است", + "description": "توصيف", + "add": "افزودن زنجيره قواعد", + "set-root": "ريشه زنجيره قواعد را ايجاد کنيد", + "set-root-rulechain-title": "به عنوان ريشه مطمئنيد؟ '{{ruleChainName}}' از قرار دادن زنجيره قواعد", + "set-root-rulechain-text": ".پس از تأييد، زنجيره قواعد، به عنوان ريشه تعيين شده و به تمام پيامهاي انتقالي رسيدگي مي کند", + "delete-rulechain-title": "مطمئنيد؟ '{{ruleChainName}}' از حذف زنجيره قواعد", + "delete-rulechain-text": ".مراقب باشيد، پس از تأييد، زنجيره قواعد و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-rulechains-title": "مطمئنيد؟ { count, plural, 1 {1 زنجيره قواعد} other {# زنجيره قواعد} } از حذف", + "delete-rulechains-action-title": "{ count, plural, 1 {1 زنجيره قواعد} other {# زنجيره قواعد} } حذف", + "delete-rulechains-text": ".مراقب باشيد، پس از تأييد، تمام زنجيره هاي قواعدِ انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "add-rulechain-text": "افزودن زنجيره قواعد جديد", + "no-rulechains-text": "هيچ زنجيره قواعدي يافت نشد", + "rulechain-details": "جزئيات زنجيره قواعد", + "details": "جزئيات", + "events": "رويدادها", + "system": "سيستم", + "import": "وارد کردن زنجيره قواعد", + "export": "صدور زنجيره قواعد", + "export-failed-error": "{{error}} :صدور زنجيره قواعد ممکن نيست", + "create-new-rulechain": "ايجاد زنجيره قواعد جديد", + "rulechain-file": "پرونده زنجيره قواعد", + "invalid-rulechain-file-error": ".وارد کردن زنجيره قواعد ممکن نيست: ساختار داده زنجيره قواعد نامعتبر است", + "copyId": "زنجيره قواعد ID رونوشت", + "idCopiedMessage": "زنجيره قواعد در حافظه موقت رونوشت شد ID", + "select-rulechain": "انتخاب زنجيره قواعد", + "no-rulechains-matching": ".يافت نشد '{{entity}}' هيچ زنجيره قواعدي منطبق بر", + "rulechain-required": "زنجيره قواعد مورد نياز است", + "management": "مديريت قواعد", + "debug-mode": "حالت اشکال زدايي" + }, + "rulenode": { + "details": "جزئيات", + "events": "رويدادها", + "search": "جستجوي گره ها", + "open-node-library": "باز کردن کتابخانه گره ها", + "add": "افزودن گره قواعد", + "name": "نام", + "name-required": ".نام مورد نياز است", + "type": "نوع", + "description": "توصيف", + "delete": "حذف گره قواعد", + "select-all-objects": "انتخاب تمام گره ها و اتصالات", + "deselect-all-objects": "لغو انتخاب تمام گره ها و اتصالات", + "delete-selected-objects": "حذف گره ها و اتصالاتِ انتخاب شده", + "delete-selected": "حذفِ انتخاب شده", + "select-all": "انتخاب همه", + "copy-selected": "رونوشتِ انتخاب شده", + "deselect-all": "لغو انتخاب همه", + "rulenode-details": "جزئيات گره قواعد", + "debug-mode": "حالت اشکال زدايي", + "configuration": "پيکربندي", + "link": "پيوند", + "link-details": "جزئيات پيوند گره قواعد", + "add-link": "افزودن پيوند", + "link-label": "برچسب پيوند", + "link-label-required": ".برچسب پيوند مورد نياز است", + "custom-link-label": "برچسب پيوند متداول", + "custom-link-label-required": ".برچسب پيوند متداول مورد نياز است", + "link-labels": "برچسب هاي پيوند", + "link-labels-required": ".برچسب هاي پيوند مورد نيازند", + "no-link-labels-found": "هيچ برچسب پيوندي يافت نشد", + "no-link-label-matching": ".پيدا نشد'{{label}}'", + "create-new-link-label": "!برچسب پيوند ايجاد کنيد", + "type-filter": "فيلتر", + "type-filter-details": "پيام هاي ورودي را با شرايط پيکربندي فيلتر کنيد", + "type-enrichment": "افزودن", + "type-enrichment-details": "افزودن اطلاعات تکميلي به فرا داده ي پيام", + "type-transformation": "تبديل", + "type-transformation-details": "تغيير بازده و فرا داده ي پيام", + "type-action": "اقدام", + "type-action-details": "انجام اقدام ويژه", + "type-external": "خارجي", + "type-external-details": "ارتباط متقابل با سيستم خارجي", + "type-rule-chain": "زنجيره قواعد", + "type-rule-chain-details": "ارسال پيامهاي وارده به زنجيره قواعدي مشخص", + "type-input": "ورودي", + "type-input-details": "ورودي منطقي زنجيره قواعد، پيامهاي ورودي را به گره قواعد مرتبط بعدي ارسال مي کند", + "type-unknown": "ناشناخته", + "type-unknown-details": "گره قواعدِ حل نشده", + "directive-is-not-loaded": ".در دسترس نيست '{{directiveName}}' دستورالعمل پيکربنديِ مشخص شده", + "ui-resources-load-error": ".پيکربندي UI عدم موفقيت در بارگذاري منابع", + "invalid-target-rulechain": "!حلّ زنجيره قواعد هدف ممکن نيست", + "test-script-function": "آزمايش تابع اسکريپت", + "message": "پيام", + "message-type": "نوع پيام", + "select-message-type": "انتخاب نوع پيام", + "message-type-required": "نوع پيام مورد نياز است", + "metadata": "فوق داده", + "metadata-required": ".ورودي هاي فرا داده نمي تواند خالي باشد", + "output": "خروجي", + "test": "آزمايش", + "help": "کمک" + }, + "tenant": { + "tenant": "کاربر", + "tenants": "کاربران", + "management": "مديريت کاربران", + "add": "افزودن کاربر", + "admins": "سرپرستان", + "manage-tenant-admins": "مديريت مديران کاربر", + "delete": "حذف کاربر", + "add-tenant-text": "افزودن کاربر جديد", + "no-tenants-text": "هيچ کاربري يافت نشد", + "tenant-details": "جزئيات کاربر", + "delete-tenant-title": "مطمئنيد؟ '{{tenantTitle}}' از حذف کاربر", + "delete-tenant-text": ".مراقب باشيد، پس از تأييد، کاربر و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-tenants-title": "مطمئنيد؟ { count, plural, 1 {1 کاربر} other {# کاربر} } از حذف", + "delete-tenants-action-title": "{ count, plural, 1 {1 کاربر} other {# کاربر} } حذف", + "delete-tenants-text": ".مراقب باشيد، پس از تأييد، تمام کاربران حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "title": "عنوان", + "title-required": ".عنوان مورد نياز است", + "description": "توصيف", + "details": "جزئيات", + "events": "رويدادها", + "copyId": "متصرّف ID رونوشت", + "idCopiedMessage": "کاربر در حافظه موقت رونوشت شد ID", + "select-tenant": "انتخاب کاربر", + "no-tenants-matching": ".يافت نشد '{{entity}}' هيچ کاربري منطبق بر", + "tenant-required": "کاربر مورد نياز است" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 ثانيه} other {ثانيه #} }", + "minutes-interval": "{ minutes, plural, 1 {1 دقيقه} other {دقيقه #} }", + "hours-interval": "{ hours, plural, 1 {1 ساعت} other {ساعت #} }", + "days-interval": "{ days, plural, 1 {1 روز} other {روز #} }", + "days": "روزها", + "hours": "ساعات", + "minutes": "دقايق", + "seconds": "ثانيه ها", + "advanced": "پيشرفته" + }, + "timewindow": { + "days": "{ days, plural, 1 { روز } other {روز #} }", + "hours": "{ hours, plural, 0 { 1ساعت } 1 { ساعت } other {# ساعت } }", + "minutes": "{ minutes, plural, 0 { 1دقيقه } 1 { دقيقه } other {دقيقه # } }", + "seconds": "{ seconds, plural, 0 { 1ثانيه } 1 { ثانيه } other {ثانيه # } }", + "realtime": "بي درنگ", + "history": "تاريخچه", + "last-prefix": "آخرين", + "period": "{{ endTime }} تا {{ startTime }} از", + "edit": "ويرايش پنجره زماني", + "date-range": "بازه داده", + "last": "آخرين", + "time-period": "دوره زماني" + }, + "user": { + "user": "کاربر", + "users": "کاربرها", + "customer-users": "کاربرهاي مشتري", + "tenant-admins": "مديران کاربر", + "sys-admin": "مدير سيستم", + "tenant-admin": "کاربر مدير", + "customer": "مشتري", + "anonymous": "بي نام", + "add": "افزودن کاربر", + "delete": "حذف کاربر", + "add-user-text": "افزودن کاربر جديد", + "no-users-text": "هيچ کاربري يافت نشد", + "user-details": "جزئيات کاربر", + "delete-user-title": "مطمئنيد؟ '{{userEmail}}' از حذف کاربر", + "delete-user-text": ".مراقب باشيد، پس از تأييد، کاربر و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-users-title": "مطمئنيد؟ { count, plural, 1 {1 کاربر} other {# کاربر} } از حذف", + "delete-users-action-title": "{ count, plural, 1 {1 کاربر} other {کاربر #} } حذف", + "delete-users-text": ".مراقب باشيد، پس از تأييد، تمام کاربرهاي انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "activation-email-sent-message": "!پست الکترونيک فعال سازي با موفقيت ارسال شد", + "resend-activation": "ارسال مجدد فعال سازي", + "email": "پست الکترونيک", + "email-required": ".پست الکترونيک مورد نياز است", + "invalid-email-format": ".قالب نامعتبر پست الکترونيک", + "first-name": "نام", + "last-name": "نام خانوادگي", + "description": "توصيف", + "default-dashboard": "داشبورد پيش فرض", + "always-fullscreen": "همواره در حالت تمام صفحه", + "select-user": "انتخاب کاربر", + "no-users-matching": ".يافت نشد '{{entity}}' هيچ کاربري منطبق بر", + "user-required": "کاربر مورد نياز است", + "activation-method": "روش فعال سازي", + "display-activation-link": "نمايش پيوند فعال سازي", + "send-activation-mail": "ارسال پست الکترونيک فعال سازي", + "activation-link": "پيوند فعال سازي کاربر", + "activation-link-text": ":
    استفاده کنيد جهت فعال سازي کاربر، از پيوند فعال سازي زير", + "copy-activation-link": "رونوشت پيوند فعال سازي", + "activation-link-copied-message": "پيوند فعال سازي کاربر در حافظه موقت رونوشت شد", + "details": "جزئيات", + "login-as-tenant-admin": "ورود به عنوان کاربر مدير", + "login-as-customer-user": "ورود به عنوان کاربر مشتري" + }, + "value": { + "type": "نوع مقدار", + "string": "رشته", + "string-value": "مقدار رشته", + "integer": "عدد صحيح", + "integer-value": "مقدار عدد صحيح", + "invalid-integer-value": "عدد صحيح نامعتبر", + "double": "دو برابر", + "double-value": "مقدار دو برابر", + "boolean": "بولين", + "boolean-value": "مقدار بولين", + "false": "نادرست", + "true": "صحيح", + "long": "بلند" + }, + "widget": { + "widget-library": "کتابخانه ويجت ها", + "widget-bundle": "بسته ويجت", + "select-widgets-bundle": "انتخاب بسته ويجت", + "management": "مديريت ويجت", + "editor": "ويرايشگر ويجت", + "widget-type-not-found": ".نوع ويجت حذف شد \n احتمالا مرتبط است
    .مشکل بارگذاري پيکربندي ويجت", + "widget-type-load-error": ":ويجت، به علت خطاهاي زير، بارگذاري نشد", + "remove": "حذف ويجت", + "edit": "ويرايش ويجت", + "remove-widget-title": "مطمئنيد؟ '{{widgetTitle}}' از حذف ويجت", + "remove-widget-text": ".پس از تأييد، ويجت و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "timeseries": "سري هاي زماني", + "search-data": "جستجوي داده", + "no-data-found": "هيچ داده اي يافت نشد", + "latest": "آخرين مقادير", + "rpc": "ويجت کنترل", + "alarm": "ويجت هشدار", + "static": "ويجت ايستا", + "select-widget-type": "انتخاب نوع ويجت", + "missing-widget-title-error": "!عنوان ويجت بايد مشخص شود", + "widget-saved": "ويجت ذخيره شد", + "unable-to-save-widget-error": "!ذخيره سازي ويجت ممکن نيست! ويجت خطاهايي دارد", + "save": "ذخيره سازي ويجت", + "saveAs": "ذخيره سازي ويجت به عنوان", + "save-widget-type-as": "ذخيره سازي نوع ويجت به عنوان", + "save-widget-type-as-text": "لطفا عنوان ويجت جديد را وارد کنيد و/يا بسته ويجت هدف را انتخاب نماييد", + "toggle-fullscreen": "حالت تمام صفحه را تغيير دهيد", + "run": "اجراي ويجت", + "title": "عنوان ويجت", + "title-required": ".عنوان ويجت مورد نياز است", + "type": "نوع ويجت", + "resources": "منابع", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "حذف منبع", + "add-resource": "افزودن منبع", + "html": "HTML", + "tidy": "مرتب", + "css": "CSS", + "settings-schema": "طرح تنظيمات", + "datakey-settings-schema": "طرح تنظيمات کليد داده", + "javascript": "Javascript", + "remove-widget-type-title": "مطمئنيد؟ '{{widgetName}}' از حذف ويجت نوع", + "remove-widget-type-text": ".پس از تأييد، نوع ويجت و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "remove-widget-type": "حذف نوع ويجت", + "add-widget-type": "افزودن نوع ويجت جديد", + "widget-type-load-failed-error": "!عدم موفقيت در بارگذاري نوع ويجت", + "widget-template-load-failed-error": "!عدم موفقيت در بارگذاري قالب ويجت", + "add": "افزودن ويجت", + "undo": "برگرداندن تغييرات ويجت", + "export": "صدور ويجت" + }, + "widget-action": { + "header-button": "دکمه هدر ويجت", + "open-dashboard-state": "هدايت به وضعيت داشبورد جديد", + "update-dashboard-state": "به روز رساني وضعيت داشبورد فعلي", + "open-dashboard": "هدايت به داشبورد ديگر", + "custom": "اقدام متداول", + "target-dashboard-state": "وضعيت داشبورد هدف", + "target-dashboard-state-required": "وضعيت داشبورد هدف مورد نياز است", + "set-entity-from-widget": "تنظيم موجودي از ويجت", + "target-dashboard": "داشبورد هدف", + "open-right-layout": "(طرح داشبورد سمت راست را باز کنيد (نماي تلفن همراه" + }, + "widgets-bundle": { + "current": "بسته فعلي", + "widgets-bundles": "بسته هاي ويجت", + "add": "افزودن بسته ويجت", + "delete": "حذف بسته ويجت", + "title": "عنوان", + "title-required": ".عنوان مورد نياز است", + "add-widgets-bundle-text": "افزودن بسته ويجت جديد", + "no-widgets-bundles-text": "هيچ بسته ويجتي يافت نشد", + "empty": "بسته ويجت خالي است", + "details": "جزئيات", + "widgets-bundle-details": "جزئيات بسته ويجت", + "delete-widgets-bundle-title": "مطمئنيد؟ '{{widgetsBundleTitle}}' از حذف بسته ويجت", + "delete-widgets-bundle-text": ".مراقب باشيد، پس از تأييد، بسته ويجت و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "delete-widgets-bundles-title": "مطمئنيد؟ { count, plural, 1 {1 بسته ويجت} other {# بسته ويجت} } از حذف", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 بسته ويجت} other {# بسته ويجت} } حذف", + "delete-widgets-bundles-text": ".مراقب باشيد، پس از تأييد، تمام بسته هاي ويجتِ انتخاب شده حذف، و تمامي داده هاي مربوطه غير قابل بازيابي مي شوند", + "no-widgets-bundles-matching": "يافت نشد '{{widgetsBundle}}' هيچ بسته ويجتي منطبق بر", + "widgets-bundle-required": ".بسته ويجت مورد نياز است", + "system": "سيستم", + "import": "وارد کردن بسته ويجت", + "export": "صدور بسته ويجت", + "export-failed-error": "{{error}} :صدور بسته ويجت ممکن نيست", + "create-new-widgets-bundle": "ايجاد بسته ويجت جديد", + "widgets-bundle-file": "پرونده بسته ويجت", + "invalid-widgets-bundle-file-error": ".وارد کردن بسته ويجت ممکن نيست: ساختار داده بسته ويجت نامعتبر است" + }, + "widget-config": { + "data": "داده", + "settings": "تنظيمات", + "advanced": "پيشرفته", + "title": "عنوان", + "general-settings": "تنظيمات عمومي", + "display-title": "نمايش عنوان", + "drop-shadow": "سايه افتادن", + "enable-fullscreen": "فعال سازي حالت تمام صفحه", + "background-color": "رنگ پس زمينه", + "text-color": "رنگ متن", + "padding": "حاشيه داخلي", + "margin": "حاشيه", + "widget-style": "سبک ويجت", + "title-style": "سبک عنوان", + "mobile-mode-settings": "تنظيمات حالت تلفن همراه", + "order": "ترتيب", + "height": "ارتفاع", + "units": "کارکتر خاص براي نمايش بعد از مقدار تعين شده", + "decimals": "تعداد ارقام بعد از مميّز شناور", + "timewindow": "پنجره زمان", + "use-dashboard-timewindow": "استفاده از پنجره زمان داشبورد", + "display-legend": "نمايش فهرست علائم", + "datasources": "منابع داده", + "maximum-datasources": "{ count, plural, 1 {.1 منبع داده مجاز است} other {# منبع داده مجازند.} } بيشترين", + "datasource-type": "نوع", + "datasource-parameters": "پارامترها", + "remove-datasource": "حذف منبع داده", + "add-datasource": "افزودن منبع داده", + "target-device": "دستگاه هدف", + "alarm-source": "منشأ هشدار", + "actions": "اقدامات", + "action": "اقدام", + "add-action": "افزودن اقدام", + "search-actions": "جستجوي اقدامات", + "action-source": "منشأ اقدام", + "action-source-required": ".منشأ اقدام مورد نياز است", + "action-name": "نام", + "action-name-required": ".نام اقدام مورد نياز است", + "action-name-not-unique": ".در حيطه يک منشأ اقدام، نام اقدام بايد منحصر بفرد باشد
    .در حال حاضر اقدامي ديگر با نام مشابه موجود است", + "action-icon": "شمايل", + "action-type": "نوع", + "action-type-required": ".نوع اقدام مورد نياز است", + "edit-action": "ويرايش اقدام", + "delete-action": "حذف اقدام", + "delete-action-title": "حذف اقدام ويجت", + "delete-action-text": "مطمئنيد؟ '{{actionName}}' از حذف اقدام ويجت با نام" + }, + "widget-type": { + "import": "وارد کردن نوع ويجت", + "export": "صدور نوع ويجت", + "export-failed-error": "{{error}} :صدور نوع ويجت ممکن نيست", + "create-new-widget-type": "ايجاد نوع جديد ويجت", + "widget-type-file": "پرونده نوع ويجت", + "invalid-widget-type-file-error": ".وارد کردن نوع ويجت ممکن نيست: ساختار داده نوع ويجت نامعتبر است" + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "یکشنبه", + "Mon": "دوشنبه", + "Tue": "سه‌شنبه", + "Wed": "چهارشنبه", + "Thu": "پنجشنبه", + "Fri": "جمعه", + "Sat": "شنبه", + "Jan": "ژانویهٔ", + "Feb": "فوریهٔ", + "Mar": "مارس", + "Apr": "آوریل", + "May": "مهٔ", + "Jun": "ژوئن", + "Jul": "ژوئیهٔ", + "Aug": "اوت", + "Sep": "سپتامبر", + "Oct": "اکتبر", + "Nov": "نوامبر", + "Dec": "دسامبر", + "January": "January", + "February": "February", + "March": "March", + "April": "April", + "June": "June", + "July": "July", + "August": "August", + "September": "September", + "October": "October", + "November": "November", + "December": "December", + "Custom Date Range": "Custom Date Range", + "Date Range Template": "Date Range Template", + "Today": "Today", + "Yesterday": "Yesterday", + "This Week": "This Week", + "Last Week": "Last Week", + "This Month": "This Month", + "Last Month": "Last Month", + "Year": "Year", + "This Year": "This Year", + "Last Year": "Last Year", + "Date picker": "Date picker", + "Hour": "Hour", + "Day": "Day", + "Week": "Week", + "2 weeks": "2 weeks", + "Month": "Month", + "3 months": "3 months", + "6 months": "6 months", + "Custom interval": "Custom interval", + "Interval": "Interval", + "Step size": "Step size", + "Ok": "Ok" + } + } + }, + "icon": { + "icon": "آيکون", + "select-icon": "انتخاب آيکون", + "material-icons": "آيکونهاي اجسام", + "show-all": "نمايش تمام آيکونها" + }, + "custom": { + "widget-action": { + "action-cell-button": "دکمه سلول عملياتي", + "row-click": "در رديف کليک کنيد", + "marker-click": "روي نشانگر کليک کنيد", + "tooltip-tag-action": "اقدام برچسب راهنماي ابزار" + } + }, + "language": { + "language": "زبان" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json new file mode 100644 index 0000000..ce66154 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -0,0 +1,2332 @@ +{ + "access": { + "access-forbidden": "Accès interdit", + "access-forbidden-text": "Vous n'avez pas accès à cet emplacement!
    Essayez de vous connecter avec un autre utilisateur si vous souhaitez toujours accéder à cet emplacement.", + "refresh-token-expired": "La session a expiré", + "refresh-token-failed": "Impossible de rafraîchir la session", + "unauthorized": "Non autorisé", + "unauthorized-access": "Accès non autorisé", + "unauthorized-access-text": "Vous devez vous connecter pour avoir accès à cette ressource!", + "permission-denied": "Permission refusée", + "permission-denied-text": "Vous n'avez pas la permission de faire l'opération demandée!" + }, + "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", + "continue": "Continuer", + "copy": "Copier", + "copy-reference": "Copier la référence", + "create": "Créer", + "decline-changes": "Refuser les modifications", + "delete": "Supprimer", + "discard-changes": "Annuler les modifications", + "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", + "select": "Sélectionner", + "download": "Télécharger", + "next-with-label": "Suivant: {{label}}", + "read-more": "En savoir plus", + "hide": "Masquer" + }, + "admin": { + "base-url": "URL de base", + "base-url-required": "L'URL de base est requis.", + "enable-tls": "Activer TLS", + "tls-version": "Version TLS", + "general": "Général", + "general-settings": "Paramètres généraux", + "mail-from": "Courriel de", + "mail-from-required": "Courriel de est requis.", + "outgoing-mail": "courrier sortant", + "outgoing-mail-settings": "Paramètres de courrier sortant", + "send-test-mail": "Envoyer un courriel 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.", + "security-settings": "Les paramètres de sécurité", + "password-policy": "Politique de mot de passe", + "minimum-password-length": "Longueur minimale du mot de passe", + "minimum-password-length-required": "La longueur minimale du mot de passe est requise", + "minimum-password-length-range": "La longueur minimale du mot de passe doit être comprise entre 5 et 50.", + "minimum-uppercase-letters": "Nombre minimum de lettres majuscules", + "minimum-uppercase-letters-range": "Le nombre minimum de lettres majuscules ne peut pas être négatif", + "minimum-lowercase-letters": "Nombre minimum de lettres minuscules", + "minimum-lowercase-letters-range": "Le nombre minimum de lettres minuscules ne peut pas être négatif", + "minimum-digits": "Nombre minimum de chiffres", + "minimum-digits-range": "Le nombre minimum de chiffres ne peut pas être négatif", + "minimum-special-characters": "Nombre minimum de caractères spéciaux", + "minimum-special-characters-range": "Le nombre minimum de caractères spéciaux ne peut pas être négatif", + "password-expiration-period-days": "Délai d'expiration du mot de passe en jours", + "password-expiration-period-days-range": "La période d'expiration du mot de passe en jours ne peut pas être négative", + "password-reuse-frequency-days": "Fréquence de réutilisation du mot de passe en jours", + "password-reuse-frequency-days-range": "La fréquence de réutilisation du mot de passe en jours ne peut être négative", + "general-policy": "Politique générale", + "max-failed-login-attempts": "Nombre maximal de tentatives de connexion infructueuses avant que le compte ne soit verrouillé", + "minimum-max-failed-login-attempts-range": "Le nombre maximal de tentatives de connexion ayant échoué ne peut pas être négatif", + "user-lockout-notification-email": "En cas de verrouillage du compte d'utilisateur, envoyez une notification par courrier électronique.", + "prohibit-different-url": "Interdire d'utiliser le nom d'hôte à partir des en-têtes de requête client", + "prohibit-different-url-hint": "Ce paramètre doit être activé pour les environnements de production. Peut causer des problèmes de sécurité lorsqu'il est désactivé.", + "enable-proxy": "Activer proxy", + "proxy-host": "Hôte proxy", + "proxy-host-required": "L'hôte proxy est requis.", + "proxy-port": "Port du proxy", + "proxy-port-required": "Port du proxy est requis.", + "proxy-port-range": "Le port proxy doit être compris entre 1 et 65535.", + "proxy-user": "Utilisateur proxy", + "proxy-password": "Mot de passe proxy", + "change-password": "Changer mot de passe", + "sms-provider": "Fournisseur SMS", + "sms-provider-settings": "Paramètres du fournisseur de SMS", + "sms-provider-type": "Type de fournisseur de SMS", + "sms-provider-type-required": "Le type de fournisseur de SMS est requis.", + "aws-access-key-id": "ID de clé d'accès AWS", + "aws-access-key-id-required": "L'ID de clé d'accès AWS est requis", + "aws-secret-access-key": "Clé d'accès secrète AWS", + "aws-secret-access-key-required": "La clé d'accès secrète AWS est requise", + "aws-region": "Région AWS", + "aws-region-required": "La région AWS est obligatoire", + "number-from": "Numéro de téléphone de", + "number-from-required": "Numéro de téléphone de est requis", + "number-to": "Numéro de téléphone à", + "number-to-required": "Numéro de téléphone à est requis.", + "phone-number-hint": "Numéro de téléphone au format E.164, ex. +19995550123", + "phone-number-hint-twilio": "Numéro de téléphone au format E.164/SID du numéro de téléphone/SID du service de messagerie, ex. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Numéro de téléphone invalide. Doit être au format E.164, ex. +19995550123.", + "phone-number-pattern-twilio": "Numéro de téléphone invalide. Doit être au format E.164/SID du numéro de téléphone/SID du service de messagerie, ex. +19995550123/PNXXX/MGXXX.", + "sms-message": "Message SMS", + "sms-message-required": "Message SMS requis.", + "sms-message-max-length": "Le message SMS ne peut pas contenir plus de 1600 caractères", + "twilio-account-sid": "SID du compte Twilio", + "twilio-account-sid-required": "SID du compte Twilio requis", + "twilio-account-token": "Jeton du compte Twilio", + "twilio-account-token-required": "Jeton du compte Twilio est requis", + "send-test-sms": "Envoyer SMS test", + "test-sms-sent": "Le SMS de test a été envoyé avec succès !", + "allow-whitespace": "Autoriser les espaces", + "domain-name": "Nom de domaine", + "domain-name-unique": "Le nom de domaine et le protocole doivent être uniques.", + "domain-name-max-length": "Le nom de domaine doit être inférieur à 256", + "error-verification-url": "Un nom de domaine ne doit pas contenir les symboles '/' et ':'. Exemple : Thingsboard.io", + "oauth2": { + "access-token-uri": "URI du jeton d'accès", + "access-token-uri-required": "URI du jeton d'accès requis.", + "activate-user": "Activer l'utilisateur", + "add-domain": "Ajouter un domaine", + "delete-domain": "Supprimer un domaine", + "add-provider": "Ajouter un fournisseur", + "delete-provider": "Supprimer un fournisseur", + "allow-user-creation": "Autoriser la création d'utilisateurs", + "always-fullscreen": "Toujours plein écran", + "authorization-uri": "URI d'autorisation", + "authorization-uri-required": "L'URI d'autorisation est obligatoire.", + "client-authentication-method": "Méthode d'authentification client", + "client-id": "Identifiant du client", + "client-id-required": "L'identifiant client est requis.", + "client-id-max-length": "L'ID client doit être inférieur à 256", + "client-secret": "Secret client", + "client-secret-required": "Secret client requis.", + "client-secret-max-length": "Le secret client doit être inférieur à 2049", + "custom-setting": "Paramètres personnalisés", + "customer-name-pattern": "Modèle de nom de client", + "customer-name-pattern-max-length": "Le modèle de nom de client doit être inférieur à 256", + "default-dashboard-name": "Nom du tableau de bord par défaut", + "default-dashboard-name-max-length": "Le nom du tableau de bord par défaut doit être inférieur à 256", + "delete-domain-text": "Attention, après la confirmation un domaine et toutes les données du fournisseur seront indisponibles.", + "delete-domain-title": "Voulez-vous vraiment supprimer les paramètres du domaine '{{domainName}}'?", + "delete-registration-text": "Attention, après la confirmation, les données d'un fournisseur seront indisponibles.", + "delete-registration-title": "Êtes-vous sûr de vouloir supprimer le fournisseur'{{name}}'?", + "email-attribute-key": "Clé d'attribut de courriel", + "email-attribute-key-required": "La clé d'attribut de courriel est requise.", + "email-attribute-key-max-length": "La clé d'attribut de courriel doit être inférieure à 32", + "first-name-attribute-key": "Clé d'attribut du prénom", + "general": "Général", + "jwk-set-uri": "URI de la clé Web JSON", + "last-name-attribute-key": "Clé d'attribut du nom de famille", + "login-button-icon": "Icône du bouton de connexion", + "login-button-label": "Libellé du fournisseur", + "login-button-label-placeholder": "Connectez-vous avec $(Provider label)", + "login-button-label-required": "L'étiquette est obligatoire.", + "login-provider": "Fournisseur de connexion", + "new-domain": "Nouveau domaine", + "password-max-length": "Le mot de passe doit être inférieur à 256", + "redirect-uri-template": "Modèle d'URI de redirection", + "copy-redirect-uri": "Copier l'URI de redirection", + "registration-id": "ID d'enregistrement", + "registration-id-required": "L'identifiant d'enregistrement est requis.", + "registration-id-unique": "L'ID d'enregistrement doit être unique pour le système.", + "scope": "Portée", + "scope-required": "La portée est requise.", + "tenant-name-pattern": "Modèle de nom du Tenant ", + "tenant-name-pattern-required": "Un modèle de nom de Tenant est requis.", + "tenant-name-pattern-max-length": "Le modèle de nom de Tenant doit être inférieur à 256", + "tenant-name-strategy": "Stratégie de nom de Tenant", + "type": "Type de Mapper", + "uri-pattern-error": "Format d'URI invalide.", + "url-pattern": "Format d'URL non valide.", + "url-required": "L'URL est requis.", + "url-max-length": "L'URL doit être inférieure à 256", + "user-info-uri": "URI des informations utilisateur", + "user-info-uri-required": "L'URI des informations utilisateur est requise.", + "username-max-length": "Le nom d'utilisateur doit être inférieur à 256", + "user-name-attribute-name": "Clé d'attribut de nom d'utilisateur", + "user-name-attribute-name-required": "La clé d'attribut du nom d'utilisateur est requise", + "protocol": "Protocole", + "enable": "Activer les paramètres OAuth2", + "domains": "Domainse", + "mobile-apps": "Applications mobiles", + "no-mobile-apps": "Aucune application configurée", + "mobile-package": "Package d'application", + "mobile-package-placeholder": "Ex. : mon.exemple.app", + "mobile-package-hint": "Pour Android : votre propre ID d'application unique. Pour iOS : identifiant du groupe de produits.", + "mobile-package-unique": "Le package d'application doit être unique.", + "mobile-app-secret": "Secret d'application", + "invalid-mobile-app-secret": "Le secret d'application ne doit contenir que des caractères alphanumériques et doit comporter entre 16 et 2 048 caractères.", + "copy-mobile-app-secret": "Copier le secret de l'application", + "add-mobile-app": "Ajouter une application", + "delete-mobile-app": "Supprimer les informations sur l'application", + "providers": "Fournisseurs", + "all-platforms": "Toutes les plateformes", + "allowed-platforms": "Plates-formes autorisées" + } + }, + "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-alarm-text": "Êtes-vous sûr de vouloir reconnaître l'alarme?", + "aknowledge-alarm-title": "Reconnaître l'alarme", + "aknowledge-alarms-text": "Êtes-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": "État d'alarme", + "alarm-status-filter": "Filtre d'état d'alarme", + "alarms": "Alarmes", + "clear": "Effacer", + "clear-alarm-text": "Êtes-vous sûr de vouloir effacer l'alarme?", + "clear-alarm-title": "Effacer l'alarme", + "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", + "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é", + "severity-critical": "Critique", + "severity-indeterminate": "indéterminée", + "severity-major": "Majeure", + "severity-minor": "mineure", + "severity-warning": "Avertissement", + "start-time": "Heure de début", + "status": "État", + "type": "Type", + "alarm-status-list": "Liste d'état des alarmes", + "any-status": "Tout statut", + "alarm-severity-list": "Liste de gravité des alarmes", + "any-severity": "Toute gravité", + "alarm-filter": "Filtre d'alarme", + "max-count-load": "Nombre maximum d'alarmes à charger (0 - illimité)", + "max-count-load-required": "Le nombre maximum d'alarmes à charger est requis.", + "max-count-load-error-min": "La valeur minimale est 0.", + "fetch-size": "Taille de la requête", + "fetch-size-required": "La taille de la requête est requise.", + "fetch-size-error-min": "La valeur minimale est 10.", + "alarm-type-list": "Liste des types d'alarme", + "any-type": "N'importe quel type", + "search-propagated-alarms": "Rechercher les alarmes propagées" + }, + "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'actifs", + "filter-type-asset-search-query-description": "Actifs de types {{assetTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-asset-type": "type d'actif", + "filter-type-asset-type-and-name-description": "Actifs de type '{{assetType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-asset-type-description": "Actifs 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-entity-view-search-query": "Requête de recherche vue d'entité", + "filter-type-entity-view-search-query-description": "Vues d'entité avec les types {{entityViewTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Requête de recherche de Edge", + "filter-type-edge-search-query-description": "Edges avec types {{edgeTypes}} qui ont {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-entity-view-type": "Type de vue d'entité", + "filter-type-entity-view-type-and-name-description": "Vues d'entité de type '{{entityView}}' et dont le nom commence par '{{prefix}}'", + "filter-type-entity-view-type-description": "Vues d'entité de type '{{entityView}}'", + "filter-type-edge-type": "Types de Edge", + "filter-type-edge-type-description": "Edges de type '{{edgeType}}'", + "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é", + "filter-type-entity-type": "Type d'entité", + "filter-type-edge-type-and-name-description": "Edges de type '{{edgeType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-apiUsageState": "État d'utilisation de l'API", + "last-level-relation": "Récupérer uniquement la relation de dernier niveau" + }, + "asset": { + "add": "Ajouter un actif", + "add-asset-text": "Ajouter un nouvel actif", + "any-asset": "Tout actif", + "asset": "Actif", + "asset-details": "Détails de l'actif", + "asset-file": "Fichier d'actif", + "asset-public": "L'actif est public", + "asset-required": "Actif requis", + "asset-type": "Type d'actif", + "asset-type-list-empty": "Aucun type d'actif sélectionné.", + "asset-type-required": "Le type d'actif est requis.", + "asset-types": "Types d'actif", + "assets": "Actifs", + "assign-asset-to-customer": "Attribuer des actifs au client", + "assign-asset-to-customer-text": "Veuillez sélectionner les actifs à attribuer au client", + "assign-assets": "Attribuer des actifs", + "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 actifs", + "assignedToCustomer": "attribué au client", + "copyId": "Copier l'Id de l'actif", + "delete": "Supprimer un actif", + "delete-asset-text": "Faites attention, après la confirmation, l'actif et toutes les données associées deviendront irrécupérables.", + "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'actif '{{assetName}}'?", + "delete-assets": "Supprimer des actifs", + "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "Attention, après la confirmation, tous les actifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-assets-title": "Êtes-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'actif", + "events": "Evénements", + "idCopiedMessage": "L'Id d'actif a été copié dans le presse-papier", + "import": "Importer des actifs", + "make-private": "Rendre l'actif privé", + "make-private-asset-text": "Après la confirmation, l'actif et toutes ses données seront rendus privés et ne seront pas accessibles par d'autres.", + "make-private-asset-title": "Êtes-vous sûr de vouloir rendre l'actif '{{assetName}}' privé '?", + "make-public": "Rendre l'actif 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'actif '{{assetName}}' public '?", + "management": "Gestion d'actifs", + "name": "Nom", + "name-required": "Nom est requis.", + "name-starts-with": "Le nom de l'actif commence par", + "no-asset-types-matching": "Aucun type d'actif correspondant à {{entitySubtype}} n'a été trouvé. ", + "no-assets-matching": "Aucun actif correspondant à {{entity}} n'a été trouvé. ", + "no-assets-text": "Aucun actif trouvé", + "public": "Public", + "select-asset": "Sélectionner un actif", + "select-asset-type": "Sélectionner le type d'actif", + "type": "Type", + "type-required": "Le type est requis.", + "unassign-asset": "Retirer l'actif", + "unassign-asset-text": "Après la confirmation, l'actif sera non attribué et ne sera pas accessible au client.", + "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'actif '{{assetName}}'?", + "unassign-assets": "Retirer les actifs", + "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets} } du client", + "unassign-assets-text": "Après la confirmation, tous les actifs 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 actifs", + "label": "Étiquette (label)", + "assign-asset-to-edge": "Attribuer des actifs à Edge", + "assign-asset-to-edge-text": "Veuillez sélectionner les actifs à attribuer a la bordure", + "unassign-asset-from-edge": "Désattribuer l'actif", + "unassign-asset-from-edge-title": "Voulez-vous vraiment annuler l'attribution de l'actif '{{assetName}}'?", + "unassign-asset-from-edge-text": "Après la confirmation, l'actif sera désaffecté et ne sera pas accessible par le edge.", + "unassign-assets-from-edge-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets} } de la bordure", + "unassign-assets-from-edge-title": "Êtes-vous sûr de vouloir désattribuer { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-from-edge-text": "Après la confirmation, tous les actifs sélectionnés seront désaffectés et ne seront pas accessibles par le edge.", + "asset-type-max-length": "Le type d'actif doit être inférieur à 256", + "name-max-length": "Le nom doit être inférieur à 256", + "label-max-length": "L'étiquette doit être inférieure à 256", + "help-text": "Use '%' selon besoin : '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "search": "Rechercher des actifs", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } sélectionnés" + }, + "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": "Étendue 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", + "key-max-length": "La clé doit être inférieure à 256", + "no-attributes-text": "Aucun attribut trouvé", + "no-telemetry-text": "Aucune télémétrie trouvée" + }, + "api-usage": { + "api-usage": "Usage de l'Api", + "alarm": "Alarme", + "alarms-created": "Alarmes créées", + "alarms-created-daily-activity": "Activité hebdomadaire d'alarmes créées", + "alarms-created-hourly-activity": "Activité horaire d'alarmes créées", + "alarms-created-monthly-activity": "Activités mensuelle d'alarmes créées", + "data-points": "Données", + "data-points-storage-days": "Jours de storage des données", + "email": "Courriel", + "email-messages": "Messages courriel", + "email-messages-daily-activity": "Activité hebdomadaire de courriels", + "email-messages-monthly-activity": "Activité menuselle de courriels", + "executions": "Exécutions", + "javascript-executions": "Exécutions JavaScript", + "javascript-functions": "Fonctions JavaScript", + "javascript-functions-daily-activity": "Activité hebdomadaire de fonctions JavaScript", + "javascript-functions-hourly-activity": "Activité horaire de fonctions JavaScript", + "javascript-functions-monthly-activity": "Activité mensuelle de fonctions JavaScript", + "latest-error": "Dernière erreur", + "notifications-email-sms": "Notifications (Coourriel/SMS)", + "notifications-hourly-activity": "Activité horaire de notifications", + "permanent-failures": "${entityName} Échecs permanents", + "permanent-timeouts": "${entityName} Temps d'arrêt permanents", + "processing-failures": "${entityName} Erreurs d'exécution", + "processing-failures-and-timeouts": "Erreurs d'exécution et temps d'arrêt", + "processing-timeouts": "${entityName} Temps d'arrêt d'exécution", + "queue-stats": "Stats de queue", + "rule-chain": "Chaîne de règles", + "rule-engine": "Engin de règles", + "rule-engine-daily-activity": "Activité hebdomadaire de l'engin de règles", + "rule-engine-executions": "Exécutions de l'engin de règles", + "rule-engine-hourly-activity": "Activité horaire de l'engin de règles", + "rule-engine-monthly-activity": "Activité mensuelle de l'engin de règles", + "rule-engine-statistics": "Statistiques de l'engin de règles", + "rule-node": "Node de règle", + "sms-messages": "Messages texte", + "sms-messages-daily-activity": "Activité hebdomadaire de messages texte", + "sms-messages-monthly-activity": "Activité mensuelle de messages texte", + "successful": "${entityName} réussi", + "telemetry": "Télémétrie", + "telemetry-persistence": "Persistance de télémétrie", + "telemetry-persistence-daily-activity": "Activité hebdomadaire de persistance de télémétrie", + "telemetry-persistence-hourly-activity": "Activité horaire de persistance de télémétrie", + "telemetry-persistence-monthly-activity": "Activité mensuelle de persistance de télémétrie", + "transport-daily-activity": "Activité hebdomadaire de transport", + "transport-data-points": "Données de transport", + "transport-hourly-activity": "Activité horaire de transport", + "transport-messages": "Messages de transport", + "transport-monthly-activity": "Activité mensuelle de transport", + "view-details": "Voir détails", + "view-statistics": "Voir statistiques" + }, + "audit-log": { + "action-data": "Donnée d'action", + "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": "État", + "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-assigned-to-edge": "Assigné au Edge", + "type-unassigned-from-edge": "Attribution retirée du Edge", + "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-login": "Connexion", + "type-logout": "Déconnexion", + "type-lockout": "Verrouillage", + "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": "Attribution retirée du client", + "type-updated": "Mise à jour", + "user": "Utilisateur", + "type-assigned-from-tenant": "Assigné par le Tenant", + "type-assigned-to-tenant": "Assigné au Tenant", + "type-provision-success": "Dispositif mis en service", + "type-provision-failure": "La mise en service du dispositif a échoué", + "type-timeseries-updated": "Telemetrie mise à jour", + "type-timeseries-deleted": "Telemetrie supprimée" + }, + "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", + "created-time": "Heure de création", + "loading": "Chargement en cours...", + "proceed": "Procéder", + "open-details-page": "Ouvrir la page détails" + }, + "confirm-on-exit": { + "html-message": "Vous avez des modifications non enregistrées.
    Êtes-vous sûr de vouloir quitter cette page?", + "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?", + "title": "Modifications non enregistrées" + }, + "contact": { + "address": "Adresse", + "address2": "Adresse 2", + "city": "Ville", + "country": "Pays", + "email": "Courriel", + "no-address": "Pas d'adresse", + "phone": "Téléphone", + "postal-code": "Code postal", + "postal-code-invalid": "Format de code postal / code postal invalide", + "state": "Province", + "state-max-length": "La longueur de l'état doit être moins que 256", + "phone-max-length": "La longueur du téléphone doit être moins que 256", + "city-max-length": "La ville spécifiée doit être moins que 256" + }, + "content-type": { + "binary": "Binaire (Base64)", + "json": "Json", + "text": "Texte" + }, + "custom": { + "widget-action": { + "action-cell-button": "Bouton de cellule d'action", + "marker-click": "Sur le marqueur cliquez", + "row-click": "Au rang, cliquez", + "polygon-click": "Cliquez sur le polygone", + "tooltip-tag-action": "Action de balise d'info-bulle", + "node-selected": "Sur le noeud sélectionné", + "element-click": "Sur l'élément HTML, cliquez sur", + "pie-slice-click": "Sur tranche cliquez", + "row-double-click": "Sur la ligne double clic" + } + }, + "customer": { + "add": "Ajouter un client", + "add-customer-text": "Ajouter un nouveau client", + "assets": "Actifs 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 customer} other {# customers} }", + "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 customer} other {# customers} }?", + "description": "Description", + "details": "Détails", + "devices": "Dispositifs du client", + "edges": "Instances Edge du client", + "entity-views": "Vues de l'entité client", + "events": "Événements", + "idCopiedMessage": "L'Id du client a été copié dans le presse-papier", + "manage-assets": "Gérer les actifs", + "manage-customer-assets": "Gérer les actifs du client", + "manage-customer-edges": "Gérer les bordures 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 actifs publics", + "manage-public-dashboards": "Gérer les tableaux de bord publics", + "manage-public-devices": "Gérer les dispositifs publics", + "manage-public-edges": "Gérer les bordures 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": "Actifs publics", + "public-dashboards": "Tableaux de bord publics", + "public-devices": "Dispositifs publics", + "public-entity-views": "Vues d'entités publiques", + "public-edges": "Bordures 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.", + "title-max-length": "La longueur du titre doit être moins que 256", + "search": "Rechercher clients", + "selected-customers": "{ count, plural, 1 {1 customer} other {# customers} } sélectionnés", + "manage-edges": "Gérer les edges" + }, + "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 à attribuer 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 attribué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 la fenêtre de temps", + "display-dashboards-selection": "Afficher la sélection des tableaux de bord", + "display-entities-selection": "Afficher la sélection des entités", + "display-title": "Afficher le titre du tableau de bord", + "drop-image": "Déposer une image ou cliquez pour sélectionner un fichier à télécharger.", + "edit-state": "Modifier l'état du tableau de bord", + "export": "Exporter le tableau de bord", + "export-failed-error": "Impossible d'exporter le tableau de bord: {{error}}", + "hide-details": "Masquer les détails", + "horizontal-margin": "Marge horizontale", + "horizontal-margin-required": "Une valeur de marge horizontale est requise.", + "import": "Importer le tableau de bord", + "import-widget": "Importer un widget", + "invalid-aliases-config": "Impossible de trouver des dispositifs correspondant à certains filtres d'alias.
    Veuillez contacter votre administrateur pour résoudre ce problème.", + "invalid-dashboard-file-error": "Impossible d'importer le tableau de bord: structure de données du tableau de bord non valide", + "invalid-widget-file-error": "Impossible d'importer le widget: structure de données de widget invalide.", + "is-root-state": "État 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": "Êtes-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 attribué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": "50 est la valeur de marge horizontale maximale.", + "max-mobile-row-height-message": "200 pixels est la valeur maximale de hauteur de ligne mobile.", + "max-vertical-margin-message": "50 est la valeur de marge verticale maximale.", + "min-columns-count-message": "Seul un nombre minimum de 10 colonnes est autorisé.", + "min-horizontal-margin-message": "0 est la valeur de marge horizontale minimale.", + "min-mobile-row-height-message": "5 pixels est la valeur minimale de hauteur de ligne mobile.", + "min-vertical-margin-message": "0 est la valeur de marge verticale minimale.", + "mobile-layout": "Paramètres de mise en page mobiles", + "mobile-row-height": "Hauteur de ligne mobile, px", + "mobile-row-height-required": "Une valeur de hauteur de ligne mobile est requise.", + "new-dashboard-title": "Nouveau titre du tableau de bord", + "no-dashboards-matching": "Aucun tableau de bord correspondant à {{entity}} n'a été trouvé. ", + "no-dashboards-text": "Aucun tableau de bord trouvé", + "no-image": "Aucune image sélectionnée", + "no-widgets": "Aucun widget configuré", + "open-dashboard": "Ouvrir le tableau de bord", + "open-toolbar": "Ouvrir la barre d'outils du tableau de bord", + "public": "Public", + "public-dashboard-notice": " Remarque: N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.", + "public-dashboard-text": "Votre tableau de bord {{dashboardTitle}} est maintenant public et accessible via le lien public
    : ", + "public-dashboard-title": "Le tableau de bord est maintenant public", + "public-link": "Lien public", + "public-link-copied-message": "Le lien public du tableau de bord a été copié dans le presse-papier", + "search-states": "Recherche des états du tableau de bord", + "select-dashboard": "Sélectionner le tableau de bord", + "select-devices": "Selectionner les dispositifs", + "select-existing": "Sélectionnez un tableau de bord existant", + "select-state": "Sélectionnez l'état cible", + "select-widget-subtitle": "Liste des types de widgets disponibles", + "select-widget-title": "Sélectionner un widget", + "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord} } sélectionnés", + "set-background": "Définir l'arrière-plan", + "settings": "Paramètres", + "show-details": "Afficher les détails", + "socialshare-text": "'{{dashboardTitle}}' propulsé par ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' propulsé par ThingsBoard", + "state": "État 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": "États 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'attribution 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", + "unassign-dashboard-from-edge-text": "Après la confirmation, tableau de bord sera non attribué et ne sera pas accessible a la bordure.", + "unassign-dashboards-from-edge-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles a la bordure.", + "assign-dashboard-to-edge": "Attribuer des tableaux de bord a la bordure", + "assign-dashboard-to-edge-text": "Veuillez sélectionner la bordure pour attribuer le ou les tableaux de bord", + "image": "Image du tableau de bord", + "mobile-app-settings": "paramètres de l'application mobile", + "mobile-order": "Ordre du tableau de bord dans l'application mobile", + "mobile-hide": "Cacher le tableau de bord dans l'application mobile", + "update-image": "Mettre à jour l'image du tableau de bord", + "take-screenshot": "Prendre une capture d'écran", + "select-widget-value": "{{title}}: sélectionner widget", + "title-max-length": "La longueur du titre doit être mpoins de 256", + "empty-image": "Aucune image", + "maximum-upload-file-size": "Taille de fichier maximum: {{ size }}", + "cannot-upload-file": "Le téléchargement a échoué", + "layout-settings": "Paramètres de mise en page", + "margin-required": "Valeur de marge requise.", + "min-margin-message": "0 est la valeur minimum permise.", + "max-margin-message": "50 est la valeur maximum permise.", + "title-settings": "Paramètres du titre", + "toolbar-settings": "Paramètres de la barre d'outils", + "hide-toolbar": "Masquer la barre d'outils", + "display-filters": "Afficher les filtres", + "display-update-dashboard-image": "Afficher l'image du tableau de bord de mise à jour", + "dashboard-logo-settings": "Paramètres du logo du tableau de bord", + "display-dashboard-logo": "Afficher le logo en mode plein écran", + "dashboard-logo-image": "Image du logo du tableau de bord", + "advanced-settings": "Paramètres avancés", + "dashboard-css": "CSS du tableau de bord", + "no-states-text": "Aucun état trouvé", + "search": "Rechercher des tableaux de bord", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } sélectionné", + "home-dashboard": "Tableau de bord d'accueil", + "home-dashboard-hide-toolbar": "Masquer la barre d'outils du tableau de bord d'accueil", + "unassign-dashboards-from-edge-title": "Êtes-vous certain de vouloir désattribuer { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "non-existent-dashboard-state-error": "L'état du tableau de bord avec ID \"{{ stateId }}\" non trouvé" + }, + "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} }", + "prev-orig-value-description": "valeur précédente d'origine;", + "prev-value-description": "résultat de l'appel de fonction précédent;", + "settings": "Paramètres", + "time-description": "horodatage de la valeur actuelle;", + "time-prev-description": "horodatage de la valeur précédente;", + "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", + "value-description": "la valeur actuelle;", + "entity-field": "Champs d'entité", + "alarm-fields-timeseries-or-attributes-required": "Les champs d'alarmes ou l'entité timeseries/attributs sont requis." + }, + "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", + "details": "Détails", + "edit-json": "Éditer JSON" + }, + "device": { + "access-token": "Jeton d'accès", + "access-token-invalid": "La longueur du jeton d'accès doit être comprise entre 1 et 32 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 des dispositifs", + "any-device": "N'importe quel dispositif", + "assign-device-to-customer": "Attribuer 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 device} other {# devices} }", + "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 device} other {# devices} }?", + "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 '{{alias}}' existe déjà.
    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", + "label": "Label", + "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": "Êtes-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", + "secret": "Secret", + "secret-required": "Code secret est requis.", + "select-device": "Selectionner un dispositif", + "select-device-type": "Sélectionner le type d'appareil", + "unable-delete-device-alias-text": "L'alias du dispositif '{{deviceAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
    {{widgetsList}}", + "unable-delete-device-alias-title": "Impossible de supprimer l'alias du dispositif", + "unassign-device": "Annuler l'affectation du dispositif", + "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", + "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", + "unassign-devices": "Annuler l'affectation des dispositifs", + "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 device} other {#devices} } 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 device} other {# devices} }?", + "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", + "assign-device-to-edge-text": "Veuillez sélectionner la bordure pour attribuer le ou les dispositifs", + "unassign-device-from-edge-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", + "unassign-device-from-edge-text": "Après la confirmation, dispositif sera non attribué et ne sera pas accessible a la bordure.", + "unassign-devices-from-edge-title": "Voulez-vous vraiment annuler l'attribution de {count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-from-edge-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par la bordure.", + "device-type-max-length": "La longueur du type de dispositif doit être moins de 256", + "help-text": "Utilisez '%' au besoin: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "unassign-devices-from-edge": "Désattribuer les dispositifs du edge", + "loading-device-credentials": "Chargement des informations d'identification du dispositf...", + "certificate-pem-format": "Certificat en format PEM", + "certificate-pem-format-required": "Certificat requis.", + "lwm2m-security-config": { + "identity": "Identité du client", + "identity-required": "Identité du client requise.", + "identity-tooltip": "L'identifiant PSK est un identifiant PSK arbitraire d'une longueur maximum de 128 octets, tel qu'indiqué au standard [RFC7925].\nL'identifiant PSK DOIT d'abord être converti en format texte puis encodé en octets utilisant la norme UTF-8.", + "client-key": "Clé du client", + "client-key-required": "Clé du client est requise.", + "client-key-tooltip-prk": "Clé publique RPK ou ID doivent être en format standard [RFC7250] et encodés en format Base64!", + "client-key-tooltip-psk": "Clé PSK doit être dans le standard [RFC4279] et en format HexDec: 32, 64, 128 caractères!", + "endpoint": "Nom du client Endpoint", + "endpoint-required": "Nom du client Endpoint est requis.", + "client-public-key": "Clé publique du client", + "client-public-key-hint": "Si la clé publique du client est vide, le certificat sera utilisé", + "client-public-key-tooltip": "La clé publique X509 doit être en format X509v3 DES-encodé et supporter exclusivement l'algorithme EC puis être encodé en format Base64!", + "mode": "Mode configuration de sécurité", + "client-tab": "Config de la sécutité du client", + "client-certificate": "Certificat du client", + "bootstrap-tab": "Client Bootstrap", + "bootstrap-server": "Serveur Bootstrap", + "lwm2m-server": "Serveur LwM2M", + "client-publicKey-or-id": "Clé publique du client ou ID", + "client-publicKey-or-id-required": "Clé publique du client ou ID requise", + "client-publicKey-or-id-tooltip-psk": "L'identifiant PSK est un identifiant PSK arbitraire d'une longueur maximum de 128 octets, tel qu'indiqué au standard [RFC7925].\nL'identifiant PSK DOIT d'abord être converti en format texte puis encodé en octets utilisant la norme UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "Clé publique RPK ou ID doivent être en format standard [RFC7250] et encodés en format Base64!", + "client-publicKey-or-id-tooltip-x509": "La clé publique X509 doit être en format X509v3 DES-encodé et supporter exclusivement l'algorithme EC puis être encodé en format Base64!", + "client-secret-key": "Clé secrète du client", + "client-secret-key-required": "Clé secrète du client est requise.", + "client-secret-key-tooltip-psk": "La clé PSK doit être en format stadard [RFC4279] et en format HexDec: 32, 64, 128 caractères!", + "client-secret-key-tooltip-prk": "La clé secrète RPK doit être en format PKCS_8 (DER encodée, standard [RFC5958]) puis encodée en format Base64!", + "client-secret-key-tooltip-x509": "La clé secrète X509 doit être en format PKCS_8 (DER encodée, standard [RFC5958]) puis encodée en format Base64!" + }, + "client-id": "ID client", + "client-id-pattern": "Contient des caractères invalides.", + "user-name": "Nom d'utilisateur", + "user-name-required": "Nom d'utilisateur requis.", + "client-id-or-user-name-necessary": "ID client et/ou identifiant sont requis", + "password": "Mot de passe", + "name-max-length": "La longueur du nom doit être moins de 256", + "label-max-length": "La longueur du Label doit être moins de 256", + "copy-mqtt-authentication": "Copier les authentifiants MQTT", + "mqtt-authentication-copied-message": "L'authentifiant MQTT du dispositif a été copié au presse-papier", + "overwrite-activity-time": "Passer par dessus le temps de l'activité pour dispositif connecté", + "import": "Importer dispositif", + "device-file": "Fichier du dispositif", + "search": "Rechercher des dispositifs", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } sélectionnés", + "device-configuration": "Configuration du dipositif", + "transport-configuration": "Configuration du transport", + "wizard": { + "device-wizard": "Wizard du dispositif", + "device-details": "Détails du dispositif", + "new-device-profile": "Créer un nouveau profil de dispositif", + "existing-device-profile": "Choisissez un profile de dispositif existant", + "specific-configuration": "Configuration spécifique", + "customer-to-assign-device": "Client auquel assigner le dispositif", + "add-credentials": "Ajouter identifiants" + } + }, + "device-profile": { + "device-profile": "Profile du dispositif", + "device-profiles": "Profiles du dispositif", + "all-device-profiles": "Tous", + "add": "Ajouter un nouveau profile de dispositif", + "edit": "Modifier le profile de dispositif", + "device-profile-details": "Détails du profile de dispositif", + "no-device-profiles-text": "Aucun profil de dispositif trouvé", + "search": "Rechercher profil de dispositif", + "selected-device-profiles": "{ count, plural, 1 {1 device profile} other {# device profiles} } sélectionné", + "no-device-profiles-matching": "Aucun dispositif correspondant à '{{entity}}' trouvé.", + "device-profile-required": "Un profil de dispositif est requis.", + "idCopiedMessage": "L'Id du profil de dispositif a été copié au presse-papier", + "set-default": "Rendre le profil de dispositif par défaut", + "delete": "Supprimer le profil de dispositif", + "copyId": "Copier l'Identifiant du profil de dispositif", + "name-max-length": "La longueur du nom devrait être moins de 256", + "new-device-profile-name": "Nom du profil de dispositif", + "new-device-profile-name-required": "Nom du profil de dispositif est requis.", + "name": "Nom", + "name-required": "Nom est requis.", + "type": "Type de profile", + "type-required": "Type de profile est requis.", + "type-default": "Défault", + "image": "Image du profile de dispositif", + "transport-type": "Type de transport", + "transport-type-required": "Type de transport est requis.", + "transport-type-default": "Défault", + "transport-type-default-hint": "Supporte comme transport MQTT de base, HTTP et CoAP", + "transport-type-mqtt-hint": "Active les configurations MQTT avancées", + "transport-type-coap-hint": "Active les configurations CoAP avancées", + "transport-type-lwm2m-hint": "Type de transport LWM2M", + "transport-type-snmp-hint": "Spécifiez la configuration du transport SNMP", + "default": "Défault", + "profile-configuration": "Configuration du profile", + "transport-configuration": "Configuration du transport", + "default-rule-chain": "Chaine de règles par défaut", + "mobile-dashboard": "Tableau de bord mobile", + "mobile-dashboard-hint": "Utilisé par les applications mobiles comme tableau de bord de détails de dispositifs.", + "select-queue-hint": "Choisissez d'une liste déroulante.", + "delete-device-profile-title": "Êtes-vous certain de vouloir supprimer le profile de dispositif '{{deviceProfileName}}'?", + "delete-device-profile-text": "Attention, après confirmation le profil du dispositif et toutes les données associées deviendront irrécupérables.", + "delete-device-profiles-title": "Êtes-vous certainde vouloir supprimer { count, plural, 1 {1 device profile} other {# device profiles} }?", + "delete-device-profiles-text": "Attention, après confirmation les profils des dispositifs sélectionnés et toutes les données associées deviendront irrécupérables.", + "set-default-device-profile-title": "Êtes-vous certain de vouloir faire du profil de dispositif '{{deviceProfileName}}' le profil par défaut?", + "set-default-device-profile-text": "Après confirmation, le profil de dispositif sera le profil par défaut et sera utilisé pour les nouveaux dispositifs n'ayant pas de profil.", + "no-device-profiles-found": "Aucun profil de dispositif trouvé.", + "create-new-device-profile": "En créer un nouveau!", + "mqtt-device-topic-filters": "Filtres de sujets de dispositifs MQTT", + "mqtt-device-topic-filters-unique": "Filtres de sujets de dispositifs MQTT doivent être uniques. ", + "mqtt-device-payload-type": "Payload de dispositif MQTT", + "mqtt-enable-compatibility-with-json-payload-format": "Activer la compatibilité avec d'autres formats de payloads.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Lorsqu'activé, la plateforme utilisera un format de payload Protobuf par défaut. Si le parsing échoue, la pateforme tentera d'utiliser le format JSON. Utile pour compatibilité avec version antérieures lors de mises à jour du firmware. Par exemple, la version originale du firmware utilisait JSON, alors que la nouvelle version du firmware utilise Protobuf. Durant le processus de mise à jour du firmware pour une flotte de dispositifs, il est requis de supporter à la fois JSON et Protobuf simultanément. Ce mode de compatibilité est légèrement moins performant alors il est recommandé de le désactivé une fois tous les dispositifs mis à jour.", + "mqtt-use-json-format-for-default-downlink-topics": "Utilisez le format JSON pouir les sujets de downlink par défaut", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Lorsqu'activé, la plateforme utilisera le format de payload JSON pour pousser des attributs et des RPC via les sujets suivants: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. This setting does not impact attribute and rpc subscriptions sent using new (v2) topics: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Où $request_id est un identifiant de requête entier.", + "snmp-add-mapping": "Ajouter un mapping SNMP", + "snmp-mapping-not-configured": "Aucun mapping pour OID vers timeseries/télémétrie n'est configuré", + "snmp-timseries-or-attribute-name": "Nom timeseries/attribut pour mapping", + "snmp-timseries-or-attribute-type": "Type timeseries/attribut pour mapping", + "mqtt-payload-type-required": "Type de payload est requis.", + "coap-device-type": "Type de dispositif CoAP", + "coap-device-payload-type": "Payload de dispositif CoAP", + "coap-device-type-required": "Type de dispositif CoAP est requis.", + "coap-device-type-default": "Défaut", + "support-level-wildcards": "[+] unique et wildcards de [#] multi-niveaux supportés.", + "telemetry-topic-filter": "Filtre de sujets de télémétrie", + "telemetry-topic-filter-required": "Filtre de sujets de télémétrie est requis.", + "attributes-topic-filter": "Filtre de sujets d'attributs", + "attributes-topic-filter-required": "Filtre de sujets d'attributs est requis.", + "telemetry-proto-schema": "Schéma proto de télémétrie", + "telemetry-proto-schema-required": "Schéma proto de télémétrie est requis.", + "attributes-proto-schema": "Schéma proto d'attributs", + "attributes-proto-schema-required": "Schéma proto d'attributs est requis.", + "rpc-response-proto-schema": "Schéma proto de réponse RPC", + "rpc-response-proto-schema-required": "Schéma proto de réponse RPC est requis.", + "rpc-response-topic-filter": "Filtre de sujets de réponse RPC", + "rpc-response-topic-filter-required": "Filtre de sujets de réponse RPC est requis.", + "rpc-request-proto-schema": "Schéma proto de requête RPC", + "rpc-request-proto-schema-required": "Schéma proto de requête RPC est requis.", + "rpc-request-proto-schema-hint": "Une requête RPC devrait toujours avoir les champs: string method = 1; int32 requestId = 2; et params = 3 de n'importe quel type de données." + }, + "dialog": { + "close": "Fermer le dialogue" + }, + "edge": { + "edge": "Bordure", + "edge-instances": "Instances de Bord", + "edge-file": "Fichier Edge", + "management": "Gestion des bordures", + "no-edges-matching": "Aucun bordure correspondant à {{entity}} n'a été trouvé.", + "add": "Ajouter un bordure", + "no-edges-text": "Aucun bordure trouvé", + "edge-details": "Détails de la bordure", + "add-edge-text": "Ajouter une nouveau bordure", + "delete": "Supprimer la bordure", + "delete-edge-title": "Êtes-vous sûr de vouloir supprimer la bordure '{{edgeName}}'?", + "delete-edge-text": "Faites attention, après la confirmation, la bordure et toutes les données associées deviendront irrécupérables", + "delete-edges-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 bordure} other {# bordure} }?", + "delete-edges-text": "Faites attention, après la confirmation, tous les bordures sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "name": "Nom", + "name-starts-with": "Le nom du bord commence par", + "name-required": "Le nom de la bordure est requis", + "description": "Dispositifs", + "details": "Détails de l'entité", + "events": "Événements", + "copy-id": "Copier borudre Id", + "id-copied-message": "Id de la bordure a été copié dans le presse-papier", + "sync": "Sync Edge", + "edge-required": "Bordure est requise", + "edge-type": "Type de la bordure", + "edge-type-required": "Type de la bordure est requise.", + "event-action": "Action d'événement", + "entity-id": "ID d'entité", + "select-edge-type": "Selectionner un type de la bordure", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner la bordure pour attribuer le ou les dispositifs", + "assign-edge-to-customer": "Attribuer la bordure au client", + "assign-edge-to-customer-text": "Veuillez sélectionner la bordure pour attribuer le ou les dispositifs", + "assignedToCustomer": "Attribué au client", + "edge-public": "Edge est public", + "assigned-to-customer": "Attribué au client", + "unassign-from-customer": "Retirer du client", + "unassign-edge-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{edgeName}}", + "unassign-edge-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client", + "unassign-edges-title": "Voulez-vous vraiment annuler l'attribution de {count, plural, 1 {1 bordure} other {# bordures} }?", + "unassign-edges-text": "Après la confirmation, tous les bordures sélectionnés ne seront plus attribués et ne seront pas accessibles par le client.", + "make-public": "Make edge public", + "make-public-edge-title": "Are you sure you want to make the edge '{{edgeName}}' public?", + "make-public-edge-text": "After the confirmation the edge and all its data will be made public and accessible by others.", + "make-private": "Rendre public Edge", + "public": "Public", + "make-private-edge-title": "Are you sure you want to make the edge '{{edgeName}}' private?", + "make-private-edge-text": "Après la confirmation, la bordure et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres", + "import": "Importer bordure", + "label": "Etiquette", + "load-entity-error": "Entité introuvable. Échec du chargement des informations", + "assign-new-edge": "Attribuer un nouvel bordure", + "unassign-from-edge": "Retirer de la bordure", + "edge-key": "Clé de la bordure", + "copy-edge-key": "Copier clé de la bordure", + "edge-key-copied-message": "Clé de la bordure a été copié dans le presse-papier", + "edge-secret": "Secret de la bordure", + "copy-edge-secret": "Copier secret de la bordure", + "edge-secret-copied-message": "Secret de la bordure a été copié dans le presse-papier", + "edge-assets": "Gérer les actifs de la bordure", + "edge-devices": "Gérer les dispositifs de la bordure", + "edge-entity-views": "Vues de l'entité vues de l'entité", + "edge-dashboards": "Gérer les tableaux de bord", + "edge-rulechains": "Chaînes de règles Edge", + "assets": "Actifs de la bordure", + "devices": "Dispositifs de la bordure", + "entity-views": "Vues de l'entité bordure", + "dashboard": "Tableau de bord Edge", + "dashboards": "Tableau de bord de la bordure", + "rulechain-templates": "Modèles de chaîne de règles", + "rulechains": "Chaînes de règles de la bordure", + "search": "Rechercher les bords", + "selected-edges": "{count, plural, 1 {1 bordure} other {# bords} } sélectionné", + "any-edge": "Tout bord", + "no-edge-types-matching": "Aucun type d'arête correspondant à \"{{entitySubtype}}\" n'a été trouvé.", + "edge-type-list-empty": "Aucun type d'arête sélectionné.", + "edge-types": "Types de bords", + "enter-edge-type": "Entrez le type d'arête", + "deployed": "Déployé", + "pending": "En attente", + "downlinks": "Liens descendants", + "no-downlinks-prompt": "Aucun lien descendant trouvé", + "sync-process-started-successfully": "Le processus de synchronisation a démarré avec succès!", + "missing-related-rule-chains-title": "Edge n'a pas de chaîne (s) de règles associées", + "missing-related-rule-chains-text": "Les chaînes de règles affectées aux tronçons utilisent des nœuds de règles qui transfèrent les messages vers les chaînes de règles non affectées à ce tronçon.

    Liste des chaînes de règles manquantes:
    {{missingRuleChains}}", + "widget-datasource-error": "Ce widget prend en charge uniquement la source de données d'entité EDGE" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Device", + "type-device-profile": "Device Profile", + "type-entity-view": "Entity View", + "type-alarm": "Alarm", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "action-type-added": "Added", + "action-type-deleted": "Deleted", + "action-type-updated": "Updated", + "action-type-post-attributes": "Post Attributes", + "action-type-attributes-updated": "Attributes Updated", + "action-type-attributes-deleted": "Attributes Deleted", + "action-type-timeseries-updated": "Timeseries Updated", + "action-type-credentials-updated": "Credentials Updated", + "action-type-assigned-to-customer": "Assigned to Customer", + "action-type-unassigned-from-customer": "Unassigned from Customer", + "action-type-relation-add-or-update": "Relation Add or Update", + "action-type-relation-deleted": "Relation Deleted", + "action-type-rpc-call": "RPC Call", + "action-type-alarm-ack": "Alarm Ack", + "action-type-alarm-clear": "Alarm Clear", + "action-type-assigned-to-edge": "Assigned to Edge", + "action-type-unassigned-from-edge": "Unassigned from Edge", + "action-type-credentials-request": "Credentials Request", + "action-type-entity-merge-request": "Entity Merge Request" + }, + "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 actifs dont le nom commence par '{{prefix}}'", + "columns-to-display": "Colonnes à afficher", + "configure-alias": "Configurer '{{alias}}' alias", + "create-new-alias": "Créez un nouveau!", + "create-new-key": "Créez un nouveau!", + "customer-name-starts-with": "Les clients dont les noms commencent par '{{prefix}}'", + "dashboard-name-starts-with": "Les tableaux de bord dont les noms commencent par '{{prefix}}'", + "details": "Détails de l'entité", + "device-name-starts-with": "Dispositifs dont le nom commence par '{{prefix}}'", + "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
    Les alias d'entité doivent être uniques dans le tableau de bord.", + "enter-entity-type": "Entrez le type d'entité", + "entities": "Entités", + "entity": "Entité", + "entity-alias": "Alias de l'entité", + "entity-list": "Liste d'entités", + "entity-list-empty": "Aucune entité sélectionnée.", + "entity-name": "Nom de l'entité", + "entity-name-filter-no-entity-matched": "Aucune entité commençant par '{{entity}}' n'a été trouvée.", + "entity-name-filter-required": "Le filtre de nom d'entité est requis.", + "entity-type": "Type d'entité", + "entity-type-list": "Liste de types d'entités", + "entity-type-list-empty": "Aucun type d'entité sélectionné.", + "entity-types": "Types d'entité", + "entity-view-name-starts-with": "Les vues d'entité dont le nom commence par '{{prefix}}'", + "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}}'", + "type-edge": "Bordure", + "type-edges": "Bordures", + "list-of-edges": "{ count, plural, 1 {Une bordure} other {Liste de # bordures} }", + "edge-name-starts-with": "Bordures dont les noms commencent 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": "Actif", + "type-assets": "Actifs", + "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-entity-view": "Vue d'entité", + "type-entity-views": "Vues d'entités", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "type-required": "Le type d'entité est obligatoire.", + "type-rule": "Régle", + "type-rulechain": "Chaîne de régles", + "type-rulechains": "Chaînes de régles", + "type-rulenode": "Noeud de régle", + "type-rulenodes": "Noeuds de régle", + "type-rules": "Régles", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "type-user": "Utilisateur", + "type-users": "Utilisateurs", + "unable-delete-entity-alias-text": "L'alias d'entité '{{entityAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
    {{widgetsList}}", + "unable-delete-entity-alias-title": "Impossible de supprimer l'alias d'entité", + "use-entity-name-filter": "Utiliser un filtre", + "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" + }, + "entity-field": { + "address": "Adresse", + "address2": "Adresse 2", + "city": "Ville", + "country": "Pays", + "created-time": "Heure de création", + "email": "Email", + "first-name": "Prénom", + "last-name": "Nom de famille", + "name": "Nom", + "phone": "Téléphone", + "state": "Prov", + "title": "Titre", + "type": "Type", + "zip": "Code postal" + }, + "entity-view": { + "add": "Ajouter une vue d'entité", + "add-alias": "Ajouter un alias de vue d'entité", + "add-entity-view-text": "Ajouter une nouvelle vue d'entité", + "alias": "Alias", + "alias-required": "Un alias de vue d'entité est requis.", + "aliases": "Alias de vue d'entité", + "any-entity-view": "Toute vue d'entité", + "assign-entity-view-to-customer": "Attribuer une (des) vue (s) d'entité à un client", + "assign-entity-view-to-customer-text": "Veuillez sélectionner les vues d'entité à affecter au client", + "assign-entity-views": "Attribuer des vues d'entité", + "assign-entity-views-text": "Attribuer { count, plural, 1 {1 entityView} other {# entityViews} } au client", + "assign-new-entity-view": "Attribuer une nouvelle vue d'entité", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client auquel attribuer la ou les vues d'entité.", + "assignedToCustomer": "Assigné au client", + "attributes-propagation": "Propagation des attributs", + "attributes-propagation-hint": "La vue d'entité copiera automatiquement les attributs spécifiés de l'entité cible chaque fois que vous enregistrez ou mettez à jour cette vue d'entité. Pour des raisons de performances, les attributs d'entité cible ne sont pas propagés à la vue d'entité à chaque changement d'attribut. Vous pouvez activer la propagation automatique en configurant le noeud de règle \" copier pour afficher \" dans votre chaîne de règles et en liant les messages \"Post attributs \" et \"attributs mis à jour \" au nouveau noeud de règle.", + "client-attributes": "Attributs du client", + "client-attributes-placeholder": "Attributs du client", + "configure-alias": "Configurez l'alias '{{alias}}'", + "copyId": "Copier l'ID de la vue d'entité", + "create-new-alias": "Créer un nouveau!", + "create-new-key": "Créer un nouveau!", + "date-limits": "Limites de date", + "delete": "Supprimer la vue d'entité", + "delete-entity-view-text": "Attention, après la confirmation, la vue de l'entité et toutes les données associées deviendront irrécupérables.", + "delete-entity-view-title": "Êtes-vous sûr de vouloir supprimer la vue de l'entité '{{entityViewName}}'?", + "delete-entity-views": "Supprimer les vues d'entité", + "delete-entity-views-action-title": "Supprimer { count, plural, 1 {1 entityView} other {# entityViews} }", + "delete-entity-views-text": "Attention, après la confirmation, toutes les vues d'entité sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", + "delete-entity-views-title": "Êtes-vous sûr de vouloir voir l'entité { count, plural, 1 {1 entityView} other {# entityViews} }?", + "description": "Description", + "details": "Détails", + "duplicate-alias-error": "Alias '{{alias}}' existe déjà.
    Les alias de vue d'entité doivent être uniques dans le tableau de bord.", + "end-date": "Date de fin", + "end-ts": "Heure de fin", + "enter-entity-view-type": "Entrer le type de vue d'entité", + "entity-view": "Vue d'entité", + "entity-view-alias": "Alias de vue d'entité", + "entity-view-details": "Détails de la vue d'entité", + "entity-view-list": "Liste de vues d'entités", + "entity-view-list-empty": "Aucune vue d'entité sélectionnée.", + "entity-view-name-filter-no-entity-view-matched": "Aucune vue d'entité commençant par '{{entityView}}' n'a été trouvée.", + "entity-view-name-filter-required": "Un filtre de nom de vue d'entité est requis.", + "entity-view-required": "Une vue d'entité est requise.", + "entity-view-type": "Type de vue d'entité", + "entity-view-type-list-empty": "Aucun type de vue d'entité sélectionné.", + "entity-view-type-required": "Le type d'entité est requis.", + "entity-view-types": "Types de vues d'entité", + "entity-views": "Vues d'entité", + "events": "Événements", + "make-private": "Rendre la vue d'entité privée", + "make-private-entity-view-text": "Après la confirmation, la vue de l'entité et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres", + "make-private-entity-view-title": "Êtes-vous sûr de vouloir rendre la vue d'entité '{{entityViewName}}' privée?", + "make-public": "Rendre la vue d'entité publique", + "make-public-entity-view-text": "Après la confirmation, la vue de l'entité et toutes ses données seront rendues publiques et accessibles à d'autres", + "make-public-entity-view-title": "Voulez-vous vraiment que la vue de l'entité '{{entityViewName}}' soit publique?", + "assign-entity-view-to-edge": "Attribuer a la bordure", + "assign-entity-view-to-edge-text": "Veuillez sélectionner la bordure auquel attribuer la ou les vues d'entité.", + "unassign-entity-view-from-edge-title": "Voulez-vous vraiment annuler l'attribution de la vue d'entité '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Après la confirmation, la vue de l'entité sera non attribuée et ne sera pas accessible par la bordure.", + "unassign-entity-views-from-edge-action-title": "Annuler l'attribution { count, plural, 1 {1 entityView} other {# entityViews} } de la bordure", + "unassign-entity-view-from-edge": "Annuler l'attribution des vues d'entité", + "unassign-entity-views-from-edge-title": "Êtes-vous sûr de vouloir annuler l'attribution { count, plural, 1 {1 entityView} other {# entityViews} }?", + "unassign-entity-views-from-edge-text": "Après la confirmation, toutes les vues des entités sélectionnées seront non attribuées et ne seront pas accessibles par la bordure.", + "management": "Gestion de vue d'entité", + "name": "Nom", + "name-required": "Un nom est requis.", + "name-starts-with": "Le nom de la vue d'entité commence par", + "no-alias-matching": "'{{alias}}' non trouvé.", + "no-aliases-found": "Aucun alias trouvé.", + "no-entity-view-types-matching": "Aucun type de vue d'entité correspondant à '{{entitySubtype}}' n'a été trouvé.", + "no-entity-views-matching": "Aucune vue d'entité correspondant à '{{entity}}' n'a été trouvée.", + "no-entity-views-text": "Aucune vue d'entité trouvée.", + "no-key-matching": "'{{key}}' non trouvé.", + "no-keys-found": "Aucune clé trouvée.", + "remove-alias": "Supprimer un alias de vue d'entité", + "select-entity-view": "Sélectionner une vue d'entité", + "select-entity-view-type": "Sélectionner le type de vue d'entité", + "server-attributes": "Attributs du serveur", + "server-attributes-placeholder": "Attributs du serveur", + "shared-attributes": "Attributs partagés", + "shared-attributes-placeholder": "Attributs partagés", + "start-date": "Date de début", + "start-ts": "Heure de début", + "target-entity": "Entité cible", + "timeseries": "Séries chronologiques", + "timeseries-data": "Données de séries chronologiques", + "timeseries-data-hint": "Configurez les clés de données de séries chronologiques de l'entité cible qui seront accessibles à la vue de l'entité. Ces données temporelles sont en lecture seule.", + "timeseries-placeholder": "Séries chronologiques", + "unable-entity-view-device-alias-text": "L'alias de dispositif '{{entityViewAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
    {{widgetsList}}", + "unable-entity-view-device-alias-title": "Impossible de supprimer l'alias de la vue d'entité.", + "unassign-entity-view": "Annuler l'affectation de la vue d'entité", + "unassign-entity-view-text": "Après la confirmation, la vue de l'entité sera non attribuée et ne sera pas accessible par le client.", + "unassign-entity-view-title": "Voulez-vous vraiment annuler l'attribution de la vue d'entité '{{entityViewName}}'?", + "unassign-entity-views": "Annuler l'attribution des vues d'entité", + "unassign-entity-views-action-title": "Annuler l'attribution { count, plural, 1 {1 entityView} other {# entityViews} } du client", + "unassign-entity-views-text": "Après la confirmation, toutes les vues des entités sélectionnées seront non attribuées et ne seront pas accessibles par le client.", + "unassign-entity-views-title": "Êtes-vous sûr de vouloir annuler l'attribution { count, plural, 1 {1 entityView} other {# entityViews} }?", + "unassign-from-customer": "Annuler l'attribution au client", + "use-entity-view-name-filter": "Use filter", + "view-entity-views": "Voir les vues d'entité", + "idCopiedMessage": "L'ID de la vue d'entité a été copiée dans le presse-papier", + "search": "Rechercher des vues d'entité", + "selected-entity-views": "{ count, plural, 1 {1 entity view} other {# entity views} } sélectionnés" + }, + "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", + "error": "erreur", + "type-edge-event": "Downlink", + "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": "État", + "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", + "all-events": "Tout", + "entity-type": "Type d'entité" + }, + "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": "Sur mesure", + "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": "Au 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": "Êtes-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": "Icônes matérielles", + "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": "Nettoyer" + }, + "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" + }, + "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": "moy", + "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": "Connexion", + "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 (courriel)", + "login-with": "Se connecter avec {{name}}", + "or": "ou" + }, + "position": { + "bottom": "Bas", + "left": "Gauche", + "right": "Droite", + "top": "Haut" + }, + "profile": { + "change-password": "Modifier le mot de passe", + "current-password": "Mot de passe actuel", + "last-login-time": "Dernière connexion", + "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": "Êtes-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": "Êtes-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": "Vers" + }, + "selected-relations": "{count, plural, 1 {1 relation} other {# relations} } sélectionné", + "to-entity": "Vers 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": "Rendre 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", + "assign-rulechains": "Attribuer aux chaînes de règles", + "assign-new-rulechain": "Attribuer une nouvele chaînes de règles", + "delete-rulechains": "Supprimer une chaînes de règles", + "unassign-rulechain": "Retirer chaîne de règles", + "unassign-rulechains": "Retirer chaînes de règles", + "unassign-rulechain-title": "AÊtes-vous sûr de vouloir retirer l'attribution de chaînes de règles '{{ruleChainName}}'?", + "unassign-rulechain-from-edge-text": "Après la confirmation, l'actif sera non attribué et ne sera pas accessible a la bordure.", + "unassign-rulechains-from-edge-action-title": "Retirer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles} } de la bordure", + "unassign-rulechains-from-edge-text": "Après la confirmation, tous les chaînes de règles sélectionnés ne seront pas attribués et ne seront pas accessibles a la bordure.", + "assign-rulechain-to-edge-title": "Attribuer les chaînes de règles a la bordure", + "assign-rulechain-to-edge-text": "Veuillez sélectionner la bordure pour attribuer le ou les chaînes de règles", + "set-edge-template-root-rulechain": "Rendre le modèle de bord de chaîne de règles racine", + "set-edge-template-root-rulechain-title": "Voulez-vous vraiment définir la racine du modèle d'arête de la chaîne de règles '{{ruleChainName}}'?", + "set-edge-template-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra la racine du modèle d'arête et sera la chaîne de règles racine pour les arêtes nouvellement créées.", + "invalid-rulechain-type-error": "Impossible d'importer la chaîne de règles: type de chaîne de règles non valide. Le type attendu est {{attenduRuleChainType}}.", + "set-auto-assign-to-edge": "Attribuer une chaîne de règles aux arêtes lors de la création", + "set-auto-assign-to-edge-title": "Voulez-vous vraiment attribuer automatiquement la chaîne de règles d'arête '{{ruleChainName}}' à l'arête (s) lors de la création?", + "set-auto-assign-to-edge-text": "Après la confirmation, la chaîne de règles d'arête sera automatiquement affectée à l'arête (s) lors de la création.", + "unset-auto-assign-to-edge": "Non défini, attribuer une chaîne de règles aux arêtes lors de la création", + "unset-auto-assign-to-edge-title": "Voulez-vous vraiment annuler l'attribution de la chaîne de règles d'arête \"{{ruleChainName}}\" aux arêtes lors de la création?", + "unset-auto-assign-to-edge-text": "Après la confirmation, la chaîne de règles d'arêtes ne sera plus automatiquement affectée aux arêtes lors de la création.", + "edge-template-root": "Racine du modèle", + "search": "Rechercher des chaînes de règles", + "selected-rulechains": "{count, plural, 1 {1 rule chain} other {# rule chains} } sélectionné", + "open-rulechain": "Ouvrir la Chaîne de règles", + "assign-to-edge": "Attribuer à Bordure", + "edge-rulechain": "Chaîne de règles Bordure", + "unassign-rulechains-from-edge-title": "Voulez-vous vraiment annuler l'attribution de {count, plural, 1 {1 rulechain} other {# rulechains} }?" + }, + "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." + }, + "timezone": { + "timezone": "Fuseau horaire", + "select-timezone": "Sélectionnez le fuseau horaire", + "no-timezones-matching": "Aucun fuseau horaire correspondant à '{{timezone}}' n'a été trouvé.", + "timezone-required": "Le fuseau horaire est requis." + }, + "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": "Êtes-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": "Êtes-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.", + "search": "Rechercher les Tenants", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenants} } sélectionnés" + }, + "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", + "hide": "Masquer" + }, + "user": { + "activation-email-sent-message": "Le courriel d'activation a été envoyé avec succès!", + "activation-link": "Lien d'activation utilisateur", + "activation-link-copied-message": "le lien d'activation de l'utilisateur a été copié dans le presse-papier", + "activation-link-text": "Pour activer l'utilisateur, utilisez le lien d'activation suivant: ", + "activation-method": "Méthode d'activation", + "add": "Ajouter un utilisateur", + "add-user-text": "Ajouter un nouvel utilisateur", + "always-fullscreen": "Toujours en plein écran", + "anonymous": "Anonyme", + "copy-activation-link": "Copier le lien d'activation", + "customer": "Client", + "customer-users": "Utilisateurs du client", + "default-dashboard": "Tableau de bord par défaut", + "delete": "Supprimer l'utilisateur", + "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", + "delete-user-title": "Êtes-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": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs} }?", + "description": "Description", + "details": "Détails", + "disable-account": "Désactiver le compte d'utilisateur", + "disable-account-message": "Le compte d'utilisateur a été désactivé avec succès!", + "display-activation-link": "Afficher le lien d'activation", + "email": "Email", + "email-required": "Email est requis.", + "enable-account": "Activer le compte d'utilisateur", + "enable-account-message": "Le compte d'utilisateur a été activé avec succès!", + "first-name": "Prénom", + "invalid-email-format": "Format de courrier électronique non valide", + "last-name": "Nom de famille", + "login-as-customer-user": "Se connecter en tant qu'utilisateur client", + "login-as-tenant-admin": "Connectez-vous en tant qu'administrateur Tenant", + "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", + "search": "Rechercher des utilisateurs", + "selected-users": "{ count, plural, 1 {1 user} other {# users} } sélectionnés" + }, + "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": "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": "Nettoyer", + "timeseries": "Séries chronologiques", + "title": "Titre du widget", + "title-required": "Le titre du widget est requis.", + "toggle-fullscreen": "Basculer le mode plein écran", + "type": "Type de widget", + "unable-to-save-widget-error": "Impossible de sauvegarder le widget! Le widget a des erreurs!", + "undo": "Annuler les modifications du widget", + "widget-bundle": "Ensemble de widget", + "widget-library": "Bibliothèque de widgets", + "widget-saved": "Widget enregistré", + "widget-template-load-failed-error": "Impossible de charger le modéle de widget!", + "widget-type-load-error": "Le widget n'a pas été chargé à cause des erreurs suivantes:", + "widget-type-load-failed-error": "Impossible de charger le type de widget!", + "widget-type-not-found": "Problème de chargement de la configuration du widget.
    Le type de widget associé a probablement été supprimé.", + "no-data": "Aucune donnée à afficher sur le widget" + }, + "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": "État du tableau de bord cible", + "target-dashboard-state-required": "L'état du tableau de bord cible est requis", + "update-dashboard-state": "Mettre à jour l'état actuel du tableau de bord" + }, + "widget-config": { + "action": "Action", + "action-icon": "Icône", + "action-name": "Nom", + "action-name-not-unique": "Une autre action portant le même nom existe déjà.
    Le nom de l'action doit être unique dans la même source d'action.", + "action-name-required": "Le nom de l'action est requis", + "action-source": "Source de l'action", + "action-source-required": "Une source d'action est requise.", + "action-type": "Type", + "action-type-required": "Le type d'action est requis.", + "actions": "Actions", + "add-action": "Ajouter une action", + "add-datasource": "Ajouter une source de données", + "advanced": "Avancé", + "alarm-source": "Source d'alarme", + "background-color": "couleur de fond", + "data": "Données", + "datasource-parameters": "Paramètres", + "datasource-type": "Type", + "datasources": "Sources de données", + "decimals": "Nombre de chiffres après virgule flottante", + "delete-action": "Supprimer l'action", + "delete-action-text": "Êtes-vous sûr de vouloir supprimer l'action du widget nommé '{{actionName}}'?", + "delete-action-title": "Supprimer l'action du widget", + "display-timewindow": "Afficher fenêtre de temps", + "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", + "title-tooltip": "Tooltip 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", + "display-icon": "Afficher l'icône du titre", + "icon-color": "Couleur de l'icône", + "icon-size": "Taille de l'icône" + }, + "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": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Dim.", + "Mon": "Lun.", + "Tue": "Mar.", + "Wed": "Mer.", + "Thu": "Jeu.", + "Fri": "Ven.", + "Sat": "Sam.", + "Jan": "Janv.", + "Feb": "Févr.", + "Mar": "Mars", + "Apr": "Avr.", + "May": "Mai", + "Jun": "Juin", + "Jul": "Juil.", + "Aug": "Août", + "Sep": "Sept.", + "Oct": "Oct.", + "Nov": "Nov.", + "Dec": "Déc.", + "January": "Janvier", + "February": "Février", + "March": "Mars", + "April": "Avril", + "June": "Juin", + "July": "Juillet", + "August": "Août", + "September": "Septembre", + "October": "Octobre", + "November": "Novembre", + "December": "Décembre", + "Custom Date Range": "Plage de dates personnalisée", + "Date Range Template": "Modèle de plage de dates", + "Today": "Aujourd'hui", + "Yesterday": "Hier", + "This Week": "Cette semaine", + "Last Week": "La semaine dernière", + "This Month": "Ce mois-ci", + "Last Month": "Le mois dernier", + "Year": "Année", + "This Year": "Cette année", + "Last Year": "L'année dernière", + "Date picker": "Sélecteur de date", + "Hour": "Heure", + "Day": "Journée", + "Week": "La semaine", + "2 weeks": "2 Semaines", + "Month": "Mois", + "3 months": "3 Mois", + "6 months": "6 Mois", + "Custom interval": "Intervalle personnalisé", + "Interval": "Intervalle", + "Step size": "Taille de pas", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Le paramètre d'attribut ne peut pas être utilisé dans ce widget", + "date": "Date", + "discard-changes": "Annuler les modifications", + "entity-attribute-required": "L'attribut d'entité est requis", + "entity-timeseries-required": "Entité timeseries est requis", + "not-allowed-entity": "L'entité sélectionnée ne peut pas avoir d'attributs partagés", + "no-attribute-selected": "Aucun attribut n'est sélectionné", + "no-datakey-selected": "Aucune date n'est sélectionnée", + "no-entity-selected": "Aucune entité sélectionnée", + "no-image": "Pas d'image", + "no-support-web-camera": "Pas de webcam supportée", + "no-timeseries-selected": "Aucune série temporelle sélectionnée", + "switch-attribute-value": "Changer la valeur de l'attribut d'entité", + "switch-camera": "Changer de caméra", + "switch-timeseries-value": "Changer la valeur de l'entité série temporelle", + "take-photo": "Prendre une photo", + "time": "Temps", + "timeseries-not-allowed": "Le paramètre série temporelle ne peut pas être utilisé dans ce widget", + "update-failed": "Mise à jour a échoué", + "update-successful": "Mise à jour réussie", + "update-attribute": "Attribut de mise à jour", + "update-timeseries": "Mise à jour de la série temporelle", + "value": "Valeur" + } + }, + "widgets-bundle": { + "add": "Ajouter un groupe de widgets", + "add-widgets-bundle-text": "Ajouter un nouveau groupe de widgets", + "create-new-widgets-bundle": "Créer un nouveau groupe de widgets", + "current": "Groupe actuel", + "delete": "Supprimer le groupe de widgets", + "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets} }", + "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets} }?", + "details": "Détails", + "empty": "Le groupe de widgets est vide", + "export": "Exporter le groupe de widgets", + "export-failed-error": "Impossible d'exporter le groupe de widgets: {{error}}", + "import": "Importer un groupe de widgets", + "invalid-widgets-bundle-file-error": "Impossible d'importer un groupe de widgets: structure de données du groupe de widgets non valides.", + "no-widgets-bundles-matching": "Aucun groupe de widgets correspondant à {{widgetsBundle}} n'a été trouvé.", + "no-widgets-bundles-text": "Aucun groupe de widgets trouvé", + "system": "Système", + "title": "Titre", + "title-required": "Le titre est requis.", + "widgets-bundle-details": "Détails des groupes de widgets", + "widgets-bundle-file": "Fichier de groupe de widgets", + "widgets-bundle-required": "Un groupe de widgets est requis.", + "widgets-bundles": "Groupes de widgets" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json new file mode 100644 index 0000000..52485e3 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -0,0 +1,1708 @@ +{ + "access": { + "unauthorized": "Non autorizzato", + "unauthorized-access": "Accesso non autorizzato", + "unauthorized-access-text": "Devi effettuare il login per accedere a questa risorsa!", + "access-forbidden": "Accesso Vietato", + "access-forbidden-text": "Non hai i diritti di accesso a questa posizione!
    Prova ad effettuare il login con un diverso account.", + "refresh-token-expired": "Sessione scaduta", + "refresh-token-failed": "Impossibile aggiornare la sessione" + }, + "action": { + "activate": "Attiva", + "suspend": "Sospendi", + "save": "Salva", + "saveAs": "Salva come", + "cancel": "Cancella", + "ok": "OK", + "delete": "Elimina", + "add": "Aggiungi", + "yes": "Sì", + "no": "No", + "update": "Aggiorna", + "remove": "Rimuovi", + "search": "Cerca", + "clear-search": "Cancella ricerca", + "assign": "Assegna", + "unassign": "Annulla assegnazione", + "share": "Condividi", + "make-private": "Rendi privato", + "apply": "Applica", + "apply-changes": "Applica modifiche", + "edit-mode": "Modalità modifica", + "enter-edit-mode": "Attiva modalità di modifica", + "decline-changes": "Annulla modifiche", + "close": "Chiudi", + "back": "Indietro", + "run": "Esegui", + "sign-in": "Registrati!", + "edit": "Modifica", + "view": "Visualizza", + "create": "Crea", + "drag": "Trascina", + "refresh": "Aggiorna", + "undo": "Annulla", + "copy": "Copia", + "paste": "Incolla", + "copy-reference": "Copia riferimento", + "paste-reference": "Incolla riferimento", + "import": "Importa", + "export": "Esporta", + "share-via": "Condividi con {{provider}}", + "discard-changes": "Annulla le modifiche" + }, + "aggregation": { + "aggregation": "Aggregazione", + "function": "Funzione di aggregazione dati", + "limit": "Valori max", + "group-interval": "Intervallo di raggruppamento", + "min": "Min", + "max": "Max", + "avg": "Media", + "sum": "Somma", + "count": "Conteggio", + "none": "Nessuna" + }, + "admin": { + "general": "Generale", + "general-settings": "Impostazioni Generali", + "outgoing-mail": "Posta in uscita", + "outgoing-mail-settings": "Impostazioni Posta in uscita", + "system-settings": "Impostazioni di sistema", + "test-mail-sent": "Mail di test inviata con successo!", + "base-url": "URL di base", + "base-url-required": "URL di base obbligatoria.", + "mail-from": "Mittente", + "mail-from-required": "Mittente obbligatorio.", + "smtp-protocol": "Protocollo SMTP", + "smtp-host": "Host SMTP", + "smtp-host-required": "Host SMTP obbligatorio.", + "smtp-port": "Porta SMTP", + "smtp-port-required": "Porta SMTP obbligatoria.", + "smtp-port-invalid": "Numero di porta SMTP non valido.", + "timeout-msec": "Timeout (msec)", + "timeout-required": "Timeout obbligatorio.", + "timeout-invalid": "Timeout non valido.", + "enable-tls": "Abilita TLS", + "tls-version" : "Versione TLS", + "send-test-mail": "Invia mail di test", + "security-settings": "Settaggi di sicurezza", + "password-policy": "Politica password", + "minimum-password-length": "Lunghezza minima password", + "minimum-password-length-required": "È richiesta una lunghezza minima della password", + "minimum-password-length-range": "La lunghezza minima della password deve essere compresa tra 5 e 50", + "minimum-uppercase-letters": "Numero minimo di lettere maiuscole", + "minimum-uppercase-letters-range": "Il numero minimo di lettere maiuscole non può essere negativo", + "minimum-lowercase-letters": "Numero minimo di lettere minuscole", + "minimum-lowercase-letters-range": "Il numero minimo di lettere minuscole non può essere negativo", + "minimum-digits": "Numero minimo di cifre", + "minimum-digits-range": "Il numero minimo di cifre non può essere negativo", + "minimum-special-characters": "Numero minimo di caratteri speciali", + "minimum-special-characters-range": "Il numero minimo di caratteri speciali non può essere negativo", + "password-expiration-period-days": "Periodo di scadenza della password in giorni", + "password-expiration-period-days-range": "Il periodo di scadenza della password in giorni non può essere negativo", + "password-reuse-frequency-days": "Frequenza di riutilizzo della password in giorni", + "password-reuse-frequency-days-range": "La frequenza di riutilizzo della password in giorni non può essere negativa", + "general-policy": "Politica generale", + "max-failed-login-attempts": "Numero massimo di tentativi di accesso non riusciti, prima che l'account sia bloccato", + "minimum-max-failed-login-attempts-range": "Il numero massimo di tentativi di accesso non riusciti non può essere negativo", + "user-lockout-notification-email": "In caso di blocco dell'account utente, inviare una notifica via e-mail" + }, + "alarm": { + "alarm": "Allarme", + "alarms": "Allarmi", + "select-alarm": "Seleziona un allarme", + "no-alarms-matching": "Nessun allarme corrispondente a '{{entity}}' è stato trovato.", + "alarm-required": "Allarme richiesto", + "alarm-status": "Stato Allarme", + "search-status": { + "ANY": "Qualsiasi", + "ACTIVE": "Attivo", + "CLEARED": "Cancellato", + "ACK": "Riconosciuto", + "UNACK": "Non riconosciuto" + }, + "display-status": { + "ACTIVE_UNACK": "Attivo Non riconosciuto", + "ACTIVE_ACK": "Attivo Riconosciuto", + "CLEARED_UNACK": "Cancellato Non riconosciuto", + "CLEARED_ACK": "Cancellato Riconosciuto" + }, + "no-alarms-prompt": "Nessun allarme trovato", + "created-time": "Orario di creazione", + "type": "Tipo", + "severity": "Livello di gravità", + "originator": "Origine", + "originator-type": "Tipo origine", + "details": "Dettagli", + "status": "Stato", + "alarm-details": "Dettagli allarme", + "start-time": "Ora inizio", + "end-time": "Ora fine", + "ack-time": "Ora conferma", + "clear-time": "Ora cancellazione", + "severity-critical": "Critico", + "severity-major": "Maggiore", + "severity-minor": "Minore", + "severity-warning": "Avviso", + "severity-indeterminate": "Indeterminato", + "acknowledge": "Conferma", + "clear": "Cancella", + "search": "Cerca allarmi", + "selected-alarms": "{ count, plural, 1 {1 allarme selezionato} other {# allarmi selezionati} }", + "no-data": "Nessun dato da visualizzare", + "polling-interval": "Intervallo di polling (sec) Allarmi", + "polling-interval-required": "Intervallo di polling Allarmi richiesto.", + "min-polling-interval-message": "L'intervallo di polling deve essere di almeno 1 sec.", + "aknowledge-alarms-title": "Conferma { count, plural, 1 {1 allarme} other {# allarmi} }", + "aknowledge-alarms-text": "Sei sicuro di voler confermare { count, plural, 1 {1 allarme} other {# allarmi} }?", + "aknowledge-alarm-title": "Conferma allarme", + "aknowledge-alarm-text": "Sei sicuro di voler confermare l'allarme?", + "clear-alarms-title": "Elimina { count, plural, 1 {1 allarme} other {# allarmi} }", + "clear-alarms-text": "Sei sicuro di voler eliminare { count, plural, 1 {1 allarme} other {# allarmi} }?", + "clear-alarm-title": "Elimina allarme", + "clear-alarm-text": "Sei sicuro di voler eliminare l'allarme?", + "alarm-status-filter": "Filtro stato allarme" + }, + "alias": { + "add": "Aggiungi alias", + "edit": "Modifica alias", + "name": "Nome Alias", + "name-required": "Nome Alias obbligatorio", + "duplicate-alias": "Un Alias con lo stesso nome è già presente.", + "filter-type-single-entity": "Singola entità", + "filter-type-entity-list": "Lista Entità", + "filter-type-entity-name": "Nome Entità", + "filter-type-state-entity": "Entità dallo stato della dashboard", + "filter-type-state-entity-description": "Entità prelevata dai parametri di stato della dashboard", + "filter-type-asset-type": "Tipo di Asset", + "filter-type-asset-type-description": "Asset di tipo '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Asset di tipo '{{assetType}}' e con un nome che inizia per '{{prefix}}'", + "filter-type-device-type": "Tipo di dispositivo", + "filter-type-device-type-description": "Dispositivi di tipo '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispositivi di tipo '{{deviceType}}' e con un nome che inizia per '{{prefix}}'", + "filter-type-entity-view-type": "Tipo vista entità", + "filter-type-entity-view-type-description": "Viste entità di tipo '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Viste entità di tipo '{{entityView}}' e con un nome che inizia per '{{prefix}}'", + "filter-type-relations-query": "Query relazioni", + "filter-type-relations-query-description": "{{entities}} che hanno una relazione {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Query ricerca asset", + "filter-type-asset-search-query-description": "Asset di tipo {{assetTypes}} che hanno una relazione {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Query ricerca dispositivo", + "filter-type-device-search-query-description": "Dispositivi di tipo {{deviceTypes}} che hanno una relazione {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Query ricerca Vista entità", + "filter-type-entity-view-search-query-description": "Viste entità di tipo {{entityViewTypes}} che hanno una relazione {{relationType}} {{direction}} {{rootEntity}}", + "entity-filter": "Filtro entità", + "resolve-multiple": "Risolvi come entità multiple", + "filter-type": "Tipo di filtro", + "filter-type-required": "Tipo di filtro richiesto.", + "entity-filter-no-entity-matched": "Nessuna entità corrispondente al filtro specificato è stata trovata.", + "no-entity-filter-specified": "Nessun filtro di entità specificato", + "root-state-entity": "Usa l'entità di stato della dashboard come radice", + "root-entity": "Entità radice", + "state-entity-parameter-name": "Nome parametro entità di stato", + "default-state-entity": "Entità di stato predefinita", + "default-entity-parameter-name": "Predefinito", + "max-relation-level": "Massimo livello relazione", + "unlimited-level": "Illimitato", + "state-entity": "Entità di stato della dashboard", + "all-entities": "Tutte le entità", + "any-relation": "qualsiasi" + }, + "asset": { + "asset": "Asset", + "assets": "Asset", + "management": "Gestione Asset", + "view-assets": "Visualizza Asset", + "add": "Aggiungi Asset", + "assign-to-customer": "Assegna a cliente", + "assign-asset-to-customer": "Assegna Asset al Cliente", + "assign-asset-to-customer-text": "Seleziona gli asset da assegnare al cliente", + "no-assets-text": "Nessun asset trovato", + "assign-to-customer-text": "Seleziona il cliente a cui assegnare l'asset / gli asset", + "public": "Pubblico", + "assignedToCustomer": "Assegnato al cliente", + "make-public": "Rendi pubblico l'asset", + "make-private": "Rendi privato l'asset", + "unassign-from-customer": "Assegnazione annullata dal cliente", + "delete": "Cancella asset", + "asset-public": "L'Asset è pubblico", + "asset-type": "Tipo di Asset", + "asset-type-required": "Tipo di Asset richiesto.", + "select-asset-type": "Seleziona tipo di asset", + "enter-asset-type": "Inserisci tipo di asset", + "any-asset": "Qualsiasi asset", + "no-asset-types-matching": "Nessun asset corrispondente al tipo '{{entitySubtype}}' è stato trovato.", + "asset-type-list-empty": "Nessun tipo di asset selezionato.", + "asset-types": "Tipi di Asset", + "name": "Nome", + "name-required": "Nome obbligatorio.", + "description": "Descrizione", + "type": "Tipo", + "type-required": "Tipo obbligatorio.", + "details": "Dettagli", + "events": "Eventi", + "add-asset-text": "Aggiungi un nuovo asset", + "asset-details": "Dettagli Asset", + "assign-assets": "Assegna asset", + "assign-assets-text": "Assegna { count, plural, 1 {1 asset} other {# asset} } al cliente", + "delete-assets": "Cancella asset", + "unassign-assets": "Annulla assegnazione asset", + "unassign-assets-action-title": "Annulla assegnazione { count, plural, 1 {1 asset} other {# asset} } al cliente", + "assign-new-asset": "Assegna un nuovo asset", + "delete-asset-title": "Sei sicuro di voler cancellare l'asset '{{assetName}}'?", + "delete-asset-text": "Attenzione, dopo la conferma l'asset e tutti i relativi dati non saranno più recuperabili.", + "delete-assets-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 asset} other {# asset} }?", + "delete-assets-action-title": "Elimina { count, plural, 1 {1 asset} other {# asset} }", + "delete-assets-text": "Attenzione, dopo la modifica tutti gli asset selezionati saranno rimossi e tutti i relativi dati non saranno più recuperabili.", + "make-public-asset-title": "Sei sicuro di voler rendere pubblico l'asset '{{assetName}}'?", + "make-public-asset-text": "Dopo la conferma l'asset e tutti i suoi dati saranno resi pubblici e accessibili dagli altri.", + "make-private-asset-title": "Sei sicuro di voler rendere privato l'asset '{{assetName}}'?", + "make-private-asset-text": "Dopo la conferma l'asset e tutti i suoi dati saranno resi privati e non accessibili dagli altri.", + "unassign-asset-title": "Sei sicuro di voler annullare l'assegnazione dell'asset '{{assetName}}'?", + "unassign-asset-text": "Dopo la conferma l'assegnazione dell'asset sarà annullata e l'asset non sarà più accessibile dal cliente.", + "unassign-asset": "Annulla assegnazione asset", + "unassign-assets-title": "Sei sicuro di voler annullare l'assegnazione di { count, plural, 1 {1 asset} other {# asset} }?", + "unassign-assets-text": "Dopo la conferma sarà annullata l'assegnazione di tutti gli asset selezionati e questi non saranno più accessibili dal cliente.", + "copyId": "Copia Id asset", + "idCopiedMessage": "Id Asset copiato negli Appunti", + "select-asset": "Seleziona asset", + "no-assets-matching": "Nessun asset corrispondente a '{{entity}}' è stato trovato.", + "asset-required": "Asset obbligatorio", + "name-starts-with": "Asset con nome che inizia per", + "label": "Etichetta" + }, + "attribute": { + "attributes": "Attributi", + "latest-telemetry": "Ultima telemetria", + "attributes-scope": "Visibilità attributi entità", + "scope-latest-telemetry": "Ultima telemetria", + "scope-client": "Attributi client", + "scope-server": "Attributi server", + "scope-shared": "Attributi condivisi", + "add": "Aggiungi attributo", + "key": "Chiave", + "last-update-time": "Ultimo aggiornamento", + "key-required": "Attributo chiave richiesto.", + "value": "Valore", + "value-required": "Attributo valore richiesto.", + "delete-attributes-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 attributo} other {# attributi} }?", + "delete-attributes-text": "Attenzione, dopo la conferma tutti gli attributi selezionati saranno rimossi.", + "delete-attributes": "Elimina attributi", + "enter-attribute-value": "Inserisci il valore dell'attributo", + "show-on-widget": "Mostra sul widget", + "widget-mode": "Modalità Widget", + "next-widget": "Widget successivo", + "prev-widget": "Widget precedente", + "add-to-dashboard": "Aggiungi alla dashboard", + "add-widget-to-dashboard": "Aggiungi widget alla dashboard", + "selected-attributes": "{ count, plural, 1 {1 attributo selezionato} other {# attributi selezionati} }", + "selected-telemetry": "{ count, plural, 1 {1 unità di telemetria selezionata} other {# unità di telemetria selezionate} }" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Log Audit", + "timestamp": "Timestamp", + "entity-type": "Tipo Entità", + "entity-name": "Nome Entità", + "user": "Utente", + "type": "Tipo", + "status": "Stato", + "details": "Dettagli", + "type-added": "Aggiunto", + "type-deleted": "Eliminato", + "type-updated": "Aggiornato", + "type-attributes-updated": "Attributi aggiornati", + "type-attributes-deleted": "Attributi eliminati", + "type-rpc-call": "Chiamata RPC", + "type-credentials-updated": "Credenziali aggiornate", + "type-assigned-to-customer": "Assegnato al Cliente", + "type-unassigned-from-customer": "Assegnazione annullata dal Cliente", + "type-activated": "Attivato", + "type-suspended": "Sospeso", + "type-credentials-read": "Credenziali lette", + "type-attributes-read": "Attributi letti", + "type-relation-add-or-update": "Relazione aggiornata", + "type-relation-delete": "Relazione eliminata", + "type-relations-delete": "Eliminate tutte le relazioni", + "type-alarm-ack": "Confermato", + "type-alarm-clear": "Eliminato", + "type-login": "Accesso", + "type-logout": "Disconnettersi", + "type-lockout": "Bloccato", + "status-success": "Successo", + "status-failure": "Fallito", + "audit-log-details": "Dettaglio log audit", + "no-audit-logs-prompt": "Log non trovati", + "action-data": "Action data", + "failure-details": "Dettagli fallimento", + "search": "Cerca log audit", + "clear-search": "Cancella ricerca" + }, + "confirm-on-exit": { + "message": "Alcune modifiche non sono state salvate. Sei sicuro di voler abbandonare questa pagina?", + "html-message": "Alcune modifiche non sono state salvate.
    Sei sicuro di voler abbandonare questa pagina?", + "title": "Modifiche non salvate" + }, + "contact": { + "country": "Nazione", + "city": "Città", + "state": "Stato / Provincia", + "postal-code": "CAP", + "postal-code-invalid": "Formato CAP non valido.", + "address": "Indirizzo", + "address2": "Indirizzo 2", + "phone": "Telefono", + "email": "Email", + "no-address": "Nessun indirizzo" + }, + "common": { + "username": "Nome utente", + "password": "Password", + "enter-username": "Inserisci nome utente", + "enter-password": "Inserisci password", + "enter-search": "Cerca ...", + "created-time": "Ora di creazione" + }, + "content-type": { + "json": "Json", + "text": "Testo", + "binary": "Binario (Base64)" + }, + "customer": { + "customer": "Cliente", + "customers": "Clienti", + "management": "Gestione cliente", + "dashboard": "Dashboard cliente", + "dashboards": "Dashboard cliente", + "devices": "Dispositivi cliente", + "entity-views": "Viste entità cliente", + "assets": "Asset cliente", + "public-dashboards": "Dashboard pubbliche", + "public-devices": "Dispositivi pubblici", + "public-assets": "Asset pubblici", + "public-entity-views": "Viste entità pubbliche", + "add": "Aggiungi cliente", + "delete": "Elimina cliente", + "manage-customer-users": "Gestisci utenti cliente", + "manage-customer-devices": "Gestisci dispositivi cliente", + "manage-customer-dashboards": "Gestisci dashboard cliente", + "manage-public-devices": "Gestisci dispositivi pubblici", + "manage-public-dashboards": "Gestisci dashboard pubbliche", + "manage-customer-assets": "Gestisci asset cliente", + "manage-public-assets": "Gestisci asset pubblici", + "add-customer-text": "Aggiungi nuovo cliente", + "no-customers-text": "Nessun cliente trovato", + "customer-details": "Dettagli cliente", + "delete-customer-title": "Sei sicuro di voler eliminare il cliente '{{customerTitle}}'?", + "delete-customer-text": "Attenzione, dopo la conferma il cliente e tutti i suoi dati non saranno più recuperabili.", + "delete-customers-title": "Sei sicuro di voler cancellare { count, plural, 1 {1 cliente} other {# clienti} }?", + "delete-customers-action-title": "Elimina { count, plural, 1 {1 cliente} other {# clienti} }", + "delete-customers-text": "Attenzione, dopo la conferma tutti i clienti selezionati saranno rimossi e i loro dati non saranno più recuperabili.", + "manage-users": "Gestisci utenti", + "manage-assets": "Gestisci asset", + "manage-devices": "Gestisci dispositivi", + "manage-dashboards": "Gestisci dashboard", + "title": "Titolo", + "title-required": "Titolo obbligatorio.", + "description": "Descrizione", + "details": "Dettagli", + "events": "Eventi", + "copyId": "Copia Id cliente", + "idCopiedMessage": "Id cliente copiato negli appunti", + "select-customer": "Seleziona cliente", + "no-customers-matching": "Nessun cliente corrispondente a '{{entity}}' è stato trovato.", + "customer-required": "Cliente obbligatorio", + "select-default-customer": "Seleziona cliente di default", + "default-customer": "Cliente di default", + "default-customer-required": "Il cliente di default è obbligatorio per il debug della dashboard a livello di Tenant" + }, + "datetime": { + "date-from": "Data da", + "time-from": "Ora da", + "date-to": "Data a", + "time-to": "Ora a" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboard", + "management": "Gestione Dashboard", + "view-dashboards": "Mostra Dashboard", + "add": "Aggiungi Dashboard", + "assign-dashboard-to-customer": "Assegna Dashboard al cliente", + "assign-dashboard-to-customer-text": "Seleziona le dashboard da assegnare al client", + "assign-to-customer-text": "Seleziona il cliente a cui assegnare la/le dashboard", + "assign-to-customer": "Assegna al cliente", + "unassign-from-customer": "Annulla assegnazione al cliente", + "make-public": "Rendi pubblica la dashboard", + "make-private": "Rendi privata la dashboard", + "manage-assigned-customers": "Gestisci clienti assegnati", + "assigned-customers": "Clienti assegnati", + "assign-to-customers": "Assegna Dashboard ai Clienti", + "assign-to-customers-text": "Seleziona i clienti da assegnare alla/alle dashboard", + "unassign-from-customers": "Annulla assegnazione Dashboard ai Clienti", + "unassign-from-customers-text": "Seleziona i clienti di cui annullare l'assegnazione alla/alle dashboard", + "no-dashboards-text": "Nessuna dashboard trovata", + "no-widgets": "Nessun widget configurato", + "add-widget": "Aggiungi nuovo widget", + "title": "Titolo", + "select-widget-title": "Seleziona widget", + "select-widget-subtitle": "Elenco tipi di widget disponibili", + "delete": "Elimina dashboard", + "title-required": "Titolo obbligatorio.", + "description": "Descrizione", + "details": "Dettagli", + "dashboard-details": "Dettagli dashboard", + "add-dashboard-text": "Aggiungi nuova dashboard", + "assign-dashboards": "Assegna dashboard", + "assign-new-dashboard": "Assegna nuova dashboard", + "assign-dashboards-text": "Assegna { count, plural, 1 {1 dashboard} other {# dashboard} } ai clienti", + "unassign-dashboards-action-text": "Annulla assegnazione { count, plural, 1 {1 dashboard} other {# dashboard} } ai clienti", + "delete-dashboards": "Elimina dashboard", + "unassign-dashboards": "Annulla assegnazione dashboard", + "unassign-dashboards-action-title": "Annulla assegnazione { count, plural, 1 {1 dashboard} other {# dashboard} } al cliente", + "delete-dashboard-title": "Sei sicuro di voler cancellare la dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Attenzione, dopo la conferma la dashboard e tutti i suoi dati non saranno più recuperabili.", + "delete-dashboards-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 dashboard} other {# dashboard} }?", + "delete-dashboards-action-title": "Cancella { count, plural, 1 {1 dashboard} other {# dashboard} }", + "delete-dashboards-text": "Attenzione, dopo la conferma tutte le dashboard selezionate saranno eliminate e tutti i loro dati non saranno più recuperabili.", + "unassign-dashboard-title": "Sei sicuro di voler annullare l'assegnazione della dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Dopo la conferma sarà annullata l'assegnazione della dashboard e questa non sarà più accessibile dal cliente.", + "unassign-dashboard": "Annulla assegnazione dashboard", + "unassign-dashboards-title": "Sei sicuro di voler annullare l'assegnazione di { count, plural, 1 {1 dashboard} other {# dashboard} }?", + "unassign-dashboards-text": "Dopo la conferma sarà annullata l'assegnazione di tutte le dashboard selezionate e queste non saranno più accessibili dal cliente.", + "public-dashboard-title": "La Dashboard è ora pubblica", + "public-dashboard-text": "La dashboard {{dashboardTitle}} è ora pubblica e accessibile al link:", + "public-dashboard-notice": "Nota: Ricorda di rendere pubblici i relativi dispositivi per accedere ai loro dati.", + "make-private-dashboard-title": "Sei sicuro di voler rendere privata la dashboard '{{dashboardTitle}}'?", + "make-private-dashboard-text": "Dopo la conferma la dashboard sarà resa privata e non più accessibile dagli altri.", + "make-private-dashboard": "Rendi privata la dashboard", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Seleziona dashboard", + "no-dashboards-matching": "Nessuna dashboard corrispondente a '{{entity}}' è stata trovata.", + "dashboard-required": "Dashboard obbligatoria.", + "select-existing": "Seleziona una dashboard esistente", + "create-new": "Crea nuova dashboard", + "new-dashboard-title": "Titolo nuova dashboard", + "open-dashboard": "Apri dashboard", + "set-background": "Imposta sfondo", + "background-color": "Colore sfondo", + "background-image": "Immagine sfondo", + "background-size-mode": "Modalità dimensione sfondo", + "no-image": "Nessuna immagine selezionata", + "drop-image": "Trascina un'immagine o fai clic per selezionare un file da caricare.", + "settings": "Impostazioni", + "columns-count": "Numero colonne", + "columns-count-required": "Numero colonne obbligatorio.", + "min-columns-count-message": "Ammesso un numero minimo di colonne pari a 10.", + "max-columns-count-message": "Ammesso un numero massimo di colonne pari a 1000.", + "widgets-margins": "Margine tra i widget", + "horizontal-margin": "Margine orizzontale", + "horizontal-margin-required": "Margine orizzontale obbligatorio.", + "min-horizontal-margin-message": "Ammesso un margine orizzontale minimo pari a 0.", + "max-horizontal-margin-message": "Ammesso un margine orizzontale massimo pari a 50.", + "vertical-margin": "Margine verticale", + "vertical-margin-required": "Margine verticale obbligatorio.", + "min-vertical-margin-message": "Ammesso un margine verticale minimo pari a 0.", + "max-vertical-margin-message": "Ammesso un margine verticale massimo pari a 50.", + "autofill-height": "Riempi automaticamente altezza layout", + "mobile-layout": "Impostazioni layout mobile", + "mobile-row-height": "Altezza riga mobile (px)", + "mobile-row-height-required": "Altezza riga mobile è richiesta.", + "min-mobile-row-height-message": "5 pixel è il minimo concesso al valore altezza riga mobile.", + "max-mobile-row-height-message": "200 pixel è il massimo concesso al valore altezza riga mobile.", + "display-title": "Mostra titolo dashboard", + "toolbar-always-open": "Mantieni aperta la barra degli strumenti", + "title-color": "Colore titolo", + "display-dashboards-selection": "Mostra selezione dashboard", + "display-entities-selection": "Mostra selezione entità", + "display-dashboard-timewindow": "Mostra intervallo temporale", + "display-dashboard-export": "Mostra esportazione", + "import": "Importa dashboard", + "export": "Esporta dashboard", + "export-failed-error": "Impossibile esportare la dashboard: {{error}}", + "create-new-dashboard": "Crea nuova dashboard", + "dashboard-file": "File dashboard", + "invalid-dashboard-file-error": "Impossibile importare la dashboard: struttura dati della dashboard non valida.", + "dashboard-import-missing-aliases-title": "Configura alias utilizzati dalla dashboard importata", + "create-new-widget": "Crea nuovo widget", + "import-widget": "Importa widget", + "widget-file": "Widget file", + "invalid-widget-file-error": "Impossibile importare il widget: struttura dati del widget non valida.", + "widget-import-missing-aliases-title": "Configura gli alias utilizzati dai widget importati", + "open-toolbar": "Apri barra degli strumenti", + "close-toolbar": "Chiudi barra degli strumenti", + "configuration-error": "Errore di configurazione", + "alias-resolution-error-title": "Errore di configurazione degli alias della dashboard", + "invalid-aliases-config": "Impossibile trovare un dispositivo corrispondente ad un qualche filtro degli alias.
    Contatta l'amministratore per risolvere il problema.", + "select-devices": "Seleziona dispositivi", + "assignedToCustomer": "Assegnato al cliente", + "assignedToCustomers": "Assegnato ai clienti", + "public": "Pubblico", + "public-link": "Link pubblico", + "copy-public-link": "Copia link pubblico", + "public-link-copied-message": "Link pubblico della dashboard copiato negli appunti", + "manage-states": "Gestisci stati dashboard", + "states": "Stati dashboard", + "search-states": "Ricerca stati dashboard", + "selected-states": "{ count, plural, 1 {1 stato dashboard selezionato} other {# stati dashboard selezionati} }", + "edit-state": "Modifica stato dashboard", + "delete-state": "Elimina stato dashboard", + "add-state": "Aggiungi stato dashboard", + "state": "Stato dashboard", + "state-name": "Nome", + "state-name-required": "Nome stato dashboard obbligatorio.", + "state-id": "Id stato", + "state-id-required": "Id stato dashboard obbligatorio.", + "state-id-exists": "Uno stato della dashboard con lo stesso id è già presente.", + "is-root-state": "Stato radice", + "delete-state-title": "Elimina stato dashboard", + "delete-state-text": "Sei sicuro di voler eliminare lo stato della dashboard di nome '{{stateName}}'?", + "show-details": "Mostra dettagli", + "hide-details": "Nascondi dettagli", + "select-state": "Seleziona stato target", + "state-controller": "Stato controller" + }, + "datakey": { + "settings": "Impostazioni", + "advanced": "Avanzate", + "label": "Etichetta", + "color": "Colore", + "units": "Simbolo speciale da mostrare accanto al valore", + "decimals": "Numero cifre decimali", + "data-generation-func": "Funzione generazione dati", + "use-data-post-processing-func": "Usa funzione dopo il processamento dei dati", + "configuration": "Configurazione data key", + "timeseries": "Serie temporali", + "attributes": "Attributi", + "alarm": "Campi allarme", + "timeseries-required": "Le serie temporali dell'entità sono richieste.", + "timeseries-or-attributes-required": "Le serie temporali o gli attributi dell'entità sono richiesti.", + "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.", + "maximum-function-types": "Massimo { count, plural, 1 {1 tipo di funzione consentito.} other {# tipi di funzione consentiti} }", + "time-description": "timestamp del valore corrente;", + "value-description": "il valore corrente;", + "prev-value-description": "risultato della precedente chiamata alla funzione;", + "time-prev-description": "timestamp del valore precedente;", + "prev-orig-value-description": "valore precedente originale;" + }, + "datasource": { + "type": "Tipo sorgente dati", + "name": "Nome", + "add-datasource-prompt": "Aggiungi una sorgente dati" + }, + "details": { + "edit-mode": "Modalità modifica", + "toggle-edit-mode": "Attiva/disattiva modalità di modifica" + }, + "device": { + "device": "Dispositivo", + "device-required": "Dispositivo richiesto.", + "devices": "Dispositivi", + "management": "Gestione dispositivo", + "view-devices": "Visualizza Dispositivi", + "device-alias": "Alias dispositivo", + "aliases": "Alias dispositivo", + "no-alias-matching": "'{{alias}}' non trovato.", + "no-aliases-found": "Nessun alias trovato.", + "no-key-matching": "'{{key}}' non trovata.", + "no-keys-found": "Nessuna chiave trovata.", + "create-new-alias": "Creane uno nuovo!", + "create-new-key": "Creane una nuova!", + "duplicate-alias-error": "Sono stati trovati dei duplicati dell'alias '{{alias}}'.
    Gli alias di un dispositivo devono essere univoci all'interno della dashboard.", + "configure-alias": "Configura alias '{{alias}}'", + "no-devices-matching": "Nessun dispositivo corrispondente a '{{entity}}' è stato trovato.", + "alias": "Alias", + "alias-required": "Alias dispositivo richiesto.", + "remove-alias": "Rimuovi alias dispositivo", + "add-alias": "Aggiungi alias dispositivo", + "name-starts-with": "Dispositivo il cui nome inizia per", + "device-list": "Lista dispositivi", + "use-device-name-filter": "Usa filtro", + "device-list-empty": "Nessun dispositivo selezionato.", + "device-name-filter-required": "Filtro nome dispositivo obbligatorio.", + "device-name-filter-no-device-matched": "Nessun dispositivo il cui nome inizia per '{{device}}' è stato trovato.", + "add": "Aggiungi Dispositivo", + "assign-to-customer": "Assegna al cliente", + "assign-device-to-customer": "Assegna dispositivo/dispositivi al Cliente", + "assign-device-to-customer-text": "Seleziona i dispositivi da assegnare al cliente", + "make-public": "Rendi pubblico il dispositivo", + "make-private": "Rendi privato il dispositivo", + "no-devices-text": "Nessun dispositivo trovato", + "assign-to-customer-text": "Seleziona il cliente a cui assegnare il dispositivo/i dispositivi", + "device-details": "Dettagli dispositivo", + "add-device-text": "Aggiungi nuovo dispositivo", + "credentials": "Credenziali", + "manage-credentials": "Gestisci credenziali", + "delete": "Elimina dispositivo", + "assign-devices": "Assegna dispositivi", + "assign-devices-text": "Assegna { count, plural, 1 {1 dispositivo} other {# dispositivi} } al cliente", + "delete-devices": "Elimina dispositivi", + "unassign-from-customer": "Annulla assegnazione al cliente", + "unassign-devices": "Annulla assegnazione dispositivi", + "unassign-devices-action-title": "Annulla assegnazione { count, plural, 1 {1 dispositivo} other {# dispositivi} } al cliente", + "assign-new-device": "Assegna nuovo dispositivo", + "make-public-device-title": "Sei sicuro di voler rendere pubblico il dispositivo '{{deviceName}}'?", + "make-public-device-text": "Dopo la conferma il dispositivo e tutti i suoi dati saranno resi pubblici e accessibili dagli altri.", + "make-private-device-title": "Sei sicuro di voler rendere privato il dispositivo '{{deviceName}}'?", + "make-private-device-text": "Dopo la conferma il dispositivo e tutti i suoi dati saranno resi privati e non più accessibili da altri utenti.", + "view-credentials": "Visualizza credenziali", + "delete-device-title": "Sei sicuro di voler eliminare il dispositivo '{{deviceName}}'?", + "delete-device-text": "Attenzione, dopo la conferma il dispositivo e tutti i suoi dati non saranno più recuperabili.", + "delete-devices-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 dispositivo} other {# dispositivi} }?", + "delete-devices-action-title": "Elimina { count, plural, 1 {1 dispositivo} other {# dispositivi} }", + "delete-devices-text": "Attenzione, dopo la conferma tutti i dispositivi selezionati saranno eliminati e i relativi dati non saranno più recuperabili.", + "unassign-device-title": "Sei sicuro di voler annullare l'assegnazione del dispositivo '{{deviceName}}'?", + "unassign-device-text": "Dopo la conferma sarà annullata l'assegnazione del dispositivo e questo non sarà più accessibile dal cliente.", + "unassign-device": "Annulla assegnazione dispositivo", + "unassign-devices-title": "Sei sicuro di voler annullare l'assegnazione di { count, plural, 1 {1 dispositivo} other {# dispositivi} }?", + "unassign-devices-text": "Dopo la conferma sarà annullata l'assegnazione di tutti i dispositivi selezionati e questi non saranno più accessibili dal cliente.", + "device-credentials": "Credenziali Dispositivo", + "credentials-type": "Tipo credenziali", + "access-token": "Token di accesso", + "access-token-required": "Token di accesso obbligatorio.", + "access-token-invalid": "Il token di accesso deve avere una lunghezza compresa tra 1 e 32 caratteri.", + "secret": "Secret", + "secret-required": "Secret obbligatorio.", + "device-type": "Tipo dispositivo", + "device-type-required": "Tipo dispositivo obbligatorio.", + "select-device-type": "Seleziona tipo dispositivo", + "enter-device-type": "Inserisci typo dispositivo", + "any-device": "Qualsiasi dispositivo", + "no-device-types-matching": "Nessun dispositivo corrispondente a '{{entitySubtype}}' è stato trovato.", + "device-type-list-empty": "Nessun tipo di dispositivo selezionato.", + "device-types": "Tipi dispositivo", + "name": "Nome", + "name-required": "Nome obbligatorio.", + "description": "Descrizione", + "events": "Eventi", + "details": "Dettagli", + "copyId": "Copia Id dispositivo", + "copyAccessToken": "Copia token di accesso", + "idCopiedMessage": "Id dispositivo copiato negli Appunti", + "accessTokenCopiedMessage": "Token di accesso del dispositivo copiato negli Appunti", + "assignedToCustomer": "Assegnato al cliente", + "unable-delete-device-alias-title": "Impossibile rimuovere l'alias del dispositivo", + "unable-delete-device-alias-text": "L'alias del dispositivo '{{deviceAlias}}' non può essere eliminato perché utilizzato dai seguenti widget:
    {{widgetsList}}", + "is-gateway": "È un gateway", + "public": "Pubblico", + "device-public": "Il dispositivo è pubblico", + "select-device": "Seleziona dispositivo" + }, + "dialog": { + "close": "Close dialog" + }, + "direction": { + "column": "Colonna", + "row": "Riga" + }, + "error": { + "unable-to-connect": "Impossibile connettersi al server! Controlla la connessione ad Internet.", + "unhandled-error-code": "Codice errore non gestito: {{errorCode}}", + "unknown-error": "Errore sconosciuto" + }, + "entity": { + "entity": "Entità", + "entities": "Entità", + "aliases": "Alias entità", + "entity-alias": "Alias entità", + "unable-delete-entity-alias-title": "Impossibile eliminare alias entità", + "unable-delete-entity-alias-text": "L'alias dell'entità '{{entityAlias}}' non può essere eliminato perché utilizzato dai seguenti widget:
    {{widgetsList}}", + "duplicate-alias-error": "Trovato un duplicato dell'alias '{{alias}}'.
    Gli alias dell'entità devono essere univoci all'interno della dashboard.", + "missing-entity-filter-error": "Manca il filtro per l'alias '{{alias}}'.", + "configure-alias": "Configura '{{alias}}' alias", + "alias": "Alias", + "alias-required": "Alias entità obbligatorio.", + "remove-alias": "Rimuovi alias entità", + "add-alias": "Aggiungi alias entità", + "entity-list": "Lista entità", + "entity-type": "Tipo entità", + "entity-types": "Tipi entità", + "entity-type-list": "Lista tipo entità", + "any-entity": "Qualsiasi entità", + "enter-entity-type": "Inserisci tipo entità", + "no-entities-matching": "Nessuna entità corrispondente a '{{entity}}' è stata trovata.", + "no-entity-types-matching": "Nessun tipo di entità corrispondente a '{{entityType}}' è stato trovato.", + "name-starts-with": "Nome inizia per", + "use-entity-name-filter": "Usa filtro", + "entity-list-empty": "Nessuna entità selezionata.", + "entity-type-list-empty": "Nessun tipo di entità selezionato.", + "entity-name-filter-required": "Filtro nome entità obbligatorio.", + "entity-name-filter-no-entity-matched": "Nessuna entità che inizia per '{{entity}}' è stata trovata.", + "all-subtypes": "Tutte", + "select-entities": "Seleziona entità", + "no-aliases-found": "Nessun alias trovato.", + "no-alias-matching": "'{{alias}}' non trovato.", + "create-new-alias": "Creane uno nuovo!", + "key": "Chiave", + "key-name": "Nome chiave", + "no-keys-found": "Nessuna chiave trovata.", + "no-key-matching": "'{{key}}' non trovata.", + "create-new-key": "Creane una nuova!", + "type": "Tipo", + "type-required": "Tipo entità obbligatorio.", + "type-device": "Dispositivo", + "type-devices": "Dispositivi", + "list-of-devices": "{ count, plural, 1 {Un dispositivo} other {Lista di # dispositivi} }", + "device-name-starts-with": "Dispositivi i cui nomi iniziano per '{{prefix}}'", + "type-asset": "Asset", + "type-assets": "Asset", + "list-of-assets": "{ count, plural, 1 {Un asset} other {Lista di # asset} }", + "asset-name-starts-with": "Asset i cui nomi iniziano per '{{prefix}}'", + "type-entity-view": "Vista entità", + "type-entity-views": "Viste entità", + "list-of-entity-views": "{ count, plural, 1 {Una vista entità} other {Lista di # viste entità} }", + "entity-view-name-starts-with": "Viste entità i cui nomi iniziano per '{{prefix}}'", + "type-rule": "Regola", + "type-rules": "Regole", + "list-of-rules": "{ count, plural, 1 {Una regola} other {Lista di # regole} }", + "rule-name-starts-with": "Regole i cui nomi iniziano per '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugin", + "list-of-plugins": "{ count, plural, 1 {Un plugin} other {Lista di # plugin} }", + "plugin-name-starts-with": "Plugin i cui nomi iniziano per '{{prefix}}'", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {Lista di # tenants} }", + "tenant-name-starts-with": "Tenants whose names start with '{{prefix}}'", + "type-customer": "Cliente", + "type-customers": "Clienti", + "list-of-customers": "{ count, plural, 1 {Un cliente} other {Lista di # clienti} }", + "customer-name-starts-with": "Clienti i cui nomi iniziano per '{{prefix}}'", + "type-user": "Utente", + "type-users": "Utenti", + "list-of-users": "{ count, plural, 1 {Un utente} other {Lista di # utenti} }", + "user-name-starts-with": "Utenti i cui nomi iniziano per '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboard", + "list-of-dashboards": "{ count, plural, 1 {Una dashboard} other {Lista di # dashboard} }", + "dashboard-name-starts-with": "Dashboard i cui nomi iniziano per '{{prefix}}'", + "type-alarm": "Allarme", + "type-alarms": "Allarmi", + "list-of-alarms": "{ count, plural, 1 {Un allarme} other {Lista di # allarmi} }", + "alarm-name-starts-with": "Allarmi i cui nomi iniziano per '{{prefix}}'", + "type-rulechain": "Rule chain", + "type-rulechains": "Rule chain", + "list-of-rulechains": "{ count, plural, 1 {Una rule chain} other {Lista di # catene di regole} }", + "rulechain-name-starts-with": "Catene di regole i cui nomi iniziano per '{{prefix}}'", + "type-rulenode": "Nodo regola", + "type-rulenodes": "Nodi regola", + "list-of-rulenodes": "{ count, plural, 1 {Un nodo regola} other {Lista di # nodi regola} }", + "rulenode-name-starts-with": "Nodi regola i cui nomi iniziano per '{{prefix}}'", + "type-current-customer": "Cliente attuale", + "search": "Ricerca entità", + "selected-entities": "{ count, plural, 1 {1 entità selezionata} other {# entità selezionate} }", + "entity-name": "Nome entità", + "details": "Dettagli entità", + "no-entities-prompt": "Nessuna entità trovata", + "no-data": "Nessun dato da mostrare", + "columns-to-display": "Colonne da mostrare" + }, + "entity-view": { + "entity-view": "Vista entità", + "entity-view-required": "Vista entità richiesta.", + "entity-views": "Viste entità", + "management": "Gestione viste entità", + "view-entity-views": "Visualizza Viste entità", + "entity-view-alias": "Alias vista entità", + "aliases": "Alias vista entità", + "no-alias-matching": "'{{alias}}' non trovato.", + "no-aliases-found": "Nessun alias trovato.", + "no-key-matching": "'{{key}}' non trovata.", + "no-keys-found": "Nessuna chiave trovata.", + "create-new-alias": "Creane uno nuovo!", + "create-new-key": "Creane una nuova!", + "duplicate-alias-error": "Sono stati trovati dei duplicati dell'alias '{{alias}}'.
    Gli alias di una vista entità devono essere univoci all'interno della dashboard.", + "configure-alias": "Configura alias '{{alias}}'", + "no-entity-views-matching": "Nessuna vista entità corrispondente a '{{entity}}' è stata trovato.", + "alias": "Alias", + "alias-required": "Alias vista entità richiesto.", + "remove-alias": "Rimuovi alias vista entità", + "add-alias": "Aggiungi alias vista entità", + "name-starts-with": "Vista entità il cui nome inizia per", + "entity-view-list": "Lista viste entità", + "use-entity-view-name-filter": "Usa filtro", + "entity-view-list-empty": "Nessuna vista entità selezionata.", + "entity-view-name-filter-required": "Filtro nome vista entità obbligatorio.", + "entity-view-name-filter-no-entity-view-matched": "Nessuna vista entità il cui nome inizia per '{{entity-view}}' è stata trovata.", + "add": "Aggiungi Vista entità", + "assign-to-customer": "Assegna al cliente", + "assign-entity-view-to-customer": "Assegna vista entità/viste entità al Cliente", + "assign-entity-view-to-customer-text": "Seleziona la vista entità da assegnare al cliente", + "no-entity-views-text": "Nessuna vista entità trovata", + "assign-to-customer-text": "Seleziona il cliente a cui assegnare la vista entità/le vista entità", + "entity-view-details": "Dettagli vista entità", + "add-entity-view-text": "Aggiungi nuova vista entità", + "delete": "Elimina vista entità", + "assign-entity-views": "Assegna viste entità", + "assign-entity-views-text": "Assegna { count, plural, 1 {1 vista entità} other {# viste entità} } al cliente", + "delete-entity-views": "Elimina viste entità", + "unassign-from-customer": "Annulla assegnazione al cliente", + "unassign-entity-views": "Annulla assegnazione viste entità", + "unassign-entity-views-action-title": "Annulla assegnazione { count, plural, 1 {1 vista entità} other {# viste entità} } al cliente", + "assign-new-entity-view": "Assegna nuova vista entità", + "delete-entity-view-title": "Sei sicuro di voler eliminare la vista entità '{{entity-viewName}}'?", + "delete-entity-view-text": "Attenzione, dopo la conferma la vista entità e tutti i suoi dati non saranno più recuperabili.", + "delete-entity-views-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 vista entità} other {# viste entità} }?", + "delete-entity-views-action-title": "Elimina { count, plural, 1 {1 vista entità} other {# viste entità} }", + "delete-entity-views-text": "Attenzione, dopo la conferma tutte le vista entità selezionati saranno eliminate e i relativi dati non saranno più recuperabili.", + "unassign-entity-view-title": "Sei sicuro di voler annullare l'assegnazione della vista entità '{{entity-viewName}}'?", + "unassign-entity-view-text": "Dopo la conferma sarà annullata l'assegnazione della vista entità e questa non sarà più accessibile dal cliente.", + "unassign-entity-view": "Annulla assegnazione vista entità", + "unassign-entity-views-title": "Sei sicuro di voler annullare l'assegnazione di { count, plural, 1 {1 vista entità} other {# viste entità} }?", + "unassign-entity-views-text": "Dopo la conferma sarà annullata l'assegnazione di tutte le vista entità selezionate e queste non saranno più accessibili dal cliente.", + "entity-view-type": "Tipo vista entità", + "entity-view-type-required": "Tipo vista entità obbligatorio.", + "select-entity-view-type": "Seleziona tipo vista entità", + "enter-entity-view-type": "Inserisci tipo vista entità", + "any-entity-view": "Qualsiasi vista entità", + "no-entity-view-types-matching": "Nessuna vista entità corrispondente a '{{entitySubtype}}' è stata trovata.", + "entity-view-type-list-empty": "Nessun tipo di vista entità selezionato.", + "entity-view-types": "Tipi vista entità", + "name": "Nome", + "name-required": "Nome obbligatorio.", + "description": "Descrizione", + "events": "Eventi", + "details": "Dettagli", + "copyId": "Copia Id vista entità", + "assignedToCustomer": "Assegnata al cliente", + "unable-entity-view-device-alias-title": "Impossibile rimuovere l'alias del vista entità", + "unable-entity-view-device-alias-text": "L'alias del vista entità '{{entity-viewAlias}}' non può essere eliminato perché utilizzato dai seguenti widget:
    {{widgetsList}}", + "select-entity-view": "Seleziona vista entità", + "make-public": "Rendi pubblica la vista entità", + "make-private": "Rendi privata la vista entità", + "start-date": "Data inizio", + "start-ts": "Ora inizio", + "end-date": "Data fine", + "end-ts": "Ora fine", + "date-limits": "Limiti temporali", + "client-attributes": "Attributi cliente", + "shared-attributes": "Attributi condivisi", + "server-attributes": "Attributi server", + "timeseries": "Serie temporali", + "client-attributes-placeholder": "Attributi cliente", + "shared-attributes-placeholder": "Attributi condivisi", + "server-attributes-placeholder": "Attributi server", + "timeseries-placeholder": "Serie temporali", + "target-entity": "Entità target", + "attributes-propagation": "Propagazione degli attributi", + "attributes-propagation-hint": "La vista entità copierà automaticamente gli attributi specificati dall'entità target ogni volta che questa vista entità sarà salvata e aggiornata. Per ragioni di performance, gli attributi dell'entità target non sono propagati alle viste entità ogni cambiamento di attributo. È possibile abilitare la propagazione automatica configurando il nodo regola \"Copia alla vista\" nella rule chain e collegando i messaggi \"Post attributes\" a \"Attributes Updated\" al nuovo nodo regola.", + "timeseries-data": "Dati delle serie temporali", + "timeseries-data-hint": "Imposta le chiavi delle serie temporali dell'entità target che saranno accessibili alla vista entità. Questi dati sono di sola lettura.", + "make-public-entity-view-title": "Sei sicuro di voler rendere pubblica la vista entità '{{entity-viewName}}'?", + "make-public-entity-view-text": "Dopo la conferma la vista entità e tutti i suoi dati saranno resi pubblici e accessibili dagli altri.", + "make-private-entity-view-title": "Sei sicuro di voler rendere privata la vista entità '{{entity-viewName}}'?", + "make-private-entity-view-text": "Dopo la conferma la vista entità e tutti i suoi dati saranno resi privati e non più accessibili da altri utenti." + }, + "event": { + "event-type": "Tipo evento", + "type-error": "Errore", + "type-lc-event": "Ciclo di vita evento", + "type-stats": "Statistiche", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "Nessun evento trovato", + "error": "Errore", + "alarm": "Allarme", + "event-time": "Orario evento", + "server": "Server", + "body": "Body", + "method": "Metodo", + "type": "Tipo", + "message-id": "Id Messaggio", + "message-type": "Tipo Messaggio", + "data-type": "Tipo di dato", + "relation-type": "Tipo di relazione", + "metadata": "Metadati", + "data": "Dati", + "event": "Evento", + "status": "Stato", + "success": "Success", + "failed": "Failed", + "messages-processed": "Messaggi elaborati", + "errors-occurred": "Si sono verificati degli errori", + "all-events": "Tutte", + "entity-type": "Tipo entità" + }, + "extension": { + "extensions": "Estensioni", + "selected-extensions": "{ count, plural, 1 {1 estensione selezionata} other {# estensioni selezionate} }", + "type": "Tipo", + "key": "Chiave", + "value": "Valore", + "id": "Id", + "extension-id": "Id Estensione", + "extension-type": "Tipo Estensione", + "transformer-json": "JSON *", + "unique-id-required": "Id estensione corrente già esistente.", + "delete": "Elimina estensione", + "add": "Aggiungi estensione", + "edit": "Modifica estensione", + "delete-extension-title": "Sei sicuro di voler eliminare l'estensione '{{extensionId}}'?", + "delete-extension-text": "Attenzione, dopo la conferma l'estensione e tutti i suoi data non saranno più recuperabili.", + "delete-extensions-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 estensione} other {# estensioni} }?", + "delete-extensions-text": "Attenzione, dopo la conferma tutte le estensioni selezionate saranno eliminate.", + "converters": "Convertitori", + "converter-id": "Id convertitore", + "configuration": "Configurazione", + "converter-configurations": "Configurazioni convertitore", + "token": "Token di sicurezza", + "add-converter": "Aggiungi convertitore", + "add-config": "Aggiungi configurazione convertitore", + "device-name-expression": "Espressione nome dispositivo", + "device-type-expression": "Espressione tipo dispositivo", + "custom": "Custom", + "to-double": "To Double", + "transformer": "Transformer", + "json-required": "Transformer json is required.", + "json-parse": "Unable to parse transformer json.", + "attributes": "Attributi", + "add-attribute": "Aggiungi attributo", + "add-map": "Add mapping element", + "timeseries": "Serie temporali", + "add-timeseries": "Add timeseries", + "field-required": "Campo obbligatorio", + "brokers": "Broker", + "add-broker": "Aggiungi broker", + "host": "Host", + "port": "Porta", + "port-range": "Il numero di porta deve essere compreso tra 1 e 65535.", + "ssl": "Ssl", + "credentials": "Credenziali", + "username": "Nome utente", + "password": "Password", + "retry-interval": "Intervallo di ripetizione in millisecondi", + "anonymous": "Anonimo", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "File certificato CA *", + "private-key": "File chiave privata *", + "cert": "File certificato *", + "no-file": "Nessun file selezionato.", + "drop-file": "Trascina un file o fai clic per selezionare un file da caricare.", + "mapping": "Mapping", + "topic-filter": "Filtro topic", + "converter-type": "Tipo convertitore", + "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 millisecondi", + "converter-json-required": "Convertitore json obbligatorio.", + "converter-json-parse": "Unable to parse converter json.", + "filter-expression": "Filter expression", + "connect-requests": "Richieste di connessione", + "add-connect-request": "Aggiungi richiesta di connessione", + "disconnect-requests": "Richieste di disconnessione", + "add-disconnect-request": "Aggiungi richiesta di disconnessione", + "attribute-requests": "Attribute requests", + "add-attribute-request": "Add attribute request", + "attribute-updates": "Attribute updates", + "add-attribute-update": "Add attribute update", + "server-side-rpc": "RPC lato server", + "add-server-side-rpc-request": "Aggiungi richiesta RPC server-side", + "device-name-filter": "Filtro nome dispositivo", + "attribute-filter": "Filtro attributo", + "method-filter": "Filtro metodo", + "request-topic-expression": "Request topic expression", + "response-timeout": "Timeout risposta in millisecondi", + "topic-expression": "Topic expression", + "client-scope": "Visibilità client", + "add-device": "Aggiungi dispositivo", + "opc-server": "Server", + "opc-add-server": "Aggiungi server", + "opc-add-server-prompt": "Aggiungi server", + "opc-application-name": "Nome applicazione", + "opc-application-uri": "Uri applicazione", + "opc-scan-period-in-seconds": "Intervallo di scansione in secondi", + "opc-security": "Sicurezza", + "opc-identity": "Identità", + "opc-keystore": "Keystore", + "opc-type": "Tipo", + "opc-keystore-type": "Tipo", + "opc-keystore-location": "Location *", + "opc-keystore-password": "Password", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Chiave password", + "opc-device-node-pattern": "Device node pattern", + "opc-device-name-pattern": "Device name pattern", + "modbus-server": "Server/slave", + "modbus-add-server": "Aggiungi server/slave", + "modbus-add-server-prompt": "Aggiungi server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Riconnessione automatica", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "Nome porta seriale", + "modbus-encoding": "Codifica", + "modbus-parity": "Parità", + "modbus-baudrate": "Baud rate", + "modbus-databits": "Data bits", + "modbus-stopbits": "Stop bits", + "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": "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": "Indirizzo registro deve essere compreso tra 0 e 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "Bit index deve essere compreso tra 0 e 15.", + "modbus-register-count": "Register count", + "modbus-register-count-range": "Register count dovrebbe essereun valore positivo.", + "modbus-byte-order": "Byte order", + "sync": { + "status": "Stato", + "sync": "Sincronizzato", + "not-sync": "Non sincronizzato", + "last-sync-time": "Ultima sincronizzazione", + "not-available": "Non disponibile" + }, + "export-extensions-configuration": "Esporta configurazione estensioni", + "import-extensions-configuration": "Importa configurazione estensioni", + "import-extensions": "Importa estensione", + "import-extension": "Importa estensione", + "export-extension": "Esporta estensione", + "file": "File estensione", + "invalid-file-error": "File estensione non valido" + }, + "fullscreen": { + "expand": "Espandi a tutto schermo", + "exit": "Esci da schermo intero", + "toggle": "Commuta modalità schermo intero", + "fullscreen": "Schermo intero" + }, + "function": { + "function": "Funzione" + }, + "grid": { + "delete-item-title": "Sei sicuro di voler eliminare questo elemento?", + "delete-item-text": "Attenzione, dopo la conferma questo elemento e tutti i suoi dati non saranno più recuperabili.", + "delete-items-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 elemento} other {# elementi} }?", + "delete-items-action-title": "Elimina { count, plural, 1 {1 elemento} other {# elementi} }", + "delete-items-text": "Attenzione, dopo la conferma tutti gli elementi selezionati saranno rimossi e i relativi dati non saranno più recuperabili.", + "add-item-text": "Aggiungi nuovo elemento", + "no-items-text": "Nessun elemento trovato", + "item-details": "Dettagli elemento", + "delete-item": "Elimina elemento", + "delete-items": "Elimina elementi", + "scroll-to-top": "Scorri verso l'alto" + }, + "help": { + "goto-help-page": "Vai all'help" + }, + "home": { + "home": "Home", + "profile": "Profilo", + "logout": "Logout", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Apri menu utente" + }, + "import": { + "no-file": "Nessun file selezionato", + "drop-file": "Trascina un file JSON o fai clic per selezionare un file da caricare." + }, + "item": { + "selected": "Selezionata" + }, + "js-func": { + "no-return-error": "La funzione deve restituire un valore!", + "return-type-mismatch": "La funzione deve restituire un valore di tipo '{{type}}'!", + "tidy": "Tidy" + }, + "key-val": { + "key": "Chiave", + "value": "Valore", + "remove-entry": "Rimuovi voce", + "add-entry": "Aggiungi voce", + "no-data": "Nessuna voce" + }, + "layout": { + "layout": "Layout", + "manage": "Gestisci layout", + "settings": "Impostazioni layout", + "color": "Colore", + "main": "Main", + "right": "Destra", + "select": "Select target layout" + }, + "legend": { + "direction": "Direzione", + "position": "Posizione Legenda", + "show-max": "Mostra valore max", + "show-min": "Mostra valore min", + "show-avg": "Mostra valore medio", + "show-total": "Mostra valore totale", + "settings": "Impostazioni legenda", + "min": "min", + "max": "max", + "avg": "avg", + "total": "totale" + }, + "login": { + "login": "Accedi", + "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", + "sign-in": "Please sign in", + "username": "Nome utente (email)", + "remember-me": "Ricordami", + "forgot-password": "Password dimenticata?", + "password-reset": "Password reset", + "new-password": "Nuova password", + "new-password-again": "Ripeti nuova password", + "password-link-sent-message": "Link reset password inviato con successo!", + "email": "Email", + "login-with": "Accedi con {{name}}", + "or": "o" + }, + "position": { + "top": "Alto", + "bottom": "Basso", + "left": "Sinistra", + "right": "Destra" + }, + "profile": { + "profile": "Profilo", + "last-login-time": "Ultimo accesso", + "change-password": "Modifica Password", + "current-password": "Password attuale" + }, + "relation": { + "relations": "Relazioni", + "direction": "Direzione", + "search-direction": { + "FROM": "Da", + "TO": "A" + }, + "direction-type": { + "FROM": "da", + "TO": "a" + }, + "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à", + "from-entity-type": "Da tipo entità", + "from-entity-name": "Da nome entità", + "to-entity": "A entità", + "from-entity": "Da entità", + "delete": "Elimina relazione", + "relation-type": "Tipo di relazione", + "relation-type-required": "Tipo di relazione obbligatorio.", + "any-relation-type": "Ogni tipo", + "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": "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", + "rulechains": "Rule chain", + "root": "Root", + "delete": "Cancella rule chain", + "name": "Nome", + "name-required": "Nome obbligatorio.", + "description": "Descrizione", + "add": "Aggiungi Rule Chain", + "set-root": "Imposta la rule chain come root", + "set-root-rulechain-title": "Sei sicuro di voler impostare la rule chain '{{ruleChainName}}' come root?", + "set-root-rulechain-text": "Dopo la conferma la rule chain diverrà root a gestirà tutti i messaggi in arrivo.", + "delete-rulechain-title": "Sei sicuro di voler eliminare la rule chain '{{ruleChainName}}'?", + "delete-rulechain-text": "Attenzione, dopo la conferma la rule chain e tutti i dati relativi non saranno più recuperabili.", + "delete-rulechains-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 rule chain} other {# rule chain} }?", + "delete-rulechains-action-title": "Elimina { count, plural, 1 {1 rule chain} other {# rule chain} }", + "delete-rulechains-text": "Attenzione, dopo la conferma tutte le rule chain selezionate saranno rimosse e tutti i relativi dati non saranno più recuperabili.", + "add-rulechain-text": "Aggiungi nuova rule chain", + "no-rulechains-text": "Nessuna rule chain trovata", + "rulechain-details": "Dettagli rule chain", + "details": "Dettagli", + "events": "Eventi", + "system": "Sistema", + "import": "Importa rule chain", + "export": "Esporta rule chain", + "export-failed-error": "Impossibile esportare rule chain: {{error}}", + "create-new-rulechain": "Crea nuova rule chain", + "rulechain-file": "File rule chain", + "invalid-rulechain-file-error": "Impossibile importare rule chain: struttura dati rule chain non valida.", + "copyId": "Copia Id rule chain", + "idCopiedMessage": "Id rule chain copiato negli appunti", + "select-rulechain": "Seleziona rule chain", + "no-rulechains-matching": "Nessuna rule chain corrispondente a '{{entity}}' è stata trovata.", + "rulechain-required": "Rule chain obbligatoria", + "management": "Gestione regole", + "debug-mode": "Modalità debug" + }, + "rulenode": { + "details": "Dettagli", + "events": "Eventi", + "search": "Ricerca nodi", + "open-node-library": "Apri libreria nodi", + "add": "Aggiungi nodo regola", + "name": "Nome", + "name-required": "Nome obbligatorio.", + "type": "Tipo", + "description": "Descrizione", + "delete": "Elimina nodo regola", + "select-all-objects": "Seleziona tutti i nodi e le connessioni", + "deselect-all-objects": "Deseleziona tutti i nodi e le connessioni", + "delete-selected-objects": "Cancella nodi e connessioni selezionate", + "delete-selected": "Elimina selezionati", + "select-all": "Seleziona tutto", + "copy-selected": "Copia selezionata", + "deselect-all": "Deseleziona tutto", + "rulenode-details": "Dettagli nodo regola", + "debug-mode": "Modalità debug", + "configuration": "Configurazione", + "link": "Link", + "link-details": "Dettagli link nodo regola", + "add-link": "Aggiungi link", + "link-label": "Etichetta link", + "link-label-required": "Etichetta link obbligatoria.", + "custom-link-label": "Etichetta link personalizzata", + "custom-link-label-required": "Etichetta link personalizzata obbligatoria.", + "link-labels": "Etichette link", + "link-labels-required": "Etichette link richieste.", + "no-link-labels-found": "Nessuna etichetta link trovata.", + "no-link-label-matching": "'{{label}}' non trovata.", + "create-new-link-label": "Creane una nuova!", + "type-filter": "Filtro", + "type-filter-details": "Filtra i messaggi in arrivo con le condizioni configurate", + "type-enrichment": "Enrichment", + "type-enrichment-details": "Aggiungi informazioni addizionali nei metadati del messaggio", + "type-transformation": "Trasformazione", + "type-transformation-details": "Modifica payload messaggio e metadati", + "type-action": "Azioni", + "type-action-details": "Perform special action", + "type-external": "External", + "type-external-details": "Interagisci con un sistema esterno", + "type-rule-chain": "Rule Chain", + "type-rule-chain-details": "Inoltra i messaggi in arrivo ad una specifica Rule Chain", + "type-input": "Input", + "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", + "type-unknown": "Sconosciuto", + "type-unknown-details": "Nodo regola non trovato", + "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": "Messaggio", + "message-type": "Tipo messaggio", + "select-message-type": "Seleziona tipo messaggio", + "message-type-required": "Tipo messaggio obbligatorio", + "metadata": "Metadata", + "metadata-required": "Metadata entries can't be empty.", + "output": "Output", + "test": "Test", + "help": "Aiuto" + }, + "tenant": { + "tenant": "Tenant", + "tenants": "Tenant", + "management": "Gestione Tenant", + "add": "Aggiungi Tenant", + "admins": "Amministratori", + "manage-tenant-admins": "Gestisci amministratori tenant", + "delete": "Cancella tenant", + "add-tenant-text": "Aggiungi nuovo tenant", + "no-tenants-text": "Nessun tenant trovato", + "tenant-details": "Dettagli tenant", + "delete-tenant-title": "Sei sicuro di voler eliminare il tenant '{{tenantTitle}}'?", + "delete-tenant-text": "Attenzione, dopo la conferma il tenant e tutti i suoi dati non saranno più recuperabili.", + "delete-tenants-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 tenant} other {# tenant} }?", + "delete-tenants-action-title": "Elimina { count, plural, 1 {1 tenant} other {# tenant} }", + "delete-tenants-text": "Attenzione, dopo la conferma tutti i tenant selezionati saranno eliminati e tutti i loro dati non saranno più recuperabili.", + "title": "Titolo", + "title-required": "Titolo obbligatorio.", + "description": "Descrizione", + "details": "Dettagli", + "events": "Eventi", + "copyId": "Copia Id tenant", + "idCopiedMessage": "Id tenant copiato negli appunti", + "select-tenant": "Seleziona tenant", + "no-tenants-matching": "Nessun tenant corrispondente a '{{entity}}' è stato trovato.", + "tenant-required": "Tenant obbligatorio" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 secondo} other {# secondi} }", + "minutes-interval": "{ minutes, plural, 1 {1 minuto} other {# minuti} }", + "hours-interval": "{ hours, plural, 1 {1 ora} other {# ore} }", + "days-interval": "{ days, plural, 1 {1 giorno} other {# giorni} }", + "days": "Giorni", + "hours": "Ore", + "minutes": "Minuti", + "seconds": "Secondi", + "advanced": "Avanzate" + }, + "timewindow": { + "days": "{ days, plural, 1 { giorno } other {# giorni } }", + "hours": "{ hours, plural, 0 { ora } 1 {1 ora } other {# ore } }", + "minutes": "{ minutes, plural, 0 { minuto } 1 {1 minuto } other {# minuti } }", + "seconds": "{ seconds, plural, 0 { secondo } 1 {1 secondo } other {# secondi } }", + "realtime": "Realtime", + "history": "Cronologia", + "last-prefix": "ultimo", + "period": "da {{ startTime }} a {{ endTime }}", + "edit": "Modifica intervallo temporale", + "date-range": "Intervallo date", + "last": "Ultimo", + "time-period": "Intervallo temporale", + "hide": "Nascondi" + }, + "user": { + "user": "Utente", + "users": "Utenti", + "customer-users": "Utente cliente", + "tenant-admins": "Amministratori Tenant", + "sys-admin": "Amministratore di sistema", + "tenant-admin": "Amministratore tenant", + "customer": "Cliente", + "anonymous": "Anonimo", + "add": "Aggiungi Utente", + "delete": "Elimina utente", + "add-user-text": "Aggiungi nuovo utente", + "no-users-text": "Nessun utente trovato", + "user-details": "Dettagli utente", + "delete-user-title": "Sei sicuro di voler eliminare l'utente '{{userEmail}}'?", + "delete-user-text": "Attenzione, dopo la conferma l'utente e tutti i suoi dati non saranno più recuperabili.", + "delete-users-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 utente} other {# utenti} }?", + "delete-users-action-title": "Elimina { count, plural, 1 {1 utente} other {# utenti} }", + "delete-users-text": "Attenzione, dopo la conferma tutti gli utenti selezionati saranno eliminati e tutti i relativi dati non saranno più recuperabili.", + "activation-email-sent-message": "Email di attivazione inviata con successo!", + "resend-activation": "Invia di nuovo attivazione", + "email": "Email", + "email-required": "Email obbligatoria.", + "invalid-email-format": "Formato email non valido.", + "first-name": "Nome", + "last-name": "Cognome", + "description": "Descrizione", + "default-dashboard": "Dashboard di default", + "always-fullscreen": "Sempre a schermo intero", + "select-user": "Seleziona utente", + "no-users-matching": "Nessun utente corrispondente a '{{entity}}' è stato trovato.", + "user-required": "Utente obbligatorio", + "activation-method": "Metodo di attivazione", + "display-activation-link": "Mostra link di attivazione", + "send-activation-mail": "Invia email di attivazione", + "activation-link": "Link attivazione utente", + "activation-link-text": "Per attivare l'utente utilizza il seguente link di attivazione :", + "copy-activation-link": "Copia link di attivazione", + "activation-link-copied-message": "Link di attivazione utente copiato negli appunti", + "details": "Dettagli", + "login-as-tenant-admin": "Accedi come Amministratore tenant", + "login-as-customer-user": "Accedi come Utente cliente", + "disable-account": "Disabilita account utente", + "enable-account": "Abilita account utente", + "enable-account-message": "L'account utente è stato abilitato correttamente!", + "disable-account-message": "L'account utente è stato disabilitato correttamente!" + }, + "value": { + "type": "Tipo valore", + "string": "String", + "string-value": "Valore string", + "integer": "Integer", + "integer-value": "Valore integer", + "invalid-integer-value": "Valore integer non valido", + "double": "Double", + "double-value": "Valore double", + "boolean": "Boolean", + "boolean-value": "Valore boolean", + "false": "Falso", + "true": "Vero", + "long": "Long" + }, + "widget": { + "widget-library": "Libreria Widget", + "widget-bundle": "Bundle widget", + "select-widgets-bundle": "Seleziona bundle widget", + "management": "Gestione widget", + "editor": "Editor Widget", + "widget-type-not-found": "Problem loading widget configuration.
    Probably associated\n widget type was removed.", + "widget-type-load-error": "Widget non caricato a causa dei seguenti errori:", + "remove": "Elimina widget", + "edit": "Modifica widget", + "remove-widget-title": "sei sicuro di voler eliminare il widget '{{widgetTitle}}'?", + "remove-widget-text": "Dopo la conferma il widget e tutti i suoi dati non saranno più recuperabili.", + "timeseries": "Time series", + "search-data": "Cerca dati", + "no-data-found": "Nessun dato trovato", + "latest": "Ultimi valori", + "rpc": "Control widget", + "alarm": "Alarm widget", + "static": "Static widget", + "select-widget-type": "Seleziona tipo widget", + "missing-widget-title-error": "Il tiolo del widget deve essere specificato!", + "widget-saved": "Widget salvato", + "unable-to-save-widget-error": "Impossibile salvare il widget! Sono presenti degli errori!", + "save": "Salva widget", + "saveAs": "Salva widget come", + "save-widget-type-as": "Salva tipo widget come", + "save-widget-type-as-text": "Please enter new widget title and/or select target widgets bundle", + "toggle-fullscreen": "Commuta modalità schermo intero", + "run": "Esegui widget", + "title": "Titolo widget", + "title-required": "Titolo widget obbligatorio.", + "type": "Tipo widget", + "resources": "Risorse", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Rimuovi risorsa", + "add-resource": "Aggiungi risorsa", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Impostazioni schema", + "datakey-settings-schema": "Impostazioni Data key schema", + "javascript": "Javascript", + "remove-widget-type-title": "Sei sicuro di voler rimuovere il tipo di widget '{{widgetName}}'?", + "remove-widget-type-text": "Dopo la conferma il tipo di widget e tutti i suoi dati non saranno più recuperabili.", + "remove-widget-type": "Rimuovi tipo widget", + "add-widget-type": "Aggiungi nuovo tipo widget", + "widget-type-load-failed-error": "Caricamento tipo widget fallito!", + "widget-template-load-failed-error": "Caricamento template widget fallito!", + "add": "Aggiungi Widget", + "undo": "Annulla modifiche widget", + "export": "Esporta widget" + }, + "widget-action": { + "header-button": "Widget header button", + "open-dashboard-state": "Navigate to new dashboard state", + "update-dashboard-state": "Aggiorna lo stato della dashboard attuale", + "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)" + }, + "widgets-bundle": { + "current": "Bundle corrente", + "widgets-bundles": "Bundle Widget", + "add": "Aggiungi Bundle Widget", + "delete": "Cancella bundle widget", + "title": "Titolo", + "title-required": "Titolo obbligatorio.", + "add-widgets-bundle-text": "Aggiungi nuovo bundle widget", + "no-widgets-bundles-text": "Nessun bundle widget trovato", + "empty": "Bundle widget vuoto", + "details": "Dettagli", + "widgets-bundle-details": "Dettagli bundle widget", + "delete-widgets-bundle-title": "Sei sicuro di voler eliminare il bundle widget '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Attenzione, dopo la conferma il bundle widget e tutti i suoi dati non saranno più recuperabili.", + "delete-widgets-bundles-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 bundle widget} other {# bundle widget} }?", + "delete-widgets-bundles-action-title": "Elimina { count, plural, 1 {1 bundle widget} other {# bundle widget} }", + "delete-widgets-bundles-text": "Attenzione, dopo la conferma tutti i bundle widget selezionati saranno rimossi e tutti i loro dati non saranno più recuperabili.", + "no-widgets-bundles-matching": "Nessun bundle widget corrispondente a '{{widgetsBundle}}' è stato trovato.", + "widgets-bundle-required": "Bundle widget obbligatorio.", + "system": "Sistema", + "import": "Importa bundle widget", + "export": "Esporta bundle widget", + "export-failed-error": "Impossibile esportare bundle widget: {{error}}", + "create-new-widgets-bundle": "Crea nuovo bundle widget", + "widgets-bundle-file": "File bundle widget", + "invalid-widgets-bundle-file-error": "Impossibile importare bundle widget: struttura dati non valida." + }, + "widget-config": { + "data": "Dati", + "settings": "Impostazioni", + "advanced": "Avanzate", + "title": "Titolo", + "title-tooltip": "Tooltip titolo", + "general-settings": "Impostazioni generali", + "display-title": "Mostra titolo", + "drop-shadow": "Drop shadow", + "enable-fullscreen": "Abilita schermo intero", + "background-color": "Colore sfondo", + "text-color": "Colore testo", + "padding": "Padding", + "margin": "Margin", + "widget-style": "Stile Widget", + "title-style": "Stile titolo", + "mobile-mode-settings": "Impostazioni modalità mobile", + "order": "Ordinamento", + "height": "Altezza", + "units": "Simbolo speciale da mostrare accanto al valore", + "decimals": "Numero di cifre decimali", + "timewindow": "Intervallo temporale", + "use-dashboard-timewindow": "Usa intervallo temporale dashboard", + "display-timewindow": "Mostra intervallo temporale", + "display-legend": "Mostra legenda", + "datasources": "Sorgenti dei dati", + "maximum-datasources": "Massimo { count, plural, 1 {1 sorgente dati consentita.} other {# sorgenti dati consentite} }", + "datasource-type": "Tipo", + "datasource-parameters": "Parametri", + "remove-datasource": "Rimuovi sorgente dati", + "add-datasource": "Aggiungi sorgente dati", + "target-device": "Dispositivo Target", + "alarm-source": "Sorgente Allarme", + "actions": "Azioni", + "action": "Azione", + "add-action": "Aggiungi azione", + "search-actions": "Ricerca azioni", + "action-source": "Sorgente azione", + "action-source-required": "Sorgente azione obbligatoria.", + "action-name": "Nome", + "action-name-required": "Nome azione obbligatorio.", + "action-name-not-unique": "Un'altra azione con lo stesso nome è già presente.
    Il nome di una azione dovrebbe essere univoco all'interno della stessa sorgente.", + "action-icon": "Icona", + "action-type": "Tipo", + "action-type-required": "Tipo azione obbligatorio.", + "edit-action": "Modifica azione", + "delete-action": "Cancella azione", + "delete-action-title": "Cancella azione del widget", + "delete-action-text": "Sei sicuro di voler cancellare l'azione del widget '{{actionName}}'?", + "display-icon": "Mostra icona titolo", + "icon-color": "Colore dell'icona", + "icon-size": "Dimensione dell'icona" + }, + "widget-type": { + "import": "Importa un tipo di widget", + "export": "Esporta un tipo di widget", + "export-failed-error": "Impossibile esportare il tipo di widget: {{error}}", + "create-new-widget-type": "Crea un nuovo tipo di widget", + "widget-type-file": "File tipo di widget", + "invalid-widget-type-file-error": "Impossibile importare un tipo di widget: struttura dati del widget non valida." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Dom", + "Mon": "Lun", + "Tue": "Mar", + "Wed": "Mer", + "Thu": "Gio", + "Fri": "Ven", + "Sat": "Sab", + "Jan": "Gen", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "Mag", + "Jun": "Giu", + "Jul": "Lug", + "Aug": "Ago", + "Sep": "Set", + "Oct": "Ott", + "Nov": "Nov", + "Dec": "Dic", + "January": "Gennaio", + "February": "Febbraio", + "March": "Marzo", + "April": "Aprile", + "June": "Giugno", + "July": "Luglio", + "August": "Agosto", + "September": "Settembre", + "October": "Ottobre", + "November": "Novembre", + "December": "Dicembre", + "Custom Date Range": "Intervallo date personalizzato", + "Date Range Template": "Modello intervallo date", + "Today": "Oggi", + "Yesterday": "Ieri", + "This Week": "Questa settimana", + "Last Week": "La settimana scorsa", + "This Month": "Questo mese", + "Last Month": "Lo scorso mese", + "Year": "Anno", + "This Year": "Quest'anno", + "Last Year": "L'anno scorso", + "Date picker": "Date picker", + "Hour": "Ora", + "Day": "Giorno", + "Week": "Settimana", + "2 weeks": "2 Settimane", + "Month": "Mese", + "3 months": "3 Mesi", + "6 months": "6 Mesi", + "Custom interval": "Intervallo personalizzato", + "Interval": "Intervallo", + "Step size": "Dimensione del passo", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Questo widget non può usare un parametro di tipo attributo", + "date": "Data", + "discard-changes": "Annulla modifiche", + "entity-attribute-required": "E' richiesta un'entità di tipo attributo", + "entity-timeseries-required": "E' richiesta un'entità di tipo serie temporale", + "not-allowed-entity": "L'entità selezionata non può avere attributi condivisi", + "no-attribute-selected": "Nessun attributo selezionato", + "no-datakey-selected": "Nessuna datakey selezionata", + "no-entity-selected": "Nessuna entità selezionata", + "no-image": "Nessuna immagine", + "no-support-web-camera": "Web camera non supportata", + "no-timeseries-selected": "Nessuna serie temporale selezionata", + "switch-attribute-value": "Cambia il valore dell'attributo", + "switch-camera": "Cambia camera", + "switch-timeseries-value": "Cambia il valore della serie temporale", + "take-photo": "Fai una foto", + "time": "Tempo", + "timeseries-not-allowed": "Questo widget non può usare un parametro di tipo serie temporale", + "update-failed": "Aggiornamento fallito", + "update-successful": "Aggiornamento eseguito con successo", + "update-attribute": "Aggiorna attributo", + "update-timeseries": "Aggiorna serie temporale", + "value": "Valore" + } + }, + "icon": { + "icon": "Icona", + "select-icon": "Seleziona icona", + "material-icons": "Icone Material", + "show-all": "Mostra tutte le icone" + }, + "custom": { + "widget-action": { + "action-cell-button": "Pulsante azione cella", + "row-click": "Click sulla riga", + "polygon-click": "Click sul poligono", + "marker-click": "Click sul marker", + "tooltip-tag-action": "Azione tooltip", + "node-selected": "Click su nodo selezionato", + "element-click": "Click su elemento HTML", + "pie-slice-click": "Click sulla fetta", + "row-double-click": "Doppio click sulla riga" + } + }, + "language": { + "language": "Lingua" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json new file mode 100644 index 0000000..6420f4f --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -0,0 +1,1516 @@ +{ + "access": { + "unauthorized": "無許可", + "unauthorized-access": "不正アクセス", + "unauthorized-access-text": "このリソースにアクセスするにはサインインする必要があります。", + "access-forbidden": "アクセス禁止", + "access-forbidden-text": "あなたはこの場所へのアクセス権を持っていません!この場所にアクセスしたい場合は、別のユーザーとサインインしてみてください。", + "refresh-token-expired": "セッションが終了しました", + "refresh-token-failed": "セッションをリフレッシュできません" + }, + "action": { + "activate": "アクティブ化する", + "suspend": "サスペンド", + "save": "セーブ", + "saveAs": "名前を付けて保存", + "cancel": "キャンセル", + "ok": "[OK]", + "delete": "削除", + "add": "追加", + "yes": "はい", + "no": "いいえ", + "update": "更新", + "remove": "削除する", + "search": "サーチ", + "clear-search": "検索をクリアする", + "assign": "割り当てます", + "unassign": "割り当て解除", + "share": "シェア", + "make-private": "プライベートにする", + "apply": "適用", + "apply-changes": "変更を適用する", + "edit-mode": "編集モード", + "enter-edit-mode": "編集モードに入る", + "decline-changes": "変更を拒否する", + "close": "閉じる", + "back": "バック", + "run": "走る", + "sign-in": "サインイン!", + "edit": "編集", + "view": "ビュー", + "create": "作成する", + "drag": "ドラッグ", + "refresh": "リフレッシュ", + "undo": "元に戻す", + "copy": "コピー", + "paste": "ペースト", + "copy-reference": "コピーリファレンス", + "paste-reference": "参照貼り付け", + "import": "インポート", + "export": "輸出する", + "share-via": "{{provider}}" + }, + "aggregation": { + "aggregation": "集約", + "function": "データ集約機能", + "limit": "最大値", + "group-interval": "グループ化の間隔", + "min": "分", + "max": "最大", + "avg": "平均", + "sum": "和", + "count": "カウント", + "none": "なし" + }, + "admin": { + "general": "一般", + "general-settings": "一般設定", + "outgoing-mail": "送信メール", + "outgoing-mail-settings": "送信メールの設定", + "system-settings": "システム設定", + "test-mail-sent": "テストメールが正常に送信されました!", + "base-url": "ベースURL", + "base-url-required": "ベースURLは必須です。", + "mail-from": "メール", + "mail-from-required": "メールの送信元が必要です。", + "smtp-protocol": "SMTPプロトコル", + "smtp-host": "SMTPホスト", + "smtp-host-required": "SMTPホストが必要です。", + "smtp-port": "SMTPポート", + "smtp-port-required": "smtpポートを指定する必要があります。", + "smtp-port-invalid": "それは有効なsmtpポートのようには見えません。", + "timeout-msec": "タイムアウト(ミリ秒)", + "timeout-required": "タイムアウトが必要です。", + "timeout-invalid": "それは有効なタイムアウトのようには見えません。", + "enable-tls": "TLSを有効にする", + "tls-version": "TLSバージョン", + "send-test-mail": "テストメールを送信する" + }, + "alarm": { + "alarm": "警報", + "alarms": "アラーム", + "select-alarm": "アラームを選択", + "no-alarms-matching": "'{{entity}}'発見されました。", + "alarm-required": "アラームが必要です", + "alarm-status": "アラーム状態", + "search-status": { + "ANY": "どれか", + "ACTIVE": "アクティブ", + "CLEARED": "クリアされた", + "ACK": "承認された", + "UNACK": "未確認の" + }, + "display-status": { + "ACTIVE_UNACK": "アクティブ未確認", + "ACTIVE_ACK": "Active Acknowledged", + "CLEARED_UNACK": "クリアされた未確認のメッセージ", + "CLEARED_ACK": "承認された承認済み" + }, + "no-alarms-prompt": "アラームが見つかりません", + "created-time": "作成時刻", + "type": "タイプ", + "severity": "重大度", + "originator": "創始者", + "originator-type": "発信者タイプ", + "details": "詳細", + "status": "状態", + "alarm-details": "アラームの詳細", + "start-time": "始まる時間", + "end-time": "終了時間", + "ack-time": "確認された時間", + "clear-time": "クリアされた時間", + "severity-critical": "クリティカル", + "severity-major": "メジャー", + "severity-minor": "マイナー", + "severity-warning": "警告", + "severity-indeterminate": "不確定", + "acknowledge": "認める", + "clear": "クリア", + "search": "アラームの検索", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} }選択された", + "no-data": "表示するデータがありません", + "polling-interval": "アラームポーリング間隔(秒)", + "polling-interval-required": "アラームのポーリング間隔が必要です。", + "min-polling-interval-message": "少なくとも1秒間のポーリング間隔が許可されます。", + "aknowledge-alarms-title": "{ count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "{ count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarms-title": "{ count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "{ count, plural, 1 {1 alarm} other {# alarms} }?" + }, + "alias": { + "add": "エイリアスを追加する", + "edit": "エイリアスを編集する", + "name": "エイリアス名", + "name-required": "エイリアス名は必須です", + "duplicate-alias": "同じ名前のエイリアスは既に存在します。", + "filter-type-single-entity": "単一のエンティティ", + "filter-type-entity-list": "エンティティリスト", + "filter-type-entity-name": "エンティティ名", + "filter-type-state-entity": "ダッシュボード状態からのエンティティ", + "filter-type-state-entity-description": "ダッシュボードの状態パラメータから取得されたエンティティ", + "filter-type-asset-type": "資産の種類", + "filter-type-asset-type-description": "'{{assetType}}'", + "filter-type-asset-type-and-name-description": "'{{assetType}}''{{prefix}}'", + "filter-type-device-type": "デバイスタイプ", + "filter-type-device-type-description": "'{{deviceType}}'", + "filter-type-device-type-and-name-description": "'{{deviceType}}''{{prefix}}'", + "filter-type-relations-query": "関係クエリ", + "filter-type-relations-query-description": "{{entities}}{{relationType}}{{direction}}{{rootEntity}}", + "filter-type-asset-search-query": "資産検索クエリ", + "filter-type-asset-search-query-description": "{{assetTypes}}{{relationType}}{{direction}}{{rootEntity}}", + "filter-type-device-search-query": "デバイス検索クエリ", + "filter-type-device-search-query-description": "{{deviceTypes}}{{relationType}}{{direction}}{{rootEntity}}", + "entity-filter": "エンティティフィルタ", + "resolve-multiple": "複数のエンティティとして解決する", + "filter-type": "フィルタタイプ", + "filter-type-required": "フィルタタイプが必要です。", + "entity-filter-no-entity-matched": "指定されたフィルタに一致するエンティティは見つかりませんでした。", + "no-entity-filter-specified": "エンティティフィルタが指定されていない", + "root-state-entity": "ルートとしてダッシュボードの状態エンティティを使用する", + "root-entity": "ルートエンティティ", + "state-entity-parameter-name": "状態エンティティのパラメータ名", + "default-state-entity": "デフォルト状態エンティティ", + "default-entity-parameter-name": "デフォルトでは", + "max-relation-level": "最大関連レベル", + "unlimited-level": "無制限レベル", + "state-entity": "ダッシュボードの状態エンティティ", + "all-entities": "すべてのエンティティ", + "any-relation": "どれか" + }, + "asset": { + "asset": "資産", + "assets": "資産", + "management": "資産運用管理", + "view-assets": "アセットの表示", + "add": "アセットを追加", + "assign-to-customer": "顧客に割り当てる", + "assign-asset-to-customer": "顧客に資産を割り当てる", + "assign-asset-to-customer-text": "顧客に割り当てる資産を選択してください", + "no-assets-text": "アセットが見つかりません", + "assign-to-customer-text": "資産を割り当てる顧客を選択してください", + "public": "パブリック", + "assignedToCustomer": "顧客に割り当てられた", + "make-public": "アセットを公開する", + "make-private": "アセットをプライベートにする", + "unassign-from-customer": "顧客からの割り当て解除", + "delete": "アセットを削除", + "asset-public": "資産は公開されています", + "asset-type": "資産の種類", + "asset-type-required": "資産の種類が必要です。", + "select-asset-type": "アセットタイプを選択", + "enter-asset-type": "アセットタイプを入力", + "any-asset": "すべてのアセット", + "no-asset-types-matching": "'{{entitySubtype}}'発見されました。", + "asset-type-list-empty": "選択されたアセットタイプはありません。", + "asset-types": "資産タイプ", + "name": "名", + "name-required": "名前は必須です。", + "description": "説明", + "type": "タイプ", + "type-required": "タイプが必要です。", + "details": "詳細", + "events": "イベント", + "add-asset-text": "新しいアセットを追加する", + "asset-details": "資産の詳細", + "assign-assets": "アセットの割り当て", + "assign-assets-text": "{ count, plural, 1 {1 asset} other {# assets} }顧客に", + "delete-assets": "アセットを削除する", + "unassign-assets": "アセットの割り当てを解除する", + "unassign-assets-action-title": "{ count, plural, 1 {1 asset} other {# assets} }顧客から", + "assign-new-asset": "新しいアセットを割り当てる", + "delete-asset-title": "'{{assetName}}'?", + "delete-asset-text": "確認後、資産と関連するすべてのデータが回復不能になることに注意してください。", + "delete-assets-title": "{ count, plural, 1 {1 asset} other {# assets} }?", + "delete-assets-action-title": "{ count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "確認後、選択したすべての資産が削除され、関連するすべてのデータは回復不能になりますので注意してください。", + "make-public-asset-title": "'{{assetName}}'パブリック?", + "make-public-asset-text": "確認後、資産とそのすべてのデータは公開され、他の人がアクセスできるようになります。", + "make-private-asset-title": "'{{assetName}}'プライベート?", + "make-private-asset-text": "確認後、資産とそのすべてのデータは非公開にされ、他の人がアクセスすることはできません。", + "unassign-asset-title": "'{{assetName}}'?", + "unassign-asset-text": "確認後、資産は割り当て解除され、顧客はアクセスできなくなります。", + "unassign-asset": "アセットの割り当てを解除する", + "unassign-assets-title": "{ count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "確認後、選択されたすべての資産が割り当て解除され、顧客がアクセスできなくなります。", + "copyId": "アセットIDをコピーする", + "idCopiedMessage": "アセットIDがクリップボードにコピーされました", + "select-asset": "アセットを選択", + "no-assets-matching": "'{{entity}}'発見されました。", + "asset-required": "資産が必要です", + "name-starts-with": "アセット名はで始まります", + "label": "ラベル" + }, + "attribute": { + "attributes": "属性", + "latest-telemetry": "最新テレメトリ", + "attributes-scope": "エンティティ属性のスコープ", + "scope-latest-telemetry": "最新テレメトリ", + "scope-client": "クライアントの属性", + "scope-server": "サーバーの属性", + "scope-shared": "共有属性", + "add": "属性を追加する", + "key": "キー", + "last-update-time": "最終更新時間", + "key-required": "属性キーは必須です。", + "value": "値", + "value-required": "属性値は必須です。", + "delete-attributes-title": "{ count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "注意してください。確認後、選択したすべての属性が削除されます。", + "delete-attributes": "属性を削除する", + "enter-attribute-value": "属性値を入力", + "show-on-widget": "ウィジェットで表示", + "widget-mode": "ウィジェットモード", + "next-widget": "次のウィジェット", + "prev-widget": "前のウィジェット", + "add-to-dashboard": "ダッシュボードに追加", + "add-widget-to-dashboard": "ウィジェットをダッシュ​​ボードに追加する", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} }選択された", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} }選択された" + }, + "audit-log": { + "audit": "監査", + "audit-logs": "監査ログ", + "timestamp": "タイムスタンプ", + "entity-type": "エンティティタイプ", + "entity-name": "エンティティ名", + "user": "ユーザー", + "type": "タイプ", + "status": "状態", + "details": "詳細", + "type-added": "追加された", + "type-deleted": "削除済み", + "type-updated": "更新しました", + "type-attributes-updated": "属性が更新されました", + "type-attributes-deleted": "属性が削除されました", + "type-rpc-call": "RPC呼び出し", + "type-credentials-updated": "資格が更新されました", + "type-assigned-to-customer": "顧客に割り当てられた", + "type-unassigned-from-customer": "顧客から割り当てられていない", + "type-activated": "活性化", + "type-suspended": "一時停止中", + "type-credentials-read": "信用証明書を読む", + "type-attributes-read": "読み取られた属性", + "type-relation-add-or-update": "関係が更新されました", + "type-relation-delete": "関係が削除されました", + "type-relations-delete": "すべてのリレーションを削除", + "type-alarm-ack": "承認された", + "type-alarm-clear": "クリアされた", + "status-success": "成功", + "status-failure": "失敗", + "audit-log-details": "監査ログの詳細", + "no-audit-logs-prompt": "ログが見つかりません", + "action-data": "行動データ", + "failure-details": "失敗の詳細", + "search": "監査ログの検索", + "clear-search": "検索をクリアする" + }, + "confirm-on-exit": { + "message": "保存されていない変更があります。あなたは本当にこのページを出るのですか?", + "html-message": "保存していない変更があります。
    このページを終了してもよろしいですか?", + "title": "保存されていない変更" + }, + "contact": { + "country": "国", + "city": "シティ", + "state": "州/県", + "postal-code": "郵便番号", + "postal-code-invalid": "無効な郵便番号形式です。", + "address": "住所", + "address2": "アドレス2", + "phone": "電話", + "email": "Eメール", + "no-address": "住所がありません" + }, + "common": { + "username": "ユーザー名", + "password": "パスワード", + "enter-username": "ユーザーネームを入力してください", + "enter-password": "パスワードを入力する", + "enter-search": "検索を入力", + "created-time": "作成時刻" + }, + "content-type": { + "json": "Json", + "text": "テキスト", + "binary": "バイナリ(Base64)" + }, + "customer": { + "customer": "顧客", + "customers": "顧客", + "management": "顧客管理", + "dashboard": "カスタマーダッシュボード", + "dashboards": "カスタマーダッシュボード", + "devices": "顧客デバイス", + "assets": "顧客資産", + "public-dashboards": "パブリックダッシュボード", + "public-devices": "パブリックデバイス", + "public-assets": "公的資産", + "add": "顧客を追加", + "delete": "顧客を削除する", + "manage-customer-users": "顧客ユーザーを管理する", + "manage-customer-devices": "顧客のデバイスを管理する", + "manage-customer-dashboards": "顧客ダッシュボードの管理", + "manage-public-devices": "パブリックデバイスを管理する", + "manage-public-dashboards": "公開ダッシュボードの管理", + "manage-customer-assets": "顧客資産の管理", + "manage-public-assets": "公的資産を管理する", + "add-customer-text": "新規顧客を追加", + "no-customers-text": "顧客が見つかりません", + "customer-details": "お客様情報", + "delete-customer-title": "'{{customerTitle}}'?", + "delete-customer-text": "確認後、お客様および関連するすべてのデータが回復不能になるので注意してください。", + "delete-customers-title": "{ count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "{ count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "確認後、選択したすべての顧客は削除され、関連するすべてのデータは回復不能になります。", + "manage-users": "ユーザーを管理する", + "manage-assets": "アセットを管理する", + "manage-devices": "デバイスを管理する", + "manage-dashboards": "ダッシュボードの管理", + "title": "タイトル", + "title-required": "タイトルは必須です。", + "description": "説明", + "details": "詳細", + "events": "イベント", + "copyId": "顧客IDをコピー", + "idCopiedMessage": "顧客IDがクリップボードにコピーされました", + "select-customer": "顧客を選択", + "no-customers-matching": "'{{entity}}'発見されました。", + "customer-required": "顧客は必須です", + "select-default-customer": "デフォルトの顧客を選択", + "default-customer": "デフォルトの顧客", + "default-customer-required": "テナントレベルのダッシュボードをデバッグするには、デフォルトの顧客が必要です" + }, + "datetime": { + "date-from": "デートから", + "time-from": "からの時間", + "date-to": "日付", + "time-to": "の時間" + }, + "dashboard": { + "dashboard": "ダッシュボード", + "dashboards": "ダッシュボード", + "management": "ダッシュボード管理", + "view-dashboards": "ダッシュボードを表示する", + "add": "ダッシュボードを追加", + "assign-dashboard-to-customer": "顧客にダッシュボードを割り当てる", + "assign-dashboard-to-customer-text": "顧客に割り当てるダッシュボードを選択してください", + "assign-to-customer-text": "ダッシュボードを割り当てる顧客を選択してください", + "assign-to-customer": "顧客に割り当てる", + "unassign-from-customer": "顧客からの割り当て解除", + "make-public": "ダッシュボードを公開する", + "make-private": "ダッシュボードを非公開にする", + "manage-assigned-customers": "割り当てられた顧客を管理する", + "assigned-customers": "割り当てられた顧客", + "assign-to-customers": "顧客にダッシュボードを割り当てる", + "assign-to-customers-text": "ダッシュボードを割り当てる顧客を選択してください", + "unassign-from-customers": "顧客からのダッシュボードの割り当て解除", + "unassign-from-customers-text": "ダッシュボードから割り当て解除する顧客を選択してください", + "no-dashboards-text": "ダッシュボードが見つかりません", + "no-widgets": "ウィジェットは設定されていません", + "add-widget": "新しいウィジェットを追加", + "title": "タイトル", + "select-widget-title": "ウィジェットを選択", + "select-widget-subtitle": "利用可能なウィジェットタイプのリスト", + "delete": "ダッシュボードの削除", + "title-required": "タイトルは必須です。", + "description": "説明", + "details": "詳細", + "dashboard-details": "ダッシュボードの詳細", + "add-dashboard-text": "新しいダッシュボードを追加する", + "assign-dashboards": "ダッシュボードの割り当て", + "assign-new-dashboard": "新しいダッシュボードを割り当てる", + "assign-dashboards-text": "{ count, plural, 1 {1 dashboard} other {# dashboards} }顧客に", + "unassign-dashboards-action-text": "{ count, plural, 1 {1 dashboard} other {# dashboards} }顧客から", + "delete-dashboards": "ダッシュボードの削除", + "unassign-dashboards": "ダッシュボードの割り当てを解除する", + "unassign-dashboards-action-title": "{ count, plural, 1 {1 dashboard} other {# dashboards} }顧客から", + "delete-dashboard-title": "'{{dashboardTitle}}'?", + "delete-dashboard-text": "確認後、ダッシュボードとすべての関連データが回復不能になるので注意してください。", + "delete-dashboards-title": "{ count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "{ count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "注意してください。確認後、選択したダッシュボードはすべて削除され、関連するすべてのデータは回復不能になります。", + "unassign-dashboard-title": "'{{dashboardTitle}}'?", + "unassign-dashboard-text": "確認後、ダッシュボードは割り当てられなくなり、顧客はアクセスできなくなります。", + "unassign-dashboard": "ダッシュボードの割り当てを解除する", + "unassign-dashboards-title": "{ count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "確認の後、選択したすべてのダッシュボードは割り当てられなくなり、顧客はアクセスできなくなります。", + "public-dashboard-title": "ダッシュボードは公開されました", + "public-dashboard-text": "{{dashboardTitle}} is now public and accessible via next public link:", + "public-dashboard-notice": "注:データにアクセスするために、関連するデバイスを公開することを忘れないでください。", + "make-private-dashboard-title": "'{{dashboardTitle}}'プライベート?", + "make-private-dashboard-text": "確認の後、ダッシュボードはプライベートにされ、他の人がアクセスすることはできません。", + "make-private-dashboard": "ダッシュボードを非公開にする", + "socialshare-text": "'{{dashboardTitle}}'ThingsBoardを搭載", + "socialshare-title": "'{{dashboardTitle}}'ThingsBoardを搭載", + "select-dashboard": "ダッシュボードを選択", + "no-dashboards-matching": "'{{entity}}'発見されました。", + "dashboard-required": "ダッシュボードが必要です。", + "select-existing": "既存のダッシュボードを選択", + "create-new": "新しいダッシュボードを作成する", + "new-dashboard-title": "新しいダッシュボードのタイトル", + "open-dashboard": "ダッシュボードを開く", + "set-background": "背景を設定する", + "background-color": "背景色", + "background-image": "背景画像", + "background-size-mode": "背景サイズモード", + "no-image": "選択した画像がありません", + "drop-image": "画像をドロップするか、クリックしてアップロードするファイルを選択します。", + "settings": "設定", + "columns-count": "列数", + "columns-count-required": "列数が必要です。", + "min-columns-count-message": "わずか10の最小列数が許可されます。", + "max-columns-count-message": "最大1000の列カウントのみが許可されます。", + "widgets-margins": "ウィジェット間のマージン", + "horizontal-margin": "水平マージン", + "horizontal-margin-required": "水平余白値が必要です。", + "min-horizontal-margin-message": "最小水平マージン値としては0だけが許容されます。", + "max-horizontal-margin-message": "最大水平マージン値は50だけです。", + "vertical-margin": "垂直マージン", + "vertical-margin-required": "垂直マージン値が必要です。", + "min-vertical-margin-message": "最小の垂直マージン値として0のみが許可されます。", + "max-vertical-margin-message": "最大垂直マージン値は50のみです。", + "autofill-height": "自動レイアウトの高さ", + "mobile-layout": "モバイルレイアウトの設定", + "mobile-row-height": "モバイル行の高さ、px", + "mobile-row-height-required": "モバイル行の高さ値が必要です。", + "min-mobile-row-height-message": "最小の行の高さの値として、5ピクセルしか許可されません。", + "max-mobile-row-height-message": "移動可能な行の高さの最大値として許可されるのは200ピクセルだけです。", + "display-title": "ダッシュボードのタイトルを表示する", + "toolbar-always-open": "ツールバーを開いたままにする", + "title-color": "タイトルカラー", + "display-dashboards-selection": "ダッシュボードの選択を表示する", + "display-entities-selection": "エンティティの選択を表示する", + "display-dashboard-timewindow": "タイムウィンドウを表示する", + "display-dashboard-export": "エクスポートの表示", + "import": "インポートダッシュボード", + "export": "エクスポートダッシュボード", + "export-failed-error": "{{error}}", + "create-new-dashboard": "新しいダッシュボードを作成する", + "dashboard-file": "ダッシュボードファイル", + "invalid-dashboard-file-error": "ダッシュボードをインポートできません:ダッシュボードのデータ構造が無効です。", + "dashboard-import-missing-aliases-title": "インポートされたダッシュボードで使用されるエイリアスを設定する", + "create-new-widget": "新しいウィジェットを作成する", + "import-widget": "インポートウィジェット", + "widget-file": "ウィジェットファイル", + "invalid-widget-file-error": "ウィジェットをインポートできません:ウィジェットのデータ構造が無効です。", + "widget-import-missing-aliases-title": "インポートされたウィジェットで使用されるエイリアスを設定する", + "open-toolbar": "ダッシュボードツールバーを開く", + "close-toolbar": "ツールバーを閉じる", + "configuration-error": "設定エラー", + "alias-resolution-error-title": "ダッシュボードエイリアス設定エラー", + "invalid-aliases-config": "エイリアスフィルタの一部に一致するデバイスを見つけることができません。
    この問題を解決するには、管理者に連絡してください。", + "select-devices": "デバイスの選択", + "assignedToCustomer": "顧客に割り当てられた", + "assignedToCustomers": "顧客に割り当てられた", + "public": "パブリック", + "public-link": "パブリックリンク", + "copy-public-link": "パブリックリンクをコピーする", + "public-link-copied-message": "ダッシュボードのパブリックリンクがクリップボードにコピーされました", + "manage-states": "ダッシュボードの状態を管理する", + "states": "ダッシュボードの状態", + "search-states": "検索ダッシュボードの状態", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} }選択された", + "edit-state": "ダッシュボードの状態を編集する", + "delete-state": "ダッシュボードの状態を削除する", + "add-state": "ダッシュボードの状態を追加する", + "state": "ダッシュボードの状態", + "state-name": "名", + "state-name-required": "ダッシュボードの状態名は必須です。", + "state-id": "状態ID", + "state-id-required": "ダッシュボードの状態IDは必須です。", + "state-id-exists": "同じIDを持つダッシュボードの状態は既に存在します。", + "is-root-state": "ルート状態", + "delete-state-title": "ダッシュボードの状態を削除する", + "delete-state-text": "'{{stateName}}'?", + "show-details": "詳細を表示", + "hide-details": "詳細を隠す", + "select-state": "ターゲット状態を選択する", + "state-controller": "状態コントローラ" + }, + "datakey": { + "settings": "設定", + "advanced": "上級", + "label": "ラベル", + "color": "色", + "units": "値の隣に表示する特別なシンボル", + "decimals": "浮動小数点の後の桁数", + "data-generation-func": "データ生成関数", + "use-data-post-processing-func": "データ後処理機能を使用する", + "configuration": "データキー設定", + "timeseries": "タイムズ", + "attributes": "属性", + "alarm": "アラームフィールド", + "timeseries-required": "エンティティの時系列データが必要です。", + "timeseries-or-attributes-required": "エンティティのtimeseries /属性は必須です。", + "maximum-timeseries-or-attributes": "{ count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "アラームフィールドが必要です。", + "function-types": "関数型", + "function-types-required": "関数型が必要です。", + "maximum-function-types": "{ count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }" + }, + "datasource": { + "type": "データソースタイプ", + "name": "名", + "add-datasource-prompt": "データソースを追加してください" + }, + "details": { + "edit-mode": "編集モード", + "toggle-edit-mode": "編集モードを切り替える" + }, + "device": { + "device": "デバイス", + "device-required": "デバイスが必要です。", + "devices": "デバイス", + "management": "端末管理", + "view-devices": "デバイスの表示", + "device-alias": "デバイスエイリアス", + "aliases": "デバイスエイリアス", + "no-alias-matching": "'{{alias}}'見つかりません。", + "no-aliases-found": "別名は見つかりませんでした。", + "no-key-matching": "'{{key}}'見つかりません。", + "no-keys-found": "キーが見つかりません。", + "create-new-alias": "新しいものを作成してください!", + "create-new-key": "新しいものを作成してください!", + "duplicate-alias-error": "'{{alias}}'
    デバイスエイリアスは、ダッシュボード内で一意である必要があります。", + "configure-alias": "'{{alias}}'エイリアス", + "no-devices-matching": "'{{entity}}'発見されました。", + "alias": "エイリアス", + "alias-required": "デバイスエイリアスが必要です。", + "remove-alias": "デバイスエイリアスを削除する", + "add-alias": "デバイスエイリアスを追加する", + "name-starts-with": "デバイス名はで始まります", + "device-list": "デバイスリスト", + "use-device-name-filter": "フィルタを使用する", + "device-list-empty": "デバイスが選択されていません。", + "device-name-filter-required": "デバイス名フィルタが必要です。", + "device-name-filter-no-device-matched": "'{{device}}'発見されました。", + "add": "デバイスを追加", + "assign-to-customer": "顧客に割り当てる", + "assign-device-to-customer": "顧客にデバイスを割り当てる", + "assign-device-to-customer-text": "顧客に割り当てるデバイスを選択してください", + "make-public": "端末を公開する", + "make-private": "デバイスを非公開にする", + "no-devices-text": "デバイスが見つかりません", + "assign-to-customer-text": "デバイスを割り当てる顧客を選択してください", + "device-details": "デバイスの詳細", + "add-device-text": "新しいデバイスを追加する", + "credentials": "資格情報", + "manage-credentials": "資格情報を管理する", + "delete": "デバイスを削除する", + "assign-devices": "デバイスを割り当てる", + "assign-devices-text": "{ count, plural, 1 {1 device} other {# devices} }顧客に", + "delete-devices": "デバイスを削除する", + "unassign-from-customer": "顧客からの割り当て解除", + "unassign-devices": "デバイスの割り当てを解除する", + "unassign-devices-action-title": "{ count, plural, 1 {1 device} other {# devices} }顧客から", + "assign-new-device": "新しいデバイスを割り当てる", + "make-public-device-title": "'{{deviceName}}'パブリック?", + "make-public-device-text": "確認後、デバイスとそのすべてのデータは公開され、他のユーザーがアクセスできるようになります。", + "make-private-device-title": "'{{deviceName}}'プライベート?", + "make-private-device-text": "確認後、デバイスとそのすべてのデータは非公開になり、他人がアクセスできなくなります。", + "view-credentials": "資格情報を表示する", + "delete-device-title": "'{{deviceName}}'?", + "delete-device-text": "確認後、デバイスと関連するすべてのデータが回復不能になるので注意してください。", + "delete-devices-title": "{ count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "{ count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "注意してください。確認後、選択したすべてのデバイスが削除され、関連するすべてのデータは回復不能になります。", + "unassign-device-title": "'{{deviceName}}'?", + "unassign-device-text": "確認の後、デバイスは割り当てが解除され、顧客がアクセスできなくなります。", + "unassign-device": "デバイスの割り当てを解除する", + "unassign-devices-title": "{ count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "確認の後、選択されたすべてのデバイスが割り当て解除され、顧客がアクセスできなくなります。", + "device-credentials": "デバイス資格情報", + "credentials-type": "資格情報タイプ", + "access-token": "アクセストークン", + "access-token-required": "アクセストークンが必要です。", + "access-token-invalid": "アクセストークンの長さは、1〜32文字でなければなりません。", + "secret": "秘密", + "secret-required": "秘密が必要です。", + "device-type": "デバイスタイプ", + "device-type-required": "デバイスタイプが必要です。", + "select-device-type": "デバイスタイプを選択", + "enter-device-type": "デバイスタイプを入力", + "any-device": "すべてのデバイス", + "no-device-types-matching": "'{{entitySubtype}}'発見されました。", + "device-type-list-empty": "選択されたデバイスタイプはありません。", + "device-types": "デバイスの種類", + "name": "名", + "name-required": "名前は必須です。", + "description": "説明", + "events": "イベント", + "details": "詳細", + "copyId": "デバイスIDをコピーする", + "copyAccessToken": "コピーアクセストークン", + "idCopiedMessage": "デバイスIDがクリップボードにコピーされました", + "accessTokenCopiedMessage": "デバイスアクセストークンがクリップボードにコピーされました", + "assignedToCustomer": "顧客に割り当てられた", + "unable-delete-device-alias-title": "デバイスエイリアスを削除できません", + "unable-delete-device-alias-text": "'{{deviceAlias}}'{{widgetsList}}", + "is-gateway": "ゲートウェイです", + "public": "パブリック", + "device-public": "デバイスは公開されています", + "select-device": "デバイスの選択" + }, + "dialog": { + "close": "ダイアログを閉じる" + }, + "error": { + "unable-to-connect": "サーバーに接続できません!インターネット接続を確認してください。", + "unhandled-error-code": "{{errorCode}}", + "unknown-error": "不明なエラー" + }, + "entity": { + "entity": "エンティティ", + "entities": "エンティティ", + "aliases": "エンティティエイリアス", + "entity-alias": "エンティティエイリアス", + "unable-delete-entity-alias-title": "エンティティエイリアスを削除できません", + "unable-delete-entity-alias-text": "'{{entityAlias}}'{{widgetsList}}", + "duplicate-alias-error": "'{{alias}}'
    エンティティのエイリアスは、ダッシュボード内で一意である必要があります。", + "missing-entity-filter-error": "'{{alias}}'.", + "configure-alias": "'{{alias}}'エイリアス", + "alias": "エイリアス", + "alias-required": "エンティティエイリアスが必要です。", + "remove-alias": "エンティティエイリアスを削除する", + "add-alias": "エンティティエイリアスを追加する", + "entity-list": "エンティティリスト", + "entity-type": "エンティティタイプ", + "entity-types": "エンティティタイプ", + "entity-type-list": "エンティティタイプリスト", + "any-entity": "任意のエンティティ", + "enter-entity-type": "エンティティタイプを入力", + "no-entities-matching": "'{{entity}}'発見されました。", + "no-entity-types-matching": "'{{entityType}}'発見されました。", + "name-starts-with": "名前はで始まる", + "use-entity-name-filter": "フィルタを使用する", + "entity-list-empty": "選択されたエンティティはありません", + "entity-type-list-empty": "エンティティタイプは選択されていません。", + "entity-name-filter-required": "エンティティ名フィルタが必要です。", + "entity-name-filter-no-entity-matched": "'{{entity}}'発見されました。", + "all-subtypes": "すべて", + "select-entities": "エンティティの選択", + "no-aliases-found": "別名は見つかりませんでした。", + "no-alias-matching": "'{{alias}}'見つかりません。", + "create-new-alias": "新しいものを作成してください!", + "key": "キー", + "key-name": "キー名", + "no-keys-found": "キーが見つかりません。", + "no-key-matching": "'{{key}}'見つかりません。", + "create-new-key": "新しいものを作成してください!", + "type": "タイプ", + "type-required": "エンティティタイプが必要です。", + "type-device": "デバイス", + "type-devices": "デバイス", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "'{{prefix}}'", + "type-asset": "資産", + "type-assets": "資産", + "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", + "asset-name-starts-with": "'{{prefix}}'", + "type-rule": "ルール", + "type-rules": "ルール", + "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", + "rule-name-starts-with": "'{{prefix}}'", + "type-plugin": "プラグイン", + "type-plugins": "プラグイン", + "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", + "plugin-name-starts-with": "'{{prefix}}'", + "type-tenant": "テナント", + "type-tenants": "テナント", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", + "tenant-name-starts-with": "'{{prefix}}'", + "type-customer": "顧客", + "type-customers": "顧客", + "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", + "customer-name-starts-with": "'{{prefix}}'", + "type-user": "ユーザー", + "type-users": "ユーザー", + "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", + "user-name-starts-with": "'{{prefix}}'", + "type-dashboard": "ダッシュボード", + "type-dashboards": "ダッシュボード", + "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", + "dashboard-name-starts-with": "'{{prefix}}'", + "type-alarm": "警報", + "type-alarms": "アラーム", + "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", + "alarm-name-starts-with": "'{{prefix}}'", + "type-rulechain": "ルールチェーン", + "type-rulechains": "ルールチェーン", + "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", + "rulechain-name-starts-with": "'{{prefix}}'", + "type-rulenode": "ルールノード", + "type-rulenodes": "ルールノード", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "'{{prefix}}'", + "type-current-customer": "現在の顧客", + "search": "検索エンティティ", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} }選択された", + "entity-name": "エンティティ名", + "details": "エンティティの詳細", + "no-entities-prompt": "エンティティが見つかりません", + "no-data": "表示するデータがありません" + }, + "event": { + "event-type": "イベントタイプ", + "type-error": "エラー", + "type-lc-event": "ライフサイクルイベント", + "type-stats": "統計", + "type-debug-rule-node": "デバッグ", + "type-debug-rule-chain": "デバッグ", + "no-events-prompt": "イベントは見つかりませんでした", + "error": "エラー", + "alarm": "警報", + "event-time": "イベント時間", + "server": "サーバ", + "body": "体", + "method": "方法", + "type": "タイプ", + "message-id": "メッセージID", + "message-type": "メッセージタイプ", + "data-type": "データ・タイプ", + "relation-type": "関係タイプ", + "metadata": "メタデータ", + "data": "データ", + "event": "イベント", + "status": "状態", + "success": "成功", + "failed": "失敗", + "messages-processed": "処理されたメッセージ", + "errors-occurred": "エラーが発生しました", + "all-events": "すべて", + "entity-type": "エンティティタイプ" + }, + "extension": { + "extensions": "拡張機能", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} }選択された", + "type": "タイプ", + "key": "キー", + "value": "値", + "id": "イド", + "extension-id": "内線番号", + "extension-type": "拡張タイプ", + "transformer-json": "JSON *", + "unique-id-required": "現在の拡張IDは既に存在します。", + "delete": "拡張子を削除", + "add": "内線番号を追加", + "edit": "拡張機能を編集する", + "delete-extension-title": "'{{extensionId}}'?", + "delete-extension-text": "確認後、拡張子と関連するすべてのデータが回復不能になることに注意してください。", + "delete-extensions-title": "{ count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "注意してください。確認後、選択したすべての内線番号が削除されます。", + "converters": "コンバーター", + "converter-id": "コンバーターID", + "configuration": "構成", + "converter-configurations": "コンバータ構成", + "token": "セキュリティトークン", + "add-converter": "コンバータを追加する", + "add-config": "コンバータ設定を追加する", + "device-name-expression": "デバイス名式", + "device-type-expression": "デバイスタイプの式", + "custom": "カスタム", + "to-double": "ダブル", + "transformer": "トランス", + "json-required": "トランスフォーマーjsonが必要です。", + "json-parse": "変圧器jsonを解析できません。", + "attributes": "属性", + "add-attribute": "属性を追加する", + "add-map": "マッピング要素を追加する", + "timeseries": "タイムズ", + "add-timeseries": "時系列を追加する", + "field-required": "フィールドは必須項目です", + "brokers": "ブローカー", + "add-broker": "ブローカーを追加", + "host": "ホスト", + "port": "ポート", + "port-range": "ポートは1〜65535の範囲内にある必要があります。", + "ssl": "SSL", + "credentials": "資格情報", + "username": "ユーザー名", + "password": "パスワード", + "retry-interval": "ミリ秒単位の再試行間隔", + "anonymous": "匿名", + "basic": "ベーシック", + "pem": "PEM", + "ca-cert": "CA証明書ファイル*", + "private-key": "秘密鍵ファイル*", + "cert": "証明書ファイル*", + "no-file": "ファイルが選択されていません。", + "drop-file": "ファイルをドロップするか、クリックしてアップロードするファイルを選択します。", + "mapping": "マッピング", + "topic-filter": "トピックフィルタ", + "converter-type": "コンバータタイプ", + "converter-json": "Json", + "json-name-expression": "デバイス名json式", + "topic-name-expression": "デバイス名トピック表現", + "json-type-expression": "デバイスタイプjson式", + "topic-type-expression": "デバイスタイプトピック表現", + "attribute-key-expression": "属性キー式", + "attr-json-key-expression": "属性キーjson式", + "attr-topic-key-expression": "属性キートピック式", + "request-id-expression": "要求ID式", + "request-id-json-expression": "リクエストID json式", + "request-id-topic-expression": "リクエストIDトピック表現", + "response-topic-expression": "応答トピック表現", + "value-expression": "値式", + "topic": "トピック", + "timeout": "タイムアウト(ミリ秒)", + "converter-json-required": "コンバータjsonが必要です。", + "converter-json-parse": "コンバータjsonを解析できません。", + "filter-expression": "フィルタ式", + "connect-requests": "接続要求", + "add-connect-request": "接続要求を追加", + "disconnect-requests": "切断要求", + "add-disconnect-request": "切断リクエストを追加する", + "attribute-requests": "属性要求", + "add-attribute-request": "属性要求を追加する", + "attribute-updates": "属性の更新", + "add-attribute-update": "属性の更新を追加する", + "server-side-rpc": "サーバー側RPC", + "add-server-side-rpc-request": "サーバー側RPC要求を追加する", + "device-name-filter": "デバイス名フィルタ", + "attribute-filter": "属性フィルタ", + "method-filter": "方法フィルター", + "request-topic-expression": "トピック表現を要求する", + "response-timeout": "応答タイムアウト(ミリ秒)", + "topic-expression": "トピック表現", + "client-scope": "クライアントスコープ", + "add-device": "デバイスを追加", + "opc-server": "サーバー", + "opc-add-server": "サーバーを追加", + "opc-add-server-prompt": "サーバーを追加してください", + "opc-application-name": "アプリケーション名", + "opc-application-uri": "アプリケーションURI", + "opc-scan-period-in-seconds": "スキャン時間(秒)", + "opc-security": "セキュリティ", + "opc-identity": "身元", + "opc-keystore": "キーストア", + "opc-type": "タイプ", + "opc-keystore-type": "タイプ", + "opc-keystore-location": "ロケーション*", + "opc-keystore-password": "パスワード", + "opc-keystore-alias": "エイリアス", + "opc-keystore-key-password": "キーのパスワード", + "opc-device-node-pattern": "デバイスノードパターン", + "opc-device-name-pattern": "デバイス名パターン", + "modbus-server": "サーバー/スレーブ", + "modbus-add-server": "サーバー/スレーブを追加する", + "modbus-add-server-prompt": "サーバー/スレーブを追加してください", + "modbus-transport": "輸送", + "modbus-port-name": "シリアルポート名", + "modbus-encoding": "エンコーディング", + "modbus-parity": "パリティ", + "modbus-baudrate": "ボーレート", + "modbus-databits": "データビット", + "modbus-stopbits": "ストップビット", + "modbus-databits-range": "データビットは7〜8の範囲内にある必要があります。", + "modbus-stopbits-range": "ストップビットは1〜2の範囲内でなければなりません。", + "modbus-unit-id": "ユニットID", + "modbus-unit-id-range": "ユニットIDは1〜247の範囲で指定してください。", + "modbus-device-name": "装置名", + "modbus-poll-period": "投票期間(ミリ秒)", + "modbus-attributes-poll-period": "属性のポーリング期間(ミリ秒)", + "modbus-timeseries-poll-period": "時系列ポーリング期間(ミリ秒)", + "modbus-poll-period-range": "投票期間は正の値でなければなりません。", + "modbus-tag": "タグ", + "modbus-function": "関数", + "modbus-register-address": "登録アドレス", + "modbus-register-address-range": "レジスタのアドレスは0〜65535の範囲内である必要があります。", + "modbus-register-bit-index": "ビットインデックス", + "modbus-register-bit-index-range": "ビットインデックスは0〜15の範囲内である必要があります。", + "modbus-register-count": "レジスタ数", + "modbus-register-count-range": "レジスタ数は正の値でなければなりません。", + "modbus-byte-order": "バイト順", + "sync": { + "status": "状態", + "sync": "同期", + "not-sync": "同期しない", + "last-sync-time": "前回の同期時間", + "not-available": "利用不可" + }, + "export-extensions-configuration": "エクステンション設定のエクスポート", + "import-extensions-configuration": "エクステンション設定のインポート", + "import-extensions": "拡張機能のインポート", + "import-extension": "インポート拡張", + "export-extension": "輸出延長", + "file": "拡張機能ファイル", + "invalid-file-error": "無効な拡張ファイル" + }, + "fullscreen": { + "expand": "フルスクリーンに拡大", + "exit": "全画面表示を終了", + "toggle": "フルスクリーンモードを切り替える", + "fullscreen": "全画面表示" + }, + "function": { + "function": "関数" + }, + "grid": { + "delete-item-title": "このアイテムを削除してもよろしいですか?", + "delete-item-text": "注意してください。確認後、この項目と関連するすべてのデータは回復不能になります。", + "delete-items-title": "{ count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "{ count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "注意してください。確認後、選択したすべてのアイテムが削除され、関連するすべてのデータは回復不能になります。", + "add-item-text": "新しいアイテムを追加", + "no-items-text": "項目は見つかりませんでした", + "item-details": "商品詳細", + "delete-item": "アイテムを削除", + "delete-items": "アイテムを削除する", + "scroll-to-top": "トップにスクロールします" + }, + "help": { + "goto-help-page": "ヘルプページに行く" + }, + "home": { + "home": "ホーム", + "profile": "プロフィール", + "logout": "ログアウト", + "menu": "メニュー", + "avatar": "アバター", + "open-user-menu": "ユーザーメニューを開く" + }, + "import": { + "no-file": "ファイルが選択されていません", + "drop-file": "JSONファイルをドロップするか、アップロードするファイルをクリックして選択します。" + }, + "item": { + "selected": "選択された" + }, + "js-func": { + "no-return-error": "関数は値を返す必要があります!", + "return-type-mismatch": "'{{type}}'タイプ!", + "tidy": "きちんとした" + }, + "key-val": { + "key": "キー", + "value": "値", + "remove-entry": "エントリを削除", + "add-entry": "エントリを追加", + "no-data": "エントリなし" + }, + "layout": { + "layout": "レイアウト", + "manage": "レイアウトの管理", + "settings": "レイアウト設定", + "color": "色", + "main": "メイン", + "right": "右", + "select": "ターゲットレイアウトを選択" + }, + "legend": { + "position": "伝説の位置", + "show-max": "最大値を表示", + "show-min": "最小値を表示する", + "show-avg": "平均値を表示", + "show-total": "合計値を表示", + "settings": "凡例の設定", + "min": "分", + "max": "最大", + "avg": "平均", + "total": "合計" + }, + "login": { + "login": "ログイン", + "request-password-reset": "リクエストパスワードのリセット", + "reset-password": "パスワードを再設定する", + "create-password": "パスワードの作成", + "passwords-mismatch-error": "入力されたパスワードは同じでなければなりません!", + "password-again": "パスワードをもう一度", + "sign-in": "サインインしてください", + "username": "ユーザー名(電子メール)", + "remember-me": "私を覚えてますか", + "forgot-password": "パスワードをお忘れですか?", + "password-reset": "パスワードのリセット", + "new-password": "新しいパスワード", + "new-password-again": "新しいパスワードを再入力", + "password-link-sent-message": "パスワードリセットリンクが正常に送信されました!", + "email": "Eメール", + "login-with": "{{name}}でログイン", + "or": "または" + }, + "position": { + "top": "上", + "bottom": "ボトム", + "left": "左", + "right": "右" + }, + "profile": { + "profile": "プロフィール", + "change-password": "パスワードを変更する", + "current-password": "現在のパスワード" + }, + "relation": { + "relations": "関係", + "direction": "方向", + "search-direction": { + "FROM": "から", + "TO": "に" + }, + "direction-type": { + "FROM": "から", + "TO": "に" + }, + "from-relations": "アウトバウンド関係", + "to-relations": "インバウンド関係", + "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} }選択された", + "type": "タイプ", + "to-entity-type": "エンティティタイプへ", + "to-entity-name": "エンティティ名に", + "from-entity-type": "エンティティタイプから", + "from-entity-name": "エンティティ名から", + "to-entity": "実体へ", + "from-entity": "エンティティから", + "delete": "関係を削除する", + "relation-type": "関係タイプ", + "relation-type-required": "関係タイプが必要です。", + "any-relation-type": "いかなるタイプ", + "add": "関係を追加する", + "edit": "関係を編集する", + "delete-to-relation-title": "'{{entityName}}'?", + "delete-to-relation-text": "'{{entityName}}'現在のエンティティとは無関係です。", + "delete-to-relations-title": "{ count, plural, 1 {1 relation} other {# relations} }?", + "delete-to-relations-text": "注意してください。確認後、選択されたリレーションはすべて削除され、対応するエンティティは現在のエンティティとは無関係になります。", + "delete-from-relation-title": "'{{entityName}}'?", + "delete-from-relation-text": "'{{entityName}}'.", + "delete-from-relations-title": "{ count, plural, 1 {1 relation} other {# relations} }?", + "delete-from-relations-text": "注意してください。確認後、選択されたリレーションはすべて削除され、現在のエンティティは対応するエンティティとは無関係になります。", + "remove-relation-filter": "関係フィルタを削除する", + "add-relation-filter": "関係フィルタを追加する", + "any-relation": "関係", + "relation-filters": "関係フィルタ", + "additional-info": "追加情報(JSON)", + "invalid-additional-info": "追加情報jsonを解析できません。" + }, + "rulechain": { + "rulechain": "ルールチェーン", + "rulechains": "ルールチェーン", + "root": "ルート", + "delete": "ルールチェーンの削除", + "name": "名", + "name-required": "名前は必須です。", + "description": "説明", + "add": "ルールチェーンを追加する", + "set-root": "ルールチェーンのルートを作る", + "set-root-rulechain-title": "'{{ruleChainName}}'ルート?", + "set-root-rulechain-text": "確認後、ルールチェーンはルートになり、すべての受信トランスポートメッセージを処理します。", + "delete-rulechain-title": "'{{ruleChainName}}'?", + "delete-rulechain-text": "確認後、ルールチェーンと関連するすべてのデータが回復不能になるので注意してください。", + "delete-rulechains-title": "{ count, plural, 1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "{ count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "確認後、選択したすべてのルールチェーンが削除され、関連するすべてのデータが回復不能になるので注意してください。", + "add-rulechain-text": "新しいルールチェーンを追加する", + "no-rulechains-text": "ルールチェーンが見つかりません", + "rulechain-details": "ルールチェーンの詳細", + "details": "詳細", + "events": "イベント", + "system": "システム", + "import": "ルールチェーンのインポート", + "export": "ルールチェーンのエクスポート", + "export-failed-error": "{{error}}", + "create-new-rulechain": "新しいルールチェーンを作成する", + "rulechain-file": "ルールチェーンファイル", + "invalid-rulechain-file-error": "ルールチェーンをインポートできません:ルールチェーンのデータ構造が無効です。", + "copyId": "ルールチェーンIDのコピー", + "idCopiedMessage": "ルールチェーンIDがクリップボードにコピーされました", + "select-rulechain": "ルールチェーンの選択", + "no-rulechains-matching": "'{{entity}}'発見されました。", + "rulechain-required": "ルールチェーンが必要です", + "management": "ルール管理", + "debug-mode": "デバッグモード" + }, + "rulenode": { + "details": "詳細", + "events": "イベント", + "search": "検索ノード", + "open-node-library": "オープンノードライブラリ", + "add": "ルールノードを追加する", + "name": "名", + "name-required": "名前は必須です。", + "type": "タイプ", + "description": "説明", + "delete": "ルールノードを削除", + "select-all-objects": "すべてのノードと接続を選択する", + "deselect-all-objects": "すべてのノードと接続の選択を解除する", + "delete-selected-objects": "選択したノードと接続を削除する", + "delete-selected": "選択を削除します", + "select-all": "すべて選択", + "copy-selected": "選択したコピー", + "deselect-all": "すべての選択を解除", + "rulenode-details": "ルールノードの詳細", + "debug-mode": "デバッグモード", + "configuration": "構成", + "link": "リンク", + "link-details": "ルールノードのリンクの詳細", + "add-link": "リンクを追加", + "link-label": "リンクラベル", + "link-label-required": "リンクラベルが必要です。", + "custom-link-label": "カスタムリンクラベル", + "custom-link-label-required": "カスタムリンクラベルが必要です。", + "link-labels": "リンクラベル", + "link-labels-required": "リンクラベルが必要です。", + "no-link-labels-found": "リンクラベルが見つかりません", + "no-link-label-matching": "'{{label}}'見つかりません。", + "create-new-link-label": "新しいものを作成してください!", + "type-filter": "フィルタ", + "type-filter-details": "設定された条件で着信メッセージをフィルタリングする", + "type-enrichment": "豊かな", + "type-enrichment-details": "メッセージメタデータに追加情報を追加する", + "type-transformation": "変換", + "type-transformation-details": "メッセージペイロードとメタデータの変更", + "type-action": "アクション", + "type-action-details": "特別なアクションを実行する", + "type-external": "外部", + "type-external-details": "外部システムとの相互作用", + "type-rule-chain": "ルールチェーン", + "type-rule-chain-details": "受信したメッセージを指定したルールチェーンに転送する", + "type-input": "入力", + "type-input-details": "ルールチェーンの論理入力、次の関連ルールノードへの着信メッセージの転送", + "type-unknown": "未知の", + "type-unknown-details": "未解決のルールノード", + "directive-is-not-loaded": "'{{directiveName}}'利用できません。", + "ui-resources-load-error": "構成UIリソースをロードできませんでした。", + "invalid-target-rulechain": "ターゲットルールチェーンを解決できません!", + "test-script-function": "テストスクリプト機能", + "message": "メッセージ", + "message-type": "メッセージタイプ", + "select-message-type": "メッセージタイプを選択", + "message-type-required": "メッセージタイプは必須です", + "metadata": "メタデータ", + "metadata-required": "メタデータのエントリを空にすることはできません。", + "output": "出力", + "test": "テスト", + "help": "助けて" + }, + "tenant": { + "tenant": "テナント", + "tenants": "テナント", + "management": "テナント管理", + "add": "テナントを追加", + "admins": "管理者", + "manage-tenant-admins": "テナント管理者の管理", + "delete": "テナントの削除", + "add-tenant-text": "新しいテナントを追加する", + "no-tenants-text": "テナントは見つかりませんでした", + "tenant-details": "テナントの詳細", + "delete-tenant-title": "'{{tenantTitle}}'?", + "delete-tenant-text": "確認後、テナントと関連するすべてのデータが回復不能になるので注意してください。", + "delete-tenants-title": "{ count, plural, 1 {1 tenant} other {# tenants} }?", + "delete-tenants-action-title": "{ count, plural, 1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "注意してください。確認後、選択されたすべてのテナントが削除され、関連するすべてのデータは回復不能になります。", + "title": "タイトル", + "title-required": "タイトルは必須です。", + "description": "説明", + "details": "詳細", + "events": "イベント", + "copyId": "テナントIDをコピーする", + "idCopiedMessage": "テナントIDがクリップボードにコピーされました", + "select-tenant": "テナントを選択", + "no-tenants-matching": "'{{entity}}'発見されました。", + "tenant-required": "テナントが必要です" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", + "days": "日々", + "hours": "時間", + "minutes": "分", + "seconds": "秒", + "advanced": "上級" + }, + "timewindow": { + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "realtime": "リアルタイム", + "history": "歴史", + "last-prefix": "最終", + "period": "{{ startTime }}{{ endTime }}", + "edit": "タイムウィンドウを編集", + "date-range": "期間", + "last": "最終", + "time-period": "期間" + }, + "user": { + "user": "ユーザー", + "users": "ユーザー", + "customer-users": "顧客ユーザー", + "tenant-admins": "テナント管理者", + "sys-admin": "システム管理者", + "tenant-admin": "テナント管理者", + "customer": "顧客", + "anonymous": "匿名", + "add": "ユーザーを追加する", + "delete": "ユーザーを削除", + "add-user-text": "新しいユーザーを追加", + "no-users-text": "ユーザが見つかりませんでした", + "user-details": "ユーザーの詳細", + "delete-user-title": "'{{userEmail}}'?", + "delete-user-text": "確認後、ユーザーと関連するすべてのデータが回復不能になるので注意してください。", + "delete-users-title": "{ count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "{ count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "注意してください。確認後、選択したすべてのユーザーが削除され、関連するすべてのデータは回復不能になります。", + "activation-email-sent-message": "アクティベーション電子メールが正常に送信されました!", + "resend-activation": "アクティブ化を再送", + "email": "Eメール", + "email-required": "電子メールが必要です。", + "invalid-email-format": "メールフォーマットが無効です。", + "first-name": "ファーストネーム", + "last-name": "苗字", + "description": "説明", + "default-dashboard": "デフォルトのダッシュボード", + "always-fullscreen": "常に全画面表示", + "select-user": "ユーザーを選択", + "no-users-matching": "'{{entity}}'発見されました。", + "user-required": "ユーザーは必須です", + "activation-method": "起動方法", + "display-activation-link": "アクティブ化リンクを表示する", + "send-activation-mail": "アクティベーションメールを送信する", + "activation-link": "ユーザーアクティベーションリンク", + "activation-link-text": "activation link :", + "copy-activation-link": "アクティブ化リンクをコピーする", + "activation-link-copied-message": "ユーザーのアクティベーションリンクがクリップボードにコピーされました", + "details": "詳細" + }, + "value": { + "type": "値のタイプ", + "string": "文字列", + "string-value": "文字列値", + "integer": "整数", + "integer-value": "整数値", + "invalid-integer-value": "整数値が無効です", + "double": "ダブル", + "double-value": "二重価値", + "boolean": "ブール", + "boolean-value": "ブール値", + "false": "偽", + "true": "真", + "long": "長いです" + }, + "widget": { + "widget-library": "ウィジェットライブラリ", + "widget-bundle": "ウィジェットバンドル", + "select-widgets-bundle": "ウィジェットのバンドルを選択", + "management": "ウィジェット管理", + "editor": "ウィジェットエディタ", + "widget-type-not-found": "ウィジェットの設定を読み込む際に問題が発生しました。
    おそらく関連付けられているウィジェットのタイプが削除されています。", + "widget-type-load-error": "次のエラーのためにウィジェットが読み込まれませんでした:", + "remove": "ウィジェットを削除", + "edit": "ウィジェットの編集", + "remove-widget-title": "'{{widgetTitle}}'?", + "remove-widget-text": "確認後、ウィジェットと関連するすべてのデータは回復不能になります。", + "timeseries": "時系列", + "search-data": "検索データ", + "no-data-found": "何もデータが見つかりませんでした", + "latest": "最新の値", + "rpc": "コントロールウィジェット", + "alarm": "アラームウィジェット", + "static": "静的ウィジェット", + "select-widget-type": "ウィジェットタイプを選択", + "missing-widget-title-error": "ウィジェットのタイトルを指定する必要があります!", + "widget-saved": "ウィジェットが保存されました", + "unable-to-save-widget-error": "ウィジェットを保存できません!ウィジェットにエラーがあります!", + "save": "ウィジェットを保存", + "saveAs": "ウィジェットを次のように保存する", + "save-widget-type-as": "ウィジェットタイプを次のように保存します", + "save-widget-type-as-text": "新しいウィジェットのタイトルを入力したり、ターゲットウィジェットのバンドルを選択してください", + "toggle-fullscreen": "フルスクリーン切り替え", + "run": "ウィジェットを実行する", + "title": "ウィジェットのタイトル", + "title-required": "ウィジェットのタイトルが必要です。", + "type": "ウィジェットタイプ", + "resources": "リソース", + "resource-url": "JavaScript / CSS URL", + "remove-resource": "リソースを削除する", + "add-resource": "リソースを追加", + "html": "HTML", + "tidy": "きちんとした", + "css": "CSS", + "settings-schema": "設定スキーマ", + "datakey-settings-schema": "データキー設定のスキーマ", + "javascript": "Javascript", + "remove-widget-type-title": "'{{widgetName}}'?", + "remove-widget-type-text": "確認後、ウィジェットのタイプと関連するすべてのデータは回復不能になります。", + "remove-widget-type": "ウィジェットタイプを削除", + "add-widget-type": "新しいウィジェットタイプを追加する", + "widget-type-load-failed-error": "ウィジェットタイプの読み込みに失敗しました!", + "widget-template-load-failed-error": "ウィジェットテンプレートを読み込めませんでした!", + "add": "ウィジェットを追加", + "undo": "ウィジェットの変更を元に戻す", + "export": "ウィジェットの書き出し" + }, + "widget-action": { + "header-button": "ウィジェットのヘッダーボタン", + "open-dashboard-state": "新しいダッシュボードの状態に移動する", + "update-dashboard-state": "現在のダッシュボードの状態を更新する", + "open-dashboard": "他のダッシュボードに移動する", + "custom": "カスタムアクション", + "target-dashboard-state": "ターゲットダッシュボードの状態", + "target-dashboard-state-required": "ターゲットダッシュボードの状態が必要です", + "set-entity-from-widget": "エンティティをウィジェットから設定する", + "target-dashboard": "ターゲットダッシュボード", + "open-right-layout": "右ダッシュボードレイアウトを開く(モバイルビュー)" + }, + "widgets-bundle": { + "current": "現在のバンドル", + "widgets-bundles": "ウィジェットバンドル", + "add": "ウィジェットのバンドルを追加", + "delete": "ウィジェットのバンドルを削除する", + "title": "タイトル", + "title-required": "タイトルは必須です。", + "add-widgets-bundle-text": "新しいウィジェットのバンドルを追加する", + "no-widgets-bundles-text": "ウィジェットバンドルが見つかりません", + "empty": "ウィジェットのバンドルが空です", + "details": "詳細", + "widgets-bundle-details": "ウィジェットのバンドルの詳細", + "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "確認後、ウィジェットはバンドルされ、関連するすべてのデータは回復不能になります。", + "delete-widgets-bundles-title": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "確認後、選択したすべてのウィジェットバンドルは削除され、関連するすべてのデータは回復不能になります。", + "no-widgets-bundles-matching": "'{{widgetsBundle}}'発見されました。", + "widgets-bundle-required": "ウィジェットバンドルが必要です。", + "system": "システム", + "import": "インポートウィジェットバンドル", + "export": "ウィジェットのエクスポートバンドル", + "export-failed-error": "{{error}}", + "create-new-widgets-bundle": "新しいウィジェットバンドルを作成する", + "widgets-bundle-file": "ウィジェットのバンドルファイル", + "invalid-widgets-bundle-file-error": "ウィジェットをインポートできません。bundle:データ構造が無効です。" + }, + "widget-config": { + "data": "データ", + "settings": "設定", + "advanced": "上級", + "title": "タイトル", + "general-settings": "一般設定", + "display-title": "タイトルを表示", + "drop-shadow": "影を落とす", + "enable-fullscreen": "フルスクリーンを有効にする", + "background-color": "背景色", + "text-color": "テキストの色", + "padding": "パディング", + "margin": "マージン", + "widget-style": "ウィジェットスタイル", + "title-style": "タイトルスタイル", + "mobile-mode-settings": "モバイルモードの設定", + "order": "注文", + "height": "高さ", + "units": "値の隣に表示する特別なシンボル", + "decimals": "浮動小数点の後の桁数", + "timewindow": "タイムウィンドウ", + "use-dashboard-timewindow": "ダッシュボードのタイムウィンドウを使用する", + "display-legend": "伝説を表示", + "datasources": "データソース", + "maximum-datasources": "{ count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", + "datasource-type": "タイプ", + "datasource-parameters": "パラメーター", + "remove-datasource": "データソースを削除", + "add-datasource": "データソースを追加", + "target-device": "ターゲットデバイス", + "alarm-source": "アラームソース", + "actions": "行動", + "action": "アクション", + "add-action": "アクションを追加", + "search-actions": "検索アクション", + "action-source": "アクションソース", + "action-source-required": "アクションソースが必要です。", + "action-name": "名", + "action-name-required": "アクション名は必須です。", + "action-name-not-unique": "同じ名前の別のアクションがすでに存在します。
    アクション名は、同じアクションソース内で一意である必要があります。", + "action-icon": "アイコン", + "action-type": "タイプ", + "action-type-required": "アクションタイプが必要です。", + "edit-action": "アクションの編集", + "delete-action": "アクションの削除", + "delete-action-title": "ウィジェットアクションを削除する", + "delete-action-text": "'{{actionName}}'?" + }, + "widget-type": { + "import": "インポートウィジェットタイプ", + "export": "ウィジェットのタイプをエクスポートする", + "export-failed-error": "{{error}}", + "create-new-widget-type": "新しいウィジェットタイプを作成する", + "widget-type-file": "ウィジェットタイプファイル", + "invalid-widget-type-file-error": "ウィジェットタイプをインポートできません:ウィジェットタイプのデータ構造が無効です。" + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "日", + "Mon": "月", + "Tue": "火", + "Wed": "水", + "Thu": "木", + "Fri": "金", + "Sat": "土", + "Jan": "1月", + "Feb": "2月", + "Mar": "3月", + "Apr": "4月", + "May": "5月", + "Jun": "6月", + "Jul": "7月", + "Aug": "8月", + "Sep": "9月", + "Oct": "10月", + "Nov": "11月", + "Dec": "12月", + "January": "1月", + "February": "2月", + "March": "行進", + "April": "4月", + "June": "六月", + "July": "7月", + "August": "8月", + "September": "9月", + "October": "10月", + "November": "11月", + "December": "12月", + "Custom Date Range": "カスタム期間", + "Date Range Template": "日付範囲テンプレート", + "Today": "今日", + "Yesterday": "昨日", + "This Week": "今週", + "Last Week": "先週", + "This Month": "今月", + "Last Month": "先月", + "Year": "年", + "This Year": "今年", + "Last Year": "昨年", + "Date picker": "日付ピッカー", + "Hour": "時", + "Day": "日", + "Week": "週間", + "2 weeks": "2週間", + "Month": "月", + "3 months": "3ヶ月", + "6 months": "6ヵ月", + "Custom interval": "カスタム間隔", + "Interval": "間隔", + "Step size": "刻み幅", + "Ok": "Ok" + } + } + }, + "icon": { + "icon": "アイコン", + "select-icon": "選択アイコン", + "material-icons": "マテリアルアイコン", + "show-all": "すべてのアイコンを表示する" + }, + "custom": { + "widget-action": { + "action-cell-button": "アクションセルボタン", + "row-click": "行のクリック", + "polygon-click": "ポリゴンクリック", + "marker-click": "マーカークリック", + "tooltip-tag-action": "ツールチップのタグアクション" + } + }, + "language": { + "language": "言語" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json new file mode 100644 index 0000000..c33964e --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json @@ -0,0 +1,1813 @@ +{ + "access": { + "unauthorized": "არა ავტორიზირებული", + "unauthorized-access": "უნებართვო წვდომა", + "unauthorized-access-text": "უნებართვო წვდომის ტექსტი", + "access-forbidden": "შესვლა აკრძალულია", + "access-forbidden-text": "თქვენ არ გაქვთ ამ რესურსზე წვდომის უფლებები!
    წვდომის მისაღებად, შეეცადეთ შეხვიდეთ როგორც სხვა მომხმარებელი.", + "refresh-token-expired": "სესია ამოიწურა", + "refresh-token-failed": "სესიის განახლება ვერ მოხერხდა", + "permission-denied": "წვდომა აკრძალულია", + "permission-denied-text": "თქვენ არ გაქვთ უფლება შეასრულოთ აღნიშნული ოპერაცია" + }, + "action": { + "activate": "გააქტიურება", + "suspend": "შეაჩერე", + "save": "შენახვა", + "saveAs": "შეინახე როგორც", + "cancel": "გაუქმება", + "ok": "კარგი", + "delete": "წაშლა", + "add": "დამატება", + "yes": "დიახ", + "no": "არა", + "update": "განახლება", + "remove": "წაშლა", + "select": "შერჩევა", + "search": "ძებნა", + "clear-search": "გასუფთავება", + "assign": "მინიჭება", + "unassign": "მოხსნა", + "share": "გაზიარება", + "make-private": "გახადე პრივატული", + "apply": "დამახსოვრება", + "apply-changes": "ცვლილების დამახსოვრება", + "edit-mode": "რედაქტირების რეჟიმში", + "enter-edit-mode": "რედაქტირების რეჟიმში შესვლა", + "decline-changes": "ცვლილებების გაუქმება", + "close": "დახურვა", + "back": "უკან", + "run": "გაშვება", + "sign-in": "შესვლა", + "edit": "რედაქტირება", + "view": "ხედი", + "create": "შექმნა", + "drag": "გადაათრიეთ", + "refresh": "განახლება", + "undo": "დაბრუნება", + "copy": "კოპირება", + "paste": "ჩასმა", + "copy-reference": "მისმართის კომპირება", + "paste-reference": "მისამართის ჩასმა", + "import": "იმპორტი", + "export": "ექსპორტი", + "share-via": "გაზიარება როგორც {{provider}}", + "continue": "გაგრძელება", + "discard-changes": "ცვლილებების გაუქმება", + "done": "შესრულებულია" + }, + "aggregation": { + "aggregation": "აგრეგაცია", + "function": "ფუნქცია", + "limit": "ზღვარი", + "group-interval": "ჯგუფური ინტერვალი", + "min": "წთ", + "max": "მაქ", + "avg": "საშუალო", + "sum": "ჯამი", + "count": "რაოდენობა", + "none": "არცერთი" + }, + "admin": { + "general": "ზოგადი", + "general-settings": "ძირითადი პარამეტრები", + "outgoing-mail": "გამავალი მეილი", + "outgoing-mail-settings": "გამავალი ფოსტის-პარამეტრები", + "system-settings": "სისტემის პარამეტრები", + "test-mail-sent": "სატესტო მეილი გაგზავნილია", + "base-url": "ბაზა-url", + "base-url-required": "ბაზა-url-აუცილებელია", + "mail-from": "გამგზავნი", + "mail-from-required": "გამგზავნის ველი აუცილებელია", + "smtp-protocol": "smtp- პროტოკოლი", + "smtp-host": "smtp- ჰოსთი", + "smtp-host-required": "smtp- ჰოსთი აუციელებელია", + "smtp-port": "smtp- პორტი", + "smtp-port-required": "smtp- პორტი აუციელებელია", + "smtp-port-invalid": "smtp-პორტი არასწორია", + "timeout-msec": "ტაიმაუტი (მ/წ)", + "timeout-required": "ტაიმაუტი აუცილებელია", + "timeout-invalid": "არასწორი ტაიმაუტის დრო", + "enable-tls": "TLS-ის ჩართვა", + "send-test-mail": "სატესტო წერილის გაგზავნა", + "security-settings": "უსაფრთხოების პარამეტრები", + "password-policy": "პაროლის პოლიტიკა", + "minimum-password-length": "მინიმალური პაროლის სიგრძე", + "minimum-password-length-required": "პაროლის მინიმალური ზომა", + "minimum-password-length-range": "პაროლის სიგრძის მინიმალური დიაპაზონი", + "minimum-uppercase-letters": "მინიმალური-დიდი ასოები", + "minimum-uppercase-letters-range": "გამოიყენეთ მინიმუმ 1 დიდი ასო", + "minimum-lowercase-letters": "მინიმალური-მცირე ასოები", + "minimum-lowercase-letters-range": "მინიმალური-მცირე ასოების დიაპაზონი", + "minimum-digits": "ციფრების მინიმალური რაოდენობა", + "minimum-digits-range": "ციფრების მინიმალური დიაპაზონი", + "minimum-special-characters": "სიმბოლოების მინიმალური რაოდენობა", + "minimum-special-characters-range": "სიმბოლოების რაოდენობა არ შეიძლება იყოს ნეგატიური", + "password-expiration-period-days": "პაროლის ვადა", + "password-expiration-period-days-range": "პაროლის ვადა არ შეიძლება იყოს უარყოფითი", + "password-reuse-frequency-days": "პაროლის განმეორებით გამოყენების სიხშირე (დღე)", + "password-reuse-frequency-days-range": "პაროლის განმეორებით გამოყენების სიხშირე არ შეიძლება იყოს ნეგატიური", + "general-policy": "ზოგადი პოლიტიკა", + "max-failed-login-attempts": "მაქსიმალური შესვლის მცდელობები", + "minimum-max-failed-login-attempts-range": "მაქსიმალური შესვლის მცდელობების რაოდენობა არ შეიძლება იყოს ნეგატიური", + "user-lockout-notification-email": "თუ დაგებლოკათ ანგარიში გააგზავნეთ ნოთიფიკაცია მეილზე" + }, + "alarm": { + "alarm": "განგაში", + "alarms": "განგაშები", + "select-alarm": "აირჩიე განგაში", + "no-alarms-matching": "შესატყვისი განგაში '{{entity}}' ვერ მოიძებნა.", + "alarm-required": "საჭიროა განგაში", + "alarm-status": "განგაშის სტატუსი", + "search-status": { + "ANY": "ნებისმიერი", + "ACTIVE": "აქტიური", + "CLEARED": "გასუფთავებული", + "ACK": "დასტური", + "UNACK": "დაუდასტურებელი" + }, + "display-status": { + "ACTIVE_UNACK": "აქტიური დაუდასტურებელი", + "ACTIVE_ACK": "აქტიური დადასტურებული", + "CLEARED_UNACK": "გასუფთავება დაუდასტურებელი", + "CLEARED_ACK": "გასუფთავება_დადასტურებული" + }, + "no-alarms-prompt": "განგაში არ არსებობს", + "created-time": "შექმნის დრო", + "type": "ტიპი", + "severity": "დონე", + "originator": "ინიციატორი", + "originator-type": "ინიციატორის ტიპი", + "details": "დეტალები", + "status": "სტატუსი", + "alarm-details": "განგაშის დეტალები", + "start-time": "დაწყების დრო", + "end-time": "დასრულების დრო", + "ack-time": "დადასტურების დრო", + "clear-time": "გასუფთავების დრო", + "severity-critical": "სიმძიმე-კრიტიკული", + "severity-major": "სიმძიმე-დიდი", + "severity-minor": "სიმძიმე-მცირე", + "severity-warning": "სიმძიმის გაფრთხილება", + "severity-indeterminate": "სიმძიმე-განუსაზღვრელი", + "acknowledge": "დადასტურება", + "clear": "გასუფთავება", + "search": "ძიება", + "selected-alarms": "შერჩეული განგაშები", + "no-data": "მონაცემები არ არის", + "polling-interval": "კითხვის ინტერვალი", + "polling-interval-required": "კითხვის ინტერვალი აუცილებელია", + "min-polling-interval-message": "გამოთხვისი მინიმალური ინტერვალი", + "aknowledge-alarms-title": "გაფრთხილების დასტური სათაური", + "aknowledge-alarms-text": "დარწმუნებული ხართ რომ გინდათ დაადასტუროთ { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "დაადასტურე გაფრთხილება", + "aknowledge-alarm-text": "გაფრთხილების დადასტურება", + "clear-alarms-title": "წაშლა { count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "დარწმუნებული ხართ რომ გინდათ წაშალოთ { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "გართხილების სათაურის წაშლა", + "clear-alarm-text": "ნამდვილად გინდათ გაფრთხილების წაშლა", + "alarm-status-filter": "განგაშის სტატუსის ფილტრი", + "max-count-load": "გაფრთხილების მაქსიმალური რაოდენობა (0-შეუზღუდავი)", + "max-count-load-required": "გაფრთხილების მაქსიმალური რაოდენობა აუცილებელია", + "max-count-load-error-min": "მინიმალური მნიშვნელობა 0", + "fetch-size": "ჩასატვირთი პაკეტის ზომა", + "fetch-size-required": "მიუთითეთ ჩასატვირთი პაკეტის ზომა", + "fetch-size-error-min": "ჩასატვირთი პაკეტის -შეცდომა-წთ" + }, + "alias": { + "add": "დამატება", + "edit": "რედაქტირება", + "name": "სახელი", + "name-required": "სახელი აუცილებელია", + "duplicate-alias": "ასეთი ჩანაწერი არსებობს", + "filter-type-single-entity": "ფილტრის ტიპის ერთეული", + "filter-type-entity-list": "ფილტრის ტიპის სია", + "filter-type-entity-name": "ფილტრის ტიპის სახელი", + "filter-type-state-entity": "ფილტრის ტიპის სუბიექტი", + "filter-type-state-entity-description": "ფილტრის ტიპის სუბიექტის აღწერა", + "filter-type-asset-type": "აქტივის ტიპი", + "filter-type-asset-type-description": "აქტივის ტიპის აღწერა '{{assetType}}'", + "filter-type-asset-type-and-name-description": "ტიპის ასეტები '{{assetType}}' და სახელები რომლები იწყება '{{prefix}}'", + "filter-type-device-type": "მოწყობილობის ტიპი", + "filter-type-device-type-description": "ტიპის მოწყობილობები '{{deviceType}}'", + "filter-type-device-type-and-name-description": "ტიპის მოწყობილობები '{{deviceType}}' რომლების სახელებიც იწყება '{{prefix}}'", + "filter-type-entity-view-type": "ობიექტის მოწყობილობის ტიპი", + "filter-type-entity-view-type-description": "ობიექტის ტიპის ვარიანტები '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "ობიექტის ტიპის განსაზღვრება '{{entityView}}' და დასახელება რომელიც იწყება '{{prefix}}'", + "filter-type-relations-query": "მოთხოვნა რელაციის ტიპის მიხედვით", + "filter-type-relations-query-description": "{{entities}}, არსებული რელაციის ტიპი {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "ძიება აქტივების მიხედვით", + "filter-type-asset-search-query-description": "აქტივების ტიპი {{assetTypes}}, არსებული აქტივები {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "ძიება მოწყობილობების მიხედვით", + "filter-type-device-search-query-description": "მოწყობილობის ტიპი {{deviceTypes}}, არსებული ტიპები {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "ძიება ობიეტქების მიხევით", + "filter-type-entity-view-search-query-description": "ობიექტის ტიპის განსაზღვრება {{entityViewTypes}}, არსებული რელაციები {{relationType}} {{direction}} {{rootEntity}}", + "entity-filter": "ობიექტების ფილტრი", + "resolve-multiple": "როგორც მრავალი ობიექტი", + "filter-type": "ფილტრის ტიპი", + "filter-type-required": "სავალდებულო ფილტრის ტიპი", + "entity-filter-no-entity-matched": "ფილტრის შესაბამისი ობიექტები არ იძებნება", + "no-entity-filter-specified": "ობიექტის ფილტრი არ არ არის მითითებული", + "root-state-entity": "ობიექტის გამოყენება დეშბორდიდან როგორც ძირეული", + "root-entity": "ძირეული ობიექტი", + "state-entity-parameter-name": "სტატუსის ერთეულის პარამეტრის სახელი", + "default-state-entity": "ობიექტის ნაგულისხმევი სტატუსი", + "default-entity-parameter-name": "ობიექტის ნაგულისხმევი სახელი", + "max-relation-level": "მაქს. რელაციის დონე", + "unlimited-level": "ულიმიტო დონე", + "state-entity": "ობიექტის სტატუსი", + "all-entities": "ყველა ერთეული", + "any-relation": "ნებისმიერი რელაცია" + }, + "asset": { + "asset": "აქტივი", + "assets": "აქტივები", + "management": "მენეჯმენტი", + "view-assets": "აქტივების ნახვა", + "add": "დამატება", + "assign-to-customer": "მომხმარებელზე მინიჭება", + "assign-asset-to-customer": "აქტივის მინიჭება კლინტზე", + "assign-asset-to-customer-text": "შეარჩიეთ კატივი კილენტზე მისანიჭებლად", + "no-assets-text": "აქტივი არ იძებნება", + "assign-to-customer-text": "აირჩიე კლიენტი აქტივის მისანიჭებლად", + "public": "საჯარო", + "assignedToCustomer": "კლიენტზე მიბმა", + "make-public": "გასაჯაროება", + "make-private": "გასაჯარეობის გათიშვა", + "unassign-from-customer": "კლიენტისგან მოხსნა", + "delete": "წაშლა", + "asset-public": "საჯარო აქტივი", + "asset-type": "აქტივის ტიპი", + "asset-type-required": "აქტივის ტიპი სავალდებულოა", + "select-asset-type": "აირჩიეთ აქტივის ტიპი", + "enter-asset-type": "შეიყვანეთ აქტივის ტიპი", + "any-asset": "ნებისმიერი აქტივი", + "no-asset-types-matching": "აქტივის ტიპი '{{entitySubtype}}' ვერ მოიძებნა.", + "asset-type-list-empty": "აქტივის ტიპი ცარიელია", + "asset-types": "აქტივების ტიპები", + "name": "სახელი", + "name-required": "სახელი სავალდებულოა.", + "description": "აღწერა", + "type": "ტიპი", + "type-required": "ტიპი სავალდებულოა.", + "details": "დეტალები", + "events": "ივენთი", + "add-asset-text": "აქტივის დამატება", + "asset-details": "აქტივების დეტალები", + "assign-assets": "აქტივის მინიჭება", + "assign-assets-text": "აქტივის { count, plural, 1 {1 asset} other {# assets} } მინიჭება კლიენტზე", + "delete-assets": "აქტივების წაშლა", + "unassign-assets": "აქტივების მოშორება", + "unassign-assets-action-title": "გამოხმობა { count, plural, 1 {1 asset} other {# assets} } კლიენტისგან", + "assign-new-asset": "ახალი აქტივის მინიჭება", + "delete-asset-title": "დარწმუნებული ხართ რომ წავშალო '{{assetName}}' აქტივი?", + "delete-asset-text": "ფრთხილად, დადასტურების შემდეგ ყველა აქტივი წაიშლება და მონაცემები ვეღარ აღდგება.", + "delete-assets-title": "დარწმუნებული ხართ რომ წაიშალოს { count, plural, 1 {1 asset} other {# assets} }?", + "delete-assets-action-title": " { count, plural, 1 {1 asset} other {# assets} } წაშლა", + "delete-assets-text": "ფრთხილად, დადასტურების შემდეგ ყველა მონიშნული აქტივი წაიშლება და მონაცემები ვეღარ აღდგება.", + "make-public-asset-title": "დარწმუნებული ხართ რომ გნებავთ აქტივის '{{assetName}}' გასაჯაროება?", + "make-public-asset-text": "დადასტურების შედეგად აქტივი და მისი მონაცემები გახდება საჯარო და ხელმისაწვდომი ყველასთვის.", + "make-private-asset-title": "დარწმუნებული ხართ რომ გნებავთ აქტივის '{{assetName}}' დამალვა?", + "make-private-asset-text": "დადასტურების შედეგად აქტივი და მისი მონაცემები გახდება პრივატული და ხელმიუწვდომელი ყველასთვის.", + "unassign-asset-title": "დატწმუნებული ხართ რომ გნებავთ აქტივის '{{assetName}}' გამოხმობა?", + "unassign-asset-text": "დადასტურების შედეგად აქტივი გახდება კლიენტისთვის ხელმიუწვდომელი.", + "unassign-asset": "აქტივის გამოხმობა", + "unassign-assets-title": "დარწმუნებული ხართ რომ გნებავთ გამოიხმოთ { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "დადასტურების შედეგად ყველა მონიშნული აქტივი გახდება კლიენტისთვის ხელმიუწვდომელი.", + "copyId": "აქტივის ID-ის დაკოპირება", + "idCopiedMessage": "აქტივი დაკოპირებულია კლიპბორდში", + "select-asset": "აქტივის მონიშვნა", + "no-assets-matching": "შესაბამისი აქტივი '{{entity}}' არ მოიძებნა.", + "asset-required": "აქტივი აუცილებელია", + "name-starts-with": "აქტივის სახელი იწყება", + "import": "აქტივების იმპორტი", + "asset-file": "აქტივის ფაილი", + "search": "აქტივების ძიება", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } მონიშნულია", + "label": "ნიშნული" + }, + "attribute": { + "attributes": "ატრიბუტები", + "latest-telemetry": "უახლესი ტელემეტრია", + "attributes-scope": "ობიექტის ატრიბუტების ფარგლები", + "scope-latest-telemetry": "უახლესი ტელემეტრია", + "scope-client": "კლიენტის ატრიბუტები", + "scope-server": "სერვერის ატრიბუტები", + "scope-shared": "ატრიბუტების გაზიარება", + "add": "ატრიბუტის დამატება", + "key": "გასაღები", + "last-update-time": "ბოლო განახლების დრო", + "key-required": "ატრიბუტის გასაღები სავალდებულოა.", + "value": "მნიშვნელობა", + "value-required": "ატრიბუტის მნიშვნელობა საჭიროა.", + "delete-attributes-title": "დარწმუნებული ხართ რომ გსურთ წაშალოთ { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "ფრთხილად, დადასტურების შემდეგ ყველა მონიშნული ატრიბუტი წაიშლება.", + "delete-attributes": "ატრიბუტების წაშკა", + "enter-attribute-value": "შეიყვანეთ ატრიბუტის მნიშვნელობა", + "show-on-widget": "ვიჯეტზე გამოტანა", + "widget-mode": "ვიჯეტის რეჟიმი", + "next-widget": "შემდეგი ვიჯეტი", + "prev-widget": "წინა ვიჯეტი", + "add-to-dashboard": "დეშბორდზე დამატება", + "add-widget-to-dashboard": "ვიჯეტის დეშბორდზე დამატება", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } მონიშნულია", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } მონიშნულია" + }, + "audit-log": { + "audit": "აუდიტი", + "audit-logs": "აუდიტორული ჟურნალი", + "timestamp": "თაიმსტემპი", + "entity-type": "ობიექტის ტიპი", + "entity-name": "ობიექტის სახელი", + "user": "მომხმარებელი", + "type": "ტიპი", + "status": "სტატუსი", + "details": "დეტალები", + "type-added": "დამატებული", + "type-deleted": "წაშლილი", + "type-updated": "განახლებული", + "type-attributes-updated": "განახლებული ატრიბუტები", + "type-attributes-deleted": "წაშლილი ატრიბუტები", + "type-rpc-call": "RPC გამოძახება", + "type-credentials-updated": "მომხმარებლის ჩანაწერი განახლებულია", + "type-assigned-to-customer": "კლიენტზე მიმაგრებული", + "type-unassigned-from-customer": "კლიენტისგან მოხსნილი", + "type-activated": "გააქტიურებული", + "type-suspended": "შეჩერებული", + "type-credentials-read": "მომხმარებლის ჩანაწრის წაკითხვა", + "type-attributes-read": "ატრიბუტების წაკითხვა", + "type-relation-add-or-update": "რელაცია განახლებულია", + "type-relation-delete": "რელაცია წაშლილია", + "type-relations-delete": "ყველა რელაციის წაშლა", + "type-alarm-ack": "დადასტურებულია", + "type-alarm-clear": "გასუფთავებულია", + "type-login": "შესვლა", + "type-logout": "გამოსვლა", + "type-lockout": "ჩაკეტვა", + "status-success": "წარმატება", + "status-failure": "წარუმატებელი", + "audit-log-details": "აუდიტის ჟურნალის დეტალები", + "no-audit-logs-prompt": "ლოგები არ მოიძებნა", + "action-data": "მოქმედების მონაცემები", + "failure-details": "პრობლემის დეტალები", + "search": "აუდიტის ლოგებში ძიება", + "clear-search": "ძებნის გასუფთავება" + }, + "confirm-on-exit": { + "message": "თქვენ გაქვთ დაუმახსოვრებელი ცვლილებები. დარწმუნებული ხართ რომ გინდათ ამ გვერდიდან გადასვლა?", + "html-message": "თქვენ გაქვთ დაუმახსოვრებელი ცვლილებები.
    დარწმუნებული ხართ რომ გინდათ ამ გვერდიდან გადასვლა?", + "title": "დაუმახსოვრებელი ცვლილებები" + }, + "contact": { + "country": "ქვეყანა", + "city": "ქალაქი", + "state": "შტატი/პროვინცია", + "postal-code": "საფოსტო ინდექსი", + "postal-code-invalid": "საფოსტო კოდი არასწორია", + "address": "მისამართი", + "address2": "მისამართი 2", + "phone": "ტელეფონი", + "email": "ელ.ფოსტა", + "no-address": "მისამართის გარეშე" + }, + "common": { + "username": "მომხმარებლის სახელი", + "password": "პაროლი", + "enter-username": "შეიყვანეთ მომხმარებლის სახელი", + "enter-password": "შეიყვანეთ პაროლი", + "enter-search": "შეიყვანეთ საძიებო სიტყვა" + }, + "content-type": { + "json": "ჯეისონი", + "text": "ტექსტი", + "binary": "ორობითი (base64)" + }, + "customer": { + "customer": "კლიენტი", + "customers": "კლიენტები", + "management": "კლიენტების მართვა", + "dashboard": "მომხმარებლის დაშბორდი", + "dashboards": "მომხმარებლის დაშბორდები", + "devices": "მომხმარებლის მოწყობილობები", + "entity-views": "მომხმარებლის ობიექტები", + "assets": "კლიენტების აქტივები", + "public-dashboards": "საჯარო დეშბორდები", + "public-devices": "საჯარო მოწყობილობები", + "public-assets": "საჯარო აქტივები", + "public-entity-views": "ობიექტის საკჯარო წარმოდგენა", + "add": "მომხმარებლის დამატება", + "delete": "მომხმარებლის წაშლა", + "manage-customer-users": "კლიენტის ჯგუფის მართვა", + "manage-customer-devices": "მომხმარებელთა მოწყობილობების მართვა", + "manage-customer-dashboards": "კლიენტის დეშბორდების მართვა", + "manage-public-devices": "საჯარო მოწყობილობების მართვა", + "manage-public-dashboards": "საჯარო დეშბორდების მართვა", + "manage-customer-assets": "კლიენტების აქტივების მართვა", + "manage-public-assets": "საჯარო აქტივების მართვა", + "add-customer-text": "ახალი მომხმარებლის დამატება", + "no-customers-text": "მომხმარებელი არ იძებნება", + "customer-details": "მომხმარებლის დეტალები", + "delete-customer-title": "დარწმუნებული ხართ რომ გსურთ წაშალოთ მომხმარებელი '{{customerTitle}}'?", + "delete-customer-text": "ყურადღებით დადასტურების შემდეგ ყველა მონიშნული მომხმარებელი და მასთან დაკავშირებული მონაცემები წაიშლება.", + "delete-customers-title": "დაწრმუნებული ხართ რომ გსურთ წაშალოთ { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "წაშლა { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "ყურადღებით დადასტურების შემდეგ ყველა მონიშნული მომხმარებელი და მასთან დაკავშირებული მონაცემები წაიშლება.", + "manage-users": "მომხმარებლების მართვა", + "manage-assets": "აქტივების მართვა", + "manage-devices": "მოწყობილობების მართვა", + "manage-dashboards": "დეშბორდების მართვა", + "title": "სათაური", + "title-required": "სათაური აუცილებელია", + "description": "აღწერილობა", + "details": "დეტალები", + "events": "ივენთები", + "copyId": "მომხმარებლის ID ის დაკოპირება", + "idCopiedMessage": "კლიენიტს ID-ის დაკოპირებულია კლიპბორდში", + "select-customer": "აირჩიე მომხმარებელი", + "no-customers-matching": "მომხმარებელი '{{entity}}' არ მოიძებნა.", + "customer-required": "მომხმარებელი სავალდებულოა", + "select-default-customer": "აირჩიეთ ნაგულისხმევი კლიენტი", + "default-customer": "ნაგულისხმევი კლიენტი", + "default-customer-required": "ნაგულისხმევი კლიენტი სავალდებულოა რომ მოხერხდეს დეშბორდის ანალიზი ტენანტის დონეზე", + "search": "მომხმარებლების ძიება", + "selected-customers": "შერჩეული მომხმარებლები" + }, + "datetime": { + "date-from": "თარიღიდან", + "time-from": "დრო-დან", + "date-to": "თარიღამდე", + "time-to": "დრომდე" + }, + "dashboard": { + "dashboard": "დეშბორდი", + "dashboards": "დეშბორდები", + "management": "დეშბორდების მენეჯმენტი", + "view-dashboards": "შეხედე დეშბორდებს", + "add": "დაამატე დეშბორდი", + "assign-dashboard-to-customer": "მიამაგრე დეშბორდი მომხმარებელს", + "assign-dashboard-to-customer-text": "გთხოვთ აირჩიოთ მოხმარებელზე მისამაგრებელი დეშბორდი", + "assign-to-customer-text": "გთხოვთ აირჩიოთ მოხმარებელი რომ მიამაგროთ დეშბორდს", + "assign-to-customer": "მომხმარებელზე მიმაგრება", + "unassign-from-customer": "მომხმარებლისგან მოხსნა", + "make-public": "გახადე დეშბორდი საჯარო", + "make-private": "გახადე დეშბორდი პრივატული", + "manage-assigned-customers": "მიმაგრებული მომხმარებლების მართვა", + "assigned-customers": "მინიჭებული მოხმარებლები", + "assign-to-customers": "დეშბორდის მომხმარებეზე მიბმა", + "assign-to-customers-text": "გთხოვთ აირჩიოთ მოხმარებლები რათა მიამაგროს დეშბორდი", + "unassign-from-customers": "მომხარებლებისგან დეშბორიდს მოხსნა", + "unassign-from-customers-text": "გთხოვთ აირჩიოთ მოხმარებლები რათა მოეხსნათ დეშბორდი", + "no-dashboards-text": "დეშბორდი არ იძებნა", + "no-widgets": "ვიჯეტები არ არის დაკომფიგირებული", + "add-widget": "ვიჯეტის დამატება", + "title": "სათაური", + "select-widget-title": "აირჩიეთ ვიჯეტი", + "select-widget-subtitle": "ხელმისაწვდომი ვიჯეტების სია", + "delete": "დეშბორდის წაშლა", + "title-required": "სათაური აუცილებელია", + "description": "აღწერილობა", + "details": "დეტალები", + "dashboard-details": "დეშბორდის დეტალები", + "add-dashboard-text": "დაამატე ახალი დეშბორდი", + "assign-dashboards": "მიამაგრე დეშბორდები", + "assign-new-dashboard": "მიამაგრე ახალი დეშბორდი", + "assign-dashboards-text": "მიამაგრე { count, plural, 1 {1 dashboard} other {# dashboards} } მომხმარებელს", + "unassign-dashboards-action-text": "მოხსენი { count, plural, 1 {1 dashboard} other {# dashboards} } მომხმარებელს", + "delete-dashboards": "დეშბორდების წაშლა", + "unassign-dashboards": "დეშბორდების მოხსნა", + "unassign-dashboards-action-title": "მოხსენი { count, plural, 1 {1 dashboard} other {# dashboards} } მომხმარებელს", + "delete-dashboard-title": "დარწმუნებული ხართ რომ გინდათ დეშბორდის წაშლა '{{dashboardTitle}}'?", + "delete-dashboard-text": "ყურადღებით, დადასტურების შემდეგ დეშბორდი ყველა მონიშნული მონაცემები გახდება ხელმიუწვდომელი", + "delete-dashboards-title": "დარწმუნებული ხართ რომ გინდათ წაშლა { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "წაშლა { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "ყურადღებით, დადასტურების შემდეგ დეშბორდი ყველა მონიშნული მონაცემები გახდება ხელმიუწვდომელი", + "unassign-dashboard-title": "ნამდვილად გსურთ დეშბორდის გამოხმობა '{{dashboardTitle}}'?", + "unassign-dashboard-text": "დადასტურების შემდგომ დეშბორდი არ იქნება ხელმისაწვდომი კლიენტისთვის", + "unassign-dashboard": "დეშბორდის გამოხმობა", + "unassign-dashboards-title": "ნამდვილად გსურთ დეშბორდის გამოხმობა { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "დადასტურების შემდგომ დეშბორდი არ იქნება ხელმისაწვდომი კლიენტისთვის", + "public-dashboard-title": "დეშბორდი ხელმისაწვდომია", + "public-dashboard-text": "დეშბორდი ხელმისაწვდომია {{dashboardTitle}} is now public and accessible via next public link:", + "public-dashboard-notice": "შენიშვნა: მოწყობილობის მონაცემებზე წვდომისთვის, თქვენ უნდა გახსთათ წვდომა ამ მოწყობილობებთან", + "make-private-dashboard-title": "დარწმუნებული ხართ რომ გინდათ გახადოთ დეშბორდი '{{dashboardTitle}}' პრივატული?", + "make-private-dashboard-text": "დადასტურების შემდეგ დეშბორდი გახდება პრივატული და აღარ იქნება ხელმისაწვდომი სხვა მომხმარებლებისთვის", + "make-private-dashboard": "აქციე დეშბორდი პრივატულად", + "socialshare-text": "სოციალური ტექსტი", + "socialshare-title": "'{{dashboardTitle}}' Powered by ThingsBoard", + "select-dashboard": "აირჩიე დეშბორდი", + "no-dashboards-matching": "'{{entity}}'-ს მზგავსი დეშბორდი არ იქნა ნაპოვნი.", + "dashboard-required": "დეშბორდი აუცილებელია", + "select-existing": "აირჩიე არსებული დეშბორდი", + "create-new": "შექმენი ახალი დეშბორდი", + "new-dashboard-title": "ახალი დეშბორდის სათაური", + "open-dashboard": "ღია დეშბორდი", + "set-background": "დააყენე ფონი", + "background-color": "ფონის ფერი", + "background-image": "ფონის სურათი", + "background-size-mode": "ფონის ზომის მოუდი", + "no-image": "აირჩიეთ სურათი", + "drop-image": "ჩააგდეთ სურათი ან დააჭირეთ სურათის ასატვირთად", + "settings": "პარამეტრები", + "columns-count": "სვეტების დათვლა", + "columns-count-required": "სვეტების დათვლა სავალდებულოა", + "min-columns-count-message": "მინიმალური სვეტების დათვლის რაოდენობაა 10", + "max-columns-count-message": "მაქსიმალური სვეტების დათვლის რაოდენობაა 1000", + "widgets-margins": "ვიჯეტებს შორის ზღვარი", + "horizontal-margin": "ჰორიზონტალური ზღვარი", + "horizontal-margin-required": "'ჰორიზონტალური ზღვარი' მნიშვნელობა სავალდებულოა", + "min-horizontal-margin-message": "მინიმალური ჰორიზონტალური ზღვარი არის 0", + "max-horizontal-margin-message": "მაქსიმალრუი ჰორიზონტალური ზღვარი არის 50", + "vertical-margin": "ვერტიკალური ზღვარი", + "vertical-margin-required": "ვერტიკალური ზღვარი მნიშვნელობა სავალდებულოა", + "min-vertical-margin-message": "მინიმალური ვერტიკალური ზღვარი არის 0", + "max-vertical-margin-message": "მაქსიმალური ვერტიკალური ზღვარი არის 50", + "autofill-height": "ინტერფეისის სიმაღლის ავტომატური შერჩევა", + "mobile-layout": "მობილური წყობის პარამეტრები", + "mobile-row-height": "მობილური რიგის სიმაღლე PX", + "mobile-row-height-required": "მობილური რიგის სიმაღლე სავალდებულოა", + "min-mobile-row-height-message": "მობილური რიგის მინიმალური სიმაღლე არის 5 პიქსელი", + "max-mobile-row-height-message": "მობილური რიგის მაქსიმალური სიმაღლე არის 200 პიქსელი", + "display-title": "აჩვენე დეშბორდის სათაური", + "toolbar-always-open": "დატოვეთ ინსტრუმენტების პანელი ღია", + "title-color": "სათაურის ფერი", + "display-dashboards-selection": "დეშბორდების არჩევანის ჩვენება", + "display-entities-selection": "ობიექტის არჩევანის ჩვენება", + "display-dashboard-timewindow": "დროის მონაკვეტის ჩვენება", + "display-dashboard-export": "ექსპორტის ჩვენება", + "import": "დეშბორდის იმპორტი", + "export": "დეშბორდის ექსპორტი", + "export-failed-error": "დეშბორდის ექსპორტი შეუძლებელია: {{error}}", + "create-new-dashboard": "ახალი დეშბორდის შექმნა", + "dashboard-file": "დეშბორდის ფაილი", + "invalid-dashboard-file-error": "დეშბორდის იმპორტი შეუძლებელია: დეშბორდის სტრუქტურა დარღვეულია", + "dashboard-import-missing-aliases-title": "დააყენეთ იმპორტირებული დეშბორდების ზედმეტსახელები", + "create-new-widget": "შექმენი ახალი ვიჯეტი", + "import-widget": "ვიჯეტის იმპორტი", + "widget-file": "ვიჯეტ ფაილი", + "invalid-widget-file-error": "ვიჯეტის იმპორტი შეუძლებელია: ვიჯეტის სტრუქტურა დარღვეულია", + "widget-import-missing-aliases-title": "დააყენე იმპორტირებული ვიჯეტის ზედმეტსახელი", + "open-toolbar": "დეშბორდის პანელის გახსნა", + "close-toolbar": "პანელის დახურვა", + "configuration-error": "კონფიგურაციის შეცდომა", + "alias-resolution-error-title": "დეშბორდის ზედმეტსახელის კონფიგურაციის შეცდომა", + "invalid-aliases-config": "ზედმეტსახელის ფილტრს არ ემთხვევა არცერთი მოწყობილობა.
    გთხოვთ მიმართეთ თქვენს ადმინისტრატორს.", + "select-devices": "აირჩიეთ მოწყობილობები", + "assignedToCustomer": "მიმაგრებული მომხმარებელზე", + "assignedToCustomers": "მიმაგრებული მომხმარებლელბზე", + "public": "საჯარო", + "public-link": "საჯარო ბმული", + "copy-public-link": "დააკოპირე საჯარო ბმული", + "public-link-copied-message": "დეშბორდის საჯარო ბმული დაკოპირებულია", + "manage-states": "გააკონტროლე დეშბორდის მდგომარეობა", + "states": "დეშბორდის მდგომარეობა", + "search-states": "მოძებნე დეშბორდის მდგომარეობა", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } არჩეულია", + "edit-state": "დეშბორდის მდგომარეობის რედაქტირება", + "delete-state": "დეშბორდის მდგომარეობის წაშლა", + "add-state": "დაამატე დეშბორდის მდგომარეობა", + "state": "დეშბორდის მდგომარეობა", + "state-name": "მდგომარეობა", + "state-name-required": "დეშბორდის მოცემულობის დასახელება სავალდებულოა", + "state-id": "ID მოცემულობა", + "state-id-required": "დეშბორდის ID მოცემულობა სავალდებულოა", + "state-id-exists": "არსებული სახელით დეშბორდი უკვე არსებობს", + "is-root-state": "არის ძირეული მდგომარეობა", + "delete-state-title": "დეშბორდის მოცემულობის წაშლა", + "delete-state-text": "დარწმუნებული ხართ რომ გსურთ წაშალოთ დეშბორდი '{{stateName}}'?", + "show-details": "დეტალების გამოტანა", + "hide-details": "დეტალების დაფარვა", + "select-state": "მდგომარების არჩევა", + "state-controller": "მდგომარების კონტროლერი", + "search": "მოძებნე დეშბორდი", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } არჩეულია" + }, + "datakey": { + "settings": "პარამეტრები", + "advanced": "დამატებითი", + "label": "ნიშნული", + "color": "ფერი", + "units": "მიუთითეთ სიმბოლოები რომლებიც უნდა გამოიყენოთ დანაყოფების შემდგომ", + "decimals": "წილადები", + "data-generation-func": "მონაცემთა გენერირების ფუნქცია", + "use-data-post-processing-func": "მონაცემების დამუშავებათა შემდგომი ფუნქციის გამოყენება", + "configuration": "მონაცემთა გასაღების კონფიგურაცია", + "timeseries": "ტელემეტრია", + "attributes": "ატრიბუტები", + "entity-field": "ერთეული ობიექტის ველი", + "alarm": "განგაში", + "timeseries-required": "ტელემეტრია ობიექტის სავალდებულოა", + "timeseries-or-attributes-required": "ტელემეტრია/ატრიბუტები სავალდებულოა", + "maximum-timeseries-or-attributes": "მაქსიმალური { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "განგაშის ველები სავალდებულოა", + "function-types": "ფუნქციის ტიპები", + "function-types-required": "ფუნქციის ტიპები სავალდებულოა", + "maximum-function-types": "მაქსიუმალური { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "დრო არსებული მნიშნველობისთვის", + "value-description": "არსებული ღირებულება", + "prev-value-description": "წინა ფუნქციის რეზულტატი", + "time-prev-description": "წინა მნიშვნელობის დრო", + "prev-orig-value-description": "ორიგინალი წინა მნიშვნელობა" + }, + "datasource": { + "type": "ინფორმაციის წყაროს ტიპი", + "name": "სახელი", + "add-datasource-prompt": "დაამატეთ მონაცემთა წყარო" + }, + "details": { + "details": "დეტალები", + "edit-mode": "რედაქტირების რეჟიმში", + "toggle-edit-mode": "რედაქტირების რეჟიმში ჩართვა/გამორთვა" + }, + "device": { + "device": "მოწყობილობა", + "device-required": "მოწყობილობა სავალდებულოა", + "devices": "მოწყობილობები", + "management": "მოწყობილობების მენეჯმენტი", + "view-devices": "ნახე მოწყობილობები", + "device-alias": "მოწყობილობის ზედმეტსახელი", + "aliases": "მოწყობილობების ზედმეტსახელები", + "no-alias-matching": "'{{alias}}' არ მოიძებნა", + "no-aliases-found": "ზედმეტსახელი არ მოიძებნა", + "no-key-matching": "'{{key}}' არ მოიძებნა", + "no-keys-found": "გასაღებები არ მოიძებნა", + "create-new-alias": "შექმენი ახალი!", + "create-new-key": "შექმენი ახალი!", + "duplicate-alias-error": "ნაპოვნია დუბლიკატი ზედმეტსახელი '{{alias}}'.
    მოწყობილობის ზედმესახელი უნდა იყოს უნიკალური დაშბორდის მასშტაბით.", + "configure-alias": "დააყენე '{{alias}}' ზედმეტსახელი", + "no-devices-matching": "მოწყობილობები რომლებიც ემთხვევა '{{entity}}' არ იქნა აღმოჩენილი", + "alias": "ზედმეტსახელი", + "alias-required": "მოწყობილობის ზედმეტსახელი სავალდებულოა", + "remove-alias": "წაშალე მოწყობილობის ზედმესახელი", + "add-alias": "დაამატე მოწყობილობის ზედმესახელი", + "name-starts-with": "მოწყობილობის სახელი იწყება", + "device-list": "მოწყობილობის სია", + "use-device-name-filter": "ფილტრის გამოყენება", + "device-list-empty": "არ არის არცეული მოწყობილობა", + "device-name-filter-required": "მოწყობილობის სახელის ფილტრი სავალდებულოა", + "device-name-filter-no-device-matched": "მოწყობილობები რომლებიც ემთხვევა '{{device}}' არ იქნა აღმოჩენილი", + "add": "მოწყობილობის დამატება", + "assign-to-customer": "კლიენტზე მიბმა", + "assign-device-to-customer": "მოწყობილობების კლიენტზე მიბმა", + "assign-device-to-customer-text": "გთხოვთ აირჩიოთ მოწყობილობები კლიენტზე მისამაგრებლად", + "make-public": "გახადე მოწყობილობა საჯარო", + "make-private": "გახადე მოწყობილობა პრივატული", + "no-devices-text": "მოწყობილობები არ იქნა აღმოჩენილი", + "assign-to-customer-text": "გთხოვთ აირჩიოთ კლიენტი რომ მიამაგროთ მოწყობილობები", + "device-details": "მოწყობილობის დეტალები", + "add-device-text": "ახალი მოწყობილობის დამატება", + "credentials": "მოხმარებლის ჩანაწერი", + "manage-credentials": "მომხმარებლის ცანაწერის მართვა", + "delete": "მოწყობილობის წაშლა", + "assign-devices": "მოწყობილობის მინიჭება", + "assign-devices-text": "მიანიჭე { count, plural, 1 {1 device} other {# devices} } კლიენტს", + "delete-devices": "წაშალე მოწყობილობები", + "unassign-from-customer": "მოხსენი მოხმარებელს", + "unassign-devices": "მოხსენი მოწყობილოებები", + "unassign-devices-action-title": "მოხსენი { count, plural, 1 {1 device} other {# devices} } მოხმარებელს", + "assign-new-device": "მიამაგრე ახალი მოწყობილობა", + "make-public-device-title": "დარწმუნებული ხართ რომ გინდათ გახადოთ '{{deviceName}}' საჯარო?", + "make-public-device-text": "დადასტურების შემდგომ , მოწყობილობა და ყველა მასთან დაკავშირებული მონაცემები იქნება საჯარო და ხელმისაწვდომი", + "make-private-device-title": "დარწმუნებული ხართ რომ გინდათ შეზღუდოთ წვდომა '{{deviceName}}' თან", + "make-private-device-text": "ყურადღებით , დადასტურების შემდგომ , მოწყობილობა და ყველა მასთან დაკავშირებული მონაცემები არ იქნება ხელმისაწვდომი", + "view-credentials": "მონაცემთა ბაზის ნახვა", + "delete-device-title": "დარწმუნებული ხართ რო გინდათ წაშალოთ მოწყობილობა '{{deviceName}}'?", + "delete-device-text": "ყურადღებით , დადასტურების შემდგომ , მოწყობილობა და ყველა მასთან დაკავშირებული ჩანაწერი არ იქნება ხელმისაწვდომი", + "delete-devices-title": "დარწმუნებული ხართ რო გინდათ წაშალოთ { count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "წაშლა { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "ყურადღებით , დადასტურების შემდგომ , მოწყობილობა და ყველა მასთან დაკავშირებული ჩანაწერი არ იქნება ხელმისაწვდომი", + "unassign-device-title": "დარწმუნებული ხართ რო გინდათ გამოიხმოთ '{{deviceName}}'?", + "unassign-device-text": "დადასტურების შემდგომ , მოწყობილობა არ იქნება მომხმარებლისთვის ხელმისაწვდომი", + "unassign-device": "მოწყობილობის გამოხმობა", + "unassign-devices-title": "დარწმუნებული ხართ რო გინდათ გამოიხმოთ { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "დადასტურების შემდგომ , მოწყობილობა არ იქნება მომხმარებლისთვის ხელმისაწვდომი", + "device-credentials": "მოწყობილობის სარეგისტრაციო მონაცემები", + "credentials-type": "მომხმარებლის ჩანაწერი", + "access-token": "ტოკენი", + "access-token-required": "ტოკენი სავალდებულოა", + "access-token-invalid": "ტოკენის სიგრძე უნდა იყოს 1 დან 32 სიმბოლომდე", + "secret": "საიდუმლო", + "secret-required": "საიდუმლო აუცილებელია", + "device-type": "მოწყობილობის ტიპი", + "device-type-required": "მოწყობილობის ტიპის სავალდებულოა", + "select-device-type": "აირჩიეთ-მოწყობილობის ტიპი", + "enter-device-type": "შეიყვანეთ მოწყობილობის ტიპი", + "any-device": "ნებისმიერი მოწყობილობა", + "no-device-types-matching": "მოწყობილობის ტიპი რომელიც შეესაბამება '{{entitySubtype}}', არ იძებნება", + "device-type-list-empty": "მოწყობილობის ტიპი არ არის მითითებული", + "device-types": "მოწყობილობის ტიპები", + "name": "სახელი", + "name-required": "სახელი სავალდებულოა", + "description": "აღწერა", + "label": "ნიშნული", + "events": "ივენთი", + "details": "დეტალები", + "copyId": "დააკოპირე მოწყობილობის ID", + "copyAccessToken": "დააკოპირე წვდომის ტოკენი", + "idCopiedMessage": "მოწყობილობის ID დაკოპირებულია", + "accessTokenCopiedMessage": "მოწყობილობის წვდომის ტოკენი დაკოპირებულია", + "assignedToCustomer": "მიმაგრებულია მოხმარებელს", + "unable-delete-device-alias-title": "შეუძლებელია მოწყობილობის ზედმესახელის წაშლა", + "unable-delete-device-alias-text": "მოწყობილობა ზედმესახელით '{{deviceAlias}}' ვერ იშლება რადგან ის გამოიყენება მომდევნო ვიჯეტში
    {{widgetsList}}", + "is-gateway": "არის კარიბჭე", + "public": "საჯარო", + "device-public": "მოწყობილობა საჯაროა", + "select-device": "აირჩიეთ მოწყობილობა", + "import": "იმპორტირებული მოწყობილობა", + "device-file": "მოწყობილობის ფაილი", + "search": "მოწყობილობების ძიება", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } არჩეულია" + }, + "dialog": { + "close": "დახუურე დიალოგი" + }, + "direction": { + "column": "სვეტი", + "row": "რიგი" + }, + "error": { + "unable-to-connect": "ვერ ვუკავშირდები სერვერს! გთხოვთ შეამოწმოთ ინტერნეტ კავშირი.", + "unhandled-error-code": "მოსაგვარებელი პრობლემის კოდი: {{errorCode}}", + "unknown-error": "უცნობი შეცდომა" + }, + "entity": { + "entity": "სუბიექტი", + "entities": "სუბიექტები", + "aliases": "სუბიექტების ზედმესახელები", + "entity-alias": "სუბიექტის ზედმეტსახელი", + "unable-delete-entity-alias-title": "ვერ მოხერხდა სუბიექტის ზედმესახელის წაშლა", + "unable-delete-entity-alias-text": "სუბიექტის ზედმესახელი '{{entityAlias}}' ვერ წაიშლება რადგან მას იყენებს მომდევნო ვიჯეტი:
    {{widgetsList}}", + "duplicate-alias-error": "ნაპოვნია დუბლიკატი ზედმესახელი '{{alias}}'.
    ობიქტის ზედმესახელი უნდა იყოს უნიკალური დეშბორდის მასშტაბით.", + "missing-entity-filter-error": "ფილტრი აკლია ზედმესახელისთვის: '{{alias}}'.", + "configure-alias": "დააყენე '{{alias}}' ზედმესახელი", + "alias": "ზედმესახელი", + "alias-required": "ობიექტის ზედმესახელი სავალდებულოა", + "remove-alias": "წაშალე ობიექტის ზედმესახელი", + "add-alias": "დაამატე ობიექტის ზედმესახელი", + "entity-list": "ობიექტის სია", + "entity-type": "ობიექტის ტიპი", + "entity-types": "ობიექტის ტიპები", + "entity-type-list": "ობიექტის ტიპის სია", + "any-entity": "ნებისმიერი ობიექტი", + "enter-entity-type": "შეიყვანეთ ობიექტის ტიპი", + "no-entities-matching": "შემდგომი ობიექტი '{{entity}}' არ მოიძებნა.", + "no-entity-types-matching": "შემდგომი ობიექტის ტიპი '{{entityType}}' არ მოიძებნა.", + "name-starts-with": "სახელი იწყება", + "use-entity-name-filter": "გამოიყენე ფილტრი", + "entity-list-empty": "ობიექტი არ არის არჩეული", + "entity-type-list-empty": "ობიექტის ტიპი არ არის არჩეული", + "entity-name-filter-required": "ობიექტის სახელის ფილტრი სავალდებულოა", + "entity-name-filter-no-entity-matched": "ობიექტები რომბლებიც იწყება '{{entity}}' -ით არ იქნა ნაპოვნი", + "all-subtypes": "ყველა", + "select-entities": "აირჩიე ობიექტები", + "no-aliases-found": "ზედმეტსახელები არ არის ნაპოვნი", + "no-alias-matching": "'{{alias}}' არ არის ნაპოვნი", + "create-new-alias": "შექმენი ახალი!", + "key": "გასაღები", + "key-name": "გასაღების სახელი", + "no-keys-found": "გასარები არ არის ნაპოვნი", + "no-key-matching": "'{{key}}' არ არის ნაპოვნი.", + "create-new-key": "შექმნა ახალი გასაღები", + "type": "ტიპი", + "type-required": "ობიექტის ტიპი სავალდებულოა", + "type-device": "მოწყობილობა", + "type-devices": "მოწყობილობები", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "მოწყობილობები რომლების სახელიც იწყება '{{prefix}}' -ით", + "type-asset": "აქტივი", + "type-assets": "აქტივები", + "list-of-assets": "{ count, plural, 1 {ერთი აქტიობა} other {სია შემდეგი აქტიობები} }", + "asset-name-starts-with": "აქტიობები, რომელიც იწყება '{{prefix}}' ით", + "type-entity-view": "წარმოდგენილი ობიექტი", + "type-entity-views": "წარმოდგენილი ობიექტები", + "list-of-entity-views": "{ count, plural, 1 {ერთი ობიექტი} other {სია შემდეგი ობიექტებიდან} }", + "entity-view-name-starts-with": "ობიექტი, რომელიც იწყება '{{prefix}}' ით", + "type-rule": " წესი", + "type-rules": " წესები", + "list-of-rules": "{ count, plural, 1 {ერთი წესი} other {სია შემდეგი წესებისგან} }", + "rule-name-starts-with": "წესი, ვისი სახელიც იწყება '{{prefix}}' ით", + "type-plugin": "პლაგინი", + "type-plugins": "პლაგინები", + "list-of-plugins": "{ count, plural, 1 {ერთი პლაგინი} other {სია შემდეგი პლაგინებისგან} }", + "plugin-name-starts-with": "პლაგინი ვისი სახელიც იწყება '{{prefix}}' ით", + "type-tenant": "მეპატრონე", + "type-tenants": "მეპატრონეები", + "list-of-tenants": "{ count, plural, 1 {ერთი მომხმარებელი} other {სია შემდეგი მომხმარებლებისგან} }", + "tenant-name-starts-with": "მომხმარებლები ვისი სახელიც იწყება '{{prefix}}' ით", + "type-customer": "მომხმარებელი", + "type-customers": "მომხმარებლები", + "list-of-customers": " { count, plural, 1 {ერთი მომხმარებელი} other {სია შემდეგი მომხმარებლებისგან} }", + "customer-name-starts-with": "მომხმარებლები ვისი სახელიც იწყება '{{prefix}}' ით", + "type-user": "მომხმარებელი", + "type-users": "მომხმარებლები", + "list-of-users": "მომხმარებელთა სია { count, plural, 1 {ერთი მომხმარებელი} other {სია შემდეგი მომხმარებლებისგან} }", + "user-name-starts-with": "მომხმარებლების ჯგუფი რომლის სახელებიც იწყება '{{prefix}}' ით", + "type-dashboard": "დეშბორდი", + "type-dashboards": "დეშბორდები", + "list-of-dashboards": "{ count, plural, 1 {დეშბორდი} other {დეშბორდების სია} }", + "dashboard-name-starts-with": "დეშბორდები რომლების სახელებიც იწყება '{{prefix}}' -ით", + "type-alarm": "განგაში", + "type-alarms": "განგაშები", + "list-of-alarms": "{ count, plural, 1 {ერთი განგაში} other {განგაშის სია} }", + "alarm-name-starts-with": "განგაშები რომლების სახელებიც იწყება '{{prefix}}' -ით", + "type-rulechain": "წესების ჯაჭვი", + "type-rulechains": "წესების ჯაჭვები", + "list-of-rulechains": "{ count, plural, 1 {ერთი წესების ჯაჭვი} other {წესების ჯაჭვის სია} }", + "rulechain-name-starts-with": "წესების ჯაჭვები რომელთა სახელებიც იწყება '{{prefix}}' -ით", + "type-rulenode": "წესების ნოუდი", + "type-rulenodes": "წესების ნოუდები", + "list-of-rulenodes": "{ თვლა, plural, 1 {ერთი წესების ნოუდი} other {წესების ნოდების სია} }", + "rulenode-name-starts-with": "წესების ნოუდები რომლების სახელებიც იწყება '{{prefix}}' -ით", + "type-current-customer": "არსებული მომხმარებელი", + "search": "ობიექტის ძიება", + "selected-entities": "{ count, plural, 1 {1 ობიექტი} other {# ობიექტები} } არჩეულია", + "entity-name": "ობიექტის სახელი", + "entity-label": "ობიექტის ეტიკეტი", + "details": "ობიექტის დეტალები", + "no-entities-prompt": "ობიექტები არ მოიძებნა", + "no-data": "მონაცემები არ არის", + "columns-to-display": "სვეტების ჩვენება" + }, + "entity-field": { + "created-time": "შექმნის დრო", + "name": "სახელი", + "type": "ტიპი", + "first-name": "სახელი", + "last-name": "გვარი", + "email": "ელ.ფოსტა", + "title": "დასახელება", + "country": "ქვეყანა", + "state": "დაბა/რეგიონი", + "city": "ქალაქი", + "address": "მისამართი", + "address2": "მისამართი 2", + "zip": "ზიპ კოდი", + "phone": "ტელეფონი", + "label": "ნიშნული" + }, + "entity-view": { + "entity-view": "წარმოდგენილი ობიექტი", + "entity-view-required": "წარმოდგენილი ობიექტი სავალდებულოა", + "entity-views": "წარმოდგენილი ობიექტი", + "management": "წარმოდგენილი ობიექტის მართვა", + "view-entity-views": "წარმოდგენილი ობიექტის ნახვა", + "entity-view-alias": "ობიექტის წამოქმნის ზედმეტსახელი ", + "aliases": "ობიექტის წამოქმნის ზედმეტსახელი არ იძებნება", + "no-alias-matching": "ზედმეტსახელი '{{alias}}' არ იძებნება", + "no-aliases-found": "ზედმეტსახელი არ იძებნება", + "no-key-matching": "გასაღები '{{key}}' არ მოიძებნა", + "no-keys-found": "გასაღები არ მოიძებნა", + "create-new-alias": "ახალი სინონიმის შექმნა", + "create-new-key": "ახალი გასაღების შექმნა", + "duplicate-alias-error": "ნაპოვნია ზედმესახელის დუბლიკატი '{{alias}}'.
    ერთეული დაშბორდის პირობებში , წარმოდგენილი ობიექტის ზედმესახელი უნდა იყოს უნიკალური", + "configure-alias": "დააყენე ზედმეტსახელი '{{alias}}'", + "no-entity-views-matching": "ზწდმეტსახელის შესაბამისი ობიექტია '{{alias}}' არ იძებნება", + "alias": "ზედმეტსახელი", + "alias-required": "ობიექტის აღმნიშვნელი ზედმეტსახელი სავალდებულოა", + "remove-alias": "ობიექტის აღმნიშვნელი ზედმეტსახელის მოშორება", + "add-alias": "ობიექტის აღმნიშვნელი ზედმეტსახელის დამატება", + "name-starts-with": "ობიექტის აღმნიშვნელი ზედმეტსახელი რომლიც იწყება ", + "entity-view-list": "წარმოდგენილი ობიექტების სია", + "use-entity-view-name-filter": "ფილტრის გამოყენება", + "entity-view-list-empty": "წარმოდგენილი ობიექტების ცარიელი სია", + "entity-view-name-filter-required": "წარმოდგენილი ობიექტების ფილტრი დასახელებით სავალდებულოა", + "entity-view-name-filter-no-entity-view-matched": "წარმოდგენილი ობიექტები რომელთა დასახელება იწყება '{{entityView}}' ით არ იძებნება", + "add": "წარმოდგენილი ობიექტები", + "assign-to-customer": "დავალება მომხმარებელს", + "assign-entity-view-to-customer": "დავალებათა ერთობა მომხმარებლისთვის", + "assign-entity-view-to-customer-text": "აირჩიეთ წარმოდგენილი ობიექტები, რომლებიც კლიენტებისთვისაა", + "no-entity-views-text": "წარმოდგენილი ობიექტები არ იძებნება", + "assign-to-customer-text": "აირჩიეთ მომხმარებელი რომლისთვისაც საჭიროა წარმოდგენილი ობიექტები, ", + "entity-view-details": " წარმოდგენილი ობიექტების დეტალები", + "add-entity-view-text": " წარმოდგენილი ობიექტების დამატება", + "delete": "წარმოდგენილი ობიექტების წაშლა", + "assign-entity-views": "წარმოდგენილი ობიექტების დამატება", + "assign-entity-views-text": "მომხმარებლის { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }", + "delete-entity-views": "წარმოდგენილი ობიექტების წაშლა", + "unassign-from-customer": "მომხმარებლისდან გამოხმობა", + "unassign-entity-views": "წარმოდგენილი ობიექტების გამოხმობა", + "unassign-entity-views-action-title": "გამოხმობა მომხმარებლის { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }", + "assign-new-entity-view": "ახალი წარმოდგენილი ობიექტები", + "delete-entity-view-title": "ნამდვილათ გინდათ წარმოდგენილი ობიექტების წაშლა '{{entityViewName}}'?", + "delete-entity-view-text": "ყურადღება! თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია არ დაექვემდებარება აღდგენას", + "delete-entity-views-title": "დარწმუნებული ხართ რომ გინდათ წაშალოთ { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }?", + "delete-entity-views-action-title": " წაშალა { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }?", + "delete-entity-views-text": "ყურადღება! თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია არ დაექვემდებარება აღდგენას", + "unassign-entity-view-title": "დარწმუნებული ხართ რომ გინდათ გამოიხმოთ წარმოდგენილი ობიექტები '{{entityViewName}}'?", + "unassign-entity-view-text": "ყურადღება! თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია მომხმარებლისთვის იქნება მიუწვდომელი", + "unassign-entity-view": "წარმოდგენილი ობიექტების გამოხმობა", + "unassign-entity-views-title": "დარწმუნებული ხართ რომ გინდათ გამოიხმოთ { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }?", + "unassign-entity-views-text": "თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია მომხმარებლისთვის იქნება მიუწვდომელი", + "entity-view-type": "წარმოდგენილი ობიექტების ტიპი", + "entity-view-type-required": "წარმოდგენილი ობიექტების ტიპი აუცილებელია", + "select-entity-view-type": "აირჩიეთ წარმოდგენილი ობიექტების ტიპი", + "enter-entity-view-type": "შეიყვანეთ წარმოდგენილი ობიექტების ტიპი", + "any-entity-view": "ნებისმიერი წარმოდგენილი ობიექტები", + "no-entity-view-types-matching": "წარმოდგენილი ობიექტების ტიპი '{{entitySubtype}}' არ იძებნება", + "entity-view-type-list-empty": "წარმოდგენილი ობიექტების ტიპი არ იძებნება", + "entity-view-types": "წარმოდგენილი ობიექტების ტიპი ", + "name": "სახელი", + "name-required": "სახელი სავალდებულოა", + "description": "აღწერა", + "events": "ივენთი", + "details": "დეტალები", + "copyId": "წარმოდგენილი ობიექტების ID ის კოპირება", + "idCopiedMessage": "idCopiedMessage", + "assignedToCustomer": "დანიშნულება მომხმარებლის ", + "unable-entity-view-device-alias-title": "ვერ ხერხდება წარმოდგენილი ობიექტების ზედმეტსახელის წაშლა", + "unable-entity-view-device-alias-text": "ვერ ხერხდება წარმოდგენილი ობიექტების ზედმეტსახელის წაშლა '{{entityViewAlias}}', რადგანაც გამოიყენება შემდეგი ვიჯეტებით
    {{widgetsList}}", + "select-entity-view": "წარმოდგენილი ობიექტების შერჩევა", + "make-public": "წარმოდგენილი ობიექტების ღია წვდომა", + "make-private": "ერთეულის ხედის დამალვა", + "start-date": "დაწყების თარიღი", + "start-ts": "დაწების დრო", + "end-date": "დასრულების თარიღი", + "end-ts": "დასრულების დრო", + "date-limits": "დროის ლიმიტი", + "client-attributes": "კლიენტ-ატრიბუტები", + "shared-attributes": "საერთო ატრიბუტები", + "server-attributes": "სერვერის ატრიბუტები", + "timeseries": "ტელემეტრია", + "client-attributes-placeholder": "მომხმარებლის ატრიბუტები", + "shared-attributes-placeholder": "ზოგადი ატრიბუტები", + "server-attributes-placeholder": "სერვერის ატრიბუტები", + "timeseries-placeholder": "ტელემეტრია", + "target-entity": "მიზნობრივი ობიექტი", + "attributes-propagation": "ატრიბუტები-გამრავლების", + "attributes-propagation-hint": "ერთეულის ხედი აუტომატურად დააკოპირებს მითითებულ ატრიბუტებს სამიზნე ატრიბუტებიდან ყოველთვის როდესაც განაახლებთ ან შეინახავთ ამ ერთეულის ხედს. წარმოდობის მიზნებიდან გამომდინარე სამიზნე ერთეულის ხედი არ გავრცელდება სათითაო ატრიბუტებზე ყოველი ცვლილების დროს. თქვენ შეძლებთ ჩართოთ ავტომატური გავრცელება \"ხედის დაკოპირება\"-ს წესის კონფიგურირებით თქვენს წესების ჯაჭვში \"ატრიბუტების დაპოსტვის\" და \"ატრიბუტების განახლება\" შეტყობინებების ახალი წესების კვანძში.", + "timeseries-data": "ტელემეტრიის მონაცემები", + "timeseries-data-hint": "მიზნობრივი ობიექტის ტელემეტრიის მონაცემების გასაღების დაყენება, რომელიც ხელმისაწვდომია აღნიშნული ობიექტის , საკითხავი ინფორმაცია", + "make-public-entity-view-title": "make-public-ერთეულის ხედი-სათაური", + "make-public-entity-view-text": "make-public- ერთეულის ხედვის ტექსტი", + "make-private-entity-view-title": "make-private- პირი-ხედი-სათაური", + "make-private-entity-view-text": "make-private- პირი-ხედვა-ტექსტი", + "search": "ძებნა", + "selected-entity-views": "შერჩეული ერთეულის შეხედულებები" + }, + "event": { + "event-type": "ღონისძიების ტიპი", + "type-error": "შეცდომა", + "type-lc-event": "საციცოცხლო ციკლის მოვლენა", + "type-stats": "სტატისტიკა", + "type-debug-rule-node": "დებაგი", + "type-debug-rule-chain": "დებაგები", + "no-events-prompt": "მოვლენა არ იძებნება", + "error": "შეცდომა", + "alarm": "განგაში", + "event-time": "ივენთის დრო", + "server": "სერვერი", + "body": "სხეული", + "method": "მეთოდი", + "type": "ტიპი", + "message-id": "მესიჯის-ID", + "message-type": "მესიჯის ტიპი", + "data-type": "მონაცემთა ტიპი", + "relation-type": "ურთიერთობის ტიპი", + "metadata": "მეტამონაცემები", + "data": "მონაცემები", + "event": "ივენთი", + "status": "სტატუსი", + "success": "წარმატება", + "failed": "ვერ მოხერხდა", + "messages-processed": "შეტყობინებების დამუშავება", + "errors-occurred": "შეცდომები მოხდა", + "all-events": "ყველა", + "entity-type": "ობიექტის ტიპი" + }, + "extension": { + "extensions": "დამატებითი აპი", + "selected-extensions": "{ count, plural, 1 {1 დამატებითი აპი} other {# დამატებითი აპიები} } selected", + "type": "ტიპი", + "key": "გასაღები", + "value": "მნიშვნელობა", + "id": "ID", + "extension-id": "დამატებიტი-აპი-ID", + "extension-type": "დამატებითი აპის ტიპი", + "transformer-json": "JSON", + "unique-id-required": "ეს დამატებითი აპის ID უკვე არსებობს", + "delete": "დამატებითი აპის წაშლა", + "add": "დამატებითი აპის დამატება", + "edit": "დამატებითი აპის რედაქტირება", + "delete-extension-title": "დარწმუნებული ხართ რომ გინდათ წაშალოთ დამატებითი აპი '{{extensionId}}'?", + "delete-extension-text": "ყურადღება, თანხმობი შემდეგ დამატებითი აპი და ყველა მასთან დაკავშირებული ინფორმაცია სამუდამოდ წაიშლება", + "delete-extensions-title": "დარწმუნებული ხართ რომ გინდათ წაშალოთ { count, plural, 1 {1 დამატებითი აპი} other {# დამატებითი აპები} }?", + "delete-extensions-text": "ყურადღებით, თანხმობის შემდეგ ყველა დამატებითი აპი წაიშლება", + "converters": "გადამყვანები", + "converter-id": "გადამყვანის-ID", + "configuration": "კონფიგურაცია", + "converter-configurations": "გადამყვანის-კონფიგურაცია", + "token": "უსაფღთხოების ტოკენი", + "add-converter": "კონვერტორის დამატება", + "add-config": "კონვერტორის კონფიგურაციის დამატება", + "device-name-expression": "მოწყობილობა-სახელი-გამოხატვა", + "device-type-expression": "მოწყობილობის ტიპის გამოხატვა", + "custom": "დაკვეთით", + "to-double": "გაორმაგდება", + "transformer": "ტრანსფორმატორი", + "json-required": "ტრანსფორმატორის json– სავალდებულოა", + "json-parse": "ტრანსფორმატორის json -ის წაკითხვა შუძლებელია", + "attributes": "ატრიბუტები", + "add-attribute": "ატრიბუტის დამატება", + "add-map": "დაამატე მაპინგ ელემენტი", + "timeseries": "დროის სერიები", + "add-timeseries": "დროის სერიების დამატება", + "field-required": "ველი სავალდებულოა", + "brokers": "ბროკერები", + "add-broker": "ბროკერის დამატება", + "host": "მასპინძელი", + "port": "პორტი", + "port-range": "პროტი უნდა იყოს მომდევნო დიაპაზონში 1 დან 65535", + "ssl": "SSL", + "credentials": "ავტორიზაციის ინფორმაცია", + "username": "მომხმარებლის სახელი", + "password": "პაროლი", + "retry-interval": "ცდა-ინტერვალი მილიწამებში", + "anonymous": "ანონიმური", + "basic": "ძირითადი", + "pem": "PEM", + "ca-cert": "ca-cert", + "private-key": "Private key ფაილი", + "cert": "სერტიფიკატ ფაილი", + "no-file": "ფაილი არ არის არჩეული", + "drop-file": "ჩააგდეთ ფაილი ან დააჭირეთ ფაილის ასატვირთად", + "mapping": "მაპინგი", + "topic-filter": "თემის ფილტრი", + "converter-type": "გადამყვანის ტიპის", + "converter-json": "JSON", + "json-name-expression": "მოწყობილობის სახელი json გამოხატულება", + "topic-name-expression": "მოწყობილობის სახელი topic გამოხატულება", + "json-type-expression": "მოწყობილობის ტიპი json გამოხატულება", + "topic-type-expression": "მოწყობილობის ტიპი json გამოხატულება", + "attribute-key-expression": "ატრიბუტ გასღების გამოხატვა", + "attr-json-key-expression": "ატრიბუტ გასაღები json გამოხატვა", + "attr-topic-key-expression": "ატრიბუტ გასაღები topic გამოხატვა", + "request-id-expression": "მოთხოვნის ID გამოხატვა", + "request-id-json-expression": "მოთხოვნის ID JSON გამოხატვა", + "request-id-topic-expression": "მოთხოვნის ID TOPIC გამოხატვა", + "response-topic-expression": "პასუხი TOPIC გამოხატვა", + "value-expression": "მნიშვნელობის გამოხატვა", + "topic": "თემა", + "timeout": "დროის ამოწურვა მილიწამებში", + "converter-json-required": "გადამყვანი-json სავალდებულოა", + "converter-json-parse": "კონვერტორი json -ის წაკითხვა შუძლებელია", + "filter-expression": "ფილტრაცია", + "connect-requests": "დაკავშირების მოთხოვნა", + "add-connect-request": "მოწყობილობის გათიშვის მოთხოვნის დამატება", + "disconnect-requests": "მოწყობილობის გათიშვის მოთხოვნა", + "add-disconnect-request": "მოწყობილობის გათიშვის მოთხოვნა დამატება", + "attribute-requests": "მოთხოვნები ატრიბუტებისთვი", + "add-attribute-request": "ატრიბუტების მოთხოვნის დამატება", + "attribute-updates": "ატრიბუტების განახლება", + "add-attribute-update": "ატრიბუტების დამატების განახლება", + "server-side-rpc": "სერვერი RPC", + "add-server-side-rpc-request": "rpc სერვერის დამატება", + "device-name-filter": "მოწყობილობის სახელის ფილტრი", + "attribute-filter": "ფილტრი ატრიბუტებისათვის ", + "method-filter": " პროცესების ფილტრი", + "request-topic-expression": "მოთხოვნა-თემის გამოხატვა", + "response-timeout": "პასუხების დაყოვნების დრო მილიწამებში", + "topic-expression": "თემების დამომხატველი დასახელება", + "client-scope": "კლიენტის მასშტაბი", + "add-device": "მოწყობილობის დამატება", + "opc-server": "opc სერვერი", + "opc-add-server": "დაამატეთ სერვერი", + "opc-add-server-prompt": "გთხოვთ დაამატეთ სერვერი", + "opc-application-name": "opc- აპლიკაციის დასახელება", + "opc-application-uri": "uri აპლიკაცია", + "opc-scan-period-in-seconds": "სკანირების სიხშირე წამში", + "opc-security": "opc- უსაფრთხოება", + "opc-identity": "opc- ინდენთიფიკაცია", + "opc-keystore": "გასაღებების საცავი", + "opc-type": "opc ტიპი", + "opc-keystore-type": "opc-keystore ტიპი", + "opc-keystore-location": "opc-keystore-ადგილმდებარეობა", + "opc-keystore-password": "opc-keystore-პაროლი", + "opc-keystore-alias": "დამატებითი დასახელება", + "opc-keystore-key-password": "opc-keystore-key-პაროლი", + "opc-device-node-pattern": "opc- მოწყობილობა-კვანძი", + "opc-device-name-pattern": "მოწყობილობის პატერრ დასახელება", + "modbus-server": "სერვერი / მოწყობილობა", + "modbus-add-server": "სერვერის დამატება/მოწყობილობა", + "modbus-add-server-prompt": "სერვერის დამატება/მოწყობილობა", + "modbus-transport": "modbus-transport", + "modbus-tcp-reconnect": "კავშირის ავტომატური აღდგენა", + "modbus-rtu-over-tcp": "modbus-rtu-over-tcp", + "modbus-port-name": "თანმემდევრული პორტის დასახელება", + "modbus-encoding": "სიმბოლოების კოდირება", + "modbus-parity": " პარიტეტი", + "modbus-baudrate": "გადაცემის სიჩქარე", + "modbus-databits": "ბიტების ბაზა", + "modbus-stopbits": "modbus-stopbits", + "modbus-databits-range": "პარამეტრი databits იყენებს მნიშვნელს 7 ან 8", + "modbus-stopbits-range": "პარამეტრი stopbits იყენებს მნიშვნელს 1 ან 2", + "modbus-unit-id": "ID მოწყობილობა", + "modbus-unit-id-range": "ID მოწყობილობის დიაპაზონი 1 დან 247 მდე", + "modbus-device-name": "მოწყობილობის სახელი", + "modbus-poll-period": "გამოკითხვის სიხშირე (მილიწამებში)", + "modbus-attributes-poll-period": "ატრიბუტების გამოკითხვის სიხშირე (მილიწამები)", + "modbus-timeseries-poll-period": "ტელემეტრიის გამოკითხვის სიხშირე ( მილიწამები)", + "modbus-poll-period-range": "ტელემეტრიისთვის სავალდებულო გამოკითხვის სიხშირე უნდა იყოს ნულზე მეტი", + "modbus-tag": "modbus-tag", + "modbus-function": "modbus- ფუნქცია", + "modbus-register-address": "რეგისტრაციის მისამართი", + "modbus-register-address-range": "რეგისტრაციის მისამართი უნდა იყოს 0 დან 65535", + "modbus-register-bit-index": "ბიტის ნომერი", + "modbus-register-bit-index-range": "modbus-Register-bit-index-range", + "modbus-register-count": "რეგისტრების რაოდენობა", + "modbus-register-count-range": "დარეგისტრირებულების რაოდენობა ნოლზე მეტი", + "modbus-byte-order": "ბაიტების წყობა", + "sync": { + "status": "სტატუსი", + "sync": "დასინქრონილდა", + "not-sync": "არ დასინქრონილდა", + "last-sync-time": "ბოლო სინქრონიზაციის დრო", + "not-available": "მიუწვდომელია" + }, + "export-extensions-configuration": " გაფართოებების კონფიგურაციის ექსპორტი", + "import-extensions-configuration": "გაფართოებების კონფიგურაციის იმპორტი", + "import-extensions": "გაფართოების იმპორტი", + "import-extension": "გაფართოების იმპორტი", + "export-extension": " გაფართოების ექსპორტი", + "file": "ფაილი", + "invalid-file-error": " ფაილის არასწორი ფორმატი" + }, + "fullscreen": { + "expand": "გაფართოება ეკრანზე", + "exit": "გასასვლელი", + "toggle": "გადართეთ მთლიან ეკრანზე", + "fullscreen": "სრულ ეკრანზე" + }, + "function": { + "function": "ფუნქცია" + }, + "grid": { + "delete-item-title": "დარწმუნებული ხართ რომ გინდათ წაშალოთ წარმოდგენილი ობიექტები", + "delete-item-text": "ყურადღება! თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია არ დაექვემდებარება აღდგენას", + "delete-items-title": "დარწმუნებული ხართ რომ გინდათ წაშალოთ { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }?", + "delete-items-action-title": " წაშალა { count, plural, 1 {1 წარმოდგენილი ობიექტები} other {# წარმოდგენილი ობიექტები} }?", + "delete-items-text": "ყურადღება! თანხმობის შემთხვევაში ობიექტის ჯგუფი და ყველა შესაბამისი ინფორმაცია არ დაექვემდებარება აღდგენას", + "add-item-text": "ახალი ობიექტის დამატება", + "no-items-text": "ობიექტი არ იძებნება", + "item-details": "ობიექტის დეტალები", + "delete-item": "ობიექტის წაშლა", + "delete-items": "ობიექტების წაშლა", + "scroll-to-top": "ზემოთ დაბრუნება" + }, + "help": { + "goto-help-page": "დახმარების გვერდზე გადასვლა" + }, + "home": { + "home": "მთავარი", + "profile": "პროფილი", + "logout": "გამოსვლა", + "menu": "მენიუ", + "avatar": "ავატარი", + "open-user-menu": "გახსენი მომხმარებლის მენიუ" + }, + "import": { + "no-file": "აირჩიეთ ფაილი", + "drop-file": "ჩააგდეთ JSON ფაილი ან დააჭირეთ ფაილის ასატვირთად", + "drop-file-csv": "ჩააგდეთ CSV ფაილი ან დააჭირეთ ფაილის ასატვირთად", + "column-value": "მნიშვნელობა", + "column-title": "სათაური", + "column-example": "მაგალითი ინფორმაციის მნიშნველობის", + "column-key": "ატრიბუტ/ტელემენტარი გასარები", + "csv-delimiter": "CSV დელიმიტერი", + "csv-first-line-header": "პირველი ხაზი შეიცავს სვეტის სახელებს", + "csv-update-data": "განაახლე ატრიბუტ/ტელემენტარი", + "import-csv-number-columns-error": "ფაილი უნდა შეიცავდეს მინიმუმ 2 სვეტს", + "import-csv-invalid-format-error": "არასწორი ფაილის ფორმატი. ხაზი: '{{line}}'", + "column-type": { + "name": "სახელი", + "type": "ტიპი", + "label": "იარლიყი", + "column-type": "სვეტის ტიპი", + "client-attribute": "კლიენტ-ატრიბუტი", + "shared-attribute": "საერთო-ატრიბუტი", + "server-attribute": "სერვერის ატრიბუტი", + "timeseries": "დროის სერიები", + "entity-field": "ობიექტის ველი", + "access-token": "წვდომის ტოკენი" + }, + "stepper-text": { + "select-file": "აირჩიე ფაილი", + "configuration": "დააიმპორტე კონფიგურაცია", + "column-type": "აირჩიე სვეტის ტიპი", + "creat-entities": "იქმნება ახალი ობიექტები" + }, + "message": { + "create-entities": "{{count}} ახალი ობიექტები წარმატებით შეიქმნა.", + "update-entities": "{{count}} ობიექტები წარმატებით განახლდა.", + "error-entities": "მოხდა შეცდომა {{count}} ობიექტების შექმნისას" + } + }, + "item": { + "selected": "შერჩეული" + }, + "js-func": { + "no-return-error": "ფუნქცია უნდა აბრუნებდეს მნიშნველობას!", + "return-type-mismatch": "'{{type}}' ის დაბრუნების ფუნქცია", + "tidy": "დალაგება" + }, + "key-val": { + "key": "გასაღები", + "value": "მნიშვნელობა", + "remove-entry": "ამოღება", + "add-entry": "დამატება", + "no-data": "მონაცემები არ არის" + }, + "layout": { + "layout": "განლაგება", + "manage": "მართვა", + "settings": "პარამეტრები", + "color": "ფერი", + "main": "მთავარი", + "right": "მარჯვენა", + "select": "შერჩევა" + }, + "legend": { + "direction": "მიმართულება", + "position": "პოზიცია", + "show-max": "მაქსიმალური მნიშვნელის ჩვენება", + "show-min": "მინიმალური მნიშვნელის ჩვენება", + "show-avg": "საშუალო მნიშვნელის ჩვენება", + "show-total": "ფასის ჩვენება", + "settings": "პარამეტრების დაყენება", + "min": "მინ", + "max": "მაქ", + "avg": "საშუალო", + "total": "სულ", + "comparison-time-ago": { + "days": "დღის წინ", + "weeks": "კვირის წინ", + "months": "თვის წინ", + "years": "1 წლის წინ" + } + }, + "login": { + "login": "შესვლა", + "request-password-reset": "პაროლის გადატვირთვის მოთხოვნა", + "reset-password": "პაროლის გადატვირთვა", + "create-password": "პაროლის შექმნა", + "passwords-mismatch-error": "პაროლები არ ემთხვევა", + "password-again": "შეიყვანეთ პაროლი თავიდა", + "sign-in": "სისტემაში შესვლა", + "username": "მომხმარებლის სახელი", + "remember-me": "დამახსოვრება", + "forgot-password": " დაგავიწყდა პაროლი?", + "password-reset": "პაროლის აღდგენა", + "expired-password-reset-message": "პაროლს ვადა გაუვიდა, გთხოვთ შეიყვანოთ ახალი", + "new-password": "ახალი პაროლი", + "new-password-again": "გაიმეორეთ ახალი პაროლი", + "password-link-sent-message": "პაროლის შეცვლის მოთხოვნა გაიგზავნა", + "email": "ელ.ფოსტა" + }, + "position": { + "top": "ზევით", + "bottom": "ქვედა", + "left": "დარჩა", + "right": "მარჯვნივ" + }, + "profile": { + "profile": "პროფილი", + "last-login-time": "ბოლო შესვლის დრო", + "change-password": "პაროლის შეცვლა", + "current-password": "მიმდინარე პაროლი" + }, + "relation": { + "relations": "ურთიერთობს", + "direction": "მიმართულება", + "search-direction": { + "FROM": "დან", + "TO": "კენ" + }, + "direction-type": { + "FROM": "დან", + "TO": "კენ" + }, + "from-relations": "ურთიერთობებიდან", + "to-relations": "მოთხოვნა", + "selected-relations": "შერჩეული { count, plural, 1 {1 ურთიერთობები } other {#ურთიერთობები} }", + "type": "ტიპი", + "to-entity-type": "ერთეული ობიექტის ტიპისთვის", + "to-entity-name": "პირის სახელი", + "from-entity-type": "ერთეულის ტიპისგან", + "from-entity-name": "ობიექტიდან გამომდინარე", + "to-entity": "ობიექტისადმი", + "from-entity": "ობიექტიდან გამომდინარე", + "delete": "წაშლა", + "relation-type": "ურთიერთობის ტიპი", + "relation-type-required": "აუცილებელი ურთიერთობის ტიპი", + "any-relation-type": "ნებისმიერი ურთიერთობის ტიპის", + "add": "დამატება", + "edit": "რედაქტირება", + "delete-to-relation-title": "ნამდვილათ გსურთ წაშალოთ '{{entityName}}'?", + "delete-to-relation-text": "ყურედღება ! დადასტურების შემდეგ '{{entityName}}' იქნება უკან გამოხმობილი", + "delete-to-relations-title": "ნამდვილათ გსურთ წაშალოთ { count, plural, 1 {1 ქმედება} other {# ქმედება} }?", + "delete-to-relations-text": "ყურედღება ! დადასტურების შემდეგ შერჩეული ობიექტი იქნება უკან გამოხმობილი", + "delete-from-relation-title": "დარწმუნებული ხართ რო გინდათ '{{entityName}}' იდან ობიექტის წაშლა?", + "delete-from-relation-text": "ყურედღება ! დადასტურების შემდეგ '{{entityName}}' იქნება უკან გამოხმობილი", + "delete-from-relations-title": "ნამდვილათ გსურთ წაშალოთ { count, plural, 1 {1 ქმედება} other {# ქმედება} }?", + "delete-from-relations-text": "ყურედღება ! დადასტურების შემდეგ არჩეული ობიექტები იქნება უკან გამოხმობილი არსებული ობიექტებიდან", + "remove-relation-filter": "რელაციის ფილტრის მოშორება", + "add-relation-filter": "რელაციის ფილტრის დამატება", + "any-relation": "ნებისმიერი რელაცია", + "relation-filters": "რელაციის ფილტრები", + "additional-info": "დამატებითი ინფორმაცია (JSON)", + "invalid-additional-info": "ვერ ხერხდება დამატებითი ინფორმაციის (JSON) იდან წაკითხვა" + }, + "rulechain": { + "rulechain": "წესების წყობა", + "rulechains": "წესების წყობა", + "root": "ძირეული", + "delete": "წესების წყობის წაშლა", + "name": "სახელი", + "name-required": "სახელი (აუცილებელია", + "description": "აღწერა", + "add": "წესების წყობის დამატება", + "set-root": "წესების წყობის ძირეულად გადაკეთება", + "set-root-rulechain-title": "ნამდვილათ გინდათ '{{ruleChainName}}' წესების წყობის ძირეულად გადაკეთება", + "set-root-rulechain-text": "ყურედღება ! დადასტურების შემდეგ არჩეული წესების წყობა იქნება ძირეული და დაამუშავებს ყველა შემოსულ იმფოს", + "delete-rulechain-title": "ნამდვილათ გსურთ წაშალოთ წესების წყობა '{{ruleChainName}}'?", + "delete-rulechain-text": "ყურედღება ! დადასტურების შემდეგ არჩეული წესების წყობა და მასთან დაკავშირებული ყველა ინფო იქნება წაშლილი", + "delete-rulechains-title": "ნამდვილათ გსურთ წაშალოთ { count, plural, 1 {1 წესების წყობა} other {# წესების წყობა} }?", + "delete-rulechains-action-title": " წაშალა { count, plural, 1 {1 წესების წყობა} other {# წესების წყობა} }?", + "delete-rulechains-text": "ყურედღება ! დადასტურების შემდეგ არჩეული წესების წყობა და მასთან დაკავშირებული ყველა ინფო იქნება წაშლილი", + "add-rulechain-text": "ახალი წესების წყობის ფუნქციონალის დამატება", + "no-rulechains-text": " წესების წყობის ფუნქციონალი არ იძებნება", + "rulechain-details": " წესების წყობის ფუნქციონალის დეტალები", + "details": "დეტალები", + "events": "ივენთი", + "system": "სისტემური", + "import": " წესების წყობის ფუნქციონალის იმპორტი", + "export": " წესების წყობის ფუნქციონალის ექსპორტი", + "export-failed-error": "ვერ ხერხდება წესების წყობის ექსპორტირება {{error}}", + "create-new-rulechain": " ახალი წესების ჯაჭვის შექმნა", + "rulechain-file": "წესების ჯაჭვის ფაილი", + "invalid-rulechain-file-error": "ვერ ხერხდება წესების ჯაჭვის იმპორტირება, არასწორი ფორმატი ", + "copyId": "წესების ჯაჭვის ID მისამართის კოპირება", + "idCopiedMessage": "წესების ჯაჭვის ID მისამართის კოპირება გაცვლით ბუფერში", + "select-rulechain": "წესების ჯაჭვის არჩევა", + "no-rulechains-matching": "წესების ჯაჭვის შესატყვისი '{{entity}}' არ იძებნება", + "rulechain-required": "წესების ჯაჭვი აუცილებელია", + "management": "წესების ჯაჭვის მართვა", + "debug-mode": "გამართვის რეჟიმი" + }, + "rulenode": { + "details": "დეტალები", + "events": "ივენთი", + "search": " წესების ძებნა", + "open-node-library": "წესების ბიბლიოთეკის გახსნა", + "add": "წესების დამატება", + "name": "სახელი", + "name-required": "სახელი (აუცილებელია", + "type": "ტიპი", + "description": "აღწერა", + "delete": "წაშლა", + "select-all-objects": "გამოყავით ყველა წესი და კავშირი", + "deselect-all-objects": "გამოყავით ყველა წესი და კავშირი / გაუქმება", + "delete-selected-objects": "წაშლა/ ყველა წესი და კავშირი", + "delete-selected": "მონიშნულის წაშლა", + "select-all": "მონიშნე ყველა", + "copy-selected": "მონიშნულის კოპირება", + "deselect-all": "გააუქმეთ მონიშვნა", + "rulenode-details": "დეტალები წესებისათვის", + "debug-mode": "გამართვის რეჟიმი", + "configuration": "კონფიგურაცია", + "link": "ბმული", + "link-details": "ბმულთან დაკავშირებული დეტალები", + "add-link": "კავშირის დამატება", + "link-label": "ბმულის იარლიყი", + "link-label-required": "ბმულის იარლიყი/აუცილებელია", + "custom-link-label": "სამომხმარებლო ბმულის იარლიყი", + "custom-link-label-required": "სამომხმარებლო ბმულის იარლიყი აუცილებელია", + "link-labels": "ბმულის იარლიყი", + "link-labels-required": "ბმულის იარლიყი/აუცილებელია", + "no-link-labels-found": "ბმულის იარლიყი არ იძებნება", + "no-link-label-matching": "ბმულის იარლიყი '{{label}}' არ იძებნება", + "create-new-link-label": "ახალი ბმულის იარლიყის შექმნა", + "type-filter": " ფილტრი", + "type-filter-details": "შემოსული იმფოს ფილტრი", + "type-enrichment": "დაშენება", + "type-enrichment-details": "იმფოს დამატება მეტადატაში", + "type-transformation": "ტრანსფორმაცია", + "type-transformation-details": "მეტადატის იმფოს შეცვლა", + "type-action": "ქმედება", + "type-action-details": "დავალების შესრულება", + "type-external": "გარეგანი", + "type-external-details": "გარე სისტემებთან ურთიერრთობა", + "type-rule-chain": "წესების ჯაჭვი", + "type-rule-chain-details": "შემოსული იმფოს გადამისამართება სხვა წესების ჟაჭვით", + "type-input": " შეყვანა", + "type-input-details": "გონივრული შემომავალი წესების ჯაჭვის გადამისამართება შემდეგ დისჩიპლინებათ", + "type-unknown": "უცნობია", + "type-unknown-details": "უცნობი-დეტალები", + "directive-is-not-loaded": "კონფიგის დირექტივა '{{directiveName}}' არ იძებნება", + "ui-resources-load-error": "ui- რესურსების ჩატვირთვის შეცდომა", + "invalid-target-rulechain": "სამიზნე წესების ჯაჭვის გადაწყვეტა შეუძლებელია", + "test-script-function": "სატესტო სკრიფტ ფუნქცია", + "message": "მესიჯი", + "message-type": "მესიჯის ტიპი", + "select-message-type": "აირჩიეთ მესიჯის ტიპი", + "message-type-required": "მესიჯის ტიპი სავალდებულოა", + "metadata": "მეტამონაცემები", + "metadata-required": "მეტამონაცემები ვერ იქნება ცარიელი", + "output": "რეზულტატი", + "test": "ტესტი", + "help": "დახმარება", + "reset-debug-mode": "Debug რეჟიმის გათიშვა ყველა ნოდისთვის" + }, + "tenant": { + "tenant": "ტენანტი", + "tenants": "ტენანტი", + "management": "ტენანტების მართვა", + "add": "ტენანტის დამატება", + "admins": "ადმინისტრატორები", + "manage-tenant-admins": "ტენატ ადმინების მართვა", + "delete": "ტენანტის წაშლა", + "add-tenant-text": "ახალი ტენანტის დამატება", + "no-tenants-text": "ტენანტი ვერ მოიძებნა", + "tenant-details": "ტენანტის დეტალები", + "delete-tenant-title": "დარწმუნებული ხართ რომ გსურთ '{{tenantTitle}}'-ის წაშლა ?", + "delete-tenant-text": "ფრთხილად, დადასტურების შემდეგ ტენანტი და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "delete-tenants-title": "დარწმუნებული ხართ რომ გსურთ წაშალოთ { count, plural, 1 {1 ტენანტი} other {# ტენანტი} }?", + "delete-tenants-action-title": "{ count, plural, 1 {1 ტენანტი} other {# ტენანტი} } წაშლა", + "delete-tenants-text": "ფრთხილად, დადასტურების შემდეგ ყველა მონიშნული ტენანტი და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "title": "სათაური", + "title-required": "სათაური საჭიროა", + "description": "აღწერა", + "details": "დეტალები", + "events": "ივენთები", + "copyId": "ტენანტის ID-ის კოპირება", + "idCopiedMessage": "ტენანტის ID-ი დაკოპირებული აკლიპბორდში", + "select-tenant": "ტენანტის არჩევა", + "no-tenants-matching": "ტენანტი '{{entity}}' ვერ მოიძებნა.", + "tenant-required": "ტენანტი სავალდებულოა", + "search": "ტენანტის ძებნა", + "selected-tenants": "{ count, plural, 1 {1 ტენანტი} other {# ტენანტი} } მონიშნულია" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 წამი} other {# წამი} }", + "minutes-interval": "{ minutes, plural, 1 {1 წუთი} other {# წუთი} }", + "hours-interval": "{ hours, plural, 1 {1 ს} other {# საათი} }", + "days-interval": "{ days, plural, 1 {1 დღე} other {# დღე} }", + "days": "დღე", + "hours": "საათი", + "minutes": "წუთი", + "seconds": "წამი", + "advanced": "დამატებითი" + }, + "timewindow": { + "days": "{ days, plural, 1 { დღე } other {# დღე } }", + "hours": "{ hours, plural, 0 { საათი } 1 {1 საათი } other {# საათი } }", + "minutes": "{ minutes, plural, 0 { წუთი } 1 {1 წუთი } other {# წუთი } }", + "seconds": "{ seconds, plural, 0 { წამი } 1 {1 წამი } other {# წამი } }", + "realtime": "რეალური დრო", + "history": "ისტორია", + "last-prefix": "ბოლო", + "period": "დან {{ startTime }} {{ endTime }} მდე", + "edit": "დროის ფანჯრის რედაქტირება", + "date-range": "თარიღის დიაპაზონი", + "last": "ბოლო", + "time-period": "დროის მონაკვეთი", + "hide": "დამალვა" + }, + "user": { + "user": "მომხმარებელი", + "users": "მომხმარებლები", + "customer-users": "კლიენტის მომხმარებლები", + "tenant-admins": "ტენანტ ადმინები", + "sys-admin": "სისტემური ადმინისტრატორი", + "tenant-admin": "ტენანტ ადმინისტრატორი", + "customer": "მომხმარებელი", + "anonymous": "ანონიმური", + "add": "მომხმარებლის დამატება", + "delete": "მომხმარებლის წაშლა", + "add-user-text": "ახალი მომხმარებლის დამატება", + "no-users-text": "მომხმარებლების ვერ მოიძებნა", + "user-details": "მომხმარებლის დეტალები", + "delete-user-title": "დარწმუნებული ხართ რომ გინდათ '{{userEmail}}' -ის წაშლა?", + "delete-user-text": "ფრთხილად, დადასტურების შემდეგ მომხმარებელი და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "delete-users-title": "დარწმუნებული ხათ რომ გსურთ წაშალოთ { count, plural, 1 {1 მომხმარებელი} other {# მომხმარებლები} }?", + "delete-users-action-title": "{ count, plural, 1 {1 მომხმარებელი} other {# მომხმარებლები} } წაშლა", + "delete-users-text": "ფრთხილად, დადასტურების შემდეგ ყველა მონიშნული მომხმარებელი და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "activation-email-sent-message": "აქტივაციი სელფოსტა წარმატებით გაიგზავნა!", + "resend-activation": "აქტივაციის გადაგზავნა", + "email": "ელ.ფოსტა", + "email-required": "ელ.ფოსტა საჭირო", + "invalid-email-format": "არასწორი ელ.ფოსტის ფორმატი", + "first-name": "სახელი", + "last-name": "გვარი", + "description": "აღწერა", + "default-dashboard": "ნაგულისხმევი დეშბორდი", + "always-fullscreen": "ყოველთვის მთელს ეკრანზე", + "select-user": "მომხმარებლის არჩევა", + "no-users-matching": "მომხმარებელი '{{entity}}' ვერ მოიძებნა.", + "user-required": "მომხმარებლი სავალდებულოა", + "activation-method": "აქტივაციის მეთოდი", + "display-activation-link": "აქტვივაციის ბმულის ჩვენება", + "send-activation-mail": "აქტივაციის ელფოსტის გაგზავნა", + "activation-link": "მომხმარებლის აქტივაციის ბმული", + "activation-link-text": "იმისთვის რომ გააქტიუროთ მომხმარებეკი გამოიყენეთ შემდეგი აქტივაციის ბმული :", + "copy-activation-link": "აქტივაციის ბმული დაკოპირება", + "activation-link-copied-message": "აქტივაციის ბმული დაკოპირებულია კლიპბორდში", + "details": "დეტალები", + "login-as-tenant-admin": "შესვლა როგორც ტენანტ ადმინი", + "login-as-customer-user": "შესვლა, როგორც კლიენტის მომხმარებელი", + "search": "მომხმარებლების ძებნა", + "selected-users": "{ count, plural, 1 {1 მომხმარებელი} other {# მომხმარებლები} } მონიშნულია", + "disable-account": "მომხმარებლის ანგარიშის გამორთვა", + "enable-account": "მომხმარებლის ანგარიშის ჩართვა", + "enable-account-message": "მომხმარებლის ანგარიშის წარმატებით ჩაირთო!", + "disable-account-message": "მომხმარებლის ანგარიშის წარმატებით გამოირთო!" + }, + "value": { + "type": "მნიშვნელობის ტიპი", + "string": "სტრინგი", + "string-value": "სტრინგის მნიშვნელობა", + "integer": "მთელი რიცხვი", + "integer-value": "მთელი რიცხვის მნიშვნელობა", + "invalid-integer-value": "არასწორი მთელი რიცხვის მნიშვნელობა", + "double": "ორმაგი", + "double-value": "ორმაგი მნიშვნელობა", + "boolean": "ლოგიკური", + "boolean-value": "ლოგიკური მნიშვნელობა", + "false": "ცრუ", + "true": "ჭეშმარიტი", + "long": "გრძელი" + }, + "widget": { + "widget-library": "ვიჯეტების ბიბლიოთეკა", + "widget-bundle": "ვიჯეტების ნაკრები", + "select-widgets-bundle": "ვიჯედების ნაკრების არჩევა", + "management": "ვიჯეტების მენეჯმენტი", + "editor": "ვიჯეტების რედაქტორი", + "widget-type-not-found": "შეცდომა ვიჯეტის კონფიგურაციის ჩატვირთვისას.
    სავარაუდოთ დაკავშირებულია ვიჯეტის ტიოის წაშლასთან.", + "widget-type-load-error": "ვიჯეტი ვერ ჩაიტვირთა შემდეგი შეცდომის გამო:", + "remove": "ვიჯეტის წაშლა", + "edit": "ვიჯეტის რედაქტირება", + "remove-widget-title": "დარწმუნებული ხართ რომ გსურთ წაშალოთ ვიჯეტი '{{widgetTitle}}'?", + "remove-widget-text": "დადასტურების შემდეგ ვიჯეტი და მასთან ასიცირებული მონაცემები იქნება დაკარგული.", + "timeseries": "მონაცემთა სერია", + "search-data": "საძიებო მონაცემები", + "no-data-found": "მონაცემი ვერ მოიძებნა", + "latest": "უახლესი მნიშვნელობები", + "rpc": "ვიჯეტის კონტროლი", + "alarm": "ვიჯეტის განგაში", + "static": "სტატიკური ვიჯეტი", + "select-widget-type": "აირჩიეთ ვიჯეტის ტიპი", + "missing-widget-title-error": "ვიჯეტის სატაური უნდა იყოს მითითებული", + "widget-saved": "ვიჯეტი შენახულია", + "unable-to-save-widget-error": "შენახვა შეუძლებელია ვიჯეტის შეცდომა", + "save": "ვიჯეტის შენახვა", + "saveAs": "შეინახე ვიჯეტი როგორც", + "save-widget-type-as": "შეინახე ვიჯეტის ტიპი როგორც", + "save-widget-type-as-text": "შეიყვანეთ ახალი ვიჯეტის სატაური ან/და აირჩიეთ სამიზნე ვიჯეტიბის ნაკრებიდან.", + "toggle-fullscreen": "მთელს ეკრანზე გაშლა", + "run": "ვიჯეტის გაშვება", + "title": "ვიჯეტის სათაური", + "title-required": "ვიჯეტის სატაური საჭიროა.", + "type": "ვიჯეტის ტიპი", + "resources": "რესურსები", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "რესურსის წაშლა", + "add-resource": "რესურსის დამატება", + "html": "HTML", + "tidy": "აკურატული", + "css": "CSS", + "settings-schema": "პარამეტრების სქემა", + "datakey-settings-schema": "მონაცემტა გასაღები პარამეტრების სქემა", + "javascript": "javascript", + "js": "JS", + "remove-widget-type-title": "დარწმუნებული ხართ რომ გსურთ ვიჯეტის ტიპი '{{widgetName}}'?", + "remove-widget-type-text": "დასტურის შემთხვევაში ვიჯეტი და მასთან ასოცირებული მონაცემები დაიკარგება.", + "remove-widget-type": "ვიჯეტის ტიპის ამოღება", + "add-widget-type": "ახალი ვიჯეტის ტიპი", + "widget-type-load-failed-error": "ვიჯეტის ტიპის ჩატვირთვა ვერ მოხერხდა!", + "widget-template-load-failed-error": "ვიჯეტის შაბლონი ჩატვირთვა ვერ მოხერხდა!", + "add": "ვიჯეტის დამატება", + "undo": "ვიჯეტის ცვლილების გაუქმება", + "export": "ვიჯეტის ექსპორტი" + }, + "widget-action": { + "header-button": "ვიჯეტის თავსართის ღილაკი", + "open-dashboard-state": "ახლი დეშბორდის მდგომარეობაზე გადასვლა", + "update-dashboard-state": "მიმდინარე დეშბორდის მდგომარეობის განახლება", + "open-dashboard": "სხვა დეშბორდზე გადასვლა", + "custom": "სხვა მოქმედება", + "custom-pretty": "სხვა მოქმედება (HTML შაბლონით)", + "target-dashboard-state": "სამიზნე დეშბორდის მდგომარეობა", + "target-dashboard-state-required": "სამიზნე დეშბორდის მდგომარეობა საჭიროა", + "set-entity-from-widget": "ობიექტის მიბიჭება ვიჯეტიდან", + "target-dashboard": "სამიზნე დეშბორდი", + "open-right-layout": "დეშბორდის მარჯვენა განლაგების გახსნა (მობილურის ხედით)" + }, + "widgets-bundle": { + "current": "მიმდინარე ნაკრები", + "widgets-bundles": "ვიჯეტების ნაკრები", + "add": "ვიჯეტების ნაკრების დამატება", + "delete": "ვიჯეტების ნაკრების წაშლა", + "title": "სათაური", + "title-required": "სათაური საჭიროა.", + "add-widgets-bundle-text": "ახალი ვიჯეტების ნაკრების დამატება", + "no-widgets-bundles-text": "ვიჯეტების ნაკრები ვერ მოიძებნა", + "empty": "ვიჯეტების ნაკრები ცარიელია", + "details": "დეტალები", + "widgets-bundle-details": "ვიჯეტების ნაკრების დეტალები", + "delete-widgets-bundle-title": "დარწმუნებული ხართ რომ გსურთ ვიჯეტების ნაკრების წაშლა '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "ფრთხილად, დადასტურების შემდეგ ვიჯეტების ნაკრები და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "delete-widgets-bundles-title": "დარწმუნებული ხარ რომ გსურს წაშალო { count, plural, 1 {1 ვიჯეტის ნაკრები} other {# ვიჯეტების ნაკრები} }?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 ვიჯეტის ნაკრები} other {# ვიჯეტის ნაკრებები} } წაშლა", + "delete-widgets-bundles-text": "ფრთხილად, დადასტურების შემდეგ ყველა მონიშნული ვიჯეტების ნაკრები და მასთან ასოცირებული მონაცემები იქნება დაკარგული.", + "no-widgets-bundles-matching": "ვიჯეტის ნაკრები '{{widgetsBundle}}' ვერ მოიძებნა.", + "widgets-bundle-required": "ვიჯეტის ნაკრები სავალდებულოა.", + "system": "სისტემა", + "import": "ვიჯეტის ნაკრების იმპორტი", + "export": "ვიჯეტის ნაკრების ექსპორტი", + "export-failed-error": "ვიჯეტის ნაკრების ექსპორტი ვერ განხორციელებული შეცდომა: {{error}}", + "create-new-widgets-bundle": "ახალი ვიჯეტის ნაკრების შექმნა", + "widgets-bundle-file": "ვიჯეტებებსი ნაკრების ფაილი", + "invalid-widgets-bundle-file-error": "ვიჯეტის ნაკრების იმპორტი ვერ განხორციელდა: არასწორი სტრუქტურა." + }, + "widget-config": { + "data": "მონაცემები", + "settings": "პარამეტრები", + "advanced": "დამატებითი", + "title": "სათაური", + "title-tooltip": "სათაურის განმარტება", + "general-settings": "ძირითადი პარამეტრები", + "display-title": "სათაურის ჩვენება", + "drop-shadow": "ჩრდილი", + "enable-fullscreen": "ჩართვა მთელს ეკრანზე", + "background-color": "ფონის ფერი", + "text-color": "ტექსტის ფერი", + "padding": "დაშორება", + "margin": "ზღვარი", + "widget-style": "ვიჯეტის სტილი", + "title-style": "სათაურის სტილი", + "mobile-mode-settings": "მობილური რეჟიმის პარამეტრები", + "order": "განლაგება", + "height": "სიმაღლე", + "units": "სპეციალური სიმბოლო შემდეგი მნიშვნელობისთვის", + "decimals": "ციფრების რაოდენობა წერტილის შემდეგ", + "timewindow": "ქრონომეტრაჟი", + "use-dashboard-timewindow": "დეშბორდის ქრონომეტრაჟის გამოყენება", + "display-timewindow": "ქრონომეტრაჟის ჩვენება", + "display-legend": "ლეგენდის ჩვენება", + "datasources": "მონაცემთა წყაროები", + "maximum-datasources": "მაქს. { count, plural, 1 {1 მონცემთა წყარო დაშვებულია.} other {# მონაცემტა წყაროები დაშვებულია} }", + "datasource-type": "ტიპი", + "datasource-parameters": "პარამეტრები", + "remove-datasource": "მონაცემთა წყაროს წაშლა", + "add-datasource": "დაამატეთ მონაცემთა წყარო", + "target-device": "სამიზნე მოწყობილობა", + "alarm-source": "განგაშის წყარო", + "actions": "მოქმედებები", + "action": "მოქმედება", + "add-action": "მოქმედების დამატება", + "search-actions": "საძიებო მოქმედებები", + "action-source": "მოქმედების წყარო", + "action-source-required": "მოქმედების წყარო საჭიროა.", + "action-name": "სახელი", + "action-name-required": "მოქმედების სახელი საჭიროა.", + "action-name-not-unique": "სხვა მოქმედება იგივე სახელით უკვე არსებობს.
    მოქმედების სახელი უნდა იყოს უნიკალური ერთი და იგივე მონაცემთა წყაროსთვის.", + "action-icon": "ხატულა", + "action-type": "ტიპი", + "action-type-required": "მოქმედების ტიპი საჭიროა.", + "edit-action": "მოქმედების რედაქტირება", + "delete-action": "მოქმედების წაშლა", + "delete-action-title": "მოქმედების ვიჯეტის წაშლა", + "delete-action-text": "დარწმუნებული ხართ რომ გსურთ წაშალოთ მოწმდებების ვიჯეტი სახელად '{{actionName}}'?", + "display-icon": "სათაურის ხატულას ჩვენება", + "icon-color": "ხატულას ფერი", + "icon-size": "ხატულას ზომა" + }, + "widget-type": { + "import": "ვიჯეტის ტიპის იმპორტი", + "export": "ვიჯეტის ტიპის ექსპორტი", + "export-failed-error": "ვიჯეტის ტიპის ექსპორტი ვერ განხორციელდა: {{error}}", + "create-new-widget-type": "ახალი ვიჯეტისტიპის შექმნა", + "widget-type-file": "ვიჯეტის ტიპის ფაილი", + "invalid-widget-type-file-error": "ვიჯეტის ტიპის იმპორტი ვერ განხორციელდ: არასწირი მონაცემტა სტრუქტურა." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "მზე", + "Mon": "ორშ", + "Tue": "სამ", + "Wed": "ოთხ", + "Thu": "ხუთ", + "Fri": "პარ", + "Sat": "შაბ", + "Jan": "იან", + "Feb": "თებ", + "Mar": "მარ", + "Apr": "აპრილი", + "May": "მაისი", + "Jun": "ივნ", + "Jul": "იული", + "Aug": "აგვისტო", + "Sep": "სექტ", + "Oct": "ოქტომბერი", + "Nov": "ნოემბერი", + "Dec": "დეკ", + "January": "იანვარი", + "February": "თებერვალი", + "March": "მარტი", + "April": "აპრილი", + "June": "ივნისი", + "July": "ივლისი", + "August": "აგვისტო", + "September": "სექტემბერი", + "October": "ოქტომბერი", + "November": "ნოემბერი", + "December": "დეკემბერი", + "Custom Date Range": "თარიღის მორგებული ზომა", + "Date Range Template": "თარიღი დიაპაზონის შაბლონი", + "Today": "დღეს", + "Yesterday": "გუშინ", + "This Week": "მიმდინარე კვირა", + "Last Week": "წინა კვირა", + "This Month": "მიმდინარე თვე", + "Last Month": "გასული თვე", + "Year": "წელი", + "This Year": "მიმდინარე წელი", + "Last Year": "გასული წელი", + "Date picker": "თარიღის ამომრჩევი", + "Hour": "საათი", + "Day": "დღე", + "Week": "კვირა", + "2 weeks": "2 კვირა", + "Month": "თვე", + "3 months": "3 თვე", + "6 months": "6 თვე", + "Custom interval": "არჩევითი ინტერვალი", + "Interval": "ინტერვალი", + "Step size": "ნაბიჯის ზომა", + "Ok": "კარგი" + } + }, + "input-widgets": { + "attribute-not-allowed": "პარამეტრის ატრიბუტის ვერ გამოიყენებთ ამ ვიჯეტისთვის", + "blocked-location": "გეოლოკაცია ადაბლოკილია თქვენს ბროუზერში", + "claim-device": "მოწყობილობის გააქტიურება", + "claim-failed": "მოწყობილობის გააქტიურება ვერ მოხერხდა!", + "claim-not-found": "მოწყობილობა ვერ მოიძებნა!", + "claim-successful": "მოწყობილობა გააქტიურდა წარმატებით!", + "date": "თარიღი", + "device-name": "მოწყობილობის სახელი", + "device-name-required": "მოწყობილობის სახელი სავალდებულუა", + "discard-changes": "ცვლილებების გაუქმება", + "entity-attribute-required": "ობიექტის ატრიბუტი სავალდებულოა", + "entity-coordinate-required": "ორივე ველი: გრძედი და განედი აუცილებელია", + "entity-timeseries-required": "ობიექტის თაიმსერია აუცილებელია", + "get-location": "მიმდინაე ადგილმდებარეობის გაგება", + "latitude": "განედი", + "longitude": "გრძედი", + "not-allowed-entity": "მონიშნულ ობიექტს არ გააჩნია გაზიარებადი ატრიბუტები", + "no-attribute-selected": "ატრიბუტებაი არ არის მონიშნული", + "no-datakey-selected": "მონაცემთა გასაღები არ არის მონიშნული", + "no-coordinate-specified": "მონაცემთა გასაღები გრძედ/განედისთვის არ არის მითითებული", + "no-entity-selected": "ობიექტი არ არის მონიშნული", + "no-image": "სურათი არ არის", + "no-support-geolocation": "თქვენ ბროუზერს არ გააჩნია გეოლოკაციის მხარდაჭერა", + "no-support-web-camera": "ვებ-კამერის მიუწვდომელია", + "no-timeseries-selected": "მონაცემტასერია არ არის მონიშნული", + "secret-key": "საიდუმლო გასაღები", + "secret-key-required": "საიდუმლო გასაღები საჭიროა", + "switch-attribute-value": "ობიექტის ატრიბუტის მნიშვნელობის გადართვა", + "switch-camera": "კამერის გადართვა", + "switch-timeseries-value": "ობიექტის მონაცემთა სერიის მნიშვნელობის გართვა", + "take-photo": "ფოტოს გადაღება", + "time": "დრო", + "timeseries-not-allowed": "მონაცემთა სერიის პარამეტრი ვერ გააქტიურდება ამ ვიჯეტისთვის", + "update-failed": "განახლება ვერ მოხერხდა", + "update-successful": "წარმატებით განახლდა", + "update-attribute": "ატრიბუტის განახლება", + "update-timeseries": "მონაცემთა სერიის განახლება", + "value": "მნიშვნელობა" + } + }, + "icon": { + "icon": "ხატულა", + "select-icon": "აირჩიეთ ხატულა", + "material-icons": "მასალის ხატულები", + "show-all": "მაჩვენე ყველა ხატულა" + }, + "custom": { + "widget-action": { + "action-cell-button": "მოქმედების უჯრედ-ღილაკი", + "row-click": "რიგზე დაჭერით", + "polygon-click": "პოლიგონზე დაჭერით", + "marker-click": "მარკერის დაჭერით", + "tooltip-tag-action": "ინსტრუმენტის სახელმძღვანელო", + "node-selected": "მონიშნულ კვანძზე", + "element-click": "HTML ელემენტზე დაჭერით", + "pie-slice-click": "სლაისზე დაჭერით", + "row-double-click": "რიგზე ორჯერ დაკლიკებით" + } + }, + "language": { + "language": "ენა" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json new file mode 100644 index 0000000..1d05b49 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json @@ -0,0 +1,2476 @@ +{ + "access": { + "unauthorized": "승인되지 않음", + "unauthorized-access": "승인되지 않은 접근", + "unauthorized-access-text": "이 리소스에 접근하려면 로그인해야 합니다!", + "access-forbidden": "접근 금지", + "access-forbidden-text": "접근 권한이 없습니다!
    만일 이 페이지에 계속 접근하려면 다른 사용자로 로그인 하세요.", + "refresh-token-expired": "세션이 만료되었습니다", + "refresh-token-failed": "세션을 새로 고칠 수 없습니다.", + "permission-denied": "권한이 없습니다", + "permission-denied-text": "이 작업을 수행할 권한이 없습니다!" + }, + "action": { + "activate": "활설화", + "suspend": "비활성화", + "save": "저장", + "saveAs": "다른 이름으로 저장", + "cancel": "취소", + "ok": "확인", + "delete": "삭제", + "add": "추가", + "yes": "네", + "no": "아니오", + "update": "업데이트", + "remove": "제거", + "select": "선택", + "search": "검색", + "clear-search": "검색 초기화", + "assign": "할당", + "unassign": "할당 해제", + "share": "공유", + "make-private": "비공개로 설정", + "apply": "적용", + "apply-changes": "변경사항 적용", + "edit-mode": "수정 모드", + "enter-edit-mode": "수정 모드 진입", + "decline-changes": "변경사항 포기", + "close": "닫기", + "back": "뒤로", + "run": "실행", + "sign-in": "로그인!", + "edit": "수정", + "view": "보기", + "create": "만들기", + "drag": "끌기", + "refresh": "새로고침", + "undo": "취소", + "copy": "복사", + "paste": "붙여넣기", + "copy-reference": "참조 복사", + "paste-reference": "참조 붙여넣기", + "import": "가져오기", + "export": "내보내기", + "share-via": "{{provider}}를 통해 공유", + "continue": "계속", + "discard-changes": "변경 취소", + "download": "다운로드", + "next-with-label": "다음: {{label}}", + "read-more": "더보기", + "hide": "숨기기", + "done": "완료" + }, + "aggregation": { + "aggregation": "집합", + "function": "데이터 집합 함수", + "limit": "최대 값", + "group-interval": "그룹 간격", + "min": "최소", + "max": "최대", + "avg": "평균", + "sum": "합계", + "count": "숫자", + "none": "없음" + }, + "admin": { + "general": "일반", + "general-settings": "일반 설정", + "outgoing-mail": "메일 전송", + "outgoing-mail-settings": "메일 전송 설정", + "system-settings": "시스템 설정", + "test-mail-sent": "테스트 메일이 성공적으로 전송되었습니다!", + "base-url": "기본 URL", + "base-url-required": "기본 URL을 입력해야 합니다.", + "prohibit-different-url": "클라이언트 요청 헤더로부터 호스트 이름 사용을 제한", + "prohibit-different-url-hint": "이 설정은 프로덕션 환경에서 활성화되어야 합니다. 비활성화되면 보안 문제가 발생할 수 있습니다.", + "mail-from": "보내는 사람", + "mail-from-required": "보내는 사람을 입력해야 합니다.", + "smtp-protocol": "SMTP 프로토콜", + "smtp-host": "SMTP 호스트", + "smtp-host-required": "SMTP 호스트를 입력해야 합니다.", + "smtp-port": "SMTP 포트", + "smtp-port-required": "SMTP 포트를 입력해야 합니다.", + "smtp-port-invalid": "올바른 SMTP 포트가 아닙니다.", + "timeout-msec": "제한시간 (ms)", + "timeout-required": "제한시간이 입력되지 않았습니다.", + "timeout-invalid": "제한시간이 올바르게 입력되지 않았습니다.", + "enable-tls": "TLS 사용", + "tls-version" : "TLS 버전", + "enable-proxy": "프록시 사용", + "proxy-host": "프록시 호스트", + "proxy-host-required": "프록시 호스트를 입력하세요.", + "proxy-port": "프록시 포트", + "proxy-port-required": "프록시 포트를 입력하세요.", + "proxy-port-range": "프록시 포트를 1~65535 범위 내에서 입력하세요.", + "proxy-user": "프록시 사용자", + "proxy-password": "프록시 비밀번호", + "send-test-mail": "테스트 메일 보내기", + "sms-provider": "SMS 제공자", + "sms-provider-settings": "SMS 제공자 설정", + "sms-provider-type": "SMS 제공자 유형", + "sms-provider-type-required": "SMS 제공자 유형을 입력하세요.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "aws-access-key-id": "AWS 액세스 키 ID", + "aws-access-key-id-required": "AWS 액세스 ID를 입력하세요.", + "aws-secret-access-key": "AWS 보안 액세스 키", + "aws-secret-access-key-required": "AWS 보안 액세스 키를 입력하세요", + "aws-region": "AWS 지역", + "aws-region-required": "AWS 지역을 입력하세요", + "number-from": "Phone Number From", + "number-from-required": "Phone Number From을(를) 입력하세요.", + "number-to": "Phone Number To", + "number-to-required": "Phone Number To을(를) 입력하세요.", + "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", + "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", + "sms-message": "SMS 메시지", + "sms-message-required": "SMS 메시지를 입력하세요.", + "sms-message-max-length": "SMS 메시지는 1600자를 넘을 수 없습니다.", + "twilio-account-sid": "Twilio 계정 SID", + "twilio-account-sid-required": "Twilio 계정 SID를 입력하세요", + "twilio-account-token": "Twilio 계정 토큰", + "twilio-account-token-required": "Twilio 계정 토큰을 입력하세요", + "send-test-sms": "테스트 SMS 보내기", + "test-sms-sent": "테스트 SMS가 성공적으로 발송되었습니다!", + "security-settings": "보안 설정", + "password-policy": "비밀번호 정책", + "minimum-password-length": "최소 비밀번호 길이", + "minimum-password-length-required": "최소 비밀번호 길이를 입력하세요", + "minimum-password-length-range": "최소 비밀번호 길이를 5~50 범위 내에서 입력하세요", + "minimum-uppercase-letters": "최소 대문자 수", + "minimum-uppercase-letters-range": "최소 대문자 수는 음수가 될 수 없습니다", + "minimum-lowercase-letters": "최소 소문자 수", + "minimum-lowercase-letters-range": "최소 소문자 수는 음수가 될 수 없습니다", + "minimum-digits": "최소 숫자 수", + "minimum-digits-range": "최소 숫자 수는 음수가 될 수 없습니다", + "minimum-special-characters": "최소 특수문자 수", + "minimum-special-characters-range": "최소 특수문자 수는 음수가 될 수 없습니다", + "password-expiration-period-days": "비밀번호 유효 주기(일)", + "password-expiration-period-days-range": "비밀번호 유효 주기는 음수가 될 수 없습니다", + "password-reuse-frequency-days": "비밀번호 재사용 주기(일)", + "password-reuse-frequency-days-range": "비밀번호 재사용 주기는 음수가 될 수 없습니다", + "general-policy": "일반 정책", + "max-failed-login-attempts": "계정이 잠기기 전까지 허용되는 최대 로그인 실패 시도 수", + "minimum-max-failed-login-attempts-range": "최대 로그인 실패 시도 수는 음수가 될 수 없습니다", + "user-lockout-notification-email": "계정이 잠길 경우 이메일로 안내 발송", + "domain-name": "도메인 이름", + "domain-name-unique": "도메인 이름과 프로토콜은 중복되지 않아야 합니다.", + "error-verification-url": "도메인 이름은 '/'이나 ':'와 같은 문자를 포함할 수 없습니다. 예: thingsboard.io", + "oauth2": { + "access-token-uri": "액세스 토큰 URI", + "access-token-uri-required": "Access token URI를 입력하세요.", + "activate-user": "사용자 활성화", + "add-domain": "도메인 추가", + "delete-domain": "도메인 삭제", + "add-provider": "제공자 추가", + "delete-provider": "제공자 삭제", + "allow-user-creation": "사용자 생성 허용", + "always-fullscreen": "항상 최대화면으로", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI를 입력하세요.", + "client-authentication-method": "클라이언트 authentication 방법", + "client-id": "Client ID", + "client-id-required": "Client ID를 입력하세요.", + "client-secret": "Client secret", + "client-secret-required": "Client secret을(를) 입력하세요.", + "custom-setting": "사용자 정의 설정", + "customer-name-pattern": "Customer name pattern", + "default-dashboard-name": "Default dashboard name", + "delete-domain-text": "Be careful, after the confirmation a domain and all provider data will be unavailable.", + "delete-domain-title": "Are you sure you want to delete settings the domain '{{domainName}}'?", + "delete-registration-text": "Be careful, after the confirmation a provider data will be unavailable.", + "delete-registration-title": "Are you sure you want to delete the provider '{{name}}'?", + "email-attribute-key": "Email 속성 키", + "email-attribute-key-required": "Email attribute key을(를) 입력하세요.", + "first-name-attribute-key": "이름 속성 키", + "general": "일반", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Last name attribute key", + "login-button-icon": "Login button icon", + "login-button-label": "Provider label", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "Label을(를) 입력하세요.", + "login-provider": "로그인 제공자", + "mapper": "Mapper", + "new-domain": "새로운 도메인", + "oauth2": "OAuth2", + "redirect-uri-template": "Redirect URI 탬플릿", + "copy-redirect-uri": "리디렉션 URI 복사", + "registration-id": "Registration ID", + "registration-id-required": "Registration ID을(를) 입력하세요.", + "registration-id-unique": "Registration ID는 시스템 상에서 중복될 수 없습니다.", + "scope": "Scope", + "scope-required": "Scope을(를) 입력하세요.", + "tenant-name-pattern": "테넌트 이름 규칙", + "tenant-name-pattern-required": "테넌트 이름 규칙을 입력하세요.", + "tenant-name-strategy": "Tenant name strategy", + "type": "Mapper type", + "uri-pattern-error": "유효하지 않은 URI 형식입니다.", + "url": "URL", + "url-pattern": "유효하지 않은 URL 형식입니다.", + "url-required": "URL을(를) 입력하세요.", + "user-info-uri": "User info URI", + "user-info-uri-required": "사용자 정보 URI을(를) 입력하세요.", + "user-name-attribute-name": "사용자 이름 속성 키", + "user-name-attribute-name-required": "사용자 이름 속성 키를 입력하세요", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "OAuth2 설정 사용" + } + }, + "alarm": { + "alarm": "알람", + "alarms": "알람", + "select-alarm": "알람 선택", + "no-alarms-matching": "'{{entity}}'에 대한 알람이 존재하지 않습니다.", + "alarm-required": "알람이 필요합니다", + "alarm-status": "알람 상태", + "alarm-status-list": "알람 상태 목록", + "any-status": "모든 상태", + "search-status": { + "ANY": "전체", + "ACTIVE": "활성", + "CLEARED": "해제", + "ACK": "수용", + "UNACK": "불수용" + }, + "display-status": { + "ACTIVE_UNACK": "활성, 미확인", + "ACTIVE_ACK": "활성, 확인", + "CLEARED_UNACK": "해제, 미확인", + "CLEARED_ACK": "해제, 확인" + }, + "no-alarms-prompt": "아무 알람도 없습니다", + "created-time": "생성 일시", + "type": "유형", + "severity": "심각도", + "originator": "만든 사람", + "originator-type": "만든 사람 유형", + "details": "상세 내역", + "status": "상태", + "alarm-details": "알람 상세", + "start-time": "시작 시각", + "end-time": "마지막 시각", + "ack-time": "확인 시간", + "clear-time": "해제 시간", + "alarm-severity-list": "알람 심각도 목록", + "any-severity": "모든 심각도", + "severity-critical": "심각한", + "severity-major": "주요한", + "severity-minor": "작은", + "severity-warning": "경고", + "severity-indeterminate": "중간", + "acknowledge": "확인", + "clear": "지우기", + "search": "알람 검색", + "selected-alarms": "{ count, plural, 1 {1 개 알람} other {# 개 알람} }이 선택됨", + "no-data": "표시할 데이터가 없습니다", + "polling-interval": "알람 풀링 간격 (초)", + "polling-interval-required": "알람 풀링 간격을 입력하세요.", + "min-polling-interval-message": "1초 이상의 간격만 허용됩니다.", + "aknowledge-alarms-title": "{ count, plural, 1 {1 개 알람} other {# 개 알람} } 확인", + "aknowledge-alarms-text": "{ count, plural, 1 {1 개 알람} other {# 개 알람} }의 알람을 수용하시겠습니까?", + "aknowledge-alarm-title": "알람 확인", + "aknowledge-alarm-text": "알람을 확인하시겠습니까??", + "clear-alarms-title": "{ count, plural, 1 {1 개 알람} other {# 개 알람} } 해제", + "clear-alarms-text": "{ count, plural, 1 {1 개 알람} other {# 개 알람} }을 해제하시겠습니까?", + "clear-alarm-title": "알람 해제", + "clear-alarm-text": "알람을 해제하시겠습니까?", + "alarm-status-filter": "알람 상태 필터", + "alarm-filter": "알람 필터", + "max-count-load": "불러올 최대 알람 수를 0~무제한 범위 내에서 입력하세요", + "max-count-load-required": "불러올 최대 알람 수를 입력하세요.", + "max-count-load-error-min": "최소 값은 0입니다.", + "fetch-size": "Fetch size", + "fetch-size-required": "Fetch size를 입력하세요.", + "fetch-size-error-min": "최소 값은 10입니다.", + "alarm-type-list": "알람 유형 목록", + "any-type": "모든 유형", + "search-propagated-alarms": "전파된 알람 검색" + }, + "alias": { + "add": "별칭 추가", + "edit": "별칭 편집", + "name": "별칭", + "name-required": "별칭을 입력하세요.", + "duplicate-alias": "동일한 별칭이 이미 존재합니다.", + "filter-type-single-entity": "단일 개체", + "filter-type-entity-list": "개체 목록", + "filter-type-entity-name": "개체 이름", + "filter-type-state-entity": "대시보드 상태로 부터의 개체", + "filter-type-state-entity-description": "개체 taken from dashboard state parameters", + "filter-type-asset-type": "자산 유형", + "filter-type-asset-type-description": "'{{assetType}}' 유형의 자산", + "filter-type-asset-type-and-name-description": "이름이 '{{prefix}}'로 시작되는 '{{assetType}}' 유형의 자산", + "filter-type-device-type": "장치 유형", + "filter-type-device-type-description": "'{{deviceType}}' 유형의 장치", + "filter-type-device-type-and-name-description": "이름이 '{{prefix}}'로 시작되는 '{{assetType}}' 유형의 장치", + "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": "관계 질의", + "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "자산 검색 질의", + "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-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": "개체 보기 검색 질의", + "filter-type-entity-view-search-query-description": "Entity views with types {{entityViewTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Api 사용량 상태", + "entity-filter": "개체 필터", + "resolve-multiple": "Resolve as multiple entities", + "filter-type": "필터 유형", + "filter-type-required": "필터 유형을 입력하세요.", + "entity-filter-no-entity-matched": "지정된 필터에 해당되는 개체가 없습니다.", + "no-entity-filter-specified": "개체 필터가 지정되지 않음", + "root-state-entity": "루트로 대시보드 상태 개체를 사용", + "last-level-relation": "Fetch last level relation only", + "root-entity": "루트 개체", + "state-entity-parameter-name": "상태 개체 파라미터 이름", + "default-state-entity": "기본 상태 개체", + "default-entity-parameter-name": "기본값", + "max-relation-level": "최대 관계 수준", + "unlimited-level": "제한 수준", + "state-entity": "대시보드 상태 개체", + "all-entities": "모든 개체", + "any-relation": "전체" + }, + "asset": { + "asset": "자산", + "assets": "자산", + "management": "자산 관리", + "view-assets": "자산 보기", + "add": "자산 추가", + "assign-to-customer": "커스터머에게 자산 할당", + "assign-asset-to-customer": "자산을 커스터머에게 할당", + "assign-asset-to-customer-text": "커스터머에게 할당할 자산을 선택하세요", + "no-assets-text": "아무 자산도 없습니다", + "assign-to-customer-text": "자산을 할당할 커스터머를 선택하세요", + "public": "공개", + "assignedToCustomer": "할당된 커스터머", + "make-public": "자산을 공개로 설정", + "make-private": "자산을 비공개로 설정", + "unassign-from-customer": "커스터머 할당 해제", + "delete": "자산 삭제", + "asset-public": "공개된 자산", + "asset-type": "자산 유형", + "asset-type-required": "자산 유형을 선택하세요.", + "select-asset-type": "자산 유형 선택", + "enter-asset-type": "자산 유형 입력", + "any-asset": "모든 자산", + "no-asset-types-matching": "'{{entitySubtype}}'과 일치하는 자산 유형이 없습니다.", + "asset-type-list-empty": "아무 자산 유형도 선택되지 않았습니다.", + "asset-types": "자산 유형", + "name": "이름", + "name-required": "이름을 입력하세요.", + "description": "설명", + "type": "유형", + "type-required": "유형일 입력하세요.", + "details": "상세 내역", + "events": "이벤트", + "add-asset-text": "새로운 자산 추가", + "asset-details": "자산 상세 내역", + "assign-assets": "자산 할당", + "assign-assets-text": "{ count, plural, 1 {1 개 자산} other {# 개 자산} }을 커스터머에게 할당", + "delete-assets": "자산 삭제", + "unassign-assets": "자산 할당 해제", + "unassign-assets-action-title": "{ count, plural, 1 {1 개 자산} other {# 개 자산} }을 커스터머로부터 할당 해제", + "assign-new-asset": "새로운 자산 할당", + "delete-asset-title": "자산 '{{assetName}}'을(를) 삭제하시겠습니까?", + "delete-asset-text": "자산 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-assets-title": "{ count, plural, 1 {1 개 자산} other {# 개 자산} }을 삭제하시겠습니까?", + "delete-assets-action-title": "{ count, plural, 1 {1 개 자산} other {# 개 자산} } 삭제", + "delete-assets-text": "자산 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "make-public-asset-title": "자산 '{{assetName}}'을(를) 공개 상태로 변경하시겠습니까?", + "make-public-asset-text": "자산 및 관련 데이터가 공개로 전환되면 다른 이들이 접근할 수 있게 됩니다.", + "make-private-asset-title": "자산 '{{assetName}}'을(를) 비공개로 변경하시겠습니까?", + "make-private-asset-text": "자산 및 관련 데이터가 비공개로 전환되면 다른 이들이 접근할 수 없게 됩니다.", + "unassign-asset-title": "자산 '{{assetName}}'을 할당 해제 하시겠습니까?", + "unassign-asset-text": "자산이 할당 해제되면 커스터머가 접근할 수 없게 됩니다.", + "unassign-asset": "자산 할당 해제", + "unassign-assets-title": "{ count, plural, 1 {1 개 자산} other {# 개 자산} }을 할당 해제 하시겠습니까?", + "unassign-assets-text": "자산이 할당 해제되면 커스터머가 접근할 수 없게 됩니다.", + "copyId": "자산 ID 복사", + "idCopiedMessage": "자산 ID가 클립보드로 복사되었습니다", + "select-asset": "자산 선택", + "no-assets-matching": "'{{entity}}'와 일치하는 자산이 없습니다.", + "asset-required": "자산을 입력하세요", + "name-starts-with": "다음으로 시작되는 자산: ", + "import": "자산 불러오기", + "asset-file": "자산 파일", + "search": "자산 검색", + "selected-assets": "{ count, plural, 1 {1 개 자산} other {# 개 자산} } 선택됨", + "label": "라벨" + }, + "attribute": { + "attributes": "속성", + "latest-telemetry": "최근 데이터", + "attributes-scope": "장치 속성 범위", + "scope-latest-telemetry": "최근 데이터", + "scope-client": "클라이언트 속성", + "scope-server": "서버 속성", + "scope-shared": "공유 속성", + "add": "속성 추가", + "key": "키", + "last-update-time": "마지막 수정된 시간", + "key-required": "속성 키를 입력하세요.", + "value": "Value", + "value-required": "속성 값을 입력하세요.", + "delete-attributes-title": "{ count, plural, 1 {속성} other {여러 속성들을} } 삭제하시겠습니까??", + "delete-attributes-text": "모든 선택된 속성들이 제거 될 것이므로 주의하십시오.", + "delete-attributes": "속성 삭제", + "enter-attribute-value": "속성 값 입력", + "show-on-widget": "위젯 보기", + "widget-mode": "위젯 모드", + "next-widget": "다음 위젯", + "prev-widget": "이전 위젯", + "add-to-dashboard": "대시보드에 추가", + "add-widget-to-dashboard": "대시보드에 위젯 추가", + "selected-attributes": "{ count, plural, 1 {1 개 속성} other {# 개 속성} }이 선택됨", + "selected-telemetry": "{ count, plural, 1 {1 개의 텔레메트리 단위} other {# 개의 텔레메트리 단위} }가 선택됨", + "no-attributes-text": "아무 속성도 찾을 수 없습니다", + "no-telemetry-text": "아무 텔레메트리도 찾을 수 없습니다." + }, + "api-usage": { + "api-usage": "Api 사용량", + "data-points": "데이터 포인트", + "data-points-storage-days": "데이터 포인트 저장 일수", + "email": "Email", + "email-messages": "Email 메시지", + "email-messages-daily-activity": "일별 Email 메세지 활동", + "email-messages-hourly-activity": "시간별 Email 메시지 활동", + "email-messages-monthly-activity": "월별 Email 메시지 활동", + "exceptions": "예외", + "executions": "Executions", + "javascript": "JavaScript", + "javascript-executions": "JavaScript executions", + "javascript-functions": "JavaScript functions", + "javascript-functions-daily-activity": "JavaScript functions daily activity", + "javascript-functions-hourly-activity": "JavaScript functions hourly activity", + "javascript-functions-monthly-activity": "JavaScript functions monthly activity", + "latest-error": "최근 오류", + "messages": "메시지", + "permanent-failures": "${entityName} Permanent Failures", + "permanent-timeouts": "${entityName} Permanent Timeouts", + "processing-failures": "${entityName} Processing Failures", + "processing-failures-and-timeouts": "Processing Failures and Timeouts", + "processing-timeouts": "${entityName} Processing Timeouts", + "queue-stats": "Queue Stats", + "rule-chain": "규칙 사슬", + "rule-engine": "규칙 엔진", + "rule-engine-daily-activity": "Rule Engine daily activity", + "rule-engine-executions": "Rule Engine executions", + "rule-engine-hourly-activity": "Rule Engine hourly activity", + "rule-engine-monthly-activity": "Rule Engine monthly activity", + "rule-engine-statistics": "Rule Engine Statistics", + "rule-node": "규칙 노드", + "sms": "SMS", + "sms-messages": "SMS 메시지", + "sms-messages-daily-activity": "일별 SMS 메시지 활동", + "sms-messages-hourly-activity": "시간별 SMS 메시지 활동", + "sms-messages-monthly-activity": "월별 SMS 메시지 활동", + "successful": "${entityName} Successful", + "telemetry": "텔레메트리", + "telemetry-persistence": "Telemetry persistence", + "telemetry-persistence-daily-activity": "Telemetry persistence daily activity", + "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", + "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", + "transport": "전송", + "transport-daily-activity": "Transport daily activity", + "transport-data-points": "Transport data points", + "transport-hourly-activity": "Transport hourly activity", + "transport-messages": "Transport messages", + "transport-monthly-activity": "Transport monthly activity", + "view-details": "상세 내역", + "view-statistics": "통계 보기" + }, + "audit-log": { + "audit": "감사", + "audit-logs": "감사 로그", + "timestamp": "타임스탬프", + "entity-type": "개체 유형", + "entity-name": "개체 이름", + "user": "사용자", + "type": "유형", + "status": "상태", + "details": "상세 내역", + "type-added": "추가됨", + "type-deleted": "삭제됨", + "type-updated": "수정됨", + "type-attributes-updated": "속성이 수정되었습니다", + "type-attributes-deleted": "속성이 삭제되었습니다", + "type-rpc-call": "RPC call", + "type-credentials-updated": "자격 증명이 갱신되었습니다", + "type-assigned-to-customer": "커스터머에게 할당", + "type-unassigned-from-customer": "지정된 커스터머 해제", + "type-activated": "활성", + "type-suspended": "일시 중지", + "type-credentials-read": "자격 증명 읽기", + "type-attributes-read": "속성 읽기", + "type-relation-add-or-update": "관계가 업데이트 되었습니다", + "type-relation-delete": "관계가 삭제되었습니다", + "type-relations-delete": "모든 관계가 삭제되었습니다", + "type-alarm-ack": "확인", + "type-alarm-clear": "해제", + "type-login": "로그인", + "type-logout": "로그아웃", + "type-lockout": "잠금", + "status-success": "성공", + "status-failure": "실패", + "audit-log-details": "감사 로그 세부 사항", + "no-audit-logs-prompt": "아무 로그도 없습니다.", + "action-data": "액션 데이터", + "failure-details": "실패 세부 사항", + "search": "감사 로그 검색", + "clear-search": "검색 초기화", + "type-assigned-from-tenant": "테넌트로부터 지정", + "type-assigned-to-tenant": "테넌트로 지정", + "type-provision-success": "장치가 프로비전됨", + "type-provision-failure": "장치 프로비저닝이 실패했습니다" + }, + "confirm-on-exit": { + "message": "변경 사항을 저장하지 않았습니다. 이 페이지를 나가시겠습니까?", + "html-message": "변경 사항을 저장하지 않았습니다.
    이 페이지를 나가시겠습니까?", + "title": "저장되지 않은 변경사항" + }, + "contact": { + "country": "국가", + "city": "시", + "state": "도", + "postal-code": "우편 번호", + "postal-code-invalid": "숫자만 입력하세요.", + "address": "주소", + "address2": "상세주소", + "phone": "전화번호", + "email": "Email", + "no-address": "주소 정보 없음" + }, + "common": { + "username": "사용자명", + "password": "비밀번호", + "enter-username": "사용자명을 입력하세요.", + "enter-password": "비밀번호를 입력하세요.", + "enter-search": "검색어 입력", + "created-time": "생성 일시", + "loading": "불러오는 중..." + }, + "content-type": { + "json": "Json", + "text": "텍스트", + "binary": "바이너리 (Base64)" + }, + "customer": { + "customer": "커스터머", + "customers": "커스터머", + "management": "커스터머 관리", + "dashboard": "커스터머 대시보드", + "dashboards": "커스터머 대시보드", + "devices": "커스터머 장치", + "entity-views": "커스터머 개체 보기", + "assets": "커스터머 자산", + "public-dashboards": "공개된 대시보드", + "public-devices": "공개된 장치", + "public-assets": "공개된 자산", + "public-entity-views": "공개된 개체 보기", + "add": "커스터머 추가", + "delete": "커스터머 삭제", + "manage-customer-users": "커스터머 사용자 관리", + "manage-customer-devices": "커스터머 장치 관리", + "manage-customer-dashboards": "커스터머 대시보드 관리", + "manage-public-devices": "공개된 장치 관리", + "manage-public-dashboards": "공개된 대시보드 관리", + "manage-customer-assets": "커스터머 자산 관리", + "manage-public-assets": "공개된 자산 관리", + "add-customer-text": "커스터머 추가", + "no-customers-text": "커스터머가 없습니다.", + "customer-details": "커스터머 상세 정보", + "delete-customer-title": "'{{customerTitle}}' 커스터머를 삭제하시겠습니까?", + "delete-customer-text": "커스터머 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-customers-title": "{ count, plural, 1 {1 개 커스터머} other {# 개 커스터머} }를 삭제하시겠습니까?", + "delete-customers-action-title": "{ count, plural, 1 {1 개 커스터머} other {# 개 커스터머} } 삭제", + "delete-customers-text": "커스터머 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "manage-users": "사용자 관리", + "manage-assets": "자산 관리", + "manage-devices": "장치 관리", + "manage-dashboards": "대시보드 관리", + "title": "제목", + "title-required": "제목을 입력하세요.", + "description": "설명", + "details": "상세 내역", + "events": "이벤트", + "copyId": "커스터머 ID 복사", + "idCopiedMessage": "커스터머 ID가 클립 보드에 복사되었습니다.", + "select-customer": "커스터머 선택", + "no-customers-matching": "'{{entity}}'에 해당하는 커스터머을 찾을 수 없습니다.", + "customer-required": "커스터머을 입력하세요.", + "select-default-customer": "기본 커스터머 선택", + "default-customer": "기본 커스터머", + "default-customer-required": "테넌트 수준에서 대시보드를 디버그 하기 위해서는 기본 커스터머이 필요합니다.", + "search": "커스터머 검색", + "selected-customers": "{ count, plural, 1 {1 개 커스터머} other {# 개 커스터머} } 선택됨" + }, + "datetime": { + "date-from": "시작 날짜", + "time-from": "시작 시간", + "date-to": "종료 날짜", + "time-to": "종료 시간" + }, + "dashboard": { + "dashboard": "대시보드", + "dashboards": "대시보드", + "management": "대시보드 관리", + "view-dashboards": "대시보드 보기", + "add": "대시보드 추가", + "assign-dashboard-to-customer": "대시보드 커스터머 선택", + "assign-dashboard-to-customer-text": "대시보드 커스터머를 선택하세요.", + "assign-to-customer-text": "대시보드 커스터머를 선택하세요.", + "assign-to-customer": "커스터머 선택", + "unassign-from-customer": "커스터머로부터 지정 해제", + "make-public": "대시보드를 공개로 전환", + "make-private": "대시보드를 비공개로 전환", + "manage-assigned-customers": "할당된 커스터머 관리", + "assigned-customers": "할당된 커스터머", + "assign-to-customers": "대시보드를 커스터머에게 할당", + "assign-to-customers-text": "대시보드를 할당할 커스터머를 선택하세요", + "unassign-from-customers": "커스터머 할당 해제", + "unassign-from-customers-text": "데시보드를 할당 해제할 커스터머를 선택하세요", + "no-dashboards-text": "대시보드가 없습니다", + "no-widgets": "설정된 위젯 없음", + "add-widget": "위젯 추가", + "title": "제목", + "select-widget-title": "위젯 선택", + "select-widget-subtitle": "사용가능한 위젯 타입 목록", + "delete": "대시보드 삭제", + "title-required": "제목을 입력하세요.", + "description": "설명", + "details": "상세", + "dashboard-details": "대시보드 상세 정보", + "add-dashboard-text": "대시보드 추가", + "assign-dashboards": "대시보드 할당", + "assign-new-dashboard": "새 대시보드 할당", + "assign-dashboards-text": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }를 커스터머에 할당", + "unassign-dashboards-action-text": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }를 커스터머로부터 지정 해제", + "delete-dashboards": "대시보드 삭제", + "unassign-dashboards": "대시보드 할당 취소", + "unassign-dashboards-action-title": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }를 커스터머에 할당 취소", + "delete-dashboard-title": "대시보드 '{{dashboardTitle}}'을(를) 삭제하시겠습니까?", + "delete-dashboard-text": "대시보드 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-dashboards-title": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }를 삭제하시겠습니까?", + "delete-dashboards-action-title": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }를 삭제", + "delete-dashboards-text": "대시보드 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "unassign-dashboard-title": "대시보드 '{{dashboardTitle}}'의 할당을 해제하시겠습니까?", + "unassign-dashboard-text": "대시보드가 할당 해제되고 커스터머는 접근 할 수 없게 됩니다.", + "unassign-dashboard": "대시보드 할달 취소", + "unassign-dashboards-title": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }의 할당을 취소하시겠습니까?", + "unassign-dashboards-text": "선택된 대시보드가 할당 해제되고 커스터머는 접근 할 수 없게 됩니다.", + "public-dashboard-title": "대시보드가 공개되었습니다", + "public-dashboard-text": "당신의 대시보드 {{dashboardTitle}}는 이제 공개되어서 다음 공개 링크를 통해 접근될 수 있습니다:", + "public-dashboard-notice": "참고: 관련된 장치에 접근하기 위해서는 장치들 또한 공개되어야 합니다.", + "make-private-dashboard-title": "대시보드 '{{dashboardTitle}}'를 비공개로 전환하시겠습니까?", + "make-private-dashboard-text": "대시보드는 비공개로 전환되어 다른 이들이 접근할 수 없게 됩니다.", + "make-private-dashboard": "대시보드 비공개 전환", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "대시보드 선택", + "no-dashboards-matching": "'{{entity}}'와 일치하는 대시보드가 없습니다.", + "dashboard-required": "대시보드를 입력하세요.", + "select-existing": "기존 대시보드 선택", + "create-new": "대시보드 생성", + "new-dashboard-title": "새로운 대시보드 제목", + "open-dashboard": "대시보드 열기", + "set-background": "대시보드 설정", + "background-color": "배경색", + "background-image": "배경 이미지", + "background-size-mode": "배경 사이즈 모드", + "no-image": "이미지 없음", + "drop-image": "이곳에 이미지를 끌어다놓거나 이곳을 클릭하여 파일을 선택하고 업로드하세요.", + "settings": "설정", + "columns-count": "열 개수", + "columns-count-required": "열 개수를 입력하세요.", + "min-columns-count-message": "열 개수를 최소 10 이상으로 입력하세요.", + "max-columns-count-message": "열 개수를 최대 100 이하로 입력하세요.", + "widgets-margins": "위젯 사이 여백 크기", + "margin-required": "여백 값을 입력하세요.", + "min-margin-message": "최소 여백 값은 0보다 커야 합니다.", + "max-margin-message": "최대 여백 값은 50보다 작아야 합니다.", + "horizontal-margin": "세로 여백", + "horizontal-margin-required": "세로 여백 값을 입력하세요.", + "min-horizontal-margin-message": "최소 세로 여백 값은 0보다 커야 합니다.", + "max-horizontal-margin-message": "최대 세로 여백 값은 50보다 작아야 합니다.", + "vertical-margin": "가로 여백", + "vertical-margin-required": "가로 여백 값을 입력하세요.", + "min-vertical-margin-message": "최소 가로 여백 값은 0보다 커야 합니다.", + "max-vertical-margin-message": "최대 가로 여백 값은 50보다 작아야 합니다.", + "autofill-height": "레이아웃 세로 길이 자동 채우기", + "mobile-layout": "모바일 레이아웃 설정", + "mobile-row-height": "모바일 행 높이, px", + "mobile-row-height-required": "모바일 행 높이를 입력하세요.", + "min-mobile-row-height-message": "최소 모바일 행 높이 값은 5 픽셀보다 커야 합니다.", + "max-mobile-row-height-message": "최대 모바일 행 높이 값은 200 픽셀보다 작아야 합니다.", + "display-title": "대시보드 제목 표시", + "toolbar-always-open": "도구 상자를 열어두기", + "title-color": "제목 색상", + "display-dashboards-selection": "대시보드 선택 표시", + "display-entities-selection": "개체 선택 표시", + "display-filters": "필터 표시", + "display-dashboard-timewindow": "타임윈도우 표시", + "display-dashboard-export": "디스플레이 내보내기", + "import": "대시보드 가져오기", + "export": "대시보드 내보내기", + "export-failed-error": "대시보드 내보내기를 할 수 없습니다.: {error}", + "create-new-dashboard": "대시보드 생성", + "dashboard-file": "대시보드 파일", + "invalid-dashboard-file-error": "대시보드 가져오기를 할 수 없습니다.: 대시보드 데이터 구조가 잘못되었습니다.", + "dashboard-import-missing-aliases-title": "대시보드 별명을 위해 누락된 장치 선택", + "create-new-widget": "새로운 위젯 생성", + "import-widget": "위젯 가져오기", + "widget-file": "위젯 파일", + "invalid-widget-file-error": "위젯 가져오기를 할 수 없습니다: 위젯 데이터 구조가 잘못되었습니다.", + "widget-import-missing-aliases-title": "위젯에서 사용하는 누락 된 장치 선택", + "open-toolbar": "대시보드 툴바 열기", + "close-toolbar": "툴바 닫기", + "configuration-error": "구성 오류", + "alias-resolution-error-title": "대시보드 별명 구성 오류", + "invalid-aliases-config": "일부 별명 필터와 일치하는 장치를 찾을 수 없습니다.
    이 문제를 해결하려면 관리자에게 문의하십시오.", + "select-devices": "장치 선택", + "assignedToCustomer": "할당된 커스터머", + "assignedToCustomers": "할당된 커스터머", + "public": "공개", + "public-link": "공개 링크", + "copy-public-link": "공개 링크 복사", + "public-link-copied-message": "대시보드 공개 링크가 클립보드로 복사되었습니다", + "manage-states": "대시보드 상태 관리", + "states": "대시보드 상태", + "search-states": "대시보드 상태 검색", + "selected-states": "{ count, plural, 1 {1 개 대시보드 상태} other {# 개 대시보드 상태} } 선택됨", + "edit-state": "대시보드 상태 편집", + "delete-state": "대시보드 상태 삭제", + "add-state": "대시보드 상태 추가", + "no-states-text": "아무 상태도 없습니다", + "state": "대시보드 상태", + "state-name": "이름", + "state-name-required": "대시보드 상태 이름을 입력하세요.", + "state-id": "상태 ID", + "state-id-required": "대시보드 상태 ID를 입력하세요.", + "state-id-exists": "동일한 대시보드 상태 ID가 이미 존재합니다.", + "is-root-state": "Root 상태", + "delete-state-title": "대시보드 상태 삭제", + "delete-state-text": "대시보드 상태 이름 '{{stateName}}'을(를) 삭제하시겠습니까?", + "show-details": "상세 내역 보기", + "hide-details": "상세 내역 숨기기", + "select-state": "대상 상태 선택", + "state-controller": "상태 컨트롤러", + "search": "대시보드 검색", + "selected-dashboards": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} } 선택됨" + }, + "datakey": { + "settings": "설정", + "advanced": "고급", + "label": "라벨", + "color": "색상", + "units": "값 뒤에 표기할 특수 기호", + "decimals": "소숫점 뒤 자릿수", + "data-generation-func": "데이터 생성 기능", + "use-data-post-processing-func": "데이터 후처리 기능 사용", + "configuration": "데이터 키 구성", + "timeseries": "시계열", + "attributes": "속성", + "entity-field": "개체 필드", + "alarm": "알람 필드", + "timeseries-required": "장치 시계열 를 입력하세요.", + "timeseries-or-attributes-required": "장치 시계열/속성 를 입력하세요.", + "alarm-fields-timeseries-or-attributes-required": "Alarm fields or entity timeseries/attributes are required.", + "maximum-timeseries-or-attributes": "Maximum { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "알람 필드를 입력하세요.", + "function-types": "함수 유형", + "function-types-required": "함수 유형을 입력하세요.", + "maximum-function-types": "최대 { count, plural, 1 {1 개 함수} other {# 개 함수} } 종류만 허용됩니다.", + "time-description": "현재 값의 타임스탬프;", + "value-description": "현재 값;", + "prev-value-description": "이전 함수 호출의 결과;", + "time-prev-description": "이전 값의 타임스탬프;", + "prev-orig-value-description": "원래 이전 값;" + }, + "datasource": { + "type": "데이터소스 유형", + "name": "이름", + "add-datasource-prompt": "데이터소스를 추가하세요." + }, + "details": { + "details": "상세 내역", + "edit-mode": "편집 모드", + "edit-json": "JSON 편집", + "toggle-edit-mode": "편집 모드 전환" + }, + "device": { + "device": "장치", + "device-required": "장치를 입력하세요.", + "devices": "장치", + "management": "장치 관리", + "view-devices": "장치 보기", + "device-alias": "장치 별명", + "aliases": "장치 별명", + "no-alias-matching": "'{{alias}}' 를 찾을 수 없습니다.", + "no-aliases-found": "별명이 없습니다.", + "no-key-matching": "'{{key}}' 를 찾을 수 없습니다.", + "no-keys-found": "Key가 없습니다.", + "create-new-alias": "새로 만들기!", + "create-new-key": "새로 만들기!", + "duplicate-alias-error": "중복된 '{{alias}}' 별명이 있습니다.
    장치 별명은 대시보드 내에서 고유해야 합니다.", + "configure-alias": "'{{alias}}' 별명 구성", + "no-devices-matching": "'{{entity}}'와 일치하는 장치를 찾을 수 없습니다.", + "alias": "별명", + "alias-required": "장치 별명을 입력하세요.", + "remove-alias": "장치 별명 삭제", + "add-alias": "장치 별명 추가", + "name-starts-with": "시작되는 이름", + "device-list": "장치 리스트", + "use-device-name-filter": "필터 사용", + "device-list-empty": "선택된 장치가 없습니다.", + "device-name-filter-required": "장치 필터 이름을 입력하세요.", + "device-name-filter-no-device-matched": "'{{device}}' 로 시작되는 장치를 찾을 수 없습니다.", + "add": "장치 추가", + "assign-to-customer": "커스터머에게 할당", + "assign-device-to-customer": "장치를 커스터머에게 할당", + "assign-device-to-customer-text": "커스터머에게 할당할 장치를 선택하십시오.", + "make-public": "장치를 공개로 전환", + "make-private": "장치를 비공개로 전환", + "no-devices-text": "장치 없음", + "assign-to-customer-text": "장치에 할당할 커스터머를 선택하세요.", + "device-details": "장치 상세 정보", + "add-device-text": "장치 추가", + "credentials": "자격 증명", + "manage-credentials": "자격 증명 관리", + "delete": "장치 삭제", + "assign-devices": "장치 할당", + "assign-devices-text": "{ count, plural, 1 {장치 1 개} other {장치 # 개} }를 커스터머에 할당", + "delete-devices": "장치 삭제", + "unassign-from-customer": "커스터머 할당 해제", + "unassign-devices": "장치 할당 취소", + "unassign-devices-action-title": "{ count, plural, 1 {장치 1 개} other {장치 # 개} }를 커스터머에게서 할당 해제", + "assign-new-device": "새로운 장치 할당", + "make-public-device-title": "Are you sure you want to make the device '{{deviceName}}' public?", + "make-public-device-text": "After the confirmation the device and all its data will be made public and accessible by others.", + "make-private-device-title": "Are you sure you want to make the device '{{deviceName}}' private?", + "make-private-device-text": "After the confirmation the device and all its data will be made private and won't be accessible by others.", + "view-credentials": "크리덴셜 보기", + "delete-device-title": "'{{deviceName}}' 장치를 삭제하시겠습니까?", + "delete-device-text": "장치 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-devices-title": "{ count, plural, 1 {장치 1 개} other {장치 # 개} }를 삭제하시겠습니까?", + "delete-devices-action-title": "{ count, plural, 1 {장치 1 개} other {장치 # 개} } 삭제", + "delete-devices-text": "장치 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "unassign-device-title": "'{{deviceName}}' 장치 할당을 해제하시겠습니까?", + "unassign-device-text": "장치가 할당 해제되고 커스터머는 액세스 할 수 없게됩니다.", + "unassign-device": "장치 할당 취소", + "unassign-devices-title": "{ count, plural, 1 {장치 1 개} other {장치 # 개} }의 할당을 해제하시겠습니까??", + "unassign-devices-text": "선택된 장치가 할당 해제되고 커스터머는 액세스 할 수 없게됩니다.", + "device-credentials": "장치 크리덴셜", + "credentials-type": "크리덴셜 타입", + "access-token": "억세스 토큰", + "access-token-required": "액세스 토큰을 입력하세요.", + "access-token-invalid": "액세스 토큰 길이는 1 - 32 자 여야합니다.", + "client-id": "클라이언트 ID", + "client-id-pattern": "잘못된 문자가 포함되어 있습니다.", + "user-name": "사용자 이름", + "user-name-required": "사용자 이름을 입력하세요.", + "client-id-or-user-name-necessary": "클라이언드 ID 와/또는 사용자 이름은 필수 사항 입니다", + "password": "비밀번호", + "secret": "시크릿", + "secret-required": "시크릿을 입력하세요.", + "device-type": "장치 유형", + "device-type-required": "장치 유형을 입력하세요.", + "select-device-type": "장치 유형 선택", + "enter-device-type": "장치 유형 입력", + "any-device": "모든 장치", + "no-device-types-matching": "'{{entitySubtype}}'에 해당되는 장치 유형을 찾을 수 없습니다.", + "device-type-list-empty": "아무 장치 유형도 선택되지 않았습니다.", + "device-types": "장치 유형", + "name": "이름", + "name-required": "이름을 입력하세요.", + "description": "설명", + "label": "라벨", + "events": "이벤트", + "details": "상세", + "copyId": "장치 아이디 복사", + "copyAccessToken": "액세스 토큰 복사", + "copy-mqtt-authentication": "MQTT 크리덴셜 복사", + "idCopiedMessage": "장치 아이디가 클립보드에 복사되었습니다.", + "accessTokenCopiedMessage": "장치 억세스 토큰이 클립보드에 복사되었습니다.", + "mqtt-authentication-copied-message": "장치 MQTT 크리덴셜이 클립보드에 복사되었습니다.", + "assignedToCustomer": "커스터머에 할당됨", + "unable-delete-device-alias-title": "장치 별명을 삭제할 수 없습니다.", + "unable-delete-device-alias-text": "'{{deviceAlias}}' 장치 별명을 삭제할 수 없습니다. 다음 위젯에서 사용하고 있습니다.
    {{widgetsList}}", + "is-gateway": "게이트웨이 여부", + "public": "공개", + "device-public": "장치가 공개됨", + "select-device": "장치 선택", + "import": "장치 불러오기", + "device-file": "장치 파일", + "search": "장치 검색", + "selected-devices": "{ count, plural, 1 {1 개 장치} other {# 개 장치} } 선택됨", + "device-configuration": "장치 설정", + "transport-configuration": "전송 설정", + "wizard": { + "device-wizard": "장치 마법사", + "device-details": "장치 상세 정보", + "new-device-profile": "새로운 장치 프로파일 생성", + "existing-device-profile": "기존 장치 프로파일 선택", + "specific-configuration": "특수 설정", + "customer-to-assign-device": "장치에 할당할 커스터머", + "add-credentials": "크리덴셜 추가" + } + }, + "device-profile": { + "device-profile": "장치 프로파일", + "device-profiles": "장치 프로파일", + "all-device-profiles": "모두", + "add": "장치 프로파일 추가", + "edit": "장치 프로파일 편집", + "device-profile-details": "장치 프로파일 상세 정보", + "no-device-profiles-text": "아무 장치 프로파일도 찾을 수 없습니다", + "search": "장치 프로파일 검색", + "selected-device-profiles": "{ count, plural, 1 {1 개 장치 프로파일} other {# 개 장치 프로파일} } 선택됨", + "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", + "device-profile-required": "장치 프로파일을 입력하세요", + "idCopiedMessage": "Device profile Id has been copied to clipboard", + "set-default": "Make device profile default", + "delete": "Delete device profile", + "copyId": "Copy device profile Id", + "new-device-profile-name": "장치 프로파일 이름", + "new-device-profile-name-required": "Device profile name is required.", + "name": "이름", + "name-required": "이름을 입력하세요.", + "type": "프로파일 유형", + "type-required": "프로파일 유형을 입력하세요.", + "type-default": "기본값", + "transport-type": "전송 유형", + "transport-type-required": "전송 유형을 입력하세요.", + "transport-type-default": "기본값", + "transport-type-default-hint": "기본 MQTT, HTTP, CoAP 전송을 지원", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "고급 MQTT 전송 설정을 활성화", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M 전송 유형", + "description": "설명", + "default": "기본값", + "profile-configuration": "프로파일 설정", + "transport-configuration": "전송 설정", + "default-rule-chain": "기본 규칙 사슬", + "select-queue-hint": "Select from a drop-down list.", + "delete-device-profile-title": "Are you sure you want to delete the device profile '{{deviceProfileName}}'?", + "delete-device-profile-text": "Be careful, after the confirmation the device profile and all related data will become unrecoverable.", + "delete-device-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 개 장치 프로파일} other {# 개 장치 프로파일} }?", + "delete-device-profiles-text": "Be careful, after the confirmation all selected device profiles will be removed and all related data will become unrecoverable.", + "set-default-device-profile-title": "Are you sure you want to make the device profile '{{deviceProfileName}}' default?", + "set-default-device-profile-text": "After the confirmation the device profile will be marked as default and will be used for new devices with no profile specified.", + "no-device-profiles-found": "No device profiles found.", + "create-new-device-profile": "Create a new one!", + "mqtt-device-topic-filters": "MQTT device topic filters", + "mqtt-device-topic-filters-unique": "MQTT device topic filters need to be unique.", + "mqtt-device-payload-type": "MQTT device payload", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payload type is required.", + "support-level-wildcards": "Single [+] and multi-level [#] wildcards supported.", + "telemetry-topic-filter": "Telemetry topic filter", + "telemetry-topic-filter-required": "Telemetry topic filter is required.", + "attributes-topic-filter": "Attributes topic filter", + "attributes-topic-filter-required": "Attributes topic filter is required.", + "telemetry-proto-schema": "Telemetry proto schema", + "telemetry-proto-schema-required": "Telemetry proto schema is required.", + "attributes-proto-schema": "Attributes proto schema", + "attributes-proto-schema-required": "Attributes proto schema is required.", + "rpc-response-topic-filter": "RPC response topic filter", + "rpc-response-topic-filter-required": "RPC response topic filter is required.", + "not-valid-pattern-topic-filter": "Not valid pattern topic filter", + "not-valid-single-character": "Invalid use of a single-level wildcard character", + "not-valid-multi-character": "Invalid use of a multi-level wildcard character", + "single-level-wildcards-hint": "[+] is suitable for any topic filter level. Ex.: v1/devices/+/telemetry or +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] can replace the topic filter itself and must be the last symbol of the topic. Ex.: # or v1/devices/me/#.", + "alarm-rules": "알람 규칙", + "alarm-rules-with-count": "Alarm rules ({{count}})", + "no-alarm-rules": "No alarm rules configured", + "add-alarm-rule": "Add alarm rule", + "edit-alarm-rule": "Edit alarm rule", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-unique": "Alarm type must be unique within the device profile alarm rules.", + "create-alarm-pattern": "Create {{alarmType}} alarm", + "create-alarm-rules": "Create alarm rules", + "no-create-alarm-rules": "No create conditions configured", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "select-alarm-severity": "Select alarm severity", + "alarm-severity-required": "Alarm severity is required.", + "condition-duration": "Condition duration", + "condition-duration-value": "Duration value", + "condition-duration-time-unit": "Time unit", + "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", + "condition-duration-value-pattern": "Duration value should be integers.", + "condition-duration-value-required": "Duration value is required.", + "condition-duration-time-unit-required": "Time unit is required.", + "advanced-settings": "Advanced settings", + "alarm-rule-details": "Details", + "add-alarm-rule-details": "Add details", + "propagate-alarm": "Propagate alarm", + "alarm-rule-relation-types-list": "Relation types to propagate", + "alarm-rule-relation-types-list-hint": "If Propagate relation types are not selected, alarms will be propagated without filtering by relation type.", + "alarm-details": "Alarm details", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "device-provisioning": "장치 프로비저닝", + "provision-strategy": "프로비저닝 전략", + "provision-strategy-required": "Provision strategy is required.", + "provision-strategy-disabled": "Disabled", + "provision-strategy-created-new": "Allow to create new devices", + "provision-strategy-check-pre-provisioned": "Check for pre-provisioned devices", + "provision-device-key": "Provision device key", + "provision-device-key-required": "Provision device key is required.", + "copy-provision-key": "Copy provision key", + "provision-key-copied-message": "Provision key has been copied to clipboard", + "provision-device-secret": "Provision device secret", + "provision-device-secret-required": "Provision device secret is required.", + "copy-provision-secret": "Copy provision secret", + "provision-secret-copied-message": "Provision secret has been copied to clipboard", + "condition": "Condition", + "condition-type": "Condition type", + "condition-type-simple": "Simple", + "condition-type-duration": "Duration", + "condition-during": "During {{during}}", + "condition-type-repeating": "Repeating", + "condition-type-required": "Condition type is required.", + "condition-repeating-value": "Count of events", + "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", + "condition-repeating-value-pattern": "Count of events should be integers.", + "condition-repeating-value-required": "Count of events is required.", + "condition-repeat-times": "Repeats { count, plural, 1 {1 time} other {# times} }", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-any-time": "Active all the time", + "schedule-specific-time": "Active at a specific time", + "schedule-custom": "Custom", + "schedule-day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "schedule-days": "Days", + "schedule-time": "Time", + "schedule-time-from": "From", + "schedule-time-to": "To", + "schedule-days-of-week-required": "At least one day of week should be selected." + }, + "dialog": { + "close": "대화 상자 닫기" + }, + "direction": { + "column": "Column", + "row": "Row" + }, + "error": { + "unable-to-connect": "서버에 연결할 수 없습니다! 인터넷 연결을 확인하십시오.", + "unhandled-error-code": "처리되지 않은 오류 코드: {{errorCode}}", + "unknown-error": "알 수 없는 오류" + }, + "entity": { + "entity": "개체", + "entities": "개체", + "aliases": "개체 별명", + "entity-alias": "개체 별명", + "unable-delete-entity-alias-title": "개체의 별명을 삭제할 수 없습니다.", + "unable-delete-entity-alias-text": "개체 별명 '{{entityAlias}}'은 다음 위젯에서 사용되고 있어 삭제할 수 없습니다:
    {{widgetsList}}", + "duplicate-alias-error": "별명 '{{alias}}'은 이미 사용중 입니다.
    대시보드 내에서 개체의 별명은 중복될 수 없습니다.", + "missing-entity-filter-error": "별명 '{{alias}}'에 대한 필터가 지정되지 않았습니다.", + "configure-alias": "별명 '{{alias}}' 설정", + "alias": "별명", + "alias-required": "개체 별명을 입력하세요.", + "remove-alias": "개체 별명 삭제", + "add-alias": "개체 별명 추가", + "entity-list": "개체 목록", + "entity-type": "개체 유형", + "entity-types": "개체 유형", + "entity-type-list": "개체 유형 목록", + "any-entity": "모든 개체", + "enter-entity-type": "개체 유형 입력", + "no-entities-matching": "'{{entity}}'에 해당되는 개체를 찾을 수 없습니다.", + "no-entity-types-matching": "'{{entityType}}'에 해당되는 개체 유형을 찾을 수 없습니다.", + "name-starts-with": "다음으로 시작하는 이름", + "use-entity-name-filter": "필터 사용", + "entity-list-empty": "아무 개체도 선택되지 않았습니다.", + "entity-type-list-empty": "개체 유형이 선택되지 않았습니다.", + "entity-name-filter-required": "개체 이름 필터를 입력하세요.", + "entity-name-filter-no-entity-matched": "'{{entity}}'로 시작되는 개체를 찾을 수 없습니다.", + "all-subtypes": "모두", + "select-entities": "선택된 개체", + "no-aliases-found": "아무 개체도 없습니다.", + "no-alias-matching": "개체 '{{alias}}'을(를) 찾을 수 없습니다.", + "create-new-alias": "새로 별명 만들기", + "key": "키", + "key-name": "키 이름", + "no-keys-found": "아무 키도 찾을 수 없습니다.", + "no-key-matching": "'{{key}}'를 찾을 수 없습니다.", + "create-new-key": "새로 키 만들기", + "type": "유형", + "type-required": "개체의 유형을 입력하세요.", + "type-device": "장치", + "type-devices": "장치", + "list-of-devices": "{ count, plural, 1 {1 개 장치} other {# 개 장치} }", + "device-name-starts-with": "이름이 '{{prefix}}'로 시작되는 장치", + "type-device-profile": "Device profile", + "type-device-profiles": "Device profiles", + "list-of-device-profiles": "{ count, plural, 1 {1 개 장치 프로파일} other {# 개 장치 프로파일 목록} }", + "device-profile-name-starts-with": "Device profiles whose names start with '{{prefix}}'", + "type-asset": "자산", + "type-assets": "자산", + "list-of-assets": "{ count, plural, 1 {1 개 자산} other {# 개 자산} }", + "asset-name-starts-with": "이름이 '{{prefix}}'로 시작되는 자산", + "type-entity-view": "개체 보기", + "type-entity-views": "개체 보기", + "list-of-entity-views": "{ count, plural, 1 {1 개 자산 보기} other {# 개 자산 보기} }", + "entity-view-name-starts-with": "이름이 '{{prefix}}'로 시작되는 자산 보기", + "type-rule": "규칙", + "type-rules": "규칙", + "list-of-rules": "{ count, plural, 1 {1 개 규칙} other {# 개 규칙} }", + "rule-name-starts-with": "이름이 '{{prefix}}'로 시작되는 규칙", + "type-plugin": "플러그인", + "type-plugins": "플러그인", + "list-of-plugins": "{ count, plural, 1 {1 개 플러그인} other {# 개 플러그인} }", + "plugin-name-starts-with": "이름이 '{{prefix}}'로 시작되는 플러그인", + "type-tenant": "테넌트", + "type-tenants": "테넌트", + "list-of-tenants": "{ count, plural, 1 {1 개 테넌트} other {# 개 테넌트} }", + "tenant-name-starts-with": "이름이 '{{prefix}}'로 시작되는 테넌트", + "type-tenant-profile": "테넌트 프로파일", + "type-tenant-profiles": "테넌트 프로파일", + "list-of-tenant-profiles": "{ count, plural, 1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일 목록} }", + "tenant-profile-name-starts-with": "'이름이 {{prefix}}'로 시작하는 테넌트 프로파일", + "type-customer": "커스터머", + "type-customers": "커스터머", + "list-of-customers": "{ count, plural, 1 {1 개 커스터머} other {# 개 커스터머} }", + "customer-name-starts-with": "이름이 '{{prefix}}'로 시작되는 커스터머", + "type-user": "사용자", + "type-users": "사용자", + "list-of-users": "{ count, plural, 1 {1 개 사용자} other {# 개 사용자} }", + "user-name-starts-with": "이름이 '{{prefix}}'로 시작되는 사용자", + "type-dashboard": "대시보드", + "type-dashboards": "대시보드", + "list-of-dashboards": "{ count, plural, 1 {1 개 대시보드} other {# 개 대시보드} }", + "dashboard-name-starts-with": "이름이 '{{prefix}}'로 시작되는 대시보드", + "type-alarm": "알람", + "type-alarms": "알람", + "list-of-alarms": "{ count, plural, 1 {1 개 알람} other {# 개 알람} }", + "alarm-name-starts-with": "이름이 '{{prefix}}'로 시작되는 알람", + "type-rulechain": "규칙 사슬", + "type-rulechains": "규칙 사슬", + "list-of-rulechains": "{ count, plural, 1 {1 개 규칙 사슬} other {# 개 규칙 사슬} }", + "rulechain-name-starts-with": "이름이 '{{prefix}}'로 시작되는 규칙 사슬", + "type-rulenode": "규칙 노드", + "type-rulenodes": "규칙 노드", + "list-of-rulenodes": "{ count, plural, 1 {1 개 규칙 노드} other {# 개 규칙 노드의 목록} }", + "rulenode-name-starts-with": "이름이 '{{prefix}}'로 시작되는 규칙 노트", + "type-current-customer": "현재 커스터머", + "type-current-tenant": "현재 테넌트", + "type-current-user": "현재 사용자", + "type-current-user-owner": "현재 소유자", + "search": "개체 검색", + "selected-entities": "{ count, plural, 1 {1 개체} other {# 개체} } 선택됨", + "entity-name": "개체 이름", + "entity-label": "개체 라벨", + "details": "개체 상세", + "no-entities-prompt": "아무 개체도 없습니다", + "no-data": "표시할 데이터가 없습니다", + "columns-to-display": "표시할 컬럼", + "type-api-usage-state": "Api 사용량 상태" + }, + "entity-field": { + "created-time": "생성 일시", + "name": "이름", + "type": "유형", + "first-name": "이름", + "last-name": "성", + "email": "Email", + "title": "제목", + "country": "국가", + "state": "주", + "city": "시", + "address": "주소", + "address2": "주소 2", + "zip": "우편번호", + "phone": "전화", + "label": "라벨" + }, + "entity-view": { + "entity-view": "개체 보기", + "entity-view-required": "개체 보기를 입력하세요.", + "entity-views": "개체 보기", + "management": "개체 보기 관리", + "view-entity-views": "개체 보기 보기", + "entity-view-alias": "개체 보기 별명", + "aliases": "개체 보기 별명", + "no-alias-matching": "'{{alias}}' not found.", + "no-aliases-found": "No aliases found.", + "no-key-matching": "'{{key}}' not found.", + "no-keys-found": "No keys found.", + "create-new-alias": "Create a new one!", + "create-new-key": "Create a new one!", + "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
    Entity View aliases must be unique within the dashboard.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "public": "Public", + "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", + "entity-view-public": "Entity view is public", + "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": "개체 보기 상세 정보", + "add-entity-view-text": "새로운 개체 보기 추가", + "delete": "Delete entity view", + "assign-entity-views": "개체 보기 할당", + "assign-entity-views-text": "{ count, plural, 1 {1 개 개체 보기} other {# 개 개체 보기} }를 커스터머에 할당", + "delete-entity-views": "Delete entity views", + "unassign-from-customer": "Unassign from customer", + "unassign-entity-views": "Unassign entity views", + "unassign-entity-views-action-title": "{ count, plural, 1 {1 개 개체 보기} other {# 개 개체 보기} }를 커스터머로 부터 할당 해제", + "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 delete { count, plural, 1 {1 개 개체 보기} other {# 개 개체 보기} }?", + "delete-entity-views-action-title": "Delete { count, plural, 1 {1 개 개체 보기} other {# 개 개체 보기} }", + "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 개 개체 보기} other {# 개 개체 보기} }?", + "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-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", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "description": "Description", + "events": "이벤트", + "details": "상세 정보", + "copyId": "개체 보기 ID 복사", + "idCopiedMessage": "개체 보기 ID가 클립보드에 복사되었습니다", + "assignedToCustomer": "할당된 커스터머", + "unable-entity-view-device-alias-title": "개체 보기 별명을 삭제할 수 없습니다", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "select-entity-view": "개체 보기 선택", + "make-public": "개체 보기를 공개로 전환", + "make-private": "개체 보기를 비공개로 전환", + "start-date": "시작 날짜", + "start-ts": "시작 시간", + "end-date": "종료 날짜", + "end-ts": "종료 시간", + "date-limits": "날짜 제한", + "client-attributes": "클라이언트 속성", + "shared-attributes": "공유 속성", + "server-attributes": "서버 속성", + "timeseries": "시계열", + "client-attributes-placeholder": "클라이언트 속성", + "shared-attributes-placeholder": "공유 속성", + "server-attributes-placeholder": "서버 속성", + "timeseries-placeholder": "시계열", + "target-entity": "대상 개체", + "attributes-propagation": "속성 전파", + "attributes-propagation-hint": "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-hint": "Configure timeseries data keys of the target entity that will be accessible to the entity view. This timeseries data is read-only.", + "make-public-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' public?", + "make-public-entity-view-text": "After the confirmation the entity view and all its data will be made public and accessible by others.", + "make-private-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' private?", + "make-private-entity-view-text": "After the confirmation the entity view and all its data will be made private and won't be accessible by others.", + "search": "Search entity views", + "selected-entity-views": "{ count, plural, 1 {1 개 개체 보기} other {# 개 개체 보기} } 선택됨" + }, + "event": { + "event-type": "이벤트 타입", + "type-error": "에러", + "type-lc-event": "주기적 이벤트", + "type-stats": "통계", + "type-debug-rule-node": "디버그", + "type-debug-rule-chain": "디버그", + "no-events-prompt": "이벤트 없음", + "error": "에러", + "alarm": "알람", + "event-time": "이벤트 발생 시간", + "server": "서버", + "body": "Body", + "method": "방법", + "type": "유형", + "message-id": "메시지 ID", + "message-type": "메시지 유형", + "data-type": "데이터 유형", + "relation-type": "관계 유형", + "metadata": "메타데이터", + "data": "데이터", + "event": "이벤트", + "status": "상태", + "success": "성공", + "failed": "실패", + "messages-processed": "처리된 메시지", + "errors-occurred": "오류가 발생했습니다", + "all-events": "모두", + "entity-type": "개체 유형" + }, + "extension": { + "extensions": "확장", + "selected-extensions": "{ count, plural, 1 {1 개 확장} other {# 개 확장} }이 선택됨", + "type": "유형", + "key": "키", + "value": "값", + "id": "ID", + "extension-id": "확장 ID", + "extension-type": "확장 종류", + "transformer-json": "JSON *", + "unique-id-required": "현재의 확장 ID가 이미 존재합니다.", + "delete": "확장 제거", + "add": "확장 추가", + "edit": "확장 편집", + "delete-extension-title": "확장 '{{extensionId}}'을 삭제하시겠습니까?", + "delete-extension-text": "확장 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-extensions-title": "{ count, plural, 1 {1 개 확장} other {# 개 확장} }을 삭제하시겠습니까?", + "delete-extensions-text": "선택된 모든 확장이 삭제됩니다.", + "converters": "변환기", + "converter-id": "변환기 ID", + "configuration": "설정", + "converter-configurations": "변환기 설정", + "token": "보안 토큰", + "add-converter": "변환기 추가", + "add-config": "변환기 설정 추가", + "device-name-expression": "장치 이름 표현", + "device-type-expression": "장치 유형 표현", + "custom": "사용자 정의", + "to-double": "To Double", + "transformer": "Transformer", + "json-required": "Transformer json을(를) 입력하세요.", + "json-parse": "Unable to parse transformer json.", + "attributes": "속성", + "add-attribute": "속성 추가", + "add-map": "Add mapping element", + "timeseries": "시계열", + "add-timeseries": "시계열 추가", + "field-required": "필드를 입력하세요.", + "brokers": "중개인", + "add-broker": "중개인 추가", + "host": "호스트", + "port": "포트", + "port-range": "포트를 1~65535 범위 내에서 입력하세요.", + "ssl": "Ssl", + "credentials": "인증", + "username": "사용자 이름", + "password": "비밀 번호", + "retry-interval": "재시도 간격(ms)", + "anonymous": "익명", + "basic": "기본", + "pem": "PEM", + "ca-cert": "CA 인증 파일 *", + "private-key": "개인키 파일 *", + "cert": "인증 파일 *", + "no-file": "아무 파일도 선택되지 않았습니다.", + "drop-file": "여기로 파일을 드래그 하거나, 여기를 클릭해서 파일을 선택하세요.", + "mapping": "Mapping", + "topic-filter": "주제 필터", + "converter-type": "변환기 유형", + "converter-json": "Json", + "json-name-expression": "장치 이름 JSON 표현", + "topic-name-expression": "장치 이름 주제 표현", + "json-type-expression": "장치 유형 JSON 표현", + "topic-type-expression": "장치 유형 주제 표현", + "attribute-key-expression": "속성 키 표현", + "attr-json-key-expression": "속성 키 JSON 표현", + "attr-topic-key-expression": "속성 키 주제 표현", + "request-id-expression": "요청 ID 표현", + "request-id-json-expression": "요청 ID JSON 표현", + "request-id-topic-expression": "요청 ID 주제 표현", + "response-topic-expression": "응답 주제 표현", + "value-expression": "값 표현", + "topic": "주제", + "timeout": "시간 제한(ms)", + "converter-json-required": "JSON 변환기가 필요합니다.", + "converter-json-parse": "JSON 변환기를 파싱할 수 없습니다.", + "filter-expression": "필터 표현", + "connect-requests": "접속 요청", + "add-connect-request": "접속 요청 추가", + "disconnect-requests": "접속 해제 요청", + "add-disconnect-request": "접속 해제 요청 추가", + "attribute-requests": "속성 요청", + "add-attribute-request": "속성 요청 추가", + "attribute-updates": "속성 업데이트", + "add-attribute-update": "속성 업데이트 추가", + "server-side-rpc": "Server side RPC", + "add-server-side-rpc-request": "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": "장치 추가", + "opc-server": "서버", + "opc-add-server": "서버 추가", + "opc-add-server-prompt": "서버를 추가해주세요", + "opc-application-name": "애플리케이션 이름", + "opc-application-uri": "애플리케이션 URI", + "opc-scan-period-in-seconds": "스캔 주기(초)", + "opc-security": "보안", + "opc-identity": "Identity", + "opc-keystore": "키스톤", + "opc-type": "유형", + "opc-keystore-type": "유형", + "opc-keystore-location": "위치 *", + "opc-keystore-password": "비밀번호", + "opc-keystore-alias": "별명", + "opc-keystore-key-password": "키 비밀번호", + "opc-device-node-pattern": "정치 노드 규칙", + "opc-device-name-pattern": "장치 이름 규칙", + "modbus-server": "서버/보조", + "modbus-add-server": "서버 추가/보조", + "modbus-add-server-prompt": "서버/보조를 추가해주세요", + "modbus-transport": "전송", + "modbus-tcp-reconnect": "자동으로 재접속", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "시리얼 포트 이름", + "modbus-encoding": "인코딩", + "modbus-parity": "Parity", + "modbus-baudrate": "전송 속도", + "modbus-databits": "데이터 비트", + "modbus-stopbits": "정지 비트", + "modbus-databits-range": "데이터 비트를 7~8 범위에서 입력하세요.", + "modbus-stopbits-range": "정지 비트를 1~2 범위에서 입력하세요.", + "modbus-unit-id": "Unit ID", + "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-device-name": "장치 이름", + "modbus-poll-period": "풀 주기 (ms)", + "modbus-attributes-poll-period": "속성 풀 주기 (ms)", + "modbus-timeseries-poll-period": "시계열 풀 주기 (ms)", + "modbus-poll-period-range": "풀 주기를 양수로 입력하세요.", + "modbus-tag": "태그", + "modbus-function": "Function", + "modbus-register-address": "등록 주소", + "modbus-register-address-range": "등록 주소를 0~65535 범위에서 입력하세요.", + "modbus-register-bit-index": "비트 인덱스", + "modbus-register-bit-index-range": "비트 인덱스를 0~15 범위에서 입력하세요.", + "modbus-register-count": "등록 카운트", + "modbus-register-count-range": "등록 카운트를 양수로 입력하세요.", + "modbus-byte-order": "Byte order", + "sync": { + "status": "상태", + "sync": "동기화", + "not-sync": "동기화 되지 않음", + "last-sync-time": "최근 동기화 시간", + "not-available": "사용 불가" + }, + "export-extensions-configuration": "확장 설정 내보내기", + "import-extensions-configuration": "확장 설정 가져오기", + "import-extensions": "확장 가져오기", + "import-extension": "확장 가져오기", + "export-extension": "확장 내보내기", + "file": "확장 파일", + "invalid-file-error": "올바르지 않은 확장 파일" + }, + "filter": { + "add": "Add filter", + "edit": "Edit filter", + "name": "Filter name", + "name-required": "Filter name is required.", + "duplicate-filter": "Filter with same name is already exists.", + "filters": "Filters", + "unable-delete-filter-title": "Unable to delete filter", + "unable-delete-filter-text": "Filter '{{filter}}' can't be deleted as it used by the following widget(s):
    {{widgetsList}}", + "duplicate-filter-error": "Duplicate filter found '{{filter}}'.
    Filters must be unique within the dashboard.", + "missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.", + "filter": "필터", + "editable": "편집 가능", + "no-filters-found": "아무 필터도 찾을 수 없습니다.", + "no-filter-text": "필터가 특정되지 않았습니다", + "add-filter-prompt": "필터를 추가해주세요", + "no-filter-matching": "필터 '{{filter}}'를 찾을 수 없습니다.", + "create-new-filter": "새로 필터를 만드세요!", + "filter-required": "필터를 입력하세요.", + "operation": { + "operation": "Operation", + "equal": "같음", + "not-equal": "같이 않음", + "starts-with": "다음으로 시작함", + "ends-with": "다음으로 끝남", + "contains": "포함함", + "not-contains": "포함하지 않음", + "greater": "큼", + "less": "작음", + "greater-or-equal": "크거나 같음", + "less-or-equal": "작거나 같음", + "and": "그리고", + "or": "또는" + }, + "ignore-case": "ignore case", + "value": "값", + "remove-filter": "Remove filter", + "preview": "Filter preview", + "no-filters": "No filters configured", + "add-filter": "Add filter", + "add-complex-filter": "Add complex filter", + "add-complex": "Add complex", + "complex-filter": "Complex filter", + "edit-complex-filter": "Edit complex filter", + "edit-filter-user-params": "Edit filter predicate user parameters", + "filter-user-params": "Filter predicate user parameters", + "user-parameters": "User parameters", + "display-label": "Label to display", + "autogenerated-label": "Auto generate label", + "order-priority": "Field order priority", + "key-filter": "Key filter", + "key-filters": "Key filters", + "key-name": "Key name", + "key-name-required": "Key name is required.", + "key-type": { + "key-type": "Key type", + "attribute": "Attribute", + "timeseries": "Timeseries", + "entity-field": "Entity field" + }, + "value-type": { + "value-type": "Value type", + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "date-time": "Datetime" + }, + "value-type-required": "Key value type is required.", + "key-value-type-change-title": "Are you sure you want to change key value type?", + "key-value-type-change-message": "If you confirm new value type all entered key filters will be removed.", + "no-key-filters": "No key filters configured", + "add-key-filter": "Add key filter", + "remove-key-filter": "Remove key filter", + "edit-key-filter": "Edit key filter", + "date": "Date", + "time": "Time", + "current-tenant": "Current tenant", + "current-customer": "Current customer", + "current-user": "Current user", + "current-device": "현재 장치", + "default-value": "기본값", + "dynamic-source-type": "Dynamic source type", + "no-dynamic-value": "No dynamic value", + "source-attribute": "소스 속성", + "switch-to-dynamic-value": "Switch to dynamic value", + "switch-to-default-value": "Switch to default value" + }, + "fullscreen": { + "expand": "전체화면으로 확장", + "exit": "전체화면 종료", + "toggle": "전체화면 모드 전환", + "fullscreen": "전체화면" + }, + "function": { + "function": "기능" + }, + "gateway": { + "add-entry": "설정 추가", + "connector-add": "새로운 연결자 추가", + "connector-enabled": "Enable connector", + "connector-name": "Connector name", + "connector-name-required": "Connector name is required.", + "connector-type": "Connector type", + "connector-type-required": "Connector type is required.", + "connectors": "Connectors configuration", + "create-new-gateway": "Create a new gateway", + "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", + "delete": "Delete configuration", + "download-tip": "Download configuration file", + "gateway": "Gateway", + "gateway-exists": "Device with same name is already exists.", + "gateway-name": "Gateway name", + "gateway-name-required": "Gateway name is required.", + "gateway-saved": "Gateway configuration successfully saved.", + "json-parse": "Not valid JSON.", + "json-required": "Field cannot be empty.", + "no-connectors": "No connectors", + "no-data": "No configurations", + "no-gateway-found": "No gateway found.", + "no-gateway-matching": " '{{item}}' not found.", + "path-logs": "Path to log files", + "path-logs-required": "Path is required.", + "remote": "Remote configuration", + "remote-logging-level": "Logging level", + "remove-entry": "Remove configuration", + "save-tip": "Save configuration file", + "security-type": "Security type", + "security-types": { + "access-token": "Access Token", + "tls": "TLS" + }, + "storage": "Storage", + "storage-max-file-records": "Maximum records in file", + "storage-max-files": "Maximum number of files", + "storage-max-files-min": "Minimum number is 1.", + "storage-max-files-pattern": "Number is not valid.", + "storage-max-files-required": "Number is required.", + "storage-max-records": "Maximum records in storage", + "storage-max-records-min": "Minimum number of records is 1.", + "storage-max-records-pattern": "Number is not valid.", + "storage-max-records-required": "Maximum records is required.", + "storage-pack-size": "Maximum event pack size", + "storage-pack-size-min": "Minimum number is 1.", + "storage-pack-size-pattern": "Number is not valid.", + "storage-pack-size-required": "Maximum event pack size is required.", + "storage-path": "Storage path", + "storage-path-required": "Storage path is required.", + "storage-type": "Storage type", + "storage-types": { + "file-storage": "File storage", + "memory-storage": "Memory storage" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard host", + "thingsboard-host-required": "Host is required.", + "thingsboard-port": "ThingsBoard port", + "thingsboard-port-max": "Maximum port number is 65535.", + "thingsboard-port-min": "Minimum port number is 1.", + "thingsboard-port-pattern": "Port is not valid.", + "thingsboard-port-required": "Port is required.", + "tidy": "Tidy", + "tidy-tip": "Tidy config JSON", + "title-connectors-json": "Connector {{typeName}} configuration", + "tls-path-ca-certificate": "Path to CA certificate on gateway", + "tls-path-client-certificate": "Path to client certificate on gateway", + "tls-path-private-key": "Path to private key on gateway", + "toggle-fullscreen": "Toggle fullscreen", + "transformer-json-config": "Configuration JSON*", + "update-config": "Add/update configuration JSON" + }, + "grid": { + "delete-item-title": "이 항목을 삭제 하시겠습니까?", + "delete-item-text": "항목 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-items-title": "{ count, plural, 1 {아이템 1 개} other {아이템 # 개} }를 삭제하시겠습니까?", + "delete-items-action-title": "{ count, plural, 1 {아이템 1 개} other {아이템 # 개} } 삭제", + "delete-items-text": "항목 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "add-item-text": "새로운 아이템 추가", + "no-items-text": "아이템이 없습니다.", + "item-details": "아이템 상세", + "delete-item": "아이템 삭제", + "delete-items": "아이템 삭제", + "scroll-to-top": "스크롤 맨 위로" + }, + "help": { + "goto-help-page": "도움" + }, + "home": { + "home": "홈", + "profile": "프로파일", + "logout": "로그아웃", + "menu": "메뉴", + "avatar": "Avatar", + "open-user-menu": "사용자 메뉴 열기" + }, + "import": { + "no-file": "선택된 파일이 없습니다.", + "drop-file": "업로드 할 JSON 파일을 끌어다 놓거나 여기 클릭하여 파일을 선택하십시오.", + "drop-file-csv": "업로드 할 CSV 파일을 끌어다 놓거나 여기 클릭하여 파일을 선택하십시오.", + "column-value": "값", + "column-title": "제목", + "column-example": "값 데이터 예시", + "column-key": "속성/텔레메트리 키", + "csv-delimiter": "CSV delimiter", + "csv-first-line-header": "첫번째 줄에 컬럼 이름 포함", + "csv-update-data": "속성/텔레메트리 업데이트", + "import-csv-number-columns-error": "A file should contain at least two columns", + "import-csv-invalid-format-error": "유효하지 않은 파일 포맷. Line: '{{line}}'", + "column-type": { + "name": "이름", + "type": "유형", + "label": "라벨", + "column-type": "컬럼 유형", + "client-attribute": "클라이언트 속성", + "shared-attribute": "공유된 속성", + "server-attribute": "서버 속성", + "timeseries": "시계열", + "entity-field": "개체 필드", + "access-token": "액세스 토큰", + "isgateway": "게이트웨이 여부", + "description": "설정" + }, + "stepper-text":{ + "select-file": "파일 선택", + "configuration": "설정 불러오기", + "column-type": "컬럼 유형 선택", + "creat-entities": "새로운 개체 생성" + }, + "message": { + "create-entities": "{{count}}개의 새로운 개체사 성공적으로 생성되었습니다.", + "update-entities": "{{count}}개의 개체가 성공적으로 업데이트 되었습니다.", + "error-entities": "{{count}}개의 개체를 생성하는데 오류가 발생했습니다." + } + }, + "item": { + "selected": "선택됨" + }, + "js-func": { + "no-return-error": "함수는 값을 반환해야 합니다!", + "return-type-mismatch": "함수는 '{{type}}' 유형의 값을 반환해야 합니다!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "키", + "value": "값", + "remove-entry": "앤트리 삭제", + "add-entry": "앤트리 추가", + "no-data": "앤트리 없음" + }, + "layout": { + "layout": "레이아웃", + "manage": "레이아웃 관리", + "settings": "레이아웃 설정", + "color": "색", + "main": "Main", + "right": "오른쪽", + "select": "대상 레이아웃 선택" + }, + "legend": { + "direction": "범례 방향", + "position": "범례 위치", + "sort-legend": "범례에 데이터키 정렬", + "show-max": "최대값 표시", + "show-min": "최소값 표시", + "show-avg": "평균값 표시", + "show-total": "총합 표시", + "settings": "범례 설정", + "min": "최소", + "max": "최대", + "avg": "평균", + "total": "합계", + "comparison-time-ago": { + "days": "(일 전)", + "weeks": "(주 전)", + "months": "(달 전)", + "years": "(년 전)" + } + }, + "login": { + "login": "로그인", + "request-password-reset": "비밀번호 재설정", + "reset-password": "비밀번호 재설정", + "create-password": "비밀번호 생성", + "passwords-mismatch-error": "입력된 비밀번호는 같아야 합니다!", + "password-again": "비밀번호 확인", + "sign-in": "로그인", + "username": "사용자명 (이메일)", + "remember-me": "아이디 저장", + "forgot-password": "비밀번호찾기", + "password-reset": "비밀번호 재설정", + "expired-password-reset-message": "당신의 크리덴셜이 만료되었습니다! 새로운 비밀번호를 생성하세요.", + "new-password": "새 비밀번호", + "new-password-again": "새 비밀번호 확인", + "password-link-sent-message": "비밀번호 재설정 링크가 성공적으로 전송되었습니다!", + "email": "이메일", + "login-with": "{{name}}으로 로그인", + "or": "또는", + "error": "로그인 오류" + }, + "position": { + "top": "상단", + "bottom": "하단", + "left": "왼쪽", + "right": "오른쪽" + }, + "profile": { + "profile": "프로파일", + "last-login-time": "마지막 로그인", + "change-password": "비밀번호 변경", + "current-password": "현재 비밀번호" + }, + "relation": { + "relations": "관계", + "direction": "방향", + "search-direction": { + "FROM": "보내는 사람", + "TO": "받는 사람" + }, + "direction-type": { + "FROM": "보내는 사람", + "TO": "받는 사람" + }, + "from-relations": "발신 관계", + "to-relations": "수신 관계", + "selected-relations": "{ count, plural, 1 {1 개 관계} other {# 개 관계} } 선택됨", + "type": "유형", + "to-entity-type": "받는 개체 유형", + "to-entity-name": "받는 개체 이름", + "from-entity-type": "보내는 개체 유형", + "from-entity-name": "받는 개체 이름", + "to-entity": "받는 개체", + "from-entity": "보내는 개체", + "delete": "관계 삭제", + "relation-type": "관계 유형", + "relation-type-required": "관계 유형을 입력하세요.", + "any-relation-type": "전체 유형", + "add": "관계 추가", + "edit": "관계 편집", + "delete-to-relation-title": "개체 '{{entityName}}'을(를) 삭제하시겠습니까?", + "delete-to-relation-text": "주의: 개체 '{{entityName}}'는 현재 개체와 관계가 해제됩니다.", + "delete-to-relations-title": "{ count, plural, 1 {1 개 관계} other {# 개 관계} }를 삭제하시겠습니까?", + "delete-to-relations-text": "주의: 모든 선택된 관계는 제거되며, 해당되는 개체는 현재의 개체와 관계가 해제됩니다.", + "delete-from-relation-title": "개체 '{{entityName}}'의 관계를 삭제하시겠습니까?", + "delete-from-relation-text": "주의: 현재 개체는 개체 '{{entityName}}'와의 관계가 해제됩니다.", + "delete-from-relations-title": "{ count, plural, 1 {1 개 관계} other {# 개 관계} }를 삭제하시겠습니까?", + "delete-from-relations-text": "주의: 모든 선택된 관계는 제거되며, 현제 개체는 해당되는 대체와 관계가 해제됩니다.", + "remove-relation-filter": "관계 필터 삭제", + "add-relation-filter": "관계 필터 추가", + "any-relation": "전체 관계", + "relation-filters": "관계 필터", + "additional-info": "추가 정보 (JSON)", + "invalid-additional-info": "추가적인 정보 JSON을 파싱할 수 없습니다.", + "no-relations-text": "아무 관계도 찾을 수 없습니다" + }, + "rulechain": { + "rulechain": "규칙 사슬", + "rulechains": "규칙 사슬", + "root": "루트", + "delete": "규칙 사슬 삭제", + "name": "이름", + "name-required": "이름을 입력하세요.", + "description": "설명", + "add": "규칙 사슬 추가", + "set-root": "규칙 사슬 루트로 전환", + "set-root-rulechain-title": "규칙 사슬 '{{ruleChainName}}'의 루트를 생성하시겠습니까?", + "set-root-rulechain-text": "규칙 사슬은 루트가 되며, 인입하는 전송 메시지를 모두 취급하게 됩니다.", + "delete-rulechain-title": "규칙 사슬 '{{ruleChainName}}'을 삭제하시겠습니까?", + "delete-rulechain-text": "주의: 규칙 사슬 및 관련된 모든 데이터의 복구가 불가능해집니다.", + "delete-rulechains-title": "{ count, plural, 1 {1 개 규칙 사슬} other {# 개 규칙 사슬} }을 삭제하시겠습니까?", + "delete-rulechains-action-title": "{ count, plural, 1 {1 개 규칙 사슬} other {# 개 규칙 사슬} } 삭제", + "delete-rulechains-text": "주의: 모든 선택된 규칙 사슬은 삭제되며, 모든 관련된 데이터는 복구가 불가능해집니다.", + "add-rulechain-text": "새로운 규칙 사슬 추가", + "no-rulechains-text": "아무 규칙 사슬도 없습니다.", + "rulechain-details": "규칙 사슬 상세 정보", + "details": "상세 내역", + "events": "이벤트", + "system": "시스템", + "import": "규칙 사슬 불러오기", + "export": "규칙 사슬 내보내기", + "export-failed-error": "규칙 사슬을 내보낼 수 없습니다: {{error}}", + "create-new-rulechain": "새로운 규칙 사슬 생성", + "rulechain-file": "규칙 사슬 파일", + "invalid-rulechain-file-error": "규식 사슬을 가져올 수 없습니다: 올바르지 않은 규칙 사슬 데이터 구조.", + "copyId": "규칙 사슬 ID 복사", + "idCopiedMessage": "규칙 사슬 ID가 클립보드로 복사되었습니다", + "select-rulechain": "규칙 사슬 선택", + "no-rulechains-matching": "'{{entity}}'와 일치하는 규칙 사슬을 찾을 수 없습니다.", + "rulechain-required": "규칙 사슬을 입력하세요", + "management": "규칙 관리", + "debug-mode": "디버그 모드", + "search": "규칙 사슬 검색", + "selected-rulechains": "{ count, plural, 1 {1 개 규칙 사슬} other {# 개 규칙 사슬} } 선택됨", + "open-rulechain": "규칙 사슬 열기" + }, + "rulenode": { + "details": "상세 내역", + "events": "이벤트", + "search": "노드 검색", + "open-node-library": "노드 라이브러리 열기", + "add": "규칙 노드 추가", + "name": "이름", + "name-required": "이름을 입력하세요.", + "type": "유형", + "description": "설명", + "delete": "규칙 노드 삭제", + "select-all-objects": "모든 노드와 연결을 선택", + "deselect-all-objects": "모든 노드와 연결을 선택 해제", + "delete-selected-objects": "선택된 노드와 연결을 삭제", + "delete-selected": "선택 삭제", + "select-all": "모두 선택", + "copy-selected": "선택 복사", + "deselect-all": "선택 해제", + "rulenode-details": "규칙 노드 상세 정보", + "debug-mode": "디버그 모드", + "configuration": "설정", + "link": "링크", + "link-details": "규칙 노드 링크 상세 정보", + "add-link": "링크 추가", + "link-label": "링크 라벨", + "link-label-required": "링크 라벨을 입력하세요.", + "custom-link-label": "링크 라벨 사용자 정의", + "custom-link-label-required": "링크 라벨 사용자 정의를 입력하세요.", + "link-labels": "링크 라벨", + "link-labels-required": "링크 라벨을 입력하세요.", + "no-link-labels-found": "아무 링크 라벨도 없습니다", + "no-link-label-matching": "'{{label}}' 을 찾을 수 없습니다.", + "create-new-link-label": "새로 링크 라벨 만들기", + "type-filter": "필터", + "type-filter-details": "Filter incoming messages with configured conditions", + "type-enrichment": "Enrichment", + "type-enrichment-details": "메시지 메타데이터에 추가적인 정보를 추가", + "type-transformation": "Transformation", + "type-transformation-details": "Change Message payload and Metadata", + "type-action": "", + "type-action-details": "특별한 조치를 수행", + "type-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": "입력", + "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", + "type-unknown": "Unknown", + "type-unknown-details": "Unresolved 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-type": "메시지 유형", + "select-message-type": "메시지 유형 선택", + "message-type-required": "메시지 유형을 입력하세요.", + "metadata": "메타데이터", + "metadata-required": "메타데이터 엔트리를 입력하세요.", + "output": "출력", + "test": "테스트", + "help": "도움말", + "reset-debug-mode": "모든 노드에 대해 디버그 모드 초기화" + }, + "timezone": { + "timezone": "Timezone", + "select-timezone": "Select timezone", + "no-timezones-matching": "No timezones matching '{{timezone}}' were found.", + "timezone-required": "Timezone is required." + }, + "queue": { + "select_name": "Select queue name", + "name": "Queue Name", + "name_required": "Queue name is required!" + }, + "tenant": { + "tenant": "테넌트", + "tenants": "테넌트", + "management": "테넌트 관리", + "add": "테넌트 추가", + "admins": "관리자", + "manage-tenant-admins": "테넌트 관리자 관리", + "delete": "테넌트 삭제", + "add-tenant-text": "테넌트 추가", + "no-tenants-text": "테넌트가 없습니다.", + "tenant-details": "테넌트 상세 정보", + "delete-tenant-title": "'{{tenantTitle}}' 테넌트를 삭제하시겠습니까?", + "delete-tenant-text": "테넌트 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-tenants-title": "{ count, plural, 1 {테넌트 1 개} other {테넌트 # 개} }를 삭제하시겠습니까?", + "delete-tenants-action-title": "{ count, plural, 1 {테넌트 1 개} other {테넌트 # 개} } 삭제", + "delete-tenants-text": "테넌트 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "title": "제목", + "title-required": "제목을 입력하세요.", + "description": "설명", + "details": "상세 내역", + "events": "이벤트", + "copyId": "테넌트 ID 복사", + "idCopiedMessage": "테넌트 ID를 클립보드로 복사", + "select-tenant": "테넌트 선택", + "no-tenants-matching": "태넌트 '{{entity}}'을(를) 찾을 수 없습니다.", + "tenant-required": "테넌트가 필요합니다.", + "search": "테넌트 검색", + "selected-tenants": "{ count, plural, 1 {1 개 테넌트} other {# 개 테넌트} } 선택됨", + "isolated-tb-rule-engine": "Processing in isolated ThingsBoard Rule Engine container", + "isolated-tb-rule-engine-details": "Requires separate microservice(s) per isolated Tenant" + }, + "tenant-profile": { + "tenant-profile": "테넌트 프로파일", + "tenant-profiles": "테넌트 프로파일", + "add": "테넌트 프로파일 추가", + "edit": "테넌트 프로파일 편집", + "tenant-profile-details": "Tenant profile details", + "no-tenant-profiles-text": "아무 테넌트 프로파일도 찾지 못했습니다", + "search": "테넌트 프로파일 검색", + "selected-tenant-profiles": "{ count, plural, 1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일} } 선택됨", + "no-tenant-profiles-matching": "No tenant profile matching '{{entity}}' were found.", + "tenant-profile-required": "테넌트 프로파일을 입력하세요", + "idCopiedMessage": "테넌트 프로파일 ID가 클립보드에 저장되었습니다", + "set-default": "테넌트 프로파일 기본값으로 지정", + "delete": "테넌트 프로파일 삭제", + "copyId": "테넌트 프로파일 ID 복사", + "name": "이름", + "name-required": "이름을 입력하세요.", + "data": "프로파일 데이터", + "profile-configuration": "프로파일 설정", + "description": "설명", + "default": "기본값", + "delete-tenant-profile-title": "Are you sure you want to delete the tenant profile '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Be careful, after the confirmation the tenant profile and all related data will become unrecoverable.", + "delete-tenant-profiles-title": "{ count, plural, 1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일} }을 삭제하시겠습니까?", + "delete-tenant-profiles-text": "Be careful, after the confirmation all selected tenant profiles will be removed and all related data will become unrecoverable.", + "set-default-tenant-profile-title": "Are you sure you want to make the tenant profile '{{tenantProfileName}}' default?", + "set-default-tenant-profile-text": "After the confirmation the tenant profile will be marked as default and will be used for new tenants with no profile specified.", + "no-tenant-profiles-found": "아무 테넌트 프로파일도 찾지 못했습니다.", + "create-new-tenant-profile": "Create a new one!", + "maximum-devices": "최대 장치 수 (0 - 무제한)", + "maximum-devices-required": "Maximum number of devices is required.", + "maximum-devices-range": "Minimum number of devices can't be negative", + "maximum-assets": "최대 자산 수 (0 - 무제한)", + "maximum-assets-required": "Maximum number of assets is required.", + "maximum-assets-range": "Maximum number of assets can't be negative", + "maximum-customers": "최대 커스터머 수 (0 - 무제한)", + "maximum-customers-required": "최대 커스터머 수를 입력하세요.", + "maximum-customers-range": "Maximum number of customers can't be negative", + "maximum-users": "Maximum number of users (0 - unlimited)", + "maximum-users-required": "Maximum number of users is required.", + "maximum-users-range": "Maximum number of users can't be negative", + "maximum-dashboards": "Maximum number of dashboards (0 - unlimited)", + "maximum-dashboards-required": "Maximum number of dashboards is required.", + "maximum-dashboards-range": "Maximum number of dashboards can't be negative", + "maximum-rule-chains": "Maximum number of rule chains (0 - unlimited)", + "maximum-rule-chains-required": "Maximum number of rule chains is required.", + "maximum-rule-chains-range": "Maximum number of rule chains can't be negative", + "transport-tenant-msg-rate-limit": "Transport tenant messages rate limit.", + "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetry messages rate limit.", + "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetry data points rate limit.", + "transport-device-msg-rate-limit": "Transport device messages rate limit.", + "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages rate limit.", + "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points rate limit.", + "max-transport-messages": "Maximum number of transport messages (0 - unlimited)", + "max-transport-messages-required": "Maximum number of transport messages is required.", + "max-transport-messages-range": "Maximum number of transport messages can't be negative", + "max-transport-data-points": "Maximum number of transport data points (0 - unlimited)", + "max-transport-data-points-required": "Maximum number of transport data points is required.", + "max-transport-data-points-range": "Maximum number of transport data points can't be negative", + "max-r-e-executions": "Maximum number of Rule Engine executions (0 - unlimited)", + "max-r-e-executions-required": "Maximum number of Rule Engine executions is required.", + "max-r-e-executions-range": "Maximum number of Rule Engine executions can't be negative", + "max-j-s-executions": "Maximum number of JavaScript executions (0 - unlimited)", + "max-j-s-executions-required": "Maximum number of JavaScript executions is required.", + "max-j-s-executions-range": "Maximum number of JavaScript executions can't be negative", + "max-d-p-storage-days": "Maximum number of data points storage days (0 - unlimited)", + "max-d-p-storage-days-required": "Maximum number of data points storage days is required.", + "max-d-p-storage-days-range": "Maximum number of data points storage days can't be negative", + "default-storage-ttl-days": "Default storage TTL days (0 - unlimited)", + "default-storage-ttl-days-required": "Default storage TTL days is required.", + "default-storage-ttl-days-range": "Default storage TTL days can't be negative", + "max-rule-node-executions-per-message": "Maximum number of rule node executions per message (0 - unlimited)", + "max-rule-node-executions-per-message-required": "Maximum number of rule node executions per message is required.", + "max-rule-node-executions-per-message-range": "Maximum number of rule node executions per message can't be negative", + "max-emails": "Maximum number of emails sent (0 - unlimited)", + "max-emails-required": "Maximum number of emails sent is required.", + "max-emails-range": "Maximum number of emails sent can't be negative", + "max-sms": "Maximum number of SMS sent (0 - unlimited)", + "max-sms-required": "Maximum number of SMS sent is required.", + "max-sms-range": "Maximum number of SMS sent can't be negative" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 초} other {# 초} }", + "minutes-interval": "{ minutes, plural, 1 {1 분} other {# 분} }", + "hours-interval": "{ hours, plural, 1 {1 시간} other {# 시간} }", + "days-interval": "{ days, plural, 1 {1 일} other {# 일} }", + "days": "일", + "hours": "시간", + "minutes": "분", + "seconds": "초", + "advanced": "고급" + }, + "timeunit": { + "seconds": "초", + "minutes": "분", + "hours": "시간", + "days": "일" + }, + "timewindow": { + "days": "{ days, plural, 1 {1 일 } other {# 일 } }", + "hours": "{ hours, plural, 0 {0 시간 } 1 {1 시간 } other {# 시간 } }", + "minutes": "{ minutes, plural, 0 {0 분 } 1 {1 분 } other {# 분 } }", + "seconds": "{ seconds, plural, 0 {0 초 } 1 {1 초 } other {# 초 } }", + "realtime": "실시간", + "history": "기록", + "last-prefix": "과거", + "period": "{{ startTime }}부터 {{ endTime }}까지", + "edit": "타임윈도우 편집", + "date-range": "날짜 범위", + "last": "과거", + "time-period": "기간", + "hide": "숨기기" + }, + "user": { + "user": "사용자", + "users": "사용자", + "customer-users": "커스터머 사용자", + "tenant-admins": "테넌트 관리자", + "sys-admin": "시스템 관리자", + "tenant-admin": "테넌트 관리자", + "customer": "커스터머", + "anonymous": "익명", + "add": "사용자 추가", + "delete": "사용자 삭제", + "add-user-text": "새로운 사용자 추가", + "no-users-text": "사용자가 없습니다.", + "user-details": "사용자 상세 정보", + "delete-user-title": "사용자 '{{userEmail}}'을(를) 삭제하시겠습니까?", + "delete-user-text": "사용자 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-users-title": "{ count, plural, 1 {사용자 1명} other {사용자 #명} }을 삭제하시겠습니까?", + "delete-users-action-title": "{ count, plural, 1 {사용자 1명} other {사용자 #명} } 삭제", + "delete-users-text": "사용자 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "activation-email-sent-message": "활성화 이메일을 보냈습니다!", + "resend-activation": "활성화 재전송", + "email": "Email", + "email-required": "Email을 입력하세요.", + "invalid-email-format": "잘못된 Email 형식입니다.", + "first-name": "이름", + "last-name": "성", + "description": "설명", + "default-dashboard": "기본 대시보드", + "always-fullscreen": "항상 전체화면", + "select-user": "사용자 선택", + "no-users-matching": "사용자 '{{entity}}'를 찾을 수 없습니다.", + "user-required": "사용자를 입력하세요", + "activation-method": "인증 방법", + "display-activation-link": "활성화 링크 표시", + "send-activation-mail": "활성화 메일 발송", + "activation-link": "사용자 활성화 링크", + "activation-link-text": "사용자를 활성화 하려면 활성화 링크로 접속하세요 :", + "copy-activation-link": "활성화 링크 복사", + "activation-link-copied-message": "사용자 활성화 링크가 클립보드로 복사되었습니다.", + "details": "상세", + "login-as-tenant-admin": "테넌트 관리자로 로그인", + "login-as-customer-user": "커스터머 사용자로 로그인", + "search": "사용자 검색", + "selected-users": "{ count, plural, 1 {1명 사용자} other {#명 사용자} } 선택됨", + "disable-account": "사용자 계정 비활성화", + "enable-account": "사용자 계정 활성", + "enable-account-message": "사용자 계정이 성공적으로 활성화 되었습니다!", + "disable-account-message": "사용자 계정이 성공적으로 비활성화 되었습니다!" + }, + "value": { + "type": "값 유형", + "string": "문자열", + "string-value": "문자열 값", + "string-value-required": "문자열 값을 입력하세요", + "integer": "정수", + "integer-value": "정수 값", + "integer-value-required": "정수 값을 입력하세요", + "invalid-integer-value": "유효하지 않은 정수 값", + "double": "실수", + "double-value": "실수 값", + "double-value-required": "실수 값을 입력하세요", + "boolean": "불리언", + "boolean-value": "불리언 값", + "false": "거짓", + "true": "참", + "long": "Long", + "json": "JSON", + "json-value": "JSON 값", + "json-value-invalid": "JSON 값의 형식이 잘못되었습니다", + "json-value-required": "JSON 값을 입력하세요." + }, + "widget": { + "widget-library": "위젯 라이브러리", + "widget-bundle": "위젯 번들", + "select-widgets-bundle": "위젯 번들 선택", + "management": "위젯 관리", + "editor": "위젯 편집기", + "widget-type-not-found": "위젯 구성을 로드하는 중 문제가 발생했습니다.
    연결된 위젯 타입이 삭제되었습니다.", + "widget-type-load-error": "다음과 같은 오류로 인해 위젯을 불러오지 못했습니다:", + "remove": "위젯 삭제", + "edit": "위젯 수정", + "remove-widget-title": "'{{widgetTitle}}' 위젯을 삭제하시겠습니까?", + "remove-widget-text": "위젯 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "timeseries": "시계열", + "search-data": "데이터 검색", + "no-data-found": "아무 데이터도 없습니다", + "latest": "최근 값", + "rpc": "컨트롤 위젯", + "alarm": "알람 위젯", + "static": "상태 위젯", + "select-widget-type": "위젯 타입 선택", + "missing-widget-title-error": "위젯 제목을 입력하세요!", + "widget-saved": "위젯이 저장되었습니다.", + "unable-to-save-widget-error": "위젯을 저장할 수 없습니다! 위젯에 오류가 있습니다!", + "save": "위젯 저장", + "saveAs": "다른 이름으로 위젯 저장", + "save-widget-type-as": "다른 이름으로 위젯 타입 저장", + "save-widget-type-as-text": "새로운 위젯 이름과 위젯 번들을 선택하세요.", + "toggle-fullscreen": "전체화면 전환", + "run": "위젯 실행", + "title": "위젯 제목", + "title-required": "위젯 제목을 입력하세요.", + "type": "위젯 타입", + "resources": "리소스", + "resource-url": "JavaScript/CSS URI", + "resource-is-module": "모듈 여부", + "remove-resource": "리소스 삭제", + "add-resource": "리소스 추가", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "스키마 설정", + "datakey-settings-schema": "데이터 키 설정 스키마", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "'{{widgetName}}' 위젯 타입을 삭제하시겠습니까?", + "remove-widget-type-text": "위젯 타입 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "remove-widget-type": "위젯 타입 삭제", + "add-widget-type": "새로운 위젯 타입 추가", + "widget-type-load-failed-error": "위젯 타입을 로드하지 못했습니다!", + "widget-template-load-failed-error": "위젯 템플릿을 로드하지 못했습니다!", + "add": "위젯 추가", + "undo": "위젯 변경사항 취소", + "export": "위젯 내보내기", + "no-data": "No data to display on widget", + "data-overflow": "Widget displays {{count}} out of {{total}} entities", + "alarm-data-overflow": "Widget displays alarms for {{allowedEntities}} (maximum allowed) entities out of {{totalEntities}} entities" + }, + "widget-action": { + "header-button": "위젯 헤더 버튼", + "open-dashboard-state": "새로운 대시보드 상태 탐색", + "update-dashboard-state": "현재 대시보드 상태 업데이트", + "open-dashboard": "다른 대시보드 탐색", + "custom": "Custom action", + "custom-pretty": "Custom action (HTML 템플릿)", + "target-dashboard-state": "대상 대시보드 상태", + "target-dashboard-state-required": "대상 대시보드 상태가 필요합니다.", + "set-entity-from-widget": "위젯으로 부터 객체 설정", + "target-dashboard": "대상 대시보드", + "open-right-layout": "Open right dashboard layout (모바일 보기)" + }, + "widgets-bundle": { + "current": "현재 번들", + "widgets-bundles": "위젯 번들", + "add": "위젯 번들 추가", + "delete": "위젯 번들 삭제", + "title": "제목", + "title-required": "제목을 입력하세요.", + "add-widgets-bundle-text": "위젯 번들 추가", + "no-widgets-bundles-text": "위젯 번들이 없습니다.", + "empty": "위젯 번들이 비어있습니다.", + "details": "상세", + "widgets-bundle-details": "위젯 번들 상세 정보", + "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}' 위젯 번들을 삭제하시겠습니까?", + "delete-widgets-bundle-text": "위젯 번들 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-widgets-bundles-title": "{ count, plural, 1 {위젯 번들 1 개} other {위젯 번들 # 개} }를 삭제하시겠습니까?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {위젯 번들 1 개} other {위젯 번들 # 개} } 삭제", + "delete-widgets-bundles-text": "위젯 번들 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "no-widgets-bundles-matching": "'{{widgetsBundle}}' 와(과) 일치하는 위젯 번들을 찾을 수 없습니다.", + "widgets-bundle-required": "위젯 번들을 입력하세요.", + "system": "시스템", + "import": "위젯 번들 가져오기", + "export": "위젯 번들 내보내기", + "export-failed-error": "위젯 번들을 내보내기 할 수 없습니다.: {{error}}", + "create-new-widgets-bundle": "새로운 위젯 번들 생성", + "widgets-bundle-file": "위젯 번들 파일", + "invalid-widgets-bundle-file-error": "위젯 번들을 가져오기 할 수 없습니다.: 잘못된 위젯 번들 데이터 구조입니다.", + "search": "위젯 번들 검색", + "selected-widgets-bundles": "{ count, plural, 1 {1 개 위젯 번들} other {# 개 위젯 번들} } 선택됨", + "open-widgets-bundle": "위젯 번들 열기" + }, + "widget-config": { + "data": "데이터", + "settings": "설정", + "advanced": "고급", + "title": "제목", + "title-tooltip": "제목 툴팁", + "general-settings": "일반 설정", + "display-title": "제목 표시", + "drop-shadow": "그림자", + "enable-fullscreen": "전체화면 사용 ", + "background-color": "배경 색", + "text-color": "글자 색", + "padding": "패딩", + "margin": "여백", + "widget-style": "위젯 스타일", + "title-style": "제목 스타일", + "mobile-mode-settings": "모바일 모드 설정", + "order": "순서", + "height": "높이", + "units": "값 옆에 표시할 특수 기호", + "decimals": "소수점 이하 자릿수", + "timewindow": "타임윈도우", + "use-dashboard-timewindow": "대시보드 타임윈도우", + "display-timewindow": "타임윈도우 표시", + "display-legend": "범례 표시", + "datasources": "데이터 소스", + "maximum-datasources": "최대 { count, plural, 1 {1 개의 데이터 소스만 허용됩니다.} other {# 개의 데이터 소스만 허용됩니다.} }", + "datasource-type": "유형", + "datasource-parameters": "파라미터", + "remove-datasource": "데이터소스 삭제", + "add-datasource": "데이터소스 추가", + "target-device": "대상 장치", + "alarm-source": "알람 소스", + "actions": "액션", + "action": "액션", + "add-action": "액션 추가", + "search-actions": "액션 검색", + "no-actions-text": "아무 액션도 없습니다", + "action-source": "액션 소스", + "action-source-required": "액션 소스를 입력하세요.", + "action-name": "이름", + "action-name-required": "액션 이름을 입력하세요.", + "action-name-not-unique": "같은 이름의 액션이 이미 존재합니다.
    같은 액션 소스에서 액션 이름이 중복될 수 없습니다.", + "action-icon": "아이콘", + "action-type": "유형", + "action-type-required": "액션 유형을 입력하세요.", + "edit-action": "액션 편집", + "delete-action": "액션 삭제", + "delete-action-title": "위젯 액션 삭제", + "delete-action-text": "위젯 액션 '{{actionName}}'을(를) 삭제하시겠습니까?", + "display-icon": "제목 아이콘 표시", + "icon-color": "아이콘 색상", + "icon-size": "아이콘 크기" + }, + "widget-type": { + "import": "위젯 타입 가져오기", + "export": "위젯 타입 내보내기", + "export-failed-error": "위젯 타입을 내보내기 할 수 없습니다.: {{error}}", + "create-new-widget-type": "새로운 위젯 타입 생성", + "widget-type-file": "위젯 타입 파일", + "invalid-widget-type-file-error": "위젯 타입을 가져오기 할 수 없습니다.: 잘못된 위젯 타입 데이터 구조입니다." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "일", + "Mon": "월", + "Tue": "화", + "Wed": "수", + "Thu": "목", + "Fri": "금", + "Sat": "토", + "Jan": "1월", + "Feb": "2월", + "Mar": "3월", + "Apr": "4월", + "May": "5월", + "Jun": "6월", + "Jul": "7월", + "Aug": "8월", + "Sep": "9월", + "Oct": "10월", + "Nov": "11월", + "Dec": "12월", + "January": "1월", + "February": "2월", + "March": "3월", + "April": "4월", + "June": "6월", + "July": "7월", + "August": "8월", + "September": "9월", + "October": "10월", + "November": "11월", + "December": "12월", + "Custom Date Range": "임의 기간 범위", + "Date Range Template": "기간 템플릿", + "Today": "오늘", + "Yesterday": "어제", + "This Week": "이번 주", + "Last Week": "지난주", + "This Month": "이번달", + "Last Month": "지난달", + "Year": "년", + "This Year": "올해", + "Last Year": "작년", + "Date picker": "날짜 선택기", + "Hour": "시간", + "Day": "일", + "Week": "주", + "2 weeks": "2주", + "Month": "달", + "3 months": "3개월", + "6 months": "6개월", + "Custom interval": "사용자 지정 간격", + "Interval": "간격", + "Step size": "단계 크기", + "Ok": "확인" + } + }, + "input-widgets": { + "attribute-not-allowed": "이 위젯에서 속성 파라미터를 사용할 수 없습니다", + "blocked-location": "당신의 브라우저에서 위치 정보가 차단되었습니다", + "claim-device": "장치 클레임", + "claim-failed": "장치를 클레임하는데 실패했습니다!", + "claim-not-found": "장치를 찾을 수 없음!", + "claim-successful": "장치를 성공적으로 클레임하였습니다!", + "date": "날짜", + "device-name": "장치 이름", + "device-name-required": "장치 이름을 입력하세요", + "discard-changes": "변경 취소", + "entity-attribute-required": "개체 소성을 입력하세요", + "entity-coordinate-required": "위도와 경도를 입력하세요", + "entity-timeseries-required": "개체 시계열을 입력하세요", + "get-location": "현재 위치 얻기", + "invalid-date": "잘못된 날짜", + "latitude": "위도", + "longitude": "경도", + "min-value-error": "최솟값은 {{value}}입니다", + "max-value-error": "최댓값은{{value}}입니다", + "not-allowed-entity": "선택된 개체는 속성을 공유할 수 없습니다", + "no-attribute-selected": "아무 속성도 선택되지 않았습니다", + "no-datakey-selected": "아무 데이터 키도 선택되지 않았습니다", + "no-coordinate-specified": "위도/경도를 위한 데이터가 특정되지 않았습니다", + "no-entity-selected": "아무 개체도 선택되지 않았습니다", + "no-image": "이미지 없음", + "no-support-geolocation": "당신의 브라우저는 위치정보를 지원하지 않습니다", + "no-support-web-camera": "당신의 브라우저는 카메라를 지원하지 않습니다", + "enable-https-use-widget": "이 위젯을 사용하기 위해 HTTPS를 활성화 하세요", + "no-found-your-camera": "카메라를 찾을 수 없습니다", + "no-permission-camera": "사용자에 의해 권한이 거부되었습니다 / 이 사이트는 카메라 사용을 허용하지 않습니다", + "no-timeseries-selected": "아무 시계열도 선택되지 않았습니다", + "secret-key": "보안키", + "secret-key-required": "보안키를 입력하세요", + "switch-attribute-value": "개체 속성 값으로 전환", + "switch-camera": "카메라 전환", + "switch-timeseries-value": "개체 시계열 값으로 전환", + "take-photo": "사진 촬영", + "time": "시간", + "timeseries-not-allowed": "이 위젯에서 시계열 파라미터를 사용할 수 없습니다", + "update-failed": "업데이트 실패", + "update-successful": "업데이트 성공", + "update-attribute": "속성 업데이트", + "update-timeseries": "시계열 업데이트", + "value": "값" + } + }, + "icon": { + "icon": "아이콘", + "select-icon": "선택된 아이콘", + "material-icons": "Material icons", + "show-all": "모든 아이콘 보기" + }, + "custom": { + "widget-action": { + "action-cell-button": "Action cell button", + "row-click": "On row click", + "polygon-click": "On polygon click", + "marker-click": "On marker click", + "tooltip-tag-action": "Tooltip tag action", + "node-selected": "On node selected", + "element-click": "On HTML element click", + "pie-slice-click": "On slice click", + "row-double-click": "On row double click" + } + }, + "language": { + "language": "언어(Language)" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json new file mode 100644 index 0000000..77ca230 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json @@ -0,0 +1,1682 @@ +{ + "access": { + "unauthorized": "Neatļauta", + "unauthorized-access": "Neatļauta piekļuve", + "unauthorized-access-text": "Lai piekļūtu šim resursam, jums jāpierakstās!", + "access-forbidden": "Piekļuve aizliegta", + "access-forbidden-text": "Jums nav piekļuves tiesību!
    Mēģiniet pierakstīties ar citu lietotājvārdu.", + "refresh-token-expired": "Sesija ir beigusies", + "refresh-token-failed": "Nevar atjaunot sesiju" + }, + "action": { + "activate": "Aktivizēt", + "suspend": "Apturēt", + "save": "Saglabāt", + "saveAs": "Saglabāt kā", + "cancel": "Atcelt", + "ok": "OK", + "delete": "Dzēst", + "add": "Pievienot", + "yes": "Jā", + "no": "Nē", + "update": "Atjaunināt", + "remove": "Noņemt", + "search": "Meklēt", + "clear-search": "Notīrīt meklēšanu", + "assign": "Piešķirt", + "unassign": "Noņemt", + "share": "Dalīties", + "make-private": "Padarīt privātu", + "apply": "Pielietot", + "apply-changes": "Pielietot izmaiņas", + "edit-mode": "Rediģēšanas režīms", + "enter-edit-mode": "Ievadiet rediģēšanas režīmu", + "decline-changes": "Noraidīt izmaiņas", + "close": "Aizvērt", + "back": "Atpakaļ", + "run": "Uz priekšu", + "sign-in": "Pierakstīties!", + "edit": "Rediģēt", + "view": "Skatīt", + "create": "Radīt", + "drag": "Velciet", + "refresh": "Atjaunot", + "undo": "Atsaukt", + "copy": "Kopēt", + "paste": "Ielīmēt", + "copy-reference": "Kopija atsauce", + "paste-reference": "Ielīmēt atsauce", + "import": "Importēt", + "export": "Eksportēt", + "share-via": "Dalīties caur {{provider}}", + "continue": "Turpināt", + "done": "Darīts" + }, + "aggregation": { + "aggregation": "Sakopojums", + "function": "Datu sakopojuma funkcija", + "limit": "Limits", + "group-interval": "Grupas intervāls", + "min": "Min", + "max": "Max", + "avg": "Vidējais", + "sum": "Sum", + "count": "Skaits", + "none": "Neviena" + }, + "admin": { + "general": "Vispārīgi", + "general-settings": "Vispārīgie iestatījumi", + "outgoing-mail": "Pasta serveris", + "outgoing-mail-settings": "Izejošā pasta servera iestatījumi", + "system-settings": "Sistēmas iestatījumi", + "test-mail-sent": "Testa pasts sekmīgi nosūtīts!", + "base-url": "pamata URL", + "base-url-required": "Pamata URL ir nepieciešams.", + "mail-from": "Pasts no", + "mail-from-required": "Pasts no ir nepieciešams.", + "smtp-protocol": "SMTP protokols", + "smtp-host": "SMTP saimnieks", + "smtp-host-required": "SMTP saimnieks ir nepieciešams.", + "smtp-port": "SMTP ports", + "smtp-port-required": "Jums vajag nodrošināt SMTP portu.", + "smtp-port-invalid": "Tas neizskatās pēc atļauta SMTP porta.", + "timeout-msec": "Pārtraukums (msec)", + "timeout-required": "Pārtraukums ir nepieciešams.", + "timeout-invalid": "Tas neizskatās pēc atļauta pārtraukuma.", + "enable-tls": "Iespējot TLS", + "send-test-mail": "Nosūtīt testa pastu" + }, + "alarm": { + "alarm": "Trauksme", + "alarms": "Trauksmes", + "select-alarm": "Atlasīt trauksmi", + "no-alarms-matching": "Nav atbilstošu trauksmju '{{entity}}' .", + "alarm-required": "Trauksme ir nepieciešama", + "alarm-status": "Trauksmes statuss", + "search-status": { + "ANY": "Jebkura", + "ACTIVE": "Aktīvs", + "CLEARED": "Dzēsts", + "ACK": "Apstiprināts", + "UNACK": "Neapstiprināts" + }, + "display-status": { + "ACTIVE_UNACK": "Aktīvs Neapstiprināts", + "ACTIVE_ACK": "Aktīvs Apstiprināts", + "CLEARED_UNACK": "Dzēsts Neapstiprināts", + "CLEARED_ACK": "Dzēsts Apstiprināts" + }, + "no-alarms-prompt": "Trauksmes nav atrastas", + "created-time": "Izveidošanas laiks", + "type": "Tips", + "severity": "Smaguma pakāpe", + "originator": "Iniciātors", + "originator-type": "Iniciātora tips", + "details": "Detaļas", + "status": "Statuss", + "alarm-details": "Trauksmes detaļas", + "start-time": "Sākuma laiks", + "end-time": "Beigu laiks", + "ack-time": "Apstiprinājuma laiks", + "clear-time": "Notīrīšanas laiks", + "severity-critical": "Smaguma pakāpe - kritiska", + "severity-major": "Būtiska", + "severity-minor": "Minora", + "severity-warning": "Brīdinājums", + "severity-indeterminate": "Nenoteikts", + "acknowledge": "Apstiprināt", + "clear": "Notīrīt", + "search": "Meklēt trauksmes", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# trauksmes} } selected", + "no-data": "Nav datu ko attēlot", + "polling-interval": "Trauksmju pārbaužu intervāls (sec)", + "polling-interval-required": "Trauksmju pārbaužu intervāls ir nepieciešams.", + "min-polling-interval-message": "Vismaz 1 sekundes pārbaužu intervāls ir atļauts.", + "aknowledge-alarms-title": "Apstiprināt { count, plural, 1 {1 alarm} other {# trauksmes} }", + "aknowledge-alarms-text": "Vai Jūs tiešām vēlaties apstirpināt { count, plural, 1 {1 alarm} other {# trauksmes} }?", + "aknowledge-alarm-title": "Apstiprināt trauksmi", + "aknowledge-alarm-text": "Vai Jūs tiešām vēlaties apstiprināt trauksmi?", + "clear-alarms-title": "Dzēst { count, plural, 1 {1 alarm} other {# trauksmes} }", + "clear-alarms-text": "Vai Jūs tiešām vēlaties dzēst { count, plural, 1 {1 alarm} other {# trauksmes} }?", + "clear-alarm-title": "Dzēst trauksmi", + "clear-alarm-text": "Vai Jūs tiešām vēlaties dzēst trauksmi?", + "alarm-status-filter": "Trauksmes statusa filtrs" + }, + "alias": { + "add": "Pievienot segvārdu", + "edit": "Rediģēt segvārdu", + "name": "Segvārda nosaukums", + "name-required": "Segvārda nosaukums vārds ir nepieciešams", + "duplicate-alias": "Segvārds ar tādu pašu nosaukumu jau eksistē.", + "filter-type-single-entity": "Viena vienība", + "filter-type-entity-list": "Vienību saraksts", + "filter-type-entity-name": "Vienības vārds", + "filter-type-state-entity": "Vienība no paneļa stāvokļa", + "filter-type-state-entity-description": "Vienība ņemta no paneļa stāvokļa parametriem", + "filter-type-asset-type": "Aktīvu tips", + "filter-type-asset-type-description": "Aktīvu tipi '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Aktīvu tips '{{assetType}}' un ar vārdu sākot ar '{{prefix}}'", + "filter-type-device-type": "Iekārtas tips", + "filter-type-device-type-description": "Iekārtas tipi '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Iekārtas tipi '{{deviceType}}' un ar vārdu sākot ar '{{prefix}}'", + "filter-type-entity-view-type": "Vienības skata tips", + "filter-type-entity-view-type-description": "Vienības skats tipam '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Vienības skats tipam '{{entityView}}' un ar vārdu sākot ar '{{prefix}}'", + "filter-type-relations-query": "Attiecību vaicājums", + "filter-type-relations-query-description": "{{entities}} kam ir {{relationType}} attiecība {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Aktīvu meklēšanas vaicājums", + "filter-type-asset-search-query-description": "Aktīvi ar tipu {{assetTypes}} kam ir {{relationType}} attiecība {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Iekārtu meklēšanas vaicājums", + "filter-type-device-search-query-description": "Iekārtas ar tipu {{deviceTypes}} kam ir {{relationType}} attiecība {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Vienības skata meklēšanas vaicājuma", + "filter-type-entity-view-search-query-description": "Vienību skats ar tipu {{entityViewTypes}} kam ir {{relationType}} attiecība {{direction}} {{rootEntity}}", + "entity-filter": "Vienību filtrs", + "resolve-multiple": "Atrisināt kā daudzas vienības", + "filter-type": "Filtra tips", + "filter-type-required": "Filtra tips ir nepieciešams.", + "entity-filter-no-entity-matched": "Nav atrastas vienības kam atbilst filtru iestatījumi.", + "no-entity-filter-specified": "Nav vienību filtrs specificēts", + "root-state-entity": "Lieto paneļa statusa vienību kā sakni ", + "root-entity": "Saknes vienības", + "state-entity-parameter-name": "Statusa vienības parametra vārds", + "default-state-entity": "Noklusējuma statusa vienība", + "default-entity-parameter-name": "Pēc noklusējuma", + "max-relation-level": "Maksimālais attiecību līmenis", + "unlimited-level": "Nelimitēts līmenis", + "state-entity": "Paneļa statusa vienība", + "all-entities": "Visas vienības", + "any-relation": "Jebkura" + }, + "asset": { + "asset": "Aktīvs", + "assets": "Aktīvi", + "management": "Aktīvu pārvaldība", + "view-assets": "Skatīt aktīvus", + "add": "Pievienot aktīvu", + "assign-to-customer": "Pieškirt klientam", + "assign-asset-to-customer": "Piešķirt aktīvu klientam", + "assign-asset-to-customer-text": "Lūdzu izvēlēties aktīvu lai pieškirtu klientam", + "no-assets-text": "Aktīvi nav atrasti", + "assign-to-customer-text": "Lūdzu izvēlēties klientu lai pieškirtu aktīvu", + "public": "Publisks", + "assignedToCustomer": "Pieškirts klientam", + "make-public": "Veidot aktīvu publisku", + "make-private": "Veidot aktīvu privātu", + "unassign-from-customer": "Noņemt klientam", + "delete": "Dzēst aktīvu", + "asset-public": "Aktīvs ir publisks", + "asset-type": "Aktīva tips", + "asset-type-required": "Aktīva tips ir nepieciešams.", + "select-asset-type": "Izvēlies aktīva tipu", + "enter-asset-type": "Ievadi aktīva tipu", + "any-asset": "Jebkurš aktīvs", + "no-asset-types-matching": "Nav atbilstošs aktīvu tips '{{entitySubtype}}' atrasts.", + "asset-type-list-empty": "Nav aktīvu tipi izvēlēti.", + "asset-types": "Aktīvu tipi", + "name": "Vārds", + "name-required": "Vārds ir nepieciešams.", + "description": "Apraksts", + "type": "Tips", + "type-required": "Tips ir nepieciešams.", + "details": "Detaļas", + "events": "Notikumi", + "add-asset-text": "Pievieno jaunu aktīvu", + "asset-details": "Aktīvu detaļas", + "assign-assets": "Piešķirt aktīvus", + "assign-assets-text": "Piešķirt { count, plural, 1 {1 asset} other {# aktīvus} } klientam", + "delete-assets": "Dzēst aktīvus", + "unassign-assets": "Noņemt aktīvus", + "unassign-assets-action-title": "Noņemt { count, plural, 1 {1 asset} other {# aktīvus} } no klienta", + "assign-new-asset": "Pieškirt jaunu aktīvu", + "delete-asset-title": "Vai esat pārliecināts,ka vēlaties dzēst aktīvu '{{assetName}}'?", + "delete-asset-text": "Esiet uzmanīgs, pēc apstiprināšanas aktīvs un saistītie dati nebūs atjaunojami.", + "delete-assets-title": "Vai esat pārliecināts ka vēlaties dzēst { count, plural, 1 {1 asset} other {# aktīvus} }?", + "delete-assets-action-title": "Dzēst { count, plural, 1 {1 asset} other {# aktīvus} }", + "delete-assets-text": "Esiet uzmanīgs, pēc apstiprinājuma visi izvēlētie aktīvi tiks dzēsti un saistītā informācija nebūs atjaunojama.", + "make-public-asset-title": "Vai esat pārliecināts ka vēlaties aktīvu '{{assetName}}' veidot publisku?", + "make-public-asset-text": "Pēc apstiprinājuma aktīvs un tā dati tiks publiski pieejami.", + "make-private-asset-title": "Vai esat pārliecināts ka vēlaties aktīvu '{{assetName}}' veidot privātu?", + "make-private-asset-text": "Pēc apstiprinājums aktīvs un tā saistītie dati būs privāti un nebūs pieejami citiem.", + "unassign-asset-title": "Vai esat pārliecināts ka vēlaties noņemt aktīvu '{{assetName}}'?", + "unassign-asset-text": "Pēc apstiprināšanas aktīvs tiks noņemts un nebūs pieejams klientiem.", + "unassign-asset": "Noņemt aktīvu", + "unassign-assets-title": "Vai esat pārliecināts ka vēlaties noņemt { count, plural, 1 {1 asset} other {# aktīvus} }?", + "unassign-assets-text": "Pēc apstiprināšanas visi izvēlētie aktīvi būs noņemti un nebūs pieejami klientiem.", + "copyId": "Kopēt aktīva Id", + "idCopiedMessage": "Aktīva Id ir kopēts uz starpliktuvi", + "select-asset": "Atlasīt aktīvu", + "no-assets-matching": "Nav atbilstošs aktīvs '{{entity}}' atrasts.", + "asset-required": "Aktīvs ir nepieciešams", + "name-starts-with": "Aktīva vārds sākas ar", + "import": "Importēt aktīvus", + "asset-file": "Aktīvu fails" + }, + "attribute": { + "attributes": "Attribūti", + "latest-telemetry": "Jaunākā telemetrija", + "attributes-scope": "Vienības atribūtu darbības joma", + "scope-latest-telemetry": "Jaunākā telemetrija", + "scope-client": "Klientu atribūti", + "scope-server": "Servera atribūti", + "scope-shared": "Dalītie atribūti", + "add": "Pievieno atribūtu", + "key": "Atslēga", + "last-update-time": "Pēdēja atjaunojuma laiks", + "key-required": "Atribūta atslēga ir nepieciešama.", + "value": "Vērtība", + "value-required": "Atribūta vērtība ir nepieciešama.", + "delete-attributes-title": "Vai esat pārliecināts ka vēlaties dzēst { count, plural, 1 {1 attribute} other {# attribūtus} }?", + "delete-attributes-text": "Esiet uzmanīgs, pēc apstiprinājuma visi izvēlētie atribūti tiks dzēsti.", + "delete-attributes": "Dzēst atribūtu", + "enter-attribute-value": "Ievadiet atribūta vērtību", + "show-on-widget": "Parādīt logrīkā", + "widget-mode": "Logrīka režīms", + "next-widget": "Nākamais logrīks", + "prev-widget": "Iepriekšējais logrīks", + "add-to-dashboard": "Pievienot panelim", + "add-widget-to-dashboard": "Pievienot logrīku panelim", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# atribūtus} } izvēlētajam", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetrijas vienības} } izvēlētas" + }, + "audit-log": { + "audit": "Audits", + "audit-logs": "Audita logs", + "timestamp": "Laika zīmogs", + "entity-type": "Vienības tips", + "entity-name": "Vienības vārds", + "user": "Lietotājs", + "type": "Tips", + "status": "Statuss", + "details": "Detaļas", + "type-added": "Pievienots", + "type-deleted": "Dzēsts", + "type-updated": "Atjaunots", + "type-attributes-updated": "Atribūti atjaunoti", + "type-attributes-deleted": "Atribūti dzēsti", + "type-rpc-call": "RPC izsaukumi", + "type-credentials-updated": "Akreditācijas dati atjaunoti", + "type-assigned-to-customer": "Pieškirts klientam", + "type-unassigned-from-customer": "Noņemts no klienta", + "type-activated": "Aktivizēts", + "type-suspended": "Apturēts", + "type-credentials-read": "Akreditācijas datu nolasījums", + "type-attributes-read": "Atribūtu nolasījums", + "type-relation-add-or-update": "Attiecība atjaunota", + "type-relation-delete": "Atiecība dzēsta", + "type-relations-delete": "Visas attiecības dzēstas", + "type-alarm-ack": "Apstiprinājums", + "type-alarm-clear": "Notīrīts", + "status-success": "Sekmīgi", + "status-failure": "Neveiksme", + "audit-log-details": "Audita loga detaļas", + "no-audit-logs-prompt": "Nav logu atrastu", + "action-data": "Aktivitāšu dati", + "failure-details": "Neveiksmju detaļas", + "search": "Meklēt audita logus", + "clear-search": "Notīrīt meklēšanu" + }, + "confirm-on-exit": { + "message": "Jums ir nesaglabātas izmaiņas. Vai tiešām vēlaties pamest šo lapu?", + "html-message": "Jums ir nesaglabātas izmaiņas.
    Vai tiešām vēlaties pamest šo lapu?", + "title": "Nesaglabātas izmaiņas" + }, + "contact": { + "country": "Valsts", + "city": "Pilsēta", + "state": "Štats/Province", + "postal-code": "Zip / Pasta kods", + "postal-code-invalid": "Invalīds Zip / Pasta koda formāts.", + "address": "Adrese", + "address2": "Adrese 2", + "phone": "Telefons", + "email": "Email", + "no-address": "Nav adreses" + }, + "common": { + "username": "Lietotājvārdse", + "password": "Parole", + "enter-username": "Ievadiet lietotājvārdu", + "enter-password": "Ievadiet paroli", + "enter-search": "Ievadiet meklēt", + "created-time": "Izveidošanas laiks" + }, + "content-type": { + "json": "Json", + "text": "Teksts", + "binary": "Bināri (Base64)" + }, + "customer": { + "customer": "Klients", + "customers": "Klienti", + "management": "Klientu pārvaldība", + "dashboard": "Klientu panelis", + "dashboards": "Klientu paneļi", + "devices": "Klienta iekārtas", + "entity-views": "Klienta vienību skati", + "assets": "Klienta aktīvi", + "public-dashboards": "Publiskie paneļi", + "public-devices": "Publiskās iekārtas", + "public-assets": "Publiskie aktīvi", + "public-entity-views": "Publisko vienību skati", + "add": "Pievienot klientu", + "delete": "Dzēst klientu", + "manage-customer-users": "Pārvaldīt klienta lietotājus", + "manage-customer-devices": "Pārvaldīt klienta iekārtas", + "manage-customer-dashboards": "Pārvaldīt klienta paneļus", + "manage-public-devices": "Pārvaldīt publiskās iekārtas", + "manage-public-dashboards": "Pārvaldīt publiskos paneļus", + "manage-customer-assets": "Pārvaldīt klienta aktīvus", + "manage-public-assets": "Pārvaldīt publiskos aktīvus", + "add-customer-text": "Pievienot jaunu klientu", + "no-customers-text": "Nav klienti atrasti", + "customer-details": "Klienta detaļas", + "delete-customer-title": "Vai esat pārliecināts, ka vēlaties dzēst klientu '{{customerTitle}}'?", + "delete-customer-text": "Esiet uzmanīgs, pēc apstiprinājuma klients un tā saistītie dati nebūs atjaunojami.", + "delete-customers-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 customer} other {# klientus} }?", + "delete-customers-action-title": "Dzēst { count, plural, 1 {1 customer} other {# klientus} }", + "delete-customers-text": "Esiet uzmanīgs, pēc apstiprinājuma visi izvēlētie klienti tisk dzēsti un to saistītie dati nebūs atjaunojami.", + "manage-users": "Pārvaldīt lietotājus", + "manage-assets": "Pārvaldīt aktīvus", + "manage-devices": "Pārvaldīt iekārtas", + "manage-dashboards": "Pārvaldīt paneļus", + "title": "Virsraksts", + "title-required": "Virsraksts ir nepieciešams.", + "description": "Apraksts", + "details": "Detaļas", + "events": "Notikumi", + "copyId": "Kopēt klienta Id", + "idCopiedMessage": "Klienta Id ir kopēts uz starpliktuvi", + "select-customer": "Atlasīt klientu", + "no-customers-matching": "Nav atbilstoši klienti '{{entity}}' atrasti.", + "customer-required": "Klients ir nepieciešams", + "select-default-customer": "Atlasīt pamata klientu", + "default-customer": "Pamata klients", + "default-customer-required": "Pamata klients ir nepieciešams lai atkļūdotu paneli īrnieka līmenī" + }, + "datetime": { + "date-from": "Datums no", + "time-from": "Laiks no", + "date-to": "Datums līdz", + "time-to": "Laiks līdz" + }, + "dashboard": { + "dashboard": "Panelis", + "dashboards": "Paneļi", + "management": "Paneļu pārvaldība", + "view-dashboards": "Skatīt paneļus", + "add": "Pievienot paneļus", + "assign-dashboard-to-customer": "Piešķirt paneļus klientam", + "assign-dashboard-to-customer-text": "Lūdzu izvēlēties paneļus lai piešķirtu tos klientam", + "assign-to-customer-text": "Lūdzu izvēlēties klientu, kuram piešķirt paneļus", + "assign-to-customer": "Piešķirt klientam", + "unassign-from-customer": "Noņemt no klienta", + "make-public": "Veidot paneli publisku", + "make-private": "Veidot paneli privātu", + "manage-assigned-customers": "Pārvaldīt piešķirtos klientus", + "assigned-customers": "Piešķirtie klienti", + "assign-to-customers": "Piešķirt paneļus klientiem", + "assign-to-customers-text": "Lūdzu atlasīt klientus lai pieškirtu paneļus", + "unassign-from-customers": "Noņemt no klientiem paneļus", + "unassign-from-customers-text": "Lūdzu atlasīt klientus kuriem noņemt paneļus", + "no-dashboards-text": "Nav paneļi atrasti", + "no-widgets": "Nav logrīki konfigurēti", + "add-widget": "Pievienot jaunu logrīku", + "title": "Virsraksts", + "select-widget-title": "Atlasīt logrīku", + "select-widget-subtitle": "Pieejamo logrīku tipu saraksts", + "delete": "Dzēst paneli", + "title-required": "Virsraksts ir nepieciešams.", + "description": "Apraksts", + "details": "Detaļas", + "dashboard-details": "Paneļa detaļas", + "add-dashboard-text": "Pievienot jaunu paneli", + "assign-dashboards": "Pieškirt paneļus", + "assign-new-dashboard": "Pieškirt jaunu paneli", + "assign-dashboards-text": "Pieškirt { count, plural, 1 {1 dashboard} other {# paneļus} } klientiem", + "unassign-dashboards-action-text": "Noņemt { count, plural, 1 {1 dashboard} other {# paneļus} } no klientiem", + "delete-dashboards": "Dzēst paneļus", + "unassign-dashboards": "Noņemt paneļus", + "unassign-dashboards-action-title": "Noņemt { count, plural, 1 {1 dashboard} other {# paneļus} } no klienta", + "delete-dashboard-title": "Vai esat pārliecināts ka vēlaties dzēst paneli '{{dashboardTitle}}'?", + "delete-dashboard-text": "Esiet uzmanīgs, pēc apstiprinājuma panelis un visi tā saistītie dati nebūs atjaunojami.", + "delete-dashboards-title": "Vai esat pārliecināts ka vēlaties dzēst { count, plural, 1 {1 dashboard} other {# paneļus} }?", + "delete-dashboards-action-title": "Dzēst { count, plural, 1 {1 dashboard} other {# paneļus} }", + "delete-dashboards-text": "Esiet uzmanīgs, pēc apstiprinājuma visi izvēlētie paneļi būs noņemti un visi saistitie dati nebūs atjaunojami.", + "unassign-dashboard-title": "Vai esat pārliecināts, ka vēlaties noņemt paneli '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Pēc apstiprinājuma panelis tiks noņemts un nebūs pieejams klientam.", + "unassign-dashboard": "Noņemt paneli", + "unassign-dashboards-title": "Vai esat pārliecināts ka vēlaties noņemt { count, plural, 1 {1 dashboard} other {# paneļus} }?", + "unassign-dashboards-text": "Pēc apstiprinājuma visi izvēlētie paneļi būs noņemti un nebūs pieejami klientam.", + "public-dashboard-title": "Panelis tagad ir publisks", + "public-dashboard-text": "Jūsu panelis {{dashboardTitle}} tagad ir publisks un pieejams pēc saites link:", + "public-dashboard-notice": "Note: Neaizmirstie veidot attiecīgās iekārtas publiski pieejamas lai piekļutu to datiem.", + "make-private-dashboard-title": "Vai esat pārliecināts, ka vēlaties veidot paneli '{{dashboardTitle}}' privātu?", + "make-private-dashboard-text": "Pēc apstiprinājuma panelis būs privāts un nebūs pieejams citiem.", + "make-private-dashboard": "Veidot paneli privātu", + "socialshare-text": "'{{dashboardTitle}}' atbalsts no ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' atbalsts no ThingsBoard", + "select-dashboard": "Atlasīt paneli", + "no-dashboards-matching": "Nav atbilstoši paneļi '{{entity}}' atrasti.", + "dashboard-required": "Penelis ir nepieciešams.", + "select-existing": "Atlasīt paneli", + "create-new": "Radīt jaunu paneli", + "new-dashboard-title": "Jauns paneļa Virsraksts", + "open-dashboard": "Atvērt paneli", + "set-background": "Iestatīt fonu", + "background-color": "Fona krāsa", + "background-image": "Fona attēls", + "background-size-mode": "Fona lieluma mode", + "no-image": "Nav izvēlēts attēls", + "drop-image": "Nomest attēlu vai noklikšķiniet lai atlasītu failu augšupielādei.", + "settings": "Iestatījumi", + "columns-count": "Kolonu skaitīšana", + "columns-count-required": "Kolonu skaitīšana ir nepieciešams.", + "min-columns-count-message": "Tikai minimums 10 kolonu skaitīšana ir atļauta.", + "max-columns-count-message": "Tikai maksimums 100 kolonu skaitīšana ir atļauta.", + "widgets-margins": "Robeža starp logrīkiem", + "horizontal-margin": "Horizontālā robeža", + "horizontal-margin-required": "Horizontālās robežas vērtība ir nepieciešama.", + "min-horizontal-margin-message": "Tikai 0 ir atļauta kā minimālā horizontālās robežas vērtība.", + "max-horizontal-margin-message": "Tikai 50 ir atļauta kā maksimālā horizontālās robežas vērtība.", + "vertical-margin": "Vertikālā robeža", + "vertical-margin-required": "Vertikālās robežas vērtība ir nepieciešama.", + "min-vertical-margin-message": "Tikai 0 ir atļauta kā minimālā vertikālās robežas vērtība.", + "max-vertical-margin-message": "Tikai 50 ir atļauta kā maksimālā vertikālās robežas vērtība.", + "autofill-height": "Automātiskās aizpildīšanas izkārtojuma augstums", + "mobile-layout": "Mobilā izkārtojuma iestatījumi", + "mobile-row-height": "Mobilās rindas augstums, px", + "mobile-row-height-required": "Mobile row height value is required.", + "min-mobile-row-height-message": "Tikai 5 pikseļi ir atļauti kā minimālās mobilās rindas augstuma vērtības.", + "max-mobile-row-height-message": "Tikai 200 pikseļi ir atļauti kā maksimālās mobilās rindas augstuma vērtības.", + "display-title": "Parādīt paneļa virsrakstu", + "toolbar-always-open": "Turēt rīkjoslu atvērtu", + "title-color": "Virsraksta krāsa", + "display-dashboards-selection": "Parādīt paneļa izvēli", + "display-entities-selection": "Parādīt vienību izvēli", + "display-dashboard-timewindow": "Parādīt laika logu", + "display-dashboard-export": "Parādīt eksportu", + "import": "Importēt paneli", + "export": "Eksportēt panelis", + "export-failed-error": "Nav iespējams eksportēt paneli: {{error}}", + "create-new-dashboard": "Radīt jaunu paneli", + "dashboard-file": "Paneļa fails", + "invalid-dashboard-file-error": "Nav iespējams importēt paneli: Invalīda paneļa datu struktūra.", + "dashboard-import-missing-aliases-title": "Jānokonfigurē segvārdi kas lietoti importētajā panelī", + "create-new-widget": "Radīt jaunu logrīku", + "import-widget": "Importēt logrīku", + "widget-file": "Logrīka fails", + "invalid-widget-file-error": "Nav iespējams importēt logrīku: Invalīda logrīka datu struktūra.", + "widget-import-missing-aliases-title": "Jānokonfigurē segvārdi kas lietoti importētajā logrīkā", + "open-toolbar": "Atvērt paneļa rīkjoslu", + "close-toolbar": "Aizvērt rīkjoslu", + "configuration-error": "Konfigurācijas kļūda", + "alias-resolution-error-title": "Paneļa segvārdu konfigurācijas kļūda", + "invalid-aliases-config": "Nav iespējams atrast nevienu iekārtu kam atbilst kāds no segvārdu filtriem.
    Lūdzu sazinieties ar savu administrātoru.", + "select-devices": "Atlasīt iekārtas", + "assignedToCustomer": "Pišķirtas klientam", + "assignedToCustomers": "Piešķirtas klientiem", + "public": "Publisks", + "public-link": "Publiska saite", + "copy-public-link": "Kopēt publisku saiti", + "public-link-copied-message": "Paneļa publiskā saite ir kopēta starpliktuvē", + "manage-states": "Pārvaldīt paneļa stāvokļus", + "states": "Paneļa stāvokļi", + "search-states": "Meklēt paneļa stāvokļus", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# paneļu statusus} } atlasītos", + "edit-state": "Rediģēt paneļa stāvokli", + "delete-state": "Dzēst paneļa stāvokli", + "add-state": "Pievienot paneļa stāvokli", + "state": "Paneļa stāvoklis", + "state-name": "Nosaukums", + "state-name-required": "Paneļa stāvokļa nosaukums ir nepieciešams.", + "state-id": "Stāvokļa Id", + "state-id-required": "Paneļa stāvokļa Id ir nepieciešams.", + "state-id-exists": "Paneļa stāvoklis ar šādu Id jau eksistē.", + "is-root-state": "Saknes stāvoklis", + "delete-state-title": "Dzēst paneļa stāvokli", + "delete-state-text": "Vai esat pārliecināts ka vēlaties dzēst paneļa stāvokli ar nosaukumu '{{stateName}}'?", + "show-details": "Rādīt detaļas", + "hide-details": "Noslēpt detaļas", + "select-state": "Atlasīt mērķa stāvokli", + "state-controller": "Stavokļa kontrolieris" + }, + "datakey": { + "settings": "Iestatījumi", + "advanced": "Pieredzējis lietotājs", + "label": "Etiķete", + "color": "Krāsa", + "units": "Speciāls simbols, ko parādīt pēc vērtība", + "decimals": "Ciparu skaits aiz komata", + "data-generation-func": "Datu ģenerācijas funkcija", + "use-data-post-processing-func": "Lietot datu pēcapstrādes funkciju", + "configuration": "Datu atslēgu konfigurācija", + "timeseries": "Laika periodi", + "attributes": "Atribūti", + "alarm": "Trauksme", + "timeseries-required": "Vienības laika periodi ir nepieciešami.", + "timeseries-or-attributes-required": "Vienības laika periodi/atribūti ir nepieciešami.", + "maximum-timeseries-or-attributes": "Maksimums { count, plural, 1 {1 timeseries/attribute is allowed.} other {# laika sērijas/atribūti ir atļauti} }", + "alarm-fields-required": "Trauksmes lauki ir nepieciešami.", + "function-types": "Funkciju tipi", + "function-types-required": "Funkciju tipi ir nepieciešami.", + "maximum-function-types": "Maksimums { count, plural, 1 {1 function type is allowed.} other {# funkciju tipi ir atļauti} }", + "time-description": "Laika zīmogs patreizējai vērtībai;", + "value-description": "patreizējā vērtība;", + "prev-value-description": "rezultāts no iepriekšējā funkciju pieprasījuma;", + "time-prev-description": "Laika zīmogs no iepriekšējās vērtības;", + "prev-orig-value-description": "Oriģinālā iepriekšējā vērtība;" + }, + "datasource": { + "type": "Datu avota tips", + "name": "Nosaukums", + "add-datasource-prompt": "Lūdzu pievienot datu avotu" + }, + "details": { + "edit-mode": "Rediģēšanas mode", + "toggle-edit-mode": "Pārslēgt rediģēšanas modi" + }, + "device": { + "device": "Iekārta", + "device-required": "Iekārta ir nepieciešama.", + "devices": "Iekārtas", + "management": "Iekārtu pārvaldība", + "view-devices": "Skatīt iekārtas", + "device-alias": "Iekārtu segvārdi", + "aliases": "Iekārtas segvārdi", + "no-alias-matching": "'{{alias}}' nav atrasti.", + "no-aliases-found": "Nav segvārdi atrasti.", + "no-key-matching": "'{{key}}' nav atrasti.", + "no-keys-found": "Nav atslēgas atrastas.", + "create-new-alias": "Radīt jaunu!", + "create-new-key": "Radīt jaunu!", + "duplicate-alias-error": "Dublēti segvārdi atrasti '{{alias}}'.
    Iekārtas segvārdiem ir jābūt unikāliem panelī.", + "configure-alias": "Konfigurēt '{{alias}}' segvārdus", + "no-devices-matching": "Nav iekārtu atbilstības '{{entity}}' atrastas.", + "alias": "Segvārdi", + "alias-required": "Iekārtu segvārdi ir nepieciešami.", + "remove-alias": "Noņemt iekārtas segvārdus", + "add-alias": "Pievienot iekārtas segvārdus", + "name-starts-with": "Iekārtas nosaukums sākas ar", + "device-list": "Iekārtu saraksts", + "use-device-name-filter": "Lietot filtru", + "device-list-empty": "Nav iekārtas atlasītas.", + "device-name-filter-required": "Iekārtas nosaukuma filtrs ir nepieciešams.", + "device-name-filter-no-device-matched": "Nav iekārtas kas sākas ar '{{device}}' atrastas.", + "add": "Pievienot iekārtu", + "assign-to-customer": "Piešķirt klientam", + "assign-device-to-customer": "Piešķirt iekārtas klientam", + "assign-device-to-customer-text": "Lūdzu atlasīt iekārtas lai pieškirtu klientam", + "make-public": "Veidot iekārtu publisku", + "make-private": "Veidot iekārtu privātu", + "no-devices-text": "Nav iekārtas atrastas", + "assign-to-customer-text": "Lūdzu atlasīt klientu lai pieškirtu iekārtas", + "device-details": "iekārtas detaļas", + "add-device-text": "Pievienot jaunu iekārtu", + "credentials": "Akreditācijas dati", + "manage-credentials": "Pārvaldīt akreditācijas datus", + "delete": "Dzēst iekārtu", + "assign-devices": "Pieškirt iekārtas", + "assign-devices-text": "Piešķirt { count, plural, 1 {1 device} other {# iekārtas} } klientam", + "delete-devices": "Dzēst iekārtas", + "unassign-from-customer": "Noņemt no klienta", + "unassign-devices": "Noņemt iekārtas", + "unassign-devices-action-title": "Noņemt { count, plural, 1 {1 device} other {# iekārtas} } no klienta", + "assign-new-device": "Pieškirt jaunu iekārtu", + "make-public-device-title": "Vai esat pārliecināts ka vēlaties veidot iekārtu '{{deviceName}}' publisku?", + "make-public-device-text": "Pēc apstiprinājuma iekārta un tās saistītie dati būs pieejami publiski un pieejami citiem.", + "make-private-device-title": "vai esat pārliecināts ka vēlaties veidot iekārtu '{{deviceName}}' privāti?", + "make-private-device-text": "Pēc apstiprinājuma iekārta un tās saistītie dati būs pieejami privāti un nebūs pieejami citiem.", + "view-credentials": "Skatīt akreditācijas datus", + "delete-device-title": "Vai esat pārliecināts, ka vēlaties dzēst iekārtu '{{deviceName}}'?", + "delete-device-text": "Esat uzmanīgs, pēc apstiprinājuma iekārta un tās saistītie dati nebūs atjaunojami.", + "delete-devices-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 device} other {# iekārtas} }?", + "delete-devices-action-title": "Dzēst { count, plural, 1 {1 device} other {# iekārtas} }", + "delete-devices-text": "Esat uzmanīgs, pēc apstiprinājuma iekārtas un to saistītie dati tiks noņemti un nebūs atjaunojami.", + "unassign-device-title": "Vai esat pārliecināts, ka vēlaties noņemt iekārtu '{{deviceName}}'?", + "unassign-device-text": "Pēc apstiprinājuma iekārta tiks noņemta un nebūs pieejama klientam.", + "unassign-device": "Noņemt iekārtu", + "unassign-devices-title": "Vai esat pārliecināts, ka vēlaties noņemt { count, plural, 1 {1 device} other {# iekārtas} }?", + "unassign-devices-text": "Pēc apstipinājuma visas atlasītās iekārtas būs noņemtas un nebūs pieejamas klientam.", + "device-credentials": "iekārtas akreditācijas dati", + "credentials-type": "Akreditācijas datu tips", + "access-token": "Piekļuves tokens", + "access-token-required": "Piekļuves tokens ir nepieciešams.", + "access-token-invalid": "Piekļuves tokena garumam ir jābūt no 1 līdz 32 rakstzīmēm.", + "secret": "Noslēpums", + "secret-required": "Noslēpums ir nepieciešams.", + "device-type": "Iekārtas tips", + "device-type-required": "Iekārtas tips ir nepieciešams.", + "select-device-type": "Atlasīt iekārtas tipu", + "enter-device-type": "Ievadīt iekārtas tipu", + "any-device": "Jebkura iekārta", + "no-device-types-matching": "Nav iekārtas tipa saderības '{{entitySubtype}}' atrastas.", + "device-type-list-empty": "Nav iekārtas tipi izvēlēti.", + "device-types": "Iekārtas tipi", + "name": "Nosaukums", + "name-required": "Nosaukums ir nepieciešams.", + "description": "Apraksts", + "label": "Etiķete", + "events": "Notikumi", + "details": "Detaļas", + "copyId": "Kopēt iekārtas Id", + "copyAccessToken": "Kopēt piekļuves tokenu", + "idCopiedMessage": "iekārtas Id ir kopēts uz starpliktuvi", + "accessTokenCopiedMessage": "Iekārtas piekļuves tokens ir kopēts uz starpliktuvi", + "assignedToCustomer": "Piešķirts klientam", + "unable-delete-device-alias-title": "Nav iespējas dzēst iekārtas segvārdus", + "unable-delete-device-alias-text": "Iekārtas segvārdi '{{deviceAlias}}' nevar būt dzēsti, jo tie lietoti sekojošajos logrīkos:
    {{widgetsList}}", + "is-gateway": "Tā ir vārteja", + "public": "Publisks", + "device-public": "Iekārta ir publiska", + "select-device": "Atlasīt iekārtu", + "import": "Importēt iekārtu", + "device-file": "Iekārtas fails" + }, + "dialog": { + "close": "Aizvērt dialogu" + }, + "direction": { + "column": "Kolona", + "row": "Rinda" + }, + "error": { + "unable-to-connect": "Nav iespējams pievienoties serverim! Lūdzu pārbaudīt interneta savienojumu.", + "unhandled-error-code": "Neapstrādāta kļūda: {{errorCode}}", + "unknown-error": "Nezināma kļūda" + }, + "entity": { + "entity": "Vienība", + "entities": "Vienības", + "aliases": "Vienību segvārdi", + "entity-alias": "Vienību segvārdi", + "unable-delete-entity-alias-title": "Nav iespējams dzēst vienību segvārdus", + "unable-delete-entity-alias-text": "Vienību segvārdi '{{entityAlias}}' nevar tikt dzēsti, jo tos izmanto sekojošie logrīki:
    {{widgetsList}}", + "duplicate-alias-error": "Dublikāti segvārdi atrasti '{{alias}}'.
    Vienību segvārdiem ir jābūt unikāliem paneļos.", + "missing-entity-filter-error": "Filtrs trūkst priekš segvārda '{{alias}}'.", + "configure-alias": "Konfigurēt '{{alias}}' segvārdus", + "alias": "Segvārds", + "alias-required": "Vienību segvārds ir nepieciešams.", + "remove-alias": "Noņemt vienību segvārdu", + "add-alias": "Pievienot vienību segvārdu", + "entity-list": "Vienību saraksts", + "entity-type": "Vienības tips", + "entity-types": "Vienības tipi", + "entity-type-list": "Vienības tipu saraksts", + "any-entity": "Jebkura vienība", + "enter-entity-type": "Ievadīt vienības tipu", + "no-entities-matching": "Nav vienības saderības '{{entity}}' atrastas.", + "no-entity-types-matching": "Nav vienības tipu saderības '{{entityType}}' atrastas.", + "name-starts-with": "Nosaukums sākas ar", + "use-entity-name-filter": "Lietot filtru", + "entity-list-empty": "Nav vienības atlasītas.", + "entity-type-list-empty": "Nav vienības tipi atlasīti.", + "entity-name-filter-required": "Vienību nosaukuma filtri ir vajadzīgi.", + "entity-name-filter-no-entity-matched": "Nav vienības kas sākas ar '{{entity}}' atrastas.", + "all-subtypes": "Visi", + "select-entities": "Atlasīt vienības", + "no-aliases-found": "Nav segvārdi atrasti.", + "no-alias-matching": "'{{alias}}' nav atrasts.", + "create-new-alias": "Radīt jaunu!", + "key": "Atslēga", + "key-name": "Atslēgas nosaukums", + "no-keys-found": "Nav atslēgas atrastas.", + "no-key-matching": "'{{key}}' nav atrasta.", + "create-new-key": "Radīt jaunu!", + "type": "Tips", + "type-required": "Vienības tips ir nepieciešams.", + "type-device": "Iekārta", + "type-devices": "Iekārtas", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # iekārtas} }", + "device-name-starts-with": "Iekārtas, kuras nosaukumi sākas ar '{{prefix}}'", + "type-asset": "Aktīvs", + "type-assets": "Aktīvi", + "list-of-assets": "{ count, plural, 1 {One asset} other {List of # aktīvi} }", + "asset-name-starts-with": "Aktīvi, kuru nosaukumi sākas ar '{{prefix}}'", + "type-entity-view": "Vienības skats View", + "type-entity-views": "Vienības skati", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # vienību skati} }", + "entity-view-name-starts-with": "Vienibas skati, kuru nosaukumi sākas ar '{{prefix}}'", + "type-rule": "Noteikums", + "type-rules": "Noteikumi", + "list-of-rules": "{ count, plural, 1 {One rule} other {List of # noteikumi} }", + "rule-name-starts-with": "Noteikumi, kuru nosaukumi sākas ar '{{prefix}}'", + "type-plugin": "Spraudnis", + "type-plugins": "Spraudņi", + "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # spraudņi} }", + "plugin-name-starts-with": "Spraudņi, kuru vārds sākas ar '{{prefix}}'", + "type-tenant": "Īrnieks", + "type-tenants": "Īrnieki", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # īrnieki} }", + "tenant-name-starts-with": "Īrnieki, kuru nosaukumi sākas ar '{{prefix}}'", + "type-customer": "Klients", + "type-customers": "Klienti", + "list-of-customers": "{ count, plural, 1 {One customer} other {List of # klienti} }", + "customer-name-starts-with": "Klienti, kuru nosaukumi sākas ar '{{prefix}}'", + "type-user": "Lietotājs", + "type-users": "Lietotāji", + "list-of-users": "{ count, plural, 1 {One user} other {List of # lietotāji} }", + "user-name-starts-with": "Lietotāji, kuru nosaukums sākas ar '{{prefix}}'", + "type-dashboard": "Panelis", + "type-dashboards": "Paneļi", + "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # paneļi} }", + "dashboard-name-starts-with": "Paneļi, kuru nosaukums sākas ar '{{prefix}}'", + "type-alarm": "Trauksme", + "type-alarms": "Trauksmes", + "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # trauksmes} }", + "alarm-name-starts-with": "Trauksmes, kuru nosaukumi sākas ar '{{prefix}}'", + "type-rulechain": "Noteikumu ķēde", + "type-rulechains": "Noteikumu ķēdes", + "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # noteikumu ķēdes} }", + "rulechain-name-starts-with": "Noteikumu ķēdes, kuru nosaukumi sākas ar '{{prefix}}'", + "type-rulenode": "Noteikumu node", + "type-rulenodes": "Noteikumu nodes", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # noteikumu nodes} }", + "rulenode-name-starts-with": "Noteikumu nodes, juru nosaukumi sākas ar '{{prefix}}'", + "type-current-customer": "Pašreizējais klients", + "search": "Meklēšanas vienības", + "selected-entities": "{ count, plural, 1 {1 entity} other {# vienības} } atlasītas", + "entity-name": "Vienības nosaukums", + "details": "Vienības detaļas", + "no-entities-prompt": "Nav vienības atrastas", + "no-data": "Nav datu ko attēlot", + "columns-to-display": "Kolonas ko attēlot" + }, + "entity-view": { + "entity-view": "Vienības skats", + "entity-view-required": "Vienības skats ir nepieciešams.", + "entity-views": "Vienības skati", + "management": "Vienības skatu pārvaldība", + "view-entity-views": "Skatīt vienību skatus", + "entity-view-alias": "Vienību skatu segvārdi", + "aliases": "Vienību skatu segvārdi", + "no-alias-matching": "'{{alias}}' nav atrasts.", + "no-aliases-found": "Nav segvārds atrasts.", + "no-key-matching": "'{{key}}' nav atrasts.", + "no-keys-found": "Nav atslēgas atrastas.", + "create-new-alias": "Radīt jaunu!", + "create-new-key": "Radīt jaunu!", + "duplicate-alias-error": "Dublēti segvārdi atrasti '{{alias}}'.
    Vienību skatu segvārdiem ir jābūt unikāliem paneļa ietvaros.", + "configure-alias": "Konfigurēt '{{alias}}' segvārdu", + "no-entity-views-matching": "Nav vienību skata atbilstības '{{entity}}' atrastas.", + "alias": "Segvārds", + "alias-required": "Vienību skatu segvārdi ir nepieciešami.", + "remove-alias": "Noņemt vienību skatu segvārdu", + "add-alias": "Pievienot vienību skatu segvārdu", + "name-starts-with": "Vienību skata nosaukums sākas ar", + "entity-view-list": "Vienību skata saraksts", + "use-entity-view-name-filter": "Lietot filtru", + "entity-view-list-empty": "Nav vienību skati atlasīti.", + "entity-view-name-filter-required": "Vienību skatu nosaukumu filtri ir nepieciešami.", + "entity-view-name-filter-no-entity-view-matched": "Nav vienību skati kas sākas ar '{{entityView}}' atrasti.", + "add": "Pievienot vienību skatu", + "assign-to-customer": "Pieškirt klientam", + "assign-entity-view-to-customer": "Piešķirt vienību skatus klientam", + "assign-entity-view-to-customer-text": "Lūdzu izvēlēties vienību skatus ko pieškirt klientam", + "no-entity-views-text": "Nav vienību skati atrasti", + "assign-to-customer-text": "Lūdzu izvēlēties klientu lai pieškirtu vienības skatus", + "entity-view-details": "Vienību skata detaļas", + "add-entity-view-text": "Pievienot jaunu vienību skatu", + "delete": "Dzēsts vienību skatu", + "assign-entity-views": "Piešķirt vienību skatus", + "assign-entity-views-text": "Piešķirt { count, plural, 1 {1 entityView} other {# vienību skati} } klientam", + "delete-entity-views": "Dzēst vienību skatus", + "unassign-from-customer": "Noņemt no klienta", + "unassign-entity-views": "Noņemt vienību skatus", + "unassign-entity-views-action-title": "Noņemt { count, plural, 1 {1 entityView} other {# vienību skati} } no klienta", + "assign-new-entity-view": "Piešķirt jaunu vienību skatu", + "delete-entity-view-title": "Vai esat pārliecināts,ka vēlaties dzēst vienību skatu '{{entityViewName}}'?", + "delete-entity-view-text": "Esiet uzmanīgs, pēc apstiprinājuma vienību skats un tā sasitītie dati nebūs atjaunojami.", + "delete-entity-views-title": "Vai esat pārliecināts, ka vēlaties vienību skatu { count, plural, 1 {1 entityView} other {# vienību skati} }?", + "delete-entity-views-action-title": "Dzēst { count, plural, 1 {1 entityView} other {# vienību skati} }", + "delete-entity-views-text": "Esiet uzmanīgs, pēc apstiprinājuma visir atlasītie vienības skati tiks noņemti un to saistītie dati nebūs atjaunojami.", + "unassign-entity-view-title": "Vai esat pārliecināts, ka vēlaties atspējot vienību skatu '{{entityViewName}}'?", + "unassign-entity-view-text": "Pēc apstiprinājuma vienību skats tiks atspējots un nebūs pieejams klientam.", + "unassign-entity-view": "Atspējot vienību skatu", + "unassign-entity-views-title": "Vai esat pārliecināts, ka vēlaties atspējot { count, plural, 1 {1 entityView} other {# vienību skatus} }?", + "unassign-entity-views-text": "Pēc apstiprinājuma visi atlasītie vienību skati būs atspējoti un nebūs pieejami klientiem.", + "entity-view-type": "Vienību skata tips", + "entity-view-type-required": "Vienību skata tips ir nepieciešams.", + "select-entity-view-type": "Atlasīt vienību skata tipu", + "enter-entity-view-type": "Ievadīt vienību skata tipu", + "any-entity-view": "Jebkurš vienību skats", + "no-entity-view-types-matching": "Nav vienību skata atbilstības '{{entitySubtype}}' atrastas.", + "entity-view-type-list-empty": "Nav vienību skatu tipi atlasīti.", + "entity-view-types": "Vienību skatu tipi", + "name": "Nosaukums", + "name-required": "Nosaukums ir nepieciešams.", + "description": "Apraksts", + "events": "Notikumi", + "details": "Detaļas", + "copyId": "Kopēt vienību skata Id", + "assignedToCustomer": "Piešķirta klientam", + "unable-entity-view-device-alias-title": "Nav iespējas dzēst vienību skata segvārdu", + "unable-entity-view-device-alias-text": "Iekārtas segvārds '{{entityViewAlias}}' nevar tikt dzēsts, jo to izmanto sekojošs logrīks:
    {{widgetsList}}", + "select-entity-view": "Atlasīt vienību skatu", + "make-public": "Veidot vienību skatu publisku", + "make-private": "Veidot vienību skatu privātu", + "start-date": "Starta datums", + "start-ts": "Starta laiks", + "end-date": "Beigu datums", + "end-ts": "Beigu laiks", + "date-limits": "Datuma limits", + "client-attributes": "Klienta atribūti", + "shared-attributes": "Dalītie atribūti", + "server-attributes": "Servera atribūti", + "timeseries": "Laika sērijas", + "client-attributes-placeholder": "Klienta atribūti", + "shared-attributes-placeholder": "Dalītie atribūti", + "server-attributes-placeholder": "Servera atribūti", + "timeseries-placeholder": "Laika sērijas", + "target-entity": "Mērķa vienība", + "attributes-propagation": "Atribūtu izplatīšana", + "attributes-propagation-hint": "Vienību skats automātiski kopē specificētos atribūtus no mērķa vienības katru reizi kad jūs saglabājat vai atjaunojat vienību skatu.", + "timeseries-data": "Laika sērijas dati", + "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.", + "make-public-entity-view-title": "Vai esat pārliecināts, ka vēlaties veidot vienību skatu '{{entityViewName}}' publisku?", + "make-public-entity-view-text": "Pēc apstiprinājuma vienību skats un tā saistītie dati būs publiski un pieejami citiem.", + "make-private-entity-view-title": "Vai esat pārliecināts, ka vēlaties veidot vienību skatu '{{entityViewName}}' privātu?", + "make-private-entity-view-text": "Pēc apstiprinājuma vienību skats un tā saistītie dati būs privāti un nebūs pieejami citiem." + }, + "event": { + "event-type": "Notikuma tips", + "type-error": "Kļūda", + "type-lc-event": "Dzīves cikla notikums", + "type-stats": "Statistika", + "type-debug-rule-node": "Atkļūdot", + "type-debug-rule-chain": "Atkļūdot", + "no-events-prompt": "Nav notikumi atrasti", + "error": "Kļūda", + "alarm": "Trauksme", + "event-time": "Notikuma laiks", + "server": "Serveris", + "body": "Galvenā daļa", + "method": "Metode", + "type": "Tips", + "message-id": "Ziņojuma Id", + "message-type": "Ziņojuma tips", + "data-type": "Datu tips", + "relation-type": "Attiecību tips", + "metadata": "Metadata", + "data": "Dati", + "event": "Notikumi", + "status": "Statuss", + "success": "Sekmīgi", + "failed": "Kļūda", + "messages-processed": "Ziņojumi apstrādāti", + "errors-occurred": "Kļūdas konstatētas", + "all-events": "Visi", + "entity-type": "Vienības tips" + }, + "extension": { + "extensions": "Paplašinājumi", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# paplašinājumi} } atlasītie", + "type": "Tips", + "key": "Atslēga", + "value": "Vērtība", + "id": "Id", + "extension-id": "Paplašinājuma id", + "extension-type": "Paplašinājuma tips", + "transformer-json": "JSON *", + "unique-id-required": "Patreizējā paplašinājuma id jau eksistē.", + "delete": "Dzēst paplašinājumu", + "add": "Pievienot paplašinājumu", + "edit": "Rediģēt paplašinājumu", + "delete-extension-title": "Vai esat pārliecināts, ka vēlaties dzēst paplašinājumu '{{extensionId}}'?", + "delete-extension-text": "Esiet uzmanīgs, pēc apstiprinājuma paplašinājums un visi tā saistītie dati nebūs atjaunojami.", + "delete-extensions-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 extension} other {# paplašinājumus} }?", + "delete-extensions-text": "Esiet uzmanīgs, pēc apstiprinājuma visi atlasītie paplašinājumi tiks dzēsti.", + "converters": "Pārveidotāji", + "converter-id": "Pārveidotāja id", + "configuration": "Konfigurācija", + "converter-configurations": "Pārveidotāja konfigurācija", + "token": "Drošības tokens", + "add-converter": "Pievienot pārveidotāju", + "add-config": "Pievienot pārveidotāja konfigurāciju", + "device-name-expression": "Iekārtas nosaukuma izteiksme", + "device-type-expression": "Iekārtas tipa izteiksme", + "custom": "Pielāgot", + "to-double": "Dubutot", + "transformer": "Pārveidotājs", + "json-required": "json pārveidotājs ir nepieciešams.", + "json-parse": "Nav iespējams parsēt json pārveidotāju.", + "attributes": "Atribūti", + "add-attribute": "Pievienot atribūtus", + "add-map": "Pievienot kartēšanas elementu", + "timeseries": "Laika sērijas", + "add-timeseries": "Pievienot laika sērijas", + "field-required": "Lauks ir nepieciešams", + "brokers": "Brokeris", + "add-broker": "Pievienot brokeri", + "host": "Saimnieks", + "port": "Ports", + "port-range": "Portam jābūt robežās no 1 līdz 65535.", + "ssl": "Ssl", + "credentials": "Akreditācijas dati", + "username": "Lietotājvārds", + "password": "Parole", + "retry-interval": "Mēģināt vēlreiz intervāls milisekundēs", + "anonymous": "Anonīmi", + "basic": "Pamata", + "pem": "PEM", + "ca-cert": "CA sertifikācijas fails *", + "private-key": "Privātās atslēgas fails *", + "cert": "Srtifikāta fails *", + "no-file": "Nav fails izvēlēts.", + "drop-file": "Nosviest failu vai klikšķināt izvēlēto failu augšupielādei.", + "mapping": "Kartēšana", + "topic-filter": "Temata filtrs", + "converter-type": "Pārveidotāja tips", + "converter-json": "Json", + "json-name-expression": "Iekārtas nosaukuma json izteiksme", + "topic-name-expression": "Iekārtas nosaukuma temata izteiksme", + "json-type-expression": "Iekārtas tipa json izteiksme", + "topic-type-expression": "Iekārtas tipa temata izteiksme", + "attribute-key-expression": "Atribūtu atslēgas izteiksme", + "attr-json-key-expression": "Atribūtu atslēgas json izteiksme", + "attr-topic-key-expression": "Attribūtu atslēgas temata izteiksme", + "request-id-expression": "Pieprasīt id izteiksmi", + "request-id-json-expression": "Pieprasīt id json izteiksmi", + "request-id-topic-expression": "Pieprasīt id temata izteiksmi", + "response-topic-expression": "Atbildēt temata izteiksmi", + "value-expression": "Vērtības izteiksme", + "topic": "Temats", + "timeout": "Pārtraukums milisekundēs", + "converter-json-required": "json pārveidotājs ir nepieciešams.", + "converter-json-parse": "Nav iespējams parsēt pārveidotāju json.", + "filter-expression": "Filtra izteiksme", + "connect-requests": "Savienot pieprasījumus", + "add-connect-request": "Pievienot savienojuma pieprasījumus", + "disconnect-requests": "Atvienot pieprasījumus", + "add-disconnect-request": "Pievienot atvienot pieprasījumus", + "attribute-requests": "Attribūtu pieprasījumus", + "add-attribute-request": "Pievienot atribūtu pieprasījumu", + "attribute-updates": "Atribūtu atjaunojumi", + "add-attribute-update": "Pievienot atribūtu atjaunojumus", + "server-side-rpc": "Servera puses RPC", + "add-server-side-rpc-request": "Pievienot servera puses RPC pieprasījumus", + "device-name-filter": "Iekārtas nosaukuma filtrs", + "attribute-filter": "Atribūtu filtrs", + "method-filter": "Metodes filtrs", + "request-topic-expression": "Pieprasīt temata izteiksmi", + "response-timeout": "Atbildes pārtraukums milisekundēs", + "topic-expression": "Temata izteiksme", + "client-scope": "Klienta darbības joma", + "add-device": "Pievienot iekārtu", + "opc-server": "Serveris", + "opc-add-server": "Pievienot serveri", + "opc-add-server-prompt": "Lūdzu pievienot serveri", + "opc-application-name": "Aplikācijas nosaukums", + "opc-application-uri": "Aplikācijas uri", + "opc-scan-period-in-seconds": "Skanēt periodu sekundēs", + "opc-security": "Drošība", + "opc-identity": "Identitāte", + "opc-keystore": "Atslēgu veikals", + "opc-type": "Tips", + "opc-keystore-type": "Tips", + "opc-keystore-location": "Vieta *", + "opc-keystore-password": "Parole", + "opc-keystore-alias": "Segvārds", + "opc-keystore-key-password": "Atslēgas parole", + "opc-device-node-pattern": "Iekārtas nodes veids", + "opc-device-name-pattern": "Iekārtas nosaukuma veids", + "modbus-server": "Serveris/vergs", + "modbus-add-server": "Pievienot serveri/vergi", + "modbus-add-server-prompt": "Lūdzu pievienot serveri/vergu", + "modbus-transport": "Transports", + "modbus-tcp-reconnect": "Automātiski atkārtoti savienot", + "modbus-rtu-over-tcp": "RTU pa TCP", + "modbus-port-name": "Seriālā porta nosaukums", + "modbus-encoding": "Kodēšana", + "modbus-parity": "Paritāte", + "modbus-baudrate": "Pārraides ātrums", + "modbus-databits": "Datu bits", + "modbus-stopbits": "Stop bits", + "modbus-databits-range": "Datu bitiem jābūt no 7 līdz 8.", + "modbus-stopbits-range": "Stop bitiem jābūt no 1 līdz 2.", + "modbus-unit-id": "Iekārtas ID", + "modbus-unit-id-range": "Iekārtas ID jābūt no 1 līdz 247.", + "modbus-device-name": "Iekārtas nosaukums", + "modbus-poll-period": "Aptaujas periods (ms)", + "modbus-attributes-poll-period": "Atribūtu aptaujas periods (ms)", + "modbus-timeseries-poll-period": "Laika sērijas aptaujas periods (ms)", + "modbus-poll-period-range": "Aptaujas periodam jābūt pzitīvai vērtībai.", + "modbus-tag": "Etiķete", + "modbus-function": "Funkcija", + "modbus-register-address": "Reģistra adrese", + "modbus-register-address-range": "Reģistra adresei jābūt no 0 līdz 65535.", + "modbus-register-bit-index": "Bita indekss", + "modbus-register-bit-index-range": "Bita indeksam jābūt no 0 līdz 15.", + "modbus-register-count": "Reģistra skaitītājs", + "modbus-register-count-range": "Reģistra skaitītājam jābūt pzitīvai vērtībai.", + "modbus-byte-order": "Baitu kārtība", + "sync": { + "status": "Statuss", + "sync": "Sync", + "not-sync": "Nav sync", + "last-sync-time": "Pēdējais sync laiks", + "not-available": "Nav pieejams" + }, + "export-extensions-configuration": "Eksportēt paplašinājuma konfigurāciju", + "import-extensions-configuration": "Importēt paplašinājuma konfigurāciju", + "import-extensions": "Importēt paplašinājumus", + "import-extension": "Importēt paplašinājumu", + "export-extension": "Eksportēt paplašinājumu", + "file": "Paplašinājuma fails", + "invalid-file-error": "Invalīds paplašinājuma fails" + }, + "fullscreen": { + "expand": "Paplašināt uz pilnu ekrānu", + "exit": "Iziet no pilna ekrāna", + "toggle": "Pārslēgties uz pilna ekrāna režīmu", + "fullscreen": "Pilns ekrāns" + }, + "function": { + "function": "Funkcija" + }, + "grid": { + "delete-item-title": "Vai esat pārliecināts, ka vēlaties dzēst šo priekšmetu?", + "delete-item-text": "Esiet uzmanīgs, pēc apstiprinājuma šī priekšmeta dati nebūs atjaunojami.", + "delete-items-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 item} other {# priekšmetus} }?", + "delete-items-action-title": "Dzēst { count, plural, 1 {1 item} other {# priekšmeti} }", + "delete-items-text": "Esiet uzmanīgs, pēc apstiprinājuma visi atlasītie priekšmeti tiks noņemti un to saistītie dati nebūs atjaunojami.", + "add-item-text": "Pievienot jaunu priekšmetu", + "no-items-text": "Nav priekšmeti atrasti", + "item-details": "Priekšmetu detaļas", + "delete-item": "Dzēst priekšmetu", + "delete-items": "Dzēst priekšmetus", + "scroll-to-top": "Iet uz sākumu" + }, + "help": { + "goto-help-page": "Ejiet uz palīdzības lapu" + }, + "home": { + "home": "Sākums", + "profile": "Profils", + "logout": "Izlogoties", + "menu": "Izvēlne", + "avatar": "Avatars", + "open-user-menu": "Atvērt lietotāja izvēlni" + }, + "import": { + "no-file": "Nav fails izvēlēts", + "drop-file": "Nosviest JSON failu vai klikšķināt uz atlasīto failu augšupielādei.", + "drop-file-csv": "Nosviest CSV failu vai klikšķināt uz atlasīto failu augšupielādei.", + "column-value": "Vērtība", + "column-title": "Virsraksts", + "column-example": "Piemēra vērtību dati", + "column-key": "Atribūts/telemetrijas atslēga key", + "csv-delimiter": "CSV kolonu atdalītājs", + "csv-first-line-header": "Pirmā līnija satur kolonu nosaukumus", + "csv-update-data": "Atjaunot atribūtu/telemetrija", + "import-csv-number-columns-error": "Failā jābūt vismaz divām kolonām", + "import-csv-invalid-format-error": "Invalīds faila formāts. Līnija: '{{line}}'", + "column-type": { + "name": "Nosaukums", + "type": "Tips", + "column-type": "Kolonas tips", + "client-attribute": "Klienta atribūts", + "shared-attribute": "Dalītais atribūts", + "server-attribute": "Servera atribūts", + "timeseries": "Laika sērijas", + "entity-field": "Vienības lauks", + "access-token": "Piekļuves tokens" + }, + "stepper-text": { + "select-file": "Atlasīt failu", + "configuration": "Importēt konfigurāciju", + "column-type": "Atlasīt kolonas tipu", + "creat-entities": "Radīt jaunas vienības" + }, + "message": { + "create-entities": "{{count}} jaunas vienības sekmīgi radītas.", + "update-entities": "{{count}} vienības sekmīgi atjaunotas.", + "error-entities": "Te ir kļūda radot {{count}} vienības." + } + }, + "item": { + "selected": "Atlasīts" + }, + "js-func": { + "no-return-error": "Funkcijai vajag atgriezt rezultātu!", + "return-type-mismatch": "Funkcijai vajag atgriezt rezultātu '{{type}}' !", + "tidy": "Sakopt" + }, + "key-val": { + "key": "Atslēga", + "value": "Vērtība", + "remove-entry": "Noņemt ierakstu", + "add-entry": "Pievienot ierakstu", + "no-data": "Nav ierakstu" + }, + "layout": { + "layout": "Izkārtojums", + "manage": "Pārvaldīt izkārtojumu", + "settings": "Izkārtojuma iestatījumi", + "color": "Krāsa", + "main": "Galvenais", + "right": "Pa labi", + "select": "Atlasīt mērķa izkārtojumu" + }, + "legend": { + "direction": "Leģendas virziens", + "position": "Leģendas pozīcija", + "show-max": "Rādīt max vērtību", + "show-min": "Rādīt min vērtību", + "show-avg": "Rādīt vidējo vērtību", + "show-total": "Rādīt kopējo vērtību", + "settings": "Leģendas iestatījumi", + "min": "min", + "max": "max", + "avg": "vidējais", + "total": "total" + }, + "login": { + "login": "Login", + "request-password-reset": "Pieprasīt atiestatīt paroli", + "reset-password": "Atiestatīt paroli", + "create-password": "Radīt paroli", + "passwords-mismatch-error": "Ievadītajai parolei ir jāsakrīt!", + "password-again": "Atkārtot paroli", + "sign-in": "Lūdzu pierakstīties", + "username": "Lietotājvārds (email)", + "remember-me": "Atcerēties mani", + "forgot-password": "Aizmirsu paroli?", + "password-reset": "Paroli atiestatīt", + "new-password": "Jaunā parole", + "new-password-again": "Atkārtot jauno paroli", + "password-link-sent-message": "paroles atiestatīšanas saite sekmīgi nosūtīta!", + "email": "Email" + }, + "position": { + "top": "Sākums", + "bottom": "Beigas", + "left": "Pa kreisi", + "right": "Pa labi" + }, + "profile": { + "profile": "Profils", + "change-password": "Mainīt paroli", + "current-password": "Patreizējā parole" + }, + "relation": { + "relations": "Attiecības", + "direction": "Virziens", + "search-direction": { + "FROM": "No", + "TO": "Uz" + }, + "direction-type": { + "FROM": "No", + "TO": "Uz" + }, + "from-relations": "Izejošāsa attiecības", + "to-relations": "Ienākošās attiecības", + "selected-relations": "{ count, plural, 1 {1 relation} other {# attiecības} } atlasītas", + "type": "Tips", + "to-entity-type": "Uz vienību tipu", + "to-entity-name": "Uz vienību nosaukumu", + "from-entity-type": "No vienību tipa", + "from-entity-name": "No vienību nosaukuma", + "to-entity": "Uz vienību", + "from-entity": "No vienības", + "delete": "Dzēst attiecību", + "relation-type": "Attiecības tips", + "relation-type-required": "Attiecību tips ir nepieciešams.", + "any-relation-type": "Jebkura tipa", + "add": "Pievienot attiecību", + "edit": "Rediģēt attiecību", + "delete-to-relation-title": "Vai esat pārliecināts, ka vēlaties dzēst attiecību uz vienību '{{entityName}}'?", + "delete-to-relation-text": "Esiet uzmanīgs, pēc apstiprinājuma vienība '{{entityName}}' būs atsaistīta no patreizējās vienības.", + "delete-to-relations-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 relation} other {# attiecības} }?", + "delete-to-relations-text": "Esiet uzmanīgs, pēc apstiprināšanas visas atlasītās attiecības būs noņemtas un attiecīgās vienības būs atsaistītas no patreizējās vienības.", + "delete-from-relation-title": "Vai esat pārliecināts, ka vēlaties dzēst attiecību no vienības '{{entityName}}'?", + "delete-from-relation-text": "Esiet uzmanīgs, pēc apstiprinājuma patreizējā vienība būs atsaistīta no vienības '{{entityName}}'.", + "delete-from-relations-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 relation} other {# attiecības} }?", + "delete-from-relations-text": "Esiet uzmanīgs, pēc apstiprinājuma visas atlasītās attiecības būs noņemtas un patreizējā vienība tiks atsaistīta no attiecīgās vienības.", + "remove-relation-filter": "Noņemt attiecību filtru", + "add-relation-filter": "Pievienot attiecību filtru", + "any-relation": "Jebkura attiecība", + "relation-filters": "Attiecību filtrs", + "additional-info": "Papildus info (JSON)", + "invalid-additional-info": "Nav iespēja parsēt papildus info json." + }, + "rulechain": { + "rulechain": "Noteikumu ķēde", + "rulechains": "Noteikumu ķēdes", + "root": "Sakne", + "delete": "Dzēsts noteikumu ķēdi", + "name": "Nosaukums", + "name-required": "Nosaukums ir nepieciešams.", + "description": "Nosaukums ir nepieciešams", + "add": "Pievienot noteikumu ķēdi", + "set-root": "Veidot noteikumu ķēdi kā sakni", + "set-root-rulechain-title": "Vai esat pārliecināts, ka vēlaties veidot noteikumu ķēdi '{{ruleChainName}}' kā sakni?", + "set-root-rulechain-text": "Pēc apstiprinājuma noteikuma ķēde tiks veidota kā sakne un apstrādās visu ienākošo informāciju.", + "delete-rulechain-title": "Vai esat pārliecināts, ka vēlaties dzēst noteikumu ķēdi '{{ruleChainName}}'?", + "delete-rulechain-text": "Esiet uzmanīgs, pēc apstiprinājuma noteikumu ķēde un saistītā informācija nebūs atjaunojama.", + "delete-rulechains-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 rule chain} other {# noteikumu ķēdes} }?", + "delete-rulechains-action-title": "Dzēst { count, plural, 1 {1 rule chain} other {# noteikumu ķēdes} }", + "delete-rulechains-text": "Esiet uzmanīgs, pēc apstiprinājuma visas atlasītās noteikumu ķēdes tiks noņemtas un to saistītos datus nevarēs atjaunot.", + "add-rulechain-text": "Pievienot jaunu noteikumu ķēdi", + "no-rulechains-text": "Nav noteikumu ķēdes atrastas", + "rulechain-details": "Noteikumu ķēdes detaļas", + "details": "Detaļas", + "events": "Notikumi", + "system": "Sistēma", + "import": "Importēt noteikumu ķēdi", + "export": "Eksportēt noteikumu ķēdi", + "export-failed-error": "Nav iespējams eksportēt noteikumu ķēdi: {{error}}", + "create-new-rulechain": "Radīt jaunu noteikumu ķēdi", + "rulechain-file": "Noteikumu ķēdes fails", + "invalid-rulechain-file-error": "Nav iespējams importēt noteikumu ķēdi: Invalīda noteikumu ķēdes datu struktūra.", + "copyId": "Kopēt noteikumu ķēdes Id", + "idCopiedMessage": "Noteikumu ķēdes Id ir kopēta uz starpliktuves", + "select-rulechain": "Atlasīt noteikumu ķēdi", + "no-rulechains-matching": "Nav noteikumu ķēdes atbilstības '{{entity}}' atrastas.", + "rulechain-required": "Noteikumu ķēde ir nepieciešama", + "management": "Noteikumu pārvaldība", + "debug-mode": "Atkļūdošanas režīms" + }, + "rulenode": { + "details": "Detaļas", + "events": "Notikumi", + "search": "Meklēt nodes", + "open-node-library": "Atvērt node bibliotēku", + "add": "Pievienot noteikumu nodi", + "name": "Nosaukums", + "name-required": "Nosaukums ir nepieciešams.", + "type": "Tips", + "description": "Apraksts", + "delete": "Dzēst noteikumu nodi", + "select-all-objects": "Atlasīt visas nodes un savienojumus", + "deselect-all-objects": "Noņemt visas nodes un savienojumus", + "delete-selected-objects": "Dzēst atlasītās nodes un savienojumus", + "delete-selected": "Dzēst atlasītos", + "select-all": "Atlasīt visu", + "copy-selected": "Kopēt atlasīto", + "deselect-all": "Noņemt visu", + "rulenode-details": "Noteikumu nodes detaļas", + "debug-mode": "Atkļūdošanas mode", + "configuration": "Konfigurācija", + "link": "Saite", + "link-details": "Noteikumu nodes saites detaļas", + "add-link": "Pievienot saiti", + "link-label": "Saites etiķete", + "link-label-required": "Saites etiķete ir nepieciešama.", + "custom-link-label": "Klienta saites etiķete", + "custom-link-label-required": "Klienta saites etiķete ir nepieciešama.", + "link-labels": "Saites etiķetes", + "link-labels-required": "Saites etiķetes ir nepieciešamas.", + "no-link-labels-found":"Nav saites etiķetes atrastas", + "no-link-label-matching": "'{{label}}' nav atrasta.", + "create-new-link-label": "Radīt jaunu!", + "type-filter": "Filtrs", + "type-filter-details": "Filtrēt ienākošos ziņojumus ar konfigurētajiem stāvokļiem", + "type-enrichment": "Bagātināšana", + "type-enrichment-details": "Pievieno papildus informāciju ziņas metadatiem", + "type-transformation": "Transformācija", + "type-transformation-details": "Mainīt ziņas datu lauku un metatdatus", + "type-action": "Aktivitāte", + "type-action-details": "Veikt specifisku aktivitāti", + "type-external": "Ārējs", + "type-external-details": "Sadarboties ar ārējām sistēmām", + "type-rule-chain": "Noteikumu ķēde", + "type-rule-chain-details": "Pārsūta ienākošo ziņu uz specifisku noteikumu ķēdi ", + "type-input": "Ievads", + "type-input-details": "Loģiskais ievads noteikumu ķēdei, pārsūta ienākošās ziņas uz nākamo attiecināto noteikumu nodi", + "type-unknown": "Nezināms", + "type-unknown-details": "Neatrisināta noteikumu node", + "directive-is-not-loaded": "Noteikta konfigurācijas direktīva '{{directiveName}}' nav pieejama.", + "ui-resources-load-error": "Nesekmīgs mēģinājums ielādēt konfigurācijas ui resursus.", + "invalid-target-rulechain": "Nav iespējams atrisināt mērķa noteikumu ķēdi!", + "test-script-function": "Testēt skripta funkciju", + "message": "Ziņa", + "message-type": "Ziņas tips", + "select-message-type": "Atlasīt ziņas tipu", + "message-type-required": "Ziņas tips ir nepieciešams", + "metadata": "Metadati", + "metadata-required": "Metadatu ievadi nevar būt tukši.", + "output": "Izeja", + "test": "Tests", + "help": "Palīdzība", + "reset-debug-mode": "Atiestatīt atkļūdošanu visās nodēs" + }, + "tenant": { + "tenant": "Īrnieks", + "tenants": "Īrnieki", + "management": "Īrnieku pārvaldība", + "add": "Pievienot īrnieku", + "admins": "Administrātori", + "manage-tenant-admins": "Pārvaldīt īrnieku administrātorus", + "delete": "Dzēst īrnieku", + "add-tenant-text": "Pievienot jaunu īrnieku", + "no-tenants-text": "Nav īrnieki atrasti", + "tenant-details": "Īrnieka detaļas", + "delete-tenant-title": "Vai esat pārliecināts, ka vēlaties dzēst īrnieku '{{tenantTitle}}'?", + "delete-tenant-text": "Esiet uzmanīgs, pēc apstiprinājuma īrnieks un tā saistītie dati nebūs atjaunojami.", + "delete-tenants-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 tenant} other {# īrniekus} }?", + "delete-tenants-action-title": "Dzēst { count, plural, 1 {1 tenant} other {# īrniekus} }", + "delete-tenants-text": "Esiet uzmanīgs, pēc apstiprinājuma visi atlasītie īrnieki tiks noņemti un saistītie dati nebūs atjaunojami.", + "title": "Virsraksts", + "title-required": "Virsraksts ir nepieciešams.", + "description": "Apraksts", + "details": "Detaļas", + "events": "Notikumi", + "copyId": "Kopēt īrnieka Id", + "idCopiedMessage": "Īrnieka Id ir kopēta uz starpliktuvi", + "select-tenant": "Atlasīt īrnieku", + "no-tenants-matching": "Nav īrnieku saderības '{{entity}}' atrastas.", + "tenant-required": "Īrnieks ir nepieciešams" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# sekundes} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minūtes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# stundas} }", + "days-interval": "{ days, plural, 1 {1 day} other {# dienas} }", + "days": "Dienas", + "hours": "Stundas", + "minutes": "Minūtes", + "seconds": "Sekundes", + "advanced": "Pieredzējis lietotājs" + }, + "timewindow": { + "days": "{ days, plural, 1 { day } other {# dienas } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# stundas } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minūtes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# sekundes } }", + "realtime": "Reālajā laikā", + "history": "Vēsture", + "last-prefix": "Pēdējās", + "period": "No {{ startTime }} to {{ endTime }}", + "edit": "Rediģēt laika logu", + "date-range": "Datumu diapazons", + "last": "Pēdējās", + "time-period": "Laika periods" + }, + "user": { + "user": "Lietotājs", + "users": "Lietotāji", + "customer-users": "Klienta lietotāji", + "tenant-admins": "Īrnieka administrātori", + "sys-admin": "Sistēmas administrātori", + "tenant-admin": "Īrnieka administrātors", + "customer": "Klients", + "anonymous": "Anonīmi", + "add": "Pievienot lietotāju", + "delete": "Dzēst lietotāju", + "add-user-text": "Pievienot jaunu lietotāju", + "no-users-text": "Nav lietotāji atrasti", + "user-details": "Lietotāja detaļas", + "delete-user-title": "Vai esat pārliecināts, ka vēlaties dzēst lietotāju '{{userEmail}}'?", + "delete-user-text": "Esiet uzmanīgs, pēc apstiprinājuma lietotājs un tā saistītie dati nebūs atjaunojami.", + "delete-users-title": "Vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 user} other {# lietotājus} }?", + "delete-users-action-title": "Dzēst { count, plural, 1 {1 user} other {# lietotājus} }", + "delete-users-text": "Esiet uzmanīgs, pēc apstiprinājuma visi atlasītie lietotāji tiks noņemti un to saistītie dati nebūs atjaunojami.", + "activation-email-sent-message": "Aktivizācijas email ir sekmīgi nosūtīts!", + "resend-activation": "Atkārtoti nosūtīt aktivizāciju", + "email": "Email", + "email-required": "Email ir nepieciešams.", + "invalid-email-format": "Invalīds email formāts.", + "first-name": "Vārds", + "last-name": "Uzvārds", + "description": "Apraksts", + "default-dashboard": "Defaultais panelis", + "always-fullscreen": "Vienmēr pilnekrāna", + "select-user": "Izvēlēties lietotāju", + "no-users-matching": "Nav lietotāju atbilstības '{{entity}}' atrastas.", + "user-required": "Lietotājs ir nepieciešams", + "activation-method": "Aktivizācijas veids", + "display-activation-link": "Parādīt aktivizācijas saiti", + "send-activation-mail": "Nosūtīt aktivizācijas email", + "activation-link": "Lietotāja aktivizācijas saite", + "activation-link-text": "Lai aktivizētu lietotāju, lieto sekojošo aktivizācijas saiti :", + "copy-activation-link": "Kopēt aktivizācijas saiti", + "activation-link-copied-message": "Lietotāja aktivizācijas saite ir kopēta uz starpliktuvi", + "details": "Detaļas", + "login-as-tenant-admin": "Login kā īrnieka administrātors", + "login-as-customer-user": "Login kā klienta lietotājs" + }, + "value": { + "type": "Vērtības tips", + "string": "Teksts", + "string-value": "Teksta informācija", + "integer": "Skaitlis", + "integer-value": "Skaitļa vērtība", + "invalid-integer-value": "Invalīda skaitļa vērtība", + "double": "Skaitlis ar cipariem aiz komata", + "double-value": "Skaitļa ar cipariem aiz komata vērtība", + "boolean": "ir/nav", + "boolean-value": "ir/nav vērtības", + "false": "Nepareizi", + "true": "Patiesi", + "long": "Ilgāk" + }, + "widget": { + "widget-library": "Logrīku bibliotēka", + "widget-bundle": "Logrīku apkopojums", + "select-widgets-bundle": "Atlasīt logrīku apkopojumu", + "management": "Logrīku pārvaldība", + "editor": "Logrīku rediģētājs", + "widget-type-not-found": "Problēma ielādēt logrīka konfigurāciju.
    Iespējams, ka attiecīgais logrīka tips ir noņemts.", + "widget-type-load-error": "Logrīks nav ielādēts dēl sekojošajām kļūdām:", + "remove": "Noņemt logrīku", + "edit": "rediģēt logrīku", + "remove-widget-title": "Vai esat pārliecināts, ka vēlaties noņemt logrīku '{{widgetTitle}}'?", + "remove-widget-text": "Pēc apstiprinājuma logrīks un tā saistītā informācija nebūs atjaunojama.", + "timeseries": "Laika sērijas", + "search-data": "Meklēt datus", + "no-data-found": "Nav datu atrasti", + "latest": "Pedējās vērtības", + "rpc": "Kontroles logrīks", + "alarm": "Trauksmes logrīks", + "static": "Statiskais logrīks", + "select-widget-type": "Atlasīt logrīka tipu", + "missing-widget-title-error": "Logrīka virsrakstam vajag būt norādītam!", + "widget-saved": "Logrīks saglabāts", + "unable-to-save-widget-error": "Nav iespēja saglabāt logrīku! Logrīkam ir kļūdas!", + "save": "Saglabāt logrīku", + "saveAs": "Saglabāt logrīku kā", + "save-widget-type-as": "Saglabāt logrīka tipu kā", + "save-widget-type-as-text": "Lūdzu ievadīt jaunu logrīka virsrakstu un/vai atlasīt mērķa logrīku apkopojumu", + "toggle-fullscreen": "Parslēgt pilnekrānu", + "run": "Palaist logrīku", + "title": "Logrīka virsraksts", + "title-required": "Logrīka virsraksts ir nepieciešams.", + "type": "Logrīka tips", + "resources": "Resursi", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Noņemt resursus", + "add-resource": "Pievienot resursus", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Iestatījumu shēma", + "datakey-settings-schema": "Datu atslēgas iestatījumu shēma", + "javascript": "Javascript", + "remove-widget-type-title": "Vai esat pārliecināts, ka vēlaties noņemt logrīka tipu '{{widgetName}}'?", + "remove-widget-type-text": "Pēc apstiprinājuma logrīka tips un tā saistītie dati nebūs atjaunojami.", + "remove-widget-type": "Noņemt logrīka tipu", + "add-widget-type": "Pievienot jaunu logrīka tipu", + "widget-type-load-failed-error": "Neveiksme ielādēt logrīka tipu!", + "widget-template-load-failed-error": "Neveiksme ielādēt logrīka paraugu!", + "add": "Pievienot logrīku", + "undo": "Atcelt logrīka izmaiņas", + "export": "Eksportēt logrīku" + }, + "widget-action": { + "header-button": "Logrīka galvenes poga", + "open-dashboard-state": "Navigēt uz jaunu paneļa stāvokli", + "update-dashboard-state": "Atjaunot patreizējo paneļa stāvokli", + "open-dashboard": "Navigēt uz citu paneli", + "custom": "Klienta aktivitāte", + "target-dashboard-state": "Mērķa paneļa stāvoklis", + "target-dashboard-state-required": "Mērķa paneļa stāvoklis ir nepieciešams", + "set-entity-from-widget": "Uzstādīt vienību no logrīka", + "target-dashboard": "Mērķa panelis", + "open-right-layout": "Atvērt pareizo paneļa izkārtojumu (mobilais skats)" + }, + "widgets-bundle": { + "current": "Patreizējais apkopojums", + "widgets-bundles": "Logrīku apkopojuma", + "add": "Pievienot logrīku apkopojumu", + "delete": "Dzēst logrīku apkopojumu", + "title": "Virsraksts", + "title-required": "Virsraksts ir nepieciešams.", + "add-widgets-bundle-text": "Pievienot jaunu logrīku apkopojumu", + "no-widgets-bundles-text": "Nav logrīku apkopojumi atrasti", + "empty": "Logrīku apkopojums ir tukšs", + "details": "Detaļas", + "widgets-bundle-details": "Logrīku apkopojumu detaļas", + "delete-widgets-bundle-title": "Vai esat pārliecināts, ka vēlaties dzēst logrīku apkopojumu '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Esiet uzmanīgs, pēc apstiprinājuma logrīka apkopojums un tā saistītie dati nebūs atjaunojami.", + "delete-widgets-bundles-title": "vai esat pārliecināts, ka vēlaties dzēst { count, plural, 1 {1 widgets bundle} other {# logrīku apkopojumus} }?", + "delete-widgets-bundles-action-title": "Dzēst { count, plural, 1 {1 widgets bundle} other {# logrīku apkopojumus} }", + "delete-widgets-bundles-text": "Esiet uzmanīgs, pēc apstiprinājuma visi atlasītie logrīku apkopojumi tiks noņemti un to saistītie dati nebūs atjaunojami.", + "no-widgets-bundles-matching": "Nav logrīku apkopojumu saderības '{{widgetsBundle}}' atrastas.", + "widgets-bundle-required": "Logrīku apkopojums ir nepieciešams.", + "system": "Sistēma", + "import": "Importēt logrīku apkopojumu", + "export": "Eksportēt logrīku apkopojumu", + "export-failed-error": "Nav iespējams eksportēt logrīku apkopojumu: {{error}}", + "create-new-widgets-bundle": "Radīt jaunu logrīku apkopojumu", + "widgets-bundle-file": "Logrīku apkopojumu fails", + "invalid-widgets-bundle-file-error": "Nav iespējams importēt logrīku apkopojumu: Invalīda logrīku apkopojuma datu struktūra." + }, + "widget-config": { + "data": "Dati", + "settings": "Iestatījumi", + "advanced": "Paaugstināta līmeņa", + "title": "Virsraksts", + "general-settings": "Pamata iestatījumi", + "display-title": "Rādīt virsrakstu", + "drop-shadow": "Nomest ēnas", + "enable-fullscreen": "Iespējot pilnekrānu", + "background-color": "Fona krāsa", + "text-color": "Teksta krāsa", + "padding": "Polsterējums", + "margin": "Robežas", + "widget-style": "Logrīka stils", + "title-style": "Virsraksta stils", + "mobile-mode-settings": "Mobilās modes iestatījumi", + "order": "Pasūtīt", + "height": "Augstums", + "units": "Papildus simbols vērtības atrādīšanai", + "decimals": "Ciparu skaits pēc komata", + "timewindow": "Laika logs", + "use-dashboard-timewindow": "Lietot paneļa laika logu", + "display-timewindow": "Rādīt laika logu", + "display-legend": "Rādīt leģendu", + "datasources": "Datu avoti", + "maximum-datasources": "Maksimums { count, plural, 1 {1 datasource is allowed.} other {# datu avoti atļautie} }", + "datasource-type": "Tips", + "datasource-parameters": "Parametri", + "remove-datasource": "Noņemt datu avotu", + "add-datasource": "Pievienot datu avotu", + "target-device": "Mērķa iekārta", + "alarm-source": "Trauksmes avots", + "actions": "Aktivitātes", + "action": "Aktivitātes", + "add-action": "Pievienot aktivitāti", + "search-actions": "Meklēt aktivitātes", + "action-source": "Aktivitāšu avots", + "action-source-required": "Aktivitāšu avoti ir nepieciešami.", + "action-name": "Nosaukums", + "action-name-required": "Aktitiāšu nosaukums ir nepieciešams.", + "action-name-not-unique": "Cita aktivitāte ar tādu pašu nosaukumu jau eksistē.
    Aktitivātes nosaukumam ir jābūt unikālam vienā aktivitātes avotā.", + "action-icon": "Ikona", + "action-type": "Tips", + "action-type-required": "Aktivitātes tips ir nepieciešams.", + "edit-action": "Rediģēt aktivitāti", + "delete-action": "Dzēst aktivitāti", + "delete-action-title": "Dzēst logrīka aktivitāti", + "delete-action-text": "Vai esat pārliecināts, ka vēlaties dzēst logrīka aktivitāti ar nosaukumu '{{actionName}}'?" + }, + "widget-type": { + "import": "Importēt logrīka tipu", + "export": "Eksportēt logrīka tipu", + "export-failed-error": "Nav iespējams eksportēt logrīka tipu: {{error}}", + "create-new-widget-type": "Radīt jaunu logrīka tipu", + "widget-type-file": "Logrīka tipa fails", + "invalid-widget-type-file-error": "Nav iespējams importēt logrīka tipu: Invalīda logrīka tipa datu struktūra." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Svētdiena", + "Mon": "Pirmdiena", + "Tue": "Otrdiena", + "Wed": "Trešdiena", + "Thu": "Ceturdiena", + "Fri": "Piekdiena", + "Sat": "Sestdiena", + "Jan": "Janvāris", + "Feb": "Februāris", + "Mar": "Marts", + "Apr": "Aprīlis", + "May": "Maijs", + "Jun": "Jūnijs", + "Jul": "Jūlijs", + "Aug": "Augusts", + "Sep": "Septembris", + "Oct": "Oktobris", + "Nov": "Novembris", + "Dec": "Decembris", + "January": "Janvāris", + "February": "Februāris", + "March": "Marts", + "April": "Aprīlis", + "June": "Jūnijs", + "July": "Jūlijs", + "August": "Augusts", + "September": "Septembris", + "October": "Oktobris", + "November": "Novembris", + "December": "Decembris", + "Custom Date Range": "Lietotāja datu diapazons", + "Date Range Template": "Datu diapazona templeits", + "Today": "Šodien", + "Yesterday": "Vakardien", + "This Week": "Šī nedēļa", + "Last Week": "Pēdējā nedēļa", + "This Month": "Šis mēnesis", + "Last Month": "Pēdējais mēnesis", + "Year": "Gads", + "This Year": "Šis gads", + "Last Year": "Pagājušais gads", + "Date picker": "Datu atlasītājs", + "Hour": "Stunda", + "Day": "Diena", + "Week": "Nedēļa", + "2 weeks": "2 Nedēļas", + "Month": "Mēnesis", + "3 months": "3 Mēneši", + "6 months": "6 Mēneši", + "Custom interval": "Klienta intervāls", + "Interval": "Intervāls", + "Step size": "Soļa lielums", + "Ok": "Ok" + } + } + }, + "icon": { + "icon": "Ikona", + "select-icon": "Atlasīt ikonas", + "material-icons": "Materiālu ikonas", + "show-all": "Rādīt visas ikonas" + }, + "custom": { + "widget-action": { + "action-cell-button": "Aktivitātes šunas poga", + "row-click": "Uz rindas klikšķis", + "polygon-click": "Uz daudzstūra klikšķis", + "marker-click": "Uz marķiera klikšķis", + "tooltip-tag-action": "Rīku padomu darbība", + "node-selected": "Uz atlasīto nodi", + "element-click": "HTML elementa klikšķis" + } + }, + "language": { + "language": "Valodas" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json new file mode 100644 index 0000000..78a81f4 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -0,0 +1,2045 @@ +{ + "access": { + "unauthorized": "Não autorizado", + "unauthorized-access": "Acesso não autorizado", + "unauthorized-access-text": "Para acessar este recurso, você precisa se inscrever!", + "access-forbidden": "Acesso proibido", + "access-forbidden-text": "Você não tem direito de acesso a esta localização!
    Se ainda deseja acessar esta localização, tente inscrever-se com um nome de usuário diferente.", + "refresh-token-expired": "A sessão expirou", + "refresh-token-failed": "Impossível atualizar a sessão", + "permission-denied": "Permissão negada", + "permission-denied-text": "Você não tem permissão para executar esta operação!" + }, + "action": { + "activate": "Ativar", + "suspend": "Suspender", + "save": "Salvar", + "saveAs": "Salvar como", + "cancel": "Cancelar", + "ok": "OK", + "delete": "Excluir", + "add": "Adicionar", + "yes": "Sim", + "no": "Não", + "update": "Atualizar", + "remove": "Remover", + "select": "Selecionar", + "search": "Pesquisar", + "clear-search": "Limpar pesquisa", + "assign": "Atribuir", + "unassign": "Cancelar atribuição", + "share": "Compartilhar", + "make-private": "Tornar privado", + "apply": "Aplicar", + "apply-changes": "Aplicar alterações", + "edit-mode": "Modo de edição", + "enter-edit-mode": "Entrar no modo de edição", + "decline-changes": "Rejeitar alterações", + "close": "Fechar", + "back": "Voltar", + "run": "Executar", + "sign-in": "Inscreva-se!", + "edit": "Editar", + "view": "Visualizar", + "create": "Criar", + "drag": "Arrastar", + "refresh": "Atualizar", + "undo": "Desfazer", + "copy": "Copiar", + "paste": "Colar", + "copy-reference": "Copiar referência", + "paste-reference": "Colar referência", + "import": "Importar", + "export": "Exportar", + "share-via": "Compartilhar via {{provider}}", + "continue": "Continuar", + "discard-changes": "Descartar alterações", + "download": "Download", + "done": "Concluído" + }, + "aggregation": { + "aggregation": "Agregação", + "function": "Função de agregação de dados", + "limit": "Valores máx.", + "group-interval": "Intervalo de agrupamento", + "min": "Mín.", + "max": "Máx.", + "avg": "Média", + "sum": "Soma", + "count": "Contagem", + "none": "Nenhum" + }, + "admin": { + "general": "Geral", + "general-settings": "Configuração geral", + "outgoing-mail": "Servidor de e-mail", + "outgoing-mail-settings": "Configuração do servidor de saída de e-mail", + "system-settings": "Configuração do sistema", + "test-mail-sent": "O e-mail de teste foi enviado corretamente!", + "base-url": "URL de base", + "base-url-required": "O URL de base é obrigatório.", + "mail-from": "E-mail de", + "mail-from-required": "'E-mail de' é obrigatório.", + "smtp-protocol": "Protocolo SMTP", + "smtp-host": "Host SMTP", + "smtp-host-required": "O host SMTP é obrigatório.", + "smtp-port": "Porta SMTP", + "smtp-port-required": "É necessário informar a porta SMPT.", + "smtp-port-invalid": "Esta não parece ser uma porta SMTP válida.", + "timeout-msec": "Tempo limite (ms)", + "timeout-required": "O tempo limite é obrigatório.", + "timeout-invalid": "Este não parece ser um tempo limite válido.", + "enable-tls": "Habilitar TLS", + "tls-version": "Versão de TLS", + "enable-proxy": "Habilitar proxy", + "proxy-host": "Host de proxy", + "proxy-host-required": "O host de proxy é obrigatório.", + "proxy-port": "Porta do proxy", + "proxy-port-required": "A porta do proxy é obrigatória.", + "proxy-port-range": "A porta do proxy deve estar em um intervalo de 1 a 65535.", + "proxy-user": "Usuário do proxy", + "proxy-password": "Senha do proxy", + "send-test-mail": "Enviar e-mail de teste", + "security-settings": "Configuração de segurança", + "password-policy": "Política de senhas", + "minimum-password-length": "Comprimento mínimo da senha", + "minimum-password-length-required": "O comprimento mínimo da senha é obrigatório", + "minimum-password-length-range": "O comprimento mínimo da senha deve estar em um intervalo de 5 a 50.", + "minimum-uppercase-letters": "Número mínimo de letras maiúsculas", + "minimum-uppercase-letters-range": "O número mínimo de letras maiúsculas não pode ser negativo", + "minimum-lowercase-letters": "Número mínimo de letras minúsculas", + "minimum-lowercase-letters-range": "O número mínimo de letras minúsculas não pode ser negativo", + "minimum-digits": "Número mínimo de dígitos", + "minimum-digits-range": "O número mínimo de dígitos não pode ser negativo", + "minimum-special-characters": "Número mínimo de caracteres especiais", + "minimum-special-characters-range": "O número mínimo de caracteres especiais não pode ser negativo", + "password-expiration-period-days": "Período de expiração de senha em dias", + "password-expiration-period-days-range": "O período de expiração de senha não pode ser negativo", + "password-reuse-frequency-days": "Frequência de reutilização de senha em dias", + "password-reuse-frequency-days-range": "A frequência em dias de reutilização da senha não pode ser negativa", + "general-policy": "Política geral", + "max-failed-login-attempts": "Número máximo de tentativas de login com falha antes de bloquear a conta", + "minimum-max-failed-login-attempts-range": "O número máximo de tentativas de login com falha não pode ser negativo", + "user-lockout-notification-email": "Em caso de bloqueio de conta do usuário, enviar notificação para o endereço de e-mail" + }, + "alarm": { + "alarm": "Alarme", + "alarms": "Alarmes", + "select-alarm": "Selecionar alarme", + "no-alarms-matching": "Nenhum alarme encontrado que coincida com '{{entity}}'.", + "alarm-required": "O alarme é obrigatório", + "alarm-status": "Status do alarme", + "alarm-status-list": "Lista de status de alarmes", + "any-status": "Qualquer status", + "search-status": { + "ANY": "Qualquer", + "ACTIVE": "Ativo", + "CLEARED": "Limpo", + "ACK": "Confirmado", + "UNACK": "Não confirmado" + }, + "display-status": { + "ACTIVE_UNACK": "Não confirmado ativo", + "ACTIVE_ACK": "Confirmado ativo", + "CLEARED_UNACK": "Não confirmado limpo", + "CLEARED_ACK": "Confirmado limpo" + }, + "no-alarms-prompt": "Nenhum alarme encontrado", + "created-time": "Hora de criação", + "type": "Tipo", + "severity": "Severidade", + "originator": "Originador", + "originator-type": "Tipo de originador", + "details": "Detalhes", + "status": "Status", + "alarm-details": "Detalhes do alarme", + "start-time": "Hora de início", + "end-time": "Hora de término", + "ack-time": "Horário de confirmação", + "clear-time": "Horário de limpeza", + "alarm-severity-list": "Lista de severidade de alarmes", + "any-severity": "Qualquer severidade", + "severity-critical": "Crítica", + "severity-major": "Principal", + "severity-minor": "Menor", + "severity-warning": "Aviso", + "severity-indeterminate": "Indeterminado", + "acknowledge": "Confirmar", + "clear": "Limpar", + "search": "Pesquisar alarmes", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } selecionado(s)", + "no-data": "Nenhum dado para exibição", + "polling-interval": "Intervalo seletivo de alarmes (s)", + "polling-interval-required": "O intervalo de chamada seletiva de alarmes é obrigatório.", + "min-polling-interval-message": "É permitido no mínimo 1 segundo de intervalo de chamada seletiva.", + "aknowledge-alarms-title": "Confirmar { count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "Tem certeza de que deseja confirmar { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Confirmar alarme", + "aknowledge-alarm-text": "Tem certeza de que deseja confirmar o alarme?", + "clear-alarms-title": "Limpar { count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "Tem certeza de que deseja limpar { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Limpar alarme", + "clear-alarm-text": "Tem certeza de que deseja limpar alarme?", + "alarm-status-filter": "Filtro de status de alarmes", + "alarm-filter": "Filtro de alarmes", + "max-count-load": "Número máximo de alarmes a serem carregados (0 - ilimitado)", + "max-count-load-required": "O número máximo de alarmes é obrigatório.", + "max-count-load-error-min": "O valor mínimo é 0.", + "fetch-size": "Tamanho da busca", + "fetch-size-required": "O tamanho da busca é obrigatório.", + "fetch-size-error-min": "O valor mínimo é 10.", + "alarm-type-list": "Lista de tipos de alarmes", + "any-type": "Qualquer tipo", + "search-propagated-alarms": "Pesquisar alarmes propagados" + }, + "alias": { + "add": "Adicionar alias", + "edit": "Editar alias", + "name": "Nome do alias", + "name-required": "O nome do alias é obrigatório", + "duplicate-alias": "Já existe um alias com o mesmo nome.", + "filter-type-single-entity": "Entidade individual", + "filter-type-entity-list": "Lista de entidades", + "filter-type-entity-name": "Nome da entidade", + "filter-type-state-entity": "Entidade do estado do dashboard", + "filter-type-state-entity-description": "Entidade obtida dos parâmetros de estado do dashboard", + "filter-type-asset-type": "Tipo de ativo", + "filter-type-asset-type-description": "Ativos do tipo '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Ativos do tipo '{{assetType}}' cujo nome começa com '{{prefix}}'", + "filter-type-device-type": "Tipo de dispositivo", + "filter-type-device-type-description": "Dispositivos do tipo '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispositivos do tipo '{{deviceType}}' cujo nome começa com '{{prefix}}'", + "filter-type-entity-view-type": "Tipo de exibições de entidades", + "filter-type-entity-view-type-description": "Exibições de entidades do tipo '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Exibições de entidades do tipo '{{entityView}}' cujo nome começa com '{{prefix}}'", + "filter-type-relations-query": "Consulta de relações", + "filter-type-relations-query-description": "{{entities}} que tem relação de {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Consulta de pesquisa de ativo", + "filter-type-asset-search-query-description": "Ativos dos tipos {{assetTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Consulta de pesquisa de dispositivo", + "filter-type-device-search-query-description": "Dispositivos dos tipos {{deviceTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Consulta de pesquisa de exibição de entidade", + "filter-type-entity-view-search-query-description": "Exibições de entidades dos tipos {{entityViewTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", + "entity-filter": "Filtro de entidades", + "resolve-multiple": "Resolver como entidades múltiplas", + "filter-type": "Tipo de filtro", + "filter-type-required": "O tipo de filtro é obrigatório.", + "entity-filter-no-entity-matched": "Nenhuma entidade encontrada que coincida com o filtro especificado.", + "no-entity-filter-specified": "Nenhum filtro de entidade especificado", + "root-state-entity": "Usar entidade de estado do dashboard como raiz", + "last-level-relation": "Procurar apenas último nível de relação", + "root-entity": "Entidade raiz", + "state-entity-parameter-name": "Nome do parâmetro de entidade de estado", + "default-state-entity": "Entidade de estado padrão", + "default-entity-parameter-name": "Predefinido", + "max-relation-level": "Nível máx. de relação", + "unlimited-level": "Nível ilimitado", + "state-entity": "Entidade de estado do dashboard", + "all-entities": "Todas as entidades", + "any-relation": "qualquer" + }, + "asset": { + "asset": "Ativo", + "assets": "Ativos", + "management": "Gerenciamento de ativos", + "view-assets": "Visualizar ativos", + "add": "Adicionar ativo", + "assign-to-customer": "Atribuir a cliente", + "assign-asset-to-customer": "Atribuir ativo(s) a cliente", + "assign-asset-to-customer-text": "Selecione os ativos a serem atribuídos ao cliente", + "no-assets-text": "Nenhum ativo encontrado", + "assign-to-customer-text": "Selecione o cliente para atribuir o(s) ativo(s)", + "public": "Público", + "assignedToCustomer": "Atribuído ao cliente", + "make-public": "Tornar ativo público", + "make-private": "Tornar ativo privado", + "unassign-from-customer": "Remover atribuição a cliente", + "delete": "Excluir ativo", + "asset-public": "Ativo é público", + "asset-type": "Tipo de ativo", + "asset-type-required": "O tipo de ativo é obrigatório.", + "select-asset-type": "Selecionar tipo de ativo", + "enter-asset-type": "Inserir tipo de ativo", + "any-asset": "Qualquer ativo", + "no-asset-types-matching": "Nenhum tipo de ativo encontrado que coincida com '{{entitySubtype}}'.", + "asset-type-list-empty": "Nenhum tipo de ativo selecionado.", + "asset-types": "Tipos de ativos", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "description": "Descrição", + "type": "Tipo", + "type-required": "O tipo é obrigatório.", + "details": "Detalhes", + "events": "Eventos", + "add-asset-text": "Adicionar novo ativo", + "asset-details": "Detalhes do ativo", + "assign-assets": "Atribuir ativos", + "assign-assets-text": "Atribuir { count, plural, 1 {1 asset} other {# assets} } a cliente", + "delete-assets": "Excluir ativos", + "unassign-assets": "Remover atribuição de ativos", + "unassign-assets-action-title": "Remover atribuição de { count, plural, 1 {1 asset} other {# assets} } a cliente", + "assign-new-asset": "Atribuir novo ativo", + "delete-asset-title": "Tem certeza de que deseja excluir o ativo '{{assetName}}'?", + "delete-asset-text": "Cuidado, após confirmar, não será possível recuperar o ativo e nenhum dado associado.", + "delete-assets-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 asset} other {# assets} }?", + "delete-assets-action-title": "Excluir { count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "Cuidado, após confirmar, todos os ativos selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "make-public-asset-title": "Tem certeza de que deseja tornar o ativo '{{assetName}}' público?", + "make-public-asset-text": "Após confirmar, o ativo e todos os dados associados a ele se tornarão públicos e poderão ser acessados por outros.", + "make-private-asset-title": "Tem certeza de que deseja tornar o ativo '{{assetName}}' privado?", + "make-private-asset-text": "Após confirmar, o ativo e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", + "unassign-asset-title": "Tem certeza de que deseja remover a atribuição do ativo '{{assetName}}'?", + "unassign-asset-text": "Após confirmar, a atribuição do ativo será removida e ele não poderá ser acessado pelo cliente.", + "unassign-asset": "Remover atribuição de ativo", + "unassign-assets-title": "Tem certeza de que deseja remover a atribuição de { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "Após confirmar, a atribuição de todos os ativos selecionados será removida e eles não poderão ser acessados pelo cliente.", + "copyId": "Copiar ID do ativo", + "idCopiedMessage": "O ID do ativo foi copiado para a área de transferência", + "select-asset": "Selecionar ativo", + "no-assets-matching": "Nenhum ativo encontrado que coincida com '{{entity}}'.", + "asset-required": "O ativo é obrigatório", + "name-starts-with": "O nome do ativo começa com", + "import": "Importar ativos", + "asset-file": "Arquivo de ativos", + "search": "Pesquisar ativo", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } selecionado(s)", + "label": "Etiqueta" + }, + "attribute": { + "attributes": "Atributos", + "latest-telemetry": "Última telemetria", + "attributes-scope": "Escopo de atributos de entidade", + "scope-latest-telemetry": "Última telemetria", + "scope-client": "Atributos do cliente", + "scope-server": "Atributos do servidor", + "scope-shared": "Atributos compartilhados", + "add": "Adicionar atributo", + "key": "Chave", + "last-update-time": "Horário da última atualização", + "key-required": "A chave de atributo é obrigatória.", + "value": "Valor", + "value-required": "O valor do atributo é obrigatório.", + "delete-attributes-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Cuidado, após confirmar, todos os atributos selecionados serão removidos.", + "delete-attributes": "Excluir atributos", + "enter-attribute-value": "Inserir valor do atributo", + "show-on-widget": "Mostrar no widget", + "widget-mode": "Modo de widget", + "next-widget": "Próximo widget", + "prev-widget": "Widget anterior", + "add-to-dashboard": "Adicionar ao dashboard", + "add-widget-to-dashboard": "Adicionar widget ao dashboard", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } selecionado(s)", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } selecionado(s)", + "no-attributes-text": "Nenhum atributo encontrado", + "no-telemetry-text": "Nenhuma telemetria encontrada" + }, + "audit-log": { + "audit": "Auditoria", + "audit-logs": "Logs de auditoria", + "timestamp": "Carimbo de data/hora", + "entity-type": "Tipo de entidade", + "entity-name": "Nome da entidade", + "user": "Usuário", + "type": "Tipo", + "status": "Status", + "details": "Detalhes", + "type-added": "Adicionado", + "type-deleted": "Excluído", + "type-updated": "Atualizado", + "type-attributes-updated": "Atributos atualizados", + "type-attributes-deleted": "Atributos excluídos", + "type-rpc-call": "Chamada RPC", + "type-credentials-updated": "Credenciais atualizadas", + "type-assigned-to-customer": "Atribuído ao cliente", + "type-unassigned-from-customer": "Atribuição a cliente removida", + "type-activated": "Ativado", + "type-suspended": "Suspenso", + "type-credentials-read": "Credenciais lidas", + "type-attributes-read": "Atributos lidos", + "type-relation-add-or-update": "Relação atualizada", + "type-relation-delete": "Relação excluída", + "type-relations-delete": "Todas as relações excluídas", + "type-alarm-ack": "Confirmado", + "type-alarm-clear": "Limpo", + "type-login": "Login", + "type-logout": "Logout", + "type-lockout": "Bloqueio", + "status-success": "Êxito", + "status-failure": "Falha", + "audit-log-details": "Detalhes da trilha de auditoria", + "no-audit-logs-prompt": "Nenhum log encontrado", + "action-data": "Dados da ação", + "failure-details": "Detalhes da falha", + "search": "Pesquisar logs de auditoria", + "clear-search": "Limpar pesquisa", + "type-assigned-from-tenant": "Atribuído do locatário", + "type-assigned-to-tenant": "Atribuído a locatário" + }, + "confirm-on-exit": { + "message": "Existem alterações sem salvar. Tem certeza de que deseja sair desta página?", + "html-message": "Existem alterações sem salvar.
    Tem certeza de que deseja sair desta página?", + "title": "Alterações sem salvar" + }, + "contact": { + "country": "País", + "city": "Cidade", + "state": "Estado", + "postal-code": "CEP / Código postal", + "postal-code-invalid": "Formato de código postal / CEP inválido.", + "address": "Endereço", + "address2": "Endereço 2", + "phone": "Telefone", + "email": "E-mail", + "no-address": "Em endereço" + }, + "common": { + "username": "Nome de usuário", + "password": "Senha", + "enter-username": "Inserir nome de usuário", + "enter-password": "Inserir senha", + "enter-search": "Inserir pesquisa", + "created-time": "Hora de criação", + "loading": "Carregando..." + }, + "content-type": { + "json": "Json", + "text": "Texto", + "binary": "Binário (Base64)" + }, + "customer": { + "customer": "Cliente", + "customers": "Clientes", + "management": "Gerenciamento de clientes", + "dashboard": "Dashboard de clientes", + "dashboards": "Dashboards de clientes", + "devices": "Dispositivos do cliente", + "entity-views": "Exibições de entidades do cliente", + "assets": "Ativos do cliente", + "public-dashboards": "Dashboards públicos", + "public-devices": "Dispositivos públicos", + "public-assets": "Ativos públicos", + "public-entity-views": "Exibições de entidades públicas", + "add": "Adicionar cliente", + "delete": "Excluir cliente", + "manage-customer-users": "Gerenciar usuários do cliente", + "manage-customer-devices": "Gerenciar dispositivos do cliente", + "manage-customer-dashboards": "Gerenciar dashboards do cliente", + "manage-public-devices": "Gerenciar dispositivos públicos", + "manage-public-dashboards": "Gerenciar dashboards públicos", + "manage-customer-assets": "Gerenciar ativos do cliente", + "manage-public-assets": "Gerenciar ativos públicos", + "add-customer-text": "Adicionar novo cliente", + "no-customers-text": "Nenhum cliente encontrado", + "customer-details": "Detalhes do cliente", + "delete-customer-title": "Tem certeza de que deseja excluir o ativo cliente '{{customerTitle}}'?", + "delete-customer-text": "Cuidado, após confirmar, não será possível recuperar o cliente e nenhum dado associado.", + "delete-customers-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Excluir { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Cuidado, após confirmar, todos os clientes selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "manage-users": "Gerenciar usuários", + "manage-assets": "Gerenciar ativos", + "manage-devices": "Gerenciar dispositivos", + "manage-dashboards": "Gerenciar dashboards", + "title": "Título", + "title-required": "O título é obrigatório.", + "description": "Descrição", + "details": "Detalhes", + "events": "Eventos", + "copyId": "Copiar ID de cliente", + "idCopiedMessage": "O ID do cliente foi copiado para a área de transferência", + "select-customer": "Selecionar cliente", + "no-customers-matching": "Nenhum cliente encontrado que coincida com '{{entity}}'.", + "customer-required": "O cliente é obrigatório", + "select-default-customer": "Selecionar cliente predefinido", + "default-customer": "Cliente predefinido", + "default-customer-required": "O cliente predefinido é necessário para depurar o dashboard no nível de locatário", + "search": "Pesquisar clientes", + "selected-customers": "{ count, plural, 1 {1 customer} other {# customers} } selecionado(s)" + }, + "datetime": { + "date-from": "Data de", + "time-from": "Hora de", + "date-to": "Data até", + "time-to": "Hora até" + }, + "dashboard": { + "dashboard": "Dashboard", + "dashboards": "Dashboards", + "management": "Gerenciamento de dashboards", + "view-dashboards": "Visualizar dashboards", + "add": "Adicionar dashboard", + "assign-dashboard-to-customer": "Atribuir dashboard(s) a cliente", + "assign-dashboard-to-customer-text": "Selecione os dashboards a serem atribuídos ao cliente", + "assign-to-customer-text": "Selecione o cliente para atribuir o(s) dashboard(s)", + "assign-to-customer": "Atribuir a cliente", + "unassign-from-customer": "Remover atribuição a cliente", + "make-public": "Tornar dashboards público", + "make-private": "Tornar dashboards privado", + "manage-assigned-customers": "Gerenciar clientes atribuídos", + "assigned-customers": "Clientes atribuídos", + "assign-to-customers": "Atribuir Dashboard(s) a Clientes", + "assign-to-customers-text": "Selecione os clientes para atribuir o(s) dashboard(s)", + "unassign-from-customers": "Remover Atribuição de Dashboard(s) a Clientes", + "unassign-from-customers-text": "Selecione os clientes para remover a atribuição do(s) dashboard(s)", + "no-dashboards-text": "Nenhum dashboard encontrado", + "no-widgets": "Nenhum widgets configurado", + "add-widget": "Adicionar novo widget", + "title": "Título", + "select-widget-title": "Selecionar widget", + "select-widget-subtitle": "Lista de tipos de widget disponíveis", + "delete": "Excluir dashboard", + "title-required": "O título é obrigatório.", + "description": "Descrição", + "details": "Detalhes", + "dashboard-details": "Detalhes do dashboard", + "add-dashboard-text": "Adicionar novo dashboard", + "assign-dashboards": "Atribuir dashboards", + "assign-new-dashboard": "Atribuir novo dashboard", + "assign-dashboards-text": "Atribuir { count, plural, 1 {1 dashboard} other {# dashboards} } a clientes", + "unassign-dashboards-action-text": "Remover atribuição de { count, plural, 1 {1 dashboard} other {# dashboards} } a clientes", + "delete-dashboards": "Excluir dashboards", + "unassign-dashboards": "Remover atribuição de dashboards", + "unassign-dashboards-action-title": "Remover atribuição de { count, plural, 1 {1 dashboard} other {# dashboards} } a cliente", + "delete-dashboard-title": "Tem certeza de que deseja excluir o dashboard '{{dashboardTitle}}'?", + "delete-dashboard-text": "Cuidado, após confirmar, não será possível recuperar o dashboard e nenhum dado associado.", + "delete-dashboards-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Excluir { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Cuidado, após confirmar, todos os dashboards selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "unassign-dashboard-title": "Tem certeza de que deseja remover a atribuição do dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Após confirmar, a atribuição do dashboard será removida e ele não poderá ser acessado pelo cliente.", + "unassign-dashboard": "Remover atribuição de dashboard", + "unassign-dashboards-title": "Tem certeza de que deseja remover a atribuição de { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Após confirmar, a atribuição de todos os dashboards selecionados será removida e eles não poderão ser acessados pelo cliente.", + "public-dashboard-title": "Agora o dashboard é público", + "public-dashboard-text": "Agora o dashboard {{dashboardTitle}} é público e pode ser acessado pelo próximo link público:", + "public-dashboard-notice": "Observação: Não se esqueça de tornar dispositivos relacionados públicos para poder acessar seus dados.", + "make-private-dashboard-title": "Tem certeza de que deseja tornar o dashboard '{{dashboardTitle}}' privado?", + "make-private-dashboard-text": "Após confirmar, o dashboard e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", + "make-private-dashboard": "Tornar dashboards privado", + "socialshare-text": "'{{dashboardTitle}}' da plataforma ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' da plataforma ThingsBoard", + "select-dashboard": "Selecionar dashboard", + "no-dashboards-matching": "Nenhum dashboard encontrado que coincida com '{{entity}}'.", + "dashboard-required": "O dashboard é obrigatório.", + "select-existing": "Selecionar dashboard existente", + "create-new": "Criar novo dashboard", + "new-dashboard-title": "Novo título de dashboard", + "open-dashboard": "Abrir dashboard", + "set-background": "Definir plano de fundo", + "background-color": "Cor de fundo", + "background-image": "Imagem de fundo", + "background-size-mode": "Modo de tamanho de fundo", + "no-image": "Nenhuma imagem selecionada", + "drop-image": "Solte uma imagem ou clique para selecionar um arquivo para carregar.", + "settings": "Configurações", + "columns-count": "Contagem de colunas", + "columns-count-required": "A contagem de colunas é obrigatória.", + "min-columns-count-message": "Somente é permitida uma contagem de no mínimo 10 colunas.", + "max-columns-count-message": "Somente é permitida uma contagem de no máximo 1000 colunas.", + "widgets-margins": "Margem entre widgets", + "margin-required": "O valor da margem é obrigatório.", + "min-margin-message": "Somente 0 é permitido como valor mínimo de margem.", + "max-margin-message": "Somente 50 é permitido como valor máximo de margem.", + "horizontal-margin": "Margem horizontal", + "horizontal-margin-required": "O valor da margem horizontal é obrigatório.", + "min-horizontal-margin-message": "Somente 0 é permitido como valor mínimo de margem horizontal.", + "max-horizontal-margin-message": "Somente 50 é permitido como valor máximo de margem horizontal.", + "vertical-margin": "Margem vertical", + "vertical-margin-required": "O valor da margem vertical é obrigatório.", + "min-vertical-margin-message": "Somente 0 é permitido como valor mínimo de margem vertical.", + "max-vertical-margin-message": "Somente 50 é permitido como valor máximo de margem vertical.", + "autofill-height": "Altura do layout de preenchimento automático", + "mobile-layout": "Configuração de layout móvel", + "mobile-row-height": "Altura de linha móvel, px", + "mobile-row-height-required": "O valor da altura de linha móvel é obrigatório.", + "min-mobile-row-height-message": "Somente 5 pixels é permitido como valor mínimo da altura de linha móvel.", + "max-mobile-row-height-message": "Somente 200 pixels é permitido como valor máximo da altura de linha móvel.", + "display-title": "Exibir título de dashboard", + "toolbar-always-open": "Manter barra de ferramentas aberto", + "title-color": "Cor do título", + "display-dashboards-selection": "Exibir seleção de dashboards", + "display-entities-selection": "Exibir seleção de entidades", + "display-filters": "Exibir filtros", + "display-dashboard-timewindow": "Exibir timewindow", + "display-dashboard-export": "Exibir exportação", + "import": "Importar dashboard", + "export": "Exportar dashboard", + "export-failed-error": "Impossível exportar dashboard: {{error}}", + "create-new-dashboard": "Criar novo dashboard", + "dashboard-file": "Arquivo de dashboard", + "invalid-dashboard-file-error": "Impossível importar dashboard: Estrutura de dados do dashboard inválida", + "dashboard-import-missing-aliases-title": "Configurar aliases usados por dashboard importado", + "create-new-widget": "Criar novo widget", + "import-widget": "Importar widget", + "widget-file": "Arquivo de widget", + "invalid-widget-file-error": "Impossível importar widget: Estrutura de dados do widget inválida.", + "widget-import-missing-aliases-title": "Configurar aliases usados por widget importado", + "open-toolbar": "Abrir barra de ferramentas de dashboard", + "close-toolbar": "Fechar barra de ferramentas", + "configuration-error": "Erro de configuração", + "alias-resolution-error-title": "Erro de configuração de aliases de dashboard", + "invalid-aliases-config": "Impossível encontrar dispositivos que coincidam com algum dos filtros de aliases.
    Para resolver esta questão, encontre em contato com seu administrador.", + "select-devices": "Selecionar dispositivos", + "assignedToCustomer": "Atribuído ao cliente", + "assignedToCustomers": "Atribuído aos clientes", + "public": "Público", + "public-link": "Link público", + "copy-public-link": "Copiar link público", + "public-link-copied-message": "O link público da dashboard foi copiado para a área de transferência", + "manage-states": "Gerenciar estados de dashboards", + "states": "Estados de dashboards", + "search-states": "Pesquisar estados de dashboards", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } selecionado(s)", + "edit-state": "Editar estado de dashboard", + "delete-state": "Excluir estado de dashboard", + "add-state": "Adicionar estado de dashboard", + "no-states-text": "Nenhum estado encontrado", + "state": "Estado do dashboard", + "state-name": "Nome", + "state-name-required": "O nome do estado do dashboard é obrigatório.", + "state-id": "ID do estado", + "state-id-required": "O ID do estado do dashboard é obrigatório.", + "state-id-exists": "Já existe um estado de dashboard com o mesmo ID.", + "is-root-state": "Estado raiz", + "delete-state-title": "Excluir estado de dashboard", + "delete-state-text": "Tem certeza de que deseja excluir o estado de dashboard com o nome '{{stateName}}'?", + "show-details": "Mostrar detalhes", + "hide-details": "Ocultar detalhes", + "select-state": "Selecionar estado alvo", + "state-controller": "Controlador de estado", + "search": "Selecionar dashboards", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } selecionado(s)" + }, + "datakey": { + "settings": "Configurações", + "advanced": "Avançado", + "label": "Etiqueta", + "color": "Cor", + "units": "Símbolo especial a ser exibido ao lado do valor", + "decimals": "Número de dígitos após ponto de flutuação", + "data-generation-func": "Função de geração de dados", + "use-data-post-processing-func": "Usar função de pós-processamento de dados", + "configuration": "Configuração de dados-chave", + "timeseries": "Intervalos de tempo", + "attributes": "Atributos", + "entity-field": "Campo de entidade", + "alarm": "Campos de alarmes", + "timeseries-required": "Os intervalos de tempo de entidade são obrigatórios.", + "timeseries-or-attributes-required": "Os intervalos de tempo/atributos de entidade são obrigatórios.", + "alarm-fields-timeseries-or-attributes-required": "Os campos de alarmes ou intervalos de tempo/atributos de entidade são obrigatórios.", + "maximum-timeseries-or-attributes": "No máximo { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "Os campos de alarmes são obrigatórios.", + "function-types": "Tipos de função", + "function-types-required": "Os tipos de função são obrigatórios.", + "maximum-function-types": "No máximo { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "carimbo de data/horário do valor atual;", + "value-description": "o valor atual;", + "prev-value-description": "resultado da chamada de função anterior;", + "time-prev-description": "carimbo de data/horário do valor anterior;", + "prev-orig-value-description": "valor original anterior;" + }, + "datasource": { + "type": "Tipo de fonte de dados", + "name": "Nome", + "add-datasource-prompt": "Adicione a fonte de dados" + }, + "details": { + "details": "Detalhes", + "edit-mode": "Modo de edição", + "edit-json": "Editar JSON", + "toggle-edit-mode": "Alternar o modo de edição" + }, + "device": { + "device": "Dispositivo", + "device-required": "O nome do dispositivo é obrigatório.", + "devices": "Dispositivos", + "management": "Gerenciamento de dispositivos", + "view-devices": "Exibir Dispositivos", + "device-alias": "Alias do dispositivo", + "aliases": "Aliases de dispositivos", + "no-alias-matching": "'{{alias}}' não encontrado.", + "no-aliases-found": "Nenhum alias encontrado.", + "no-key-matching": "'{{key}}' não encontrado.", + "no-keys-found": "Nenhuma chave encontrado.", + "create-new-alias": "Criar um novo!", + "create-new-key": "Criar um novo!", + "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
    Os aliases de dispositivos devem ser exclusivos no dashboard.", + "configure-alias": "Configurar alias de '{{alias}}'", + "no-devices-matching": "Nenhum dispositivo encontrado que coincida com '{{entity}}'.", + "alias": "Alias", + "alias-required": "O alias do dispositivo é obrigatório.", + "remove-alias": "Remover alias do dispositivo", + "add-alias": "Adicionar alias do dispositivo", + "name-starts-with": "O nome do dispositivo começa com", + "device-list": "Lista de dispositivos", + "use-device-name-filter": "Usar filtro", + "device-list-empty": "Nenhum dispositivo selecionado.", + "device-name-filter-required": "O filtro do nome do dispositivo é obrigatório.", + "device-name-filter-no-device-matched": "Nenhum dispositivo encontrado que comece com '{{device}}'.", + "add": "Adicionar Dispositivo", + "assign-to-customer": "Atribuir a cliente", + "assign-device-to-customer": "Atribuir Dispositivo(s) a Cliente", + "assign-device-to-customer-text": "Selecione os dispositivos a serem atribuídos ao cliente", + "make-public": "Tornar dispositivo público", + "make-private": "Tornar dispositivo privado", + "no-devices-text": "Nenhum dispositivo encontrado", + "assign-to-customer-text": "Selecione cliente para atribuir o(s) dispositivo(s)", + "device-details": "Detalhes do dispositivo", + "add-device-text": "Adicionar novo dispositivo", + "credentials": "Credenciais", + "manage-credentials": "Gerenciar credenciais", + "delete": "Excluir dispositivo", + "assign-devices": "Atribuir dispositivos", + "assign-devices-text": "Atribuir { count, plural, 1 {1 device} other {# devices} } a cliente", + "delete-devices": "Excluir dispositivos", + "unassign-from-customer": "Remover atribuição a cliente", + "unassign-devices": "Remover atribuição de dispositivos", + "unassign-devices-action-title": "Remover atribuição de { count, plural, 1 {1 device} other {# devices} } a cliente", + "assign-new-device": "Atribuir novo dispositivo", + "make-public-device-title": "Tem certeza de que deseja tornar o dispositivo '{{deviceName}}' público?", + "make-public-device-text": "Após confirmar, o dispositivo e todos os dados associados a ele se tornarão públicos e poderão ser acessados por outros.", + "make-private-device-title": "Tem certeza de que deseja tornar o dispositivo '{{deviceName}}' privado?", + "make-private-device-text": "Após confirmar, o dispositivo e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", + "view-credentials": "Exibir credenciais", + "delete-device-title": "Tem certeza de que deseja excluir o dispositivo '{{deviceName}}'?", + "delete-device-text": "Cuidado, após confirmar, não será possível recuperar o dispositivo e nenhum dado associado.", + "delete-devices-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "Excluir { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Cuidado, após confirmar, todos os dispositivos selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "unassign-device-title": "Tem certeza de que deseja remover a atribuição do dispositivo '{{deviceName}}'?", + "unassign-device-text": "Após confirmar, a atribuição do dispositivo será removida e ele não poderá ser acessado pelo cliente.", + "unassign-device": "Remover atribuição de dispositivo", + "unassign-devices-title": "Tem certeza de que deseja remover a atribuição de { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "Após confirmar, a atribuição de todos os dispositivos selecionados será removida e eles não poderão ser acessados pelo cliente.", + "device-credentials": "Credenciais do dispositivo", + "credentials-type": "Tipo de credencial", + "access-token": "Token de acesso", + "access-token-required": "O token de acesso é obrigatório.", + "access-token-invalid": "O token de acesso deve ter de 1 a 32 caracteres.", + "secret": "Segredo", + "secret-required": "O segredo é obrigatório.", + "device-type": "Tipo de dispositivo", + "device-type-required": "O tipo de dispositivo é obrigatório.", + "select-device-type": "Selecionar tipo de dispositivo", + "enter-device-type": "Inserir tipo de dispositivo", + "any-device": "Qualquer dispositivo", + "no-device-types-matching": "Nenhum tipo de dispositivo encontrado que coincida com '{{entitySubtype}}'.", + "device-type-list-empty": "Nenhum tipo de dispositivo selecionado.", + "device-types": "Tipos de dispositivos", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "description": "Descrição", + "label": "Etiqueta", + "events": "Eventos", + "details": "Detalhes", + "copyId": "Copiar ID de dispositivo", + "copyAccessToken": "Copiar token de acesso", + "idCopiedMessage": "O ID do dispositivo foi copiado para a área de transferência", + "accessTokenCopiedMessage": "O token de acesso foi copiado para a área de transferência", + "assignedToCustomer": "Atribuído ao cliente", + "unable-delete-device-alias-title": "Impossível excluir alias do dispositivo", + "unable-delete-device-alias-text": "Não é possível excluir o alias do dispositivo '{{deviceAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
    {{widgetsList}}", + "is-gateway": "É gateway", + "public": "Público", + "device-public": "O dispositivo é público", + "select-device": "Selecionar dispositivo", + "import": "Importar dispositivo", + "device-file": "Arquivo de dispositivo", + "search": "Pesquisar dispositivos", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } selecionado(s)" + }, + "dialog": { + "close": "Fechar diálogo" + }, + "direction": { + "column": "Coluna", + "row": "Linha" + }, + "error": { + "unable-to-connect": "Impossível conectar ao servidor! Verifique sua conexão à Internet.", + "unhandled-error-code": "Código de erro sem tratamento: {{errorCode}}", + "unknown-error": "Erro desconhecido" + }, + "entity": { + "entity": "Entidade", + "entities": "Entidades", + "aliases": "Aliases de entidades", + "entity-alias": "Alias de entidade", + "unable-delete-entity-alias-title": "Impossível excluir alias de entidade", + "unable-delete-entity-alias-text": "Não é possível excluir o alias de entidade '{{entityAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
    {{widgetsList}}", + "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
    Os aliases de entidades devem ser exclusivos no dashboard.", + "missing-entity-filter-error": "Filtro ausente para o alias '{{alias}}'.", + "configure-alias": "Configurar alias de '{{alias}}'", + "alias": "Alias", + "alias-required": "O alias de entidade é obrigatório.", + "remove-alias": "Remover alias de entidade", + "add-alias": "Adicionar alias de entidade", + "entity-list": "Lista de entidades", + "entity-type": "Tipo de entidade", + "entity-types": "Tipos de entidades", + "entity-type-list": "Lista de tipos de entidades", + "any-entity": "Qualquer entidade", + "enter-entity-type": "Inserir tipo de entidade", + "no-entities-matching": "Nenhuma entidade encontrada que coincida com '{{entity}}'.", + "no-entity-types-matching": "Nenhum tipo de entidade encontrado que coincida com '{{entityType}}'.", + "name-starts-with": "O nome começa com", + "use-entity-name-filter": "Usar filtro", + "entity-list-empty": "Nenhuma entidade selecionada.", + "entity-type-list-empty": "Nenhum tipo de entidade selecionado.", + "entity-name-filter-required": "O filtro do nome de entidade é obrigatório.", + "entity-name-filter-no-entity-matched": "Nenhuma entidade encontrada que comece com '{{entity}}'.", + "all-subtypes": "Tudo", + "select-entities": "Selecionar entidades", + "no-aliases-found": "Nenhum alias encontrado.", + "no-alias-matching": "'{{alias}}' não encontrado.", + "create-new-alias": "Criar um novo!", + "key": "Chave", + "key-name": "Nome da chave", + "no-keys-found": "Nenhuma chave encontrado.", + "no-key-matching": "'{{key}}' não encontrado.", + "create-new-key": "Criar um novo!", + "type": "Tipo", + "type-required": "O tipo de entidade é obrigatório.", + "type-device": "Dispositivo", + "type-devices": "Dispositivos", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "Dispositivos cujos nomes começam com '{{prefix}}'", + "type-asset": "Ativo", + "type-assets": "Ativos", + "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", + "asset-name-starts-with": "Ativos cujos nomes começam com '{{prefix}}'", + "type-entity-view": "Exibição de entidade", + "type-entity-views": "Exibições de entidades", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Exibições de entidades cujos nomes começam com '{{prefix}}'", + "type-rule": "Regra", + "type-rules": "Regras", + "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", + "rule-name-starts-with": "Regras cujos nomes começam com '{{prefix}}'", + "type-plugin": "Plug-in", + "type-plugins": "Plug-ins", + "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", + "plugin-name-starts-with": "Plug-ins cujos nomes começam com '{{prefix}}'", + "type-tenant": "Locatário", + "type-tenants": "Locatários", + "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", + "tenant-name-starts-with": "Locatários cujos nomes começam com '{{prefix}}'", + "type-customer": "Cliente", + "type-customers": "Clientes", + "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", + "customer-name-starts-with": "Clientes cujos nomes começam com '{{prefix}}'", + "type-user": "Usuário", + "type-users": "Usuários", + "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", + "user-name-starts-with": "Usuários cujos nomes começam com '{{prefix}}'", + "type-dashboard": "Dashboard", + "type-dashboards": "Dashboards", + "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", + "dashboard-name-starts-with": "Dashboards cujos nomes começam com '{{prefix}}'", + "type-alarm": "Alarme", + "type-alarms": "Alarmes", + "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", + "alarm-name-starts-with": "Alarmes cujos nomes começam com '{{prefix}}'", + "type-rulechain": "Cadeia de regras", + "type-rulechains": "Cadeias de regras", + "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", + "rulechain-name-starts-with": "Cadeias de regras cujos nomes começam com '{{prefix}}'", + "type-rulenode": "Nó de regra", + "type-rulenodes": "Nós de regras", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Nós de regras cujos nomes começam com '{{prefix}}'", + "type-current-customer": "Cliente atual", + "type-current-tenant": "Locatário atual", + "type-current-user": "Usuário atual", + "type-current-user-owner": "Proprietário de usuário atual", + "search": "Pesquisar entidades", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } selecionado(s)", + "entity-name": "Nome da entidade", + "entity-label": "Etiqueta de entidade", + "details": "Detalhes de entidades", + "no-entities-prompt": "Nenhuma entidade encontrada", + "no-data": "Nenhum dado para exibição", + "columns-to-display": "Colunas para Exibição" + }, + "entity-field": { + "created-time": "Hora de criação", + "name": "Nome", + "type": "Tipo", + "first-name": "Nome", + "last-name": "Sobrenome", + "email": "E-mail", + "title": "Título", + "country": "País", + "state": "Estado", + "city": "Cidade", + "address": "Endereço", + "address2": "Endereço 2", + "zip": "CEP", + "phone": "Telefone", + "label": "Etiqueta" + }, + "entity-view": { + "entity-view": "Exibição de entidade", + "entity-view-required": "A exibição de entidade é obrigatória.", + "entity-views": "Exibições de entidades", + "management": "Habilitar gerenciamento de exibições", + "view-entity-views": "Visualizar exibições de entidades", + "entity-view-alias": "Alias de exibição de entidade", + "aliases": "Aliases de exibições de entidades", + "no-alias-matching": "'{{alias}}' não encontrado.", + "no-aliases-found": "Nenhum alias encontrado.", + "no-key-matching": "'{{key}}' não encontrado.", + "no-keys-found": "Nenhuma chave encontrado.", + "create-new-alias": "Criar um novo!", + "create-new-key": "Criar um novo!", + "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
    Os aliases de exibições de entidades devem ser exclusivos no dashboard.", + "configure-alias": "Configurar alias de '{{alias}}'", + "no-entity-views-matching": "Nenhuma exibição de entidade encontrada que coincida com '{{entity}}'.", + "public": "Público", + "alias": "Alias", + "alias-required": "O alias de exibição de entidade é obrigatório.", + "remove-alias": "Remover alias de exibição de entidade", + "add-alias": "Adicionar alias de exibição de entidade", + "name-starts-with": "O nome da exibição de entidade começa com", + "entity-view-list": "Lista de exibições de entidades", + "use-entity-view-name-filter": "Usar filtro", + "entity-view-list-empty": "Nenhuma exibição de entidade selecionada.", + "entity-view-name-filter-required": "O filtro de nomes de exibições de entidades é obrigatório.", + "entity-view-name-filter-no-entity-view-matched": "Nenhuma exibição de entidade encontrada que comece com '{{entityView}}'.", + "add": "Adicionar exibição de entidade", + "entity-view-public": "A exibição de entidade é pública", + "assign-to-customer": "Atribuir a cliente", + "assign-entity-view-to-customer": "Atribuir Exibição(ões) de entidade(s) a Cliente", + "assign-entity-view-to-customer-text": "Selecione as exibições de entidades a serem atribuídas ao cliente", + "no-entity-views-text": "Nenhuma exibição de entidade encontrada", + "assign-to-customer-text": "Selecione o cliente para atribuir a(s) exibição(ões) de entidade(s)", + "entity-view-details": "Detalhes de exibições de entidades", + "add-entity-view-text": "Adicionar nova exibição de entidade", + "delete": "Excluir exibição de entidade", + "assign-entity-views": "Atribuir exibições de entidades", + "assign-entity-views-text": "Atribuir { count, plural, 1 {1 entity view} other {# entity views} } a cliente", + "delete-entity-views": "Excluir exibições de entidades", + "unassign-from-customer": "Remover atribuição a cliente", + "unassign-entity-views": "Remover atribuição de exibições de entidades", + "unassign-entity-views-action-title": "Remover atribuição de { count, plural, 1 {1 entity view} other {# entity views} } a cliente", + "assign-new-entity-view": "Atribuir nova exibição de entidade", + "delete-entity-view-title": "Tem certeza de que deseja excluir a exibição de entidade '{{entityViewName}}'?", + "delete-entity-view-text": "Cuidado, após confirmar, não será possível recuperar a exibição de entidade e todos os dados associados.", + "delete-entity-views-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 entity view} other {# entity views} }?", + "delete-entity-views-action-title": "Excluir { count, plural, 1 {1 entity view} other {# entity views} }", + "delete-entity-views-text": "Cuidado, após confirmar, todas as exibições de entidades selecionadas serão removidas e não será possível recuperar nenhum dado associado.", + "unassign-entity-view-title": "Tem certeza de que deseja remover a atribuição da exibição de entidade '{{entityViewName}}'?", + "unassign-entity-view-text": "Após confirmar, a atribuição da exibição de entidade será removida e ela não poderá ser acessada pelo cliente.", + "unassign-entity-view": "Remover atribuição de exibição de entidade", + "unassign-entity-views-title": "Tem certeza de que deseja remover a atribuição de { count, plural, 1 {1 entity view} other {# entity views} }?", + "unassign-entity-views-text": "Após confirmar, a atribuição de todas as exibições de entidade selecionadas será removida e elas não poderão ser acessadas pelo cliente.", + "entity-view-type": "Tipo de exibições de entidades", + "entity-view-type-required": "O tipo de exibição de entidade é obrigatório.", + "select-entity-view-type": "Selecionar tipo de exibição de entidade", + "enter-entity-view-type": "Inserir tipo de exibição de entidade", + "any-entity-view": "Qualquer exibição de entidade", + "no-entity-view-types-matching": "Nenhum tipo de exibição de entidade encontrado que coincida com '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Nenhum tipo de exibição de entidade selecionado.", + "entity-view-types": "Tipos de exibição de entidade", + "created-time": "Hora de criação", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "description": "Descrição", + "events": "Eventos", + "details": "Detalhes", + "copyId": "Copiar ID de exibição de entidade", + "idCopiedMessage": "O ID de exibição de entidade foi copiado para a área de transferência", + "assignedToCustomer": "Atribuído ao cliente", + "unable-entity-view-device-alias-title": "Impossível excluir alias de exibição de entidade", + "unable-entity-view-device-alias-text": "Não é possível excluir o alias do dispositivo '{{entityViewAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
    {{widgetsList}}", + "select-entity-view": "Selecionar exibição de entidade", + "make-public": "Tornar exibição de entidade pública", + "make-private": "Tornar exibição de entidade privada", + "start-date": "Data de início", + "start-ts": "Hora de início", + "end-date": "Data de término", + "end-ts": "Hora de término", + "date-limits": "Limites de datas", + "client-attributes": "Atributos do cliente", + "shared-attributes": "Atributos compartilhados", + "server-attributes": "Atributos do servidor", + "timeseries": "Intervalos de tempo", + "client-attributes-placeholder": "Atributos do cliente", + "shared-attributes-placeholder": "Atributos compartilhados", + "server-attributes-placeholder": "Atributos do servidor", + "timeseries-placeholder": "Intervalos de tempo", + "target-entity": "Entidade alvo", + "attributes-propagation": "Propagação de atributos", + "attributes-propagation-hint": "A exibição de entidade copiará automaticamente atributos especificados da entidade alvo sempre que essa exibição de entidade for salva ou atualizada. Por questões de desempenho, os atributos da entidade alvo não são propagados para a exibição de entidade em cada alteração de atributo. A propagação automática pode ser habilitada configurando o nó de regra \"copiar para exibição\" na cadeia de regras e vinculando mensagens de \"Pós-Atributos\" e \"Atributos Atualizados\" ao novo nó de regra.", + "timeseries-data": "Dados de intervalos de tempo", + "timeseries-data-hint": "Configurar chaves de dados de intervalos de tempo da entidade alvo que estará acessível para a exibição de entidade. Esses dados de intervalo de tempo são apenas de leitura.", + "make-public-entity-view-title": "Tem certeza de que deseja tornar a exibição de entidade '{{entityViewName}}' pública?", + "make-public-entity-view-text": "Após confirmar, a exibição de entidade e todos os dados associados a ela se tornarão públicos e poderão ser acessados por outros.", + "make-private-entity-view-title": "Tem certeza de que deseja tornar a exibição de entidade '{{entityViewName}}' privada?", + "make-private-entity-view-text": "Após confirmar, a exibição de entidade e todos os dados associados a ela se tornarão privados e não poderão ser acessados por outros.", + "search": "Pesquisar exibições de entidades", + "selected-entity-views": "{ count, plural, 1 {1 entity view} other {# entity views} } selecionado(s)" + }, + "event": { + "event-type": "Tipo de evento", + "type-error": "Erro", + "type-lc-event": "Evento de ciclo de vida", + "type-stats": "Estatísticas", + "type-debug-rule-node": "Depuração", + "type-debug-rule-chain": "Depuração", + "no-events-prompt": "Nenhum evento encontrado", + "error": "Erro", + "alarm": "Alarme", + "event-time": "Tempo de evento", + "server": "Servidor", + "body": "Corpo", + "method": "Método", + "type": "Tipo", + "message-id": "ID de mensagem", + "message-type": "Tipo de Mensagem", + "data-type": "Tipo de Dados", + "relation-type": "Tipo de Relação", + "metadata": "Metadados", + "data": "Dados", + "event": "Evento", + "status": "Status", + "success": "Êxito", + "failed": "Falhou", + "messages-processed": "Mensagens processadas", + "errors-occurred": "Erros", + "all-events": "Tudo", + "entity-type": "Tipo de entidade" + }, + "extension": { + "extensions": "Extensões", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } selecionado(s)", + "type": "Tipo", + "key": "Chave", + "value": "Valor", + "id": "ID", + "extension-id": "ID da extensão", + "extension-type": "Tipo de extensão", + "transformer-json": "JSON *", + "unique-id-required": "O ID de extensão atual já existe.", + "delete": "Excluir extensão", + "add": "Adicionar extensão", + "edit": "Editar extensão", + "delete-extension-title": "Tem certeza de que deseja excluir a extensão '{{extensionId}}'?", + "delete-extension-text": "Cuidado, após confirmar, não será possível recuperar a extensão e nenhum dado associado.", + "delete-extensions-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Cuidado, após confirmar, todas as extensões selecionadas serão removidas.", + "converters": "Conversores", + "converter-id": "ID do conversor", + "configuration": "Configuração", + "converter-configurations": "Configurações de conversores", + "token": "Token de segurança", + "add-converter": "Adicionar conversor", + "add-config": "Adicionar configuração de conversor", + "device-name-expression": "Expressão do nome do dispositivo", + "device-type-expression": "Expressão do tipo de dispositivo", + "custom": "Personalizado", + "to-double": "Duplicar", + "transformer": "Transformador", + "json-required": "O json do transformador é obrigatório", + "json-parse": "Impossível analisar json do transformador", + "attributes": "Atributos", + "add-attribute": "Adicionar atributo", + "add-map": "Adicionar elemento de mapeamento", + "timeseries": "Intervalos de tempo", + "add-timeseries": "Adicionar intervalos de tempo", + "field-required": "O campo é obrigatório", + "brokers": "Brokers", + "add-broker": "Adicionar broker", + "host": "Host", + "port": "Porta", + "port-range": "A porta deve estar em um intervalo de 1 a 65535.", + "ssl": "Ssl", + "credentials": "Credenciais", + "username": "Nome de usuário", + "password": "Senha", + "retry-interval": "Intervalo de repetição em milissegundos", + "anonymous": "Anônimo", + "basic": "Básico", + "pem": "PEM", + "ca-cert": "Arquivo de certificado da Autoridade de Certificação *", + "private-key": "Arquivo de chave privada *", + "cert": "Arquivo de certificado *", + "no-file": "Nenhum arquivo selecionado.", + "drop-file": "Solte um arquivo ou clique para selecionar um arquivo para carregar.", + "mapping": "Mapeamento", + "topic-filter": "Filtro de tópico", + "converter-type": "Tipo de conversor", + "converter-json": "Json", + "json-name-expression": "Expressão json do nome do dispositivo", + "topic-name-expression": "Expressão de tópico do nome do dispositivo", + "json-type-expression": "Expressão json do tipo de dispositivo", + "topic-type-expression": "Expressão de tópico do tipo de dispositivo", + "attribute-key-expression": "Expressão da chave de atributo", + "attr-json-key-expression": "Expressão json da chave de atributo", + "attr-topic-key-expression": "Expressão de tópico da chave de atributo", + "request-id-expression": "Expressão de ID da solicitação", + "request-id-json-expression": "Expressão json de ID da solicitação", + "request-id-topic-expression": "Expressão de tópico de ID da solicitação", + "response-topic-expression": "Expressão de tópico de resposta", + "value-expression": "Expressão do valor", + "topic": "Tópico", + "timeout": "Tempo limite em milissegundos", + "converter-json-required": "Json do conversor é obrigatório.", + "converter-json-parse": "Impossível analisar json do conversor", + "filter-expression": "Expressão do filtro", + "connect-requests": "Solicitações de conexão", + "add-connect-request": "Adicionar solicitação de conexão", + "disconnect-requests": "Solicitações de desconexão", + "add-disconnect-request": "Adicionar solicitação de desconexão", + "attribute-requests": "Solicitações de atributo", + "add-attribute-request": "Adicionar solicitação de atributo", + "attribute-updates": "Atualizações de atributos", + "add-attribute-update": "Adicionar atualização de atributo", + "server-side-rpc": "RPC no lado do servidor", + "add-server-side-rpc-request": "Adicionar solicitação de RPC no lado do servidor", + "device-name-filter": "Filtro do nome do dispositivo", + "attribute-filter": "Filtro de atributo", + "method-filter": "Filtro de método", + "request-topic-expression": "Expressão de tópico de solicitação", + "response-timeout": "Tempo limite de resposta em milissegundos", + "topic-expression": "Expressão de tópico", + "client-scope": "Escopo de cliente", + "add-device": "Adicionar dispositivo", + "opc-server": "Servidores", + "opc-add-server": "Adicionar servidor", + "opc-add-server-prompt": "Adicione servidor", + "opc-application-name": "Nome do aplicativo", + "opc-application-uri": "Uri do aplicativo", + "opc-scan-period-in-seconds": "Período de digitalização em segundos", + "opc-security": "Segurança", + "opc-identity": "Identidade", + "opc-keystore": "Keystore", + "opc-type": "Tipo", + "opc-keystore-type": "Tipo", + "opc-keystore-location": "Localização*", + "opc-keystore-password": "Senha", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Senha principal", + "opc-device-node-pattern": "Padrão de nó de dispositivo", + "opc-device-name-pattern": "Padrão de nome de dispositivo", + "modbus-server": "Servidores/subordinados", + "modbus-add-server": "Adicionar servidor/subordinado", + "modbus-add-server-prompt": "Adicione servidor/subordinado", + "modbus-transport": "Transporte", + "modbus-tcp-reconnect": "Reconectar automaticamente", + "modbus-rtu-over-tcp": "RTU sobre TCP", + "modbus-port-name": "Nome da porta serial", + "modbus-encoding": "Codificação", + "modbus-parity": "Paridade", + "modbus-baudrate": "Taxa de transmissão", + "modbus-databits": "Bits de dados", + "modbus-stopbits": "Bits de parada", + "modbus-databits-range": "Os bits de dados devem estar em um intervalo de 7 a 8.", + "modbus-stopbits-range": "Os bits de parada devem estar em um intervalo de 1 a 2.", + "modbus-unit-id": "ID da unidade", + "modbus-unit-id-range": "O ID da unidade deve estar em um intervalo de 1 a 247.", + "modbus-device-name": "Nome do dispositivo", + "modbus-poll-period": "Período de sondagem (ms)", + "modbus-attributes-poll-period": "Atributos do período de sondagem (ms)", + "modbus-timeseries-poll-period": "Intervalos de tempo do período de sondagem (ms)", + "modbus-poll-period-range": "O período de sondagem deve ser um valor positivo.", + "modbus-tag": "Etiqueta", + "modbus-function": "Função", + "modbus-register-address": "Endereço do registro", + "modbus-register-address-range": "O endereço do registro deve estar em um intervalo de 0 a 65535.", + "modbus-register-bit-index": "Índice de bits", + "modbus-register-bit-index-range": "O índice de bits deve estar em um intervalo de 0 a 15.", + "modbus-register-count": "Contagem de registro", + "modbus-register-count-range": "A contagem de registro deve ser um valor positivo.", + "modbus-byte-order": "Ordem de byte", + "sync": { + "status": "Status", + "sync": "Sincronizar", + "not-sync": "Não sincronizar", + "last-sync-time": "Horário da última sincronização", + "not-available": "Não disponível" + }, + "export-extensions-configuration": "Exportar configuração de extensões", + "import-extensions-configuration": "Importar configuração de extensões", + "import-extensions": "Importar extensões", + "import-extension": "Importar extensão", + "export-extension": "Exportar extensão", + "file": "Arquivo de extensões", + "invalid-file-error": "Arquivo de extensão inválido" + }, + "filter": { + "add": "Adicionar filtro", + "edit": "Editar filtro", + "name": "Nome do filtro", + "name-required": "O nome do filtro é obrigatório.", + "duplicate-filter": "Já existe um filtro com o mesmo nome.", + "filters": "Filtros", + "unable-delete-filter-title": "Impossível excluir filtro", + "unable-delete-filter-text": "Não é possível excluir o filtro '{{filter}}' porque ele é usado pelo(s) seguinte(s) widget(s):
    {{widgetsList}}", + "duplicate-filter-error": "Filtro '{{filter}}' duplicado encontrado.
    Os filtros devem ser exclusivos no dashboard.", + "missing-key-filters-error": "Filtros chave ausentes para o filtro '{{filter}}'.", + "filter": "Filtro", + "editable": "Editável", + "no-filters-found": "Nenhum filtro encontrado.", + "no-filter-matching": "'{{filter}}' não encontrado.", + "create-new-filter": "Criar um novo!", + "filter-required": "O filtro é obrigatório.", + "operation": { + "operation": "Operação", + "equal": "igual", + "not-equal": "diferente", + "starts-with": "começa com", + "ends-with": "termina com", + "contains": "contém", + "not-contains": "não contém", + "greater": "maior que", + "less": "menor que", + "greater-or-equal": "maior ou igual", + "less-or-equal": "menor ou igual", + "and": "e", + "or": "ou" + }, + "ignore-case": "Ignorar caixa", + "value": "Valor", + "remove-filter": "Remover filtro", + "no-filters": "Nenhum filtro configurado", + "add-filter": "Adicionar filtro", + "add-complex-filter": "Adicionar filtro complexo", + "add-complex": "Adicionar complexo", + "complex-filter": "Filtro complexo", + "edit-complex-filter": "Editar filtro complexo", + "edit-filter-user-params": "Editar parâmetros de usuário de predicado de filtro", + "user-parameters": "Parâmetros de usuário", + "display-label": "Etiqueta para exibição", + "autogenerated-label": "Gerar etiqueta automaticamente", + "order-priority": "Prioridade de ordem de campo", + "key-filter": "Filtro chave", + "key-filters": "Filtros chave", + "key-name": "Nome da chave", + "key-name-required": "O nome da chave é obrigatório.", + "key-type": { + "key-type": "Tipo de chave", + "attribute": "Atributo", + "timeseries": "Intervalos de tempo", + "entity-field": "Campo de entidade" + }, + "value-type": { + "value-type": "Tipo de valor", + "string": "Cadeia de caracteres", + "numeric": "Numérico", + "boolean": "Booliano", + "date-time": "Data/Hora" + }, + "value-type-required": "O tipo de valor chave é obrigatório.", + "key-value-type-change-title": "Tem certeza de que deseja alterar o tipo de valor chave?", + "key-value-type-change-message": "Se confirmar o novo tipo de valor, todos os filtros chave inseridos serão removidos.", + "no-key-filters": "Nenhum filtro chave configurado", + "add-key-filter": "Adicionar filtro chave", + "remove-key-filter": "Remover filtro chave", + "edit-key-filter": "Editar filtro chave", + "date": "Data", + "time": "Hora", + "current-tenant": "Locatário atual", + "current-customer": "Cliente atual", + "current-user": "Usuário atual", + "default-value": "Valor padrão", + "dynamic-source-type": "Tipo de fonte dinâmica", + "no-dynamic-value": "Nenhum valor dinâmico", + "source-attribute": "Atributo da fonte", + "switch-to-dynamic-value": "Alternar para valor dinâmico", + "switch-to-default-value": "Alternar para valor padrão" + }, + "fullscreen": { + "expand": "Expandir para tela inteira", + "exit": "Sair de tela inteira", + "toggle": "Alternar modo de tela inteira", + "fullscreen": "Tela inteira" + }, + "function": { + "function": "Função" + }, + "gateway": { + "add-entry": "Adicionar configuração", + "connector-add": "Adicionar novo conector", + "connector-enabled": "Habilitar conector", + "connector-name": "Nome do conector", + "connector-name-required": "O nome do conector é obrigatório.", + "connector-type": "Tipo de conector", + "connector-type-required": "O tipo de conector é obrigatório.", + "connectors": "Configuração de conectores", + "create-new-gateway": "Criar um novo gateway", + "create-new-gateway-text": "Tem certeza de que deseja criar um novo gateway com o nome: '{{gatewayName}}'?", + "delete": "Excluir configuração", + "download-tip": "Download de arquivo de configuração", + "gateway": "Gateway", + "gateway-exists": "Já existe um dispositivo com o mesmo nome.", + "gateway-name": "Nome do gateway", + "gateway-name-required": "O nome do gateway é obrigatório.", + "gateway-saved": "A configuração do gateway foi salva corretamente.", + "json-parse": "JSON inválido.", + "json-required": "O campo não pode estar em branco.", + "no-connectors": "Sem conectores", + "no-data": "Sem configurações", + "no-gateway-found": "Nenhum gateway encontrado.", + "no-gateway-matching": " '{{item}}' não encontrado.", + "path-logs": "Caminho para arquivos de log", + "path-logs-required": "O caminho é obrigatório", + "remote": "Configuração remota", + "remote-logging-level": "Nível de registro em log", + "remove-entry": "Remover configuração", + "save-tip": "Salvar arquivo de configuração", + "security-type": "Tipo de segurança", + "security-types": { + "access-token": "Token de Acesso", + "tls": "TLS" + }, + "storage": "Armazenamento", + "storage-max-file-records": "Número máximo de registros em arquivo", + "storage-max-files": "Número máximo de arquivos", + "storage-max-files-min": "O número mínimo é 1.", + "storage-max-files-pattern": "O número não é válido.", + "storage-max-files-required": "O número é obrigatório.", + "storage-max-records": "Número máximo de registros em armazenamento", + "storage-max-records-min": "O número mínimo de registros é 1.", + "storage-max-records-pattern": "O número não é válido.", + "storage-max-records-required": "O número máximo de registros é obrigatório.", + "storage-pack-size": "Tamanho máximo de pacote de eventos", + "storage-pack-size-min": "O número mínimo é 1.", + "storage-pack-size-pattern": "O número não é válido.", + "storage-pack-size-required": "O tamanho máximo de pacote de eventos é obrigatório.", + "storage-path": "Caminho de armazenamento", + "storage-path-required": "O caminho de armazenamento é obrigatório.", + "storage-type": "Tipo de armazenamento", + "storage-types": { + "file-storage": "Armazenamento de arquivo", + "memory-storage": "Armazenamento de memória" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "Host ThingsBoard", + "thingsboard-host-required": "O host é obrigatório.", + "thingsboard-port": "Porta ThingsBoard", + "thingsboard-port-max": "O número máximo de portas é 65535.", + "thingsboard-port-min": "O número mínimo de portas é 1.", + "thingsboard-port-pattern": "A porta não é válida.", + "thingsboard-port-required": "A porta é obrigatória.", + "tidy": "Tidy", + "tidy-tip": "Config Tidy JSON", + "title-connectors-json": "Configuração do conector {{typeName}}", + "tls-path-ca-certificate": "Caminho para certificado de Autoridade de Certificação no gateway", + "tls-path-client-certificate": "Caminho para certificado de cliente no gateway", + "tls-path-private-key": "Caminho para chave privada no gateway", + "toggle-fullscreen": "Alternar tela inteira", + "transformer-json-config": "Configuração JSON*", + "update-config": "Adicionar/atualizar configuração de JSON" + }, + "grid": { + "delete-item-title": "Tem certeza de que deseja excluir este item?", + "delete-item-text": "Cuidado, após confirmar, não será possível recuperar este item e nenhum dado associado.", + "delete-items-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Excluir { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Cuidado, após confirmar, todos os itens selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "add-item-text": "Adicionar novo item", + "no-items-text": "Nenhum item encontrado", + "item-details": "Detalhes do item", + "delete-item": "Excluir item", + "delete-items": "Excluir itens", + "scroll-to-top": "Rolar para o início" + }, + "help": { + "goto-help-page": "Ir para a página de Ajuda" + }, + "home": { + "home": "Página Inicial", + "profile": "Perfil", + "logout": "Logout", + "menu": "Menu", + "avatar": "Avatar", + "open-user-menu": "Abrir menu de usuário" + }, + "import": { + "no-file": "Nenhum arquivo selecionado", + "drop-file": "Solte um arquivo JSON ou clique para selecionar um arquivo para carregar.", + "drop-file-csv": "Solte um arquivo CSV ou clique para selecionar um arquivo para carregar.", + "column-value": "Valor", + "column-title": "Título", + "column-example": "Exemplo de dados de valor", + "column-key": "Chave de atributo/telemetria", + "csv-delimiter": "Delimitador de CSV", + "csv-first-line-header": "A primeira linha contém nomes de coluna", + "csv-update-data": "Atualizar atributos/telemetria", + "import-csv-number-columns-error": "Um arquivo deve conter no mínimo duas colunas", + "import-csv-invalid-format-error": "Formato de arquivo inválido. Linha: '{{line}}'", + "column-type": { + "name": "Nome", + "type": "Tipo", + "label": "Etiqueta", + "column-type": "Tipo de coluna", + "client-attribute": "Atributo do cliente", + "shared-attribute": "Atributo compartilhado", + "server-attribute": "Atributo do servidor", + "timeseries": "Intervalos de tempo", + "entity-field": "Campo de entidade", + "access-token": "Token de acesso", + "isgateway": "É Gateway", + "description": "Descrição" + }, + "stepper-text": { + "select-file": "Selecionar um arquivo", + "configuration": "Importar configuração", + "column-type": "Selecionar tipos de colunas", + "creat-entities": "Criar novas entidades" + }, + "message": { + "create-entities": "{{count}} novas entidades foram criadas corretamente.", + "update-entities": "{{count}} novas entidades foram atualizadas corretamente.", + "error-entities": "Ocorreu um erro ao criar {{count}} entidades." + } + }, + "item": { + "selected": "Selecionado" + }, + "js-func": { + "no-return-error": "A função deve retornar um valor!", + "return-type-mismatch": "A função deve retornar um valor do tipo '{{type}}'!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Chave", + "value": "Valor", + "remove-entry": "Remover entrada", + "add-entry": "Adicionar entrada", + "no-data": "Sem entradas" + }, + "layout": { + "layout": "Layout", + "manage": "Gerenciar layouts", + "settings": "Configuração de layout", + "color": "Cor", + "main": "Principal", + "right": "Direita", + "select": "Selecionar layout alvo" + }, + "legend": { + "direction": "Direção da legenda", + "position": "Posição da legenda", + "show-max": "Mostrar valor máximo", + "show-min": "Mostrar valor mínimo", + "show-avg": "Mostrar valor médio", + "show-total": "Mostrar valor total", + "settings": "Configuração de legenda", + "min": "mín.", + "max": "máx.", + "avg": "méd.", + "total": "total", + "comparison-time-ago": { + "days": "(dia atrás)", + "weeks": "(semana atrás)", + "months": "(mês atrás)", + "years": "(ano atrás)" + } + }, + "login": { + "login": "Login", + "request-password-reset": "Solicitar redefinição de senha", + "reset-password": "Redefinir senha", + "create-password": "Criar senha", + "passwords-mismatch-error": "As senhas inseridas devem ser idênticas!", + "password-again": "Repetir senha", + "sign-in": "Inscreva-se", + "username": "Nome de usuário (e-mail)", + "remember-me": "Lembrar-me", + "forgot-password": "Esqueceu a senha?", + "password-reset": "Redefinir senha", + "expired-password-reset-message": "As credenciais de expiraram! Crie uma nova senha.", + "new-password": "Nova senha", + "new-password-again": "Repetir nova senha", + "password-link-sent-message": "O link de redefinição de senha foi enviado corretamente!", + "email": "E-mail", + "login-with": "Login com {{name}}", + "or": "ou", + "error": "Erro de login" + }, + "position": { + "top": "Acima", + "bottom": "Abaixo", + "left": "Esquerda", + "right": "Direita" + }, + "profile": { + "profile": "Perfil", + "last-login-time": "Último login", + "change-password": "Modificar senha", + "current-password": "Senha atual" + }, + "relation": { + "relations": "Relações", + "direction": "Direção", + "search-direction": { + "FROM": "De", + "TO": "Para" + }, + "direction-type": { + "FROM": "de", + "TO": "para" + }, + "from-relations": "Relações de saída", + "to-relations": "Relações de entrada", + "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selecionado(s)", + "type": "Tipo", + "to-entity-type": "Para tipo de entidade", + "to-entity-name": "Para nome de entidade", + "from-entity-type": "De tipo de entidade", + "from-entity-name": "De nome de entidade", + "to-entity": "Para entidade", + "from-entity": "De entidade", + "delete": "Excluir relação", + "relation-type": "Tipo de relação", + "relation-type-required": "O tipo de relação é obrigatório.", + "any-relation-type": "Qualquer tipo", + "add": "Adicionar relação", + "edit": "Editar relação", + "delete-to-relation-title": "Tem certeza de que deseja excluir a relação com a entidade '{{entityName}}'?", + "delete-to-relation-text": "Cuidado, após confirmar, a relação da entidade '{{entityName}}' com a entidade atual será anulada.", + "delete-to-relations-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 relation} other {# relations} }?", + "delete-to-relations-text": "Cuidado, após confirmar, todas as relações serão removidas e a relação das entidades correspondentes com a entidade atual será anulada.", + "delete-from-relation-title": "Tem certeza de que deseja excluir a relação da entidade '{{entityName}}'?", + "delete-from-relation-text": "Cuidado, após confirmar, a relação da entidade atual com a entidade '{{entityName}}' será anulada.", + "delete-from-relations-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 relation} other {# relations} }?", + "delete-from-relations-text": "Cuidado, após confirmar, todas as relações serão removidas e a relação da entidade atual com as entidades correspondentes será anulada.", + "remove-relation-filter": "Remover filtro de relação", + "add-relation-filter": "Adicionar filtro de relação", + "any-relation": "Qualquer relação", + "relation-filters": "Filtros de relação", + "additional-info": "Informações adicionais (JSON)", + "invalid-additional-info": "Impossível analisar informações adicionais de json", + "no-relations-text": "Nenhuma relação encontrada" + }, + "rulechain": { + "rulechain": "Cadeia de regras", + "rulechains": "Cadeias de regras", + "root": "Raiz", + "delete": "Excluir cadeia de regras", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "description": "Descrição", + "add": "Adicionar cadeia de regras", + "set-root": "Tornar cadeia de regras em raiz", + "set-root-rulechain-title": "Tem certeza de que deseja tornar a cadeia de regras '{{ruleChainName}}' em raiz?", + "set-root-rulechain-text": "Após confirmar, a cadeia de regras se tornará em raiz e irá tratar todas as mensagens de transporte recebidas.", + "delete-rulechain-title": "Tem certeza de que deseja excluir a cadeia de regras '{{ruleChainName}}'?", + "delete-rulechain-text": "Cuidado, após confirmar, não será possível recuperar a cadeia de regras e todos os dados associados.", + "delete-rulechains-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "Excluir { count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Cuidado, após confirmar, todas as cadeias de regras serão removidas e não será possível recuperar nenhum dado associado.", + "add-rulechain-text": "Adicionar nova cadeia de regras", + "no-rulechains-text": "Nenhuma cadeia de regras encontrada", + "rulechain-details": "Detalhes de cadeia de regras", + "details": "Detalhes", + "events": "Eventos", + "system": "Sistema", + "import": "Importar cadeia de regras", + "export": "Exportar cadeia de regras", + "export-failed-error": "Impossível exportar cadeia de regras: {{error}}", + "create-new-rulechain": "Criar nova cadeia de regras", + "rulechain-file": "Arquivo de cadeia de regras", + "invalid-rulechain-file-error": "Impossível importar cadeia de regras: Estrutura de dados de cadeia de regras inválida.", + "copyId": "Copiar ID de cadeia de regras", + "idCopiedMessage": "O ID da cadeia de regras foi copiado para a área de transferência", + "select-rulechain": "Selecionar cadeia de regras", + "no-rulechains-matching": "Nenhuma cadeia de regras encontrada que coincida com '{{entity}}'.", + "rulechain-required": "A cadeia de regras é obrigatória", + "management": "Gerenciamento de regras", + "debug-mode": "Modo de depuração", + "search": "Pesquisar cadeias de regras", + "selected-rulechains": "{ count, plural, 1 {1 rule chain} other {# rule chains} } selecionada(s)", + "open-rulechain": "Abrir cadeia de regras" + }, + "rulenode": { + "details": "Detalhes", + "events": "Eventos", + "search": "Pesquisar nós", + "open-node-library": "Abrir biblioteca de nós", + "add": "Adicionar nó de regra", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "type": "Tipo", + "description": "Descrição", + "delete": "Excluir nó de regras", + "select-all-objects": "Selecionar todos os nós e conexões", + "deselect-all-objects": "Desmarcar todos os nós e conexões", + "delete-selected-objects": "Excluir nós e conexões selecionados", + "delete-selected": "Excluir selecionado", + "select-all": "Selecionar tudo", + "copy-selected": "Copiar selecionado(s)", + "deselect-all": "Desmarcar tudo", + "rulenode-details": "Detalhes do nó de regras", + "debug-mode": "Modo de depuração", + "configuration": "Configuração", + "link": "Link", + "link-details": "Detalhes do link do nó de regras", + "add-link": "Adicionar link", + "link-label": "Etiqueta de link", + "link-label-required": "A etiqueta de link é obrigatória.", + "custom-link-label": "Etiqueta de link personalizada", + "custom-link-label-required": "A etiqueta de link personalizada é obrigatória.", + "link-labels": "Etiquetas de links", + "link-labels-required": "As etiquetas de links são obrigatórias.", + "no-link-labels-found": "Nenhuma etiqueta de link encontrada", + "no-link-label-matching": "'{{label}}' não encontrado.", + "create-new-link-label": "Criar um novo!", + "type-filter": "Filtrar", + "type-filter-details": "Filtrar mensagens recebidas com condições configuradas", + "type-enrichment": "Enriquecimento", + "type-enrichment-details": "Adicionar informações adicionais aos Metadados de mensagens", + "type-transformation": "Transformação", + "type-transformation-details": "Alterar payload e metadados de mensagens", + "type-action": "Ação", + "type-action-details": "Executar ação especial", + "type-external": "Externo", + "type-external-details": "Interage com sistema externo", + "type-rule-chain": "Cadeia de Regras", + "type-rule-chain-details": "Encaminha mensagens recebidas para cadeia de regras especificada", + "type-input": "Entrada", + "type-input-details": "Entrada lógica de cadeia de regras, encaminha mensagens recebidas para nó de regras associado", + "type-unknown": "Desconhecido", + "type-unknown-details": "Nó de regras não resolvido", + "directive-is-not-loaded": "Diretriz de configuração '{{directiveName}}' definida não está disponível.", + "ui-resources-load-error": "Erro ao carregar configuração de recursos de interface de usuário.", + "invalid-target-rulechain": "Impossível resolver cadeia de regras alvo!", + "test-script-function": "Testar funcionamento de script", + "message": "Mensagem", + "message-type": "Tipo de mensagem", + "select-message-type": "Selecionar tipo de mensagem", + "message-type-required": "O tipo de mensagem é obrigatório", + "metadata": "Metadados", + "metadata-required": "As entradas de metadados não podem estar em branco.", + "output": "Saída", + "test": "Teste", + "help": "Ajuda", + "reset-debug-mode": "Redefinir modo de depuração em todos os nós" + }, + "timezone": { + "timezone": "Fuso horário", + "select-timezone": "Selecionar fuso horário", + "no-timezones-matching": "Nenhum fuso horário encontrado que coincida com '{{timezone}}'.", + "timezone-required": "O fuso horário é obrigatório." + }, + "queue": { + "select_name": "Selecionar nome de fila", + "name": "Nome de Fila", + "name_required": "O nome de fila é obrigatório!" + }, + "tenant": { + "tenant": "Locatário", + "tenants": "Locatários", + "management": "Gerenciamento de locatários", + "add": "Adicionar Locatário", + "admins": "Administradores", + "manage-tenant-admins": "Gerenciar administradores de locatários", + "delete": "Excluir locatário", + "add-tenant-text": "Adicionar novo locatário", + "no-tenants-text": "Nenhum locatário encontrado", + "tenant-details": "Detalhes do locatário", + "delete-tenant-title": "Tem certeza de que deseja excluir o locatário '{{tenantTitle}}'?", + "delete-tenant-text": "Cuidado, após confirmar, não será possível recuperar o locatário e nenhum dado associado.", + "delete-tenants-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 tenant} other {# tenants} }?", + "delete-tenants-action-title": "Excluir { count, plural, 1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "Cuidado, após confirmar, todos os locatários selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "title": "Título", + "title-required": "O título é obrigatório.", + "description": "Descrição", + "details": "Detalhes", + "events": "Eventos", + "copyId": "Copiar ID do locatário", + "idCopiedMessage": "O ID do locatário foi copiado para a área de transferência", + "select-tenant": "Selecionar locatário", + "no-tenants-matching": "Nenhum locatário encontrado que coincida com '{{entity}}'.", + "tenant-required": "O locatário é obrigatório", + "search": "Pesquisar locatários", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenants} } selecionado(s)", + "isolated-tb-rule-engine": "Processamento em contêiner isolado do ThingsBoard Rule Engine", + "isolated-tb-rule-engine-details": "Exige microsserviço(s) separado(s) para cada locatário isolado" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", + "days": "Dias", + "hours": "Horas", + "minutes": "Minutos", + "seconds": "Segundos", + "advanced": "Avançado" + }, + "timewindow": { + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "realtime": "Tempo real", + "history": "Histórico", + "last-prefix": "último", + "period": "de {{ startTime }} até {{ endTime }}", + "edit": "Editar janela de tempo", + "date-range": "Intervalo de datas", + "last": "Última", + "time-period": "Período de tempo", + "hide": "Ocultar" + }, + "user": { + "user": "Usuário", + "users": "Usuários", + "customer-users": "Usuários do cliente", + "tenant-admins": "Administradores de locatários", + "sys-admin": "Administrador do sistema", + "tenant-admin": "Administrador de locatários", + "customer": "Cliente", + "anonymous": "Anônimo", + "add": "Adicionar usuário", + "delete": "Excluir usuário", + "add-user-text": "Adicionar novo usuário", + "no-users-text": "Nenhum usuário encontrado", + "user-details": "Detalhes do usuário", + "delete-user-title": "Tem certeza de que deseja excluir o usuário '{{userEmail}}'?", + "delete-user-text": "Cuidado, após confirmar, não será possível recuperar o usuário e nenhum dado associado.", + "delete-users-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "Excluir { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Cuidado, após confirmar, todos os usuários selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "activation-email-sent-message": "O e-mail de ativação foi enviado corretamente!", + "resend-activation": "Reenviar ativação", + "email": "E-mail", + "email-required": "O e-mail é obrigatório.", + "invalid-email-format": "Formato de e-mail inválido.", + "first-name": "Nome", + "last-name": "Sobrenome", + "description": "Descrição", + "default-dashboard": "Dashboard padrão", + "always-fullscreen": "Sempre tela inteira", + "select-user": "Selecionar usuário", + "no-users-matching": "Nenhum usuário encontrado que coincida com '{{entity}}'.", + "user-required": "O usuário é obrigatório", + "activation-method": "Método de ativação", + "display-activation-link": "Exibir link de ativação", + "send-activation-mail": "Enviar e-mail de ativação", + "activation-link": "Link de ativação de usuário", + "activation-link-text": "Para ativar o usuário, utilize o seguinte link de ativação:", + "copy-activation-link": "Copiar link de ativação", + "activation-link-copied-message": "O link de ativação foi copiado para a área de transferência", + "details": "Detalhes", + "login-as-tenant-admin": "Login como administrador de locatários", + "login-as-customer-user": "Login como usuário do cliente", + "search": "Pesquisar usuários", + "selected-users": "{ count, plural, 1 {1 user} other {# users} } selecionado(s)", + "disable-account": "Desativar conta de usuário", + "enable-account": "Ativar conta de usuário", + "enable-account-message": "A conta de usuário foi ativada corretamente!", + "disable-account-message": "A conta de usuário foi desativada corretamente!" + }, + "value": { + "type": "Tipo de valor", + "string": "Cadeia de caracteres", + "string-value": "Valor da cadeia de caracteres", + "string-value-required": "O valor da cadeia de caracteres é obrigatório", + "integer": "Número inteiro", + "integer-value": "Valor do número inteiro", + "integer-value-required": "O valor do número inteiro é obrigatório", + "invalid-integer-value": "Valor de número inteiro inválido", + "double": "Duplo", + "double-value": "Valor duplo", + "double-value-required": "O valor duplo de caracteres é obrigatório", + "boolean": "Booliano", + "boolean-value": "Valor booliano", + "false": "Falso", + "true": "Verdadeiro", + "long": "Longo", + "json": "JSON", + "json-value": "Valor de JSON", + "json-value-invalid": "O formato do valor de JSON é inválido", + "json-value-required": "O valor de JSON é obrigatório." + }, + "widget": { + "widget-library": "Biblioteca de widgets", + "widget-bundle": "Pacote de widgets", + "select-widgets-bundle": "Selecionar pacote de widgets", + "management": "Gerenciamento de widgets", + "editor": "Editor de widgets", + "widget-type-not-found": "Problema ao carregar configuração de widget.
    Provavelmente\n o tipo de widget foi removido.", + "widget-type-load-error": "O widget não foi carregado devido aos seguintes erros:", + "remove": "Remover widget", + "edit": "Editar widget", + "remove-widget-title": "Tem certeza de que deseja remover o widget '{{widgetTitle}}'?", + "remove-widget-text": "Após confirmar, não será possível recuperar o widget e nenhum dado associado.", + "timeseries": "Intervalos de tempo", + "search-data": "Pesquisar dados", + "no-data-found": "Nenhum dado encontrado", + "latest": "Últimos valores", + "rpc": "Widget de controle", + "alarm": "Widget de alarme", + "static": "Widget estático", + "select-widget-type": "Selecionar tipo de widget", + "missing-widget-title-error": "É necessário especificar o título de widget!", + "widget-saved": "Widget salvo", + "unable-to-save-widget-error": "Impossível salvar widget! O widget tem erros!", + "save": "Salvar widget", + "saveAs": "Salvar widget como", + "save-widget-type-as": "Salvar tipo de widget como", + "save-widget-type-as-text": "Inserir novo título de widget e/ou selecionar pacote de widgets alvo", + "toggle-fullscreen": "Alternar tela inteira", + "run": "Executar widget", + "title": "Título de widget", + "title-required": "O título do widget é obrigatório.", + "type": "Tipo de widget", + "resources": "Recursos", + "resource-url": "URL de JavaScript/CSS", + "resource-is-module": "É módulo", + "remove-resource": "Remover recurso", + "add-resource": "Adicionar recurso", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Esquema de configuração", + "datakey-settings-schema": "Esquema de configuração de chave de dados", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Tem certeza de que deseja remover o tipo de widget '{{widgetName}}'?", + "remove-widget-type-text": "Após confirmar, não será possível recuperar o tipo de widget e nenhum dado associado.", + "remove-widget-type": "Remover tipo de widget", + "add-widget-type": "Adicionar novo tipo de widget", + "widget-type-load-failed-error": "Erro ao carregar tipo de widget!", + "widget-template-load-failed-error": "Erro ao carregar modelo de widget!", + "add": "Adicionar widget", + "undo": "Desfazer alterações de widget", + "export": "Exportar widget", + "no-data": "Nenhum dado para exibição em widget", + "data-overflow": "O widget exibe {{count}} de um total de {{total}} entidades", + "alarm-data-overflow": "O widget exibe alarmes para (no máximo) {{allowedEntities}} entidades de um total de {{totalEntities}} entidades" + }, + "widget-action": { + "header-button": "Botão de cabeçalho de widget", + "open-dashboard-state": "Navegar para novo estado de dashboard", + "update-dashboard-state": "Atualizar estado de dashboard atual", + "open-dashboard": "Navegar para outro dashboard", + "custom": "Personalizar ação", + "custom-pretty": "Personalizar ação (com modelo de HTML)", + "target-dashboard-state": "Estado de dashboard alvo", + "target-dashboard-state-required": "O estado do dashboard alvo é obrigatório", + "set-entity-from-widget": "Definir entidade em widget", + "target-dashboard": "Dashboard alvo", + "open-right-layout": "Abrir layout do dashboard à direita (exibição móvel)" + }, + "widgets-bundle": { + "current": "Pacote atual", + "widgets-bundles": "Pacotes de widgets", + "add": "Adicionar pacote de widgets", + "delete": "Excluir pacote de widgets", + "title": "Título", + "title-required": "O título é obrigatório.", + "add-widgets-bundle-text": "Adicionar novo pacote de widgets", + "no-widgets-bundles-text": "Nenhum pacote de widgets encontrado", + "empty": "O pacote de widgets está vazio", + "details": "Detalhes", + "widgets-bundle-details": "Detalhes do pacote de widgets", + "delete-widgets-bundle-title": "Tem certeza de que deseja excluir o pacote de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Cuidado, após confirmar, não será possível recuperar o pacote de widgets e nenhum dado associado.", + "delete-widgets-bundles-title": "Tem certeza de que deseja excluir { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "Excluir { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Cuidado, após confirmar, todos os pacotes de widgets selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "no-widgets-bundles-matching": "Nenhum conversor de pacotes de widgets encontrado que coincida com '{{widgetsBundle}}'.", + "widgets-bundle-required": "O pacote de widgets é obrigatório.", + "system": "Sistema", + "import": "Importar pacote de widgets", + "export": "Exportar pacote de widgets", + "export-failed-error": "Impossível exportar pacote de widgets: {{error}}", + "create-new-widgets-bundle": "Criar novo pacote de widgets", + "widgets-bundle-file": "Arquivo de pacote de widgets", + "invalid-widgets-bundle-file-error": "Impossível importar pacote de widgets: Estrutura de dados de pacote de widgets inválida.", + "search": "Pesquisar pacotes de widgets", + "selected-widgets-bundles": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} } selecionado(s)", + "open-widgets-bundle": "Abrir pacote de widgets" + }, + "widget-config": { + "data": "Dados", + "settings": "Configurações", + "advanced": "Avançado", + "title": "Título", + "title-tooltip": "Título da dica de ferramenta", + "general-settings": "Configuração geral", + "display-title": "Exibir título", + "drop-shadow": "Sombra projetada", + "enable-fullscreen": "Habilitar tela inteira", + "background-color": "Cor de fundo", + "text-color": "Cor de texto", + "padding": "Preenchimento", + "margin": "Margem", + "widget-style": "Estilo de widget", + "title-style": "Estilo do título", + "mobile-mode-settings": "Configuração do modo móvel", + "order": "Ordem", + "height": "Altura", + "units": "Símbolo especial a ser exibido ao lado do valor", + "decimals": "Número de dígitos após ponto de flutuação", + "timewindow": "Janela de tempo", + "use-dashboard-timewindow": "Usar janela de tempo do dashboard", + "display-timewindow": "Exibir timewindow", + "display-legend": "Exibir legenda", + "datasources": "Fontes de dados", + "maximum-datasources": "No máximo { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", + "datasource-type": "Tipo", + "datasource-parameters": "Parâmetros", + "remove-datasource": "Remover fonte de dados", + "add-datasource": "Adicionar fonte de dados", + "target-device": "Dispositivo alvo", + "alarm-source": "Fonte do alarme", + "actions": "Ações", + "action": "Ação", + "add-action": "Adicionar ação", + "search-actions": "Pesquisar ações", + "no-actions-text": "Nenhuma ação encontrada", + "action-source": "Fonte da ação", + "action-source-required": "A fonte da ação é obrigatória.", + "action-name": "Nome", + "action-name-required": "O nome da ação é obrigatório!", + "action-name-not-unique": "Já existe outra ação com o mesmo nome.
    O nome da ação na mesma fonte de ação deve ser exclusivo.", + "action-icon": "Ícone", + "action-type": "Tipo", + "action-type-required": "O tipo de ação é obrigatório.", + "edit-action": "Editar ação", + "delete-action": "Excluir ação", + "delete-action-title": "Excluir ação de widget", + "delete-action-text": "Tem certeza de que deseja excluir a ação de widget com o nome '{{actionName}}'?", + "display-icon": "Exibir ícone de título", + "icon-color": "Cor do ícone", + "icon-size": "Tamanho do ícone" + }, + "widget-type": { + "import": "Importar tipo de widget", + "export": "Exportar tipo de widget", + "export-failed-error": "Impossível exportar tipo de widget: {{error}}", + "create-new-widget-type": "Criar novo tipo de widget", + "widget-type-file": "Arquivo de tipo de widget", + "invalid-widget-type-file-error": "Impossível importar tipo de widget: Estrutura de dados de tipo de widget inválida." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Dom", + "Mon": "Seg", + "Tue": "Ter", + "Wed": "Qua", + "Thu": "Qui", + "Fri": "Sex", + "Sat": "Sáb", + "Jan": "Jan", + "Feb": "Fev", + "Mar": "Mar", + "Apr": "Abr", + "May": "Maio", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Ago", + "Sep": "Set", + "Oct": "Out", + "Nov": "Nov", + "Dec": "Dez", + "January": "Janeiro", + "February": "Fevereiro", + "March": "Março", + "April": "Abril", + "June": "Junho", + "July": "Julho", + "August": "Agosto", + "September": "Setembro", + "October": "Outubro", + "November": "Novembro", + "December": "Dezembro", + "Custom Date Range": "Personalizar intervalo de datas", + "Date Range Template": "Modelo de intervalo de datas", + "Today": "Hoje", + "Yesterday": "Ontem", + "This Week": "Esta semana", + "Last Week": "Semana passada", + "This Month": "Este mês", + "Last Month": "Mês passado", + "Year": "Ano", + "This Year": "Este ano", + "Last Year": "Ano passado", + "Date picker": "Seletor de data", + "Hour": "Hora", + "Day": "Dia", + "Week": "Semana", + "2 weeks": "2 semanas", + "Month": "Mês", + "3 months": "3 meses", + "6 months": "6 meses", + "Custom interval": "Intervalo personalizado", + "Interval": "Intervalo", + "Step size": "Tamanho da etapa", + "Ok": "OK" + } + }, + "input-widgets": { + "attribute-not-allowed": "O parâmetro de atributo não pode ser usado neste widget", + "blocked-location": "A geolocalização está bloqueada no seu navegador", + "claim-device": "Obter dispositivo", + "claim-failed": "Erro ao obter dispositivo!", + "claim-not-found": "Dispositivo não encontrado!", + "claim-successful": "O dispositivo foi obtido corretamente!", + "date": "Data", + "device-name": "Nome do dispositivo", + "device-name-required": "O nome do dispositivo é obrigatório", + "discard-changes": "Descartar alterações", + "entity-attribute-required": "O atributo da entidade é obrigatório", + "entity-coordinate-required": "Os dois campos, latitude e longitude, são obrigatórios", + "entity-timeseries-required": "Os intervalos de tempo de entidade são obrigatórios", + "get-location": "Obter localização atual", + "latitude": "Latitude", + "longitude": "Longitude", + "not-allowed-entity": "A entidade selecionada não pode ter atributos compartilhados", + "no-attribute-selected": "Nenhum atributo selecionado", + "no-datakey-selected": "Nenhuma chave de dados selecionada", + "no-coordinate-specified": "Chave de dados de latitude/longitude não especificada", + "no-entity-selected": "Nenhuma entidade selecionada", + "no-image": "Sem imagem", + "no-support-geolocation": "Seu navegador não é compatível com geolocalização", + "no-support-web-camera": "Nenhuma câmera Web compatível", + "no-timeseries-selected": "Nenhum intervalo de tempo selecionado", + "secret-key": "Chave de segredo", + "secret-key-required": "A chave de segredo é obrigatória", + "switch-attribute-value": "Alternar valor de atributo da entidade", + "switch-camera": "Alternar câmera", + "switch-timeseries-value": "Alternar valor de intervalo de tempo da entidade", + "take-photo": "Tirar foto", + "time": "Hora", + "timeseries-not-allowed": "O parâmetro de intervalo de série não pode ser usado neste widget", + "update-failed": "Atualização falhou", + "update-successful": "Atualização bem-sucedida", + "update-attribute": "Atualizar atributo", + "update-timeseries": "Atualizar intervalos de tempo", + "value": "Valor" + } + }, + "icon": { + "icon": "Ícone", + "select-icon": "Selecionar ícone", + "material-icons": "Ícones de materiais", + "show-all": "Mostrar todos os ícones" + }, + "custom": { + "widget-action": { + "action-cell-button": "Botão da célula de ação", + "row-click": "Clicar em linha", + "polygon-click": "Clique no polígono", + "marker-click": "Clique no marcador", + "tooltip-tag-action": "Ação do rótulo de dica de ferramenta", + "node-selected": "Selecionado no nó", + "element-click": "Clique em elemento de HTML", + "pie-slice-click": "Clique na fatia", + "row-double-click": "Dois clique na fila" + } + }, + "language": { + "language": "Idioma" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json new file mode 100644 index 0000000..01af73e --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json @@ -0,0 +1,1798 @@ +{ + "access": { + "unauthorized": "Neautorizat", + "unauthorized-access": "Acces Neautorizat", + "unauthorized-access-text": "Pentru a accesa această resursă, utilizatorul trebuie să fie identificat", + "access-forbidden": "Acces Interzis", + "access-forbidden-text": "Nu ai drept de acces la această resursă!
    Pentru a obţine accesul, identifică-te cu alt nume de utilizator", + "refresh-token-expired": "Sesiunea a expirat", + "refresh-token-failed": "Sesiunea nu poate fi reîncărcată" + }, + "action": { + "activate": "Activează", + "suspend": "Suspendă", + "save": "Salvează", + "saveAs": "Salvează Cu Alt Nume", + "cancel": "Renunţă", + "ok": "OK", + "delete": "Şterge", + "add": "Adaugă", + "yes": "Da", + "no": "Nu", + "update": "Actualizează", + "remove": "Elimină", + "search": "Caută", + "clear-search": "Resetează Căutarea", + "assign": "Repartizează", + "unassign": "Şterge Repartizarea", + "share": "Partajare", + "make-private": "Declară Privat", + "apply": "Aplică", + "apply-changes": "Aplică Schimbările", + "edit-mode": "Mod Editare", + "enter-edit-mode": "Mod Editare", + "decline-changes": "Refuză Schimbările", + "close": "Închide", + "back": "Înapoi", + "run": "Execută-Rulează", + "sign-in": "Înregistrează Cont Nou", + "edit": "Editează", + "view": "Vizualizează", + "create": "Creează", + "drag": "Trage", + "refresh": "Reactualizează", + "undo": "Anulează Ultima Comandă", + "copy": "Copiere", + "paste": "Lipire", + "copy-reference": "Copiere Referință", + "paste-reference": "Lipire Referință", + "import": "Import", + "export": "Export", + "share-via": "Distribuie prin {{provider}}", + "continue": "Continuă", + "discard-changes": "Anulează Schimbări", + "done": "Terminat" + }, + "aggregation": { + "aggregation": "Agregare", + "function": "Funcţie Agregare Date", + "limit": "Valori Maxime", + "group-interval": "Interval Grupare", + "min": "Minim", + "max": "Maxim", + "avg": "Medie", + "sum": "Sumă", + "count": "Numără", + "none": "Nimic" + }, + "admin": { + "general": "General", + "general-settings": "Setări Generale", + "outgoing-mail": " Server eMail", + "outgoing-mail-settings": "Setări Pentru : Outgoing Mail Server", + "system-settings": "Setări Sistem", + "test-mail-sent": "Mesajul de test setări pentru email a fost trimis cu succes", + "base-url": "Adresa De Bază URL", + "base-url-required": "Adresa de bază URL este obligatorie", + "mail-from": "Mesaj eMail de la expeditor", + "mail-from-required": "Adresa eMail a expeditorului este obligatorie", + "smtp-protocol": "Setări Protocol SMTP", + "smtp-host": "Adresă SMTP", + "smtp-host-required": "Adresa SMTP este obligatorie", + "smtp-port": "Port SMTP", + "smtp-port-required": "Trebuie să precizaţi un port SMTP", + "smtp-port-invalid": "Textul introdus nu pare să fie al unui port SMTP", + "timeout-msec": "Timp expirare (milisecunde)", + "timeout-required": "Timpul de expirare este obligatoriu", + "timeout-invalid": "Timpul de expirare nu pare să fie valid", + "enable-tls": "Permite TLS", + "send-test-mail": "Trimite mesaj eMail test", + "security-settings": "Setări Securitate", + "password-policy": "Reguli Pentru Definirea Parolei", + "minimum-password-length": "Numărul Minim De Caractere Al Parolei", + "minimum-password-length-required": "Numărul minim de caractere al parolei este obligatoriu", + "minimum-password-length-range": "Numărul minim de caractere al parolei trebuie să fie între 5 - 50", + "minimum-uppercase-letters": "Numărul Minim De Caractere Scrise Cu MAJUSCULĂ Din Parolă", + "minimum-uppercase-letters-range": "Numărul minim de caractere scrise cu majusculă din parolă nu poate fi negativ", + "minimum-lowercase-letters": "Numărul Minim De Caractere Scrise Cu Literă mică Din Parolă", + "minimum-lowercase-letters-range": "Numărul minim de caractere scrise cu literă mică din parolă nu poate fi negativ", + "minimum-digits": "Numărul Minim De Cifre Din Parolă", + "minimum-digits-range": "Numărul minim de cifre din parolă nu poate fi negativ", + "minimum-special-characters": "Numărul Minim De Caractere Speciale Din Parolă", + "minimum-special-characters-range": "Numărul minim de caractere speciale din parolă nu poate fi negativ", + "password-expiration-period-days": "Perioada de expirare a parolei (zile)", + "password-expiration-period-days-range": "Perioada de expirare a parolei (zile) nu poate fi negativă", + "password-reuse-frequency-days": "Frecvenţa de refolosire a parolei (zile)", + "password-reuse-frequency-days-range": "Frecvenţa de refolosire a parolei(zile) nu poate fi negativă", + "general-policy": "Reguli Generale", + "max-failed-login-attempts": "Numărul maxim de încercări eşuate de accesare a paginii înainte de blocarea contului", + "minimum-max-failed-login-attempts-range": "Numărul maxim de încercări eşuate de accesare a paginii nu poate fi negativ", + "user-lockout-notification-email": "În Cazul Blocării Contului, Trimite eMail De Notificare" + }, + "alarm": { + "alarm": "Alarmă", + "alarms": "Alarme", + "select-alarm": "Selectează Alarmă", + "no-alarms-matching": "Nu au fost găsite alarme pentru '{{entity}}'", + "alarm-required": "Alarma este obligatorie", + "alarm-status": "Stare Alarmă", + "search-status": { + "ANY": "Oricare", + "ACTIVE": "Activă", + "CLEARED": "Ştearsă", + "ACK": "Observată", + "UNACK": "Neobservată" + }, + "display-status": { + "ACTIVE_UNACK": "Activă Neobservată", + "ACTIVE_ACK": "Activă Observată", + "CLEARED_UNACK": "Ștearsă Neobservată", + "CLEARED_ACK": "Ștearsă Observată" + }, + "no-alarms-prompt": "NiciO Alarmă Găsită", + "created-time": "Data Creării", + "type": "Tipul", + "severity": "Urgenţa", + "originator": "Iniţiator", + "originator-type": "Tip Iniţiator", + "details": "Detalii", + "status": "Stare", + "alarm-details": "Detalii Alarmă", + "start-time": "Început", + "end-time": "Sfârşit", + "ack-time": "Data Observării", + "clear-time": "Data Ştergerii", + "severity-critical": "Critică", + "severity-major": "Majoră", + "severity-minor": "Minoră", + "severity-warning": "Avertizare", + "severity-indeterminate": "Nedeterminată", + "acknowledge": "Marchează Observat", + "clear": "Şterge", + "search": "Caută Alarme", + "selected-alarms": "{ count, plural, 1 {o alarmă} other {# alarme} } selectate", + "no-data": "Nu există date de afişat", + "polling-interval": "Interval actualizare alarme (secunde)", + "polling-interval-required": "Intervalul pentru actualizarea alarmelor este obligatoriu", + "min-polling-interval-message": "Valoarea minimă permisă pentru interval actualizare alarme este o secundă", + "aknowledge-alarms-title": "Ai selectat { count, plural, 1 {o alarmă} other {# alarme} }", + "aknowledge-alarms-text": "Sigur vrei să marchezi ca 'Observat' { count, plural, 1 {o alarmă} other {# alarme} }?", + "aknowledge-alarm-title": "Alarmă Observată", + "aknowledge-alarm-text": "Sigur vrei să marchezi alarma ca 'Observat'?", + "clear-alarms-title": "Şterge { count, plural, 1 {o alarmă} other {# alarme} }", + "clear-alarms-text": "Sigur vrei să ștergi { count, plural, 1 {o alarmă} other {# alarme} }?", + "clear-alarm-title": "Şterge Alarma", + "clear-alarm-text": "Sigur vrei să ștergi alarma?", + "alarm-status-filter": "Stare Filtre Alarmă", + "max-count-load": "Număr maxim de alarme înregistrate (0=nelimitat)", + "max-count-load-required": "Numărul maxim de alarme înregistrate este obligatoriu", + "max-count-load-error-min": "Numărul maxim de alarme este 0", + "fetch-size": "Număr alarme afişate", + "fetch-size-required": "Numărul alarmelor afişate este obligatoriu", + "fetch-size-error-min": "Valoarea minimă este 10" + }, + "alias": { + "add": "Adaugă Pseudonim", + "edit": "Editează Pseudonim", + "name": "Denumire Pseudonim", + "name-required": "Denumirea pseudonimului este obligatorie", + "duplicate-alias": "Există deja un pseudonim cu aceeaşi denumire", + "filter-type-single-entity": "O Singură Entitate", + "filter-type-entity-list": "Listă Entităţi", + "filter-type-entity-name": "Nume Entitate", + "filter-type-state-entity": "Entitate din starea panoului", + "filter-type-state-entity-description": "Entitate luată din parametrii stării panoului", + "filter-type-asset-type": "Tip Proprietate", + "filter-type-asset-type-description": "Proprietăţi de tip: '{{assetType}}'", + "filter-type-asset-type-and-name-description": "proprietăţi de tipul: '{{assetType}}' a căror denumire începe cu: '{{prefix}}'", + "filter-type-device-type": "Tip Dispozitiv", + "filter-type-device-type-description": "Dispozitive tip: '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispozitive Tip: '{{deviceType}}' a căror denumire începe cu: '{{prefix}}'", + "filter-type-entity-view-type": "Tip entitate definită", + "filter-type-entity-view-type-description": "Tip entități definite: '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Entități definite de tip: '{{entityView}}' a căror denumire începe cu : '{{prefix}}'", + "filter-type-relations-query": "Relaţii Interogare", + "filter-type-relations-query-description": "{{entities}} care au relații tip {{relationType}} în direcția {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Proprietate Asset search query", + "filter-type-asset-search-query-description": "Proprietăţi de tip {{assetTypes}} care au relații tip {{relationType}} în direcția {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Criteriu Căutare Dispozitiv", + "filter-type-device-search-query-description": "Dispozitive de tip {{deviceTypes}} care au relații tip {{relationType}} în direcția {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Criteriu căutare entitate definită", + "filter-type-entity-view-search-query-description": "Entități definite de tip {{entityViewTypes}} care au relații tip {{relationType}} în direcția {{direction}} {{rootEntity}}", + "entity-filter": "Filtru Entitate", + "resolve-multiple": "Rezolvă Ca Entităţi Multiple", + "filter-type": "Tip Filtru", + "filter-type-required": "Tipul filtrului este obligatoriu", + "entity-filter-no-entity-matched": "Nu au fost găsite entităţi corespunzătoare filtrului specificat", + "no-entity-filter-specified": "Nu a fost specificat filtru pentru entitate", + "root-state-entity": "Foloseşte Entitatea Stare Panou Ca Origine", + "root-entity": "Entitate Rădăcină", + "state-entity-parameter-name": "Nume Parametru Stare Entitate", + "default-state-entity": "Stare Entitate Implicită", + "default-entity-parameter-name": "Implicită", + "max-relation-level": "Nivel Maxim Relaţie", + "unlimited-level": "Nivel Nelimitat", + "state-entity": "Entitate Stare Panou", + "all-entities": "Toate Entităţile", + "any-relation": "Oricare" + }, + "asset": { + "asset": "Proprietate", + "assets": "Proprietăţi", + "management": "Administrare Proprietăți", + "view-assets": "Vezi Proprietăţi", + "add": "Adaugă Proprietate", + "assign-to-customer": "Repartizează Proprietate", + "assign-asset-to-customer": "Repartizează proprietăţi clientului", + "assign-asset-to-customer-text": "Selectează proprietăţile care vor fi repartizate clientului", + "no-assets-text": "Nu au fost găsite proprietăţi", + "assign-to-customer-text": "Selectează clientul căruia îi vor fi repartizate proprietăţile", + "public": "Publică", + "assignedToCustomer": "Repartizată clientului", + "make-public": "Declară proprietate publică", + "make-private": "Declară proprietate privată", + "unassign-from-customer": "Şterge repartizare client", + "delete": "Şterge proprietate", + "asset-public": "Proprietate publică", + "asset-type": "Tip Proprietate", + "asset-type-required": "Tipul proprietății este obligatoriu", + "select-asset-type": "Alege tipul proprietății", + "enter-asset-type": "Introdu tipul proprietății", + "any-asset": "Orice Proprietate", + "no-asset-types-matching": "Nu a fost găsită nicio proprietate conținând '{{entitySubtype}}'", + "asset-type-list-empty": "Nu a fost selectat niciun tip de proprietate", + "asset-types": "Tipuri Proprietate", + "name": "Nume Proprietate", + "name-required": "Numele este obligatoriu", + "description": "Descriere Proprietate", + "type": "Tip Proprietate", + "type-required": "Tipul proprietății este obligatoriu", + "details": "Detalii Proprietate", + "events": "Evenimente", + "add-asset-text": "Adaugă proprietate", + "asset-details": "Detalii proprietate", + "assign-assets": "Repartizează proprietăţi", + "assign-assets-text": "Repartizează { count, plural, 1 {o proprietate} other {# proprietăţi} } clientului", + "delete-assets": "Şterge proprietăţi", + "unassign-assets": "Şterge repartizare proprietăţi", + "unassign-assets-action-title": "Şterge repartizare { count, plural, 1 {o proprietate} other {# proprietăţi} } clientului", + "assign-new-asset": "Repartizează proprietate nouă", + "delete-asset-title": "Sigur vrei să ștergi '{{assetName}}'?", + "delete-asset-text": "ATENŢIE! După confirmare, proprietatea şi toate datele referitoare la aceasta, vor fi șterse IREVERSIBIL!", + "delete-assets-title": "Sigur vrei să ștergi { count, plural, 1 {o proprietate} other {# proprietăţi} }?", + "delete-assets-action-title": "Ştergi { count, plural, 1 {o proprietate} other {# proprietăţi} }", + "delete-assets-text": "ATENŢIE! După confirmare, toate proprietăţile selectate şi toate datele referitoare la aceastea, vor fi șterse IREVERSIBIL!", + "make-public-asset-title": "Sigur vrei ca proprietatea '{{assetName}}' să devină publică ", + "make-public-asset-text": "ATENŢIE! După confirmare, proprietatea selectată şi toate datele referitoare la aceasta vor putea fi accesate de către oricine", + "make-private-asset-title": "Sigur vrei ca proprietatea '{{assetName}}' să devină privată?", + "make-private-asset-text": "ATENŢIE! După confirmare, proprietatea selectată şi toate datele referitoare la aceasta vor putea fi accesate doar de către proprietar", + "unassign-asset-title": "Sigur vrei să ştergi repartizarea pentru proprietatea '{{assetName}}'?", + "unassign-asset-text": "ATENŢIE! După confirmare, repartizarea proprietăţii nu va mai putea fi accesată de către client", + "unassign-asset": "Şterge repartizare proprietate", + "unassign-assets-title": "Sigur vrei să ştergi repartizarea pentru { count, plural, 1 {o proprietate} other {# proprietăţi} }?", + "unassign-assets-text": "ATENŢIE! După confirmare, repartizările proprietăţilor selectate nu vor mai putea fi accesate de către client", + "copyId": "Copiază ID proprietate", + "idCopiedMessage": "ID-ul proprietăţii a fost copiat în clipboard", + "select-asset": "Selectează proprietate", + "no-assets-matching": "Nu au fost găsite proprietăţi al căror nume conține '{{entity}}'", + "asset-required": "Proprietatea este obligatorie", + "name-starts-with": "Numele proprietăţii începe cu", + "import": "Importă proprietăţi", + "asset-file": "Fişier Proprietăţi", + "label": "Eticheta" + }, + "attribute": { + "attributes": "Atribute", + "latest-telemetry": "Ultimele Date Telemetrice", + "attributes-scope": "Scop Atribute Entitate", + "scope-latest-telemetry": "Ultimele Date Telemetrice", + "scope-client": "Atribute Client", + "scope-server": "Atribute Server", + "scope-shared": "Atribute Partajate", + "add": "Adaugă Atribut", + "key": "Cheie", + "last-update-time": "Ultima Actualizare", + "key-required": "Cheia atributului este obligatorie", + "value": "Valoare", + "value-required": "Valoarea atributului este obligatorie", + "delete-attributes-title": "Sigur vrei să ștergi { count, plural, 1 {un atribut} other {# atribute} }?", + "delete-attributes-text": "ATENŢIE! După confirmare, toate atributele selectate vor fi şterse", + "delete-attributes": "Şterge Atribute", + "enter-attribute-value": "Specifică Valoarea Atributului", + "show-on-widget": "Afişează În Widget", + "widget-mode": "Modul Widget", + "next-widget": "Widget Următor ", + "prev-widget": "Widget Precedent", + "add-to-dashboard": "Adaugă în panou", + "add-widget-to-dashboard": "Adaugă Widget În Panou", + "selected-attributes": "{ count, plural, 1 {un atribut} other {# atribute} } selectate", + "selected-telemetry": "{ count, plural, 1 {o unitate telemetrică} other {# unităţi telemetrice} } selectate" + }, + "audit-log": { + "audit": "Audit", + "audit-logs": "Jurnale Audit", + "timestamp": "Cronologie", + "entity-type": "Tip Entitate", + "entity-name": "Denumire Entitate", + "user": "Utilizator", + "type": "Tip", + "status": "Stare", + "details": "Detalii", + "type-added": "Adăugat", + "type-deleted": "Şters", + "type-updated": "Actualizat", + "type-attributes-updated": "Atribute actualizate", + "type-attributes-deleted": "Atribute şterse", + "type-rpc-call": "RPC call", + "type-credentials-updated": "Acreditări actualizate", + "type-assigned-to-customer": "Repartizat către client", + "type-unassigned-from-customer": "Anulează Repartizarea Către Client", + "type-activated": "Activat", + "type-suspended": "Suspendat", + "type-credentials-read": "Acreditări citite", + "type-attributes-read": "Atribute citite", + "type-relation-add-or-update": "Relaţie actualizată", + "type-relation-delete": "Relaţie ștearsă", + "type-relations-delete": "Toate relaţiile șterse", + "type-alarm-ack": "Confirmat", + "type-alarm-clear": "Şters", + "type-login": "Intră În Cont", + "type-logout": "Parăseşte Contul", + "type-lockout": "Blocat", + "status-success": "Succes", + "status-failure": "Eșec", + "audit-log-details": "Detalii Jurnale Audit", + "no-audit-logs-prompt": "Nu Au Fost Găsite Jurnale", + "action-data": "Detalii Acțiune", + "failure-details": "Detalii Eșec", + "search": "Caută Jurnale Audit", + "clear-search": "Resetează Căutarea" + }, + "confirm-on-exit": { + "message": "Au rămas modificări nesalvate. Doriţi să părăsiţi această pagină fară a salva modificările?", + "html-message": "Au rămas modificări nesalvate.
    Doriţi să părăsiţi această pagină fară a salva modificările?", + "title": "Modificări Nesalvate" + }, + "contact": { + "country": "Ţară", + "city": "Oraş", + "state": "Judeţ", + "postal-code": "Cod Poştal", + "postal-code-invalid": "Cod poștal incorect", + "address": "Adresă 1", + "address2": "Adresă 2", + "phone": "Telefon", + "email": "eMail", + "no-address": "Fără Adresă" + }, + "common": { + "username": "Nume Utilizator", + "password": "Parola", + "enter-username": "Introdu nume utilizator", + "enter-password": "Introdu parola", + "enter-search": "Definește căutarea", + "created-time": "Data Creării" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "customer": { + "customer": "Client", + "customers": "Clienţi", + "management": "Administrare Clienţi", + "dashboard": "Panou Control Client", + "dashboards": "Panouri Control Clienţi", + "devices": "Dispozitive Client", + "entity-views": "Entități Definite Client", + "assets": "Proprietăţi Client", + "public-dashboards": "Panouri Control Publice", + "public-devices": "Dispozitive Publice", + "public-assets": "Proprietăţi Publice", + "public-entity-views": "Definiții Entități Publice Client", + "add": "Adaugă Client", + "delete": "Şterge Client", + "manage-customer-users": "Administrare Utilizatori Client", + "manage-customer-devices": "Administrare Dispozitive Client", + "manage-customer-dashboards": "Administrare Panouri Control Client", + "manage-public-devices": "Administrare Dispozitive Publice", + "manage-public-dashboards": "Administrare Panouri Control Publice", + "manage-customer-assets": "Administrare Proprietăţi Client", + "manage-public-assets": "Administrare Proprietăţi Publice", + "add-customer-text": "Adăugare Client Nou", + "no-customers-text": "Nu au fost găsiţi clienţi", + "customer-details": "Detalii Client", + "delete-customer-title": "Vrei să ștergi clientul '{{customerTitle}}'?", + "delete-customer-text": "ATENŢIE! După confirmare, clientul şi toate datele referitoare la acesta vor fi șterse IREVERSIBIL!", + "delete-customers-title": "Vrei să ștergi { count, plural, 1 {un client} other {# clienţi} }?", + "delete-customers-action-title": "Şterge { count, plural, 1 {un client} other {# clienţi} }", + "delete-customers-text": "ATENŢIE! După confirmare, toaţi clienţii selectate şi toate datele referitoare la aceaştia, vor fi șterse IREVERSIBIL!", + "manage-users": "Administrare Utilizatori", + "manage-assets": "Administrare Proprietăţi", + "manage-devices": "Administrare Dispozitive", + "manage-dashboards": "Administrare Panouri Control Publice", + "title": "Titlu", + "title-required": "Titlul este obligatoriu", + "description": "Descriere", + "details": "Detalii", + "events": "Evenimente", + "copyId": "Copie ID Client", + "idCopiedMessage": "ID Client A Fost Copiat In Clipboard", + "select-customer": "Selectează Client", + "no-customers-matching": "Niciun client nu se potrivește cu:'{{entity}}'", + "customer-required": "Clientul este obligatoriu", + "select-default-customer": "Selecteaza Client Implicit", + "default-customer": "Client Implicit", + "default-customer-required": "Clientul implicit este obligatoriu pentru a putea depana panoul de control la nivel de PROPRIETAR" + }, + "datetime": { + "date-from": "Dată început:", + "time-from": "Oră început:", + "date-to": "Dată sfârșit:", + "time-to": "Oră sfârșit:" + }, + "dashboard": { + "dashboard": "Panou", + "dashboards": "Panouri", + "management": "Administrare Panouri", + "view-dashboards": "Afişează Panouri", + "add": "Adaugă Panou", + "assign-dashboard-to-customer": "Repartizează Panou/Panouri Clientului", + "assign-dashboard-to-customer-text": "Selectează panourile care vor fi repartizate clientului", + "assign-to-customer-text": "Alege Clientul Căruia îi vor fi repartizate Panourile", + "assign-to-customer": "Repartizează Clientului", + "unassign-from-customer": "Şterge Repartizarea Către Client", + "make-public": "Declară Panou Public", + "make-private": "Declară Panou Privat", + "manage-assigned-customers": "Administrare Clienţi Repartizaţi", + "assigned-customers": "Clienţi Repartizaţi", + "assign-to-customers": "Repartizează Panouri Către Clienţi", + "assign-to-customers-text": "Alege clienţii cărora le vor fi repartizate panourile", + "unassign-from-customers": "Şterge repartizarea panourilor către clienţi", + "unassign-from-customers-text": "Alege clienţii cărora le va fi ștearsă repartizarea panourilor", + "no-dashboards-text": "Nu au fost găsite panouri", + "no-widgets": "Nu sunt widgeturi configurate", + "add-widget": "Adăugare Widget Nou", + "title": "Titlu Widget", + "select-widget-title": "Alege Widget", + "select-widget-subtitle": "Listă Tipuri Widget", + "delete": "Şterge Panou", + "title-required": "Titlul este obligatoriu", + "description": "Descriere", + "details": "Detalii", + "dashboard-details": "Detalii Panou", + "add-dashboard-text": "Adăugare Panou Nou", + "assign-dashboards": "Repartizare Panouri", + "assign-new-dashboard": "Repartizare Panou Nou", + "assign-dashboards-text": "Repartizează { count, plural, 1 {un panou} other {# panouri} } clienţilor", + "unassign-dashboards-action-text": "Şterge repartizare { count, plural, 1 {un panou} other {# panouri} } către clienți", + "delete-dashboards": "Şterge Panouri", + "unassign-dashboards": "Şterge Repartizare Panouri", + "unassign-dashboards-action-title": "Şterge Repartizare { count, plural, 1 {un panou} other {# panouri} } către client", + "delete-dashboard-title": "Vrei să ștergi panoul '{{dashboardTitle}}'?", + "delete-dashboard-text": "ATENŢIE! După confirmare, panoul și datele aferente acestuia vor fi șterse IREVERSIBIL!", + "delete-dashboards-title": "Vrei să ștergi { count, plural, 1 {un panou} other {# panouri} }?", + "delete-dashboards-action-title": "Ştergere { count, plural, 1 {un panou} other {# panouri} }", + "delete-dashboards-text": "ATENŢIE! După confirmare, panourile selectate şi datele aferente acestora vor fi șterse IREVERSIBIL!", + "unassign-dashboard-title": "Vrei să ştergi repartizarea panoului '{{dashboardTitle}}'?", + "unassign-dashboard-text": "ATENŢIE! După confirmare, panoul nu va mai putea fi accesat de către client", + "unassign-dashboard": "Şterge Repartizare Panou", + "unassign-dashboards-title": "Vrei să ştergi repartizarea a { count, plural, 1 {un panou} other {# panouri} }?", + "unassign-dashboards-text": "ATENŢIE! După confirmare, panoul nu va mai putea fi accesat de către client", + "public-dashboard-title": "Panoul a devenit public", + "public-dashboard-text": "Panoul tău {{dashboardTitle}} a devenit public şi este accesibil la link:", + "public-dashboard-notice": "Notă: Nu uitaţi să definiţi ca publice şi dispozitivele aferente acestui panou, pentru a le face vizibile", + "make-private-dashboard-title": "Doriţi să definiţi panoul '{{dashboardTitle}}' ca privat?", + "make-private-dashboard-text": "ATENŢIE! După confirmare, panoul va putea fi accesat doar de către proprietar", + "make-private-dashboard": "Declară Panou Privat", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Selectează Panou", + "no-dashboards-matching": "Nu au fost găsite panouri al căror nume conține '{{entity}}'", + "dashboard-required": "Panoul este obligatoriu", + "select-existing": "Selectează Un Panou Existent", + "create-new": "Creează Panou Nou", + "new-dashboard-title": "Denumire Panou Nou", + "open-dashboard": "Deschide Panou", + "set-background": "Setează Culoarea De Fundal (Background)", + "background-color": "Culoarea (Background)", + "background-image": "Imaginea (Background)", + "background-size-mode": "Mod Mărime (Background)", + "no-image": "Nicio imagine selectată", + "drop-image": "Trage o imagine sau alege un fişier", + "settings": "Setări", + "columns-count": "Număr Coloane", + "columns-count-required": "Numărul coloanelor este obligatoriu", + "min-columns-count-message": "Numărul minim permis de coloane este 10", + "max-columns-count-message": "Numărul maxim permis de coloane este 1000", + "widgets-margins": "Spaţiu vertical între widgeturi", + "horizontal-margin": "Spaţiu orizontal între widget-uri", + "horizontal-margin-required": "Valoarea spaţiului orizontal este obligatorie", + "min-horizontal-margin-message": "Valoarea minimă permisă pentru spaţiul orizontal între widgeturi este 0", + "max-horizontal-margin-message": "Valoarea maximă permisă pentru spaţiul orizontal între widgeturi este 0", + "vertical-margin": "Spaţiu vertical între widgeturi", + "vertical-margin-required": "Valoarea spaţiului vertical este obligatorie", + "min-vertical-margin-message": "Valoarea minimă permisă pentru spaţiul vertical între widgeturi este 0", + "max-vertical-margin-message": "Valoarea maximă permisă pentru spaţiul vertical între widgeturi este 50", + "autofill-height": "Auto Umplere Pe Înălţime", + "mobile-layout": "Setări Pagină Pentru Dispozitive Mobile", + "mobile-row-height": "Înălţimea liniei în pagina pentru dispozitive mobile (pixeli)", + "mobile-row-height-required": "Valoarea pentru înălţimea liniei este obligatorie", + "min-mobile-row-height-message": "Valoarea minimă permisă pentru înălţimea liniei în pagina pentru dispozitive mobile este 5 pixeli", + "max-mobile-row-height-message": "Valoarea maximă permisă pentru înălţimea liniei în pagina pentru dispozitive mobile este 200 pixeli", + "display-title": "Afişează Titlul Panoului", + "toolbar-always-open": "Menţine Deschisă Bara De Instrumente", + "title-color": "Culoare Titlu", + "display-dashboards-selection": "Afişează Selecţie Panouri", + "display-entities-selection": "Afişează Selecţie Entităţi", + "display-dashboard-timewindow": "Afişează Interval", + "display-dashboard-export": "Afişează Exportul", + "import": "Importă Panou", + "export": "Exportă Panou", + "export-failed-error": "Panoul nu poate fi exportat: {{error}}", + "create-new-dashboard": "Creează Panou Nou", + "dashboard-file": "Fişier Pentru Panou", + "invalid-dashboard-file-error": "Panoul nu poate fi importat; structură de date invalidă", + "dashboard-import-missing-aliases-title": "Configurează pseudonim pentru panoul importat", + "create-new-widget": "Creează Widget Nou", + "import-widget": "Importă Widget", + "widget-file": "Fişier Pentru Widget", + "invalid-widget-file-error": "Widgetul nu poate fi importat; structură de date invalidă", + "widget-import-missing-aliases-title": "Configurează pseudonim pentru widgetul importat", + "open-toolbar": "Deschide Bara De Instrumente Pentru Panou", + "close-toolbar": "Închide Bara De Instrumente", + "configuration-error": "Eroare De Configurare", + "alias-resolution-error-title": "Eroare configurare pseudonim panou", + "invalid-aliases-config": "Nu există nici un dispozitiv corespunzător filtrului de pseudonime.
    Pentru a rezolva această problemă, contactează administratorul", + "select-devices": "Selectează Dispozitiv", + "assignedToCustomer": "Repartizat Clientului", + "assignedToCustomers": "Repartizate Clienților", + "public": "Publice", + "public-link": "Adresă Pagină Publică", + "copy-public-link": "Copiază Adresa Paginii Publice", + "public-link-copied-message": "Adresa paginii publice a panoului a fost copiată în clipboard", + "manage-states": "Administrare Stări Panou", + "states": "Stări Panou", + "search-states": "Caută Stări Panou", + "selected-states": "{ count, plural, 1 {o stare panou} other {# stări panou} } selectate", + "edit-state": "Editează Stare Panou", + "delete-state": "Şterge Stare Panou", + "add-state": "Adaugă Stare Panou", + "state": "Stare Panou", + "state-name": "Denumire", + "state-name-required": "Denumirea stării panoului este obligatorie", + "state-id": "ID Stare Panou", + "state-id-required": "IDul Stării panoului este obligatoriu", + "state-id-exists": "O stare panou având acest ID este deja înregistrată", + "is-root-state": "Stare Rădăcină", + "delete-state-title": "Şterge Stare Panou", + "delete-state-text": "Sigur vrei să ștergi starea panou '{{stateName}}'?", + "show-details": "Afişează Detalii", + "hide-details": "Ascunde Detalii", + "select-state": "Selectează Stare Destinaţie", + "state-controller": "Controler Stări" + }, + "datakey": { + "settings": "Setări", + "advanced": "Avansat", + "label": "Etichetă", + "color": "Culoare", + "units": "Simbol special atașat valorii afişate", + "decimals": "Număr zecimale", + "data-generation-func": "Funcţie Generare Date", + "use-data-post-processing-func": "Utilizare Funcţie Post Procesare", + "configuration": "Configurare Chei Date", + "timeseries": "Serii Temporale", + "attributes": "Atribute", + "entity-field": "Câmp Entitate", + "alarm": "Câmpuri Alarmă", + "timeseries-required": "Seriile temporale pentru entitate sunt obligatorii", + "timeseries-or-attributes-required": "Seriile temporale sau atributele pentru entitate sunt obligatorii", + "maximum-timeseries-or-attributes": "{ count, plural, 1 { Este permisă maximum o serie temporală sau atribut} other {Sunt permise maximum # serii temporale sau atribute} }", + "alarm-fields-required": "Câmpurile pentru alarmă sunt obligatorii", + "function-types": "Tipuri Funcţie", + "function-types-required": "Tipurile de funcţie sunt obligatorii", + "maximum-function-types": "Maximum { count, plural, 1 {Este permis doar un tip de funcţie} other {Sunt permise doar # tipuri de funcţie} }", + "time-description": "Cronologie valoare curentă;", + "value-description": "valoare curentă;", + "prev-value-description": "Rezultat execuție funcţie precedentă;", + "time-prev-description": "Cronologie valoare precedentă;", + "prev-orig-value-description": "Valoare precedentă originală;" + }, + "datasource": { + "type": "Tip Sursă Date", + "name": "Denumire", + "add-datasource-prompt": "Adaugă Sursă Date" + }, + "details": { + "edit-mode": "Mod Editare", + "toggle-edit-mode": "Schimbă Mod Editare" + }, + "device": { + "device": "Dispozitiv", + "device-required": "Dispozitivul este obligatoriu", + "devices": "Dispozitive", + "management": "Administrare Dispozitive", + "view-devices": "Vizualizare Dispozitive", + "device-alias": "Pseudonim Dispozitiv", + "aliases": "Pseudonime Dispozitiv", + "no-alias-matching": "'{{alias}}' nu a fost găsit", + "no-aliases-found": "Nu au fost găsite pseudonime", + "no-key-matching": "'{{key}}' nu a fost găsită", + "no-keys-found": "Nu a fost găsită nicio cheie", + "create-new-alias": "Creează Pseudonim Nou", + "create-new-key": "Creează Cheie Nouă", + "duplicate-alias-error": "Pseudonimul ales este deja înregistrat : '{{alias}}'.
    Pseudonimele dispozitivelor trebuie să fie unice în acelaşi panou", + "configure-alias": "Configurează Pseudonim: '{{alias}}'", + "no-devices-matching": "Nu au fost găsite dispozitive al căror nume conține: '{{entity}}'", + "alias": "Pseudonim", + "alias-required": "Pseudonimul pentru dispozitiv este obligatoriu", + "remove-alias": "Şterge Pseudonim Dispozitiv", + "add-alias": "Adaugă Pseudonim Dispozitiv", + "name-starts-with": "Denumirea dispozitivului începe cu: ", + "device-list": "Listă Dispozitive", + "use-device-name-filter": "Utilizează Filtru Căutare", + "device-list-empty": "Nu este selectat niciun dispozitiv", + "device-name-filter-required": "Filtrul de căutare pentru nume dispozitiv este obligatoriu", + "device-name-filter-no-device-matched": "Nu au fost găsite dispozitive al căror nume începe cu '{{device}}'", + "add": "Adaugă Dispozitiv", + "assign-to-customer": "Repartizează Clientului", + "assign-device-to-customer": "Repartizează Dispozitiv(e) Clientului", + "assign-device-to-customer-text": "Selectează dispozitivele de repartizat clientului ", + "make-public": "Configurează Ca Dispozitiv Public", + "make-private": "Configurează Ca Dispozitiv Privat", + "no-devices-text": "Nu există dispozitive", + "assign-to-customer-text": "Selectrază clientul căruia să-i fie repartizat(e) dispozitiv(ele)", + "device-details": "Detalii Dispozitiv", + "add-device-text": "Adaugă Dispozitiv Nou", + "credentials": "Acreditări", + "manage-credentials": "Administrare Acreditări", + "delete": "Şterge Dispozitiv", + "assign-devices": "Repartizeză Dispozitive", + "assign-devices-text": "Repartizeză { count, plural, 1 {un dispozitiv} other {# dispozitive} } Clientului", + "delete-devices": "Şterge Dispozitive", + "unassign-from-customer": "Şterge repartizarea către client", + "unassign-devices": "Şterge repartizarea dispozitivelor", + "unassign-devices-action-title": "Şterge repartizarea a { count, plural, 1 {un dispozitiv} other {# dispozitive} } de la client", + "assign-new-device": "Repartizare Dispozitiv Nou", + "make-public-device-title": "Sigur vrei să faci dispozitivul '{{deviceName}}' public?", + "make-public-device-text": "După confirmare, dispozitivul și toate datele aferente acestuia vor fi făcute publice, fiind deci accesibile oricui", + "make-private-device-title": "Sigur vrei să faci dispozitivul '{{deviceName}}' privat?", + "make-private-device-text": "După confirmare, dispozitivul și toate datele aferente acestuia vor fi private, deci accesibile doar proprietarului", + "view-credentials": "Vezi Credențiale", + "delete-device-title": "Sigur vrei să ștergi dispozitivul '{{deviceName}}'?", + "delete-device-text": "ATENȚIE! După confirmare, dispozitivul împreună cu datele aferente acestuia vor fi șterse IREVERSIBIL!", + "delete-devices-title": "Sigur vrei să ștergi { count, plural, 1 {un dispozitiv} other {# dispozitive} }?", + "delete-devices-action-title": "Delete { count, plural, 1 {un dispozitiv} other {# dispozitive} }", + "delete-devices-text": "ATENȚIE! După confirmare, toate dispozitivele selectate, împreună cu datele aferente acestora, vor fi șterse IREVERSIBIL!", + "unassign-device-title": "Sigur vrei să ștergi repartizarea dispozitivului '{{deviceName}}'?", + "unassign-device-text": "ATENȚIE! După confirrmare, dispozitivul nu va mai fi accesibil clientului", + "unassign-device": "Șterge repartizare dispozitiv", + "unassign-devices-title": "Sigur vrei să ștergi repartizarea pentru { count, plural, 1 {un dispozitiv} other {# devices} }?", + "unassign-devices-text": "ATENȚIE! După confirmare, repartizarea dispozitivelor va fi ștearsă, acestea nemaifiind accesibile clientului", + "device-credentials": "Credențiale Dispozitiv", + "credentials-type": "Tip Credențiale", + "access-token": "Token Acces", + "access-token-required": "Tokenul de acces este necesar", + "access-token-invalid": "Dimensiunea tokenului de acces trebuie să fie de 1-32 caractere", + "secret": "Cod Secret", + "secret-required": "Codul secret este necesar", + "device-type": "Tip Dispozitiv", + "device-type-required": "Tipul dispozitivului este obligatoriu", + "select-device-type": "Alege tipul dispozitivului", + "enter-device-type": "Introdu tipul dispozitivului", + "any-device": "Orice Dispozitiv", + "no-device-types-matching": "Nu au fost găsite dispozitive de tip '{{entitySubtype}}' ", + "device-type-list-empty": "Nu au fost selectate tipuri dispozitiv", + "device-types": "Tipuri dispozitiv", + "name": "Nume", + "name-required": "Numele este obligatoriu", + "description": "Descriere", + "label": "Etichetă", + "events": "Evenimente", + "details": "Detalii", + "copyId": "Copie ID Dispozitiv", + "copyAccessToken": "Copiază Token Acces", + "idCopiedMessage": "ID dispozitiv copiat în clipboard", + "accessTokenCopiedMessage": "Token acces dispozitiv copiat în clipboard", + "assignedToCustomer": "Repartizat clientului", + "unable-delete-device-alias-title": "Pseudonimul dispozitivului nu poate fi șters", + "unable-delete-device-alias-text": "Pseudonimul dispozitivului '{{deviceAlias}}' nu poate fi șters, întrucât este folosit de widget(urile):
    {{widgetsList}}", + "is-gateway": "Este gateway", + "public": "Public", + "device-public": "Dispozitivul este public", + "select-device": "Selectează Dispozitiv", + "import": "Importă Dispozitiv", + "device-file": "Fișier dispozitiv" + }, + "dialog": { + "close": "Închide Casetă Dialog" + }, + "direction": { + "column": "Coloană", + "row": "Linie" + }, + "error": { + "unable-to-connect": "Conexiunea cu serverul imposibilă! Ești conectat la Internet?", + "unhandled-error-code": "Cod eroare negestionată: {{errorCode}}", + "unknown-error": "Eroare necunoscută" + }, + "entity": { + "entity": "Entitate", + "entities": "Entități", + "aliases": "Pseudonime Entități", + "entity-alias": "Pseudonim Entitate", + "unable-delete-entity-alias-title": "Ștergerea pseudonimului entității este imposibilă", + "unable-delete-entity-alias-text": "Pseudonimul entității '{{entityAlias}}', fiind folosit de widgetul/widgeturile:
    {{widgetsList}}", + "duplicate-alias-error": "Pseudonimul entității este duplicat
    Pseudonimele entităților trebuie să fie unice, în același panou", + "missing-entity-filter-error": "Lipsă filtru pentru pseudonimul '{{alias}}'", + "configure-alias": "Configurează pseudonimul '{{alias}}'", + "alias": "Pseudonim", + "alias-required": "Pseudonimul entității este necesar", + "remove-alias": "șterge Alias Entitate", + "add-alias": "Adaugă Alias Entitate", + "entity-list": "Listă Entități", + "entity-type": "Tip Entitate", + "entity-types": "Tipuri Entități", + "entity-type-list": "Listă Tipuri Entități", + "any-entity": "Orice Entitate", + "enter-entity-type": "Introdu Tip Entitate", + "no-entities-matching": "Nu a fost găsită nicio entitate conținând '{{entity}}' ", + "no-entity-types-matching": "Nu au fost găsite tipuri de entități conținând '{{entityType}}'", + "name-starts-with": "Numele începe cu", + "use-entity-name-filter": "Folosește filtru", + "entity-list-empty": "Nicio entitate selectată", + "entity-type-list-empty": "Niciun tip entitate selectat", + "entity-name-filter-required": "Filtrul pentru nume entitate este necesar", + "entity-name-filter-no-entity-matched": "Nu a fost găsită nicio entitate al cărei nume începe cu '{{entity}}'", + "all-subtypes": "Toate", + "select-entities": "Selectează entități", + "no-aliases-found": "Niciun pseudonim găsit", + "no-alias-matching": "Nu am găsit pseudonimul '{{alias}}'", + "create-new-alias": "Creează unul nou!", + "key": "Cheie", + "key-name": "Nume Cheie", + "no-keys-found": "Nicio cheie găsită", + "no-key-matching": "Nu am găsit cheia '{{key}}'", + "create-new-key": "Creează una nouă!", + "type": "Tip", + "type-required": "Tipul entității este necesar", + "type-device": "Dispozitiv", + "type-devices": "Dispozitive", + "list-of-devices": "{ count, plural, 1 {un dispozitiv} other {Listă # dispozitive} }", + "device-name-starts-with": "Dispozitive a căror nume începe cu '{{prefix}}'", + "type-asset": "Proprietate", + "type-assets": "Proprietăți", + "list-of-assets": "{ count, plural, 1 {o proprietate} other {Listă # proprietăți} }", + "asset-name-starts-with": "Proprietate a cărei nume începe cu '{{prefix}}'", + "type-entity-view": "Entitate Definită", + "type-entity-views": "Entități Definite", + "list-of-entity-views": "{ count, plural, 1 {o entitate definită} other {Listă # entități definite} }", + "entity-view-name-starts-with": "Entități definite al căror nume începe cu '{{prefix}}'", + "type-rule": "Regulă", + "type-rules": "Reguli", + "list-of-rules": "{ count, plural, 1 {o regulă} other {Listă # reguli} }", + "rule-name-starts-with": "Reguli al căror nume începe cu '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugin-uri", + "list-of-plugins": "{ count, plural, 1 {un plugin} other {Listă # plugin-uri} }", + "plugin-name-starts-with": "Plugin-uri al căror nume începe cu '{{prefix}}'", + "type-tenant": "Locatar", + "type-tenants": "Locatari", + "list-of-tenants": "{ count, plural, 1 {un locatar} other {Listă # locatari} }", + "tenant-name-starts-with": "Locatari al căror nume începe cu '{{prefix}}'", + "type-customer": "Client", + "type-customers": "Clienţi", + "list-of-customers": "{ count, plural, 1 {un client} other {Listă # Clienţi} }", + "customer-name-starts-with": "Clienţi al căror nume începe cu '{{prefix}}'", + "type-user": "Utilizator", + "type-users": "Utilizatori", + "list-of-users": "{ count, plural, 1 {un utilizator} other {Listă # utilizatori} }", + "user-name-starts-with": "Utilizatori al căror nume începe cu '{{prefix}}'", + "type-dashboard": "Panou Control", + "type-dashboards": "Panouri Control", + "list-of-dashboards": "{ count, plural, 1 {un panou control} other {Listă # panouri control} }", + "dashboard-name-starts-with": "Panouri control al căror nume începe cu '{{prefix}}'", + "type-alarm": "Alarmă", + "type-alarms": "Alarme", + "list-of-alarms": "{ count, plural, 1 {o alarmă} other {Listă # alarme} }", + "alarm-name-starts-with": "Alarme al căror nume începe cu '{{prefix}}'", + "type-rulechain": "Flux", + "type-rulechains": "Fluxuri", + "list-of-rulechains": "{ count, plural, 1 {un flux} other {Listă # fluxuri} }", + "rulechain-name-starts-with": "Fluxuri al căror nume începe cu '{{prefix}}'", + "type-rulenode": "Nod flux", + "type-rulenodes": "Noduri flux", + "list-of-rulenodes": "{ count, plural, 1 {un nod flux} other {Listă # noduri flux} }", + "rulenode-name-starts-with": "Noduri flux al căror nume începe cu '{{prefix}}'", + "type-current-customer": "Client Curent", + "search": "Caută Entități", + "selected-entities": "{ count, plural, 1 {o entitate} other {# entități} } selectate", + "entity-name": "Nume Entitate", + "entity-label": "Etichetă Entitate", + "details": "Detalii Entitate", + "no-entities-prompt": "Nu au fost găsite entități", + "no-data": "Nu există date de afișat", + "columns-to-display": "Coloane Afișate" + }, + "entity-field": { + "created-time": "Momentul Creării", + "name": "Denumire", + "type": "Tip", + "first-name": "Prenume", + "last-name": "Nume", + "email": "eMail", + "title": "Formula De Adresare", + "country": "Ţara", + "state": "Judeţ", + "city": "Oraş", + "address": "Adresa 1", + "address2": "Adresa 2", + "zip": "Cod Poştal", + "phone": "Telefon", + "label": "Etichetă" + }, + "entity-view": { + "entity-view": "Entitate Definită", + "entity-view-required": "Entitatea definită este obligatorie", + "entity-views": "Entități Definite", + "management": "Administrare Entități Definite", + "view-entity-views": "Afişează Entități Definite", + "entity-view-alias": "Pseudonim Entitate Definită", + "aliases": "Pseudonime Entități Definite", + "no-alias-matching": "'{{alias}}' Pseudonimul nu a fost găsit", + "no-aliases-found": "Nu au fost găsite pseudonime", + "no-key-matching": "Cheia '{{key}}' nu a fost găsită", + "no-keys-found": "Nu a fost găsită nicio cheie", + "create-new-alias": "Creează Alias Nou", + "create-new-key": "Creează Cheie Nouă", + "duplicate-alias-error": "Pseudonimul: '{{alias}}' este deja înregistrat
    Pseudonimele pentru entităţi trebuie să fie unice în acelaşi panou", + "configure-alias": "Configurează pseudonim'{{alias}}'", + "no-entity-views-matching": "Nu au fost găsite entități definite după criteriul: '{{entity}}'", + "alias": "Pseudonim", + "alias-required": "Pseudonimul entității definite este obligatoriu", + "remove-alias": "Şterge pseudonim entitate definită", + "add-alias": "Adaugă pseudonim entitate definită", + "name-starts-with": "Numele entității definite începe cu ", + "entity-view-list": "Listă Entități Definite", + "use-entity-view-name-filter": "Filtrează", + "entity-view-list-empty": "Nu au fost selectate entități definite", + "entity-view-name-filter-required": "Filtrul pentru numele entității definite este obligatoriu", + "entity-view-name-filter-no-entity-view-matched": "Nu au fost găsite entități definite al căror nume conține: '{{entityView}}'", + "add": "Adaugă Entitate Definită", + "assign-to-customer": "Repartizare către client", + "assign-entity-view-to-customer": "Repartizare Entități Definite Clientului", + "assign-entity-view-to-customer-text": "Selectează entitățile definite ce vor fi repartizate clientului", + "no-entity-views-text": "Nu există entități definite", + "assign-to-customer-text": "Selectează clientul căruia îi vor fi repartizate entitățile definite", + "entity-view-details": "Detalii Entitate Definită", + "add-entity-view-text": "Adaugă Entitate Definită", + "delete": "ßterge Entitate Definită", + "assign-entity-views": "Repartizează Entitate Definită", + "assign-entity-views-text": "Repartizează { count, plural, 1 {o entitate definită} other {# entități definite} } clientului", + "delete-entity-views": "Şterge Entități Definite", + "unassign-from-customer": "Ştergere Repartizare Către Client", + "unassign-entity-views": "Ştergere Repartizare Entități Definite", + "unassign-entity-views-action-title": "Şterge repartizare { count, plural, 1 {o entitate definită} other {# entități definite} } de la client ", + "assign-new-entity-view": "Repartizează Entitate Definită Nouă", + "delete-entity-view-title": "Sigur vrei să ștergi entitatea definită : '{{entityViewName}}'?", + "delete-entity-view-text": "ATENŢIE! După confirmare, entitatea definită şi toate datele asociate cu aceasta vor fi șterse IREVERSIBIL!", + "delete-entity-views-title": "Sigur vrei să ștergi{ count, plural, 1 {o entitate definită} other {# entități definite} }?", + "delete-entity-views-action-title": "Şterge { count, plural, 1 {o entitate definită} other {# entități definite} }", + "delete-entity-views-text": "ATENŢIE! După confirmare, toate entitățile definite și datele asociate acestora vor fi șterse IREVERSIBIL!", + "unassign-entity-view-title": "Sigur vrei să ștergi repartizarea entității definite : '{{entityViewName}}'?", + "unassign-entity-view-text": "ATENŢIE! După confirmare, clientul nu va mai putea accesa entitatea definită selectată", + "unassign-entity-view": "Şterge Repartizare Entitate Definită", + "unassign-entity-views-title": "Sigur vrei să ștergi repartizarea a { count, plural, 1 {o entitate definită} other {# entități definite} }?", + "unassign-entity-views-text": "ATENŢIE! După confirmare, clientul nu va mai putea accesa entitățile definite selectate", + "entity-view-type": "Tip Entitate Definită", + "entity-view-type-required": "Tipul ecranului pentru entitate este obligatoriu", + "select-entity-view-type": "Selectaţi Tipul Ecranului Pentru Entitate", + "enter-entity-view-type": "Introduceţi Tipul Ecranului Pentru Entitate", + "any-entity-view": "Orice Entitate Definită", + "no-entity-view-types-matching": "Nu au fost găsite tipuri de entitate definită care să corespundă criteriului '{{entitySubtype}}'", + "entity-view-type-list-empty": "Nu au fost selectate tipuri de entitate definită", + "entity-view-types": "Tipuri Entitate Definită", + "name": "Denumire", + "name-required": "Denumirea este obligatorie", + "description": "Descriere", + "events": "Evenimente", + "details": "Detalii", + "copyId": "Copie ID Entitate Definită", + "assignedToCustomer": "Repartizat Clientului", + "unable-entity-view-device-alias-title": "Pseudonimul entității definite nu poate fi şters", + "unable-entity-view-device-alias-text": "Pseudonimul dispozitivului : '{{entityViewAlias}}' nu poate fi șters, fiind folosit de widgetul/widgeturile :
    {{widgetsList}}", + "select-entity-view": "Selectează Entitate Definită", + "make-public": "Declară Entitate Definită Publică", + "make-private": "Declară Entitate Definită Privată", + "start-date": "Dată Început", + "start-ts": "Oră Început", + "end-date": "Dată Sfârșit", + "end-ts": "Oră Sfârșit", + "date-limits": "Limite Dată", + "client-attributes": "Atribute Client", + "shared-attributes": "Atribute Partajate", + "server-attributes": "Atribute Server", + "timeseries": "Serii Temporale", + "client-attributes-placeholder": "Atribute Client", + "shared-attributes-placeholder": "Atribute Partajate", + "server-attributes-placeholder": "Atribute Server", + "timeseries-placeholder": "Serii Temporale", + "target-entity": "Entitate Destinaţie", + "attributes-propagation": "Propagare Atribute", + "attributes-propagation-hint": "Entitatea definită va copia automat atributele specificate de la entitatea destinaţie, de fiecare dată când este salvată sau actualizată. Din motive de performanţă, atributele entităţii de destinaţie nu sunt propagate către entitatea definită la fiecare modificare de atribut. Poți activa propagarea automată a atributelor prin configurarea nodului în mod \"copiază pentru a vedea\" în fluxul tău și conectarea mesajelor \"Post Atribute\" și \"Atribute Actualizate\" la noul nod", + "timeseries-data": "Date Serii Temporale", + "timeseries-data-hint": "Configurează intervale timp pentru seriile temporale ale entităţii destinaţie, care vor fi accesibile prin entitatea definită. Acestea nu vor putea fi modificate", + "make-public-entity-view-title": "Sigur vrei să faci publică entitatea definită: '{{entityViewName}}'?", + "make-public-entity-view-text": "ATENŢIE! După confirmare, entitatea definită și datele aferente acesteia vor deveni publice, deci accesibile oricui", + "make-private-entity-view-title": "Sigur vrei să faci privată entitatea definită: '{{entityViewName}}'?", + "make-private-entity-view-text": "ATENŢIE! După confirmare, entitatea definită şi toate datele aferente acesteia, vor deveni private, la ele având acces doar proprietarul" + }, + "event": { + "event-type": "Tip Eveniment", + "type-error": "Eroare", + "type-lc-event": "Durată Eveniment", + "type-stats": "Statistică", + "type-debug-rule-node": "Depanare", + "type-debug-rule-chain": "Depanare", + "no-events-prompt": "Nu au fost găsite evenimente", + "error": "Eroare", + "alarm": "Alarmă", + "event-time": "Oră Eveniment", + "server": "Server", + "body": "Corp", + "method": "Metodă", + "type": "Tip", + "message-id": "ID Mesaj", + "message-type": "Tip Mesaj", + "data-type": "Tip Date", + "relation-type": "Tip Relaţie", + "metadata": "Metadata", + "data": "Date", + "event": "Eveniment", + "status": "Stare", + "success": "Succes", + "failed": "Eşuat", + "messages-processed": "Mesaje procesate", + "errors-occurred": "Au apărut erori", + "all-events": "Toate", + "entity-type": "Tip Entitate" + }, + "extension": { + "extensions": "Extensii", + "selected-extensions": "{ count, plural, 1 {o extensie selectată} other {# extensii selectate} }", + "type": "Tip", + "key": "Cheie", + "value": "Valoare", + "id": "ID", + "extension-id": "ID Extensie", + "extension-type": "Tip Extensie", + "transformer-json": "JSON *", + "unique-id-required": "ID-ul curent pentru extensie este deja înregistrat", + "delete": "Şterge Extensie", + "add": "Adaugă Extensie", + "edit": "Editează Extensie", + "delete-extension-title": "Sigur vrei să ștergi extensia: '{{extensionId}}'?", + "delete-extension-text": "ATENŢIE! După confirmare, extensia şi toate datele asociate acesteia, vor fi șterse IREVERSIBIL!", + "delete-extensions-title": "Sigur vrei să ștergi { count, plural, 1 {o extensie} other {# extensii} }?", + "delete-extensions-text": "ATENŢIE! După confirmare, toate extensiile selectate vor fi șterse IREVERSIBIL!", + "converters": "Convertoare", + "converter-id": "ID Convertoare", + "configuration": "Configurare", + "converter-configurations": "Configurator Convertoare", + "token": "Token De Securitate", + "add-converter": "Adaugă Convertor", + "add-config": "Adaugă Configurator Convertoare", + "device-name-expression": "Expresie Denumire Dispozitiv", + "device-type-expression": "Expresie Tip Dispozitiv", + "custom": "Definit De Utilizator", + "to-double": "To Double", + "transformer": "Transformare", + "json-required": "Este necesar transformer json", + "json-parse": "Analiză transformer json imposibilă", + "attributes": "Atribute", + "add-attribute": "Adaugă Atribut", + "add-map": "Adaugă Mapare", + "timeseries": "Date Cronologice", + "add-timeseries": "Adaugă Set Date Cronologice", + "field-required": "Câmpul Este Obligatoriu", + "brokers": "Brokeri", + "add-broker": "Adaugă Broker", + "host": "Gazdă", + "port": "Port", + "port-range": "Valoarea portului trebuie să fie în intervalul 1 - 65535", + "ssl": "SSL", + "credentials": "Acreditări", + "username": "Nume Utilizator", + "password": "Parolă", + "retry-interval": "Interval Reîncercare (milisecunde)", + "anonymous": "Anonim", + "basic": "Bazic", + "pem": "PEM", + "ca-cert": "Fişier Certificat CA", + "private-key": "Fişier Cheie Privată *", + "cert": "Fişier Certificat *", + "no-file": "Niciun fișier selectat", + "drop-file": "Trageţi un fişier sau selectaţi cu mouseul un fişier pentru a fi încărcat", + "mapping": "Mapare", + "topic-filter": "Filtru Topic", + "converter-type": "Tipul Convertor", + "converter-json": "Json", + "json-name-expression": "Expresie JSON Pentru Nume Dispozitiv", + "topic-name-expression": "Expresie TOPIC Pentru Nume Dispozitiv", + "json-type-expression": "Expresie JSON Pentru Tip Dispozitiv", + "topic-type-expression": "Expresie TOPIC Pentru Tip Dispozitiv", + "attribute-key-expression": "Expresie Cheie Atribut", + "attr-json-key-expression": "Expresie JSON Cheie Atribut", + "attr-topic-key-expression": "Expresie Topic Cheie Atribut", + "request-id-expression": "Expresie Cerere ID", + "request-id-json-expression": "Expresie JSON Cerere ID", + "request-id-topic-expression": "Expresie TOPIC Cerere ID", + "response-topic-expression": "Expresie Răspuns Topic", + "value-expression": "Valoare Expresie", + "topic": "Topic", + "timeout": "Timp De Expirare (milisecunde)", + "converter-json-required": "Convertorul JSON este obligatoriu", + "converter-json-parse": "Analiză converter JSON imposibilă", + "filter-expression": "Filtrează expresie", + "connect-requests": "Solicitări Conectare", + "add-connect-request": "Adaugă Solicitare Conectare", + "disconnect-requests": "Solicitări Deconectare", + "add-disconnect-request": "Adaugă Solicitare Deconectare", + "attribute-requests": "Solicitări Atribut", + "add-attribute-request": "Adaugă Solicitare Atribut", + "attribute-updates": "Actualizări Atribut", + "add-attribute-update": "Adaugă Actualizare Atribut", + "server-side-rpc": "Server RPC", + "add-server-side-rpc-request": "Adaugă Solicitare RPC Server-Side", + "device-name-filter": "Filtru Nume Dispozitiv", + "attribute-filter": "Filtru Atribut", + "method-filter": "Filtru Metodă", + "request-topic-expression": "Solicită expresie topic", + "response-timeout": "Timp răspuns în milisecunde", + "topic-expression": "Expresie Topic", + "client-scope": "Scop Client", + "add-device": "Adaugă Dispozitiv", + "opc-server": "Servere", + "opc-add-server": "Adaugă Server", + "opc-add-server-prompt": "Te rog, adaugă server ", + "opc-application-name": "Nume Aplicație", + "opc-application-uri": "URI Aplicație", + "opc-scan-period-in-seconds": "Perioadă Scanare (secunde)", + "opc-security": "Securitate", + "opc-identity": "Identitate", + "opc-keystore": "Keystore", + "opc-type": "Tip OPC", + "opc-keystore-type": "Tip Keystore", + "opc-keystore-location": "Localizare *", + "opc-keystore-password": "Parolă", + "opc-keystore-alias": "Pseudonim", + "opc-keystore-key-password": "Cheie Parolă", + "opc-device-node-pattern": "Structură Nod Dispozitiv", + "opc-device-name-pattern": "Structură Nume Dispozitiv", + "modbus-server": "Servere/sclavi", + "modbus-add-server": "Adaugă server/sclav", + "modbus-add-server-prompt": "Te rog adaugă server/sclav", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Reconectare Automată", + "modbus-rtu-over-tcp": "RTU peste TCP", + "modbus-port-name": "Nume Port Serial", + "modbus-encoding": "Codificare", + "modbus-parity": "Paritate", + "modbus-baudrate": "Rată Baud", + "modbus-databits": "Biți Date", + "modbus-stopbits": "Biți Stop", + "modbus-databits-range": "Bitii de date trebuie să fie în intervalul 7-8", + "modbus-stopbits-range": "Bitii de stop trebuie să fie în intervalul 1-2", + "modbus-unit-id": "ID Unitate", + "modbus-unit-id-range": "ID-ul unității trebuie să fie în intervalul 1-247", + "modbus-device-name": "Nume Dispozitiv", + "modbus-poll-period": "Perioadă Interogare (ms)", + "modbus-attributes-poll-period": "Perioadă Interogare Atribute (ms)", + "modbus-timeseries-poll-period": "Perioadă Interogare serii temporale (ms)", + "modbus-poll-period-range": "Perioada de interogare trebuie să fie pozitivă", + "modbus-tag": "Tag", + "modbus-function": "Funcție", + "modbus-register-address": "Adresă Registru", + "modbus-register-address-range": "Adresa registrului trebuie să fie în intervalul 0-65535", + "modbus-register-bit-index": "Index biți", + "modbus-register-bit-index-range": "Indexul biților trebuie să fie în intervalul 0-15", + "modbus-register-count": "Număr Regiștri", + "modbus-register-count-range": "Numărul regiștrilor trebuie să fie pozitiv", + "modbus-byte-order": "Ordine Bytes", + "sync": { + "status": "Stare", + "sync": "Sincronizat", + "not-sync": "Nesincronizat", + "last-sync-time": "Ultima Sincronizare", + "not-available": "Indisponibil" + }, + "export-extensions-configuration": "Exportă configuraţie extensii", + "import-extensions-configuration": "Importă configuraţie extensii", + "import-extensions": "Importă Extensii", + "import-extension": "Importă Extensie", + "export-extension": "Exportă Extensie", + "file": "Fişier Extensii", + "invalid-file-error": "Fişier extensie invalid" + }, + "fullscreen": { + "expand": "Extinde Către Ecran Complet", + "exit": "Ieşire Din Ecran Complet", + "toggle": "Comutare Ecran Complet", + "fullscreen": "Ecran Complet" + }, + "function": { + "function": "Funcţie" + }, + "grid": { + "delete-item-title": "Sigur vrei să ștergi elementul?", + "delete-item-text": "ATENŢIE! După confirmare, elementul şi toate datele referitoare la acesta, vor fi șterse IREVERSIBIL!", + "delete-items-title": "Sigur vrei să ștergi { count, plural, 1 {un element} other {# elemente} }?", + "delete-items-action-title": "Şterge { count, plural, 1 {un element} other {# elemente} }", + "delete-items-text": "ATENŢIE! După confirmare, toate elementele selectate şi toate datele referitoare la acestea, vor fi șterse IREVERSIBIL!", + "add-item-text": "Adaugă Element Nou", + "no-items-text": "Nu Au Fost Găsite Elemente", + "item-details": "Detalii Element", + "delete-item": "Şterge Element", + "delete-items": "Şterge Elemente", + "scroll-to-top": "Derulare la începutul listei" + }, + "help": { + "goto-help-page": "Mergi la pagina de ajutor" + }, + "home": { + "home": "Acasă", + "profile": "Profil", + "logout": "Deconectare", + "menu": "Meniu", + "avatar": "Avatar", + "open-user-menu": "Deschide Meniu Utilizator" + }, + "import": { + "no-file": "Niciun fișier selectat", + "drop-file": "Trage un fişier de tip JSON sau selectează cu mausul un fişier de tip JSON pentru a fi încărcat", + "drop-file-csv": "Trage un fişier de tip CSV sau selectează cu mouse-ul un fişier de tip csv pentru a fi încărcat", + "column-value": "Valoare", + "column-title": "Denumire", + "column-example": "Exemplu valori date", + "column-key": "Cheie Atribut/Telemetrie", + "csv-delimiter": "Delimitator CSV", + "csv-first-line-header": "Prima linie conţine denumiri de coloane", + "csv-update-data": "Actualizare atribute/telemetrie", + "import-csv-number-columns-error": "Un fișier trebuie să conțină cel puțin două coloane", + "import-csv-invalid-format-error": "Format fișier incorect, linia: '{{line}}'", + "column-type": { + "name": "Denumire", + "type": "Tip", + "label": "Etichetă", + "column-type": "Tipul Coloanei", + "client-attribute": "Atribut Client", + "shared-attribute": "Atribut Partajat", + "server-attribute": "Atribut Server", + "timeseries": "Date Cronologice", + "entity-field": "Câmp Entitate", + "access-token": "Token De Acces" + }, + "stepper-text": { + "select-file": "Selectează un fişier", + "configuration": "Importă configurație", + "column-type": "Selectează tipul de coloane", + "creat-entities": "Creează entităţi noi" + }, + "message": { + "create-entities": "{{count}} entităţi noi au fost create cu succes", + "update-entities": "{{count}} entităţi noi au fost actualizate cu succes", + "error-entities": "A intervenit o eroare la crearea a {{count}} entităţi" + } + }, + "item": { + "selected": "Selectat" + }, + "js-func": { + "no-return-error": "Funcţia trebuie să returneze o valoare", + "return-type-mismatch": "Funcţia trebuie să returneze o valoare de tip: '{{type}}'", + "tidy": "Tidy" + }, + "key-val": { + "key": "Cheie", + "value": "Valoare", + "remove-entry": "Șterge Intrare", + "add-entry": "Adaugă Intrare", + "no-data": "Nu sunt intrări" + }, + "layout": { + "layout": "Amplasament", + "manage": "Modifică Amplasamente", + "settings": "Configurare Amplasamente", + "color": "Culoare", + "main": "Principal", + "right": "Dreapta", + "select": "Alege Amplasament Țintă" + }, + "legend": { + "direction": "Direcţie Legendă", + "position": "Poziţie Legendă", + "show-max": "Afişează Valoare Maximă", + "show-min": "Afişează Valoare Minimă", + "show-avg": "Afişează Valoare Medie", + "show-total": "Afişează Valoare Totală", + "settings": "Setări Legendă", + "min": "Minim", + "max": "Maxim", + "avg": "Medie", + "total": "Total", + "comparison-time-ago": { + "days": "(Ziua Trecută (Ieri))", + "weeks": "(Săptamâna Trecută)", + "months": "(Luna Trecută)", + "years": "(Anul Trecut)" + } + }, + "login": { + "login": "Conectare", + "request-password-reset": "Solicită Resetarea Parolei", + "reset-password": "Resetează Parolă", + "create-password": "Creează Parolă", + "passwords-mismatch-error": "Parola reintrodusă trebuie să fie identică!", + "password-again": "Rescrie Parola", + "sign-in": "Intră în Cont", + "username": "Nume Utilizator (Adresa De eMail)", + "remember-me": "Ține-mă minte!", + "forgot-password": "Ai Uitat Parola?", + "password-reset": "Resetează Parola", + "expired-password-reset-message": "Parola ta a expirat! Este necesară schimbarea acesteia", + "new-password": "Parolă nouă", + "new-password-again": "Verificare parolă nouă", + "password-link-sent-message": "Ți-am trimis pe eMail un link pentru resetarea parolei", + "email": "eMail", + "login-with": "Conectare cu {{name}}", + "or": "sau" + }, + "position": { + "top": "Sus", + "bottom": "Jos", + "left": "Stânga", + "right": "Dreapta" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Ultima Accesare", + "change-password": "Schimbă Parola", + "current-password": "Parola Actuală" + }, + "relation": { + "relations": "Relaţii", + "direction": "Direcţie", + "search-direction": { + "FROM": "Dinspre", + "TO": "Înspre" + }, + "direction-type": { + "FROM": "Dinspre", + "TO": "Către" + }, + "from-relations": "Ieșire", + "to-relations": "Intrare", + "selected-relations": "{ count, plural, 1 {o relaţie selectată } other {# relaţii selectate } }", + "type": "Tip", + "to-entity-type": "Către Tip Entitate", + "to-entity-name": "Către Nume Entitate", + "from-entity-type": "Dinspre Tip Entitate", + "from-entity-name": "Dinspre Nume Entitate", + "to-entity": "Către Entitate", + "from-entity": "Dinspre Entitate", + "delete": "Şterge Relaţie", + "relation-type": "Tip Relaţie", + "relation-type-required": "Tipul relației este obligatoriu", + "any-relation-type": "Orice Tip", + "add": "Adaugă Relaţie", + "edit": "Şterge Relaţie", + "delete-to-relation-title": "Sigur vrei să ștergi relația către entitatea '{{entityName}}'?", + "delete-to-relation-text": "ATENŢIE! După confirmare,relaţia către entitatea '{{entityName}}' va fi ştearsă", + "delete-to-relations-title": "Sigur vrei să ștergi { count, plural, 1 {o relaţie} other {# relaţii} }?", + "delete-to-relations-text": "ATENŢIE! După confirmare, relaţiile selectate către entităţile corespondente și toate referirile la acestea, vor fi șterse IREVERSIBIL!", + "delete-from-relation-title": "Sigur vrei să ștergi relația dinspre entitatea '{{entityName}}'?", + "delete-from-relation-text": "ATENŢIE! După confirmare,relaţia dinspre entitatea '{{entityName}}' va fi ștearsă", + "delete-from-relations-title": "Sigur vrei să ștergi { count, plural, 1 {o relaţie} other {# relaţii} }?", + "delete-from-relations-text": "ATENŢIE! După confirmare, relaţiile selectate către entităţile corespondente și toate referirile la acestea, vor fi șterse IREVERSIBIL!", + "remove-relation-filter": "Elimină Filtru Relaţie", + "add-relation-filter": "Adaugă Filtru Relaţie", + "any-relation": "Orice Relaţie", + "relation-filters": "Filtre Relaţie", + "additional-info": "Informaţii Adiţionale (JSON)", + "invalid-additional-info": "Informaţiile adiţionale (JSON) nu au putut fi analizate" + }, + "rulechain": { + "rulechain": "Flux", + "rulechains": "Fluxuri", + "root": "Origine", + "delete": "Şterge Fluxuri", + "name": "Denumire", + "name-required": "Denumirea este obligatorie", + "description": "Descriere", + "add": "Adaugă Fluxuri", + "set-root": "Stabileşte Originea Fluxurilor", + "set-root-rulechain-title": "Sigur vrei să setezi '{{ruleChainName}}' ca rădăcină?", + "set-root-rulechain-text": "ATENŢIE! După confirmare, fluxul va deveni rădăcină şi va gestiona toate mesajele de intrare", + "delete-rulechain-title": "Sigur vrei să ștergi fluxul '{{ruleChainName}}'?", + "delete-rulechain-text": "ATENŢIE! După confirmare, fluxul şi toate datele referitoare la acesta, vor fi șterse IREVERSIBIL!", + "delete-rulechains-title": "Sigur vrei să ștergi { count, plural, 1 {un flux} other {# fluxuri} }?", + "delete-rulechains-action-title": "Ştergi { count, plural, 1 {un flux} other {# fluxuri} }", + "delete-rulechains-text": "ATENŢIE! După confirmare, fluxul şi toate datele referitoare la acesta, vor fi șterse IREVERSIBIL!", + "add-rulechain-text": "Adaugă Flux Nou", + "no-rulechains-text": "Nu au fost găsite fluxuri", + "rulechain-details": "Detalii Flux", + "details": "Detalii", + "events": "Evenimente", + "system": "Sistem", + "import": "Importă Flux", + "export": "Exportă Flux", + "export-failed-error": "Fluxul nu poate fi exportat; {{error}}", + "create-new-rulechain": "Creează Flux Nou", + "rulechain-file": "Fişierul Flux", + "invalid-rulechain-file-error": "Fluxul nu poate fi importat; structură date invalidă", + "copyId": "Copiază ID Flux", + "idCopiedMessage": "ID Flux copiat în clipboard", + "select-rulechain": "Selectează Flux", + "no-rulechains-matching": "Nu au fost găsite fluxuri după criteriul: '{{entity}}'", + "rulechain-required": "Fluxul este obligatoriu", + "management": "Administrare Fluxuri", + "debug-mode": "Mod Depanare" + }, + "rulenode": { + "details": "Detalii", + "events": "Evenimente", + "search": "Noduri Căutare", + "open-node-library": "Bibliotecă Noduri", + "add": "Adaugă Regulă Nod", + "name": "Denumire", + "name-required": "Denumirea este obligatorie", + "type": "Tip", + "description": "Descriere", + "delete": "Şterge Regulă Nod", + "select-all-objects": "Selectează Toate Nodurile Şi Conexiunile", + "deselect-all-objects": "Deselectează Toate Nodurile Şi Conexiunile", + "delete-selected-objects": "Şterge Toate Nodurile Şi Conexiunile", + "delete-selected": "Şterge Selecţia", + "select-all": "Selectează Tot", + "copy-selected": "Copiază Selecţia", + "deselect-all": "Deselectează Tot", + "rulenode-details": "Detalii Regulă Nod", + "debug-mode": "Mod Depanare", + "configuration": "Configurare", + "link": "Link", + "link-details": "Detalii Link Regulă Nod", + "add-link": "Adaugă Link", + "link-label": "Etichetă Link", + "link-label-required": "Eticheta link-ului este obligatorie", + "custom-link-label": "Etichetă Link Definit de Utilizator", + "custom-link-label-required": "Eticheta pentru link, definită de către utilizator, este obligatorie", + "link-labels": "Etichete Link-uri)", + "link-labels-required": "Etichetele link-urilor sunt obligatorii", + "no-link-labels-found": "Nu au fost găsite etichete pentru link", + "no-link-label-matching": "Eticheta : '{{label}}' nu a fost găsită", + "create-new-link-label": "Creează Etichetă Nouă", + "type-filter": "Filtrează", + "type-filter-details": "Filtrează mesajele de intrare după condiţiile configurate", + "type-enrichment": "Îmbogățire", + "type-enrichment-details": "Adaugă informaţii adiţionale in metadata mesajului", + "type-transformation": "Transformare", + "type-transformation-details": "Schimbă payload şi metadata mesajului", + "type-action": "Acţiune", + "type-action-details": "Execută o acţiune specială", + "type-external": "Extern", + "type-external-details": "Interacţiuni cu sisteme externe", + "type-rule-chain": "Flux", + "type-rule-chain-details": "Transmite mesajele de intrare către fluxul specificat", + "type-input": "Intrare", + "type-input-details": "Intrarea logică pentru flux, transmite mesajele de intrare către următoarea regulă de nod înrudită", + "type-unknown": "Necunoscut", + "type-unknown-details": "Detalii regulă nod necunoscute", + "directive-is-not-loaded": "Configurarea definită pentru directiva : '{{directiveName}}' nu este disponibilă", + "ui-resources-load-error": "Eroare la încărcarea configurării resurselor UI", + "invalid-target-rulechain": "Fluxul Destinaţie nu a fost găsit", + "test-script-function": "Funcţie Test Script", + "message": "Mesaj", + "message-type": "Tip Mesaj", + "select-message-type": "Selectează Tipul Mesajului", + "message-type-required": "Tipul mesajului este obligatoriu", + "metadata": "Metadata", + "metadata-required": "Intrările metadata nu pot fi vide", + "output": "Ieşire", + "test": "Test", + "help": "Ajutor", + "reset-debug-mode": "Dezactivează modul depanare în toate nodurile" + }, + "tenant": { + "tenant": "Locatar", + "tenants": "Locatari", + "management": "Administrare Locatar", + "add": "Adaugă Locatar", + "admins": "Administratori", + "manage-tenant-admins": "Gestionare Administratori Locatar", + "delete": "Şterge Locatar", + "add-tenant-text": "Adaugă Locatar Nou", + "no-tenants-text": "Nu au fost găsiţi locatari", + "tenant-details": "Detalii Locatar", + "delete-tenant-title": "Sigur vrei să ștergi locatarul: '{{tenantTitle}}'?", + "delete-tenant-text": "ATENŢIE! După confirmare, locatarul şi toate datele referitoare la acesta, vor fi șterse IREVERSIBIL!", + "delete-tenants-title": "Sigur vrei să ștergi { count, plural, 1 {un locatar} other {# locatari} } ?", + "delete-tenants-action-title": "Şterge { count, plural, 1 {un locatar} other {# locatari} }", + "delete-tenants-text": "ATENŢIE! După confirmare, locatarii selectaţi şi datele aferente acestora, vor fi șterse IREVERSIBIL!", + "title": "Titlu", + "title-required": "Titlul este obligatoriu", + "description": "Descriere", + "details": "Detalii", + "events": "Evenimente", + "copyId": "Copiază ID Locatar", + "idCopiedMessage": "ID Locatar a fost copiat în clipboard", + "select-tenant": "Selectează locatar", + "no-tenants-matching": "Nu au fost găsiţi locatari după criteriul: '{{entity}}'", + "tenant-required": "Locatarul este obligatoriu" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {o secundă} other {# secunde} }", + "minutes-interval": "{ minutes, plural, 1 {un minut} other {# minute} }", + "hours-interval": "{ hours, plural, 1 {o oră} other {# ore} }", + "days-interval": "{ days, plural, 1 {o zi} other {# zile} }", + "days": "Zile", + "hours": "Ore", + "minutes": "Minute", + "seconds": "Secunde", + "advanced": "Personalizat" + }, + "timewindow": { + "days": "{ days, plural, 1 {o zi} other {# zile} }", + "hours": "{ hours, plural, 1 {o oră} other {# ore} }", + "minutes": "{ minutes, plural, 1 {un minut} other {# minute} }", + "seconds": "{ seconds, plural, 1 {o secundă} other {# secunde} }", + "realtime": "Timp Real", + "history": "Istoric", + "last-prefix": "Interval:", + "period": "Început {{ startTime}} Sfârșit {{endTime}}", + "edit": "Editează Interval", + "date-range": "Interval Date", + "last": "Ultima/Ultimele", + "time-period": "Interval:", + "hide": "Ascunde" + }, + "user": { + "user": "Utilizator", + "users": "Utilizatori", + "customer-users": "Utilizatori Client", + "tenant-admins": "Administratori Locatar", + "sys-admin": "Administratori Sistem", + "tenant-admin": "Administrator Locatar", + "customer": "Clienţi", + "anonymous": "Anonim", + "add": "Adaugă Utilizator", + "delete": "Şterge Utilizator", + "add-user-text": "Adaugă Utilizator Nou", + "no-users-text": "Nu Există Utilizatori", + "user-details": "Detalii Utilizator", + "delete-user-title": "Sigur vrei să ștergi utilizatorul '{{userEmail}}'?", + "delete-user-text": "ATENŢIE! După confirmare, utilizatorul şi toate datele aferente acestuia, vor fi șterse IREVERSIBIL!", + "delete-users-title": "Sigur vrei să ștergi { count, plural, 1 {un utilizator} other {# utilizatori} }?", + "delete-users-action-title": "Ştergere { count, plural, 1 {un utilizator} other {# utilizatori} }", + "delete-users-text": "ATENŢIE! După confirmare, toţi utilizatorii selectaţi împreună cu datele aferente acestora, vor fi șterse IREVERSIBIL!", + "activation-email-sent-message": "Mesajul eMail pentru activare a fost trimis cu succes!", + "resend-activation": "Retrimite mesaj eMail de activare", + "email": "Adresă eMail", + "email-required": "Adresa eMail este obligatorie", + "invalid-email-format": "Adresa eMail este incorectă", + "first-name": "Prenume", + "last-name": "Nume", + "description": "Descriere", + "default-dashboard": "Panou Implicit", + "always-fullscreen": "Permanent Ecran Complet", + "select-user": "Selectează Utilizator", + "no-users-matching": "Nu există utilizatori care corespund criteriului: '{{entity}}'", + "user-required": "Utilizatorul este obligatoriu", + "activation-method": "Metoda De Activare", + "display-activation-link": "Afişează link activare", + "send-activation-mail": "Trimite mesaj eMail pentru activare", + "activation-link": "Link activare utilizator:", + "activation-link-text": "Pentru activarea contului, folosiți link: ", + "copy-activation-link": "Copiază link activare", + "activation-link-copied-message": "Link-ul de activare utilizator a fost copiat în clipboard", + "details": "Detalii", + "login-as-tenant-admin": "Acces ca locatar administrator", + "login-as-customer-user": "Acces ca utilizator client", + "disable-account": "Dezactivează cont utilizator", + "enable-account": "Activează cont utilizator", + "enable-account-message": "Cont utilizator activat!", + "disable-account-message": "Cont utilizator dezactivat!" + }, + "value": { + "type": "Tip Valoare", + "string": "Şir Caractere", + "string-value": "Valoare Şir Caractere", + "integer": "Număr Întreg", + "integer-value": "Valoare Număr Întreg", + "invalid-integer-value": "Valoare număr întreg incorectă", + "double": "Tip Double", + "double-value": "Valoare Tip Double", + "boolean": "Tip Bool: ", + "boolean-value": "Valoare Bool", + "false": "Fals", + "true": "Adevărat", + "long": "Tip Long" + }, + "widget": { + "widget-library": "Biblioteci Widgets", + "widget-bundle": "Pachete Widgets", + "select-widgets-bundle": "Selectează Pachete Widgets", + "management": "Administrare Widgets", + "editor": "Editor Widget", + "widget-type-not-found": "Eroare la încărcarea configuraţiei widgetului.
    Probabil Asocierea \n cu tipul de widget a fost eliminată", + "widget-type-load-error": "Widgetul nu a fost încărcat din cauza următoarelor erori:", + "remove": "Elimină Widget", + "edit": "Editează Widget", + "remove-widget-title": "Sigur vrei să ștergi widgetul '{{widgetTitle}}'?", + "remove-widget-text": "ATENŢIE! După confirmare, widgetul şi toate datele aferente acestuia, vor fi șterse IREVERSIBIL!", + "timeseries": "Serii Temporale", + "search-data": "Caută Date", + "no-data-found": "Nu Au Fost Găsite Date", + "latest": "Ultimele Valori", + "rpc": "Widget Control ", + "alarm": "Widget Alarmă", + "static": "Widget Static", + "select-widget-type": "Selectaţi Tip Widget", + "missing-widget-title-error": "Titlul widgetului trebuie specificat!", + "widget-saved": "Widget Salvat", + "unable-to-save-widget-error": "Widgetul conține erori și nu poate fi salvat!", + "save": "Salvează Widget", + "saveAs": "Salvează Widget Ca...", + "save-widget-type-as": "Salvează Tip Widget Ca...", + "save-widget-type-as-text": "Introduceţi titlu nou widget şi/sau selectați pachet widget destinație", + "toggle-fullscreen": "Comută Ecran Complet", + "run": "Execută Widget", + "title": "Titlu Widget", + "title-required": "Titlul widgetului este obligatoriu", + "type": "Tip Widget", + "resources": "Resurse", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Şterge Resursă", + "add-resource": "Adaugă Resursă", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Schemă setări", + "datakey-settings-schema": "Schemă setări chei date", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Sigur vrei să ștergi tip widget '{{widgetName}}'?", + "remove-widget-type-text": "ATENŢIE! După confirmare, tipul de widget şi toate datele aferente acestuia, vor fi șterse IREVERSIBIL!", + "remove-widget-type": "Şterge Tip Widget", + "add-widget-type": "Adaugă Tip Nou Widget", + "widget-type-load-failed-error": "Eroare încărcare tip widget!", + "widget-template-load-failed-error": "Eroare încarcare şablon widget!", + "add": "Adaugă Widget Nou", + "undo": "Anulează Modificări Widget", + "export": "Exportă Widget" + }, + "widget-action": { + "header-button": "Buton Principal Widget", + "open-dashboard-state": "Deschide Altă Stare a Panoului", + "update-dashboard-state": "Actualizează Starea Curentă A Panoului", + "open-dashboard": "Comută Către Alt Panou", + "custom": "Acţiuni Utilizator", + "custom-pretty": "Acţiuni Utilizator (cu şablon HTML)", + "target-dashboard-state": "Stare Panou Destinaţie", + "target-dashboard-state-required": "Starea panoului de destinaţie este obligatorie!", + "set-entity-from-widget": "Setează Entitate din Widget", + "target-dashboard": "Panou Destinaţie", + "open-right-layout": "Deschide Aspect Corect Al Panoului (accesare de pe mobil)" + }, + "widgets-bundle": { + "current": "Pachet Curent Widgeturi", + "widgets-bundles": "Pachete Widgeturi", + "add": "Adăugare Pachete Widgeturi", + "delete": "Ştergere Pachete Widgeturi", + "title": "Titlu", + "title-required": "Titlul este obligatoriu", + "add-widgets-bundle-text": "Adaugă pachet nou widgeturi", + "no-widgets-bundles-text": "Nu există pachete widgeturi", + "empty": "Pachetul de widgeturi este gol", + "details": "Detalii", + "widgets-bundle-details": "Detalii Pachet Widgeturi", + "delete-widgets-bundle-title": "Sigur vrei să ștergi pachetul de widgeturi '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "ATENŢIE! După Confirmare, pachetul de widgeturi şi toate datele aferente acestuia, vor fi șterse IREVERSIBIL!", + "delete-widgets-bundles-title": "Sigur vrei să ștergi { count, plural, 1 {un pachet widgeturi} other {# pachete widgeturi} }?", + "delete-widgets-bundles-action-title": "Şterge { count, plural, 1 {un packet widgeturi} other {# pachete widgeturi} }", + "delete-widgets-bundles-text": "ATENŢIE! După confirmare, toate pachetele selectate de widget-uri şi datele aferente acestuia, vor fi șterse IREVERSIBIL!", + "no-widgets-bundles-matching": "Nu au fost găsite pachete de widgeturi conținând textul '{{widgetsBundle}}' ", + "widgets-bundle-required": "Denumirea pachetelor de widgeturi este obligatorie", + "system": "Sistem", + "import": "Importă Pachet Widgeturi", + "export": "Exportă Pachet Widgeturi", + "export-failed-error": "Export pachet widgeturi imposibil: {{error}}", + "create-new-widgets-bundle": "Definire Pachet Widgeturi Nou", + "widgets-bundle-file": "Alege fișier pachet widgeturi", + "invalid-widgets-bundle-file-error": "Export pachet widgeturi imposibil; Structură date invalidă" + }, + "widget-config": { + "data": "Date", + "settings": "Setări", + "advanced": "Setări Avansate", + "title": "Titlu", + "title-tooltip": "Mesaj Detalii Titlu", + "general-settings": "Setări Generale", + "display-title": "Titlu Afişat", + "drop-shadow": "Cu Umbră", + "enable-fullscreen": "Permite Ecran Complet", + "background-color": "Culoare Fundal", + "text-color": "Culoare Text", + "padding": "Margine Interioară", + "margin": "Margine Exterioară", + "widget-style": "Stil Widget", + "title-style": "Stil Titlu", + "mobile-mode-settings": "Setări Afișare Mobil", + "order": "Ordine", + "height": "Înăltime", + "units": "Unitate măsură", + "decimals": "Număr Zecimale", + "timewindow": "Interval Timp", + "use-dashboard-timewindow": "Folosire Interval Timp Panou", + "display-timewindow": "Afişare Interval Timp", + "display-legend": "Afişare Legendă", + "datasources": "Surse Date", + "maximum-datasources": "Maximum { count, plural, 1 {o sursă date permisă} other {# surse date permise} }", + "datasource-type": "Tip", + "datasource-parameters": "Parametri", + "remove-datasource": "Elimină Sursă Date", + "add-datasource": "Adaugă Sursă Date", + "target-device": "Dispozitiv Destinaţie", + "alarm-source": "Sursă Alarmă", + "actions": "Acţiuni", + "action": "Acţiune", + "add-action": "Adaugă Acţiune", + "search-actions": "Caută Acţiuni", + "action-source": "Sursa Acțiunii", + "action-source-required": "Sursa acțiunii este obligatorie", + "action-name": "Numele Acțiunii", + "action-name-required": "Numele acțiunii este obligatoriu", + "action-name-not-unique": "O acţiune cu acelaşi nume este deja definită
    Numele definit al acțiunii trebuie să fie unic in aceeaşi sursă de date", + "action-icon": "Pictogramă", + "action-type": "Tipul", + "action-type-required": "Tipul acțiunii este obligatoriu", + "edit-action": "Editare Acţiune", + "delete-action": "Ştergere Acţiune", + "delete-action-title": "Şterge acţiunea ", + "delete-action-text": "Ești sigur că vrei să ștergi acţiunea '{{actionName}}'?", + "display-icon": "Afişează Pictograma Titlului", + "icon-color": "Culoare Pictogramă", + "icon-size": "Mărime Pictogramă" + }, + "widget-type": { + "import": "Import Tip Widget", + "export": "Export Tip Widget", + "export-failed-error": "Eroare! Export imposibil pentru tip widget: {{error}}", + "create-new-widget-type": "Defineşte tip widget nou", + "widget-type-file": "Alege fişier pentru tip widget", + "invalid-widget-type-file-error": "Eroare! Tip widget nu poate fi importat; structură de date invalidă" + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "D", + "Mon": "L", + "Tue": "M", + "Wed": "M", + "Thu": "J", + "Fri": "V", + "Sat": "S", + "Jan": "Ian", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "Mai", + "Jun": "Iun", + "Jul": "Iul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec", + "January": "Ianuarie", + "February": "Februarie", + "March": "Martie", + "April": "Aprilie", + "Maz": "Mai", + "June": "Iunie", + "July": "Iulie", + "August": "August", + "September": "Septembrie", + "October": "Octombrie", + "November": "Noiembrie", + "December": "Decembrie", + "Custom Date Range": "Interval Date Personalizat", + "Date Range Template": "Șablon Interval Date", + "Today": "Astăzi", + "Yesterday": "Ieri", + "This Week": "Săptămâna Aceasta", + "Last Week": "Săptămâna Trecută", + "This Month": "Luna Aceasta", + "Last Month": "Luna Trecută", + "Year": "Anul", + "This Year": "Anul Acesta", + "Last Year": "Anul Trecut", + "Date picker": "Alege Data", + "Hour": "Oră", + "Day": "Zi", + "Week": "Săptămână", + "2 weeks": "2 săptămâni", + "Month": "Lună", + "3 months": "3 luni", + "6 months": "6 luni", + "Custom interval": "Interval Personalizat", + "Interval": "Interval:", + "Step size": "Pas", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Acest widget nu poate folosi atributul specificat", + "blocked-location": "Acest browser blochează localizarea", + "claim-device": "Revendică Dispozitiv", + "claim-failed": "Încercarea revendicare dispozitiv eșuată", + "claim-not-found": "Dispozitivul nu a fost găsit", + "claim-successful": "Dispozitivul a fost revendicat cu succes", + "date": "Data", + "device-name": "Nume Dispozitiv", + "device-name-required": "Numele dispozitivului este obligatoriu", + "discard-changes": "Anulare Modificări", + "entity-attribute-required": "Atributul entităţii este obligatoriu", + "entity-coordinate-required": "Atât latitudinea Şi longitudinea sunt obligatorii", + "entity-timeseries-required": "Seriile temporale pentru entitate sînt obligatorii", + "get-location": "Află locaţia GPS actuală", + "latitude": "Latitudine", + "longitude": "Longitudine", + "not-allowed-entity": "Entitatea selectată nu poate avea atribute partajate", + "no-attribute-selected": "Niciun Atribut Selectat", + "no-datakey-selected": "Nicio cheie selectată", + "no-coordinate-specified": "Cheie date latitude/longitude nespecificată", + "no-entity-selected": "Nicio entitate selectată", + "no-image": "Lipsă Imagine", + "no-support-geolocation": "Acest browser nu permite geolocalizarea", + "no-support-web-camera": "Cameră Web nesuportată", + "no-timeseries-selected": "Serii temporale nespecificate", + "secret-key": "Cheie Secretă", + "secret-key-required": "Cheia secretă este obligatorie", + "switch-attribute-value": "Schimbă valoare atribut entitate", + "switch-camera": "Schimbă Camera", + "switch-timeseries-value": "Schimbă valori serii temporale entitate", + "take-photo": "Captură Imagine", + "time": "Timp", + "timeseries-not-allowed": "Parametrul nu este compatibil cu acest widget", + "update-failed": "Actualizare eșuată", + "update-successful": "Actualizare reușită", + "update-attribute": "Actualizare Atribut", + "update-timeseries": "Actualizare Serii Temporale", + "value": "Valoare" + } + }, + "icon": { + "icon": "Pictogramă", + "select-icon": "Selectează Pictogramă", + "material-icons": "Material Pictogramă", + "show-all": "Afişează Toate Pictogramele" + }, + "custom": { + "widget-action": { + "action-cell-button": "Acțiune buton celulă", + "row-click": "Eveniment : Click pe linie tabel", + "polygon-click": "Eveniment : Click pe poligon", + "marker-click": "Eveniment : Click pe marker", + "tooltip-tag-action": "Acţiune marcaj detalii mesaj ", + "node-selected": "Eveniment : Nod Selectat", + "element-click": "eveniment : click Pe element HTML", + "pie-slice-click": "Eveniment : Click pe sector cerc", + "row-double-click": "Eveniment : Dublu click pe linia tabelului" + } + }, + "language": { + "language": "Limba" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json new file mode 100644 index 0000000..e9d5587 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json @@ -0,0 +1,1881 @@ +{ + "access": { + "unauthorized": "Неавторизированный", + "unauthorized-access": "Несанкционированный доступ", + "unauthorized-access-text": "Вы должны войти в систему для получения доступа к этому ресурсу!", + "access-forbidden": "Доступ запрещен", + "access-forbidden-text": "У вас нет прав доступа к этому ресурсу!
    Для получения доступа попробуйте войти под другим пользователем.", + "refresh-token-expired": "Сессия истекла", + "refresh-token-failed": "Не удалось обновить сессию" + }, + "action": { + "activate": "Активировать", + "suspend": "Приостановить", + "save": "Сохранить", + "saveAs": "Сохранить как", + "cancel": "Отмена", + "ok": "ОК", + "delete": "Удалить", + "add": "Добавить", + "yes": "Да", + "no": "Нет", + "update": "Обновить", + "remove": "Удалить", + "search": "Поиск", + "clear-search": "Очистить", + "assign": "Присвоить", + "unassign": "Отозвать", + "share": "Поделиться", + "make-private": "Закрыть для общего доступа", + "apply": "Применить", + "apply-changes": "Применить изменения", + "edit-mode": "Режим редактирования", + "enter-edit-mode": "Режим редактирования", + "decline-changes": "Отменить изменения", + "close": "Закрыть", + "back": "Назад", + "run": "Запуск", + "sign-in": "Войти", + "edit": "Редактировать", + "view": "Просмотреть", + "create": "Создать", + "drag": "Переместить", + "refresh": "Обновить", + "undo": "Откатить", + "copy": "Копировать", + "paste": "Вставить", + "copy-reference": "Копировать ссылку", + "paste-reference": "Вставить ссылку", + "import": "Импортировать", + "export": "Экспортировать", + "share-via": "Поделиться в {{provider}}", + "continue": "Продолжить", + "discard-changes": "Отменить изменения", + "done": "Завершено" + }, + "aggregation": { + "aggregation": "Агрегация", + "function": "Тип агрегации данных", + "limit": "Максимальное значение", + "group-interval": "Интервал группировки", + "min": "Мин", + "max": "Maкс", + "avg": "Среднее", + "sum": "Сумма", + "count": "Количество", + "none": "Без агрегации" + }, + "admin": { + "general": "Общие", + "general-settings": "Общие настройки", + "outgoing-mail": "Исходящая почта", + "outgoing-mail-settings": "Настройки исходящей почты", + "system-settings": "Системные настройки", + "test-mail-sent": "Пробное письмо успешно отправлено!", + "base-url": "Базовая URL", + "base-url-required": "Базовая URL обязательна.", + "mail-from": "Отправитель", + "mail-from-required": "Отправитель обязателен.", + "smtp-protocol": "SMTP протокол", + "smtp-host": "SMTP хост", + "smtp-host-required": "SMTP хост обязателен.", + "smtp-port": "SMTP порт", + "smtp-port-required": "SMTP порт обязателен.", + "smtp-port-invalid": "Недействительный SMTP порт.", + "timeout-msec": "Таймаут (мс)", + "timeout-required": "Таймаут обязателен.", + "timeout-invalid": "Недействительный таймаут.", + "enable-tls": "Включить TLS", + "tls-version" : "Версия TLS", + "send-test-mail": "Отправить пробное письмо", + "security-settings": "Настройки безопасности", + "password-policy": "Политика паролей", + "minimum-password-length": "Минимальная длина пароля", + "minimum-password-length-required": "Требуется минимальная длина пароля", + "minimum-password-length-range": "Минимальная длина пароля должна быть в диапазоне от 5 до 50", + "minimum-uppercase-letters": "Минимальное количество прописных букв", + "minimum-uppercase-letters-range": "Минимальное количество прописных букв не может быть отрицательным", + "minimum-lowercase-letters": "Минимальное количество строчных букв", + "minimum-lowercase-letters-range": "Минимальное количество строчных букв не может быть отрицательным", + "minimum-digits": "Минимальное количество цифр", + "minimum-digits-range": "Минимальное количество цифр не может быть отрицательным", + "minimum-special-characters": "Минимальное количество специальных символов", + "minimum-special-characters-range": "Минимальное количество специальных символов не может быть отрицательным", + "password-expiration-period-days": "Срок действия пароля в днях", + "password-expiration-period-days-range": "Срок действия пароля в днях не может быть отрицательным", + "password-reuse-frequency-days": "Частота повторного использования пароля в днях", + "password-reuse-frequency-days-range": "Частота повторного использования пароля в днях не может быть отрицательной", + "general-policy": "Общая политика", + "max-failed-login-attempts": "Максимальное количество неудачных попыток входа в систему, прежде чем учетная запись заблокирована", + "minimum-max-failed-login-attempts-range": "Максимальное количество неудачных попыток входа в систему не может быть отрицательным", + "user-lockout-notification-email": "В случае блокировки учетной записи пользователя отправьте уведомление на электронную почту", + "smpp-provider": { + "smpp-version": "SMPP версия", + "smpp-host": "SMPP хост", + "smpp-host-required": "SMPP хост обязателен.", + "smpp-port": "SMPP порт", + "smpp-port-required": "SMPP порт обязателен.", + "system-id": "ИД системи", + "system-id-required": "ИД системи обязателен.", + "password": "Пароль", + "password-required": "Пароль обязателен.", + "type-settings": "Настройки типов", + "source-settings": "Настройки источника", + "destination-settings": "Настройки назначения", + "additional-settings": "Дополнительные настройки", + "system-type": "Тип системы", + "bind-type": "Тип привязки", + "service-type": "Тип обслуживания", + "source-address": "Адрес источника", + "source-ton": "Тип номера источника", + "source-npi": "Идентификация плана нумерации источника", + "destination-ton": "Тип номера назничения", + "destination-npi": "Идентификация плана нумерации назначения", + "address-range": "Диапазон адресов", + "coding-scheme": "Схема кодирования" + } + }, + "alarm": { + "alarm": "Оповещение", + "alarms": "Оповещения", + "select-alarm": "Выбрать оповещение", + "no-alarms-matching": "Оповещения '{{entity}}' не найдены.", + "alarm-required": "Оповещение обязательно", + "alarm-status": "Статус оповещения", + "search-status": { + "ANY": "Все", + "ACTIVE": "Активные", + "CLEARED": "Сброшенные", + "ACK": "Подтвержденные", + "UNACK": "Неподтвержденные" + }, + "display-status": { + "ACTIVE_UNACK": "Активные неподтвержденные", + "ACTIVE_ACK": "Активные подтвержденные", + "CLEARED_UNACK": "Сброшенные неподтвержденные", + "CLEARED_ACK": "Сброшенные подтвержденные" + }, + "no-alarms-prompt": "Оповещения отсутствуют", + "created-time": "Время создания", + "type": "Тип", + "severity": "Уровень", + "originator": "Инициатор", + "originator-type": "Тип инициатора", + "details": "Подробности", + "status": "Статус", + "alarm-details": "Подробности об оповещении", + "start-time": "Время начала", + "end-time": "Время окончания", + "ack-time": "Время подтверждения", + "clear-time": "Время сброса", + "severity-critical": "Критический", + "severity-major": "Основной", + "severity-minor": "Второстепенный", + "severity-warning": "Предупреждение", + "severity-indeterminate": "Неопределенный", + "acknowledge": "Подтвердить", + "clear": "Сбросить", + "search": "Поиск оповещений", + "selected-alarms": "Выбрано { count, plural, 1 {1 оповещение} few {# оповещения} other {# оповещений} }", + "no-data": "Нет данных для отображения", + "polling-interval": "Интервал опроса оповещений (сек)", + "polling-interval-required": "Интервал опроса оповещений обязателен.", + "min-polling-interval-message": "Минимальный интервал опроса оповещений 1 секунда.", + "aknowledge-alarms-title": "Подтвердить { count, plural, 1 {1 оповещение} other {# оповещений} }", + "aknowledge-alarms-text": "Вы точно хотите подтвердить { count, plural, 1 {1 оповещение} other {# оповещений} }?", + "aknowledge-alarm-title": "Подтвердить оповещение", + "aknowledge-alarm-text": "Вы точно хотите подтвердить оповещение?", + "clear-alarms-title": "Сбросить { count, plural, 1 {1 оповещение} other {# оповещений} }", + "clear-alarms-text": "Вы точно хотите сбросить { count, plural, 1 {1 оповещение} other {# оповещений} }?", + "clear-alarm-title": "Сбросить оповещение", + "clear-alarm-text": "Вы точно хотите сбросить оповещение?", + "alarm-status-filter": "Фильтр оповещений", + "max-count-load": "Максимальное количество оповещений для загрузки (0 - неограниченно)", + "max-count-load-required": "Максимальное количество оповещений для загрузки обязателен.", + "max-count-load-error-min": "Минимальное значение 0.", + "fetch-size": "Размер пакета для загрузки", + "fetch-size-required": "Размер пакета для загрузки обязателен.", + "fetch-size-error-min": "Минимальное значение 10." + }, + "alias": { + "add": "Добавить псевдоним", + "edit": "Редактировать псевдоним", + "name": "Псевдоним", + "name-required": "Псевдоним обязателен", + "duplicate-alias": "Такой псевдоним уже существует.", + "filter-type-single-entity": "Отдельный объект", + "filter-type-entity-list": "Список объектов", + "filter-type-entity-name": "Название объекта", + "filter-type-state-entity": "Объект из состояния дашборда", + "filter-type-state-entity-description": "Объект, полученный из параметров состояния дашборда", + "filter-type-asset-type": "Тип актива", + "filter-type-asset-type-description": "Активы типа '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Активы типа '{{assetType}}' и названием, начинающимся с '{{prefix}}'", + "filter-type-device-type": "Тип устройства", + "filter-type-device-type-description": "Устройства типа '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Устройства типа '{{deviceType}}' и названием, начинающимся с '{{prefix}}'", + "filter-type-entity-view-type": "Тип Представления Объекта", + "filter-type-entity-view-type-description": "Представления Объекта типа '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Представления Объекта типа '{{entityView}}' и названием, начинающимся с '{{prefix}}'", + "filter-type-relations-query": "Запрос по типу отношений", + "filter-type-relations-query-description": "{{entities}}, имеющие отношение типа {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Поисковый запрос по активам", + "filter-type-asset-search-query-description": "Активы типа {{assetTypes}}, имеющие отношение типа {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Поисковый запрос по устройствам", + "filter-type-device-search-query-description": "Устройства типа {{deviceTypes}}, имеющие отношение типа {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Поисковый запрос по представлениям объектов", + "filter-type-entity-view-search-query-description": "Представления объектов типа {{entityViewTypes}}, имеющие отношение типа {{relationType}} {{direction}} {{rootEntity}}", + "entity-filter": "Фильтр объектов", + "resolve-multiple": "Принять как несколько объектов", + "filter-type": "Тип фильтра", + "filter-type-required": "Тип фильтра обязателен.", + "entity-filter-no-entity-matched": "Объекты, соответствующие фильтру, не найдены.", + "no-entity-filter-specified": "Не указан фильтр объектов", + "root-state-entity": "Использовать объект, полученный из дашборда, как корневой", + "last-level-relation": "Использовать только отношения последнего уровня", + "root-entity": "Корневой объект", + "state-entity-parameter-name": "Название объекта состояния", + "default-state-entity": "Объект состояния по умолчанию", + "default-entity-parameter-name": "По умолчанию", + "max-relation-level": "Максимальная глубина отношений", + "unlimited-level": "Неограниченная глубина", + "state-entity": "Объект состояния дашборда", + "all-entities": "Все объекты", + "any-relation": "не указано" + }, + "asset": { + "asset": "Актив", + "assets": "Активы", + "management": "Управление активами", + "view-assets": "Просмотреть активы", + "add": "Добавить актив", + "assign-to-customer": "Присвоить клиенту", + "assign-asset-to-customer": "Присвоить актив(ы) клиенту", + "assign-asset-to-customer-text": "Пожалуйста, выберите активы, которые нужно присвоить объекту", + "no-assets-text": "Активы не найдены", + "assign-to-customer-text": "Пожалуйста, выберите клиента, которому нужно присвоить актив(ы)", + "public": "Общедоступные", + "assignedToCustomer": "Присвоить клиенту", + "make-public": "Открыть общий доступ к активу", + "make-private": "Закрыть общий доступ к активу", + "unassign-from-customer": "Отозвать у клиента", + "delete": "Удалить актив", + "asset-public": "Актив общедоступный", + "asset-type": "Тип актива", + "asset-type-required": "Тип актива обязателен.", + "select-asset-type": "Выберите тип актива", + "enter-asset-type": "Введите тип актива", + "any-asset": "Любой актив", + "no-asset-types-matching": "Активы типа '{{entitySubtype}}' не найдены.", + "asset-type-list-empty": "Типы активов не выбраны.", + "asset-types": "Типы активов", + "name": "Название", + "name-required": "Название обязательно.", + "description": "Описание", + "type": "Тип", + "type-required": "Тип обязателен.", + "details": "Подробности", + "events": "События", + "add-asset-text": "Добавить новый актив", + "asset-details": "Подробности об активе", + "assign-assets": "Присвоить активы", + "assign-assets-text": "Присвоить { count, plural, 1 {1 актив} few {# актива} other {# активов} } клиенту", + "delete-assets": "Удалить активы", + "unassign-assets": "Отозвать активы", + "unassign-assets-action-title": "Отозвать { count, plural, 1 {1 актив} few {# актива} other {# активов} } у клиента", + "assign-new-asset": "Присвоить новый актив", + "delete-asset-title": "Вы точно хотите удалить '{{assetName}}'?", + "delete-asset-text": "Внимание, после подтверждения актив и все связанные с ним данные будут безвозвратно удалены.", + "delete-assets-title": "Вы точно хотите удалить { count, plural, 1 {1 актив} few {# актива} other {# активов} }", + "delete-assets-action-title": "Удалить { count, plural, 1 {1 актив} few {# актива} other {# активов} }", + "delete-assets-text": "Внимание, после подтверждения выбранные активы и все связанные с ними данные будут безвозвратно удалены.", + "make-public-asset-title": "Вы точно хотите открыть общий доступ к активу '{{assetName}}'?", + "make-public-asset-text": "Внимание, после подтверждения актив и все связанные с ним данные станут общедоступными.", + "make-private-asset-title": "Вы точно хотите закрыть общий доступ к активу '{{assetName}}'?", + "make-private-asset-text": "После подтверждения актив и все связанные с ним данные будут закрыты для общего доступа", + "unassign-asset-title": "Вы точно хотите отозвать актив '{{assetName}}'?", + "unassign-asset-text": "После подтверждения актив будут отозван, и клиент потеряет к нему доступ.", + "unassign-asset": "Отозвать актив", + "unassign-assets-title": "Вы точно хотите отозвать { count, plural, 1 {1 актив} few {# актива} other {# активов} }?", + "unassign-assets-text": "После подтверждения активы будут отозваны, и клиент потеряет к ним доступ.", + "copyId": "Копировать ИД актива", + "idCopiedMessage": "ИД актива скопировано в буфер обмена", + "select-asset": "Выбрать активы", + "no-assets-matching": "Активы, соответствующие '{{entity}}', не найдены.", + "asset-required": "Актив обязателен", + "name-starts-with": "Название актива, начинающееся с", + "import": "Импортировать активы", + "asset-file": "Файл с активами", + "label": "Метка" + }, + "attribute": { + "attributes": "Атрибуты", + "latest-telemetry": "Последняя телеметрия", + "attributes-scope": "Контекст атрибутов объекта", + "scope-latest-telemetry": "Последняя телеметрия", + "scope-client": "Клиентские атрибуты", + "scope-server": "Серверные атрибуты", + "scope-shared": "Общие атрибуты", + "add": "Добавить атрибут", + "key": "Ключ", + "last-update-time": "Последнее обновление", + "key-required": "Ключ атрибута обязателен.", + "value": "Значение", + "value-required": "Значение атрибута обязательно.", + "delete-attributes-title": "Вы уверенны, что хотите удалить { count, plural, one {1 атрибут} few {# атрибута} other {# атрибутов} }? ", + "delete-attributes-text": "Внимание, после подтверждения выбранные атрибуты будут удалены.", + "delete-attributes": "Удалить атрибуты", + "enter-attribute-value": "Введите значение атрибута", + "show-on-widget": "Показать на виджете", + "widget-mode": "Виджет-режим", + "next-widget": "Следующий виджет", + "prev-widget": "Предыдущий виджет", + "add-to-dashboard": "Добавить на дашборд", + "add-widget-to-dashboard": "Добавить виджет на дашборд", + "selected-attributes": "{ count, plural, 1 {Выбран} other {Выбраны} } { count, plural, one {1 атрибут} few {# атрибута} other {# атрибутов} }", + "selected-telemetry": "{ count, plural, 1 {Выбран} other {Выбраны} } { count, plural, 1 {1 параметр} few {# параметра} other {# параметров} } телеметрии" + }, + "audit-log": { + "audit": "Аудит", + "audit-logs": "Логи аудита", + "timestamp": "Время", + "entity-type": "Тип объекта", + "entity-name": "Название объекта", + "user": "Пользователь", + "type": "Тип", + "status": "Статус", + "details": "Подробности", + "type-added": "Добавленный", + "type-deleted": "Удаленный", + "type-updated": "Обновленный", + "type-attributes-updated": "Обновлены атрибуты", + "type-attributes-deleted": "Удалены атрибуты", + "type-rpc-call": "RPC вызов", + "type-credentials-updated": "Обновлены учетные данные", + "type-assigned-to-customer": "Присвоен клиенту", + "type-unassigned-from-customer": "Отозван у клиента", + "type-activated": "Активирован", + "type-suspended": "Приостановлен", + "type-credentials-read": "Чтение учетные данных", + "type-attributes-read": "Чтение атрибутов", + "type-relation-add-or-update": "Обновлены отношения", + "type-relation-delete": "Удалены отношения", + "type-relations-delete": "Удалены все отношения", + "type-alarm-ack": "Подтвержден", + "type-alarm-clear": "Сброшен", + "type-login": "Вход", + "type-logout": "Выход", + "type-lockout": "Заблокирован", + "status-success": "Успех", + "status-failure": "Сбой", + "audit-log-details": "Подробности аудит лога", + "no-audit-logs-prompt": "Логи не найдены", + "action-data": "Данные действия", + "failure-details": "Подробности сбоя", + "search": "Поиск аудит логов", + "clear-search": "Очистить поиск" + }, + "confirm-on-exit": { + "message": "У вас есть несохраненные изменения. Вы точно хотите покинуть эту страницу?", + "html-message": "У вас есть несохраненные изменения.
    Вы точно хотите покинуть эту страницу?", + "title": "Несохраненные изменения" + }, + "contact": { + "country": "Страна", + "city": "Город", + "state": "Штат", + "postal-code": "Почтовый код", + "postal-code-invalid": "Допустимы только цифры", + "address": "Адрес", + "address2": "Адрес 2", + "phone": "Телефон", + "email": "Эл. адрес", + "no-address": "Адрес не указан" + }, + "common": { + "username": "Имя пользователя", + "password": "Пароль", + "enter-username": "Введите имя пользователя", + "enter-password": "Введите пароль", + "enter-search": "Введите условие поиска", + "created-time": "Время создания" + }, + "content-type": { + "json": "Json", + "text": "Текстовый", + "binary": "Бинарный (Base64)" + }, + "customer": { + "customer": "Клиент", + "customers": "Клиенты", + "management": "Управление клиентами", + "dashboard": "Дашборд клиента", + "dashboards": "Дашборды клиента", + "devices": "Устройства клиента", + "entity-views": "Представления объектов клиента", + "assets": "Активы клиента", + "public-dashboards": "Общедоступные дашборды", + "public-devices": "Общедоступные устройства", + "public-assets": "Общедоступные активы", + "public-entity-views": "Общедоступные представления объектов", + "add": "Добавить клиента", + "delete": "Удалить клиента", + "manage-customer-users": "Управление пользователями клиента", + "manage-customer-devices": "Управление устройствами клиента", + "manage-customer-dashboards": "Управление дашбордами клиента", + "manage-public-devices": "Управление общедоступными устройствами", + "manage-public-dashboards": "Управление общедоступными дашбордами", + "manage-customer-assets": "Управление активами клиента", + "manage-public-assets": "Управление общедоступными активами", + "add-customer-text": "Добавить нового клиента", + "no-customers-text": "Клиенты не найдены", + "customer-details": "Подробности о клиенте", + "delete-customer-title": "Вы точно хотите удалить клиента '{{customerTitle}}'?", + "delete-customer-text": "Внимание, после подтверждения клиент и все связанные с ним данные будут безвозвратно удалены.", + "delete-customers-title": "Вы точно хотите удалить { count, plural, 1 {1 клиент} few {# клиента} other {# клиентов} }?", + "delete-customers-action-title": "Удалить { count, plural, 1 {1 клиент} few {# клиента} other {# клиентов} }", + "delete-customers-text": "Внимание, после подтверждения выбранные клиенты и все связанные с ними данные будут безвозвратно удалены.", + "manage-users": "Управление пользователями", + "manage-assets": "Управление активами", + "manage-devices": "Управление устройствами", + "manage-dashboards": "Управление дашбордами", + "title": "Имя", + "title-required": "Название обязательно.", + "description": "Описание", + "details": "Подробности", + "events": "События", + "copyId": "Копировать ИД клиента", + "idCopiedMessage": "ИД клиента скопирован в буфер обмена", + "select-customer": "Выбрать клиента", + "no-customers-matching": "Клиенты, соответствующие '{{entity}}', не найдены.", + "customer-required": "Клиент обязателен", + "select-default-customer": "Выбрать клиента по умолчанию", + "default-customer": "Клиент по умолчанию", + "default-customer-required": "Клиент по умолчанию обязателен для отладки дашборда на уровне на уровне Владельца" + }, + "datetime": { + "date-from": "Дата с", + "time-from": "Время с", + "date-to": "Дата до", + "time-to": "Время до" + }, + "dashboard": { + "dashboard": "Дашборд", + "dashboards": "Дашборды", + "management": "Управление дашбордами", + "view-dashboards": "Просмотреть дашборды", + "add": "Добавить дашборд", + "assign-dashboard-to-customer": "Прикрепить дашборд(ы) к клиенту", + "assign-dashboard-to-customer-text": "Пожалуйста, выберите дашборды, которые нужно прикрепить к клиенту", + "assign-to-customer-text": "Пожалуйста, выберите клиента, к которому нужно прикрепить дашборд(ы)", + "assign-to-customer": "Прикрепить к клиенту", + "unassign-from-customer": "Отозвать у клиента", + "make-public": "Открыть дашборд для общего доступа", + "make-private": "Закрыть дашборд для общего доступа", + "manage-assigned-customers": "Управление назначенными клиентами", + "assigned-customers": "Назначенные клиенты", + "assign-to-customers": "Присвоить дашборд(ы) клиенту", + "assign-to-customers-text": "Пожалуйста, выбери клиентов, которым нужно присвоить дашборд(ы)", + "unassign-from-customers": "Отозвать дашборд(ы) у клиентов", + "unassign-from-customers-text": "Пожалуйста, выберите клиентов, у которых нужно отозвать дашборд(ы)", + "no-dashboards-text": "Дашборды не найдены", + "no-widgets": "Виджеты не сконфигурированы", + "add-widget": "Добавить новый виджет", + "title": "Название", + "select-widget-title": "Выберите виджет", + "copyId": "Копировать идентификатор дашборда", + "idCopiedMessage": "Идентификатор дашборда скопирован в буфер обмена", + "select-widget-subtitle": "Список доступных виджетов", + "delete": "Удалить дашборд", + "title-required": "Название обязательно.", + "description": "Описание", + "details": "Подробности", + "dashboard-details": "Подробности о дашборде", + "add-dashboard-text": "Добавить новый дашборд", + "assign-dashboards": "Прикрепить дашборды", + "assign-new-dashboard": "Прикрепить новый дашборд", + "assign-dashboards-text": "Прикрепить { count, plural, 1 {1 дашборд} few {# дашборда} other {# дашбордов} } к клиенту", + "unassign-dashboards-action-text": "Отозвать { count, plural, 1 {1 дашборд} few {# дашборда} other {# дашбордов} } у клиента", + "delete-dashboards": "Удалить дашборды", + "unassign-dashboards": "Отозвать дашборды", + "unassign-dashboards-action-title": "Отозвать { count, plural, one {1 дашборд} few {# дашборда} other {# дашбордов} } у клиента", + "delete-dashboard-title": "Вы точно хотите удалить дашборд '{{dashboardTitle}}'?", + "delete-dashboard-text": "Внимание, после подтверждения дашборд и все связанные с ним данные будут безвозвратно утеряны.", + "delete-dashboards-title": "Вы точно хотите удалить { count, plural, one {1 дашборд} few {# дашборда} other {# дашбордов} }?", + "delete-dashboards-action-title": "Удалить { count, plural, one {1 дашборд} few {# дашборда} other {# дашбордов} }", + "delete-dashboards-text": "Внимание, после подтверждения дашборды и все связанные с ними данные будут безвозвратно утеряны.", + "unassign-dashboard-title": "Вы точно хотите отозвать дашборд '{{dashboardTitle}}'?", + "unassign-dashboard-text": "После подтверждения дашборд не будет доступен клиенту.", + "unassign-dashboard": "Отозвать дашборд", + "unassign-dashboards-title": "Вы точно хотите отозвать { count, plural, one {1 дашборд} few {# дашборда} other {# дашбордов} }?", + "unassign-dashboards-text": "После подтверждения выбранные дашборды не будут доступны клиенту.", + "public-dashboard-title": "Теперь дашборд общедоступный", + "public-dashboard-text": "Теперь ваш дашборд {{dashboardTitle}} доступен всем по ссылке:", + "public-dashboard-notice": "Примечание: Для получения доступа к данным устройства нужно открыть общий доступ к этому устройству.", + "make-private-dashboard-title": "Вы точно хотите закрыть общий доступ к дашборду '{{dashboardTitle}}'?", + "make-private-dashboard-text": "После подтверждения дашборд будет закрыт для общего доступа.", + "make-private-dashboard": "Закрыть дашборд для общего доступа", + "socialshare-text": "'{{dashboardTitle}}' сделано ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' сделано ThingsBoard", + "select-dashboard": "Выберите дашборд", + "no-dashboards-matching": "Дашборд '{{entity}}' не найден.", + "dashboard-required": "Дашборд обязателен.", + "select-existing": "Выберите существующий дашборд", + "create-new": "Создать новый дашборд", + "new-dashboard-title": "Новое название дашборда", + "open-dashboard": "Открыть дашборд", + "set-background": "Установить фон", + "background-color": "Фоновый цвет", + "background-image": "Фоновая картинка", + "background-size-mode": "Размер фона", + "no-image": "Картинка не выбрана", + "drop-image": "Перетащите картинку или кликните для выбора файла.", + "settings": "Настройки", + "columns-count": "Количество колонок", + "columns-count-required": "Количество колонок обязательно.", + "min-columns-count-message": "Минимальное число колонок - 10.", + "max-columns-count-message": "Максимальное число колонок - 1000.", + "widgets-margins": "Величина отступа между виджетами", + "horizontal-margin": "Величина горизонтального отступа", + "horizontal-margin-required": "Величина горизонтального отступа обязательна.", + "min-horizontal-margin-message": "Минимальная величина горизонтального отступа - 0.", + "max-horizontal-margin-message": "Максимальная величина горизонтального отступа - 50.", + "vertical-margin": "Величина вертикального отступа", + "vertical-margin-required": "Величина вертикального отступа обязательна.", + "min-vertical-margin-message": "Минимальная величина вертикального отступа - 0.", + "max-vertical-margin-message": "Максимальная величина вертикального отступа - 50.", + "autofill-height": "Автозаполнение по высоте", + "mobile-layout": "Настройки мобильного режима", + "mobile-row-height": "Высота строки в мобильном режиме, px", + "mobile-row-height-required": "Высота строки в мобильном режиме обязательна.", + "min-mobile-row-height-message": "Минимальная высота строки в мобильном режиме составляет 5 px.", + "max-mobile-row-height-message": "Максимальная высота строки в мобильном режиме составляет 200 px.", + "display-title": "Показать название дашборда", + "toolbar-always-open": "Отображать панель инструментов", + "title-color": "Цвет названия", + "display-dashboards-selection": "Отображать выборку дашбордов", + "display-entities-selection": "Отображать выбору объектов", + "display-dashboard-timewindow": "Показать временное окно", + "display-dashboard-export": "Показать экспорт", + "import": "Импортировать дашборд", + "export": "Экспортировать дашборд", + "export-failed-error": "Не удалось экспортировать дашборд: {{error}}", + "create-new-dashboard": "Создать новый дашборд", + "dashboard-file": "Файл дашборда", + "invalid-dashboard-file-error": "Не удалось импортировать дашборд: неизвестная схема данных дашборда.", + "dashboard-import-missing-aliases-title": "Настроить псевдонимы импортированного дашборда", + "create-new-widget": "Создать новый виджет", + "import-widget": "Импортировать виджет", + "widget-file": "Виджет-файл", + "invalid-widget-file-error": "Не удалось импортировать виджет: неправильный формат данных.", + "widget-import-missing-aliases-title": "Настроить псевдонимы, которые использует импортированный виджет", + "open-toolbar": "Открыть панель инструментов дашборда", + "close-toolbar": "Закрыть панель инструментов", + "configuration-error": "Ошибка в настройках", + "alias-resolution-error-title": "Ошибка в настройках псевдонимов дашборда", + "invalid-aliases-config": "Не удалось найти устройство, соответствующее фильтру псевдонимов.
    Пожалуйста, обратитесь к администратору для устранения неполадки.", + "select-devices": "Выберите устройства", + "assignedToCustomer": "Присвоенные клиенту", + "assignedToCustomers": "Присвоенные клиентам", + "public": "Публичный", + "public-link": "Публичная ссылка", + "copy-public-link": "Копировать публичную ссылку", + "public-link-copied-message": "Публичная ссылка на дашборд скопирована в буфер обмена.", + "manage-states": "Управление состоянием дашборда", + "states": "Состояния дашборда", + "search-states": "Поиск состояния дашборда", + "selected-states": "Выбрано { count, plural, 1 {1 состояние} few {# состояния} other {# состояний} } дашборда", + "edit-state": "Изменить состояние дашборда", + "delete-state": "Удалить состояние дашборда", + "add-state": "Добавить состояние дашборда", + "state": "Состояние дашборда", + "state-name": "Название", + "state-name-required": "Название состояния дашборда обязательно.", + "state-id": "ИД состояния", + "state-id-required": "ИД состояния дашборда обязателен.", + "state-id-exists": "Состояния дашборда с таким именем уже существует.", + "is-root-state": "Корневое состояние", + "delete-state-title": "Удалить состояние дашборда", + "delete-state-text": "Вы точно хотите удалить состояние дашборда '{{stateName}}'?", + "show-details": "Показать подробности", + "hide-details": "Скрыть подробности", + "select-state": "Выбрать состояние", + "state-controller": "Контроллер состояния" + }, + "datakey": { + "settings": "Настройки", + "advanced": "Дополнительно", + "label": "Метка", + "color": "Цвет", + "units": "Укажите символы, которые нужно указывать после значения", + "decimals": "Число знаков после запятой", + "data-generation-func": "Функция генерации данных", + "use-data-post-processing-func": "Использовать функцию пост-обработки данных", + "configuration": "Конфигурация ключа данных", + "timeseries": "Телеметрия", + "attributes": "Атрибуты", + "entity-field": "Поле объекта", + "alarm": "Параметры оповещения", + "timeseries-required": "Телеметрия объекта обязательна.", + "timeseries-or-attributes-required": "Телеметрия/атрибуты обязательны.", + "maximum-timeseries-or-attributes": "Максимальное количество параметров телеметрии или атрибутов равно {{count}}", + "alarm-fields-required": "Параметры оповещения обязательны.", + "function-types": "Тип функции", + "function-types-required": "Тип функции обязателен.", + "maximum-function-types": "Максимальное количество типов функции равно {{count}}", + "time-description": "время текущего значения;", + "value-description": "текущее значение;", + "prev-value-description": "результат предыдущего вызова функции;", + "time-prev-description": "время предыдущего значения;", + "prev-orig-value-description": "исходное предыдущее значение;" + }, + "datasource": { + "type": "Тип источника данных", + "name": "Название", + "add-datasource-prompt": "Пожалуйста, добавьте источник данных" + }, + "details": { + "edit-mode": "Режим редактирования", + "edit-json": "Редактировать JSON", + "toggle-edit-mode": "Режим редактирования" + }, + "device": { + "device": "Устройство", + "device-required": "Устройство обязательно.", + "devices": "Устройства", + "management": "Управление устройствами", + "view-devices": "Просмотреть устройства", + "device-alias": "Псевдоним устройства", + "aliases": "Псевдонимы устройства", + "no-alias-matching": "'{{alias}}' не найден.", + "no-aliases-found": "Псевдонимы не найдены.", + "no-key-matching": "'{{key}}' не найден.", + "no-keys-found": "Ключи не найдены.", + "create-new-alias": "Создать новый!", + "create-new-key": "Создать новый!", + "duplicate-alias-error": "Найден дублирующийся псевдоним '{{alias}}'.
    В рамках дашборда псевдонимы устройств должны быть уникальными.", + "configure-alias": "Настроить '{{alias}}' псевдоним", + "no-devices-matching": "Устройство '{{entity}}' не найдено.", + "alias": "Псевдоним", + "alias-required": "Псевдоним устройства обязателен.", + "remove-alias": "Удалить псевдоним устройства", + "add-alias": "Добавить псевдоним устройства", + "name-starts-with": "Название начинается с", + "device-list": "Список устройств", + "use-device-name-filter": "Использовать фильтр", + "device-list-empty": "Устройства не выбраны.", + "device-name-filter-required": "Фильтр названия устройства обязателен.", + "device-name-filter-no-device-matched": "Устройства, названия которых начинаются с '{{device}}', не найдены.", + "add": "Добавить устройство", + "assign-to-customer": "Присвоить клиенту", + "assign-device-to-customer": "Присвоить устройство(а) клиенту", + "assign-device-to-customer-text": "Пожалуйста, выберите устройства, которые нужно присвоить клиенту", + "make-public": "Открыть общий доступ к устройству", + "make-private": "Закрыть общий доступ к устройству", + "no-devices-text": "Устройства не найдены", + "assign-to-customer-text": "Пожалуйста, выберите клиента, которому нужно присвоить устройство(а)", + "device-details": "Подробности об устройстве", + "add-device-text": "Добавить новое устройство", + "credentials": "Учетные данные", + "manage-credentials": "Управление учетными данными", + "delete": "Удалить устройство", + "assign-devices": "Присвоить устройство", + "assign-devices-text": "Присвоить { count, plural, one {1 устройство} few {# устройства} other {# устройств} } клиенту", + "delete-devices": "Удалить устройства", + "unassign-from-customer": "Отозвать у клиенту", + "unassign-devices": "Отозвать устройства", + "unassign-devices-action-title": "Отозвать у клиента { count, plural, one {1 устройство} few {# устройства} other {# устройств} }", + "assign-new-device": "Присвоить новое устройство", + "make-public-device-title": "Вы точно хотите открыть общий доступ к устройству '{{deviceName}}'?", + "make-public-device-text": "После подтверждения устройство и все связанные с ним данные будут общедоступными.", + "make-private-device-title": "Вы точно хотите закрыть общий доступ к устройству '{{deviceName}}'", + "make-private-device-text": "После подтверждения устройство и все связанные с ним данные будут закрыты для общего доступа.", + "view-credentials": "Просмотреть учетные данные", + "delete-device-title": "Вы точно хотите удалить устройство '{{deviceName}}'?", + "delete-device-text": "Внимание, после подтверждения устройство и все связанные с ним данные будут безвозвратно утеряны.", + "delete-devices-title": "Вы точно хотите удалить { count, plural, one {1 устройство} few {# устройства} other {# устройств} }?", + "delete-devices-action-title": "Удалить { count, plural, one {1 устройство} few {# устройства} other {# устройств} }", + "delete-devices-text": "Внимание, после подтверждения выбранные устройства и все связанные с ними данные будут безвозвратно утеряны..", + "unassign-device-title": "Вы точно хотите отозвать устройство '{{deviceName}}'?", + "unassign-device-text": "После подтверждения устройство будет недоступно клиенту.", + "unassign-device": "Отозвать устройство", + "unassign-devices-title": "Вы точно хотите отозвать { count, plural, one {1 устройство} few {# устройства} other {# устройств} }?", + "unassign-devices-text": "После подтверждения выбранные устройства будут недоступны клиенту.", + "device-credentials": "Учетные данные устройства", + "credentials-type": "Тип учетных данных", + "access-token": "Токен", + "access-token-required": "Токен обязателен.", + "access-token-invalid": "Длина токена должна быть от 1 до 32 символов.", + "secret": "Секрет", + "secret-required": "Секрет обязателен.", + "device-type": "Тип устройства", + "device-type-required": "Тип устройства обязатеен.", + "select-device-type": "Выберите тип устройства", + "enter-device-type": "Введите тип устройства", + "any-device": "Любое устройство", + "no-device-types-matching": "Тип устройства, соответствующий '{{entitySubtype}}', не найден.", + "device-type-list-empty": "Не выбран тип устройства.", + "device-types": "Типы устройств", + "name": "Название", + "name-required": "Название обязательно.", + "description": "Описание", + "events": "События", + "details": "Подробности", + "copyId": "Копировать идентификатор устройства", + "copyAccessToken": "Копировать токен", + "idCopiedMessage": "Идентификатор устройства скопирован в буфер обмена", + "accessTokenCopiedMessage": "Токен устройства скопирован в буфер обмена", + "assignedToCustomer": "Присвоен клиенту", + "unable-delete-device-alias-title": "Не удалось удалить псевдоним устройства", + "unable-delete-device-alias-text": "Не удалось удалить псевдоним '{{deviceAlias}}' устройства, т.к. он используется следующими виджетами:
    {{widgetsList}}", + "is-gateway": "Гейтвей", + "public": "Общедоступный", + "device-public": "Устройство общедоступно", + "select-device": "Выбрать устройство", + "import": "Импортировать устройства", + "device-file": "Файл с устройствами" + }, + "dialog": { + "close": "Закрыть диалог" + }, + "direction": { + "column": "Колонка", + "row": "Строка" + }, + "error": { + "unable-to-connect": "Не удалось подключиться к серверу! Пожалуйста, проверьте интернет-соединение.", + "unhandled-error-code": "Код необработанной ошибки: {{errorCode}}", + "unknown-error": "Неизвестная ошибка" + }, + "entity": { + "entity": "Объект", + "entities": "Объекты", + "aliases": "Псевдонимы объекта", + "entity-alias": "Псевдоним объекта", + "unable-delete-entity-alias-title": "Не удалось удалить псевдоним объекта", + "unable-delete-entity-alias-text": "Псевдоним объекта '{{entityAlias}}' не может быть удален, так как используется следующим(и) виджетом(ами):
    {{widgetsList}}", + "duplicate-alias-error": "Найден дубликат псевдонима '{{alias}}'.
    В рамках одного дашборда псевдонимы объектов должны быть уникальными.", + "missing-entity-filter-error": "Отсутствует фильтр для псевдонима '{{alias}}'.", + "configure-alias": "Настроить псевдоним '{{alias}}'", + "alias": "Псевдоним", + "alias-required": "Псевдоним объекта обязателен.", + "remove-alias": "Убрать псевдоним объекта", + "add-alias": "Добавить псевдоним объекта", + "entity-list": "Список объектов", + "entity-type": "Тип объекта", + "entity-types": "Типы объекта", + "entity-type-list": "Список типов объекта", + "any-entity": "Любой объект", + "enter-entity-type": "Введите тип объекта", + "no-entities-matching": "Объекты, соответствующие '{{entity}}', не найдены.", + "no-entity-types-matching": "Типы объектов, соответствующие '{{entityType}}', не найдены.", + "name-starts-with": "Название, начинающееся с", + "use-entity-name-filter": "Использовать фильтр", + "entity-list-empty": "Не выбраны объекты.", + "entity-type-list-empty": "Не выбраны типы объекта.", + "entity-name-filter-required": "Фильтр по названию объекта обязателен.", + "entity-name-filter-no-entity-matched": "Объекты, чье название начинается с '{{entity}}', не найдены.", + "all-subtypes": "Все", + "select-entities": "Выберите объекты", + "no-aliases-found": "Псевдонимы не найдены.", + "no-alias-matching": "Псевдоним '{{alias}}' не найден.", + "create-new-alias": "Создать новый!", + "key": "Ключ", + "key-name": "Название ключа", + "no-keys-found": "Ключ не найден.", + "no-key-matching": "Ключ '{{key}}' не найден.", + "create-new-key": "Создать новый!", + "type": "Тип", + "type-required": "Тип объекта обязателен.", + "type-device": "Устройство", + "type-devices": "Устройства", + "list-of-devices": "{ count, plural, 1 {Одно устройство} other {Список из # устройств} }", + "device-name-starts-with": "Устройства, чьи название начинается с '{{prefix}}'", + "type-asset": "Актив", + "type-assets": "Активы", + "list-of-assets": "{ count, plural, 1 {Один актив} other {Список из # активов} }", + "asset-name-starts-with": "Активы, чьи название начинается с '{{prefix}}'", + "type-entity-view": "Представление Объекта", + "type-entity-views": "Представления Объекта", + "list-of-entity-views": "{ count, plural, 1 {Одно представление объекта} other {Список из # представлений объекта} }", + "entity-view-name-starts-with": "Представления Объекта, чьи название начинается с '{{prefix}}'", + "type-rule": "Правило", + "type-rules": "Правила", + "list-of-rules": "{ count, plural, 1 {Одно правило} other {Список из # правил} }", + "rule-name-starts-with": "Правила, чьи названия начинаются с '{{prefix}}'", + "type-plugin": "Плагин", + "type-plugins": "Плагины", + "list-of-plugins": "{ count, plural, 1 {Один плагин} other {Список из # плагинов} }", + "plugin-name-starts-with": "Плагины, чьи имена начинаются с '{{prefix}}'", + "type-tenant": "Владелец", + "type-tenants": "Владельцы", + "list-of-tenants": "{ count, plural, 1 {Один владелец} other {Список из # владельцев} }", + "tenant-name-starts-with": "Владельцы, чьи имена начинаются с '{{prefix}}'", + "type-customer": "Клиент", + "type-customers": "Клиенты", + "list-of-customers": "{ count, plural, 1 {Один клиент} other {Список из # клиентов} }", + "customer-name-starts-with": "Клиенты, чьи имена начинаются с '{{prefix}}'", + "type-user": "Пользователь", + "type-users": "Пользователи", + "list-of-users": "{ count, plural, 1 {Один пользователь} other {Список из # пользователей} }", + "user-name-starts-with": "Пользователи, чьи имена начинаются с '{{prefix}}'", + "type-dashboard": "Дашборд", + "type-dashboards": "Дашборды", + "list-of-dashboards": "{ count, plural, 1 {Один дашборд} other {Список из # дашбордов} }", + "dashboard-name-starts-with": "Дашборды, чьи названия начинаются с '{{prefix}}'", + "type-alarm": "Оповещение", + "type-alarms": "Оповещения", + "list-of-alarms": "{ count, plural, 1 {Одно оповещение} other {Список из # оповещений} }", + "alarm-name-starts-with": "Оповещения, чьи названия начинаются с '{{prefix}}'", + "type-rulechain": "Цепочка правил", + "type-rulechains": "Цепочки правил", + "list-of-rulechains": "{ count, plural, 1 {Одна цепочка правил} other {Список из # цепочек правил} }", + "rulechain-name-starts-with": "Цепочки правил, чьи названия начинаются с '{{prefix}}'", + "type-rulenode": "Правило", + "type-rulenodes": "Правила", + "list-of-rulenodes": "{ count, plural, 1 {Одно правило} other {Список из # правил} }", + "rulenode-name-starts-with": "Правила, чьи названия начинаются с '{{prefix}}'", + "type-current-customer": "Текущий клиент", + "type-current-tenant": "Текущий владелец", + "search": "Поиск объектов", + "selected-entities": "Выбран(ы) { count, plural, 1 {1 объект} few {# объекта} other {# объектов} }", + "entity-name": "Название объекта", + "entity-label": "Метка объекта", + "details": "Подробности об объекте", + "no-entities-prompt": "Объекты не найдены", + "no-data": "Нет данных для отображения", + "columns-to-display": "Отобразить следующие колонки" + }, + "entity-field": { + "created-time": "Время создания", + "name": "Название", + "type": "Тип", + "first-name": "Имя", + "last-name": "Фамилия", + "email": "Электронная почта", + "title": "Название", + "country": "Страна", + "state": "Штат/Область", + "city": "Город", + "address": "Адрес", + "address2": "Адрес 2", + "zip": "Индекс", + "phone": "Телефон", + "label": "Метка" + }, + "entity-view": { + "entity-view": "Представление Объекта", + "entity-view-required": "Представление объекта обязательно.", + "entity-views": "Представления Объектов", + "management": "Управление представлениями объектов", + "view-entity-views": "Просмотр представлений объектов", + "entity-view-alias": "Псевдоним Представления Объекта", + "aliases": "Псевдонимы Представления Объекта", + "no-alias-matching": "Псевдоним '{{alias}}' не найден.", + "no-aliases-found": "Псевдонимы не найдены.", + "no-key-matching": "Ключ '{{key}}' не найден.", + "no-keys-found": "Ключи не найдены.", + "create-new-alias": "Создать новый!", + "create-new-key": "Создать новый!", + "duplicate-alias-error": "Найден дубликат псевдонима '{{alias}}'.
    В рамках одного дашборда псевдонимы представлений объектов должны быть уникальными.", + "configure-alias": "Настроить псевдоним '{{alias}}'", + "no-entity-views-matching": "Объекты, соответствующие '{{entity}}', не найдены.", + "alias": "Псевдоним", + "alias-required": "Псевдоним представления объекта обязателен.", + "remove-alias": "Убрать псевдоним представления объекта", + "add-alias": "Добавить псевдоним представления объекта", + "name-starts-with": "Представления объектов, чьи название начинается с", + "entity-view-list": "Список представлений объектов", + "use-entity-view-name-filter": "Использовать фильтр", + "entity-view-list-empty": "Не выбраны представления объектов.", + "entity-view-name-filter-required": "Для представлений объектов фильтр по названиям обязателен.", + "entity-view-name-filter-no-entity-view-matched": "Представление объектов, чьи название начинаются с '{{entityView}}', не найдены.", + "add": "Представление объекта", + "assign-to-customer": "Назначить клиенту", + "assign-entity-view-to-customer": "Назначить представление(я) объекта(ов) клиенту", + "assign-entity-view-to-customer-text": "Пожалуйста, выберите представления объектов, которые нужно назначить клиенту", + "no-entity-views-text": "Представления объектов не найдены", + "assign-to-customer-text": "Пожалуйста, выберите клиента, которому нужно назначить представления объектов", + "entity-view-details": "Подробности о представлении объекта", + "add-entity-view-text": "Добавить новое представление объекта", + "delete": "Удалить представление объекта", + "assign-entity-views": "Назначить представления объектов", + "assign-entity-views-text": "Назначить клиенту { count, plural, 1 {1 представление объекта} few {# представления объектов} other {# представлений объектов} }", + "delete-entity-views": "Удалить представления объектов", + "unassign-from-customer": "Отозвать у клиента", + "unassign-entity-views": "Отозвать представления объектов", + "unassign-entity-views-action-title": "Отозвать у клиента { count, plural, 1 {1 представление объекта} few {# представления объектов} other {# представлений объектов} }", + "assign-new-entity-view": "Назначит новое представление объекта", + "delete-entity-view-title": "Вы точно хотите удалить представление объекта '{{entityViewName}}'?", + "delete-entity-view-text": "Внимание, после подтверждения представление объекта и все связанные с ним данные будут безвозвратно удалены.", + "delete-entity-views-title": "Вы точно хотите удалить { count, plural, 1 {1 представление объекта} few {# представления объектов} other {# представлений объектов} }?", + "delete-entity-views-action-title": "Удалить { count, plural, 1 {1 представление объекта} few {# представления объектов} other {# представлений объектов} }", + "delete-entity-views-text": "Внимание, после подтверждения выбранные представления объектов и все связанные с ними данные будут безвозвратно удалены.", + "unassign-entity-view-title": "Вы точно хотите отозвать представление объекта '{{entityViewName}}'?", + "unassign-entity-view-text": "После подтверждение представление объекта будет недоступно клиенту.", + "unassign-entity-view": "Отозвать представление объекта", + "unassign-entity-views-title": "Вы точно хотите отозвать { count, plural, 1 {1 представление объекта} few {# представления объектов} other {# представлений объектов} }?", + "unassign-entity-views-text": "После подтверждение выбранные представления объектов будет недоступно клиенту.", + "entity-view-type": "Тип представления объекта", + "entity-view-type-required": "Тип представления объекта обязателен.", + "select-entity-view-type": "Выберите тип представления объекта", + "enter-entity-view-type": "Введите тип представления объекта", + "any-entity-view": "Любое представление объекта", + "no-entity-view-types-matching": "Типы представления объекта, соответствующие '{{entitySubtype}}', не найдены.", + "entity-view-type-list-empty": "Не выбраны типы представления объекта.", + "entity-view-types": "Типы представления объекта", + "name": "Название", + "name-required": "Название обязательно.", + "description": "Описание", + "events": "События", + "details": "Подробности", + "copyId": "Копировать ИД представление объекта", + "assignedToCustomer": "Назначено клиенту", + "unable-entity-view-device-alias-title": "Не удалось удалить псевдоним представления объекта", + "unable-entity-view-device-alias-text": "Не удалось удалить псевдоним устройства '{{entityViewAlias}}', т.к. он используется следующими виджетами:
    {{widgetsList}}", + "select-entity-view": "Выбрать представление объекта", + "make-public": "Открыть общий доступ к представлению объекта", + "make-private": "Закрыть общий доступ к представлению объекта", + "start-date": "Дата начала", + "start-ts": "Время начала", + "end-date": "Дата окончания", + "end-ts": "Время окончания", + "date-limits": "Временной лимит", + "client-attributes": "Клиентские атрибуты", + "shared-attributes": "Общие атрибуты", + "server-attributes": "Серверные атрибуты", + "timeseries": "Телеметрия", + "client-attributes-placeholder": "Клиентские атрибуты", + "shared-attributes-placeholder": "Общие атрибуты", + "server-attributes-placeholder": "Серверные атрибуты", + "timeseries-placeholder": "Телеметрия", + "target-entity": "Целевой объект", + "attributes-propagation": "Пробросить атрибуты", + "attributes-propagation-hint": "Представление объекта автоматически копирует указанные атрибуты с Целевого Объекта каждый раз, когда вы сохраняете или обновляете это представление. В целях производительности атрибуты целевого объекта не пробрасываются в представление объекта на каждом их изменении. Вы можете включить автоматический проброс, настроив в вашей цепочке правило \"copy to view\" и соединив его с сообщениями типа \"Post attributes\" и \"Attributes Updated\".", + "timeseries-data": "Данные телеметрии", + "timeseries-data-hint": "Настроить ключи данных телеметрии целевого объекта, которые будут доступны представлению объекта. Эти данные только для чтения.", + "make-public-entity-view-title": "Вы уверенны, что хотите открыть общий доступ к представленю объекта '{{entityViewName}}'?", + "make-public-entity-view-text": "После подтверждения представление объекта и все связанные с ним данные станут публичными и доступными для других пользователей.", + "make-private-entity-view-title": "Вы уверенны, что хотите закрыть общий доступ к представлению объекта '{{entityViewName}}'?", + "make-private-entity-view-text": "После подтверждения представление объекта и все звязанные с ним данные станут приватными и не будут доступны для других пользователей." + }, + "event": { + "event-type": "Тип события", + "type-error": "Ошибка", + "type-lc-event": "Событие жизненного цикла", + "type-stats": "Статистика", + "type-debug-rule-node": "Отладка", + "type-debug-rule-chain": "Отладка", + "no-events-prompt": "События не найдены", + "error": "Ошибка", + "alarm": "Аварийное оповещение", + "event-time": "Время возникновения события", + "server": "Сервер", + "body": "Тело", + "method": "Метод", + "type": "Тип", + "message-id": "ИД сообщения", + "message-type": "Тип сообщения", + "data-type": "Тип данных", + "relation-type": "Тип отношения", + "metadata": "Метаданные", + "data": "Данные", + "event": "Событие", + "status": "Статус", + "success": "Успех", + "failed": "Неудача", + "messages-processed": "Сообщения обработаны", + "errors-occurred": "Возникли ошибки", + "all-events": "Все", + "entity-type": "Тип объекта", + "clear-request-title": "Удалить все события", + "clear-request-text": "Вы точно хотите удалить все события?" + }, + "extension": { + "extensions": "Расширение", + "selected-extensions": "Выбрано { count, plural, 1 {1 расширение} few {# расширения} other {# расширений} }", + "type": "Тип", + "key": "Ключ", + "value": "Значение", + "id": "ИД", + "extension-id": "ИД расширения", + "extension-type": "Тип расширения", + "transformer-json": "JSON *", + "unique-id-required": "Такое ИД расширения уже существует.", + "delete": "Удалить расширение", + "add": "Добавить расширение", + "edit": "Редактировать расширение", + "delete-extension-title": "Вы точно хотите удалить расширение '{{extensionId}}'?", + "delete-extension-text": "Внимание, после подтверждения расширение и все связанные с ним данные будут безвозвратно удалены.", + "delete-extensions-title": "Вы точно хотите удалить { count, plural, 1 {1 расширение} few {# расширения} other {# расширений} }?", + "delete-extensions-text": "Внимание, после подтверждения выбранные расширения будут удалены.", + "converters": "Конвертеры", + "converter-id": "ИД конвертера", + "configuration": "Конфигурация", + "converter-configurations": "Конфигурация конвертера", + "token": "Токен безопасности", + "add-converter": "Добавить конвертер", + "add-config": "Добавить конфигурацию конвертера", + "device-name-expression": "Маска названия устройства", + "device-type-expression": "Маска типа устройства", + "custom": "Пользовательский", + "to-double": "To Double", + "transformer": "Преобразователь", + "json-required": "JSON преобразователя обязателен.", + "json-parse": "Не удалось распознать JSON преобразователя.", + "attributes": "Атрибуты", + "add-attribute": "Добавить атрибут", + "add-map": "Добавить элемент сопоставления", + "timeseries": "Телеметрия", + "add-timeseries": "Добавить параметр телеметрии", + "field-required": "Параметр обязателен", + "brokers": "Брокеры", + "add-broker": "Добавить брокер", + "host": "Хост", + "port": "Порт", + "port-range": "Значение порта лежит в диапазоне от 1 до 65535.", + "ssl": "SSL", + "credentials": "Учетные данные", + "username": "Имя пользователя", + "password": "Пароль", + "retry-interval": "Интервал повтора в миллисекундах", + "anonymous": "Анонимный", + "basic": "Общий", + "pem": "PEM", + "ca-cert": "Файл CA сертификата *", + "private-key": "Файл приватного ключа *", + "cert": "Файл сертификата *", + "no-file": "Не выбран файл.", + "drop-file": "Перетяните файл или нажмите для выбора файла.", + "mapping": "Сопоставление", + "topic-filter": "Фильтр тем", + "converter-type": "Тип конвертера", + "converter-json": "JSON", + "json-name-expression": "JSON выражение для названия устройства", + "topic-name-expression": "Выражение для названия устройства в названии темы", + "json-type-expression": "JSON выражение для типа устройства", + "topic-type-expression": "Выражение для типа устройства в названии темы", + "attribute-key-expression": "Выражение для атрибута", + "attr-json-key-expression": "JSON выражение для атрибута", + "attr-topic-key-expression": "Выражение для атрибута в названии темы", + "request-id-expression": "Выражение для ИД запроса", + "request-id-json-expression": "JSON выражение для ИД запроса", + "request-id-topic-expression": "Выражение для ИД запроса в названии темы", + "response-topic-expression": "Выражение для темы ответов", + "value-expression": "Выражение для значения", + "topic": "Тема", + "timeout": "Таймаут в миллисекундах", + "converter-json-required": "JSON конвертер обязателен.", + "converter-json-parse": "Не удалось распознать JSON конвертера.", + "filter-expression": "Выражение для фильтрации", + "connect-requests": "Запросы о подключении устройства", + "add-connect-request": "Добавить запросы о подключении устройства", + "disconnect-requests": "Запросы об отсоединении устройства", + "add-disconnect-request": "Добавить запрос об отсоединении устройства", + "attribute-requests": "Запросы для атрибутов", + "add-attribute-request": "Добавить запрос для атрибутов", + "attribute-updates": "Обновление атрибутов", + "add-attribute-update": "Добавить обновление атрибутов", + "server-side-rpc": "Серверный RPC", + "add-server-side-rpc-request": "Добавить серверный RPC", + "device-name-filter": "Фильтр для названия устройства", + "attribute-filter": "Фильтр для атрибутов", + "method-filter": "Фильтр для процедур", + "request-topic-expression": "Выражение для темы запросов", + "response-timeout": "Время ожидания ответа в миллисекундах", + "topic-expression": "Выражение для названия темы", + "client-scope": "Клиентский", + "add-device": "Добавить устройство", + "opc-server": "Серверы", + "opc-add-server": "Добавить сервер", + "opc-add-server-prompt": "Пожалуйста, добавьте сервер", + "opc-application-name": "Название приложения", + "opc-application-uri": "URI приложения", + "opc-scan-period-in-seconds": "Частота сканирования в секундах", + "opc-security": "Безопасность", + "opc-identity": "Идентификация", + "opc-keystore": "Хранилище ключей", + "opc-type": "Тип", + "opc-keystore-type": "Тип", + "opc-keystore-location": "Расположение *", + "opc-keystore-password": "Пароль", + "opc-keystore-alias": "Псевдоним", + "opc-keystore-key-password": "Пароль для ключ", + "opc-device-node-pattern": "Паттерн OPC узла устройства", + "opc-device-name-pattern": "Паттерн названия устройства", + "modbus-server": "Серверы/ведомые устройства", + "modbus-add-server": "Добавить сервер/ведомое устройство", + "modbus-add-server-prompt": "Пожалуйста, добавить сервер/ведомое устройство", + "modbus-transport": "Транспорт", + "modbus-tcp-reconnect": "Переподключатсься автоматически", + "modbus-port-name": "Название последовательного порта", + "modbus-encoding": "Кодирование символов", + "modbus-parity": "Паритет", + "modbus-baudrate": "Скорость передачи", + "modbus-databits": "Биты данных", + "modbus-stopbits": "Стоп-биты", + "modbus-databits-range": "Параметр \"Биты данных\" может принимать значения 7 или 8.", + "modbus-stopbits-range": "Параметр \"Стоп-биты\" может принимать значения 1 или 2.", + "modbus-unit-id": "ИД устройства", + "modbus-unit-id-range": "ИД устройства должен быть в диапазоне от 1 до 247.", + "modbus-device-name": "Название устройства", + "modbus-poll-period": "Частота опроса (в миллисекундах)", + "modbus-attributes-poll-period": "Частота опроса для атрибутов (в миллисекундах)", + "modbus-timeseries-poll-period": "Частота опроса для телеметрии (в миллисекундах)", + "modbus-poll-period-range": "Значение параметра \"Частота опроса\" должно быть больше ноля.", + "modbus-tag": "Тег", + "modbus-function": "Modbus функция", + "modbus-register-address": "Адрес регистра", + "modbus-register-address-range": "Адрес регистра должен быть в диапазоне от 0 до 65535.", + "modbus-register-bit-index": "Номер бита", + "modbus-register-bit-index-range": "Номер бита должен быть в диапазоне от 0 до 15.", + "modbus-register-count": "Количество регистров", + "modbus-register-count-range": "Количество регистров должно быть больше ноля.", + "modbus-byte-order": "Порядок байтов", + "sync": { + "status": "Статус", + "sync": "Синхронизирован", + "not-sync": "Не синхронизирован", + "last-sync-time": "Время последней синхронизации", + "not-available": "Не доступен" + }, + "export-extensions-configuration": "Экспортировать конфигурацию расширений", + "import-extensions-configuration": "Импортировать конфигурацию расширений", + "import-extensions": "Импортировать расширения", + "import-extension": "Импортировать расширение", + "export-extension": "Экспортировать расширение", + "file": "Файл расширений", + "invalid-file-error": "Не правильный формат файла" + }, + "fullscreen": { + "expand": "Во весь экран", + "exit": "Выйти из полноэкранного режима", + "toggle": "Во весь экран", + "fullscreen": "Полноэкранный режим" + }, + "function": { + "function": "Функция" + }, + "grid": { + "delete-item-title": "Вы точно хотите удалить этот объект?", + "delete-item-text": "Внимание, после подтверждения объект и все связанные с ним данные будут безвозвратно утеряны.", + "delete-items-title": "Вы точно хотите удалить { count, plural, one {1 объект} few {# объекта} other {# объектов} }?", + "delete-items-action-title": "Удалить { count, plural, one {1 объект} few {# объекта} other {# объектов} }", + "delete-items-text": "Внимание, после подтверждения выбранные объекты и все связанные с ними данные будут безвозвратно утеряны.", + "add-item-text": "Добавить новый объект", + "no-items-text": "Объекты не найдены", + "item-details": "Подробности об объекте", + "delete-item": "Удалить объект", + "delete-items": "Удалить объекты", + "scroll-to-top": "Прокрутка к началу" + }, + "help": { + "goto-help-page": "Перейти к справке" + }, + "home": { + "home": "Главная", + "profile": "Профиль", + "logout": "Выйти из системы", + "menu": "Меню", + "avatar": "Аватар", + "open-user-menu": "Открыть меню пользователя" + }, + "import": { + "no-file": "Файл не выбран", + "drop-file": "Перетащите JSON файл или кликните для выбора файла.", + "drop-file-csv": "Перетащите CSV файл или кликните для выбора файла.", + "column-value": "Значение", + "column-title": "Название", + "column-example": "Пример значений данных", + "column-key": "Ключ атрибута/телеметрии", + "csv-delimiter": "Разделитель в CSV файле", + "csv-first-line-header": "Первая строка содержит названия колонок", + "csv-update-data": "Обновить атрибут/телеметрию", + "import-csv-number-columns-error": "Файл должен содержать как минимум две колонки", + "import-csv-invalid-format-error": "Неверный формат данных. Строка: '{{line}}'", + "column-type": { + "name": "Название", + "type": "Тип", + "label": "Метка", + "column-type": "Тип колонки", + "client-attribute": "Клиентский атрибут", + "shared-attribute": "Общий атрибут", + "server-attribute": "Серверный атрибут", + "timeseries": "Телеметрия", + "entity-field": "Entity field", + "access-token": "Токен" + }, + "stepper-text": { + "select-file": "Выберите файл", + "configuration": "Конфигурация импорта", + "column-type": "Выберите тип колонок", + "creat-entities": "Создание новых объектов" + }, + "message": { + "create-entities": "{{count}} новый(х) объект(ов) было успешно создано.", + "update-entities": "{{count}} объект(ов) успешно обновлено.", + "error-entities": "Возникла ошибка при создании {{count}} объекта(ов)." + } + }, + "item": { + "selected": "Выбранные" + }, + "js-func": { + "no-return-error": "Функция должна возвращать значение!", + "return-type-mismatch": "Функция должна возвращать значение типа '{{type}}'!" + }, + "key-val": { + "key": "Ключ", + "value": "Значение", + "remove-entry": "Удалить элемент", + "add-entry": "Добавить элемент", + "no-data": "Элементы отсутствуют" + }, + "layout": { + "layout": "Макет", + "manage": "Управление макетами", + "settings": "Настройки макета", + "color": "Цвет", + "main": "Основной", + "right": "Правый", + "select": "Выбрать макет" + }, + "legend": { + "direction": "Расположение элементов легенды", + "position": "Расположение легенды", + "show-max": "Показать максимальное значение", + "show-min": "Показать минимальное значение", + "show-avg": "Показать среднее значение", + "show-total": "Показать сумму", + "settings": "Настройки легенды", + "min": "Мин", + "max": "Макс", + "avg": "Среднее", + "total": "Сумма", + "comparison-time-ago": { + "days": "(день назад)", + "weeks": "(неделю назад)", + "months": "(месяц назад)", + "years": "(год назад)" + } + }, + "login": { + "login": "Войти", + "request-password-reset": "Запрос на сброс пароля", + "reset-password": "Сбросить пароль", + "create-password": "Создать пароль", + "passwords-mismatch-error": "Введенные пароли должны быть одинаковыми!", + "password-again": "Введите пароль еще раз", + "sign-in": "Пожалуйста, войдите в систему", + "username": "Имя пользователя (эл. адрес)", + "remember-me": "Запомнить меня", + "forgot-password": "Забыли пароль?", + "password-reset": "Пароль сброшен", + "expired-password-reset-message": "Срок действия Вашего пароля закончился! Пожалуйста, создайте новый пароль.", + "new-password": "Новый пароль", + "new-password-again": "Повторите новый пароль", + "password-link-sent-message": "Ссылка для сброса пароля была успешно отправлена!", + "email": "Эл. адрес", + "login-with": "Войти через {{name}}", + "or": "или" + }, + "position": { + "top": "Верх", + "bottom": "Низ", + "left": "Левый край", + "right": "Правый край" + }, + "profile": { + "profile": "Профиль", + "last-login-time": "Время последнего входа в систему", + "change-password": "Изменить пароль", + "current-password": "Текущий пароль", + "copy-jwt-token": "Копировать JWT токен", + "tokenCopiedMessage": "JWT токен скопирован в буфер обмена", + "tokenCopiedWarnMessage": "JWT токен недействителен! Перезагрузите страницу." + }, + "relation": { + "relations": "Отношения", + "direction": "Направления", + "search-direction": { + "FROM": "От", + "TO": "К" + }, + "direction-type": { + "FROM": "от", + "TO": "к" + }, + "from-relations": "Исходящие отношения", + "to-relations": "Входящие отношения", + "selected-relations": "Выбрано { count, plural, 1 {1 отношение} few {# отношения} other {# отношений} }", + "type": "Тип", + "to-entity-type": "К типу объекта", + "to-entity-name": "К объекта", + "from-entity-type": "От типа объекта", + "from-entity-name": "От объекта", + "to-entity": "К объекту", + "from-entity": "От объекта", + "delete": "Удалить отношение", + "relation-type": "Тип отношения", + "relation-type-required": "Тип отношения обязателен.", + "any-relation-type": "Любой тип", + "add": "Добавить отношение", + "edit": "Редактировать отношение", + "delete-to-relation-title": "Вы точно хотите удалить отношение, идущее к объекту '{{entityName}}'?", + "delete-to-relation-text": "Внимание, после подтверждения объект '{{entityName}}' будет отвязан от текущего объекта.", + "delete-to-relations-title": "Вы точно хотите удалить { count, plural, 1 {1 отношение} few {# отношения} other {# отношений} }?", + "delete-to-relations-text": "Внимание, после подтверждения выбранные объекты будут отвязаны от текущего объекта.", + "delete-from-relation-title": "Вы точно хотите удалить отношение, идущее от объекта '{{entityName}}'?", + "delete-from-relation-text": "Внимание, после подтверждения текущий объект будет отвязан от объекта '{{entityName}}'.", + "delete-from-relations-title": "Вы точно хотите удалить { count, plural, 1 {1 отношение} few {# отношения} other {# отношений} }?", + "delete-from-relations-text": "Внимание, после подтверждения выбранные объекты будут отвязаны от соответствующих объектов.", + "remove-relation-filter": "Удалить фильтр отношений", + "add-relation-filter": "Добавить фильтр отношений", + "any-relation": "Любое отношение", + "relation-filters": "Фильтры отношений", + "additional-info": "Дополнительная информация (JSON)", + "invalid-additional-info": "Не удалось распознать JSON с дополнительной информацией." + }, + "rulechain": { + "rulechain": "Цепочка правил", + "rulechains": "Цепочки правил", + "root": "Корневая", + "delete": "Удалить цепочку правил", + "name": "Названия", + "name-required": "Название необходимо.", + "description": "Описание", + "add": "Добавить цепочку правил", + "set-root": "Сделать цепочку корневой", + "set-root-rulechain-title": "Вы точно хотите сделать цепочку правил '{{ruleChainName}}' корневой?", + "set-root-rulechain-text": "После подтверждения цепочка правил станет корневой и будет обрабатывать все входящие сообщения.", + "delete-rulechain-title": "Вы точно хотите удалить цепочку правил '{{ruleChainName}}'?", + "delete-rulechain-text": "Внимание, после подтверждения цепочка правил и все связанные с ней данные будут безвозвратно удалены.", + "delete-rulechains-title": "Вы точно хотите удалить { count, plural, 1 {1 цепочку правил} few {# цепочки правил} other {# цепочек правил} }?", + "delete-rulechains-action-title": "Удалить { count, plural, 1 {1 цепочку правил} few {# цепочки правил} other {# цепочек правил} }", + "delete-rulechains-text": "Внимание, после подтверждения выбранные цепочки правил и все связанные с ними данные будут безвозвратно удалены.", + "add-rulechain-text": "Добавить новую цепочку правил", + "no-rulechains-text": "Цепочки правил не найдены", + "rulechain-details": "Подробности о цепочке правил", + "details": "Подробности", + "events": "События", + "system": "Системная", + "import": "Импортировать цепочку правил", + "export": "Экспортировать цепочку правил", + "export-failed-error": "Не удалось экспортировать цепочку правил: {{error}}", + "create-new-rulechain": "Создать новую цепочку правил", + "rulechain-file": "Файл цепочки правил", + "invalid-rulechain-file-error": "Не удалось импортировать цепочку правил: неправильный формат.", + "copyId": "Копировать ИД цепочки правил", + "idCopiedMessage": "ИД цепочки правил скопирован в буфер обмена", + "select-rulechain": "Выбрать цепочку правил", + "no-rulechains-matching": "Цепочки правил, соответствующие '{{entity}}', не найдены.", + "rulechain-required": "Цепочка правил обязательна", + "management": "Управление цепочками правил", + "debug-mode": "Режим отладки" + }, + "rulenode": { + "details": "Подробности", + "events": "События", + "search": "Поиск правил", + "open-node-library": "Открыть библиотеку правил", + "add": "Добавить правило", + "name": "Название", + "name-required": "Название обязательно.", + "type": "Тип", + "description": "Описание", + "delete": "Удалить правило", + "select-all-objects": "Выделить все правила и связи", + "deselect-all-objects": "Отменить выделение правил и связей", + "delete-selected-objects": "Удалить выделенные правила и связи", + "delete-selected": "Удалить выделенные", + "select-all": "Выделить всё", + "copy-selected": "Копировать выделенное", + "deselect-all": "Отменить выделение", + "rulenode-details": "Подробности о правиле", + "debug-mode": "Режим отладки", + "configuration": "Настройки", + "link": "Связь", + "link-details": "Подробности о связи правила", + "add-link": "Добавить связь", + "link-label": "Метка связи", + "link-label-required": "Метка связи обязателен.", + "custom-link-label": "Пользовательская метка связи", + "custom-link-label-required": "Пользовательская метка связи обязателен.", + "link-labels": "Метки связи", + "link-labels-required": "Метки связи обязательны.", + "no-link-labels-found": "Метки связи не найдены", + "no-link-label-matching": "Метка '{{label}}' не найдена.", + "create-new-link-label": "Создать новую!", + "type-filter": "Фильтр", + "type-filter-details": "Фильтр входящих сообщений с заданными условиями", + "type-enrichment": "Насыщение", + "type-enrichment-details": "Добавить данные в метадату сообщения", + "type-transformation": "Преобразование", + "type-transformation-details": "Изменить содержимое сообщение и его метадату", + "type-action": "Действие", + "type-action-details": "Выполнить заданное действие", + "type-external": "Сторонние", + "type-external-details": "Взаимодействовать со сторонними системами", + "type-rule-chain": "Цепочка правил", + "type-rule-chain-details": "Перенаправить входящее сообщение в другую цепочку правил", + "type-input": "Вход", + "type-input-details": "Логический вход цепочки правил перенаправляет входящие сообщения в следующее правило", + "type-unknown": "Неизвестный", + "type-unknown-details": "Неопределенное правило", + "directive-is-not-loaded": "Указанная директива конфигурации '{{directiveName}}' не доступна.", + "ui-resources-load-error": "Не удалось загрузить UI ресурсы.", + "invalid-target-rulechain": "Не удалось определить целевую цепочку правил!", + "test-script-function": "Протестировать скрипт", + "message": "Сообщение", + "message-type": "Тип сообщения", + "select-message-type": "Выбрать тип сообщения", + "message-type-required": "Тип сообщения обязателен", + "metadata": "Метаданные", + "metadata-required": "Метаданные объекта не могут быть пустыми.", + "output": "Выход", + "test": "Протестировать", + "help": "Помощь", + "reset-debug-mode": "Сбросить режим отладки во всех правилах" + }, + "queue": { + "select_name": "Выберите имя для Queue", + "name": "Имя для Queue", + "name_required": "Поле 'Имя для Queue' обязательно к заполнению!" + }, + "tenant": { + "tenant": "Владелец", + "tenants": "Владельцы", + "management": "Управление владельцами", + "add": "Добавить владельца", + "admins": "Администраторы", + "manage-tenant-admins": "Управление администраторами владельца", + "delete": "Удалить владельца", + "add-tenant-text": "Добавить нового владельца", + "no-tenants-text": "Владельцы не найдены", + "tenant-details": "Подробности об владельце", + "delete-tenant-title": "Вы точно хотите удалить владельца '{{tenantTitle}}'?", + "delete-tenant-text": "Внимание, после подтверждения владелец и все связанные с ним данные будут безвозвратно утеряны.", + "delete-tenants-title": "Вы точно хотите удалить { count, plural, one {1 владельца} other {# владельцев} }?", + "delete-tenants-action-title": "Удалить { count, plural, one {1 владельца} other {# владельцев} }", + "delete-tenants-text": "Внимание, после подтверждения выбранные Владельцы и все связанные с ними данные будут безвозвратно утеряны.", + "title": "Имя", + "title-required": "Имя обязательно.", + "description": "Описание", + "details": "Подробности", + "events": "События", + "copyId": "Копировать ИД владельца", + "idCopiedMessage": "ИД владельца скопирован в буфер обмена", + "select-tenant": "Выбрать владельца", + "no-tenants-matching": "Владельцы, соответствующие '{{entity}}', не найдены.", + "tenant-required": "Владелец обязателен" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, one {1 секунда} few {# секунды} other {# секунд} }", + "minutes-interval": "{ minutes, plural, one {1 минута} few {# минуты} other {# минут} }", + "hours-interval": "{ hours, plural, one {1 час} few {# часа} other {# часов} }", + "days-interval": "{ days, plural, one {1 день} few {# дня} other {# дней} }", + "days": "Дни", + "hours": "Часы", + "minutes": "Минуты", + "seconds": "Секунды", + "advanced": "Дополнительно" + }, + "timewindow": { + "days": "{ days, plural, one {1 день} few {# дня} other {# дней} }", + "hours": "{ hours, plural, one {1 час} few {# часа} other {# часов} }", + "minutes": "{ minutes, plural, one {1 минута} few {# минуты} other {# минут} }", + "seconds": "{ seconds, plural, one {1 секунда} few {# секунды} other {# секунд} }", + "realtime": "Режим реального времени", + "history": "История", + "last-prefix": "Последние", + "period": "с {{ startTime }} до {{ endTime }}", + "edit": "Изменить временное окно", + "date-range": "Диапазон дат", + "last": "Последние", + "time-period": "Период времени", + "hide": "Скрыть" + }, + "user": { + "user": "Пользователь", + "users": "Пользователи", + "customer-users": "Пользователи клиента", + "tenant-admins": "Администраторы владельца", + "sys-admin": "Системный администратор", + "tenant-admin": "Администратор владельца", + "customer": "Клиент", + "anonymous": "Аноним", + "add": "Добавить пользователя", + "delete": "Удалить пользователя", + "add-user-text": "Добавить нового пользователя", + "no-users-text": "Пользователи не найдены", + "user-details": "Подробности о пользователе", + "delete-user-title": "Вы точно хотите удалить пользователя '{{userEmail}}'?", + "delete-user-text": "Внимание, после подтверждения пользователь и все связанные с ним данные будут безвозвратно утеряны.", + "delete-users-title": "Вы точно хотите удалить { count, plural, one {1 пользователя} other {# пользователей} }?", + "delete-users-action-title": "Удалить { count, plural, one {1 пользователя} other {# пользователей} }", + "delete-users-text": "Внимание, после подтверждения выбранные пользователи и все связанные с ними данные будут безвозвратно утеряны.", + "activation-email-sent-message": "Активационное письмо успешно отправлено!", + "resend-activation": "Повторить отправку активационного письма", + "email": "Эл. адрес", + "email-required": "Эл. адрес обязателен.", + "invalid-email-format": "Неправильный формат эл. адреса'.", + "first-name": "Имя", + "last-name": "Фамилия", + "description": "Описание", + "default-dashboard": "Дашборд по умолчанию", + "always-fullscreen": "Всегда в полноэкранном режиме", + "select-user": "Выбрать пользователя", + "no-users-matching": "Пользователи, соответствующие '{{entity}}', не найдены.", + "user-required": "Необходимо указать пользователя", + "activation-method": "Метод активации", + "display-activation-link": "Отобразить ссылку для активации", + "send-activation-mail": "Отправить активационное письмо", + "activation-link": "Активационная ссылка для пользователя", + "activation-link-text": "Для активации пользователя используйте ссылку :", + "copy-activation-link": "Копировать активационную ссылку", + "activation-link-copied-message": "Ссылка для активации пользователя скопирована в буфер обмена", + "details": "Подробности", + "login-as-tenant-admin": "Войти как администратор владельца", + "login-as-customer-user": "Войти как пользователь клиента", + "disable-account": "Отключить учетную запись пользователя", + "enable-account": "Включить учетную запись пользователя", + "enable-account-message": "Учетная запись пользователя была успешно включена!", + "disable-account-message": "Учетная запись пользователя была успешно отключена!", + "copyId": "Копировать ИД пользователя", + "idCopiedMessage": "ИД пользователя скопирован в буфер обмена" + }, + "value": { + "type": "Тип значения", + "string": "Строка", + "string-value": "Строковое значение", + "integer": "Целое число", + "integer-value": "Целочисленное значение", + "invalid-integer-value": "Неправильный формат целого числа", + "double": "Число двойной точности", + "double-value": "Значение двойной точности", + "boolean": "Логический тип", + "boolean-value": "Логическое значение", + "false": "Ложь", + "true": "Правда", + "long": "Целое число" + }, + "widget": { + "widget-library": "Галерея виджетов", + "widget-bundle": "Набор виджетов", + "select-widgets-bundle": "Выберите набор виджетов", + "management": "Управление виджетами", + "editor": "Редактор виджетов", + "widget-type-not-found": "Ошибка при загрузке конфигурации виджета.
    Возможно, связанный с ней\n тип виджета уже удален.", + "widget-type-load-error": "Не удалось загрузить виджет по следующим причинам:", + "remove": "Удалить виджет", + "edit": "Редактировать виджет", + "remove-widget-title": "Вы точно хотите удалить виджет '{{widgetTitle}}'?", + "remove-widget-text": "Внимание, после подтверждения виджет и все связанные с ним данные будут безвозвратно утеряны.", + "timeseries": "Телеметрия", + "search-data": "Поиск данных", + "no-data-found": "Данные не найдено", + "latest": "Последние значения", + "rpc": "Управляющий виджет", + "alarm": "Виджет оповещений", + "static": "Статический виджет", + "select-widget-type": "Выберите тип виджета", + "missing-widget-title-error": "Укажите название виджета!", + "widget-saved": "Виджет сохранен", + "unable-to-save-widget-error": "Не удалось сохранить виджет! Виджет содержит ошибки!", + "save": "Сохранить виджет", + "saveAs": "Сохранить виджет как", + "save-widget-type-as": "Сохранить тип виджета как", + "save-widget-type-as-text": "Пожалуйста, введите название виджета и/или укажите целевой набор виджетов", + "toggle-fullscreen": "Во весь экран", + "run": "Запустить виджет", + "title": "Название виджета", + "title-required": "Название виджета обязательно.", + "type": "Тип виджета", + "resources": "Ресурсы", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Удалить ресурс", + "add-resource": "Добавить ресурс", + "html": "HTML", + "tidy": "Форматировать", + "css": "CSS", + "settings-schema": "Схема конфигурации", + "datakey-settings-schema": "Схема конфигурации ключа данных", + "javascript": "Javascript", + "remove-widget-type-title": "Вы точно хотите удалить виджет '{{widgetName}}'?", + "remove-widget-type-text": "Внимание, после подтверждения тип виджета и все связанные с ним данные будут безвозвратно утеряны.", + "remove-widget-type": "Удалить тип виджета", + "add-widget-type": "Добавить новый тип виджета", + "widget-type-load-failed-error": "Не удалось загрузить тип виджета!", + "widget-template-load-failed-error": "Не удалось загрузить шаблон виджета!", + "add": "Добавить виджет", + "undo": "Откатить изменения в виджете", + "export": "Экспортировать виджет" + }, + "widget-action": { + "header-button": "Кнопка заголовка виджета", + "open-dashboard-state": "Перейти к новому состоянию дашборда", + "update-dashboard-state": "Обновить текущее состояние дашборда", + "open-dashboard": "Перейти к другому дашборду", + "custom": "Пользовательское действие", + "custom-pretty": "Пользовательское действие (с HTML шаблоном)", + "target-dashboard-state": "Целевое состояние дашборда", + "target-dashboard-state-required": "Целевое состояние дашборда обязательно", + "set-entity-from-widget": "Установить объект из виджета", + "target-dashboard": "Целевой дашборд", + "open-right-layout": "Открыть мобильный режим дашборда" + }, + "widgets-bundle": { + "current": "Текущий набор", + "widgets-bundles": "Наборы виджетов", + "add": "Добавить набор виджетов", + "delete": "Удалить набор виджетов", + "title": "Название", + "title-required": "Название обязательно.", + "add-widgets-bundle-text": "Добавить новый набор виджетов", + "no-widgets-bundles-text": "Наборы виджетов не найдены", + "empty": "Пустой набор виджетов", + "details": "Подробности", + "widgets-bundle-details": "Подробности о наборе виджетов", + "delete-widgets-bundle-title": "Вы точно хотите удалить набор виджетов '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Внимание, после подтверждения набор виджетов и все связанные с ним данные будут безвозвратно утеряны.", + "delete-widgets-bundles-title": "Вы точно хотите удалить { count, plural, one {1 набор виджетов} few {# набора виджетов} other {# наборов виджетов} }?", + "delete-widgets-bundles-action-title": "Удалить { count, plural, one {1 набор виджетов} few {# набора виджетов} other {# наборов виджетов} }", + "delete-widgets-bundles-text": "Внимание, после подтверждения выбранные наборы виджетов и все связанные с ними данные будут безвозвратно утеряны..", + "no-widgets-bundles-matching": "Набор виджетов '{{widgetsBundle}}' не найден.", + "widgets-bundle-required": "Набор виджетов обязателен.", + "system": "Системный", + "import": "Импортировать набор виджетов", + "export": "Экспортировать набор виджетов", + "export-failed-error": "Не удалось экспортировать набор виджетов: {{error}}", + "create-new-widgets-bundle": "Создать новый набор виджетов", + "widgets-bundle-file": "Файл набора виджетов", + "invalid-widgets-bundle-file-error": "Не удалось импортировать набор виджетов: неизвестная схема данных набора виджетов." + }, + "widget-config": { + "data": "Данные", + "settings": "Настройки", + "advanced": "Дополнительно", + "title": "Название", + "general-settings": "Общие настройки", + "display-title": "Показать название на виджете", + "drop-shadow": "Тень", + "enable-fullscreen": "Во весь экран", + "background-color": "Цвет фона", + "text-color": "Цвет текста", + "padding": "Отступ", + "margin": "Margin", + "widget-style": "Стиль виджета", + "title-style": "Стиль названия", + "mobile-mode-settings": "Мобильный режим", + "order": "Порядок", + "height": "Высота", + "units": "Специальный символ после значения", + "decimals": "Количество цифр после запятой", + "timewindow": "Временное окно", + "use-dashboard-timewindow": "Использовать временное окно дашборда", + "display-timewindow": "Показывать временное окно", + "legend": "Легенда", + "display-legend": "Показать легенду", + "datasources": "Источники данных", + "maximum-datasources": "Максимальной количество источников данных равно {{count}}", + "datasource-type": "Тип", + "datasource-parameters": "Параметры", + "remove-datasource": "Удалить источник данных", + "add-datasource": "Добавить источник данных", + "target-device": "Целевое устройство", + "alarm-source": "Источник оповещения", + "actions": "Действия", + "action": "Действие", + "add-action": "Добавить действие", + "search-actions": "Поиск действий", + "action-source": "Источник действий", + "action-source-required": "Источник действий обязателен.", + "action-name": "Название", + "action-name-required": "Название действия обязательно.", + "action-name-not-unique": "Действие с таким именем уже существует.
    Название должно быть уникально в рамках одного источника действий.", + "action-icon": "Иконка", + "action-type": "Тип", + "action-type-required": "Тип действий обязателен.", + "edit-action": "Редактировать действие", + "delete-action": "Удалить действие", + "delete-action-title": "Удалить действие виджета", + "delete-action-text": "Вы точно хотите удалить действие виджета '{{actionName}}'?", + "title-icon": "Иконка в названии виджета", + "display-icon": "Показывать иконку в названии виджета", + "icon-color": "Цвет иконки", + "icon-size": "Размер иконки", + "advanced-settings": "Расширенные настройки", + "data-settings": "Настройки данных", + "no-data-display-message": "\"Нет данных для отображения\" альтернативный текст" + }, + "widget-type": { + "import": "Импортировать тип виджета", + "export": "Экспортировать тип виджета", + "export-failed-error": "Не удалось экспортировать тип виджета: {{error}}", + "create-new-widget-type": "Создать новый тип виджета", + "widget-type-file": "Файл типа виджета", + "invalid-widget-type-file-error": "Не удалось импортировать виджет: неизвестная схема данных типа виджета." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Вс", + "Mon": "Пн", + "Tue": "Вт", + "Wed": "Ср", + "Thu": "Чт", + "Fri": "Пт", + "Sat": "Сб", + "Jan": "Янв.", + "Feb": "Февр.", + "Mar": "Март", + "Apr": "Апр.", + "May": "Май", + "Jun": "Июнь", + "Jul": "Июль", + "Aug": "Авг.", + "Sep": "Сент.", + "Oct": "Окт.", + "Nov": "Нояб.", + "Dec": "Дек.", + "January": "Январь", + "February": "Февраль", + "March": "Март", + "April": "Апрель", + "June": "Июнь", + "July": "Июль", + "August": "Август", + "September": "Сентябрь", + "October": "Октября", + "November": "Ноябрь", + "December": "Декабрь", + "Custom Date Range": "Пользовательский диапазон дат", + "Date Range Template": "Шаблон диапазона дат", + "Today": "Сегодня", + "Yesterday": "Вчера", + "This Week": "На этой неделе", + "Last Week": "Прошлая неделя", + "This Month": "Этот месяц", + "Last Month": "Прошлый месяц", + "Year": "Год", + "This Year": "В этом году", + "Last Year": "Прошлый год", + "Date picker": "Выбор даты", + "Hour": "Час", + "Day": "День", + "Week": "Неделю", + "2 weeks": "2 Недели", + "Month": "Месяц", + "3 months": "3 Месяца", + "6 months": "6 Месяцев", + "Custom interval": "Пользовательский интервал", + "Interval": "Интервал", + "Step size": "Размер шага", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Атрибут не может быть выбран в этом виджете", + "date": "Дата", + "blocked-location": "Геолокация заблокирована в вашем браузере", + "claim-device": "Подтвердить устройство", + "claim-failed": "Не удалось подтвердить устройство!", + "claim-not-found": "Устройство не найдено!", + "claim-successful": "Устройство успешно подтверждено!", + "discard-changes": "Отменить изменения", + "device-name": "Название устройства", + "device-name-required": "Необходимо указать название устройства", + "entity-attribute-required": "Значение атрибута обязателено", + "entity-coordinate-required": "Необходимо указать широту и долготу", + "entity-timeseries-required": "Значение телеметрии обязательно", + "get-location": "Получить текущее местоположение", + "latitude": "Широта", + "longitude": "Долгота", + "not-allowed-entity": "Выбраный объект не имеет общих атрибутов", + "no-attribute-selected": "Атрибут не выбран", + "no-datakey-selected": "Ни один datakey не выбран", + "no-entity-selected": "Объект не выбран", + "no-coordinate-specified": "Ключ для широты/долготы не указан", + "no-support-geolocation": "Ваш браузер не поддерживает геолокацию", + "no-image": "Нет изображения", + "no-support-web-camera": "Нет поддерживаемой веб-камеры", + "no-timeseries-selected": "Параметр телеметрии не выбран", + "secret-key": "Секретный ключ", + "secret-key-required": "Необходимо указать секретный ключ", + "switch-attribute-value": "Изменить значение атрибута", + "switch-camera": "Изменить камеру", + "switch-timeseries-value": "Изменить значение телеметрии", + "take-photo": "Сделать фото", + "time": "Время", + "timeseries-not-allowed": "Телеметрия не может быть выбрана в этом виджете", + "update-failed": "Не удалось обновить", + "update-successful": "Успешно обновлено", + "update-attribute": "Обновить атрибут", + "update-timeseries": "Обновить телеметрию", + "value": "Значение" + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Тип сообщения", + "method": "Метод", + "params": "Параметры", + "created-time": "Время создания", + "expiration-time": "Время жизни", + "retries": "Повторные попытки", + "status": "Статус", + "filter": "Фильтр", + "refresh": "Обновить", + "add": "Добавить RPC запрос", + "details": "Детали", + "delete": "Удалить", + "delete-request-title": "Удалить RPC запрос", + "delete-request-text": "Вы точно хотите удалить RPC запрос?", + "details-title": "Детали RPC ID: ", + "additional-info": "Дополнительная информация", + "response": "Ответ", + "any-status": "Любой статус", + "rpc-status-list": "Список RPC статусов", + "no-request-prompt": "Запросы не найдены", + "send-request": "Отправить запрос", + "add-title": "Добавить новый RPC запрос", + "method-error": "Метод обязателен.", + "white-space-error": "Пробелы не допускаются.", + "rpc-status": { + "QUEUED": "В ОЧЕРЕДИ", + "SENT": "ОТПРАВЛЕННО", + "DELIVERED": "ДОСТАВЛЕННО", + "SUCCESSFUL": "УСПЕШНО", + "TIMEOUT": "ВРЕМЯ ИСТЕКЛО", + "EXPIRED": "ПРОСРОЧЕНО", + "FAILED": "НЕУДАЧНО" + }, + "rpc-search-status-all": "ВСЕ", + "message-types": { + "false": "Двусторонний", + "true": "Односторонний" + } + } + }, + "icon": { + "icon": "Иконка", + "select-icon": "Выбрать иконку", + "material-icons": "Иконки в стиле Material", + "show-all": "Показать все иконки" + }, + "custom": { + "widget-action": { + "action-cell-button": "Кнопка действия в ячейке таблицы", + "row-click": "Действий при щелчке на строку", + "marker-click": "Действия при щелчке на маркер", + "polygon-click": "Действия при щелчке на полигон", + "tooltip-tag-action": "Действие при нажатии на ссылку в подсказке", + "node-selected": "Действий при выборе ноды", + "element-click": "Действий при щелчке на HTML элементе", + "pie-slice-click": "Действий при щелчке на секции круговой диаграммы", + "row-double-click": "Действий при двойном щелчке на строку" + } + }, + "language": { + "language": "Язык" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json new file mode 100644 index 0000000..c57ec4d --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json @@ -0,0 +1,2476 @@ +{ + "access": { + "unauthorized": "Nepooblaščeno", + "unauthorized-access": "Nepooblaščen dostop", + "unauthorized-access-text": "Za dostop do tega vira se morate prijaviti!", + "access-forbidden": "Dostop prepovedan", + "access-forbidden-text": "Nimate pravic dostopa do te lokacije!
    Poskusite se prijaviti z drugim uporabnikom, če še vedno želite dostopati do te lokacije.", + "refresh-token-expired": "Seja je potekla", + "refresh-token-failed": "Seje ni mogoče osvežiti", + "permission-denied": "Dovoljenje zavrnjeno", + "permission-denied-text": "Nimate dovoljenja za izvedbo tega dejanja!" + }, + "action": { + "activate": "Aktiviraj", + "suspend": "Začasno ustavi", + "save": "Shrani", + "saveAs": "Shrani kot", + "cancel": "Prekliči", + "ok": "V redu", + "delete": "Izbriši", + "add": "Dodaj", + "yes": "Da", + "no": "Ne", + "update": "Nadgradnja", + "remove": "Odstrani", + "select": "Izberi", + "search": "Iskanje", + "clear-search": "Počisti iskanje", + "assign": "Dodeli", + "unassign": "Preklic dodelitve", + "share": "Deliti", + "make-private": "Naj bo zasebno", + "apply": "Uporabi", + "apply-changes": "Uporabi spremembe", + "edit-mode": "Način urejanja", + "enter-edit-mode": "Vstopi v način urejanja", + "decline-changes": "Zavrni spremembe", + "close": "Zapri", + "back": "Nazaj", + "run": "Zaženi", + "sign-in": "Prijavite se!", + "edit": "Uredi", + "view": "Pogled", + "create": "Ustvari", + "drag": "Povleci", + "refresh": "Osveži", + "undo": "Razveljavi", + "copy": "Kopiraj", + "paste": "Prilepi", + "copy-reference": "Kopiraj sklic", + "paste-reference": "Prilepi sklic", + "import": "Uvozi", + "export": "Izvozi", + "share-via": "Deli prek {{provider}}", + "continue": "Nadaljuj", + "discard-changes": "Zavrzi spremembe", + "download": "Prenesi", + "next-with-label": "Naslednji: {{label}}", + "read-more": "Preberi več", + "hide": "Skrij", + "done": "Končano" + }, + "aggregation": { + "aggregation": "Združevanje", + "function": "Funkcija združevanja podatkov", + "limit": "Največje vrednosti", + "group-interval": "Interval združevanja", + "min": "Min", + "max": "Max", + "avg": "Povprečje", + "sum": "Vsota", + "count": "Štetje", + "none": "Brez" + }, + "admin": { + "general": "Splošno", + "general-settings": "Splošne nastavitve", + "outgoing-mail": "Poštni strežnik", + "outgoing-mail-settings": "Nastavitve strežnika odhodne pošte", + "system-settings": "Sistemske nastavitve", + "test-mail-sent": "Testna pošta je bila uspešno poslana!", + "base-url": "Osnovni URL", + "base-url-required": "Zahtevan je osnovni URL.", + "prohibit-different-url": "Prohibit to use hostname from the client request headers", + "prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled", + "mail-from": "Pošta od", + "mail-from-required": "Zahtevana je pošta od.", + "smtp-protocol": "Protokol SMTP", + "smtp-host": "SMTP gostitelj", + "smtp-host-required": "Zahtevan je gostitelj SMTP.", + "smtp-port": "Vrata SMTP", + "smtp-port-required": "Navesti morate vrata SMTP.", + "smtp-port-invalid": "To ne izgleda kot veljavna vrata SMTP.", + "timeout-msec": "Časovna omejitev (msec)", + "timeout-required": "Časovna omejitev je potrebna.", + "timeout-invalid": "To ne izgleda kot veljavna časovna omejitev.", + "enable-tls": "Omogoči TLS", + "tls-version": "Različica TLS", + "enable-proxy": "Omogoči proxy", + "proxy-host": "Gostitelj proxy", + "proxy-host-required": "Zahtevan je strežnik proxy.", + "proxy-port": "Proxy vrata", + "proxy-port-required": "Vrata proxy so potrebna.", + "proxy-port-range": "Vrata proxy naj bodo v območju od 1 do 65535.", + "proxy-user": "Uporabnik proxy", + "proxy-password": "Geslo proxy", + "send-test-mail": "Pošlji testno pošto", + "sms-provider": "SMS provider", + "sms-provider-settings": "SMS provider settings", + "sms-provider-type": "SMS provider type", + "sms-provider-type-required": "SMS provider type is required.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID is required", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key is required", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region is required", + "number-from": "Phone Number From", + "number-from-required": "Phone Number From is required.", + "number-to": "Phone Number To", + "number-to-required": "Phone Number To is required.", + "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", + "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", + "sms-message": "SMS message", + "sms-message-required": "SMS message is required.", + "sms-message-max-length": "SMS message can't be longer 1600 characters", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID is required", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token is required", + "send-test-sms": "Send test SMS", + "test-sms-sent": "Test SMS was successfully sent!", + "security-settings": "Varnostne nastavitve", + "password-policy": "Pravilnik o geslih", + "minimum-password-length": "Najkrajša dolžina gesla", + "minimum-password-length-required": "Zahtevana je najkrajša dolžina gesla", + "minimum-password-length-range": "Minimalna dolžina gesla naj bo v območju od 5 do 50", + "minimum-uppercase-letters": "Najmanjše število velikih črk", + "minimum-uppercase-letters-range": "Najmanjše število velikih črk ne sme biti negativno", + "minimum-lowercase-letters": "Najmanjše število malih črk", + "minimum-lowercase-letters-range": "Najmanjše število malih črk ne sme biti negativno", + "minimum-digits": "Najmanjše število številk", + "minimum-digits-range": "Najmanjše število številk ne sme biti negativno", + "minimum-special-characters": "Najmanjše število posebnih znakov", + "minimum-special-characters-range": "Najmanjše število posebnih znakov ne sme biti negativno", + "password-expiration-period-days": "Obdobje veljavnosti gesla v dneh", + "password-expiration-period-days-range": "Obdobje veljavnosti gesla v dneh ne sme biti negativno", + "password-reuse-frequency-days": "Pogostost ponovne uporabe gesla v dneh", + "password-reuse-frequency-days-range": "Pogostost ponovne uporabe gesla v dneh ne sme biti negativna", + "general-policy": "Splošna politika", + "max-failed-login-attempts": "Največje število neuspelih poskusov prijave, preden se račun zaklene", + "minimum-max-failed-login-attempts-range": "Največje število neuspelih poskusov prijave ne sme biti negativno", + "user-lockout-notification-email": "V primeru zaklepa uporabniškega računa, pošlji obvestilo na e-pošto", + "domain-name": "Domain name", + "domain-name-unique": "Domain name and protocol need to unique.", + "error-verification-url": "A domain name shouldn't contain symbols '/' and ':'. Example: thingsboard.io", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI is required.", + "activate-user": "Activate user", + "add-domain": "Add domain", + "delete-domain": "Delete domain", + "add-provider": "Add provider", + "delete-provider": "Delete provider", + "allow-user-creation": "Allow user creation", + "always-fullscreen": "Always fullscreen", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI is required.", + "client-authentication-method": "Client authentication method", + "client-id": "Client ID", + "client-id-required": "Client ID is required.", + "client-secret": "Client secret", + "client-secret-required": "Client secret is required.", + "custom-setting": "Custom settings", + "customer-name-pattern": "Customer name pattern", + "default-dashboard-name": "Default dashboard name", + "delete-domain-text": "Be careful, after the confirmation a domain and all provider data will be unavailable.", + "delete-domain-title": "Are you sure you want to delete settings the domain '{{domainName}}'?", + "delete-registration-text": "Be careful, after the confirmation a provider data will be unavailable.", + "delete-registration-title": "Are you sure you want to delete the provider '{{name}}'?", + "email-attribute-key": "Email attribute key", + "email-attribute-key-required": "Email attribute key is required.", + "first-name-attribute-key": "First name attribute key", + "general": "General", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Last name attribute key", + "login-button-icon": "Login button icon", + "login-button-label": "Provider label", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "Label is required.", + "login-provider": "Login provider", + "mapper": "Mapper", + "new-domain": "New domain", + "oauth2": "OAuth2", + "redirect-uri-template": "Redirect URI template", + "copy-redirect-uri": "Copy redirect URI", + "registration-id": "Registration ID", + "registration-id-required": "Registration ID is required.", + "registration-id-unique": "Registration ID need to unique for the system.", + "scope": "Scope", + "scope-required": "Scope is required.", + "tenant-name-pattern": "Tenant name pattern", + "tenant-name-pattern-required": "Tenant name pattern is required.", + "tenant-name-strategy": "Tenant name strategy", + "type": "Mapper type", + "uri-pattern-error": "Invalid URI format.", + "url": "URL", + "url-pattern": "Invalid URL format.", + "url-required": "URL is required.", + "user-info-uri": "User info URI", + "user-info-uri-required": "User info URI is required.", + "user-name-attribute-name": "User name attribute key", + "user-name-attribute-name-required": "User name attribute key is required", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Enable OAuth2 settings" + } + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmi", + "select-alarm": "Izberi alarm", + "no-alarms-matching": "Najdeni niso bili nobeni alarmi, ki se ujemajo z '{{entity}}'.", + "alarm-required": "Potreben je alarm", + "alarm-status": "Stanje alarma", + "alarm-status-list": "Seznam stanja alarma", + "any-status": "Kateri koli status", + "search-status": { + "ANY": "Katerikoli", + "ACTIVE": "Aktivno", + "CLEARED": "Počiščeno", + "ACK": "Potrjeno", + "UNACK": "Nepriznano" + }, + "display-status": { + "ACTIVE_UNACK": "Aktivno nepriznano", + "ACTIVE_ACK": "Aktivno potrjeno", + "CLEARED_UNACK": "Počiščeno nepriznano", + "CLEARED_ACK": "Počiščeno potrjeno" + }, + "no-alarms-prompt": "Ni najdenih alarmov", + "created-time": "Ustvarjeni čas", + "type": "Vrsta", + "severity": "Resnost", + "originator": "Izvirnik", + "originator-type": "Vrsta izvirnika", + "details": "Podrobnosti", + "status": "Stanje", + "alarm-details": "Podrobnosti alarma", + "start-time": "Začetni čas", + "end-time": "Končni čas", + "ack-time": "Priznani čas", + "clear-time": "Očiščen čas", + "alarm-severity-list": "Seznam resnosti alarma", + "any-severity": "Katerakoli resnost", + "severity-critical": "Kritična", + "severity-major": "Visoka", + "severity-minor": "Nizka", + "severity-warning": "Opozorilo", + "severity-indeterminate": "Nedoločeno", + "acknowledge": "Potrdi", + "clear": "Počisti", + "search": "Iskanje alarmov", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } izbran", + "no-data": "Ni podatkov za prikaz", + "polling-interval": "Interval iskanja alarmov (s)", + "polling-interval-required": "Zahtevan je interval glasovanja alarmov.", + "min-polling-interval-message": "Dovoljen je vsaj 1-sekundni interval glasovanja.", + "aknowledge-alarms-title": "Potrdite { count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "Ali ste prepričani, da želite potrditi { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Potrdite alarm", + "aknowledge-alarm-text": "Ali ste prepričani, da želite potrditi alarm?", + "clear-alarms-title": "Počisti { count, plural, 1 {1 alarm} other {# alarmi} }", + "clear-alarms-text": "Ali ste prepričani, da želite počistiti { count, plural, 1 {1 alarm} other {# alarme} }?", + "clear-alarm-title": "Počisti alarm", + "clear-alarm-text": "Ali ste prepričani, da želite počistiti alarm?", + "alarm-status-filter": "Filter stanja alarma", + "alarm-filter": "Alarmni filter", + "max-count-load": "Največje število naloženih alarmov (0 - neomejeno)", + "max-count-load-required": "Zahtevano je največje število alarmov za nalaganje.", + "max-count-load-error-min": "Najmanjša vrednost je 0.", + "fetch-size": "Velikost prenosa", + "fetch-size-required": "Zahtevana je velikost prenosa.", + "fetch-size-error-min": "Najmanjša vrednost je 10.", + "alarm-type-list": "Seznam vrst alarmov", + "any-type": "Katerakoli vrsta", + "search-propagated-alarms": "Iskanje razširjenih alarmov" + }, + "alias": { + "add": "Dodaj vzdevek", + "edit": "Uredi vzdevek", + "name": "Ime vzdevka", + "name-required": "Ime vzdevka je obvezno", + "duplicate-alias": "Vzdevek z istim imenom že obstaja.", + "filter-type-single-entity": "Enota", + "filter-type-entity-list": "Seznam entitet", + "filter-type-entity-name": "Ime entitete", + "filter-type-state-entity": "Subjekt iz stanja nadzorne plošče", + "filter-type-state-entity-description": "Entiteta, vzeta iz parametrov stanja nadzorne plošče", + "filter-type-asset-type": "Vrsta sredstva", + "filter-type-asset-type-description": "Sredstva vrste '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Sredstva vrste '{{assetType}}' in z imenom, ki se začne z '{{prefix}}'", + "filter-type-device-type": "Vrsta naprave", + "filter-type-device-type-description": "Naprave tipa '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Naprave vrste '{{deviceType}}' in z imenom, ki se začne z '{{prefix}}'", + "filter-type-entity-view-type": "Vrsta pogleda entitete", + "filter-type-entity-view-type-description": "Pogledi entitet vrste '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Pogledi entitet vrste '{{entityView}}' in z imenom, ki se začne z '{{prefix}}'", + "filter-type-relations-query": "Poizvedba odnosov", + "filter-type-relations-query-description": "{{entities}}, ki imajo {{relationType}} relacijo {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Iskalna poizvedba sredstva", + "filter-type-asset-search-query-description": "Sredstva s tipi {{assetTypes}}, ki imajo {{relationType}} relacijo {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Iskalna poizvedba naprave", + "filter-type-device-search-query-description": "Naprave s tipi {{deviceTypes}}, ki imajo {{relationType}} relacijo {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Iskalna poizvedba pogleda entitete", + "filter-type-entity-view-search-query-description": "Pogledi entitet s tipi {{entityViewTypes}}, ki imajo {{relationType}} relacijo {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Api Usage State", + "entity-filter": "Filter entitet", + "resolve-multiple": "Reši kot več entitet", + "filter-type": "Vrsta filtra", + "filter-type-required": "Zahtevana je vrsta filtra.", + "entity-filter-no-entity-matched": "Najdenih ni nobenih entitet, ki se ujemajo z navedenim filtrom.", + "no-entity-filter-specified": "Ni določen noben filter entitete", + "root-state-entity": "Uporabi entiteto stanja na nadzorni plošči kot izvorno", + "last-level-relation": "Pridobi samo razmerje na zadnji ravni", + "root-entity": "Izvorna entiteta", + "state-entity-parameter-name": "Ime parametra državne entitete", + "default-state-entity": "Privzeta državna entiteta", + "default-entity-parameter-name": "Privzeto", + "max-relation-level": "Najvišja stopnja relacije", + "unlimited-level": "Neomejena raven", + "state-entity": "Entiteta stanja nadzorne plošče", + "all-entities": "Vsi subjekti", + "any-relation": "katerikoli" + }, + "asset": { + "asset": "Sredstvo", + "assets": "Sredstva", + "management": "Upravljanje sredstev", + "view-assets": "Ogled sredstev", + "add": "Dodaj sredstvo", + "assign-to-customer": "Dodeli stranki", + "assign-asset-to-customer": "Dodeli sredstva stranki", + "assign-asset-to-customer-text": "Izberite sredstva, ki jih želite dodeliti stranki", + "no-assets-text": "Sredstva ni bilo mogoče najti", + "assign-to-customer-text": "Izberite stranko, ki bo dodelila sredstvo (-a)", + "public": "Javno", + "assignedToCustomer": "Dodeljeno stranki", + "make-public": "Naj bo sredstvo javno", + "make-private": "Naj bo sredstvo zasebno", + "unassign-from-customer": "Odjavi od stranke", + "delete": "Izbriši sredstvo", + "asset-public": "Sredstvo je javno", + "asset-type": "Vrsta sredstva", + "asset-type-required": "Zahtevana je vrsta sredstva.", + "select-asset-type": "Izberite vrsto sredstva", + "enter-asset-type": "Vnesite vrsto sredstva", + "any-asset": "Katerokoli sredstvo", + "no-asset-types-matching": "Najdena ni bila nobena vrsta sredstva, ki se ujema z '{{entitySubtype}}'.", + "asset-type-list-empty": "Izbrana ni nobena vrsta sredstva.", + "asset-types": "Vrste sredstev", + "name": "Ime", + "name-required": "Ime je obvezno.", + "description": "Opis", + "type": "Vrsta", + "type-required": "Tip je obvezen.", + "details": "Podrobnosti", + "events": "Dogodki", + "add-asset-text": "Dodaj novo sredstvo", + "asset-details": "Podrobnosti o sredstvih", + "assign-assets": "Dodelitev sredstev", + "assign-assets-text": "Stranki dodeli { count, plural, 1 {1 sredstvo} other {# assets} }", + "delete-assets": "Izbriši sredstva", + "unassign-assets": "Preklic dodelitve sredstev", + "unassign-assets-action-title": "Stranki ne dodeli { count, plural, 1 {1 sredstvo} other {# assets} }", + "assign-new-asset": "Dodeli novo sredstvo", + "delete-asset-title": "Ali ste prepričani, da želite izbrisati sredstvo '{{assetName}}'?", + "delete-asset-text": "Bodite previdni, po potrditvi bo sredstvo in vsi povezani podatki nepopravljivi.", + "delete-assets-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 sredstvo} other {# assets} }?", + "delete-assets-action-title": "Izbriši { count, plural, 1 {1 sredstvo} other {# assets} }", + "delete-assets-text": "Bodite previdni, po potrditvi bodo vsa izbrana sredstva odstranjena in vsi povezani podatki bodo postali nepopravljivi.", + "make-public-asset-title": "Ali ste prepričani, da želite sredstvo '{{assetName}}' objaviti kot javno?", + "make-public-asset-text": "Po potrditvi bodo sredstvo in vsi njegovi podatki javno dostopni in dostopni drugim.", + "make-private-asset-title": "Ali ste prepričani, da želite sredstvo '{{assetName}}' narediti zasebno?", + "make-private-asset-text": "Po potrditvi bodo sredstvo in vsi njegovi podatki postali zasebni in ne bodo dostopni drugim.", + "unassign-asset-title": "Ali ste prepričani, da želite dodeliti sredstvo '{{assetName}}'?", + "unassign-asset-text": "Po potrditvi sredstvo ne bo dodeljeno in stranka ne bo dostopna.", + "unassign-asset": "Preklic dodelitve sredstva", + "unassign-assets-title": "Ali ste prepričani, da želite odpovedati { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "Po potrditvi vsa izbrana sredstva ne bodo dodeljena in stranka ne bo dostopna do njih.", + "copyId": "Kopiraj ID sredstva", + "idCopiedMessage": "ID sredstva je kopiran v odložišče", + "select-asset": "Izberi sredstvo", + "no-assets-matching": "Nobeno sredstvo, ki se ujema z '{{entity}}', ni bilo mogoče najti.", + "asset-required": "Zahtevano je sredstvo", + "name-starts-with": "Ime sredstva se začne z", + "import": "Uvozi sredstva", + "asset-file": "Datoteka sredstva", + "search": "Išči sredstva", + "selected-assets": "{ count, plural, 1 {1 asset} other {# assets} } izbrano", + "label": "Oznaka" + }, + "attribute": { + "attributes": "Lastnosti", + "latest-telemetry": "Najnovejša telemetrija", + "attributes-scope": "Obseg atributov entitete", + "scope-latest-telemetry": "Najnovejša telemetrija", + "scope-client": "Atributi odjemalca", + "scope-server": "Atributi strežnika", + "scope-shared": "Skupni atributi", + "add": "Dodaj atribut", + "key": "Ključ", + "last-update-time": "Čas zadnje posodobitve", + "key-required": "Potreben je ključ atributa.", + "value": "Vrednost", + "value-required": "Vrednost atributa je obvezna.", + "delete-attributes-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Bodite previdni, po potrditvi bodo odstranjeni vsi izbrani atributi.", + "delete-attributes": "Izbriši atribute", + "enter-attribute-value": "Vnesite vrednost atributa", + "show-on-widget": "Pokaži na pripomočku", + "widget-mode": "Način pripomočka", + "next-widget": "Naslednji pripomoček", + "prev-widget": "Prejšnji pripomoček", + "add-to-dashboard": "Dodaj na nadzorno ploščo", + "add-widget-to-dashboard": "Dodaj pripomoček na nadzorno ploščo", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } izbrano", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } izbran", + "no-attributes-text": "Ni najdenih atributov", + "no-telemetry-text": "Telemetrija ni najdena" + }, + "api-usage": { + "api-usage": "Api Usage", + "data-points": "Data points", + "data-points-storage-days": "Data points storage days", + "email": "Email", + "email-messages": "Email messages", + "email-messages-daily-activity": "Email messages daily activity", + "email-messages-hourly-activity": "Email messages hourly activity", + "email-messages-monthly-activity": "Email messages monthly activity", + "exceptions": "Exceptions", + "executions": "Executions", + "javascript": "JavaScript", + "javascript-executions": "JavaScript executions", + "javascript-functions": "JavaScript functions", + "javascript-functions-daily-activity": "JavaScript functions daily activity", + "javascript-functions-hourly-activity": "JavaScript functions hourly activity", + "javascript-functions-monthly-activity": "JavaScript functions monthly activity", + "latest-error": "Latest Error", + "messages": "Messages", + "permanent-failures": "${entityName} Permanent Failures", + "permanent-timeouts": "${entityName} Permanent Timeouts", + "processing-failures": "${entityName} Processing Failures", + "processing-failures-and-timeouts": "Processing Failures and Timeouts", + "processing-timeouts": "${entityName} Processing Timeouts", + "queue-stats": "Queue Stats", + "rule-chain": "Rule Chain", + "rule-engine": "Rule Engine", + "rule-engine-daily-activity": "Rule Engine daily activity", + "rule-engine-executions": "Rule Engine executions", + "rule-engine-hourly-activity": "Rule Engine hourly activity", + "rule-engine-monthly-activity": "Rule Engine monthly activity", + "rule-engine-statistics": "Rule Engine Statistics", + "rule-node": "Rule Node", + "sms": "SMS", + "sms-messages": "SMS messages", + "sms-messages-daily-activity": "SMS messages daily activity", + "sms-messages-hourly-activity": "SMS messages hourly activity", + "sms-messages-monthly-activity": "SMS messages monthly activity", + "successful": "${entityName} Successful", + "telemetry": "Telemetry", + "telemetry-persistence": "Telemetry persistence", + "telemetry-persistence-daily-activity": "Telemetry persistence daily activity", + "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", + "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", + "transport": "Transport", + "transport-daily-activity": "Transport daily activity", + "transport-data-points": "Transport data points", + "transport-hourly-activity": "Transport hourly activity", + "transport-messages": "Transport messages", + "transport-monthly-activity": "Transport monthly activity", + "view-details": "View details", + "view-statistics": "View statistics" + }, + "audit-log": { + "audit": "Revizija", + "audit-logs": "Revizijski dnevniki", + "timestamp": "Časovni žig", + "entity-type": "Vrsta entitete", + "entity-name": "Ime entitete", + "user": "Uporabnik", + "type": "Vrsta", + "status": "Stanje", + "details": "Podrobnosti", + "type-added": "Dodano", + "type-deleted": "Izbrisano", + "type-updated": "Posodobljeno", + "type-attributes-updated": "Atributi posodobljeni", + "type-attributes-deleted": "Atributi izbrisani", + "type-rpc-call": "Klic RPC", + "type-credentials-updated": "Poverilnice posodobljene", + "type-assigned-to-customer": "Dodeljeno kupcu", + "type-unassigned-from-customer": "Odstop od stranke", + "type-activated": "Aktivirano", + "type-suspended": "Suspendirano", + "type-credentials-read": "Poverilnice prebrane", + "type-attributes-read": "Atributi prebrani", + "type-relation-add-or-update": "Razmerje posodobljeno", + "type-relation-delete": "Razmerje izbrisano", + "type-relations-delete": "Vse povezave izbrisane", + "type-alarm-ack": "Potrjeno", + "type-alarm-clear": "Počiščeno", + "type-login": "Vpiši se", + "type-logout": "Odjava", + "type-lockout": "Izključitev", + "status-success": "Uspeh", + "status-failure": "Neuspeh", + "audit-log-details": "Podrobnosti dnevnika revizije", + "no-audit-logs-prompt": "Ni najdenih dnevnikov", + "action-data": "Podatki o dejanju", + "failure-details": "Podrobnosti o napaki", + "search": "Išči dnevnike revizije", + "clear-search": "Počisti iskanje", + "type-assigned-from-tenant": "Dodeljeno od najemnika", + "type-assigned-to-tenant": "Dodeljeno najemniku", + "type-provision-success": "Device provisioned", + "type-provision-failure": "Device provisioning was failed" + }, + "confirm-on-exit": { + "message": "Imate neshranjene spremembe. Ali ste prepričani, da želite zapustiti to stran?", + "html-message": "Imate neshranjene spremembe.
    Ali ste prepričani, da želite zapustiti to stran?", + "title": "Neshranjene spremembe" + }, + "contact": { + "country": "Država", + "city": "Mesto", + "state": "Država / Regija", + "postal-code": "Poštna številka", + "postal-code-invalid": "Neveljavna oblika poštne številke.", + "address": "Naslov", + "address2": "Naslov 2", + "phone": "Telefon", + "email": "E-naslov", + "no-address": "Brez naslova" + }, + "common": { + "username": "Uporabniško ime", + "password": "Geslo", + "enter-username": "Vnesite uporabniško ime", + "enter-password": "Vnesite geslo", + "enter-search": "Vnesite iskanje", + "created-time": "Ustvarjeni čas", + "loading": "Nalaganje..." + }, + "content-type": { + "json": "Json", + "text": "Besedilo", + "binary": "Binarni (Base64)" + }, + "customer": { + "customer": "Stranka", + "customers": "Stranke", + "management": "Upravljanje strank", + "dashboard": "Nadzorna plošča stranke", + "dashboards": "Nadzorne plošče strank", + "devices": "Naprave za stranke", + "entity-views": "Pogledi stranke", + "assets": "Sredstva strank", + "public-dashboards": "Javne nadzorne plošče", + "public-devices": "Javne naprave", + "public-assets": "Javno sredstvo", + "public-entity-views": "Pogledi javnih subjektov", + "add": "Dodaj stranko", + "delete": "Izbriši stranko", + "manage-customer-users": "Upravljanje uporabniških strank", + "manage-customer-devices": "Upravljanje naprav strank", + "manage-customer-dashboards": "Upravljanje nadzornih plošč za stranke", + "manage-public-devices": "Upravljanje javnih naprav", + "manage-public-dashboards": "Upravljanje javnih nadzornih plošč", + "manage-customer-assets": "Upravljanje sredstev strank", + "manage-public-assets": "Upravljanje javnih sredstev", + "add-customer-text": "Dodaj novo stranko", + "no-customers-text": "Nobena stranka ni najdena", + "customer-details": "Podrobnosti o stranki", + "delete-customer-title": "Ali ste prepričani, da želite izbrisati stranko '{{customerTitle}}'?", + "delete-customer-text": "Previdno, po potrditvi bo stranka in vsi povezani podatki postali nepopravljivi.", + "delete-customers-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Izbriši { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Bodite previdni, po potrditvi bodo vse izbrane stranke odstranjene in vsi povezani podatki bodo postali nepopravljivi.", + "manage-users": "Upravljanje uporabnikov", + "manage-assets": "Upravljanje sredstev", + "manage-devices": "Upravljanje naprav", + "manage-dashboards": "Upravljanje nadzornih plošč", + "title": "Naslov", + "title-required": "Naslov je obvezen.", + "description": "Opis", + "details": "Podrobnosti", + "events": "Dogodki", + "copyId": "Kopiraj ID stranke", + "idCopiedMessage": "Id stranke je kopiran v odložišče", + "select-customer": "Izberi stranko", + "no-customers-matching": "Najdena ni bila nobena stranka, ki se ujema z '{{entity}}'.", + "customer-required": "Stranka je obvezna", + "select-default-customer": "Izberi privzeto stranko", + "default-customer": "Privzeta stranka", + "default-customer-required": "Za razhroščevanje nadzorne plošče na ravni najemnika je potrebna privzeta stranka", + "search": "Iskanje strank", + "selected-customers": "{ count, plural, 1 {1 customer} other {# customers} } izbrano" + }, + "datetime": { + "date-from": "Datum, od", + "time-from": "Čas od", + "date-to": "Datum do", + "time-to": "Čas do" + }, + "dashboard": { + "dashboard": "Nadzorna plošča", + "dashboards": "Nadzorne plošče", + "management": "Upravljanje z nadzorno ploščo", + "view-dashboards": "Ogled nadzornih plošč", + "add": "Dodaj nadzorno ploščo", + "assign-dashboard-to-customer": "Dodeli nadzorne plošče strankam", + "assign-dashboard-to-customer-text": "Izberite nadzorne plošče, ki jih želite dodeliti stranki", + "assign-to-customer-text": "Izberite stranko, ki bo dodelila nadzorno ploščo (-e)", + "assign-to-customer": "Dodeli stranki", + "unassign-from-customer": "Odvzemi stranki", + "make-public": "Naredi nadzorno ploščo javno", + "make-private": "Naj nadzorna plošča postane zasebna", + "manage-assigned-customers": "Upravljanje dodeljenih strank", + "assigned-customers": "Dodeljene stranke", + "assign-to-customers": "Dodeli nadzorne plošče strankam", + "assign-to-customers-text": "Izberite stranke, ki bodo dodelile nadzorno ploščo (-e)", + "unassign-from-customers": "Odstrani nadzorne plošče strankam", + "unassign-from-customers-text": "Na nadzorni plošči izberite stranke, ki jih želite odjaviti", + "no-dashboards-text": "Ni najdenih nadzornih plošč", + "no-widgets": "Pripomočki niso nastavljeni", + "add-widget": "Dodaj nov pripomoček", + "title": "Naslov", + "select-widget-title": "Izberi pripomoček", + "select-widget-subtitle": "Seznam razpoložljivih vrst gradnikov", + "delete": "Izbriši nadzorno ploščo", + "title-required": "Naslov je obvezen.", + "description": "Opis", + "details": "Podrobnosti", + "dashboard-details": "Podrobnosti o nadzorni plošči", + "add-dashboard-text": "Dodaj novo nadzorno ploščo", + "assign-dashboards": "Dodeli nadzorne plošče", + "assign-new-dashboard": "Dodeli novo nadzorno ploščo", + "assign-dashboards-text": "Strankam dodeli { count, plural, 1 {1 dashboard} other {# dashboards} }", + "unassign-dashboards-action-text": "Strankam ne dodeli { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards": "Izbriši nadzorne plošče", + "unassign-dashboards": "Preklic dodelitve nadzornih plošč", + "unassign-dashboards-action-title": "Stranki odstrani { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboard-title": "Ali ste prepričani, da želite izbrisati nadzorno ploščo '{{dashboardTitle}}'?", + "delete-dashboard-text": "Previdno, po potrditvi bodo armaturna plošča in vsi povezani podatki nepopravljivi.", + "delete-dashboards-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Izbriši { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Bodite previdni, po potrditvi bodo vse izbrane nadzorne plošče odstranjene in vsi povezani podatki bodo postali nepopravljivi.", + "unassign-dashboard-title": "Ali ste prepričani, da želite odstraniti nadzorno ploščo '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Po potrditvi bo nadzorna plošča odstranjena in nedostopna stranki.", + "unassign-dashboard": "Preklic dodelitve nadzorne plošče", + "unassign-dashboards-title": "Ali ste prepričani, da želite odstraniti { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Po potrditvi bodo vse izbrane nadzorne plošče odstranjene in nedostopne stranki.", + "public-dashboard-title": "Nadzorna plošča je zdaj javna", + "public-dashboard-text": "Vaša nadzorna plošča {{dashboardTitle}} je zdaj javna in dostopna prek naslednje javne povezave", + "public-dashboard-notice": "Opomba: Ne pozabite objaviti sorodnih naprav za dostop do njihovih podatkov.", + "make-private-dashboard-title": "Ali ste prepričani, da želite nadzorno ploščo '{{dashboardTitle}}' narediti zasebno?", + "make-private-dashboard-text": "Po potrditvi bo nadzorna plošča postala zasebna in ne bo dostopna drugim.", + "make-private-dashboard": "Naj nadzorna plošča postane zasebna", + "socialshare-text": "'{{dashboardTitle}}' poganja ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' poganja ThingsBoard", + "select-dashboard": "Izberi nadzorno ploščo", + "no-dashboards-matching": "Najdena ni bila nobena nadzorna plošča, ki se ujema z '{{entity}}'.", + "dashboard-required": "Potrebna je nadzorna plošča.", + "select-existing": "Izberi obstoječo nadzorno ploščo", + "create-new": "Ustvari novo nadzorno ploščo", + "new-dashboard-title": "Nov naslov nadzorne plošče", + "open-dashboard": "Odpri nadzorno ploščo", + "set-background": "Nastavi ozadje", + "background-color": "Barva ozadja", + "background-image": "Slika ozadja", + "background-size-mode": "Način velikosti ozadja", + "no-image": "Izbrana ni nobena slika", + "drop-image": "Spustite sliko ali kliknite, da izberete datoteko za nalaganje.", + "settings": "Nastavitve", + "columns-count": "Število stolpcev", + "columns-count-required": "Število stolpcev je obvezno.", + "min-columns-count-message": "Dovoljeno je samo 10 najmanjših števcev stolpcev.", + "max-columns-count-message": "Dovoljeno je samo 1000 največje število stolpcev.", + "widgets-margins": "Razmik med pripomočki", + "margin-required": "Zahtevana je vrednost razmika.", + "min-margin-message": "Najmanjša dovoljena vrednost razmika je 0.", + "max-margin-message": "Največja dovoljena vrednost razmika je 50.", + "horizontal-margin": "Vodoravni razmik", + "horizontal-margin-required": "Zahtevana je horizontalna vrednost razmika.", + "min-horizontal-margin-message": "Najmanjša dovoljena vrednost vodoravnega razmika je 0.", + "max-horizontal-margin-message": "Največja dovoljena vrednost vodoravnega razmika je 50.", + "vertical-margin": "Navpični razmik", + "vertical-margin-required": "Zahtevana je navpična vrednost razmika.", + "min-vertical-margin-message": "Najmanjša dovoljena vrednost navpičnega razmika je 0.", + "max-vertical-margin-message": "Največja dovoljena vrednost navpičnega razmika je 50.", + "autofill-height": "Samodejno polnjenje višine postavitve", + "mobile-layout": "Nastavitve mobilne postavitve", + "mobile-row-height": "Višina mobilne vrstice, px", + "mobile-row-height-required": "Vrednost višine mobilne vrstice je obvezna.", + "min-mobile-row-height-message": "Najmanjša dovoljena vrednost višine mobilne vrstice je 5 slikovnih pik.", + "max-mobile-row-height-message": "Največja dovoljena vrednost višine mobilne vrstice je 200 slikovnih pik.", + "display-title": "Prikaži naslov nadzorne plošče", + "toolbar-always-open": "Orodna vrstica naj bo odprta", + "title-color": "Barva naslova", + "display-dashboards-selection": "Prikaži izbiro nadzornih plošč", + "display-entities-selection": "Prikaži izbor entitet", + "display-filters": "Prikaži filtre", + "display-dashboard-timewindow": "Prikaži časovno okno", + "display-dashboard-export": "Prikaži izvoz", + "import": "Uvozi nadzorno ploščo", + "export": "Izvozi nadzorno ploščo", + "export-failed-error": "Nadzorne plošče ni mogoče izvoziti: {{error}}", + "create-new-dashboard": "Ustvari novo nadzorno ploščo", + "dashboard-file": "Datoteka nadzorne plošče", + "invalid-dashboard-file-error": "Nadzorne plošče ni mogoče uvoziti: neveljavna struktura podatkov na nadzorni plošči.", + "dashboard-import-missing-aliases-title": "Konfiguriranje vzdevkov, ki jih uporablja uvožena nadzorna plošča", + "create-new-widget": "Ustvari nov pripomoček", + "import-widget": "Uvozi pripomoček", + "widget-file": "Datoteka pripomočka", + "invalid-widget-file-error": "Pripomočka ni mogoče uvoziti: neveljavna struktura podatkov pripomočka.", + "widget-import-missing-aliases-title": "Konfiguriranje vzdevkov, ki jih uporablja uvoženi gradnik", + "open-toolbar": "Odpri orodno vrstico armaturne plošče", + "close-toolbar": "Zapri orodno vrstico", + "configuration-error": "Napaka v konfiguraciji", + "alias-resolution-error-title": "Napaka pri konfiguraciji vzdevkov na nadzorni plošči", + "invalid-aliases-config": "Ni mogoče najti nobene naprave, ki se ujema z nekaterimi filtri vzdevkov.
    Prosimo, obrnite se na skrbnika, da odpravite to težavo.", + "select-devices": "Izberi naprave", + "assignedToCustomer": "Dodeljeno stranki", + "assignedToCustomers": "Dodeljeno strankam", + "public": "Javno", + "public-link": "Javna povezava", + "copy-public-link": "Kopiraj javno povezavo", + "public-link-copied-message": "Javna povezava na nadzorni plošči je bila kopirana v odložišče", + "manage-states": "Upravljanje stanj nadzorne plošče", + "states": "Stanja na nadzorni plošči", + "search-states": "Išči stanja na nadzorni plošči", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard state} } izbrano", + "edit-state": "Uredi stanje nadzorne plošče", + "delete-state": "Izbriši stanje armaturne plošče", + "add-state": "Dodaj stanje nadzorne plošče", + "no-states-text": "Ni nobene države", + "state": "Stanje nadzorne plošče", + "state-name": "Ime", + "state-name-required": "Ime stanja nadzorne plošče je obvezno.", + "state-id": "ID države", + "state-id-required": "ID stanja nadzorne plošče je obvezen.", + "state-id-exists": "Stanje nadzorne plošče z istim ID že obstaja.", + "is-root-state": "Izvorno stanje", + "delete-state-title": "Izbriši stanje nadzorne plošče", + "delete-state-text": "Ali ste prepričani, da želite izbrisati stanje nadzorne plošče z imenom '{{stateName}}'?", + "show-details": "Pokaži podrobnosti", + "hide-details": "Skrij podrobnosti", + "select-state": "Izberi ciljno stanje", + "state-controller": "Državni nadzornik", + "search": "Iskanje po nadzornih ploščah", + "selected-dashboards": "{ count, plural, 1 {1 dashboard} other {# dashboards} } izbrano" + }, + "datakey": { + "settings": "Nastavitve", + "advanced": "Napredno", + "label": "Oznaka", + "color": "Barva", + "units": "Poseben simbol za prikaz poleg vrednosti", + "decimals": "Število števk po plavajoči vejici", + "data-generation-func": "Funkcija ustvarjanja podatkov", + "use-data-post-processing-func": "Uporabi funkcijo naknadne obdelave podatkov", + "configuration": "Konfiguracija podatkovnega ključa", + "timeseries": "Časovne serije", + "attributes": "Lastnosti", + "entity-field": "Polje entitete", + "alarm": "Polja alarma", + "timeseries-required": "Potrebni so časovni nizi entitet.", + "timeseries-or-attributes-required": "Potrebni so časovni nizi / atributi entitet.", + "alarm-fields-timeseries-or-attributes-required": "Polja alarma ali časovni nizi / atributi entitet so obvezni.", + "maximum-timeseries-or-attributes": "Največ { count, plural, 1 {1 timeseries / attribute is allowed.} other {# timeseries / attributes are allowed} }", + "alarm-fields-required": "Polja alarma so obvezna.", + "function-types": "Vrste funkcij", + "function-types-required": "Zahtevane so vrste funkcij.", + "maximum-function-types": "Največ dovoljeno je { count, plural, 1 {1 type of function.} other {# vrste funkcij so dovoljene} }", + "time-description": "časovni žig trenutne vrednosti;", + "value-description": "trenutna vrednost;", + "prev-value-description": "rezultat prejšnjega klica funkcije;", + "time-prev-description": "časovni žig prejšnje vrednosti;", + "prev-orig-value-description": "prvotna prejšnja vrednost;" + }, + "datasource": { + "type": "Vrsta vira podatkov", + "name": "Ime", + "add-datasource-prompt": "Prosimo, dodajte vir podatkov" + }, + "details": { + "details": "Podrobnosti", + "edit-mode": "Način urejanja", + "edit-json": "Uredi JSON", + "toggle-edit-mode": "Preklopi način urejanja" + }, + "device": { + "device": "Naprava", + "device-required": "Naprava je potrebna.", + "devices": "Naprave", + "management": "Upravljanje naprav", + "view-devices": "Ogled naprav", + "device-alias": "Vzdevek naprave", + "aliases": "Vzdevki naprav", + "no-alias-matching": "'{{alias}}' ni mogoče najti.", + "no-aliases-found": "Vzdevkov ni bilo mogoče najti.", + "no-key-matching": "'{{key}}' ni mogoče najti.", + "no-keys-found": "Ključev ni bilo mogoče najti.", + "create-new-alias": "Ustvari novega!", + "create-new-key": "Ustvari novega!", + "duplicate-alias-error": "Najden je podvojen vzdevek '{{alias}}'.
    Vzdevki naprav morajo biti na nadzorni plošči edinstveni.", + "configure-alias": "Konfiguriraj vzdevek '{{alias}}'", + "no-devices-matching": "Najdena ni bila nobena naprava, ki se ujema z '{{entity}}'.", + "alias": "Vzdevek", + "alias-required": "Vzdevek naprave je obvezen.", + "remove-alias": "Odstrani vzdevek naprave", + "add-alias": "Dodaj vzdevek naprave", + "name-starts-with": "Ime naprave se začne z", + "device-list": "Seznam naprav", + "use-device-name-filter": "Uporabi filter", + "device-list-empty": "Izbrana ni nobena naprava.", + "device-name-filter-required": "Potreben je filter imena naprave.", + "device-name-filter-no-device-matched": "Najdena ni bila nobena naprava, ki se začne z '{{device}}'.", + "add": "Dodaj napravo", + "assign-to-customer": "Dodeli stranki", + "assign-device-to-customer": "Dodeli naprave strankam", + "assign-device-to-customer-text": "Izberite naprave, ki jih želite dodeliti stranki", + "make-public": "Naredi napravo javno", + "make-private": "Naj bo naprava zasebna", + "no-devices-text": "Naprave ni bilo mogoče najti", + "assign-to-customer-text": "Prosimo, izberite stranko, ki bo dodelila naprave (-e)", + "device-details": "Podrobnosti o napravi", + "add-device-text": "Dodaj novo napravo", + "credentials": "Poverilnice", + "manage-credentials": "Upravljanje poverilnic", + "delete": "Izbriši napravo", + "assign-devices": "Dodeli naprave", + "assign-devices-text": "Kupcu dodeli { count, plural, 1 {1 device} other {# devices} }", + "delete-devices": "Izbriši naprave", + "unassign-from-customer": "Odjavi od stranke", + "unassign-devices": "Odjavi naprave", + "unassign-devices-action-title": "Odstrani { count, plural, 1 {1 device} other {# devices} } od stranke", + "assign-new-device": "Dodeli novo napravo", + "make-public-device-title": "Ali ste prepričani, da želite napravo '{{deviceName}}' narediti javno?", + "make-public-device-text": "Po potrditvi bo naprava in vsi njeni podatki javno dostopni drugim.", + "make-private-device-title": "Ali ste prepričani, da želite napravo '{{deviceName}}' narediti zasebno?", + "make-private-device-text": "Po potrditvi bo naprava in vsi njeni podatki postali zasebni in nedostopni drugim.", + "view-credentials": "Ogled poverilnic", + "delete-device-title": "Ali ste prepričani, da želite izbrisati napravo '{{deviceName}}'?", + "delete-device-text": "Previdno, po potrditvi naprava in vsi povezani podatki postanejo nepopravljivi.", + "delete-devices-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 device} other {# naprav} }?", + "delete-devices-action-title": "Izbriši { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Bodite previdni, po potrditvi bodo vse izbrane naprave odstranjene in vsi povezani podatki nepopravljivi.", + "unassign-device-title": "Ali ste prepričani, da želite odstraniti napravo '{{deviceName}}'?", + "unassign-device-text": "Po potrditvi naprava ne bo dodeljena in nedostopna stranki.", + "unassign-device": "Preklic dodelitve naprave", + "unassign-devices-title": "Ali ste prepričani, da želite odstraniti { count, plural, 1 {1 device} other {# naprave} }?", + "unassign-devices-text": "Po potrditvi bodo vse izbrane naprave nedodeljene in nedostopne stranki.", + "device-credentials": "Poverilnice naprave", + "credentials-type": "Vrsta poverilnic", + "access-token": "Dostopni žeton", + "access-token-required": "Zahtevan je žeton za dostop.", + "access-token-invalid": "Dolžina žetona za dostop mora biti od 1 do 32 znakov.", + "client-id": "Client ID", + "client-id-pattern": "Contains invalid character.", + "user-name": "User Name", + "user-name-required": "User Name is required.", + "client-id-or-user-name-necessary": "Client ID and/or User Name are necessary", + "password": "Password", + "secret": "Skrivnost", + "secret-required": "Potrebna je skrivnost.", + "device-type": "Vrsta naprave", + "device-type-required": "Zahtevana je vrsta naprave.", + "select-device-type": "Izberite vrsto naprave", + "enter-device-type": "Vnesite vrsto naprave", + "any-device": "Katerakoli naprava", + "no-device-types-matching": "Najdena ni bila nobena vrsta naprave, ki se ujema z '{{entitySubtype}}'.", + "device-type-list-empty": "Izbrana ni nobena vrsta naprave.", + "device-types": "Vrste naprav", + "name": "Ime", + "name-required": "Ime je obvezno.", + "description": "Opis", + "label": "Oznaka", + "events": "Dogodki", + "details": "Podrobnosti", + "copyId": "Kopiraj ID naprave", + "copyAccessToken": "Kopiraj žeton za dostop", + "copy-mqtt-authentication": "Copy MQTT credentials", + "idCopiedMessage": "ID naprave je bil kopiran v odložišče", + "accessTokenCopiedMessage": "Žeton za dostop do naprave je bil kopiran v odložišče", + "mqtt-authentication-copied-message": "Device MQTT authentication has been copied to clipboard", + "assignedToCustomer": "Dodeljeno stranki", + "unable-delete-device-alias-title": "Vzdevka naprave ni mogoče izbrisati", + "unable-delete-device-alias-text": "Vzdevka naprave '{{deviceAlias}}' ni mogoče izbrisati, saj ga uporabljajo naslednji pripomočki:
    {{widgetsList}}", + "is-gateway": "Je prehod", + "public": "Javno", + "device-public": "Naprava je javna", + "select-device": "Izberi napravo", + "import": "Uvozi napravo", + "device-file": "Datoteka naprave", + "search": "Iskalne naprave", + "selected-devices": "{ count, plural, 1 {1 device} other {# devices} } izbrano", + "device-configuration": "Device configuration", + "transport-configuration": "Transport configuration", + "wizard": { + "device-wizard": "Device Wizard", + "device-details": "Device details", + "new-device-profile": "Create new device profile", + "existing-device-profile": "Select existing device profile", + "specific-configuration": "Specific configuration", + "customer-to-assign-device": "Customer to assign the device", + "add-credentials": "Add credentials" + } + }, + "device-profile": { + "device-profile": "Device profile", + "device-profiles": "Device profiles", + "all-device-profiles": "All", + "add": "Add device profile", + "edit": "Edit device profile", + "device-profile-details": "Device profile details", + "no-device-profiles-text": "No device profiles found", + "search": "Search device profiles", + "selected-device-profiles": "{ count, plural, 1 {1 device profile} other {# device profiles} } selected", + "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", + "device-profile-required": "Device profile is required", + "idCopiedMessage": "Device profile Id has been copied to clipboard", + "set-default": "Make device profile default", + "delete": "Delete device profile", + "copyId": "Copy device profile Id", + "new-device-profile-name": "Device profile name", + "new-device-profile-name-required": "Device profile name is required.", + "name": "Name", + "name-required": "Name is required.", + "type": "Profile type", + "type-required": "Profile type is required.", + "type-default": "Default", + "transport-type": "Transport type", + "transport-type-required": "Transport type is required.", + "transport-type-default": "Default", + "transport-type-default-hint": "Supports basic MQTT, HTTP and CoAP transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Enables advanced MQTT transport settings", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M transport type", + "description": "Description", + "default": "Default", + "profile-configuration": "Profile configuration", + "transport-configuration": "Transport configuration", + "default-rule-chain": "Default rule chain", + "select-queue-hint": "Select from a drop-down list.", + "delete-device-profile-title": "Are you sure you want to delete the device profile '{{deviceProfileName}}'?", + "delete-device-profile-text": "Be careful, after the confirmation the device profile and all related data will become unrecoverable.", + "delete-device-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 device profile} other {# device profiles} }?", + "delete-device-profiles-text": "Be careful, after the confirmation all selected device profiles will be removed and all related data will become unrecoverable.", + "set-default-device-profile-title": "Are you sure you want to make the device profile '{{deviceProfileName}}' default?", + "set-default-device-profile-text": "After the confirmation the device profile will be marked as default and will be used for new devices with no profile specified.", + "no-device-profiles-found": "No device profiles found.", + "create-new-device-profile": "Create a new one!", + "mqtt-device-topic-filters": "MQTT device topic filters", + "mqtt-device-topic-filters-unique": "MQTT device topic filters need to be unique.", + "mqtt-device-payload-type": "MQTT device payload", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payload type is required.", + "support-level-wildcards": "Single [+] and multi-level [#] wildcards supported.", + "telemetry-topic-filter": "Telemetry topic filter", + "telemetry-topic-filter-required": "Telemetry topic filter is required.", + "attributes-topic-filter": "Attributes topic filter", + "attributes-topic-filter-required": "Attributes topic filter is required.", + "telemetry-proto-schema": "Telemetry proto schema", + "telemetry-proto-schema-required": "Telemetry proto schema is required.", + "attributes-proto-schema": "Attributes proto schema", + "attributes-proto-schema-required": "Attributes proto schema is required.", + "rpc-response-topic-filter": "RPC response topic filter", + "rpc-response-topic-filter-required": "RPC response topic filter is required.", + "not-valid-pattern-topic-filter": "Not valid pattern topic filter", + "not-valid-single-character": "Invalid use of a single-level wildcard character", + "not-valid-multi-character": "Invalid use of a multi-level wildcard character", + "single-level-wildcards-hint": "[+] is suitable for any topic filter level. Ex.: v1/devices/+/telemetry or +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] can replace the topic filter itself and must be the last symbol of the topic. Ex.: # or v1/devices/me/#.", + "alarm-rules": "Alarm rules", + "alarm-rules-with-count": "Alarm rules ({{count}})", + "no-alarm-rules": "No alarm rules configured", + "add-alarm-rule": "Add alarm rule", + "edit-alarm-rule": "Edit alarm rule", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-unique": "Alarm type must be unique within the device profile alarm rules.", + "create-alarm-pattern": "Create {{alarmType}} alarm", + "create-alarm-rules": "Create alarm rules", + "no-create-alarm-rules": "No create conditions configured", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "select-alarm-severity": "Select alarm severity", + "alarm-severity-required": "Alarm severity is required.", + "condition-duration": "Condition duration", + "condition-duration-value": "Duration value", + "condition-duration-time-unit": "Time unit", + "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", + "condition-duration-value-pattern": "Duration value should be integers.", + "condition-duration-value-required": "Duration value is required.", + "condition-duration-time-unit-required": "Time unit is required.", + "advanced-settings": "Advanced settings", + "alarm-rule-details": "Details", + "add-alarm-rule-details": "Add details", + "propagate-alarm": "Propagate alarm", + "alarm-rule-relation-types-list": "Relation types to propagate", + "alarm-rule-relation-types-list-hint": "If Propagate relation types are not selected, alarms will be propagated without filtering by relation type.", + "alarm-details": "Alarm details", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "device-provisioning": "Device provisioning", + "provision-strategy": "Provision strategy", + "provision-strategy-required": "Provision strategy is required.", + "provision-strategy-disabled": "Disabled", + "provision-strategy-created-new": "Allow to create new devices", + "provision-strategy-check-pre-provisioned": "Check for pre-provisioned devices", + "provision-device-key": "Provision device key", + "provision-device-key-required": "Provision device key is required.", + "copy-provision-key": "Copy provision key", + "provision-key-copied-message": "Provision key has been copied to clipboard", + "provision-device-secret": "Provision device secret", + "provision-device-secret-required": "Provision device secret is required.", + "copy-provision-secret": "Copy provision secret", + "provision-secret-copied-message": "Provision secret has been copied to clipboard", + "condition": "Condition", + "condition-type": "Condition type", + "condition-type-simple": "Simple", + "condition-type-duration": "Duration", + "condition-during": "During {{during}}", + "condition-type-repeating": "Repeating", + "condition-type-required": "Condition type is required.", + "condition-repeating-value": "Count of events", + "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", + "condition-repeating-value-pattern": "Count of events should be integers.", + "condition-repeating-value-required": "Count of events is required.", + "condition-repeat-times": "Repeats { count, plural, 1 {1 time} other {# times} }", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-any-time": "Active all the time", + "schedule-specific-time": "Active at a specific time", + "schedule-custom": "Custom", + "schedule-day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "schedule-days": "Days", + "schedule-time": "Time", + "schedule-time-from": "From", + "schedule-time-to": "To", + "schedule-days-of-week-required": "At least one day of week should be selected." + }, + "dialog": { + "close": "Zapri pogovorno okno" + }, + "direction": { + "column": "Stolpec", + "row": "Vrstica" + }, + "error": { + "unable-to-connect": "Povezave s strežnikom ni mogoče vzpostaviti! Preverite internetno povezavo.", + "unhandled-error-code": "Neobdelana koda napake: {{errorCode}}", + "unknown-error": "Neznana napaka" + }, + "entity": { + "entity": "Entiteta", + "entities": "Subjekti", + "aliases": "Vzdevki entitet", + "entity-alias": "Vzdevek entitete", + "unable-delete-entity-alias-title": "Vzdevka entitete ni mogoče izbrisati", + "unable-delete-entity-alias-text": "Vzdevka entitete '{{entityAlias}}' ni mogoče izbrisati, ker ga uporabljajo naslednji pripomočki:
    {{widgetsList}}", + "duplicate-alias-error": "Najden je podvojen vzdevek '{{alias}}'.
    Vzdevki entitet morajo biti na nadzorni plošči edinstveni.", + "missing-entity-filter-error": "Za vzdevek '{{alias}}' manjka filter.", + "configure-alias": "Konfiguriraj vzdevek '{{alias}}'", + "alias": "Vzdevek", + "alias-required": "Zahtevan je vzdevek entitete.", + "remove-alias": "Odstrani vzdevek entitete", + "add-alias": "Dodaj vzdevek entitete", + "entity-list": "Seznam entitet", + "entity-type": "Vrsta entitete", + "entity-types": "Vrste entitet", + "entity-type-list": "Seznam entitet", + "any-entity": "Katerikoli subjekt", + "enter-entity-type": "Vnesite vrsto entitete", + "no-entities-matching": "Najdena ni bila nobena enota, ki se ujema z '{{entity}}'.", + "no-entity-types-matching": "Najdena ni bila nobena vrsta entitete, ki se ujema z '{{entityType}}'.", + "name-starts-with": "Ime se začne z", + "use-entity-name-filter": "Uporabi filter", + "entity-list-empty": "Nobena entiteta ni izbrana.", + "entity-type-list-empty": "Izbrana ni nobena vrsta entitete.", + "entity-name-filter-required": "Potreben je filter imena entitete.", + "entity-name-filter-no-entity-matched": "Najti ni bilo nobene entitete, ki se začne z '{{entity}}'.", + "all-subtypes": "Vse", + "select-entities": "Izberi entitete", + "no-aliases-found": "Vzdevkov ni bilo mogoče najti.", + "no-alias-matching": "'{{alias}}' ni mogoče najti.", + "create-new-alias": "Ustvari novega!", + "key": "Ključ", + "key-name": "Ime ključa", + "no-keys-found": "Ključev ni bilo mogoče najti.", + "no-key-matching": "'{{key}}' ni mogoče najti.", + "create-new-key": "Ustvari novega!", + "type": "Vrsta", + "type-required": "Zahteva se vrsta entitete.", + "type-device": "Naprava", + "type-devices": "Naprave", + "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", + "device-name-starts-with": "Naprave, katerih imena se začnejo z '{{prefix}}'", + "type-device-profile": "Device profile", + "type-device-profiles": "Device profiles", + "list-of-device-profiles": "{ count, plural, 1 {One device profile} other {List of # device profiles} }", + "device-profile-name-starts-with": "Device profiles whose names start with '{{prefix}}'", + "type-asset": "Sredstvo", + "type-assets": "Sredstva", + "list-of-assets": "{ count, plural, 1 {One asset} other {Seznam # sredstev} }", + "asset-name-starts-with": "Sredstva, katerih imena se začnejo z '{{prefix}}'", + "type-entity-view": "Pogled entitete", + "type-entity-views": "Pogledi entitet", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {Seznam # pogledov entitet} }", + "entity-view-name-starts-with": "Pogledi entitet, katerih imena se začnejo z '{{prefix}}'", + "type-rule": "Pravilo", + "type-rules": "Pravila", + "list-of-rules": "{ count, plural, 1 {One rule} other {Seznam # pravil} }", + "rule-name-starts-with": "Pravila, katerih imena se začnejo z '{{prefix}}'", + "type-plugin": "Vključiti", + "type-plugins": "Vtičniki", + "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", + "plugin-name-starts-with": "Vtičniki, katerih imena se začnejo z '{{prefix}}'", + "type-tenant": "Najemnik", + "type-tenants": "Najemniki", + "list-of-tenants": "{ count, plural, 1 {One najemnik} other {Seznam # najemnikov} }", + "tenant-name-starts-with": "Najemniki, katerih imena se začnejo z '{{prefix}}'", + "type-tenant-profile": "Tenant profile", + "type-tenant-profiles": "Tenant profiles", + "list-of-tenant-profiles": "{ count, plural, 1 {One tenant profile} other {List of # tenant profiles} }", + "tenant-profile-name-starts-with": "Tenant profiles whose names start with '{{prefix}}'", + "type-customer": "Stranka", + "type-customers": "Stranke", + "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", + "customer-name-starts-with": "Stranke, katerih imena se začnejo z '{{prefix}}'", + "type-user": "Uporabnik", + "type-users": "Uporabniki", + "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", + "user-name-starts-with": "Uporabniki, katerih imena se začnejo z '{{prefix}}'", + "type-dashboard": "Nadzorna plošča", + "type-dashboards": "Nadzorne plošče", + "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", + "dashboard-name-starts-with": "Nadzorne plošče, katerih imena se začnejo z '{{prefix}}'", + "type-alarm": "Alarm", + "type-alarms": "Alarmi", + "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", + "alarm-name-starts-with": "Alarmi, katerih imena se začnejo z '{{prefix}}'", + "type-rulechain": "Veriga pravil", + "type-rulechains": "Pravila", + "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", + "rulechain-name-starts-with": "Verige pravil, katerih imena se začnejo z '{{prefix}}'", + "type-rulenode": "Vozlišče pravila", + "type-rulenodes": "Vozlišča pravil", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Vozlišča pravil, katerih imena se začnejo z '{{prefix}}'", + "type-current-customer": "Trenutna stranka", + "type-current-tenant": "Trenutni najemnik", + "type-current-user": "Trenutni uporabnik", + "type-current-user-owner": "Trenutni lastnik uporabnika", + "search": "Iskanje entitet", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entitet} } izbranih", + "entity-name": "Ime entitete", + "entity-label": "Oznaka entitete", + "details": "Podrobnosti o entiteti", + "no-entities-prompt": "Ni entitet", + "no-data": "Ni podatkov za prikaz", + "columns-to-display": "Stolpci za prikaz", + "type-api-usage-state": "Api Usage State" + }, + "entity-field": { + "created-time": "Čas ustvaritve", + "name": "Ime", + "type": "Vrsta", + "first-name": "Ime", + "last-name": "Priimek", + "email": "E-naslov", + "title": "Naziv", + "country": "Država", + "state": "Država", + "city": "Mesto", + "address": "Naslov", + "address2": "Naslov 2", + "zip": "Poštna številka", + "phone": "Telefon", + "label": "Oznaka" + }, + "entity-view": { + "entity-view": "Pogled entitete", + "entity-view-required": "Zahtevan je pogled entitete.", + "entity-views": "Pogledi entitet", + "management": "Upravljanje entitetnega pogleda", + "view-entity-views": "Ogled pogledov entitete", + "entity-view-alias": "Vzdevek entitetnega pogleda", + "aliases": "Vzdevki entitetnega pogleda", + "no-alias-matching": "'{{alias}}' ni mogoče najti.", + "no-aliases-found": "Vzdevkov ni bilo mogoče najti.", + "no-key-matching": "'{{key}}' ni mogoče najti.", + "no-keys-found": "Ključev ni bilo mogoče najti.", + "create-new-alias": "Ustvari novega!", + "create-new-key": "Ustvari novega!", + "duplicate-alias-error": "Najden je podvojen vzdevek '{{alias}}'.
    Vzdevki entitetnega pogleda na nadzorni plošči morajo biti edinstveni.", + "configure-alias": "Konfiguriraj vzdevek '{{alias}}'", + "no-entity-views-matching": "Najden ni bil noben pogled entitete, ki se ujema z '{{entity}}'.", + "public": "Javno", + "alias": "Vzdevek", + "alias-required": "Zahtevan je vzdevek pogleda entitete.", + "remove-alias": "Odstrani vzdevek pogleda entitete", + "add-alias": "Dodaj vzdevek pogleda entitete", + "name-starts-with": "Ime entitete se začne z", + "entity-view-list": "Seznam entitetnega pogleda", + "use-entity-view-name-filter": "Uporabi filter", + "entity-view-list-empty": "Izbran ni noben pogled entitete.", + "entity-view-name-filter-required": "Potreben je filter imena pogleda entitete.", + "entity-view-name-filter-no-entity-view-matched": "Najden ni bil noben pogled entitete, ki se začne z '{{entityView}}'.", + "add": "Dodaj pogled entitete", + "entity-view-public": "Pogled entitete je javen", + "assign-to-customer": "Dodeli stranki", + "assign-entity-view-to-customer": "Dodelitev pogledov entitet strankam", + "assign-entity-view-to-customer-text": "Izberite poglede entitet, ki jih želite dodeliti stranki", + "no-entity-views-text": "Ni najden noben pogled entitete", + "assign-to-customer-text": "Izberite stranko, ki bo dodelila poglede entitet", + "entity-view-details": "Podrobnosti o pogledu entitete", + "add-entity-view-text": "Dodaj nov pogled entitete", + "delete": "Izbriši pogled entitete", + "assign-entity-views": "Dodelitev pogledov entitete", + "assign-entity-views-text": "Stranki dodeli { count, plural, 1 {1 entity view} other {# entity views} }", + "delete-entity-views": "Izbriši poglede entitet", + "unassign-from-customer": "Odstrani od stranke", + "unassign-entity-views": "Odstrani poglede entitete", + "unassign-entity-views-action-title": "Od stranke odstrani { count, plural, 1 {1 entity view} other {# entity views} }", + "assign-new-entity-view": "Dodeli nov pogled entitete", + "delete-entity-view-title": "Ali ste prepričani, da želite izbrisati pogled entitete '{{entityViewName}}'?", + "delete-entity-view-text": "Bodite previdni, po potrditvi bodo pogled entitete in vsi povezani podatki nepopravljivi.", + "delete-entity-views-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 entity view} other {# entity views} }?", + "delete-entity-views-action-title": "Izbriši { count, plural, 1 {1 entity view} other {# entity views} }", + "delete-entity-views-text": "Bodite previdni, po potrditvi bodo odstranjeni vsi pogledi izbranih entitet in vsi povezani podatki bodo postali nepopravljivi.", + "unassign-entity-view-title": "Ali ste prepričani, da želite odstraniti pogled entitete '{{entityViewName}}?", + "unassign-entity-view-text": "Po potrditvi bo pogled entitete odstranjen in nedostopen stranki.", + "unassign-entity-view": "Odstrani pogled entitete", + "unassign-entity-views-title": "Ali ste prepričani, da želite odstraniti { count, plural, 1 {1 entity view} other {# entity views} }?", + "unassign-entity-views-text": "Po potrditvi bodo vsi pogledi izbranih entitet odstranjeni in stranki nedostopni.", + "entity-view-type": "Vrsta pogleda entitete", + "entity-view-type-required": "Zahteva se vrsta pogleda entitete.", + "select-entity-view-type": "Izberi vrsto pogleda entitete", + "enter-entity-view-type": "Vnesite vrsto pogleda entitete", + "any-entity-view": "Katerikoli pogled entitete", + "no-entity-view-types-matching": "Najdena ni bila nobena vrsta pogleda entitete, ki se ujema z '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Izbrana ni nobena vrsta pogleda entitete.", + "entity-view-types": "Vrste pogleda entitete", + "created-time": "Ustvarjeni čas", + "name": "Ime", + "name-required": "Ime je obvezno.", + "description": "Opis", + "events": "Dogodki", + "details": "Podrobnosti", + "copyId": "Kopiraj ID pogleda entitete", + "idCopiedMessage": "ID entitete je kopiran v odložišče", + "assignedToCustomer": "Dodeljeno stranki", + "unable-entity-view-device-alias-title": "Ni mogoče izbrisati vzdevka pogleda entitete", + "unable-entity-view-device-alias-text": "Vzdevka naprave '{{entityViewAlias}}' ni mogoče izbrisati, saj ga uporabljajo naslednji pripomočki:
    {{widgetsList}}", + "select-entity-view": "Izberi pogled entitete", + "make-public": "Naj bo pogled entitete javen", + "make-private": "Naj bo pogled entitete zaseben", + "start-date": "Začetni datum", + "start-ts": "Začetni čas", + "end-date": "Končni datum", + "end-ts": "Končni čas", + "date-limits": "Datumske omejitve", + "client-attributes": "Atributi odjemalca", + "shared-attributes": "Skupni atributi", + "server-attributes": "Atributi strežnika", + "timeseries": "Časovne serije", + "client-attributes-placeholder": "Atributi odjemalca", + "shared-attributes-placeholder": "Skupni atributi", + "server-attributes-placeholder": "Atributi strežnika", + "timeseries-placeholder": "Časovne serije", + "target-entity": "Ciljna entiteta", + "attributes-propagation": "Propragacija atributov", + "attributes-propagation-hint": "Pogled entitete bo samodejno kopiral določene atribute iz ciljne entitete vsakič, ko shranite ali posodobite ta pogled entitete. Zaradi učinkovitosti se atributi ciljne entitete ne propagirajo v pogled entitete pri vsaki spremembi atributa. Samodejno propagacijo lahko omogočite tako, da konfigurirate \" kopiraj v ogled\" vozlišče pravila v verigi pravil in povezovanje sporočil \"Objavi atribute\" in \"Atributi posodobljeni\" na novo vozlišče pravila.", + "timeseries-data": "Podatki časovnih serij", + "timeseries-data-hint": "Konfigurirajte podatkovne ključe časovnih serij ciljne entitete, ki bodo dostopne pogledu entitete. Ti podatki časovnih serij so samo za branje.", + "make-public-entity-view-title": "Ali ste prepričani, da želite pogled entitete '{{entityViewName}}' narediti javen?", + "make-public-entity-view-text": "Po potrditvi bodo pogled entitete in vsi njegovi podatki javni in dostopni drugim.", + "make-private-entity-view-title": "Ali ste prepričani, da želite pogled entitete '{{entityViewName}}' narediti zaseben?", + "make-private-entity-view-text": "Po potrditvi bodo pogled entitete in vsi njegovi podatki postali zasebni in drugim nedostopni.", + "search": "Išči poglede entitet", + "selected-entity-views": "{ count, plural, 1 {1 entity view} other {# entity views} } izbranih" + }, + "event": { + "event-type": "Vrsta dogodka", + "type-error": "Napaka", + "type-lc-event": "Dogodek življenjskega cikla", + "type-stats": "Statistika", + "type-debug-rule-node": "Odpravljanje napak", + "type-debug-rule-chain": "Odpravljanje napak", + "no-events-prompt": "Ni dogodkov", + "error": "Napaka", + "alarm": "Alarm", + "event-time": "Čas dogodka", + "server": "Strežnik", + "body": "Vsebina", + "method": "Metoda", + "type": "Vrsta", + "message-id": "ID sporočila", + "message-type": "Vrsta sporočila", + "data-type": "Vrsta podatkov", + "relation-type": "Vrsta povezave", + "metadata": "Metapodatki", + "data": "Podatki", + "event": "Dogodek", + "status": "Stanje", + "success": "Uspeh", + "failed": "Ni uspelo", + "messages-processed": "Obdelana sporočila", + "errors-occurred": "Prišlo je do napak", + "all-events": "Vse", + "entity-type": "Vrsta entitete" + }, + "extension": { + "extensions": "Razširitve", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } izbrano", + "type": "Vrsta", + "key": "Ključ", + "value": "Vrednost", + "id": "ID", + "extension-id": "ID razširitve", + "extension-type": "Vrsta razširitve", + "transformer-json": "JSON *", + "unique-id-required": "Trenutni ID razširitve že obstaja.", + "delete": "Izbriši razširitev", + "add": "Dodaj razširitev", + "edit": "Uredi razširitev", + "delete-extension-title": "Ali ste prepričani, da želite izbrisati razširitev '{{extensionId}}'?", + "delete-extension-text": "Bodite previdni, po potrditvi podaljšanje in vsi povezani podatki ne bodo več obnovljivi.", + "delete-extensions-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Previdno, po potrditvi bodo odstranjene vse izbrane razširitve.", + "converters": "Pretvorniki", + "converter-id": "ID pretvornika", + "configuration": "Konfiguracija", + "converter-configurations": "Konfiguracije pretvornika", + "token": "Varnostni žeton", + "add-converter": "Dodaj pretvornik", + "add-config": "Dodaj konfiguracijo pretvornika", + "device-name-expression": "Izraz imena naprave", + "device-type-expression": "Izraz vrste naprave", + "custom": "Po meri", + "to-double": "Podvojiti", + "transformer": "Transformator", + "json-required": "Transformator json je potreben.", + "json-parse": "Ni mogoče razčleniti transformatorja json.", + "attributes": "Lastnosti", + "add-attribute": "Dodaj atribut", + "add-map": "Dodaj element preslikave", + "timeseries": "Časovne serije", + "add-timeseries": "Dodaj časovne vrste", + "field-required": "Polje je obvezno", + "brokers": "Posredniki", + "add-broker": "Dodaj posrednika", + "host": "Gostitelj", + "port": "Pristanišče", + "port-range": "Vrata naj bodo v območju od 1 do 65535.", + "ssl": "SSL", + "credentials": "Poverilnice", + "username": "Uporabniško ime", + "password": "Geslo", + "retry-interval": "Interval ponovitve v milisekundah", + "anonymous": "Anonimno", + "basic": "Osnovno", + "pem": "PEM", + "ca-cert": "Datoteka potrdila CA *", + "private-key": "Datoteka z zasebnim ključem *", + "cert": "Datoteka s potrdilom *", + "no-file": "Izbrana ni nobena datoteka.", + "drop-file": "Spustite datoteko ali kliknite, da izberete datoteko za nalaganje.", + "mapping": "Preslikava", + "topic-filter": "Glavni filter", + "converter-type": "Vrsta pretvornika", + "converter-json": "Json", + "json-name-expression": "Izraz json imena naprave", + "topic-name-expression": "Izraz teme imena naprave", + "json-type-expression": "Tip naprave json izraz", + "topic-type-expression": "Izraz teme naprave", + "attribute-key-expression": "Izraz ključnega atributa", + "attr-json-key-expression": "Izraz json ključa atributa", + "attr-topic-key-expression": "Atribut ključne besede izraza", + "request-id-expression": "Zahtevaj izraz za id", + "request-id-json-expression": "Zahtevaj izraz json id", + "request-id-topic-expression": "Zahtevaj izraz teme za id", + "response-topic-expression": "Izraz teme odziva", + "value-expression": "Vrednostni izraz", + "topic": "Tema", + "timeout": "Časovna omejitev v milisekundah", + "converter-json-required": "Potreben je pretvornik json.", + "converter-json-parse": "Ni mogoče razčleniti pretvornika json.", + "filter-expression": "Filtriraj izraz", + "connect-requests": "Zahteve za povezovanje", + "add-connect-request": "Dodaj zahtevo za povezavo", + "disconnect-requests": "Prekini zahteve", + "add-disconnect-request": "Dodaj zahtevo za prekinitev povezave", + "attribute-requests": "Zahteve za atribute", + "add-attribute-request": "Dodaj zahtevo za atribut", + "attribute-updates": "Posodobitve atributov", + "add-attribute-update": "Dodaj posodobitev atributa", + "server-side-rpc": "RPC na strežniški strani", + "add-server-side-rpc-request": "Dodaj zahtevo RPC na strani strežnika", + "device-name-filter": "Filter imena naprave", + "attribute-filter": "Filter atributov", + "method-filter": "Filter metode", + "request-topic-expression": "Zahtevaj izraz teme", + "response-timeout": "Časovna omejitev odziva v milisekundah", + "topic-expression": "Izraz teme", + "client-scope": "Obseg stranke", + "add-device": "Dodaj napravo", + "opc-server": "Strežniki", + "opc-add-server": "Dodaj strežnik", + "opc-add-server-prompt": "Prosimo, dodajte strežnik", + "opc-application-name": "Ime aplikacije", + "opc-application-uri": "Uri aplikacije", + "opc-scan-period-in-seconds": "Obdobje skeniranja v sekundah", + "opc-security": "Varnost", + "opc-identity": "Identiteta", + "opc-keystore": "Trgovina s ključi", + "opc-type": "Vrsta", + "opc-keystore-type": "Vrsta", + "opc-keystore-location": "Lokacija *", + "opc-keystore-password": "Geslo", + "opc-keystore-alias": "Vzdevek", + "opc-keystore-key-password": "Ključno geslo", + "opc-device-node-pattern": "Vzorec vozlišča naprave", + "opc-device-name-pattern": "Vzorec imena naprave", + "modbus-server": "Strežniki / podrejeni", + "modbus-add-server": "Dodaj strežnik / podrejen", + "modbus-add-server-prompt": "Prosimo, dodajte strežnik / podrejen", + "modbus-transport": "Prenos", + "modbus-tcp-reconnect": "Samodejno ponovno vzpostavi povezavo", + "modbus-rtu-over-tcp": "RTU prek TCP", + "modbus-port-name": "Ime serijskih vrat", + "modbus-encoding": "Kodiranje", + "modbus-parity": "Parnost", + "modbus-baudrate": "Hitrost prenosa", + "modbus-databits": "Podatkovni bit", + "modbus-stopbits": "Stop bits", + "modbus-databits-range": "Podatkovni bit mora biti v območju od 7 do 8.", + "modbus-stopbits-range": "Stop bitov mora biti v območju od 1 do 2.", + "modbus-unit-id": "ID enote", + "modbus-unit-id-range": "ID enote mora biti v območju od 1 do 247.", + "modbus-device-name": "Ime naprave", + "modbus-poll-period": "Obdobje ankete (ms)", + "modbus-attributes-poll-period": "Obdobje ankete atributov (ms)", + "modbus-timeseries-poll-period": "Obdobje ankete časovnih serij (ms)", + "modbus-poll-period-range": "Obdobje ankete mora imeti pozitivno vrednost.", + "modbus-tag": "Oznaka", + "modbus-function": "Funkcija", + "modbus-register-address": "Registrski naslov", + "modbus-register-address-range": "Naslov registra mora biti v območju od 0 do 65535.", + "modbus-register-bit-index": "Bitni indeks", + "modbus-register-bit-index-range": "Bitni indeks naj bo v območju od 0 do 15.", + "modbus-register-count": "Število registra", + "modbus-register-count-range": "Število registra mora biti pozitivna vrednost.", + "modbus-byte-order": "Vrstni red bajtov", + "sync": { + "status": "Stanje", + "sync": "Sinhronizacija", + "not-sync": "Ni sinhronizacija", + "last-sync-time": "Čas zadnje sinhronizacije", + "not-available": "Ni na voljo" + }, + "export-extensions-configuration": "Izvozi konfiguracijo razširitev", + "import-extensions-configuration": "Uvozi konfiguracijo razširitev", + "import-extensions": "Uvozi razširitve", + "import-extension": "Uvozi razširitev", + "export-extension": "Razširitev izvoza", + "file": "Razširitvena datoteka", + "invalid-file-error": "Neveljavna datoteka razširitve" + }, + "filter": { + "add": "Dodaj filter", + "edit": "Uredi filter", + "name": "Ime filtra", + "name-required": "Ime filtra je obvezno.", + "duplicate-filter": "Filter z istim imenom že obstaja.", + "filters": "Filtri", + "unable-delete-filter-title": "Filtra ni mogoče izbrisati", + "unable-delete-filter-text": "Filtra '{{filter}}' ni mogoče izbrisati, kot ga uporabljajo naslednji pripomočki:
    {{widgetsList}}", + "duplicate-filter-error": "Najden je podvojen filter '{{filter}}'.
    Filtri morajo biti na nadzorni plošči edinstveni.", + "missing-key-filters-error": "Za filter '{{filter}}' manjkajo ključni filtri.", + "filter": "Filter", + "editable": "Urejanje", + "no-filters-found": "Ni najden noben filter.", + "no-filter-text": "No filter specified", + "add-filter-prompt": "Please add filter", + "no-filter-matching": "'{{filter}}' ni mogoče najti.", + "create-new-filter": "Ustvari novega!", + "filter-required": "Potreben je filter.", + "operation": { + "operation": "Dejanje", + "equal": "enako", + "not-equal": "ni enako", + "starts-with": "začne se z", + "ends-with": "konča se z", + "contains": "vsebuje", + "not-contains": "ne vsebuje", + "greater": "večji kot", + "less": "manj kot", + "greater-or-equal": "večje ali enako", + "less-or-equal": "manjše ali enako", + "and": "in", + "or": "ali" + }, + "ignore-case": "Prezri črko", + "value": "Vrednost", + "remove-filter": "Odstrani filter", + "preview": "Predogled filtra", + "no-filters": "Ni nastavljenih filtrov", + "add-filter": "Dodaj filter", + "add-complex-filter": "Dodaj kompleksni filter", + "add-complex": "Dodaj kompleks", + "complex-filter": "Kompleksni filter", + "edit-complex-filter": "Uredi kompleksni filter", + "edit-filter-user-params": "Uredi uporabniške parametre predikata filtra", + "filter-user-params": "Filter predicate user parameters", + "user-parameters": "Uporabniški parametri", + "display-label": "Oznaka za prikaz", + "autogenerated-label": "Samodejno ustvari oznako", + "order-priority": "Prednostni vrstni red", + "key-filter": "Ključni filter", + "key-filters": "Ključni filtri", + "key-name": "Ime ključa", + "key-name-required": "Ime ključa je obvezno.", + "key-type": { + "key-type": "Tip ključa", + "attribute": "Atribut", + "timeseries": "Časovne serije", + "entity-field": "Polje entitete" + }, + "value-type": { + "value-type": "Vrsta vrednosti", + "string": "Niz", + "numeric": "Številska", + "boolean": "Logična", + "date-time": "Datum in čas" + }, + "value-type-required": "Zahtevana je vrsta vrednosti ključa.", + "key-value-type-change-title": "Ali ste prepričani, da želite spremeniti vrsto vrednosti ključa?", + "key-value-type-change-message": "Če potrdite novo vrsto vrednosti, bodo odstranjeni vsi vneseni filtri ključev.", + "no-key-filters": "Noben ključni filter ni konfiguriran", + "add-key-filter": "Dodaj ključni filter", + "remove-key-filter": "Odstrani filter ključev", + "edit-key-filter": "Uredi filter ključev", + "date": "Datum", + "time": "Čas", + "current-tenant": "Trenutni najemnik", + "current-customer": "Trenutna stranka", + "current-user": "Trenutni uporabnik", + "current-device": "Current device", + "default-value": "Privzeta vrednost", + "dynamic-source-type": "Dinamična vrsta vira", + "no-dynamic-value": "Ni dinamične vrednosti", + "source-attribute": "Izvorni atribut", + "switch-to-dynamic-value": "Preklopi na dinamično vrednost", + "switch-to-default-value": "Preklopi na privzeto vrednost" + }, + "fullscreen": { + "expand": "Razširi na celozaslonski način", + "exit": "Izhod iz celozaslonskega načina", + "toggle": "Preklopi na celozaslonski način", + "fullscreen": "Celozaslonski način" + }, + "function": { + "function": "Funkcija" + }, + "gateway": { + "add-entry": "Dodaj konfiguracijo", + "connector-add": "Dodaj nov priključek", + "connector-enabled": "Omogoči priključek", + "connector-name": "Ime priključka", + "connector-name-required": "Ime priključka je obvezno.", + "connector-type": "Vrsta priključka", + "connector-type-required": "Zahteva se vrsta priključka.", + "connectors": "Konfiguracija priključkov", + "create-new-gateway": "Ustvari nov prehod", + "create-new-gateway-text": "Ali ste prepričani, da želite ustvariti nov prehod z imenom: '{{gatewayName}}'?", + "delete": "Izbriši konfiguracijo", + "download-tip": "Prenos konfiguracijske datoteke", + "gateway": "Prehod", + "gateway-exists": "Naprava z istim imenom že obstaja.", + "gateway-name": "Ime prehoda", + "gateway-name-required": "Ime prehoda je obvezno.", + "gateway-saved": "Konfiguracija prehoda je uspešno shranjena.", + "json-parse": "Neveljaven JSON.", + "json-required": "Polje ne sme biti prazno.", + "no-connectors": "Ni priključkov", + "no-data": "Brez konfiguracij", + "no-gateway-found": "Prehod ni najden.", + "no-gateway-matching": " '{{item}}' ni mogoče najti.", + "path-logs": "Pot do dnevniških datotek", + "path-logs-required": "Pot je obvezna.", + "remote": "Oddaljena konfiguracija", + "remote-logging-level": "Raven beleženja", + "remove-entry": "Odstrani konfiguracijo", + "save-tip": "Shrani konfiguracijsko datoteko", + "security-type": "Vrsta zaščite", + "security-types": { + "access-token": "Dostopni žeton", + "tls": "TLS" + }, + "storage": "Shramba", + "storage-max-file-records": "Največ zapisov v datoteki", + "storage-max-files": "Največje število datotek", + "storage-max-files-min": "Najmanjše število je 1.", + "storage-max-files-pattern": "Številka ni veljavna.", + "storage-max-files-required": "Številka je obvezna.", + "storage-max-records": "Največ zapisov v pomnilniku", + "storage-max-records-min": "Najmanjše število zapisov je 1.", + "storage-max-records-pattern": "Številka ni veljavna.", + "storage-max-records-required": "Zahtevan je največ zapisov.", + "storage-pack-size": "Največja velikost paketa dogodkov", + "storage-pack-size-min": "Najmanjše število je 1.", + "storage-pack-size-pattern": "Številka ni veljavna.", + "storage-pack-size-required": "Zahtevana je največja velikost paketa dogodkov.", + "storage-path": "Pot pomnilnika", + "storage-path-required": "Zahtevana je pot do pomnilnika.", + "storage-type": "Vrsta pomnilnika", + "storage-types": { + "file-storage": "Shramba datotek", + "memory-storage": "Spomin pomnilnika" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "Gostitelj ThingsBoard", + "thingsboard-host-required": "Potreben je gostitelj.", + "thingsboard-port": "Vrata ThingsBoard", + "thingsboard-port-max": "Največja številka vrat je 65535.", + "thingsboard-port-min": "Najmanjša številka vrat je 1.", + "thingsboard-port-pattern": "Vrata niso veljavna.", + "thingsboard-port-required": "Potrebna so vrata.", + "tidy": "Urejeno", + "tidy-tip": "Urejena konfiguracija JSON", + "title-connectors-json": "Konfiguracija konektorja {{typeName}}", + "tls-path-ca-certificate": "Pot do potrdila CA na prehodu", + "tls-path-client-certificate": "Pot do potrdila stranke na prehodu", + "tls-path-private-key": "Pot do zasebnega ključa na prehodu", + "toggle-fullscreen": "Preklop na celozaslonski način", + "transformer-json-config": "Konfiguracija JSON *", + "update-config": "Dodaj / posodobi konfiguracijo JSON" + }, + "grid": { + "delete-item-title": "Ali ste prepričani, da želite izbrisati ta element?", + "delete-item-text": "Bodite previdni, po potrditvi bodo ta element in vsi povezani podatki nepopravljivi.", + "delete-items-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 item} other {# items} }?", + "delete-items-action-title": "Izbriši { count, plural, 1 {1 item} other {# items} }", + "delete-items-text": "Bodite previdni, po potrditvi bodo vsi izbrani elementi odstranjeni in vsi povezani podatki nepopravljivi.", + "add-item-text": "Dodaj novo postavko", + "no-items-text": "Ni najdenih postavk", + "item-details": "Podrobnosti o postavki", + "delete-item": "Izbriši postavko", + "delete-items": "Izbriši postavke", + "scroll-to-top": "Pomakni se na vrh" + }, + "help": { + "goto-help-page": "Pojdi na stran s pomočjo" + }, + "home": { + "home": "Domov", + "profile": "Profil", + "logout": "Odjava", + "menu": "Meni", + "avatar": "Avatar", + "open-user-menu": "Odpri uporabniški meni" + }, + "import": { + "no-file": "Izbrana ni nobena datoteka", + "drop-file": "Spustite datoteko JSON ali kliknite, da izberete datoteko za nalaganje.", + "drop-file-csv": "Spustite datoteko CSV ali kliknite, da izberete datoteko za nalaganje.", + "column-value": "Vrednost", + "column-title": "Naslov", + "column-example": "Primer vrednosti podatkov", + "column-key": "Atribut / ključ telemetrije", + "csv-delimiter": "Ločilo CSV", + "csv-first-line-header": "Prva vrstica vsebuje imena stolpcev", + "csv-update-data": "Posodobi atribute / telemetrijo", + "import-csv-number-columns-error": "Datoteka mora vsebovati vsaj dva stolpca", + "import-csv-invalid-format-error": "Neveljavna oblika datoteke. Vrstica: '{{line}}'", + "column-type": { + "name": "Ime", + "type": "Vrsta", + "label": "Oznaka", + "column-type": "Vrsta stolpca", + "client-attribute": "Atribut odjemalca", + "shared-attribute": "Skupni atribut", + "server-attribute": "Atribut strežnika", + "timeseries": "Časovne serije", + "entity-field": "Polje entitete", + "access-token": "Dostopni žeton", + "isgateway": "Je prehod", + "description": "Opis" + }, + "stepper-text": { + "select-file": "Izberi datoteko", + "configuration": "Uvozi konfiguracijo", + "column-type": "Izberi vrsto stolpca", + "creat-entities": "Ustvarjanje novih entitet" + }, + "message": { + "create-entities": "{{count}} novih entitet je bilo uspešno ustvarjenih.", + "update-entities": "{{count}} entitet je bilo uspešno posodobljenih.", + "error-entities": "Prišlo je do napake pri ustvarjanju {{count}} entitet." + } + }, + "item": { + "selected": "Izbrano" + }, + "js-func": { + "no-return-error": "Funkcija mora vrniti vrednost!", + "return-type-mismatch": "Funkcija mora vrniti vrednost vrste '{{type}}'!", + "tidy": "Urejeno", + "mini": "Mini" + }, + "key-val": { + "key": "Ključ", + "value": "Vrednost", + "remove-entry": "Odstrani vnos", + "add-entry": "Dodaj vnos", + "no-data": "Ni vnosov" + }, + "layout": { + "layout": "Postavitev", + "manage": "Upravljanje postavitev", + "settings": "Nastavitve postavitve", + "color": "Barva", + "main": "Glavni", + "right": "Pravi", + "select": "Izberi ciljno postavitev" + }, + "legend": { + "direction": "Smer legende", + "position": "Položaj legende", + "sort-legend": "Sort datakeys in legend", + "show-max": "Prikaži največjo vrednost", + "show-min": "Pokaži najmanjšo vrednost", + "show-avg": "Prikaži povprečno vrednost", + "show-total": "Prikaži skupno vrednost", + "settings": "Nastavitve legende", + "min": "najmanj", + "max": "največ", + "avg": "povprečno", + "total": "skupaj", + "comparison-time-ago": { + "days": "(pretekli dan)", + "weeks": "(pretekli teden)", + "months": "(pretekli mesec)", + "years": "(preteklo leto)" + } + }, + "login": { + "login": "Vpiši se", + "request-password-reset": "Zahtevaj ponastavitev gesla", + "reset-password": "Ponastavitev gesla", + "create-password": "Ustvari geslo", + "passwords-mismatch-error": "Vnesena gesla morajo biti enaka!", + "password-again": "Ponovi geslo", + "sign-in": "Prosimo, prijavite se", + "username": "Uporabniško ime (e-pošta)", + "remember-me": "Zapomni si me", + "forgot-password": "Ste pozabili geslo?", + "password-reset": "Resetiranje gesla", + "expired-password-reset-message": "Vaše poverilnice so potekle! Ustvarite novo geslo.", + "new-password": "Novo geslo", + "new-password-again": "Ponovi novo geslo", + "password-link-sent-message": "Povezava za ponastavitev gesla je bila uspešno poslana!", + "email": "E-naslov", + "login-with": "Prijava z {{name}}", + "or": "ali", + "error": "Napaka pri prijavi" + }, + "position": { + "top": "Zgoraj", + "bottom": "Spodaj", + "left": "Levo", + "right": "Desno" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Zadnja prijava", + "change-password": "Spremeni geslo", + "current-password": "Trenutno geslo" + }, + "relation": { + "relations": "Odnosi", + "direction": "Smer", + "search-direction": { + "FROM": "Od", + "TO": "Za" + }, + "direction-type": { + "FROM": "od", + "TO": "za" + }, + "from-relations": "Odhodna razmerja", + "to-relations": "Vhodna razmerja", + "selected-relations": "{ count, plural, 1 {1 relationship} other {# Relations} } izbran", + "type": "Vrsta", + "to-entity-type": "Za vrsto entitete", + "to-entity-name": "Za ime entitete", + "from-entity-type": "Od vrste entitete", + "from-entity-name": "Od imena entitete", + "to-entity": "Za entiteto", + "from-entity": "Od entitete", + "delete": "Izbriši relacijo", + "relation-type": "Vrsta relacije", + "relation-type-required": "Vrsta relacije je obvezna.", + "any-relation-type": "Katerakoli vrsta", + "add": "Dodaj relacijo", + "edit": "Uredi relacijo", + "delete-to-relation-title": "Ali ste prepričani, da želite izbrisati relacijo z entiteto '{{entityName}}'?", + "delete-to-relation-text": "Previdno, po potrditvi entiteta '{{entityName}}' ne bo v relaciji s trenutno entiteto.", + "delete-to-relations-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 relacijo} other {# relacije} }?", + "delete-to-relations-text": "Bodite previdni, po potrditvi bodo vse izbrane relacije odstranjene in entitete ne bodo povezane med sabo.", + "delete-from-relation-title": "Ali ste prepričani, da želite izbrisati relacijo iz entitete '{{entityName}}'?", + "delete-from-relation-text": "Bodite previdni, po potrditvi trenutna entiteta ne bo v relacijii z entiteto '{{entityName}}'.", + "delete-from-relations-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 relacija} other {# odnosi} }?", + "delete-from-relations-text": "Bodite previdni, po potrditvi bodo odstranjene vse izbrane relacije in trenutna entiteta ne bo v relaciji z omenjenimi entitetami.", + "remove-relation-filter": "Odstrani relacijski filter", + "add-relation-filter": "Dodaj relacijski filter", + "any-relation": "Kakršnakoli relacija", + "relation-filters": "Relacijski filtri", + "additional-info": "Dodatne informacije (JSON)", + "invalid-additional-info": "Dodatnih informacij json ni mogoče razčleniti.", + "no-relations-text": "Ni najdenih relacij" + }, + "rulechain": { + "rulechain": "Veriga pravil", + "rulechains": "Pravila", + "root": "Izvor", + "delete": "Izbriši verigo pravil", + "name": "Ime", + "name-required": "Ime je obvezno.", + "description": "Opis", + "add": "Dodaj verigo pravil", + "set-root": "Ustvari izvor verige pravil", + "set-root-rulechain-title": "Ali ste prepričani, da želite narediti izvorno verigo pravil '{{ruleChainName}}'?", + "set-root-rulechain-text": "Po potrditvi bo veriga pravil postala izvorna in bo obravnavala vsa dohodna prometna sporočila.", + "delete-rulechain-title": "Ali ste prepričani, da želite izbrisati verigo pravil '{{ruleChainName}}'?", + "delete-rulechain-text": "Bodite previdni, po potrditvi bodo veriga pravil in vsi povezani podatki nepopravljivi.", + "delete-rulechains-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "Izbriši { count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Bodite previdni, po potrditvi bodo odstranjene vse izbrane verige pravil in vsi povezani podatki bodo postali nepopravljivi.", + "add-rulechain-text": "Dodaj novo verigo pravil", + "no-rulechains-text": "Nobena veriga pravil ni najdena", + "rulechain-details": "Podrobnosti o verigi pravil", + "details": "Podrobnosti", + "events": "Dogodki", + "system": "Sistem", + "import": "Uvozi verigo pravil", + "export": "Izvozi verigo pravil", + "export-failed-error": "Verige pravil ni mogoče izvoziti: {{error}}", + "create-new-rulechain": "Ustvari novo verigo pravil", + "rulechain-file": "Datoteka verige pravil", + "invalid-rulechain-file-error": "Ni mogoče uvoziti verige pravil: neveljavna struktura podatkov verige pravil.", + "copyId": "Kopiraj ID verige pravil", + "idCopiedMessage": "ID verige pravil je kopiran v odložišče", + "select-rulechain": "Izberi verigo pravil", + "no-rulechains-matching": "Najdena ni bila nobena veriga pravil, ki se ujema z '{{entity}}'.", + "rulechain-required": "Zahtevana je veriga pravil", + "management": "Upravljanje pravil", + "debug-mode": "Način za odpravljanje napak", + "search": "Iskanje verig pravil", + "selected-rulechains": "{ count, plural, 1 {1 rule chain} other {# rule chains} } izbrano", + "open-rulechain": "Odprta veriga pravil" + }, + "rulenode": { + "details": "Podrobnosti", + "events": "Dogodki", + "search": "Iskanje vozlišč", + "open-node-library": "Odpri knjižnico vozlišč", + "add": "Dodaj vozlišče pravila", + "name": "Ime", + "name-required": "Ime je obvezno.", + "type": "Vrsta", + "description": "Opis", + "delete": "Izbriši vozlišče pravila", + "select-all-objects": "Izberi vsa vozlišča in povezave", + "deselect-all-objects": "Prekliči izbiro vseh vozlišč in povezav", + "delete-selected-objects": "Izbriši izbrana vozlišča in povezave", + "delete-selected": "Izbriši izbrano", + "select-all": "Izberi vse", + "copy-selected": "Kopiraj izbrano", + "deselect-all": "Prekliči izbor", + "rulenode-details": "Podrobnosti vozlišča pravila", + "debug-mode": "Način za odpravljanje napak", + "configuration": "Konfiguracija", + "link": "Povezava", + "link-details": "Podrobnosti povezave vozlišča pravila", + "add-link": "Dodaj povezavo", + "link-label": "Oznaka povezave", + "link-label-required": "Oznaka povezave je obvezna.", + "custom-link-label": "Oznaka povezave po meri", + "custom-link-label-required": "Zahtevana je oznaka povezave po meri.", + "link-labels": "Oznake povezav", + "link-labels-required": "Oznake povezav so obvezne.", + "no-link-labels-found": "Oznak povezav ni bilo mogoče najti", + "no-link-label-matching": "'{{label}}' ni mogoče najti.", + "create-new-link-label": "Ustvari novega!", + "type-filter": "Filter", + "type-filter-details": "Filtriraj dohodna sporočila s konfiguriranimi pogoji", + "type-enrichment": "Obogatitev", + "type-enrichment-details": "Dodaj dodatne informacije v metapodatke sporočila", + "type-transformation": "Preobrazba", + "type-transformation-details": "Spremeni koristni tovor sporočila in metapodatke", + "type-action": "Dejanje", + "type-action-details": "Izvedite posebno dejanje", + "type-external": "Zunanji", + "type-external-details": "Interakcija z zunanjim sistemom", + "type-rule-chain": "Veriga pravil", + "type-rule-chain-details": "Posredovanje dohodnih sporočil določeni verigi pravil", + "type-input": "Vnos", + "type-input-details": "Logični vnos verige pravil, posreduje dohodna sporočila naslednjemu povezanemu vozlišču pravila", + "type-unknown": "Neznano", + "type-unknown-details": "Nerazrešeno vozlišče pravila", + "directive-is-not-loaded": "Določena konfiguracijska direktiva '{{DirectiveName}}' ni na voljo.", + "ui-resources-load-error": "Napajanje virov uporabniškega vmesnika ni uspelo.", + "invalid-target-rulechain": "Ni mogoče razrešiti ciljne verige pravil!", + "test-script-function": "Preizkusi funkcijo skripte", + "message": "Sporočilo", + "message-type": "Vrsta sporočila", + "select-message-type": "Izberi vrsto sporočila", + "message-type-required": "Zahtevana je vrsta sporočila", + "metadata": "Metapodatki", + "metadata-required": "Vnosi metapodatkov ne smejo biti prazni.", + "output": "Izdelek", + "test": "Test", + "help": "Pomoč", + "reset-debug-mode": "Ponastavi način za odpravljanje napak v vseh vozliščih" + }, + "timezone": { + "timezone": "Časovni pas", + "select-timezone": "Izberite časovni pas", + "no-timezones-matching": "Časovnih pasov, ki se ujemajo z '{{timezone}}', ni bilo mogoče najti.", + "timezone-required": "Časovni pas je obvezen." + }, + "queue": { + "select_name": "Izberi ime čakalne vrste", + "name": "Ime čakalne vrste", + "name_required": "Ime čakalne vrste je obvezno!" + }, + "tenant": { + "tenant": "Najemnik", + "tenants": "Najemniki", + "management": "Upravljanje najemnikov", + "add": "Dodaj najemnika", + "admins": "Skrbniki", + "manage-tenant-admins": "Upravljanje skrbnikov najemnikov", + "delete": "Izbriši najemnika", + "add-tenant-text": "Dodaj novega najemnika", + "no-tenants-text": "Najemnikov ni bilo mogoče najti", + "tenant-details": "Podrobnosti o najemniku", + "delete-tenant-title": "Ali ste prepričani, da želite izbrisati najemnika '{{tenantTitle}}'?", + "delete-tenant-text": "Previdno, po potrditvi bodo najemnik in vsi povezani podatki postali nepopravljivi.", + "delete-tenants-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 tenant} other {# tenants} }?", + "delete-tenants-action-title": "Izbriši { count, plural, 1 {1 tenant} other {# tenants} }", + "delete-tenants-text": "Bodite previdni, po potrditvi bodo vsi izbrani najemniki odstranjeni in vsi povezani podatki nepopravljivi.", + "title": "Naslov", + "title-required": "Naslov je obvezen.", + "description": "Opis", + "details": "Podrobnosti", + "events": "Dogodki", + "copyId": "Kopiraj ID najemnika", + "idCopiedMessage": "ID najemnika je kopiran v odložišče", + "select-tenant": "Izberi najemnika", + "no-tenants-matching": "Najden ni bil noben najemnik, ki se ujema z '{{entity}}'.", + "tenant-required": "Najemnik je obvezen", + "search": "Iskanje najemnikov", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenants} } izbran", + "isolated-tb-rule-engine": "Obdelava v izoliranem odlagališču ThingsBoard Rule Engine", + "isolated-tb-rule-engine-details": "Zahteva ločene mikro storitve na izoliranega najemnika" + }, + "tenant-profile": { + "tenant-profile": "Tenant profile", + "tenant-profiles": "Tenant profiles", + "add": "Add tenant profile", + "edit": "Edit tenant profile", + "tenant-profile-details": "Tenant profile details", + "no-tenant-profiles-text": "No tenant profiles found", + "search": "Search tenant profiles", + "selected-tenant-profiles": "{ count, plural, 1 {1 tenant profile} other {# tenant profiles} } selected", + "no-tenant-profiles-matching": "No tenant profile matching '{{entity}}' were found.", + "tenant-profile-required": "Tenant profile is required", + "idCopiedMessage": "Tenant profile Id has been copied to clipboard", + "set-default": "Make tenant profile default", + "delete": "Delete tenant profile", + "copyId": "Copy tenant profile Id", + "name": "Name", + "name-required": "Name is required.", + "data": "Profile data", + "profile-configuration": "Profile configuration", + "description": "Description", + "default": "Default", + "delete-tenant-profile-title": "Are you sure you want to delete the tenant profile '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Be careful, after the confirmation the tenant profile and all related data will become unrecoverable.", + "delete-tenant-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 tenant profile} other {# tenant profiles} }?", + "delete-tenant-profiles-text": "Be careful, after the confirmation all selected tenant profiles will be removed and all related data will become unrecoverable.", + "set-default-tenant-profile-title": "Are you sure you want to make the tenant profile '{{tenantProfileName}}' default?", + "set-default-tenant-profile-text": "After the confirmation the tenant profile will be marked as default and will be used for new tenants with no profile specified.", + "no-tenant-profiles-found": "No tenant profiles found.", + "create-new-tenant-profile": "Create a new one!", + "maximum-devices": "Maximum number of devices (0 - unlimited)", + "maximum-devices-required": "Maximum number of devices is required.", + "maximum-devices-range": "Minimum number of devices can't be negative", + "maximum-assets": "Maximum number of assets (0 - unlimited)", + "maximum-assets-required": "Maximum number of assets is required.", + "maximum-assets-range": "Maximum number of assets can't be negative", + "maximum-customers": "Maximum number of customers (0 - unlimited)", + "maximum-customers-required": "Maximum number of customers is required.", + "maximum-customers-range": "Maximum number of customers can't be negative", + "maximum-users": "Maximum number of users (0 - unlimited)", + "maximum-users-required": "Maximum number of users is required.", + "maximum-users-range": "Maximum number of users can't be negative", + "maximum-dashboards": "Maximum number of dashboards (0 - unlimited)", + "maximum-dashboards-required": "Maximum number of dashboards is required.", + "maximum-dashboards-range": "Maximum number of dashboards can't be negative", + "maximum-rule-chains": "Maximum number of rule chains (0 - unlimited)", + "maximum-rule-chains-required": "Maximum number of rule chains is required.", + "maximum-rule-chains-range": "Maximum number of rule chains can't be negative", + "transport-tenant-msg-rate-limit": "Transport tenant messages rate limit.", + "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetry messages rate limit.", + "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetry data points rate limit.", + "transport-device-msg-rate-limit": "Transport device messages rate limit.", + "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages rate limit.", + "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points rate limit.", + "max-transport-messages": "Maximum number of transport messages (0 - unlimited)", + "max-transport-messages-required": "Maximum number of transport messages is required.", + "max-transport-messages-range": "Maximum number of transport messages can't be negative", + "max-transport-data-points": "Maximum number of transport data points (0 - unlimited)", + "max-transport-data-points-required": "Maximum number of transport data points is required.", + "max-transport-data-points-range": "Maximum number of transport data points can't be negative", + "max-r-e-executions": "Maximum number of Rule Engine executions (0 - unlimited)", + "max-r-e-executions-required": "Maximum number of Rule Engine executions is required.", + "max-r-e-executions-range": "Maximum number of Rule Engine executions can't be negative", + "max-j-s-executions": "Maximum number of JavaScript executions (0 - unlimited)", + "max-j-s-executions-required": "Maximum number of JavaScript executions is required.", + "max-j-s-executions-range": "Maximum number of JavaScript executions can't be negative", + "max-d-p-storage-days": "Maximum number of data points storage days (0 - unlimited)", + "max-d-p-storage-days-required": "Maximum number of data points storage days is required.", + "max-d-p-storage-days-range": "Maximum number of data points storage days can't be negative", + "default-storage-ttl-days": "Default storage TTL days (0 - unlimited)", + "default-storage-ttl-days-required": "Default storage TTL days is required.", + "default-storage-ttl-days-range": "Default storage TTL days can't be negative", + "max-rule-node-executions-per-message": "Maximum number of rule node executions per message (0 - unlimited)", + "max-rule-node-executions-per-message-required": "Maximum number of rule node executions per message is required.", + "max-rule-node-executions-per-message-range": "Maximum number of rule node executions per message can't be negative", + "max-emails": "Maximum number of emails sent (0 - unlimited)", + "max-emails-required": "Maximum number of emails sent is required.", + "max-emails-range": "Maximum number of emails sent can't be negative", + "max-sms": "Maximum number of SMS sent (0 - unlimited)", + "max-sms-required": "Maximum number of SMS sent is required.", + "max-sms-range": "Maximum number of SMS sent can't be negative" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", + "days": "Dnevi", + "hours": "Ure", + "minutes": "Minute", + "seconds": "Sekunde", + "advanced": "Napredno" + }, + "timeunit": { + "seconds": "Seconds", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days" + }, + "timewindow": { + "days": "{ days, plural, 1 {dan} other {# dni} }", + "hours": "{ hours, plural, 0 {hour} 1 {1 hour} other {# hours} }", + "minutes": "{ minute, plural, 0 {minute} 1 {1 minuta} other {# minut} }", + "seconds": "{ seconds, plural, 0 {second} 1 {1 second} other {# seconds} }", + "realtime": "V realnem času", + "history": "Zgodovina", + "last-prefix": "zadnji", + "period": "od {{ startTime }} do {{ endTime }}", + "edit": "Uredi časovno okno", + "date-range": "Časovno obdobje", + "last": "Zadnji", + "time-period": "Časovno obdobje", + "hide": "Skrij" + }, + "user": { + "user": "Uporabnik", + "users": "Uporabniki", + "customer-users": "Uporabniki kupcev", + "tenant-admins": "Skrbniki najemnikov", + "sys-admin": "Sistemski administrator", + "tenant-admin": "Skrbnik najemnika", + "customer": "Stranka", + "anonymous": "Anonimno", + "add": "Dodaj uporabnika", + "delete": "Izbriši uporabnika", + "add-user-text": "Dodaj novega uporabnika", + "no-users-text": "Uporabnikov ni bilo mogoče najti", + "user-details": "Podrobnosti o uporabniku", + "delete-user-title": "Ali ste prepričani, da želite izbrisati uporabnika '{{userEmail}}'?", + "delete-user-text": "Previdno, po potrditvi bodo uporabnik in vsi povezani podatki nepopravljivi.", + "delete-users-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "Izbriši { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Previdno, po potrditvi bodo vsi izbrani uporabniki odstranjeni in vsi povezani podatki nepopravljivi.", + "activation-email-sent-message": "Aktivacijsko e-poštno sporočilo je bilo uspešno poslano!", + "resend-activation": "Znova pošlji aktivacijo", + "email": "E-pošta", + "email-required": "E-pošta je obvezna.", + "invalid-email-format": "Neveljavna oblika e-pošte.", + "first-name": "Ime", + "last-name": "Priimek", + "description": "Opis", + "default-dashboard": "Privzeta nadzorna plošča", + "always-fullscreen": "Vedno v celozaslonskem načinu", + "select-user": "Izberi uporabnika", + "no-users-matching": "Najti ni bilo mogoče nobenega uporabnika, ki se ujema z '{{entity}}'.", + "user-required": "Uporabnik je potreben", + "activation-method": "Način aktiviranja", + "display-activation-link": "Prikaži aktivacijsko povezavo", + "send-activation-mail": "Pošlji aktivacijsko pošto", + "activation-link": "Povezava za aktiviranje uporabnika", + "activation-link-text": "Če želite uporabnika aktivirati, uporabite naslednjo aktivacijsko povezavo :", + "copy-activation-link": "Kopiraj aktivacijsko povezavo", + "activation-link-copied-message": "Povezava za aktiviranje uporabnika je bila kopirana v odložišče", + "details": "Podrobnosti", + "login-as-tenant-admin": "Prijava kot skrbnik najemnika", + "login-as-customer-user": "Prijavi se kot uporabnik stranke", + "search": "Iskanje uporabnikov", + "selected-users": "{ count, plural, 1 {1 user} other {# users} } izbranih", + "disable-account": "Onemogoči uporabniški račun", + "enable-account": "Omogoči uporabniški račun", + "enable-account-message": "Uporabniški račun je bil uspešno omogočen!", + "disable-account-message": "Uporabniški račun je bil uspešno onemogočen!" + }, + "value": { + "type": "Vrsta vrednosti", + "string": "Niz", + "string-value": "Vrednost niza", + "string-value-required": "Vrednost niza je potrebna", + "integer": "Celota", + "integer-value": "Celotna vrednost", + "integer-value-required": "Zahtevana je celoštevilčna vrednost", + "invalid-integer-value": "Neveljavna celoštevilska vrednost", + "double": "Podvojeno", + "double-value": "Podvojena vrednost", + "double-value-required": "Zahtevana je podvojena vrednost", + "boolean": "Logično", + "boolean-value": "Logična vrednost", + "false": "Napačno", + "true": "Pravilno", + "long": "Dolgo", + "json": "JSON", + "json-value": "Vrednost JSON", + "json-value-invalid": "Vrednost JSON ima neveljavno obliko", + "json-value-required": "Vrednost JSON je potrebna." + }, + "widget": { + "widget-library": "Knjižnica pripomočkov", + "widget-bundle": "Paket pripomočkov", + "select-widgets-bundle": "Izberi paket pripomočkov", + "management": "Upravljanje pripomočkov", + "editor": "Urejevalnik pripomočkov", + "widget-type-not-found": "Težava pri nalaganju konfiguracije pripomočka.
    Verjetno je bil povezan tip pripomočka odstranjen.", + "widget-type-load-error": "Pripomoček ni bil naložen zaradi naslednjih napak:", + "remove": "Odstrani pripomoček", + "edit": "Uredi pripomoček", + "remove-widget-title": "Ali ste prepričani, da želite odstraniti pripomoček '{{widgetTitle}}'?", + "remove-widget-text": "Po potrditvi bodo pripomoček in vsi povezani podatki nepopravljivi.", + "timeseries": "Časovne serije", + "search-data": "Iskanje podatkov", + "no-data-found": "Podatkov ni mogoče najti", + "latest": "Najnovejše vrednosti", + "rpc": "Nadzorni pripomoček", + "alarm": "Pripomoček za alarm", + "static": "Statični pripomoček", + "select-widget-type": "Izberi vrsto pripomočka", + "missing-widget-title-error": "Navesti je treba pripomoček!", + "widget-saved": "Pripomoček je shranjen", + "unable-to-save-widget-error": "Pripomočka ni mogoče shraniti! V pripomočku so napake!", + "save": "Shrani pripomoček", + "saveAs": "Shrani pripomoček kot", + "save-widget-type-as": "Shrani vrsto pripomočka kot", + "save-widget-type-as-text": "Vnesite nov naslov pripomočka in / ali izberite paket ciljnih pripomočkov", + "toggle-fullscreen": "Preklop na celozaslonski način", + "run": "Zaženi pripomoček", + "title": "Naslov pripomočka", + "title-required": "Naslov pripomočka je obvezen.", + "type": "Vrsta pripomočka", + "resources": "Viri", + "resource-url": "URL JavaScript / CSS", + "resource-is-module": "Je modul", + "remove-resource": "Odstrani vir", + "add-resource": "Dodaj vir", + "html": "HTML", + "tidy": "Urejeno", + "css": "CSS", + "settings-schema": "Shema nastavitev", + "datakey-settings-schema": "Shema nastavitev podatkovnega ključa", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Ali ste prepričani, da želite odstraniti vrsto pripomočka '{{widgetName}}'?", + "remove-widget-type-text": "Po potrditvi bodo vrsta pripomočka in vsi povezani podatki nepopravljivi.", + "remove-widget-type": "Odstrani vrsto pripomočka", + "add-widget-type": "Dodaj novo vrsto pripomočka", + "widget-type-load-failed-error": "Vrste pripomočka ni bilo mogoče naložiti!", + "widget-template-load-failed-error": "Predloge pripomočka ni bilo mogoče naložiti!", + "add": "Dodaj pripomoček", + "undo": "Razveljavi spremembe pripomočka", + "export": "Izvozi pripomoček", + "no-data": "Na pripomočku ni podatkov za prikaz", + "data-overflow": "Pripomoček prikazuje {{count}} od {{total}} entitet", + "alarm-data-overflow": "Pripomoček prikazuje alarme za {{allowedEntities}} (največ dovoljenih) entitet od {{totalEntities}} entitet" + }, + "widget-action": { + "header-button": "Gumb glave pripomočka", + "open-dashboard-state": "Pomakni se do novega stanja na nadzorni plošči", + "update-dashboard-state": "Posodobi trenutno stanje nadzorne plošče", + "open-dashboard": "Pomakni se na drugo nadzorno ploščo", + "custom": "Dejanje po meri", + "custom-pretty": "Dejanje po meri (s predlogo HTML)", + "target-dashboard-state": "Ciljno stanje nadzorne plošče", + "target-dashboard-state-required": "Ciljno stanje nadzorne plošče je potrebno", + "set-entity-from-widget": "Nastavi entiteto iz pripomočka", + "target-dashboard": "Ciljna nadzorna plošča", + "open-right-layout": "Odpri pravilno postavitev nadzorne plošče (mobilni pogled)" + }, + "widgets-bundle": { + "current": "Trenutni paketi", + "widgets-bundles": "Paketi pripomočkov", + "add": "Dodaj paket pripomočkov", + "delete": "Izbriši paket pripomočkov", + "title": "Naslov", + "title-required": "Naslov je obvezen.", + "add-widgets-bundle-text": "Dodaj nov paket pripomočkov", + "no-widgets-bundles-text": "Najden ni bil noben paket pripomočkov", + "empty": "Paket pripomočkov je prazen", + "details": "Podrobnosti", + "widgets-bundle-details": "Podrobnosti o paketu pripomočkov", + "delete-widgets-bundle-title": "Ali ste prepričani, da želite izbrisati paket pripomočkov '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Bodite previdni, po potrditvi bodo pripomočki in vsi povezani podatki postali nepopravljivi.", + "delete-widgets-bundles-title": "Ali ste prepričani, da želite izbrisati { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "Izbriši { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Bodite previdni, po potrditvi bodo odstranjeni vsi paketi pripomočkov, vsi povezani podatki pa bodo postali nepopravljivi.", + "no-widgets-bundles-matching": "Najden ni bil noben paket pripomočkov, ki se ujema z '{{widgetsBundle}}'.", + "widgets-bundle-required": "Paket pripomočkov je potreben.", + "system": "Sistem", + "import": "Uvozi paket pripomočkov", + "export": "Izvozi paket pripomočkov", + "export-failed-error": "Ni mogoče izvoziti paketa pripomočkov: {{error}}", + "create-new-widgets-bundle": "Ustvari nov paket pripomočkov", + "widgets-bundle-file": "Datoteka paketa pripomočkov", + "invalid-widgets-bundle-file-error": "Ni mogoče uvoziti paketa pripomočkov: neveljavna podatkovna struktura paketa pripomočkov.", + "search": "Iskanje po paketih pripomočkov", + "selected-widgets-bundles": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} } izbranih", + "open-widgets-bundle": "Odpri paket pripomočkov" + }, + "widget-config": { + "data": "Podatki", + "settings": "Nastavitve", + "advanced": "Napredno", + "title": "Naslov", + "title-tooltip": "Opis naslova", + "general-settings": "Splošne nastavitve", + "display-title": "Prikaži naslov", + "drop-shadow": "Spusti senco", + "enable-fullscreen": "Omogoči celozaslonski način", + "background-color": "Barva ozadja", + "text-color": "Barva besedila", + "padding": "Oblazinjenje", + "margin": "Stopnja", + "widget-style": "Slog pripomočkov", + "title-style": "Slog naslova", + "mobile-mode-settings": "Nastavitve mobilnega načina", + "order": "Naročilo", + "height": "Višina", + "units": "Poseben simbol za prikaz poleg vrednosti", + "decimals": "Število številk po plavajoči vejici", + "timewindow": "Časovno okno", + "use-dashboard-timewindow": "Uporabi časovno okno nadzorne plošče", + "display-timewindow": "Prikaži časovno okno", + "display-legend": "Prikaži legendo", + "datasources": "Viri podatkov", + "maximum-datasources": "Največ { count, plural, 1 {1 vir podatkov je dovoljen.} other {# vir podatkov je dovoljen} }", + "datasource-type": "Vrsta", + "datasource-parameters": "Parametri", + "remove-datasource": "Odstrani vir podatkov", + "add-datasource": "Dodaj vir podatkov", + "target-device": "Ciljna naprava", + "alarm-source": "Vir alarma", + "actions": "Dejanja", + "action": "Dejanje", + "add-action": "Dodaj dejanje", + "search-actions": "Iskanje dejanj", + "no-actions-text": "Ni najdenih dejanj", + "action-source": "Vir dejanja", + "action-source-required": "Zahtevan je vir dejanj.", + "action-name": "Ime", + "action-name-required": "Ime dejanja je obvezno.", + "action-name-not-unique": "Še eno dejanje z istim imenom že obstaja.
    Ime dejanja mora biti enolično v istem viru dejanj.", + "action-icon": "Ikona", + "action-type": "Vrsta", + "action-type-required": "Zahtevana je vrsta dejanja.", + "edit-action": "Uredi dejanje", + "delete-action": "Izbriši dejanje", + "delete-action-title": "Izbriši dejanje pripomočka", + "delete-action-text": "Ali ste prepričani, da želite izbrisati dejanje pripomočka z imenom '{{actionName}}'?", + "display-icon": "Prikaži ikono naslova", + "icon-color": "Barva ikone", + "icon-size": "Velikost ikone" + }, + "widget-type": { + "import": "Uvozi vrsto pripomočka", + "export": "Izvozi vrsto pripomočka", + "export-failed-error": "Ni mogoče izvoziti vrste pripomočka: {{error}}", + "create-new-widget-type": "Ustvari novo vrsto pripomočka", + "widget-type-file": "Datoteka vrste pripomočka", + "invalid-widget-type-file-error": "Ne morem uvoziti vrste gradnika: Neveljavna struktura podatkov vrste pripomočka." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Ned", + "Mon": "Pon", + "Tue": "Tor", + "Wed": "Sre", + "Thu": "Čet", + "Fri": "Pet", + "Sat": "Sob", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "Maj", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Avg", + "Sep": "Sep", + "Oct": "Okt", + "Nov": "Nov", + "Dec": "Dec", + "January": "Januar", + "February": "Februar", + "March": "Marec", + "April": "April", + "June": "Junij", + "July": "Julij", + "August": "Avgust", + "September": "September", + "October": "Oktober", + "November": "November", + "December": "December", + "Custom Date Range": "Časovno obdobje po meri", + "Date Range Template": "Predloga časovnega obdobja", + "Today": "Danes", + "Yesterday": "Včeraj", + "This Week": "Ta teden", + "Last Week": "Prejšnji teden", + "This Month": "Ta mesec", + "Last Month": "Prejšnji mesec", + "Year": "Leto", + "This Year": "To leto", + "Last Year": "Lansko leto", + "Date picker": "Izbirnik datuma", + "Hour": "Ura", + "Day": "Dan", + "Week": "Teden", + "2 weeks": "2 tedna", + "Month": "Mesec", + "3 months": "3 mesece", + "6 months": "6 mesecev", + "Custom interval": "Interval po meri", + "Interval": "Interval", + "Step size": "Velikost koraka", + "Ok": "V redu" + } + }, + "input-widgets": { + "attribute-not-allowed": "Parametra atributa v tem pripomočku ni mogoče uporabiti", + "blocked-location": "Geolokacija je blokirana v vašem brskalniku", + "claim-device": "Zahtevaj napravo", + "claim-failed": "Naprave ni bilo mogoče zahtevati!", + "claim-not-found": "Naprave ni mogoče najti!", + "claim-successful": "Naprava je bila uspešno zahtevana!", + "date": "Datum", + "device-name": "Ime naprave", + "device-name-required": "Ime naprave je obvezno", + "discard-changes": "Zavrzi spremembe", + "entity-attribute-required": "Zahtevan je atribut entitete", + "entity-coordinate-required": "Oba polja, zemljepisna širina in dolžina sta obvezna", + "entity-timeseries-required": "Potreben je časovni niz entitet", + "get-location": "Pridobi trenutno lokacijo", + "invalid-date": "Invalid Date", + "latitude": "Zemljepisna širina", + "longitude": "Zemljepisna dolžina", + "min-value-error": "Min value is {{value}}", + "max-value-error": "Max value is {{value}}", + "not-allowed-entity": "Izbrana entiteta ne sme imeti atributov v skupni rabi", + "no-attribute-selected": "Noben atribut ni izbran", + "no-datakey-selected": "Nobena podatkovna tipka ni izbrana", + "no-coordinate-specified": "Podatkovni ključ za zemljepisno širino / dolžino ni določen", + "no-entity-selected": "Nobena entiteta ni izbrana", + "no-image": "Ni slike", + "no-support-geolocation": "Vaš brskalnik ne podpira geolokacije", + "no-support-web-camera": "Ni podprte spletne kamere", + "enable-https-use-widget": "Please enable HTTPS to use this widget", + "no-found-your-camera": "Can't find your camera", + "no-permission-camera": "Permission was denied by the user / This site doesn't have permission to use the camera", + "no-timeseries-selected": "Izbrana ni nobena časovna serija", + "secret-key": "Skrivni ključ", + "secret-key-required": "Potreben je skrivni ključ", + "switch-attribute-value": "Preklopi vrednost atributa entitete", + "switch-camera": "Preklopi kamero", + "switch-timeseries-value": "Preklopi vrednost časovnega niza entitete", + "take-photo": "Fotografiraj", + "time": "Čas", + "timeseries-not-allowed": "V tem pripomočku ni mogoče uporabiti parametra časovne vrste", + "update-failed": "Posodobitev ni uspela", + "update-successful": "Posodobitev uspešna", + "update-attribute": "Posodobi atribut", + "update-timeseries": "Posodobi časovne vrste", + "value": "Vrednost" + } + }, + "icon": { + "icon": "Ikona", + "select-icon": "Izberi ikono", + "material-icons": "Ikone materiala", + "show-all": "Pokaži vse ikone" + }, + "custom": { + "widget-action": { + "action-cell-button": "Gumb akcijske celice", + "row-click": "Klik na vrstico", + "polygon-click": "Klik na poligon", + "marker-click": "Klik na oznako", + "tooltip-tag-action": "Dejanje oznake orodja", + "node-selected": "Na izbranem vozlišču", + "element-click": "Klik na HTML element", + "pie-slice-click": "Klik na rezino", + "row-double-click": "Dvojni klik na vrstico" + } + }, + "language": { + "language": "Jezik" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json new file mode 100644 index 0000000..174c648 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -0,0 +1,3121 @@ +{ + "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!
    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", + "permission-denied": "İzin Reddedildi", + "permission-denied-text": "Bu işlemi gerçekleştirme izniniz yok!" + }, + "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", + "select": "Seç", + "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ş", + "continue": "Devam", + "discard-changes": "Değişikliklerden Vazgeç", + "download": "İndir", + "next-with-label": "Sonraki: {{label}}", + "read-more": "Devamını Oku", + "hide": "Gizle", + "done": "Tamamlandı" + }, + "aggregation": { + "aggregation": "Aggregation", + "function": "Veri toplama fonksiyonu", + "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", + "home-settings": "Ana Sayfa Ayarları", + "outgoing-mail": "Giden Posta Sunucusu", + "outgoing-mail-settings": "Giden Posta Sunucusu Ayarları", + "system-settings": "Sistem Ayarları", + "test-mail-sent": "Test e-postası başarıyla gönderildi!", + "base-url": "Taban URL", + "base-url-required": "Taban URL gerekli.", + "prohibit-different-url": "İstemci istek başlıklarından ana bilgisayar adını kullanmayı yasakla", + "prohibit-different-url-hint": "Bu ayar, üretim ortamları için etkinleştirilmelidir. Devre dışı bırakıldığında güvenlik sorunlarına neden olabilir", + "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 gerekli.", + "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", + "tls-version": "TLS sürümü", + "enable-proxy": "Proxy etkinleştir", + "proxy-host": "Proxy sunucusu", + "proxy-host-required": "Proxy sunucusu gereklidir.", + "proxy-port": "Proxy portu", + "proxy-port-required": "Proxy portu gereklidir.", + "proxy-port-range": "Proxy portu 1 ile 65535 aralığında olmalıdır.", + "proxy-user": "Proxy kullanıcı adı", + "proxy-password": "Proxy şifresi", + "change-password": "Şifre değiştir", + "send-test-mail": "Test postası gönder", + "sms-provider": "SMS sağlayıcı", + "sms-provider-settings": "SMS sağlayıcı ayarları", + "sms-provider-type": "SMS sağlayıcı türü", + "sms-provider-type-required": "SMS sağlayıcı türü gereklidir.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "aws-access-key-id": "AWS Erişim Anahtarı Kimliği", + "aws-access-key-id-required": "AWS Erişim Anahtarı Kimliği gereklidir", + "aws-secret-access-key": "AWS Gizli Erişim Anahtarı", + "aws-secret-access-key-required": "AWS Gizli Erişim Anahtarı gereklidir", + "aws-region": "AWS Bölgesi", + "aws-region-required": "AWS Bölgesi gereklidir", + "number-from": "Gönderen Telefon Numarası", + "number-from-required": "Gönderen Telefon Numarası gereklidir.", + "number-to": "Gönderilen Telefon Numarası", + "number-to-required": "Gönderilen Telefon Numarası gereklidir.", + "phone-number-hint": "Telefon Numarası (E.164 formatında, ör: +905555555555)", + "phone-number-hint-twilio": "Telefon Numarası E.164 formatında/Telefon Numarasının SID'si/Mesajlaşma Hizmeti SID'si, ör: +905555555555/PNXXX/MGXXX", + "phone-number-pattern": "Geçersiz telefon numarası. E.164 formatında olmalıdır, ör: +905555555555.", + "phone-number-pattern-twilio": "Geçersiz telefon numarası. E.164 formatı/Telefon Numarasının SID'si/Mesaj Hizmeti SID'si olmalıdır, ör: +905555555555/PNXXX/MGXXX", + "sms-message": "SMS mesajı", + "sms-message-required": "SMS mesajı gereklidir.", + "sms-message-max-length": "SMS mesajı 1600 karakterden uzun olamaz", + "twilio-account-sid": "Twilio Hesabı SID'si", + "twilio-account-sid-required": "Twilio Hesabı SID'si gereklidir", + "twilio-account-token": "Twilio Hesabı Token", + "twilio-account-token-required": "Twilio Hesabı Token gereklidir", + "send-test-sms": "Test SMS'i gönder", + "test-sms-sent": "Test SMS'i başarıyla gönderildi!", + "security-settings": "Güvenlik Ayarları", + "password-policy": "Şifre politikası", + "minimum-password-length": "Minimum şifre uzunluğu", + "minimum-password-length-required": "Minimum şifre uzunluğu zorunludur", + "minimum-password-length-range": "Minimum şifre uzunluğu 5 ile 50 arasında olmalıdır", + "minimum-uppercase-letters": "Minimum büyük harf sayısı", + "minimum-uppercase-letters-range": "Minimum büyük harf sayısı negatif olamaz", + "minimum-lowercase-letters": "Minimum küçük harf sayısı", + "minimum-lowercase-letters-range": "Minimum küçük harf sayısı negatif olamaz", + "minimum-digits": "Minimum rakam sayısı", + "minimum-digits-range": "Minimum rakam sayısı negatif olamaz", + "minimum-special-characters": "Minimum özel karakter sayısı", + "minimum-special-characters-range": "Minimum özel karakter sayısı negatif olamaz", + "password-expiration-period-days": "Gün bazlı şifre son kullanma peryodu", + "password-expiration-period-days-range": "Gün bazlı şifre son kullanma peryodu negatif olamaz", + "password-reuse-frequency-days": "Gün bazlı şifre yeniden kullanım sıklığı", + "password-reuse-frequency-days-range": "Gün bazlı şifre yeniden kullanım sıklığı negatif olamaz", + "general-policy": "Genel politika", + "max-failed-login-attempts": "Hesap kilitlenmesi için gerekli maksimum hatalı giriş deneme sayısı", + "minimum-max-failed-login-attempts-range": "Hesap kilitlenmesi için gerekli maksimum hatalı giriş deneme sayısı negatif olamaz", + "user-lockout-notification-email": "Hesap kilidi kaldırıldığında bilgilendirme maili gönder", + "domain-name": "Alan adı", + "domain-name-unique": "Alan adı ve protokolün benzersiz olması gerekir.", + "error-verification-url": "Bir alan adı '/' ve ':' sembollerini içermemelidir. Örnek: thingsboard.io", + "oauth2": { + "access-token-uri": "Erişim belirteci URI'si", + "access-token-uri-required": "Erişim belirteci URI'si gerekli.", + "activate-user": "Kullanıcıyı etkinleştir", + "add-domain": "Alan ekle", + "delete-domain": "Alanı sil", + "add-provider": "Sağlayıcı ekle", + "delete-provider": "Sağlayıcıyı sil", + "allow-user-creation": "Kullanıcı oluşturmaya izin ver", + "always-fullscreen": "Her zaman tam ekran", + "authorization-uri": "Yetkilendirme URI'si", + "authorization-uri-required": "Yetkilendirme URI'si gerekli.", + "client-authentication-method": "İstemci kimlik doğrulama yöntemi", + "client-id": "Kullanıcı Grubu Kimliği", + "client-id-required": "Kullanıcı Grubu Kimliği gereklidir.", + "client-secret": "Kullanıcı Grubu Özel Anahtarı", + "client-secret-required": "Kullanıcı Grubu Özel Anahtarı gereklidir.", + "custom-setting": "Özel ayarlar", + "customer-name-pattern": "Kullanıcı Grubu adı kalıbı", + "default-dashboard-name": "Varsayılan pano adı", + "delete-domain-text": "Dikkatli olun, onaydan sonra bir alan adı ve tüm sağlayıcı verileri kullanılamayacak.", + "delete-domain-title": "'{{domainName}}' alan adının ayarlarını silmek istediğinizden emin misiniz?", + "delete-registration-text": "Dikkatli olun, onaydan sonra sağlayıcı verileri kullanılamayacak.", + "delete-registration-title": "'{{name}}' sağlayıcısını silmek istediğinizden emin misiniz?", + "email-attribute-key": "E-posta öznitelik anahtarı", + "email-attribute-key-required": "E-posta öznitelik anahtarı gerekli.", + "first-name-attribute-key": "Ad öznitelik anahtarı", + "general": "Genel", + "jwk-set-uri": "JSON Web Anahtarı URI'sı", + "last-name-attribute-key": "Soyadı öznitelik anahtarı", + "login-button-icon": "Giriş düğmesi simgesi", + "login-button-label": "Sağlayıcı etiketi", + "login-button-label-placeholder": "$(Provider label) ile giriş yapın", + "login-button-label-required": "Etiket gerekli.", + "login-provider": "Giriş sağlayıcı", + "mapper": "Eşleyici", + "new-domain": "Yeni alan", + "oauth2": "OAuth2", + "redirect-uri-template": "Yönlendirme URI şablonu", + "copy-redirect-uri": "Yönlendirme URI'sini kopyala", + "registration-id": "Kayıt Kimliği", + "registration-id-required": "Kayıt kimliği gerekli.", + "registration-id-unique": "Kayıt kimliğinin sistem için benzersiz olması gerekir.", + "scope": "Kapsam", + "scope-required": "Kapsam gerekli.", + "tenant-name-pattern": "Tenant isim modeli", + "tenant-name-pattern-required": "Tenant isim modeli gerekli.", + "tenant-name-strategy": "Tenant isim stratejisi", + "type": "Eşleyici türü", + "uri-pattern-error": "Geçersiz URI biçimi.", + "url": "URL", + "url-pattern": "Geçersiz URL biçimi.", + "url-required": "URL gerekli.", + "user-info-uri": "Kullanıcı bilgisi URI'si", + "user-info-uri-required": "Kullanıcı bilgisi URI'si gerekli.", + "user-name-attribute-name": "Kullanıcı adı öznitelik anahtarı", + "user-name-attribute-name-required": "Kullanıcı adı öznitelik anahtarı gerekli", + "protocol": "Protokol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "OAuth2 ayarlarını etkinleştir", + "domains": "Etki Alanları", + "mobile-apps": "Mobil uygulamalar", + "no-mobile-apps": "Yapılandırılan uygulama yok", + "mobile-package": "Uygulama paketi", + "mobile-package-placeholder": "Ör.: benim.example.app", + "mobile-package-hint": "Android için: kendi benzersiz Uygulama Kimliğiniz. iOS için: Ürün paketi tanımlayıcısı.", + "mobile-package-unique": "Uygulama paketi benzersiz olmalıdır.", + "mobile-app-secret": "Uygulama Özel Anahtarı", + "invalid-mobile-app-secret": "Uygulama Özel Anahtarı yalnızca alfasayısal karakterler içermeli ve 16 ila 2048 karakter uzunluğunda olmalıdır.", + "copy-mobile-app-secret": "Uygulama Özel Anahtarını Kopyala", + "add-mobile-app": "Uygulama ekle", + "delete-mobile-app": "Uygulama bilgilerini sil", + "providers": "Sağlayıcılar", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Tüm platformlar", + "allowed-platforms": "İzin verilen platformlar" + } + }, + "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", + "alarm-status-list": "Alarm durum listesi", + "any-status": "Herhangi bir durum", + "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ı", + "alarm-severity-list": "Alarm önem listesi", + "any-severity": "Herhangi bir önem derecesi", + "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?", + "aknowledge-alarm-title": "Alarmı Onayla", + "aknowledge-alarm-text": "Alarmı onaylamak istediğinizden 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?", + "clear-alarm-title": "Alarmı Temizle", + "clear-alarm-text": "Alarmı silmek istediğinizden emin misiniz?", + "alarm-status-filter": "Alarm Durum Filtresi", + "alarm-filter": "Alarm Filtresi", + "max-count-load": "Yüklenecek maksimum alarm sayısı (0 - sınırsız)", + "max-count-load-required": "Yüklenecek maksimum alarm sayısı gerekli.", + "max-count-load-error-min": "Minimum değer 0'dır.", + "fetch-size": "İstek boyutu", + "fetch-size-required": "İstek boyutu gereklidir.", + "fetch-size-error-min": "Minimum değer 10'dur.", + "alarm-type-list": "Alarm tipi listesi", + "any-type": "Her hangi bir tür", + "search-propagated-alarms": "Yayılan alarmları ara" + }, + "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": "Gösterge panelinden öğe", + "filter-type-state-entity-description": "Gösterge paneli 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": "Cihaz türü", + "filter-type-device-type-description": "'{{deviceType}}' türünde cihazlar", + "filter-type-device-type-and-name-description": "Adı '{{prefix}}' ile başlayan'{{deviceType}}' türünde cihazlar", + "filter-type-entity-view-type": "Öğe Görünümü türü", + "filter-type-entity-view-type-description": "'{{entityViewType}}' türünde Öğe Görünümleri", + "filter-type-entity-view-type-and-name-description": "'{{entityViewType}}' türünde ve adı '{{prefix}}' ile başlayan Öğe Görünümleri", + "filter-type-edge-type": "Uç tipi", + "filter-type-edge-type-description": "'{{edgeType}}' türünün uçları", + "filter-type-edge-type-and-name-description": "'{{edgeType}}' türü ve adı '{{prefix}}' ile başlayan kenarlar", + "filter-type-relations-query": "İlişkiler sorgusu", + "filter-type-relations-query-description": "{{relationType}} türünde ilişkili olan öğeler: {{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": "Cihaz arama sorgusu", + "filter-type-device-search-query-description": "{{relationType}} türünde ilişkisi olan cihaz tipleri {{deviceTypes}}. {{direction}}: {{rootEntity}}", + "filter-type-entity-view-search-query": "Öğe görünümü arama sorgusu", + "filter-type-entity-view-search-query-description": "{{relationType}} {{direction}} {{rootEntity}} ilişkisine sahip {{entityViewTypes}} türlerine sahip öğe görünümleri", + "filter-type-apiUsageState": "API Kullanım Durumu", + "filter-type-edge-search-query": "Uç arama sorgusu", + "filter-type-edge-search-query-description": "{{relationType}} {{direction}} {{rootEntity}} ilişkisine sahip {{edgeType}} türlerine sahip uçlar", + "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": "Gösterge panelini kök olarak kullan", + "last-level-relation": "Yalnızca son düzey ilişkiyi getir", + "root-entity": "Kök öğe", + "state-entity-parameter-name": "Durum varlığı parametre adı", + "default-state-entity": "Varsayılan durum öğesi", + "default-entity-parameter-name": "Varsayılan", + "max-relation-level": "Maksimum ilişki düzeyi", + "unlimited-level": "Sınırsız seviye", + "state-entity": "Gösterge 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": "Etkinlikler", + "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", + "assign-asset-to-edge-title": "Varlıkları Uç'a Ata", + "assign-asset-to-edge-text": "Lütfen uca atanacak varlıkları seçin", + "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.", + "unassign-assets-from-edge": "Uçtan varlıkların atamasını kaldı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ı", + "help-text": "İhtiyaca göre '%' kullanın: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "import": "Varlıkları içe aktar", + "asset-file": "Varlık dosyası", + "label": "Etiket", + "search": "Varlık ara", + "assign-asset-to-edge": "Varlıkları Uç'a Ata", + "unassign-asset-from-edge": "Öğe atamasını kaldır", + "unassign-asset-from-edge-title": "'{{assetName}}' öğesinin atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-asset-from-edge-text": "Onaydan sonra varlığın ataması kaldırılacak ve uç tarafından erişilebilir olmayacaktır.", + "unassign-assets-from-edge-title": "{ count, plural, 1 {1 varlık} other {# varlık} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-assets-from-edge-text": "Onaydan sonra seçilen tüm varlıkların ataması kaldırılacak ve uç tarafından erişilebilir olmayacaktır.", + "selected-assets": "{ count, plural, 1 {1 varlık} other {# varlık} } seçildi" + }, + "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": "Gösterge paneline ekle", + "add-widget-to-dashboard": "Göstergeyi, gösterge 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", + "no-attributes-text": "Öznitelik bulunamadı", + "no-telemetry-text": "Telemetri bulunamadı" + }, + "api-usage": { + "api-usage": "API Kullanımı", + "alarm": "Alarm", + "alarms-created": "Oluşturulan Alarmlar", + "alarms-created-daily-activity": "Günlük oluşturulan alarmlar", + "alarms-created-hourly-activity": "Saatlik oluşturulan alarmlar", + "alarms-created-monthly-activity": "Aylık oluşturulan alarmlar", + "data-points": "Veri noktaları", + "data-points-storage-days": "Veri noktaları depolama günleri", + "email": "E-posta", + "email-messages": "E-posta mesajları", + "email-messages-daily-activity": "Günlük E-posta mesajları", + "email-messages-monthly-activity": "Aylık E-posta mesajları", + "exceptions": "Sıradışı Durumlar", + "executions": "Çalıştırmalar", + "javascript": "JavaScript", + "javascript-executions": "JavaScript çalıştırmaları", + "javascript-functions": "JavaScript fonksiyonları", + "javascript-functions-daily-activity": "Günlük JavaScript fonksiyonları", + "javascript-functions-hourly-activity": "Saatlik JavaScript fonksiyonları", + "javascript-functions-monthly-activity": "Aylık JavaScript fonksiyonları", + "latest-error": "Son Hata", + "messages": "Mesajlar", + "notifications": "Bildirimler", + "notifications-email-sms": "Bildirimler (E-posta/SMS)", + "notifications-hourly-activity": "Saatlik Bildirimler", + "permanent-failures": "${entityName} Kalıcı Hatalar", + "permanent-timeouts": "${entityName} Kalıcı Zaman Aşımları", + "processing-failures": "${entityName} İşleme Hataları", + "processing-failures-and-timeouts": "İşleme Hataları ve Zaman Aşımları", + "processing-timeouts": "${entityName} İşleme Zaman Aşımları", + "queue-stats": "Sıra İstatistikleri", + "rule-chain": "Kural Zinciri", + "rule-engine": "Kural Motoru", + "rule-engine-daily-activity": "Günlük Rule Engine etkinliği", + "rule-engine-executions": "Kural Motoru yürütmeleri", + "rule-engine-hourly-activity": "Saatlik Rule Engine etkinliği", + "rule-engine-monthly-activity": "Aylık Rule Engine etkinliği", + "rule-engine-statistics": "Kural Motoru İstatistikleri", + "rule-node": "Kural Düğümü", + "sms": "SMS", + "sms-messages": "SMS mesajları", + "sms-messages-daily-activity": "Günlük SMS mesajları etkinliği", + "sms-messages-monthly-activity": "Aylık SMS mesajları etkinliği", + "successful": "${entityName} Başarılı", + "telemetry": "Telemetri", + "telemetry-persistence": "Telemetri kalıcılığı", + "telemetry-persistence-daily-activity": "Günlük Telemetri kalıcılığı", + "telemetry-persistence-hourly-activity": "Saatlik Telemetri kalıcılığı", + "telemetry-persistence-monthly-activity": "Aylık Telemetri kalıcılığı", + "transport": "Aktarım", + "transport-daily-activity": "Günlük Aktarım etkinliği", + "transport-data-points": "Aktarım veri noktaları", + "transport-hourly-activity": "Saatlik Aktarım etkinliği", + "transport-messages": "Aktarım mesajları", + "transport-monthly-activity": "Aylık Aktarım etkinliği", + "view-details": "Detayları göster", + "view-statistics": "İstatistikleri görüntüle" + }, + "audit-log": { + "audit": "Log ve Hata Yönetimi", + "audit-logs": "Loglar ve Hatalar", + "timestamp": "Zaman", + "entity-type": "Öğe Türü", + "entity-name": "Öğe İsmi", + "user": "Kullanıcı", + "type": "Tür", + "status": "Durum", + "details": "Detaylar", + "type-added": "Eklendi", + "type-deleted": "Silindi", + "type-updated": "Güncellendi", + "type-attributes-updated": "Öznitelikler güncellendi", + "type-attributes-deleted": "Öznitelikler 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-assigned-to-edge": "Uç'a Atandı", + "type-unassigned-from-edge": "Uç'tan Kaldırıldı", + "type-activated": "Etkinleştirildi", + "type-suspended": "Askıya alındı", + "type-credentials-read": "Kimlik bilgileri okundu", + "type-attributes-read": "Öznitelikler 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", + "type-login": "Giriş", + "type-logout": "Çıkış", + "type-lockout": "Kilitleme", + "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", + "type-assigned-from-tenant": "Tenant'tan atandı", + "type-assigned-to-tenant": "Tenant'a atandı", + "type-provision-success": "Cihaz sağlandı", + "type-provision-failure": "Cihaz temel hazırlığı başarısız oldu", + "type-timeseries-updated": "Telemetri güncellendi", + "type-timeseries-deleted": "Telemetri silindi" + }, + "confirm-on-exit": { + "message": "Kaydedilmemiş değişiklikler var. Sayfadan ayrılmak istediğinize emin misiniz?", + "html-message": "Kaydedilmemiş değişiklikler var.
    Sayfadan ayrılmak istediğinize emin misiniz?", + "title": "Kaydedilmemiş Değişiklikler" + }, + "contact": { + "country": "Ülke", + "city": "Şehir", + "state": "Eyalet / İl", + "postal-code": "Posta Kodu", + "postal-code-invalid": "Geçersiz Posta Kodu.", + "address": "Adres", + "address2": "Adres 2", + "phone": "Telefon", + "email": "E-posta", + "no-address": "Adres yok" + }, + "common": { + "username": "Kullanıcı adı", + "password": "Parola", + "enter-username": "Kullanıcı adı gir", + "enter-password": "Parola gir", + "enter-search": "Arama gir", + "created-time": "Oluşma zamanı", + "loading": "Yükleniyor...", + "proceed": "İlerle" + }, + "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 Gösterge Paneli", + "dashboards": "Kullanıcı Grubu Gösterge Panellleri", + "devices": "Kullanıcı Grubu Cihazları", + "entity-views": "Kullanıcı Grubu Öğe Görüntüleme Sayısı", + "assets": "Kullanıcı Grubu Varlıkları", + "public-dashboards": "Açık Gösterge Panelleri", + "public-devices": "Açık Cihazlar", + "public-assets": "Açık Varlıklar", + "public-edges": "Açık Uçlar", + "public-entity-views": "Açık Öğe Görünümleri", + "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 cihazlarını yönet", + "manage-customer-dashboards": "Kullanıcı grubu Gösterge panellerini yönet", + "manage-public-devices": "Açık cihazları yönet", + "manage-public-dashboards": "Açık Gösterge panellerini yönet", + "manage-customer-assets": "Kullanıcı Grubu varlıklarını yönet", + "manage-public-assets": "Açık varlıkları yönet", + "manage-customer-edges": "Kullanıcı Grubu uçlarını yönetin", + "manage-public-edges": "Açık Uçları yönetin", + "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}}' Kullanıcı Grubunu silmek istediğinizden emin misiniz?", + "delete-customer-text": "Dikkatli olun, onaydan sonra kullanıcı grubu ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "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": "YARI: Onaylandıktan sonra tüm seçili kullanıcı grupları ve ilişkili veriler geri yüklenemez şekilde silinecek.", + "manage-users": "Kullanıcıları yönet", + "manage-assets": "Varlıkları yönet", + "manage-devices": "Cihazları yönet", + "manage-dashboards": "Gösterge panellerini yönet", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Detaylar", + "events": "Etkinlikler", + "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 kullanıcı grubunu seç", + "default-customer": "Varsayılan kullanıcı grubu", + "default-customer-required": "Tenant düzeyinde gösterge tablosunda hata ayıklamak için varsayılan kullanıcı grubu gerekiyor", + "search": "Kullanıcı grubu ara", + "selected-customers": "{ count, plural, 1 {1 kullanıcı grubu} other {# kullanıcı grubu} } seçildi", + "edges": "Kullanıcı Grubu uç örnekleri", + "manage-edges": "Uçları yönet" + }, + "datetime": { + "date-from": "Tarihinden", + "time-from": "Saatinden", + "date-to": "Tarihine", + "time-to": "Saatine" + }, + "dashboard": { + "dashboard": "Gösterge Paneli", + "dashboards": "Gösterge Panelleri", + "management": "Gösterge Paneli Yönetimi", + "view-dashboards": "Gösterge Panellerini Görüntüle", + "add": "Gösterge Paneli Ekle", + "assign-dashboard-to-customer": "Kullanıcı Grubuna Gösterge Panel(ler)i Ata", + "assign-dashboard-to-customer-text": "Lütfen kullanıcı grubuna atanacak gösterge panellerini seçin", + "assign-dashboard-to-edge-title": "Gösterge panellerini Uç'a Ata", + "assign-to-customer-text": "Lütfen gösterge panellerini atayacak kullanıcı grubu seçin", + "assign-to-customer": "Kullanıcı grubuna ata", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "make-public": "Gösterge panelini açık hale getir", + "make-private": "Gösterge panelini özel hale getir", + "manage-assigned-customers": "Atanan kullanıcı gruplarını yönet", + "assigned-customers": "Atanan kullanıcı grupları", + "assign-to-customers": "Kullanıcı gruplarına gösterge paneli ata", + "assign-to-customers-text": "Lütfen gösterge panosunu atamak için kullanıcı gruplarını seçin", + "unassign-from-customers": "Kullanıcı gruplarından gösterge panelini kaldır", + "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için kullanıcı gruplarını seçin", + "no-dashboards-text": "Gösterge paneli bulunamadı", + "no-widgets": "Hiçbir gösterge yapılandırılmadı", + "add-widget": "Yeni gösterge ekle", + "title": "Başlık", + "image": "Gösterge Paneli resmi", + "mobile-app-settings": "Mobil uygulama ayarları", + "mobile-order": "Mobil uygulamada gösterge paneli sırası", + "mobile-hide": "Mobil uygulamada gösterge panelini gizle", + "update-image": "Gösterge paneli resmini güncelle", + "take-screenshot": "Ekran görüntüsü al", + "select-widget-title": "Gösterge seç", + "select-widget-value": "{{title}}: gösterge seç", + "select-widget-subtitle": "Kullanılabilir gösterge türleri listesi", + "delete": "Gösterge paneli sil", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Detaylar", + "dashboard-details": "Gösterge paneli detayları", + "add-dashboard-text": "Yeni Gösterge paneli ekle", + "assign-dashboards": "Gösterge panelleri ata", + "assign-new-dashboard": "Yeni gösterge paneli ata", + "assign-dashboards-text": "{ count, plural, 1 {1 gösterge panelini} other {# gösterge panelini} } kullanıcı grubuna ata", + "unassign-dashboards-action-text": "Kullanıcı Gruplarından atama { count, plural, 1 {1 gösterge tablosu} other {# panolar} }", + "delete-dashboards": "Gösterge panellerini sil", + "unassign-dashboards": "Gösterge panellerinden atamayı kaldır", + "unassign-dashboards-action-title": "{ count, plural, 1 {1 gösterge panelinin} other {# gösterge panelinin} } atamaları kullanıcı grubundan kaldır", + "delete-dashboard-title": "'{{dashboardTitle}}' isimli gösterge panelini silmek istediğinize emin misiniz?", + "delete-dashboard-text": "UYARI: Onaylandıktan sonra gösterge paneli ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-dashboards-title": "{ count, plural, 1 {1 gösterge panelini} other {# gösterge panelini} } silmek istediğinize emin misiniz?", + "delete-dashboards-action-title": "{ count, plural, 1 {1 gösterge panelini} other {# gösterge panelini} } sil", + "delete-dashboards-text": "UYARI: Onaylandıktan sonra tüm seçili gösterge panelleri ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-dashboard-title": "'{{dashboardTitle}}' isimli gösterge panelindeki atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboard-text": "Onaylandıktan sonra gösterge panelinin ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez hale gelecektir.", + "unassign-dashboard": "Gösterge panelinin ataması kaldır", + "unassign-dashboards-title": "{count, plural, 1 {1 gösterge panelindeki} other {# gösterge panelindeki} } atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboards-text": "Onaylandıktan {{dashboardTitle}}
    açık hale getirildi ve bu bağlantıdan erişilebilir durumda", + "public-dashboard-notice": "Not: Gösterge panelinden tüm verilere erişebilmek adına ilişkili cihazları da açık hale getirmeniz gerekmektedir.", + "make-private-dashboard-title": "'{{dashboardTitle}}' isimli gösterge panelini özel hale getirmek istediğinize emin misiniz?", + "make-private-dashboard-text": "Onaylandıktan sonra gösterge paneli özel hale getirilecek ve başkaları tarafından erişilemez olacak.", + "make-private-dashboard": "Gösterge panelini özel hale getir", + "socialshare-text": "'{{dashboardTitle}}'", + "socialshare-title": "'{{dashboardTitle}}'", + "select-dashboard": "Gösterge paneli seç", + "no-dashboards-matching": "'{{entity}}' ile eşleşen gösterge paneli bulunamadı.", + "dashboard-required": "Gösterge paneli gerekli.", + "select-existing": "Var olan bir gösterge paneli seç", + "create-new": "Yeni bir gösterge paneli oluştur", + "new-dashboard-title": "Yeni gösterge paneli başlığı", + "open-dashboard": "Gösterge 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", + "empty-image": "Resim yok", + "drop-image": "Bir resim bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "maximum-upload-file-size": "Maksimum yükleme dosyası boyutu: {{ size }}", + "cannot-upload-file": "Dosya yüklenemiyor", + "settings": "Ayarlar", + "layout-settings": "Görünüm ayarları", + "columns-count": "Sütun sayısı", + "columns-count-required": "Sütun sayısı gerekli.", + "min-columns-count-message": "Yalnızca 10 minimum sütun sayısına izin verilir.", + "max-columns-count-message": "Yalnızca 1000 maksimum sütun sayısına izin verilir.", + "widgets-margins": "Göstergeler arasındaki boşluk", + "margin-required": "Boşluk değeri gerekli.", + "min-margin-message": "Minimum boşluk değeri olarak yalnızca 0'a izin verilir.", + "max-margin-message": "Maksimum boşluk değeri olarak yalnızca 50'ye izin verilir.", + "horizontal-margin": "Yatay kenar boşluğu", + "horizontal-margin-required": "Yatay kenar boşluğu değeri gerekli.", + "min-horizontal-margin-message": "Minimum yatay kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", + "max-horizontal-margin-message": "Maksimum yatay kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", + "vertical-margin": "Dikey kenar boşluğu", + "vertical-margin-required": "Dikey kenar boşluğu değeri gerekli.", + "min-vertical-margin-message": "Minimum dikey kenar boşluğu değeri olarak yalnızca 0'a izin verilir.", + "max-vertical-margin-message": "Maksimum dikey kenar boşluğu değeri olarak yalnızca 50'ye izin verilir.", + "autofill-height": "Otomatik doldurma görünüm yüksekliği", + "mobile-layout": "Mobil görünüm ayarları", + "mobile-row-height": "Mobil satır yüksekliği, px", + "mobile-row-height-required": "Mobil satır yükseklik değeri gerekli.", + "min-mobile-row-height-message": "Minimum mobil satır yüksekliği değeri olarak yalnızca 5 piksele izin verilir.", + "max-mobile-row-height-message": "Maksimum mobil satır yüksekliği değeri olarak yalnızca 200 piksele izin verilir.", + "title-settings": "Başlık ayarları", + "display-title": "Gösterge paneli başlığını görüntüle", + "title-color": "Başlık rengi", + "toolbar-settings": "Araç çubuğu ayarları", + "hide-toolbar": "Araç çubuğunu gizle", + "toolbar-always-open": "Araç çubuğunu açık tut", + "display-dashboards-selection": "Gösterge paneli seçimini görüntüle", + "display-entities-selection": "Öğe seçimini görüntüle", + "display-filters": "Görüntü filtreleri", + "display-dashboard-timewindow": "Zaman penceresini göster", + "display-dashboard-export": "Dışa aktarmayı görüntüle", + "display-update-dashboard-image": "Gösterge paneli resmini güncellemeyi görüntüle", + "dashboard-logo-settings": "Gösterge paneli logosu ayarları", + "display-dashboard-logo": "Gösterge paneli tam ekran modunda logoyu görüntüle", + "dashboard-logo-image": "Gösterge paneli logosu resmi", + "import": "Gösterge panelini içe aktar", + "export": "Gösterge panelini dışa aktar", + "export-failed-error": "Gösterge paneli dışa aktarılamıyor: {{hata}}", + "create-new-dashboard": "Yeni gösterge paneli oluştur", + "dashboard-file": "Gösterge paneli dosyası", + "invalid-dashboard-file-error": "Gösterge paneli içe aktarılamıyor: Geçersiz Gösterge paneli veri yapısı.", + "dashboard-import-missing-aliases-title": "İçe aktarılan gösterge paneli tarafından kullanılan kısa adları 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ılamıyor: Geçersiz gösterge veri yapısı.", + "widget-import-missing-aliases-title": "İçe aktarılan gösterge tarafından kullanılan kısa adları yapılandırın", + "open-toolbar": "Gösterge paneli araç çubuğunu aç", + "close-toolbar": "Araç çubuğunu kapat", + "configuration-error": "Yapılandırma hatası", + "alias-resolution-error-title": "Gösterge paneli kısa adları yapılandırma hatası", + "invalid-aliases-config": "Kısa ad filtresinin bazılarıyla eşleşen herhangi bir cihaz bulunamadı.
    Bu sorunu çözmek için lütfen yöneticinizle iletişime geçin.", + "select-devices": "Cihaz 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": "Gösterge paneli açık bağlantısı panoya kopyalandı", + "manage-states": "Gösterge paneli durumlarını yönet", + "states": "Gösterge paneli durumları", + "search-states": "Gösterge paneli durumlarını ara", + "selected-states": "{ count, plural, 1 {1 gösterge paneli durumu} other {# gösterge paneli durumları} } seçildi", + "edit-state": "Gösterge paneli durumunu düzenle", + "delete-state": "Gösterge paneli durumunu sil", + "add-state": "Gösterge paneli durumu ekle", + "no-states-text": "Durum bulunamadı", + "state": "Gösterge paneli durumu", + "state-name": "İsim", + "state-name-required": "Gösterge paneli durumu ismi gerekli.", + "state-id": "Durum Kimliği", + "state-id-required": "Durum Kimliği gerekli.", + "state-id-exists": "Aynı kimliğe sahip gösterge paneli durumu zaten var.", + "is-root-state": "Kök durum", + "delete-state-title": "Gösterge paneli durumunu sil", + "delete-state-text": "'{{stateName}}' adlı gösterge paneli durumunu silmek istediğinizden emin misiniz?", + "show-details": "Detayları göster", + "hide-details": "Detayları gizle", + "select-state": "Hedef durumu seçin", + "state-controller": "Durum denetleyicisi", + "search": "Gösterge panellerini ara", + "selected-dashboards": "{ count, plural, 1 {1 gösterge paneli} other {# gösterge panelleri} } seçildi", + "home-dashboard": "Ana sayfa gösterge paneli", + "home-dashboard-hide-toolbar": "Ana sayfa gösterge paneli araç çubuğunu gizle", + "unassign-dashboard-from-edge-text": "Onaydan sonra gösterge panelinin ataması kaldırılacak ve uç tarafından erişilebilir olmayacaktır.", + "unassign-dashboards-from-edge-title": "{ count, plural, 1 {1 gösterge paneli} other {# gösterge panelleri} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-dashboards-from-edge-text": "Onaydan sonra, seçilen tüm gösterge panellerinin ataması kaldırılacak ve uç tarafından erişilemeyecek.", + "assign-dashboard-to-edge": "Gösterge panellerini uca ata", + "assign-dashboard-to-edge-text": "Lütfen uca atanacak gösterge panellerini seçin" + }, + "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", + "entity-field": "Öğe alanı", + "alarm": "Alarm alanları", + "timeseries-required": "Zaman serisi öğesi gerekli.", + "timeseries-or-attributes-required": "Zaman serisi/öznitelikler öğesi gerekli.", + "alarm-fields-timeseries-or-attributes-required": "Alarm alanları veya 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} }", + "time-description": "geçerli değerin zaman damgası;", + "value-description": "geçerli değer;", + "prev-value-description": "önceki fonksiyon çağrısının sonucu;", + "time-prev-description": "önceki değerin zaman damgası;", + "prev-orig-value-description": "orijinal önceki değer;" + }, + "datasource": { + "type": "Veri kaynağı türü", + "name": "İsim", + "label": "Etiket", + "add-datasource-prompt": "Lütfen veri kaynağı ekleyin" + }, + "details": { + "details": "Detaylar", + "edit-mode": "Düzenleme modu", + "edit-json": "JSON Düzenle", + "toggle-edit-mode": "Düzenleme modunu aç/kapat" + }, + "device": { + "device": "Cihaz", + "device-required": "Cihaz gerekli.", + "devices": "Cihazlar", + "management": "Cihaz Yönetimi", + "view-devices": "Cihazları görüntüle", + "device-alias": "Cihaz kısa adı", + "aliases": "Cihaz 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ş.
    Cihaz 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 cihaz bulunamadı.", + "alias": "Kısa ad", + "alias-required": "Cihaz kısa adı gerekli.", + "remove-alias": "Cihaz kısa adını kaldır", + "add-alias": "Cihaz kısa adı ekle", + "name-starts-with": "... ile başlayan cihaz adı", + "help-text": "İhtiyaca göre '%' kullanın: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "device-list": "Cihaz listesi", + "use-device-name-filter": "Filtre kullan", + "device-list-empty": "Hiçbir cihaz seçilmedi.", + "device-name-filter-required": "Cihaz adı filtresi gerekli.", + "device-name-filter-no-device-matched": "'{{device}}' ile başlayan herhangi bir cihaz bulunamadı.", + "add": "Cihaz ekle", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-device-to-customer": "Cihazları Kullanıcı Grubuna Ata", + "assign-device-to-customer-text": "Lütfen kullanıcı grubuna atanacak cihazları seçin", + "assign-device-to-edge-title": "Cihazları uca ata", + "assign-device-to-edge-text": "Lütfen uca atanacak cihazları seçin", + "make-public": "Cihazı açık hale getir", + "make-private": "Cihazı gizli hale getir", + "no-devices-text": "Hiçbir cihaz bulunamadı", + "assign-to-customer-text": "Lütfen cihaz(lar)ı atayacak kullanıcı grubu seçin", + "device-details": "Cihaz detayları", + "add-device-text": "Yeni cihaz ekle", + "credentials": "Kimlik bilgileri", + "manage-credentials": "Kimlik bilgilerini yönet", + "delete": "Cihaz sil", + "assign-devices": "Cihaz ata", + "assign-devices-text": "{ count, plural, 1 {1 cihazı} other {# cihazı} } kullanıcı grubuna ata", + "delete-devices": "Cihazları sil", + "unassign-from-customer": "Kullanıcı Grubundan atamayı kaldır", + "unassign-devices": "Cihazlardan atamayı kaldır", + "unassign-devices-action-title": "{ count, plural, 1 {1 cihazın} other {# cihazın} } atamasını kullanıcı grubundan kaldır", + "unassign-device-from-edge-title": "'{{deviceName}}' cihazının atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-device-from-edge-text": "Onaydan sonra cihazın ataması kaldırılacak ve cihaza uç tarafından erişilemeyecek.", + "unassign-devices-from-edge": "Cihazların atamasını uçtan kaldır", + "assign-new-device": "Yeni cihaz ata", + "make-public-device-title": "'{{deviceName}}' isimli cihazı açık hale getirmek istediğinizden emin misiniz?", + "make-public-device-text": "Onaylandıktan sonra cihaz ve verileri açık hale getirilecek ve diğerleri tarafından erişilebilir olacak.", + "make-private-device-title": "'{{deviceName}}' isimli cihazı gizli hale getirmek istediğinizden emin misiniz?", + "make-private-device-text": "Onaylandıktan sonra cihaz 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 cihazı silmek istediğinize emin misiniz?", + "delete-device-text": "UYARI: Onaylandıktan sonra cihaz ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-devices-title": "{ count, plural, 1 {1 cihazı} other {# cihazı} } silmek istediğinize emin misiniz?", + "delete-devices-action-title": "{ count, plural, 1 {1 cihazı} other {# cihazı} } sil", + "delete-devices-text": "UYARI: Onaylandıktan sonra tüm seçili cihazlar ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-device-title": "'{{deviceName}}' isimli cihazın atamasını kaldırmak istediğinize emin misiniz?", + "unassign-device-text": "Onaylandıktan sonra cihazın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "unassign-device": "Cihaz atamasını kaldır", + "unassign-devices-title": "{ count, plural, 1 {1 cihazın} other {# cihazın} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-devices-text": "Onaylandıktan sonra seçili cihazların atamaları kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "device-credentials": "Cihaz Kimlik Bilgileri", + "loading-device-credentials": "Cihaz kimlik bilgileri yükleniyor...", + "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 32 karakter arasında olmalıdır.", + "lwm2m-security-config": { + "identity": "İstemci Kimliği", + "identity-required": "İstemci Kimliği gerekli.", + "client-key": "İstemci Anahtarı", + "client-key-required": "İstemci Anahtarı gerekli.", + "endpoint": "Uç Nokta İstemci Adı", + "endpoint-required": "Uç Nokta İstemci Adı gerekli.", + "mode": "Güvenlik yapılandırma modu", + "client-tab": "İstemci Güvenlik Yapılandırması", + "client-certificate": "İstemci sertifikası", + "bootstrap-tab": "Önyükleme İstemcisi", + "bootstrap-server": "Önyükleme Sunucusu", + "lwm2m-server": "LwM2M Sunucusu", + "client-publicKey-or-id": "İstemci Genel Anahtarı veya Kimliği", + "client-publicKey-or-id-required": "İstemci Genel Anahtarı veya Kimliği gerekli.", + "client-secret-key": "İstemci Gizli Anahtarı", + "client-secret-key-required": "İstemci Gizli Anahtarı gerekli.", + "client-public-key": "İstemci açık anahtarı", + "client-public-key-hint": "İstemci açık anahtarı boşsa, güvenilen sertifika kullanılacaktır." + }, + "client-id": "İstemci ID", + "client-id-pattern": "Geçersiz karakter içeriyor.", + "user-name": "Kullanıcı Adı", + "user-name-required": "Kullanıcı Adı gerekli.", + "client-id-or-user-name-necessary": "İstemci ID veya Kullanıcı Adı gerekli", + "password": "Şifre", + "secret": "Gizli Anahtar", + "secret-required": "Gizli Anahtar is required.", + "device-type": "Cihaz türü", + "device-type-required": "Cihaz türü gerekli.", + "select-device-type": "Cihaz türü seç", + "enter-device-type": "Cihaz türünü girin", + "any-device": "Herhangi bir cihaz", + "no-device-types-matching": "'{{entitySubtype}}' ile eşleşen cihaz türü bulunamadı.", + "device-type-list-empty": "Hiçbir cihaz türü seçilmedi.", + "device-types": "Cihaz Türleri", + "name": "İsim", + "name-required": "İsim gerekli.", + "description": "Açıklama", + "label": "Etiket", + "events": "Etkinlikler", + "details": "Detaylar", + "copyId": "Cihaz kimliğini kopyala", + "copyAccessToken": "Erişim şifresini kopyala", + "copy-mqtt-authentication": "MQTT kimlik bilgilerini kopyala", + "idCopiedMessage": "Cihaz Kimliği panoya kopyalandı", + "accessTokenCopiedMessage": "Cihaz erişim şifresi panoya kopyalandı", + "mqtt-authentication-copied-message": "Cihaz MQTT kimlik doğrulaması panoya kopyalandı", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "unable-delete-device-alias-title": "Cihaz kısa adı silinemiyor", + "unable-delete-device-alias-text": "Cihaz kısa adı('{{deviceAlias}}'), şu göstergeler tarafından kullanıldığı için silinemedi:
    {{widgetsList}}", + "is-gateway": "Ağ geçidi mi?", + "overwrite-activity-time": "Bağlı cihaz için etkinlik süresini üstüne yaz", + "public": "Açık", + "device-public": "Cihaz açık", + "select-device": "Cihaz seç", + "import": "Cihazı içe aktar", + "device-file": "Cihaz dosyası", + "search": "Cihaz ara", + "selected-devices": "{ count, plural, 1 {1 cihaz} other {# cihaz} } seçildi", + "device-configuration": "Cihaz yapılandırması", + "transport-configuration": "Aktarım yapılandırması", + "wizard": { + "device-wizard": "Cihaz Sihirbazı", + "device-details": "Cihaz ayrıntıları", + "new-device-profile": "Yeni cihaz profili oluştur", + "existing-device-profile": "Mevcut cihaz profilini seçin", + "specific-configuration": "Özel yapılandırma", + "customer-to-assign-device": "Cihazı atamak için kullanıcı grubu", + "add-credentials": "Kimlik bilgileri ekle" + }, + "unassign-devices-from-edge-title": "{ count, plural, 1 {1 cihazın} other {# cihazın} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-devices-from-edge-text": "Onaydan sonra, seçilen tüm cihazların ataması kaldırılacak ve uç tarafından erişilemeyecek." + }, + "device-profile": { + "device-profile": "Cihaz profili", + "device-profiles": "Cihaz profilleri", + "all-device-profiles": "Tümü", + "add": "Cihaz profili ekle", + "edit": "Cihaz profilini düzenle", + "device-profile-details": "Cihaz profili ayrıntıları", + "no-device-profiles-text": "Cihaz profili bulunamadı", + "search": "Cihaz profillerini ara", + "selected-device-profiles": "{ count, plural, 1 {1 cihaz profili} other {# cihaz profili} } seçildi", + "no-device-profiles-matching": "'{{entity}}' ile eşleşen cihaz profili bulunamadı.", + "device-profile-required": "Cihaz profili gerekli", + "idCopiedMessage": "Cihaz profili kimliği panoya kopyalandı", + "set-default": "Cihaz profilini varsayılan yap", + "delete": "Cihaz profilini sil", + "copyId": "Cihaz profili kimliğini kopyala", + "new-device-profile-name": "Cihaz profili adı", + "new-device-profile-name-required": "Cihaz profili adı gerekli.", + "name": "İsim", + "name-required": "İsim gerekli.", + "type": "Profil türü", + "type-required": "Profil türü gerekli.", + "type-default": "Varsayılan", + "image": "Cihaz profil resmi", + "transport-type": "Aktarım türü", + "transport-type-required": "Aktarım türü gerekli.", + "transport-type-default": "Varsayılan", + "transport-type-default-hint": "Temel MQTT, HTTP ve CoAP aktarımını destekler", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Gelişmiş MQTT aktarım ayarlarını etkinleştirir", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Gelişmiş CoAP aktarım ayarlarını etkinleştirir", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M aktarım türü", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "SNMP aktarım yapılandırmasını belirtin", + "description": "Açıklama", + "default": "Varsayılan", + "profile-configuration": "Profil yapılandırması", + "transport-configuration": "Aktarım yapılandırması", + "default-rule-chain": "Varsayılan kural zinciri", + "mobile-dashboard": "Mobil gösterge paneli", + "mobile-dashboard-hint": "Mobil uygulama tarafından cihaz ayrıntıları gösterge paneli olarak kullanılır", + "select-queue-hint": "Açılır listeden seçin veya özel bir ad ekleyin.", + "delete-device-profile-title": "'{{deviceProfileName}}' cihaz profilini silmek istediğinizden emin misiniz?", + "delete-device-profile-text": "Dikkatli olun, onaydan sonra cihaz profili ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "delete-device-profiles-title": "{ count, plural, 1 {1 cihaz profilini} other {# cihaz profilini} } silmek istediğinizden emin misiniz?", + "delete-device-profiles-text": "Dikkatli olun, onaydan sonra seçilen tüm cihaz profilleri kaldırılacak ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "set-default-device-profile-title": "'{{deviceProfileName}}' cihaz profilini varsayılan yapmak istediğinizden emin misiniz?", + "set-default-device-profile-text": "Onaydan sonra cihaz profili varsayılan olarak işaretlenecek ve profil belirtilmemiş yeni cihazlar için kullanılacaktır.", + "no-device-profiles-found": "Cihaz profili bulunamadı.", + "create-new-device-profile": "Yeni bir tane oluştur!", + "mqtt-device-topic-filters": "MQTT cihaz konu filtreleri", + "mqtt-device-topic-filters-unique": "MQTT cihaz konu filtrelerinin benzersiz olması gerekir.", + "mqtt-device-payload-type": "MQTT cihaz yükü", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "snmp-add-mapping": "SNMP eşlemesi ekle", + "snmp-mapping-not-configured": "OID için yapılandırılmış zaman serisi/telemetri eşlemesi yok", + "snmp-timseries-or-attribute-name": "Eşleme için zaman serisi/öznitelik adı", + "snmp-timseries-or-attribute-type": "Eşleme için zaman serisi/öznitelik türü", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Yük türü gerekli.", + "coap-device-type": "CoAP cihaz tipi", + "coap-device-payload-type": "CoAP cihaz yükü", + "coap-device-type-required": "CoAP cihaz türü gerekli.", + "coap-device-type-default": "Varsayılan", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Tekli [+] ve çoklu [#] joker karakter destekler.", + "telemetry-topic-filter": "Telemetri konu filtresi", + "telemetry-topic-filter-required": "Telemetri konu filtresi gerekli.", + "attributes-topic-filter": "Öznitelikler konu filtresi", + "attributes-topic-filter-required": "Öznitelikler konu filtresi gerekli.", + "telemetry-proto-schema": "Telemetri proto şeması", + "telemetry-proto-schema-required": "Telemetri proto şeması gerekli.", + "attributes-proto-schema": "Öznitelikler proto şeması", + "attributes-proto-schema-required": "Öznitelikler proto şeması gerekli.", + "rpc-response-proto-schema": "RPC yanıt protokolü şeması", + "rpc-response-proto-schema-required": "RPC yanıt protokolü şeması gerekli.", + "rpc-response-topic-filter": "RPC yanıtı konu filtresi", + "rpc-response-topic-filter-required": "RPC yanıtı konu filtresi gerekli.", + "rpc-request-proto-schema": "RPC istek proto şeması", + "rpc-request-proto-schema-required": "RPC istek proto şeması gerekli.", + "rpc-request-proto-schema-hint": "RPC istek mesajında her zaman bu alanlar olmalıdır: string method = 1; int32 requestId = 2; ve any params = 3.", + "not-valid-pattern-topic-filter": "Geçersiz konu filtresi modeli", + "not-valid-single-character": "Tek düzeyli joker karakterin geçersiz kullanımı", + "not-valid-multi-character": "Çok seviyeli joker karakterin geçersiz kullanımı", + "single-level-wildcards-hint": "[+] herhangi bir konu filtresi seviyesi için uygundur. Ör: v1/devices/+/telemetry veya +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] konu filtresinin yerini alabilir ve konunun son sembolü olmalıdır. Ör: # or v1/devices/me/#.", + "alarm-rules": "Alarm kuralları", + "alarm-rules-with-count": "Alarm kuralları ({{count}})", + "no-alarm-rules": "Yapılandırılmış alarm kuralı yok", + "add-alarm-rule": "Alarm kuralı ekle", + "edit-alarm-rule": "Alarm kuralını düzenle", + "alarm-type": "Alarm tipi", + "alarm-type-required": "Alarm türü gerekli.", + "alarm-type-unique": "Alarm türü, cihaz profili alarm kuralları dahilinde benzersiz olmalıdır.", + "create-alarm-pattern": "{{alarmType}} alarmı oluşturun", + "create-alarm-rules": "Alarm kuralları oluşturun", + "no-create-alarm-rules": "Yapılandırılmış koşul oluşturma yok", + "add-create-alarm-rule-prompt": "Lütfen alarm oluşturma kuralı ekleyin", + "clear-alarm-rule": "Alarm kuralını temizle", + "no-clear-alarm-rule": "Alarm temizleme kuralı yapılandırılmamış", + "add-create-alarm-rule": "Oluşturma koşulu ekle", + "add-clear-alarm-rule": "Alarm temizleme koşulu ekle", + "select-alarm-severity": "Alarm şiddetini seçin", + "alarm-severity-required": "Alarm şiddeti gerekli.", + "condition-duration": "Koşul süresi", + "condition-duration-value": "Süre değeri", + "condition-duration-time-unit": "Zaman birimi", + "condition-duration-value-range": "Süre değeri 1 ile 2147483647 arasında olmalıdır.", + "condition-duration-value-pattern": "Süre değeri tamsayı olmalıdır.", + "condition-duration-value-required": "Süre değeri gerekli.", + "condition-duration-time-unit-required": "Zaman birimi gerekli.", + "advanced-settings": "Gelişmiş ayarlar", + "alarm-rule-details": "Detaylar", + "add-alarm-rule-details": "Detay ekle", + "alarm-rule-mobile-dashboard": "Mobil gösterge paneli", + "alarm-rule-mobile-dashboard-hint": "Mobil uygulama tarafından alarm ayrıntıları gösterge paneli olarak kullanılır", + "alarm-rule-no-mobile-dashboard": "Gösterge paneli seçilmedi", + "propagate-alarm": "Alarmı yay", + "alarm-rule-relation-types-list": "Yayılacak ilişki türleri", + "alarm-rule-relation-types-list-hint": "Yayma ilişki türleri seçilmezse, alarmlar ilişki türüne göre filtreleme yapılmadan yayılır.", + "alarm-details": "Alarm ayrıntıları", + "alarm-rule-condition": "Alarm kuralı koşulu", + "enter-alarm-rule-condition-prompt": "Lütfen alarm kuralı koşulu ekleyin", + "edit-alarm-rule-condition": "Alarm kuralı koşulunu düzenle", + "device-provisioning": "Cihaz tedarik", + "provision-strategy": "Tedarik stratejisi", + "provision-strategy-required": "Tedarik stratejisi gerekli.", + "provision-strategy-disabled": "Devre dışı", + "provision-strategy-created-new": "Yeni cihazlar oluşturmaya izin ver", + "provision-strategy-check-pre-provisioned": "Önceden hazırlanmış cihazları kontrol edin", + "provision-device-key": "Cihaz Sağlama Anahtarı", + "provision-device-key-required": "Cihaz Sağlama Anahtarı gerekli.", + "copy-provision-key": "Cihaz Sağlama Anahtarını kopyala", + "provision-key-copied-message": "Cihaz Sağlama Anahtarı panoya kopyalandı", + "provision-device-secret": "Cihaz Sağlama Özel Anahtarı", + "provision-device-secret-required": "Cihaz Sağlama Özel Anahtarı gerekli.", + "copy-provision-secret": "Cihaz Sağlama Özel Anahtarını kopyala", + "provision-secret-copied-message": "Cihaz Sağlama Özel Anahtarı panoya kopyalandı", + "condition": "Koşul", + "condition-type": "Koşul türü", + "condition-type-simple": "Basit", + "condition-type-duration": "Süre", + "condition-during": "{{during}} sırasında", + "condition-during-dynamic": "\"{{ attribute }}\" sırasında ({{during}})", + "condition-type-repeating": "Tekrarlayan", + "condition-type-required": "Koşul türü gerekli.", + "condition-repeating-value": "Etkinlik sayısı sayısı", + "condition-repeating-value-range": "Etkinlik sayısı 1 ile 2147483647 arasında olmalıdır.", + "condition-repeating-value-pattern": "Etkinlik sayısı tamsayı olmalıdır.", + "condition-repeating-value-required": "Etkinlik sayısı gerekli.", + "condition-repeat-times": "{ count, plural, 1 {1 kere} other {# kere} } tekrar eder", + "condition-repeat-times-dynamic": "\"{ attribute }\" ({ count, plural, 1 {1 kere} other {# kere} } tekrar eder)", + "schedule-type": "Plan türü", + "schedule-type-required": "Plan türü gerekli.", + "schedule": "Plan", + "edit-schedule": "Alarm planını düzenle", + "schedule-any-time": "Her zaman aktif", + "schedule-specific-time": "Belirli bir zamanda aktif", + "schedule-custom": "Özel", + "schedule-day": { + "monday": "Pazartesi", + "tuesday": "Salı", + "wednesday": "Çarşamba", + "thursday": "Perşembe", + "friday": "Cuma", + "saturday": "Cumartesi", + "sunday": "Pazar" + }, + "schedule-days": "Gün", + "schedule-time": "Saat", + "schedule-time-from": "Başlangıç", + "schedule-time-to": "Bitiş", + "schedule-days-of-week-required": "Haftanın en az bir günü seçilmelidir.", + "create-device-profile": "Yeni cihaz profili oluştur", + "import": "Cihaz profilini içe aktar", + "export": "Cihaz profilini dışa aktar", + "export-failed-error": "Cihaz profili dışa aktarılamıyor: {{error}}", + "device-profile-file": "Cihaz profili dosyası", + "invalid-device-profile-file-error": "Cihaz profili içe aktarılamıyor: Geçersiz cihaz profili veri yapısı.", + "power-saving-mode": "Güç tasarrufu modu", + "power-saving-mode-type": { + "default": "Cihaz profili güç tasarrufu modunu kullan", + "psm": "Güç tasarrufu modu (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "eDRX çevrim", + "edrx-cycle-required": "eDRX çevrim gerekli.", + "edrx-cycle-pattern": "eDRX çevrimi pozitif bir tam sayı olmalıdır.", + "edrx-cycle-min": "Minimum eDRX çevrim sayısı {{ min }} saniyedir.", + "paging-transmission-window": "Paging Transmission Window", + "paging-transmission-window-required": "Paging Transmission Window gerekli.", + "paging-transmission-window-pattern": "Paging Transmission Window pozitif bir tam sayı olmalıdır.", + "paging-transmission-window-min": "Minimum Paging Transmission Window sayısı {{ min }} saniyedir.", + "psm-activity-timer": "PSM Etkinlik Zamanlayıcısı", + "psm-activity-timer-required": "PSM Etkinlik Zamanlayıcısı gerekli.", + "psm-activity-timer-pattern": "PSM etkinlik zamanlayıcısı pozitif bir tam sayı olmalıdır.", + "psm-activity-timer-min": "Minimum PSM etkinlik zamanlayıcı sayısı {{ min }} saniyedir.", + "lwm2m": { + "object-list": "Nesne listesi", + "object-list-empty": "Hiçbir nesne seçilmedi.", + "no-objects-found": "Hiçbir nesne bulunamadı.", + "no-objects-matching": "'{{object}}' ile eşleşen nesne bulunamadı.", + "model-tab": "LWM2M Modeli", + "add-new-instances": "Yeni nesne ekle", + "instances-list": "Nesne listesi", + "instances-list-required": "Nesne listesi gerekli.", + "instance-id-pattern": "Nesne ID pozitif bir tam sayı olmalıdır.", + "instance-id-max": "Maksimum nesne kimliği değeri {{max}}.", + "instance": "Nesne", + "resource-label": "#ID Kaynak adı", + "observe-label": "Gözlem", + "attribute-label": "Öznitelik", + "telemetry-label": "Telemetri", + "edit-observe-select": "Gözlemi düzenlemek için telemetri veya özniteliği seçin", + "edit-attributes-select": "Öznitelikleri düzenlemek için telemetri veya öznitelik seçin", + "no-attributes-set": "Öznitelik ayarlanmadı", + "key-name": "Anahtar adı", + "key-name-required": "Anahtar adı gerekli", + "attribute-name": "Ad özniteliği", + "attribute-name-required": "Ad özniteliği gerekli.", + "attribute-value": "Öznitelik değeri", + "attribute-value-required": "Öznitelik değeri gerekli.", + "attribute-value-pattern": "Öznitelik değeri pozitif bir tam sayı olmalıdır.", + "edit-attributes": "Öznitelikleri düzenle: {{ name }}", + "view-attributes": "Öznitelikleri görüntüle: {{ name }}", + "add-attribute": "Öznitelik ekle", + "edit-attribute": "Öznitelik düzenle", + "view-attribute": "Öznitelik görüntüle", + "remove-attribute": "Öznitelik kaldır", + "mode": "Güvenlik yapılandırma modu", + "short-id": "Kısa ID", + "short-id-required": "Kısa ID gerekli.", + "short-id-range": "Kısa ID 1 ile 65534 aralığında olmalıdır.", + "short-id-pattern": "Kısa ID pozitif bir tam sayı olmalıdır.", + "lifetime": "İstemci kayıt ömrü", + "lifetime-required": "İstemci kayıt ömrü gerekli.", + "lifetime-pattern": "İstemci kayıt ömrü, pozitif bir tam sayı olmalıdır.", + "default-min-period": "İki bildirim(ler) arasındaki minimum süre", + "default-min-period-required": "Minimum süre gerekli.", + "default-min-period-pattern": "Minimum süre pozitif bir tam sayı olmalıdır.", + "notification-storing": "Devre dışı bırakıldığında veya çevrimdışı olduğunda bildirim depolama", + "binding": "Bağlama", + "bootstrap-tab": "Bootstrap", + "bootstrap-server": "Bootstrap Sunucusu", + "lwm2m-server": "LwM2M Sunucusu", + "server-host": "Host", + "server-host-required": "Host gerekli.", + "server-port": "Port", + "server-port-required": "Port gerekli.", + "server-port-pattern": "Port pozitif bir tam sayı olmalıdır.", + "server-port-range": "Port 1 ila 65535 aralığında olmalıdır.", + "server-public-key": "Sunucu Açık Anahtarı", + "server-public-key-required": "Sunucu Açık Anahtarı gerekli.", + "client-hold-off-time": "Bekleme Süresi", + "client-hold-off-time-required": "Bekleme Süresi gerekli.", + "client-hold-off-time-pattern": "Bekleme Süresi pozitif bir tam sayı olmalıdır.", + "client-hold-off-time-tooltip": "Yalnızca Bootstrap Sunucusu ile kullanım için İstemci Bekleme Süresi", + "account-after-timeout": "Zaman aşımından sonra hesap", + "account-after-timeout-required": "Zaman aşımından sonraki hesap gerekli.", + "account-after-timeout-pattern": "Zaman aşımından sonraki hesap pozitif bir tam sayı olmalıdır.", + "account-after-timeout-tooltip": "Bu kaynak tarafından verilen zaman aşımı değerinden sonra Bootstrap Sunucu Hesabı.", + "others-tab": "Diğer ayarlar", + "client-strategy": "Bağlanırken istemci stratejisi", + "client-strategy-label": "Strateji", + "client-strategy-only-observe": "Yalnızca ilk bağlantıdan sonra istemciye yapılan isteği gözlemleyin", + "client-strategy-read-all": "Tüm Kaynakları Okuyun ve Kayıttan Sonra İstemciye Yapılan Talebi Gözlemleyin", + "fw-update": "Donanım yazılımı güncellemesi", + "fw-update-strategy": "Donanım yazılımı güncelleme stratejisi", + "fw-update-strategy-data": "Nesne 19 ve Kaynak 0 (Veri) kullanarak Donanım yazılımı güncellemesini binary dosya olarak gönderin", + "fw-update-strategy-package": "Nesne 5 ve Kaynak 0 (Paket) kullanarak Donanım yazılımı güncellemesini binary dosya olarak gönderin", + "fw-update-strategy-package-uri": "Paketi indirmek ve ürün Donanım yazılımı güncellemesini Nesne 5 ve Kaynak 1 (Paket URI'si) olarak göndermek için otomatik olarak benzersiz CoAP URL'si oluşturun", + "sw-update": "Software güncellemesi", + "sw-update-strategy": "Software güncelleme stratejisi", + "sw-update-strategy-package": "Nesne 9 ve Kaynak 2 (Paket) kullanarak binary dosya gönderin", + "sw-update-strategy-package-uri": "Paketi indirmek ve Nesne 9 ve Kaynak 3'ü (Paket URI) kullanarak yazılım güncellemesini göndermek için otomatik olarak benzersiz CoAP URL'si oluşturun", + "fw-update-resource": "Donanım yazılımı güncellemesi CoAP kaynağı", + "fw-update-resource-required": "Donanım yazılımı güncellemesi CoAP kaynağı gerekli.", + "sw-update-resource": "Yazılım güncellemesi CoAP kaynağı", + "sw-update-resource-required": "Yazılım güncellemesi CoAP kaynağı gerekli.", + "config-json-tab": "Json Yapılandırma Profil Cihazı", + "attributes-name": { + "min-period": "Minimum süre", + "max-period": "Maksimum süre", + "greater-than": "Büyüktür", + "less-than": "Küçüktür", + "step": "Adım", + "min-evaluation-period": "Minimum değerlendirme süresi", + "max-evaluation-period": "Maksimum değerlendirme süresi" + }, + "composite-operations-support": "İç içe Okuma/Yazma/Gözlemleme işlemlerini destekler" + }, + "snmp": { + "add-communication-config": "İletişim yapılandırması ekle", + "add-mapping": "Eşleme ekle", + "authentication-passphrase": "Kimlik doğrulama parolası", + "authentication-passphrase-required": "Kimlik doğrulama parolası gerekli.", + "authentication-protocol": "Kimlik doğrulama protokolü", + "authentication-protocol-required": "Kimlik doğrulama protokolü gerekli.", + "communication-configs": "İletişim yapılandırmaları", + "community": "Topluluk dizisi", + "community-required": "Topluluk dizesi gerekli.", + "context-name": "İçerik adı", + "data-key": "Veri anahtarı", + "data-key-required": "Veri anahtarı gerekli.", + "data-type": "Veri türü", + "data-type-required": "Veri türü gerekli.", + "engine-id": "Engine ID", + "host": "Host", + "host-required": "Host gerekli.", + "oid": "OID", + "oid-pattern": "Geçersiz OID biçimi.", + "oid-required": "OID gerekli.", + "please-add-communication-config": "Lütfen iletişim yapılandırmasını ekleyin", + "please-add-mapping-config": "Lütfen eşleme yapılandırmasını ekleyin", + "port": "Port", + "port-format": "Geçersiz port biçimi.", + "port-required": "Port gerekli.", + "privacy-passphrase": "Gizlilik parolası", + "privacy-passphrase-required": "Gizlilik parolası gerekli.", + "privacy-protocol": "Gizlilik protokolü", + "privacy-protocol-required": "Gizlilik protokolü gerekli.", + "protocol-version": "Protokol sürümü", + "protocol-version-required": "Protokol sürümü gerekli.", + "querying-frequency": "Sorgulama sıklığı, ms", + "querying-frequency-invalid-format": "Sorgulama sıklığı pozitif bir tam sayı olmalıdır.", + "querying-frequency-required": "Sorgulama sıklığı gerekli.", + "retries": "Deneme sayısı", + "retries-invalid-format": "Deneme sayısı pozitif bir tam sayı olmalıdır.", + "retries-required": "Deneme sayısı gerekli.", + "scope": "Kapsam", + "scope-required": "Kapsam gerekli.", + "security-name": "Güvenlik adı", + "security-name-required": "Güvenlik adı gerekli.", + "timeout-ms": "Zaman aşımı, ms", + "timeout-ms-invalid-format": "Zaman aşımı pozitif bir tam sayı olmalıdır.", + "timeout-ms-required": "Zaman aşımı gerekli.", + "user-name": "Kullanıcı adı", + "user-name-required": "Kullanıcı adı gerekli." + } + }, + "dialog": { + "close": "Kapat" + }, + "direction": { + "column": "Kolon", + "row": "Satır" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Edge instances", + "edge-file": "Edge file", + "management": "Edge management", + "no-edges-matching": "No edges matching '{{entity}}' were found.", + "add": "Add Edge", + "no-edges-text": "No edges found", + "edge-details": "Edge details", + "add-edge-text": "Add new edge", + "delete": "Delete edge", + "delete-edge-title": "Are you sure you want to delete the edge '{{edgeName}}'?", + "delete-edge-text": "Be careful, after the confirmation the edge and all related data will become unrecoverable.", + "delete-edges-title": "Are you sure you want to edge { count, plural, 1 {1 edge} other {# edges} }?", + "delete-edges-text": "Be careful, after the confirmation all selected edges will be removed and all related data will become unrecoverable.", + "name": "Name", + "name-starts-with": "Edge name starts with", + "name-required": "Name is required.", + "description": "Description", + "details": "Details", + "events": "Events", + "copy-id": "Copy Edge Id", + "id-copied-message": "Edge Id has been copied to clipboard", + "sync": "Sync Edge", + "edge-required": "Edge required", + "edge-type": "Edge type", + "edge-type-required": "Edge type is required.", + "event-action": "Event action", + "entity-id": "Entity ID", + "select-edge-type": "Select edge type", + "assign-to-customer": "Assign to customer", + "assign-to-customer-text": "Please select the customer to assign the edge(s)", + "assign-edge-to-customer": "Assign Edge(s) To Customer", + "assign-edge-to-customer-text": "Please select the edges to assign to the customer", + "assignedToCustomer": "Assigned to customer", + "edge-public": "Edge is public", + "assigned-to-customer": "Assigned to: {{customerTitle}}", + "unassign-from-customer": "Unassign from customer", + "unassign-edge-title": "Are you sure you want to unassign the edge '{{edgeName}}'?", + "unassign-edge-text": "After the confirmation the edge will be unassigned and won't be accessible by the customer.", + "unassign-edges-title": "Are you sure you want to unassign { count, plural, 1 {1 edge} other {# edges} }?", + "unassign-edges-text": "After the confirmation all selected edges will be unassigned and won't be accessible by the customer.", + "make-public": "Make edge public", + "make-public-edge-title": "Are you sure you want to make the edge '{{edgeName}}' public?", + "make-public-edge-text": "After the confirmation the edge and all its data will be made public and accessible by others.", + "make-private": "Make edge private", + "public": "Public", + "make-private-edge-title": "Are you sure you want to make the edge '{{edgeName}}' private?", + "make-private-edge-text": "After the confirmation the edge and all its data will be made private and won't be accessible by others.", + "import": "Import edge", + "label": "Label", + "load-entity-error": "Failed to load data. Entity has been deleted.", + "assign-new-edge": "Assign new edge", + "unassign-from-edge": "Unassign from edge", + "edge-key": "Edge key", + "copy-edge-key": "Copy Edge key", + "edge-key-copied-message": "Edge key has been copied to clipboard", + "edge-secret": "Edge secret", + "copy-edge-secret": "Copy Edge secret", + "edge-secret-copied-message": "Edge secret has been copied to clipboard", + "edge-assets": "Edge assets", + "edge-devices": "Edge devices", + "edge-entity-views": "Edge entity views", + "edge-dashboards": "Edge dashboards", + "edge-rulechains": "Edge rule chains", + "assets": "Edge assets", + "devices": "Edge devices", + "entity-views": "Edge entity views", + "dashboard": "Edge dashboard", + "dashboards": "Edge Dashboards", + "rulechain-templates": "Rule chain templates", + "rulechains": "Rule chains", + "search": "Search edges", + "selected-edges": "{ count, plural, 1 {1 edge} other {# edges} } selected", + "any-edge": "Any edge", + "no-edge-types-matching": "No edge types matching '{{entitySubtype}}' were found.", + "edge-type-list-empty": "No edge types selected.", + "edge-types": "Edge types", + "enter-edge-type": "Enter edge type", + "deployed": "Deployed", + "pending": "Pending", + "downlinks": "Downlinks", + "no-downlinks-prompt": "No downlinks found", + "sync-process-started-successfully": "Sync process started successfully!", + "missing-related-rule-chains-title": "Edge has missing related rule chain(s)", + "missing-related-rule-chains-text": "Assigned to edge rule chain(s) use rule nodes that forward message(s) to rule chain(s) that are not assigned to this edge.

    List of missing rule chain(s):
    {{missingRuleChains}}", + "widget-datasource-error": "This widget supports only EDGE entity datasource" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Device", + "type-device-profile": "Device Profile", + "type-entity-view": "Entity View", + "type-alarm": "Alarm", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "action-type-added": "Added", + "action-type-deleted": "Deleted", + "action-type-updated": "Updated", + "action-type-post-attributes": "Post Attributes", + "action-type-attributes-updated": "Attributes Updated", + "action-type-attributes-deleted": "Attributes Deleted", + "action-type-timeseries-updated": "Timeseries Updated", + "action-type-credentials-updated": "Credentials Updated", + "action-type-assigned-to-customer": "Assigned to Customer", + "action-type-unassigned-from-customer": "Unassigned from Customer", + "action-type-relation-add-or-update": "Relation Add or Update", + "action-type-relation-deleted": "Relation Deleted", + "action-type-rpc-call": "RPC Call", + "action-type-alarm-ack": "Alarm Ack", + "action-type-alarm-clear": "Alarm Clear", + "action-type-assigned-to-edge": "Assigned to Edge", + "action-type-unassigned-from-edge": "Unassigned from Edge", + "action-type-credentials-request": "Credentials Request", + "action-type-entity-merge-request": "Entity Merge Request" + }, + "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", + "entities-count": "Öğe sayısı", + "aliases": "Öğe kısa adları", + "entity-alias": "Öğe kısa adı", + "unable-delete-entity-alias-title": "Öğe kısa adı silinemedi", + "unable-delete-entity-alias-text": "Öğe kısa adı('{{entityAlias}}'), şu göstergeler tarafından kullanıldığı için silinemiyor:
    {{widgetsList}}", + "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.
    Öğe kısa adları kontrol paneli özelinde emsalsiz olmalı.", + "missing-entity-filter-error": "'{{alias}}' için filtre bulunmuyor.", + "configure-alias": "'{{alias}}' kısa adını yapılandır", + "alias": "Kısa ad", + "alias-required": "Öğe kısa adı gerekli.", + "remove-alias": "Öğe kısa adını kaldır", + "add-alias": "Öğe kısa adı ekle", + "entity-list": "Öğe listesi", + "entity-type": "Öğe türü", + "entity-types": "Öğe türleri", + "entity-type-list": "Öğe türü listesi", + "any-entity": "Herhangi bir öğe", + "enter-entity-type": "Öğe türü girin", + "no-entities-matching": "'{{entity}}' ile eşleşen öğe bulunamadı.", + "no-entity-types-matching": "'{{entityType}}' ile eşleşen öğe türü bulunamadı.", + "name-starts-with": "... ile başlayan isim", + "help-text": "İhtiyaca göre '%' kullanın: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "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": "Cihaz", + "type-devices": "Cihazlar", + "list-of-devices": "{ count, plural, 1 {Bir cihaz} other {# cihazın listesi} }", + "device-name-starts-with": "İsimleri '{{prefix}}' ile başlayan cihazlar", + "type-device-profile": "Cihaz profili", + "type-device-profiles": "Cihaz profilleri", + "list-of-device-profiles": "{ count, plural, 1 {Bir cihaz profili} other {# cihaz profilinin listesi} }", + "device-profile-name-starts-with": "Adları '{{prefix}}' ile başlayan cihaz profilleri", + "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": "İsmi {{prefix}} 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-tenant-profile": "Tenant profili", + "type-tenant-profiles": "Tenant profilleri", + "list-of-tenant-profiles": "{ count, plural, 1 {Bir tenant profili} other {# tenant profili listesi} }", + "tenant-profile-name-starts-with": "İsmi '{{prefix}}' ile başlayan tenant profilleri", + "type-customer": "Kullanıcı Grubu", + "type-customers": "Kullanıcı Grupları", + "list-of-customers": "{ count, plural, 1 {Bir kullanıcı grubu} other {# kullanıcı grupları listesi} }", + "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ı listesi} }", + "user-name-starts-with": "İsmi '{{prefix}}' ile başlayan kullanıcılar", + "type-dashboard": "Gösterge Paneli", + "type-dashboards": "Gösterge Panelleri", + "list-of-dashboards": "{ count, plural, 1 {Bir gösterge paneli} other {# gösterge paneli listesi} }", + "dashboard-name-starts-with": "İsmi '{{prefix}}' ile başlayan gösterge panelleri", + "type-alarm": "Alarm", + "type-alarms": "Alarmlar", + "list-of-alarms": "{ count, plural, 1 {Bir alarm} other {# alarm 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 zinciri listesi} }", + "rulechain-name-starts-with": "İsmi '{{prefix}}' ile başlayan kural zincirleri", + "type-rulenode": "Kural düğümü", + "type-rulenodes": "Kural düğümleri", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "İsmi '{{prefix}}' ile başlayan kural düğümleri", + "type-current-customer": "Aktif Kullanıcı Grubu", + "type-current-tenant": "Aktif Tenant", + "type-current-user": "Aktif Kullanıcı", + "type-current-user-owner": "Aktif Kullanıcı Sahibi", + "search": "Öğeleri ara", + "selected-entities": "{ count, plural, 1 {1 öğe} other {# öğe} } seçildi", + "entity-name": "Öğe adı", + "entity-label": "Öğe etiketi", + "details": "Öğe detayları", + "no-entities-prompt": "Öğe bulunamadı", + "no-data": "Gösterilecek veri yok", + "columns-to-display": "Görüntülenecek Sütunlar", + "type-api-usage-state": "API Kullanım Durumu", + "type-edge": "Uç", + "type-edges": "Uçlar", + "list-of-edges": "{ count, plural, 1 {Bir uç} other {# uç listesi} }", + "edge-name-starts-with": "İsmi '{{prefix}}' ile başlayan uçlar", + "type-tb-resource": "Kaynak", + "type-ota-package": "OTA paketi" + }, + "entity-field": { + "created-time": "Oluşturulma zamanı", + "name": "İsim", + "type": "Tür", + "first-name": "Ad", + "last-name": "Soyad", + "email": "E-posta", + "title": "Başlık", + "country": "Ülke", + "state": "Eyalet", + "city": "Şehir", + "address": "Adres", + "address2": "Adres 2", + "zip": "Posta kodu", + "phone": "Telefon", + "label": "Etiket" + }, + "entity-view": { + "entity-view": "Öğe Görünümü", + "entity-view-required": "Öğe Görünümü gerekli.", + "entity-views": "Öğe Görünümleri", + "management": "Öğe Görünümü yönetimi", + "view-entity-views": "Öğe Görünümlerini Görüntüle", + "entity-view-alias": "Öğe Görünümü kısa adı", + "aliases": "Öğe Görünümü kısa adları", + "no-alias-matching": "'{{alias}}' bulunamadı.", + "no-aliases-found": "Kısa ad bulunamadı.", + "no-key-matching": "'{{key}}' 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 kısa ad bulundu '{{alias}}'.
    öğe Görünümü kısa adları gösterge panelinde benzersiz olmalıdır.", + "configure-alias": "'{{alias}}' kısa adını yapılandırın", + "no-entity-views-matching": "'{{entity}}' ile eşleşen öğe görünümü bulunamadı.", + "public": "Açık", + "alias": "Kısa ad", + "alias-required": "Öğe Görünümü kısa adı gerekli.", + "remove-alias": "Öğe görünümü kısa adını kaldır", + "add-alias": "Öğe görünümü kısa adı ekle", + "name-starts-with": "Öğe Görünümü adı ifadesi", + "help-text": "İhtiyaca göre '%' kullanın: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "Öğe Görünümü listesi", + "use-entity-view-name-filter": "Filtre kullan", + "entity-view-list-empty": "Hiçbir öğe görünümü seçilmedi.", + "entity-view-name-filter-required": "Öğe görünümü adı filtresi gerekli.", + "entity-view-name-filter-no-entity-view-matched": "'{{entityView}}' ile başlayan öğe görünümü bulunamadı.", + "add": "Öğe Görünümü Ekle", + "entity-view-public": "Öğe görünümü herkese açık", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-entity-view-to-customer": "Öğe görünümlerini kullanıcı grubuna ata", + "assign-entity-view-to-customer-text": "Lütfen kullanıcı grubuna atanacak öğe görünümlerini seçin", + "assign-entity-view-to-edge-title": "Öğe görünümlerini uca ata", + "no-entity-views-text": "Öğe görünümü bulunamadı", + "assign-to-customer-text": "Lütfen öğe görünümlerini atamak için müşteriyi seçin", + "entity-view-details": "Öğe görünümü ayrıntıları", + "add-entity-view-text": "Yeni öğe görünümü ekle", + "delete": "Öğe görünümünü sil", + "assign-entity-views": "Öğe görünümlerini ata", + "assign-entity-views-text": "{ count, plural, 1 {1 öğe görünümünü} other {# öğe görünümünü} } kullanıcı grubuna ata", + "delete-entity-views": "Öğe görünümlerini sil", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "unassign-entity-views": "Öğe görünümlerinin atamasını kaldır", + "unassign-entity-views-action-title": "{ count, plural, 1 {1 öğe görünümünü} other {# öğe görünümünü} } kullanıcı grubundan kaldır", + "assign-new-entity-view": "Yeni öğe görünümü ata", + "delete-entity-view-title": "'{{entityViewName}}' öğe görünümünü silmek istediğinizden emin misiniz?", + "delete-entity-view-text": "Dikkatli olun, onaydan sonra öğe görünümü ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "delete-entity-views-title": "{ count, plural, 1 {1 öğe görünümünü} other {# öğe görünümünü} } silmek istediğinizden emin misiniz?", + "delete-entity-views-action-title": "{ count, plural, 1 {1 öğe görünümünü} other {# öğe görünümünü} } sil", + "delete-entity-views-text": "Dikkatli olun, onaydan sonra seçilen tüm öğe görünümleri kaldırılacak ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "unassign-entity-view-title": "'{{entityViewName}}' öğe görünümünün atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-view-text": "Onaydan sonra öğe görünümünün ataması kaldırılacak ve müşteri tarafından erişilebilir olmayacaktır.", + "unassign-entity-view": "Öğe görünümünün atamasını kaldır", + "unassign-entity-views-title": "{ count, plural, 1 {1 öğe görünümünün} other {# öğe görünümünün} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-text": "Onaydan sonra, seçilen tüm öğe görünümlerinin ataması kaldırılacak ve kullanıcı grubu tarafından erişilebilir olmayacaktır.", + "entity-view-type": "Öğe Görünümü türü", + "entity-view-type-required": "Öğe Görünümü türü gerekli.", + "select-entity-view-type": "Öğe Görünümü türü seç", + "enter-entity-view-type": "Öğe görünümü türünü girin", + "any-entity-view": "Herhangi bir öğe görünümü", + "no-entity-view-types-matching": "'{{entitySubtype}}' ile eşleşen öğe görünümü türü bulunamadı.", + "entity-view-type-list-empty": "Hiçbir öğe görünümü türü seçilmedi.", + "entity-view-types": "Öğe Görünümü türleri", + "created-time": "Oluşturulan zaman", + "name": "İsim", + "name-required": "İsim zorunlu.", + "description": "Açıklama", + "events": "Etkinlikler", + "details": "Detaylar", + "copyId": "Öğe görünümü kimliğini kopyala", + "idCopiedMessage": "Öğe görünümü kimliği panoya kopyalandı", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "unable-entity-view-device-alias-title": "Öğe görünümü kısa adı silinemiyor", + "unable-entity-view-device-alias-text": "Cihaz kısa adı '{{entityViewAlias}}', aşağıdaki gösterge(ler) tarafından kullanıldığı için silinemez:
    {{widgetsList}}", + "select-entity-view": "Öğe görünümü seç", + "make-public": "Öğe görünümünü herkese açık yap", + "make-private": "Öğe görünümünü gizli yap", + "start-date": "Başlangıç tarihi", + "start-ts": "Başlangıç saati", + "end-date": "Bitiş tarihi", + "end-ts": "Bitiş saati", + "date-limits": "Tarih limitleri", + "client-attributes": "İstemci öznitelikler", + "shared-attributes": "Paylaşılan öznitelikler", + "server-attributes": "Sunucu öznitelikler", + "timeseries": "Zaman serisi", + "client-attributes-placeholder": "İstemci öznitelikler", + "shared-attributes-placeholder": "Paylaşılan öznitelikler", + "server-attributes-placeholder": "Sunucu öznitelikler", + "timeseries-placeholder": "Zaman serisi", + "target-entity": "Hedef öğe", + "attributes-propagation": "Öznitelik işlenmesi", + "attributes-propagation-hint": "Öğe Görünümü, bu öğe görünümünü her kaydettiğinizde veya güncellediğinizde hedef öğeden belirtilen öznitelikleri otomatik olarak kopyalayacaktır. Performans nedenleriyle, hedef öğe öznitelikleri, her bir öznitelik değişikliğinde öğe görünümüne işlenmez. Kural zincirinizde \"copy to view\" kural düğümünü yapılandırarak ve \"Post attributes\" ve \"Attributes Updated\" iletilerini yeni kural düğümüne bağlayarak otomatik işlenmesini etkinleştirebilirsiniz.", + "timeseries-data": "Zaman serisi verileri", + "timeseries-data-hint": "Öğe görünümü tarafından erişilebilir olacak hedef varlığın zaman serisi veri anahtarlarını yapılandırın. Bu zaman serisi verileri salt okunurdur.", + "make-public-entity-view-title": "'{{entityViewName}}' öğe görünümünü herkese açık hale getirmek istediğinizden emin misiniz?", + "make-public-entity-view-text": "Onaydan sonra öğe görünümü ve tüm verileri herkese açık hale getirilecek ve başkaları tarafından erişilebilir hale getirilecektir.", + "make-private-entity-view-title": "'{{entityViewName}}' öğe görünümünü gizli yapmak istediğinizden emin misiniz?", + "make-private-entity-view-text": "Onaydan sonra öğe görünümü ve tüm verileri gizli hale getirilecek ve başkaları tarafından erişilemeyecek.", + "assign-entity-view-to-edge": "Öğe Görünümlerini Uca Ata", + "assign-entity-view-to-edge-text": "Lütfen uca atanacak öğe görünümlerini seçin", + "unassign-entity-view-from-edge-title": "'{{entityViewName}}' öğe görünümünün atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-view-from-edge-text": "Onaydan sonra öğe görünümünün ataması kaldırılacak ve uç tarafından erişilebilir olmayacaktır.", + "unassign-entity-views-from-edge-action-title": "{ count, plural, 1 {1 öğe görünümünü} other {# öğe görünümünü} } uçtan kaldır", + "unassign-entity-view-from-edge": "Öğe görünümünün atamasını kaldır", + "unassign-entity-views-from-edge-title": "{ count, plural, 1 {1 öğe görünümünün} other {# öğe görünümünün} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-from-edge-text": "Onaydan sonra, seçilen tüm öğe görünümlerinin ataması kaldırılacak ve uç tarafından erişilebilir olmayacaktır." + }, + "event": { + "event-type": "Etkinlik türü", + "events-filter": "Etkinlik Filtresi", + "type-error": "Hata", + "type-lc-event": "Yaşam Döngüsü Etkinliği", + "type-stats": "İstatistikler", + "type-debug-rule-node": "Hata Ayıklama", + "type-debug-rule-chain": "Hata Ayıklama", + "no-events-prompt": "Hiçbir etkinlik bulunamadı", + "error": "Hata", + "alarm": "Alarm", + "event-time": "Etkinlik zamanı", + "server": "Sunucu", + "body": "Body", + "method": "Metod", + "type": "Tür", + "message-id": "Mesaj Kimliği", + "message-type": "Mesaj Türü", + "data-type": "Veri Türü", + "relation-type": "İlişki Türü", + "metadata": "Meta veri", + "data": "Veri", + "event": "Etkinlik", + "status": "Durum", + "success": "Başarılı", + "failed": "Başarısız", + "messages-processed": "İşlenen mesajlar", + "min-messages-processed": "İşlenen minimum mesaj sayısı", + "errors-occurred": "Hatalar oluştu", + "min-errors-occurred": "Minimum hata oluştu", + "min-value": "Minimum değer 0'dır.", + "all-events": "Tümü", + "has-error": "Hata var", + "entity-id": "Öğe Kimliği", + "entity-type": "Öğe Türü" + }, + "extension": { + "extensions": "Uzantılar", + "selected-extensions": "{ count, plural, 1 {1 uzantı} other {# uzantı} } seçildi", + "type": "Tür", + "key": "Anahtar", + "value": "Değer", + "id": "ID", + "extension-id": "Uzantı Kimliği", + "extension-type": "Uzantı Türü", + "transformer-json": "JSON *", + "unique-id-required": "Mevcut uzantı kimliği zaten var.", + "delete": "Uzantıyı sil", + "add": "Uzantı ekle", + "edit": "Uzantıyı düzenle", + "delete-extension-title": "'{{extensionId}}' uzantısını silmek istediğinizden emin misiniz?", + "delete-extension-text": "Dikkatli olun, onaydan sonra uzantı ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "delete-extensions-title": "{ count, plural, 1 {1 uzantıyı} other {# uzantıyı} } silmek istediğinizden emin misiniz?", + "delete-extensions-text": "Dikkatli olun, onaydan sonra seçilen tüm uzantılar kaldırılacaktır.", + "converters": "Çeviriciler", + "converter-id": "Çevirici ID", + "configuration": "Yapılandırma", + "converter-configurations": "Çevirici Yapılandırmaları", + "token": "Güvenlik tokeni", + "add-converter": "Çevirici ekle", + "add-config": "Çevirici yapılandırması ekle", + "device-name-expression": "Cihaz adı modeli", + "device-type-expression": "Cihaz türü modeli", + "custom": "Özel", + "to-double": "Double'a çevir", + "transformer": "Dönüştürücü", + "json-required": "Dönüştürücü json gerekli.", + "json-parse": "Dönüştürücü json'u ayrıştırılamıyor.", + "attributes": "Öznitelikler", + "add-attribute": "Öznitelik ekle", + "add-map": "Eşleme elemanı ekle", + "timeseries": "Zaman serisi", + "add-timeseries": "Zaman serisi ekle", + "field-required": "Alan gerekli", + "brokers": "Brokerlar", + "add-broker": "Broker ekle", + "host": "Host", + "port": "Port", + "port-range": "Port 1 ila 65535 aralığında olmalıdır.", + "ssl": "SSL", + "credentials": "Kimlik Bilgileri", + "username": "Kullanıcı Adı", + "password": "Şifre", + "retry-interval": "Milisaniye cinsinden yeniden deneme aralığı", + "anonymous": "Anonim", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "CA sertifika dosyası *", + "private-key": "Özel anahtar dosyası *", + "cert": "Sertifika dosyası *", + "no-file": "Dosya seçilmedi.", + "drop-file": "Bir dosya bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "mapping": "Eşleme", + "topic-filter": "Konu filtresi", + "converter-type": "Çevirici Türü", + "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", + "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-tcp-reconnect": "Automatically reconnect", + "modbus-rtu-over-tcp": "RTU over TCP", + "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", + "sync": { + "status": "Status", + "sync": "Sync", + "not-sync": "Not sync", + "last-sync-time": "Last sync time", + "not-available": "Not available" + }, + "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" + }, + "filter": { + "add": "Filtre ekle", + "edit": "Filtre düzenle", + "name": "Filtre ismi", + "name-required": "Filtre ismi gerekli.", + "duplicate-filter": "Aynı ada sahip filtre zaten mevcut.", + "filters": "Filtreler", + "unable-delete-filter-title": "Filtre silinemiyor", + "unable-delete-filter-text": "'{{filter}}' filtresi, şu gösterge(ler) tarafından kullanıldığı için silinemez:
    {{widgetsList}}", + "duplicate-filter-error": "Yinelenen filtre \"{{filter}}\" bulundu.
    Filtreler, gösterge panelinde benzersiz olmalıdır.", + "missing-key-filters-error": "\"{{filter}}\" filtresi için anahtar filtreler eksik.", + "filter": "Filtre", + "editable": "Düzenlenebilir", + "no-filters-found": "Filtre bulunamadı.", + "no-filter-text": "Filtre belirtilmedi", + "add-filter-prompt": "Lütfen filtre ekleyin", + "no-filter-matching": "'{{filter}}' bulunamadı.", + "create-new-filter": "Yeni bir tane oluştur!", + "filter-required": "Filtre gerekli.", + "operation": { + "operation": "Operasyon", + "equal": "equal", + "not-equal": "not equal", + "starts-with": "starts with", + "ends-with": "ends with", + "contains": "contains", + "not-contains": "not contains", + "greater": "greater than", + "less": "less than", + "greater-or-equal": "greater or equal", + "less-or-equal": "less or equal", + "and": "and", + "or": "or" + }, + "ignore-case": "ignore case", + "value": "Değer", + "remove-filter": "Filtreyi kaldır", + "preview": "Filtre önizlemesi", + "no-filters": "Yapılandırılmış filtre yok", + "add-filter": "Filtre ekle", + "add-complex-filter": "Karmaşık filtre ekle", + "add-complex": "Kompleks ekle", + "complex-filter": "Karmaşık filtre", + "edit-complex-filter": "Karmaşık filtreyi düzenle", + "edit-filter-user-params": "Filtre belirteci kullanıcı parametrelerini düzenle", + "filter-user-params": "Filtre belirteci kullanıcı parametreleri", + "user-parameters": "Kullanıcı parametreleri", + "display-label": "Görüntülenecek etiket", + "autogenerated-label": "Otomatik etiket oluştur", + "order-priority": "Alan sırası önceliği", + "key-filter": "Anahtar filtresi", + "key-filters": "Anahtar filtreleri", + "key-name": "Anahtar adı", + "key-name-required": "Anahtar adı gerekli.", + "key-type": { + "key-type": "Anahtar türü", + "attribute": "Öznitelik", + "timeseries": "Zaman serisi", + "entity-field": "Öğe alanı", + "constant": "Sabit" + }, + "value-type": { + "value-type": "Değer türü", + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "date-time": "Datetime" + }, + "value-type-required": "Anahtar değer türü gerekli.", + "key-value-type-change-title": "Anahtar değer türünü değiştirmek istediğinizden emin misiniz?", + "key-value-type-change-message": "Yeni değer türünü onaylarsanız, girilen tüm anahtar filtreler kaldırılacaktır.", + "no-key-filters": "Yapılandırılmış anahtar filtre yok", + "add-key-filter": "Anahtar filtre ekle", + "remove-key-filter": "Anahtar filtreyi kaldır", + "edit-key-filter": "Anahtar filtresini düzenle", + "date": "Tarih", + "time": "Saat", + "current-tenant": "Aktif tenant", + "current-customer": "Aktif kullanıcı grubu", + "current-user": "Aktif kullanıcı", + "current-device": "Aktif cihaz", + "default-value": "Varsayılan değer", + "dynamic-source-type": "Dinamik kaynak türü", + "no-dynamic-value": "Dinamik değer yok", + "source-attribute": "Kaynak özniteliği", + "switch-to-dynamic-value": "Dinamik değere geç", + "switch-to-default-value": "Varsayılan değere geç", + "inherit-owner": "Sahibinden devral", + "source-attribute-not-set": "Kaynak özniteliği ayarlanmamışsa" + }, + "fullscreen": { + "expand": "Tam ekran yap", + "exit": "Tam ekrandan çık", + "toggle": "Tam ekran modu aç/kapat", + "fullscreen": "Tam ekran" + }, + "function": { + "function": "Fonksiyon" + }, + "gateway": { + "add-entry": "Yapılandırma ekle", + "connector-add": "Yeni bağlayıcı ekle", + "connector-enabled": "Bağlayıcıyı etkinleştir", + "connector-name": "Bağlayıcı adı", + "connector-name-required": "Bağlayıcı adı gerekli.", + "connector-type": "Bağlayıcı tipi", + "connector-type-required": "Bağlayıcı türü gerekli.", + "connectors": "Bağlayıcıların yapılandırması", + "create-new-gateway": "Yeni bir ağ geçidi oluştur", + "create-new-gateway-text": "'{{gatewayName}}' adında yeni bir ağ geçidi oluşturmak istediğinizden emin misiniz?", + "delete": "Yapılandırmayı sil", + "download-tip": "Yapılandırma dosyasını indirin", + "gateway": "Ağ geçidi", + "gateway-exists": "Aynı ada sahip cihaz zaten var.", + "gateway-name": "Ağ geçidi adı", + "gateway-name-required": "Ağ geçidi adı gerekli.", + "gateway-saved": "Ağ geçidi yapılandırması başarıyla kaydedildi.", + "json-parse": "Geçerli bir JSON değil.", + "json-required": "Alan boş olamaz.", + "no-connectors": "Bağlayıcı yok", + "no-data": "Yapılandırma yok", + "no-gateway-found": "Ağ geçidi bulunamadı.", + "no-gateway-matching": " '{{item}}' bulunamadı.", + "path-logs": "Log dosyaları yolu", + "path-logs-required": "Log dosyaları dizini gerekli.", + "remote": "Uzaktan yapılandırma", + "remote-logging-level": "Loglama seviyesi", + "remove-entry": "Yapılandırmayı kaldır", + "save-tip": "Yapılandırma dosyasını kaydet", + "security-type": "Güvenlik türü", + "security-types": { + "access-token": "Access Token", + "tls": "TLS" + }, + "storage": "Depolama", + "storage-max-file-records": "Dosyadaki maksimum kayıt", + "storage-max-files": "Maksimum dosya sayısı", + "storage-max-files-min": "Minimum sayı 1'dir.", + "storage-max-files-pattern": "Sayı geçerli değil.", + "storage-max-files-required": "Sayı gerekli.", + "storage-max-records": "Depodaki maksimum kayıt", + "storage-max-records-min": "Minimum kayıt sayısı 1'dir.", + "storage-max-records-pattern": "Sayı geçerli değil.", + "storage-max-records-required": "Maksimum kayıt gerekli.", + "storage-pack-size": "Maksimum etkinlik paketi boyutu", + "storage-pack-size-min": "Minimum sayı 1'dir.", + "storage-pack-size-pattern": "Sayı geçerli değil.", + "storage-pack-size-required": "Maksimum etkinlik paketi boyutu gerekli.", + "storage-path": "Depolama yolu", + "storage-path-required": "Depolama yolu gerekli.", + "storage-type": "Depolama türü", + "storage-types": { + "file-storage": "Dosya depolama", + "memory-storage": "Bellek depolama" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard host", + "thingsboard-host-required": "Host gerekli.", + "thingsboard-port": "ThingsBoard port", + "thingsboard-port-max": "Maksimum port numarası 65535.", + "thingsboard-port-min": "Minimum port numarası 1'dir.", + "thingsboard-port-pattern": "Port geçerli değil.", + "thingsboard-port-required": "Port gerekli.", + "tidy": "Tidy", + "tidy-tip": "Tidy config JSON", + "title-connectors-json": "Connector {{typeName}} configuration", + "tls-path-ca-certificate": "Path to CA certificate on gateway", + "tls-path-client-certificate": "Path to client certificate on gateway", + "tls-path-private-key": "Path to private key on gateway", + "toggle-fullscreen": "Toggle fullscreen", + "transformer-json-config": "Configuration JSON*", + "update-config": "Add/update configuration JSON" + }, + "grid": { + "delete-item-title": "Bu öğeyi silmek istediğinizden emin misiniz?", + "delete-item-text": "Dikkatli olun, onaylandıktan sonra bu öğe ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "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": "Dikkatli olun, onaydan sonra seçilen tüm öğeler kaldırılacak ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "add-item-text": "Yeni öğe ekle", + "no-items-text": "Hiç bir öğe bulunamadı", + "item-details": "Ürün ayrıntıları", + "delete-item": "Öğeyi sil", + "delete-items": "Öğeleri sil", + "scroll-to-top": "Yukarı 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.", + "drop-file-csv": "Bir CSV dosyası bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "column-value": "Değer", + "column-title": "Başlık", + "column-example": "Örnek değer verileri", + "column-key": "Öznitelik/telemetri anahtarı", + "csv-delimiter": "CSV sınırlayıcı", + "csv-first-line-header": "İlk satır sütun adlarını içerir", + "csv-update-data": "Öznitelikleri/telemetriyi güncelle", + "import-csv-number-columns-error": "Bir dosya en az iki sütun içermelidir", + "import-csv-invalid-format-error": "Geçersiz dosya formatı. Satır: '{{line}}'", + "column-type": { + "name": "İsim", + "type": "Tür", + "label": "Etiket", + "column-type": "Sütun türü", + "client-attribute": "İstemci öznitelik", + "shared-attribute": "Paylaşılan öznitelik", + "server-attribute": "Sunucu öznitelik", + "timeseries": "Zaman serisi", + "entity-field": "Öğe alanı", + "access-token": "Access token", + "isgateway": "Ağ Geçidi", + "activity-time-from-gateway-device": "Ağ geçidi cihazından etkinlik süresi", + "description": "Açıklama", + "routing-key": "Uç Anahtarı", + "secret": "Uç Secret" + }, + "stepper-text": { + "select-file": "Bir dosya seçin", + "configuration": "Yapılandırmayı içe aktar", + "column-type": "Sütun türünü seçin", + "creat-entities": "Yeni varlıklar oluşturma" + }, + "message": { + "create-entities": "{{count}} yeni öğe başarıyla oluşturuldu.", + "update-entities": "{{count}} öğe başarıyla güncellendi.", + "error-entities": "{{count}} öğe oluşturulurken bir hata oluştu." + } + }, + "item": { + "selected": "Seçildi" + }, + "js-func": { + "no-return-error": "Fonksiyon bir değer döndürmelidir!", + "return-type-mismatch": "Fonksiyon, '{{type}}' türünde bir değer döndürmelidir!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Anahtar", + "value": "Değer", + "remove-entry": "Kaydı kaldır", + "add-entry": "Kayıt ekle", + "no-data": "Kayıt 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": { + "direction": "Lejant yönü", + "position": "Lejant konumu", + "sort-legend": "Veri anahtarlarını lejantta sıralayın", + "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": "max", + "avg": "ort", + "total": "toplam", + "comparison-time-ago": { + "previousInterval": "(önceki aralık)", + "days": "(gün önce)", + "weeks": "(hafta önce)", + "months": "(ay önce)", + "years": "(yıl önce)" + } + }, + "login": { + "login": "Giriş Yap", + "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", + "expired-password-reset-message": "Kimlik bilgilerinizin süresi doldu! Lütfen yeni şifre oluşturun.", + "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", + "login-with": "{{name}} ile Giriş Yap", + "or": "ya da", + "error": "Giriş hatası" + }, + "ota-update": { + "add": "Paket ekle", + "assign-firmware": "Atanan donanım yazılımı (Firmware)", + "assign-firmware-required": "Atanan donanım yazılımı gerekli", + "assign-software": "Atanan yazılım", + "assign-software-required": "Atanan yazılım gerekli (Software)", + "auto-generate-checksum": "Otomatik checksum oluştur", + "checksum": "Checksum", + "checksum-hint": "Checksum boşsa, otomatik olarak oluşturulur", + "checksum-algorithm": "Checksum algoritması", + "checksum-copied-message": "Paket checksum panoya kopyalandı", + "change-firmware": "Firmware değişikliği { count, plural, 1 {1 cihazın} other {# cihazın} } güncellenmesine neden olabilir.", + "change-software": "Software değişikliği { count, plural, 1 {1 cihazın} other {# cihazın} }.", + "chose-compatible-device-profile": "Yüklenen paket yalnızca seçilen profile sahip cihazlar için geçerli olacaktır.", + "chose-firmware-distributed-device": "Cihazlara dağıtılacak firmware'i seçin", + "chose-software-distributed-device": "Cihazlara dağıtılacak software'i seçin", + "content-type": "İçerik türü", + "copy-checksum": "Checksum kopyala", + "copy-direct-url": "Açık URL'yi kopyala", + "copyId": "Paket kimliğini kopyala", + "copied": "Kopyalandı!", + "delete": "Paketi sil", + "delete-ota-update-text": "Dikkatli olun, onaydan sonra OTA güncellemesi kurtarılamaz hale gelecektir.", + "delete-ota-update-title": "'{{title}}' OTA güncellemesini silmek istediğinizden emin misiniz?", + "delete-ota-updates-text": "Dikkatli olun, onaydan sonra seçilen tüm OTA güncellemeleri kaldırılacaktır.", + "delete-ota-updates-title": "{ count, plural, 1 {1 OTA güncellemesini} other {# OTA güncellemesini} } silmek istediğinizden emin misiniz?", + "description": "Açıklama", + "direct-url": "Açık URL", + "direct-url-copied-message": "Paket açık URL'si panoya kopyalandı", + "direct-url-required": "Açık URL gerekli", + "download": "Paketi indir", + "drop-file": "Bir paket dosyası bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "file-name": "Dosya adı", + "file-size": "Dosya boyutu", + "file-size-bytes": "Bayt (byte) cinsinden dosya boyutu", + "idCopiedMessage": "Paket kimliği panoya kopyalandı", + "no-firmware-matching": "'{{entity}}' ile eşleşen uyumlu Ürün Yazılımı OTA Güncelleme paketi bulunamadı.", + "no-firmware-text": "Uyumlu Donanım Yazılımı OTA Güncelleme paketi sağlanmadı.", + "no-packages-text": "Paket bulunamadı", + "no-software-matching": "'{{entity}}' ile eşleşen uyumlu Yazılım OTA Güncelleme paketi bulunamadı.", + "no-software-text": "Uyumlu Yazılım OTA Güncelleme paketi sağlanmadı.", + "ota-update": "OTA güncellemesi", + "ota-update-details": "OTA güncelleme ayrıntıları", + "ota-updates": "OTA güncellemeleri", + "package-type": "Paket Tipi", + "packages-repository": "Paket deposu", + "search": "Paketleri ara", + "selected-package": "{ count, plural, 1 {1 paket} other {# paket} } seçildi", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Binary dosya yükle", + "use-external-url": "Harici URL kullan", + "version": "Sürüm", + "version-required": "Sürüm gerekli.", + "version-tag": "Sürüm Etiketi", + "version-tag-hint": "Özel etiket, cihazınız tarafından bildirilen paket sürümüyle eşleşmelidir.", + "warning-after-save-no-edit": "Paket yüklendikten sonra başlığı, sürümü, cihaz profilini ve paket türünü değiştiremezsiniz.." + }, + "position": { + "top": "Üst", + "bottom": "Alt", + "left": "Sol", + "right": "Sağ" + }, + "profile": { + "profile": "Profil", + "last-login-time": "Son giriş tarihi", + "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.", + "no-relations-text": "İlişki bulunamadı" + }, + "resource": { + "add": "Kaynak Ekle", + "copyId": "Kaynak kimliğini kopyala", + "delete": "Kaynağı sil", + "delete-resource-text": "Dikkatli olun, onaydan sonra kaynak kurtarılamaz hale gelecektir..", + "delete-resource-title": "'{{resourceTitle}}' kaynağını silmek istediğinizden emin misiniz?", + "delete-resources-action-title": "{ count, plural, 1 {1 kaynağı} other {# kaynağı} } sil", + "delete-resources-text": "Lütfen seçilen kaynakların cihaz profillerinde kullanılsalar bile silineceğini unutmayın.", + "delete-resources-title": "{ count, plural, 1 {1 kaynağı} other {# kaynağı} } silmek istediğinizden emin misiniz?", + "download": "Kaynağı indir", + "drop-file": "Bir kaynak dosyası bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "empty": "Kaynak boş", + "file-name": "Dosya adı", + "idCopiedMessage": "Kaynak Kimliği panoya kopyalandı", + "no-resource-matching": "'{{widgetsBundle}}' ile eşleşen kaynak bulunamadı.", + "no-resource-text": "Kaynak bulunamadı", + "open-widgets-bundle": "Widget paketini aç", + "resource": "Kaynak", + "resource-library-details": "Kaynak ayrıntıları", + "resource-type": "Kaynak türü", + "resources-library": "Kaynak kütüphanesi", + "search": "Kaynak ara", + "selected-resources": "{ count, plural, 1 {1 kaynak} other {# kaynak} } seçildi", + "system": "Sistem", + "title": "Başlık", + "title-required": "Başlık gerekli." + }, + "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", + "search": "Kural Ara", + "selected-rulechains": "{ count, plural, 1 {1 kural} other {# kural} } seçildi", + "open-rulechain": "Kuralı Aç", + "assign-new-rulechain": "Yeni kural zinciri atayın", + "edge-template-root": "Şablon Kökü", + "assign-to-edge": "Uca Ata", + "edge-rulechain": "Uç kuralı zinciri", + "unassign-rulechain-from-edge-text": "Onaydan sonra kural zincirinin ataması kaldırılacak ve kenar tarafından erişilebilir olmayacak.", + "unassign-rulechains-from-edge-title": "{ count, plural, 1 {1 kural zincirinin} other {# kural zincirinin} } atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-rulechains-from-edge-text": "Onaydan sonra, seçilen tüm kural zincirlerinin ataması kaldırılacak ve uç tarafından erişilemeyecek.", + "assign-rulechain-to-edge-title": "Uca Kural Zinciri/Zincirleri Ata", + "assign-rulechain-to-edge-text": "Lütfen uca atanacak kural zincirlerini seçin", + "set-edge-template-root-rulechain": "Kural zincirini uç kök şablonu yap", + "set-edge-template-root-rulechain-title": "'{{ruleChainName}}' kural zincirini uç kök şablonu yapmak istediğinizden emin misiniz?", + "set-edge-template-root-rulechain-text": "Onaydan sonra kural zinciri, uç kök şablonu olacak ve yeni oluşturulan uçlar için kök kural zinciri olacaktır.", + "invalid-rulechain-type-error": "Kural zinciri içe aktarılamıyor: Geçersiz kural zinciri türü. Beklenen tür {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Oluşturma sırasında uçlara kural zinciri atayın", + "set-auto-assign-to-edge-title": "Oluşturma sırasında uçlara '{{ruleChainName}}' uç kural zincirini atamak istediğinizden emin misiniz?", + "set-auto-assign-to-edge-text": "Onaydan sonra, uç kuralı zinciri, oluşturma sırasında uç(lar)a otomatik olarak atanacaktır.", + "unset-auto-assign-to-edge": "Oluşturma sırasında uç(lar)a kural zinciri atama", + "unset-auto-assign-to-edge-title": "'{{ruleChainName}}' uç kural zincirini oluşturma sırasında uçlara atamak istemediğinizden emin misiniz?", + "unset-auto-assign-to-edge-text": "Onaydan sonra, kenar kuralı zinciri artık oluşturma sırasında uç(lar)a otomatik olarak atanmayacaktır.", + "unassign-rulechain-title": "'{{ruleChainName}}' kural zincirinin atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-rulechains": "Kural zincirlerinin atamasını kaldır" + }, + "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", + "reset-debug-mode": "Tüm düğümlerde hata ayıklama modunu sıfırla" + }, + "timezone": { + "timezone": "Saat dilimi", + "select-timezone": "Saat dilimini seçin", + "no-timezones-matching": "'{{timezone}}' ile eşleşen saat dilimi bulunamadı.", + "timezone-required": "Saat dilimi gerekli.", + "browser-time": "Tarayıcı Süresi" + }, + "queue": { + "select_name": "Kuyruk adını seçin", + "name": "Kuyruk Adı", + "name_required": "Kuyruk Adı gerekli" + }, + "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", + "search": "Tenantları ara", + "selected-tenants": "{ count, plural, 1 {1 tenant} other {# tenant} } seçildi", + "isolated-tb-rule-engine": "ThingsBoard soyutlanmış kural yönetimi konteynerda işlensin", + "isolated-tb-rule-engine-details": "Her soyutlanmış tenant ayrı bir mikro servis gerektirir" + }, + "tenant-profile": { + "tenant-profile": "Tenant profili", + "tenant-profiles": "Tenant profilleri", + "add": "Tenant profili ekle", + "edit": "Tenant profili düzenle", + "tenant-profile-details": "Tenant profili ayrıntıları", + "no-tenant-profiles-text": "Tenant profili bulunamadı", + "search": "Tenant profillerini ara", + "selected-tenant-profiles": "{ count, plural, 1 {1 tenant profili} other {# tenant profili} } seçildi", + "no-tenant-profiles-matching": "'{{entity}}' ile eşleşen tenant profili bulunamadı.", + "tenant-profile-required": "Tenant profili gerekli", + "idCopiedMessage": "Tenant profili kimliği panoya kopyalandı", + "set-default": "Tenant profilini varsayılan yap", + "delete": "Tenant profilini sil", + "copyId": "Tenant profili kimliğini kopyala", + "name": "İsim", + "name-required": "İsim gerekli.", + "data": "Profil verisi", + "profile-configuration": "Profil yapılandırması", + "description": "Açıklama", + "default": "Varsayılan", + "delete-tenant-profile-title": "'{{tenantProfileName}}' tenant profilini silmek istediğinizden emin misiniz?", + "delete-tenant-profile-text": "Dikkatli olun, onaydan sonra tenant profili ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "delete-tenant-profiles-title": "{ count, plural, 1 {1 tenant profilini} other {# tenant profilini} } silmek istediğinizden emin misiniz?", + "delete-tenant-profiles-text": "Dikkatli olun, onaydan sonra seçilen tüm tenant profilleri kaldırılacak ve ilgili tüm veriler kurtarılamaz hale gelecektir.", + "set-default-tenant-profile-title": "Tenant profilini '{{tenantProfileName}}' varsayılan yapmak istediğinizden emin misiniz?", + "set-default-tenant-profile-text": "Onaydan sonra tenant profili varsayılan olarak işaretlenecek ve profili belirtilmemiş yeni tenantlar için kullanılacaktır.", + "no-tenant-profiles-found": "Tenant profili bulunamadı.", + "create-new-tenant-profile": "Yeni bir tane oluştur!", + "create-tenant-profile": "Yeni tenant profili oluştur", + "import": "Tenant profilini içe aktar", + "export": "Tenant profilini dışa aktar", + "export-failed-error": "Tenant profili dışa aktarılamıyor: {{error}}", + "tenant-profile-file": "Tenant profil dosyası", + "invalid-tenant-profile-file-error": "Tenant profili içe aktarılamıyor: Geçersiz tenant profili veri yapısı.", + "maximum-devices": "Maksimum cihaz sayısı (0 - sınırsız)", + "maximum-devices-required": "Maksimum cihaz sayısı gerekli.", + "maximum-devices-range": "Minimum cihaz sayısı negatif olamaz", + "maximum-assets": "Maksimum varlık sayısı (0 - sınırsız)", + "maximum-assets-required": "Maksimum varlık sayısı gerekli.", + "maximum-assets-range": "Maksimum varlık sayısı negatif olamaz", + "maximum-customers": "Maksimum kullanıcı grubu sayısı (0 - sınırsız)", + "maximum-customers-required": "Maksimum kullanıcı grubu sayısı gerekli.", + "maximum-customers-range": "Maksimum kullanıcı grubu sayısı negatif olamaz", + "maximum-users": "Maksimum kullanıcı sayısı (0 - sınırsız)", + "maximum-users-required": "Maksimum kullanıcı sayısı gerekli.", + "maximum-users-range": "Maksimum kullanıcı sayısı negatif olamaz", + "maximum-dashboards": "Maksimum gösterge paneli sayısı (0 - sınırsız)", + "maximum-dashboards-required": "Maksimum gösterge paneli sayısı gerekli.", + "maximum-dashboards-range": "Maksimum gösterge paneli sayısı negatif olamaz", + "maximum-rule-chains": "Maksimum kural zinciri sayısı (0 - sınırsız)", + "maximum-rule-chains-required": "Maksimum kural zinciri sayısı gerekli.", + "maximum-rule-chains-range": "Maksimum kural zinciri sayısı negatif olamaz", + "maximum-resources-sum-data-size": "Bayt cinsinden kaynak dosyalarının maksimum toplamı (0 - sınırsız)", + "maximum-resources-sum-data-size-required": "Kaynak dosyaları boyutunun maksimum toplamı gerekli.", + "maximum-resources-sum-data-size-range": "Kaynak dosyaları boyutunun maksimum toplamı negatif olamaz", + "maximum-ota-packages-sum-data-size": "Ota paketi dosyalarının bayt cinsinden maksimum toplamı (0 - sınırsız)", + "maximum-ota-package-sum-data-size-required": "Ota paketi dosyalarının maksimum toplamı gerekli.", + "maximum-ota-package-sum-data-size-range": "Ota paketi dosyalarının maksimum toplamı negatif olamaz", + "transport-tenant-msg-rate-limit": "Taşıma tenant mesajları hız sınırı.", + "transport-tenant-telemetry-msg-rate-limit": "Taşıma tenant telemetri iletileri hız sınırı.", + "transport-tenant-telemetry-data-points-rate-limit": "Taşıma tenant telemetri veri noktaları hız sınırı.", + "transport-device-msg-rate-limit": "Taşıma cihazı mesajları hız sınırı.", + "transport-device-telemetry-msg-rate-limit": "Taşıma cihazı telemetri mesajları hız sınırı.", + "transport-device-telemetry-data-points-rate-limit": "Taşıma cihazı telemetri veri noktaları hız sınırı.", + "max-transport-messages": "Maksimum taşıma mesajı sayısı (0 - sınırsız)", + "max-transport-messages-required": "Maksimum taşıma mesajı sayısı gerekli.", + "max-transport-messages-range": "Maksimum taşıma mesajı sayısı negatif olamaz", + "max-transport-data-points": "Maksimum taşıma veri noktası sayısı (0 - sınırsız)", + "max-transport-data-points-required": "Maksimum taşıma veri noktası sayısı gerekli.", + "max-transport-data-points-range": "Maksimum taşıma veri noktası sayısı negatif olamaz", + "max-r-e-executions": "Maksimum Kural Motoru yürütme sayısı (0 - sınırsız)", + "max-r-e-executions-required": "Maksimum Kural Motoru yürütme sayısı gerekli.", + "max-r-e-executions-range": "Maksimum Kural Motoru yürütme sayısı negatif olamaz", + "max-j-s-executions": "Maksimum JavaScript yürütme sayısı (0 - sınırsız)", + "max-j-s-executions-required": "Maksimum JavaScript yürütme sayısı gerekli.", + "max-j-s-executions-range": "Maksimum JavaScript yürütme sayısı negatif olamaz", + "max-d-p-storage-days": "Maksimum veri noktası depolama günü sayısı (0 - sınırsız)", + "max-d-p-storage-days-required": "Maksimum veri noktası depolama günü sayısı gerekli.", + "max-d-p-storage-days-range": "Maksimum veri noktası depolama günü sayısı negatif olamaz", + "default-storage-ttl-days": "Varsayılan depolama TTL günleri (0 - sınırsız)", + "default-storage-ttl-days-required": "Varsayılan depolama TTL günleri gerekli.", + "default-storage-ttl-days-range": "Varsayılan depolama TTL günleri negatif olamaz", + "alarms-ttl-days": "Alarm TTL günleri (0 - sınırsız)", + "alarms-ttl-days-required": "Alarm TTL günleri gerekli", + "alarms-ttl-days-days-range": "Alarm TTL günleri negatif olamaz", + "rpc-ttl-days": "RPC TTL günleri (0 - sınırsız)", + "rpc-ttl-days-required": "RPC TTL günleri gerekli", + "rpc-ttl-days-days-range": "RPC TTL günleri negatif olamaz", + "max-rule-node-executions-per-message": "Mesaj başına maksimum kural düğümü yürütme sayısı (0 - sınırsız)", + "max-rule-node-executions-per-message-required": "İleti başına maksimum kural düğümü yürütme sayısı gerekli.", + "max-rule-node-executions-per-message-range": "İleti başına maksimum kural düğümü yürütme sayısı negatif olamaz", + "max-emails": "Gönderilen maksimum e-posta sayısı (0 - sınırsız)", + "max-emails-required": "Gönderilen maksimum e-posta sayısı gerekli.", + "max-emails-range": "Gönderilen maksimum e-posta sayısı negatif olamaz", + "max-sms": "Gönderilen maksimum SMS sayısı (0 - sınırsız)", + "max-sms-required": "Gönderilen maksimum SMS sayısı gerekli.", + "max-sms-range": "Gönderilen maksimum SMS sayısı negatif olamaz", + "max-created-alarms": "Oluşturulan maksimum alarm sayısı (0 - sınırsız)", + "max-created-alarms-required": "Oluşturulan maksimum alarm sayısı gerekli.", + "max-created-alarms-range": "Oluşturulan maksimum alarm sayısı negatif olamaz" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 saniye} other {# saniye} }", + "minutes-interval": "{ minutes, plural, 1 {1 dakika} other {# dakika} }", + "hours-interval": "{ hours, plural, 1 {1 saat} other {# saat} }", + "days-interval": "{ days, plural, 1 {1 gün} other {# gün} }", + "days": "Gün", + "hours": "Saat", + "minutes": "Dakika", + "seconds": "Saniye", + "advanced": "İleri düzey", + "predefined": { + "yesterday": "Dün", + "day-before-yesterday": "Dünden önceki gün", + "this-day-last-week": "Geçen hafta bugün", + "previous-week": "Önceki hafta (Paz - Cmt)", + "previous-week-iso": "Önceki hafta (Pzt - Paz)", + "previous-month": "Geçen ay", + "previous-year": "Geçen yıl", + "current-hour": "Mevcut saat", + "current-day": "Bugün", + "current-day-so-far": "Şimdiye kadarki gün", + "current-week": "Bu hafta (Paz - Cmt)", + "current-week-iso": "Bu hafta (Pzt - Paz)", + "current-week-so-far": "Şu ana kadarki hafta (Paz - Cmt)", + "current-week-iso-so-far": "Şu ana kadarki hafta (Pzt - Paz)", + "current-month": "Bu ay", + "current-month-so-far": "Şimdiye kadarki ay", + "current-year": "Bu yıl", + "current-year-so-far": "Şu ana kadarki yıl" + } + }, + "timeunit": { + "milliseconds": "Milisaniye", + "seconds": "Saniye", + "minutes": "Dakika", + "hours": "Saat", + "days": "Gün" + }, + "timewindow": { + "days": "{ days, plural, 1 { gün } other {# gün } }", + "hours": "{ hours, plural, 0 { saat } 1 {1 saat } other {# saat } }", + "minutes": "{ minutes, plural, 0 { dakika } 1 {1 dakika } other {# dakika } }", + "seconds": "{ seconds, plural, 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", + "hide": "Gizle", + "interval": "Aralık" + }, + "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 bağlantıyı 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", + "search": "Kullanıcı ara", + "selected-users": "{ count, plural, 1 {1 kullanıcı} other {# kullanıcı} } seçildi", + "disable-account": "Kullanıcı Hesabını Devre Dışı Bırak", + "enable-account": "Kullanıcı Hesabını Etkinleştir", + "enable-account-message": "Kullanıcı hesabı başarıyla etkinleştirildi!", + "disable-account-message": "Kullanıcı hesabı başarıyla devre dışı bırakıldı!" + }, + "value": { + "type": "Değer türü", + "string": "String", + "string-value": "String değeri", + "string-value-required": "String değeri gerekli", + "integer": "Integer", + "integer-value": "Integer değeri", + "integer-value-required": "Integer değeri gerekli", + "invalid-integer-value": "Geçersiz integer değeri", + "double": "Double", + "double-value": "Double değeri", + "double-value-required": "Double değeri gerekli", + "boolean": "Boolean", + "boolean-value": "Boolean değeri", + "false": "False", + "true": "True", + "long": "Long", + "json": "JSON", + "json-value": "JSON değeri", + "json-value-invalid": "JSON formatı geçersiz", + "json-value-required": "JSON değeri gerekli." + }, + "widget": { + "widget-library": "Gösterge Kütüphanesi", + "widget-bundle": "Gösterge Paketi", + "select-widgets-bundle": "Gösterge paketi seç", + "management": "Gösterge yönetimi", + "editor": "Gösterge düzenleyici", + "widget-type-not-found": "Gösterge yapılandırması yüklenemedi.
    Muhtemelen ilgili\n gösterge türü kaldırılmış.", + "widget-type-load-error": "Gösterge şu sebeplerden dolayı yüklenemedi:", + "remove": "Göstergeyi kaldır", + "edit": "Göstergeyi düzenle", + "remove-widget-title": "'{{widgetTitle}}' isimli göstermeyi kaldırmak istediğinizden emin misiniz?", + "remove-widget-text": "UYARI: Onaylandıktan sonra gösterge ve tüm ilişkili verileri geri yüklenemez şekilde silinecek.", + "timeseries": "Zaman serisi", + "search-data": "Arama verileri", + "no-data-found": "Veri bulunamadı", + "latest": "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 paketi 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": "Tidy", + "css": "CSS", + "settings-schema": "Ayarlar şeması", + "datakey-settings-schema": "Veri anahtarı ayarları şeması", + "widget-settings": "Gösterge Ayarları", + "description": "Açıklama", + "image-preview": "Resim Önizleme", + "javascript": "Javascript", + "js": "JS", + "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", + "no-data": "Göstergede görüntülenecek veri yok", + "data-overflow": "Gösterge, {{total}} öğeden {{count}} tanesini gösteriyor", + "alarm-data-overflow": "Gösterge, {{totalEntities}} öğeden {{allowedEntities}} (izin verilen maksimum) öğe için alarm görüntüler", + "search": "Gösterge ara", + "filter": "Gösterge filtre türü", + "loading-widgets": "Göstergeler yükleniyor..." + }, + "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", + "custom-pretty": "Özel Aksiyon (HTML şablonuyla)", + "mobile-action": "Mobil Aksiyon", + "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)", + "open-in-separate-dialog": "Ayrı iletişim kutusunda aç", + "dialog-title": "iletişim kutusu başlığı", + "dialog-hide-dashboard-toolbar": "İletişim kutusunda gösterge paneli araç çubuğunu gizle", + "dialog-width": "Görüş alanı genişliğine göre yüzde olarak iletişim kutusu genişliği", + "dialog-height": "Görüş alanı yüksekliğine göre yüzde olarak iletişim kutusu yüksekliği", + "dialog-size-range-error": "İletişim kutusu boyutu yüzde değeri 1 ile 100 arasında olmalıdır.", + "open-new-browser-tab": "Yeni bir tarayıcı sekmesinde aç", + "mobile": { + "action-type": "Mobil aksiyon türü", + "action-type-required": "Mobil aksiyon türü gerekli", + "take-picture-from-gallery": "Galeriden resim al", + "take-photo": "Fotoğraf çek", + "map-direction": "Harita yol tarifini aç", + "map-location": "Harita konumunu aç", + "scan-qr-code": "QR Kodunu Tara", + "make-phone-call": "Telefon araması yap", + "get-location": "Telefon konumunu al", + "take-screenshot": "Ekran görüntüsü al" + } + }, + "widgets-bundle": { + "current": "Şimdiki paket", + "widgets-bundles": "Gösterge Paketleri", + "add": "Gösterge Paketi Ekle", + "delete": "Gösterge paketini sil", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "image-preview": "Resim Önizleme", + "add-widgets-bundle-text": "Yeni gösterge paketi ekle", + "no-widgets-bundles-text": "Hiçbir gösterge paketi bulunamadı", + "empty": "Gösterge paketi boş", + "details": "Detaylar", + "widgets-bundle-details": "Gösterge paketi detayları", + "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}' isimli gösterge paketini silmek istediğinize emin misiniz?", + "delete-widgets-bundle-text": "UYARI: Onaylandıktan sonra gösterge paketi ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-widgets-bundles-title": "{ count, plural, 1 {1 gösterge paketini} other {# gösterge paketini} } silmek istediğinize emin misiniz?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 gösterge paketini} other {# gösterge paketini} } sil", + "delete-widgets-bundles-text": "UYARI: Onaylandıktan sonra seçili tüm gösterge paketleri ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "no-widgets-bundles-matching": "'{{widgetsBundle}}' ile eşleşen gösterge paketi bulunamadı.", + "widgets-bundle-required": "Gösterge paketi gerekli.", + "system": "Sistem", + "import": "Gösterge paketini içe aktar", + "export": "Gösterge paketini dışa aktar", + "export-failed-error": "Gösterge paketini dışa aktaramadı: {{error}}", + "create-new-widgets-bundle": "Yeni gösterge paketi oluştur", + "widgets-bundle-file": "Gösterge paketi dosyası", + "invalid-widgets-bundle-file-error": "Gösterge paketi içe aktarılamadı: Geçersiz gösterge paketi veri yapısı.", + "search": "Gösterge paketi ara", + "selected-widgets-bundles": "{ count, plural, 1 {1 gösterge paketi} other {# gösterge paketi} } seçildi", + "open-widgets-bundle": "Gösterge paketlerini aç", + "loading-widgets-bundles": "Gösterge paketleri yükleniyor..." + }, + "widget-config": { + "data": "Veri", + "settings": "Ayarlar", + "advanced": "İleri düzey", + "title": "Başlık", + "title-tooltip": "Başlık İpucu", + "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", + "mobile-hide": "Göstergeyi mobil modda gizle", + "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-timewindow": "Zaman penceresini göster", + "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", + "no-actions-text": "Aksiyon bulunamadı", + "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.
    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?", + "display-icon": "Başlık simgesini görüntüle", + "icon-color": "İkon rengi", + "icon-size": "İkon boyutu" + }, + "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ı." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Paz", + "Mon": "Pzt", + "Tue": "Sal", + "Wed": "Çar", + "Thu": "Per", + "Fri": "Cum", + "Sat": "Cmt", + "Jan": "Oca", + "Feb": "Şub", + "Mar": "Mar", + "Apr": "Nis", + "May": "May", + "Jun": "Haz", + "Jul": "Tem", + "Aug": "Ağu", + "Sep": "Eyl", + "Oct": "Eki", + "Nov": "Kas", + "Dec": "Ara", + "January": "Ocak", + "February": "Şubat", + "March": "Mart", + "April": "Nisan", + "June": "Haziran", + "July": "Temmuz", + "August": "Ağustos", + "September": "Eylül", + "October": "Ekim", + "November": "Kasım", + "December": "Aralık", + "Custom Date Range": "Özel Tarih Aralığı", + "Date Range Template": "Tarih Aralığı Şablonu", + "Today": "Bugün", + "Yesterday": "Dün", + "This Week": "Bu hafta", + "Last Week": "Geçen hafta", + "This Month": "Bu ay", + "Last Month": "Geçen ay", + "Year": "Yıl", + "This Year": "Bu yıl", + "Last Year": "Geçen yıl", + "Date picker": "Tarih seçici", + "Hour": "Saat", + "Day": "Gün", + "Week": "Hafta", + "2 weeks": "2 Hafta", + "Month": "Ay", + "3 months": "3 Ay", + "6 months": "6 Ay", + "Custom interval": "Özel aralık", + "Interval": "Aralık", + "Step size": "Adım boyutu", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Öznitelik parametresi bu göstergede kullanılamaz", + "blocked-location": "Tarayıcınızda coğrafi konum engellendi", + "claim-device": "Talep cihaz", + "claim-failed": "Cihaz talep edilemedi!", + "claim-not-found": "Cihaz bulunamadı!", + "claim-successful": "Cihaz başarıyla talep edildi!", + "date": "Tarih", + "device-name": "Cihaz adı", + "device-name-required": "Cihaz adı gerekli", + "discard-changes": "Değişiklikleri gözardı et", + "entity-attribute-required": "Öğe özniteliği gerekli", + "entity-coordinate-required": "Enlem ve boylam olmak üzere her iki alan da gereklidir", + "entity-timeseries-required": "Öğe zaman serisi gerekli", + "get-location": "Geçerli konumu al", + "invalid-date": "Geçersiz tarih", + "latitude": "Enlem", + "longitude": "Boylam", + "min-value-error": "Minimum değer {{value}}", + "max-value-error": "Maksimum değer {{value}}", + "not-allowed-entity": "Seçili öğe, paylaşılan niteliklere sahip olamaz", + "no-attribute-selected": "Hiçbir özellik seçilmedi", + "no-datakey-selected": "Veri anahtarı seçilmedi", + "no-coordinate-specified": "Enlem/boylam için veri anahtarı belirtilmedi", + "no-entity-selected": "Hiçbir öğe seçilmedi", + "no-image": "Resim yok", + "no-support-geolocation": "Tarayıcınız coğrafi konumu desteklemiyor", + "no-support-web-camera": "Tarayıcınız kameraları desteklemiyor", + "enable-https-use-widget": "Bu göstergeyi kullanmak için lütfen HTTPS'yi etkinleştirin", + "no-found-your-camera": "Kameranız bulunamadı", + "no-permission-camera": "İzin kullanıcı tarafından reddedildi / Bu sitenin kamerayı kullanma izni yok", + "no-timeseries-selected": "Zaman serisi seçilmedi", + "secret-key": "Gizli anahtar", + "secret-key-required": "Gizli anahtar gerekli", + "switch-attribute-value": "Öğe öznitelik değerine geç", + "switch-camera": "Kameraya geç", + "switch-timeseries-value": "Öğe zaman serisi değerine geç", + "take-photo": "Fotoğraf çek", + "time": "Zaman", + "timeseries-not-allowed": "Timeseries parametresi bu göstergede kullanılamaz", + "update-failed": "Güncelleştirme başarısız", + "update-successful": "Güncelleştirme başarılı", + "update-attribute": "Özniteliği güncelle", + "update-timeseries": "Zaman serisini güncelle", + "value": "Değer" + }, + "invalid-qr-code-text": "QR kodu için geçersiz giriş metni. Girdi bir string türüne sahip olmalıdır" + }, + "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", + "polygon-click": "Satır tıklama eylemi", + "marker-click": "Çokgen tıklama eylemi", + "tooltip-tag-action": "İpucu etiket eylemi", + "node-selected": "Düğüm seçme eylemi", + "element-click": "HTML eleman tıklama eylemi", + "pie-slice-click": "Pay/dilim tıklama eylemi", + "row-double-click": "Satır çift tıklama eylemi" + } + }, + "language": { + "language": "Dil" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json new file mode 100644 index 0000000..1522a03 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -0,0 +1,2493 @@ +{ + "access": { + "unauthorized": "Неавторизований", + "unauthorized-access": "Неавторизований доступ", + "unauthorized-access-text": "Щоб отримати доступ до цього ресурсу, потрібно ввійти в систему!", + "access-forbidden": "Доступ заборонено", + "access-forbidden-text": "Недостатньо прав для доступу!
    Спробуйте увійти як інший користувач, якщо ви все ще хочете отримати доступ до цього ресурсу.", + "refresh-token-expired": "Дані про сесію застарілі", + "refresh-token-failed": "Не вдається відновити сеанс" + }, + "action": { + "activate": "Активувати", + "suspend": "Призупинити", + "save": "Зберегти", + "saveAs": "Зберегти як", + "cancel": "Скасувати", + "ok": "OK", + "delete": "Видалити", + "add": "Додати", + "yes": "Так", + "no": "Ні", + "update": "Оновити", + "remove": "Видалити", + "search": "Пошук", + "clear-search": "Очистити пошук", + "assign": "Надати", + "unassign": "Позбавити", + "share": "Поділитися", + "make-private": "Зробити приватним", + "apply": "Застосувати", + "apply-changes": "Застосувати зміни", + "edit-mode": "Режим редагування", + "enter-edit-mode": "Ввійти в режим редагування", + "decline-changes": "Відхилити зміни", + "close": "Закрити", + "back": "Назад", + "run": "Запустити", + "sign-in": "Увійти!", + "edit": "Редагувати", + "view": "Переглянути", + "create": "Створити", + "drag": "Перетягнути", + "refresh": "Оновити", + "undo": "Скасувати", + "copy": "Скопіювати", + "paste": "Вставити", + "copy-reference": "Копіювати посилання", + "paste-reference": "Вставити посилання", + "import": "Імпортувати", + "export": "Експортувати", + "share-via": "Поділитися через {{provider}}", + "continue": "Продовжити", + "discard-changes": "Скасувати зміни", + "move": "Перемістити", + "select": "Вибрати", + "done": "Завершено" + }, + "aggregation": { + "aggregation": "Агрегація", + "function": "Функція агрегації даних", + "limit": "Максимальні значення", + "group-interval": "Інтервал групування", + "min": "Мінімальний", + "max": "Максимальний", + "avg": "Середній", + "sum": "Сума", + "count": "Рахувати", + "none": "Відсутня" + }, + "admin": { + "general": "Загальне", + "general-settings": "Загальні налаштування", + "outgoing-mail": "Поштовий сервер", + "outgoing-mail-settings": "Налаштування сервера вихідної пошти", + "system-settings": "Налаштування системи", + "test-mail-sent": "Тестовий лист успішно відправлено!", + "base-url": "Базова URL-адреса", + "base-url-required": "Базова URL-адреса обов'язкова.", + "mail-from": "Електронна адреса", + "mail-from-required": "Електронна адреса обов'язкова.", + "smtp-protocol": "Протокол SMTP", + "smtp-host": "Хост SMTP", + "smtp-host-required": "Хост SMTP обов'язковий.", + "smtp-port": "SMTP-порт", + "smtp-port-required": "Ви повинні надати SMTP-порт.", + "smtp-port-invalid": "Це не схоже на дійсний SMTP-порт.", + "timeout-msec": "Час очікування (msec)", + "timeout-required": "Необхідно задати час очікування.", + "timeout-invalid": "Це не схоже на правильний час очікування.", + "enable-tls": "Увімкнути TLS", + "tls-version" : "Версія TLS", + "send-test-mail": "Надіслати тестове повідомлення", + "use-system-mail-settings": "Використовувати параметри системного поштового сервера", + "mail-templates": "Шаблони електронної пошти", + "mail-template-settings": "Налаштування шаблонів електронної пошти", + "use-system-mail-template-settings": "Використовувати шаблони системної електронної пошти", + "mail-template": { + "mail-template": "Шаблон електронної пошти", + "test": "Тестове повідомлення", + "activation": "Повідомлення про активацію рахунку", + "account-activated": "Обліковий запис активовано", + "reset-password": "Відновити повідомлення пароля", + "password-was-reset": "Пароль було надіслано повідомленням" + }, + "mail-subject": "Тема повідомлення", + "mail-body": "Вміст повідомлення", + "security-settings": "Налаштування безпеки", + "password-policy": "Політика щодо паролів", + "minimum-password-length": "Мінімальна довжина пароля", + "minimum-password-length-required": "Потрібна мінімальна довжина пароля", + "minimum-password-length-range": "Мінімальна довжина пароля повинна бути в межах від 5 до 50", + "minimum-uppercase-letters": "Мінімальна кількість великих літер", + "minimum-uppercase-letters-range": "Мінімальна кількість великих літер не може бути негативною", + "minimum-lowercase-letters": "Мінімальна кількість малих літер", + "minimum-lowercase-letters-range": "Мінімальна кількість малих літер не може бути негативною", + "minimum-digits": "Мінімальна кількість цифр", + "minimum-digits-range": "Мінімальна кількість цифр не може бути негативною", + "minimum-special-characters": "Мінімальна кількість спеціальних символів", + "minimum-special-characters-range": "Мінімальна кількість спеціальних символів не може бути негативною", + "password-expiration-period-days": "Термін дії пароля в днях", + "password-expiration-period-days-range": "Термін дії пароля в днях не може бути негативним", + "password-reuse-frequency-days": "Частота повторного використання пароля в днях", + "password-reuse-frequency-days-range": "Частота повторного використання пароля в днях не може бути негативною", + "general-policy": "Загальна політика", + "max-failed-login-attempts": "Максимальна кількість невдалих спроб входу, перш ніж обліковий запис заблоковано", + "minimum-max-failed-login-attempts-range": "Максимальна кількість невдалих спроб входу не може бути негативною", + "user-lockout-notification-email": "У разі блокування облікового запису користувача, надішліть сповіщення на електронну пошту", + "smpp-provider": { + "smpp-version": "SMPP верія", + "smpp-host": "SMPP хост", + "smpp-host-required": "Хост SMPP обов'язковий.", + "smpp-port": "SMPP порт", + "smpp-port-required": "Порт SMPP обов'язковий.", + "system-id": "Id системи", + "system-id-required": "Id системи обязателен.", + "password": "Пароль", + "password-required": "Пароль обязателен.", + "type-settings": "Налаштування типів", + "source-settings": "Налаштування джерела", + "destination-settings": "Налаштування призначення", + "additional-settings": "Додаткові налаштування", + "system-type": "Тип системи", + "bind-type": "Тип зв'язування", + "service-type": "Тип обслуговування", + "source-address": "Адреса джерела", + "source-ton": "Тип номера джерела", + "source-npi": "Идентификация плана нумерации джерела", + "destination-ton": "Тип номера призначення", + "destination-npi": "Ідентифікація плану нумерації призначення", + "address-range": "Діапазон адрес", + "coding-scheme": "Схема кодування" + } + }, + "alarm": { + "alarm": "Сигнал тривоги", + "alarms": "Сигнали тривоги", + "select-alarm": "Вибрати сигнал тривоги", + "no-alarms-matching": "Сигналів тривоги '{{entity}}' не знайдено.", + "alarm-required": "Сигнал тривоги необхідний", + "alarm-status": "Статус сигналу тривоги", + "search-status": { + "ANY": "Будь які", + "ACTIVE": "Активні", + "CLEARED": "Неактивні", + "ACK": "Прийняті", + "UNACK": "Неприйняті" + }, + "display-status": { + "ACTIVE_UNACK": "Активні та неприйняті", + "ACTIVE_ACK": "Активні та прийняті", + "CLEARED_UNACK": "Неактивні та неприйняті", + "CLEARED_ACK": "Неактивні та прийняті" + }, + "no-alarms-prompt": "Сигналів тривоги не знайдено", + "created-time": "Час створення", + "type": "Тип", + "severity": "Серйозність", + "originator": "Ініціатор", + "originator-type": "Тип ініціатору", + "details": "Деталі", + "status": "Статус", + "alarm-details": "Деталі сигналу тривоги", + "start-time": "Початок", + "end-time": "Кінець", + "ack-time": "Час прийняття", + "clear-time": "Час деактивації", + "severity-critical": "Критичні", + "severity-major": "Важливі", + "severity-minor": "Неважливі", + "severity-warning": "Попередження", + "severity-indeterminate": "Невизначені", + "acknowledge": "Прийняти", + "clear": "Деактивувати", + "search": "Шукати сигнали тривоги", + "selected-alarms": "{ count, plural, 1 {1 сигнал тривоги} other {# сигнали тривоги} } вибрані", + "no-data": "Немає даних для відображення", + "polling-interval": "Інтервал опитування (сек)", + "polling-interval-required": "Необхідно задати інтервал опитування.", + "min-polling-interval-message": "Дозволяється щонайменше 1 секунда інтервалу очікування.", + "aknowledge-alarms-title": "Підтвердити { count, plural, 1 {1 сигнал тривоги} other {# сигнали тривоги} }", + "aknowledge-alarms-text": "Ви впевнені, що хочете підтвердити { count, plural, 1 {1 сигнал тривоги} other {# сигнали тривоги} }?", + "aknowledge-alarm-title": "Підтвердити сигнал тривоги", + "aknowledge-alarm-text": "Ви впевнені, що хочете підтвердити сигнал тривоги?", + "clear-alarms-title": "Деактивувати { count, plural, 1 {1 сигнал тривоги} other {# сигнали тривоги} }", + "clear-alarms-text": "Ви впевнені, що хочете деактивувати { count, plural, 1 {1 сигнал тривоги} other {# сигнали тривоги} }?", + "clear-alarm-title": "Деактивувати сигнал тривоги", + "clear-alarm-text": "Ви впевнені, що хочете деактивувати сигнал тривоги?", + "alarm-status-filter": "Фільтр статусу сигналу тривоги", + "max-count-load": "Максимальна кількість сигналів тривоги для завантаження (0 - необмежено)", + "max-count-load-required": "Необхідно задати максимальну кількість сигналів тривоги для завантаження.", + "max-count-load-error-min": "Мінімальне значення 0.", + "fetch-size": "Розмір пакету для завантаження", + "fetch-size-required": "Необхідно задати розмір пакету для завантаження.", + "fetch-size-error-min": "Мінімальне значення 10." + }, + "alias": { + "add": "Додати псевдонім ", + "edit": "Редагувати псевдонім", + "name": "Ім'я", + "name-required": "Необхідно вказати псевдонім", + "duplicate-alias": "Псевдонім з такою назвою вже існує.", + "filter-type-single-entity": "Єдина сутність", + "filter-type-entity-group": "Група сутностей", + "filter-type-entity-list": "Список сутностей", + "filter-type-entity-name": "Назва сутності", + "filter-type-entity-group-list": "Список груп сутностей", + "filter-type-entity-group-name": "Назва групи сутностей", + "filter-type-state-entity": "Сутність з стану панелі пристроїв", + "filter-type-state-entity-description": "Сутність, взята з параметрів стану панелі пристроїв", + "filter-type-asset-type": "Тип активу", + "filter-type-asset-type-description": "Тип активів '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Тип активів '{{assetType}}' і ім'я, що починаються з '{{prefix}}'", + "filter-type-device-type": "Тип пристрою", + "filter-type-device-type-description": "Тип пристроїв '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Тип пристроїв '{{deviceType}}' і ім'я, що починаються з '{{prefix}}'", + "filter-type-entity-view-type": "Тип перегляду сутності", + "filter-type-entity-view-type-description": "Перегляд сутності з типом '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Перегляд сутності з типом'{{entityView}}' і іменем, що починаються з '{{prefix}}'", + "filter-type-relations-query": "Запит відносин", + "filter-type-relations-query-description": "{{entities}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Запит пошуку активу", + "filter-type-asset-search-query-description": "Активи з типами {{assetTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Запит пошуку пристрою", + "filter-type-device-search-query-description": "Пристрої з типами {{deviceTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Запит пошуку переглядів сутностей", + "filter-type-entity-view-search-query-description": "Перегляд сутності з типами {{entityViewTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", + "entity-filter": "Фільтр сутності", + "resolve-multiple": "Як декілька сутностей", + "filter-type": "Тип фільтра", + "filter-type-required": "Необхідно вказати тип фільтра.", + "entity-filter-no-entity-matched": "Не знайдено жодних сутностей, які відповідають вказаному фільтру.", + "no-entity-filter-specified": "Фільтр обїектів не вказано", + "root-state-entity": "Використовувати сутінсть стану як кореневу", + "last-level-relation": "Використовувати лише відношення останнього рівня", + "group-state-entity": "Використовувати групу сутностей стану як кореневу", + "root-entity": "Коренева сутність", + "state-entity-parameter-name": "Параметр сутності стану", + "default-state-entity": "Сутність стану за замовчуванням", + "default-state-entity-group": "Група сутностей стану за замовчуванням", + "default-entity-parameter-name": "За замовчуванням", + "max-relation-level": "Максимальна глибина відносин", + "unlimited-level": "Необмежена глибина", + "state-entity": "Сутність стану панелі пристроїв", + "entities-of-group-state-entity": "Сутності із групи сутностей стану", + "all-entities": "Всі сутності", + "any-relation": "не вказано" + }, + "asset": { + "asset": "Актив", + "assets": "Активи", + "management": "Управління активами", + "view-assets": "Переглянути активи", + "add": "Додати активи", + "assign-to-customer": "Надати клієнту", + "assign-asset-to-customer": "Надати активи клієнту", + "assign-asset-to-customer-text": "Будь ласка, виберіть ресурси, призначені для клієнта", + "no-assets-text": "Не знайдено активів", + "assign-to-customer-text": "Будь ласка, виберіть клієнта, щоб надати активи", + "public": "Публічно", + "assignedToCustomer": "Наданий клієнту", + "make-public": "Зробити актив(и) публічним(и)", + "make-private": "Зробити актив(и) приватним(и)", + "unassign-from-customer": "Позбавити клієнта", + "delete": "Видалити актив", + "asset-public": "Актив є загальнодоступним", + "asset-type": "Тип активу", + "asset-type-required": "Тип активу обов'язковий.", + "select-asset-type": "Виберіть тип активу", + "enter-asset-type": "Введіть тип активу", + "any-asset": "Будь-який актив", + "no-asset-types-matching": "Не знайдено жодних активів, що відповідають даному типу '{{entitySubtype}}'.", + "asset-type-list-empty": "Не вибрано жодного типу активів.", + "asset-types": "Типи активів", + "name": "Ім'я", + "name-required": "Ім'я обов'язкове.", + "description": "Опис", + "type": "Тип", + "type-required": "Тип обов'язковий.", + "details": "Подробиці", + "events": "Події", + "add-asset-text": "Додати новий актив", + "asset-details": "Інформація про актив", + "assign-assets": "Надати активи", + "assign-assets-text": "Надати { count, plural, 1 {1 актив} other {# активи} } клієнту", + "delete-assets": "Видалити активи", + "unassign-assets": "Позбавити активів", + "unassign-assets-action-title": "Позбавити { count, plural, 1 {1 актив} other {# активи} } клієнта", + "assign-new-asset": "Надати новий актив", + "delete-asset-title": "Ви впевнені, що хочете видалити актив '{{assetName}}'?", + "delete-asset-text": "Будьте обережні, після підтвердження, актив і всі пов'язані з ним дані буде втрачено", + "delete-assets-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 актив} other {# активи} }?", + "delete-assets-action-title": "Видалити{ count, plural, 1 {1 актив} other {# активи} }", + "delete-assets-text": "Будьте обережні, після підтвердження всі вибрані об'єкти буде видалено, і всі пов'язані з ними дані буде втрачено.", + "make-public-asset-title": "Ви дійсно хочете, щоб актив '{{assetName}}' був загальнодоступним?", + "make-public-asset-text": "Після підтвердження, актив і всі його дані будуть доступними для інших.", + "make-private-asset-title": "Ви впевнені, що хочете зробити актив {{assetName}} приватним?", + "make-private-asset-text": "Після підтвердження, актив та всі його дані будуть приватними та не будуть доступні іншим.", + "unassign-asset-title": "Ви впевнені, що хочете позбавити активу '{{assetName}}'?", + "unassign-asset-text": "Після підтвердження клієнт буде позбавлений активу. Дані активу не будуть доступні клієнту.", + "unassign-asset": "Позбавити активу", + "unassign-assets-title": "Ви впевнені, що хочете позбавити активів { count, plural, 1 {1 актив} other {# активи} }?", + "unassign-assets-text": "Після підтвердження, клієнт буде позбавлений усіх вибраних активів. Дані активів не будуть доступні клієнту.", + "copyId": "Копіювати Id активу", + "idCopiedMessage": "Id активу був скопійований у буфер обміну", + "select-asset": "Виберіть актив", + "no-assets-matching": "Не знайдено жодних активів, що відповідають'{{entity}}'.", + "asset-required": "Необхідно задати актив", + "name-starts-with": "Назва активу починається з", + "selected-assets": "{ count, plural, 1 {1 актив} other {# активи} } selected", + "search": "Пошук активів", + "select-group-to-add": "Виберіть цільову групу, щоб додати вибрані активи", + "select-group-to-move": "Виберіть цільову групу для переміщення вибраних активів", + "remove-assets-from-group": "Ви впевнені, що хочете видалити { count, plural, 1 {1 актив} other {# актив} } з групи '{entityGroup}'?", + "group": "Група активів", + "list-of-groups": "{ count, plural, 1 {Одна група активів} other {Список # груп активів} }", + "group-name-starts-with": "Групи активів, чиї імена починаються з '{{prefix}}'", + "import": "Імпортувати активи", + "asset-file": "Файл з активами", + "label": "Мітка" + }, + "attribute": { + "attributes": "Атрибути", + "latest-telemetry": "Остання телеметрія", + "attributes-scope": "Область видимості атрибутів", + "scope-latest-telemetry": "Остання телеметрія", + "scope-client": "Клієнтські атрибути", + "scope-server": "Серверні атрибути", + "scope-shared": "Спільні атрибути", + "add": "Додати атрибут", + "add-attribute-prompt": "Будь ласка, додайте атрибут", + "key": "Ключ", + "last-update-time": "Останнє оновлення", + "key-required": "Ключ атрибута обов'язковий.", + "value": "Значення", + "value-required": "Значення атрибута обов'язкове.", + "delete-attributes-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Будьте обережні, після підтвердження, всі виділені атрибути будуть видалені.", + "delete-attributes": "Видалити атрибути", + "enter-attribute-value": "Введіть значення атрибута", + "show-on-widget": "Показати на віджеті", + "widget-mode": "Режим віджетів", + "next-widget": "Наступний віджет", + "prev-widget": "Попередній віджет", + "add-to-dashboard": "Додати до інформаційної панелі", + "add-widget-to-dashboard": "Додати віджет до інформаційної панелі", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } selected ...вибрані вибрати", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } selected" + }, + "audit-log": { + "audit": "Операція", + "audit-logs": "Журнал операцій", + "timestamp": "Тимчасова позначка", + "entity-type": "Тип одиниці", + "entity-name": "Назва організації", + "user": "Користувач", + "type": "Тип", + "status": "Статус", + "details": "Подробиці", + "type-added": "Додано", + "type-deleted": "Вилучено", + "type-updated": "Оновлено", + "type-attributes-updated": "Атрибути оновлені", + "type-attributes-deleted": "Атрибути видалені", + "type-rpc-call": "RPC дзвінок", + "type-credentials-updated": "Авторизаційні дані оновлено", + "type-assigned-to-customer": "Призначено клієнту", + "type-unassigned-from-customer": "Позбавлено від клієнта", + "type-activated": "Активовано", + "type-suspended": "Призупинено", + "type-credentials-read": "Авторизаційні дані прочитані", + "type-attributes-read": "Атрибути читаються", + "type-added-to-entity-group": "Додано до групи", + "type-removed-from-entity-group": "Вилучено з групи", + "type-relation-add-or-update": "Відношення оновлено", + "type-relation-delete": "Відношення видалено", + "type-relations-delete": "Всі відношення видалено", + "type-alarm-ack": "Визнано", + "type-alarm-clear": "Очищено", + "type-login": "Вхід", + "type-logout": "Вихід", + "type-lockout": "Заблокований", + "type-rest-api-rule-engine-call": "Rule engine REST API call", + "status-success": "Успішно", + "status-failure": "Невдало", + "audit-log-details": "Подробиці журналу операцій", + "no-audit-logs-prompt": "Жодних журналів операцій не знайдено", + "action-data": "Дані про дії", + "failure-details": "Невдалі подробиці", + "search": "Пошук журналів перевірки", + "clear-search": "Очистити пошук" + }, + "confirm-on-exit": { + "message": "У вас є незбережені зміни. Ви впевнені, що хочете залишити цю сторінку?", + "html-message": "У вас є незбережені зміни.
    Ви впевнені, що хочете залишити цю сторінку?", + "title": "Незбережені зміни" + }, + "contact": { + "country": "Країна", + "city": "Місто", + "state": "Штат / Провінція", + "postal-code": "Поштовий індекс", + "postal-code-invalid": "Неправильний формат поштового індексу.", + "address": "Адреса", + "address2": "Адреса 2", + "phone": "Телефон", + "email": "Електронна пошта", + "no-address": "Немає адреси" + }, + "common": { + "username": "Ім'я користувача", + "password": "Пароль", + "enter-username": "Введіть ім'я користувача", + "enter-password": "Введіть пароль", + "enter-search": "Введіть пошук", + "created-time": "Час створення" + }, + "converter": { + "converter": "Перетворювач даних", + "converters": "Перетворювачі даних", + "select-converter": "Виберіть перетворювач даних", + "no-converters-matching": "Не має перетворювачів даних, які відповідають '{{entity}}'.", + "converter-required": "Необхідно вказати перетворювач даних", + "delete": "Видалити перетворювач даних", + "management": "Управління перетворювачами даних", + "add-converter-text": "Додати новий перетворювач даних", + "no-converters-text": "Перетворювачів даних не знайдено", + "selected-converters": "{ count, plural, 1 {1 перетворювач даних} other {# перетворювачі даних} } вибраний", + "delete-converter-title": "Ви впевнені, що хочете видалити перетворювач даних '{{converterName}}'?", + "delete-converter-text": "Будьте обережні, після підтвердження, перетворювач даних та всі пов'язані з ним дані,стануть недоступними).", + "delete-converters-title": "Ви впевнені, що хочете видалити{ count, plural, 1 {1 перетворювач даних} other {# перетворювачі даних} }?", + "delete-converters-action-title": "Видалити { count, plural, 1 {1 перетворювач даних} other {# перетворювачі даних} }", + "delete-converters-text": "Будьте обережні, після підтвердження всі вибрані перетворювачі даних буде видалено, і всі пов'язані з ними дані буде втрачено.", + "events": "Події", + "add": "Додати перетворювач даних", + "converter-details": "Подробиці про перетворювач даних", + "details": "Подробиці", + "copyId": "Копіювати Id перетворювача даних", + "idCopiedMessage": "Id перетворювача даних було скопійовано у буфер обміну", + "debug-mode": "Режим налагодження", + "name": "Ім'я", + "name-required": "Ім'я обов'язкове.", + "description": "Опис", + "decoder": "Декодер", + "encoder": "Кодер", + "test-decoder-fuction": "Тестування функції декодера", + "test-encoder-fuction": "Тестування функції кодера", + "decoder-input-params": "Вхідні параметри декодера", + "encoder-input-params": "Вхідні параметри кодера", + "payload": "Вхідне повідомлення", + "payload-content-type": "Тип контенту вхідного повідомлення", + "payload-content": "Зміст вхідного повідомлення", + "message": "Повідомлення", + "message-type": "Тип повідомлення", + "message-type-required": "Необхідно задати тип повідомлення", + "test": "Тест", + "metadata": "Метадані", + "metadata-required": "Записи метаданих не можуть бути порожніми.", + "integration-metadata": "Метедані інтеграції", + "integration-metadata-required": "Параметри метаданих інтеграції не можуть бути порожніми.", + "output": "Вихідні дані", + "import": "Імпорт перетворювача даних", + "export": "Експорт перетворювача даних", + "export-failed-error": "Неможливо експортувати перетворювач даних: {{помилка}}", + "create-new-converter": "Створити новий перетворювач даних", + "converter-file": "Файл перетворювача даних(конвектер файл)", + "invalid-converter-file-error": "Неможливо імпортувати перетворювач даних: недійсна структура даних перетворювача.", + "type": "Тип", + "type-required": "Необхідно задати тип.", + "type-uplink": "Від пристрою", + "type-downlink": "До пристрою" + }, + "content-type": { + "json": "Json", + "text": "Текст", + "binary": "Бінарний (Base64)" + }, + "customer": { + "customer": "Клієнт", + "customers": "Клієнти", + "management": "Клієнтський менеджмент", + "dashboard": "Інформаційна панель клієнта", + "dashboards": "Інформаційні панелі клієнта", + "devices": "Пристрої клієнта", + "entity-views": "Представлення сутностей", + "assets": "Клієнтські активи", + "public-dashboards": "Публічні інформаційні панелі", + "public-devices": "Публічні пристрої", + "public-assets": "Публічні активи", + "public-entity-views": "Публічне представлення сутностей 440", + "add": "Додати клієнта", + "delete": "Видалити клієнта", + "manage-customer-users": "Керування користувачами клієнта", + "manage-customer-devices": "Керування пристроями клієнта", + "manage-customer-dashboards": "Керування інформаційними панелями клієнта", + "manage-public-devices": "Керувати загальнодоступними пристроями", + "manage-public-dashboards": "Керування загальнодоступними інформаційними панелями", + "manage-customer-assets": "Керування активами клієнта", + "manage-public-assets": "Керування загальнодоступними активами", + "add-customer-text": "Додати нового клієнта", + "no-customers-text": "Клієнтів не знайдено", + "customer-details": "Інформація про клієнта", + "delete-customer-title": "Ви впевнені, що хочете видалити клієнта '{{customerTitle}}'?", + "delete-customer-text": "Будьте обережні, після підтвердження, клієнт та всі пов'язані з ним дані, стануть недоступними.", + "delete-customers-title": "Ви впевнені, що хочете видалити {count, plural, 1 {1 клієнт} other {# клієнти} }?", + "delete-customers-action-title": "Видалити{ count, plural, 1 {1 клієнт} other {# клієнти} }", + "delete-customers-text": "Будьте обережні, після підтвердження, всі вибрані клієнти будуть видалені і всі пов'язані з ними дані, стануть недоступними.", + "manage-users": "Керування користувачами", + "manage-assets": "Керування активами", + "manage-devices": "Керування пристроями", + "manage-dashboards": "Керування інформаційними панелями", + "title": "Назва", + "title-required": "Необхідно задати назву.", + "description": "Опис", + "details": "Подробиці", + "events": "Події", + "copyId": "Копіювати Id клієнта", + "idCopiedMessage": "Id клієнта було скопійовано в буфер обміну", + "select-customer": "Виберіть клієнта", + "no-customers-matching": "Клієнтів, які відповідають '{{entity}}' не знайдено.", + "customer-required": "Необхідно задати клієнта", + "selected-customers": "{ count, plural, 1 {1 клієнт} other {# клієнти} } вибрано", + "search": "Пошук клієнтів", + "select-group-to-add": "Виберіть цільову групу, щоб додати вибраних клієнтів", + "select-group-to-move": "Виберіть цільову групу для переміщення вибраних клієнтів", + "remove-customers-from-group": "Ви впевнені, що хочете видалити{ count, plural, 1 {1 клієнт} other {# клієнти} } з групи'{entityGroup}'?", + "group": "Група клієнтів", + "list-of-groups": "{ count, plural, 1 {Одна група клієнтів} other {Список # груп клієнтів} }", + "group-name-starts-with": "Групи клієнтів, імена яких починаються з '{{prefix}}'", + "select-default-customer": "Вибрати клієнта за замовчуванням", + "default-customer": "Клієнт за замовчуванням", + "default-customer-required": "Необхідно вказати клієнта за замовчуванням для налагодження панелі візуалізації на рівні замовника", + "allow-white-labeling": "Дозволити брендування" + }, + "custom-translation": { + "custom-translation": "Переклад для користувача", + "translation-map": "Карта перекладу", + "key": "Ключ перекладу", + "import": "Імпорт перекладу", + "export": "Експорт перекладу", + "export-data": "Дані про експорт перекладу", + "import-data": "Дані про імпорт перекладу", + "translation-file": "Файл перекладу", + "invalid-translation-file-error": "Неможливо імпортувати файл перекладу: недійсна структура даних перекладу.", + "custom-translation-hint": "Визначте індивідуальний переклад в JSON нижче. Цей JSON перезапише переклад за замовчуванням. Натисніть 'Завантажити файл перекладу', щоб отримати існуючий переклад. Ви також можете скористатись завантаженим файлом як посиланням на наявні пари параметрів перекладу ключ-значення.", + "download-locale-file": "Завантажити файл перекладу" + }, + "datetime": { + "date-from": "Дата від", + "time-from": "Час від", + "date-to": "Дата до", + "time-to": "Час до" + }, + "dashboard": { + "dashboard": "Панель приладів", + "dashboards": "Панелі приладів", + "management": "Управління панеллю приладів", + "view-dashboards": "Переглянути панелі приладів", + "add": "Додати панель приладів", + "assign-dashboard-to-customer": "Призначити панель(і) приладів замовнику", + "assign-dashboard-to-customer-text": "Будь ласка, виберіть панелі пристроїв, щоб призначити їх клієнту", + "assign-to-customer-text": "Виберіть клієнта, щоб призначити панелі пристроїв", + "assign-to-customer": "Призначити клієнту", + "unassign-from-customer": "Позбавити клієнта", + "make-public": "Зробити панель приладів публічною", + "make-private": "Зробити панель приладів приватною", + "manage-assigned-customers": "Керування призначеними клієнтами", + "assigned-customers": "Призначені клієнтам", + "assign-to-customers": "Призначити панелі приладів клієнтам", + "assign-to-customers-text": "Виберіть клієнтів для призначення панелей приладів", + "unassign-from-customers": "Позбавити клієнтів призначенних панелей приладів", + "unassign-from-customers-text": "Виберіть клієнтів для позбавлення їх призначених панелей приладів", + "no-dashboards-text": "Панелі приладів не знайдені", + "no-widgets": "Не налаштовано жодних віджетів", + "add-widget": "Додати новий віджет", + "title": "Назва", + "select-widget-title": "Вибрати віджет", + "copyId": "Копіювати ідентифікатор панелі приладів", + "idCopiedMessage": "Ідентифікатор панелі приладів скопійовано в буфер обміну", + "select-widget-subtitle": "Список доступних типів віджетів", + "delete": "Видалити панель приладів", + "title-required": "Необхідно задати назву.", + "description": "Опис", + "details": "подробиці", + "dashboard-details": "Подробиці панелі приладів", + "add-dashboard-text": "Додати нову панель приладів", + "assign-dashboards": "Призначити панель приладів", + "assign-new-dashboard": "Призначити нову панель приладів", + "assign-dashboards-text": "Призначити { count, plural, 1 {1 панель приладів} other {# панелі приладів} } користувачам", + "unassign-dashboards-action-text": "Позбавити { count, plural, 1 {1 палелі приладів} other {# панелей приладів} } клієнтів", + "delete-dashboards": "Видалити панель приладів", + "unassign-dashboards": "Позбавити панелей приладів", + "unassign-dashboards-action-title": "Позбавити { count, plural, 1 {1 палелі приладів} other {# панелей приладів} } клієнтів", + "delete-dashboard-title": "Ви впевнені, що хочете видалити панель приладів '{{назва панелі приладів}}'?", + "delete-dashboard-text": "Будьте обережні, після підтвердження, панель приладів і всі пов'язані з нею дані стануть недоступними.", + "delete-dashboards-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 панель приладів} other {# панелі приладів} }?", + "delete-dashboards-action-title": "Видалити { count, plural, 1 {1 панель приладів} other {# панелі приладів} }", + "delete-dashboards-text": "Будьте обережні, після підтвердження, всі вибрані панелі приладів буде видалено, і всі пов'язані з ними дані стануть недоступними.", + "unassign-dashboard-title": "Ви впевнені, що хочете позбавити панелі приладів '{{назва інформаційної панелі}}'?", + "unassign-dashboard-text": "Після підтвердження, клієнт буде позбавлений панелі приладів. Панель приладів і пов'язані з нею дані будуть недоступні клієнтові.", + "unassign-dashboard": "Позбавити панелі приладів", + "unassign-dashboards-title": "Ви впевнені, що хочете позбавити { count, plural, 1 {1 панелі приладів} other {# панелей приладів} }?", + "unassign-dashboards-text": "Після підтвердження, клієтн буде позбавлений усіх вибраних панелей приладів і даних, які з ними пов'язані.", + "public-dashboard-title": "Панель приладів тепер публічна", + "public-dashboard-text": "Ваша панель приладів {{dashboardTitle}} тепер публічна і доступна іншим link:", + "public-dashboard-notice": "Note: Не забудьте зробити спільні пристрої загальнодоступними, щоб отримати доступ до їхніх даних.", + "make-private-dashboard-title": "Ви впевнені, що хочете зробити панель приладів '{{назва панелі приладів}}' приватною?", + "make-private-dashboard-text": "Після підтвердження панель приладів стане приватною і не буде доступною іншим.", + "make-private-dashboard": "Зробити панель приладів приватною", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Вибрати панель приладів", + "no-dashboards-matching": "Не знайдено жодних панелей прилодів'{{entity}}' які відповідають.", + "dashboard-required": "Необхідно задати панель приладів.", + "select-existing": "Виберіть існуючу панель приладів", + "create-new": "Створити нову панель приладів", + "new-dashboard-title": "Нова назва панелі приладів", + "open-dashboard": "Відрити панель приладів", + "set-background": "Встановити фон", + "background-color": "Колір фону", + "background-image": "Фонове зображення", + "background-size-mode": "Режим фонового розміру", + "no-image": "Не вибрано жодного зображення", + "drop-image": "Перетягніть зображення або клацніть, щоб вибрати файл для завантаження.", + "settings": "Налаштування", + "columns-count": "Кількість стовпців", + "columns-count-required": "Необхідно вказати кількість стовпців.", + "min-columns-count-message": "Дозволений мінімум -10 стовпців.", + "max-columns-count-message": "Дозволений максимум - 1000 стовпців.", + "widgets-margins": "Відступ між віджетами", + "horizontal-margin": "Горизонтальний відступ", + "horizontal-margin-required": "Необхідно вказати горизонтальний відступ.", + "min-horizontal-margin-message": "Допустиме мінімальне значення горизонтального відступу - 0.", + "max-horizontal-margin-message": "Допустиме максимальне значення горизонтального відступу - 50.", + "vertical-margin": "Вертикальний відступ", + "vertical-margin-required": "Необхідно вказати вертикальний відступ.", + "min-vertical-margin-message": "Допустиме мінімальне значення вертикального відступу - 0.", + "max-vertical-margin-message": "Допустиме максимальне значення вертикального відступу - 50.", + "autofill-height": "Висота автоматичного заповнення макета", + "mobile-layout": "Налаштування макета для мобільних пристроїв", + "mobile-row-height": "Висота рядка для мобільних пристроїв, px", + "mobile-row-height-required": "Потрібно вказати значення висоти рядка для мобільних пристроїв.", + "min-mobile-row-height-message": "Допустиме мінімальне значення висоти рядка для мобільних пристроїв - 5 пікселів.", + "max-mobile-row-height-message": "Допустиме максимальне значення висоти рядка для мобільних пристроїв - 200 пікселів.", + "display-title": "Відображати назву панелі візуалізації", + "toolbar-always-open": "Тримайте панель візуалізації відкритою", + "title-color": "Колір назви", + "display-dashboards-selection": "Відображення вибору панелей візуалізації", + "display-entities-selection": "Вибір відображення сутності", + "display-dashboard-timewindow": "Відобразити налаштування часового проміжку", + "display-dashboard-export": "Відображення експорту", + "import": "Імпортувати панель візуалізації", + "export": "Експортувати панель візуалізації", + "export-failed-error": "Неможливо експортувати панель візуалізації: {{error}}", + "export-pdf": "Експортувати як PDF", + "export-png": "Експортувати як PNG", + "export-jpg": "Експортувати як JPEG", + "export-json-config": "Експортувати конфігурацію JSON", + "download-dashboard-progress": "Генерування панелі візуалізації {{reportType}} ...", + "create-new-dashboard": "Створити нову панель візуалізації", + "dashboard-file": "Файл панелі візуалізації", + "invalid-dashboard-file-error": "Неможливо імпортувати панель візуалізації: неправильна структура даних панелі візуалізації.", + "dashboard-import-missing-aliases-title": "Configure aliases used by imported dashboard Налаштування псевдонімів, що використовуються імпортованою панеллю візуалізації", + "create-new-widget": "Створити новий віджет", + "import-widget": "Імпортувати віджет", + "widget-file": "Файл віджета", + "invalid-widget-file-error": "Неможливо імпортувати віджет: неправильна структура даних віджета.", + "widget-import-missing-aliases-title": "Налаштувати псевдоніми, що використовуються імпортованим віджетом", + "open-toolbar": "Відкрити панель інструменів ", + "close-toolbar": "Закрити панель інструменів", + "configuration-error": "Помилка конфігурації", + "alias-resolution-error-title": "Помилка конфігурації псевдонімів панелі візуалізації", + "invalid-aliases-config": "Неможливо знайти пристрої, які відповідають певному фільтру псевдонімів.
    Зверніться до свого адміністратора, щоб вирішити цю проблему.", + "select-devices": "Вибрати пристрої", + "assignedToCustomer": "Призначений клієнту", + "assignedToCustomers": "Призначений клієнтам", + "public": "Публічно", + "public-link": "Публічне посилання", + "copy-public-link": "Копіювати публічне посилання", + "public-link-copied-message": "Публічне посилання було скопійоване в буфер обміну панелі візуалізації", + "manage-states": "Керування станами панелі візуалізації", + "states": "Стани панелі візуалізації", + "search-states": "Пошук станів панелі візуалізації", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } вибрано", + "edit-state": "Редагувати стан панелі візуалізації", + "delete-state": "Видалити стан панелі візуалізації ", + "add-state": "Додати стан панелі візуалізації", + "state": "Стан панелі візуалізації", + "state-name": "Ім'я", + "state-name-required": "Необхідно вказати назву стану панелі візуалізації.", + "state-id": "Стан Id", + "state-id-required": "Необхідно вказати id стану панелі візуалізації.", + "state-id-exists": "Стан інформаційної панелі з таким id вже існує.", + "is-root-state": "Основний стан", + "delete-state-title": "Видалити стан панелі візуалізації", + "delete-state-text": "Ви впевнені, що хочете видалити стан панелі візуалізації з іменем '{{stateName}}'?", + "show-details": "Показати деталі", + "hide-details": "Приховати деталі", + "select-state": "Виберіть цільовий стан", + "state-controller": "Контроллер стану" + }, + "datakey": { + "settings": "Налаштування", + "advanced": "Додатково", + "label": "Мітка", + "color": "Колір", + "units": "Спеціальний символ, який показує наступне значення", + "decimals": "Кількість цифр після плаваючої точки", + "data-generation-func": "Функція генерації даних", + "use-data-post-processing-func": "Використовувати функцію пост-обробки даних", + "configuration": "Конфігурація ключа даних", + "timeseries": "Телеметрія", + "attributes": "Атрибути", + "entity-field": "Поле сутності", + "alarm": "Поля сигнала тривоги", + "timeseries-required": "Необхідно вказати Телеметрія.", + "timeseries-or-attributes-required": "Необхідно вказати телеметрію/атрибути.", + "maximum-timeseries-or-attributes": "Максимальні { count, plural, 1 {1 телеметрія/атрибут дозволені.} other {# телеметрія/атрибути дозволені} }", + "alarm-fields-required": "Необхідно вказати поля сигнала тривоги.", + "function-types": "Типи функцій", + "function-types-required": "Необхідно вказати типи функцій.", + "maximum-function-types": "Maximum { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "мітка часу поточного значення;", + "value-description": "поточне значення;", + "prev-value-description": "результат попереднього виклику функції;", + "time-prev-description": "мітка часу попереднього значення;", + "prev-orig-value-description": "оригінальне попереднє значення;" + }, + "datasource": { + "type": "Тип джерела даних", + "name": "Ім'я", + "add-datasource-prompt": "Додайте джерело даних" + }, + "details": { + "details": "Деталі", + "edit-mode": "Режим редагування", + "edit-json": "Редагувати JSON", + "toggle-edit-mode": "Перемкнути режим редагування" + }, + "device": { + "device": "Пристрій", + "device-required": "Необхідно задати пристрій.", + "devices": "Пристрої", + "management": "Управління пристроєм", + "view-devices": "Перегляд пристроїв", + "device-alias": "Псевдонім пристрою", + "aliases": "Псевдонім пристроїв", + "no-alias-matching": "'{{alias}}' не знайдено.", + "no-aliases-found": "Псевдонімів не знайдено.", + "no-key-matching": "'{{key}}' не знайдено.", + "no-keys-found": "Ключі не знайдено.", + "create-new-alias": "Створити новий!", + "create-new-key": "Створити новий!", + "duplicate-alias-error": "Псевдонім з таким іменем '{{alias}}' вже існює.
    Псевдоніми пристроїв повинні бути унікальними на панелі візуалізації.", + "configure-alias": "Налаштувати псевдонім '{{alias}}'", + "no-devices-matching": "Не знайдено жодних пристроїв, які відповідають '{{entity}}'.", + "alias": "Псевдонім", + "alias-required": "Необхідно задати псевдонім пристрою.", + "remove-alias": "Видалити псевдонім пристрою", + "add-alias": "Додати псевдонім пристрою", + "name-starts-with": "Ім'я пристрою починається з", + "device-list": "Список пристроїв", + "use-device-name-filter": "Використати фільтр", + "device-list-empty": "Не вибрано жодного пристрою.", + "device-name-filter-required": "Необхідно задати назву фільтра пристрою.", + "device-name-filter-no-device-matched": "Не знайдено жодних пристроїв, що починаються з '{{device}}'.", + "add": "Додати пристрій", + "assign-to-customer": "Призначити клієнту", + "assign-device-to-customer": "Призначити пристрій (ої) клієнту", + "assign-device-to-customer-text": "Виберіть пристрої, які слід призначити клієнту", + "make-public": "Зробити пристрій публічним", + "make-private": "Зробити пристрій приватним", + "no-devices-text": "Не знайдено жодного пристрою", + "assign-to-customer-text": "Виберіть клієнта для призначення пристрою (їв)", + "device-details": "Деталі пристрою", + "add-device-text": "Додати новий пристрій", + "credentials": "Авторизаційні дані", + "manage-credentials": "Керування авторизаційними даними", + "delete": "Видалити пристрій", + "assign-devices": "Призначити пристрої", + "assign-devices-text": "Призначити { count, plural, 1 {1 пристрій} other {# пристрої} } клієнту", + "delete-devices": "Видалити пристрої", + "unassign-from-customer": "Позбавити клієнта пристроїв", + "unassign-devices": "Позбавити пристроїв", + "unassign-devices-action-title": "Позбавити клієнта { count, plural, 1 {1 пристрою} other {# пристроїв} }", + "assign-new-device": "Призначити новий пристрій", + "make-public-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' публічним?", + "make-public-device-text": "Після підтвердження пристрій і всі його дані будуть публічними та доступними для інших.", + "make-private-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' приватним?", + "make-private-device-text": "Після підтвердження пристрій і всі його дані будуть приватними та недоступними для інших.", + "view-credentials": "Переглянути авторизаційні дані", + "delete-device-title": "Ви впевнені, що хочете видалити пристрій '{{deviceName}}'?", + "delete-device-text": "Будьте обережні, після підтвердження, пристрій і всі пов'язані з ним дані стануть недоступними.", + "delete-devices-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 пристрій} other {# пристрої} }?", + "delete-devices-action-title": "Видалити { count, plural, 1 {1 пристрій} other {# пристрої} }", + "delete-devices-text": "Будьте обережні, після підтвердження, всі вибрані пристрої будуть видалені, і всі пов'язані з ними дані стануть недоступними.", + "unassign-device-title": "Ви впевнені, що хочете позбавити пристрою '{{deviceName}}'?", + "unassign-device-text": "Після підтвердження, клієнт буде позбавлений пристрою.", + "unassign-device": "Позбавити пристою", + "unassign-devices-title": "Ви впевненені, що хочете позбавити { count, plural, 1 {1 пристрою} other {# пристроїв} }?", + "unassign-devices-text": "Після підтвердження, клієнт буде позбавлений пристрою і пристрій стане не доступним клієнту", + "device-credentials": "Авторизаційні дані прстрою", + "credentials-type": "Тип авторизаційних даних", + "access-token": "Маркер доступу", + "access-token-required": "Необхідно вказати маркер доступу.", + "access-token-invalid": "Маркер доступу має бути від 1 до 32 символів.", + "secret": "Секрет", + "secret-required": "Необхідно вказати секрет.", + "device-type": "Тип пристрою", + "device-type-required": "Необхідно вказати тип пристрою.", + "select-device-type": "Виберіть тип пристрою", + "enter-device-type": "Введіть тип пристрою", + "any-device": "Будь-який пристрій", + "no-device-types-matching": "Не знайдено типів пристроїв, які відповідають '{{entitySubtype}}'.", + "device-type-list-empty": "Не вибрано типів пристроїв.", + "device-types": "Типи пристрою", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "description": "Опис", + "events": "Події", + "details": "Деталі", + "copyId": "Копіювати Id пристрою", + "copyAccessToken": "Копіювати маркер доступу", + "idCopiedMessage": "Id пристрою скопійовано в буфер обміну", + "accessTokenCopiedMessage": "Маркер доступу до пристрою скопійовано в буфер обміну", + "assignedToCustomer": "Призначений клієнту", + "unable-delete-device-alias-title": "Неможливо видалити псевдонім пристрою", + "unable-delete-device-alias-text": "Псевдонім пристрою '{{deviceAlias}}' не може бути видалений, оскільки він використовується наступним(и) віджетом(ами):
    {{widgetsList}}", + "is-gateway": "Шлюз", + "public": "Публічно", + "device-public": "Пристрій є публічним", + "select-device": "Виберіть пристрій", + "import": "Імпортувати пристрої", + "device-file": "Файл з пристроями", + "selected-devices": "{ count, plural, 1 {1 пристрій} other {# пристрої} } вибрано", + "search": "Шукати пристрої", + "select-group-to-add": "Виберіть цільову групу, щоб додати вибраний пристрій", + "select-group-to-move": "Виберіть цільову групу для переміщення вибраних пристроїв", + "remove-devices-from-group": "Ви впевнені, що хочете видалити { count, plural, 1 {1 пристрій} other {# пристрої} } з групи '{entityGroup}'?", + "group": "Група пристроїв", + "list-of-groups": "{ count, plural, 1 {Одна група пристроїв} other {Список # груп пристроїв} }", + "group-name-starts-with": "Групи пристроїв, імена яких починаються з '{{prefix}}'" + }, + "dialog": { + "close": "Закрити діалогове вікно" + }, + "direction": { + "column": "Колонка", + "row": "Рядок" + }, + "error": { + "unable-to-connect": "Неможливо підключитися до сервера! Перевірте підключення до Інтернету.", + "unhandled-error-code": "Неопрацьований помилковий код: {{errorCode}}", + "unknown-error": "Невідома помилка" + }, + "entity": { + "entity": "Сутність", + "entities": "Сутності", + "aliases": "Псевдоніми сутності", + "entity-alias": "Псевдонім сутності", + "unable-delete-entity-alias-title": "Неможливо видалити псевдонім сутності", + "unable-delete-entity-alias-text": "Псевдонім сутності'{{entityAlias}}' неможливо видалити, так як це використовується наступним віджетом(s):
    {{widgetsList}}", + "duplicate-alias-error": "Знайдено повторюваний псевдонім '{{alias}}'.
    Псевдоніми сутностей повинні бути унікальними на панелі візуалізації.", + "missing-entity-filter-error": "Відсутній фільтр для псевдоніма '{{alias}}'.", + "configure-alias": "Налаштувати '{{alias}}' псевдонім", + "alias": "Псевдонім", + "alias-required": "Необхідно вказати псевдонім сутності.", + "remove-alias": "Видалити псевдонім сутності", + "add-alias": "Додати псевдонім сутності", + "entity-list": "Список сутності", + "entity-type": "Тип сутності", + "entity-types": "Типи сутності", + "entity-type-list": "Список типу сутності", + "any-entity": "Будь-яка сутність", + "enter-entity-type": "Введіть тип сутності", + "no-entities-matching": "Не знайдено жожних сутностей, що відповідають '{{entity}}' що відповідають.", + "no-entity-types-matching": "Не знайдено жожних типів сутностей, що відповідають '{{entityType}}'.", + "name-starts-with": "Назва починається з", + "use-entity-name-filter": "Використовуйте фільтр", + "entity-list-empty": "Не вибрано жодних сутностей.", + "entity-type-list-empty": "Не вибрано жодних типів сутностей.", + "entity-name-filter-required": "Необхідно задати фільтр по імені.", + "entity-name-filter-no-entity-matched": "Не знайдено жодних сутностей, що починаються з '{{entity}}'.", + "all-subtypes": "Всі", + "select-entities": "Виберіть сутність", + "no-aliases-found": "Псевдонімів не знайдено.", + "no-alias-matching": "'{{alias}}' не знайдено.", + "create-new-alias": "Створити новий псевдонім!", + "key": "Ключ", + "key-name": "Ім'я ключа", + "no-keys-found": "No keys found.", + "no-key-matching": "'{{key}}' не знайдено.", + "create-new-key": "Створити новий ключ!", + "type": "Тип", + "type-required": "Необхідно задати тип сутності.", + "type-device": "Пристрій", + "type-devices": "Пристрої", + "list-of-devices": "{ count, plural, 1 {Один пристрій} other {Список # пристроїв} }", + "device-name-starts-with": "Пристрої, імена яких починаються з '{{prefix}}'", + "type-asset": "Актив", + "type-assets": "Активи", + "list-of-assets": "{ count, plural, 1 {Один актив} other {Список # активів} }", + "asset-name-starts-with": "Активи, імена яких починаються з '{{prefix}}'", + "type-entity-view": "Перегляд сутності", + "type-entity-views": "Перегляди сутності", + "list-of-entity-views": "{ count, plural, 1 {Один перегляд сутності} other {Список # переглядів сутності} }", + "entity-view-name-starts-with": "Перегляди сутностей, імена яких починаються з '{{prefix}}'", + "type-rule": "Правило", + "type-rules": "Правила", + "list-of-rules": "{ count, plural, 1 {Одне правило} other {Список # правил} }", + "rule-name-starts-with": "Правила, імена яких починаються з '{{prefix}}'", + "type-plugin": "Плагін", + "type-plugins": "Плагіни", + "list-of-plugins": "{ count, plural, 1 {Один плагін} other {Список # плагінів} }", + "plugin-name-starts-with": "Плагіни, імена яких починаються з '{{prefix}}'", + "type-tenant": "Власник", + "type-tenants": "Власники", + "list-of-tenants": "{ count, plural, 1 {Один власник} other {Список # власників} }", + "tenant-name-starts-with": "Власники, імена яких починаються з '{{prefix}}'", + "type-customer": "Клієнт", + "type-customers": "Клієнти", + "list-of-customers": "{ count, plural, 1 {Один клієнт} other {Список # клієнтів} }", + "customer-name-starts-with": "Клієнти, імена яких починаються з '{{prefix}}'", + "type-user": "Користувач", + "type-users": "Користувачі", + "list-of-users": "{ count, plural, 1 {Один користувач} other {Список # користувачів } }", + "user-name-starts-with": "Користувачі, імена яких починаються з '{{prefix}}'", + "type-dashboard": "Панель візуалізації", + "type-dashboards": "Панелі візуалізації", + "list-of-dashboards": "{ count, plural, 1 {Одна панель візуалізації} other {Список # панелей візуалізації} }", + "dashboard-name-starts-with": "Панелі візуалізації, імена яких починаються з '{{prefix}}'", + "type-alarm": "Сигнал тривоги", + "type-alarms": "Сигнали тривоги", + "list-of-alarms": "{ count, plural, 1 {Один сигнал тривоги} other {Список # сигналів тривоги} }", + "alarm-name-starts-with": "Сигнали тривоги, імена яких починаються '{{prefix}}'", + "type-rulechain": "Ланцюжок правил", + "type-rulechains": "Ланцюжки правил", + "list-of-rulechains": "{ count, plural, 1 {Один ланцюжок правил} other {Список # ланцюжків правил} }", + "rulechain-name-starts-with": "Правило ланцюжків, імена яких починаються '{{prefix}}'", + "type-scheduler-event": "Scheduler event", + "type-scheduler-events": "Scheduler events", + "list-of-scheduler-events": "{ count, plural, 1 {One scheduler event} other {List of # scheduler events} }", + "scheduler-event-name-starts-with": "Scheduler events whose names start with '{{prefix}}'", + "type-blob-entity": "Blob entity", + "type-blob-entities": "Blob entities", + "list-of-blob-entities": "{ count, plural, 1 {One blob entity} other {List of # blob entities} }", + "blob-entity-name-starts-with": "Blob entities whose names start with '{{prefix}}'", + "type-rulenode": "Правило", + "type-rulenodes": "Правила", + "list-of-rulenodes": "{ count, plural, 1 {Одне правило} other {Список # правил} }", + "rulenode-name-starts-with": "Список правил, імена яких починаються '{{prefix}}'", + "type-current-customer": "Поточний клієнт", + "type-current-tenant": "Поточний власник", + "search": "Пошук сутностей", + "selected-entities": "{ count, plural, 1 {1 сутність} other {# сутності} } вибрано", + "entity-name": "Ім'я сутності", + "entity-label": "Мітка сутності", + "details": "Подробиці сутності", + "no-entities-prompt": "Сутності не знайдено", + "no-data": "Немає даних для відображення", + "columns-to-display": "Стовпці для відображення", + "type-entity-group": "Група сутностей", + "type-converter": "Перетворювач даних", + "type-converters": "Перетворювачі даних", + "list-of-converters": "{ count, plural, 1 {Однин перетворювач даних} other {Список # перетворювачів даних} }", + "converter-name-starts-with": "Перетворювачі даних, імена яких починаються з '{{prefix}}'", + "type-integration": "Інтеграція", + "type-integrations": "Інтеграції", + "list-of-integrations": "{ count, plural, 1 {Одна інтеграція} other {Список # інтеграцій} }", + "integration-name-starts-with": "Інтеграції, імена яких починаються з '{{prefix}}'" + }, + "entity-field": { + "created-time": "Час створення", + "name": "Ім'я", + "type": "Тип", + "first-name": "Ім'я", + "last-name": "Прізвище", + "email": "Електронна пошта", + "title": "Назва", + "country": "Країна", + "state": "Штат", + "city": "Місто", + "address": "Адреса", + "address2": "Адреса 2", + "zip": "Zip", + "phone": "Телефон", + "label": "Мітка" + }, + "entity-group": { + "entity-group": "Група сутності", + "details": "Деталі", + "columns": "Стовпці", + "add-column": "Додати стовпець", + "column-value": "Значення", + "column-value-required": "Необхідно вказати значення.", + "column-title": "Назва", + "default-sort-order": "Основний порядок сортування", + "default-sort-order-required": "Необхідно вказати основний порядок сортування.", + "hide-in-mobile-view": "Мобільний приховано", + "use-cell-style-function": "Використовувати функцію стилю комірки", + "use-cell-content-function": "Use cell content function", + "edit-column": "Редагувати стовпець", + "column-details": "Деталі стовпця", + "actions": "Дії", + "settings": "Налаштування", + "delete": "Видалити групу сутностей", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "description": "Опис", + "add": "Додати групу сутностей", + "add-entity-group-text": "Додати нову групу сутностей", + "no-entity-groups-text": "Не знайдено жодних груп сутності", + "entity-group-details": "Деталі групи сутності", + "delete-entity-groups": "Видалити групи сутностей", + "delete-entity-group-title": "Ви впевнені, що хочете видалити групу сутності '{{entityGroupName}}'?", + "delete-entity-group-text": "Будьте обережні, після підтвердження, група сутностей і всі пов'язані з нею дані стануть недоступними.", + "delete-entity-groups-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 групу сутності} other {# групи сутностей} }?", + "delete-entity-groups-action-title": "Видалити { count, plural, 1 {1 групу сутності} other {# групи сутностей} }", + "delete-entity-groups-text": "Будьте обережні, після підтвердження, всі виділені групи сутностей і пов'язані з ними дані, стануть недоступними.", + "device-groups": "Групи пристроїв", + "asset-groups": "Групи активів", + "customer-groups": "Групи клієнтів", + "device-group": "Група пристроїв", + "asset-group": "Група активів", + "customer-group": "Група клієнтів", + "fetch-more": "Отримати більше", + "column-type": { + "column-type": "Тип стовпця", + "client-attribute": "Атрибут клієнта", + "shared-attribute": "Спільний атрибут", + "server-attribute": "Атрибут сервера", + "timeseries": "Телеметрія", + "entity-field": "Поле сутності" + }, + "column-type-required": "Необхідно вказати тип стовпця.", + "entity-field": { + "created-time": "Час створення", + "name": "Ім'я", + "type": "Тип", + "assigned_customer": "Призначений клієнт", + "authority": "Авторитет", + "first_name": "Ім'я", + "last_name": "Прізвище", + "email": "Електронна пошта", + "title": "Назва", + "country": "Країна", + "state": "Штат", + "city": "Місто", + "address": "Адреса", + "address2": "Адреса 2", + "zip": "Zip", + "phone": "Телефон" + }, + "sort-order": { + "asc": "У порядку зростання", + "desc": "У порядку зменшення", + "none": "Немає" + }, + "details-mode": { + "on-row-click": "Клацніть на рядок", + "on-action-button-click": "Клацніть на кнопку детелі", + "disabled": "Вимкнено" + }, + "add-to-group": "Додати до групи", + "move-to-group": "Перемістити до групи", + "select-entity-group": "Виберіть групу сутностей", + "no-entity-groups-matching": "Не знайдено жодних груп сутностей, що відповідають '{{entityGroup}}'.", + "target-entity-group-required": "Необхідно вказати цільову групу сутності.", + "remove-from-group": "Видалити з групи", + "group-table-title": "Group table title", + "enable-search": "Увімкнути пошук сутностей", + "enable-add": "Увімкнути додавання сутностей", + "enable-delete": "Увімкнути видалення сутностей", + "enable-selection": "Увімкнути вибір сутностей", + "enable-group-transfer": "Увімкнути дії групового перенесення", + "display-pagination": "Відображення сторінок", + "default-page-size": "Розмір сторінки за замовчуванням", + "enable-assignment-actions": "Увімкнути дії призначення", + "enable-credentials-management": "Увімкнути керування авторизаційними даними", + "enable-users-management": "Увімкнути керування користувачами", + "enable-assets-management": "Увімкнути керування активами", + "enable-devices-management": "Увімкнути керування пристроями", + "enable-dashboards-management": "Увімкнути керування панелями візуалізації", + "open-details-on": "Відкрити деталі сутності по", + "select-existing": "Виберіть існуючу групу сутностей", + "create-new": "Створити нову групу сутностей", + "new-entity-group-name": "Нове ім'я групи сутностей", + "entity-group-list": "Список групи сутностей", + "entity-group-list-empty": "Не вибрано жодної групи сутностей.", + "name-starts-with": "Назва групи сутностей починається з", + "entity-group-name-filter-required": "Необхідно задати назву групи сутностей." + }, + "entity-view": { + "entity-view": "Представлення сутності", + "entity-view-required": "Необхідно вказати представлення сутності.", + "entity-views": "Представлення сутностей", + "management": "Керування представленням сутностей", + "view-entity-views": "Переглянути представлення сутностей", + "entity-view-alias": "Псевдонім представлення сутності", + "aliases": "Псевдоніми представлення сутності", + "no-alias-matching": "Псевдонім'{{alias}}' не знайдено.", + "no-aliases-found": "Псевдоніми не знайдено.", + "no-key-matching": "'Ключ {{key}}' не знайдено.", + "no-keys-found": "Ключі не знайдено.", + "create-new-alias": "Створити новий!", + "create-new-key": "Створити новий!", + "duplicate-alias-error": "Псевдонім з такою назвою вже існує '{{alias}}'.
    Псевдоніми представлення повинні бути унікальними на панелі візуалізації.", + "configure-alias": "Налаштувати псевдонім '{{alias}}'", + "no-entity-views-matching": "Сутності, які відповідають '{{entity}}' не знайдені.", + "alias": "Псевдонім", + "alias-required": "Необхідно вказати псевдонім представлення сутності.", + "remove-alias": "Видалити псевдонім представлення сутності", + "add-alias": "Додати псевдонім представлення сутності", + "name-starts-with": "Ім'я представлення сутності починається з", + "entity-view-list": "Список представленнь сутності", + "use-entity-view-name-filter": "Використати фільтр", + "entity-view-list-empty": "Не вибрано жодного представлення сутності.", + "entity-view-name-filter-required": "Необхідно вказвти фільтр назв представлення сутності.", + "entity-view-name-filter-no-entity-view-matched": "Представлення сутностей, назви яких починаються з '{{entityView}}' не знайдено.", + "add": "Додати представлення сутності", + "assign-to-customer": "Призначити клієнту", + "assign-entity-view-to-customer": "Призначити представлення сутності(ей) клієнту", + "assign-entity-view-to-customer-text": "Будь ласка, виберіть представлення сутності для призначення клієнту", + "no-entity-views-text": "Представлення сутності не знайдено", + "assign-to-customer-text": "Будь ласка, виберіть клієнта, для призначиення представлення(ь) сутності(ей)", + "entity-view-details": "Деталі представлення сутності", + "add-entity-view-text": "Додати нове представлення сутності", + "delete": "Видалити представлення сутності", + "assign-entity-views": "Призначити представлення сутності", + "assign-entity-views-text": "Призначити { count, plural, 1 {1 представлення сутності} other {# представлення сутностей } } клієнту", + "delete-entity-views": "Видалити представлення сутностей", + "unassign-from-customer": "Відкликати у клієнта", + "unassign-entity-views": "Відкликати представлення сутностей", + "unassign-entity-views-action-title": "Відкликати { count, plural, 1 {1 представлення сутності} other {# представлень сутностей} } у клієнта", + "assign-new-entity-view": "Призначити нове представлення сутності", + "delete-entity-view-title": "Ви впевнені, що хочете видалити представлення сутності'{{entityViewName}}'?", + "delete-entity-view-text": "Будьте обережні, після підтвердження, представлення сутності та всі пов'язані з ним дані стануть недоступними.", + "delete-entity-views-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 представлення сутності } other {# представлення сутностей } }?", + "delete-entity-views-action-title": "Видалити { count, plural, 1 {1 представлення сутності } other {# представлення сутностей } }", + "delete-entity-views-text": "Будьте обережні, після підтвердження, всі виділені представлення сутностей та дні, пов'язані з ними стануть недоступними.", + "unassign-entity-view-title": "Ви впевнені, що хочете відкликати представлення сутності '{{entityViewName}}'?", + "unassign-entity-view-text": "Після підтвердження представлення сутності буде відкликане у клієнта. Дані представлення сутності не будуть доступні клієнту.", + "unassign-entity-view": "Відкликати представлення сутності", + "unassign-entity-views-title": "Ви впевнені, що хочете відкликати { count, plural, 1 {1 представлення сутності} other {# представлень сутностей} }?", + "unassign-entity-views-text": "Після підтвердження, клієнта буде позбавлено всіх виділених представлень сутностей. Дані представлень сутностей не будуть доступні клієнту.", + "entity-view-type": "Тип представлення сутності", + "entity-view-type-required": "Необхідно вказати тип представлення сутності.", + "select-entity-view-type": "Виберіть тип представлення сутності", + "enter-entity-view-type": "Введіть тип представлення сутності", + "any-entity-view": "Будь-яке представлення сутності", + "no-entity-view-types-matching": "Не знайдено жодних типів представлення сутності, що відповідають '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Не вибрано тип представлення сутності.", + "entity-view-types": "Типи представлення сутності", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "description": "Опис", + "events": "Події", + "details": "Деталі", + "copyId": "Скопіювати Id представлення сутності", + "assignedToCustomer": "Призначений клієнту", + "unable-entity-view-device-alias-title": "Неможливо видалити псевдонім представлення сутності", + "unable-entity-view-device-alias-text": "Не вдалося видалити псевдонім пристрою'{{entityViewAlias}}', так як він використовується наступним(и) віджетом(ами):
    {{widgetsList}}", + "select-entity-view": "Вибрати представлення сутності", + "make-public": "Зробити представлення сутності публічним", + "make-private": "Зробити представлення сутності приватним", + "start-date": "Дата початку", + "start-ts": "Час початку", + "end-date": "Дата закінчення", + "end-ts": "Час завершення", + "date-limits": "Обмеження дати", + "client-attributes": "Атрибути клієнта", + "shared-attributes": "Спільні атрибути", + "server-attributes": "Атрибути сервера", + "timeseries": "Телеметрія", + "client-attributes-placeholder": "Атрибути клієнта", + "shared-attributes-placeholder": "Спільні атрибути", + "server-attributes-placeholder": "Атрибути сервера", + "timeseries-placeholder": "Телеметрія", + "target-entity": "Цільова сутність", + "attributes-propagation": "Поширення атрибутів", + "attributes-propagation-hint": "Представлення сутності автоматично копіюватиме вказані атрибути з цільової сутності кожного разу, коли ви зберігаєте або оновлюєте його. В цілях продуктивності, атрибути цільової сутності не поширюються на представлення сутності при кожній зміні її атрибутів. Можна ввімкнути автоматичне поширення, налаштувавши правило \"copy to view\" у вашому ланцюжку правил і пов'язуючи його з повідомленнями типу \"Post attributes\" і \"Attributes Updated\"..", + "timeseries-data": "Дані телеметрії", + "timeseries-data-hint": "Налаштуйте ключі даних телеметрії цільової сутності, які будуть доступні представленню сутності. Ці дані доступні лише для читання.", + "make-public-entity-view-title": "Ви впевнені, що бажаєте зробити представлення сутності '{{entityViewName}}' публічним?", + "make-public-entity-view-text": "Після підтвердження представлення сутності і всі пов'язані з ним дані стануть публічними і будуть доступні для інших користувачів.", + "make-private-entity-view-title": "Ви впевнені, що бажаєте зробити представлення сутності '{{entityViewName}}' приватним?", + "make-private-entity-view-text": "Після підтвердження представлення сутності і всі пов'язані з ним дані стануть приватними і не будуть доступні для інших користувачів." + }, + "event": { + "events": "Події", + "event-type": "Тип події", + "type-error": "Помилка", + "type-lc-event": "Подія життєвого циклу", + "type-stats": "Статистика", + "type-debug-converter": "Налагоджувати", + "type-debug-integration": "Налагоджувати", + "type-debug-rule-node": "Налагоджувати", + "type-debug-rule-chain": "Налагоджувати", + "no-events-prompt": "Не знайдено жодних подій", + "error": "Помилка", + "alarm": "Сигнал тривоги", + "event-time": "Час події", + "server": "Сервер", + "body": "Тіло", + "method": "Метод", + "type": "Тип", + "in": "In", + "out": "Out", + "metadata": "Метадані", + "message": "Повідомлення", + "message-id": "Id повідомлення", + "message-type": "Тип повідомлення", + "data-type": "Тип даних", + "relation-type": "Тип зв'язку", + "data": "Дані", + "event": "Подія", + "status": "Статус", + "success": "Успіх", + "failed": "Невдача", + "messages-processed": "Повідомлення опрацьовані", + "errors-occurred": "Виникли помилки", + "all-events": "Всі", + "entity-type": "Тип сутності", + "clear-request-title": "Видалити всі події", + "clear-request-text": "Ви впевнені, що хочете видалити всі події?" + }, + "extension": { + "extensions": "Розширення", + "selected-extensions": " вибрано { count, plural, 1 {1 розширення} other {# розширення} }", + "type": "Тип", + "key": "Ключ", + "value": "Значення", + "id": "Id", + "extension-id": "Id розширення", + "extension-type": "Тип розширення", + "transformer-json": "JSON *", + "unique-id-required": "Таке Id розширення вже існує.", + "delete": "Видалити розширення", + "add": "Додати розширення", + "edit": "Редагувати розширення", + "delete-extension-title": "Ви дійсно бажаєте видалити розширення '{{extensionId}}'?", + "delete-extension-text": "Будьте обережні, після підтвердження, розширення та всі пов'язані з ним дані стануть недоступними.", + "delete-extensions-title": "Ви дійсно бажаєте видалити { count, plural, 1 {1 розширення} other {# розширення} }?", + "delete-extensions-text": "Будьте обережні, після підтвердження, всі вибрані розширення будуть видалені.", + "converters": "Перетворювачі", + "converter-id": "Id перетворювача", + "configuration": "Конфігурація", + "converter-configurations": "Конфігурації перетворювача", + "token": "Маркер безпеки", + "add-converter": "Додати конвертер", + "add-config": "Додати конфігурацію конвертера", + "device-name-expression": "Маска імені пристрою", + "device-type-expression": "Маска типу пристрою", + "custom": "Користувач", + "to-double": "Подвоїти", + "transformer": "Трансформатор", + "json-required": "Необхідно вказати json трансформатора.", + "json-parse": "Неможливо проаналізувати json трансформатора.", + "attributes": "Атрибути", + "add-attribute": "Додати атрибут", + "add-map": "Додати елемент відображення", + "timeseries": "Телеметрія", + "add-timeseries": "Додати параметри телеметрії", + "field-required": "Field is required", + "brokers": "Брокери", + "add-broker": "Додати брокера", + "host": "Хост", + "port": "Порт", + "port-range": "Значення порту має бути в діапазоні від 1 до 65535.", + "ssl": "Ssl", + "credentials": "Авторизаційні дані", + "username": "Ім'я користувача", + "password": "Пароль", + "retry-interval": "Інтервал повтору в мілісекундах", + "anonymous": "Анонімний", + "basic": "Основний", + "pem": "PEM", + "ca-cert": "Файл CA сертифіката *", + "private-key": "Файл приватного ключа *", + "cert": "Файл сертифіката *", + "no-file": "Не вибрано жодного файлу.", + "drop-file": "Перетягніть файл, або клацніть, щоб вибрати файл для завантаження.", + "mapping": "Зіставлення", + "topic-filter": "Фільтр тем", + "converter-type": "Тип конвертера", + "converter-json": "Json", + "json-name-expression": " Json вираз для назви пристрою", + "topic-name-expression": "Вираз для назви пристрою в назві теми", + "json-type-expression": "Json вираз для типу пристрою", + "topic-type-expression": "Вираз для типу пристрою в назві теми", + "attribute-key-expression": "Вираз для ключа атрибута", + "attr-json-key-expression": " Json вираз для ключа атрибута", + "attr-topic-key-expression": "Вираз для ключа атрибута в назві теми", + "request-id-expression": "Вираз для id запиту", + "request-id-json-expression": "Json вираз для id запиту", + "request-id-topic-expression": "Вираз для id запиту в назві теми", + "response-topic-expression": "Вираз для теми відповідей", + "value-expression": "Вираз для значення", + "topic": "Тема", + "timeout": "Час очікування в мілісекундах", + "converter-json-required": "Необхідно вказати json конвертер.", + "converter-json-parse": "Неможливо проаналізувати json конвертера.", + "filter-expression": "Вираз для фільтра", + "connect-requests": "Запити на підключення", + "add-connect-request": "Додати запит на підключення", + "disconnect-requests": "Відключення запитів", + "add-disconnect-request": "Додати запит на відключення", + "attribute-requests": "Запити атрибутів", + "add-attribute-request": "Додати запит атрибута", + "attribute-updates": "Оновлення атрибутів", + "add-attribute-update": "Додати оновлення атрибутів", + "server-side-rpc": "Серверна сторона RPC", + "add-server-side-rpc-request": "Додати RPC-запит на стороні сервера", + "device-name-filter": "Фільтр назви пристрою", + "attribute-filter": "Фільтр атрибутів", + "method-filter": "Фільтр методів", + "request-topic-expression": "Вираз для теми запитів", + "response-timeout": "Час очікування відповіді в мілісекундах", + "topic-expression": "Вираз для назви теми", + "client-scope": "Обсяг клієнта", + "add-device": "Додати пристрій", + "opc-server": "Сервери", + "opc-add-server": "Додати сервер", + "opc-add-server-prompt": "Будь ласка, додайте сервер", + "opc-application-name": "Назва програми", + "opc-application-uri": "URI програми", + "opc-scan-period-in-seconds": "Період сканування в секундах", + "opc-security": "Безпека", + "opc-identity": "Ідентифікація", + "opc-keystore": "Сховище ключів", + "opc-type": "Тип", + "opc-keystore-type": "Тип", + "opc-keystore-location": "Розташування *", + "opc-keystore-password": "Пароль", + "opc-keystore-alias": "Псевдонім", + "opc-keystore-key-password": "Пароль для ключа", + "opc-device-node-pattern": "Патерн OPC вузла пристрою", + "opc-device-name-pattern": "Патерн назви пристрою", + "modbus-server": "Сервери/ведені пристрої", + "modbus-add-server": "Додати сервер/ведений пристрій", + "modbus-add-server-prompt": "Будь ласка, додайте сервер/ведений пристрій", + "modbus-transport": "Транспорт", + "modbus-tcp-reconnect": "Перепідключатися автоматично", + "modbus-port-name": "Ім'я послідовного порту", + "modbus-encoding": "Кодування", + "modbus-parity": "Паритет", + "modbus-baudrate": "Швидкість передачі даних", + "modbus-databits": "Біти даних", + "modbus-stopbits": "Стоп-біти", + "modbus-databits-range": "Біти даних повинні знаходитися в діапазоні від 7 до 8.", + "modbus-stopbits-range": "Стоп-біти повинні знаходитися в діапазоні від 1 до 2.", + "modbus-unit-id": "Unit ID", + "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-device-name": "Ім'я пристрою", + "modbus-poll-period": "Період опитування (мс)", + "modbus-attributes-poll-period": "Період опитування атрибутів (мс)", + "modbus-timeseries-poll-period": "Період опитування телеметрії (мс)", + "modbus-poll-period-range": "Період опитування повинен бути більше 0.", + "modbus-tag": "Тег", + "modbus-function": "Modbus функція", + "modbus-register-address": "Адреса регістру ", + "modbus-register-address-range": "Адреса регістру повинна бути в діапазоні від 0 до 65535.", + "modbus-register-bit-index": "Номер бітів", + "modbus-register-bit-index-range": "Номер бітів повинен знаходитися в діапазоні від 0 до 15.", + "modbus-register-count": "Рахунок регістру", + "modbus-register-count-range": "Рахунок регістру повинен бути більше 0.", + "modbus-byte-order": "Порядок байтів", + "sync": { + "status": "Статус", + "sync": "Синхронізований", + "not-sync": "Не синхронізований", + "last-sync-time": "Час останньої синхронізації", + "not-available": "Недоступний" + }, + "export-extensions-configuration": "Експортувати конфігурацію розширень", + "import-extensions-configuration": "Імпортувати конфігурацію розширень", + "import-extensions": "Імпортувати розширення", + "import-extension": "Імпортувати розширення", + "export-extension": "Експортувати розширення", + "file": "Файл розширень", + "invalid-file-error": "Не правильний формат файла" + }, + "fullscreen": { + "expand": "Відкрити у повноекранному режимі", + "exit": "Вийти з повноекранного режиму", + "toggle": "Перемкнути повноекранний режим", + "fullscreen": "Повноекранний режим" + }, + "function": { + "function": "Функція" + }, + "grid": { + "delete-item-title": "Ви впенені, що хочете видалити цей елемент?", + "delete-item-text": "Будьте обережні, після підтвердження, цей елемент і всі пов'язані з ним дані, стануть недоступними.", + "delete-items-title": "Ви впенені, що хочете видалити { count, plural, 1 {1 елемент} other {# елементи} }?", + "delete-items-action-title": "Видалити{ count, plural, 1 {1 елемент} other {# елементи} }", + "delete-items-text": "Будьте обережні, після підтвердження, всі виділені елементи і пов'язані з ними дані, стануть недоступними.", + "add-item-text": "Додати новий елемент", + "no-items-text": "Не знайдено жодного елемента", + "item-details": "Деталі елемента", + "delete-item": "Видалити елемент", + "delete-items": "Видалити елементи", + "scroll-to-top": "Перейти угору" + }, + "help": { + "goto-help-page": "Перейти на сторінку довідки" + }, + "home": { + "home": "Домашня сторінка", + "profile": "Профіль", + "logout": "Вийти", + "menu": "Меню", + "avatar": "Аватар", + "open-user-menu": "Відкрити меню користувача" + }, + "import": { + "no-file": "Не вибрано жодного файлу", + "drop-file": "Перетягніть JSON файл, або клацніть, щоб вибрати файл для завантаження.", + "drop-csv-file": "Перетягніть CSV файл, або клацніть, щоб вибрати файл для завантаження.", + "column-value": "Значення", + "column-title": "Назва", + "column-example": "Приклад значень даних", + "column-key": "Ключ атрибута/телеметрії", + "csv-delimiter": "Розділювач в CSV файлі", + "csv-first-line-header": "Перший рядок містить назви колонок", + "csv-update-data": "Оновити атрибути/телеметрію", + "import-csv-number-columns-error": "Файл має містити як мінімум дві колонки", + "import-csv-invalid-format-error": "Невірний формат даних. Рядок: '{{line}}'", + "column-type": { + "name": "Назва", + "type": "Тип", + "label": "Мітка", + "column-type": "Тип колонки", + "client-attribute": "Атрибут клієнта", + "shared-attribute": "Спільний атрибут", + "server-attribute": "Атрибут сервера", + "timeseries": "Телеметрія", + "entity-field": "Entity field", + "access-token": "Токен" + }, + "stepper-text": { + "select-file": "Виберіть файл", + "configuration": "Конфігурація імпорту", + "column-type": "Виберіть тип колонок", + "creat-entities": "Створення нових сутностей" + }, + "message": { + "create-entities": "{{count}} нову(их) сутність(ей) успішно створено.", + "update-entities": "{{count}} сутність(ей) успішно оновлено.", + "error-entities": "Виникла помилка при створенні {{count}} сутності(ей)." + } + }, + "integration": { + "integration": "Інтеграція", + "integrations": "Інтеграції", + "select-integration": "Виберіть інтеграцію", + "no-integrations-matching": "Не знайдено жодних інтеграцій, які відповідають '{{entity}}'.", + "integration-required": "Необхідно вказати інтеграцію", + "delete": "Видалити інтеграцію", + "management": "Управління інтеграціями", + "add-integration-text": "Додати нову інтеграцію", + "no-integrations-text": "Не знайдено жодної інтеграції", + "selected-integrations": "{ count, plural, 1 {1 інтеграція} other {# інтеграції} } вибрано", + "delete-integration-title": "Ви впевнені, що хочете видалити інтеграцію '{{integrationName}}'?", + "delete-integration-text": "Будьте обережні, після підтвердження інтеграція та всі пов'язані з нею дані стануть недоступними.", + "delete-integrations-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 інтеграцію} other {# інтеграції} }?", + "delete-integrations-action-title": "Видалити { count, plural, 1 {1 інтеграцію} other {# інтеграції} }", + "delete-integrations-text": "Будьте обережні, після підтвердження всі вибрані інтеграції будуть видалені, і всі пов'язані з ними дані стануть недоступними.", + "events": "Події", + "add": "Додати інтеграцію", + "integration-details": "Деталі інтеграції", + "details": "Деталі", + "copyId": "Копіювати Id інтеграції", + "idCopiedMessage": "Id інтеграції скопійовано в буфер обміну", + "debug-mode": "Режим налагодження", + "enable-security": "Увімкнути безпеку", + "headers-filter": "Заголовки фільтра", + "header": "Заголовок", + "no-headers-filter": "Немає фільтрів заголовків", + "downlink-url": "Downlink URL", + "application-uri": "URI програми", + "as-id": "AS ID", + "as-id-required": "Необхідно вказати AS ID.", + "as-key": "AS ключ", + "as-key-required": "Необхідно вказати AS ключ.", + "max-time-diff-in-seconds": "Максимальна різниця в часі (секунди)", + "max-time-diff-in-seconds-required": "Необхідно вказати максимальну різницю в часі.", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "description": "Опис", + "base-url": "Базова URL-адреса", + "base-url-required": "Необхідно вказати базову URL-адресу", + "security-key": "Ключ захисту", + "http-endpoint": "URL кінцевої точки HTTP", + "copy-http-endpoint-url": "Скопіювати URL-адресу кінцевої точки HTTP", + "http-endpoint-url-copied-message": "URL кінцевої точки HTTP скопійовано в буфер обміну", + "host": "Хост", + "host-required": "Необхідно вказати хост.", + "host-type": "Тип хоста", + "host-type-required": "Необхідно вказати тип хоста.", + "custom-host": "Хост користувача", + "custom-host-required": "Необхідний спеціальний хост.", + "port": "Порт", + "port-required": "Необхідно вказати порт.", + "port-range": "Порт має бути в діапазоні від 1 до 65535.", + "connect-timeout": "Час очікування з'єднання (сек)", + "connect-timeout-required": " Необхідно вказати час з'єднання підключення.", + "connect-timeout-range": "Час очікування з'єднання має бути в діапазоні від 1 до 200.", + "client-id": "ID клієнта", + "clean-session": "Очистити сеанс", + "enable-ssl": "Увімкнути SSL", + "credentials": "Авторизаційні дані", + "credentials-type": "Тип авторизаційних даних", + "credentials-type-required": "Необхідно вказати тип авторизаційних даних.", + "username": "Ім'я користувача", + "username-required": "Необхідно вказати ім'я користувача.", + "password": "Пароль", + "password-required": "Необхідно вказати пароль.", + "ca-cert": "Файл сертифіката CA *", + "private-key": "Файл приватного ключа *", + "private-key-password": "Пароль приватного ключа", + "cert": "Файл сертифіката*", + "no-file": "Не вибрано жодного файлу.", + "drop-file": "Перетягніть файл, або клацніть, щоб вибрати файл для завантаження.", + "topic-filters": "Тематичні фільтри", + "remove-topic-filter": "Видалити фільтр тем", + "add-topic-filter": "Додати фільтр тем", + "add-topic-filter-prompt": "Будь ласка, додайте фільтр тем", + "topic": "Тема", + "mqtt-qos": "QoS", + "mqtt-qos-at-most-once": "Не більше одного разу", + "mqtt-qos-at-least-once": "Принаймні, один раз", + "mqtt-qos-exactly-once": "Точно один раз", + "downlink-topic-pattern": "Downlink topic pattern", + "downlink-topic-pattern-required": "Downlink topic pattern is required.", + "aws-iot-endpoint": "AWS IoT Endpoint", + "aws-iot-endpoint-required": "AWS IoT Endpoint is required.", + "aws-iot-credentials": "Авторизаційні дані AWS IoT", + "application-credentials": "Авторизаційні дані додатків", + "api-key": "API ключ", + "api-key-required": "Необхідно вказати API ключ.", + "auth-token": "Маркер аутентифікації", + "auth-token-required": "Необхідно вказати маркер аутентифікації.", + "region": "Регіон", + "region-required": "Необхідно вказати регіон.", + "application-id": "ID програми", + "application-id-required": "Необхідно вказати ID програми.", + "access-key": "Ключ доступу", + "access-key-required": "Необхідно вказати ключ доступу.", + "connection-parameters": "Параметри підключення", + "service-bus-namespace-name": "Service Bus Namespace Name", + "service-bus-namespace-name-required": "Необхідно вказати Service Bus Namespace Name is required.", + "event-hub-name": "Event Hub Name", + "event-hub-name-required": "Необхідно вказати Event Hub Name is required.", + "sas-key-name": "Назва ключа SAS", + "sas-key-name-required": "Необхідно вказати назву ключа SAS.", + "sas-key": "Ключ SAS", + "sas-key-required": "SAS Key is required.", + "iot-hub-name": "IoT Hub Name (required for downlink)", + "metadata": "Метадані", + "type": "Тип", + "type-required": "Необхідно вказати тип.", + "uplink-converter": "Конвертер передачі даних", + "uplink-converter-required": "Необхідно вказати конвертер передачі даних.", + "downlink-converter": "Downlink data converter", + "type-http": "HTTP", + "type-ocean-connect": "OceanConnect", + "type-sigfox": "SigFox", + "type-thingpark": "ThingPark", + "type-tmobile-iot-cdp": "T-Mobile – IoT CDP", + "type-mqtt": "MQTT", + "type-aws-iot": "AWS IoT", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ttn": "TheThingsNetwork", + "type-azure-event-hub": "Azure Event Hub", + "type-opc-ua": "OPC-UA", + "type-ffb": "FFB", + "opc-ua-application-name": "Назва програми", + "opc-ua-application-uri": "Application uri", + "opc-ua-scan-period-in-seconds": "Період сканування в секундах", + "opc-ua-scan-period-in-seconds-required": "Необхідно вказати період сканування в секундах", + "opc-ua-timeout": "Час очікування в мілісекундах", + "opc-ua-timeout-required": "Необхідно вказати час очікування в мілісекундах", + "opc-ua-security": "Безпека", + "opc-ua-security-required": "Необхідно задати безпеку", + "opc-ua-identity": "Ідентифікація", + "opc-ua-identity-required": "Необхідно вказати ідентифікацію", + "opc-ua-keystore": "Сховище ключів", + "add-opc-ua-keystore-prompt": "Будь ласка, додайте файл сховища ключів", + "opc-ua-keystore-required": "Необхідно вказати сховище ключів", + "opc-ua-type": "Тип", + "opc-ua-keystore-type": "Тип сховища ключів", + "opc-ua-keystore-type-required": "Необхідно вказати тип", + "opc-ua-keystore-location": "Розташування *", + "opc-ua-keystore-password": "Пароль", + "opc-ua-keystore-password-required": "Необхідно вказати пароль", + "opc-ua-keystore-alias": "Псевдонім", + "opc-ua-keystore-alias-required": "Необхідно вказати псевдонім", + "opc-ua-keystore-key-password": "Пароль ключа", + "opc-ua-keystore-key-password-required": "Необхідно вказати пароль ключа", + "opc-ua-mapping": "Зіставлення", + "add-opc-ua-mapping-prompt": "Будь ласка, додайте зіставлення", + "opc-ua-mapping-type": "Тип зіставлення", + "opc-ua-mapping-type-required": "Необхідно вказати тип зіставлення", + "opc-ua-device-node-pattern": "Шаблон вузла пристрою", + "opc-ua-device-node-pattern-required": "Необхідно вказати шаблон вузла пристрою", + "opc-ua-add-map": "Додати елемент зіставлення", + "subscription-tags": "Теги передплати Теги підписки", + "remove-subscription-tag": "Видалити тег підписки", + "add-subscription-tag": "Додати тег підписки", + "add-subscription-tag-prompt": "Будь ласка, додайте тег підписки", + "key": "Ключ", + "path": "Шлях", + "required": "Необхідно" + }, + "item": { + "selected": "Вибрані" + }, + "js-func": { + "no-return-error": "Функція повинна повертати значення!", + "return-type-mismatch": "Функція повинна повернути значення типу '{{type}}'!" + }, + "key-val": { + "key": "Ключ", + "value": "Значення", + "remove-entry": "Видалити елемент", + "add-entry": "Додати елемент", + "no-data": "Елементи відсутні" + }, + "layout": { + "layout": "Макет", + "manage": "Керування макетами", + "settings": "Налаштування макета", + "color": "Колір", + "main": "Основний", + "right": "Правий", + "select": "Вибрати макет" + }, + "legend": { + "direction": "Розташування елементів легенди", + "position": "Розташування легенди", + "show-max": "Показати максимальне значення", + "show-min": "Показати мінімальне значення ", + "show-avg": "Показати середнє значення", + "show-total": "Показати суму", + "settings": "Налаштування легенди", + "min": "мін", + "max": "макс", + "avg": "середнє", + "total": "Сума", + "comparison-time-ago": { + "days": "(день тому)", + "weeks": "(тиждень тому)", + "months": "(місяць тому)", + "years": "(рік тому)" + } + }, + "login": { + "login": "Увійти", + "request-password-reset": "Запит скидання пароля", + "reset-password": "Скинути пароль", + "create-password": "Створити пароль", + "passwords-mismatch-error": "Введені паролі повинні бути однаковими!", + "password-again": "Введіть пароль ще раз", + "sign-in": "Будь ласка, увійдіть в систему", + "username": "Ім'я користувача (ел. пошта)", + "remember-me": "Запам'ятати мене", + "forgot-password": "Забули пароль?", + "password-reset": "Скидання пароля", + "expired-password-reset-message": "Термін дії Вашого паролю закінчився! Будь ласка, створіть новий пароль.", + "new-password": "Новий пароль", + "new-password-again": "Повторіть новий пароль", + "password-link-sent-message": "Посилання для скидання пароля було успішно надіслано!", + "email": "Електронна пошта", + "login-with": "Увійти через {{name}}", + "or": "або" + }, + "position": { + "top": "Угорі", + "bottom": "Знизу", + "left": "Ліворуч", + "right": "Праворуч" + }, + "profile": { + "profile": "Профіль", + "last-login-time": "Час останнього входу", + "change-password": "Змінити пароль", + "current-password": "Поточний пароль", + "copy-jwt-token": "Копіювати JWT токен", + "tokenCopiedMessage": "JWT токен скопійовано в буфер обміну", + "tokenCopiedWarnMessage": "JWT токен не є дійсним! Перезавантажте сторінку." + }, + "relation": { + "relations": "Відношення", + "direction": "Напрямок", + "search-direction": { + "FROM": "З", + "TO": "До" + }, + "direction-type": { + "FROM": "з", + "TO": "до" + }, + "from-relations": "Вихідні відношення", + "to-relations": "Вхідні відношення", + "selected-relations": "Вибрано { count, plural, 1 {1 відношення} other {# відношення} }", + "type": "Тип", + "to-entity-type": "До типу сутності", + "to-entity-name": "До імені сутності", + "from-entity-type": "Від типу сутності", + "from-entity-name": "Від імені сутності", + "to-entity": "До сутності", + "from-entity": "Від сутності", + "delete": "Видалити відношення", + "relation-type": "Тип відношення", + "relation-type-required": "Необхідно вказати тип відношення.", + "any-relation-type": "Будь-який тип", + "add": "Додати відношення", + "edit": "Редагувати відношення", + "delete-to-relation-title": "Ви впевнені, що хочете видалити відношення до сутності '{{entityName}}'?", + "delete-to-relation-text": "Будьте обережні, після підтвердження, сутність '{{entityName}}' не буде пов'язана з поточним об'єктом.", + "delete-to-relations-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 відношення} other {# відношення} }?", + "delete-to-relations-text": "Будьте обережні, після підтвердження, всі вибрані відношення стануть не пов'язані з поточною сутністю.", + "delete-from-relation-title": "Ви впевнені, що хочете видалити зв'язок, який йде від сутності '{{entityName}}'?", + "delete-from-relation-text": "Будьте обережні, після підтвердження поточна сутність буде відв'язана від сутності '{{entityName}}'.", + "delete-from-relations-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 відношення} other {# відношення} }?", + "delete-from-relations-text": "Будьте обережні, після підтвердження, всі вибрані відношення будуть видалені, а поточна сутність стане не зв'язаною з відповідними сутностями.", + "remove-relation-filter": "Видалити фільтр відношення", + "add-relation-filter": "Додати фільтр відношення", + "any-relation": "Будь-яке відношення", + "relation-filters": "Фільтри відношення", + "additional-info": "Додаткова інформація (JSON)", + "invalid-additional-info": "Не вдалося розібрати JSON з додатковою інформацією ." + }, + "rulechain": { + "rulechain": "Ланцюг правил", + "rulechains": "Ланцюги правил", + "root": "Основний", + "delete": "Видалити ланцюг правил", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "description": "Опис", + "add": "Додати ланцюг правил", + "set-root": "Зробити ланцюг правил основним", + "set-root-rulechain-title": "Ви впевнені, що хочете зробити ланцюг правил '{{ruleChainName}}' основним?", + "set-root-rulechain-text": "Після підтвердження ланцюг правил стане основним і буде обробляти всі вхідні транспортні повідомлення.", + "delete-rulechain-title": "Ви впевнені, що хочете видалити ланцюг правил '{{ruleChainName}}'?", + "delete-rulechain-text": "Будьте обережні, після підтвердження ланцюг правил і всі пов'язані з ним дані стануть недоступними.", + "delete-rulechains-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 ланцюг правил} other {# ланцюги правил} }?", + "delete-rulechains-action-title": "Видалити{ count, plural, 1 {1 ланцюг правил} other {# ланцюги правил} }", + "delete-rulechains-text": "Будьте обережні, після підтвердження, вибрані ланцюги правил і всі пов'язані з ними дані стануть недоступними.", + "add-rulechain-text": "Додати новий ланцюг правил", + "no-rulechains-text": "Ланцюг правил не знайдено", + "rulechain-details": "Деталі ланцюга правил", + "details": "Деталі", + "events": "Події", + "system": "Система", + "import": "Імпортувати ланцюг правил", + "export": "Експортувати ланцюг правил", + "export-failed-error": "Не вдалося експортувати ланцюг правил: {{error}}", + "create-new-rulechain": "Створити новий ланцюг правил", + "rulechain-file": "Файл ланцюга правил", + "invalid-rulechain-file-error": "Неможливо імпортувати ланцюг правил: недійсна структуру даних ланцюга правил.", + "copyId": "Копіювати Id ланцюга правил", + "idCopiedMessage": "Id ланцюга правил скопійовано в буфер обміну", + "select-rulechain": "Вибрати ланцюг правил", + "no-rulechains-matching": "Не знайдено жодних ланцюгів правил, які відповідають '{{entity}}'.", + "rulechain-required": "Необхідно вказати ланцюг правил", + "management": "Управління ланцюгами правил", + "debug-mode": "Режим налагодження" + }, + "rulenode": { + "details": "Деталі", + "events": "Події", + "search": "Пошук вузлів", + "open-node-library": "Відкрити бібліотеку вузлів", + "add": "Додати вузол правил", + "name": "Ім'я", + "name-required": "Необхідно вказати ім'я.", + "type": "Тип", + "description": "Опис", + "delete": "Видалити вузол правил", + "select-all-objects": "Вибрати усі вузли та з'єднання", + "deselect-all-objects": "Зняти виділення з усіх вузлів і з'єднань", + "delete-selected-objects": "Видалити вибрані вузли та з'єднання", + "delete-selected": "Видалити вибране", + "select-all": "Вибрати все", + "copy-selected": "Копіювати вибране", + "deselect-all": "Відмінити вибране", + "rulenode-details": "Деталі вузла правил", + "debug-mode": "Режим налагодження", + "configuration": "Конфігурація", + "link": "Посилання", + "link-details": "Деталі посилання про вузол правил", + "add-link": "Додати посилання", + "link-label": "Мітка посилання", + "link-label-required": "Необхідно вказати мітку посилання.", + "custom-link-label": "Мітка посилання користувача", + "custom-link-label-required": "Необхідно вказати мітку посилання користувача.", + "link-labels": "Мітки посилання", + "link-labels-required": "Необхідно вказати мітки посилання.", + "no-link-labels-found": "Не знайдено жодних міток посилання", + "no-link-label-matching": "Мітка'{{label}}' не знайдена.", + "create-new-link-label": "Створити нову!", + "type-filter": "Фільтр", + "type-filter-details": "Фільтрувати вхідні повідомлення з заданими умовами", + "type-enrichment": "Насичення", + "type-enrichment-details": "Додати додаткову інформацію до метаданих повідомлень", + "type-transformation": "Трансформація", + "type-transformation-details": "Змінити склад повідомлення та його метадані", + "type-action": "Дія", + "type-action-details": "Виконати задану дію", + "type-analytics": "Аналітика", + "type-analytics-details": "Виконує аналіз потокових або збережених даних", + "type-external": "Зовнішній", + "type-external-details": "Взаємодіє з зовнішньою системою", + "type-rule-chain": "Ланцюг правил", + "type-rule-chain-details": "Перенаправити вхідне повідомлення на вказаний ланцюг правил", + "type-input": "Вхід", + "type-input-details": "Логічний вхід ланцюга правил, перенаправляє вхідні повідомлення на наступний пов'язаний вузол правил", + "type-unknown": "Невідомий", + "type-unknown-details": "Невизначений вузол правил", + "directive-is-not-loaded": "Вказана директива конфігурації '{{directiveName}}' недоступна.", + "ui-resources-load-error": "Не вдалося завантажити UI ресурси.", + "invalid-target-rulechain": "Не вдається визначити цільовий ланцюг правил!", + "test-script-function": "Протестувати скрипт", + "message": "Повідомлення", + "message-type": "Тип повідомлення", + "select-message-type": "Вибрати тип повідомлення", + "message-type-required": "Необхідно вказати тип повідомлення", + "metadata": "Метадані", + "metadata-required": "Записи метаданих не можуть бути порожніми.", + "output": "Вихід", + "test": "Тест", + "help": "Допомога", + "reset-debug-mode": "Вимкнути режим налогодження у всіх правилах" + }, + "scheduler": { + "scheduler": "Планувальник", + "scheduler-event": "Подія планувальника", + "select-scheduler-event": "Вибрати подію", + "no-scheduler-events-matching": "Не знайдено жодних подій, які відповідають '{{entity}}'.", + "scheduler-event-required": "Необхвдно вказати заплановану подію", + "management": "Управління планувальником", + "scheduler-events": "Планування подій", + "add-scheduler-event": "Додати подію", + "search-scheduler-events": "Пошук події", + "created-time": "Час створення", + "name": "Ім'я", + "type": "Тип", + "created_customer": "Створено клієнтом", + "edit-scheduler-event": "Редагувати подію", + "delete-scheduler-event": "Видалити подію", + "no-scheduler-events": "Не знайдено жодних запланованих подій", + "selected-scheduler-events": "{ count, plural, 1 {1 запланована подія} other {# заплановані події} } вибрано", + "delete-scheduler-event-title": "Ви впевнені, що хочете видалити подію '{{schedulerEventName}}'?", + "delete-scheduler-event-text": "Будьте обережні, після підтвердження подія і всі пов'язані з нею дані стануть недоступними.", + "delete-scheduler-events-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 запланована подія} other {# заплановані події} }?", + "delete-scheduler-events-text": "Будьте обережні, після підтвердження всі вибрані події будуть видалені, і всі пов'язані з ними дані стануть недоступними.", + "create": "Створити подію планувальника", + "edit": "Змінити подію планувальника", + "name-required": "Необхідно задати ім'я", + "configuration": "Конфігурація", + "schedule": "Розклад", + "start-time": "Початок", + "repeat": "Повтор", + "repeats": "Повтори", + "daily": "Щодня", + "weekly": "Щотижня", + "repeats-required": "Потрібно вказати повторення.", + "repeat-on": "Повторювати по", + "repeat-every": "Повторювати кожний(у)", + "ends-on": "Завершення", + "sunday-label": "Нд", + "monday-label": "Пн", + "tuesday-label": "Вт", + "wednesday-label": "Ср", + "thursday-label": "Чт", + "friday-label": "Пт", + "saturday-label": "Сб", + "repeat-on-sunday": "Повторити у неділю", + "repeat-on-monday": "Повторити в понеділок", + "repeat-on-tuesday": "Повторити у вівторок", + "repeat-on-wednesday": "Повторити в середу", + "repeat-on-thursday": "Повторити в червер", + "repeat-on-friday": "Повторити в п'ятницю", + "repeat-on-saturday": "Повторити в суботу", + "event-type": "Тип події", + "select-event-type": "Вибрати тип події", + "event-type-required": "Необхідно вказати типи події.", + "list-mode": "Перегляд списку", + "calendar-mode": "Перегляд календаря", + "calendar-view-type": "Тип перегляду календаря", + "month": "Місяць", + "week": "Тиждень", + "day": "День", + "agenda-week": "Порядок тижня", + "agenda-day": "Порялок дня", + "list-year": "Список року", + "list-month": "Список місяця", + "list-week": "Список тижня", + "list-day": "Список дня", + "today": "Сьогодні", + "navigate-before": "Перейти до", + "navigate-next": "Перейти далі", + "starting-from": "Починаючи з", + "until": "до", + "on": "в", + "sunday": "Неділя", + "monday": "Понеділок", + "tuesday": "Вівторок", + "wednesday": "Середа", + "thursday": "Четвер", + "friday": "П'ятниця", + "saturday": "Субота", + "originator": "Ініціатор", + "single-entity": "Єдина сутність", + "group-of-entities": "Група сутностей", + "single-device": "Один пристрій", + "group-of-devices": "Група пристроїв", + "message-body": "Текст повідомлення", + "target": "Ціль", + "rpc-method": "Метод", + "rpc-method-required": "Необхідно вказати метод", + "rpc-params": "Параметри", + "select-dashboard-state": "Виберіть стан панелі візуалізації", + "hours": "Години", + "minutes": "Хвилини", + "seconds": "Секунди", + "time-interval-required": "Необхідно вказати часовий інтервал", + "time-unit-required": "Необхідно вказати одиниці часу" + }, + "report": { + "report-config": "Конфігурація звіту", + "email-config": "Конфігурація електронної пошти", + "dashboard-state-param": "Значення параметра стану панелі візуалізації", + "base-url": "Базова URL-адреса", + "base-url-required": "Необхідно вказати базову URL-адресу.", + "use-dashboard-timewindow": "Використовуйте вікно часу на панелі інструментів", + "timewindow": "Вікно часу", + "name-pattern": "Шаблон імені звіту", + "name-pattern-required": "Необхідно задати шаблон назви звіту", + "type": "Report type", + "use-current-user-credentials": "Використовувати поточні авторизаційні дані користувача", + "customer-user-credentials": "Авторизаційні дані користувачів", + "customer-user-credentials-required": "Необхідно задати авторизаційні дані користувачів", + "generate-test-report": "Створити звіт про перевірку", + "send-email": "Відправити лист", + "from": "Від", + "from-required": "Необхідно вказати від кого.", + "to": "До", + "to-required": "Необхідно вказати до кого.", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Тема", + "subject-required": "Необхідно вказати тему.", + "body": "Текст", + "body-required": "Лист не може бути пустим." + }, + "blob-entity": { + "blob-entity": "Blob сутності", + "select-blob-entity": "Вибрати blob сутності", + "no-blob-entities-matching": "Не знайдено жодних сутностей blob, які відповідають '{{entity}}'.", + "blob-entity-required": "Необхідно вказати blob сутності", + "files": "Файли", + "search": "Пошук файлів", + "clear-search": "Очистити пошук", + "no-blob-entities-prompt": "Файлів не знайдено", + "report": "Звіт", + "created-time": "Час створення", + "name": "Ім'я", + "type": "Тип", + "created_customer": "Створено клієнтом", + "download-blob-entity": "Завантажити файл", + "delete-blob-entity": "Видалити файл", + "delete-blob-entity-title": "Ви впевнені, що хочете видалити файл '{{blobEntityName}}'?", + "delete-blob-entity-text": "Будьте обережні, після підствердження, дані файлу стануть недоступними." + }, + "timezone": { + "timezone": "Часовий пояс", + "select-timezone": "Вибрати часовий пояс ", + "no-timezones-matching": "Не знайдено жодних часових поясів, які відповідають '{{timezone}}'.", + "timezone-required": "Необхідно вказати часовий пояс." + }, + "queue": { + "select_name": "Виберіть ім'я для Queue", + "name": "Iм'я для Queue", + "name_required": "Поле 'Имя для Queue' обязательно к заполнению!" + }, + "tenant": { + "tenant": "Власник", + "tenants": "Власники", + "management": "Управління власниками", + "add": "Додати власника", + "admins": "Адміністратори", + "manage-tenant-admins": "Керування адміністраторами власника", + "delete": "Видалити власника", + "add-tenant-text": "Додати нового власника", + "no-tenants-text": "Не знайдено жодного власника", + "tenant-details": "Подробиці про власника", + "delete-tenant-title": "Ви впевнені, що хочете видалити власника'{{tenantTitle}}'?", + "delete-tenant-text": "Будьте обережні, після підтвердження власник і всі пов'язані з ним дані стануть недоступними.", + "delete-tenants-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 власник} other {# власники} }?", + "delete-tenants-action-title": "Видалити { count, plural, 1 {1 власник} other {# власники} }", + "delete-tenants-text": "Будьте обережні, після підтвердження, усі вибрані власники будуть видалені, і всі пов'язані з ними дані стануть недоступними.", + "title": "Назва", + "title-required": "Необхідно вказати назву.", + "description": "Опис", + "details": "Деталі", + "events": "Події", + "copyId": "Копіювати Id власника", + "idCopiedMessage": "Id власника скопійовано в буфер обміну", + "select-tenant": "Вибрати власника", + "no-tenants-matching": "Не знайдено жодних власників, які відповідають '{{entity}}'.", + "tenant-required": "Необхідно вказати власника", + "selected-tenants": "{ count, plural, 1 {1 власник} other {# власники} } вибрано", + "search": "Пошук власників" + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 секунда} other {# секунди} }", + "minutes-interval": "{ minutes, plural, 1 {1 хвилина} other {# хвилини} }", + "hours-interval": "{ hours, plural, 1 {1 година} other {# години} }", + "days-interval": "{ days, plural, 1 {1 день} other {# дні} }", + "days": "Дні", + "hours": "Години", + "minutes": "Хвилини", + "seconds": "Секунди", + "advanced": "Додатково" + }, + "timewindow": { + "days": "{ days, plural, 1 { день } other {# дні } }", + "hours": "{ hours, plural, 0 { годин } 1 {1 година } other {# години } }", + "minutes": "{ minutes, plural, 0 { хвилин } 1 {1 хвилина } other {# хвилини } }", + "seconds": "{ seconds, plural, 0 { секунд } 1 {1 секунда } other {# секунди } }", + "realtime": "Реальний час", + "history": "Історія", + "last-prefix": "Останнє", + "period": "з {{ startTime }} до {{ endTime }}", + "edit": "Редагувати вікно часу", + "date-range": "Проміжок часу", + "last": "Останнє", + "time-period": "Період часу", + "hide": "Приховати" + }, + "user": { + "user": "Користувач", + "users": "Користувачі", + "customer-users": "Користувачі клієнта", + "tenant-admins": "Адміністратори власників", + "sys-admin": "Системний адміністратор", + "tenant-admin": "Адміністратор власника", + "customer": "Клієнт", + "anonymous": "Анонім", + "add": "Додати користувача", + "delete": "Видалити користувача", + "add-user-text": "Додати нового користувача", + "no-users-text": "Не знайдено жодного користувача", + "user-details": "Подробиці про користувача", + "delete-user-title": "Ви впевнені, що хочете видалити користувача'{{userEmail}}'?", + "delete-user-text": "Будьте обережні, після підтвердження, користувач і всі пов'язані з ним дані стануть недоступними.", + "delete-users-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 користувача} other {# користувачів} }?", + "delete-users-action-title": "Видалити { count, plural, 1 {1 користувача} other {# користувачів} }", + "delete-users-text": "Будьте обережні, після підтвердження, усіх виділених користувачів буде видалено, і всі пов'язані з ними дані стануть недоступними.", + "activation-email-sent-message": "Активаційний лист успішно надіслано!", + "resend-activation": "Повторно надіслати активаційного листа", + "email": "Електронна пошта", + "email-required": "Необхідно вказати електронну пошту.", + "invalid-email-format": "Недійсний формат електронної пошти.", + "first-name": "Ім'я", + "last-name": "Прізвище", + "description": "Опис", + "default-dashboard": "Панель візуалізації за замовчуванням", + "always-fullscreen": "Завжди в повноекранному режимі", + "select-user": "Вибрати користувача", + "no-users-matching": "Не знайдено жодного користувача, що відповідає '{{entity}}'.", + "user-required": "Необхідно вказати користувача", + "activation-method": "Спосіб активації", + "display-activation-link": "Показати посилання для активації", + "send-activation-mail": "Надіслати активаційного листа", + "activation-link": "Активаційне посилання для користувача", + "activation-link-text": "Для активації користувача, скористайтеся наступним посиланням :", + "copy-activation-link": "Скопіювати активаційне посилання ", + "activation-link-copied-message": "Посилання на активацію користувача було скопійовано в буфер обміну", + "selected-users": "{ count, plural, 1 {1 користувач} other {# користувачі} } вибрано", + "search": "Пошук користувачів", + "details": "Подробиці", + "login-as-tenant-admin": "Увійти як адміністратор власника", + "login-as-customer-user": "Увійти як користувач клієнта", + "disable-account": "Вимкнути обліковий запис користувача", + "enable-account": "Увімкнути обліковий запис користувача", + "enable-account-message": "Обліковий запис користувача успішно увімкнено!", + "disabled-account-message": "Обліковий запис користувача успішно вимкнено!", + "copyId": "Копіювати Id користувача", + "idCopiedMessage": "Id користувача було скопійовано в буфер обміну" + }, + "value": { + "type": "Тип значення", + "string": "Рядок", + "string-value": "Значення рядка", + "integer": "Ціле", + "integer-value": "Ціле значення", + "invalid-integer-value": "Недійсне ціле значення", + "double": "Подвійне", + "double-value": "Подвійне значення", + "boolean": "Логічне", + "boolean-value": "Логічне значення", + "false": "Помилкове", + "true": "Правдиве", + "long": "Довге" + }, + "widget": { + "widget-library": "Бібліотека віджетів", + "widget-bundle": "Пакет віджетів", + "select-widgets-bundle": "Виберіть пакет віджетів", + "management": "Керування віджетами", + "editor": "Редактор віджетів", + "widget-type-not-found": "Помилка завантаження конфігурації віджетів.
    Можливо, пов'язаний з нею\n тип віджета було видалено.", + "widget-type-load-error": "Віджет не вдалося завантажити з наступних причин:", + "remove": "Видалити віджет", + "edit": "Відредагувати віджет", + "remove-widget-title": "Ви впевнені, що хочете видалити віджет '{{widgetTitle}}'?", + "remove-widget-text": "Після підтвердження віджет і всі пов'язані з ним дані стануть недоступними.", + "timeseries": "Телеметрія", + "search-data": "Пошук даних", + "no-data-found": "Даних не знайдено", + "latest": "Останні значення", + "rpc": "Керуючий віджет", + "alarm": "Віджет сигнала тривоги", + "static": "Статичний віджет", + "select-widget-type": "Вибрати тип віджета", + "missing-widget-title-error": "Необхідно вказати назву віджета!", + "widget-saved": "Віджет збережено", + "unable-to-save-widget-error": "Неможливо зберегти віджет! Віджет має помилки!", + "save": "Зберегти віджет", + "saveAs": "Зберегти віджет як", + "save-widget-type-as": "Зберегти тип віджета як", + "save-widget-type-as-text": "Введіть новий заголовок віджета та / або виберіть цільові віджети", + "toggle-fullscreen": "Перейти в повноекранний режим", + "run": "Запустити віджет", + "title": "Назва віджета", + "title-required": "Необхідно вказати назву віджета.", + "type": "Тип віджета", + "resources": "Ресурси", + "resource-url": "JavaScript/CSS URL", + "remove-resource": "Видалити ресурс", + "add-resource": "Додати ресурс", + "html": "HTML", + "tidy": "Форматувати", + "css": "CSS", + "settings-schema": "Схема налаштувань", + "datakey-settings-schema": "Схема налаштувань ключів даних", + "javascript": "Javascript", + "remove-widget-type-title": "Ви впевнені, що хочете видалити тип віджета '{{widgetName}}'?", + "remove-widget-type-text": "Будьте обережні, після підтвердження, тип віджета і всі пов'язані з ним дані стануть недоступними.", + "remove-widget-type": "Видалити тип віджета", + "add-widget-type": "Додати новий тип віджета", + "widget-type-load-failed-error": "Не вдалося завантажити тип віджета!", + "widget-template-load-failed-error": "Не вдалося завантажити шаблон віджета!", + "add": "Додати віджет", + "undo": "Скасувати зміни віджета", + "export": "Експортувати віджет", + "export-data": "Експортувати дані віджетів", + "export-to-csv": "Експортувати дані в CSV...", + "export-to-excel": "Експортувати дані в XLS..." + }, + "widget-action": { + "header-button": "Кнопка заголовка віджета", + "open-dashboard-state": "Перейти до нового стану панелі візуалізації", + "update-dashboard-state": "Оновити поточний стан панелі візуалізації", + "open-dashboard": "Перейти до іншої панелі візуалізації", + "custom": "Дії користувачів", + "custom-pretty": "Дії користувачів (з HTML шаблоном)", + "target-dashboard-state": "Цільовий стан панелі візуалізації", + "target-dashboard-state-required": "Необхідно вказати цільовий стан панелі візуалізації", + "set-entity-from-widget": "Встановити сутність із віджета", + "target-dashboard": "Цільова панель візуалізації", + "open-right-layout": "Відкрити мобільний режим панелі візуалізації" + }, + "widgets-bundle": { + "current": "Поточний зв'язок", + "widgets-bundles": "Пакети віджетів", + "add": "Додати пакет віджетів", + "delete": "Видалити пакет віджетів", + "title": "Назва", + "title-required": "Необхідно вказати назву віджета.", + "add-widgets-bundle-text": "Додати новий пакет віджетів", + "no-widgets-bundles-text": "Не знайдено жодних пакетів віджетів", + "empty": "Пакет віджетів порожній", + "details": "Подробиці", + "widgets-bundle-details": "Деталі пакетів віджетів", + "delete-widgets-bundle-title": "Ви впевнені, що хочете видалити пакет віджетів '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Будьте обережні, після підтвердження, пакети віджетів і всі пов'язані з ними дані стануть недоступними.", + "delete-widgets-bundles-title": "Ви впевнені, що хочете видалити { count, plural, 1 {пакет віджетів} other {# пакети віджетів} }?", + "delete-widgets-bundles-action-title": "Видалити { count, plural, 1 {1 пакет віджетів} other {# пакет віджетів} }", + "delete-widgets-bundles-text": "Будьте обережні, після підтвердження, всі виділені пакети віджетів і всі пов'язані з ними дані стануть недоступними.", + "no-widgets-bundles-matching": "Не знайдено жодних пакетів віджетів, які відповідають '{{widgetsBundle}}'.", + "widgets-bundle-required": "Необхідно вказати пакет віджетів.", + "system": "Системний", + "import": "Імпортувати пакет віджетів", + "export": "Експортувати пакет віджетів", + "export-failed-error": "Неможливо експортувати пакет віджетів: {{error}}", + "create-new-widgets-bundle": "Створити новий пакет віджетів", + "widgets-bundle-file": "Файл набору віджетів", + "invalid-widgets-bundle-file-error": "Неможливо імпортувати пакет віджетів: недійсна структура даних пакету віджетів." + }, + "widget-config": { + "data": "Дані", + "settings": "Налаштування", + "advanced": "Додатково", + "title": "Назва", + "general-settings": "Загальні налаштування", + "display-title": "Відобразити назву у віджеті", + "drop-shadow": "Тінь", + "enable-fullscreen": "Увімкнути повноекранний режим", + "enable-data-export": "Увімкнути експорт даних", + "background-color": "Колір фону", + "text-color": "Колір тексту", + "padding": "Відступ", + "margin": "Границі", + "widget-style": "Стиль віджетів", + "title-style": "Стиль заголовка", + "mobile-mode-settings": "мобільний режим", + "order": "Порядок", + "height": "Висота", + "units": "Спеціальний символ після значення", + "decimals": "Кількість цифр після коми", + "timewindow": "Вікно часу", + "use-dashboard-timewindow": "Використати вікно часу на панелі візуалізації", + "display-timewindow": "Показувати вікно часу", + "legend": "Легенда", + "display-legend": "Показати легенду", + "datasources": "Джерела даних", + "maximum-datasources": "Максимально { count, plural, 1 {1 дозволене джерело даних.} other {# дозволені джерела даних } }", + "datasource-type": "Тип", + "datasource-parameters": "Параметри", + "remove-datasource": "Видалити джерело даних", + "add-datasource": "Додати джерело даних", + "target-device": "Цільовий пристрій", + "alarm-source": "Джерело сигнала тривоги", + "actions": "Дії", + "action": "Дія", + "add-action": "Додати дію", + "search-actions": "Пошук дії", + "action-source": "Джерело дії", + "action-source-required": "Необхідно вказати джерело дії.", + "action-name": "Ім'я дії", + "action-name-required": "Необхідно вказати ім'я дії.", + "action-name-not-unique": "Дія з такою назвою вже існує.
    Назва дії має бути унікальною в межах одного джерела дії.", + "action-icon": "Іконка", + "action-type": "Тип", + "action-type-required": "Необхідно вказати тип дії.", + "edit-action": "Редагувати дію", + "delete-action": "Видалити дію", + "delete-action-title": "Видалити дію віджета", + "delete-action-text": "Ви впевнені, що хочете видалити дію віджета '{{actionName}}'?", + "title-icon": "Іконка у назві віджету", + "display-icon": "Показувати іконку у назві віджету", + "icon-color": "Колір іконки", + "icon-size": "Розмір іконки", + "advanced-settings": "Розширені налаштування", + "data-settings": "Налаштування даних", + "no-data-display-message": "\"Немає данних для відображення\" альтернативний текст" + }, + "widget-type": { + "import": "Імпортувати тип віджета", + "export": "Експортувати тип віджета", + "export-failed-error": "Неможливо експортувати тип віджета: {{error}}", + "create-new-widget-type": "Створити новий тип віджета", + "widget-type-file": "Файл типу віджета", + "invalid-widget-type-file-error": "Неможливо імпортувати тип віджету: неправильна структура даних типу віджета." + }, + "widgets": { + "date-range-navigator": { + "localizationMap": { + "Sun": "Нд", + "Mon": "Пн", + "Tue": "Вт", + "Wed": "Ср", + "Thu": "Чт", + "Fri": "Пт", + "Sat": "Сб", + "Jan": "Січ.", + "Feb": "Лют.", + "Mar": "Берез.", + "Apr": "Квіт.", + "May": "Трав.", + "Jun": "Черв.", + "Jul": "Лип.", + "Aug": "Серп.", + "Sep": "Верес.", + "Oct": "Жовт.", + "Nov": "Листоп.", + "Dec": "Груд.", + "January": "Січень", + "February": "Лютий", + "March": "Березень", + "April": "Квітень", + "June": "Червень", + "July": "Липень", + "August": "Серпень", + "September": "Вересень", + "October": "Жовтень", + "November": "Листопад", + "December": "Грудень", + "Custom Date Range": "Користувацький діапазон дат", + "Date Range Template": "Шаблон діапазону дат", + "Today": "Сьогодні", + "Yesterday": "Вчора", + "This Week": "Цього тижня", + "Last Week": "Минулий тиждень", + "This Month": "Цей місяць", + "Last Month": "Минулий місяць", + "Year": "Рік", + "This Year": "Цього року", + "Last Year": "Минулий рік", + "Date picker": "Вибір дати", + "Hour": "Година", + "Day": "День", + "Week": "Тиждень", + "2 weeks": "2 Тижні", + "Month": "Місяць", + "3 months": "3 Місяці", + "6 months": "6 Місяців", + "Custom interval": "Користувацький інтервал", + "Interval": "Інтервал", + "Step size": "Розмір кроку", + "Ok": "Ok" + } + }, + "input-widgets": { + "attribute-not-allowed": "Атрибут не може бути вибраний в цьому віджеті", + "date": "Дата", + "blocked-location": "Геолокація заблокована у вашому браузері", + "claim-device": "Підтвердити пристрій", + "claim-failed": "Не вдалося підтвердити пристрій!", + "claim-not-found": "Пристрій не знайдено!", + "claim-successful": "Пристрій успішно підтверджено!", + "discard-changes": "Скасувати зміни", + "device-name": "Назва пристрою", + "device-name-required": "Необхідно вказати назву пристрою", + "entity-attribute-required": "Значення атрибута обов'язкове", + "entity-coordinate-required": "Необхідно вказати широту та довготу", + "entity-timeseries-required": "Значення телеметрії обов'язкове", + "get-location": "Отримати поточне місцезнаходження", + "latitude": "Широта", + "longitude": "Довгота", + "not-allowed-entity": "Обрана сутність не має спільних атрибутів", + "no-attribute-selected": "Атрибут не вибрано", + "no-datakey-selected": "Ні один datakey не обраний", + "no-entity-selected": "Сутність не вибрано", + "no-coordinate-specified": "Ключ для широти/довготи не вказаний", + "no-support-geolocation": "Ваш браузер не підтримує геолокацію", + "no-image": "Немає зображення", + "no-support-web-camera": "Нет поддерживаемой веб-камеры", + "no-timeseries-selected": "Параметр телеметрії не вибрано", + "secret-key": "Секретний ключ", + "secret-key-required": "Необхідно вказати секретний ключ", + "switch-attribute-value": "Змінити значення атрибута", + "switch-camera": "Змінити камеру", + "switch-timeseries-value": "Змінити значення телеметрії", + "take-photo": "Зробити фото", + "time": "Час", + "timeseries-not-allowed": "Телеметрія не може бути вибрана в цьому віджеті", + "update-failed": "Не вдалося оновити", + "update-successful": "Успішно оновлено", + "update-attribute": "Оновити атрибут", + "update-timeseries": "Оновити телеметрію", + "value": "Значення" + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Тип повідомлення", + "method": "Метод", + "params": "Параметри", + "created-time": "Час створення", + "expiration-time": "Час життя", + "retries": "Повторні спроби", + "status": "Статус", + "filter": "Фільтр", + "refresh": "Оновити", + "add": "Додати RPC запит", + "details": "Деталі", + "delete": "Видалити", + "delete-request-title": "Видалити RPC запит", + "delete-request-text": "Ви впевнені, що хочете видалити RPC запит?", + "details-title": "Деталі RPC ID: ", + "additional-info": "Додаткова інформація", + "response": "Відповідь", + "any-status": "Будь-який статус", + "rpc-status-list": "Список RPC статусів", + "no-request-prompt": "Запитів не знайдено", + "send-request": "Відправити запит", + "add-title": "Додати новий RPC запит", + "method-error": "Необхідно вказати метод.", + "white-space-error": "Пробіли не допускаються.", + "rpc-status": { + "QUEUED": "В ЧЕРЗІ", + "SENT": "ВІДПРАВЛЕНО", + "DELIVERED": "ДОСТАВЛЕННО", + "SUCCESSFUL": "УСПІШНО", + "TIMEOUT": "ЧАС МИНУВ", + "EXPIRED": "ПРОСРОЧЕНО", + "FAILED": "НЕ ВДАЛО" + }, + "rpc-search-status-all": "ВСІ", + "message-types": { + "false": "Двосторонній", + "true": "Односторонній" + } + } + }, + "white-labeling": { + "white-labeling": "Білий маркування", + "login-white-labeling": "Login White Labeling", + "preview": "Попередній перегляд", + "app-title": "Назва програми", + "favicon": "Іконка веб-сайту", + "favicon-description": "*.ico, *.gif or *.png image with maximum size {{kbSize}} KBytes.", + "favicon-size-error": "Зображення веб-сайту завелике. Максимально дозволений розмір зображення веб-сайту {{kbSize}} KBytes.", + "favicon-type-error": "Недійсний формат файлу зображення веб-сайту. Приймаються лише зображення ICO, GIF або PNG.", + "drop-favicon-image": "Зніміть зображення піктограми веб-сайту або клацніть, щоб вибрати файл для завантаження.", + "no-favicon-image": "Не вибрано жодної іконки", + "logo": "Логотип", + "logo-description": "Будь-яке зображення з максимальним розміром {{kbSize}} KBytes.", + "logo-size-error": "Зображення логотипу занадто велике. Максимально дозволений розмір зображення логотипу{{kbSize}} KBytes.", + "logo-type-error": "Недійсний формат файлу логотипу. Приймаються тільки зображення.", + "drop-logo-image": "Зніміть зображення логотипу або клацніть, щоб вибрати файл для завантаження.", + "no-logo-image": "Не вибрано жожного логотипу", + "logo-height": "Висота логотипу, px", + "primary-palette": "Основна палітра", + "accent-palette": "Палітра акцент", + "customize-palette": "Налаштування", + "edit-palette": "Редагувати палітру", + "save-palette": "Зберегти палітру", + "primary-background": "Первинний фон", + "secondary-background": "Вторинний фон", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Колір фону сторінки", + "dark-foreground": "Темний передній план", + "domain-name": "Доменне ім'я" + }, + "icon": { + "icon": "Іконка", + "select-icon": "Виберіть Іконку", + "material-icons": "Іконки в стилі Material", + "show-all": "Показати всі іконки" + }, + "custom": { + "widget-action": { + "action-cell-button": "Кнопка дії в комірці таблиці", + "row-click": "Клацніть на рядок", + "marker-click": "Клацніть на маркер", + "polygon-click": "Дія при натисканні на полігон", + "tooltip-tag-action": "Дія при натисканні на посилання в підказці", + "node-selected": "Дії при виборі ноди", + "element-click": "Дії при натисканні на HTML елементі", + "pie-slice-click": "Дії при натисканні на секції кругової діаграми", + "row-double-click": "Дії при подвійному натисканні на рядок" + } + }, + "paginator" : { + "items-per-page": "Елементів на сторінці:", + "first-page-label": "Перша сторінка", + "last-page-label": "Остання сторінка", + "next-page-label": "Наступна сторінка", + "previous-page-label": "Попередня сторінка", + "items-per-page-separator": "з" + }, + "language": { + "language": "Мова" + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json new file mode 100644 index 0000000..2f1d4ca --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -0,0 +1,4786 @@ +{ + "access": { + "unauthorized": "未授权", + "unauthorized-access": "未授权访问", + "unauthorized-access-text": "您需要登陆才能访问这个资源!", + "access-forbidden": "禁止访问", + "access-forbidden-text": "您没有访问此位置的权限
    如果您仍希望访问此位置,请尝试使用其他用户登录。", + "refresh-token-expired": "会话已过期", + "refresh-token-failed": "无法刷新会话", + "permission-denied": "权限被拒绝", + "permission-denied-text": "您没有执行此操作的权限!" + }, + "action": { + "activate": "激活", + "suspend": "暂停", + "save": "保存", + "saveAs": "另存为", + "cancel": "取消", + "ok": "确定", + "delete": "删除", + "add": "添加", + "yes": "是", + "no": "否", + "update": "更新", + "remove": "移除", + "select": "选择", + "search": "查询", + "clear-search": "清除查询", + "assign": "分配", + "unassign": "取消分配", + "share": "分享", + "make-private": "私有", + "apply": "应用", + "apply-changes": "应用更改", + "edit-mode": "编辑模式", + "enter-edit-mode": "进入编辑模式", + "decline-changes": "撤销更改", + "close": "关闭", + "back": "后退", + "run": "运行", + "sign-in": "登录!", + "edit": "编辑", + "view": "查看", + "create": "创建", + "drag": "拖拽", + "refresh": "刷新", + "undo": "撤销", + "copy": "复制", + "paste": "粘贴", + "copy-reference": "复制引用", + "paste-reference": "粘贴引用", + "import": "导入", + "export": "导出", + "share-via": "通过{{provider}}分享", + "continue": "继续", + "discard-changes": "放弃更改", + "download": "下载", + "next-with-label": "下一个:{{label}}", + "read-more": "阅读更多", + "hide": "隐藏", + "done": "完毕", + "print": "打印", + "restore": "恢复", + "confirm": "确认" + }, + "aggregation": { + "aggregation": "聚合", + "function": "数据聚合功能", + "limit": "限制数", + "group-interval": "分组间隔", + "min": "最小值", + "max": "最大值", + "avg": "平均值", + "sum": "求和", + "count": "计数", + "none": "无" + }, + "admin": { + "general": "基本设置", + "general-settings": "基本设置", + "home-settings": "首页设置", + "outgoing-mail": "发送邮件", + "outgoing-mail-settings": "发送邮件设置", + "system-settings": "系统设置", + "test-mail-sent": "测试邮件发送成功!", + "base-url": "基本URL", + "base-url-required": "基本URL必填。", + "prohibit-different-url": "禁止从客户端请求头中使用主机名", + "prohibit-different-url-hint": "应为生产环境启用此设置。禁用时可能会导致安全问题", + "mail-from": "邮件来自", + "mail-from-required": "邮件发件人必填。", + "smtp-protocol": "SMTP协议", + "smtp-host": "SMTP主机", + "smtp-host-required": "SMTP主机必填。", + "smtp-port": "SMTP端口", + "smtp-port-required": "您必须提供一个smtp端口。", + "smtp-port-invalid": "这看起来不是有效的smtp端口。", + "timeout-msec": "超时时间(毫秒)", + "timeout-required": "超时必填。", + "timeout-invalid": "这看起来不像有效的超时值。", + "enable-tls": "启用TLS", + "tls-version": "TLS版本", + "enable-proxy": "启用代理", + "proxy-host": "代理主机", + "proxy-host-required": "代理主机必填。", + "proxy-port": "代理端口", + "proxy-port-required": "代理端口必填。", + "proxy-port-range": "代理端口应在1到65535之间。", + "proxy-user": "代理用户", + "proxy-password": "代理密码", + "change-password": "Change password", + "send-test-mail": "发送测试邮件", + "sms-provider": "SMS 服务商", + "sms-provider-settings": "SMS 服务商设置", + "sms-provider-type": "SMS 服务商类型", + "sms-provider-type-required": "SMS 服务商类型必填。", + "sms-provider-type-aws-sns": "亚马逊社交网站", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS访问密钥ID", + "aws-access-key-id-required": "需要访问AWS密钥ID", + "aws-secret-access-key": "AWS秘密访问密钥", + "aws-secret-access-key-required": "AWS 访问密钥必填", + "aws-region": "AWS地区", + "aws-region-required": "AWS 区域必填", + "number-from": "发送方电话号码", + "number-from-required": "发送方电话号码必填。", + "number-to": "电话号码至", + "number-to-required": "电话号码必填。", + "phone-number-hint": "E.164格式的手机号码,例如+19995550123", + "phone-number-hint-twilio": "Phone Number in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "手机号码无效。应为E.164格式,例如+19995550123。", + "phone-number-pattern-twilio": "Invalid phone number. Should be in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX.", + "sms-message": "短信", + "sms-message-required": "短消息内容必填。", + "sms-message-max-length": "短信长度不能超过1600个字符", + "twilio-account-sid": "Twilio帐户SID", + "twilio-account-sid-required": "Twilio 帐户的 SID 必填", + "twilio-account-token": "Twilio帐户令牌", + "twilio-account-token-required": "Twilio 帐户令牌必填", + "send-test-sms": "发送测试短信", + "test-sms-sent": "测试短信发送成功!", + "security-settings": "安全设置", + "password-policy": "密码策略", + "minimum-password-length": "最小密码长度", + "minimum-password-length-required": "最小密码长度必填", + "minimum-password-length-range": "最小密码长度应在5到50之间", + "minimum-uppercase-letters": "最少大写字母位数", + "minimum-uppercase-letters-range": "最少大写字母位数不能为负数", + "minimum-lowercase-letters": "最少小写字母位数", + "minimum-lowercase-letters-range": "最少小写字母位数不能为负数", + "minimum-digits": "最少数字位数", + "minimum-digits-range": "最少数字位数不能为负数", + "minimum-special-characters": "最少特殊字符位数", + "minimum-special-characters-range": "最少特殊字符位数不能为负数", + "password-expiration-period-days": "密码有效期(天)", + "password-expiration-period-days-range": "密码过期期限(天)不能为负", + "password-reuse-frequency-days": "密码重用频率(天)", + "password-reuse-frequency-days-range": "天内密码重用频率不能为负", + "allow-whitespace": "Allow whitespace", + "general-policy": "基本策略", + "max-failed-login-attempts": "登录失败之前的最大登录尝试次数", + "minimum-max-failed-login-attempts-range": "登录失败次数不能为负数", + "user-lockout-notification-email": "如果用户帐户锁定,请发送通知到电子邮件", + "domain-name": "域名", + "domain-name-unique": "域名和协议必须是唯一的。", + "domain-name-max-length": "域名长度不能大于256", + "error-verification-url": "域名不应包含符号 “/” 和 “:”。例:thingsboard.io", + "oauth2": { + "access-token-uri": "访问令牌URI", + "access-token-uri-required": "访问令牌 URI 必填。", + "activate-user": "激活用户", + "add-domain": "添加域", + "delete-domain": "删除域", + "add-provider": "添加 Provider", + "delete-provider": "删除 Provider", + "allow-user-creation": "允许用户创建", + "always-fullscreen": "始终全屏", + "authorization-uri": "授权URI", + "authorization-uri-required": "授权 URI 必填。", + "client-authentication-method": "客户端身份验证方法", + "client-id": "客户端ID", + "client-id-required": "客户端 ID 必填。", + "client-id-max-length": "客户端ID长度不能大于256", + "client-secret": "客户机密", + "client-secret-required": "需要客户端密码。", + "client-secret-max-length": "客户端密钥长度不能大于2049", + "custom-setting": "自定义设置", + "customer-name-pattern": "客户名称模式", + "customer-name-pattern-max-length": "客户名称模式长度不能大于256", + "default-dashboard-name": "默认仪表板名称", + "default-dashboard-name-max-length": "默认仪表板名称长度不能大于256", + "delete-domain-text": "请注意:确认后,域和所有 provider data 将不可恢复。", + "delete-domain-title": "确定要删除域 '{{domainName}}' 的设置吗?", + "delete-registration-text": "请注意:确认后 provider data 将不可恢复。", + "delete-registration-title": "确定要删除 provider '{{name}}' 吗?", + "email-attribute-key": "电子邮件属性键", + "email-attribute-key-required": "电子邮件属性密钥必填。", + "email-attribute-key-max-length": "电子邮件属性键长度不能大于32", + "first-name-attribute-key": "名字属性键", + "first-name-attribute-key-max-length": "名字属性键长度不能大于32", + "general": "基本设置", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "姓氏属性键", + "last-name-attribute-key-max-length": "姓氏属性键长度不能大于32", + "login-button-icon": "登录按钮图标", + "login-button-label": "Provider 标签", + "login-button-label-placeholder": "使用 $(Provider label) 登录", + "login-button-label-required": "标签必填。", + "login-provider": "Login provider", + "mapper": "Mapper", + "new-domain": "新建域", + "oauth2": "OAuth2", + "password-max-length": "密码长度不能大于256", + "redirect-uri-template": "重定向URI模板", + "copy-redirect-uri": "复制重定向URI", + "registration-id": "注册ID", + "registration-id-required": "注册 ID 必填。", + "registration-id-unique": "系统的注册ID必须是唯一的。", + "scope": "范围", + "scope-required": "范围必填。", + "tenant-name-pattern": "租户名称模式", + "tenant-name-pattern-required": "租户名称模式必填。", + "tenant-name-pattern-max-length": "租户名称模式长度不能大于256", + "tenant-name-strategy": "租户名称策略", + "type": "映射器类型", + "uri-pattern-error": "无效的URI格式。", + "url": "统一资源定位地址", + "url-pattern": "无效的URL格式。", + "url-required": "URL 必填。", + "url-max-length": "URL地址长度不能大于256", + "user-info-uri": "用户信息URI", + "user-info-uri-required": "用户信息 URI 必填。", + "username-max-length": "用户名称长度不能大于256", + "user-name-attribute-name": "用户名属性键", + "user-name-attribute-name-required": "用户名属性密钥必填", + "protocol": "协议", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "启用OAuth2设置", + "domains": "域名", + "mobile-apps": "移动端", + "no-mobile-apps": "无移动端应用配置", + "mobile-package": "应用程包", + "mobile-package-placeholder": "例如: my.example.app", + "mobile-package-hint": "Android:应用程序ID,iOS:产品标识符", + "mobile-package-unique": "应用程序包必须是唯一的。", + "mobile-app-secret": "应用程序密钥", + "invalid-mobile-app-secret": "应用程序密钥必须包含字母,数字,字符并且长度必须在16到2048个字符。", + "copy-mobile-app-secret": "复制应用程序密钥", + "add-mobile-app": "添加应用程序", + "delete-mobile-app": "删除应用程序", + "providers": "提供程序", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "所有平台", + "allowed-platforms": "允许平台" + }, + "smpp-provider": { + "smpp-version": "SMPP版本", + "smpp-host": "SMPP主机", + "smpp-host-required": "SMPP主机必填", + "smpp-port": "SMPP端口", + "smpp-port-required": "SMPP端口必填", + "system-id": "系统ID", + "system-id-required": "系统ID必填", + "password": "密码", + "password-required": "密码必填", + "type-settings": "类型设置", + "source-settings": "源设置", + "destination-settings": "目的地", + "additional-settings": "其他设置", + "system-type": "系统类型", + "bind-type": "绑定类型", + "service-type": "服务类型", + "source-address": "源地址", + "source-ton": "源TON", + "source-npi": "源NPI", + "destination-ton": "目的地TON号码", + "destination-npi": "目的地NPI号码", + "address-range": "地址范围", + "coding-scheme": "编码方案", + "bind-type-tx": "发送者", + "bind-type-rx": "接收者", + "bind-type-trx": "收发器", + "ton-unknown": "未知", + "ton-international": "International", + "ton-national": "National", + "ton-network-specific": "Network Specific", + "ton-subscriber-number": "Subscriber Number", + "ton-alphanumeric": "Alphanumeric", + "ton-abbreviated": "Abbreviated", + "npi-unknown": "0 - Unknown", + "npi-isdn": "1 - ISDN/telephone numbering plan (E163/E164)", + "npi-data-numbering-plan": "3 - Data numbering plan (X.121)", + "npi-telex-numbering-plan": "4 - Telex numbering plan (F.69)", + "npi-land-mobile": "6 - Land Mobile (E.212)", + "npi-national-numbering-plan": "8 - National numbering plan", + "npi-private-numbering-plan": "9 - Private numbering plan", + "npi-ermes-numbering-plan": "10 - ERMES numbering plan (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (to be defined by WAP Forum)", + "scheme-smsc": "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free)", + "scheme-ia5": "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octet Unspecified (8-bit binary)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet Unspecified (8-bit binary)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cyrillic (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebrew (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Pictogram Encoding", + "scheme-music-codes": "10 - Music Codes (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Extended Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Korean Graphic Character Set (KS C 5601/KS X 1001)" + }, + "queue-select-name": "选择队列名称", + "queue-name": "名称", + "queue-name-required": "队列名称必填。", + "queues": "队列", + "queue-partitions": "分区", + "queue-submit-strategy": "提交策略", + "queue-processing-strategy": "处理策略", + "queue-configuration": "队列配置", + "repository-settings": "仓库设置", + "repository-url": "仓库 URL", + "repository-url-required": "仓库 URL 必填。", + "default-branch": "默认分支名称", + "repository-read-only": "Read-only", + "authentication-settings": "身份验证设置", + "auth-method": "身份验证方法", + "auth-method-username-password": "密码/访问令牌", + "auth-method-private-key": "私钥", + "password-access-token": "密码/访问令牌", + "change-password-access-token": "更改密码/访问令牌", + "private-key": "私钥", + "drop-private-key-file-or": "拖放私钥文件或", + "passphrase": "口令", + "enter-passphrase": "输入口令", + "change-passphrase": "更改口令", + "check-access": "检查访问权限", + "check-repository-access-success": "已成功验证仓库访问!", + "delete-repository-settings-title": "确定要删除仓库设置吗?", + "delete-repository-settings-text": "请注意:确认后,仓库设置将被删除,版本控制功能将不可用。", + "auto-commit-settings": "自动提交设置", + "auto-commit-entities": "自动提交实体", + "no-auto-commit-entities-prompt": "没有设置自动提交的实体", + "delete-auto-commit-settings-title": "确定要删除自动提交设置吗?", + "delete-auto-commit-settings-text": "请注意:确认后,自动提交设置将被删除,所有实体的自动提交将被禁用。", + "2fa": { + "2fa": "双因素身份验证", + "available-providers": "可用选项", + "issuer-name": "发行者名称", + "issuer-name-required": "发行者名称必填。", + "max-verification-failures-before-user-lockout": "用户锁定前最大验证失败次数", + "max-verification-failures-before-user-lockout-pattern": "最大验证失败次数必须为正整数。", + "number-of-checking-attempts": "检查尝试次数", + "number-of-checking-attempts-pattern": "检查尝试次数必须为正整数。", + "number-of-checking-attempts-required": "检查尝试次数必填。", + "number-of-codes": "验证码数量", + "number-of-codes-pattern": "验证码数量必须为正整数。", + "number-of-codes-required": "验证码数量必填。", + "provider": "Provider", + "retry-verification-code-period": "重试验证码周期(秒)", + "retry-verification-code-period-pattern": "最短时间为5秒", + "retry-verification-code-period-required": "重试验证代码周期必填。", + "total-allowed-time-for-verification": "总允许验证时间(秒)", + "total-allowed-time-for-verification-pattern": "总允许验证最小时间为60秒", + "total-allowed-time-for-verification-required": "总允许验证时间必填。", + "use-system-two-factor-auth-settings": "使用系统双因素身份验证设置", + "verification-code-check-rate-limit": "验证码检查速率限制", + "verification-code-lifetime": "验证码生存期(秒)", + "verification-code-lifetime-pattern": "验证码生存期必须为正整数。", + "verification-code-lifetime-required": "验证码生存期必填。", + "verification-message-template": "验证消息模板", + "verification-limitations": "验证限制", + "verification-message-template-pattern": "验证消息需要包含模板:${code}", + "verification-message-template-required": "验证消息模板必填。", + "within-time": "在时间内 (秒)", + "within-time-pattern": "时间必须是正整数。", + "within-time-required": "时间必填。" + } + }, + "alarm": { + "alarm": "告警", + "alarms": "告警", + "select-alarm": "选择告警", + "no-alarms-matching": "未找到匹配 '{{entity}}' 的告警", + "alarm-required": "告警必填", + "alarm-status": "告警状态", + "alarm-status-list": "告警状态列表", + "any-status": "任何状态", + "search-status": { + "ANY": "所有", + "ACTIVE": "激活", + "CLEARED": "已清除", + "ACK": "已确认", + "UNACK": "未确认" + }, + "display-status": { + "ACTIVE_UNACK": "激活未确认", + "ACTIVE_ACK": "激活已确认", + "CLEARED_UNACK": "清除未确认", + "CLEARED_ACK": "清除已确认" + }, + "no-alarms-prompt": "未发现告警", + "created-time": "创建时间", + "type": "类型", + "severity": "严重程度", + "originator": "发起者", + "originator-type": "发起者类型", + "details": "详情", + "status": "状态", + "alarm-details": "告警详细信息", + "start-time": "开始时间", + "end-time": "结束时间", + "ack-time": "确认时间", + "clear-time": "清除时间", + "alarm-severity-list": "警报严重性列表", + "any-severity": "任何严重程度", + "severity-critical": "危险", + "severity-major": "重要", + "severity-minor": "次要", + "severity-warning": "警告", + "severity-indeterminate": "不确定", + "acknowledge": "应答", + "clear": "清除", + "search": "查找告警", + "selected-alarms": "已选择 { count, plural, 1 {# 个告警} other {# 个告警} }", + "no-data": "无数据显示", + "polling-interval": "告警轮询间隔(秒)", + "polling-interval-required": "告警轮询间隔必填。", + "min-polling-interval-message": "轮询间隔至少是1秒。", + "aknowledge-alarms-title": "确认 { count, plural, 1 {# 个告警} other {# 个告警} }", + "aknowledge-alarms-text": "确定要确认 { count, plural, 1 {# 个告警} other {# 个告警} }吗?", + "aknowledge-alarm-title": "确认告警", + "aknowledge-alarm-text": "确定要确认告警吗?", + "clear-alarms-title": "清除 { count, plural, 1 {# 个告警} other {# 个告警} }", + "clear-alarms-text": "确定要清除 { count, plural, 1 {# 个告警} other {# 个告警} }?", + "clear-alarm-title": "清除警报", + "clear-alarm-text": "确定要清除警报吗?", + "alarm-status-filter": "告警状态筛选器", + "alarm-filter": "告警筛选器", + "max-count-load": "要加载的最大告警数(0-无限制)", + "max-count-load-required": "加载的最大告警数必填。", + "max-count-load-error-min": "最小值为0。", + "fetch-size": "获取大小", + "fetch-size-required": "Fetch size 必填。", + "fetch-size-error-min": "最小值为10。", + "alarm-type-list": "告警类型列表", + "any-type": "任何类型", + "search-propagated-alarms": "检索已传递的警报" + }, + "alias": { + "add": "添加别名", + "edit": "编辑别名", + "name": "别名", + "name-required": "别名必填", + "duplicate-alias": "别名已经存在。", + "filter-type-single-entity": "单个实体", + "filter-type-entity-list": "实体列表", + "filter-type-entity-name": "实体名称", + "filter-type-entity-type": "Entity type", + "filter-type-state-entity": "仪表板状态实体", + "filter-type-state-entity-description": "仪表板实体令牌状态参数", + "filter-type-asset-type": "资产类型", + "filter-type-asset-type-description": "类型为 '{{assetType}}' 的资产", + "filter-type-asset-type-and-name-description": "类型为 '{{assetType}}' 且以 '{{prefix}}' 开头的资产", + "filter-type-device-type": "设备类型", + "filter-type-device-type-description": "类型为 '{{deviceType}}' 的设备", + "filter-type-device-type-and-name-description": "类型为 '{{deviceType}}' 且以 '{{prefix}}' 开头的设备", + "filter-type-entity-view-type": "实体视图类型", + "filter-type-entity-view-type-description": "类型为 '{{entityView}}' 的实体视图", + "filter-type-entity-view-type-and-name-description": "类型为 {{entityView}}' 且以 '{{prefix}}' 开头的实体视图", + "filter-type-edge-type": "边缘类型", + "filter-type-edge-type-description": "类型为 '{{edgeType}}' 的边缘", + "filter-type-edge-type-and-name-description": "类型为 '{{edgeType}}' 且以 '{{prefix}}' 开头的边缘", + "filter-type-relations-query": "关联查询", + "filter-type-relations-query-description": "具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的 {{entities}} ", + "filter-type-asset-search-query": "资产搜索查询", + "filter-type-asset-search-query-description": "类型为 {{assetTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的资产", + "filter-type-device-search-query": "设备搜索查询", + "filter-type-device-search-query-description": "类型为 {{deviceTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的设备", + "filter-type-entity-view-search-query": "实体视图搜索查询", + "filter-type-entity-view-search-query-description": "类型为 {{entityViewTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的实体视图", + "filter-type-apiUsageState": "Api使用状态", + "filter-type-edge-search-query": "边缘搜索查询", + "filter-type-edge-search-query-description": "类型为 {{edgeTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的边缘", + "entity-filter": "实体筛选器", + "resolve-multiple": "解析为多实体", + "filter-type": "筛选器类型", + "filter-type-required": "筛选器类型必填。", + "entity-filter-no-entity-matched": "未找到匹配指定筛选条件的实体。", + "no-entity-filter-specified": "没有指定实体筛选器", + "root-state-entity": "使用仪表板状态实体作为根实体", + "last-level-relation": "仅获取最后一级关联", + "root-entity": "根实体", + "state-entity-parameter-name": "状态实体参数名称", + "default-state-entity": "默认状态实体", + "default-entity-parameter-name": "默认", + "max-relation-level": "最大关联层级", + "unlimited-level": "不限层级", + "state-entity": "仪表板状态实体", + "all-entities": "所有实体", + "any-relation": "不限" + }, + "asset": { + "asset": "资产", + "assets": "资产", + "management": "资产管理", + "view-assets": "查看资产", + "add": "添加资产", + "asset-type-max-length": "资产类型要小于256", + "assign-to-customer": "分配给客户", + "assign-asset-to-customer": "将资产分配给客户", + "assign-asset-to-customer-text": "请选择要分配给客户的资产", + "no-assets-text": "未找到资产", + "assign-to-customer-text": "请选择客户以分配资产", + "public": "公开", + "assignedToCustomer": "分配客户", + "make-public": "资产设为公开", + "make-private": "资产设为私有", + "unassign-from-customer": "取消分配客户", + "delete": "删除资产", + "asset-public": "资产公开", + "asset-type": "资产类型", + "asset-type-required": "资产类型必填。", + "select-asset-type": "选择资产类型", + "enter-asset-type": "输入资产类型", + "any-asset": "任何资产", + "no-asset-types-matching": "未找到匹配 '{{entitySubtype}}' 的资产类型。", + "asset-type-list-empty": "资产类型未选择。", + "asset-types": "资产类型", + "name": "名称", + "name-required": "名称必填。", + "name-max-length": "名称长度不能大于256", + "label-max-length": "标签长度应小于256", + "description": "说明", + "type": "类型", + "type-required": "类型必填。", + "details": "详情", + "events": "事件", + "add-asset-text": "添加新资产", + "asset-details": "资产详情", + "assign-assets": "分配资产", + "assign-assets-text": "分配 { count, plural, 1 {# 个资产} other {# 个资产} } 给客户", + "assign-asset-to-edge-title": "将资产分配给边缘", + "assign-asset-to-edge-text": "请选择要分配给边缘的资产", + "delete-assets": "删除资产", + "unassign-assets": "取消分配资产", + "unassign-assets-action-title": "从客户处取消分配 { count, plural, 1 {# 个资产} other {# 个资产} }", + "assign-new-asset": "分配新资产", + "delete-asset-title": "确定要删除资产 '{{assetName}}'吗?", + "delete-asset-text": "请注意:确认后,资产及其所有相关数据将不可恢复。", + "delete-assets-title": "确定要删除 { count, plural, 1 {# 个资产} other {# 个资产} }吗?", + "delete-assets-action-title": "删除 { count, plural, 1 {# 个资产} other {# 个资产} }", + "delete-assets-text": "请注意:确认后,所有选定的资产将被删除,所有相关的数据将不可恢复。", + "make-public-asset-title": "确定要将资产 '{{assetName}}' 设为公开吗?", + "make-public-asset-text": "确认后,资产及其所有数据将被公开并被他人访问。", + "make-private-asset-title": "确定要将资产 '{{assetName}}' 设为私有吗?", + "make-private-asset-text": "确认后,资产及其所有数据将被私有化,无法被他人访问。", + "unassign-asset-title": "确定要取消对'{{assetName}}'资产的分配吗?", + "unassign-asset-text": "确认后,资产将未分配,客户无法访问。", + "unassign-asset": "未分配资产", + "unassign-assets-title": "确定要取消分配 { count, plural, 1 {# 个资产} other {# 个资产} }吗?", + "unassign-assets-text": "确认后,所有选定的资产将被分配,客户无法访问。", + "unassign-assets-from-edge": "取消分配边缘", + "copyId": "复制资产ID", + "idCopiedMessage": "资产ID已经复制到粘贴板", + "select-asset": "选择资产", + "no-assets-matching": "未找到匹配 '{{entity}}' 的资产。", + "asset-required": "资产必填", + "name-starts-with": "资产名称以此开头", + "help-text": "根据需要可以使用'%'进行匹配,例如:'%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'。", + "import": "导入资产", + "asset-file": "资产档案", + "label": "标签", + "search": "查找资产", + "assign-asset-to-edge": "Assign Asset(s) To Edge", + "unassign-asset-from-edge": "取消分配边缘", + "unassign-asset-from-edge-title": "确定要取消对'{{assetName}}'资产的分配吗?", + "unassign-asset-from-edge-text": "确认后,所有选定的资产将被分配,边缘无法访问。", + "unassign-assets-from-edge-title": "确定要取消分配 { count, plural, 1 {1 资产} other {# 个资产} }吗?", + "unassign-assets-from-edge-text": "确认后,所有选定的资产将被分配,边缘无法访问。", + "selected-assets": "已选择 { count, plural, 1 {# 个资产} other {# 个资产} }" + }, + "attribute": { + "attributes": "属性", + "latest-telemetry": "最新遥测数据", + "attributes-scope": "设备属性范围", + "scope-latest-telemetry": "最新遥测数据", + "scope-client": "客户端属性", + "scope-server": "服务端属性", + "scope-shared": "共享属性", + "add": "添加属性", + "key": "键名", + "key-max-length": "关键字长度应小于256", + "last-update-time": "最后更新时间", + "key-required": "属性键必填。", + "value": "数值", + "value-required": "属性值必填。", + "delete-attributes-title": "确定要删除 { count, plural, 1 {# 个属性} other {# 个属性} }吗?", + "delete-attributes-text": "注意,确认后所有选中的属性都会被删除。", + "delete-attributes": "删除属性", + "enter-attribute-value": "输入属性值", + "show-on-widget": "在部件上显示", + "widget-mode": "部件模式", + "next-widget": "下一个部件", + "prev-widget": "上一个部件", + "add-to-dashboard": "添加到仪表板", + "add-widget-to-dashboard": "将部件添加到仪表板", + "selected-attributes": "已选择{ count, plural, 1 {# 个属性} other {# 个属性} }", + "selected-telemetry": "已选择 { count, plural, 1 {# 遥测} other {# 遥测} }", + "no-attributes-text": "未找到属性", + "no-telemetry-text": "未找到遥测数据" + }, + "api-usage": { + "api-usage": "Api统计", + "alarm": "警报", + "alarms-created": "创建警报数", + "alarms-created-daily-activity": "每天产生的警报", + "alarms-created-hourly-activity": "每小时产生的警报", + "alarms-created-monthly-activity": "每月产生的警报", + "data-points": "数据点", + "data-points-storage-days": "日存储数据点数", + "email": "邮件", + "email-messages": "邮件信息", + "email-messages-daily-activity": "每天产生的邮件信息", + "email-messages-monthly-activity": "每月产生的邮件信息", + "exceptions": "例外", + "executions": "执行", + "javascript": "JavaScript", + "javascript-executions": "JavaScript例外", + "javascript-functions": "JavaScript函数", + "javascript-functions-daily-activity": "每天执行的JavaScript函数", + "javascript-functions-hourly-activity": "每小时执行的JavaScript函数", + "javascript-functions-monthly-activity": "每月执行的JavaScript函数", + "latest-error": "最新错误", + "messages": "消息", + "notifications": "通知", + "notifications-email-sms": "通知(Email/SMS)", + "notifications-hourly-activity": "每小时的通知", + "permanent-failures": "${entityName}永久性故障", + "permanent-timeouts": "${entityName}永久超时", + "processing-failures": "${entityName}处理失败", + "processing-failures-and-timeouts": "处理失败和超时", + "processing-timeouts": "${entityName}处理超时", + "queue-stats": "队列统计信息", + "rule-chain": "规则链", + "rule-engine": "规则引擎", + "rule-engine-daily-activity": "每天的规则引擎活动", + "rule-engine-executions": "规则引擎执行", + "rule-engine-hourly-activity": "每小时的规则引擎活动", + "rule-engine-monthly-activity": "每月的规则引擎活动", + "rule-engine-statistics": "规则引擎统计信息", + "rule-node": "规则节点", + "sms": "SMS", + "sms-messages": "短信信息", + "sms-messages-daily-activity": "每天的短信息", + "sms-messages-monthly-activity": "每小时的短信息", + "successful": "${entityName}成功", + "telemetry": "遥测数据", + "telemetry-persistence": "遥测持久化", + "telemetry-persistence-daily-activity": "每天的遥测持久化", + "telemetry-persistence-hourly-activity": "每小时的遥测持久化", + "telemetry-persistence-monthly-activity": "每月的遥测持久化", + "transport": "传输", + "transport-daily-activity": "每天传输数据量", + "transport-data-points": "传输数据点", + "transport-hourly-activity": "每小时传输数据量", + "transport-messages": "传输消息", + "transport-monthly-activity": "每月传输数据量", + "view-details": "查看详细信息", + "view-statistics": "查看统计信息" + }, + "audit-log": { + "audit": "审计", + "audit-logs": "审计日志", + "timestamp": "时间戳", + "entity-type": "实体类型", + "entity-name": "实体名称", + "user": "用户", + "type": "类型", + "status": "状态", + "details": "详情", + "type-added": "添加", + "type-deleted": "删除", + "type-updated": "更新", + "type-attributes-updated": "更新属性", + "type-attributes-deleted": "删除属性", + "type-rpc-call": "RPC调用", + "type-credentials-updated": "更新凭据", + "type-assigned-to-customer": "分配给客户", + "type-unassigned-from-customer": "未分配给客户", + "type-assigned-to-edge": "分配给边缘", + "type-unassigned-from-edge": "未分配给边缘", + "type-activated": "激活", + "type-suspended": "暂停", + "type-credentials-read": "读取凭据", + "type-attributes-read": "读取属性", + "type-relation-add-or-update": "关联已更新", + "type-relation-delete": "关联已删除", + "type-relations-delete": "删除所有关联", + "type-alarm-ack": "已确认", + "type-alarm-clear": "已清除", + "type-login": "登录", + "type-logout": "注销", + "type-lockout": "锁定", + "status-success": "成功", + "status-failure": "失败", + "audit-log-details": "审计日志详情", + "no-audit-logs-prompt": "未找到日志", + "action-data": "活动数据", + "failure-details": "失败详情", + "search": "查找审计日志", + "clear-search": "清空查找", + "type-assigned-from-tenant": "从租户分配", + "type-assigned-to-tenant": "分配给租户", + "type-provision-success": "设备已预配置", + "type-provision-failure": "设备预配置失败", + "type-timeseries-updated": "遥测数据已更新", + "type-timeseries-deleted": "遥测数据已删除" + }, + "confirm-on-exit": { + "message": "有未保存的更改,确定要离开此页吗?", + "html-message": "有未保存的更改。
    确定要离开此页面吗?", + "title": "未保存的更改" + }, + "contact": { + "country": "国家", + "city": "城市", + "state": "州", + "postal-code": "邮政编码", + "postal-code-invalid": "只允许数字。", + "address": "地址", + "address2": "地址二", + "phone": "电话", + "email": "电子邮件", + "no-address": "无地址", + "state-max-length": "省份名称长度应小于256", + "phone-max-length": "电话号码长度应小于256", + "city-max-length": "城市名称长度应小于256" + }, + "common": { + "username": "用户名", + "password": "密码", + "enter-username": "输入用户名", + "enter-password": "输入密码", + "enter-search": "输入检索条件", + "created-time": "创建时间", + "loading": "正在加载中...", + "proceed": "继续", + "open-details-page": "打开详情页" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "customer": { + "customer": "客户", + "customers": "客户", + "management": "客户管理", + "dashboard": "客户仪表板", + "dashboards": "客户仪表板", + "devices": "客户设备", + "entity-views": "客户实体视图", + "assets": "客户资产", + "public-dashboards": "公共仪表板", + "public-devices": "公共设备", + "public-assets": "公共资产", + "public-edges": "公共边缘", + "public-entity-views": "公共实体视图", + "add": "添加客户", + "delete": "删除此客户", + "manage-customer-users": "管理客户用户", + "manage-customer-devices": "管理客户设备", + "manage-customer-dashboards": "管理客户仪表板", + "manage-public-devices": "管理公共设备", + "manage-public-dashboards": "管理公共仪表板", + "manage-customer-assets": "管理客户资产", + "manage-public-assets": "管理公共资产", + "manage-customer-edges": "管理客户边缘", + "manage-public-edges": "管理公共边缘", + "add-customer-text": "添加新客户", + "no-customers-text": "未找到客户", + "customer-details": "客户详情", + "delete-customer-title": "确定要删除客户'{{customerTitle}}'吗?", + "delete-customer-text": "请注意:确认后,客户及其所有相关数据将不可恢复。", + "delete-customers-title": "确定要删除 { count, plural, 1 {# 个客户} other {# 个客户} }吗?", + "delete-customers-action-title": "删除 { count, plural, 1 {# 个客户} other {# 个客户} }", + "delete-customers-text": "请注意:确认后,所有选定的客户将被删除,所有相关数据将不可恢复。", + "manage-users": "管理用户", + "manage-assets": "管理资产", + "manage-devices": "管理设备", + "manage-dashboards": "管理仪表板", + "title": "标题", + "title-required": "标题必填。", + "title-max-length": "标题长度必须少于256个字符", + "description": "说明", + "details": "详情", + "events": "事件", + "copyId": "复制客户ID", + "idCopiedMessage": "客户ID已复制到粘贴板", + "select-customer": "选择客户", + "no-customers-matching": "未找到匹配 '{{entity}}' 的客户。", + "customer-required": "客户必填", + "select-default-customer": "选择默认的客户", + "default-customer": "默认客户", + "default-customer-required": "为了调试租户级别上的仪表板,需要默认客户。", + "search": "查找客户", + "selected-customers": "已选择 { count, plural, 1 {# 个客户} other {# 个客户} }", + "edges": "客户边缘实例", + "manage-edges": "管理边缘" + }, + "datetime": { + "date-from": "开始日期", + "time-from": "开始时间", + "date-to": "结束日期", + "time-to": "结束时间" + }, + "dashboard": { + "dashboard": "仪表板", + "dashboards": "仪表板库", + "management": "仪表板管理", + "view-dashboards": "查看仪表板", + "add": "添加仪表板", + "assign-dashboard-to-customer": "将仪表板分配给客户", + "assign-dashboard-to-customer-text": "请选择要分配给客户的仪表板", + "assign-dashboard-to-edge-title": "将仪表板分配给边缘", + "assign-to-customer-text": "请选择客户分配仪表板", + "assign-to-customer": "分配给客户", + "unassign-from-customer": "取消分配客户", + "make-public": "仪表板设为公开", + "make-private": "仪表板设为私有", + "manage-assigned-customers": "管理已分配的客户", + "assigned-customers": "已分配的客户", + "assign-to-customers": "将仪表板分配给客户", + "assign-to-customers-text": "请选择客户指定仪表板", + "unassign-from-customers": "客户未分配仪表板", + "unassign-from-customers-text": "请选择从仪表板中取消分配的客户", + "no-dashboards-text": "未找到仪表板", + "no-widgets": "没有配置部件", + "add-widget": "添加新的部件", + "title": "标题", + "image": "仪表板图片", + "mobile-app-settings": "移动端应用设置", + "mobile-order": "移动端应用中的仪表板排序", + "mobile-hide": "在移动端应用中隐藏仪表板", + "update-image": "更新缩略图", + "take-screenshot": "截图", + "select-widget-title": "选择部件", + "select-widget-value": "{{title}}: 选择部件", + "select-widget-subtitle": "可用的部件类型列表", + "delete": "删除仪表板", + "title-required": "标题必填。", + "title-max-length": "标题长度必须少于256个字符", + "description": "说明", + "details": "详情", + "dashboard-details": "仪表板详情", + "add-dashboard-text": "添加新的仪表板", + "assign-dashboards": "分配仪表板", + "assign-new-dashboard": "分配新的仪表板", + "assign-dashboards-text": "分配 { count, plural, 1 {# 个仪表板} other {# 个仪表板} } 给客户", + "unassign-dashboards-action-text": "取消分配 { count, plural, 1 {# 个仪表板} other {# 个仪表板} } 给客户", + "delete-dashboards": "删除仪表板", + "unassign-dashboards": "取消分配仪表板", + "unassign-dashboards-action-title": "取消分配此客户 { count, plural, 1 {# 个仪表板} other {# 个仪表板} }", + "delete-dashboard-title": "确定要删除仪表板 '{{dashboardTitle}}'吗?", + "delete-dashboard-text": "请注意:确认后,仪表板及其所有相关数据将不可恢复。", + "delete-dashboards-title": "确定要删除 { count, plural, 1 {# 个仪表板} other {# 个仪表板} }吗?", + "delete-dashboards-action-title": "删除 { count, plural, 1 {# 个仪表板} other {# 个仪表板} }", + "delete-dashboards-text": "请注意:确认后,所有选定的仪表板将被删除,所有相关数据将不可恢复。", + "unassign-dashboard-title": "确定要取消分配仪表板 '{{dashboardTitle}}'吗?", + "unassign-dashboard-text": "确认后,面板将被取消分配,客户将无法访问。", + "unassign-dashboard": "取消分配仪表板", + "unassign-dashboards-title": "确定要取消分配仪表板 { count, plural, 1 {# 个仪表板} other {# 个仪表板} } 吗?", + "unassign-dashboards-text": "确认后,所有选定的仪表板将被取消分配,客户将无法访问。", + "public-dashboard-title": "仪表板现已公开", + "public-dashboard-text": "仪表板 {{dashboardTitle}} 已被公开,可通过如下 链接访问:", + "public-dashboard-notice": "提示: 不要忘记将相关设备公开以访问其数据。", + "make-private-dashboard-title": "确定要将仪表板 '{{dashboardTitle}}' 设为私有吗?", + "make-private-dashboard-text": "确认后,仪表板将被设为私有,不能被其他人访问。", + "make-private-dashboard": "仪表板设为私有", + "socialshare-text": "'{{dashboardTitle}}' 由Thingsboard提供支持", + "socialshare-title": "'{{dashboardTitle}}' 由Thingsboard提供支持", + "select-dashboard": "选择仪表板", + "no-dashboards-matching": "未找到匹配 '{{entity}}' 的仪表板。", + "dashboard-required": "仪表板必填。", + "select-existing": "选择现有仪表板", + "create-new": "创建新的仪表板", + "new-dashboard-title": "新仪表板标题", + "open-dashboard": "打开仪表板", + "set-background": "设置背景", + "background-color": "背景颜色", + "background-image": "背景图片", + "background-size-mode": "背景大小模式", + "no-image": "无图像选择", + "empty-image": "No image", + "drop-image": "拖拽图像或单击以选择要上传的文件。", + "maximum-upload-file-size": "最大上传文件大小: {{ size }}", + "cannot-upload-file": "无法上传文件", + "settings": "设置", + "layout-settings": "Layout settings", + "columns-count": "列数", + "columns-count-required": "需要列数。", + "min-columns-count-message": "只允许最少10列", + "max-columns-count-message": "只允许最多1000列", + "widgets-margins": "部件间边距", + "margin-required": "边距值必填。", + "min-margin-message": "最小边距值只允许为0。", + "max-margin-message": "仅允许50作为最大边距值。", + "horizontal-margin": "水平边距", + "horizontal-margin-required": "需要水平边距值。", + "min-horizontal-margin-message": "只允许0作为最小水平边距值。", + "max-horizontal-margin-message": "只允许50作为最大水平边距值。", + "vertical-margin": "垂直边距", + "vertical-margin-required": "需要垂直边距值。", + "min-vertical-margin-message": "只允许0作为最小垂直边距值。", + "max-vertical-margin-message": "只允许50作为最大垂直边距值。", + "autofill-height": "自动填充布局高度", + "mobile-layout": "移动端布局设置", + "mobile-row-height": "移动端行高距(px)", + "mobile-row-height-required": "移动端行高距必填。", + "min-mobile-row-height-message": "移动端行高距至少5px。", + "max-mobile-row-height-message": "移动端行高距最多200px。", + "title-settings": "Title settings", + "display-title": "显示仪表板标题", + "title-color": "标题颜色", + "toolbar-settings": "Toolbar settings", + "hide-toolbar": "Hide toolbar", + "toolbar-always-open": "工具栏常驻", + "display-dashboards-selection": "显示仪表板选项", + "display-entities-selection": "显示实体选项", + "display-filters": "显示筛选器", + "display-dashboard-timewindow": "显示时间窗口", + "display-dashboard-export": "显示导出", + "display-update-dashboard-image": "显示仪表板缩略图", + "dashboard-logo-settings": "Logo设置", + "display-dashboard-logo": "在仪表板全屏模式下显示 Logo", + "dashboard-logo-image": "仪表板 Logo 图片", + "advanced-settings": "高级设置", + "dashboard-css": "仪表板样式", + "import": "导入仪表板", + "export": "导出仪表板", + "export-failed-error": "无法导出仪表板: {{error}}", + "create-new-dashboard": "创建新的仪表板", + "dashboard-file": "仪表板文件", + "invalid-dashboard-file-error": "无法导入仪表板: 仪表板数据结构无效。", + "dashboard-import-missing-aliases-title": "配置导入仪表板使用的别名", + "create-new-widget": "创建新部件", + "import-widget": "导入部件", + "widget-file": "部件文件", + "invalid-widget-file-error": "无法导入窗口部件: 窗口部件数据结构无效。", + "widget-import-missing-aliases-title": "配置导入的窗口部件使用的别名", + "open-toolbar": "打开仪表板工具栏", + "close-toolbar": "关闭工具栏", + "configuration-error": "配置错误", + "alias-resolution-error-title": "仪表板别名配置错误", + "invalid-aliases-config": "无法找到与某些别名筛选器匹配的任何设备。
    请联系您的管理员以解决此问题。", + "select-devices": "选择设备", + "assignedToCustomer": "分配给客户", + "assignedToCustomers": "分配给客户", + "public": "公开", + "copyId": "复制仪表板Id", + "idCopiedMessage": "仪表板ID已经复制到粘贴板", + "public-link": "公共链接", + "copy-public-link": "复制公共链接", + "public-link-copied-message": "仪表板的公共链接已被复制到剪贴板", + "manage-states": "仪表板状态管理", + "states": "仪表板状态", + "search-states": "仪表板状态检索", + "selected-states": "已选择 { count, plural, 1 {# 个仪表板状态} other {# 个仪表板状态} }", + "edit-state": "仪表板状态编辑", + "delete-state": "删除仪表板状态", + "add-state": "添加仪表板状态", + "no-states-text": "未找到状态", + "state": "仪表板状态", + "state-name": "名称", + "state-name-required": "仪表板状态名必填。", + "state-id": "状态ID", + "state-id-required": "仪表板状态ID必填。", + "state-id-exists": "仪表板状态ID已经存在。", + "is-root-state": "根状态", + "delete-state-title": "删除仪表板状态", + "delete-state-text": "确定要删除仪表板状态 '{{stateName}}' 吗?", + "show-details": "显示详情", + "hide-details": "隐藏详情", + "select-state": "选择目标状态", + "state-controller": "状态控制", + "search": "查找仪表板", + "selected-dashboards": "已选择 { count, plural, 1 {# 个仪表盘} other {# 个仪表盘} }", + "home-dashboard": "首页仪表板", + "home-dashboard-hide-toolbar": "隐藏首页仪表板工具栏", + "unassign-dashboard-from-edge-text": "确认后,所有选定的仪表板将被取消分配,边缘将无法访问。", + "unassign-dashboards-from-edge-title": "确定要取消分配仪表板 { count, plural, 1 {# 个仪表板} other {# 个仪表板} } 吗?", + "unassign-dashboards-from-edge-text": "确认后,所有选定的仪表板将被取消分配,边缘将无法访问。", + "assign-dashboard-to-edge": "将仪表板分配给边缘", + "assign-dashboard-to-edge-text": "请选择要分配给边缘的仪表板", + "non-existent-dashboard-state-error": "仪表盘\"{{ stateId }}\"未找到" + }, + "datakey": { + "settings": "设置", + "advanced": "高级", + "label": "标签", + "color": "颜色", + "units": "单位符号", + "decimals": "小数位数", + "data-generation-func": "数据生成功能", + "use-data-post-processing-func": "使用数据后处理功能", + "configuration": "数据键配置", + "timeseries": "Timeseries", + "attributes": "属性", + "entity-field": "实体字段", + "alarm": "告警字段", + "timeseries-required": "实体 Timeseries 必填。", + "timeseries-or-attributes-required": "实体 Timeseries/属性必填。", + "alarm-fields-timeseries-or-attributes-required": "告警字段或实体 Timeseries/属性必填。", + "maximum-timeseries-or-attributes": "最多允许 { count, plural, 1 {# 个 timeseries/属性。} other {# 个 timeseries/属性。} }", + "alarm-fields-required": "告警字段必填。", + "function-types": "函数类型", + "function-type": "函数类型", + "function-types-required": "需要函数类型。", + "alarm-keys": "报警数据键", + "alarm-key": "报警数据键", + "alarm-key-functions": "报警数据函数", + "alarm-key-function": "报警数据函数", + "latest-keys": "最新数据键", + "latest-key": "最新数据键", + "latest-key-functions": "最新数据函数", + "latest-key-function": "最新数据函数", + "timeseries-keys": "时序数据键", + "timeseries-key": "时序数据键", + "timeseries-key-functions": "时序数据函数", + "timeseries-key-function": "时序数据函数", + "maximum-function-types": "最多允许 { count, plural, 1 {# 个函数类型} other {# 个函数类型} }", + "time-description": "当前值的时间戳;", + "value-description": "当前值;", + "prev-value-description": "上一次函数调用的结果;", + "time-prev-description": "上一个值的时间戳;", + "prev-orig-value-description": "先前的原始值;", + "aggregation": "聚合", + "aggregation-type-hint-common": "出于性能原因聚合值计算仅适用于“当前日期”、“当前月份”等固定时间间隔,而不适用于“最近30分钟”或“最近24小时”等滑动窗口间隔。", + "aggregation-type-none-hint": "取最新值。", + "aggregation-type-min-hint": "在选定时间窗口内的数据点中查找最小值。", + "aggregation-type-max-hint": "在选定时间窗口内的数据点中查找最大值。", + "aggregation-type-avg-hint": "计算选定时间窗口内数据点之间的平均值。", + "aggregation-type-sum-hint": "对选定时间窗口内所有数据点的值求和。", + "aggregation-type-count-hint": "选定时间窗口内的数据点总数。", + "delta-calculation": "增量计算", + "enable-delta-calculation": "启用增量计算", + "enable-delta-calculation-hint": "启用后将根据选定时间窗口和指定比较时段的聚合值计算数据键值。出于性能原因,增量计算仅适用于历史时间窗口,而不适用于实时值。例如,您可以计算昨天的能耗与前天的能耗之间的差值。", + "delta-calculation-result": "增量计算结果", + "delta-calculation-result-previous-value": "上个值", + "delta-calculation-result-delta-absolute": "绝对值", + "delta-calculation-result-delta-percent": "百分比" + }, + "datasource": { + "type": "数据源类型", + "name": "名称", + "label": "标签", + "add-datasource-prompt": "请添加数据源" + }, + "details": { + "details": "详情", + "edit-mode": "编辑模式", + "edit-json": "编辑JSON", + "toggle-edit-mode": "切换编辑模式" + }, + "device": { + "device": "设备", + "device-required": "设备必填", + "devices": "设备", + "management": "设备管理", + "view-devices": "查看设备", + "device-alias": "设备别名", + "device-type-max-length": "设备类型长度必须少于256个字符", + "aliases": "设备别名", + "no-alias-matching": "'{{alias}}' 未找到。", + "no-aliases-found": "未找到别名。", + "no-key-matching": "'{{key}}' 未找到。", + "no-keys-found": "未找到密钥。", + "create-new-alias": "创建一个新的!", + "create-new-key": "创建一个新的!", + "duplicate-alias-error": "找到重复别名 '{{alias}}'。
    设备别名必须是唯一的。", + "configure-alias": "配置 '{{alias}}' 别名", + "no-devices-matching": "未找到与 '{{entity}}' 匹配的设备。", + "alias": "别名", + "alias-required": "设备别名必填。", + "remove-alias": "删除设备别名", + "add-alias": "添加设备别名", + "name-starts-with": "名称前缀", + "help-text": "根据需要可以使用'%'进行匹配,例如:'%device_name_contains%','%device_name_ends','device_starts_with'。", + "device-list": "设备列表", + "use-device-name-filter": "使用筛选器", + "device-list-empty": "没有被选中的设备", + "device-name-filter-required": "设备名称筛选器必填。", + "device-name-filter-no-device-matched": "未找到以'{{device}}' 开头的设备。", + "add": "添加设备", + "assign-to-customer": "分配给客户", + "assign-device-to-customer": "将设备分配给客户", + "assign-device-to-customer-text": "请选择要分配给客户的设备", + "assign-device-to-edge-title": "将设备分配给边缘", + "assign-device-to-edge-text": "请选择要分配给边缘的设备", + "make-public": "公开", + "make-private": "私有", + "no-devices-text": "未找到设备", + "assign-to-customer-text": "请选择客户分配设备", + "device-details": "设备详细信息", + "add-device-text": "添加新设备", + "credentials": "凭据", + "manage-credentials": "管理凭据", + "delete": "删除设备", + "assign-devices": "分配设备", + "assign-devices-text": "将 {count,plural,1 {# 个设备} other {# 个设备} }分配给客户", + "delete-devices": "删除设备", + "unassign-from-customer": "取消分配客户", + "unassign-devices": "取消分配设备", + "unassign-devices-action-title": "取消分配此客户 {count,plural,1 {# 个设备} other {# 个设备} }", + "unassign-device-from-edge-title": "确定要取消分配设备 '{{deviceName}}' 吗?", + "unassign-device-from-edge-text": "确认后,设备将被取消分配,边缘将无法访问。", + "unassign-devices-from-edge": "取消分配边缘", + "assign-new-device": "分配新设备", + "make-public-device-title": "确定要将设备 '{{deviceName}}' 设为公开吗?", + "make-public-device-text": "确认后,设备及其所有数据将被设为公开并可被其他人访问。", + "make-private-device-title": "确定要将设备 '{{deviceName}}' 设为私有吗?", + "make-private-device-text": "确认后,设备及其所有数据将被设为私有,不被其他人访问。", + "view-credentials": "查看凭据", + "delete-device-title": "确定要删除设备的{{deviceName}}吗?", + "delete-device-text": "请注意:确认后,设备及其所有相关数据将不可恢复。", + "delete-devices-title": "确定要删除{count,plural,1 {# 个设备} other {# 个设备} } 吗?", + "delete-devices-action-title": "删除 {count,plural,1 {# 个设备} other {# 个设备} }", + "delete-devices-text": "请注意:确认后,所有选定的设备将被删除,所有相关数据将不可恢复。", + "unassign-device-title": "确定要取消分配设备 '{{deviceName}}' 吗?", + "unassign-device-text": "确认后,设备将被取消分配,客户将无法访问。", + "unassign-device": "取消分配设备", + "unassign-devices-title": "确定要取消分配 {count,plural,1 {# 个设备} other {# 个设备} } 吗?", + "unassign-devices-text": "确认后,所有选定的设备将被取消分配,并且客户将无法访问。", + "device-credentials": "设备凭据", + "loading-device-credentials": "加载设备凭证...", + "credentials-type": "凭据类型", + "access-token": "访问令牌", + "access-token-required": "访问令牌必填", + "access-token-invalid": "访问令牌长度必须为1到32个字符。", + "certificate-pem-format": "PEM证书格式", + "certificate-pem-format-required": "PEM证书格式必填。", + "lwm2m-security-config": { + "identity": "客户端标识", + "identity-required": "客户端标识必填。", + "identity-tooltip": "PSK标识符是多达128字节的任意PSK标识符,如标准[RFC7925]所述。", + "client-key": "客户端密钥", + "client-key-required": "客户端密钥必填。", + "client-key-tooltip-prk": "RPK公钥必须满足标准[RFC7250]并且Base64编码!", + "client-key-tooltip-psk": "PSK密钥必须是[RFC4279]标准和HexDec格式: 32, 64, 128字符!", + "endpoint": "客户端名称", + "endpoint-required": "客户端名称必填。", + "client-public-key": "客户端公钥", + "client-public-key-hint": "如果客户端公钥为空则使用信任证书", + "client-public-key-tooltip": "X509公钥必须是DER-encoded X509v3格式并支持EC算法编码Base64!", + "mode": "安全配置模式", + "client-tab": "客户端安全配置", + "client-certificate": "客户端证书", + "bootstrap-tab": "Bootstrap Client", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "client-publicKey-or-id": "客户端公钥或ID", + "client-publicKey-or-id-required": "客户端公钥或ID必填。", + "client-publicKey-or-id-tooltip-psk": "SK标识符是最多128字节,如标准[RFC7925]所述。", + "client-publicKey-or-id-tooltip-rpk": "RPK必须满足标准[RFC7250]而且编码为 Base64!", + "client-publicKey-or-id-tooltip-x509": "X509公钥必须是X509v3格式而且支持EC算法", + "client-secret-key": "客户端密钥", + "client-secret-key-required": "客户端密钥必填。", + "client-secret-key-tooltip-psk": "PSK键必须是[RFC4279]标准和HexDec格式: 32, 64, 128i字符!", + "client-secret-key-tooltip-prk": "RPK密钥必须是PKCS_8格式(DER编码, 标准[RFC5958]) 并编码为Base64!", + "client-secret-key-tooltip-x509": "X509密钥必须是PKCS_8格式(DER编码, 标准[RFC5958])!" + }, + "client-id": "客户端ID", + "client-id-pattern": "包含无效字符。", + "user-name": "用户名", + "user-name-required": "用户名必填。", + "client-id-or-user-name-necessary": "客户端ID和/或用户名是必需的", + "password": "密码", + "secret": "密钥", + "secret-required": "密钥必填", + "device-type": "设备类型", + "device-type-required": "设备类型必填。", + "select-device-type": "选择设备类型", + "enter-device-type": "输入设备类型", + "any-device": "任意设备", + "no-device-types-matching": "未找到匹配 '{{entitySubtype}}' 的设备类型。", + "device-type-list-empty": "未选择设备类型", + "device-types": "设备类型", + "name": "名称", + "name-required": "名称必填。", + "name-max-length": "名称长度必须少于256个字符", + "label-max-length": "标签长度必须少于256个字符", + "description": "说明", + "label": "标签", + "events": "事件", + "details": "详情", + "copyId": "复制设备ID", + "copyAccessToken": "复制访问令牌", + "copy-mqtt-authentication": "复制MQTT凭据", + "idCopiedMessage": "设备ID已复制到剪贴板", + "accessTokenCopiedMessage": "设备访问令牌已复制到剪贴板", + "mqtt-authentication-copied-message": "设备MQTT身份验证已复制到剪贴板", + "assignedToCustomer": "分配给客户", + "unable-delete-device-alias-title": "无法删除设备别名", + "unable-delete-device-alias-text": "设备别名 '{{deviceAlias}}' 不能够被删除,因为它被下列部件所使用:
    {{widgetsList}}", + "is-gateway": "是否网关", + "overwrite-activity-time": "覆盖已连接设备的活动时间", + "public": "公开", + "device-public": "设备公开", + "select-device": "选择设备", + "import": "导入设备", + "device-file": "设备文件", + "search": "查找设备", + "selected-devices": "已选择 { count, plural, 1 {# 个设备} other {# 个设备} }", + "device-configuration": "设备配置", + "transport-configuration": "传输配置", + "wizard": { + "device-wizard": "设备向导", + "device-details": "设备详细信息", + "new-device-profile": "新建设备配置", + "existing-device-profile": "选择已有设备配置", + "specific-configuration": "指定配置", + "customer-to-assign-device": "客户分配设备", + "add-credentials": "添加凭据" + }, + "unassign-devices-from-edge-title": "确定要取消分配 { count, plural, 1 {1 个设备} other {# 个设备} } 吗?", + "unassign-devices-from-edge-text": "确认后,设备将被取消分配,边缘将无法访问。" + }, + "asset-profile": { + "asset-profile": "资产配置", + "asset-profiles": "资产配置", + "all-asset-profiles": "全部", + "add": "添加资产配置", + "edit": "编辑资产配置", + "asset-profile-details": "资产配置详情", + "no-asset-profiles-text": "未找到资产配置", + "search": "搜索资产配置", + "selected-asset-profiles": "已选择 { count, plural, 1 {1 个资产配置} other {# 个资产配置} }", + "no-asset-profiles-matching": "未找到与 '{{entity}}' 匹配的资产配置。", + "asset-profile-required": "资产配置必填", + "idCopiedMessage": "资产配置ID已复制到剪贴板", + "set-default": "设为默认资产配置", + "delete": "删除资产配置", + "copyId": "复制资产配置ID", + "name-max-length": "名称长度必须少于256个字符", + "new-device-profile-name": "资产配置名称", + "new-device-profile-name-required": "资产配置名称必填。", + "name": "名称", + "name-required": "名称必填。", + "image": "资产配置图片", + "description": "说明", + "default": "默认", + "default-rule-chain": "默认规则链", + "mobile-dashboard": "移动端仪表板", + "mobile-dashboard-hint": "被移动端应用用作资产详情仪表板", + "select-queue-hint": "从下拉列表中选择。", + "delete-asset-profile-title": "确定要删除 '{{assetProfileName}}' 资产配置吗?", + "delete-asset-profile-text": "请注意:确认后,资产配置和所有相关数据将不可恢复。", + "delete-asset-profiles-title": "确定要删除 { count, plural, 1 {1 个资产配置} other {# 个资产配置} }吗?", + "delete-asset-profiles-text": "请注意:确认后,所有选定的资产配置将被删除,所有相关数据将不可恢复。", + "set-default-asset-profile-title": "确定要将 '{{assetProfileName}}' 设为默认资产配置吗?", + "set-default-asset-profile-text": "确认后,资产配置将被标记为默认,并将用于未指定配置的新资产。", + "no-asset-profiles-found": "未不到资产配置。", + "create-new-asset-profile": "创建一个新的!", + "create-asset-profile": "创建资产配置", + "import": "导入资产配置", + "export": "导出资产配置", + "export-failed-error": "无法导出资产配置: {{error}}", + "asset-profile-file": "资产配置文件", + "invalid-asset-profile-file-error": "无法导入资产配置:无效的资产配置数据结构。" + }, + "device-profile": { + "device-profile": "设备配置", + "device-profiles": "设备配置", + "all-device-profiles": "全部", + "add": "添加设备配置", + "edit": "编辑设备配置", + "device-profile-details": "设备配置详情", + "no-device-profiles-text": "未找到设备配置", + "search": "查找设备配置", + "selected-device-profiles": "已选择 { count, plural, 1 {# 个设备配置} other {# 个设备配置} }", + "no-device-profiles-matching": "未找到与 '{{entity}}' 匹配的设备配置。", + "device-profile-required": "设备配置必填", + "idCopiedMessage": "设备配置 ID 已复制到剪贴板", + "set-default": "设为默认设备配置", + "delete": "删除设备配置", + "copyId": "复制设备配置 ID", + "name-max-length": "名称长度必须少于256个字符", + "new-device-profile-name": "设备配置名称", + "new-device-profile-name-required": "设备配置名称必填。", + "name": "名称", + "name-required": "名称是必需的。", + "type": "配置类型", + "type-required": "配置类型必填。", + "type-default": "默认", + "image": "设备配置图片", + "transport-type": "传输方式", + "transport-type-required": "传输方式必填。", + "transport-type-default": "默认", + "transport-type-default-hint": "支持基本MQTT、HTTP和CoAP传输", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "启用高级MQTT传输设置", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "启用高级 CoAP 传输设置", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M传输类型", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "指定 SNMP 传输配置", + "description": "说明", + "default": "默认", + "profile-configuration": "配置", + "transport-configuration": "传输配置", + "default-rule-chain": "默认规则链", + "mobile-dashboard": "移动端仪表板", + "mobile-dashboard-hint": "被移动端应用用作设备详情仪表板", + "select-queue-hint": "从下拉列表中选择或添加自定义名称。", + "delete-device-profile-title": "确定要删除 '{{deviceProfileName}}' 设备配置吗?", + "delete-device-profile-text": "请注意:确认后,设备配置和所有相关数据将不可恢复。", + "delete-device-profiles-title": "确定要删除 { count, plural, 1 {# 个设备配置} other {# 个设备配置} }吗?", + "delete-device-profiles-text": "请注意:确认后,所有选定的设备配置将被删除,所有相关数据将不可恢复。", + "set-default-device-profile-title": "确定要将设备配置 '{{deviceProfileName}}' 设为默认值吗?", + "set-default-device-profile-text": "确认后,设备配置将被标记为默认,并将用于未指定配置的新设备。", + "no-device-profiles-found": "未找到设备配置。", + "create-new-device-profile": "创建一个新的!", + "mqtt-device-topic-filters": "MQTT 设备 Topic 筛选器", + "mqtt-device-topic-filters-unique": "MQTT设备 Topic 筛选器必须唯一。", + "mqtt-device-payload-type": "MQTT 设备 Payload", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "启用与其他payload格式兼容。", + "mqtt-enable-compatibility-with-json-payload-format-hint": "启用后平台将默认使用Protobuf的payload格式,如果解析失败平台将尝试使用JSON的payload格式。对于固件更新期间的向后兼容性很有用,例如固件的初始版本使用Json而新版本使用Protobuf在设备队列的固件更新过程中,需要同时支持Protobuf和JSON。兼容模式会导致一点的性能下降,因此建议在所有设备更新后禁用此模式。", + "mqtt-use-json-format-for-default-downlink-topics": "缺省下行主题采用json格式", + "mqtt-use-json-format-for-default-downlink-topics-hint": "启用后平台将使用Json的playload格式通过以下主题推送属性和RPC:v1/devices/me/attributes/response/$request_idv1/devices/me/attributes v1/devices/me/rpc/request/$request_idv1/devices/me/rpc/response/$request_id。此设置不会影响使用新(v2)主题发送的属性和rpc订阅:v2/a/res/$request_idv2/av2/r /req/$request_idv2/r/res/$request_id。其中$request_id是一个整数请求标识符。", + "mqtt-send-ack-on-validation-exception": "发布消息验证失败时发送PUBACK", + "mqtt-send-ack-on-validation-exception-hint": "默认情况下平台将关闭相关消息验证失败的MQTT会话,启用后平台将发布确认而不是关闭会话。", + "snmp-add-mapping": "添加SNMP映射", + "snmp-mapping-not-configured": "没有配置时间序列/属性的OID映射", + "snmp-timseries-or-attribute-name": "映射时间序列/属性名称", + "snmp-timseries-or-attribute-type": "映射时间序列/属性名称", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payload 类型必填。", + "coap-device-type": "CoAP 设备类型", + "coap-device-payload-type": "CoAP 设备消息 Payload", + "coap-device-type-required": "CoAP 设备类型必填。", + "coap-device-type-default": "默认", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "支持单[+]和多级[#]通配符。", + "telemetry-topic-filter": "遥测数据 topic 筛选器", + "telemetry-topic-filter-required": "遥测数据 topic 筛选器必填。", + "attributes-topic-filter": "Attributes topic filter", + "attributes-topic-filter-required": "Attributes topic 筛选器必填。", + "telemetry-proto-schema": "遥测数据 proto schema", + "telemetry-proto-schema-required": "遥测数据 proto schema 必填。", + "attributes-proto-schema": "Attributes proto schema", + "attributes-proto-schema-required": "Attributes proto schema 必填。", + "rpc-response-proto-schema": "RPC 响应 proto schema", + "rpc-response-proto-schema-required": "RPC 响应 proto schema 必填。", + "rpc-response-topic-filter": "RPC响应 Topic 筛选器", + "rpc-response-topic-filter-required": "RPC响应 Topic 筛选器必填。", + "rpc-request-proto-schema": "RPC 请求 proto schema", + "rpc-request-proto-schema-required": "RPC 请求 proto schema 必填。", + "rpc-request-proto-schema-hint": "RPC 请求消息应始终包含字段:string method = 1; int32 requestId = 2; 和params = 3的任何数据类型。", + "not-valid-pattern-topic-filter": "无效的 Topic 筛选器模式", + "not-valid-single-character": "单级通配符的使用无效", + "not-valid-multi-character": "多级通配符的使用无效", + "single-level-wildcards-hint": "[+] is suitable for any topic filter level。例如:v1/devices/+/telemetry or +/devices/+/attributes。", + "multi-level-wildcards-hint": "[#]可以替换 topic filter 本身,并且必须是 topic 的最后一个符号。例如:# or v1/devices/me/#。", + "alarm-rules": "告警规则", + "alarm-rules-with-count": "告警规则 ({{count}})", + "no-alarm-rules": "未配置告警规则", + "add-alarm-rule": "添加告警规则", + "edit-alarm-rule": "编辑告警规则", + "alarm-type": "告警类型", + "alarm-type-required": "告警类型必填。", + "alarm-type-unique": "警报类型在设备配置警报规则中必须唯一。", + "alarm-type-max-length": "Alarm type should be less than 256", + "create-alarm-pattern": "创建 {{alarmType}} 告警", + "create-alarm-rules": "创建告警规则", + "no-create-alarm-rules": "未配置创建条件", + "add-create-alarm-rule-prompt": "请添加创建告警规则", + "clear-alarm-rule": "清除告警规则", + "no-clear-alarm-rule": "未配置明确条件", + "add-create-alarm-rule": "添加创建条件", + "add-clear-alarm-rule": "添加清除条件", + "select-alarm-severity": "选择告警严重性", + "alarm-severity-required": "告警严重级别必填。", + "condition-duration": "条件持续时间", + "condition-duration-value": "持续时间值", + "condition-duration-time-unit": "时间单位", + "condition-duration-value-range": "持续时间值应在1到2147483647之间。", + "condition-duration-value-pattern": "持续时间值应为整数。", + "condition-duration-value-required": "持续时间值必填。", + "condition-duration-time-unit-required": "时间单位必填。", + "advanced-settings": "高级设置", + "alarm-rule-details": "详情", + "alarm-rule-details-hint": "提示:使用${keyName}警报条件中属性值或报文关键字。", + "add-alarm-rule-details": "详情模板:", + "alarm-rule-mobile-dashboard": "移动仪表盘", + "alarm-rule-mobile-dashboard-hint": "用于警报详细报告", + "alarm-rule-no-mobile-dashboard": "未选择仪表板", + "propagate-alarm": "传递警报", + "alarm-rule-relation-types-list": "要传递的关联类型", + "alarm-rule-relation-types-list-hint": "如果未选择传递关联类型,则将不按关联类型过滤而传递告警。", + "propagate-alarm-to-owner": "向实体所有者(客户或租户)传播警报", + "propagate-alarm-to-tenant": "向租户传播警报", + "alarm-details": "告警详细信息", + "alarm-rule-condition": "告警规则条件", + "enter-alarm-rule-condition-prompt": "请添加告警规则条件", + "edit-alarm-rule-condition": "编辑告警规则条件", + "device-provisioning": "设备预配置", + "provision-strategy": "预配置策略", + "provision-strategy-required": "预配置策略必填。", + "provision-strategy-disabled": "禁用", + "provision-strategy-created-new": "允许创建新设备", + "provision-strategy-check-pre-provisioned": "检查预配置的设备", + "provision-device-key": "预配置设备密钥名", + "provision-device-key-required": "预配置设备密钥名必填。", + "copy-provision-key": "复制预配置密钥名", + "provision-key-copied-message": "预配置密钥名已复制到剪贴板", + "provision-device-secret": "预配置设备密钥", + "provision-device-secret-required": "预配置设备密钥必填。", + "copy-provision-secret": "复制预配置密钥", + "provision-secret-copied-message": "预配置密钥已复制到剪贴板", + "condition": "条件", + "condition-type": "条件类型", + "condition-type-simple": "简单", + "condition-type-duration": "持续时间", + "condition-during": "在 {{during}} 期间", + "condition-during-dynamic": "在\"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "重复", + "condition-type-required": "条件类型必填。", + "condition-repeating-value": "事件计数", + "condition-repeating-value-range": "事件计数应在1到2147483647之间。", + "condition-repeating-value-pattern": "事件计数应为整数。", + "condition-repeating-value-required": "事件计数值必填。", + "condition-repeat-times": "重复 { count, plural, 1 {# 次} other {# 次} }", + "condition-repeat-times-dynamic": "重复\"{ attribute }\" ({ count, plural, 1 {1 时间} other {# 时间} })", + "schedule-type": "计划程序类型", + "schedule-type-required": "计划类型必填。", + "schedule": "启用规则:", + "edit-schedule": "编辑告警日程表", + "schedule-any-time": "始终启用", + "schedule-specific-time": "定时启用", + "schedule-custom": "自定义启用", + "schedule-day": { + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期日" + }, + "schedule-days": "天", + "schedule-time": "时间", + "schedule-time-from": "从", + "schedule-time-to": "到", + "schedule-days-of-week-required": "每周至少选择一天。", + "create-device-profile": "创建设备配置", + "import": "导入设备配置", + "export": "导出设备配置", + "export-failed-error": "无法导出设备配置文件: {{error}}", + "device-profile-file": "设备配置文件", + "invalid-device-profile-file-error": "无法导入设备配置:无效的设备配置数据结构。", + "power-saving-mode": "节能模式", + "power-saving-mode-type": { + "default": "使用设备配置的节能模式", + "psm": "节能模式(PSM)", + "drx": "非连续接收(DRX)", + "edrx": "连续接收(eDRX)" + }, + "edrx-cycle": "eDRX循环", + "edrx-cycle-required": "eDRX循环必填。", + "edrx-cycle-pattern": "eDRX循环必须是一个正整数。", + "edrx-cycle-min": "eDRX循环的最小值{{ min }}秒。", + "paging-transmission-window": "分页传输窗口", + "paging-transmission-window-required": "分页传输窗口必填。", + "paging-transmission-window-pattern": "分页传输窗口必须是正整数。", + "paging-transmission-window-min": "分页传输窗口的最小值{{ min }}秒。", + "psm-activity-timer": "PSM活动计时器", + "psm-activity-timer-required": "PSM活动计时器必填。", + "psm-activity-timer-pattern": "PSM活动计时器必须是正整数", + "psm-activity-timer-min": "PSM活动计时器的最小数量为{{ min }}秒。", + "lwm2m": { + "object-list": "Object列表", + "object-list-empty": "没有选择边object。", + "no-objects-found": "没有选择边object。", + "no-objects-matching": "没有找到匹配的object'{{object}}'。", + "model-tab": "LWM2M模式", + "add-new-instances": "添加新实例", + "instances-list": "实例列表", + "instances-list-required": "实例列表必填。", + "instance-id-pattern": "实例id必须是一个正整数。", + "instance-id-max": "实例id最大值是{{max}}", + "instance": "实例", + "resource-label": "#I资源D名称", + "observe-label": "观察者", + "attribute-label": "属性", + "telemetry-label": "遥测", + "edit-observe-select": "选择观察者编辑遥测或属性", + "edit-attributes-select": "选择要编辑的遥测或属性", + "no-attributes-set": "没有设置属性", + "key-name": "键名", + "key-name-required": "键名必填", + "attribute-name": "属性名称", + "attribute-name-required": "属性名称必填。", + "attribute-value": "属性值", + "attribute-value-required": "属性值必填。", + "attribute-value-pattern": "属性值必须是一个正整数。", + "edit-attributes": "编辑属性:{{ name }}", + "view-attributes": "查看属性:{{ name }}", + "add-attribute": "添加属性", + "edit-attribute": "编辑属性", + "view-attribute": "查看属性", + "remove-attribute": "移除属性", + "delete-server-text": "请注意确认删除后服务器配置将无法恢复。", + "delete-server-title": "你确定要删除服务器吗?", + "mode": "安全配置模式", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap Server (ShortId...)", + "lwm2m-server-legend": "LwM2M Server (ShortId...)", + "server": "服务器", + "short-id": "服务器ID", + "short-id-tooltip": "服务器ID用作关联服务器对象实例的链接。", + "short-id-required": "服务器ID必填。", + "short-id-range": "服务器ID应在1到65534范围内。", + "short-id-pattern": "服务器ID必须是一个正整数。", + "lifetime": "客户端注册生命周期", + "lifetime-required": "客户端注册生命周期必填。", + "lifetime-pattern": "客户端注册生命周期必须是一个正整数。", + "default-min-period": "最小期限", + "default-min-period-tooltip": "LWM2M客户端在观察中不包含此参数时使用的默认值。", + "default-min-period-required": "最小期限必填。", + "default-min-period-pattern": "最小期限必须是一个正整数。", + "notification-storing": "禁用或离线时通知存储", + "binding": "绑定", + "binding-type": { + "u": "U: 客户端通过UDP绑定。", + "m": "M: 客户端通过MQTT绑定。", + "h": "H: 客户端通过HTTP绑定。", + "t": "T: 客户端通过TCP绑定。", + "s": "S: 客户端通过SMS绑定。", + "n": "N: 客户端通过非IP绑定将响应发送到请求(支持LWM2M 1.1)。", + "uq": "UQ: 通过UDP队列模式连接(不支持LWM2M 1.1)。", + "uqs": "UQS: 通过UDP和SMS活动连接(不支持LWM2M 1.1)。", + "tq": "TQ: 通过TCP队列模式连接(不支持LWM2M 1.1)。", + "tqs": "TQS: 通过TCP和SMS活动连接(不支持LWM2M 1.1)。", + "sq": "SQ: 通过队列模式的SMS连接(不支持LWM2M 1.1)。" + }, + "binding-tooltip": "这是LwM2M服务器对象的\"绑定\"资源列表 - /1/x/7。\n表示LwM2M客户端中支持的绑定模式。\n此值应与设备对象(/3/0/16)中的\"支持的绑定和模式\"资源。\n虽然支持多个传输但在整个传输会话期间只能使用一个传输绑定。\n 例如:当UDP和SMS都受支持,LwM2M客户端和LwM2M服务器可以选择在整个传输会话期间通过UDP或SMS进行通信。", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "include-bootstrap-server": "包含Bootstrap Server更新", + "bootstrap-update-title": "你已经配置了Bootstrap Server,您确定要排除更新吗?", + "bootstrap-update-text": "请注意确认更新后Bootstrap Server配置数据将无法恢复。", + "server-host": "主机", + "server-host-required": "主机必填。", + "server-port": "端口", + "server-port-required": "端口必填。", + "server-port-pattern": "端口必须是一个正整数。", + "server-port-range": "端口应在1到65535范围内。", + "server-public-key": "服务器公钥", + "server-public-key-required": "服务器公钥必填。", + "client-hold-off-time": "停留时间", + "client-hold-off-time-required": "停留时间必填。", + "client-hold-off-time-pattern": "停留时间必须是一个正整数。", + "client-hold-off-time-tooltip": "客户端仅与Bootstrap-Server共用停留时间", + "account-after-timeout": "帐户超时", + "account-after-timeout-required": "帐户超时必填。", + "account-after-timeout-pattern": "帐户超时必须是一个正整数。", + "account-after-timeout-tooltip": "Bootstrap-Server帐户资源的超时值。", + "server-type": "Server type", + "add-new-server-title": "添加新的服务器配置", + "add-server-config": "添加服务器配置", + "add-lwm2m-server-config": "添加LwM2M服务器", + "no-config-servers": "没有服务器配置", + "others-tab": "其它设置", + "client-strategy": "客户端连接策略", + "client-strategy-label": "策略", + "client-strategy-only-observe": "只在初始连接后观察对客户的请求", + "client-strategy-read-all": "注册后阅读所有资源并观察对客户的请求", + "fw-update": "固件升级", + "fw-update-strategy": "固件升级策略", + "fw-update-strategy-data": "发布固件升级二制文件使用Object 19和Resource 0数据。", + "fw-update-strategy-package": "发布固件升级二制文件使用Object 5和Resource 0包", + "fw-update-strategy-package-uri": "自动生成唯一的CoAP地址下载包和发布软件更新作为Object 5和Resource 1(软件包URI)。", + "sw-update": "软件更新", + "sw-update-strategy": "软件更新策略", + "sw-update-strategy-package": "发布二制文件使用Object 9和Resource 2(包)", + "sw-update-strategy-package-uri": "自动生成唯一的CoAP地址下载包和发布软件更新作为Object 9和Resource 3(软件包URI)。", + "fw-update-resource": "固件更新COAP资源", + "fw-update-resource-required": "固件更新COAP资源必填。", + "sw-update-resource": "软件更新COAP资源", + "sw-update-resource-required": "软件更新COAP资源必填。", + "config-json-tab": "设备配置JSON", + "attributes-name": { + "min-period": "最小期限", + "max-period": "最大期限", + "greater-than": "大于", + "less-than": "少于", + "step": "停止", + "min-evaluation-period": "最小期限评估", + "max-evaluation-period": "最大期限评估" + }, + "composite-operations-support": "支持Read/Write/Observe操作" + }, + "snmp": { + "add-communication-config": "添加通信配置", + "add-mapping": "添加映射", + "authentication-passphrase": "身份验证密码", + "authentication-passphrase-required": "身份验证密码必填。", + "authentication-protocol": "身份验证协议", + "authentication-protocol-required": "身份验证协议必填。", + "communication-configs": "通信配置", + "community": "Community字符串", + "community-required": "Community字符串必填。", + "context-name": "上下文名称", + "data-key": "数据键", + "data-key-required": "数据键必填。", + "data-type": "数据类型", + "data-type-required": "数据类型必填。", + "engine-id": "引擎ID", + "host": "主机", + "host-required": "主机必填。", + "oid": "OID", + "oid-pattern": "无效OID格式", + "oid-required": "OID必填。", + "please-add-communication-config": "请添加通信配置", + "please-add-mapping-config": "请添加映射配置", + "port": "端口", + "port-format": "端口格式无效", + "port-required": "端口必填。", + "privacy-passphrase": "私有密码", + "privacy-passphrase-required": "私有密码必填。", + "privacy-protocol": "私有协议", + "privacy-protocol-required": "私有协议必填。", + "protocol-version": "协议版本", + "protocol-version-required": "协议版本必填。", + "querying-frequency": "查询频率(ms)", + "querying-frequency-invalid-format": "查询频率必须是一个正整数。", + "querying-frequency-required": "查询频率必填。", + "retries": "重试", + "retries-invalid-format": "重试必须是一个正整数。", + "retries-required": "重试必填。", + "scope": "范围", + "scope-required": "范围必填。", + "security-name": "Security名称", + "security-name-required": "Security名称必填。", + "timeout-ms": "超时(ms)", + "timeout-ms-invalid-format": "超时必须是一个正整数。", + "timeout-ms-required": "超时必填。", + "user-name": "用户名", + "user-name-required": "用户名必填。" + } + }, + "dialog": { + "close": "关闭对话框" + }, + "direction": { + "column": "列", + "row": "排" + }, + "edge": { + "edge": "边缘", + "edge-instances": "边缘实例", + "edge-file": "边缘文件", + "name-max-length": "名称长度必须少于256个字符", + "label-max-length": "标签长度必须少于256个字符", + "type-max-length": "类型长度必须少于256个字符", + "management": "边缘管理", + "no-edges-matching": "未找到匹配的边缘 '{{entity}}'。", + "add": "增加边缘", + "no-edges-text": "未找到边缘", + "edge-details": "边缘详情", + "add-edge-text": "增加新的边缘", + "delete": "删除边缘", + "delete-edge-title": "确定删除边缘 '{{edgeName}}'吗?", + "delete-edge-text": "当心, 确认后,边缘以及所有关联数据将不可恢复。", + "delete-edges-title": "确定删除 { count, plural, 1 {1 个边缘} other {# 个边缘} }吗?", + "delete-edges-text": "当心, 确认后,选定的边缘以及所有关联数据将不可恢复。", + "name": "名称", + "name-starts-with": "边缘名称前缀", + "name-required": "名称必填。", + "description": "说明", + "details": "详情", + "events": "事件", + "copy-id": "复制边缘编号", + "id-copied-message": "边缘编号已经复制到剪切板", + "sync": "同步边缘", + "edge-required": "边缘必填。", + "edge-type": "边缘类型", + "edge-type-required": "边缘类型必填。", + "event-action": "事件行动", + "entity-id": "实体编号", + "select-edge-type": "选择边缘类型", + "assign-to-customer": "分配给客户", + "assign-to-customer-text": "请选择需要分配给边缘的客户", + "assign-edge-to-customer": "分配边缘给客户", + "assign-edge-to-customer-text": "请选择需要分配给边缘的客户", + "assignedToCustomer": "分配给客户", + "edge-public": "边缘公开", + "assigned-to-customer": "分配给: {{customerTitle}}", + "unassign-from-customer": "取消分配客户", + "unassign-edge-title": "确定取消分配边缘 '{{edgeName}}' 吗?", + "unassign-edge-text": "确定后,边缘将被取消分配,并且客户将无法访问。", + "unassign-edges-title": "确定要取消分配 {count,plural,1 {1 个边缘} other {# 个边缘} } 吗?", + "unassign-edges-text": "确定后,所有选定的边缘将被取消分配,并且客户将无法访问。", + "make-public": "公开", + "make-public-edge-title": "确定要将边缘 '{{edgeName}}' 设为公开吗?", + "make-public-edge-text": "确认后,边缘及其所有数据将被设为公开并可被其他人访问。", + "make-private": "私有", + "public": "公开", + "make-private-edge-title": "确定要将边缘 '{{edgeName}}' 设为私有吗?", + "make-private-edge-text": "确认后,边缘及其所有数据将被设为私有,不被其他人访问。", + "import": "导入边缘", + "label": "标签", + "load-entity-error": "加载数据失败,实体已经被删除。", + "assign-new-edge": "分配新边缘", + "unassign-from-edge": "取消分配边缘", + "edge-key": "边缘键", + "copy-edge-key": "复制边缘键", + "edge-key-copied-message": "边缘键已经被复制到剪切板", + "edge-secret": "边缘密钥", + "copy-edge-secret": "复制边缘密钥", + "edge-secret-copied-message": "边缘密钥已经被复制到剪切板", + "edge-assets": "边缘资产", + "edge-devices": "边缘设备", + "edge-entity-views": "边缘实体视图", + "edge-dashboards": "边缘仪表板", + "edge-rulechains": "边缘规则链", + "assets": "边缘资产", + "devices": "边缘设备", + "entity-views": "边缘实体视图", + "dashboard": "边缘仪表板", + "dashboards": "边缘仪表板", + "rulechain-templates": "规则链模版", + "rulechains": "规则链", + "search": "搜索边缘", + "selected-edges": "{ count, plural, 1 {1 个边缘} other {# 个边缘} } 被选中", + "any-edge": "任何边缘", + "no-edge-types-matching": "未找到匹配的边缘类型 '{{entitySubtype}}'。", + "edge-type-list-empty": "没有选择边缘类型。", + "edge-types": "边缘类型", + "enter-edge-type": "输入边缘类型", + "deployed": "已部署", + "pending": "待定", + "downlinks": "下行", + "no-downlinks-prompt": "未找到下行", + "sync-process-started-successfully": "同步处理开始成功!", + "missing-related-rule-chains-title": "边缘缺少关联规则链", + "missing-related-rule-chains-text": "分配给边缘的规则链使用规则节点将消息转发给未分配给当前边缘的规则链。

    缺少的规则链列表:
    {{missingRuleChains}}", + "widget-datasource-error": "组件只支持边缘实体数据源" + }, + "edge-event": { + "type-dashboard": "仪表板", + "type-asset": "资产", + "type-device": "设备", + "type-device-profile": "设备概要", + "type-asset-profile": "Asset Profile", + "type-entity-view": "实体视图", + "type-alarm": "告警", + "type-rule-chain": "规则链", + "type-rule-chain-metadata": "规则链元数据", + "type-edge": "边缘", + "type-user": "用户", + "type-customer": "客户", + "type-relation": "关联", + "type-widgets-bundle": "部件包", + "type-widgets-type": "部件类型", + "type-admin-settings": "管理员设置", + "action-type-added": "增加", + "action-type-deleted": "删除", + "action-type-updated": "更新", + "action-type-post-attributes": "推送属性", + "action-type-attributes-updated": "属性更新", + "action-type-attributes-deleted": "属性删除", + "action-type-timeseries-updated": "时序更新", + "action-type-credentials-updated": "认证更新", + "action-type-assigned-to-customer": "分配给客户", + "action-type-unassigned-from-customer": "取消分配客户", + "action-type-relation-add-or-update": "关联增加或更新", + "action-type-relation-deleted": "关联删除", + "action-type-rpc-call": "RPC调用", + "action-type-alarm-ack": "告警确认", + "action-type-alarm-clear": "告警清除", + "action-type-assigned-to-edge": "分配给边缘", + "action-type-unassigned-from-edge": "取消分配边缘", + "action-type-credentials-request": "认证请求", + "action-type-entity-merge-request": "实体合并请求" + }, + "error": { + "unable-to-connect": "无法连接到服务器!请检查您的互联网连接。", + "unhandled-error-code": "未处理的错误代码: {{errorCode}}", + "unknown-error": "未知错误" + }, + "entity": { + "entity": "实体", + "entities": "实体", + "entities-count": "实体数量", + "aliases": "实体别名", + "entity-alias": "实体别名", + "unable-delete-entity-alias-title": "无法删除实体别名", + "unable-delete-entity-alias-text": "实体别名 '{{entityAlias}}' 被以下部件使用不能删除:
    {{widgetsList}}", + "duplicate-alias-error": "别名 '{{alias}}' 重复。
    同一仪表板别名必须唯一。", + "missing-entity-filter-error": "别名 '{{alias}}' 缺少筛选器", + "configure-alias": "别名 '{{alias}}' 配置", + "alias": "别名", + "alias-required": "实体别名必填。", + "remove-alias": "移除实体别名", + "add-alias": "添加实体别名", + "entity-list": "实体列表", + "entity-type": "实体类型", + "entity-types": "实体类型", + "entity-type-list": "实体类型列表", + "any-entity": "任意实体", + "enter-entity-type": "输入实体类型", + "no-entities-matching": "未找到匹配 '{{entity}}' 的实体。", + "no-entity-types-matching": "未找到匹配 '{{entityType}}' 类型的实体。", + "name-starts-with": "名称开始于", + "help-text": "根据需要可以使用'%'进行匹配,例如:'%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'。", + "use-entity-name-filter": "用户筛选器", + "entity-list-empty": "没有选择实体。", + "entity-type-list-empty": "没有选择实体类型。", + "entity-name-filter-required": "实体名筛选器必填。", + "entity-name-filter-no-entity-matched": "未找到以 '{{entity}}' 开头的实体", + "all-subtypes": "全部", + "select-entities": "选择实体", + "no-aliases-found": "未找到别名", + "no-alias-matching": "未找到 '{{alias}}'", + "create-new-alias": "创建一个新的!", + "key": "键名", + "key-name": "键名", + "no-keys-found": "未找到键名", + "no-key-matching": "未找到键名 '{{key}}'", + "create-new-key": "创建一个新的!", + "type": "类型", + "type-required": "实体类型必填。", + "type-device": "设备", + "type-devices": "设备", + "list-of-devices": "{ count, plural, 1 {1 个设备} other {# 个设备} }", + "device-name-starts-with": "以 '{{prefix}}' 开头的设备", + "type-device-profile": "设备配置", + "type-device-profiles": "设备配置", + "list-of-device-profiles": "{ count, plural, 1 {1 个设备配置} other {# 个设备配置} }", + "device-profile-name-starts-with": "名称以 '{{prefix}}' 开头的设备配置", + "type-asset-profile": "资产配置", + "type-asset-profiles": "资产配置", + "list-of-asset-profiles": "{ count, plural, 1 {One asset profile} other {List of # asset profiles} }", + "asset-profile-name-starts-with": "Asset profiles whose names start with '{{prefix}}'", + "type-asset": "资产", + "type-assets": "资产", + "list-of-assets": "{ count, plural, 1 {1 个资产} other {# 个资产} }", + "asset-name-starts-with": "以 '{{prefix}}' 开头的资产", + "type-entity-view": "实体视图", + "type-entity-views": "实体视图", + "list-of-entity-views": "{ count, plural, 1 {1 个实体视图} other {# 个实体视图} }", + "entity-view-name-starts-with": "以 '{{prefix}}' 开头的实体视图", + "type-rule": "规则", + "type-rules": "规则", + "list-of-rules": "{ count, plural, 1 {1 个规则} other {# 个规则} }", + "rule-name-starts-with": "以 '{{prefix}}' 开头的规则", + "type-plugin": "插件", + "type-plugins": "插件", + "list-of-plugins": "{ count, plural, 1 {1 个插件} other {# 个插件} }", + "plugin-name-starts-with": "以 '{{prefix}}' 开头的插件", + "type-tenant": "租户", + "type-tenants": "租户", + "list-of-tenants": "{ count, plural, 1 {1 个租户} other {# 个租户} }", + "tenant-name-starts-with": "以 '{{prefix}}' 开头的租户", + "type-tenant-profile": "租户简介", + "type-tenant-profiles": "租户配置", + "list-of-tenant-profiles": "{ count, plural, 1 {1 个租户配置} other {# 个租户配置} }", + "tenant-profile-name-starts-with": "名称以 '{{prefix}}' 开头的租户配置", + "type-customer": "客户", + "type-customers": "客户", + "list-of-customers": "{ count, plural, 1 {1 个客户} other {# 个客户} }", + "customer-name-starts-with": "以 '{{prefix}}' 开头的客户", + "type-user": "用户", + "type-users": "用户", + "list-of-users": "{ count, plural, 1 {1 个用户} other {# 个用户} }", + "user-name-starts-with": "以 '{{prefix}}' 开头的用户", + "type-dashboard": "仪表板", + "type-dashboards": "仪表板", + "list-of-dashboards": "{ count, plural, 1 {1 个仪表板} other {# 个仪表板} }", + "dashboard-name-starts-with": "以 '{{prefix}}' 开头的仪表板", + "type-alarm": "告警", + "type-alarms": "告警", + "list-of-alarms": "{ count, plural, 1 {1 个告警} other {# 个告警} }", + "alarm-name-starts-with": "以 '{{prefix}}' 开头的告警", + "type-rulechain": "规则链", + "type-rulechains": "规则链库", + "list-of-rulechains": "{ count, plural, 1 {1 个规则链} other {# 个规则链} }", + "rulechain-name-starts-with": "规则链前缀名称 '{{prefix}}'", + "type-rulenode": "规则节点", + "type-rulenodes": "规则节点", + "list-of-rulenodes": "{ count, plural, 1 {1 个规则节点} other {# 个规则节点} }", + "rulenode-name-starts-with": "名称以 '{{prefix}}' 开头的规则节点", + "type-current-customer": "当前客户", + "type-current-tenant": "当前租户", + "type-current-user": "当前用户", + "type-current-user-owner": "当前用户所有者", + "type-widgets-bundle": "Widgets bundle", + "type-widgets-bundles": "Widgets bundles", + "list-of-widgets-bundles": "{ count, plural, 1 {1 个部件包} other {列表 # 部件包} }", + "search": "实体检索", + "selected-entities": "已选择 { count, plural, 1 {# 个实体} other {# 个实体} }", + "entity-name": "实体名", + "entity-label": "实体标签", + "details": "实体详情", + "no-entities-prompt": "未找到实体", + "no-data": "无数据", + "columns-to-display": "要显示的列", + "type-api-usage-state": "Api使用状态", + "type-edge": "边缘", + "type-edges": "边缘", + "list-of-edges": "{ count, plural, 1 {1 个边缘} other {列表 # 个边缘} }", + "edge-name-starts-with": "以 '{{prefix}}' 开头的边缘", + "type-tb-resource": "资源", + "type-ota-package": "OTA包" + }, + "entity-field": { + "created-time": "创建时间", + "name": "名称", + "type": "类型", + "first-name": "名字", + "last-name": "姓氏", + "email": "电子邮件", + "title": "标题", + "country": "国家", + "state": "省/州", + "city": "城市", + "address": "地址", + "address2": "地址二", + "zip": "邮政编码", + "phone": "电话", + "label": "标签" + }, + "entity-view": { + "entity-view": "实体视图", + "entity-view-required": "实体视图必填。", + "entity-views": "实体视图", + "management": "实体视图管理", + "view-entity-views": "查看实体视图", + "entity-view-alias": "实体视图别名", + "aliases": "实体视图别名", + "no-alias-matching": "未找到匹配 '{{alias}}' 的别名。", + "no-aliases-found": "未找到别名。", + "no-key-matching": "'{{key}}' 未找到。", + "no-keys-found": "未找到密钥。", + "create-new-alias": "创建一个新的!", + "create-new-key": "创建一个新的!", + "duplicate-alias-error": "找到重复别名 '{{alias}}'。
    实体视图别名必须是唯一的。", + "configure-alias": "配置 '{{alias}}' 别名", + "no-entity-views-matching": "未找到与 '{{entity}}' 匹配的实体视图。", + "public": "公开", + "alias": "别名", + "alias-required": "实体视图别名必填。", + "remove-alias": "删除实体视图别名", + "add-alias": "添加实体视图别名", + "name-starts-with": "名称前缀", + "help-text": "根据需要可以使用'%'进行匹配,例如:'%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'。", + "entity-view-list": "实体视图列表", + "use-entity-view-name-filter": "使用筛选器", + "entity-view-list-empty": "没有被选中的实体视图", + "entity-view-name-filter-required": "实体视图名称筛选器必填。", + "entity-view-name-filter-no-entity-view-matched": "未找到以'{{entityView}}' 开头的实体视图。", + "add": "添加实体视图", + "entity-view-public": "实体视图是公共的", + "assign-to-customer": "分配给客户", + "assign-entity-view-to-customer": "将实体视图分配给客户", + "assign-entity-view-to-customer-text": "请选择要分配给客户的实体视图", + "assign-entity-view-to-edge-title": "将实体视图分配给边缘", + "no-entity-views-text": "未找到实体视图", + "assign-to-customer-text": "请选择客户分配实体视图", + "entity-view-details": "实体视图详细信息", + "add-entity-view-text": "添加新实体视图", + "delete": "删除实体视图", + "assign-entity-views": "分配实体视图", + "assign-entity-views-text": "分配 { count, plural, 1 {# 个实体视图} other {# 个实体视图} } 给客户", + "delete-entity-views": "删除实体视图", + "unassign-from-customer": "取消分配客户", + "unassign-entity-views": "取消分配实体视图", + "unassign-entity-views-action-title": "从客户处取消分配{count,plural,1 {# 实体视图} other {# 实体视图} }", + "assign-new-entity-view": "分配新实体视图", + "delete-entity-view-title": "确定要删除实体视图 '{{entityViewName}}'吗?", + "delete-entity-view-text": "请注意:确认后实体视图及其所有相关数据将不可恢复。", + "delete-entity-views-title": "确定要删除 { count, plural, 1 {# 实体视图} other {# 实体视图} }吗?", + "delete-entity-views-action-title": "删除 { count, plural, 1 {# 个实体视图} other {# 个实体视图} }", + "delete-entity-views-text": "请注意:确认后,所有选定的实体视图将被删除,所有相关的数据将不可恢复。", + "unassign-entity-view-title": "确定要取消对 '{{entityViewName}}' 实体视图的分配吗?", + "unassign-entity-view-text": "确认后,实体视图将未分配,客户无法访问。", + "unassign-entity-view": "未分配实体视图", + "unassign-entity-views-title": "确定要取消分配 { count, plural, 1 {# 个实体视图} other {# 个实体视图} } 吗?", + "unassign-entity-views-text": "确认后,所有选定的实体视图将被分配,客户无法访问。", + "entity-view-type": "实体视图类型", + "entity-view-type-required": "实体视图类型必填。", + "select-entity-view-type": "选择实体视图类型", + "enter-entity-view-type": "输入实体视图类型", + "any-entity-view": "任何实体视图", + "no-entity-view-types-matching": "未找到匹配 '{{entitySubtype}}' 的实体视图类型。", + "entity-view-type-list-empty": "实体视图类型未选择。", + "entity-view-types": "实体视图类型", + "created-time": "创建时间", + "name": "名称", + "name-required": "名称必填。", + "name-max-length": "名称长度必须少于256个字符", + "type-max-length": "视图类型长度必须少于256个字符", + "description": "说明", + "events": "事件", + "details": "详情", + "copyId": "复制实体视图ID", + "idCopiedMessage": "实体视图ID已复制到剪贴板", + "assignedToCustomer": "分配给客户", + "unable-entity-view-device-alias-title": "无法删除实体视图别名", + "unable-entity-view-device-alias-text": "实体视图别名 '{{entityViewAlias}}' 不能够被删除,因为它被下列部件所使用:
    {{widgetsList}}", + "select-entity-view": "选择实体视图", + "make-public": "实体视图设为公开", + "make-private": "实体视图设为私有", + "start-date": "开始日期", + "start-ts": "开始时间", + "end-date": "结束日期", + "end-ts": "结束时间", + "date-limits": "日期限制", + "client-attributes": "客户端属性", + "shared-attributes": "共享属性", + "server-attributes": "服务端属性", + "timeseries": "时间序列", + "client-attributes-placeholder": "客户端属性", + "shared-attributes-placeholder": "共享属性", + "server-attributes-placeholder": "服务端属性", + "timeseries-placeholder": "时间序列", + "target-entity": "目标实体", + "attributes-propagation": "属性传播", + "attributes-propagation-hint": "每次保存或更新这个实体视图时将自动从目标实体复制指定的属性。由于性能原因目标实体属性不会在每次属性更改时传递到实体视图。你可以通过配置\"copy to view\"规则链中的规则节点,并将\"Post attributes\"和\"attributes Updated\"消息链接到新规则节点,从而启用自动传递。", + "timeseries-data": "时间序列数据", + "timeseries-data-hint": "配置目标实体的 Timeseries 数据键,以便实体视图可以访问这些键。此 Timeseries 数据是只读的。", + "search": "查找实体视图", + "selected-entity-views": "已选择 { count, plural, 1 {# 个实体视图} other {# 个实体视图} }", + "make-public-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为公开吗?", + "make-public-entity-view-text": "确认后,实体视图及其所有数据将被公开并被他人访问。", + "make-private-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为私有吗?", + "make-private-entity-view-text": "确认后,实体视图及其所有数据将被私有化,无法被他人访问。", + "assign-entity-view-to-edge": "将实体视图分配给边缘", + "assign-entity-view-to-edge-text": "请选择要分配给边缘的实体视图", + "unassign-entity-view-from-edge-title": "确定要取消对 '{{entityViewName}}' 实体视图的分配吗?", + "unassign-entity-view-from-edge-text": "确认后,实体视图将未分配,边缘无法访问。", + "unassign-entity-views-from-edge-action-title": "从边缘处取消分配{count,plural,1 {# 实体视图} other {# 实体视图} }", + "unassign-entity-view-from-edge": "未分配实体视图", + "unassign-entity-views-from-edge-title": "确定要取消分配 { count, plural, 1 {# 个实体视图} other {# 个实体视图} } 吗?", + "unassign-entity-views-from-edge-text": "确认后,所有选定的实体视图将被分配,边缘无法访问。" + }, + "event": { + "event-type": "事件类型", + "events-filter": "事件筛选器", + "clean-events": "Clear Events", + "type-error": "错误", + "type-lc-event": "生命周期事件", + "type-stats": "类型统计", + "type-debug-rule-node": "调试", + "type-debug-rule-chain": "调试", + "no-events-prompt": "未找到事件", + "error": "错误", + "alarm": "告警", + "event-time": "事件时间", + "server": "服务器", + "body": "整体", + "method": "方法", + "type": "类型", + "message": "Message", + "message-id": "消息ID", + "copy-message-id": "Copy message Id", + "message-type": "消息类型", + "data-type": "数据类型", + "relation-type": "关联类型", + "metadata": "元数据", + "data": "数据", + "event": "事件", + "status": "状态", + "success": "成功", + "failed": "失败", + "messages-processed": "消息处理", + "max-messages-processed": "最多处理消息", + "min-messages-processed": "最少处理消息", + "errors-occurred": "错误发生", + "max-errors-occurred": "最多错误", + "min-errors-occurred": "最少错误", + "min-value": "最小值是0。", + "all-events": "全部", + "has-error": "有错误", + "entity-id": "实体ID", + "copy-entity-id": "复制实体ID", + "entity-type": "实体类型", + "clear-filter": "清除过滤器", + "clear-request-title": "清除所有事件", + "clear-request-text": "确除清空所有事件?" + }, + "extension": { + "extensions": "扩展", + "selected-extensions": "已选择 { count, plural, 1 {# 个扩展} other {# 个扩展} }", + "type": "类型", + "key": "键名", + "value": "价值", + "id": "ID", + "extension-id": "扩展ID", + "extension-type": "扩展类型", + "transformer-json": "JSON *", + "unique-id-required": "当前扩展ID已经存在。", + "delete": "删除扩展", + "add": "添加扩展", + "edit": "编辑扩展", + "delete-extension-title": "确定要删除扩展 '{{extensionId}}'吗?", + "delete-extension-text": "请注意:确认后,扩展和所有相关数据将不可恢复。", + "delete-extensions-title": "确定要删除 { count, plural, 1 {# 个扩展} other {# 个扩展} }吗?", + "delete-extensions-text": "请注意:确认后,所有选定的扩展将被删除,所有相关数据将不可恢复。", + "converters": "转换器", + "converter-id": "转换器序号", + "configuration": "配置", + "converter-configurations": "转换器的配置", + "token": "安全令牌", + "add-converter": "添加转换器", + "add-config": "添加转换器配置", + "device-name-expression": "设备名称表达式", + "device-type-expression": "设备类型表达式", + "custom": "定制", + "to-double": "加倍", + "transformer": "转换器", + "json-required": "Transformer JSON 必填。", + "json-parse": "无法解析转换器JSON。", + "attributes": "属性", + "add-attribute": "添加属性", + "add-map": "添加映射元素", + "timeseries": "Timeseries", + "add-timeseries": "添加 Timeseries", + "field-required": "必填字段", + "brokers": "代理服务器组", + "add-broker": "添加代理服务器", + "host": "主机", + "port": "端口", + "port-range": "端口应该在1到65535的范围内。", + "ssl": "SSL", + "credentials": "证书", + "username": "用户名", + "password": "密码", + "retry-interval": "重试间隔(毫秒)", + "anonymous": "匿名", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "CA certificate file *", + "private-key": "Private key file *", + "cert": "Certificate file *", + "no-file": "没有选择文件。", + "drop-file": "删除文件或单击以选择要上载的文件。", + "mapping": "映射", + "topic-filter": "Topic筛选器", + "converter-type": "转换类型", + "converter-json": "Json", + "json-name-expression": "设备名称JSON表达式", + "topic-name-expression": "设备名称主题表达式", + "json-type-expression": "设备类型JSON表达式", + "topic-type-expression": "设备类型主题表达式", + "attribute-key-expression": "属性键名表达式", + "attr-json-key-expression": "属性键JSON表达式", + "attr-topic-key-expression": "属性键名Topic表达式", + "request-id-expression": "请求ID表达式", + "request-id-json-expression": "请求ID JSON表达式", + "request-id-topic-expression": "请求ID主题表达式", + "response-topic-expression": "响应主题表达式", + "value-expression": "值表达式", + "topic": "主题", + "timeout": "超时时间(毫秒)", + "converter-json-required": "Converter JSON 必填。", + "converter-json-parse": "无法解析转换JSON。", + "filter-expression": "筛选条件表达式", + "connect-requests": "连接请求", + "add-connect-request": "添加连接请求", + "disconnect-requests": "断开请求", + "add-disconnect-request": "添加断开请求", + "attribute-requests": "属性请求", + "add-attribute-request": "添加属性请求", + "attribute-updates": "属性更新", + "add-attribute-update": "添加属性更新", + "server-side-rpc": "服务端RPC", + "add-server-side-rpc-request": "添加服务端RPC请求", + "device-name-filter": "设备名称筛选器", + "attribute-filter": "属性筛选器", + "method-filter": "方法筛选器", + "request-topic-expression": "请求主题表达式", + "response-timeout": "响应超时(毫秒)", + "topic-expression": "主题表达", + "client-scope": "客户范围", + "add-device": "添加服务器", + "opc-server": "服务器组", + "opc-add-server": "添加服务器", + "opc-add-server-prompt": "请添加服务器", + "opc-application-name": "应用名称", + "opc-application-uri": "应用URI", + "opc-scan-period-in-seconds": "秒级扫描周期", + "opc-security": "安全性", + "opc-identity": "身份", + "opc-keystore": "密钥库", + "opc-type": "类型", + "opc-keystore-type": "类型", + "opc-keystore-location": "Location *", + "opc-keystore-password": "密码", + "opc-keystore-alias": "别名", + "opc-keystore-key-password": "密钥密码", + "opc-device-node-pattern": "设备节点模式", + "opc-device-name-pattern": "设备名称模式", + "modbus-server": "Servers/slaves", + "modbus-add-server": "添加 server/slave", + "modbus-add-server-prompt": "请添加 server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "自动重新连接", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "串口名称", + "modbus-encoding": "编码", + "modbus-parity": "奇偶性", + "modbus-baudrate": "波特率", + "modbus-databits": "数据位", + "modbus-stopbits": "停止位", + "modbus-databits-range": "数据位应该在7到8的范围内。", + "modbus-stopbits-range": "停止位应该在1到2的范围内。", + "modbus-unit-id": "单位编号", + "modbus-unit-id-range": "单位ID应该在1到247的范围内", + "modbus-device-name": "设备名称", + "modbus-poll-period": "轮询周期 (毫秒)", + "modbus-attributes-poll-period": "轮询属性周期 (毫秒)", + "modbus-timeseries-poll-period": "Timeseries 轮询周期 (毫秒)", + "modbus-poll-period-range": "轮询周期应为正值。", + "modbus-tag": "标签", + "modbus-function": "函数", + "modbus-register-address": "寄存器地址", + "modbus-register-address-range": "寄存器地址应该在0到65535的范围内。", + "modbus-register-bit-index": "位索引", + "modbus-register-bit-index-range": "位索引应该在0到15的范围内。", + "modbus-register-count": "寄存器计数", + "modbus-register-count-range": "寄存器计数应该是一个正值。", + "modbus-byte-order": "字节顺序", + "sync": { + "status": "状态", + "sync": "同步", + "not-sync": "不同步", + "last-sync-time": "最后同步时间", + "not-available": "无法使用" + }, + "export-extensions-configuration": "导出扩展配置", + "import-extensions-configuration": "导入扩展配置", + "import-extensions": "导入扩展", + "import-extension": "导入扩展", + "export-extension": "导出扩展", + "file": "扩展文件", + "invalid-file-error": "无效的扩展文件" + }, + "filter": { + "add": "添加筛选器", + "edit": "编辑筛选器", + "name": "筛选器名称", + "name-required": "筛选器名称必填。", + "duplicate-filter": "同名筛选器已存在。", + "filters": "筛选器", + "unable-delete-filter-title": "无法删除筛选器", + "unable-delete-filter-text": "无法删除筛选器 '{{filter}}' ,因为它由以下小部件使用:
    {{widgetsList}}", + "duplicate-filter-error": "找到重复的筛选器 '{{filter}}'。
    筛选器在仪表板中必须是唯一的。", + "missing-key-filters-error": "筛选器 '{{filter}}' 的键名筛选条件缺失。", + "filter": "筛选器", + "editable": "可编辑", + "no-filters-found": "未找到筛选器。", + "no-filter-text": "未指定筛选器", + "add-filter-prompt": "请添加筛选器", + "no-filter-matching": "未找到 '{{filter}}' 。", + "create-new-filter": "请新增!", + "filter-required": "筛选器必填。", + "operation": { + "operation": "操作", + "equal": "等于", + "not-equal": "不等于", + "starts-with": "开始于", + "ends-with": "结束于", + "contains": "包含", + "not-contains": "不包含", + "greater": "大于", + "less": "小于", + "greater-or-equal": "大于或等于", + "less-or-equal": "小于或等于", + "and": "和", + "or": "或", + "in": "匹配", + "not-in": "不匹配" + }, + "ignore-case": "忽略大小写", + "value": "值", + "remove-filter": "删除筛选器", + "preview": "筛选器预览", + "no-filters": "未配置筛选器", + "add-filter": "添加筛选器", + "add-complex-filter": "添加复合筛选器", + "add-complex": "添加复合", + "complex-filter": "复合筛选器", + "edit-complex-filter": "编辑复合筛选器", + "edit-filter-user-params": "编辑筛选器谓词用户参数", + "filter-user-params": "过滤谓词用户参数", + "user-parameters": "用户参数", + "display-label": "要显示的标签", + "autogenerated-label": "自动生成标签", + "order-priority": "字段顺序优先级", + "key-filter": "键名筛选器", + "key-filters": "键名筛选器", + "key-name": "键名", + "key-name-required": "键名必填。", + "key-type": { + "key-type": "键类型", + "attribute": "属性", + "timeseries": "Timeseries", + "entity-field": "实体", + "constant": "常量" + }, + "value-type": { + "value-type": "值类型", + "string": "字符串", + "numeric": "数字", + "boolean": "布尔值", + "date-time": "日期时间" + }, + "value-type-required": "键值类型是必需的。", + "key-value-type-change-title": "确定要更改键值类型吗?", + "key-value-type-change-message": "如果您确认新的值类型,所有输入的键过滤器将被删除。", + "no-key-filters": "未配置键名筛选器", + "add-key-filter": "添加键名筛选器", + "remove-key-filter": "删除键名筛选器", + "edit-key-filter": "编辑键名筛选器", + "date": "日期", + "time": "时间", + "current-tenant": "当前租户", + "current-customer": "当前客户", + "current-user": "当前用户", + "current-device": "当前设备", + "default-value": "默认值", + "dynamic-source-type": "动态源类型", + "dynamic-value": "动态值", + "no-dynamic-value": "无动态值", + "source-attribute": "源属性", + "switch-to-dynamic-value": "切换到动态值", + "switch-to-default-value": "切换到默认值", + "inherit-owner": "从所有者继承", + "source-attribute-not-set": "如果未设置源属性" + }, + "fullscreen": { + "expand": "展开到全屏", + "exit": "退出全屏", + "toggle": "切换全屏模式", + "fullscreen": "全屏" + }, + "function": { + "function": "函数" + }, + "gateway": { + "add-entry": "添加配置", + "connector-add": "添加新连接器", + "connector-enabled": "启用连接器", + "connector-name": "连接器名称", + "connector-name-required": "连接器名称名称必填。", + "connector-type": "连接器类型", + "connector-type-required": "连接器名称类型必填。", + "connectors": "连接器配置", + "create-new-gateway": "创建新网关", + "create-new-gateway-text": "确定要创建名为 '{{gatewayName}}' 的新网关?", + "delete": "删除配置", + "download-tip": "下载配置", + "gateway": "网关", + "gateway-exists": "同名设备已存在。", + "gateway-name": "网关名称", + "gateway-name-required": "网关名称必填。", + "gateway-saved": "已成功保存网关配置。", + "json-parse": "无效的JSON。", + "json-required": "字段不能为空。", + "no-connectors": "无连接器", + "no-data": "没有配置", + "no-gateway-found": "未找到网关。", + "no-gateway-matching": "未找到 '{{item}}' 。", + "path-logs": "日志文件的路径", + "path-logs-required": "路径是必需的。", + "remote": "远程配置", + "remote-logging-level": "日志记录级别", + "remove-entry": "删除配置", + "save-tip": "保存配置", + "security-type": "安全类型", + "security-types": { + "access-token": "访问令牌", + "tls": "TLS" + }, + "storage": "存储", + "storage-max-file-records": "文件中的最大记录数", + "storage-max-files": "最大文件数", + "storage-max-files-min": "最小值为1。", + "storage-max-files-pattern": "数字无效。", + "storage-max-files-required": "数字是必需的。", + "storage-max-records": "存储中的最大记录数", + "storage-max-records-min": "最小记录数为1。", + "storage-max-records-pattern": "数字无效。", + "storage-max-records-required": "最大记录项必填。", + "storage-pack-size": "最大事件包大小", + "storage-pack-size-min": "最小值为1。", + "storage-pack-size-pattern": "数字无效。", + "storage-pack-size-required": "最大事件包大小必填。", + "storage-path": "存储路径", + "storage-path-required": "存储路径必填。", + "storage-type": "存储类型", + "storage-types": { + "file-storage": "文件存储", + "memory-storage": "内存存储" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard主机", + "thingsboard-host-required": "主机必填。", + "thingsboard-port": "ThingsBoard端口", + "thingsboard-port-max": "最大端口号为65535。", + "thingsboard-port-min": "最小端口号为1。", + "thingsboard-port-pattern": "端口无效。", + "thingsboard-port-required": "端口必填。", + "tidy": "整洁", + "tidy-tip": "整理配置JSON", + "title-connectors-json": "连接器 {{typeName}} 配置", + "tls-path-ca-certificate": "网关上CA证书的路径", + "tls-path-client-certificate": "网关上客户端证书的路径", + "tls-path-private-key": "网关上私钥的路径", + "toggle-fullscreen": "切换全屏", + "transformer-json-config": "配置JSON*", + "update-config": "添加/更新配置JSON" + }, + "grid": { + "delete-item-title": "确定要删除此项吗?", + "delete-item-text": "请注意,确认后,项目及其所有相关数据将不可恢复。", + "delete-items-title": "确定删除 { count, plural, 1 {# 项} other {# 项} }吗?", + "delete-items-action-title": "删除 { count, plural, 1 {# 个元素} other {# 个元素} }", + "delete-items-text": "请注意,确认后所有选择的项目将被删除,所有相关数据将不可恢复。", + "add-item-text": "添加新项目", + "no-items-text": "未找到项目", + "item-details": "项目详细信息", + "delete-item": "删除项目", + "delete-items": "删除项目", + "scroll-to-top": "滚动到顶部" + }, + "help": { + "goto-help-page": "查看帮助", + "show-help": "显示帮助" + }, + "home": { + "home": "首页", + "profile": "属性", + "logout": "注销", + "menu": "菜单", + "avatar": "头像", + "open-user-menu": "打开用户菜单" + }, + "file-input": { + "browse-file": "浏览文件", + "browse-files": "浏览文件" + }, + "image-input": { + "drop-image-or": "拖拽图片或", + "drop-images-or": "拖拽图片或", + "no-images": "没有图片", + "images": "图片" + }, + "import": { + "no-file": "没有选择文件", + "drop-file": "拖动一个JSON文件或者单击以选择要上传的文件。", + "drop-json-file-or": "拖拽JSON文件或", + "drop-file-csv": "拖动一个CSV文件或单击以选择要上载的文件。", + "drop-file-csv-or": "拖拽CSV文件或", + "column-value": "数值", + "column-title": "标题", + "column-example": "示例值数据", + "column-key": "属性/遥测键", + "credentials": "Credentials", + "csv-delimiter": "CSV分隔符", + "csv-first-line-header": "第一行包含列名", + "csv-update-data": "更新属性/遥测", + "details": "Details", + "import-csv-number-columns-error": "一个文件至少应该包含两列", + "import-csv-invalid-format-error": "文件格式无效。行: '{{line}}'", + "column-type": { + "name": "名称", + "type": "类型", + "label": "标签", + "column-type": "列类型", + "client-attribute": "客户端属性", + "shared-attribute": "共享属性", + "server-attribute": "服务器属性", + "timeseries": "Timeseries", + "entity-field": "实体字段", + "access-token": "访问令牌", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT客户端ID", + "user-name": "MQTT用户名", + "password": "MQTT密码" + }, + "lwm2m": { + "client-endpoint": "客户端终节点名称", + "security-config-mode": "安全配置模式", + "client-identity": "客户标识", + "client-key": "客户端公钥", + "client-cert": "客户端证书", + "bootstrap-server-security-mode": "LwM2M bootstrap server安全模式", + "bootstrap-server-secret-key": "LwM2M bootstrap server密钥", + "bootstrap-server-public-key-id": "LwM2M bootstrap server公钥", + "lwm2m-server-security-mode": "LwM2M server安全模式", + "lwm2m-server-secret-key": "LwM2M server证书密钥", + "lwm2m-server-public-key-id": "LwM2M server公钥" + }, + "isgateway": "是否网关", + "activity-time-from-gateway-device": "网关设备活动时间", + "description": "说明", + "routing-key": "边缘键", + "secret": "边缘密钥" + }, + "stepper-text": { + "select-file": "选择一个文件", + "configuration": "导入配置", + "column-type": "选择列类型", + "creat-entities": "创建新实体" + }, + "message": { + "create-entities": "{{count}} 个新实体已成功创建。", + "update-entities": "{{count}} 个实体已成功更新。", + "error-entities": "创建 {{count}} 个实体时出错。" + } + }, + "item": { + "selected": "选择" + }, + "js-func": { + "no-return-error": "函数必须返回值!", + "return-type-mismatch": "函数必须返回 '{{type}}' 类型的值!", + "tidy": "整洁", + "mini": "迷你" + }, + "key-val": { + "key": "键名", + "value": "数值", + "remove-entry": "删除条目", + "add-entry": "增加条目", + "no-data": "没有条目" + }, + "layout": { + "layout": "布局", + "manage": "布局管理", + "settings": "布局设置", + "color": "颜色", + "main": "主体", + "right": "右侧", + "left": "左", + "select": "选择目标布局", + "percentage-width": "百分比宽度(%)", + "fixed-width": "固定(px)", + "left-width": "左列(%)", + "right-width": "右列(%)", + "pick-fixed-side": "固定侧: ", + "layout-fixed-width": "固定宽度(px)", + "value-min-error": "值是必须大于{{min}}{{unit}}", + "value-max-error": "值是必须小于{{max}}{{unit}}", + "layout-fixed-width-required": "固定宽度必填", + "right-width-percentage-required": "右侧百分比宽度必填", + "left-width-percentage-required": "左侧百分比宽度必填", + "divider": "分隔线", + "right-side": "右侧布局", + "left-side": "左侧布局" + }, + "legend": { + "direction": "图例方向", + "position": "图例位置", + "sort-legend": "在图例中排序数据键", + "show-max": "显示最大值", + "show-min": "显示最小值", + "show-avg": "显示平均值", + "show-total": "显示总数", + "show-latest": "显示最新数据值", + "settings": "图例设置", + "min": "最小值", + "max": "最大值", + "avg": "平均值", + "total": "总数", + "latest": "latest", + "comparison-time-ago": { + "previousInterval": "(历史间隔)", + "customInterval": "(自定义间隔)", + "days": "(一天前)", + "weeks": "(一周前)", + "months": "(一个月前)", + "years": "(一年前)" + } + }, + "login": { + "login": "登录", + "request-password-reset": "请求密码重置", + "reset-password": "重置密码", + "create-password": "创建密码", + "two-factor-authentication": "双重因素身份验证", + "passwords-mismatch-error": "输入的密码必须相同!", + "password-again": "再次输入密码", + "sign-in": "登录 ", + "username": "用户名(电子邮件)", + "remember-me": "记住我", + "forgot-password": "忘记密码?", + "password-reset": "密码重置", + "expired-password-reset-message": "您的凭据已过期!请创建新密码。", + "new-password": "新密码", + "new-password-again": "再次输入新密码", + "password-link-sent-message": "密码重置链接已成功发送!", + "email": "电子邮件", + "login-with": "使用 {{name}} 登录", + "or": "或", + "error": "登录错误", + "verify-your-identity": "身份验证", + "select-way-to-verify": "选择验证方式", + "resend-code": "重发验证码", + "resend-code-wait": "{ time, plural, 1 {1 秒} other {# 秒} }后重发验证码", + "try-another-way": "尝试其他方法", + "totp-auth-description": "请从验证APP中查看验证码。", + "totp-auth-placeholder": "验证码", + "sms-auth-description": "验证码已发送到手机号码 {{contact}}。", + "sms-auth-placeholder": "SMS 验证码", + "email-auth-description": "验证码已发送到电子邮箱地址 {{contact}}。", + "email-auth-placeholder": "电子邮件验证码", + "backup-code-auth-description": "请输入一个备份验证码。", + "backup-code-auth-placeholder": "备份验证码" + }, + "markdown": { + "edit": "编辑", + "preview": "预览", + "copy-code": "单击拷贝", + "copied": "已拷贝!" + }, + "ota-update": { + "add": "添加包", + "assign-firmware": "分配的固件", + "assign-firmware-required": "分配的固件必填", + "assign-software": "分配的软件", + "assign-software-required": "分配的软件必填", + "auto-generate-checksum": "自动生成校验和", + "checksum": "校验和", + "checksum-hint": "如果校验和为空,会自动生成", + "checksum-algorithm": "校验和算法", + "checksum-copied-message": "包校验和已复制到剪贴板", + "change-firmware": "固件的更改可能会导致 { count, plural, 1 {1 个设备} other {# 个设备} } 的更新。", + "change-software": "软件的更改可能会导致 { count, plural, 1 {1 个设备} other {# 个设备} } 的更新。", + "chose-compatible-device-profile": "上传的包仅适用于具有所选配置文件的设备。", + "chose-firmware-distributed-device": "选择将分发到设备的固件", + "chose-software-distributed-device": "选择将分发到设备的软件", + "content-type": "内容类型", + "copy-checksum": "复制校验和", + "copy-direct-url": "复制直接URL", + "copyId": "复制包ID", + "copied": "已复制!", + "delete": "删除包", + "delete-ota-update-text": "请注意:确认后,OTA升级将不可恢复。", + "delete-ota-update-title": "确定要删除 '{{title}}' OTA升级吗?", + "delete-ota-updates-text": "请注意:确认后,所有选中的OTA升级将被删除。", + "delete-ota-updates-title": "确定要删除 { count, plural, 1 {1 OTA升级} other {# OTA升级} } 吗?", + "description": "说明", + "direct-url": "直接URL", + "direct-url-copied-message": "包直接URL已复制到剪贴板", + "direct-url-required": "直接URL必填", + "download": "下载包", + "drop-file": "拖放打包文件或点击选择要上传的文件。", + "drop-package-file-or": "拖拽文件或", + "file-name": "文件名", + "file-size": "文件大小", + "file-size-bytes": "文件大小(以字节为单位)", + "idCopiedMessage": "包ID已被复制到剪贴板", + "no-firmware-matching": "未找到与'{{entity}}'匹配的兼容固件OTA升级包。", + "no-firmware-text": "没有提供兼容的固件OTA升级包。", + "no-packages-text": "未找到包", + "no-software-matching": "未找到匹配 '{{entity}}' 的兼容软件OTA升级包。", + "no-software-text": "没有提供兼容的软件OTA升级包。", + "ota-update": "OTA 升级", + "ota-update-details": "OTA 升级详情", + "ota-updates": "OTA 升级", + "package-type": "包类型", + "packages-repository": "包仓库", + "search": "搜索包", + "selected-package": "{ count, plural, 1 {1 个包} other {# 个包} } 选中", + "title": "标题", + "title-required": "标题必填。", + "title-max-length": "标题长度必须少于256个字符", + "types": { + "firmware": "固件", + "software": "软件" + }, + "upload-binary-file": "上传二进制文件", + "use-external-url": "使用外部URL", + "version": "版本", + "version-required": "版本必填。", + "version-tag": "版本标签", + "version-tag-hint": "自定义标签应与您设备报告的软件包版本相匹配。", + "version-max-length": "版本长度必须少于256个字符", + "warning-after-save-no-edit": "上传包后,您将无法修改标题、版本、设备配置文件和包类型。" + }, + "position": { + "top": "顶部", + "bottom": "底部", + "left": "左侧", + "right": "右侧" + }, + "profile": { + "profile": "属性", + "last-login-time": "最后登录", + "change-password": "更改密码", + "current-password": "当前密码", + "copy-jwt-token": "复制 JWT 令牌", + "jwt-token": "JWT 令牌", + "token-valid-till": "令牌有效期至", + "tokenCopiedSuccessMessage": "JWT 令牌已复制到剪贴板", + "tokenCopiedWarnMessage": "JWT 令牌已过期!请刷新页面。" + }, + "profiles": { + "profiles": "配置" + }, + "security": { + "security": "安全", + "2fa": { + "2fa": "双因素身份验证", + "2fa-description": "双因素身份验证可保护您的帐户免受未经授权的访问。在登录时必须输入安全验证码。", + "authenticate-with": "可以使用以下身份验证:", + "disable-2fa-provider-text": "禁用 {{ name }} 会降低帐户的安全性", + "disable-2fa-provider-title": "确定要禁用 {{ name }} 吗?", + "get-new-code": "获取新验证码", + "main-2fa-method": "用作主要的双因素身份验证方法", + "dialog": { + "activation-step-description-email": "下次登录时,系统将提示您输入电子邮件中的验证码。", + "activation-step-description-sms": "下次登录时,系统将提示您输入短信中的验证码。", + "activation-step-description-totp": "下次登录时,您需要提供一个双因素身份验证码。", + "activation-step-label": "激活", + "backup-code-description": "打印出验证码,以便在您需要时使用它们登录帐户。每个备份验证码可以使用一次。", + "backup-code-warn": "离开此页后,这些代码将无法再次显示。使用以下选项安全存放。", + "download-txt": "下载(txt)", + "email-step-description": "输入用作身份验证的电子邮件", + "email-step-label": "电子邮件", + "enable-email-title": "启用电子邮件验证", + "enable-sms-title": "启用SMS验证", + "enable-totp-title": "启用验证APP", + "enter-verification-code": "输入6位验证码", + "get-backup-code-title": "获取备份验证码", + "next": "下一步", + "scan-qr-code": "使用验证APP扫描二维码", + "send-code": "发送验证码", + "sms-step-description": "输入用作身份验证的手机号码", + "sms-step-label": "手机号码", + "success": "操作成功!", + "totp-step-description-install": "可以安装像Google Authenticator、Authy或Duo这样的应用程序。", + "totp-step-description-open": "在手机上打开验证APP。", + "totp-step-label": "获取APP", + "verification-code": "6位验证码", + "verification-code-invalid": "验证码格式无效", + "verification-code-incorrect": "验证码不正确", + "verification-code-many-request": "请求过多,请检查验证码", + "verification-step-description": "输入发送到 {{ address }} 的6位代码", + "verification-step-label": "验证" + }, + "provider": { + "email": "电子邮件", + "email-description": "使用您电子邮件中的验证码进行身份验证。", + "email-hint": "身份验证码通过电子邮件发送到 {{ info }}", + "sms": "SMS", + "sms-description": "使用短信进行身份验证。当登录时,系统会通过短信向您发送验证码。", + "sms-hint": "身份验证码通过短信发送到 {{ info }}", + "totp": "验证APP", + "totp-description": "使用手机上的Google Authenticator、Authy或Duo等应用程序进行身份验证,它将生成用于登录的验证码。", + "totp-hint": "已为您的帐户设置验证APP", + "backup_code": "备份验证码", + "backup-code-description": "这些可打印的一次性密码允许您在离开手机时登录,比如正在旅行。", + "backup-code-hint": "{{ info }}个一次性代码处于激活状态" + } + }, + "password-requirement": { + "at-least": "至少:", + "character": "{ count, plural, 1 {1 位字符} other {# 位字符} }", + "digit": "{ count, plural, 1 {1 位数字} other {# 位数字} }", + "incorrect-password-try-again": "密码不正确。再试一次", + "lowercase-letter": "{ count, plural, 1 {1 位小写字母} other {# 位小写字母} }", + "new-passwords-not-match": "新密码不匹配", + "password-should-not-contain-spaces": "密码不应包含空格", + "password-not-meet-requirements": "密码不符合要求", + "password-requirements": "密码要求", + "password-should-difference": "新密码应与当前密码不同", + "special-character": "{ count, plural, 1 {1 位特殊字符} other {# 位特殊字符} }", + "uppercase-letter": "{ count, plural, 1 {1 位大写字母} other {# 位大写字母} }" + } + }, + "relation": { + "relations": "关联", + "direction": "方向", + "search-direction": { + "FROM": "从", + "TO": "到" + }, + "direction-type": { + "FROM": "从", + "TO": "到" + }, + "from-relations": "向外的关联", + "to-relations": "向内的关联", + "selected-relations": "已选择{ count, plural, 1 {# 个关联} other {# 个关联} }", + "type": "类型", + "to-entity-type": "到实体类型", + "to-entity-name": "到实体名称", + "from-entity-type": "从实体类型", + "from-entity-name": "从实体类型", + "to-entity": "到实体", + "from-entity": "从实体", + "delete": "删除关联", + "relation-type": "关联类型", + "relation-type-required": "关联类型必填", + "relation-type-max-length": "关联类型长度必须少于256个字符", + "any-relation-type": "任何类型", + "add": "添加关联", + "edit": "编辑关联", + "delete-to-relation-title": "确定要删除实体 '{{entityName}}' 的关联吗?", + "delete-to-relation-text": "确定删除后实体 '{{entityName}}' 将取消与当前实体的关联关系。", + "delete-to-relations-title": "确定要删除 { count, plural, 1 {# 个关联} other {# 个关联} }吗?", + "delete-to-relations-text": "确定删除所有选择的关联关系后,与当前实体对应的所有关联关系将被移除。", + "delete-from-relation-title": "确定要从实体 '{{entityName}}' 删除关联吗?", + "delete-from-relation-text": "确定删除后,当前实体将与实体 '{{entityName}}' 取消关联", + "delete-from-relations-title": "确定删除 { count, plural, 1 {# 个关联} other {# 个关联} } 吗?", + "delete-from-relations-text": "确定删除所有选择的关联关系后,当前实体将与对应的实体取消关联", + "remove-relation-filter": "移除关联筛选器", + "add-relation-filter": "添加关联筛选器", + "any-relation": "任意关联", + "relation-filters": "关联筛选器", + "additional-info": "附加信息 (JSON)", + "invalid-additional-info": "无法解析附加信息JSON。", + "no-relations-text": "未找到关联" + }, + "resource": { + "add": "添加资源", + "copyId": "复制资源ID", + "delete": "删除资源", + "delete-resource-text": "请注意:确认后,资源将不可恢复。", + "delete-resource-title": "确定要删除资源 '{{resourceTitle}}' 吗?", + "delete-resources-action-title": "删除 { count, plural, 1 {# 个资源} other {# 个资源} }", + "delete-resources-text": "请注意:确认后,所有选定的资源将被删除。", + "delete-resources-title": "确定要删除 { count, plural, 1 {# 个资源} other {# 个资源} }吗?", + "download": "Download resource", + "drop-file": "拖拽资源文件或单击以选择要上传的文件。", + "drop-resource-file-or": "Drag and drop a resource file or", + "empty": "资源为空", + "file-name": "File name", + "idCopiedMessage": "拖拽资源文件或", + "no-resource-matching": "未找到与 '{{widgetsBundle}}' 匹配的资源。", + "no-resource-text": "未找到资源", + "open-widgets-bundle": "打开部件库", + "resource": "资源", + "resource-library-details": "资源库详情", + "resource-type": "资源类型", + "resources-library": "资源库", + "search": "查找资源", + "selected-resources": "已选择{ count, plural, 1 {# 个资源} other {# 个资源} }", + "system": "系统", + "title": "标题", + "title-required": "标题是必填项。", + "title-max-length": "标题长度必须少于256个字符" + }, + "rulechain": { + "rulechain": "规则链", + "rulechains": "规则链库", + "root": "是否根链", + "delete": "删除规则链", + "name": "名称", + "name-required": "名称必填。", + "name-max-length": "名称长度必须少于256个字符", + "description": "说明", + "add": "添加规则链", + "set-root": "设置为根规则链", + "set-root-rulechain-title": "确定要设置'{{ruleChainName}}'为根规则链吗?", + "set-root-rulechain-text": "确认后,规则链将变为根规格链,并将处理所有传入的传输消息。", + "delete-rulechain-title": " 确定要删除规则链'{{ruleChainName}}'吗?", + "delete-rulechain-text": "请注意,确认后,规则链和所有相关数据将不可恢复。", + "delete-rulechains-title": "确定要删除{count, plural, 1 { 1 个规则链} other {# 个规则链} }吗?", + "delete-rulechains-action-title": "删除 { count, plural, 1 {# 个规则链} other {# 个规则链} }", + "delete-rulechains-text": "请注意:确认后,所有选定的规则链将被删除,所有相关的数据将不可恢复。", + "add-rulechain-text": "添加新的规则链", + "no-rulechains-text": "未找到规则链", + "rulechain-details": "规则链详情", + "details": "详情", + "events": "事件", + "system": "系统", + "import": "导入规则链", + "export": "导出规则链", + "export-failed-error": "无法导出规则链:{{error}}", + "create-new-rulechain": "创建新的规则链", + "rulechain-file": "规则链文件", + "invalid-rulechain-file-error": "不能导入规则链:无效的规则链数据格式。", + "copyId": "复制规则链ID", + "idCopiedMessage": "规则ID已经复制到粘贴板", + "select-rulechain": "选择规则链", + "no-rulechains-matching": "没有发现匹配'{{entity}}'的规则链。", + "rulechain-required": "规则链必填", + "management": "规则集管理", + "debug-mode": "调试模式", + "search": "查找规则链", + "selected-rulechains": "已选择 { count, plural, 1 {# 个规则链} other {# 个规则链} }", + "open-rulechain": "打开规则链", + "assign-new-rulechain": "Assign new rulechain", + "edge-template-root": "模版根链", + "assign-to-edge": "分配给边缘", + "edge-rulechain": "边缘规则链", + "unassign-rulechain-from-edge-text": "确认后,规则链将会取消分配,边缘无法访问。", + "unassign-rulechains-from-edge-title": "确定要取消分配 { count, plural, 1 {1 个规则链} other {# 个规则链} }吗?", + "unassign-rulechains-from-edge-text": "确认后,选定的规则链将会取消分配,边缘无法访问。", + "assign-rulechain-to-edge-title": "分配规则链给边缘", + "assign-rulechain-to-edge-text": "请选择要分配给边缘的规则链", + "set-edge-template-root-rulechain": "设置为边缘模版根规则链", + "set-edge-template-root-rulechain-title": "确定将 '{{ruleChainName}}' 设置为边缘模版根规则链吗?", + "set-edge-template-root-rulechain-text": "确认后,将会成为边缘模版根规则链,且它会成为新创建边缘的根规则链。", + "invalid-rulechain-type-error": "不能导入规则链:无效的规则链类型。期望类型为{{expectedRuleChainType}}。", + "set-auto-assign-to-edge": "将规则链分配给新创建的边缘", + "set-auto-assign-to-edge-title": "确定将规则链'{{ruleChainName}}'自动分配给新创建的边缘吗?", + "set-auto-assign-to-edge-text": "确认后,将自动分配规则链给新创建的边缘。", + "unset-auto-assign-to-edge": "不将规则链分配给边缘", + "unset-auto-assign-to-edge-title": "确定不再将规则链'{{ruleChainName}}'自动分配给新创建的边缘吗?", + "unset-auto-assign-to-edge-text": "确认后,将不再自动分配规则链给新创建的边缘。", + "unassign-rulechain-title": "你确定要取消分配规则链'{{ruleChainName}}'?", + "unassign-rulechains": "取消分配规则链" + }, + "rulenode": { + "details": "详情", + "events": "事件", + "search": "查找节点", + "open-node-library": "打开节点库", + "add": "添加规则节点", + "name": "名称", + "name-required": "名称必填。", + "name-max-length": "名称长度必须少于256个字符", + "type": "类型", + "description": "说明", + "delete": "删除规则节点", + "select-all-objects": "选择所有节点和连接", + "deselect-all-objects": "取消选择所有节点和连接", + "delete-selected-objects": "删除选定的节点和连接", + "delete-selected": "删除选定", + "create-nested-rulechain": "创建嵌套规则链", + "select-all": "选择全部", + "copy-selected": "选择副本", + "deselect-all": "取消选择", + "rulenode-details": "规则节点详情", + "debug-mode": "调试模式", + "configuration": "配置", + "link": "链接", + "link-details": "规则节点链接详情", + "add-link": "添加链接", + "link-label": "链接标签", + "link-label-required": "链接标签必填", + "custom-link-label": "自定义链接标签", + "custom-link-label-required": "自定义链接标签必填", + "link-labels": "链接标签", + "link-labels-required": "链接标签必填。", + "no-link-labels-found": "未找到链接标签", + "no-link-label-matching": "未找到匹配 '{{label}}' 的链接标签。", + "create-new-link-label": "创建一个新的!", + "type-filter": "筛选器", + "type-filter-details": "使用配置条件筛选传入消息", + "type-enrichment": "属性集", + "type-enrichment-details": "向消息元数据中添加附加信息", + "type-transformation": "变换", + "type-transformation-details": "更改消息 Payload 和元数据", + "type-action": "动作", + "type-action-details": "执行特别动作", + "type-external": "外部的", + "type-external-details": "与外部系统交互", + "type-rule-chain": "规则链", + "type-rule-chain-details": "将传入消息转发到指定的规则链", + "type-flow": "流", + "type-flow-details": "组织消息流", + "type-input": "输入", + "type-input-details": "规则链的逻辑输入,将传入消息转发到下一个相关规则节点", + "type-unknown": "未知", + "type-unknown-details": "未解析的规则节点", + "directive-is-not-loaded": "定义的配置指令 '{{directiveName}}' 不可用。", + "ui-resources-load-error": "加载配置UI资源失败。", + "invalid-target-rulechain": "无法解析目标规则链!", + "test-script-function": "测试脚本功能", + "script-lang-java-script": "Java Script", + "script-lang-tbel": "TBEL", + "message": "消息", + "message-type": "消息类型", + "select-message-type": "选择消息类型", + "message-type-required": "消息类型必填", + "metadata": "元数据", + "metadata-required": "元数据项不能为空。", + "output": "输出", + "test": "测试", + "help": "帮助", + "reset-debug-mode": "重置所有节点中的调试模式" + }, + "timezone": { + "timezone": "时区", + "select-timezone": "选择时区", + "no-timezones-matching": "找不到匹配的'{{timezone}}'时区。", + "timezone-required": "时区必填。", + "browser-time": "浏览器时间" + }, + "queue": { + "queue-name": "队列", + "no-queues-found": "未找到队列", + "no-queues-matching": "未找到匹配 '{{queue}}' 的队列", + "select-name": "选择队列名称", + "name": "名称", + "name-required": "队列名称必填。", + "name-unique": "队列名称必须唯一。", + "name-pattern": "队列名称不能包含ASCII字母数字以外的字符, '.', '_' 和 '-'等。", + "queue-required": "队列必填。", + "topic-required": "队列主题必填。", + "poll-interval-required": "轮询间隔必填。", + "poll-interval-min-value": "轮询间隔不能小于1", + "partitions-required": "分区必填。", + "partitions-min-value": "分区不能小于1", + "pack-processing-timeout-required": "处理超时时间必填。", + "pack-processing-timeout-min-value": "处理超时时间不能小于1", + "batch-size-required": "批量处理大小必填。", + "batch-size-min-value": "批量处理大小不能小于1", + "retries-required": "重试次数必填。", + "retries-min-value": "重试次数不能为负", + "failure-percentage-required": "失败百分比必填。", + "failure-percentage-min-value": "失败百分比值不能小于0", + "failure-percentage-max-value": "失败百分比值不能大于100", + "pause-between-retries-required": "重试间隔必填。", + "pause-between-retries-min-value": "重试间隔不能小于1", + "max-pause-between-retries-required": "最大重试间隔必填。", + "max-pause-between-retries-min-value": "最大重试间隔不能小于1", + "submit-strategy-type-required": "提交策略类型必填。", + "processing-strategy-type-required": "处理策略类型必填。", + "queues": "队列", + "selected-queues": "已选择 { count, plural, 1 {1 个队列} other {# 个队列} }", + "delete-queue-title": "确定要删除 '{{queueName}}' 队列吗?", + "delete-queues-title": "确定要删除 { count, plural, 1 {1 个队列} other {# 个队列} }吗?", + "delete-queue-text": "请注意:确认后,队列和所有相关数据将不可恢复。", + "delete-queues-text": "确认后,所有选定队列都将被删除,无法访问。", + "search": "搜索队列", + "add": "添加队列", + "details": "队列详情", + "topic": "主题", + "submit-settings": "提交设置", + "submit-strategy": "Strategy type *", + "grouping-parameter": "分组参数", + "processing-settings": "重试处理设置", + "processing-strategy": "Processing type *", + "retries-settings": "重试设置", + "polling-settings": "轮询设置", + "batch-processing": "批量处理", + "poll-interval": "轮询间隔", + "partitions": "分区", + "immediate-processing": "即时处理", + "consumer-per-partition": "每个分区消费者单独轮询消息", + "consumer-per-partition-hint": "每个分区启用单独的消费者", + "processing-timeout": "处理超时(毫秒)", + "batch-size": "批量处理大小", + "retries": "重试次数 (0 – 无限制)", + "failure-percentage": "跳过重试的失败消息百分比", + "pause-between-retries": "重试间隔(秒)", + "max-pause-between-retries": "最大重试间隔(秒)", + "delete": "删除队列", + "copyId": "复制队列ID", + "idCopiedMessage": "队列ID已复制到剪贴板", + "description": "说明", + "description-hint": "此文本将显示在队列说明中,而不是所选策略中", + "alt-description": "提交策略:{{submitStrategy}},处理策略:{{processingStrategy}}", + "strategies": { + "sequential-by-originator-label": "按发起者顺序处理", + "sequential-by-originator-hint": "在确认设备A的前一条消息之前,不会提交设备A的新消息", + "sequential-by-tenant-label": "按租户顺序处理", + "sequential-by-tenant-hint": "在确认租户A的前一条消息之前,不会提交租户A的新消息", + "sequential-label": "顺序处理", + "sequential-hint": "在确认前一条消息之前,不会提交新消息", + "burst-label": "突发处理", + "burst-hint": "所有消息都按到达的顺序提交到规则链", + "batch-label": "批量处理", + "batch-hint": "在确认前一批消息之前,不会提交新批", + "skip-all-failures-label": "跳过所有失败", + "skip-all-failures-hint": "忽略所有失败", + "skip-all-failures-and-timeouts-label": "跳过所有失败和超时", + "skip-all-failures-and-timeouts-hint": "忽略所有失败和超时", + "retry-all-label": "全部重试", + "retry-all-hint": "重试处理包中的所有消息", + "retry-failed-label": "失败重试", + "retry-failed-hint": "重试处理包中的所有失败消息", + "retry-timeout-label": "超时重试", + "retry-timeout-hint": "重试处理包中的所有超时消息", + "retry-failed-and-timeout-label": "失败与超时重试", + "retry-failed-and-timeout-hint": "重试处理包中所有失败和超时的消息" + } + }, + "server-error": { + "general": "一般服务器错误", + "authentication": "授权错误", + "jwt-token-expired": "JWT令牌已过期", + "tenant-trial-expired": "租户过期", + "credentials-expired": "凭据过期", + "permission-denied": "没有权限", + "invalid-arguments": "无效参数", + "bad-request-params": "请求无效", + "item-not-found": "找不到项目", + "too-many-requests": "请求过于频繁", + "too-many-updates": "更新过于频繁" + }, + "tenant": { + "tenant": "租户", + "tenants": "租户", + "management": "租户管理", + "add": "添加租户", + "admins": "管理员", + "manage-tenant-admins": "管理租户管理员", + "delete": "删除租户", + "add-tenant-text": "添加新租户", + "no-tenants-text": "未找到租户", + "tenant-details": "租客详情", + "title-max-length": "标题长度必须少于256个字符", + "delete-tenant-title": "确定要删除租户'{{tenantTitle}}'吗?", + "delete-tenant-text": "请注意:确认后,租户和所有相关数据将不可恢复。", + "delete-tenants-title": "确定要删除 {count,plural,1 {# 个租户} other {# 个租户} } 吗?", + "delete-tenants-action-title": "删除 { count, plural, 1 {# 个租户} other {# 个租户} }", + "delete-tenants-text": "请注意:确认后,所有选定的租户将被删除,所有相关数据将不可恢复。", + "title": "标题", + "title-required": "标题必填。", + "description": "说明", + "details": "详情", + "events": "事件", + "copyId": "复制租户ID", + "idCopiedMessage": "租户ID已经复制到粘贴板", + "select-tenant": "选择租户", + "no-tenants-matching": "未找到匹配 '{{entity}}' 的租户", + "tenant-required": "租户必填", + "search": "查找租户", + "selected-tenants": "已选择 { count, plural, 1 {# 个租户} other {# 个租户} }", + "isolated-tb-rule-engine": "使用独立的规则引擎服务", + "isolated-tb-rule-engine-details": "每个独立租户需要单独的规则引擎微服务" + }, + "tenant-profile": { + "tenant-profile": "租户配置", + "tenant-profiles": "租户配置", + "add": "添加租户配置", + "edit": "编辑租户配置", + "tenant-profile-details": "租户配置详细信息", + "no-tenant-profiles-text": "未找到租户配置", + "name-max-length": "名称长度必须少于256个字符", + "search": "查找租户配置", + "selected-tenant-profiles": "已选择 { count, plural, 1 {# 个租户配置} other {# 个租户配置} }", + "no-tenant-profiles-matching": "未找到与 '{{entity}}' 匹配的租户配置。", + "tenant-profile-required": "租户配置必填", + "idCopiedMessage": "租户配置ID已复制到剪贴板", + "set-default": "设置该租户配置为默认", + "delete": "删除租户配置", + "copyId": "复制租户配置ID", + "name": "名称", + "name-required": "名称必填。", + "data": "配置数据", + "profile-configuration": "配置设置", + "description": "说明", + "default": "默认", + "delete-tenant-profile-title": "确定要删除租户配置 '{{tenantProfileName}}'吗?", + "delete-tenant-profile-text": "请注意:确认后,租户配置和所有相关数据将不可恢复。", + "delete-tenant-profiles-title": "确定要删除 { count, plural, 1 {# 个租户配置} other {# 个租户配置} }吗?", + "delete-tenant-profiles-text": "请注意:确认后,所有选定的租户配置将被删除,所有相关数据将不可恢复。", + "set-default-tenant-profile-title": "确定要将租户配置 '{{tenantProfileName}}' 设为默认值吗?", + "set-default-tenant-profile-text": "确认后,此租户配置将被标记为默认配置,并将用于未指定配置的新租户。", + "no-tenant-profiles-found": "未找到租户配置。", + "create-new-tenant-profile": "创建一个新的!", + "create-tenant-profile": "创建新的租户配置", + "import": "导入租户配置", + "export": "导出租户配置", + "export-failed-error": "无法导出租户配置: {{error}}", + "tenant-profile-file": "租户配置文件", + "invalid-tenant-profile-file-error": "无法导入租户配置:无效的租户配置数据结构。", + "advanced-settings": "高级设置", + "entities": "实体", + "rule-engine": "规则引擎", + "time-to-live": "TTL", + "alarms-and-notifications": "告警与通知", + "ota-files-in-bytes": "OTA文件(字节)", + "ws-title": "WS", + "unlimited": "(0 - 无限制)", + "maximum-devices": "最大设备数", + "maximum-devices-required": "最大设备数必填。", + "maximum-devices-range": "最大设备数不能为负数", + "maximum-assets": "最大资产数", + "maximum-assets-required": "最大资产数必填。", + "maximum-assets-range": "最大资产数不能为负数", + "maximum-customers": "最大客户数", + "maximum-customers-required": "最大客户数必填。", + "maximum-customers-range": "最大客户数不能为负数", + "maximum-users": "最大用户数", + "maximum-users-required": "最大用户数必填。", + "maximum-users-range": "最大用户数不能为负数", + "maximum-dashboards": "最大仪表板数", + "maximum-dashboards-required": "最大仪表板数必填。", + "maximum-dashboards-range": "最大仪表板数不能为负数", + "maximum-rule-chains": "最大规则链数", + "maximum-rule-chains-required": "最大规则链数必填。", + "maximum-rule-chains-range": "最大规则链数不能为负数", + "maximum-resources-sum-data-size": "资源文件总大小", + "maximum-resources-sum-data-size-required": "资源文件总大小必填。", + "maximum-resources-sum-data-size-range": "资源文件总大小不能为负数", + "maximum-ota-packages-sum-data-size": "OTA包文件总大小", + "maximum-ota-package-sum-data-size-required": "OTA包文件总大小必填。", + "maximum-ota-package-sum-data-size-range": "OTA包文件总大小不能为负数", + "transport-tenant-msg-rate-limit": "租户消息", + "transport-tenant-telemetry-msg-rate-limit": "租户遥测消息", + "transport-tenant-telemetry-data-points-rate-limit": "租户遥测数据点", + "transport-device-msg-rate-limit": "设备消息", + "transport-device-telemetry-msg-rate-limit": "设备遥测数据点", + "transport-device-telemetry-data-points-rate-limit": "设备遥测消息", + "tenant-entity-export-rate-limit": "实体版本创建", + "tenant-entity-import-rate-limit": "实体版本加载", + "max-transport-messages": "最大传输消息数", + "max-transport-messages-required": "最大传输消息数必填。", + "max-transport-messages-range": "最大传输消息数不能为负数", + "max-transport-data-points": "最大传输数据点数", + "max-transport-data-points-required": "最大传输数据点数必填。", + "max-transport-data-points-range": "最大传输数据点数不能为负", + "max-r-e-executions": "最大规则引擎执行数", + "max-r-e-executions-required": "最大规则引擎执行数必填。", + "max-r-e-executions-range": "最大规则引擎执行数不能为负", + "max-j-s-executions": "最大 JavaScript 执行数", + "max-j-s-executions-required": "最大 JavaScript 执行数必填。", + "max-j-s-executions-range": "最大 JavaScript 执行数不能为负数", + "max-d-p-storage-days": "最大存储点天", + "max-d-p-storage-days-required": "最大存储点天必填。", + "max-d-p-storage-days-range": "最大存储点天不能为负数", + "default-storage-ttl-days": "默认存储TTL天数", + "default-storage-ttl-days-required": "默认存储TTL天数必填。.", + "default-storage-ttl-days-range": "默认存储TTL天数不能为负数", + "alarms-ttl-days": "告警TTL天数", + "alarms-ttl-days-required": "告警TTL天数必填。", + "alarms-ttl-days-days-range": "告警TTL天数不能为负数", + "rpc-ttl-days": "RPC TTL天数", + "rpc-ttl-days-required": "RPC TTL天数必填。", + "rpc-ttl-days-days-range": "RPC TTL天数不能为负数", + "max-rule-node-executions-per-message": "每条消息的最大规则节点执行数", + "max-rule-node-executions-per-message-required": "每个消息的最大规则节点执行数必填。", + "max-rule-node-executions-per-message-range": "每条消息的最大规则节点执行数不能为负", + "max-emails": "最大电子邮件发送数", + "max-emails-required": "最大电子邮件发送数必填。", + "max-emails-range": "最大电子邮件发送数不能为负", + "max-sms": "最大短信发送数", + "max-sms-required": "最大短信发送数必填。", + "max-sms-range": "最大短信发送数不能为负", + "max-created-alarms": "最大创建告警数", + "max-created-alarms-required": "最大创建告警数必填。", + "max-created-alarms-range": "最大创建告警数不能为负数", + "no-queue": "未配置队列", + "add-queue": "添加队列", + "queues-with-count": "队列 ({{count}})", + "tenant-rest-limits": "租户REST请求", + "customer-rest-limits": "客户REST请求", + "incorrect-pattern-for-rate-limits": "格式为以冒号分割容量与周期(秒)并以逗号分割配置对,例如 100:1,2000:60", + "too-small-value-zero": "数值必须大于0", + "too-small-value-one": "数值必须大于1", + "cassandra-tenant-limits-configuration": "租户Cassandra查询", + "ws-limit-max-sessions-per-tenant": "租户最大会话数", + "ws-limit-max-sessions-per-customer": "客户最大会话数", + "ws-limit-max-sessions-per-regular-user": "普通用户最大会话数", + "ws-limit-max-sessions-per-public-user": "公共用户最大会话数", + "ws-limit-queue-per-session": "会话最大消息队列大小", + "ws-limit-max-subscriptions-per-tenant": "租户最大订阅数", + "ws-limit-max-subscriptions-per-customer": "客户最大订阅数", + "ws-limit-max-subscriptions-per-regular-user": "普通用户最大订阅数", + "ws-limit-max-subscriptions-per-public-user": "公共用户最大订阅数", + "ws-limit-updates-per-session": "会话WS更新", + "rate-limits": { + "add-limit": "添加限制", + "advanced-settings": "高级设置", + "edit-limit": "编辑限制", + "but-less-than": "但小于", + "edit-transport-tenant-msg-title": "编辑传输租户消息速率限制", + "edit-transport-tenant-telemetry-msg-title": "编辑传输租户遥测消息速率限制", + "edit-transport-tenant-telemetry-data-points-title": "编辑传输租户遥测数据点速率限制", + "edit-transport-device-msg-title": "编辑传输设备消息速率限制", + "edit-transport-device-telemetry-msg-title": "编辑传输设备遥测消息速率限制", + "edit-transport-device-telemetry-data-points-title": "编辑传输设备遥测数据点速率限制", + "edit-transport-tenant-msg-rate-limit-title": "编辑传输租户消息速率限制", + "edit-customer-rest-limits-title": "编辑客户REST请求速率限制", + "edit-ws-limit-updates-per-session-title": "编辑会话WS更新速率限制", + "edit-cassandra-tenant-limits-configuration-title": "编辑租户Cassandra查询速率限制", + "edit-tenant-entity-export-rate-limit-title": "编辑实体版本创建速率限制", + "edit-tenant-entity-import-rate-limit-title": "编辑实体版本加载速率限制", + "messages-per": "条消息每", + "not-set": "未配置", + "number-of-messages": "消息数量", + "number-of-messages-required": "消息数量必填。", + "number-of-messages-min": "最小值为1。", + "preview": "预览", + "per-seconds": "每秒", + "per-seconds-required": "时间比率必填。", + "per-seconds-min": "最小值为1。", + "rate-limits": "速率限制", + "remove-limit": "删除限制", + "transport-tenant-msg": "传输租户消息", + "transport-tenant-telemetry-msg": "传输租户遥测消息", + "transport-tenant-telemetry-data-points": "传输租户遥测数据点", + "transport-device-msg": "传输设备消息", + "transport-device-telemetry-msg": "传输设备遥测消息", + "transport-device-telemetry-data-points": "传输设备遥测数据点", + "sec": "秒" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {# 秒} other {# 秒} }", + "minutes-interval": "{ minutes, plural, 1 {# 分} other {# 分} }", + "hours-interval": "{ hours, plural, 1 {# 小时} other {# 小时} }", + "days-interval": "{ days, plural, 1 {# 天} other {# 天} }", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "seconds": "秒", + "advanced": "高级", + "predefined": { + "yesterday": "昨天", + "day-before-yesterday": "前天", + "this-day-last-week": "前一周的这一天", + "previous-week": "前一周(周日至周六)", + "previous-week-iso": "前一周(周一至周日)", + "previous-month": "前一个月", + "previous-year": "前一年", + "current-hour": "当前小时", + "current-day": "当前天", + "current-day-so-far": "当天到目前为止", + "current-week": "本周(周日至周六)", + "current-week-iso": "本周(周一至周日)", + "current-week-so-far": "本周到目前为止(周日至周六)", + "current-week-iso-so-far": "本周到目前为止(周一至周日)", + "current-month": "本月", + "current-month-so-far": "本月到目前为止", + "current-year": "本年", + "current-year-so-far": "本年到目前为止" + } + }, + "timeunit": { + "milliseconds": "毫秒", + "seconds": "秒", + "minutes": "分钟", + "hours": "小时", + "days": "天" + }, + "timewindow": { + "days": "{ days, plural, 1 {# 天 } other {# 天 } }", + "hours": "{ hours, plural, 0 {- 小时 } 1 {# 小时 } other {# 小时 } }", + "minutes": "{ minutes, plural, 0 {- 分 } 1 {# 分 } other {# 分 } }", + "seconds": "{ seconds, plural, 0 {- 秒 } 1 {# 秒 } other {# 秒 } }", + "realtime": "实时", + "history": "历史", + "last-prefix": "最后", + "period": "从 {{ startTime }} 到 {{ endTime }}", + "edit": "编辑时间窗口", + "date-range": "日期范围", + "last": "最后", + "time-period": "时间段", + "hide": "隐藏", + "interval": "区间" + }, + "user": { + "user": "用户", + "users": "用户", + "customer-users": "客户用户", + "tenant-admins": "租户管理员", + "sys-admin": "系统管理员", + "tenant-admin": "租户管理员", + "customer": "客户", + "anonymous": "匿名", + "add": "添加用户", + "delete": "删除用户", + "add-user-text": "添加新用户", + "no-users-text": "未找到用户", + "user-details": "用户详细信息", + "delete-user-title": "确定要删除用户 '{{userEmail}}' 吗?", + "delete-user-text": "请注意:确认后,用户和所有相关数据将不可恢复。", + "delete-users-title": "确定要删除 { count, plural, 1 {# 个用户} other {# 个用户} } 吗?", + "delete-users-action-title": "删除 { count, plural, 1 {# 个用户} other {# 个用户} }", + "delete-users-text": "请注意:确认后,所有选定的用户将被删除,所有相关数据将不可恢复。", + "activation-email-sent-message": "激活电子邮件已成功发送!", + "resend-activation": "重新发送激活", + "email": "电子邮件", + "email-required": "电子邮件必填。", + "invalid-email-format": "无效的邮件格式。", + "first-name": "名字", + "last-name": "姓氏", + "description": "说明", + "default-dashboard": "默认面板", + "always-fullscreen": "始终全屏", + "select-user": "选择用户", + "no-users-matching": "未找到匹配 '{{entity}}' 的用户。", + "user-required": "用户必填", + "activation-method": "激活方式", + "display-activation-link": "显示激活链接", + "send-activation-mail": "发送激活邮件", + "activation-link": "用户激活链接", + "activation-link-text": "使用该链接 激活 激活用户:", + "copy-activation-link": "复制用户激活链接", + "activation-link-copied-message": "用户激活链接已经复制到粘贴板", + "details": "详情", + "login-as-tenant-admin": "以租户管理员身份登录", + "login-as-customer-user": "以客户用户身份登录", + "search": "查找用户", + "selected-users": "已选择 { count, plural, 1 {# 个用户} other {# 个用户} }", + "disable-account": "禁用用户帐户", + "enable-account": "启用用户帐户", + "enable-account-message": "已成功启用用户帐户!", + "disable-account-message": "已成功禁用用户帐户!", + "copyId": "复制用户Id", + "idCopiedMessage": "用户ID已经复制到粘贴板" + }, + "value": { + "type": "值类型", + "string": "字符串", + "string-value": "字符串值", + "string-value-required": "字符串值必填", + "integer": "数字", + "integer-value": "数字值", + "integer-value-required": "整数值必填", + "invalid-integer-value": "整数值无效", + "double": "双精度小数", + "double-value": "双精度小数值", + "double-value-required": "需要双精度值", + "boolean": "布尔值", + "boolean-value": "布尔值", + "false": "假", + "true": "真", + "long": "Long", + "json": "JSON", + "json-value": "JSON值", + "json-value-invalid": "JSON值的格式无效", + "json-value-required": "JSON值必填。" + }, + "version-control": { + "version-control": "版本控制", + "management": "版本控制管理", + "search": "搜索版本", + "branch": "分支", + "default": "默认", + "select-branch": "选择分支", + "branch-required": "分支必填", + "create-entity-version": "创建实体版本", + "version-name": "版本名称", + "version-name-required": "版本名称必填", + "author": "作者", + "export-relations": "导出关联", + "export-attributes": "导出属性", + "export-credentials": "导出凭据", + "entity-versions": "实体版本", + "versions": "版本", + "created-time": "创建时间", + "version-id": "版本ID", + "no-entity-versions-text": "未找到实体版本", + "no-versions-text": "未找到版本", + "copy-full-version-id": "复制完整版本ID", + "create-version": "创建版本", + "creating-version": "请稍候,正在创建版本...", + "nothing-to-commit": "没有要提交的更改", + "restore-version": "还原版本", + "restore-entity-from-version": "从版本 '{{versionName}}' 还原实体", + "restoring-entity-version": "请稍候,正在还原实体版本...", + "load-relations": "加载关联", + "load-attributes": "加载属性", + "load-credentials": "加载凭据", + "compare-with-current": "与当前比较", + "diff-entity-with-version": "与实体版本 '{{versionName}}' 不同", + "previous-difference": "上一个差异", + "next-difference": "下一个差异", + "current": "当前", + "differences": "{ count, plural, 1 {1 个差异} other {# 个差异} }", + "create-entities-version": "创建实体版本", + "default-sync-strategy": "默认同步策略", + "sync-strategy-merge": "合并", + "sync-strategy-overwrite": "覆盖", + "entities-to-export": "导出的实体", + "entities-to-restore": "还原的实体", + "sync-strategy": "同步策略", + "all-entities": "所有实体", + "no-entities-to-export-prompt": "请指定要导出的实体", + "no-entities-to-restore-prompt": "请指定要还原的实体", + "add-entity-type": "添加实体类型", + "remove-all": "全部删除", + "version-create-result": "{ added, plural, 0 {没有实体} 1 {1 个实体} other {# 个实体} } 被添加。
    { modified, plural, 0 {没有实体} 1 {1 个实体} other {# 个实体} } 被修改。
    { removed, plural, 0 {没有实体} 1 {1 个实体} other {# 个实体} } 被删除。", + "remove-other-entities": "删除其他实体", + "find-existing-entity-by-name": "按名称查找现有实体", + "restore-entities-from-version": "从版本 '{{versionName}}' 还原实体", + "restoring-entities-from-version": "请稍候,正在还原实体...", + "no-entities-restored": "未还原任何实体", + "created": "{{created}} 创建", + "updated": "{{updated}} 更新", + "deleted": "{{deleted}} 删除", + "remove-other-entities-confirm-text": "请注意!在还原版本中不存在的当前实体
    将被永久 删除

    请输入 remove other entities 进行确认。", + "auto-commit-to-branch": "自动提交到 {{ branch }} 分支", + "default-create-entity-version-name": "{{entityName}} 更新", + "sync-strategy-merge-hint": "创建或更新选定的实体,仓库其他实体均不修改。", + "sync-strategy-overwrite-hint": "创建或更新选定的实体,仓库其他实体将被删除。", + "device-credentials-conflict": "无法加载外部ID为 {{entityId}} 的设备
    因为数据库中已存在相同的凭据。
    请考虑禁用还原表单中的 加载凭据 设置。", + "missing-referenced-entity": "无法加载外部ID为 {{sourceEntityId}}{{sourceEntityTypeName}}
    因为它引用了缺失的 {{targetEntityTypeName}} (ID:{{targetEntityId}}).", + "runtime-failed": "失败: {{message}}", + "auto-commit-settings-read-only-hint": "自动提交功能不适用于存储库设置中启用的只读选项。" + }, + "widget": { + "widget-library": "部件库", + "widget-bundle": "部件包", + "all-bundles": "All bundles", + "select-widgets-bundle": "选择部件包", + "management": "管理部件", + "editor": "部件编辑器", + "widget-type-not-found": "加载部件配置出错。
    可能关联的部件已经删除了。", + "widget-type-load-error": "由于以下错误未加载小部件:", + "remove": "删除部件", + "edit": "编辑部件", + "remove-widget-title": "确定要删除 '{{widgetTitle}}'部件吗?", + "remove-widget-text": "确认后,控件和所有相关数据将变得不可恢复。", + "timeseries": "Timeseries", + "search-data": "查找数据", + "no-data-found": "未找到数据", + "latest": "最新值", + "rpc": "控件部件", + "alarm": "告警部件", + "static": "静态部件", + "select-widget-type": "选择窗口部件类型", + "missing-widget-title-error": "部件标题必须指定!", + "widget-saved": "部件已保存", + "unable-to-save-widget-error": "无法保存部件!控件有错误!", + "save": "保存部件", + "saveAs": "部件另存为", + "save-widget-type-as": "部件类型另存为", + "save-widget-type-as-text": "请输入新的部件标题或选择目标部件包", + "toggle-fullscreen": "切换全屏", + "run": "运行部件", + "title": "部件标题", + "title-required": "部件标题必填。", + "type": "部件类型", + "resources": "资源", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "是否模块", + "remove-resource": "删除资源", + "add-resource": "添加资源", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "设置模式", + "datakey-settings-schema": "数据键设置模式", + "latest-datakey-settings-schema": "最新数据键设置模式", + "widget-settings": "部件设置", + "description": "描述", + "image-preview": "图片预览", + "settings-form-selector": "设置表单选择器", + "data-key-settings-form-selector": "数据键设置表单选择器", + "latest-data-key-settings-form-selector": "最新数据键设置表单选择器", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "确定要删除部件类型 '{{widgetName}}'吗?", + "remove-widget-type-text": "确认后,窗口部件类型和所有相关数据将不可恢复。", + "remove-widget-type": "删除部件类型", + "add-widget-type": "添加新的部件类型", + "widget-type-load-failed-error": "无法加载部件类型!", + "widget-template-load-failed-error": "无法加载部件模板!", + "add": "添加部件", + "undo": "撤消部件更改", + "export": "导出部件", + "no-data": "小部件上没有要显示的数据", + "data-overflow": "部件显示 {{count}}之外{{total}} 实体", + "alarm-data-overflow": "窗口部件显示{{allowedEntities}}(最大允许的)实的警报{{totalEntities}}实体", + "search": "搜索部件", + "filter": "部件类型过滤", + "loading-widgets": "加载部件..." + }, + "widget-action": { + "header-button": "顶部按钮clcik", + "open-dashboard-state": "切换到新仪表板状态", + "update-dashboard-state": "更新当前仪表板状态", + "open-dashboard": "切换到另一个仪表板", + "custom": "自定义动作", + "custom-pretty": "自定义操作(使用HTML模板)", + "mobile-action": "移动端动作", + "target-dashboard-state": "目标仪表板状态", + "target-dashboard-state-required": "目标仪表板状态必填", + "set-entity-from-widget": "从部件中设置实体", + "target-dashboard": "目标仪表板", + "open-right-layout": "打开右侧布局 (移动端视图)", + "state-display-type": "显示仪表板状态选项", + "open-normal": "普通", + "open-in-separate-dialog": "在单独的对话框中打开", + "open-in-popover": "在popover打开", + "dialog-title": "对话框标题", + "dialog-hide-dashboard-toolbar": "在对话框中隐藏仪表板工具栏", + "dialog-width": "对话框宽度相对视图宽度", + "dialog-height": "对话框高度相对视图高度", + "dialog-size-range-error": "对话框尺寸比值必须是1至100范围内。", + "popover-preferred-placement": "首选弹出位置", + "popover-placement-top": "上", + "popover-placement-topLeft": "左上", + "popover-placement-topRight": "右上", + "popover-placement-right": "右", + "popover-placement-rightTop": "右上", + "popover-placement-rightBottom": "右下", + "popover-placement-bottom": "下", + "popover-placement-bottomLeft": "左下", + "popover-placement-bottomRight": "右下", + "popover-placement-left": "左", + "popover-placement-leftTop": "左上", + "popover-placement-leftBottom": "左下", + "popover-hide-on-click-outside": "点击隐藏外面的弹出窗口", + "popover-hide-dashboard-toolbar": "隐藏仪表板工具栏", + "popover-width": "浏览器中的弹出宽度(例如100px, 25vw)单位", + "popover-height": "浏览器中的弹出高度(例如:100px,25vh)单位", + "popover-style": "弹出样式", + "open-new-browser-tab": "在新的浏览器选项卡中打开", + "mobile": { + "action-type": "手机端动作类型", + "action-type-required": "手机端动作类型必填。", + "take-picture-from-gallery": "画册拍照", + "take-photo": "拍照", + "map-direction": "打开地图说明", + "map-location": "打开地图位置", + "scan-qr-code": "扫描二维码", + "make-phone-call": "拨打电话", + "get-location": "获得位置", + "take-screenshot": "截图" + } + }, + "widgets-bundle": { + "current": "当前组", + "widgets-bundles": "部件包", + "add": "添加部件包", + "delete": "删除部件包", + "title": "标题", + "title-required": "标题必填。", + "title-max-length": "标题长度必须少于256个字符", + "description": "描述", + "image-preview": "图片预览", + "add-widgets-bundle-text": "添加新的部件包", + "no-widgets-bundles-text": "未找到部件包", + "empty": "部件包是空的", + "details": "详情", + "widgets-bundle-details": "部件包详细信息", + "delete-widgets-bundle-title": "确定要删除部件包 '{{widgetsBundleTitle}}'吗?", + "delete-widgets-bundle-text": "请注意:确认后,部件包和所有相关数据将不可恢复。", + "delete-widgets-bundles-title": "确定要删除 { count, plural, 1 {# 个部件包} other {# 个部件包} } 吗?", + "delete-widgets-bundles-action-title": "删除 { count, plural, 1 {# 个部件包} other {# 个部件包} }", + "delete-widgets-bundles-text": "请注意:确认后,所有选定的部件包将被删除,所有相关数据将不可恢复。", + "no-widgets-bundles-matching": "未找到与 '{{widgetsBundle}}' 匹配的部件包。", + "widgets-bundle-required": "部件包必填。", + "system": "系统", + "import": "导入部件包", + "export": "导出部件包", + "export-failed-error": "无法导出部件包: {{error}}", + "create-new-widgets-bundle": "创建新的部件包", + "widgets-bundle-file": "部件包文件", + "invalid-widgets-bundle-file-error": "无法导入部件包:无效的部件包数据结构。", + "search": "查找部件包", + "selected-widgets-bundles": "已选择 { count, plural, 1 {# 个部件包} other {# 个部件包} }", + "open-widgets-bundle": "打开部件包", + "loading-widgets-bundles": "加载部件包..." + }, + "widget-config": { + "data": "数据", + "settings": "设置", + "advanced": "高级", + "title": "标题", + "title-tooltip": "标题提示框", + "general-settings": "基础设置", + "display-title": "显示标题", + "drop-shadow": "阴影", + "enable-fullscreen": "启用全屏", + "background-color": "背景颜色", + "text-color": "文字颜色", + "padding": "内边距", + "margin": "外边距", + "widget-style": "部件风格", + "widget-css": "部件CSS", + "title-style": "标题风格", + "mobile-mode-settings": "移动端设置", + "order": "顺序", + "height": "高度", + "mobile-hide": "移动端隐藏部件", + "units": "特殊符号展示值", + "decimals": "浮点数后的位数", + "timewindow": "时间窗口", + "use-dashboard-timewindow": "使用仪表板的时间窗口", + "display-timewindow": "显示时间窗口", + "legend": "图例", + "display-legend": "显示图例", + "datasources": "数据源", + "maximum-datasources": "最大允许{ count, plural, 1 {1个数据源。} other {#个数据源。} }", + "timeseries-key-error": "应至少指定一个时间序列数据密钥", + "datasource-type": "类型", + "datasource-parameters": "参数", + "remove-datasource": "移除数据源", + "add-datasource": "添加数据源", + "target-device": "目标设备", + "alarm-source": "警报源", + "actions": "动作", + "action": "动作", + "add-action": "添加动作", + "search-actions": "搜索动作", + "no-actions-text": "找不到动作", + "action-source": "动作源", + "action-source-required": "动作源必填", + "action-name": "名称", + "action-name-required": "动作名称必填。", + "action-name-not-unique": "动作名称已经存在。
    相同动作源的动作名称必须唯一。", + "action-icon": "图标", + "show-hide-action-using-function": "使用函数显示/隐藏操作", + "action-type": "类型", + "action-type-required": "类型必填", + "edit-action": "编辑动作", + "delete-action": "删除删除", + "delete-action-title": "删除部件动作", + "delete-action-text": "确定要删除部件动作'{{actionName}}'吗?", + "title-icon": "标题图标", + "display-icon": "显示标题图标", + "icon-color": "图标颜色", + "icon-size": "图标大小", + "advanced-settings": "高级设置", + "data-settings": "数据设置", + "no-data-display-message": "\"无数据显示\" 替代消息", + "data-page-size": "每个数据源的最大实体数", + "settings-component-not-found": "找不到选择器'{{selector}}'的每个数据源设置窗体组件的最大实体数" + }, + "widget-type": { + "import": "导入部件类型", + "export": "导出部件类型", + "export-failed-error": "无法导出部件类型: {{error}}", + "create-new-widget-type": "创建新的部件类型", + "widget-type-file": "部件类型文件", + "invalid-widget-type-file-error": "无法导入部件类型:无效的部件类型数据结构。" + }, + "widgets": { + "chart": { + "common-settings": "通用设置", + "enable-stacking-mode": "启用堆叠模式", + "line-shadow-size": "线条阴影大小", + "display-smooth-lines": "显示光滑(弯曲)线条", + "default-bar-width": "默认条形宽度不聚合数据(毫秒)", + "bar-alignment": "条形对齐", + "bar-alignment-left": "左", + "bar-alignment-right": "右", + "bar-alignment-center": "中", + "default-font-size": "默认字体大小", + "default-font-color": "默认字体颜色", + "thresholds-line-width": "所有线条默认宽度", + "tooltip-settings": "提示栏设置", + "show-tooltip": "显示提示栏", + "hover-individual-points": "坐标悬停显示", + "show-cumulative-values": "在堆叠模式下显示累加数据", + "hide-zero-false-values": "隐藏提示栏中0或false值", + "tooltip-value-format-function": "提示栏格式化函数", + "grid-settings": "网格设置", + "show-vertical-lines": "显示垂直参考线", + "show-horizontal-lines": "显示水平参考线", + "grid-outline-border-width": "网格边框宽度(像系)", + "primary-color": "边框颜色", + "background-color": "背景颜色", + "ticks-color": "参考线颜色", + "xaxis-settings": "X轴设置", + "axis-title": "标题", + "xaxis-tick-labels-settings": "标签设置", + "show-tick-labels": "显示标签", + "yaxis-settings": "y轴设置", + "min-scale-value": "最小值", + "max-scale-value": "最大值", + "yaxis-tick-labels-settings": "标签设置", + "tick-step-size": "步长", + "number-of-decimals": "小数位数", + "ticks-formatter-function": "刻度格式化函数", + "comparison-settings": "比较设置", + "enable-comparison": "启用比较", + "time-for-comparison": "比较时期", + "time-for-comparison-previous-interval": "历史时间间隔(默认)", + "time-for-comparison-days": "一天前", + "time-for-comparison-weeks": "一周前", + "time-for-comparison-months": "一月前", + "time-for-comparison-years": "一年前", + "time-for-comparison-custom-interval": "自定义间隔", + "custom-interval-value": "自定义间隔值(毫秒)", + "comparison-x-axis-settings": "比较x轴设置", + "axis-position": "位置", + "axis-position-top": "上面(默认)", + "axis-position-bottom": "下面", + "custom-legend-settings": "自定义图例设置", + "enable-custom-legend": "启用自定义图例(你可以使用属性/时间序列标签)", + "key-name": "键名", + "key-name-required": "键名必填", + "key-type": "键类型", + "key-type-attribute": "属性", + "key-type-timeseries": "时间序列", + "label-keys-list": "在标签中使用键列表", + "no-label-keys": "没有配置键", + "add-label-key": "添加键", + "line-width": "线条宽度", + "color": "颜色", + "data-is-hidden-by-default": "默认隐藏数据", + "disable-data-hiding": "禁用用数据隐藏", + "remove-from-legend": "从图例中移除数据键", + "exclude-from-stacking": "从堆叠模式排除", + "line-settings": "线条设置", + "show-line": "显示线条", + "fill-line": "线条填充", + "points-settings": "点设置", + "show-points": "显示点", + "points-line-width": "宽度", + "points-radius": "圆角", + "point-shape": "形状", + "point-shape-circle": "圆形", + "point-shape-cross": "十字形", + "point-shape-diamond": "菱形", + "point-shape-square": "矩形", + "point-shape-triangle": "三角形", + "point-shape-custom": "自定义", + "point-shape-draw-function": "绘制函数", + "show-separate-axis": "显示独立轴", + "axis-position-left": "左", + "axis-position-right": "右", + "thresholds": "阈值", + "no-thresholds": "没有配置阈值", + "add-threshold": "添加阈值", + "show-values-for-comparison": "显示比较的历史值", + "comparison-values-label": "历史数什标签", + "threshold-settings": "阈值设置", + "use-as-threshold": "将值作为阈值", + "threshold-line-width": "阈值线条宽度", + "threshold-color": "阈值颜色", + "common-pie-settings": "饼图设置", + "radius": "圆角", + "inner-radius": "内圆角", + "tilt": "倾斜", + "stroke-settings": "斜线设置", + "width-pixels": "宽度(像素)", + "show-labels": "显示标签", + "animation-settings": "动画设置", + "animated-pie": "启用动画", + "border-settings": "边框设置", + "border-width": "边框宽度", + "border-color": "边框颜色", + "legend-settings": "图例设置", + "display-legend": "显示图例", + "labels-font-color": "标签字体颜色" + }, + "dashboard-state": { + "dashboard-state-settings": "仪表板状态设置", + "dashboard-state": "仪表板状态ID", + "autofill-state-layout": "自动填充状态布局高度", + "default-margin": "部件默认外边距", + "default-background-color": "默认背景色", + "sync-parent-state-params": "同步父仪表板状态参数" + }, + "date-range-navigator": { + "date-range-picker-settings": "日期范围选择设置", + "hide-date-range-picker": "隐藏日期范围选择", + "picker-one-panel": "日期范围选择板", + "picker-auto-confirm": "自动确认日期范围选择", + "picker-show-template": "日期范围选择显示模板", + "first-day-of-week": "一周的第一天", + "interval-settings": "间隔设置", + "hide-interval": "隐藏间隔", + "initial-interval": "初始间隔", + "interval-hour": "小时", + "interval-day": "天", + "interval-week": "周", + "interval-two-weeks": "2周", + "interval-month": "月", + "interval-three-months": "3个月", + "interval-six-months": "6个月", + "step-settings": "步长设置", + "hide-step-size": "步长设置", + "initial-step-size": "初始步长大小", + "hide-labels": "隐藏标签", + "use-session-storage": "使用会话存储", + "localizationMap": { + "Sun": "周日", + "Mon": "周一", + "Tue": "周二", + "Wed": "周三", + "Thu": "周四", + "Fri": "周五", + "Sat": "周六", + "Jan": "1月", + "Feb": "2月", + "Mar": "3月", + "Apr": "4月", + "May": "5月", + "Jun": "6月", + "Jul": "7月", + "Aug": "8月", + "Sep": "9月", + "Oct": "10月", + "Nov": "11月", + "Dec": "12月", + "January": "一月", + "February": "二月", + "March": "三月", + "April": "四月", + "June": "六月", + "July": "七月", + "August": "八月", + "September": "九月", + "October": "十月", + "November": "十一月", + "December": "十二月", + "Custom Date Range": "自定义日期范围", + "Date Range Template": "日期范围模板", + "Today": "今天", + "Yesterday": "昨天", + "This Week": "本星期", + "Last Week": "上星期", + "This Month": "本月", + "Last Month": "上月", + "Year": "年", + "This Year": "今年", + "Last Year": "去年", + "Date picker": "日期选择", + "Hour": "小时", + "Day": "天", + "Week": "周", + "2 weeks": "2周", + "Month": "月", + "3 months": "3个月", + "6 months": "6个月", + "Custom interval": "自定义间隔", + "Interval": "间隔", + "Step size": "步长", + "Ok": "确定" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "层次数据设置", + "relations-query-function": "关系查询函数", + "has-children-function": "是否子级函数", + "node-state-settings": "状态设置", + "node-opened-function": "展开函数", + "node-disabled-function": "禁用函数", + "display-settings": "显示设置", + "node-icon-function": "icon函数", + "node-text-function": "文本函数", + "sort-settings": "排序设置", + "nodes-sort-function": "排序函数" + }, + "edge": { + "display-default-title": "显示默认标题" + }, + "gateway": { + "general-settings": "基础设置", + "widget-title": "部件村题", + "default-archive-file-name": "默认文件名称", + "device-type-for-new-gateway": "网关设备类型", + "messages-settings": "消息设置", + "save-config-success-message": "配置保存成功消息", + "device-name-exists-message": "设备名称已经存在消消息", + "gateway-title": "网关标题", + "read-only": "只读", + "events-title": "事件标题", + "events-filter": "事件过滤", + "event-key-contains": "包含事件key..." + }, + "gauge": { + "default-color": "默认颜色", + "radial-gauge-settings": "仪表盘设置", + "ticks-settings": "刻度设置", + "min-value": "最小值", + "max-value": "最大值", + "start-ticks-angle": "起始角度", + "ticks-angle": "结束角度", + "major-ticks-count": "主要刻度数量", + "major-ticks-color": "主要刻度颜色", + "minor-ticks-count": "次要刻度数量", + "minor-ticks-color": "次要刻度颜色", + "tick-numbers-font": "字体", + "unit-title-settings": "标题设置", + "show-unit-title": "显示标题", + "unit-title": "单位", + "title-font": "字体", + "units-settings": "单位设置", + "units-font": "字体", + "value-box-settings": "数值设置", + "show-value-box": "显示数值框", + "value-int": "长度", + "value-font": "字体", + "value-box-rect-stroke-color": "数值框矩形颜色", + "value-box-rect-stroke-color-end": "数值框矩形颜色渐变", + "value-box-background-color": "数值框背景色", + "value-box-shadow-color": "数值框阴影颜色", + "plate-settings": "表盘设置", + "show-plate-border": "显示边框", + "plate-color": "背景色", + "needle-settings": "指针设置", + "needle-circle-size": "针圈大小", + "needle-color": "颜色", + "needle-color-end": "渐变色", + "needle-color-shadow-up": "针头颜色", + "needle-color-shadow-down": "阴影颜色", + "highlights-settings": "色块设置", + "highlights-width": "色块宽度", + "highlights": "色块", + "highlight-from": "从", + "highlight-to": "到", + "highlight-color": "颜色", + "no-highlights": "没有配置色块", + "add-highlight": "添加色块", + "animation-settings": "动画设置", + "enable-animation": "启用动画", + "animation-duration": "动画时长", + "animation-rule": "动画类型", + "animation-linear": "线形", + "animation-quad": "方形", + "animation-quint": "五角形", + "animation-cycle": "环形", + "animation-bounce": "循环", + "animation-elastic": "弹跳", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "线型设置", + "bar-stroke-width": "量规宽度", + "bar-stroke-color": "量规颜色", + "bar-background-color": "量规背景色", + "bar-background-color-end": "量规背景渐变色", + "progress-bar-color": "进度条颜色", + "progress-bar-color-end": "进度条渐变色", + "major-ticks-names": "主要刻度名称", + "show-stroke-ticks": "显示主要刻度", + "major-ticks-font": "主要刻度字体", + "border-color": "边框颜色", + "border-width": "边框宽度", + "needle-circle-color": "指针颜色", + "animation-target": "动画触发", + "animation-target-needle": "指针", + "animation-target-plate": "表盘", + "common-settings": "通用设置", + "gauge-type": "仪表盘类型", + "gauge-type-arc": "弧形", + "gauge-type-donut": "环形", + "gauge-type-horizontal-bar": "水平", + "gauge-type-vertical-bar": "垂直", + "donut-start-angle": "角度从", + "bar-settings": "条形设置", + "relative-bar-width": "相对条宽", + "neon-glow-brightness": "霓虹灯效果亮度,(0-100),0-禁用效果", + "stripes-thickness": "条纹的厚度0是没有条纹", + "rounded-line-cap": "显示圆形线盖", + "bar-color-settings": "条颜色设置", + "use-precise-level-color-values": "使用精确的颜色值", + "bar-colors": "从下到上的条形颜色", + "color": "颜色", + "no-bar-colors": "没有配置条形颜色", + "add-bar-color": "添加条形颜色", + "from": "从", + "to": "到", + "fixed-level-colors": "连界值条形颜色", + "gauge-title-settings": "仪表盘标题设置", + "show-gauge-title": "显示仪表盘标题", + "gauge-title": "仪表盘标题", + "gauge-title-font": "仪表盘标题", + "unit-title-and-timestamp-settings": "单位标题和时间戳设置", + "show-timestamp": "显示值时间戳", + "timestamp-format": "时间戳格式化", + "label-font": "标签字体显示值", + "value-settings": "数值设置", + "show-value": "显示文本值", + "min-max-settings": "最小/最大标签设置", + "show-min-max": "显示最小值和最大值", + "min-max-font": "标签最大和最小字体", + "show-ticks": "显示刻度", + "tick-width": "刻度宽度", + "tick-color": "刻度颜色", + "tick-values": "刻度值", + "no-tick-values": "没有配置刻度值", + "add-tick-value": "添加刻度值" + }, + "gpio": { + "pin": "Pin", + "label": "标签", + "row": "行", + "column": "列", + "color": "颜色", + "panel-settings": "面板设置", + "background-color": "背景颜色", + "gpio-switches": "GPIO开关", + "no-gpio-switches": "没有配置GPIO开关", + "add-gpio-switch": "添加开关", + "gpio-status-request": "GPIO状态请求", + "method-name": "方法名", + "method-body": "方法体", + "gpio-status-change-request": "GPIO状态更改请求", + "parse-gpio-status-function": "GPIO状态解析函数", + "gpio-leds": "led", + "no-gpio-leds": "没有配置led", + "add-gpio-led": "添加LED" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "属性参数不能在此小部件中使用", + "blocked-location": "在浏览器中阻止地理位置", + "claim-device": "声明设备", + "claim-failed": "声明设备失败!", + "claim-not-found": "找不到设备!", + "claim-successful": "设备已成功申领!", + "date": "日期", + "device-name": "设备名称", + "device-name-required": "设备名称必填", + "discard-changes": "放弃更改", + "entity-attribute-required": "实体属性必填", + "entity-coordinate-required": "纬度和经度两个字段都是必需的", + "entity-timeseries-required": "实体时间序列必填", + "get-location": "获取当前位置", + "invalid-date": "无效日期", + "latitude": "纬度", + "longitude": "经度", + "min-value-error": "最小值是{{value}}", + "max-value-error": "最大值是{{value}}", + "not-allowed-entity": "所选实体不能具有共享属性", + "no-attribute-selected": "未选择任何属性", + "no-datakey-selected": "未选择数据键", + "no-coordinate-specified": "未指定纬度/经度的数据键", + "no-entity-selected": "未选择实体", + "no-image": "没有图像", + "no-support-geolocation": "你的浏览器不支持地理定位", + "no-support-web-camera": "你的浏览器不支持摄像头", + "enable-https-use-widget": "请启用HTTPS以使用此部件", + "no-found-your-camera": "找不到摄像机", + "no-permission-camera": "权限被用户拒绝/此站点无权使用摄像机", + "no-timeseries-selected": "未选择时间序列", + "secret-key": "密钥", + "secret-key-required": "密钥必填", + "switch-attribute-value": "切换实体属性值", + "switch-camera": "切换摄像机", + "switch-timeseries-value": "切换实体时间序列值", + "take-photo": "拍照", + "time": "时间", + "timeseries-not-allowed": "时间序列参数不能用于此部件", + "update-failed": "更新失败", + "update-successful": "更新成功", + "update-attribute": "更新属性", + "update-timeseries": "更新时间序列", + "value": "数据值", + "general-settings": "基础设置", + "widget-title": "部件标题", + "claim-button-label": "声称按钮标签", + "show-secret-key-field": "显示“密钥”输入字段", + "labels-settings": "标签设置", + "show-labels": "显示标签", + "device-name-label": "设备名称设备名称输入字段的标签输入字段", + "secret-key-label": "设备名称设备密钥输入字段的标签输入字段", + "messages-settings": "信息设置", + "claim-device-success-message": "设备声明成功的文本信息", + "claim-device-not-found-message": "设备声明未找到信息", + "claim-device-failed-message": "设备声明失败的文本信息", + "claim-device-name-required-message": "设备声明“设备名称”必填的错误信息", + "claim-device-secret-key-required-message": "设备声明“设备密钥”必填的错误信息", + "show-label": "显示标签", + "label": "标签", + "required": "必填", + "required-error-message": "错误信息“必填”", + "show-result-message": "显示结果信息", + "integer-field-settings": "Integer字段设置", + "min-value": "最大值", + "max-value": "最小值", + "double-field-settings": "Double字段设置", + "text-field-settings": "Text字段设置", + "min-length": "最小长度", + "max-length": "最大长度", + "checkbox-settings": "Checkbox设置", + "true-label": "Checked标签", + "false-label": "Unchecked标签", + "image-input-settings": "Image字段设置", + "display-preview": "显示预览", + "display-clear-button": "显示清除按钮", + "display-apply-button": "显示应用按钮", + "display-discard-button": "显示弃用按钮", + "datetime-field-settings": "Date/time字段设置", + "display-time-input": "显示time输入", + "latitude-key-name": "Latitude键名", + "longitude-key-name": "Longitude键名", + "show-get-location-button": "显示“获取当前位置”按钮", + "use-high-accuracy": "使用高精度", + "location-fields-settings": "位置设置", + "latitude-label": "latitude标签", + "longitude-label": "longitude标签", + "input-fields-alignment": "对齐方式", + "input-fields-alignment-column": "列(默认)", + "input-fields-alignment-row": "行", + "latitude-field-required": "Latitude字段必填", + "longitude-field-required": "Longitude字段必填", + "attribute-settings": "属性设置", + "widget-mode": "部件模式", + "widget-mode-update-attribute": "更新属性", + "widget-mode-update-timeseries": "更新时间序列", + "attribute-scope": "属性范围", + "attribute-scope-server": "服务端属性", + "attribute-scope-shared": "共享属性", + "value-required": "启用数值必填", + "image-settings": "图片设置", + "image-format": "图片格式", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "图片压缩", + "max-image-width": "最大宽度", + "max-image-height": "最大高度", + "action-buttons": "动作按钮", + "show-action-buttons": "显示动作按钮", + "update-all-values": "更新所有值", + "save-button-label": "“保存”按钮标签", + "reset-button-label": "“撤销”按钮标签", + "group-settings": "分组设置", + "show-group-title": "显示分组标题", + "group-title": "分组标题", + "fields-alignment": "对齐字段", + "fields-alignment-row": "行(默认)", + "fields-alignment-column": "列", + "fields-in-row": "行中的字段数", + "option-value": "值(为创建空选项编写“null”)", + "option-label": "标签", + "hide-input-field": "隐藏输入字段", + "datakey-type": "Datakey类型", + "datakey-type-server": "服务端属性(默认)", + "datakey-type-shared": "共享属性", + "datakey-type-timeseries": "时间序列", + "datakey-value-type": "Datakey值类型", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Integer", + "datakey-value-type-boolean-checkbox": "Boolean(多选)", + "datakey-value-type-boolean-switch": "Boolean(开关)", + "datakey-value-type-date-time": "Date & Time", + "datakey-value-type-date": "Date", + "datakey-value-type-time": "Time", + "datakey-value-type-select": "Select", + "value-is-required": "值是必填", + "ability-to-edit-attribute": "编辑属性的能力", + "ability-to-edit-attribute-editable": "可编辑(默认)", + "ability-to-edit-attribute-disabled": "禁用", + "ability-to-edit-attribute-readonly": "只读", + "disable-on-datakey-name": "禁用另一个datakey的错误值(指定datakey名称)", + "slide-toggle-settings": "滑块设置", + "slide-toggle-label-position": "滑动标签位置", + "slide-toggle-label-position-after": "后面", + "slide-toggle-label-position-before": "前面", + "select-options": "下拉选项", + "no-select-options": "没有配置下拉选项", + "add-select-option": "添加下接选项", + "numeric-field-settings": "数字字段设置", + "step-interval": "步长间隔", + "error-messages": "错误信息", + "min-value-error-message": "“最小值”错误信息", + "max-value-error-message": "“最大值”错误信息", + "invalid-date-error-message": "“无效日期”错误消息", + "icon-settings": "图标设置", + "use-custom-icon": "自定义图标", + "input-cell-icon": "输入单元格之前显示的图标", + "value-conversion-settings": "值转换设置", + "get-value-settings": "获取值设置", + "use-get-value-function": "启用获取值函数", + "get-value-function": "获取值函数", + "set-value-settings": "设置值设置", + "use-set-value-function": "启用值值函数", + "set-value-function": "启用函数" + }, + "invalid-qr-code-text": "无效的二维码文本。", + "qr-code": { + "use-qr-code-text-function": "启用二维码文本函数", + "qr-code-text-pattern": "二维码文本模式(例如:${entityName}|${keyName}一些文本。')", + "qr-code-text-pattern-hint": "二维码文本模式使用实体别名中第一个找到的键的值。", + "qr-code-text-pattern-required": "二维码文本必填。", + "qr-code-text-function": "二维码文本函数" + }, + "label-widget": { + "label-pattern": "模式", + "label-pattern-hint": "提示文本,如:'${keyName}单位。'或${#<key index>}单位'", + "label-pattern-required": "模式必填", + "label-position": "相对位置", + "x-pos": "X", + "y-pos": "Y", + "background-color": "背景色", + "font-settings": "字体设置", + "background-image": "背景图片", + "labels": "标签", + "no-labels": "没有配置标签", + "add-label": "添加标签" + }, + "navigation": { + "title": "标题", + "navigation-path": "导航路径", + "filter-type": "过滤类型", + "filter-type-all": "所有内容", + "filter-type-include": "包含内容", + "filter-type-exclude": "排除内容", + "items": "内容", + "enter-urls-to-filter": "输入URL进行过滤..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "消息类型", + "method": "方法", + "params": "参数", + "created-time": "创建时间", + "expiration-time": "到期时间", + "retries": "重试", + "status": "状态", + "filter": "过滤", + "refresh": "刷新", + "add": "添加RPC请求", + "details": "详情", + "delete": "删除", + "delete-request-title": "删除持续化RPC请求", + "delete-request-text": "您确定要删除请求吗?", + "details-title": "详情RPC ID: ", + "additional-info": "附加信息", + "response": "响应", + "any-status": "任何状态", + "rpc-status-list": "RPC状态列表", + "no-request-prompt": "无显示请求", + "send-request": "发送请求", + "add-title": "创建持续化RPC请求", + "method-error": "方法名必填。", + "timeout-error": "最小超时值为5000(5秒)。", + "white-space-error": "不允许使用空白。", + "rpc-status": { + "QUEUED": "QUEUED", + "SENT": "SENT", + "DELIVERED": "DELIVERED", + "SUCCESSFUL": "SUCCESSFUL", + "TIMEOUT": "TIMEOUT", + "EXPIRED": "EXPIRED", + "FAILED": "FAILED" + }, + "rpc-search-status-all": "所有", + "message-types": { + "false": "Two-way", + "true": "One-way" + }, + "general-settings": "基础设置", + "enable-filter": "启用过滤", + "enable-sticky-header": "滚动时显示标头", + "enable-sticky-action": "滚动时显示动作列", + "display-request-details": "显示请求详情", + "allow-send-request": "允许发送RPC请求", + "allow-delete-request": "允许删除请求", + "columns-settings": "设置列", + "display-columns": "显示列", + "column": "列", + "no-columns-found": "没有配置列", + "no-columns-matching": "'{{column}}'找不到。" + }, + "rpc": { + "value-settings": "设置值", + "initial-value": "初始值", + "retrieve-value-settings": "恢复开/关设置", + "retrieve-value-method": "恢复方法", + "retrieve-value-method-none": "无需恢复", + "retrieve-value-method-rpc": "RPC方法恢复", + "retrieve-value-method-attribute": "订阅属性", + "retrieve-value-method-timeseries": "订阅时间序列", + "attribute-value-key": "属性key", + "timeseries-value-key": "时序key", + "get-value-method": "RPC获取方法", + "parse-value-function": "解析函数", + "update-value-settings": "更新值设置", + "set-value-method": "RPC获取方法", + "convert-value-function": "转换函数", + "rpc-settings": "RPC设置", + "request-timeout": "RPC请求超(毫秒)", + "persistent-rpc-settings": "持久化RPC设置", + "request-persistent": "RPC请求持久化", + "persistent-polling-interval": " RPC持久化轮询间隔(毫秒)", + "common-settings": "通用设置", + "switch-title": "开关标题", + "show-on-off-labels": "显示开/关", + "slide-toggle-label": "滑块切换标签", + "label-position": "标签位置", + "label-position-before": "前面", + "label-position-after": "后面", + "slider-color": "滑块颜色", + "slider-color-primary": "Primary", + "slider-color-accent": "Accent", + "slider-color-warn": "Warn", + "button-style": "按钮样式", + "button-raised": "按钮凸起", + "button-primary": "原色", + "button-background-color": "背景色", + "button-text-color": "文字颜色", + "widget-title": "部件标题", + "button-label": "按钮标签", + "device-attribute-scope": "设备属性范围", + "server-attribute": "服务端属性", + "shared-attribute": "共享属性", + "device-attribute-parameters": "设备属性参数", + "is-one-way-command": "是否单向命令", + "rpc-method": "RPC方法", + "rpc-method-params": "RPC参数", + "show-rpc-error": "显示RPC命令执行错误", + "led-title": "LED标题", + "led-color": "LED颜色", + "check-status-settings": "检查状态设置", + "perform-rpc-status-check": "执行RPC设备状态检查", + "retrieve-led-status-value-method": "使用方法状态值恢复LED", + "led-status-value-attribute": "设备属性包含LED状态值", + "led-status-value-timeseries": "设备时间序列包含LED状态值", + "check-status-method": "RPC设备状态检查方法", + "parse-led-status-value-function": "解析函数", + "knob-title": "旋转按钮", + "min-value": "最小值", + "max-value": "最大值" + }, + "maps": { + "select-entity": "选择实体", + "select-entity-hint": "提示:选择后单击地图以设置位置", + "tooltips": { + "placeMarker": "单击放置'{{entityName}}'实体", + "firstVertex": "单击放置多边形'{{entityName}}'的第一个点", + "firstVertex-cut": "单击放置第一个点", + "continueLine": "单击继续绘制'{{entityName}}'多边形", + "continueLine-cut": "单击继续绘制", + "finishLine": "单击任何现有标记完成", + "finishPoly": "单击第一个标记完成'{{entityName}}'并保存", + "finishPoly-cut": "单击第一个标记完成并保存", + "finishRect": "单击'{{entityName}}'多边形完成并保存", + "startCircle": "单击'{{entityName}}'放置圆圈中心", + "finishCircle": "单击'{{entityName}}'完成圆圈", + "placeCircleMarker": "单击放置圆形标记" + }, + "actions": { + "finish": "完成", + "cancel": "取消", + "removeLastVertex": "删除最后一点" + }, + "buttonTitles": { + "drawMarkerButton": "放置实体", + "drawPolyButton": "创建多边形", + "drawLineButton": "创建多边形线条", + "drawCircleButton": "创建圆形", + "drawRectButton": "创建矩形", + "editButton": "编辑模式", + "dragButton": "拖放模式", + "cutButton": "剪切多边形区域", + "deleteButton": "移除", + "drawCircleMarkerButton": "创建圆形标志", + "rotateButton": "旋转多边形" + }, + "map-provider-settings": "地图设置", + "map-provider": "提供程序", + "map-provider-google": "Google地图", + "map-provider-openstreet": "OpenStreet地图", + "map-provider-here": "HERE地图", + "map-provider-image": "Image地图", + "map-provider-tencent": "Tencent地图", + "openstreet-provider": "OpenStreet地图提供程序", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik(默认)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "自定义提供程序", + "custom-provider-tile-url": "提供程序URL", + "google-maps-api-key": "Google地图Key", + "default-map-type": "默认地图类型", + "google-map-type-roadmap": "路线地图", + "google-map-type-satelite": "卫星", + "google-map-type-hybrid": "混合", + "google-map-type-terrain": "街道", + "map-layer": "地图", + "here-map-normal-day": "HERE.normalDay(默认)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "证书", + "here-app-id": "HERE地图id", + "here-app-code": "HERE地图code", + "tencent-maps-api-key": "Tencent地图Key", + "tencent-map-type-roadmap": "路线地图", + "tencent-map-type-satelite": "卫星", + "tencent-map-type-hybrid": "混合", + "image-map-background": "地图北景", + "image-map-background-from-entity-attribute": "从实体属性中获取背景图", + "image-url-source-entity-alias": "图像URL源实体别名", + "image-url-source-entity-attribute": "图像URL源实体属性", + "common-map-settings": "通用设置", + "x-pos-key-name": "X位置键名", + "y-pos-key-name": "Y位置键名", + "latitude-key-name": "Latitude键名", + "longitude-key-name": "Longitude键名", + "default-map-zoom-level": "地图缩放级别(1-20)", + "default-map-center-position": "地图中心位置(0,0)", + "disable-scroll-zooming": "地图中心位置", + "disable-double-click-zooming": "禁用双击缩放", + "disable-zoom-control-buttons": "禁用缩放按钮", + "fit-map-bounds": "拟合地图标记", + "use-default-map-center-position": "地图默认中心位置", + "entities-limit": "加载实体限制", + "markers-settings": "标记设置", + "marker-offset-x": "标记X宽度偏移量", + "marker-offset-y": "标记Y高度偏移量", + "position-function": "位置转换函数应返回x,y坐标", + "draggable-marker": "拖动标记", + "label": "标签设置", + "show-label": "显示标签", + "use-label-function": "标签函数", + "label-pattern": "标签模式(例如:'${entityName}', '${entityName}: (文本${keyName}单位)' )", + "label-function": "标签函数", + "tooltip": "提示栏设置", + "show-tooltip": "显示提示栏", + "show-tooltip-action": "提示拦动作", + "show-tooltip-action-click": "单击显示(默认)", + "show-tooltip-action-hover": "悬停显示", + "auto-close-tooltips": "自动关闭", + "use-tooltip-function": "启用函数", + "tooltip-pattern": "提示栏(例如:'文本${keyName}单位。'或 Link text')", + "tooltip-function": "提示栏函数", + "tooltip-offset-x": "提示栏x标记宽度偏移量", + "tooltip-offset-y": "提示栏y标记高度偏移量", + "color": "颜色设置", + "use-color-function": "启用函数", + "color-function": "颜色函数", + "marker-image": "图片标记设置", + "use-marker-image-function": "启用函数", + "custom-marker-image": "自定义图片标记", + "custom-marker-image-size": "自定义图片标记大小(像素)", + "marker-image-function": "自定义图片标记函数", + "marker-images": "图片标记", + "polygon-settings": "多边形设置", + "show-polygon": "启用多边形", + "polygon-key-name": "多边形键名", + "enable-polygon-edit": "启用编辑", + "polygon-label": "标签", + "show-polygon-label": "启用标签", + "use-polygon-label-function": "启用函数", + "polygon-label-pattern": "标签模式(例如: '${entityName}', '${entityName}:(文本${keyName}单位)' )", + "polygon-label-function": "标签函数", + "polygon-tooltip": "提示栏", + "show-polygon-tooltip": "启用提示栏", + "auto-close-polygon-tooltips": "自动关闭", + "use-polygon-tooltip-function": "启用函数", + "polygon-tooltip-pattern": "提示栏(例如:'文本${keyName}单位'或Link text')", + "polygon-tooltip-function": "提示栏函数", + "polygon-color": "颜色", + "polygon-opacity": "透明度”", + "use-polygon-color-function": "启用函数", + "polygon-color-function": "颜色函数", + "polygon-stroke": "斜线", + "stroke-color": "斜线颜色", + "stroke-opacity": "透明度", + "stroke-weight": "宽度", + "use-polygon-stroke-color-function": "启用函数", + "polygon-stroke-color-function": "颜色函数", + "circle-settings": "圆形设置", + "show-circle": "启用圆形", + "circle-key-name": "圆形键名", + "enable-circle-edit": "启用编辑", + "circle-label": "标签", + "show-circle-label": "启用标签", + "use-circle-label-function": "启用函数", + "circle-label-pattern": "标签模式(例如:'${entityName}', '${entityName}:(文本${keyName}单位)')", + "circle-label-function": "标签函数", + "circle-tooltip": "提示栏", + "show-circle-tooltip": "显示提示栏", + "auto-close-circle-tooltips": "自动关闭", + "use-circle-tooltip-function": "启用函数", + "circle-tooltip-pattern": "提示栏(例如:'文本${keyName}单位。'或Link text')", + "circle-tooltip-function": "提示栏函数", + "circle-fill-color": "填充色", + "circle-fill-color-opacity": "透明度", + "use-circle-fill-color-function": "启用函数", + "circle-fill-color-function": "填充颜色函数", + "circle-stroke": "斜线设置", + "use-circle-stroke-color-function": "启用函数", + "circle-stroke-color-function": "颜色函数", + "markers-clustering-settings": "标记集群设置", + "use-map-markers-clustering": "启用地图标记集群", + "zoom-on-cluster-click": "单击群集时变焦", + "max-cluster-zoom": "集群最大的缩放比例(0-18)", + "max-cluster-radius-pixels": "最大半径", + "cluster-zoom-animation": "放大动画", + "show-markers-bounds-on-cluster-mouse-over": "显示鼠标在群集上时显示标记的边界", + "spiderfy-max-zoom-level": "最大缩放比例(查看所有群集标记)", + "load-optimization": "负载优化", + "cluster-chunked-loading": "块加载集群标记", + "cluster-markers-lazy-load": "延迟加载集群标记", + "editor-settings": "编辑设置", + "enable-snapping": "启用顶点捕捉", + "init-draggable-mode": "启用拖动模式初始化地图", + "hide-all-edit-buttons": "隐藏所有按钮", + "hide-draw-buttons": "隐藏绘制按钮", + "hide-edit-buttons": "隐藏编辑按钮", + "hide-remove-button": "隐藏删除按钮", + "route-map-settings": "路由设置", + "trip-animation-settings": "旅行动画设置", + "normalization-step": "正常数据步长(ms)", + "tooltip-background-color": "工具提示背景颜色", + "tooltip-font-color": "工具提示字体颜色", + "tooltip-opacity": "工具提示不透明度(0-1)", + "auto-close-tooltip": "自动关闭工具提示", + "rotation-angle": "设置标记的附加旋转角度(度)", + "path-settings": "路径设置", + "path-color": "路径颜色", + "use-path-color-function": "使用路径颜色功能", + "path-color-function": "路径颜色功能", + "path-decorator": "路径装饰器", + "use-path-decorator": "使用路径装饰器", + "decorator-symbol": "装饰符号", + "decorator-symbol-arrow-head": "箭头", + "decorator-symbol-dash": "短跑", + "decorator-symbol-size": "装饰器符号尺寸(像素)", + "use-path-decorator-custom-color": "使用路径装饰器自定义颜色", + "decorator-custom-color": "装饰器定制颜色", + "decorator-offset": "装饰器偏移", + "end-decorator-offset": "结束装饰器偏移", + "decorator-repeat": "装饰器重复", + "points-settings": "点设置", + "show-points": "显示点", + "point-color": "点颜色", + "point-size": "点大小(像素)", + "use-point-color-function": "使用点颜色功能", + "point-color-function": "点颜色函数", + "use-point-as-anchor": "使用点作为锚", + "point-as-anchor-function": "点为锚函数", + "independent-point-tooltip": "独立点工具提示", + "clustering-markers": "集群标记", + "use-icon-create-function": "启用集群标记", + "marker-color-function": "标记颜色函数" + }, + "markdown": { + "use-markdown-text-function": "使用markdown/HTML函数", + "markdown-text-function": "Markdown/HTML函数", + "markdown-text-pattern": "Markdown/HTML模式(markdown或HTML变量, 例如:'${entityName}或${keyName}一些文本')", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label-position": "标签位置", + "label-position-left": "左", + "label-position-top": "上" + }, + "table": { + "common-table-settings": "表格设置", + "enable-search": "启用搜索", + "enable-sticky-header": "始终显示标题", + "enable-sticky-action": "始终显示动作", + "hidden-cell-button-display-mode": "隐藏单元按钮操作显示模式", + "show-empty-space-hidden-action": "显示空空间而不是隐藏的单元按钮动作", + "dont-reserve-space-hidden-action": "不要为隐藏动作按钮保留空间", + "display-timestamp": "显示时间", + "display-milliseconds": "显示毫秒", + "display-pagination": "显示分页", + "default-page-size": "分页数量", + "use-entity-label-tab-name": "实体标签名称", + "hide-empty-lines": "隐藏空线", + "row-style": "行样式", + "use-row-style-function": "启用样式函数", + "row-style-function": "样式函数", + "cell-style": "列样式", + "use-cell-style-function": "启用样式函数", + "cell-style-function": "样式函数", + "cell-content": "内容", + "use-cell-content-function": "启用内容函数", + "cell-content-function": "内容函数", + "show-latest-data-column": "显示最新数据列", + "latest-data-column-order": "样式函数", + "entities-table-title": "实体表格标题", + "enable-select-column-display": "启用数据列", + "display-entity-name": "显示实体名称", + "entity-name-column-title": "实体名称列标题", + "display-entity-label": "显示实体标签", + "entity-label-column-title": "实体标签列标题", + "display-entity-type": "显示实体类型", + "default-sort-order": "默认排序", + "custom-title": "Custom header title", + "column-width": "列宽", + "default-column-visibility": "默认显示", + "column-visibility-visible": "显示", + "column-visibility-hidden": "隐藏", + "column-visibility-hidden-mobile": "Hidden in mobile mode", + "column-selection-to-display": "“要显示”列中的列选择", + "column-selection-to-display-enabled": "启用", + "column-selection-to-display-disabled": "禁用", + "alarms-table-title": "警报标题", + "enable-alarms-selection": "启用警报选择", + "enable-alarms-search": "启用警报搜索", + "enable-alarm-filter": "启用警报过滤器", + "display-alarm-details": "显示警报详细", + "allow-alarms-ack": "允许确认警报", + "allow-alarms-clear": "允许清除警报" + }, + "value-source": { + "value-source": "数值源", + "predefined-value": "预定义值", + "entity-attribute": "从实体属性中获取值", + "value": "数据值", + "source-entity-alias": "实体别", + "source-entity-attribute": "实体属性" + }, + "widget-font": { + "font-family": "字体", + "size": "大小", + "relative-font-size": "相对字体大小", + "font-style": "样式", + "font-style-normal": "常规", + "font-style-italic": "斜体", + "font-style-oblique": "倾斜", + "font-weight": "字形", + "font-weight-normal": "常规", + "font-weight-bold": "加粗", + "font-weight-bolder": "相对加粗", + "font-weight-lighter": "相对常规", + "color": "颜色", + "shadow-color": "阴影颜色" + } + }, + "icon": { + "icon": "图标", + "select-icon": "选择图标", + "material-icons": "素材图标", + "show-all": "显示所有图标" + }, + "phone-input": { + "phone-input-label": "手机号码", + "phone-input-required": "手机号码必填", + "phone-input-validation": "手机号码无效或不存在", + "phone-input-pattern": "无效的手机号码。应为E.164格式,例如:{{phoneNumber}}", + "phone-input-hint": "E.164格式手机号码,例如:{{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "单元格click", + "row-click": "数据行click", + "polygon-click": "多边形click", + "marker-click": "标记click", + "circle-click": "圆形click", + "tooltip-tag-action": "提示框click", + "node-selected": "节点选中click", + "element-click": "HTML元素click", + "pie-slice-click": " 饼状click", + "row-double-click": "双击clcik" + } + }, + "paginator": { + "items-per-page": "每页数量:", + "first-page-label": "第一页", + "last-page-label": "最后一页", + "next-page-label": "下一页", + "previous-page-label": "上一页", + "items-per-page-separator": "-" + }, + "language": { + "language": "语言", + "locales": { + "de_DE": "德语", + "fr_FR": "法语", + "zh_CN": "简体中文", + "zh_TW": "繁體中文", + "en_US": "英语", + "it_IT": "意大利语", + "ko_KR": "韩语", + "ru_RU": "俄罗斯语", + "es_ES": "西班牙语", + "es_CA": "Catalan", + "ja_JP": "日本語", + "tr_TR": "土耳其语", + "fa_IR": "波斯语", + "uk_UA": "乌克兰语", + "cs_CZ": "捷克语", + "el_GR": "希腊语", + "ro_RO": "罗马尼亚语", + "lv_LV": "拉脱维亚语", + "ka_GE": "格鲁吉亚语", + "pt_BR": "葡萄牙语", + "sl_SI": "斯洛文尼亚语" + } + } +} diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json new file mode 100644 index 0000000..e5a54a6 --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json @@ -0,0 +1,4618 @@ +{ + "access": { + "unauthorized": "未授權", + "unauthorized-access": "未授權存取", + "unauthorized-access-text": "您需要登入才能存取這個資源!", + "access-forbidden": "禁止存取", + "access-forbidden-text": "您沒有存取此位置的權限
    如果您仍希望存取此位置,請嘗試使用其他用戶登入。", + "refresh-token-expired": "Session 已過期", + "refresh-token-failed": "無法更新 Session", + "permission-denied": "許可權被拒絕", + "permission-denied-text": "您沒有執行此操作的許可權!" + }, + "action": { + "activate": "啟動", + "suspend": "暫停", + "save": "儲存", + "saveAs": "另存為", + "cancel": "取消", + "ok": "確定", + "delete": "刪除", + "add": "增加", + "yes": "是", + "no": "否", + "update": "更新", + "remove": "移除", + "select": "選擇", + "search": "查詢", + "clear-search": "清除查詢", + "assign": "分配", + "unassign": "取消分配", + "share": "分享", + "make-private": "私有", + "apply": "應用", + "apply-changes": "應用更改", + "edit-mode": "編輯模式", + "enter-edit-mode": "進入編輯模式", + "decline-changes": "取消更改", + "close": "關閉", + "back": "返回", + "run": "執行", + "sign-in": "登入!", + "edit": "編輯", + "view": "查看", + "create": "建立", + "drag": "拖拉", + "refresh": "更新", + "undo": "取消", + "copy": "複製", + "paste": "貼上", + "copy-reference": "複製引用", + "paste-reference": "貼上引用", + "import": "匯入", + "export": "匯出", + "share-via": "通過 {{provider}}分享", + "continue": "繼續", + "discard-changes": "放棄更改", + "download": "下載", + "next-with-label": "下一步: {{label}}", + "read-more": "讀更多", + "hide": "隱藏", + "done": "完成", + "print": "列印", + "restore": "恢復", + "confirm": "確認" + }, + "aggregation": { + "aggregation": "聚合", + "function": "資料聚合功能", + "limit": "最大值", + "group-interval": "分組間隔", + "min": "最少值", + "max": "最大值", + "avg": "平均值", + "sum": "總計", + "count": "計數", + "none": "空" + }, + "admin": { + "general": "一般", + "general-settings": "一般設定", + "home-settings": "主頁設訂", + "outgoing-mail": "發送郵件", + "outgoing-mail-settings": "發送郵件設定", + "system-settings": "系統設定", + "test-mail-sent": "測試郵件發送成功!", + "base-url": "基本URL", + "base-url-required": "基本URL必填。", + "prohibit-different-url": "禁止使用用戶端請求標頭中的主機名", + "prohibit-different-url-hint": "應為生產環境啟用此設定。禁用時可能會導致安全問題", + "mail-from": "郵件來自", + "mail-from-required": "郵件發件人必填。", + "smtp-protocol": "SMTP協定", + "smtp-host": "SMTP主機", + "smtp-host-required": "SMTP主機必填。", + "smtp-port": "SMTP連接埠", + "smtp-port-required": "您必須提供一個smtp連接埠。", + "smtp-port-invalid": "這看起來不是有效的smtp連接埠。", + "timeout-msec": "超時(ms)", + "timeout-required": "超時必填。", + "timeout-invalid": "這看起來不像有效的超時值。", + "enable-tls": "啟用TLS", + "tls-version": "TLS版本", + "enable-proxy": "啟用proxy", + "proxy-host": "Proxy主機", + "proxy-host-required": "需要Proxy主機。", + "proxy-port": "Proxy通訊埠", + "proxy-port-required": "需要Proxy通訊埠。", + "proxy-port-range": "Proxy通訊埠應在1到65535的範圍內。", + "proxy-user": "Proxy用戶", + "proxy-password": "Proxy密碼d", + "change-password": "修改密碼", + "send-test-mail": "發送測試郵件", + "sms-provider": "SMS提供者", + "sms-provider-settings": "SMS提供者設定", + "sms-provider-type": "SMS提供者類型", + "sms-provider-type-required": "SMS提供者形態為必填", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS存取金鑰ID", + "aws-access-key-id-required": "AWS存取金鑰ID為必填", + "aws-secret-access-key": "AWS私密存取金鑰ID", + "aws-secret-access-key-required": "AWS私密存取金鑰ID為必填", + "aws-region": "AWS地區", + "aws-region-required": "AWS地區為必填", + "number-from": "電話號碼來自", + "number-from-required": "電話號碼來自為必填", + "number-to": "電話號碼發送至", + "number-to-required": "電話號碼發送至為必填", + "phone-number-hint": "E.164格式的電話號碼,例如+19995550123", + "phone-number-hint-twilio": "E.164格式的電話號碼/電話號碼的SID/訊息服務SID,例如+19995550123/PNXXX/MGXXX", + "phone-number-pattern": "電話號碼無效。應採用E.164格式,例如+19995550123。", + "phone-number-pattern-twilio": "電話號碼無效。應採用E.164格式/電話號碼的SID/訊息服務SID,例如+19995550123/PNXXX/MGXXX。", + "sms-message": "SMS簡訊", + "sms-message-required": "SMS簡訊為必填", + "sms-message-max-length": "SMS簡訊不能超過1600個字元", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID為必填", + "twilio-account-token": "Twilio Account token", + "twilio-account-token-required": "Twilio Account token為必填", + "send-test-sms": "發送測試SMS", + "test-sms-sent": "測試SMS已成功發送!", + "security-settings": "安全設定", + "password-policy": "密碼政策", + "minimum-password-length": "密碼長度下限", + "minimum-password-length-required": "密碼長度下限為必填", + "minimum-password-length-range": "密碼長度下限範圍值為5-50", + "minimum-uppercase-letters": "大寫字母數下限", + "minimum-uppercase-letters-range": "大寫字母數下限不可為否", + "minimum-lowercase-letters": "小寫字母數下限", + "minimum-lowercase-letters-range": "小寫字母數下限,不可為否", + "minimum-digits": "數字數下限", + "minimum-digits-range": "數字數下限不能為否", + "minimum-special-characters": "特殊字元數下限", + "minimum-special-characters-range": "特殊字元數下限,不可為否", + "password-expiration-period-days": "密碼失效期限,以天為單位", + "password-expiration-period-days-range": "密碼失效期限,以天為單位,不可為否", + "password-reuse-frequency-days": "密碼重用頻率天數", + "password-reuse-frequency-days-range": "密碼重用頻率天數,不可為否", + "allow-whitespace": "允許空白字元", + "general-policy": "通用政策", + "max-failed-login-attempts": "帳號上鎖前的最多嘗試登入錯誤次數", + "minimum-max-failed-login-attempts-range": "最多嘗試登入錯誤次數,不可為否", + "user-lockout-notification-email": "避免帳號被鎖,寄送email通知", + "domain-name": "網域名稱", + "domain-name-unique": "網域名稱與通訊協定需是唯一值", + "domain-name-max-length": "網域名稱需少於256字元", + "error-verification-url": "網域名稱不可包含符號 '/' 及 ':' 例如:thingsboard.io", + "oauth2": { + "access-token-uri": "訪問token URI", + "access-token-uri-required": "訪問token為必填", + "activate-user": "啓動用戶", + "add-domain": "增加網域", + "delete-domain": "刪除網域", + "add-provider": "增加提供者", + "delete-provider": "刪除提供者", + "allow-user-creation": "允許用戶建立", + "always-fullscreen": "始終以全螢幕顯示", + "authorization-uri": "授權URI", + "authorization-uri-required": "授權URI為必填", + "client-authentication-method": "客戶端驗證方法", + "client-id": "客戶ID", + "client-id-required": "客戶ID為必填", + "client-id-max-length": "客戶ID長度需小於256", + "client-secret": "客戶密鑰", + "client-secret-required": "客戶密鑰為必填", + "client-secret-max-length": "客戶密鑰長度需小於2049", + "custom-setting": "客製化設定", + "customer-name-pattern": "顧客名稱模式", + "customer-name-pattern-max-length": "顧客名稱模式長度需小於256", + "default-dashboard-name": "預設儀表板名稱", + "default-dashboard-name-max-length": "預設儀表板名稱長度需小於256", + "delete-domain-text": "小心!確認後,此網域及所有提供者資料將被刪除,將無法恢復。", + "delete-domain-title": "您確定要刪除網域設定 '{{domainName}}'嗎?", + "delete-registration-text": "小心!確認後,提供者資料將被刪除,將無法恢復。", + "delete-registration-title": "您確定要刪除提供者 '{{name}}'?", + "email-attribute-key": "Email屬性key", + "email-attribute-key-required": "Email屬性key為必填", + "email-attribute-key-max-length": "Email屬性key長度需小於32", + "first-name-attribute-key": "名字屬性key", + "first-name-attribute-key-max-length": "名字屬性key長度需小於32", + "general": "General", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "姓氏屬性key", + "last-name-attribute-key-max-length": "姓氏屬性key長度需小於32", + "login-button-icon": "登入鍵圖示", + "login-button-label": "提供者標籤", + "login-button-label-placeholder": "使用$(Provider label)登入", + "login-button-label-required": "標籤為必填", + "login-provider": "登入提供者", + "mapper": "匹配器", + "new-domain": "新網域", + "oauth2": "OAuth2", + "password-max-length": "密碼需小於256", + "redirect-uri-template": "轉址URI格式", + "copy-redirect-uri": "複製轉址URI", + "registration-id": "註冊 ID", + "registration-id-required": "註冊ID為必填", + "registration-id-unique": "註冊ID需為系統中唯一值。", + "scope": "Scope", + "scope-required": "Scope為必填", + "tenant-name-pattern": "租戶名稱格式", + "tenant-name-pattern-required": "租戶名稱格式為必填", + "tenant-name-pattern-max-length": "租戶名稱格式長度需小於256", + "tenant-name-strategy": "租戶命名規則", + "type": "匹配器類型", + "uri-pattern-error": "無效URI格式", + "url": "URL", + "url-pattern": "無效URI格式", + "url-required": "URI為必填", + "url-max-length": "URI長度需小於256", + "user-info-uri": "用戶訊息URI", + "user-info-uri-required": "用戶訊息URI為必填", + "username-max-length": "用戶名稱長度需小於256", + "user-name-attribute-name": "用戶名稱屬性key", + "user-name-attribute-name-required": "用戶名稱屬性key為必填", + "protocol": "協定", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "啟用OAuth2設定", + "domains": "網域", + "mobile-apps": "手機應用程式", + "no-mobile-apps": "無應用程式設定", + "mobile-package": "應用套裝軟體", + "mobile-package-placeholder": "例如my.example.app", + "mobile-package-hint": "對於Android:您自己的唯一應用程序ID。對於iOS:產品Bundle ID", + "mobile-package-unique": "應用程式套件需為唯一值", + "mobile-app-secret": "應用程式密鑰", + "invalid-mobile-app-secret": "應用程式密鑰需包含字母數字字元,長度需介於16到2048之間。", + "copy-mobile-app-secret": "複製應用程式密鑰", + "add-mobile-app": "增加應用程式", + "delete-mobile-app": "刪除應用程式資訊", + "providers": "提供者", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "所有平台", + "allowed-platforms": "允許平台" + }, + "smpp-provider": { + "smpp-version": "SMPP版本", + "smpp-host": "SMPP主機t", + "smpp-host-required": "需要SMPP主機", + "smpp-port": "SMPP通訊埠", + "smpp-port-required": "需要SMPP通訊埠", + "system-id": "系統ID", + "system-id-required": "需要系統ID", + "password": "密碼", + "password-required": "需要密碼", + "type-settings": "類型設定", + "source-settings": "來源設定", + "destination-settings": "目的地設定", + "additional-settings": "額外設定", + "system-type": "系統類型", + "bind-type": "綁定類型", + "service-type": "服務類型", + "source-address": "來源地址", + "source-ton": "來源 TON", + "source-npi": "來源NPI", + "destination-ton": "目的地TON (號碼類型)", + "destination-npi": "目的地NPI (編號計畫標識)", + "address-range": "地址範圍", + "coding-scheme": "編碼方案", + "bind-type-tx": "傳輸者", + "bind-type-rx": "接收者", + "bind-type-trx": "收發者", + "ton-unknown": "不明", + "ton-international": "國際的", + "ton-national": "國家的", + "ton-network-specific": "網路專用的", + "ton-subscriber-number": "訂閱者數量", + "ton-alphanumeric": "字母數字的", + "ton-abbreviated": "簡稱", + "npi-unknown": "0 - 未知", + "npi-isdn": "1 - ISDN/電話編號計畫 (E163/E164)", + "npi-data-numbering-plan": "3 - 數據編號計畫 (X.121)", + "npi-telex-numbering-plan": "4 - 電傳編號計畫 (F.69)", + "npi-land-mobile": "6 - 陸地移動 (E.212)", + "npi-national-numbering-plan": "8 - 國家編號計畫", + "npi-private-numbering-plan": "9 - 專用編號計畫", + "npi-ermes-numbering-plan": "10 - ERMES編號計畫 (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - 網際網路 (IP)", + "npi-wap-client-id": "18 - WAP客戶端ID (由WAP論壇定義)", + "scheme-smsc": "0 - SMSC預設字母 (ASCII表示短程式碼和長程式碼,GSM表示免費)", + "scheme-ia5": "1 - IA5 (ASCII表示短程式碼和長程式碼,拉丁語9表示免費(ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - 八位元組未指定 (8位元二進制)", + "scheme-latin-1": "3 - 拉丁語1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - 八位元組未指定 (8位元二進制)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cyrillic (ISO-8859-5)", + "scheme-latin-hebrew": "7 - 拉丁語/希伯來語 (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - 編碼圖標", + "scheme-music-codes": "10 - 音樂程式碼(ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - 擴展漢字JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - 韓文圖形字元集 (KS C 5601/KS X 1001)" + }, + "queue-select-name": "選擇佇列名稱", + "queue-name": "佇列名稱", + "queue-name-required": "佇列名稱為必填!", + "queues": "佇列", + "queue-partitions": "分割", + "queue-submit-strategy": "提交策略", + "queue-processing-strategy": "程序策略", + "queue-configuration": "佇列設定", + "repository-settings": "儲存設定", + "repository-url": "儲存URL", + "repository-url-required": "儲存URL為必填。", + "default-branch": "預設分支名稱", + "authentication-settings": "驗證設定", + "auth-method": "驗證方法", + "auth-method-username-password": "密碼/訪問token", + "auth-method-private-key": "私鑰", + "password-access-token": "密碼/訪問token", + "change-password-access-token": "更改密碼/訪問token", + "private-key": "私鑰", + "drop-private-key-file-or": "拖拉私鑰檔案或", + "passphrase": "密語", + "enter-passphrase": "輸入密語", + "change-passphrase": "更改密語", + "check-access": "檢查訪問", + "check-repository-access-success": "儲存訪問驗證成功", + "delete-repository-settings-title": "您確定要刪除儲存設定嗎?", + "delete-repository-settings-text": "小心!確認後,儲存設定及其版本控制資料將被刪除,所有相關資料將無法恢復", + "auto-commit-settings": "自動提交設定", + "auto-commit-entities": "自動提交實體", + "no-auto-commit-entities-prompt": "沒有配置自動提交的實體", + "delete-auto-commit-settings-title": "您確定要刪除自動提交設定嗎?", + "delete-auto-commit-settings-text": "小心!確認後,自動提交設定將被刪除,實體配置自動提交功能也將被停用", + "2fa": { + "2fa": "雙重驗證", + "available-providers": "可用的供應商", + "issuer-name": "發行者名稱", + "issuer-name-required": "發行者名稱為必填。", + "max-verification-failures-before-user-lockout": "用戶被鎖定前的最多驗證錯誤次數", + "max-verification-failures-before-user-lockout-pattern": "用戶驗證錯誤次數必須為正整數", + "number-of-checking-attempts": "用戶驗證嘗試次數", + "number-of-checking-attempts-pattern": "用戶驗證嘗試次數必須為正整數", + "number-of-checking-attempts-required": "用戶驗證嘗試次數為必填", + "number-of-codes": "程式數量", + "number-of-codes-pattern": "程式數量必須為正整數", + "number-of-codes-required": "程式數量為必填", + "provider": "提供者", + "retry-verification-code-period": "輸入重試驗證碼時間(秒)", + "retry-verification-code-period-pattern": "輸入重試驗證碼時間最少5秒", + "retry-verification-code-period-required": "輸入重試驗證碼時間為必填", + "total-allowed-time-for-verification": "允許重試驗證碼時間總和(秒)", + "total-allowed-time-for-verification-pattern": "允許重試驗證碼時間總和最少60秒", + "total-allowed-time-for-verification-required": "允許重試驗證碼時間總和為必填", + "use-system-two-factor-auth-settings": "使用系統雙重驗證設定", + "verification-code-check-rate-limit": "驗證碼檢查率限制", + "verification-code-lifetime": "驗證碼使用期限(秒)", + "verification-code-lifetime-pattern": "驗證碼使用期限必須為正整數", + "verification-code-lifetime-required": "驗證碼使用期限為必填。", + "verification-message-template": "驗證訊息模板", + "verification-limitations": "驗證條件", + "verification-message-template-pattern": "驗證條件必要模式: ${code}", + "verification-message-template-required": "驗證條件必要模式為必填", + "within-time": "時間內 (秒)", + "within-time-pattern": "時間必須為正整數", + "within-time-required": "時間為必填" + } + }, + "alias": { + "add": "增加別名", + "edit": "編輯別名", + "name": "別名", + "name-required": "別名必填", + "duplicate-alias": "別名已經存在。", + "filter-type-single-entity": "單一實體", + "filter-type-entity-list": "實體列表", + "filter-type-entity-name": "實體名稱", + "filter-type-entity-type": "實體類型", + "filter-type-state-entity": "實體(儀表板狀態)", + "filter-type-state-entity-description": "實體taken(儀表板狀態參數)", + "filter-type-asset-type": "資產類型", + "filter-type-asset-type-description": "類型為 '{{assetType}}' 的資產", + "filter-type-asset-type-and-name-description": "類型為 '{{assetType}}' 且以 '{{prefix}}' 開頭的資產", + "filter-type-device-type": "設備類型", + "filter-type-device-type-description": "類型為 '{{deviceType}}' 的設備", + "filter-type-device-type-and-name-description": "類型為 '{{deviceType}}' 且以 '{{prefix}}' 開頭的設備", + "filter-type-entity-view-type": "實體視圖類型", + "filter-type-entity-view-type-description": "類型為 '{{entityView}}' 的實體視圖", + "filter-type-entity-view-type-and-name-description": "類型為 {{entityView}}' 且以 '{{prefix}}' 開頭的實體視圖", + "filter-type-edge-type": "邊緣類型", + "filter-type-edge-type-description": " '類型為{{edgeType}}'的邊緣", + "filter-type-edge-type-and-name-description": "類型為 '{{edgeType}}' 且名稱以 '{{prefix}}'開頭的邊緣", + "filter-type-relations-query": "關聯查詢", + "filter-type-relations-query-description": "具有 {{relationType}} 關聯 {{direction}} {{rootEntity}} 的 {{entities}} ", + "filter-type-asset-search-query": "資產搜尋查詢", + "filter-type-asset-search-query-description": "類型為 {{assetTypes}} 且具有 {{relationType}} 關聯 {{direction}} {{rootEntity}} 的資產", + "filter-type-device-search-query": "設備搜尋查詢", + "filter-type-device-search-query-description": "類型為 {{deviceTypes}} 且具有 {{relationType}} 關聯 {{direction}} {{rootEntity}} 的設備", + "filter-type-entity-view-search-query": "實體視圖搜尋查詢", + "filter-type-entity-view-search-query-description": "類型為 {{entityViewTypes}} 且具有 {{relationType}} 關聯 {{direction}} {{rootEntity}} 的實體視圖", + "filter-type-apiUsageState": "api使用狀態", + "filter-type-edge-search-query": "邊緣搜尋查詢", + "filter-type-edge-search-query-description": "具有{{relationType}}關聯{{direction}} {{rootEntity}}類型為{{edgeTypes}}的邊緣", + "entity-filter": "實體過濾", + "resolve-multiple": "解決為多實體", + "filter-type": "過濾類型", + "filter-type-required": "過濾類型必填。", + "entity-filter-no-entity-matched": "未找到符合指定過濾條件的實體。", + "no-entity-filter-specified": "沒有指定實體過濾條件", + "root-state-entity": "使用儀表板狀態實體作為根實體", + "last-level-relation": "僅獲取最後一級關聯", + "root-entity": "根實體", + "state-entity-parameter-name": "狀態實體參數名稱", + "default-state-entity": "預設狀態實體", + "default-entity-parameter-name": "預設", + "max-relation-level": "最大關聯層級", + "unlimited-level": "不限層級", + "state-entity": "儀表板狀態實體", + "all-entities": "所有實體", + "any-relation": "不限" + }, + "asset": { + "asset": "資產", + "assets": "資產", + "management": "資產管理", + "view-assets": "查看資產", + "add": "增加資產", + "asset-type-max-length": "資產類型應小於256", + "assign-to-customer": "分配給客戶", + "assign-asset-to-customer": "將資產分配給客戶", + "assign-asset-to-customer-text": "請選擇要分配給客戶的資產", + "no-assets-text": "未找到資產", + "assign-to-customer-text": "請選擇客戶以分配資產", + "public": "公開", + "assignedToCustomer": "分配客戶", + "make-public": "資產設為公開", + "make-private": "資產設為私有", + "unassign-from-customer": "取消分配客戶", + "delete": "刪除資產", + "asset-public": "資產公開", + "asset-type": "資產類型", + "asset-type-required": "資產類型必填。", + "select-asset-type": "選擇資產類型", + "enter-asset-type": "輸入資產類型", + "any-asset": "任何資產", + "no-asset-types-matching": "沒有找到符合 '{{entitySubtype}}' 的資產類型。", + "asset-type-list-empty": "資產類型未選擇。", + "asset-types": "資產類型", + "name": "名稱", + "name-required": "名稱必填。", + "name-max-length": "名稱應小於256", + "label-max-length": "標籤應小於256", + "description": "描述", + "type": "類型", + "type-required": "類型必填。", + "details": "詳細資訊", + "events": "事件", + "add-asset-text": "增加新資產", + "asset-details": "資產詳細資訊", + "assign-assets": "分配資產", + "assign-assets-text": "分配 { count, plural, 1 {1 資產} other {# 資產} } 給客戶", + "assign-asset-to-edge-title": "將資產分配給邊緣", + "assign-asset-to-edge-text": "請選擇要分配給邊緣的資產", + "delete-assets": "刪除資產", + "unassign-assets": "取消分配資產", + "unassign-assets-action-title": "從客戶處取消分配 { count, plural, 1 {1 資產} other {# 資產} } ", + "assign-new-asset": "分配新資產", + "delete-asset-title": "確定要刪除資產 '{{assetName}}'?", + "delete-asset-text": "小心!確認後資產及其所有相關資料將無法恢復。", + "delete-assets-title": "確定要刪除 { count, plural, 1 {1 資產} other {# 資產} }?", + "delete-assets-action-title": "刪除 { count, plural, 1 {1 資產} other {# 資產} }", + "delete-assets-text": "小心,確認後,所有選擇的資產將被刪除,所有相關的資料將變得無法恢復。", + "make-public-asset-title": "你確定你想建立公開'{{assetName}}'資產?", + "make-public-asset-text": "確認後,資產及其所有資料將被公開並被他人存取。", + "make-private-asset-title": "你確定你想建立私有 '{{assetName}}' 資產?", + "make-private-asset-text": "確認後,資產及其所有資料將被私有化,無法被他人存取。", + "unassign-asset-title": "您確定要取消對'{{assetName}}'資產的分配嗎?", + "unassign-asset-text": "確認後,資產將未分配,客戶無法存取。", + "unassign-asset": "未分配資產", + "unassign-assets-title": "您確定要取消分配 { count, plural, 1 {1 資產} other {# 資產} }嗎?", + "unassign-assets-text": "確認後,所有選擇的資產將被分配,客戶無法存取。", + "unassign-assets-from-edge": "從邊緣取消分配資產", + "copyId": "複製資產ID", + "idCopiedMessage": "資產ID已經複製到剪貼簿", + "select-asset": "選擇資產", + "no-assets-matching": "沒有找到符合 '{{entity}}' 的資產。", + "asset-required": "資產必填", + "name-starts-with": "資產名稱以此開頭", + "help-text": "根據需要使用 '%':'%asset_name_contains%'、 '%asset_name_ends'、 'asset_starts_with'。", + "import": "匯入資產", + "asset-file": "資產文件", + "label": "標籤", + "search": "搜尋資產", + "assign-asset-to-edge": "將資產分配給邊緣", + "unassign-asset-from-edge": "取消分配資產", + "unassign-asset-from-edge-title": "您確定要取消分配資產'{{assetName}}'嗎?", + "unassign-asset-from-edge-text": "確認後,資產將被取消分配,邊緣將無法訪問。", + "unassign-assets-from-edge-title": "您確定要取消分配 { count, plural, 1 {1 資產} other {# 資產} }?", + "unassign-assets-from-edge-text": "確認後,所有選定的資產都將被取消分配,邊緣將無法訪問。.", + "selected-assets": "{ count, plural, 1 {1 資產} other {# 資產} }被選中" + }, + "attribute": { + "attributes": "屬性", + "latest-telemetry": "最新遙測", + "attributes-scope": "設備屬性範圍", + "scope-latest-telemetry": "最新遙測", + "scope-client": "客戶端屬性", + "scope-server": "服務端屬性", + "scope-shared": "共享屬性", + "add": "增加屬性", + "key": "鍵", + "key-max-length": "金鑰應小於256", + "last-update-time": "最後更新時間", + "key-required": "屬性鍵必填。", + "value": "值", + "value-required": "屬性值必填。", + "delete-attributes-title": "您確定要刪除 { count, plural, 1 {1 屬性} other {# 屬性} }嗎?", + "delete-attributes-text": "注意,確認後所有選中的屬性都會被刪除。", + "delete-attributes": "刪除屬性", + "enter-attribute-value": "輸入屬性值", + "show-on-widget": "在部件上顯示", + "widget-mode": "部件模式", + "next-widget": "下一個部件", + "prev-widget": "上一個部件", + "add-to-dashboard": "增加到儀表板", + "add-widget-to-dashboard": "將部件增加到儀表板", + "selected-attributes": "{ count, plural, 1 {1 屬性} other {# 屬性} } 被選中", + "selected-telemetry": "{ count, plural, 1 {1 遙測} other {# 遙測} }被選中", + "no-attributes-text": "找不到屬性", + "no-telemetry-text": "找不到遙測" + }, + "api-usage": { + "api-usage": "api使用狀態", + "alarm": "警告", + "alarms-created": "警告建立", + "alarms-created-daily-activity": "警告建立每日活動", + "alarms-created-hourly-activity": "警告建立每小時活動", + "alarms-created-monthly-activity": "警告建立每月活動", + "data-points": "數據端", + "data-points-storage-days": "數據端儲存天數", + "email": "Email", + "email-messages": "Email訊息", + "email-messages-daily-activity": "Email訊息每日活動", + "email-messages-monthly-activity": "Email訊息每月活動", + "exceptions": "例外", + "executions": "執行", + "javascript": "JavaScript", + "javascript-executions": "JavaScript執行", + "javascript-functions": "JavaScript功能", + "javascript-functions-daily-activity": "JavaScript功能每日活動", + "javascript-functions-hourly-activity": "JavaScript功能每小時活動", + "javascript-functions-monthly-activity": "JavaScript功能每月活動", + "latest-error": "最新錯誤", + "messages": "訊息", + "notifications": "通知", + "notifications-email-sms": "(Email/SMS)通知", + "notifications-hourly-activity": "通知每小時活動", + "permanent-failures": "${entityName}永久失敗", + "permanent-timeouts": "${entityName}永久超時", + "processing-failures": "${entityName} 處理失敗", + "processing-failures-and-timeouts": "處理失敗與超時", + "processing-timeouts": "${entityName} 處理超時", + "queue-stats": "佇列統計", + "rule-chain": "規則鏈", + "rule-engine": "規則引擎", + "rule-engine-daily-activity": "規則引擎每日活動", + "rule-engine-executions": "規則引擎執行", + "rule-engine-hourly-activity": "規則引擎每小時活動", + "rule-engine-monthly-activity": "規則引擎每月活動", + "rule-engine-statistics": "規則引擎統計", + "rule-node": "規則節點", + "sms": "SMS", + "sms-messages": "SMS訊息", + "sms-messages-daily-activity": "SMS訊息每日活動", + "sms-messages-monthly-activity": "SMS訊息每月活動", + "successful": "${entityName} 成功", + "telemetry": "遙測", + "telemetry-persistence": "遙測持久性", + "telemetry-persistence-daily-activity": "遙測持久性每日活動", + "telemetry-persistence-hourly-activity": "遙測持久性每小時活動", + "telemetry-persistence-monthly-activity": "遙測持久性每月活動", + "transport": "傳輸", + "transport-daily-activity": "傳輸每日活動", + "transport-data-points": "傳輸資料端", + "transport-hourly-activity": "傳輸每小時活動", + "transport-messages": "傳輸訊息", + "transport-monthly-activity": "傳輸每月活動", + "view-details": "檢視詳細資料", + "view-statistics": "檢視統計數據" + }, + "audit-log": { + "audit": "審計", + "audit-logs": "審計日誌", + "timestamp": "時間戳", + "entity-type": "實體類型", + "entity-name": "實體名稱", + "user": "用戶", + "type": "類型", + "status": "狀態", + "details": "詳細資訊", + "type-added": "增加", + "type-deleted": "刪除", + "type-updated": "更新", + "type-attributes-updated": "更新屬性", + "type-attributes-deleted": "刪除屬性", + "type-rpc-call": "RPC調用", + "type-credentials-updated": "更新憑據", + "type-assigned-to-customer": "分配給客戶", + "type-unassigned-from-customer": "未分配給客戶", + "type-assigned-to-edge": "分配給邊緣", + "type-unassigned-from-edge": "從邊緣取消分配", + "type-activated": "啟動", + "type-suspended": "暫停", + "type-credentials-read": "讀取憑據", + "type-attributes-read": "讀取屬性", + "type-relation-add-or-update": "關聯已更新", + "type-relation-delete": "關聯已刪除", + "type-relations-delete": "所有關聯已刪除", + "type-alarm-ack": "已確認", + "type-alarm-clear": "清除", + "type-login": "登入", + "type-logout": "登出", + "type-lockout": "登出", + "status-success": "成功", + "status-failure": "失敗", + "audit-log-details": "審計日誌詳細資訊", + "no-audit-logs-prompt": "找不到日誌", + "action-data": "活動資料", + "failure-details": "失敗詳細資訊", + "search": "查找審計日誌", + "clear-search": "清空查找", + "type-assigned-from-tenant": "從租戶分配", + "type-assigned-to-tenant": "分配給租戶", + "type-provision-success": "設備已配置", + "type-provision-failure": "設備配置失敗", + "type-timeseries-updated": "遙測已更新", + "type-timeseries-deleted": "遙測已刪除" + }, + "confirm-on-exit": { + "message": "您有未儲存的更改。確定要離開此頁嗎?", + "html-message": "您有未儲存的更改。
    確定要離開此頁面嗎?", + "title": "未儲存的更改" + }, + "contact": { + "country": "國家", + "city": "城市", + "state": "州", + "postal-code": "郵政編碼", + "postal-code-invalid": "只允許數字。", + "address": "地址", + "address2": "地址2", + "phone": "手機", + "email": "郵箱", + "no-address": "無地址", + "state-max-length": "狀態長度應小於256", + "phone-max-length": "電話號碼應小於256", + "city-max-length": "指定城市應小於256" + }, + "common": { + "username": "用戶名", + "password": "密碼", + "enter-username": "輸入用戶名", + "enter-password": "輸入密碼", + "enter-search": "輸入檢索條件", + "created-time": "建立時間", + "loading": "正在加載...", + "proceed": "繼續", + "open-details-page": "打開詳細資訊頁" + }, + "content-type": { + "json": "Json", + "text": "文字", + "binary": "二進制 (Base64)" + }, + "customer": { + "customer": "客戶", + "customers": "客戶", + "management": "客戶管理", + "dashboard": "客戶儀表板", + "dashboards": "客戶儀表板", + "devices": "客戶設備", + "entity-views": "客戶實體視圖", + "assets": "客戶資產", + "public-dashboards": "公共儀表板", + "public-devices": "公共設備", + "public-assets": "公共資產", + "public-entity-views": "公共實體視圖", + "add": "增加客戶", + "delete": "刪除客戶", + "manage-customer-users": "管理客戶用戶", + "manage-customer-devices": "管理客戶設備", + "manage-customer-dashboards": "管理客戶儀表板", + "manage-public-devices": "管理公共設備", + "manage-public-dashboards": "管理公共儀表板", + "manage-customer-assets": "管理客戶資產", + "manage-public-assets": "管理公共資產", + "manage-customer-edges": "管理客戶優勢", + "manage-public-edges": "管理客戶優勢", + "add-customer-text": "增加新客戶", + "no-customers-text": "沒有找到客戶", + "customer-details": "客戶詳細資訊", + "delete-customer-title": "您確定要刪除客戶'{{customerTitle}}'嗎?", + "delete-customer-text": "小心!確認後,客戶及其所有相關資料將無法恢復。", + "delete-customers-title": "您確定要刪除 { count, plural, 1 {1 客戶} other {# 客戶} }嗎?", + "delete-customers-action-title": "刪除 { count, plural, 1 {1 客戶} other {# 客戶} }", + "delete-customers-text": "小心!確認後,所有選擇的客戶將被刪除,所有相關資料將無法恢復。", + "manage-users": "管理用戶", + "manage-assets": "管理資產", + "manage-devices": "管理設備", + "manage-dashboards": "管理儀表板", + "title": "標題", + "title-required": "需要標題", + "title-max-length": "標題應小於256", + "description": "描述", + "details": "詳細資訊", + "events": "事件", + "copyId": "複製客戶ID", + "idCopiedMessage": "客戶ID已複製到剪貼板", + "select-customer": "選擇客戶", + "no-customers-matching": "沒有找到符合 '{{entity}}' 的客戶。", + "customer-required": "客戶是必選項", + "select-default-customer": "選擇預設的客戶", + "default-customer": "預設客戶", + "default-customer-required": "為了測試租戶級別上的儀表板,需要預設客戶。", + "search": "搜尋客戶", + "selected-customers": "{ count, plural, 1 {1 客戶} other {# 客戶} }被選中", + "edges": "客戶邊緣實體物件", + "manage-edges": "管理邊緣" + }, + "datetime": { + "date-from": "日期從", + "time-from": "時間從", + "date-to": "日期到", + "time-to": "時間到" + }, + "dashboard": { + "dashboard": "儀表板", + "dashboards": "儀表板庫", + "management": "儀表板管理", + "view-dashboards": "查看儀表板", + "add": "增加儀表板", + "assign-dashboard-to-customer": "將儀表板分配給客戶", + "assign-dashboard-to-customer-text": "請選擇要分配給客戶的儀表板", + "assign-dashboard-to-edge-title": "將儀表板分配給邊緣", + "assign-to-customer-text": "請選擇客戶分配儀表板", + "assign-to-customer": "分配給客戶", + "unassign-from-customer": "取消分配客戶", + "make-public": "儀表板設為公開", + "make-private": "儀表板設為私有", + "manage-assigned-customers": "管理已分配的客戶", + "assigned-customers": "已分配的客戶", + "assign-to-customers": "將儀表板分配給客戶", + "assign-to-customers-text": "請選擇客戶指定儀表板", + "unassign-from-customers": "客戶未分配儀表板", + "unassign-from-customers-text": "請選擇從儀表板中取消分配的客戶", + "no-dashboards-text": "沒有找到儀表板", + "no-widgets": "沒有配置部件", + "add-widget": "增加新的部件", + "title": "標題", + "image": "儀表板圖像", + "mobile-app-settings": "移動應用程序設定", + "mobile-order": "移動應用程序中的儀表板順序", + "mobile-hide": "在移動應用程序中隱藏儀表板", + "update-image": "更新儀表板圖像", + "take-screenshot": "截圖", + "select-widget-title": "選擇部件", + "select-widget-value": "{{title}}: 選擇小部件", + "select-widget-subtitle": "可用的部件類型列表", + "delete": "刪除儀表板", + "title-required": "需要標題。", + "title-max-length": "標題應小於256", + "description": "描述", + "details": "詳細資訊", + "dashboard-details": "儀表板詳細資訊", + "add-dashboard-text": "增加新的儀表板", + "assign-dashboards": "分配儀表板", + "assign-new-dashboard": "分配新的儀表板", + "assign-dashboards-text": "分配 { count, plural, 1 {1 儀表板} other {# 儀表板} } 給客戶", + "unassign-dashboards-action-text": "未分配 { count, plural, 1 {1 儀表板} other {# 儀表板} } 給客戶", + "delete-dashboards": "刪除儀表板", + "unassign-dashboards": "取消分配儀表板", + "unassign-dashboards-action-title": "從客戶處取消分配 { count, plural, 1 {1 儀表板} other {# 儀表板} } ", + "delete-dashboard-title": "您確定要刪除儀表板 '{{dashboardTitle}}'嗎?", + "delete-dashboard-text": "小心!確認後儀表板及其所有相關資料將無法恢復。", + "delete-dashboards-title": "你確定你要刪除 { count, plural, 1 {1 儀表板} other {# 儀表板} }嗎?", + "delete-dashboards-action-title": "刪除 { count, plural, 1 {1 儀表板} other {# 儀表板} }", + "delete-dashboards-text": "小心!確認後所有選擇的儀表板將被刪除,所有相關資料將無法恢復。", + "unassign-dashboard-title": "您確定要取消分配儀表板 '{{dashboardTitle}}'嗎?", + "unassign-dashboard-text": "確認後,面板將被取消分配,客戶將無法存取。", + "unassign-dashboard": "取消分配儀表板", + "unassign-dashboards-title": "您確定要取消分配儀表板 { count, plural, 1 {1 儀表板} other {# 儀表板} } 嗎?", + "unassign-dashboards-text": "確認後,所有選擇的儀表板將被取消分配,客戶將無法存取。", + "public-dashboard-title": "儀表板現已公佈", + "public-dashboard-text": "你的儀表板{{dashboardTitle}} 已被公開,可通過如下連結存取:", + "public-dashboard-notice": "提示: 不要忘記將相關設備公開以存取其資料。", + "make-private-dashboard-title": "您確定要將儀表板 '{{dashboardTitle}}' 設為私有嗎?", + "make-private-dashboard-text": "確認後,儀表板將被設為私有,不能被其他人存取。", + "make-private-dashboard": "儀表板設為私有", + "socialshare-text": "'{{dashboardTitle}}' 由Thingsboard提供支持", + "socialshare-title": "'{{dashboardTitle}}' 由Thingsboard提供支持", + "select-dashboard": "選擇儀表板", + "no-dashboards-matching": "找不到符合 '{{entity}}' 的儀表板。", + "dashboard-required": "儀表板必填。", + "select-existing": "選擇現有儀表板", + "create-new": "建立新的儀表板", + "new-dashboard-title": "新儀表板標題", + "open-dashboard": "打開儀表板", + "set-background": "設定背景", + "background-color": "背景顏色", + "background-image": "背景圖片", + "background-size-mode": "背景大小模式", + "no-image": "無圖像選擇", + "empty-image": "沒有圖像", + "drop-image": "拖拉圖像或單擊以選擇要上傳的文件。", + "maximum-upload-file-size": "最大上傳文件大小: {{ size }}", + "cannot-upload-file": "無法上傳文件", + "settings": "設定", + "layout-settings": "佈局設定", + "columns-count": "列數", + "columns-count-required": "需要列數。", + "min-columns-count-message": "只允許最少10列", + "max-columns-count-message": "只允許最多1000列", + "widgets-margins": "部件間邊距", + "margin-required": "需要邊距值", + "min-margin-message": "只允許0作為最小邊距值0。", + "max-margin-message": "只允許50作為最大邊距值。", + "horizontal-margin": "水平邊距", + "horizontal-margin-required": "需要水平邊距值。", + "min-horizontal-margin-message": "只允許0作為最小水平邊距值。", + "max-horizontal-margin-message": "只允許50作為最大水平邊距值。", + "vertical-margin": "垂直邊距", + "vertical-margin-required": "需要垂直邊距值。", + "min-vertical-margin-message": "只允許0作為最小垂直邊距值。", + "max-vertical-margin-message": "只允許50作為最大垂直邊距值。", + "autofill-height": "自動填充佈局高度", + "mobile-layout": "移動端佈局設定", + "mobile-row-height": "移動端行高距(px)", + "mobile-row-height-required": "移動端行高距必填。", + "min-mobile-row-height-message": "移動端行高距至少5px。", + "max-mobile-row-height-message": "移動端行高距最多200px。", + "title-settings": "標題設定", + "display-title": "顯示儀表板標題", + "toolbar-always-open": "工具欄常駐", + "title-color": "標題顏色", + "toolbar-settings": "工具欄設定", + "hide-toolbar": "隱藏工具欄", + "display-dashboards-selection": "顯示儀表板選項", + "display-entities-selection": "顯示實體選項", + "display-filters": "顯示過濾器", + "display-dashboard-timewindow": "顯示時間窗口", + "display-dashboard-export": "顯示匯出", + "display-update-dashboard-image": "顯示更新儀表板圖像", + "dashboard-logo-settings": "儀表板logo設定", + "display-dashboard-logo": "在儀表板全螢幕模式下顯示logo", + "dashboard-logo-image": "儀表板logo圖像", + "advanced-settings": "進階設定", + "dashboard-css": "儀表板CSS", + "import": "匯入儀表板", + "export": "匯出儀表板", + "export-failed-error": "無法匯出儀表板: {{error}}", + "create-new-dashboard": "建立新的儀表板", + "dashboard-file": "儀表板文件", + "invalid-dashboard-file-error": "無法匯入儀表板: 儀表板資料結構無效。", + "dashboard-import-missing-aliases-title": "配置匯入儀表板使用的別名", + "create-new-widget": "建立新部件", + "import-widget": "匯入部件", + "widget-file": "部件文件", + "invalid-widget-file-error": "無法匯入窗口部件: 窗口部件資料結構無效。", + "widget-import-missing-aliases-title": "配置匯入的窗口部件使用的別名", + "open-toolbar": "打開儀表板工具欄", + "close-toolbar": "關閉工具欄", + "configuration-error": "配置錯誤", + "alias-resolution-error-title": "儀表板別名配置錯誤", + "invalid-aliases-config": "無法找到與某些別名過濾器符合的任何設備。
    請聯繫您的管理員以解決此問題。", + "select-devices": "選擇設備", + "assignedToCustomer": "分配給客戶", + "assignedToCustomers": "分配給客戶們", + "public": "公共", + "copyId": "複製儀表板id", + "idCopiedMessage": "儀表板Id已被複製到剪貼板", + "public-link": "公共連結", + "copy-public-link": "複製公共連結", + "public-link-copied-message": "儀表板的公共連結已被複製到剪貼板", + "manage-states": "儀表板狀態管理", + "states": "儀表板狀態", + "search-states": "儀表板狀態檢索", + "selected-states": "{ count, plural, 1 {1 儀表板狀態} other {# 儀表板狀態} } 選中", + "edit-state": "儀表板狀態編輯", + "delete-state": "刪除儀表板狀態", + "add-state": "增加儀表板狀態", + "no-states-text": "未找到任何狀態", + "state": "儀表板狀態", + "state-name": "狀態名", + "state-name-required": "儀表板狀態名必填。", + "state-id": "狀態ID", + "state-id-required": "儀表板狀態ID必填。", + "state-id-exists": "儀表板狀態ID已經存在。", + "is-root-state": "根狀態", + "delete-state-title": "刪除儀表板狀態", + "delete-state-text": "確定要刪除儀表板狀態 '{{stateName}}' 嗎?", + "show-details": "顯示詳細資訊", + "hide-details": "隱藏詳細資訊", + "select-state": "選擇目標狀態", + "state-controller": "狀態控制", + "search": "搜尋儀表板", + "selected-dashboards": "{ count, plural, 1 {1 儀表板} other {# 儀表板} }被選中", + "home-dashboard": "主儀表板", + "home-dashboard-hide-toolbar": "隱藏主儀表板工具欄", + "unassign-dashboard-from-edge-text": "確認後,儀表板將被取消分配,邊緣將無法訪問。", + "unassign-dashboards-from-edge-title": "您確定要取消分配 { count, plural, 1 {1 儀表板} other {# 儀表板} }嗎?", + "unassign-dashboards-from-edge-text": "確認後,所有選定的儀表板都將被取消分配,且邊緣將無法訪問。", + "assign-dashboard-to-edge": "將儀表板分配給邊緣", + "assign-dashboard-to-edge-text": "請選擇要分配給邊緣的儀表板", + "non-existent-dashboard-state-error": "未找到id為 \"{{ stateId }}\" 的儀表板狀態" + }, + "datakey": { + "settings": "設定", + "advanced": "進階", + "label": "標籤", + "color": "顏色", + "units": "單位符號", + "decimals": "小數位數", + "data-generation-func": "資料生成功能", + "use-data-post-processing-func": "使用資料後處理功能", + "configuration": "資料鍵配置", + "timeseries": "時間序列", + "attributes": "屬性", + "entity-field": "實體字段", + "alarm": "警告字段", + "timeseries-required": "需要設備時間序列。", + "timeseries-or-attributes-required": "設備時間/屬性必填。", + "alarm-fields-timeseries-or-attributes-required": "需要警告字段或實體時間序列/屬性。", + "maximum-timeseries-or-attributes": "最大允許 { count, plural, 1 {1 時間序列/屬性} other {# 時間序列/屬性} }", + "alarm-fields-required": "警告字段必填。", + "function-types": "函數類型", + "function-type": "函數類型", + "function-types-required": "需要函數類型。", + "maximum-function-types": "至少需要 { count, plural, 1 {1 函數類型} other {# 函數類型} }", + "alarm-keys": "警告資料鍵", + "alarm-key": "警告資料鍵", + "alarm-key-functions": "警告鍵功能", + "alarm-key-function": "警告鍵功能", + "latest-keys": "最新資料鍵", + "latest-key": "最新資料鍵", + "latest-key-functions": "最新按鍵功能", + "latest-key-function": "最新按鍵功能", + "timeseries-keys": "時間序列資料鍵", + "timeseries-key": "時間序列資料鍵", + "timeseries-key-functions": "時間序列鍵功能", + "timeseries-key-function": "時間序列鍵功能", + "time-description": "當前值的時間戳;", + "value-description": "當前值;", + "prev-value-description": "上一個函數調用的結果;", + "time-prev-description": "上一個值的時間戳;", + "prev-orig-value-description": "原始先前值;" + }, + "datasource": { + "type": "資料源類型", + "name": "資料源名稱", + "label": "標籤", + "add-datasource-prompt": "請增加資料源" + }, + "details": { + "details": "詳細資訊", + "edit-mode": "編輯模式", + "edit-json": "編輯JSON", + "toggle-edit-mode": "切換編輯模式" + }, + "device": { + "device": "設備", + "device-required": "設備必填", + "devices": "設備", + "management": "設備管理", + "view-devices": "查看設備", + "device-alias": "設備別名", + "device-type-max-length": "設備類型應小於256", + "aliases": "設備別名", + "no-alias-matching": "'{{alias}}' 沒有找到。", + "no-aliases-found": "找不到別名。", + "no-key-matching": "'{{key}}' 沒有找到。", + "no-keys-found": "找不到密鑰。", + "create-new-alias": "建立一個新的!", + "create-new-key": "建立一個新的!", + "duplicate-alias-error": "找到重複別名 '{{alias}}'。
    設備別名必須是唯一的。", + "configure-alias": "配置 '{{alias}}' 別名", + "no-devices-matching": "找不到與 '{{entity}}' 符合的設備。", + "alias": "別名", + "alias-required": "需要設備別名。", + "remove-alias": "刪除設備別名", + "add-alias": "增加設備別名", + "name-starts-with": "名稱前綴", + "help-text": "根據需要使用'%': '%device_name_contains%', '%device_name_ends', 'device_starts_with'。", + "device-list": "設備列表", + "use-device-name-filter": "使用過濾器", + "device-list-empty": "沒有被選中的設備", + "device-name-filter-required": "設備名稱過濾器必填。", + "device-name-filter-no-device-matched": "找不到以'{{device}}' 開頭的設備。", + "add": "增加設備", + "assign-to-customer": "分配給客戶", + "assign-device-to-customer": "將設備分配給客戶", + "assign-device-to-customer-text": "請選擇要分配給客戶的設備", + "assign-device-to-edge-title": "將設備分配給邊緣", + "assign-device-to-edge-text": "請選擇要分配給邊緣的設備", + "make-public": "公開", + "make-private": "私有", + "no-devices-text": "找不到設備", + "assign-to-customer-text": "請選擇客戶分配設備", + "device-details": "設備詳細訊息", + "add-device-text": "增加新設備", + "credentials": "憑據", + "manage-credentials": "管理憑據", + "delete": "刪除設備", + "assign-devices": "分配設備", + "assign-devices-text": "將{count,plural,1 {1 設備} other {# 設備} }分配給客戶", + "delete-devices": "刪除設備", + "unassign-from-customer": "取消分配客戶", + "unassign-devices": "取消分配設備", + "unassign-devices-action-title": "從客戶處取消分配{count,plural,1 {1 設備} other {# 設備} }", + "unassign-device-from-edge-title": "您確定要取消分配設備'{{deviceName}}'嗎?", + "unassign-device-from-edge-text": "確認後,設備將被取消分配,邊緣將無法訪問。", + "unassign-devices-from-edge": "從邊緣取消分配設備", + "assign-new-device": "分配新設備", + "make-public-device-title": "您確定要將設備 '{{deviceName}}' 設為公開嗎?", + "make-public-device-text": "確認後,設備及其所有資料將被設為公開並可被其他人存取。", + "make-private-device-title": "您確定要將設備 '{{deviceName}}' 設為私有嗎?", + "make-private-device-text": "確認後,設備及其所有資料將被設為私有,不被其他人存取。", + "view-credentials": "查看憑據", + "delete-device-title": "您確定要刪除設備的{{deviceName}}嗎?", + "delete-device-text": "小心!確認後設備及其所有相關資料將無法恢復。", + "delete-devices-title": "您確定要刪除{count,plural,1 {1 設備} other {# 設備} } 嗎?", + "delete-devices-action-title": "刪除 {count,plural,1 {1 設備} other {# 設備} }", + "delete-devices-text": "小心!確認後所有選擇的設備將被刪除,所有相關資料將無法恢復。", + "unassign-device-title": "您確定要取消分配設備 '{{deviceName}}'?", + "unassign-device-text": "確認後,設備將被取消分配,客戶將無法存取。", + "unassign-device": "取消分配設備", + "unassign-devices-title": "您確定要取消分配{count,plural,1 {1 設備} other {# 設備} } 嗎?", + "unassign-devices-text": "確認後,所有選擇的設備將被取消分配,並且客戶將無法存取。", + "device-credentials": "設備憑據", + "loading-device-credentials": "正在加載設備憑據...", + "credentials-type": "憑據類型", + "access-token": "存取", + "access-token-required": "需要存取token", + "access-token-invalid": "存取token長度必須為1到32個字符。", + "certificate-pem-format": "PEM格式的憑據", + "certificate-pem-format-required": "需要憑據", + "lwm2m-security-config": { + "identity": "客戶端身份", + "identity-required": "需要客戶端身份", + "identity-tooltip": "如標準[RFC7925]中所述,PSK識別字是任意的PSK識別字,最多128位元組。.\n PSK識別字必須首先轉換成字串,然後使用UTF-8編碼為八位位元組。", + "client-key": "客戶端金鑰", + "client-key-required": "需要客戶端金鑰。", + "client-key-tooltip-prk": "PRK公鑰或id必須採用標準[RFC7250]並編碼為Base64格式!", + "client-key-tooltip-psk": "PSK密鑰必須採用標準[RFC4279]和HexDec格式:32、64、128個字元!", + "endpoint": "端點客戶端名稱", + "endpoint-required": "需要端點客戶端名稱", + "client-public-key": "客戶端公鑰", + "client-public-key-hint": "如果客戶端公鑰為空,則使用可信憑據", + "client-public-key-tooltip": "X509公鑰必須是DER編碼的X509v3格式,並且只支持EC算法,然後編碼成Base64格式!", + "mode": "安全配置模式", + "client-tab": "客戶端安全配置", + "client-certificate": "客戶端憑據", + "bootstrap-tab": "Bootstrap客戶端", + "bootstrap-server": "Bootstrap伺服器", + "lwm2m-server": "LwM2M伺服器", + "client-publicKey-or-id": "客戶端公鑰或Id", + "client-publicKey-or-id-required": "需要客戶端公鑰或Id", + "client-publicKey-or-id-tooltip-psk": "如標準[RFC7925]中所述,PSK識別字是最多128位元組的任意PSK識別字。\n PSK識別字首先必須先轉換為字串,然後使用UTF-8編碼為八位位元組。", + "client-publicKey-or-id-tooltip-rpk": "RPK公鑰或id必須採用標準[RFC7250]並編碼為Base64格式!", + "client-publicKey-or-id-tooltip-x509": "X509公鑰必須是DER編碼的X509v3格式,並且只支持EC算法,然後編碼為Base64格式", + "client-secret-key": "客戶端密鑰", + "client-secret-key-required": "需要客戶端密鑰", + "client-secret-key-tooltip-psk": "PSK密鑰必須採用標準[RFC4279]和HexDec格式: 32、64、128個字元!", + "client-secret-key-tooltip-prk": "RPK密鑰必須是PKCS_8格式 (DER 編碼,標準[RFC5958]) ,然後編碼為Base64格式!", + "client-secret-key-tooltip-x509": "X509密鑰必須是PKCS_8格式(DER編碼,標準[RFC5958]),然後編碼為Base64格式!" + }, + "client-id": "客戶ID", + "client-id-pattern": "包含無效字元。", + "user-name": "用戶名稱", + "user-name-required": "需要用戶名稱。", + "client-id-or-user-name-necessary": "客戶ID和/或用戶名是必須的", + "password": "密碼", + "secret": "密鑰", + "secret-required": "密鑰必填", + "device-type": "設備類型", + "device-type-required": "設備類型必填。", + "select-device-type": "選擇設備類型", + "enter-device-type": "輸入設備類型", + "any-device": "任意設備", + "no-device-types-matching": "沒有找到符合 '{{entitySubtype}}' 的設備類型。", + "device-type-list-empty": "未選擇設備類型", + "device-types": "設備類型", + "name": "名稱", + "name-required": "名稱必填。", + "name-max-length": "名稱應小於256", + "label-max-length": "標籤應小於256", + "description": "說明", + "label": "標籤", + "events": "事件", + "details": "詳細訊息", + "copyId": "複製設備ID", + "copyAccessToken": "複製存取token", + "copy-mqtt-authentication": "複製MQTT憑據", + "idCopiedMessage": "設備ID已複製到剪貼板", + "accessTokenCopiedMessage": "設備存取token已複製到剪貼板", + "mqtt-authentication-copied-message": "設備MQTT身份驗證已複製到剪貼板", + "assignedToCustomer": "分配給客戶", + "unable-delete-device-alias-title": "無法刪除設備別名", + "unable-delete-device-alias-text": "設備別名 '{{deviceAlias}}' 不能夠被刪除,因為它被下列部件所使用:
    {{widgetsList}}", + "is-gateway": "是閘道", + "overwrite-activity-time": "覆蓋已連接設備的活動時間", + "public": "公開", + "device-public": "設備公開", + "select-device": "選擇設備", + "import": "匯入設備", + "device-file": "設備文件", + "search": "搜尋設備", + "selected-devices": "{ count, plural, 1 {1 設備} other {# 設備} }被選中", + "device-configuration": "設備配置", + "transport-configuration": "傳輸配置", + "wizard": { + "device-wizard": "設備嚮導", + "device-details": "設備詳情", + "new-device-profile": "建立設備協議", + "existing-device-profile": "選擇現有的設備協議", + "specific-configuration": "具體配置", + "customer-to-assign-device": "客戶指定設備", + "add-credentials": "新增驗證資訊" + }, + "unassign-devices-from-edge-title": "您確定要解除邊緣設備 { count, plural, 1 {1 device} other {# devices} }的指定嗎?", + "unassign-devices-from-edge-text": "確認後邊緣指定設備將解除指定及其所有相關資料將無法恢復。" + }, + "asset-profile": { + "asset-profile": "Asset profile", + "asset-profiles": "Asset profiles", + "all-asset-profiles": "All", + "add": "Add asset profile", + "edit": "Edit asset profile", + "asset-profile-details": "Asset profile details", + "no-asset-profiles-text": "No asset profiles found", + "search": "Search asset profiles", + "selected-asset-profiles": "{ count, plural, 1 {1 asset profile} other {# asset profiles} } selected", + "no-asset-profiles-matching": "No asset profile matching '{{entity}}' were found.", + "asset-profile-required": "Asset profile is required", + "idCopiedMessage": "Asset profile Id has been copied to clipboard", + "set-default": "Make asset profile default", + "delete": "Delete asset profile", + "copyId": "Copy asset profile Id", + "name-max-length": "Name should be less than 256", + "new-device-profile-name": "Asset profile name", + "new-device-profile-name-required": "Asset profile name is required.", + "name": "Name", + "name-required": "Name is required.", + "image": "Asset profile image", + "description": "Description", + "default": "Default", + "default-rule-chain": "Default rule chain", + "mobile-dashboard": "Mobile dashboard", + "mobile-dashboard-hint": "Used by mobile application as a asset details dashboard", + "select-queue-hint": "Select from a drop-down list.", + "delete-asset-profile-title": "Are you sure you want to delete the asset profile '{{assetProfileName}}'?", + "delete-asset-profile-text": "Be careful, after the confirmation the asset profile and all related data will become unrecoverable.", + "delete-asset-profiles-title": "Are you sure you want to delete { count, plural, 1 {1 asset profile} other {# asset profiles} }?", + "delete-asset-profiles-text": "Be careful, after the confirmation all selected asset profiles will be removed and all related data will become unrecoverable.", + "set-default-asset-profile-title": "Are you sure you want to make the asset profile '{{assetProfileName}}' default?", + "set-default-asset-profile-text": "After the confirmation the asset profile will be marked as default and will be used for new assets with no profile specified.", + "no-asset-profiles-found": "No asset profiles found.", + "create-new-asset-profile": "Create a new one!", + "create-asset-profile": "Create new asset profile", + "import": "Import asset profile", + "export": "Export asset profile", + "export-failed-error": "Unable to export asset profile: {{error}}", + "asset-profile-file": "Asset profile file", + "invalid-asset-profile-file-error": "Unable to import asset profile: Invalid asset profile data structure." + }, + "device-profile": { + "device-profile": "設備協議", + "device-profiles": "設備協議", + "all-device-profiles": "全部", + "add": "增加設備協議", + "edit": "編輯設備協議", + "device-profile-details": "設備協議詳情", + "no-device-profiles-text": "未找到設備協議文件", + "search": "搜尋設備協議", + "selected-device-profiles": "{ count, plural, 1 {1 設備協議} other {# 設備協議} }被選中", + "no-device-profiles-matching": "未找到與 '{{entity}}'匹配的設備協議。", + "device-profile-required": "需要設備協議", + "idCopiedMessage": "設備協議Id已複製到剪貼板", + "set-default": "將設備協議設為預設", + "delete": "刪除設備協議", + "copyId": "複製設備協議Id", + "name-max-length": "名稱應小於256", + "new-device-profile-name": "設備協議名稱", + "new-device-profile-name-required": "需要設備協議名稱。", + "name": "名稱", + "name-required": "需要名稱", + "type": "協議類型", + "type-required": "需要協議類型。", + "type-default": "預設", + "image": "設備協議圖像", + "transport-type": "傳輸類型", + "transport-type-required": "需要傳輸類型。", + "transport-type-default": "預設", + "transport-type-default-hint": "支持基本的MQTT、HTTP和CoAP 傳輸", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "啟用進階MQTT傳輸設定", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "啟用進階CoAP傳輸設定", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M傳輸類型", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "指定SNMP傳輸配置", + "description": "描述", + "default": "預設", + "profile-configuration": "屬性配置", + "transport-configuration": "傳輸配置", + "default-rule-chain": "預設規則鏈", + "mobile-dashboard": "移動儀表板", + "mobile-dashboard-hint": "由移動應用程序用作設備詳情儀表板", + "select-queue-hint": "從下拉列表中選擇。", + "delete-device-profile-title": "您確定要刪除 '{{deviceProfileName}}' 設備協議嗎?", + "delete-device-profile-text": "小心!確認後此設備協議及其OTA更新的相關資料將無法恢復。", + "delete-device-profiles-title": "您確定要刪除設備協議標題嗎{ count, plural, 1 {1 設備協議} other {# 設備協議} }?", + "delete-device-profiles-text": "小心!確認後所有選擇的設備協議及其OTA更新的相關資料將無法恢復。", + "set-default-device-profile-title": "您確定要將 '{{deviceProfileName}}' 設為設備協議預設值嗎?", + "set-default-device-profile-text": "確認後此預設值將將做爲未指定的新設備協議。", + "no-device-profiles-found": "未找到設備協議文件", + "create-new-device-profile": "建立一個新的!", + "mqtt-device-topic-filters": "MQTT設備主題過濾", + "mqtt-device-topic-filters-unique": "MQTT設備主題過濾必須為唯一值", + "mqtt-device-payload-type": "MQTT設備内容", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "啟用與其他内容格式的相容性", + "mqtt-enable-compatibility-with-json-payload-format-hint": "啟用後,平台將預設使用Protobuf内容。如果解析失敗,平台將嘗試使用JSON内容格式。在靭體更新期間對向後相容很有用。例如,初始版本的靭體使用Json,而新版本則使用Protobuf。在設備群的靭體新過程中,需要同時支援Protobuf和JSON。相容性模式會帶來輕微的性能下降,因此建議在所有設備都更新後禁用該模式。", + "mqtt-use-json-format-for-default-downlink-topics": "使用Json格式做爲預設的下行主題", + "mqtt-use-json-format-for-default-downlink-topics-hint": "啟用後,平台將使用Json有效負載格式通過以下主題推送屬性和RPC:v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id。此設置不會影響使用新(v2)主題發送的屬性和rpc訂閱: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id。其中 $request_id是一個整數請求識別字。", + "mqtt-send-ack-on-validation-exception": "在PUBLISH消息驗證失敗時發送PUBACK", + "mqtt-send-ack-on-validation-exception-hint": "預設情況下,平台將在消息驗證失敗時關閉MQTT會談。當啟用時,平台將發送發佈確認,而非關閉會談。", + "snmp-add-mapping": "增加SNMP映射", + "snmp-mapping-not-configured": "未配置OID到時間序列/遙測的映射", + "snmp-timseries-or-attribute-name": "映射的時間序列/屬性名稱", + "snmp-timseries-or-attribute-type": "映射的時間序列/屬性類型", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "需要負載類型", + "coap-device-type": "CoAP設備類型", + "coap-device-payload-type": "CoAP設備有效負載", + "coap-device-type-required": "需要CoAP設備類型。", + "coap-device-type-default": "預設", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "支持單[+] 和多級[#]萬用字元。", + "telemetry-topic-filter": "遙測主題過濾器", + "telemetry-topic-filter-required": "需要遙測主題過濾器。", + "attributes-topic-filter": "屬性主題過濾器", + "attributes-topic-filter-required": "需要屬性主題過濾器。", + "telemetry-proto-schema": "遙測原型模式", + "telemetry-proto-schema-required": "需要遙測原型模式。", + "attributes-proto-schema": "屬性原型模式", + "attributes-proto-schema-required": "需要屬性原型模式。", + "rpc-response-proto-schema": "RPC響應原型模式", + "rpc-response-proto-schema-required": "需要RPC響應原型模式。", + "rpc-response-topic-filter": "RPC響應主題過濾器", + "rpc-response-topic-filter-required": "需要RPC響應主題過濾器。", + "rpc-request-proto-schema": "RPC請求原型模式", + "rpc-request-proto-schema-required": "需要RPC請求原型模式。", + "rpc-request-proto-schema-hint": "RPC請求訊息應該總是有字段:string method = 1; int32 requestId = 2; 和params = 3的任何資料類型。", + "not-valid-pattern-topic-filter": "無效的模式主題過濾器", + "not-valid-single-character": "單級萬用字元的使用無效", + "not-valid-multi-character": "多級萬用字元的使用無效", + "single-level-wildcards-hint": "[+]適用於任何主題過濾級別。例如 v1/devices/+/telemetry+/devices/+/attributes。", + "multi-level-wildcards-hint": "[#]可以替換主題過濾器本身,並且必須是主題的最後一個符號。 例如: #v1/devices/me/#。", + "alarm-rules": "警告規則", + "alarm-rules-with-count": "警告規則 ({{count}})", + "no-alarm-rules": "未配置警告規則", + "add-alarm-rule": "增加警告規則", + "edit-alarm-rule": "編輯警告規則", + "alarm-type": "警告類型", + "alarm-type-required": "需要警告類型。", + "alarm-type-unique": "警告類型必須為符合設備協議警告規則的唯一值", + "alarm-type-max-length": "警告類型應小於256。", + "create-alarm-pattern": "建立{{alarmType}}警告", + "create-alarm-rules": "建立警告規則", + "no-create-alarm-rules": "未配置創建條件", + "add-create-alarm-rule-prompt": "請添加建立警告規則", + "clear-alarm-rule": "清除警告規則", + "no-clear-alarm-rule": "未配置清除條件", + "add-create-alarm-rule": "增加建立條件", + "add-clear-alarm-rule": "增加清除條件", + "select-alarm-severity": "選擇警告嚴重性", + "alarm-severity-required": "需要警告嚴重性。", + "condition-duration": "條件持續時間", + "condition-duration-value": "持續時間值", + "condition-duration-time-unit": "時間單位", + "condition-duration-value-range": "持續時間值應在1到2147483647的範圍內。", + "condition-duration-value-pattern": "持續時間值應該是整數。", + "condition-duration-value-required": "需要持續時間值。", + "condition-duration-time-unit-required": "需要時間單位。", + "advanced-settings": "進階設定", + "alarm-rule-details": "詳細資訊", + "alarm-rule-details-hint": "提示:使用${keyName}替換警告規則條件中使用的屬性或遙測鍵的值。", + "add-alarm-rule-details": "增加詳細資訊", + "alarm-rule-mobile-dashboard": "移動儀表板", + "alarm-rule-mobile-dashboard-hint": "由移動應用程序用作警告詳情儀表板", + "alarm-rule-no-mobile-dashboard": "未選擇儀表板", + "propagate-alarm": "傳播警告給相關實體", + "alarm-rule-relation-types-list": "要傳播的關聯類型", + "alarm-rule-relation-types-list-hint": "如果未選擇傳播關聯類型,則將在不按關聯類型過濾的情況下傳播警告。", + "propagate-alarm-to-owner": "傳播警告給相關實體擁有者", + "propagate-alarm-to-tenant": "傳播警告給租戶", + "alarm-details": "警告詳細資料", + "alarm-rule-condition": "警告規則條件", + "enter-alarm-rule-condition-prompt": "請新增警告規則條件", + "edit-alarm-rule-condition": "編輯警告規則條件", + "device-provisioning": "設備佈建", + "provision-strategy": "設備佈建策略", + "provision-strategy-required": "設備佈建策略必填", + "provision-strategy-disabled": "關閉", + "provision-strategy-created-new": "允許建立新設備", + "provision-strategy-check-pre-provisioned": "檢視預先佈建設備", + "provision-device-key": "佈建設備金鑰", + "provision-device-key-required": "佈建設備金鑰必填", + "copy-provision-key": "複製佈建金鑰", + "provision-key-copied-message": "佈建金鑰已複製到剪貼板", + "provision-device-secret": "佈建設備密鑰", + "provision-device-secret-required": "佈建設備金鑰必填", + "copy-provision-secret": "複製佈建密鑰", + "provision-secret-copied-message": "佈建密鑰已複製到剪貼板", + "condition": "條件", + "condition-type": "條件類型", + "condition-type-simple": "條件類型-簡易", + "condition-type-duration": "條件類型-期間", + "condition-during": "{{during}}期間", + "condition-during-dynamic": "在\"{{ attribute }}\" ({{during}})期間", + "condition-type-repeating": "條件類型-重複", + "condition-type-required": "條件類型必填", + "condition-repeating-value": "事件計數", + "condition-repeating-value-range": "事件計數應在1到2147483647的範圍內。", + "condition-repeating-value-pattern": "事件計數應該是整數", + "condition-repeating-value-required": "需要事件計數。", + "condition-repeat-times": "重複 { count, plural, 1 {1 次} other {# 次 }", + "condition-repeat-times-dynamic": "重複 \"{ attribute }\" ({ count, plural, 1 {1 次} other {# 次} })", + "schedule-type": "排程類型", + "schedule-type-required": "排程類型必填", + "schedule": "排程", + "edit-schedule": "編輯警告排程", + "schedule-any-time": "永久啟動", + "schedule-specific-time": "某時間啟動", + "schedule-custom": "客製排程", + "schedule-day": { + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期天" + }, + "schedule-days": "天", + "schedule-time": "次", + "schedule-time-from": "從", + "schedule-time-to": "到", + "schedule-days-of-week-required": "排程每周至少要選擇一天", + "create-device-profile": "建立新設備協議", + "import": "匯入新設備協議", + "export": "匯出新設備協議", + "export-failed-error": "無法匯出設備協議:{{error}}", + "device-profile-file": "設備協議文件", + "invalid-device-profile-file-error": "無法匯入新設備協議:無效的設備協議資料結構。", + "power-saving-mode": "節能模式", + "power-saving-mode-type": { + "default": "使用設備協議的節能模式", + "psm": "節能模式 (PSM)", + "drx": "不間斷接收 (DRX)", + "edrx": "延伸不間斷接收 (eDRX)" + }, + "edrx-cycle": "eDRX週期", + "edrx-cycle-required": "延伸不間斷接收必填", + "edrx-cycle-pattern": "延伸不間斷接收樣式", + "edrx-cycle-min": "eDRX週期的最小數量為{{ min }}秒。", + "paging-transmission-window": "呼叫傳輸窗口", + "paging-transmission-window-required": "呼叫傳輸窗口必填", + "paging-transmission-window-pattern": "呼叫傳輸窗口值需為正整數", + "paging-transmission-window-min": "pPaging傳輸窗口的最小數量是{{ min }}秒。", + "psm-activity-timer": "節能模式活動計時器", + "psm-activity-timer-required": "節能模式活動計時器必填", + "psm-activity-timer-pattern": "節能模式活動計時器值需為正整數", + "psm-activity-timer-min": "節能模式活動計時器最小值為 {{ min }} 秒", + "lwm2m": { + "object-list": "物件列表", + "object-list-empty": "無選擇物件", + "no-objects-found": "找不到物件", + "no-objects-matching": "找不到匹配物件 '{{object}}' ", + "model-tab": "LWM2M模型", + "add-new-instances": "新增執行個體", + "instances-list": "執行個體列表", + "instances-list-required": "執行個體列表為必填", + "instance-id-pattern": "執行個體id值需為正整數", + "instance-id-max": "執行個體id最大值{{max}}", + "instance": "執行個體", + "resource-label": "資源名稱", + "observe-label": "觀測標籤", + "attribute-label": "屬性", + "telemetry-label": "遙測", + "edit-observe-select": "編輯觀測請選擇遙測或屬性", + "edit-attributes-select": "編輯屬性觀測請選擇遙測或屬性", + "no-attributes-set": "未設定屬性", + "key-name": "金鑰名稱", + "key-name-required": "金鑰名稱必填", + "attribute-name": "名稱屬性", + "attribute-name-required": "名稱屬性必填", + "attribute-value": "屬性值", + "attribute-value-required": "屬性值必填", + "attribute-value-pattern": "屬性值需為正整數", + "edit-attributes": "編輯屬性名稱{{ name }}", + "view-attributes": "檢視屬性名稱{{ name }}", + "add-attribute": "新增屬性", + "edit-attribute": "編輯屬性", + "view-attribute": "檢視屬性", + "remove-attribute": "移除屬性", + "delete-server-text": "小心!確認後伺服器配置資料將無法恢復。", + "delete-server-title": "您確定要刪除伺服器嗎?", + "mode": "安全性配置模式", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap伺服器(短Id...)", + "lwm2m-server-legend": "LwM2M伺服器 (短Id...)", + "server": "伺服器", + "short-id": "短伺服器ID", + "short-id-tooltip": "短伺服器ID。用作關聯伺服器物件實例的鏈接。 \n此標識符唯一標識為LwM2M伺服器客戶端配置的每個LwM2M 伺服器。\n當引導伺服器資源的值為'false'時,必須設置資源。\nID:0值和ID:65535值不得用於識別LwM2M伺服器。.", + "short-id-required": "短伺服器ID必填", + "short-id-range": "短伺服器ID長度需介於1到65534。", + "short-id-pattern": "短伺服器ID需為正整數", + "lifetime": "客戶登記使用期限", + "lifetime-required": "客戶登記使用期限必填", + "lifetime-pattern": "客戶登記使用期限需為正整數", + "default-min-period": "預設最小期間 (s)", + "default-min-period-tooltip": "如果觀測中未包含此參數,則LwM2M客戶端應在觀測的最短時段內使用預設值。", + "default-min-period-required": "預設最小期間必填", + "default-min-period-pattern": "最小期間需為正整數", + "notification-storing": "關閉或離線時通知儲存通知", + "binding": "綁定", + "binding-type": { + "u": "透過UDP綁定可隨時觸及客戶。", + "m": "透過MQTT綁定可隨時觸及客戶", + "h": "透過HTTP綁定可隨時觸及客戶", + "t": "透過TCP綁定可隨時觸及客戶", + "s": "透過SMS綁定可隨時觸及客戶", + "n": "N:客戶端必須通過非IP綁定發送對此類請求的響應(自LWM2M 1.1開始支持)。", + "uq": "UQ:佇列模式下的UDP連接(自LWM2M 1.1起不支持)", + "uqs": "UQS:UDP和SMS連接都處於活動狀態; 佇列模式下的UDP,標準模式下的SMS (自LWM2M 1.1起不支持)", + "tq": "TQ:佇列模式下的TCP連接(自LWM2M 1.1起不支持)", + "tqs": "TQS:TCP和SMS連接都處於活動狀態; 佇列模式下的TCP,標準模式下的SMS (自LWM2M 1.1起不支持)", + "sq": "SQ:佇列模式下的SMS連接(自LWM2M 1.1起不支持)" + }, + "binding-tooltip": "這是LwM2M伺服器物件的\"binding\" 資源中的列表 - /1/x/7。\n 表示LwM2M客戶端中支持的綁定模式。\n 此值應與設備物件(/3/0/16)中\"支持的綁定與模式\"資源中的值相同。\n雖然支持多種傳輸,但在整個傳輸對話期間只能使用一個傳輸綁定。\n 例如,當UDP和SMS都被支持時,LwM2M客戶端和LwM2M伺服器可以選擇在整個傳輸對話期間通過UDP或SMS進行通信。", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "include-bootstrap-server": "包括Bootstrap Server更新", + "bootstrap-update-title": "您已經配置過Boostrap Server,您確定要排除更新?", + "bootstrap-update-text": "小心!確認後Boostrap Server配置資料將無法恢復。", + "server-host": "主機", + "server-host-required": "需要主機。", + "server-port": "連接埠", + "server-port-required": "需要連接埠。", + "server-port-pattern": "連接埠必須是正整數。", + "server-port-range": "連接埠應該在1到65535的範圍內。", + "server-public-key": "伺服器公鑰", + "server-public-key-required": "伺服器公鑰必填", + "client-hold-off-time": "延遲時間", + "client-hold-off-time-required": "延遲時間必填", + "client-hold-off-time-pattern": "延遲時間需為正整數", + "client-hold-off-time-tooltip": "僅用於Bootstrap-Server的客戶端延遲時間", + "account-after-timeout": "超時後的帳戶", + "account-after-timeout-required": "需要超時後的帳戶", + "account-after-timeout-pattern": "超時後的帳戶必須為正整數。", + "account-after-timeout-tooltip": "此資源給定超時值後的Bootstrap-Server帳戶", + "server-type": "伺服器類型", + "add-new-server-title": "增加新的伺服器配置", + "add-server-config": "增加伺服器配置", + "add-lwm2m-server-config": "增加LwM2M伺服器", + "no-config-servers": "未配置伺服器", + "others-tab": "其他設定", + "client-strategy": "連接時的客戶端策略", + "client-strategy-label": "策略", + "client-strategy-only-observe": "在初始連接後,只向客户發出觀測請求。", + "client-strategy-read-all": "在註冊後,向客户發出資源及觀測請求。", + "fw-update": "韌體更新", + "fw-update-strategy": "韌體更新政策", + "fw-update-strategy-data": "使用物件19和資源0(資料)將韌體更新作為二進位檔案推送", + "fw-update-strategy-package": "使用物件5和資源0 (套件)將韌體更新作為二進位檔案推送", + "fw-update-strategy-package-uri": "自動生成唯一的CoAP URL 以下載套件並將韌體更新推送為物件5和資源1 (套件URI)", + "sw-update": "軟體更新", + "sw-update-strategy": "軟體更新政策", + "sw-update-strategy-package": "使用物件9和資源2(套件)推送二進位檔案", + "sw-update-strategy-package-uri": "自動生成唯一的CoAP URL以下載套件並使用物件9和資源3 (套件URI)推送軟體更新", + "fw-update-resource": "韌體更新CoAP資源", + "fw-update-resource-required": "需要韌體更新CoAP資源。", + "sw-update-resource": "軟體更新CoAP資源", + "sw-update-resource-required": "需要軟體更新CoAP資源。", + "config-json-tab": "Json設定屬性設備", + "attributes-name": { + "min-period": "最小期間", + "max-period": "最大期間", + "greater-than": "大於", + "less-than": "小於", + "step": "步", + "min-evaluation-period": "最小評估期間", + "max-evaluation-period": "最大評估期間" + }, + "composite-operations-support": "支持複合讀/寫/觀察 操作" + }, + "snmp": { + "add-communication-config": "新增通訊配置", + "add-mapping": "新增映射", + "authentication-passphrase": "密語驗證", + "authentication-passphrase-required": "密語驗證必填", + "authentication-protocol": "驗證協定", + "authentication-protocol-required": "驗證協定必填", + "communication-configs": "通訊配置", + "community": "社群字串", + "community-required": "社群字串為填", + "context-name": "情境名稱", + "data-key": "資料金鑰", + "data-key-required": "資料金鑰必填", + "data-type": "資料類型", + "data-type-required": "資料類型必填", + "engine-id": "引擎ID", + "host": "主機", + "host-required": "需要主機。", + "oid": "OID", + "oid-pattern": "OID格式無效。.", + "oid-required": "需要OID。", + "please-add-communication-config": "請新增通訊配置", + "please-add-mapping-config": "請新增映射配置", + "port": "連接埠", + "port-format": "無效連接埠格式", + "port-required": "需要連接埠。", + "privacy-passphrase": "隱私密語", + "privacy-passphrase-required": "隱私密語必填", + "privacy-protocol": "隱私協定", + "privacy-protocol-required": "隱私協定必填", + "protocol-version": "協定版本", + "protocol-version-required": "協定版本必填", + "querying-frequency": "查詢頻率", + "querying-frequency-invalid-format": "查詢頻率值需為正整數", + "querying-frequency-required": "查詢頻率必填", + "retries": "重試", + "retries-invalid-format": "重試值需為正整數", + "retries-required": "重試必填", + "scope": "範圍", + "scope-required": "範圍必填", + "security-name": "安全性名稱", + "security-name-required": "安全性名稱必填", + "timeout-ms": "超時,毫秒", + "timeout-ms-invalid-format": "超時值需為正整數", + "timeout-ms-required": "超時必填", + "user-name": "用戶名稱", + "user-name-required": "用戶名稱必填" + } + }, + "dialog": { + "close": "關閉對話框" + }, + "direction": { + "column": "列", + "row": "行" + }, + "edge": { + "edge": "邊緣", + "edge-instances": "邊緣實例", + "edge-file": "邊緣文件", + "name-max-length": "名稱應小於256", + "label-max-length": "標籤應小於256", + "type-max-length": "類型應小於256", + "management": "邊緣管理", + "no-edges-matching": "找不到與 '{{entity}}'匹配的邊緣 ", + "add": "新增邊緣", + "no-edges-text": "未找到邊緣", + "edge-details": "邊緣詳情", + "add-edge-text": "增加新邊緣", + "delete": "刪除邊緣", + "delete-edge-title": "您確定要刪除邊緣'{{edgeName}}'嗎?", + "delete-edge-text": "小心!確認後邊緣及其所有相關資料將無法恢復。", + "delete-edges-title": "您確定要刪除邊緣 { count, plural, 1 {1 edge} other {# edges} } 嗎?", + "delete-edges-text": "小心,確認後,所有選定的邊緣將被刪除,所有相關資料將無法恢復。", + "name": "名稱", + "name-starts-with": "邊緣名稱始于", + "name-required": "名稱必填", + "description": "描述", + "details": "詳細資訊", + "events": "事件", + "copy-id": "複製邊緣Id", + "id-copied-message": "邊緣已複製到剪貼簿", + "sync": "邊緣同步", + "edge-required": "邊緣必填", + "edge-type": "邊緣類型", + "edge-type-required": "邊緣類型必填", + "event-action": "事件動作", + "entity-id": "實體ID", + "select-edge-type": "選擇邊緣類型", + "assign-to-customer": "分配給客戶", + "assign-to-customer-text": "請選擇要分配邊緣的客戶", + "assign-edge-to-customer": "將邊緣分配給客戶", + "assign-edge-to-customer-text": "請選擇要分配給客戶的邊緣", + "assignedToCustomer": "分配給客戶", + "edge-public": "邊緣是公開的", + "assigned-to-customer": "分配給:{{customerTitle}}", + "unassign-from-customer": "從客戶取消分配", + "unassign-edge-title": "您確定要取消分配邊緣'{{edgeName}}'嗎?", + "unassign-edge-text": "確認後,邊緣將被取消分配,客戶將無法訪問。", + "unassign-edges-title": "您確定要取消分配 { count, plural, 1 {1 邊緣} other {# 邊緣} }嗎?", + "unassign-edges-text": "確認後,所有選定的邊緣都將被取消分配,客戶將無法訪問。", + "make-public": "公開邊緣", + "make-public-edge-title": "您確定要公開邊緣'{{edgeName}}' 嗎?", + "make-public-edge-text": "確認後,邊緣及其所有資料將公開並可供其他人訪問。", + "make-private": "將邊緣設為私有", + "public": "公開", + "make-private-edge-title": "您確定要將邊緣'{{edgeName}}'設為私有嗎?", + "make-private-edge-text": "確認後,邊緣及其所有資料將被設為私有,其他人無法訪問。", + "import": "匯入邊緣", + "label": "標籤", + "load-entity-error": "加載資料失敗。實體已被刪除。", + "assign-new-edge": "分配新邊緣", + "unassign-from-edge": "從邊緣取消分配", + "edge-key": "邊緣鍵", + "copy-edge-key": "複製邊緣鍵", + "edge-key-copied-message": "邊緣鍵已複製到剪貼板", + "edge-secret": "邊緣密鑰", + "copy-edge-secret": "複製邊緣密鑰", + "edge-secret-copied-message": "邊緣密鑰已複製到剪貼板", + "edge-assets": "邊緣資產", + "edge-devices": "邊緣設備", + "edge-entity-views": "邊緣實體視圖", + "edge-dashboards": "邊緣儀表板", + "edge-rulechains": "邊緣規則鏈", + "assets": "邊緣資產", + "devices": "邊緣設備", + "entity-views": "邊緣實體視圖", + "dashboard": "邊緣儀表板", + "dashboards": "邊緣儀表板", + "rulechain-templates": "角色鏈模型", + "rulechains": "規則鏈", + "search": "搜尋邊緣", + "selected-edges": "{ count, plural, 1 {1 邊緣} other {# 邊緣} }被選中", + "any-edge": "任何邊緣", + "no-edge-types-matching": "未找到與'{{entitySubtype}}'匹配的邊緣類型。", + "edge-type-list-empty": "未選擇邊緣類型", + "edge-types": "邊緣類型", + "enter-edge-type": "輸入邊緣類型", + "deployed": "部署", + "pending": "待辦的", + "downlinks": "下行鏈結", + "no-downlinks-prompt": "未找到下行鏈結", + "sync-process-started-successfully": "同步程序已成功啟動!", + "missing-related-rule-chains-title": "邊緣缺少相關規則鏈", + "missing-related-rule-chains-text": "分配給邊緣規則鏈的規則節點將訊息轉發給未分配給此邊緣的規則鏈。

    缺少的規則鏈清單:
    {{missingRuleChains}}", + "widget-datasource-error": "此部件僅支持邊緣實體資料來源" + }, + "edge-event": { + "type-dashboard": "儀表板", + "type-asset": "資產", + "type-device": "設備", + "type-device-profile": "設備協議", + "type-entity-view": "實體視圖", + "type-alarm": "警告", + "type-rule-chain": "規則鏈", + "type-rule-chain-metadata": "規則鏈元資料", + "type-edge": "邊緣", + "type-user": "用戶", + "type-customer": "客戶", + "type-relation": "關聯", + "type-widgets-bundle": "部件包", + "type-widgets-type": "部件類型", + "type-admin-settings": "管理員設定", + "action-type-added": "增加", + "action-type-deleted": "刪除", + "action-type-updated": "更新", + "action-type-post-attributes": "發布屬性", + "action-type-attributes-updated": "更新屬性", + "action-type-attributes-deleted": "刪除屬性", + "action-type-timeseries-updated": "更新時間序列", + "action-type-credentials-updated": "更新憑據", + "action-type-assigned-to-customer": "分配給客戶", + "action-type-unassigned-from-customer": "從客戶處取消分配", + "action-type-relation-add-or-update": "關聯新增或更新", + "action-type-relation-deleted": "刪除關聯", + "action-type-rpc-call": "RPC調用", + "action-type-alarm-ack": "警告確認", + "action-type-alarm-clear": "警告清除", + "action-type-assigned-to-edge": "分配給邊緣", + "action-type-unassigned-from-edge": "從邊緣取消分配", + "action-type-credentials-request": "憑據請求", + "action-type-entity-merge-request": "實體合併請求" + }, + "error": { + "unable-to-connect": "無法連接到伺服器!請檢查您的互聯網連接。", + "unhandled-error-code": "未處理的錯誤代碼: {{errorCode}}", + "unknown-error": "未知錯誤" + }, + "entity": { + "entity": "實體", + "entities": "實體", + "entities-count": "實體計數", + "aliases": "實體別名", + "entity-alias": "實體別名", + "unable-delete-entity-alias-title": "無法刪除實體別名", + "unable-delete-entity-alias-text": "實體別名 '{{entityAlias}}' 被以下部件使用不能刪除:
    {{widgetsList}}", + "duplicate-alias-error": "別名 '{{alias}}' 重複。
    同一儀表板別名必須唯一。", + "missing-entity-filter-error": "別名 '{{alias}}' 缺少過濾器", + "configure-alias": "別名 '{{alias}}' 配置", + "alias": "別名", + "alias-required": "實體別名必填。", + "remove-alias": "移除實體別名", + "add-alias": "增加實體別名", + "entity-list": "實體列表", + "entity-type": "實體類型", + "entity-types": "實體類型", + "entity-type-list": "實體類型列表", + "any-entity": "任意實體", + "enter-entity-type": "輸入實體類型", + "no-entities-matching": "沒有找到符合 '{{entity}}' 的實體。", + "no-entity-types-matching": "沒有找到符合 '{{entityType}}' 類型的實體。", + "name-starts-with": "名稱開始於", + "help-text": "根據需要使用'%':'%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "use-entity-name-filter": "用戶過濾", + "entity-list-empty": "沒有選擇實體。", + "entity-type-list-empty": "沒有選擇實體類型。", + "entity-name-filter-required": "實體名過濾器必填。", + "entity-name-filter-no-entity-matched": "沒有找到以 '{{entity}}' 開頭的實體", + "all-subtypes": "所有", + "select-entities": "選擇實體", + "no-aliases-found": "沒有找到別名", + "no-alias-matching": "沒有找到 '{{alias}}'", + "create-new-alias": "建立新別名", + "key": "鍵", + "key-name": "鍵名", + "no-keys-found": "沒有找到鍵", + "no-key-matching": "沒有找到鍵 '{{key}}'", + "create-new-key": "建立新鍵", + "type": "類型", + "type-required": "實體類型必填。", + "type-device": "設備", + "type-devices": "設備", + "list-of-devices": "{ count, plural, 1 {一個設備} other {# 設備列表} }", + "device-name-starts-with": "以 '{{prefix}}' 開頭的設備", + "type-asset": "資產", + "type-assets": "資產", + "list-of-assets": "{ count, plural, 1 {一個資產} other {# 資產列表} }", + "asset-name-starts-with": "以 '{{prefix}}' 開頭的資產", + "type-entity-view": "實體視圖", + "type-entity-views": "實體視圖", + "list-of-entity-views": "{ count, plural, 1 {一個實體視圖} other {# 實體視圖列表} }", + "entity-view-name-starts-with": "以 '{{prefix}}' 開頭的實體視圖", + "type-rule": "規則", + "type-rules": "規則", + "list-of-rules": "{ count, plural, 1 {一個規則} other {# 規則列表} }", + "rule-name-starts-with": "以 '{{prefix}}' 開頭的規則", + "type-plugin": "插件", + "type-plugins": "插件", + "list-of-plugins": "{ count, plural, 1 {一個插件} other {# 插件列表} }", + "tenant-name-starts-with": "名稱以 '{{prefix}}'開頭的租戶", + "type-tenant-profile": "租戶屬性", + "type-tenant-profiles": "租戶屬性", + "list-of-tenant-profiles": "{ count, plural, 1 {一個租戶屬性} other {# 租戶屬性列表} }", + "tenant-profile-name-starts-with": "名稱以 '{{prefix}}'開頭的租戶屬性", + "plugin-name-starts-with": "以 '{{prefix}}' 開頭的插件", + "type-tenant": "租戶", + "type-tenants": "租戶", + "list-of-tenants": "{ count, plural, 1 {一個租戶} other {# 租戶列表} }", + "type-customer": "客戶", + "type-customers": "客戶", + "list-of-customers": "{ count, plural, 1 {一個客戶} other {# 客戶列表} }", + "customer-name-starts-with": "以 '{{prefix}}' 開頭的客戶", + "type-user": "用戶", + "type-users": "用戶", + "list-of-users": "{ count, plural, 1 {一個用戶} other {# 用戶列表} }", + "user-name-starts-with": "以 '{{prefix}}' 開頭的用戶", + "type-dashboard": "儀表板", + "type-dashboards": "儀表板", + "list-of-dashboards": "{ count, plural, 1 {一個儀表板} other {# 儀表板列表} }", + "dashboard-name-starts-with": "以 '{{prefix}}' 開頭的儀表板", + "type-alarm": "警告", + "type-alarms": "警告", + "list-of-alarms": "{ count, plural, 1 {一個警告} other {# 警告列表} }", + "alarm-name-starts-with": "以 '{{prefix}}' 開頭的警告", + "type-rulechain": "規則鏈", + "type-rulechains": "規則鏈", + "list-of-rulechains": "{ count, plural, 1 {一個規則鏈} other {# 規則鏈列表} }", + "rulechain-name-starts-with": "規則鏈前綴名稱 '{{prefix}}'", + "type-rulenode": "規則節點", + "type-rulenodes": "規則節點", + "list-of-rulenodes": "{ count, plural, 1 {一個規則節點} other {# 規則節點列表} }", + "rulenode-name-starts-with": "名稱以'{{prefix}}'開頭的規則節點", + "type-current-customer": "當前客戶", + "type-current-tenant": "當前租戶", + "type-current-user": "當前用戶", + "type-current-user-owner": "當前用戶所有者", + "type-widgets-bundle": "部件包", + "type-widgets-bundles": "部件包", + "list-of-widgets-bundles": "{ count, plural, 1 {一個部件包} other {# 部件包列表} }", + "search": "實體檢索", + "selected-entities": "{ count, plural, 1 {1 實體} other {# 實體} } 被選中", + "entity-name": "實體名", + "entity-label": "實體標籤", + "details": "實體詳細資訊", + "no-entities-prompt": "沒有找到實體", + "no-data": "無資料", + "columns-to-display": "要顯示的列", + "type-api-usage-state": "api使用狀態", + "type-edge": "邊緣", + "type-edges": "邊緣", + "list-of-edges": "{ count, plural, 1 {一個邊緣} other {# 邊緣列表} }", + "edge-name-starts-with": "名稱以'{{prefix}}'開頭的邊緣", + "type-tb-resource": "資源", + "type-ota-package": "OTA套件" + }, + "entity-field": { + "created-time": "建立時間", + "name": "名稱", + "type": "類型", + "first-name": "名字", + "last-name": "姓", + "email": "電子郵件", + "title": "標題", + "country": "國家", + "state": "州", + "city": "城市", + "address": "地址", + "address2": "地址2", + "zip": "Zip", + "phone": "手機", + "label": "標籤" + }, + "entity-view": { + "entity-view": "實體視圖", + "entity-view-required": "實體視圖必填。", + "entity-views": "實體視圖", + "management": "實體視圖管理", + "view-entity-views": "查看實體視圖", + "entity-view-alias": "實體視圖別名", + "aliases": "實體視圖別名", + "no-alias-matching": "'{{alias}}' 沒有找到。", + "no-aliases-found": "找不到別名。", + "no-key-matching": "'{{key}}' 沒有找到。", + "no-keys-found": "找不到密鑰。", + "create-new-alias": "建立一個新的!", + "create-new-key": "建立一個新的!", + "duplicate-alias-error": "找到重複別名 '{{alias}}'。
    實體視圖別名必須是唯一的。", + "configure-alias": "配置 '{{alias}}' 別名", + "no-devices-matching": "找不到與 '{{entity}}' 符合的實體視圖。", + "public": "公開", + "alias": "別名", + "alias-required": "需要實體視圖別名。", + "remove-alias": "刪除實體視圖別名", + "add-alias": "增加實體視圖別名", + "name-starts-with": "名稱前綴", + "help-text": "根據需要使用'%':'%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "entity-view-list": "實體視圖列表", + "use-entity-view-name-filter": "使用過濾器", + "entity-view-list-empty": "沒有被選中的實體視圖", + "entity-view-name-filter-required": "實體視圖名稱過濾器必填。", + "entity-view-name-filter-no-entity-view-matched": "找不到以'{{entityView}}' 開頭的實體視圖。", + "add": "增加實體視圖", + "entity-view-public": "實體視圖是公開的", + "assign-to-customer": "分配給客戶", + "assign-entity-view-to-customer": "將實體視圖分配給客戶", + "assign-entity-view-to-customer-text": "請選擇要分配給客戶的實體視圖", + "assign-entity-view-to-edge-title": "將實體視圖分配給邊緣", + "no-entity-views-text": "找不到實體視圖", + "assign-to-customer-text": "請選擇客戶分配實體視圖", + "entity-view-details": "實體視圖詳細訊息", + "add-entity-view-text": "增加新實體視圖", + "delete": "刪除實體視圖", + "assign-entity-views": "分配實體視圖", + "assign-entity-views-text": "分配 { count, plural, 1 {1 實體視圖} other {# 實體視圖} } 給客戶", + "delete-entity-views": "刪除實體視圖", + "unassign-from-customer": "取消分配客戶", + "unassign-entity-views": "取消分配實體視圖", + "unassign-entity-views-action-title": "從客戶處取消分配{count,plural,1 {1 實體視圖} other {# 實體視圖} }", + "assign-new-entity-view": "分配新實體視圖", + "delete-entity-view-title": "確定要刪除實體視圖 '{{entityViewName}}'?", + "delete-entity-view-text": "小心!確認後實體視圖及其所有相關資料將無法恢復。", + "delete-entity-views-title": "確定要刪除 { count, plural, 1 {1 實體視圖} other {# 實體視圖} }?", + "delete-entity-views-action-title": "刪除 { count, plural, 1 {1 實體視圖} other {# 實體視圖} }", + "delete-entity-views-text": "小心,確認後,所有選擇的實體視圖將被刪除,所有相關的資料將變得無法恢復。", + "unassign-entity-view-title": "您確定要取消對 '{{entityViewName}}'實體視圖的分配嗎?", + "unassign-entity-view-text": "確認後,實體視圖將未分配,客戶無法存取。", + "unassign-entity-view": "未分配實體視圖", + "unassign-entity-views-title": "您確定要取消分配 { count, plural, 1 {1 實體視圖} other {# 實體視圖} }嗎?", + "unassign-entity-views-text": "確認後,所有選擇的實體視圖將被分配,客戶無法存取。", + "entity-view-type": "實體視圖類型", + "entity-view-type-required": "實體視圖類型必填。", + "select-entity-view-type": "選擇實體視圖類型", + "enter-entity-view-type": "輸入實體視圖類型", + "any-entity-view": "任何實體視圖", + "no-entity-view-types-matching": "沒有找到符合 '{{entitySubtype}}' 的實體視圖類型。", + "entity-view-type-list-empty": "實體視圖類型未選擇。", + "entity-view-types": "實體視圖類型", + "created-time": "建立時間", + "name": "名稱", + "name-required": "名稱必填。", + "name-max-length": "名稱應小於256", + "type-max-length": "實體視圖類型應小於256", + "description": "描述", + "events": "事件", + "details": "詳細資訊", + "copyId": "複製實體視圖ID", + "idCopiedMessage": "實體視圖Id已複製到剪貼板", + "assignedToCustomer": "分配給客戶", + "unable-entity-view-device-alias-title": "無法刪除實體視圖別名", + "unable-entity-view-device-alias-text": "實體視圖別名 '{{entityViewAlias}}' 不能夠被刪除,因為它被下列部件所使用:
    {{widgetsList}}", + "select-entity-view": "選擇實體視圖", + "make-public": "實體視圖設為公開", + "make-private": "實體視圖設為私有", + "start-date": "開始日期", + "start-ts": "開始時間", + "end-date": "結束日期", + "end-ts": "結束時間", + "date-limits": "日期限製", + "client-attributes": "客戶端屬性", + "shared-attributes": "共享屬性", + "server-attributes": "服務端屬性", + "timeseries": "時間序列", + "client-attributes-placeholder": "客戶端屬性", + "shared-attributes-placeholder": "共享屬性", + "server-attributes-placeholder": "服務端屬性", + "timeseries-placeholder": "時間序列", + "target-entity": "目標實體", + "attributes-propagation": "屬性傳播", + "attributes-propagation-hint": "每次儲存或更新這個實體視圖時,實體視圖將自動從目標實體複製指定的屬性。由於性能原因,目標實體屬性不會在每次屬性更改時傳播到實體視圖。您可以通過配置\"copy to view\"規則鏈中的規則節點,並將\"Post attributes\"和\"attributes Updated\"消息連結到新規則節點,從而啟用自動傳播。", + "timeseries-data": "時間序列資料", + "timeseries-data-hint": "配置目標實體的時間序列資料鍵,以便實體視圖可以存取這些鍵。這個時間序列資料是只讀的。", + "search": "搜尋實體視圖", + "selected-entity-views": "{ count, plural, 1 {1 實體視圖} other {# 實體視圖} } 被選中", + "make-public-entity-view-title": "你確定你想建立公開 '{{entityViewName}}' 實體視圖?", + "make-public-entity-view-text": "確認後,實體視圖 及其所有資料將被公開並被他人存取。", + "make-private-entity-view-title": "你確定你想建立私有 '{{entityViewName}}' 實體視圖?", + "make-private-entity-view-text": "確認後,實體視圖及其所有資料將被私有化,無法被他人存取。", + "assign-entity-view-to-edge": "將實體視圖分配給邊緣", + "assign-entity-view-to-edge-text": "請選擇要分配給邊緣的實體視圖", + "unassign-entity-view-from-edge-title": "您確定要取消分配實體視圖'{{entityViewName}}'嗎?", + "unassign-entity-view-from-edge-text": "確認後,實體視圖將被取消分配,邊緣將無法訪問。", + "unassign-entity-views-from-edge-action-title": "從邊緣取消分配 { count, plural, 1 {1 實體視圖} other {# 實體視圖} } ", + "unassign-entity-view-from-edge": "取消分配實體視圖", + "unassign-entity-views-from-edge-title": "您確定要取消分配{ count, plural, 1 {1 實體視圖} other {# 實體視圖} }嗎?", + "unassign-entity-views-from-edge-text": "確認後,所有選定的實體視圖都將被取消分配,並且邊緣將無法訪問。" + }, + "event": { + "event-type": "事件類型", + "events-filter": "事件過濾器", + "clean-events": "清除事件", + "type-error": "錯誤", + "type-lc-event": "生命週期事件", + "type-stats": "類型統計", + "type-debug-rule-node": "測試", + "type-debug-rule-chain": "測試", + "no-events-prompt": "找不到事件", + "error": "錯誤", + "alarm": "報警", + "event-time": "事件時間", + "server": "伺服器", + "body": "整體", + "method": "方法", + "type": "類型", + "message": "消息", + "message-id": "消息ID", + "copy-message-id": "複製消息ID", + "message-type": "消息類型", + "data-type": "資料類型", + "relation-type": "關聯類型", + "metadata": "元資料", + "data": "資料", + "event": "事件", + "status": "狀態", + "success": "成功", + "failed": "失敗", + "messages-processed": "消息處理", + "max-messages-processed": "處理的最大消息", + "min-messages-processed": "處理的最小消息", + "errors-occurred": "錯誤發生", + "max-errors-occurred": "發生的最大錯誤", + "min-errors-occurred": "發生的最小錯誤", + "min-value": "最小值為0。", + "all-events": "所有", + "has-error": "有錯誤", + "entity-id": "實體ID", + "copy-entity-id": "複製實體ID", + "entity-type": "實體類型", + "clear-filter": "清除過濾器", + "clear-request-title": "清除所有事件", + "clear-request-text": "您確定要清除所有事件嗎?" + }, + "extension": { + "extensions": "擴展", + "selected-extensions": "{ count, plural, 1 {1 擴展} other {# 擴展} } 被選擇", + "type": "類型", + "key": "鍵名", + "value": "值", + "id": "ID", + "extension-id": "擴展ID", + "extension-type": "擴展類型", + "transformer-json": "JSON *", + "unique-id-required": "當前擴展ID已經存在。", + "delete": "刪除擴展", + "add": "增加擴展", + "edit": "編輯擴展", + "delete-extension-title": "確實要刪除擴展名'{{extensionId}}'嗎?", + "delete-extension-text": "小心,確認後,擴展和所有相關資料將變得無法恢復。", + "delete-extensions-title": "您確定要刪除 { count, plural, 1 {1 表達式} other {# 表達式} }嗎?", + "delete-extensions-text": "小心,確認後,所有選擇的擴展將被刪除。", + "converters": "轉換器", + "converter-id": "轉換器序號", + "configuration": "配置", + "converter-configurations": "轉換器的配置", + "token": "安全token", + "add-converter": "增加轉換器", + "add-config": "增加轉換器配置", + "device-name-expression": "設備名稱表達式", + "device-type-expression": "設備類型表達式", + "custom": "顧客", + "to-double": "加倍", + "transformer": "轉換器", + "json-required": "轉換器JSON必填。", + "json-parse": "無法解析轉換器JSON。", + "attributes": "屬性", + "add-attribute": "增加屬性", + "add-map": "增加映射元素", + "timeseries": "時間序列", + "add-timeseries": "增加時間序列", + "field-required": "必填欄位", + "brokers": "代理伺服器組", + "add-broker": "增加代理伺服器", + "host": "主機", + "port": "連接埠", + "port-range": "連接埠應該在1到65535的範圍內。", + "ssl": "Ssl", + "credentials": "證書", + "username": "用戶名", + "password": "密碼", + "retry-interval": "以毫秒為單位的重試間隔", + "anonymous": "匿名", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "CA證書文件 *", + "private-key": "私鑰文件 *", + "cert": "證書文件 *", + "no-file": "沒有選擇文件。", + "drop-file": "刪除文件或單擊以選擇要上載的文件。", + "mapping": "映射", + "topic-filter": "主題濾波", + "converter-type": "轉換類型", + "converter-json": "Json", + "json-name-expression": "設備名稱JSON表達式", + "topic-name-expression": "設備名稱主題表達式", + "json-type-expression": "設備類型JSON表達式", + "topic-type-expression": "設備類型主題表達式", + "attribute-key-expression": "屬性關鍵字表達式", + "attr-json-key-expression": "屬性鍵JSON表達式", + "attr-topic-key-expression": "屬性關鍵字主題表達式", + "request-id-expression": "請求ID表達式", + "request-id-json-expression": "請求ID JSON表達式", + "request-id-topic-expression": "請求ID主題表達式", + "response-topic-expression": "響應主題表達式", + "value-expression": "值表達式", + "topic": "主題", + "timeout": "毫秒超時", + "converter-json-required": "轉換JSON是必需的。", + "converter-json-parse": "無法解析轉換JSON。", + "filter-expression": "過濾表達式", + "connect-requests": "連接請求", + "add-connect-request": "增加連接請求", + "disconnect-requests": "斷開請求", + "add-disconnect-request": "增加斷開請求", + "attribute-requests": "屬性請求", + "add-attribute-request": "增加屬性請求", + "attribute-updates": "屬性更新", + "add-attribute-update": "增加屬性更新", + "server-side-rpc": "服務端RPC", + "add-server-side-rpc-request": "增加服務端RPC請求", + "device-name-filter": "設備名稱濾波", + "attribute-filter": "屬性濾波", + "method-filter": "方法濾波", + "request-topic-expression": "請求主題表達式", + "response-timeout": "毫秒內響應超時", + "topic-expression": "主題表達", + "client-scope": "客戶範圍", + "add-device": "增加伺服器", + "opc-server": "伺服器組", + "opc-add-server": "增加伺服器", + "opc-add-server-prompt": "請增加伺服器", + "opc-application-name": "應用名稱", + "opc-application-uri": "應用URI", + "opc-scan-period-in-seconds": "秒級掃描週期", + "opc-security": "安全性", + "opc-identity": "身份", + "opc-keystore": "密鑰庫", + "opc-type": "類型", + "opc-keystore-type": "類型", + "opc-keystore-location": "位置 *", + "opc-keystore-password": "密碼", + "opc-keystore-alias": "別名", + "opc-keystore-key-password": "密鑰密碼", + "opc-device-node-pattern": "設備節點模式", + "opc-device-name-pattern": "設備名稱模式", + "modbus-server": "Servers/slaves", + "modbus-add-server": "增加 server/slave", + "modbus-add-server-prompt": "請增加 server/slave", + "modbus-transport": "傳輸", + "modbus-tcp-reconnect": "自動重新連接", + "modbus-rtu-over-tcp": "TCP上的RTU", + "modbus-port-name": "串口名稱", + "modbus-encoding": "編碼", + "modbus-parity": "奇偶性", + "modbus-baudrate": "鮑率", + "modbus-databits": "資料位元", + "modbus-stopbits": "停止位元", + "modbus-databits-range": "資料位元應該在7到8的範圍內。", + "modbus-stopbits-range": "停止位元應該在1到2的範圍內。", + "modbus-unit-id": "單位編號", + "modbus-unit-id-range": "單位ID應該在1到247的範圍內", + "modbus-device-name": "設備名稱", + "modbus-poll-period": "輪詢週期 (ms)", + "modbus-attributes-poll-period": "輪詢屬性週期 (ms)", + "modbus-timeseries-poll-period": "時間序列輪詢週期 (ms)", + "modbus-poll-period-range": "輪詢週期應為正值。", + "modbus-tag": "標籤", + "modbus-function": "函數", + "modbus-register-address": "寄存器地址", + "modbus-register-address-range": "寄存器地址應該在0到65535的範圍內。", + "modbus-register-bit-index": "位元索引", + "modbus-register-bit-index-range": "位元索引應該在0到15的範圍內。", + "modbus-register-count": "寄存器計數", + "modbus-register-count-range": "寄存器計數應該是一個正值。", + "modbus-byte-order": "字節順序", + "sync": { + "status": "狀態", + "sync": "同步", + "not-sync": "不同步", + "last-sync-time": "最後同步時間", + "not-available": "無法使用" + }, + "export-extensions-configuration": "匯出擴展配置", + "import-extensions-configuration": "匯入擴展配置", + "import-extensions": "匯入擴展", + "import-extension": "匯入擴展", + "export-extension": "匯出擴展", + "file": "擴展文件", + "invalid-file-error": "無效的擴展文件" + }, + "filter": { + "add": "增加過濾器", + "edit": "編輯過濾器", + "name": "過濾器名稱", + "name-required": "需要過濾器名稱。", + "duplicate-filter": "同名過濾器已存在。", + "filters": "過濾器", + "unable-delete-filter-title": "無法刪除過濾器", + "unable-delete-filter-text": "無法刪除過濾器'{{filter}}' ,因為它被以下部件使用:
    {{widgetsList}}", + "duplicate-filter-error": "找到重複的過濾器 '{{filter}}'。
    過濾器在儀表板中必須是唯一的。", + "missing-key-filters-error": "過濾器'{{filter}}'缺少關鍵過濾器。", + "filter": "過濾器", + "editable": "可編輯", + "no-filters-found": "未找到過濾器。", + "no-filter-text": "未指定過濾器", + "add-filter-prompt": "請增加過濾器", + "no-filter-matching": "找不到'{{filter}}'。", + "create-new-filter": "建立一個新的!", + "filter-required": "需要過濾器。", + "operation": { + "operation": "操作", + "equal": "等於", + "not-equal": "不相等", + "starts-with": "開始於", + "ends-with": "結束於", + "contains": "包含", + "not-contains": "不包含", + "greater": "大於", + "less": "少於", + "greater-or-equal": "大於或等於", + "less-or-equal": "少於或等於", + "and": "和", + "or": "或", + "in": "在", + "not-in": "不在" + }, + "ignore-case": "忽略大小寫", + "value": "值", + "remove-filter": "移除過濾器", + "preview": "過濾器預覽", + "no-filters": "未配置過濾器", + "add-filter": "增加過濾器", + "add-complex-filter": "增加複合過濾器", + "add-complex": "增加複合體", + "complex-filter": "複合過濾器", + "edit-complex-filter": "編輯複合過濾器", + "edit-filter-user-params": "編輯過濾謂詞用戶參數", + "filter-user-params": "過濾謂詞用戶參數", + "user-parameters": "用戶參數", + "display-label": "要顯示的標籤", + "autogenerated-label": "自動生成標籤", + "order-priority": "字段順序優先級", + "key-filter": "關鍵過濾器", + "key-filters": "關鍵過濾器", + "key-name": "鍵名", + "key-name-required": "需要鍵名。", + "key-type": { + "key-type": "鍵類型", + "attribute": "屬性", + "timeseries": "時間序列", + "entity-field": "實體字段", + "constant": "常數" + }, + "value-type": { + "value-type": "值類型", + "string": "字串", + "numeric": "數字", + "boolean": "布林值", + "date-time": "日期和時間" + }, + "value-type-required": "需要鍵值類型。", + "key-value-type-change-title": "您確定要更改鍵值類型嗎?", + "key-value-type-change-message": "如果您確認新的值類型,所有輸入的鍵過濾器都將被刪除。", + "no-key-filters": "未配置鍵過濾器", + "add-key-filter": "增加鍵過濾器", + "remove-key-filter": "移除鍵過濾器", + "edit-key-filter": "編輯鍵過濾器", + "date": "日期", + "time": "時間", + "current-tenant": "當前租戶", + "current-customer": "當前客戶", + "current-user": "當前用戶", + "current-device": "當前設備", + "default-value": "預設值", + "dynamic-source-type": "動態源類型", + "dynamic-value": "動態值", + "no-dynamic-value": "沒有動態值", + "source-attribute": "源屬性", + "switch-to-dynamic-value": "切換到動態值", + "switch-to-default-value": "切換到預設值", + "inherit-owner": "從所有者繼承", + "source-attribute-not-set": "如果未設置源屬性" + }, + "fullscreen": { + "expand": "展開到全螢幕", + "exit": "退出全螢幕", + "toggle": "切換全螢幕模式", + "fullscreen": "全螢幕" + }, + "function": { + "function": "函數" + }, + "gateway": { + "add-entry": "增加配置", + "connector-add": "增加新連接器", + "connector-enabled": "啟用連接器", + "connector-name": "連接器名稱", + "connector-name-required": "需要連接器名稱。", + "connector-type": "連接器類型", + "connector-type-required": "需要連接器類型。", + "connectors": "連接器配置", + "create-new-gateway": "建立新閘道", + "create-new-gateway-text": "您確定要建立一個名稱為:'{{gatewayName}}'的新閘道嗎?", + "delete": "刪除配置", + "download-tip": "下載配置文件", + "gateway": "閘道", + "gateway-exists": "同名設備已存在。", + "gateway-name": "閘道名稱", + "gateway-name-required": "需要閘道名稱。", + "gateway-saved": "閘道配置已成功保存。", + "json-parse": "無效的JSON", + "json-required": "欄位不能為空。", + "no-connectors": "無連接器", + "no-data": "無配置", + "no-gateway-found": "未找到閘道。", + "no-gateway-matching": " 未找到'{{item}}'。", + "path-logs": "日誌文件的路徑", + "path-logs-required": "需要路徑。", + "remote": "移除配置", + "remote-logging-level": "日誌記錄級別", + "remove-entry": "移除配置", + "save-tip": "保存配置文件", + "security-type": "安全類型", + "security-types": { + "access-token": "訪問Token", + "tls": "TLS" + }, + "storage": "貯存", + "storage-max-file-records": "文件中的最大紀錄", + "storage-max-files": "最大文件數", + "storage-max-files-min": "最小數量為1。", + "storage-max-files-pattern": "號碼無效。", + "storage-max-files-required": "需要號碼。", + "storage-max-records": "存儲中的最大紀錄", + "storage-max-records-min": "最小紀錄數為1。", + "storage-max-records-pattern": "號碼無效。", + "storage-max-records-required": "需要最大紀錄數", + "storage-pack-size": "最大事件包大小", + "storage-pack-size-min": "最小數量為1。", + "storage-pack-size-pattern": "號碼無效.", + "storage-pack-size-required": "需要最大事件包大小", + "storage-path": "存儲路徑", + "storage-path-required": "需要存儲路徑。", + "storage-type": "存儲類型", + "storage-types": { + "file-storage": "文件存儲", + "memory-storage": "記憶體存儲" + }, + "thingsboard": "ThingsBoard", + "thingsboard-host": "ThingsBoard主機", + "thingsboard-host-required": "需要主機。", + "thingsboard-port": "ThingsBoard連接埠", + "thingsboard-port-max": "最大埠號為 65535。", + "thingsboard-port-min": "最小埠號為1。", + "thingsboard-port-pattern": "連接埠無效。", + "thingsboard-port-required": "需要連接埠。", + "tidy": "整理", + "tidy-tip": "整理配置JSON", + "title-connectors-json": "連接器{{typeName}}配置", + "tls-path-ca-certificate": "閘道上CA證書的路徑", + "tls-path-client-certificate": "閘道上用戶端憑據的路徑", + "tls-path-private-key": "閘道上的私鑰路徑", + "toggle-fullscreen": "切換全螢幕", + "transformer-json-config": "配置JSON*", + "update-config": "增加/更新配置JSON" + }, + "grid": { + "delete-item-title": "您確定要刪除此項嗎?", + "delete-item-text": "注意,確認後此項及其所有相關資料將變得無法恢復。", + "delete-items-title": "你確定你要刪除 { count, plural, 1 {1 項} other {# 項} }嗎?", + "delete-items-action-title": "刪除 { count, plural, 1 {1 項} other {# 項} }", + "delete-items-text": "注意,確認後所有選擇的項目將被刪除,所有相關資料將無法恢復。", + "add-item-text": "增加新項目", + "no-items-text": "沒有找到項目", + "item-details": "項目詳細訊息", + "delete-item": "刪除項目", + "delete-items": "刪除項目", + "scroll-to-top": "滾動到頂部" + }, + "help": { + "goto-help-page": "轉到幫助頁面", + "show-help": "顯示幫助" + }, + "home": { + "home": "首頁", + "profile": "屬性", + "logout": "註銷", + "menu": "菜單", + "avatar": "頭像", + "open-user-menu": "打開用戶菜單" + }, + "file-input": { + "browse-file": "瀏覽文件", + "browse-files": "瀏覽文件" + }, + "image-input": { + "drop-image-or": "拖放圖像或", + "drop-images-or": "拖放圖像或", + "no-images": "未選擇任何圖像", + "images": "圖像" + }, + "import": { + "no-file": "沒有選擇文件", + "drop-file": "拖動一個JSON文件或者單擊以選擇要上傳的文件。", + "drop-json-file-or": "拖放JSON文件或", + "drop-file-csv": "拖放CSV文件或單擊以選擇要上傳的文件。", + "drop-file-csv-or": "拖放CSV文件或", + "column-value": "值", + "column-title": "標題", + "column-example": "示例值資料", + "column-key": "屬性/遙測鍵", + "credentials": "證書", + "csv-delimiter": "CSV分隔符號", + "csv-first-line-header": "第一行包含列名", + "csv-update-data": "更新屬性/遙測", + "details": "詳細資訊", + "import-csv-number-columns-error": "一個文件應至少包含兩列", + "import-csv-invalid-format-error": "文件格式無效。行: '{{line}}'", + "column-type": { + "name": "名稱", + "type": "類型", + "label": "標籤", + "column-type": "列類型", + "client-attribute": "客戶端屬性", + "shared-attribute": "共享屬性", + "server-attribute": "伺服器屬性", + "timeseries": "時間序列", + "entity-field": "實體字段", + "access-token": "訪問token", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT客戶ID", + "user-name": "MQTT用戶名稱", + "password": "MQTT密碼" + }, + "lwm2m": { + "client-endpoint": "LwM2M端點客戶端名稱", + "security-config-mode": "LwM2M安全配置模式", + "client-identity": "LwM2M客戶端身份", + "client-key": "LwM2M 客戶密鑰", + "client-cert": "LwM2M客戶公鑰", + "bootstrap-server-security-mode": "LwM2M bootstrap server 安全模式", + "bootstrap-server-secret-key": "LwM2M bootstrap server 密鑰", + "bootstrap-server-public-key-id": "LwM2M bootstrap server 公鑰或ID", + "lwm2m-server-security-mode": "LwM2M伺服器安全模式", + "lwm2m-server-secret-key": "LwM2M伺服器密鑰", + "lwm2m-server-public-key-id": "LwM2M伺服器公鑰或ID" + }, + "isgateway": "是閘道", + "activity-time-from-gateway-device": "來自閘道設備的活動時間", + "description": "描述", + "routing-key": "邊緣鍵", + "secret": "邊緣密鑰" + }, + "stepper-text": { + "select-file": "選擇一個文件", + "configuration": "匯入配置", + "column-type": "選擇列類型", + "creat-entities": "創造新實體" + }, + "message": { + "create-entities": "已成功建立{{count}}個新實體。", + "update-entities": "{{count}}個實體已成功更新。", + "error-entities": "建立 {{count}}個實體時出錯。" + } + }, + "item": { + "selected": "選擇" + }, + "js-func": { + "no-return-error": "函數必須返回值!", + "return-type-mismatch": "函數必須返回 '{{type}}' 類型的值!", + "tidy": "整理", + "mini": "迷你" + }, + "key-val": { + "key": "鍵名", + "value": "值", + "remove-entry": "刪除條目", + "add-entry": "增加條目", + "no-data": "沒有條目" + }, + "layout": { + "layout": "佈局", + "manage": "佈局管理", + "settings": "佈局設定", + "color": "顏色", + "main": "主體", + "right": "右側", + "select": "選擇目標佈局" + }, + "legend": { + "position": "圖例位置", + "show-max": "顯示最大值", + "show-min": "顯示最小值", + "show-avg": "顯示平均值", + "show-total": "顯示總數", + "settings": "圖例設定", + "min": "最小值", + "max": "最大值", + "avg": "平均值", + "total": "總數" + }, + "login": { + "login": "登入", + "request-password-reset": "請求密碼重置", + "reset-password": "重置密碼", + "create-password": "建立密碼", + "two-factor-authentication": "雙因素身份驗證", + "passwords-mismatch-error": "輸入的密碼必須相同!", + "password-again": "再次輸入密碼", + "sign-in": "登入 ", + "username": "用戶名(電子郵件)", + "remember-me": "記住我", + "forgot-password": "忘記密碼?", + "password-reset": "密碼重置", + "expired-password-reset-message": "您的憑據已過期!請建立新密碼。", + "new-password": "新密碼", + "new-password-again": "再次輸入新密碼", + "password-link-sent-message": "密碼重置連結已成功發送!", + "email": "電子郵件", + "login-with": "使用{{name}}登入", + "or": "或", + "error": "登入錯誤", + "verify-your-identity": "驗證您的身份", + "select-way-to-verify": "選擇驗證方式", + "resend-code": "重發碼", + "resend-code-wait": "以 { time, plural, 1 {1 秒} other {# 秒} }重新發送程式碼", + "try-another-way": "嘗試別的方式", + "totp-auth-description": "請輸入安全驗證碼從您的驗證應用程式", + "totp-auth-placeholder": "程式碼", + "sms-auth-description": "安全代碼已發送至您的手機{{contact}}。", + "sms-auth-placeholder": "SMS程式碼", + "email-auth-description": "安全代碼已發送到您位於{{contact}}的電子郵件地址。", + "email-auth-placeholder": "電子郵件代碼", + "backup-code-auth-description": "請輸入您的備用代碼之一。", + "backup-code-auth-placeholder": "備用代碼" + }, + "markdown": { + "edit": "編輯", + "preview": "預覽", + "copy-code": "點擊複製", + "copied": "複製!" + }, + "ota-update": { + "add": "增加套件", + "assign-firmware": "指定韌體", + "assign-firmware-required": "指定韌體必填", + "assign-software": "指定軟體", + "assign-software-required": "指定軟體必填", + "auto-generate-checksum": "自動生成核對", + "checksum": "核對", + "checksum-hint": "如果核對為空,將自動生成", + "checksum-algorithm": "核對演算法", + "checksum-copied-message": "套件核對已複製到剪貼板", + "change-firmware": "韌體的更改可能會導致{ count, plural, 1 {1 設備} other {# 設備} }的更新。", + "change-software": "軟體的更改可能會導致{ count, plural, 1 {1 設備} other {# 設備} }的更新。", + "chose-compatible-device-profile": "上傳套件只有在所選擇的設備配置檔案獲得", + "chose-firmware-distributed-device": "選擇將分發到設備的韌體", + "chose-software-distributed-device": "選擇將分發到設備的軟體", + "content-type": "內容類型", + "copy-checksum": "複製校驗碼", + "copy-direct-url": "複製直接URL", + "copyId": "複製套件Id", + "copied": "複製!", + "delete": "刪除套件", + "delete-ota-update-text": "小心,確認後OTA更新將無法恢復。", + "delete-ota-update-title": "您確定要刪除OTA更新 '{{title}}'嗎?", + "delete-ota-updates-text": "小心,確認後,所有選擇的OTA更新都將被刪除。", + "delete-ota-updates-title": "您確定要刪除{ count, plural, 1 {1 OTA更新} other {# OTA更新} }嗎?", + "description": "描述", + "direct-url": "直接URL", + "direct-url-copied-message": "軟體包直接URL已複製到剪貼板", + "direct-url-required": "需要直接URL", + "download": "下載套件", + "drop-file": "拖放軟體包文件或單擊以選擇要上傳的文件。", + "drop-package-file-or": "拖放軟體包文件或", + "file-name": "文件名稱", + "file-size": "文件大小", + "file-size-bytes": "文件大小(位元組)", + "idCopiedMessage": "軟體包ID已複製到剪貼板", + "no-firmware-matching": "未找到與'{{entity}}' 匹配的兼容韌體OTA更新軟體包", + "no-firmware-text": "未提供兼容的韌體OTA更新軟體包。", + "no-packages-text": "未找到套件", + "no-software-matching": "未找到與 '{{entity}}' 匹配的兼容軟體OTA更新軟體包。", + "no-software-text": "未提供兼容的軟體OTA更新軟體包。", + "ota-update": "OTA更新", + "ota-update-details": "OTA更新詳情", + "ota-updates": "OTA更新", + "package-type": "套件類型", + "packages-repository": "軟體包存儲庫", + "search": "搜尋套件", + "selected-package": "{ count, plural, 1 {1 套件} other {# 套件} } 被選中", + "title": "標題", + "title-required": "需要標題。", + "title-max-length": "標題應小於256", + "types": { + "firmware": "韌體", + "software": "軟體" + }, + "upload-binary-file": "上傳二進制文件", + "use-external-url": "使用外部URL", + "version": "版本", + "version-required": "需要版本。", + "version-tag": "版本標記", + "version-tag-hint": "自定義標記應與您設備報告的軟體包版本匹配。", + "version-max-length": "版本應小於256", + "warning-after-save-no-edit": "上傳軟體包後,您將無法修改標題、版本、設備配置文件和軟體包類型。" + }, + "position": { + "top": "頂部", + "bottom": "底部", + "left": "左側", + "right": "右側" + }, + "profile": { + "profile": "屬性", + "change-password": "更改密碼", + "current-password": "當前密碼", + "copy-jwt-token": "複製JWT token", + "jwt-token": "JWT token", + "token-valid-till": "Token有效期至", + "tokenCopiedSuccessMessage": "JWT token已複製到剪貼板", + "tokenCopiedWarnMessage": "JWT token已過期,請刷新頁面。" + }, + "security": { + "security": "安全性", + "2fa": { + "2fa": "雙因素身份驗證", + "2fa-description": "雙因素身份驗證可保護您的帳戶免受未經授權的訪問。您只需在登入時輸入安全碼。", + "authenticate-with": "您可以通過以下方式進行身份驗證:", + "disable-2fa-provider-text": "禁用{{name}}將會降低您的帳戶安全性", + "disable-2fa-provider-title": "您確定要禁用{{name}}嗎?", + "get-new-code": "獲取新程式碼", + "main-2fa-method": "用作主要的雙因素身份驗證方法", + "dialog": { + "activation-step-description-email": "下次登入時,系統會提示您輸入將發送到您的電子郵件地址的安全代碼。", + "activation-step-description-sms": "下次登入時,系統會提示您輸入將發送到電話號碼的安全碼。", + "activation-step-description-totp": "下次登入時,您將需要提供一個雙因素身份驗證代碼。", + "activation-step-label": "啟動", + "backup-code-description": "列印出代碼,以便在您需要使用它們登入帳號時方便使用。每個備用代碼可以使用一次。", + "backup-code-warn": "離開此頁面後,這些代碼將無法再次顯示。使用以下選項安全地存儲它們。", + "download-txt": "下載(txt)", + "email-step-description": "輸入電子郵件以用作您的身份驗證器", + "email-step-label": "電子郵件", + "enable-email-title": "啟用電子郵件驗證器", + "enable-sms-title": "啟用SMS驗證器", + "enable-totp-title": "啟用驗證器應用程式", + "enter-verification-code": "在此輸入6位數代碼", + "get-backup-code-title": "獲取備用代碼", + "next": "下一個", + "scan-qr-code": "使用您的驗證應用程式掃描此QR code", + "send-code": "發送代碼", + "sms-step-description": "輸入一個電話號碼以用作您的身份驗證器", + "sms-step-label": "電話號碼", + "success": "成功!", + "totp-step-description-install": "您可以安裝Google Authenticator、Authy或Duo等應用程式。", + "totp-step-description-open": "在您的手機上打開身份驗證器應用程式", + "totp-step-label": "取得應用程式", + "verification-code": "6位數代碼", + "verification-code-invalid": "驗證碼格式無效", + "verification-code-incorrect": "驗證碼不正確", + "verification-code-many-request": "檢查驗證碼的請求過多", + "verification-step-description": "輸入我們剛剛發送到{{address}}的6位數代碼", + "verification-step-label": "驗證" + }, + "provider": { + "email": "電子郵件", + "email-description": "使用發送到您電子郵件地址的安全代碼進行身份驗證。", + "email-hint": "驗證碼通過電子郵件發送到{{ info }}", + "sms": "SMS", + "sms-description": "使用您的手機進行身份驗證。當您登入時,我們會透過SMS訊息向您發送一個安全碼。", + "sms-hint": "驗證碼通過簡訊發送到{{ info }}", + "totp": "身份驗證器應用程式", + "totp-description": "在您的手機上使用Google Authenticator、Authy或Duo等應用程式進行身份驗證。它將生成用於登入的安全代碼。", + "totp-hint": "已為您的帳戶設定身份驗證器應用程式", + "backup_code": "備用代碼", + "backup-code-description": "這些可列印的一次性密碼可讓您在離開手機時(例如在旅行時)登入。", + "backup-code-hint": "{{ info }}一次性代碼目前處於活動狀態" + } + }, + "password-requirement": { + "at-least": "至少:", + "character": "{ count, plural, 1 {1 字元} other {# 字元} }", + "digit": "{ count, plural, 1 {1 數字} other {# 數字} }", + "incorrect-password-try-again": "密碼不正確。再試一次", + "lowercase-letter": "{ count, plural, 1 {1 小寫字母} other {# 小寫字母} }", + "new-passwords-not-match": "新密碼不匹配", + "password-should-not-contain-spaces": "您的密碼不應包含空格", + "password-not-meet-requirements": "密碼不符合要求", + "password-requirements": "密碼要求", + "password-should-difference": "新密碼應與當前密碼不同", + "special-character": "{ count, plural, 1 {1 特殊字元} other {# 特殊字元} }", + "uppercase-letter": "{ count, plural, 1 {1 大寫字母} other {# 大寫字母} }" + } + }, + "relation": { + "relations": "關聯", + "direction": "方向", + "search-direction": { + "FROM": "從", + "TO": "到" + }, + "direction-type": { + "FROM": "從", + "TO": "到" + }, + "from-relations": "向外的關聯", + "to-relations": "向內的關聯", + "selected-relations": "{ count, plural, 1 {1 關聯} other {# 關聯} } 被選中", + "type": "類型", + "to-entity-type": "到實體類型", + "to-entity-name": "到實體名稱", + "from-entity-type": "從實體類型", + "from-entity-name": "從實體類型", + "to-entity": "到實體", + "from-entity": "從實體", + "delete": "刪除關聯", + "relation-type": "關聯類型", + "relation-type-required": "關聯類型必填", + "relation-type-max-length": "關聯類型應小於256", + "any-relation-type": "任意類型", + "add": "增加關聯", + "edit": "編輯關聯", + "delete-to-relation-title": "確定要刪除實體 '{{entityName}}' 的關聯嗎?", + "delete-to-relation-text": "確定刪除後實體 '{{entityName}}' 將取消與當前實體的關聯關係。", + "delete-to-relations-title": "確定要刪除 { count, plural, 1 {1 關聯} other {# 關聯} }?", + "delete-to-relations-text": "確定刪除所有選擇的關聯關係後,與當前實體對應的所有關聯關係將被移除。", + "delete-from-relation-title": "確定要從實體 '{{entityName}}' 刪除關聯嗎?", + "delete-from-relation-text": "確定刪除後,當前實體將與實體 '{{entityName}}' 取消關聯", + "delete-from-relations-title": "確定刪除 { count, plural, 1 {1 關聯} other {# 關聯} } 嗎?", + "delete-from-relations-text": "確定刪除所有選擇的關聯關係後,當前實體將與對應的實體取消關聯", + "remove-relation-filter": "移除關聯過濾器", + "add-relation-filter": "增加關聯過濾器", + "any-relation": "任意關聯", + "relation-filters": "關聯過濾器", + "additional-info": "附加訊息 (JSON)", + "invalid-additional-info": "無法解析附加訊息json。", + "no-relations-text": "未找到關聯" + }, + "resource": { + "add": "增加資源", + "copyId": "複製資源ID", + "delete": "刪除資源", + "delete-resource-text": "小心,確認後資源將無法恢復。", + "delete-resource-title": "您確定要刪除資源'{{resourceTitle}}'嗎?", + "delete-resources-action-title": "刪除 { count, plural, 1 {1 資源} other {# 資源} }", + "delete-resources-text": "請注意,所選資源,即使用於設備協議,也將被刪除。", + "delete-resources-title": "您確定要刪除 { count, plural, 1 {1 資源} other {# 資源} }嗎?", + "download": "下載資源", + "drop-file": "拖放資源文件或單擊以選擇要上傳的文件。", + "drop-resource-file-or": "拖放資源文件或", + "empty": "資源為空", + "file-name": "文件名", + "idCopiedMessage": "資源ID已複製到剪貼板", + "no-resource-matching": "找不到與 '{{widgetsBundle}}'匹配的資源。", + "no-resource-text": "未找到資源", + "open-widgets-bundle": "打開部件包", + "resource": "資源", + "resource-library-details": "資源詳細資訊", + "resource-type": "資源類型", + "resources-library": "資源庫", + "search": "搜尋資源", + "selected-resources": "{ count, plural, 1 {1 資源} other {# 資源} } 被選中", + "system": "系統", + "title": "標題", + "title-required": "需要標題。", + "title-max-length": "標題應小於256" + }, + "rulechain": { + "rulechain": "規則鏈", + "rulechains": "規則鏈庫", + "root": "根實體", + "delete": "刪除規則", + "activate": "啟動規則", + "suspend": "暫停規則", + "active": "啟動", + "suspended": "暫停", + "name": "名稱", + "name-required": "名稱必填。", + "description": "描述", + "add": "增加規則", + "set-root": "建立規則鏈根", + "set-root-rulechain-title": "您確定要生成規則鏈'{{RuleChainName}}'根嗎?", + "set-root-rulechain-text": "確認之後,規則鏈將變為根規格鏈,並將處理所有傳入的傳輸消息。", + "delete-rulechain-title": " 確實要刪除規則鏈'{{ruleChainName}}'嗎?", + "delete-rulechain-text": "小心,在確認規則鏈和所有相關資料將變得無法恢復。", + "delete-rulechains-title": "確實要刪除{count, plural, 1 { 1 規則鏈} other {# 規則鏈庫} }嗎?", + "delete-rulechains-action-title": "刪除 { count, plural, 1 {1 規則鏈} other {# 規則鏈庫} }", + "delete-rulechains-text": "小心,確認後,所有選擇的規則鏈將被刪除,所有相關的資料將變得無法恢復。", + "add-rulechain-text": "增加新的規則鏈", + "no-rulechains-text": "規則鏈沒有發現", + "rulechain-details": "規則鏈詳細資訊", + "details": "詳細資訊", + "events": "事件", + "system": "系統", + "import": "匯入規則", + "export": "匯出規則", + "export-failed-error": "無法匯出規則:{{error}}", + "create-new-rulechain": "建立新的規則鏈", + "rulechain-file": "規則鏈文件", + "invalid-rulechain-file-error": "不能匯入規則鏈:無效的規則鏈資料格式。", + "copyId": "複製規則鏈ID", + "idCopiedMessage": "規則ID已經複製到剪貼板", + "select-rulechain": "選擇規則鏈", + "no-rulechains-matching": "沒有發現符合'{{entity}}'的規則鏈。", + "rulechain-required": "規則鏈必填", + "management": "規則集管理", + "debug-mode": "測試模式" + }, + "rulenode": { + "details": "詳細資訊", + "events": "事件", + "search": "搜尋節點", + "open-node-library": "打開節點庫", + "add": "增加規則節點", + "name": "名稱", + "name-required": "名稱必填。", + "name-max-length": "名稱應小於256", + "type": "類型", + "description": "描述", + "delete": "刪除規則節點", + "select-all-objects": "選擇所有節點和連接", + "deselect-all-objects": "取消選擇所有節點和連接", + "delete-selected-objects": "刪除選擇的節點和連接", + "delete-selected": "刪除選擇", + "create-nested-rulechain": "建立嵌套規則鏈", + "select-all": "選擇全部", + "copy-selected": "選擇副本", + "deselect-all": "取消選擇", + "rulenode-details": "規則節點詳細資訊", + "debug-mode": "測試模式", + "configuration": "配置", + "link": "連結", + "link-details": "規則節點連結詳細資訊", + "add-link": "增加連結", + "link-label": "連結標籤", + "link-label-required": "連結標籤必填", + "custom-link-label": "自定義連結標籤", + "custom-link-label-required": "自定義連結標籤必填", + "link-labels": "連結標籤", + "link-labels-required": "需要連結標籤。", + "no-link-labels-found": "找不到連結標籤", + "no-link-label-matching": "沒找到'{{label}}'。", + "create-new-link-label": "建立一個新的!", + "type-filter": "過濾器", + "type-filter-details": "使用配置條件過濾傳入消息", + "type-enrichment": "屬性集", + "type-enrichment-details": "向消息元資料中增加附加訊息", + "type-transformation": "變換", + "type-transformation-details": "更改消息有效載荷和元資料", + "type-action": "動作", + "type-action-details": "執行特別動作", + "type-external": "外部的", + "type-external-details": "與外部系統交互", + "type-rule-chain": "規則鏈", + "type-rule-chain-details": "將傳入消息轉發到指定的規則鏈", + "type-flow": "流動", + "type-flow-details": "組織消息流", + "type-input": "輸入", + "type-input-details": "規則鏈的邏輯輸入,將傳入消息轉發到下一個相關規則節點", + "type-unknown": "未知", + "type-unknown-details": "未解析的規則節點", + "directive-is-not-loaded": "定義的配置指令 '{{directiveName}}' 不可用。", + "ui-resources-load-error": "加載配置UI資源失敗。", + "invalid-target-rulechain": "無法解析目標規則鏈!", + "test-script-function": "測試腳本功能", + "message": "消息", + "message-type": "消息類型", + "select-message-type": "選擇消息類型", + "message-type-required": "消息類型必填", + "metadata": "元資料", + "metadata-required": "元資料項不能為空。", + "output": "輸出", + "test": "測試", + "help": "幫助", + "reset-debug-mode": "重置所有節點中的調試模式" + }, + "timezone": { + "timezone": "時區", + "select-timezone": "選擇時區", + "no-timezones-matching": "找不到匹配的時區'{{timezone}}' ", + "timezone-required": "時區必填", + "browser-time": "瀏覽時間" + }, + "queue": { + "queue-name": "佇列", + "no-queues-found": "找不到佇列", + "no-queues-matching": "未找到與'{{queue}}'匹配的佇列。", + "select-name": "選擇佇列名稱", + "name": "名稱", + "name-required": "佇列名稱必填", + "name-unique": "佇列名稱不是唯一值", + "name-pattern": "佇列名稱包含ASCII字母數字以外的字元'.'、 '_' 和 '-'!", + "queue-required": "需要佇列!", + "topic-required": "佇列主題必填", + "poll-interval-required": "輪詢區隔時間必填", + "poll-interval-min-value": "輪詢區隔時間值不可小於1", + "partitions-required": "分區必填", + "partitions-min-value": "分區值不可小於1", + "pack-processing-timeout-required": "處理超時必填", + "pack-processing-timeout-min-value": "處理超時值不可小於1", + "batch-size-required": "批次檔尺寸必填", + "batch-size-min-value": "批次檔尺寸值不可小於1", + "retries-required": "重試必填", + "retries-min-value": "重試值不可為否", + "failure-percentage-required": "故障百分比必填", + "failure-percentage-min-value": "故障百分比不可小於0", + "failure-percentage-max-value": "故障百分比不可大於100", + "pause-between-retries-required": "重試之間的暫停必填", + "pause-between-retries-min-value": "重試之間的暫停不可小於1", + "max-pause-between-retries-required": "重試之間的暫停最大值必填", + "max-pause-between-retries-min-value": "重試之間的暫停最大值不可小於1", + "submit-strategy-type-required": "提交策略類型必填", + "processing-strategy-type-required": "處理策略類型必填", + "queues": "佇列", + "selected-queues": "{ count, plural, 1 {1 佇列} other {# 佇列} }被選中", + "delete-queue-title": "您確定要刪除佇列'{{queueName}}'嗎?", + "delete-queues-title": "您確定要刪除{ count, plural, 1 {1 佇列} other {# 佇列} }嗎?", + "delete-queue-text": "小心,確認後佇列和所有相關資料將無法恢復。", + "delete-queues-text": "確認後,所有選定的佇列將被刪除且無法訪問。", + "search": "搜尋佇列", + "add": "增加佇列", + "details": "佇列詳細資訊", + "topic": "主題", + "submit-settings": "提交設定", + "submit-strategy": "策略類型 *", + "grouping-parameter": "群組參數", + "processing-settings": "重試處理設定", + "processing-strategy": "處理類型 *", + "retries-settings": "重試設定", + "polling-settings": "輪詢設定", + "batch-processing": "批次設定", + "poll-interval": "輪詢間隔", + "partitions": "分區", + "immediate-processing": "立刻處理", + "consumer-per-partition": "發送輪訊訊息給每位顧客", + "consumer-per-partition-hint": "啓用個別顧客的分區", + "processing-timeout": "處理範圍內,毫秒", + "batch-size": "批量大小", + "retries": "重試次數 (0 – 無限制)", + "failure-percentage": "跳過重試的失敗消息百分比", + "pause-between-retries": "在以下範圍內重試,秒", + "max-pause-between-retries": "在秒內的其他重試", + "delete": "刪除佇列", + "copyId": "複製佇列ID", + "idCopiedMessage": "佇列ID已複製到剪貼板", + "description": "描述", + "description-hint": "此文字將顯示在佇列描述中,而不是所選策略中", + "alt-description": "提交策略:{{submitStrategy}},處理策略:{{processingStrategy}}", + "strategies": { + "sequential-by-originator-label": "按發起人排序", + "sequential-by-originator-hint": "例如,在確認設備A的上一條消息之前,不會提交設備A的新消息", + "sequential-by-tenant-label": "按租戶排序", + "sequential-by-tenant-hint": "例如,在確認租戶A的上一條消息之前,不會提交租戶A的新消息", + "sequential-label": "順序的", + "sequential-hint": "在前一條消息被確認之前不會提交新消息", + "burst-label": "破裂", + "burst-hint": "所有消息都按照它們到達的順序提交到規則鏈", + "batch-label": "批次", + "batch-hint": "在確認前一批之前不會提交新批次", + "skip-all-failures-label": "跳過所有故障", + "skip-all-failures-hint": "忽略所有故障", + "skip-all-failures-and-timeouts-label": "跳過所有故障與超時", + "skip-all-failures-and-timeouts-hint": "忽略所有故障與超時", + "retry-all-label": "全部重試", + "retry-all-hint": "所有加工處理套件重試訊息", + "retry-failed-label": "重試失敗", + "retry-failed-hint": "所有加工處理套件重試失敗訊息", + "retry-timeout-label": "重試超時", + "retry-timeout-hint": "所有加工處理套件重試超時訊息", + "retry-failed-and-timeout-label": "重試失敗與超時", + "retry-failed-and-timeout-hint": "所有加工處理套件重試失敗與超時訊息" + } + }, + "server-error": { + "general": "常規伺服器錯誤", + "authentication": "身份驗證錯誤", + "jwt-token-expired": "JWT token已過期", + "tenant-trial-expired": "租戶試用已過期", + "credentials-expired": "憑據已過期", + "permission-denied": "許可權被拒絕", + "invalid-arguments": "無效參數", + "bad-request-params": "錯誤的請求參數", + "item-not-found": "未找到項目", + "too-many-requests": "請求過多", + "too-many-updates": "更新過多" + }, + "tenant": { + "tenant": "租戶", + "tenants": "租戶", + "management": "租戶管理", + "add": "增加租戶", + "admins": "管理員", + "manage-tenant-admins": "管理租戶管理員", + "delete": "刪除租戶", + "add-tenant-text": "增加新租戶", + "no-tenants-text": "沒有找到租戶", + "tenant-details": "租客詳細資訊", + "title-max-length": "標題應小於256", + "delete-tenant-title": "您確定要刪除租戶'{{tenantTitle}}'嗎?", + "delete-tenant-text": "小心!確認後,租戶和所有相關資料將無法恢復。", + "delete-tenants-title": "您確定要刪除 {count,plural,1 {1 租戶} other {# 租戶} } 嗎?", + "delete-tenants-action-title": "刪除 { count, plural, 1 {1 租戶} other {# 租戶} }", + "delete-tenants-text": "小心!確認後,所有選擇的租戶將被刪除,所有相關資料將無法恢復。", + "title": "標題", + "title-required": "標題必填。", + "description": "描述", + "details": "詳細資訊", + "events": "事件", + "copyId": "複製租戶ID", + "idCopiedMessage": "租戶ID已經複製到剪貼板", + "select-tenant": "選擇租戶", + "no-tenants-matching": "沒有找到符合 '{{entity}}' 的租戶", + "tenant-required": "租戶必填", + "search": "搜尋租戶", + "selected-tenants": "{ count, plural, 1 {1 租戶} other {# 租戶} } 已選中", + "isolated-tb-rule-engine": "在隔離的ThingsBoard Rule規則引擎容器中處理", + "isolated-tb-rule-engine-details": "每個獨立租戶需要單獨的微服務" + }, + "tenant-profile": { + "tenant-profile": "租戶屬性", + "tenant-profiles": "租戶屬性", + "add": "新增租戶屬性", + "edit": "編輯租戶屬性", + "tenant-profile-details": "租戶屬性詳細資料", + "no-tenant-profiles-text": "找不到租戶屬性", + "name-max-length": "租戶屬性名稱應小於256", + "search": "搜尋租戶屬性", + "selected-tenant-profiles": "{ count, plural, 1 {1 租戶屬性} other {# 租戶屬性} } 被選中", + "no-tenant-profiles-matching": "找不到與'{{entity}}'匹配的租戶屬性 ", + "tenant-profile-required": "租戶屬性為必填", + "idCopiedMessage": "租戶屬性Id已複製到剪貼板", + "set-default": "將租戶屬性設為預設值", + "delete": "刪除租戶屬性", + "copyId": "複製租戶屬性Id", + "name": "姓名", + "name-required": "姓名為必填", + "data": "屬性資料", + "profile-configuration": "屬性配置", + "description": "描述", + "default": "預設", + "delete-tenant-profile-title": "是否確定要刪除租戶屬性 '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "小心,確認後租戶屬性及所有相關資料將變得無法恢復。", + "delete-tenant-profiles-title": "您確定要刪除 { count, plural, 1 {1 租戶屬性} other {# 租戶屬性} }嗎?", + "delete-tenant-profiles-text": "小心,確認後所有選定的租戶屬性將被刪除,所有相關資料將變得無法恢復。", + "set-default-tenant-profile-title": "是否確定要將租戶屬性'{{tenantProfileName}}' 設為預設值?", + "set-default-tenant-profile-text": "確認後,租戶屬性將被標記為預設值,並將用於未指定屬性的新租戶。", + "no-tenant-profiles-found": "未找到租戶屬性", + "create-new-tenant-profile": "創造一個新的!", + "create-tenant-profile": "創造新的租戶屬性", + "import": "匯入租戶屬性", + "export": "匯出租戶屬性", + "export-failed-error": "無法匯出租戶屬性: {{error}}", + "tenant-profile-file": "租戶屬性文件", + "invalid-tenant-profile-file-error": "無法匯入租戶屬性:無效的租戶屬性資料結構。", + "advanced-settings": "進階設定", + "entities": "實體", + "rule-engine": "規則引擎", + "time-to-live": "生存時間", + "alarms-and-notifications": "警告和通知", + "ota-files-in-bytes": "OTA文件(位元組)", + "ws-title": "WS", + "unlimited": "(0 - 無限制)", + "maximum-devices": "設備最大數量", + "maximum-devices-required": "設備最大數量必填", + "maximum-devices-range": "設備最大數量不可為否", + "maximum-assets": "資產最大數量", + "maximum-assets-required": "資產最大數量必填", + "maximum-assets-range": "資產最大數量不可為否", + "maximum-customers": "顧客最大數量", + "maximum-customers-required": "顧客最大數量必填", + "maximum-customers-range": "顧客最大數量不可為否", + "maximum-users": "用戶最大數量", + "maximum-users-required": "用戶最大數量必填", + "maximum-users-range": "用戶最大數量不可為否", + "maximum-dashboards": "儀表板最大數量", + "maximum-dashboards-required": "儀表板最大數量必填", + "maximum-dashboards-range": "儀表板最大數量不可為否", + "maximum-rule-chains": "規則鏈最大數量", + "maximum-rule-chains-required": "規則鏈最大數量必填", + "maximum-rule-chains-range": "規則鏈最大數量不可為否", + "maximum-resources-sum-data-size": "資源檔尺寸總計", + "maximum-resources-sum-data-size-required": "資源檔尺寸總計必填", + "maximum-resources-sum-data-size-range": "資源檔尺寸總計不可為否", + "maximum-ota-packages-sum-data-size": "OTA套件檔尺寸總計", + "maximum-ota-package-sum-data-size-required": "需要OTA套件檔總和大小。", + "maximum-ota-package-sum-data-size-range": "OTA套件檔尺寸總計不可為否", + "transport-tenant-msg-rate-limit": "傳輸租戶訊息", + "transport-tenant-telemetry-msg-rate-limit": "傳輸租戶遙測訊息", + "transport-tenant-telemetry-data-points-rate-limit": "傳輸租戶遙測資料端", + "transport-device-msg-rate-limit": "傳輸設備訊息", + "transport-device-telemetry-msg-rate-limit": "傳輸設備遙測訊息", + "transport-device-telemetry-data-points-rate-limit": "傳輸設備遙測資料端", + "tenant-entity-export-rate-limit": "實體版本建立", + "tenant-entity-import-rate-limit": "實體版本負載", + "max-transport-messages": "傳輸訊息最大數量", + "max-transport-messages-required": "傳輸訊息最大數量必填", + "max-transport-messages-range": "傳輸訊息最大數量不可為否", + "max-transport-data-points": "傳輸資料端最大數量", + "max-transport-data-points-required": "傳輸資料端最大數量比填", + "max-transport-data-points-range": "傳輸資料端最大數量不可為否", + "max-r-e-executions": "規則引擎執行最大數量", + "max-r-e-executions-required": "規則引擎執行最大數量必填", + "max-r-e-executions-range": "規則引擎執行最大數量不可為否", + "max-j-s-executions": "JavaScript執行最大數量", + "max-j-s-executions-required": "JavaScript執行最大數量必填", + "max-j-s-executions-range": "JavaScript執行最大數量不可為否", + "max-d-p-storage-days": "資料端儲存天最大數", + "max-d-p-storage-days-required": "資料端儲存天最大數必填", + "max-d-p-storage-days-range": "資料端儲存天最大數不可為否", + "default-storage-ttl-days": "預設資料端儲存天總計", + "default-storage-ttl-days-required": "預設資料端儲存天總計必填", + "default-storage-ttl-days-range": "預設資料端儲存天總計不可為否", + "alarms-ttl-days": "警告天總計", + "alarms-ttl-days-required": "警告天總計必填", + "alarms-ttl-days-days-range": "警告天總計不可為否", + "rpc-ttl-days": "RPC TTL天數", + "rpc-ttl-days-required": "需要RPC TTL天數", + "rpc-ttl-days-days-range": "RPC TTL天數不能為負數", + "max-rule-node-executions-per-message": "每一訊息執行的規則節點最大數", + "max-rule-node-executions-per-message-required": "每一訊息執行的規則節點最大數必填", + "max-rule-node-executions-per-message-range": "每一訊息執行的規則節點最大數不可為否", + "max-emails": "已寄發Email最大數", + "max-emails-required": "已寄發Email最大數必填", + "max-emails-range": "已寄發Email最大數必填不可為否", + "max-sms": "已寄發SMS最大數", + "max-sms-required": "已寄發SMS最大數必填", + "max-sms-range": "已寄發SMS最大數不可為否", + "max-created-alarms": "已建立警告最大數", + "max-created-alarms-required": "已建立警告最大數必填", + "max-created-alarms-range": "已建立警告最大數不可為否", + "no-queue": "無佇列配置", + "add-queue": "新增佇列", + "queues-with-count": "佇列 ({{count}})", + "tenant-rest-limits": "剩餘租戶請求", + "customer-rest-limits": "剩餘顧客請求", + "incorrect-pattern-for-rate-limits": "格式是逗號分隔的容量和週期(以秒為單位),中間有一個冒號,列如100:1,2000:60", + "too-small-value-zero": "該值必須大於0", + "too-small-value-one": "該值必須大於1", + "cassandra-tenant-limits-configuration": "租戶的Cassandra查詢", + "ws-limit-max-sessions-per-tenant": "每個租戶的最大對談數", + "ws-limit-max-sessions-per-customer": "每個客戶的最大對談數", + "ws-limit-max-sessions-per-regular-user": "每個普通用戶的最大對話數", + "ws-limit-max-sessions-per-public-user": "每個公共用戶的最大對談數", + "ws-limit-queue-per-session": "每個對談的消息佇列最大大小", + "ws-limit-max-subscriptions-per-tenant": "每個租戶的最大訂閱數", + "ws-limit-max-subscriptions-per-customer": "每個客戶的最大訂閱數", + "ws-limit-max-subscriptions-per-regular-user": "每個普通用戶的最大訂閱數", + "ws-limit-max-subscriptions-per-public-user": "每個公共用戶的最大訂閱數", + "ws-limit-updates-per-session": "每個對談的WS更新", + "rate-limits": { + "add-limit": "增加限制", + "advanced-settings": "進階設定", + "edit-limit": "編輯限制", + "but-less-than": "但小於", + "edit-transport-tenant-msg-title": "編輯傳輸租戶訊息速率限制", + "edit-transport-tenant-telemetry-msg-title": "編輯傳輸租戶遙測訊息速率限制", + "edit-transport-tenant-telemetry-data-points-title": "編輯傳輸租戶遙測資料端速率限制", + "edit-transport-device-msg-title": "編輯傳輸設備訊息速率限制", + "edit-transport-device-telemetry-msg-title": "編輯傳輸設備遙測訊息速率限制", + "edit-transport-device-telemetry-data-points-title": "編輯傳輸設備遙測資料端速率限制", + "edit-transport-tenant-msg-rate-limit-title": "編輯傳輸租戶訊息速率限制", + "edit-customer-rest-limits-title": "編輯剩餘顧客速率限制", + "edit-ws-limit-updates-per-session-title": "編輯每個對談的 WS更新速率限制", + "edit-cassandra-tenant-limits-configuration-title": "編輯租戶速率限制的 Cassandra 查詢", + "edit-tenant-entity-export-rate-limit-title": "編輯實體版本創建速率限制", + "edit-tenant-entity-import-rate-limit-title": "編輯實體版本加載速率限制", + "messages-per": "每條消息", + "not-set": "未設定", + "number-of-messages": "消息數", + "number-of-messages-required": "需要消息數。", + "number-of-messages-min": "最小值為1。", + "preview": "預覽", + "per-seconds": "每秒", + "per-seconds-required": "需要時間速率。", + "per-seconds-min": "最小值為1。", + "rate-limits": "速率限制", + "remove-limit": "移除限制", + "transport-tenant-msg": "傳輸租戶消息", + "transport-tenant-telemetry-msg": "傳輸租戶遙測消息", + "transport-tenant-telemetry-data-points": "傳輸租戶遙測資料點", + "transport-device-msg": "傳輸設備消息", + "transport-device-telemetry-msg": "傳輸設備遙測消息", + "transport-device-telemetry-data-points": "傳輸設備遙測資料點", + "sec": "秒" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, 1 {1 秒} other {# 秒} }", + "minutes-interval": "{ minutes, plural, 1 {1 分} other {# 分} }", + "hours-interval": "{ hours, plural, 1 {1 小時} other {# 小時} }", + "days-interval": "{ days, plural, 1 {1 天} other {# 天} }", + "days": "天", + "hours": "時", + "minutes": "分", + "seconds": "秒", + "advanced": "高級", + "predefined": { + "yesterday": "昨天", + "day-before-yesterday": "前天", + "this-day-last-week": "上週的這一天", + "previous-week": "前一週 (週日 - 週六)", + "previous-week-iso": "前一週 (週一 - 週日)", + "previous-month": "前一個月", + "previous-year": "上一年", + "current-hour": "當前時間", + "current-day": "當前日期", + "current-day-so-far": "當天到目前為止", + "current-week": "本週 (週日 - 週六)", + "current-week-iso": "本週 (週一 - 週日)", + "current-week-so-far": "本週到目前為止 (週日 - 週六)", + "current-week-iso-so-far": "本週到目前為止 (週一 - 週日)", + "current-month": "這個月", + "current-month-so-far": "本月至今", + "current-year": "今年", + "current-year-so-far": "本年度至今" + } + }, + "timeunit": { + "milliseconds": "毫秒", + "seconds": "秒", + "minutes": "分", + "hours": "時", + "days": "天" + }, + "timewindow": { + "days": "{ days, plural, 1 { 天 } other {# 天 } }", + "hours": "{ hours, plural, 0 { 小時 } 1 {1 小時 } other {# 小時 } }", + "minutes": "{ minutes, plural, 0 { 分 } 1 {1 分 } other {# 分 } }", + "seconds": "{ seconds, plural, 0 { 秒 } 1 {1 秒 } other {# 秒 } }", + "realtime": "實時", + "history": "歷史", + "last-prefix": "最後", + "period": "從 {{ startTime }} 到 {{ endTime }}", + "edit": "編輯時間窗口", + "date-range": "日期範圍", + "last": "最後", + "time-period": "時間段", + "hide": "隱藏", + "interval": "間隔" + }, + "user": { + "user": "用戶", + "users": "用戶", + "customer-users": "客戶用戶", + "tenant-admins": "租戶管理員", + "sys-admin": "系統管理員", + "tenant-admin": "租戶管理員", + "customer": "客戶", + "anonymous": "匿名", + "add": "增加用戶", + "delete": "刪除用戶", + "add-user-text": "增加新用戶", + "no-users-text": "找不到用戶", + "user-details": "用戶詳細訊息", + "delete-user-title": "您確定要刪除用戶 '{{userEmail}}' 嗎?", + "delete-user-text": "小心!確認後,用戶和所有相關資料將無法恢復。", + "delete-users-title": "你確定你要刪除 { count, plural, 1 {1 用戶} other {# 用戶} } 嗎?", + "delete-users-action-title": "刪除 { count, plural, 1 {1 用戶} other {# 用戶} }", + "delete-users-text": "小心!確認後,所有選擇的用戶將被刪除,所有相關資料將無法恢復。", + "activation-email-sent-message": "啟動電子郵件已成功發送!", + "resend-activation": "重新發送啟動", + "email": "電子郵件", + "email-required": "電子郵件必填。", + "invalid-email-format": "無效的郵件格式。", + "first-name": "名字", + "last-name": "姓", + "description": "描述", + "default-dashboard": "預設面板", + "always-fullscreen": "始終全螢幕", + "select-user": "選擇用戶", + "no-users-matching": "沒有找到符合 '{{entity}}' 的用戶。", + "user-required": "用戶必填", + "activation-method": "啟動方式", + "display-activation-link": "顯示啟動連結", + "send-activation-mail": "發送啟動郵件", + "activation-link": "用戶啟動連結", + "activation-link-text": "使用該連結 啟動 啟動用戶:", + "copy-activation-link": "複製用戶啟動連結", + "activation-link-copied-message": "用戶啟動連結已經複製到剪貼板", + "details": "詳細訊息", + "login-as-tenant-admin": "以租戶管理者帳號登入", + "login-as-customer-user": "以顧客用戶帳號登入", + "search": "搜尋用戶", + "selected-users": "{ count, plural, 1 {1 用戶} other {# 用戶} } 被選中", + "disable-account": "關閉用戶帳號", + "enable-account": "啓用用戶帳號", + "enable-account-message": "用戶帳號已成功啓用", + "disable-account-message": "用戶帳號已成功關閉", + "copyId": "複製用戶Id", + "idCopiedMessage": "用戶Id已複製到剪貼板" + }, + "value": { + "type": "值類型", + "string": "字符串", + "string-value": "字符串值", + "string-value-required": "需要字符串值", + "integer": "數字", + "integer-value": "數字值", + "integer-value-required": "需要整數值", + "invalid-integer-value": "整數值無效", + "double": "雙精度浮點數", + "double-value": "雙精度浮點數值", + "double-value-required": "需要雙值", + "boolean": "布林", + "boolean-value": "布林值", + "false": "假", + "true": "真", + "long": "長", + "json": "JSON", + "json-value": "JSON值", + "json-value-invalid": "JSON值的格式無效", + "json-value-required": "需要JSON值。" + }, + "version-control": { + "version-control": "版本控制", + "management": "版本控制管理", + "search": "搜尋版本", + "branch": "分支", + "default": "預設", + "select-branch": "選取分支", + "branch-required": "需要分支", + "create-entity-version": "創建實體版本", + "version-name": "版本名稱", + "version-name-required": "需要版本名稱", + "author": "作者", + "export-relations": "匯出關聯", + "export-attributes": "匯出屬性", + "export-credentials": "匯出憑據", + "entity-versions": "實體版本", + "versions": "本版", + "created-time": "創建時間", + "version-id": "版本ID", + "no-entity-versions-text": "未找到實體版本", + "no-versions-text": "未找到版本", + "copy-full-version-id": "複製完整版本ID", + "create-version": "建立版本", + "creating-version": "正在創建版本... 請稍候t", + "nothing-to-commit": "沒有要提交的更改", + "restore-version": "恢復版本", + "restore-entity-from-version": "從版本'{{versionName}}'恢復實體", + "restoring-entity-version": "正在恢復實體版本... 請稍候", + "load-relations": "負載關聯", + "load-attributes": "加載屬性", + "load-credentials": "加載憑據", + "compare-with-current": "與當前比較", + "diff-entity-with-version": "與實體版本'{{versionName}}'的差異", + "previous-difference": "上一個差異", + "next-difference": "下一個差異", + "current": "當前的", + "differences": "{ count, plural, 1 {1 差異} other {# 差異} }", + "create-entities-version": "創建實體版本", + "default-sync-strategy": "預設同步策略", + "sync-strategy-merge": "合併", + "sync-strategy-overwrite": "覆蓋", + "entities-to-export": "要匯出的實體", + "entities-to-restore": "要恢復的實體", + "sync-strategy": "同步策略", + "all-entities": "所有實體", + "no-entities-to-export-prompt": "請指定要匯出的實體", + "no-entities-to-restore-prompt": "請指定要恢復的實體", + "add-entity-type": "增加實體類型", + "remove-all": "移除全部", + "version-create-result": "{ added, plural, 0 {無實體} 1 {1 實體} other {# 實體} } 已增加。.
    { modified, plural, 0 {無實體} 1 {1 實體} other {# 實體} } 已修改。
    { removed, plural, 0 {無實體} 1 {1 實體} other {# 實體} } 已移除。", + "remove-other-entities": "移除其他實體", + "find-existing-entity-by-name": "按名稱查找現有實體", + "restore-entities-from-version": "從版本'{{versionName}}恢復實體'", + "restoring-entities-from-version": "正在恢復實體... 請稍候", + "no-entities-restored": "沒有恢復實體", + "created": "{{created}}已創建", + "updated": "{{updated}}已更新", + "deleted": "{{deleted}} 已刪除", + "remove-other-entities-confirm-text": "小心!這將永久刪除您要恢復的版本中不存在的所有當前實體。請鍵入刪除其他實體進行確認。", + "auto-commit-to-branch": "自動提交到{{ branch }}分支", + "default-create-entity-version-name": "{{entityName}} 更新", + "sync-strategy-merge-hint": "在存儲庫中創建或更新選定實體。所有其他存儲實體都不會被修改。", + "sync-strategy-overwrite-hint": "在存儲庫中創建或更新選定的實體。所有其他存儲庫實體都將被刪除。", + "device-credentials-conflict": "無法加載具有外部ID{{entityId}}的設備。
    因為資料庫中已存在另一台設備的相同憑據。 請考慮在恢復表單中禁用加載憑據設置。", + "missing-referenced-entity": "無法加載具有外部ID{{sourceEntityId}}的{{sourceEntityTypeName}},因為它引用了缺少的ID為{{targetEntityId}}的{{targetEntityTypeName}}。", + "runtime-failed": "失敗: {{message}}" + }, + "widget": { + "widget-library": "部件庫", + "widget-bundle": "部件包", + "all-bundles": "所有包", + "select-widgets-bundle": "選擇部件包", + "management": "管理部件", + "editor": "部件編輯器", + "widget-type-not-found": "加載部件配置出錯。
    可能關聯的\n 部件已經刪除了。", + "widget-type-load-error": "由於以下錯誤未加載小部件:", + "remove": "刪除部件", + "edit": "編輯部件", + "remove-widget-title": "確實要刪除 '{{widgetTitle}}'部件嗎?", + "remove-widget-text": "確認後,控件和所有相關資料將變得無法恢復。", + "timeseries": "時間序列", + "search-data": "搜尋資料", + "no-data-found": "沒有找到資料", + "latest": "最新值", + "rpc": "控件部件", + "alarm": "警告部件", + "static": "靜態部件", + "select-widget-type": "選擇窗口部件類型", + "missing-widget-title-error": "部件標題必須指定!", + "widget-saved": "部件已儲存", + "unable-to-save-widget-error": "無法儲存部件!控件有錯誤!", + "save": "儲存部件", + "saveAs": "部件另存為", + "save-widget-type-as": "部件類型另存為", + "save-widget-type-as-text": "請輸入新的部件標題或選擇目標部件包", + "toggle-fullscreen": "切換全螢幕", + "run": "執行部件", + "title": "部件標題", + "title-required": "需要部件標題。", + "type": "部件類型", + "resources": "資源", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "是模組", + "remove-resource": "刪除資源", + "add-resource": "增加資源", + "html": "HTML", + "tidy": "整理", + "css": "CSS", + "settings-schema": "設定模式", + "datakey-settings-schema": "資料鍵設定模式", + "latest-datakey-settings-schema": "最新資料鍵設置架構", + "widget-settings": "部件設定", + "description": "描述", + "image-preview": "圖像預覽", + "settings-form-selector": "設置表單選擇器", + "data-key-settings-form-selector": "資料鍵設置表單選擇器", + "latest-data-key-settings-form-selector": "最新資料鍵設置表單選擇器", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "您確定要刪除部件類型 '{{widgetName}}'嗎?", + "remove-widget-type-text": "確認後,窗口部件類型和所有相關資料將無法恢復。", + "remove-widget-type": "刪除部件類型", + "add-widget-type": "增加新的部件類型", + "widget-type-load-failed-error": "無法加載部件類型!", + "widget-template-load-failed-error": "無法加載部件模板!", + "add": "增加部件", + "undo": "復原部件更改", + "export": "匯出部件", + "no-data": "部件上沒有要顯示的資料", + "data-overflow": "小部件顯示{{count}}個,共{{total}}個實體", + "alarm-data-overflow": "工具顯示{{totalEntities}}個實體中的{{allowedEntities}} (最大允許)個實體的警告", + "search": "搜尋部件", + "filter": "部件過濾器類型", + "loading-widgets": "加載部件..." + }, + "widget-action": { + "header-button": "部件頂部按鈕", + "open-dashboard-state": "切換到新儀表板狀態", + "update-dashboard-state": "更新當前儀表板狀態", + "open-dashboard": "切換到另一個儀表板", + "custom": "自定義動作", + "custom-pretty": "自定義操作 (使用HTML模板)", + "mobile-action": "移動操作", + "target-dashboard-state": "目標儀表板狀態", + "target-dashboard-state-required": "目標儀表板狀態必填", + "set-entity-from-widget": "從部件中設定實體", + "target-dashboard": "目標儀表板", + "open-right-layout": "打開右側佈局 (移動端視圖)", + "state-display-type": "儀表板狀態顯示選項", + "open-normal": "切換正常", + "open-in-separate-dialog": "在分別的對話切換", + "open-in-popover": "彈出提示框", + "dialog-title": "對話標題", + "dialog-hide-dashboard-toolbar": "在對話工具欄中隱藏儀表板", + "dialog-width": "對話框寬度相對於視口寬度的百分比", + "dialog-height": "對話框高度相對於視口高度的百分比", + "dialog-size-range-error": "對話框大小百分比值應在1到100的範圍內。", + "popover-preferred-placement": "彈出提示框配置偏好", + "popover-placement-top": "頂部", + "popover-placement-topLeft": "頂部左側", + "popover-placement-topRight": "頂部右側", + "popover-placement-right": "右側", + "popover-placement-rightTop": "右側頂部", + "popover-placement-rightBottom": "右側底部", + "popover-placement-bottom": "底部", + "popover-placement-bottomLeft": "底部左側", + "popover-placement-bottomRight": "底部右側", + "popover-placement-left": "左側", + "popover-placement-leftTop": "左側頂部", + "popover-placement-leftBottom": "左側底部", + "popover-hide-on-click-outside": "在外部單擊時隱藏彈出視窗", + "popover-hide-dashboard-toolbar": "在彈出提示框中隱藏對話工具欄", + "popover-width": "瀏覽器單元中的彈出框寬度(例如100px、25vw)", + "popover-height": "瀏覽器單元中的彈出框高度 (例如100px、25vh)", + "popover-style": "彈出提示框形式", + "open-new-browser-tab": "在新的瀏覽器選項中打開", + "mobile": { + "action-type": "手機動作類型", + "action-type-required": "手機動作類型必填", + "take-picture-from-gallery": "由圖庫中擷取圖片", + "take-photo": "擷取圖片", + "map-direction": "開啓地圖指引", + "map-location": "開啓地圖位置", + "scan-qr-code": "掃描QR Code", + "make-phone-call": "打電話", + "get-location": "取得電話位置", + "take-screenshot": "擷取螢幕畫面" + } + }, + "widgets-bundle": { + "current": "當前包", + "widgets-bundles": "部件包", + "add": "增加部件包", + "delete": "刪除部件包", + "title": "標題", + "title-required": "標題必填。", + "title-max-length": "標題應小於256", + "description": "描述", + "image-preview": "圖像預覽", + "add-widgets-bundle-text": "增加新的部件包", + "no-widgets-bundles-text": "找不到部件包", + "empty": "部件包是空的", + "details": "詳細資訊", + "widgets-bundle-details": "部件包詳細訊息", + "delete-widgets-bundle-title": "您確定要刪除部件包 '{{widgetsBundleTitle}}'嗎?", + "delete-widgets-bundle-text": "小心!確認後,部件包和所有相關資料將無法恢復。", + "delete-widgets-bundles-title": "你確定你要刪除 { count, plural, 1 {1 部件包} other {# 部件包} } 嗎?", + "delete-widgets-bundles-action-title": "刪除 { count, plural, 1 {1 部件包} other {# 部件包} }", + "delete-widgets-bundles-text": "小心!確認後,所有選擇的部件包將被刪除,所有相關資料將無法恢復。", + "no-widgets-bundles-matching": "沒有找到與 '{{widgetsBundle}}' 符合的部件包。", + "widgets-bundle-required": "需要部件包。", + "system": "系統", + "import": "匯入部件包", + "export": "匯出部件包", + "export-failed-error": "無法匯出部件包: {{error}}", + "create-new-widgets-bundle": "建立新的部件包", + "widgets-bundle-file": "部件包文件", + "invalid-widgets-bundle-file-error": "無法匯入部件包:無效的部件包資料結構。", + "search": "搜尋部件包", + "selected-widgets-bundles": "{ count, plural, 1 {1 部件包} other {#部件包} } 被選中", + "open-widgets-bundle": "打開部件包", + "loading-widgets-bundles": "加載部件包..." + }, + "widget-config": { + "data": "資料", + "settings": "設定", + "advanced": "高級", + "title": "標題", + "title-tooltip": "標題工具提示", + "general-settings": "一般設定", + "display-title": "顯示標題", + "drop-shadow": "陰影", + "enable-fullscreen": "啟用全螢幕", + "background-color": "背景顏色", + "text-color": "文字顏色", + "padding": "填充", + "margin": "邊緣", + "widget-style": "部件風格", + "widget-css": "部件CSS", + "title-style": "標題風格", + "mobile-mode-settings": "移動端設定", + "order": "順序", + "height": "高度", + "mobile-hide": "在移動模式下隱藏部件", + "units": "特殊符號展示值", + "decimals": "浮點數後的位數", + "timewindow": "時間窗口", + "use-dashboard-timewindow": "使用儀表板的時間窗口", + "display-timewindow": "顯示時間視窗", + "legend": "圖例", + "display-legend": "顯示圖例", + "datasources": "資料源", + "maximum-datasources": "最大允許 { count, plural, 1 {1 資料} other {# 資料} }", + "datasource-type": "類型", + "datasource-parameters": "參數", + "remove-datasource": "移除資料源", + "add-datasource": "增加資料源", + "target-device": "目標設備", + "alarm-source": "警告源", + "actions": "動作", + "action": "動作", + "add-action": "增加動作", + "search-actions": "動作檢索", + "no-actions-text": "未找到動作", + "action-source": "動作源", + "action-source-required": "動作源必填", + "action-name": "動作名稱", + "action-name-required": "動作名稱必填。", + "action-name-not-unique": "動作名稱已經存在。
    統一動作源的動作名稱必須唯一。", + "action-icon": "圖示", + "show-hide-action-using-function": "使用函數顯示/隱藏動作", + "action-type": "類型", + "action-type-required": "類型必填", + "edit-action": "編輯動作", + "delete-action": "刪除動作", + "delete-action-title": "刪除部件動作", + "delete-action-text": "確定要刪除部件動作 '{{actionName}}' 嗎?", + "title-icon": "標題icon", + "display-icon": "顯示標題icon", + "icon-color": "Icon顏色", + "icon-size": "Icon尺寸", + "advanced-settings": "進階設定", + "data-settings": "資料設定", + "no-data-display-message": "\"沒有要顯示的資料\" 替代消息", + "data-page-size": "每個資料來源的最大實體數", + "settings-component-not-found": "未找到選擇器'{{selector}}'的設定表單組件" + }, + "widget-type": { + "import": "匯入部件類型", + "export": "匯出部件類型", + "export-failed-error": "無法匯出部件類型: {{error}}", + "create-new-widget-type": "建立新的部件類型", + "widget-type-file": "部件類型文件", + "invalid-widget-type-file-error": "無法匯入部件類型:無效的部件類型資料結構。" + }, + "widgets": { + "chart": { + "common-settings": "常用設定", + "enable-stacking-mode": "啟用堆疊模式", + "line-shadow-size": "線條陰影大小", + "display-smooth-lines": "顯示平滑(彎曲) 的線條", + "default-bar-width": "非聚合資料的預設條形寬度(毫秒)", + "bar-alignment": "條形對齊", + "bar-alignment-left": "左側", + "bar-alignment-right": "右側", + "bar-alignment-center": "中間", + "default-font-size": "預設字體大小", + "default-font-color": "預設", + "thresholds-line-width": "所有閾值的預設線寬", + "tooltip-settings": "工具提示設定", + "show-tooltip": "顯示工具提示", + "hover-individual-points": "懸停單個點", + "show-cumulative-values": "在堆疊模式下顯示累積值", + "hide-zero-false-values": "從工具提示中隱藏零/錯誤值", + "tooltip-value-format-function": "工具提示值格式函數", + "grid-settings": "網格設定", + "show-vertical-lines": "顯示垂直線", + "show-horizontal-lines": "顯示水平線", + "grid-outline-border-width": "網格輪廓/邊框寬度 (px)", + "primary-color": "主色", + "background-color": "背顔色", + "ticks-color": "刻度顔色", + "xaxis-settings": "X軸設定", + "axis-title": "軸標題", + "xaxis-tick-labels-settings": "X軸刻度標籤設定", + "show-tick-labels": "顯示X軸刻度標籤", + "yaxis-settings": "Y軸設定", + "min-scale-value": "最小刻度值", + "max-scale-value": "最大刻度值", + "yaxis-tick-labels-settings": "Y軸刻度標籤設定", + "tick-step-size": "刻度尺寸", + "number-of-decimals": "顯示小數點", + "ticks-formatter-function": "刻度格式化功能", + "comparison-settings": "比較設定", + "enable-comparison": "啓用比較", + "time-for-comparison": "比較期間", + "time-for-comparison-previous-interval": "上一個間隔 (預設)", + "time-for-comparison-days": "一天前", + "time-for-comparison-weeks": "一週前", + "time-for-comparison-months": "一個月前", + "time-for-comparison-years": "一年前", + "time-for-comparison-custom-interval": "自定義間隔", + "custom-interval-value": "自定義間隔值 (毫秒)", + "comparison-x-axis-settings": "比較X軸設定", + "axis-position": "軸位置", + "axis-position-top": "頂部 (預設)", + "axis-position-bottom": "底部", + "custom-legend-settings": "自定義圖例設定", + "enable-custom-legend": "啟用自定義圖例(這將允許您在鍵標籤中使用屬性/時間序列值)", + "key-name": "鍵名", + "key-name-required": "需要鍵名", + "key-type": "鍵類型", + "key-type-attribute": "屬性", + "key-type-timeseries": "時間序列", + "label-keys-list": "標籤中使用的鍵列表", + "no-label-keys": "未配置鍵", + "add-label-key": "增加新鍵", + "line-width": "線寬", + "color": "顏色", + "data-is-hidden-by-default": "預設資料隱藏", + "disable-data-hiding": "禁用資料隱藏", + "remove-from-legend": "刪除圖例中的物件屬性", + "exclude-from-stacking": "從堆疊中排除(在 \"堆疊\" 模式下可用)", + "line-settings": "線條設定", + "show-line": "顯示線條", + "fill-line": "填充線條", + "points-settings": "點物件設定", + "show-points": "顯示點物件", + "points-line-width": "點物件的線條寬度", + "points-radius": "點物件半徑", + "point-shape": "點物件外形", + "point-shape-circle": "圓形", + "point-shape-cross": "十字架", + "point-shape-diamond": "鑽石形", + "point-shape-square": "正方形", + "point-shape-triangle": "三角形", + "point-shape-custom": "客製功能", + "point-shape-draw-function": "點物件外形繪圖功能", + "show-separate-axis": "顯示獨立軸", + "axis-position-left": "左", + "axis-position-right": "右", + "thresholds": "門檻值", + "no-thresholds": "找不到指定的閥值", + "add-threshold": "新增閥值", + "show-values-for-comparison": "顯示歷史比較值", + "comparison-values-label": "歷史比較值標籤", + "threshold-settings": "閥值設定", + "use-as-threshold": "使用主要值做爲門檻值", + "threshold-line-width": "閥值的線條寬度", + "threshold-color": "閥值顔色", + "common-pie-settings": "通用派圖設定", + "radius": "半徑", + "inner-radius": "内半徑", + "tilt": "傾斜", + "stroke-settings": "筆畫設定", + "width-pixels": "寬度 (pixels)", + "show-labels": "顯示標籤", + "animation-settings": "動畫設定", + "animated-pie": "啓動派圖動畫 (實驗性的)", + "border-settings": "邊框設定", + "border-width": "邊框寬度", + "border-color": "邊框顔色", + "legend-settings": "圖例設定", + "display-legend": "顯示圖例", + "labels-font-color": "標籤文字顔色" + }, + "dashboard-state": { + "dashboard-state-settings": "儀表板狀態設定", + "dashboard-state": "儀表板狀態id", + "autofill-state-layout": "預設自動填入狀態版面高度", + "default-margin": "預設部件邊緣", + "default-background-color": "預設背景顔色", + "sync-parent-state-params": "與父儀表板狀態參數同步" + }, + "date-range-navigator": { + "date-range-picker-settings": "日期範圍選擇器設定", + "hide-date-range-picker": "隱藏日期範圍選擇器", + "picker-one-panel": "日期範圍選擇器一個面板", + "picker-auto-confirm": "日期範圍選擇器自動確認", + "picker-show-template": "日期範圍選擇器顯示模板", + "first-day-of-week": "每週的第一天", + "interval-settings": "區隔時間設定", + "hide-interval": "隱藏區隔時間", + "initial-interval": "初始區隔時間", + "interval-hour": "區隔時間-每小時", + "interval-day": "區隔時間-每天", + "interval-week": "區隔時間-每週", + "interval-two-weeks": "區隔時間-2週", + "interval-month": "區隔時間-每月", + "interval-three-months": "區隔時間-3個月", + "interval-six-months": "區隔時間-6個月", + "step-settings": "步驟設定", + "hide-step-size": "隱藏步驟尺寸", + "initial-step-size": "初始步驟尺寸", + "hide-labels": "隱藏標籤", + "use-session-storage": "使用會談儲存", + "localizationMap": { + "Sun": "週日", + "Mon": "週一", + "Tue": "週二", + "Wed": "週三", + "Thu": "週四", + "Fri": "週五", + "Sat": "週六", + "Jan": "1月", + "Feb": "2月", + "Mar": "3月", + "Apr": "4月", + "May": "5月", + "Jun": "6月", + "Jul": "7月", + "Aug": "8月", + "Sep": "9月", + "Oct": "10月", + "Nov": "11月", + "Dec": "12月", + "January": "一月", + "February": "二月", + "March": "三月", + "April": "四月", + "June": "六月", + "July": "七月", + "August": "八月", + "September": "九月", + "October": "十月", + "November": "十一月", + "December": "十二月", + "Custom Date Range": "自定義日期範圍", + "Date Range Template": "日期範圍模板", + "Today": "今天", + "Yesterday": "昨天", + "This Week": "本星期", + "Last Week": "上個星期", + "This Month": "這個月", + "Last Month": "上個月", + "Year": "年", + "This Year": "今年", + "Last Year": "去年", + "Date picker": "日期選擇器", + "Hour": "小時", + "Day": "天", + "Week": "週", + "2 weeks": "2週", + "Month": "月", + "3 months": "3個月", + "6 months": "6個月", + "Custom interval": "自定義間隔", + "Interval": "間隔", + "Step size": "步長", + "Ok": "Ok" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "階層資料設定", + "relations-query-function": "節點關聯查詢功能", + "has-children-function": "節點有子功能", + "node-state-settings": "節點狀態設定", + "node-opened-function": "預設節點開啟功能", + "node-disabled-function": "節點禁用功能", + "display-settings": "顯示設定", + "node-icon-function": "節點圖示功能", + "node-text-function": "節點文字功能", + "sort-settings": "分類設定", + "nodes-sort-function": "節點分類設定" + }, + "edge": { + "display-default-title": "顯示預設標題" + }, + "gateway": { + "general-settings": "通用設定", + "widget-title": "部件標題", + "default-archive-file-name": "預設存檔檔名", + "device-type-for-new-gateway": "新閘道的設備類型", + "messages-settings": "訊息設定", + "save-config-success-message": "指定閘道儲存成功的文字訊息", + "device-name-exists-message": "當輸入已存在的設備名稱的文字訊息", + "gateway-title": "閘道表格", + "read-only": "唯讀", + "events-title": "閘道事件表單標題", + "events-filter": "事件過濾器", + "event-key-contains": "事件鍵包含..." + }, + "gauge": { + "default-color": "預設顏色", + "radial-gauge-settings": "逕向量規設定", + "ticks-settings": "刻度設定", + "min-value": "最小值", + "max-value": "最大值", + "start-ticks-angle": "開始刻度角度", + "ticks-angle": "刻度線角度", + "major-ticks-count": "主要刻度數", + "major-ticks-color": "主要刻度顏色", + "minor-ticks-count": "次要刻度計數", + "minor-ticks-color": "次要刻度顏色", + "tick-numbers-font": "刻度數字字體", + "unit-title-settings": "單元標題設定", + "show-unit-title": "顯示單元標題", + "unit-title": "單元標題", + "title-font": "標題文字字體", + "units-settings": "單位設定", + "units-font": "單位文字字體", + "value-box-settings": "值框設定", + "show-value-box": "顯示值框", + "value-int": "數值整數部分的位數", + "value-font": "值文字字體", + "value-box-rect-stroke-color": "值框矩形筆畫顏色", + "value-box-rect-stroke-color-end": "值框矩形筆畫顏色 - 結束漸變", + "value-box-background-color": "值框背景顏色", + "value-box-shadow-color": "值框陰影顏色", + "plate-settings": "板設定", + "show-plate-border": "顯示板邊框", + "plate-color": "板顏色", + "needle-settings": "針設定", + "needle-circle-size": "針圈尺寸", + "needle-color": "針顏色", + "needle-color-end": "針顏色 - 結束漸變", + "needle-color-shadow-up": "針影顏色的上半部分", + "needle-color-shadow-down": "放置針陰影顏色", + "highlights-settings": "高光設定", + "highlights-width": "高光寬度", + "highlights": "高光", + "highlight-from": "從", + "highlight-to": "至", + "highlight-color": "顏色", + "no-highlights": "未配置高光", + "add-highlight": "增加高光", + "animation-settings": "動畫設定", + "enable-animation": "啟用動畫", + "animation-duration": "動畫持續時間", + "animation-rule": "動畫規則", + "animation-linear": "線性", + "animation-quad": "四邊形", + "animation-quint": "導軌", + "animation-cycle": "循環", + "animation-bounce": "彈跳", + "animation-elastic": "彈性", + "animation-dequad": "對偶", + "animation-dequint": "去導軌", + "animation-decycle": "循環", + "animation-debounce": "去抖動", + "animation-delastic": "彈性", + "linear-gauge-settings": "線性量規設定", + "bar-stroke-width": "條形筆畫寬度", + "bar-stroke-color": "條形筆畫顏色", + "bar-background-color": "量規條背景顏色", + "bar-background-color-end": "條形背景顏色 - 結束漸變", + "progress-bar-color": "進度條顏色", + "progress-bar-color-end": "進度條顏色 - 結束漸變", + "major-ticks-names": "主要刻度名稱", + "show-stroke-ticks": "顯示刻度筆畫", + "major-ticks-font": "主要刻度字體", + "border-color": "邊框顏色", + "border-width": "邊框寬度", + "needle-circle-color": "針圈顏色", + "animation-target": "動畫目標", + "animation-target-needle": "針", + "animation-target-plate": "板", + "common-settings": "常用量規設定", + "gauge-type": "量規類型", + "gauge-type-arc": "弧", + "gauge-type-donut": "圓環圖", + "gauge-type-horizontal-bar": "水平條形圖", + "gauge-type-vertical-bar": "垂直條形圖", + "donut-start-angle": "起始角度", + "bar-settings": "量規條設定", + "relative-bar-width": "相對條形寬度", + "neon-glow-brightness": "霓虹燈效果亮度,(0-100),0 - 禁用效果", + "stripes-thickness": "條紋的厚度,0 - 沒有條紋", + "rounded-line-cap": "顯示圓角線蓋", + "bar-color-settings": "條形圖顏色設定", + "use-precise-level-color-values": "使用精確的顏色級別", + "bar-colors": "條形顏色,從下到上", + "color": "顏色", + "no-bar-colors": "未配置條形圖顏色", + "add-bar-color": "增加條形圖顏色", + "from": "從", + "to": "至", + "fixed-level-colors": "使用邊界值的條形圖顏色", + "gauge-title-settings": "量規標題設定", + "show-gauge-title": "顯示量規標題", + "gauge-title": "量規標題", + "gauge-title-font": "量規標題字體", + "unit-title-and-timestamp-settings": "單元標題和時間戳設定", + "show-timestamp": "顯示值時間戳", + "timestamp-format": "時間戳格式", + "label-font": "顯示低於值的標籤字體", + "value-settings": "值設定", + "show-value": "顯示值文字", + "min-max-settings": "最小/最大標籤設定", + "show-min-max": "顯示最小值和最大值", + "min-max-font": "最小和最大標籤的字體", + "show-ticks": "顯示刻度", + "tick-width": "刻度寬度", + "tick-color": "刻度顏色", + "tick-values": "刻度值", + "no-tick-values": "未配置刻度值", + "add-tick-value": "增加刻度值" + }, + "gpio": { + "pin": "針", + "label": "標籤", + "row": "行", + "column": "列", + "color": "顏色", + "panel-settings": "面板設定", + "background-color": "背景顏色", + "gpio-switches": "GPIO開關", + "no-gpio-switches": "未配置GPIO開關", + "add-gpio-switch": "增加GPIO開關", + "gpio-status-request": "GPIO狀態請求", + "method-name": "方法名稱", + "method-body": "方法主體", + "gpio-status-change-request": "GPIO狀態更改請求", + "parse-gpio-status-function": "解析gpio狀態函數", + "gpio-leds": "GPIO led", + "no-gpio-leds": "未配置GPIO led", + "add-gpio-led": "增加GPIO led" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "屬性參數在這個部件無法使用", + "blocked-location": "您的瀏覽器封鎖地理位置定位", + "claim-device": "設備請求", + "claim-failed": "設備請求失敗!", + "claim-not-found": "找不到設備", + "claim-successful": "設備請求成功!", + "date": "日期", + "device-name": "設備名稱", + "device-name-required": "設備名稱必填", + "discard-changes": "忽略變更", + "entity-attribute-required": "實體屬性必填", + "entity-coordinate-required": "經緯度兩項必填", + "entity-timeseries-required": "需要實體時間序列", + "get-location": "獲取當前位置", + "invalid-date": "失效日期", + "latitude": "緯度", + "longitude": "經度", + "min-value-error": "最小值為{{value}}", + "max-value-error": "最大值為{{value}}", + "not-allowed-entity": "所選實體不能具有共享屬性", + "no-attribute-selected": "未選擇任何屬性", + "no-datakey-selected": "未選擇任何數據鍵", + "no-coordinate-specified": "未指定緯度/經度的數據鍵", + "no-entity-selected": "未選擇實體", + "no-image": "沒有圖像", + "no-support-geolocation": "您的瀏覽器不支持地理定位", + "no-support-web-camera": "您的瀏覽器不支持相機", + "enable-https-use-widget": "請啟用HTTPS以使用此部件", + "no-found-your-camera": "找不到您的相機", + "no-permission-camera": "權限被用戶拒絕 / 此站點無權使用相機", + "no-timeseries-selected": "未選擇時間序列", + "secret-key": "密鑰", + "secret-key-required": "需要密鑰", + "switch-attribute-value": "切換實體屬性值", + "switch-camera": "切換相機", + "switch-timeseries-value": "切換實體時間序列值", + "take-photo": "拍照", + "time": "時間", + "timeseries-not-allowed": "此部件中不能使用時間序列參數", + "update-failed": "更新失敗", + "update-successful": "更新成功", + "update-attribute": "更新屬性", + "update-timeseries": "更新時間序列", + "value": "值", + "general-settings": "通用設定", + "widget-title": "部件標題", + "claim-button-label": "認領按鈕標籤", + "show-secret-key-field": "顯示 '密鑰' 輸入欄位", + "labels-settings": "標籤設定", + "show-labels": "顯示標籤", + "device-name-label": "設備名稱輸入欄位的標籤", + "secret-key-label": "密鑰輸入欄位的標籤", + "messages-settings": "消息設定", + "claim-device-success-message": "設備認領成功的簡訊", + "claim-device-not-found-message": "找不到設備時的簡訊", + "claim-device-failed-message": "設備認領失敗的簡訊", + "claim-device-name-required-message": "'需要設備名稱'的錯誤消息", + "claim-device-secret-key-required-message": "'需要密鑰'的朔戊消息", + "show-label": "顯示標籤", + "label": "標籤", + "required": "必須", + "required-error-message": "'必須'的錯誤訊息", + "show-result-message": "顯示結果消息", + "integer-field-settings": "整數字段設定", + "min-value": "最小值", + "max-value": "最大值", + "double-field-settings": "雙欄位設定", + "text-field-settings": "文字字段設定", + "min-length": "最小長度", + "max-length": "最長長度", + "checkbox-settings": "複選框設定", + "true-label": "選中的標籤", + "false-label": "未選中的標籤", + "image-input-settings": "圖像輸入設定", + "display-preview": "顯示預覽", + "display-clear-button": "顯示清除按鈕", + "display-apply-button": "顯示應用按鈕", + "display-discard-button": "顯示放棄按鈕", + "datetime-field-settings": "日期/時間字段設定", + "display-time-input": "顯示時間輸入", + "latitude-key-name": "緯度鍵名", + "longitude-key-name": "經度鍵名", + "show-get-location-button": "顯示按鈕 '獲取當前位置'", + "use-high-accuracy": "使用高精度", + "location-fields-settings": "位置字段設定", + "latitude-label": "緯度標籤", + "longitude-label": "經度標籤", + "input-fields-alignment": "輸入欄位對齊", + "input-fields-alignment-column": "列 (預設)", + "input-fields-alignment-row": "行", + "latitude-field-required": "需要緯度欄位", + "longitude-field-required": "需要經度欄位", + "attribute-settings": "屬性設定", + "widget-mode": "部件模式", + "widget-mode-update-attribute": "更新屬性", + "widget-mode-update-timeseries": "更新時間序列", + "attribute-scope": "屬性範圍", + "attribute-scope-server": "伺服器屬性", + "attribute-scope-shared": "共享屬性", + "value-required": "所需值", + "image-settings": "圖像設定", + "image-format": "圖像格式", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "使用jpeg和webp等有損壓縮的圖像品質", + "max-image-width": "最大圖像寬度", + "max-image-height": "最大圖像高度", + "action-buttons": "操作按鈕", + "show-action-buttons": "顯示操作按鈕", + "update-all-values": "更新所有值,而不僅僅是修改的值", + "save-button-label": "'保存'按鈕標籤", + "reset-button-label": "'取消'按鈕標籤", + "group-settings": "群組設定", + "show-group-title": "顯示與不同實體相關的欄位組的標題", + "group-title": "群組標題", + "fields-alignment": "欄位對齊", + "fields-alignment-row": "行 (預設)", + "fields-alignment-column": "列", + "fields-in-row": "行中的欄位數", + "option-value": "數值 (為創建空選項寫入 'null')", + "option-label": "標籤", + "hide-input-field": "隱藏輸入欄位", + "datakey-type": "數據鍵類型", + "datakey-type-server": "伺服器屬性 (預設)", + "datakey-type-shared": "共享屬性", + "datakey-type-timeseries": "時間序列", + "datakey-value-type": "數據鍵值類型", + "datakey-value-type-string": "字符串", + "datakey-value-type-double": "雙重的", + "datakey-value-type-integer": "整數", + "datakey-value-type-boolean-checkbox": "布林值(複選框)", + "datakey-value-type-boolean-switch": "布林值 (開關)", + "datakey-value-type-date-time": "日期和時間", + "datakey-value-type-date": "日期", + "datakey-value-type-time": "時間", + "datakey-value-type-select": "選擇", + "value-is-required": "需要值", + "ability-to-edit-attribute": "編輯屬性的能力", + "ability-to-edit-attribute-editable": "啟用 (預設)", + "ability-to-edit-attribute-disabled": "已禁用", + "ability-to-edit-attribute-readonly": "唯讀", + "disable-on-datakey-name": "禁用另一個數據鍵的錯誤值 (指定數據鍵名稱)", + "slide-toggle-settings": "滑塊切換設定", + "slide-toggle-label-position": "滑塊切換標籤位置", + "slide-toggle-label-position-after": "後", + "slide-toggle-label-position-before": "前", + "select-options": "選擇選項", + "no-select-options": "未配置選擇選項", + "add-select-option": "增加選擇選項", + "numeric-field-settings": "數值欄位設定", + "step-interval": "值之間的步長間隔", + "error-messages": "錯誤訊息", + "min-value-error-message": "錯誤訊息: '低於最大值'", + "max-value-error-message": "錯誤訊息: '超過最大值'", + "invalid-date-error-message": "錯誤訊息: '無效日期'", + "icon-settings": "圖示設定", + "use-custom-icon": "使用客製圖示", + "input-cell-icon": "在輸入儲存格之前顯示圖示", + "value-conversion-settings": "值換算設定", + "get-value-settings": "取得數值設定", + "use-get-value-function": "使用getValue函數", + "get-value-function": "getValue函數", + "set-value-settings": "設定值設定", + "use-set-value-function": "使用setValue函數", + "set-value-function": "setValue函數" + }, + "invalid-qr-code-text": "無效的QR code文字,輸入應該有一個字串類型。", + "qr-code": { + "use-qr-code-text-function": "使用QR code文字功能", + "qr-code-text-pattern": "QR code文字模式 (例如 '${entityName} | ${keyName} - 一些文字。')", + "qr-code-text-pattern-required": "QR code文字模式必填", + "qr-code-text-function": "QR code文字功能" + }, + "label-widget": { + "label-pattern": "圖案", + "label-pattern-hint": "提示: 例如 '文字 ${keyName} 單位。' 或 ${#<key index>} 單位", + "label-pattern-required": "需要圖案", + "label-position": "位置 (相對於背景的百分比)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "背景顔色", + "font-settings": "文字設定", + "background-image": "背景圖片", + "labels": "標籤", + "no-labels": "無指定標籤", + "add-label": "新增標籤" + }, + "navigation": { + "title": "標題", + "navigation-path": "導航路徑", + "filter-type": "過濾器種類", + "filter-type-all": "所有過濾器項目", + "filter-type-include": "包括過濾器", + "filter-type-exclude": "不包括過濾器", + "items": "項目", + "enter-urls-to-filter": "使用urls過濾...." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "消息類型", + "method": "方法", + "params": "參數", + "created-time": "建立時間", + "expiration-time": "到期時間", + "retries": "重試", + "status": "狀態", + "filter": "過濾器", + "refresh": "刷新", + "add": "增加RPC請求", + "details": "詳細資訊", + "delete": "刪除", + "delete-request-title": "刪除永久RPC請求", + "delete-request-text": "您確定要刪除請求嗎?", + "details-title": "詳細信息RPC ID: ", + "additional-info": "附加信息", + "response": "回復", + "any-status": "任何狀態", + "rpc-status-list": "RPC狀態列表", + "no-request-prompt": "沒有顯示請求", + "send-request": "發送請求", + "add-title": "創建永久RPC請求", + "method-error": "需要方法。", + "timeout-error": "最小超時值為5000 (5秒)。", + "white-space-error": "不允許使用空格。", + "rpc-status": { + "QUEUED": "排序", + "SENT": "發送", + "DELIVERED": "發表", + "SUCCESSFUL": "成功", + "TIMEOUT": "超時", + "EXPIRED": "已到期", + "FAILED": "失敗" + }, + "rpc-search-status-all": "全部", + "message-types": { + "false": "雙向", + "true": "單向" + }, + "general-settings": "一般設定", + "enable-filter": "啓動過濾器", + "enable-sticky-header": "當捲動時顯示標題", + "enable-sticky-action": "當捲動時顯示操作列", + "display-request-details": "顯示請求詳細資料", + "allow-send-request": "允許傳送RPC請求", + "allow-delete-request": "允許刪除請求", + "columns-settings": "列設定", + "display-columns": "顯示列", + "column": "列", + "no-columns-found": "找不到列", + "no-columns-matching": "找不到'{{column}}'" + }, + "rpc": { + "value-settings": "值設定", + "initial-value": "初始值", + "retrieve-value-settings": "檢索開/關的值", + "retrieve-value-method": "檢索值使用方法", + "retrieve-value-method-none": "請勿檢索", + "retrieve-value-method-rpc": "呼叫RPC取得值的方法", + "retrieve-value-method-attribute": "訂閲屬性", + "retrieve-value-method-timeseries": "訂閲時間序列", + "attribute-value-key": "屬性鍵", + "timeseries-value-key": "時間序列鍵", + "get-value-method": "RPC取得值的方法", + "parse-value-function": "解析值函數", + "update-value-settings": "更新值設定", + "set-value-method": "RPC設定值方法", + "convert-value-function": "轉換值函數", + "rpc-settings": "RPC設定", + "request-timeout": "RPC請求超時 (毫秒)", + "persistent-rpc-settings": "永久RPC設定", + "request-persistent": "RPC請求持續", + "persistent-polling-interval": "獲取永久RPC命令響應的輪詢間隔 (毫秒)", + "common-settings": "常用設定", + "switch-title": "切換標題", + "show-on-off-labels": "顯示開/關標籤", + "slide-toggle-label": "滑塊切換標籤", + "label-position": "標籤位置", + "label-position-before": "前", + "label-position-after": "後", + "slider-color": "滑塊顏色", + "slider-color-primary": "主要的", + "slider-color-accent": "口音", + "slider-color-warn": "警告", + "button-style": "按鈕樣式", + "button-raised": "凸起的按鈕", + "button-primary": "主要顏色", + "button-background-color": "按鈕背景顏色", + "button-text-color": "按鈕文字顏色", + "widget-title": "部件標題", + "button-label": "按鈕標籤", + "device-attribute-scope": "設備屬性範圍", + "server-attribute": "伺服器屬性", + "shared-attribute": "共享屬性", + "device-attribute-parameters": "設備屬性參數", + "is-one-way-command": "是單向命令", + "rpc-method": "RPC方法", + "rpc-method-params": "RPC方法參數", + "show-rpc-error": "顯示RPC命令執行錯誤", + "led-title": "LED標題", + "led-color": "LED顏色", + "check-status-settings": "檢查狀態設定", + "perform-rpc-status-check": "執行RPC設備狀態檢查", + "retrieve-led-status-value-method": "使用方法檢索led狀態值", + "led-status-value-attribute": "包含led狀態值的設備屬性", + "led-status-value-timeseries": "包含led狀態值的設備時間序列", + "check-status-method": "RPC檢查設備狀態方法", + "parse-led-status-value-function": "解析led狀態值函數", + "knob-title": "旋鈕標題", + "min-value": "最小值", + "max-value": "最大值" + }, + "maps": { + "select-entity": "選擇實體", + "select-entity-hint": "提示:選擇後點擊地圖設置位置", + "tooltips": { + "placeMarker": "單擊以放置'{{entityName}}'實體", + "firstVertex": " '{{entityName}}'的多邊形:單擊以放置第一個點", + "firstVertex-cut": "單擊以放置第一個點", + "continueLine": " '{{entityName}}'的多邊形:單擊以繼續繪製", + "continueLine-cut": "點擊繼續繪製", + "finishLine": "單擊任何現有標記以完成", + "finishPoly": "'{{entityName}}'個多邊形:點擊第一個標記以完成並保存", + "finishPoly-cut": "單擊第一個標記以完成並保存", + "finishRect": "'{{entityName}}'的多邊形:點擊完成並保存", + "startCircle": "'{{entityName}}'的圓圈:點擊以放置圓圈中心", + "finishCircle": "'{{entityName}}'的圓圈:點擊以完成圓圈", + "placeCircleMarker": "單擊以放置圓形標記" + }, + "actions": { + "finish": "結束", + "cancel": "取消", + "removeLastVertex": "移除最後一點" + }, + "buttonTitles": { + "drawMarkerButton": "放置實體", + "drawPolyButton": "創線多邊形", + "drawLineButton": "創建折線", + "drawCircleButton": "創建圓形", + "drawRectButton": "創建矩形", + "editButton": "編輯模式", + "dragButton": "拖放模式", + "cutButton": "切割多邊形區域", + "deleteButton": "移除", + "drawCircleMarkerButton": "創建圓形標記", + "rotateButton": "旋轉多邊形" + }, + "map-provider-settings": "地圖提供者設定", + "map-provider": "地圖提供者", + "map-provider-google": "Google地圖", + "map-provider-openstreet": "OpenStreet地圖", + "map-provider-here": "HERE地圖", + "map-provider-image": "圖像地圖", + "map-provider-tencent": "騰訊地圖", + "openstreet-provider": "OpenStreet 地圖提供者", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (預設)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "使用自定義提供程序", + "custom-provider-tile-url": "自定義提供程序磁貼URL", + "google-maps-api-key": "Google地圖API金鑰", + "default-map-type": "預設地圖類型", + "google-map-type-roadmap": "道路模式", + "google-map-type-satelite": "衛星模式", + "google-map-type-hybrid": "混合模式", + "google-map-type-terrain": "地形模式", + "map-layer": "地圖圖層", + "here-map-normal-day": "HERE.正常日間 (預設)", + "here-map-normal-night": "HERE.正常夜間", + "here-map-hybrid-day": "HERE.混合日", + "here-map-terrain-day": "HERE.地形日", + "credentials": "證書", + "here-app-id": "HERE app id", + "here-app-code": "HERE app程式碼", + "tencent-maps-api-key": "騰訊地圖API金鑰", + "tencent-map-type-roadmap": "道路模式", + "tencent-map-type-satelite": "衛星模式", + "tencent-map-type-hybrid": "混合模式", + "image-map-background": "地圖圖片背景", + "image-map-background-from-entity-attribute": "從實體屬性獲取地圖背景", + "image-url-source-entity-alias": "圖片URL來源別名", + "image-url-source-entity-attribute": "圖片URL來源屬性", + "common-map-settings": "一般地圖設定", + "x-pos-key-name": "X軸座標主要名稱", + "y-pos-key-name": "Y軸座標主要名稱", + "latitude-key-name": "緯度主要名稱", + "longitude-key-name": "經度主要名稱", + "default-map-zoom-level": "預設地圖縮放級別 (0 - 20)", + "default-map-center-position": "預設地圖中心位置(0,0)", + "disable-scroll-zooming": "關閉捲動縮放功能", + "disable-double-click-zooming": "關閉雙擊縮放功能", + "disable-zoom-control-buttons": "關閉縮放控制按鈕功能", + "fit-map-bounds": "符合地圖邊界以遮蓋所有標記", + "use-default-map-center-position": "使用預設地圖中心位置", + "entities-limit": "實體負載限制", + "markers-settings": "標記設定", + "marker-offset-x": "標記的X偏移量相對於位置乘以標記的寬度", + "marker-offset-y": "標記的Y偏移量相對於位置乘以標記的高度", + "position-function": "位置轉換函數,應將X,Y座標作爲雙數從0到1分別返回", + "draggable-marker": "可拖拉的標記", + "label": "標籤", + "show-label": "顯示標籤", + "use-label-function": "使用標籤功能", + "label-pattern": "標籤(模式示例: '${entityName}'、'${entityName}:(文字 ${keyName} 單位。)' )", + "label-function": "標籤功能", + "tooltip": "工具提示", + "show-tooltip": "顯示工具提示", + "show-tooltip-action": "顯示工具提示的動作", + "show-tooltip-action-click": "顯示工具提示的動作點擊(預設)", + "show-tooltip-action-hover": "顯示工具提示的懸停效果", + "auto-close-tooltips": "自動關閉工具提示", + "use-tooltip-function": "使用工具提示功能", + "tooltip-pattern": "工具提示 (例如 '文字 ${keyName} 單位.' 或連結文字')", + "tooltip-function": "工具提示功能", + "tooltip-offset-x": "工具提示相對於標記錨的X偏移量乘以標記的寬度", + "tooltip-offset-y": "工具提示相對於標記錨的Y偏移量乘以標記的寬度", + "color": "顔色", + "use-color-function": "使用顔色功能", + "color-function": "顔色功能", + "marker-image": "標記圖片", + "use-marker-image-function": "使用標記圖片功能", + "custom-marker-image": "客製標記圖片", + "custom-marker-image-size": "客製標記圖片尺寸(px)", + "marker-image-function": "標記圖片功能", + "marker-images": "多張標記圖片", + "polygon-settings": "多邊形設定", + "show-polygon": "顯示多邊形", + "polygon-key-name": "多邊形主要名稱", + "enable-polygon-edit": "啓用多邊形編輯", + "polygon-label": "多邊形標籤", + "show-polygon-label": "顯示多邊形標籤", + "use-polygon-label-function": "使用多邊形功能", + "polygon-label-pattern": "多邊形標籤 (模式示例: '${entityName}'、 '${entityName}: (文字${keyName} 單位。)' )", + "polygon-label-function": "多邊形標籤功能", + "polygon-tooltip": "多邊形工具提示", + "show-polygon-tooltip": "顯示多邊形標籤", + "auto-close-polygon-tooltips": "自動關閉多邊形工具提示", + "use-polygon-tooltip-function": "使用多邊形工具提示功能", + "polygon-tooltip-pattern": "工具提示 (例如'文字 ${keyName} 單位' 或 連結文字')", + "polygon-tooltip-function": "多邊形工具提示功能", + "polygon-color": "多邊形顔色", + "polygon-opacity": "多邊形不透明度", + "use-polygon-color-function": "使用多邊形顔色功能", + "polygon-color-function": "多邊形顔色功能", + "polygon-stroke": "多邊形顔色", + "stroke-color": "筆畫顔色", + "stroke-opacity": "筆畫不透明度", + "stroke-weight": "筆畫粗細", + "use-polygon-stroke-color-function": "使用多邊形筆畫顔色功能", + "polygon-stroke-color-function": "多邊形筆畫顔色功能", + "circle-settings": "圓形設定", + "show-circle": "顯示圓形", + "circle-key-name": "圓形主要名稱", + "enable-circle-edit": "啓用圓形編輯", + "circle-label": "圓形標籤", + "show-circle-label": "顯示圓形標籤", + "use-circle-label-function": "使用圓形標籤功能", + "circle-label-pattern": "圓形標籤 (模式示例: '${entityName}'、'${entityName}: (文字${keyName}單位。)' )", + "circle-label-function": "圓形標籤功能", + "circle-tooltip": "圓形工具提示", + "show-circle-tooltip": "顯示圓形工具提示", + "auto-close-circle-tooltips": "自動關閉圓形工具提示", + "use-circle-tooltip-function": "使用圓形工具提示功能", + "circle-tooltip-pattern": "工具提示 (例如'文字${keyName} 單位' 或連結文字')", + "circle-tooltip-function": "圓形工具提示功能", + "circle-fill-color": "圓形填充顔色", + "circle-fill-color-opacity": "圓形填充顔色不透明度", + "use-circle-fill-color-function": "使用圓形填充顔色功能", + "circle-fill-color-function": "圓形填充顔色功能", + "circle-stroke": "圓形筆畫", + "use-circle-stroke-color-function": "使用圓形筆畫顔色功能", + "circle-stroke-color-function": "圓形筆畫顔色功能", + "markers-clustering-settings": "標記叢集設定", + "use-map-markers-clustering": "使用地圖標記叢集", + "zoom-on-cluster-click": "當點擊地圖標記叢集時縮放", + "max-cluster-zoom": "標記可以是叢集的一部分時的最大縮放級別(0 - 18)", + "max-cluster-radius-pixels": "一個叢集可覆蓋的最大半徑,以像素為單位", + "cluster-zoom-animation": "當縮放時在標記上顯示動畫", + "show-markers-bounds-on-cluster-mouse-over": "當滑鼠移到一個叢集上時,顯示標記的邊界", + "spiderfy-max-zoom-level": "在最大變焦水平下的Spiderfy(爲了可看到所有的叢集標記)", + "load-optimization": "負載優化", + "cluster-chunked-loading": "使用塊來添加標記,這樣頁面就不會凍結了", + "cluster-markers-lazy-load": "使用延遲載入來添加標記", + "editor-settings": "編輯者設定", + "enable-snapping": "啓用對其他頂點的抓取以實現精確繪圖", + "init-draggable-mode": "在可拖拉模式下初始化地圖", + "hide-all-edit-buttons": "隱藏所有編輯控制按鈕", + "hide-draw-buttons": "隱藏繪圖按鈕", + "hide-edit-buttons": "隱藏編輯按鈕", + "hide-remove-button": "隱藏移除按鈕", + "route-map-settings": "路線圖設定", + "trip-animation-settings": "行程動畫設定", + "normalization-step": "正規化資料步驟(毫秒)", + "tooltip-background-color": "提示工具背景顔色", + "tooltip-font-color": "提示文字顔色", + "tooltip-opacity": "提示工具不透明度(0-1)", + "auto-close-tooltip": "自動關閉提示工具", + "rotation-angle": "設定標記的額外選擇角度", + "path-settings": "路徑設定", + "path-color": "路徑顔色", + "use-path-color-function": "使用路徑顔色功能", + "path-color-function": "路徑顔色功能", + "path-decorator": "路徑裝飾器", + "use-path-decorator": "使用路徑裝飾器", + "decorator-symbol": "裝飾器符號", + "decorator-symbol-arrow-head": "裝飾器符號-箭頭", + "decorator-symbol-dash": "裝飾器符號-儀表板", + "decorator-symbol-size": "裝飾器符號尺寸 (px)", + "use-path-decorator-custom-color": "使用路徑裝飾器客製顔色", + "decorator-custom-color": "裝飾器客製顔色", + "decorator-offset": "裝飾器串列修改", + "end-decorator-offset": "結束裝飾器串列修改", + "decorator-repeat": "裝飾器重複", + "points-settings": "點物件設定", + "show-points": "顯示點物件", + "point-color": "點物件顔色", + "point-size": "點物件尺寸(px)", + "use-point-color-function": "使用點物件顔色功能", + "point-color-function": "點物件顔色功能", + "use-point-as-anchor": "使用點物件做爲錨", + "point-as-anchor-function": "使用點物件做爲錨功能", + "independent-point-tooltip": "獨立點物件的工具提示" + }, + "markdown": { + "use-markdown-text-function": "使用markdown語言功能", + "markdown-text-function": "Markdown/HTML value function", + "markdown-text-pattern": "Markdown/HTML模式(markdown 或帶有變量的HTML,例如'${entityName} 或${keyName} - 一些文字。')", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label-position": "標籤位置", + "label-position-left": "標籤置左", + "label-position-top": "標籤置頂" + }, + "table": { + "common-table-settings": "通用資料表設定", + "enable-search": "啓動搜尋", + "enable-sticky-header": "始終顯示標題", + "enable-sticky-action": "始終顯示動作欄", + "hidden-cell-button-display-mode": "隱藏儲存格按鈕動作顯示模式", + "show-empty-space-hidden-action": "顯示空格而不是隱藏儲存格的按鈕動作", + "dont-reserve-space-hidden-action": "不要為隱藏的動作按鈕保留空格", + "display-timestamp": "顯示時間戳列", + "display-milliseconds": "顯示毫秒時間戳", + "display-pagination": "顯示分頁", + "default-page-size": "預設頁面尺寸", + "use-entity-label-tab-name": "在分頁標籤名稱中使用實體標籤", + "hide-empty-lines": "隱藏空白線", + "row-style": "行種類", + "use-row-style-function": "使用行種類功能", + "row-style-function": "行種類功能", + "cell-style": "儲存格種類", + "use-cell-style-function": "使用單元格種類功能", + "cell-style-function": "儲存格種類功能", + "cell-content": "儲存格内容", + "use-cell-content-function": "使用儲存格内容功能", + "cell-content-function": "儲存格内容功能", + "show-latest-data-column": "顯示最新資料欄", + "latest-data-column-order": "最新資料欄順序", + "entities-table-title": "實體表格標題", + "enable-select-column-display": "啓用要顯示的選擇列", + "display-entity-name": "顯示實體名稱列", + "entity-name-column-title": "實體名稱列標題", + "display-entity-label": "顯示實體標籤列", + "entity-label-column-title": "實體標籤列標題", + "display-entity-type": "顯示實體類型列", + "default-sort-order": "預設排序順序", + "column-width": "列寬 (px or %)", + "default-column-visibility": "預設列能見性", + "column-visibility-visible": "顯示", + "column-visibility-hidden": "隱藏", + "column-selection-to-display": "'要顯示的列'中的列選擇", + "column-selection-to-display-enabled": "已啟用", + "column-selection-to-display-disabled": "已禁用", + "alarms-table-title": "警告表格標題", + "enable-alarms-selection": "啓動警告選項", + "enable-alarms-search": "啓動警告搜尋", + "enable-alarm-filter": "啓動警告過濾", + "display-alarm-details": "顯示警告詳細資訊", + "allow-alarms-ack": "允許警告確認", + "allow-alarms-clear": "允許警告清除" + }, + "value-source": { + "value-source": "值來源", + "predefined-value": "預定義值", + "entity-attribute": "取至實體屬性的值", + "value": "值", + "source-entity-alias": "源實體別名", + "source-entity-attribute": "源實體屬性" + }, + "widget-font": { + "font-family": "字型家族", + "size": "文字大小", + "relative-font-size": "相對文字大小(百分比)", + "font-style": "字體類別", + "font-style-normal": "正體字", + "font-style-italic": "斜體字", + "font-style-oblique": "斜體字", + "font-weight": "文字粗細", + "font-weight-normal": "一般粗細的文字", + "font-weight-bold": "粗體字", + "font-weight-bolder": "更粗的字", + "font-weight-lighter": "更細的字", + "color": "文字顔色", + "shadow-color": "文字陰影顔色" + } + }, + "icon": { + "icon": "圖示", + "select-icon": "選擇圖示", + "material-icons": "素材圖示", + "show-all": "顯示所有圖示" + }, + "phone-input": { + "phone-input-label": "電話號碼", + "phone-input-required": "電話號碼為必填項", + "phone-input-validation": "電話號碼無效或不可能", + "phone-input-pattern": "無效的電話號碼。應採用E.164格式,例如{{phoneNumber}}", + "phone-input-hint": "E.164格式的電話號碼,例如{{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "動作單元格按鈕", + "row-click": "點選行", + "polygon-click": "單擊多邊形", + "marker-click": "點選標記", + "circle-click": "點擊圓圈", + "tooltip-tag-action": "提示標籤動作", + "node-selected": "在選定的節點上", + "element-click": "在HTML元素上單擊", + "pie-slice-click": "點擊切片", + "row-double-click": "在行上雙擊" + } + }, + "language": { + "language": "語言", + "locales": { + "de_DE": "Deutsch", + "fr_FR": "Français", + "zh_CN": "简体中文", + "zh_TW": "繁體中文", + "en_US": "English", + "it_IT": "Italiano", + "ko_KR": "한국어", + "ru_RU": "Русский", + "es_ES": "Español", + "ja_JP": "日本語", + "tr_TR": "Türkçe", + "fa_IR": "فارسي", + "uk_UA": "Українська", + "cs_CZ": "Česky", + "el_GR": "Ελληνικά", + "ro_RO": "Română", + "lv_LV": "Latviešu", + "ka_GE": "ქართული", + "pt_BR": "Português do Brasil", + "sl_SI": "Slovenščina" + } + } +} diff --git a/ui-ngx/src/assets/logo_title_white.svg b/ui-ngx/src/assets/logo_title_white.svg new file mode 100644 index 0000000..3e6d570 --- /dev/null +++ b/ui-ngx/src/assets/logo_title_white.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/logo_white.svg b/ui-ngx/src/assets/logo_white.svg new file mode 100644 index 0000000..52a38c9 --- /dev/null +++ b/ui-ngx/src/assets/logo_white.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/mdi.svg b/ui-ngx/src/assets/mdi.svg new file mode 100644 index 0000000..abc8408 --- /dev/null +++ b/ui-ngx/src/assets/mdi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/shadow.png b/ui-ngx/src/assets/shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..fc2f271799dbf1ec2055dd532952cd9c1adff6e2 GIT binary patch literal 712 zcmV;(0yq7MP)DPwCqqps>CHM?|!y z5@-i@Vt4v!5yADje^sQdo^bOC^;Sfmh}zGz6)FojnqV^tv={r*cILGSt(t3BBS9AO z0W0~ti0G(R)AS8Eg+17hgE)}3J;AD7))r+^6)BvvvVn@`S<%g901LQ79X2D<-iwOS zvn-;wB+~j_L~*HeL9c+71lg64dqos?C_f1*_@dP$AMhSuSG&1az$t7I9XgX>+xmd3 z;~ie(C0^ipo?BJD0*-0TyU7IZR&T4RCZhEa57M6DW4A@U02VW9QEQkMgSB{r{7KCO z`-)cycOOsjCim&8UI3Rx5r4w0Nby#!L0r6VJJm?AH+Y2Gc#?a}Ya7OE1thxbvlt7T zL_KcFIbmU zqDb49JZ^`piO#%=OSmf@YE3E&SWCFN=;{r5zKO>;hx4ND2BLmG7ACZ6u_5a74$k7D zXn=vKAHcd+Cy(JK&fuB>q_k6I0V|?G4vTNV>o_Ig>VriKl?80#D2|A+a~hWi4b`r) ufX8rL)ZHmu7&y@ybwZ@`IIX7+?Kac^0000 \ No newline at end of file diff --git a/ui-ngx/src/environments/environment.prod.ts b/ui-ngx/src/environments/environment.prod.ts new file mode 100644 index 0000000..f724eb6 --- /dev/null +++ b/ui-ngx/src/environments/environment.prod.ts @@ -0,0 +1,25 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 const environment = { + appTitle: 'ThingsBoard', + production: true, +// @ts-ignore + tbVersion: TB_VERSION, +// @ts-ignore + supportedLangs: SUPPORTED_LANGS, + defaultLang: 'en_US' +}; diff --git a/ui-ngx/src/environments/environment.ts b/ui-ngx/src/environments/environment.ts new file mode 100644 index 0000000..82d426e --- /dev/null +++ b/ui-ngx/src/environments/environment.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + appTitle: 'ThingsBoard', + production: false, +// @ts-ignore + tbVersion: TB_VERSION, +// @ts-ignore + supportedLangs: SUPPORTED_LANGS, + defaultLang: 'en_US' +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/ui-ngx/src/index.html b/ui-ngx/src/index.html new file mode 100644 index 0000000..415dae7 --- /dev/null +++ b/ui-ngx/src/index.html @@ -0,0 +1,107 @@ + + + + + + ThingsBoard + + + + + + + + + + +
    +
    +
    +
    +
    + + diff --git a/ui-ngx/src/karma.conf.js b/ui-ngx/src/karma.conf.js new file mode 100644 index 0000000..aa28772 --- /dev/null +++ b/ui-ngx/src/karma.conf.js @@ -0,0 +1,47 @@ +/* + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function(config) { + config.set({ + basePath: "", + frameworks: ["jasmine", "@angular-devkit/build-angular"], + plugins: [ + require("karma-jasmine"), + require("karma-chrome-launcher"), + require("karma-jasmine-html-reporter"), + require("karma-coverage-istanbul-reporter"), + require("@angular-devkit/build-angular/plugins/karma"), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require("path").join(__dirname, "../coverage/tb-license-server"), + reports: ["html", "lcovonly", "text-summary"], + fixWebpackSourcePaths: true, + }, + reporters: ["progress", "kjhtml"], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ["Chrome"], + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/ui-ngx/src/main.ts b/ui-ngx/src/main.ts new file mode 100644 index 0000000..eb11823 --- /dev/null +++ b/ui-ngx/src/main.ts @@ -0,0 +1,30 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from '@app/app.module'; +import { environment } from '@env/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); diff --git a/ui-ngx/src/polyfills.ts b/ui-ngx/src/polyfills.ts new file mode 100644 index 0000000..7cf0550 --- /dev/null +++ b/ui-ngx/src/polyfills.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags.ts'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ + +import './zone-flags'; +import 'zone.js'; // Included with Angular CLI. +import 'core-js/es/array'; + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ + +(window as any).global = window; + +/*************************************************************************************************** + * WIDGETS IMPORTS + */ + +(window as any).GAUGES_NO_AUTO_INIT = true; diff --git a/ui-ngx/src/scss/animations.scss b/ui-ngx/src/scss/animations.scss new file mode 100644 index 0000000..5e4c9ce --- /dev/null +++ b/ui-ngx/src/scss/animations.scss @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@keyframes tbMoveFromTopFade { + from { + opacity: 0; + + transform: translate(0, -100%); + } +} + +@keyframes tbMoveToTopFade { + to { + opacity: 0; + + transform: translate(0, -100%); + } +} + +@keyframes tbMoveFromBottomFade { + from { + opacity: 0; + + transform: translate(0, 100%); + } +} + +@keyframes tbMoveToBottomFade { + to { + opacity: 0; + + transform: translate(0, 150%); + } +} diff --git a/ui-ngx/src/scss/constants.scss b/ui-ngx/src/scss/constants.scss new file mode 100644 index 0000000..8638619 --- /dev/null +++ b/ui-ngx/src/scss/constants.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +$mat-xs: "screen and (max-width: 599px)"; +$mat-sm: "screen and (min-width: 600px) and (max-width: 959px)"; +$mat-md: "screen and (min-width: 960px) and (max-width: 1279px)"; +$mat-lg: "screen and (min-width: 1280px) and (max-width: 1919px)"; +$mat-xl: "screen and (min-width: 1920px) and (max-width: 5000px)"; +$mat-lt-sm: "screen and (max-width: 599px)"; +$mat-lt-md: "screen and (max-width: 959px)"; +$mat-lt-lg: "screen and (max-width: 1279px)"; +$mat-lt-xl: "screen and (max-width: 1919px)"; +$mat-gt-xs: "screen and (min-width: 600px)"; +$mat-gt-sm: "screen and (min-width: 960px)"; +$mat-gt-md: "screen and (min-width: 1280px)"; +$mat-gt-xmd: "screen and (min-width: 1600px)"; +$mat-gt-xl: "screen and (min-width: 1920px)"; + +$primary-hue-3: rgb(207, 216, 220) !default; diff --git a/ui-ngx/src/scss/fonts.scss b/ui-ngx/src/scss/fonts.scss new file mode 100644 index 0000000..e7b8bec --- /dev/null +++ b/ui-ngx/src/scss/fonts.scss @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@font-face { + font-family: "Segment7Standard"; + font-style: italic; + font-weight: 400; + src: url("data:font/opentype;charset=utf-8;base64,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AAEAAAAIAAAAAAAEAAAAAAAAAAEAAAAAzD2izwAAAADPr89TAAAAAM+4fiECSQAAAkkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABpwBCAC8ALwAvAFwBpwAvAC8ALwBcAC8AXAIBAC8ALwGWAC8ALwBCAC4ALgBYAC8ALwBcAacALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8ALwGWAC8ALwAvAC8ALwAvAC8AQgAvAC4ALwAvAC8ALwAvAC8ALwAvAEIALwBCAFwBpwAvAC8ALwAvAC8ALwAvAC8BlgAvAC8ALwAvAC8ALwAvAEIALwAuAC8ALwAvAC8ALwAvAC8AAAGWAAAALwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEIAAAAAAAAAAAAvAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC8ALwAAAAAAAAAAAAAAAAAAAC8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") format("opentype"); +} diff --git a/ui-ngx/src/scss/mixins.scss b/ui-ngx/src/scss/mixins.scss new file mode 100644 index 0000000..243761e --- /dev/null +++ b/ui-ngx/src/scss/mixins.scss @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 "sass:math"; + +@mixin tb-mat-icon-size($size) { + width: #{$size}px; + min-width: #{$size}px; + height: #{$size}px; + min-height: #{$size}px; + font-size: #{$size}px; + line-height: #{$size}px; + svg { + width: #{$size}px; + height: #{$size}px; + } +} + +@mixin tb-mat-icon-button-size($size) { + width: #{$size}px; + height: #{$size}px; + line-height: #{$size}px; + padding: 0 !important; + .mat-icon { + display: block; + margin: auto; + } +} + +@mixin tb-checkered-bg() { + background-color: #fff; + background-image: + linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd), + linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd); + background-position: 0 0, 4px 4px; + background-size: 8px 8px; +} + +@function sqrt($r) { + $x0: 1; + $x1: $x0; + + @for $i from 1 through 10 { + $x1: $x0 - math.div($x0 * $x0 - abs($r), 2 * $x0); + $x0: $x1; + } + + @return $x1; +} diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss new file mode 100644 index 0000000..451525f --- /dev/null +++ b/ui-ngx/src/styles.scss @@ -0,0 +1,1488 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* You can add global styles to this file, and also import other style files */ + +@import '~typeface-roboto/index.css'; +@import '~font-awesome/css/font-awesome.min.css'; +@import 'theme.scss'; +@import './scss/constants'; +@import './scss/animations'; +@import './scss/mixins'; +@import './scss/fonts'; + +body, html { + height: 100%; + min-height: 100%; + position: relative; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-touch-callout: none; + + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + padding: 0; + background-color: #eee; + overflow: hidden; +} + +tb-root { + margin: 0; + width: 100%; + min-height: 100%; + height: 100%; + display: flex; + flex-direction: row; + box-sizing: border-box; +} + +/*************** + * TYPE DEFAULTS + ***************/ + +body, +button, +html, +input, +select, +textarea, +td, +th { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 16px; +} + +body { + line-height: normal; +} + +a { + font-weight: 400; + color: #106cc8; + text-decoration: none; + border-bottom: 1px solid rgba(64, 84, 178, .25); + + transition: border-bottom .35s; +} + +a:hover, +a:focus { + border-bottom: 1px solid #4054b2; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + &, &.mat-headline { + margin-top: 1rem; + margin-bottom: 1rem; + } +} + +h1 { + font-size: 3.4rem; + font-weight: 400; + line-height: 4rem; +} + +h2 { + font-size: 2.4rem; + font-weight: 400; + line-height: 3.2rem; +} + +h3 { + font-size: 2rem; + font-weight: 500; + letter-spacing: .005em; +} + +h4 { + font-size: 1.6rem; + font-weight: 400; + line-height: 2.4rem; + letter-spacing: .01em; +} + +h5 { + font-size: 1.4rem; + font-weight: 400; + line-height: 2rem; + letter-spacing: .01em; +} + +h6 { + font-size: 1.2rem; + font-weight: 400; + line-height: 1.6rem; + letter-spacing: .01em; +} + +p { + margin: .8em 0 1.6em; + font-size: 1.6rem; + font-weight: 400; + line-height: 1.6em; + letter-spacing: .01em; +} + +strong { + font-weight: 500; +} + +blockquote { + padding-left: 16px; + margin-left: 0; + font-style: italic; + border-left: 3px solid rgba(0, 0, 0, .12); +} + +fieldset { + padding: 0; + margin: 0; + border: none; +} + +section.tb-header-buttons { + position: absolute; + top: 86px; + right: 0; + z-index: 3; + pointer-events: none; + + @media #{$mat-gt-sm} { + top: 86px; + } + + .tb-btn-header { + margin: 6px 8px; + position: relative !important; + display: inline-block !important; + animation: tbMoveFromTopFade .3s ease both; + + &.tb-hide { + animation: tbMoveToTopFade .3s ease both; + } + } +} + +section.tb-footer-buttons { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 30; + pointer-events: none; + + .tb-btn-footer { + margin: 6px 8px; + position: relative !important; + display: inline-block !important; + animation: tbMoveFromBottomFade .3s ease both; + &.tb-hide { + animation: tbMoveToBottomFade .3s ease both; + } + } +} + + +.tb-details-buttons { + button { + margin: 6px 8px; + } +} + +label { + &.tb-title { + padding-bottom: 15px; + font-size: 13px; + font-weight: 400; + color: #666; + pointer-events: none; + + &.no-padding { + padding-bottom: 0; + } + + &.tb-required::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } + + &.tb-error { + color: rgb(221, 44, 0); + + &.tb-required::after { + color: rgb(221, 44, 0); + } + } + } + &.tb-small { + font-size: 12px; + color: rgba(0, 0, 0, .54); + pointer-events: none; + } +} + +.tb-noselect { + user-select: none; +} + +.tb-readonly-label { + color: rgba(0, 0, 0, .54); +} + +.tb-disabled-label { + color: rgba(0, 0, 0, .44); +} + +div { + &.tb-small { + font-size: 14px; + color: rgba(0, 0, 0, .54); + } +} + +.tb-hint { + padding-bottom: 15px; + font-size: 12px; + line-height: 14px; + color: #808080; +} + +.mat-caption { + &.tb-required::after { + font-size: 10px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } +} + +pre.tb-highlight { + display: block; + padding: 15px; + margin: 20px 0; + overflow-x: auto; + background-color: #f7f7f7; + + code { + box-sizing: border-box; + display: inline-block; + padding: 0; + font-family: monospace; + font-size: 16px; + font-weight: 700; + color: #303030; + vertical-align: bottom; + } +} + +.tb-notice { + padding: 15px; + font-size: 16px; + background-color: #f7f7f7; + border: 1px solid #ccc; +} + +.ace_editor { + font-size: 16px !important; +} + +.tb-timewindow-panel, .tb-legend-config-panel { + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); +} + +.tb-panel-actions { + margin-bottom: 0; + padding: 8px 8px 8px 16px; + .mat-button+.mat-button, + .mat-button+.mat-raised-button, + .mat-raised-button+.mat-button, + .mat-raised-button+.mat-raised-button { + margin-left: 8px; + } +} + +.tb-container { + position: relative; + padding: 10px 0; + margin-top: 32px; +} + +.tb-prompt { + display: flex; + font-size: 18px; + font-weight: 400; + line-height: 18px; + color: rgba(0, 0, 0, .38); + &.required { + color: rgb(221, 44, 0); + } +} + +.tb-fullscreen { + position: fixed !important; + top: 0; + left: 0; + width: 100% !important; + height: 100% !important; +} + +.tb-fullscreen-parent { + background: #eee; + z-index: 0; +} + +mat-label { + &.tb-title { + font-size: 13px; + font-weight: 400; + color: #666; + pointer-events: none; + + &.no-padding { + padding-bottom: 0; + } + + &.tb-required::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } + + &.tb-error { + color: rgb(221, 44, 0); + + &.tb-required::after { + color: rgb(221, 44, 0); + } + } + } +} + +.tb-error-messages { + height: 24px; //30px + margin-top: -6px; +} + +.tb-error-message { + transition: all .3s cubic-bezier(.55, 0, .55, .2); + padding: 10px 0 0 10px; + overflow: hidden; + font-size: 12px; + line-height: 14px; + color: rgb(221, 44, 0); +} + +.tb-autocomplete { + .mat-option { + display: block; + line-height: 24px; + height: auto !important; + padding-top: 8px; + border-bottom: 1px solid #eee; + font-size: 14px; + .mat-option-text { + line-height: 24px; + height: auto !important; + white-space: normal !important; + } + } + .mat-option.tb-not-found { + padding: 0; + border-bottom: none; + .mat-option-text { + display: block; + .tb-not-found-content { + padding: 8px 16px 7px; + border-bottom: 1px solid #eee; + } + } + } +} + +.tb-ace-doc-tooltip { + code { + color: #444; + &.title { + font-size: 14px; + } + } + div.tb-function-info { + font-size: 14px; + } + div.tb-function-return { + font-size: 1rem; + letter-spacing: 0.03rem; + color: #444; + code { + font-size: 14px; + letter-spacing: normal; + } + } + div.tb-api-title { + font-weight: bold; + font-size: 16px; + color: #6e6e6e; + padding-top: 12px; + padding-bottom: 12px; + } + table.tb-api-table { + width: 100%; + border-collapse: collapse; + tr { + border-bottom: 1px solid #a8a8a8; + &:last-child { + border-bottom: none; + } + td { + font-size: 14px; + line-height: 1.6rem; + &:first-child { + font-weight: 600; + padding-left: 16px; + width: 20%; + } + &.arg-description { + font-size: 1rem; + letter-spacing: .03rem; + color: #444; + } + } + } + } +} + +.tb-markdown-view { + display: block; + + $headings: h1, h2, h3, h4, h5, h6; + + h1 { + font-size: 32px; + padding-right: 60px; + } + + @each $heading in $headings { + #{$heading} { + color: #0F161D; + line-height: normal; + font-weight: 500; + border-bottom: none; + margin: 0; + } + & > #{$heading} { + padding: 30px 32px 10px; + } + } + + p { + font-size: 16px; + font-weight: 400; + line-height: 1.25em; + margin: 0; + } + + p + p { + margin-top: 10px; + } + + p, div { + color: rgba(15, 22, 29, 0.8); + line-height: 1.5em; + } + + & > p, & > div { + padding-right: 32px; + padding-left: 32px; + } + + ul { + padding-left: 62px; + padding-right: 32px; + color: rgba(15, 22, 29, 0.8); + margin-top: 16px; + margin-bottom: 16px; + } + + ul { + @each $heading in $headings { + & + #{$heading} { + padding-top: 14px; + } + } + } + + li { + padding-bottom: .75em; + line-height: 1.5em; + ul { + margin-bottom: 0; + } + p { + padding-left: 0; + } + } + + a { + font-weight: 500; + color: #2a7dec; + text-decoration: none; + border: none; + &:hover { + color: #2a7dec; + text-decoration: underline; + border: none; + } + } + + & > table { + margin-left: 32px; + width: calc(100% - 64px); + border: 1px solid rgba(42,125,236,.2); + border-radius: 4px; + border-collapse: unset; + border-spacing: 0; + margin-top: 30px; + margin-bottom: 30px; + overflow: hidden; + table-layout: fixed; + &.auto { + table-layout: auto; + } + + & > thead { + background-color: #f9fbff; + color: rgba(33,37,41,.6); + + & > tr { + & > th { + border-bottom: 1px solid rgba(42,125,236,.2); + font-size: 16px; + padding: 12px 16px; + text-align: left; + margin: 0; + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 12px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + } + + & > tbody { + & > tr:not(:last-child) { + & > td { + border-bottom: 1px solid rgba(42,125,236,.2); + } + } + & > tr { + & > td { + font-size: 16px; + padding: 12px 16px; + text-align: left; + margin: 0; + color: rgba(15, 22, 29, 0.8); + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 12px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + } + th, td { + font-size: .85em; + padding: 8px; + margin: 0; + text-align: left; + } + td[align=center], th[align=center] { + text-align: center; + } + + td[align=right], th[align=right] { + text-align: right; + } + + tr td div { + padding-left: 0; + padding-right: 0; + } + } + + + div.divider { + padding-top: 32px; + border-bottom: 1px solid rgba(15, 22, 29, 0.1); + } + + ul + div.divider { + padding-top: 16px; + } + + img { + max-width: 100%; + } + + button.tb-button { + cursor: pointer; + display: inline-block; + border-radius: 4px; + border: none; + padding: 10px 20px; + line-height: 24px; + color: #fff; + background-color: #305680; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2); + text-decoration: none; + font-size: 16px; + font-weight: 500; + transition: background-color .4s cubic-bezier(.25,.8,.25,1); + } + + button.tb-button:hover { + background-color: #264363; + color: #fff; + text-decoration: none; + } + + #video { + width: 100%; + margin: 0; + position: relative; + } + + + #video #video_wrapper { + position: relative; + width: 100%; + padding-bottom: 56.25%; + padding-left: 0; + padding-right: 0; + } + + #video #video_wrapper iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + @media screen and (min-width: 750px) { + #video { + width: 100%; + display: block; + } + } + + @media screen and (min-width: 1025px) { + #video { + width: 50%; + position: relative; + } + } + + code:not([class*=language-]) { + color: #eb5757; + font-family: monospace; + font-size: 16px; + } + + div.code-wrapper { + position: relative; + button.clipboard-btn { + pointer-events: none; + outline: none; + position: absolute; + width: 206px; + height: 42px; + top: 0; + right: 32px; + background: 0 0; + border: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + &.multiline { + right: 44px; + } + p { + padding: 8px; + top: 1px; + transition: .2s; + color: #2a7dec; + background: rgba(255,255,255,.85); + backdrop-filter: blur(4px); + opacity: 0; + font-weight: 500; + right: 32px; + position: absolute; + } + div { + background-color: #fff; + position: absolute; + width: 38px; + height: 38px; + top: 3px; + right: 3px; + padding: 10px; + img { + position: initial; + width: 18px; + height: 18px; + filter: invert(51%) sepia(6%) saturate(172%) hue-rotate(177deg) brightness(94%) contrast(92%); + } + } + } + + &:hover { + cursor: pointer; + pre[class*="language-"] { + border: solid 1px #2a7dec; + } + button.clipboard-btn { + p { + opacity: 1; + } + div { + img { + filter: invert(49%) sepia(97%) saturate(3730%) hue-rotate(200deg) brightness(95%) contrast(95%); + } + } + } + } + } + + th, td { + div.code-wrapper { + display: inline-block; + width: 100%; + + button.clipboard-btn { + top: -10px; + right: 0; + padding: 0 3px; + } + } + } + + pre[class*="language-"] { + font-size: 16px; + border: 1px solid rgba(42,125,236,.2); + border-radius: 4px; + background: 0 0; + padding: 8px 16px; + color: #212529; + .token.atrule, .token.attr-value, .token.keyword { + color: #2a7dec; + } + .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { + color: #eb5757; + } + .token.punctuation { + color: #212529; + } + &.line-numbers { + padding-left: 66px; + & > code { + span.line-numbers-rows { + top: -12px; + bottom: -12px; + left: -66px; + width: 50px; + border: none; + padding: 8px 12px 8px 18px; + text-align: right; + background: #f9fbff; + & > span:before { + color: rgba(33,37,41,.6); + padding-right: 0; + } + } + } + &.no-line-numbers { + padding-left: 16px; + & > code { + span.line-numbers-rows { + display: none; + } + } + } + } + } +} + +// Tooltipster + +.tooltipster-sidetip.tooltipster-tb { + .tooltipster-box { + background: rgba(3, 8, 40, 0.64); + border: none; + border-radius: 4px; + .tooltipster-content { + padding: 4px 8px; + font-size: 12px; + line-height: 16px; + font-weight: 500; + color: #ffffff; + } + } +} + +.tb-default, .tb-dark { + + /********************************* + * MATERIAL DESIGN CUSTOMIZATIONS + ********************************/ + + .mat-tooltip { + white-space: pre-line; + } + + button { + pointer-events: all; + } + + button.mat-menu-item { + font-size: 15px; + } + + button.mat-fab.mat-fab-bottom-right { + top: auto; + right: 20px; + bottom: 20px; + left: auto; + position: absolute; + } + + .layout-padding, .layout-padding > * { + @media #{$mat-lt-sm} { + padding: 4px; + } + @media #{$mat-gt-xs} { + padding: 8px; + } + } + + .mat-padding { + padding: 8px; + @media #{$mat-gt-sm} { + padding: 16px; + } + } + + .mat-content { + position: relative; + overflow: auto; + } + + .layout-wrap { + flex-wrap: wrap; + } + + mat-form-field.mat-block { + display: block; + } + + .mat-form-field{ + .mat-icon { + margin-right: 4px; + margin-left: 4px; + } + } + + button.mat-menu-item { + overflow: hidden; + fill: #737373; + .tb-alt-text { + float: right; + } + } + + // Material table + + mat-toolbar.mat-primary { + button.mat-icon-button { + mat-icon { + color: white; + } + } + } + + mat-toolbar.mat-table-toolbar { + background: #fff; + padding: 0 24px; + .mat-toolbar-tools { + padding: 0; + & > button.mat-icon-button:last-child { + margin-right: -12px; + } + } + } + + mat-toolbar.mat-table-toolbar:not(.mat-primary), .mat-cell, .mat-expansion-panel-header { + button.mat-icon-button { + mat-icon { + color: rgba(0, 0, 0, .54); + } + &[disabled][disabled] { + mat-icon { + color: rgba(0, 0, 0, .26); + } + } + } + } + + .mat-table { + width: 100%; + max-width: 100%; + display: table; + table-layout: auto; + border-collapse: separate; + margin: 0; + } + + mat-footer-row::after, mat-header-row::after, mat-row::after { + content: none; + } + + mat-header-row { + height: 60px; + } + + mat-footer-row, mat-row { + height: 52px; + } + + mat-header-row, mat-footer-row, mat-row { + min-height: auto; + } + + .mat-row, + .mat-header-row { + display: table-row; + } + + + .mat-header-row.mat-table-sticky { + background-clip: padding-box; + .mat-header-cell { + position: sticky; + top: 0; + z-index: 10; + background: inherit; + background-clip: padding-box; + &.mat-table-sticky { + z-index: 11 !important; + } + } + } + + .mat-cell.mat-table-sticky { + background-clip: padding-box; + } + + .mat-row { + transition: background-color .2s; + &:hover:not(.tb-current-entity) { + background-color: #f4f4f4; + } + &.tb-current-entity { + background-color: #e9e9e9; + } + &.tb-pointer { + cursor: pointer; + } + } + + .mat-row:not(.mat-row-select), .mat-header-row:not(.mat-row-select) { + mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child { + padding: 0 12px; + } + mat-cell:nth-child(n+2):nth-last-child(n+2), mat-footer-cell:nth-child(n+2):nth-last-child(n+2), mat-header-cell:nth-child(n+2):nth-last-child(n+2) { + padding: 0 28px 0 0; + } + } + + .mat-row.mat-row-select, .mat-header-row.mat-row-select { + mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child { + width: 30px; + padding: 0 0 0 12px; + } + mat-cell:nth-child(2), mat-footer-cell:nth-child(2), mat-header-cell:nth-child(2) { + padding: 0 12px; + } + mat-cell:nth-child(n+3):nth-last-child(n+2), mat-footer-cell:nth-child(n+3):nth-last-child(n+2), mat-header-cell:nth-child(n+3):nth-last-child(n+2) { + padding: 0 28px 0 0; + } + &.mat-selected:not(.tb-current-entity) { + background-color: #ededed; + } + } + + .mat-cell, + .mat-header-cell { + min-width: 40px; + word-wrap: initial; + display: table-cell; + box-sizing: content-box; + line-break: unset; + width: 0; + overflow: hidden; + vertical-align: middle; + border-width: 0; + border-bottom-width: 1px; + border-bottom-color: rgba(0, 0, 0, 0.12); + border-style: solid; + text-overflow: ellipsis; + touch-action: auto !important; + &:last-child { + padding: 0 12px 0 0; + } + &.mat-column-select { + min-width: 30px; + max-width: 30px; + width: 30px; + padding: 0 0 0 12px; + } + &.mat-column-actions { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .mat-header-cell { + white-space: nowrap; + button.mat-sort-header-button { + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + &.mat-number-cell { + .mat-sort-header-container { + justify-content: flex-end; + } + } + } + + .mat-cell { + &.mat-number-cell { + text-align: end; + } + mat-icon { + color: rgba(0, 0, 0, .54); + } + } + + .mat-cell, .mat-footer-cell { + font-size: 13px; + } + + .mat-cell, .mat-footer-cell, .mat-header-cell { + // fix for ie11 'align-items: center' + height: 20px; + } + + .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; + } + + .mat-toolbar-tools { + //font-size: 20px; + letter-spacing: .005em; + //box-sizing: border-box; + font-weight: 400; + display: flex; + align-items: center; + flex-direction: row; + width: 100%; + height: 64px; + //max-height: 64px; + padding: 0 16px; + //margin: 0; + } + + .mat-icon { + vertical-align: bottom; + box-sizing: content-box; + &.tb-mat-16 { + @include tb-mat-icon-size(16); + } + &.tb-mat-18 { + @include tb-mat-icon-size(18); + } + &.tb-mat-20 { + @include tb-mat-icon-size(20); + } + &.tb-mat-28 { + @include tb-mat-icon-size(28); + } + &.tb-mat-32 { + @include tb-mat-icon-size(32); + } + &.tb-mat-96 { + @include tb-mat-icon-size(96); + } + } + + .mat-icon-button { + &.tb-mat-28 { + @include tb-mat-icon-button-size(28); + } + &.tb-mat-32 { + @include tb-mat-icon-button-size(32); + } + &.tb-mat-96 { + @include tb-mat-icon-button-size(96); + } + } + + .mat-snack-bar-container { + position: absolute; + background: none; + box-shadow: none; + margin: 0; + padding: 0; + border: none; + border-radius: inherit; + max-width: inherit; + min-width: inherit; + pointer-events: none; + display: flex; + } + + .mat-snack-bar-handset { + .mat-snack-bar-container { + position: relative !important; + width: 100% !important; + top: 0 !important; + left: 0 !important; + height: inherit !important; + tb-snack-bar-component { + width: 100%; + } + } + } + + .mat-drawer-side { + border: none; + } + + .mat-drawer-inner-container { + display: flex; + flex-direction: column; + overflow: hidden; + } + + mat-drawer.tb-details-drawer { + z-index: 59 !important; + width: 100% !important; + max-width: 100% !important; + @media #{$mat-gt-sm} { + width: 80% !important; + } + @media #{$mat-gt-md} { + width: 65% !important; + } + } + + .mat-card-subtitle, .mat-card-content { + font-size: 16px; + } + + .mat-toolbar > button:first-child { + margin-left: -8px; + } + + .mat-toolbar > button:last-child { + margin-right: -8px; + } + + .mat-toolbar { + line-height: normal; + + h1, h2, h3, h4, h5, h6 { + overflow: hidden; + text-overflow: ellipsis; + } + } + + mat-toolbar *, mat-toolbar :after, mat-toolbar :before { + box-sizing: border-box; + } + + .mat-button, .mat-flat-button, .mat-stroked-button, .mat-raised-button { + &:not(.mat-icon-button) { + @media #{$mat-lt-md} { + padding: 0 6px; + min-width: 88px; + } + mat-icon { + margin-right: 5px; + } + } + } + + .tb-dialog { + .mat-dialog-container { + padding: 0; + > *:first-child, form { + max-width: 100%; + min-width: 100%; + display: flex; + flex-direction: column; + } + .mat-dialog-content { + margin: 0; + padding: 24px; + } + .mat-dialog-actions { + margin-bottom: 0; + padding: 8px; + } + } + } + + .tb-fullscreen-dialog-gt-xs { + @media #{$mat-gt-xs} { + min-height: 100%; + min-width: 100%; + max-width: none !important; + position: absolute !important; + top: 0; + bottom: 0; + left: 0; + right: 0; + .mat-dialog-container { + > *:first-child, form { + min-width: 100% !important; + } + .mat-dialog-content { + max-height: 100%; + } + } + } + } + + .tb-fullscreen-dialog { + @media #{$mat-lt-sm} { + min-height: 100%; + min-width: 100%; + max-width: none !important; + position: absolute !important; + top: 0; + bottom: 0; + left: 0; + right: 0; + .mat-dialog-container { + > *:first-child, form { + min-width: 100% !important; + height: 100%; + } + .mat-dialog-content { + max-height: 100%; + } + } + } + } + + .tb-absolute-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + .tb-layout-fill { + margin: 0; + width: 100%; + min-height: 100%; + height: 100%; + } + + .tb-progress-cover { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 6; + background-color: #eee; + opacity: 1; + } + + .mat-button.tb-fullscreen-button-style, + .tb-fullscreen-button-style { + background: #ccc; + opacity: .85; + + mat-icon { + color: #666; + } + } + + span.no-data-found { + position: relative; + display: flex; + height: calc(100% - 60px); + text-align: center; + } + + + mat-tab-group.tb-headless { + margin-top: -50px; + } + + .tb-primary-background { + background-color: $primary; + } + + .mat-chip-list.dragging { + .mat-chip { + &.mat-standard-chip { + &::after { + transition: none; + } + &.dragging { + .mat-chip-ripple { + display: none !important; + } + } + &.dropping { + //border: dashed 2px; + //opacity: .5; + + //.md-chip-content { + // margin: -2px; + //} + } + + &.dropping-before { + &::after { + position: absolute; + top: 0; + right: 50%; + bottom: 0; + left: 0; + content: ""; + background-color: #fff; + border: dashed 2px; + border-radius: 16px; + opacity: .7; + } + } + + &.dropping-after { + &::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 50%; + content: ""; + background-color: #fff; + border: dashed 2px; + border-radius: 16px; + opacity: .7; + } + } + } + } + } + + .tb-color-preview { + cursor: pointer; + box-sizing: border-box; + position: relative; + width: 24px; + min-width: 24px; + height: 24px; + overflow: hidden; + content: ""; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .14), 0 2px 2px 0 rgba(0, 0, 0, .098), 0 1px 5px 0 rgba(0, 0, 0, .084); + + @include tb-checkered-bg(); + + .tb-color-result { + width: 100%; + height: 100%; + } + } + + .tb-tooltip-multiline { + max-width: 400px; + height: auto !important; + padding-top: 6px; + padding-bottom: 6px; + line-height: 1.5; + white-space: pre-line; + } + + .tb-toast-panel { + pointer-events: none !important; + } + + .tb-draggable { + &.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + } + + .tb-drop-list { + &.cdk-drop-list-dragging { + .tb-draggable { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + } + } + + .tb-drag-handle { + cursor: move; + mat-icon { + pointer-events: none; + } + } +} diff --git a/ui-ngx/src/test.ts b/ui-ngx/src/test.ts new file mode 100644 index 0000000..09a77fc --- /dev/null +++ b/ui-ngx/src/test.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/ui-ngx/src/theme.scss b/ui-ngx/src/theme.scss new file mode 100644 index 0000000..cad2c72 --- /dev/null +++ b/ui-ngx/src/theme.scss @@ -0,0 +1,254 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 '~@angular/material' as mat; +@import '~@mat-datetimepicker/core/datetimepicker/datetimepicker-theme.scss'; +@import './scss/constants'; + +@include mat.core(); + +$tb-primary-color: #305680; +$tb-secondary-color: #527dad; +$tb-hue3-color: #a7c1de; + +$tb-dark-primary-color: #9fa8da; + +$tb-mat-indigo: ( + 50: #e8eaf6, + 100: #c5cae9, + 200: #9fa8da, + 300: #7986cb, + 400: #5c6bc0, + 500: $tb-primary-color, + 600: $tb-secondary-color, + 700: #303f9f, + 800: #283593, + 900: #1a237e, + A100: $tb-hue3-color, + A200: #536dfe, + A400: #3d5afe, + A700: #304ffe, + contrast: ( + 50: rgba(black, 0.87), + 100: rgba(black, 0.87), + 200: rgba(black, 0.87), + 300: white, + 400: white, + 500: white, + 600: white, + 700: white, + 800: white, + 900: white, + A100: rgba(black, 0.87), + A200: white, + A400: white, + A700: white, + ) +); + +$tb-primary: mat.define-palette($tb-mat-indigo); +$tb-accent: mat.define-palette(mat.$deep-orange-palette); + +$background: (background: map_get(mat.$grey-palette, 200)); + +$tb-theme-background: map_merge(mat.$light-theme-background-palette, $background); + +$tb-mat-theme: mat.define-light-theme( + $tb-primary, + $tb-accent +); + +$tb-theme: map_merge($tb-mat-theme, (background: $tb-theme-background)); + +$primary: mat.get-color-from-palette($tb-primary); +$accent: mat.get-color-from-palette($tb-accent); + +$tb-dark-mat-indigo: ( + 50: #e8eaf6, + 100: #c5cae9, + 200: #9fa8da, + 300: #7986cb, + 400: #5c6bc0, + 500: $tb-dark-primary-color, + 600: $tb-secondary-color, + 700: #303f9f, + 800: $tb-primary-color, + 900: #1a237e, + A100: $tb-hue3-color, + A200: #536dfe, + A400: #3d5afe, + A700: #304ffe, + contrast: ( + 50: rgba(black, 0.87), + 100: rgba(black, 0.87), + 200: rgba(black, 0.87), + 300: rgba(black, 0.87), + 400: rgba(black, 0.87), + 500: map_get($tb-mat-indigo, 900), + 600: white, + 700: white, + 800: white, + 900: white, + A100: rgba(black, 0.87), + A200: rgba(black, 0.87), + A400: rgba(black, 0.87), + A700: rgba(black, 0.87), + ) +); + +$tb-dark-primary: mat.define-palette($tb-dark-mat-indigo); + +$tb-dark-theme-background: ( + status-bar: black, + app-bar: map_get($tb-dark-mat-indigo, 900), + background: map_get($tb-dark-mat-indigo, 800), + hover: rgba(white, 0.04), + card: map_get($tb-dark-mat-indigo, 800), + dialog: map_get($tb-dark-mat-indigo, 800), + disabled-button: rgba(white, 0.12), + raised-button: map-get($tb-dark-mat-indigo, 50), + focused-button: rgba(white, 0.12), + selected-button: map_get($tb-dark-mat-indigo, 900), + selected-disabled-button: map_get($tb-dark-mat-indigo, 800), + disabled-button-toggle: black, + unselected-chip: map_get($tb-dark-mat-indigo, 700), + disabled-list-option: black, + tooltip: map_get(mat.$grey-palette, 700), +); + +@function get-tb-dark-theme($primary, $accent, $warn: mat.define-palette(mat.$red-palette)) { + @return ( + primary: $primary, + accent: $accent, + warn: $warn, + is-dark: true, + foreground: mat.$dark-theme-foreground-palette, + background: $tb-dark-theme-background, + ); +} + +$tb-dark-theme: get-tb-dark-theme( + $tb-dark-primary, + $tb-accent +); + +@mixin mat-fab-toolbar-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, warn); + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + mat-fab-toolbar { + .mat-fab-toolbar-background { + background: mat.get-color-from-palette($background, app-bar); + color: mat.get-color-from-palette($foreground, text); + } + &.mat-primary { + .mat-fab-toolbar-background { + background: mat.get-color-from-palette($primary); + color: mat.get-color-from-palette($primary, default-contrast); + } + } + &.mat-accent { + .mat-fab-toolbar-background { + background: mat.get-color-from-palette($accent); + color: mat.get-color-from-palette($accent, default-contrast); + } + } + &.mat-warn { + .mat-fab-toolbar-background { + background: mat.get-color-from-palette($warn); + color: mat.get-color-from-palette($warn, default-contrast); + } + } + } +} + +@mixin _mat-toolbar-inverse-color($palette) { + background: mat.get-color-from-palette($palette, default-contrast); + color: rgba(black, 0.87); +} + +@mixin mat-fab-toolbar-inverse-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, warn); + $background: map-get($theme, foreground); + $foreground: map-get($theme, background); + + mat-fab-toolbar { + .mat-fab-toolbar-background { + background: mat.get-color-from-palette($background, app-bar); + color: mat.get-color-from-palette($foreground, text); + } + &.mat-primary { + .mat-fab-toolbar-background { + @include _mat-toolbar-inverse-color($primary); + } + } + mat-toolbar { + &.mat-primary { + @include _mat-toolbar-inverse-color($primary); + button.mat-icon-button { + mat-icon { + color: mat.get-color-from-palette($primary); + } + } + } + } + .mat-fab { + &.mat-primary { + background: mat.get-color-from-palette($primary, default-contrast); + color: mat.get-color-from-palette($primary); + } + } + } + +} + +@mixin tb-components-theme($theme) { + $primary: map-get($theme, primary); + $warn: map-get($theme, warn); + + mat-toolbar{ + &.mat-hue-3 { + background-color: mat.get-color-from-palette($primary, 'A100'); + } + } + + @include mat-fab-toolbar-theme($tb-theme); + + div.tb-dashboard-page.mobile-app { + @include mat-fab-toolbar-inverse-theme($tb-theme); + } + + .same-color.mat-form-field-invalid { + .mat-form-field-suffix { + color: mat.get-color-from-palette($warn, text); + } + } +} + +.tb-default { + @include mat.all-component-themes($tb-theme); + @include mat-datetimepicker-theme($tb-theme); + @include tb-components-theme($tb-theme); +} + +.tb-dark { + @include mat.all-component-themes($tb-dark-theme); +} + diff --git a/ui-ngx/src/thingsboard.ico b/ui-ngx/src/thingsboard.ico new file mode 100644 index 0000000000000000000000000000000000000000..8564792b7552b1f660b92688cf38194dce71ec5d GIT binary patch literal 4286 zcmb_fOK1~O6n!yD1hsXexG1J8b(NW+3k&|b776~Qb7MMT$v(4ZByipDP~BpJ_5<|Z$bm(19ja(iFiz4zSn-n;J;Awu|TZ58xi zT%Rh$bRonX2)aZEg!+pg*n)iQxPA`u%{KKem47m>Z-f1XZ5n%3_ObQ>dl=$C@=4R^ zk@1qIzR$Ih-LWm>xNPUP9oN>t{sVG@`-1AErLRU@zKAbzsL5U8NIXu+*x(zq+R`FoKA)@HqwJD;?)?jZS8J{}I7r-9b)SLODsoiazM);=Xo z%~IR7TR!7f@u{gB6*lz&`@4&uL%pw({ZK=FHMvdwnQdyj&_7sX<25n$2Qp5hwr%OX zz$v&`We&>RgukMVwl?uzRyTi%M^#NS2e&?cA@*ju&(b#GdxZmRYHZ`J6w|HtDehZd zlauUEnraa-ysv)C`vNwZ1IK&yCC0NY?Q($p6kp`v$(^TpU@X?~?Qe*QeJ%M8HT30L zAdPx}FOOTfCvmpy_QhKsYoYv91HQg;{z@BbY;5K+u&!Hcl{lKm*JXc8-#WVd#sDxv zj6>R8ckeo@zMtbbyHjsP{ymTym$|>t&x7kF{H=HVKHHx+1NG#WB(aw z{FbquV&o6vt^|p7eQ>527tp?Wc^~rgu?KvYG%^TYudNFV=FG2n{ zjXC({rnB-Y#IGg?3S@_6L-2yE}w_Uc;maLD-OB|H5F0XEl% zoY$wV-BRJ>UEK-1VeFOjD!bh0$(?uKJ%0OwdBMN(W|47&v@us*(zwfSCUDw<{ze?= zw+heKs5t=RBZS6MgHewI-znaCqkA^Um}MV#U%aE5k^8yr<-oZmyIeQJ-2Xo{-FvtL z2C(-wsBqmi%wP`Bs_Y|q$H`{*CvrRHCkkSQ string; + radius?: any; + background?: { + color?: string; + opacity?: number; + }; + threshold?: number; + }; + combine?: { + threshold?: number; + color?: string; + label?: string; + }; + highlight?: number; +} + +declare type JQueryPlotSelectionMode = 'x' | 'y' | 'xy' | null; +declare type JQueryPlotSelectionShape = 'round' | 'mitter' | 'bevel'; + +interface JQueryPlotSelection { + mode?: JQueryPlotSelectionMode; + color?: string; + shape?: JQueryPlotSelectionShape; + minSize?: number; +} + +interface JQueryPlotSelectionRanges { + [axis: string]: { + from: number; + to: number; + }; +} diff --git a/ui-ngx/src/typings/jquery.jstree.typings.d.ts b/ui-ngx/src/typings/jquery.jstree.typings.d.ts new file mode 100644 index 0000000..77d65c1 --- /dev/null +++ b/ui-ngx/src/typings/jquery.jstree.typings.d.ts @@ -0,0 +1,29 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +interface JSTreeEventData { + instance: JSTree; +} + +interface JSTreeModelEventData extends JSTreeEventData { + nodes: string[]; + parent: string; +} + +interface JQuery { + on(events: 'changed.jstree', handler: (e: Event, data: JSTreeEventData) => void): this; + on(events: 'model.jstree', handler: (e: Event, data: JSTreeModelEventData) => void): this; +} diff --git a/ui-ngx/src/typings/jquery.typings.d.ts b/ui-ngx/src/typings/jquery.typings.d.ts new file mode 100644 index 0000000..0466c92 --- /dev/null +++ b/ui-ngx/src/typings/jquery.typings.d.ts @@ -0,0 +1,19 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +interface JQuery { + terminal(options?: any): any; +} diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts new file mode 100644 index 0000000..898f8a6 --- /dev/null +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { FormattedData } from '@shared/models/widget.models'; + +// redeclare module, maintains compatibility with @types/leaflet +declare module 'leaflet' { + interface MarkerOptions { + tbMarkerData?: FormattedData; + } +} diff --git a/ui-ngx/src/typings/leaflet-geoman-extend.d.ts b/ui-ngx/src/typings/leaflet-geoman-extend.d.ts new file mode 100644 index 0000000..f294861 --- /dev/null +++ b/ui-ngx/src/typings/leaflet-geoman-extend.d.ts @@ -0,0 +1,1520 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +/* tslint:disable:adjacent-overload-signatures unified-signatures */ + +import * as L from 'leaflet'; + +declare module 'leaflet-pm' { + + /** + * Extends built in leaflet Layer Options. + */ + interface LayerOptions { + pmIgnore?: boolean; + snapIgnore?: boolean; + } + + /** + * Extends built in leaflet Map Options. + */ + interface MapOptions { + pmIgnore?: boolean; + } + + /** + * Extends built in leaflet Map. + */ + interface Map { + pm: PM.PMMap; + } + + /** + * Extends built in leaflet Path. + */ + interface Path { + pm: PM.PMLayer; + } + /** + * Extends built in leaflet ImageOverlay. + */ + interface ImageOverlay { + pm: PM.PMLayer; + } + + /** + * Extends built in leaflet LayerGroup. + */ + interface LayerGroup { + pm: PM.PMLayerGroup; + } + + /** + * Extends built in leaflet Polyline. + */ + interface Polyline { + /** Returns true if Line or Polygon has a self intersection. */ + hasSelfIntersection(): boolean; + } + + /** + * Extends @types/leaflet events... + * + * Todo: This is kind of a mess, and it makes all these event handlers show + * up on Layers and Map. Leaflet itself is based around Evented, and @types/leaflet + * makes this very hard to work around. + * + */ + interface Evented { + /****************************************** + * + * AVAILABLE ON MAP + LAYER, THESE ARE OK ON EVENTED. + * + ********************************************/ + + /** Fired when a layer is removed via Removal Mode. */ + on(type: 'pm:remove', fn: PM.RemoveEventHandler): this; + once(type: 'pm:remove', fn: PM.RemoveEventHandler): this; + off(type: 'pm:remove', fn?: PM.RemoveEventHandler): this; + + /** Fired when the layer being cut. Draw+Edit Mode */ + on(type: 'pm:cut', fn: PM.CutEventHandler): this; + once(type: 'pm:cut', fn: PM.CutEventHandler): this; + off(type: 'pm:cut', fn?: PM.CutEventHandler): this; + + /** Fired when rotation is enabled for a layer. */ + on(type: 'pm:rotateenable', fn: PM.RotateEnableEventHandler): this; + once(type: 'pm:rotateenable', fn: PM.RotateEnableEventHandler): this; + off(type: 'pm:rotateenable', fn?: PM.RotateEnableEventHandler): this; + + /** Fired when rotation is disabled for a layer. */ + on(type: 'pm:rotatedisable', fn: PM.RotateDisableEventHandler): this; + once(type: 'pm:rotatedisable', fn: PM.RotateDisableEventHandler): this; + off(type: 'pm:rotatedisable', fn?: PM.RotateDisableEventHandler): this; + + /** Fired when rotation starts on a layer. */ + on(type: 'pm:rotatestart', fn: PM.RotateStartEventHandler): this; + once(type: 'pm:rotatestart', fn: PM.RotateStartEventHandler): this; + off(type: 'pm:rotatestart', fn?: PM.RotateStartEventHandler): this; + + /** Fired when a layer is rotated. */ + on(type: 'pm:rotate', fn: PM.RotateEventHandler): this; + once(type: 'pm:rotate', fn: PM.RotateEventHandler): this; + off(type: 'pm:rotate', fn?: PM.RotateEventHandler): this; + + /** Fired when rotation ends on a layer. */ + on(type: 'pm:rotateend', fn: PM.RotateEndEventHandler): this; + once(type: 'pm:rotateend', fn: PM.RotateEndEventHandler): this; + off(type: 'pm:rotateend', fn?: PM.RotateEndEventHandler): this; + + /****************************************** + * + * TODO: DRAW/EDIT MODE EVENTS LAYER ONLY + * + ********************************************/ + + /** Fired during a marker move/drag. */ + on(type: 'pm:snapdrag', fn: PM.SnapEventHandler): this; + once(type: 'pm:snapdrag', fn: PM.SnapEventHandler): this; + off(type: 'pm:snapdrag', fn?: PM.SnapEventHandler): this; + + /** Fired when a vertex is snapped. */ + on(type: 'pm:snap', fn: PM.SnapEventHandler): this; + once(type: 'pm:snap', fn: PM.SnapEventHandler): this; + off(type: 'pm:snap', fn?: PM.SnapEventHandler): this; + + /** Fired when a vertex is unsnapped. */ + on(type: 'pm:unsnap', fn: PM.SnapEventHandler): this; + once(type: 'pm:unsnap', fn: PM.SnapEventHandler): this; + off(type: 'pm:unsnap', fn?: PM.SnapEventHandler): this; + + /** Called when the center of a circle is placed/moved. */ + on(type: 'pm:centerplaced', fn: PM.CenterPlacedEventHandler): this; + once(type: 'pm:centerplaced', fn: PM.CenterPlacedEventHandler): this; + off(type: 'pm:centerplaced', fn?: PM.CenterPlacedEventHandler): this; + + /****************************************** + * + * TODO: CUT/EDIT MODE EVENTS LAYER ONLY + * + ********************************************/ + + /** Fired when a layer is edited. */ + on(type: 'pm:edit', fn: PM.EditEventHandler): this; + once(type: 'pm:edit', fn: PM.EditEventHandler): this; + off(type: 'pm:edit', fn?: PM.EditEventHandler): this; + + /****************************************** + * + * TODO: DRAW MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when Drawing Mode is toggled. */ + on( + type: 'pm:globaldrawmodetoggled', + fn: PM.GlobalDrawModeToggledEventHandler, + context?: any + ): L.Evented; + once( + type: 'pm:globaldrawmodetoggled', + fn: PM.GlobalDrawModeToggledEventHandler, + context?: any + ): L.Evented; + off( + type: 'pm:globaldrawmodetoggled', + fn?: PM.GlobalDrawModeToggledEventHandler, + context?: any + ): L.Evented; + + /** Called when drawing mode is enabled. Payload includes the shape type and working layer. */ + on( + type: 'pm:drawstart', + fn: PM.DrawStartEventHandler, + context?: any + ): L.Evented; + once( + type: 'pm:drawstart', + fn: PM.DrawStartEventHandler, + context?: any + ): L.Evented; + off( + type: 'pm:drawstart', + fn?: PM.DrawStartEventHandler, + context?: any + ): L.Evented; + + /** Called when drawing mode is disabled. Payload includes the shape type. */ + on( + type: 'pm:drawend', + fn: PM.DrawEndEventHandler, + context?: any + ): L.Evented; + once( + type: 'pm:drawend', + fn: PM.DrawEndEventHandler, + context?: any + ): L.Evented; + off( + type: 'pm:drawend', + fn?: PM.DrawEndEventHandler, + context?: any + ): L.Evented; + + /** Called when drawing mode is disabled. Payload includes the shape type. */ + on(type: 'pm:create', fn: PM.CreateEventHandler, context?: any): L.Evented; + once( + type: 'pm:create', + fn: PM.CreateEventHandler, + context?: any + ): L.Evented; + off( + type: 'pm:create', + fn?: PM.CreateEventHandler, + context?: any + ): L.Evented; + + /****************************************** + * + * TODO: DRAW MODE EVENTS ON LAYER ONLY + * + ********************************************/ + + /** Called when a new vertex is added. */ + on(type: 'pm:vertexadded', fn: PM.VertexAddedEventHandler): this; + once(type: 'pm:vertexadded', fn: PM.VertexAddedEventHandler): this; + off(type: 'pm:vertexadded', fn?: PM.VertexAddedEventHandler): this; + + /****************************************** + * + * TODO: EDIT MODE EVENTS ON LAYER ONLY + * + ********************************************/ + + /** Fired when edit mode is disabled and a layer is edited and its coordinates have changed. */ + on(type: 'pm:update', fn: PM.UpdateEventHandler): this; + once(type: 'pm:update', fn: PM.UpdateEventHandler): this; + off(type: 'pm:update', fn?: PM.UpdateEventHandler): this; + + /** Fired when edit mode on a layer is enabled. */ + on(type: 'pm:enable', fn: PM.EnableEventHandler): this; + once(type: 'pm:enable', fn: PM.EnableEventHandler): this; + off(type: 'pm:enable', fn?: PM.EnableEventHandler): this; + + /** Fired when edit mode on a layer is disabled. */ + on(type: 'pm:disable', fn: PM.DisableEventHandler): this; + once(type: 'pm:disable', fn: PM.DisableEventHandler): this; + off(type: 'pm:disable', fn?: PM.DisableEventHandler): this; + + /** Fired when a vertex is added. */ + on(type: 'pm:vertexadded', fn: PM.VertexAddedEventHandler2): this; + once(type: 'pm:vertexadded', fn: PM.VertexAddedEventHandler2): this; + off(type: 'pm:vertexadded', fn?: PM.VertexAddedEventHandler2): this; + + /** Fired when a vertex is removed. */ + on(type: 'pm:vertexremoved', fn: PM.VertexRemovedEventHandler): this; + once(type: 'pm:vertexremoved', fn: PM.VertexRemovedEventHandler): this; + off(type: 'pm:vertexremoved', fn?: PM.VertexRemovedEventHandler): this; + + /** Fired when a vertex is clicked. */ + on(type: 'pm:vertexclick', fn: PM.VertexClickEventHandler): this; + once(type: 'pm:vertexclick', fn: PM.VertexClickEventHandler): this; + off(type: 'pm:vertexclick', fn?: PM.VertexClickEventHandler): this; + + /** Fired when dragging of a marker which corresponds to a vertex starts. */ + on(type: 'pm:markerdragstart', fn: PM.MarkerDragStartEventHandler): this; + once(type: 'pm:markerdragstart', fn: PM.MarkerDragStartEventHandler): this; + off(type: 'pm:markerdragstart', fn?: PM.MarkerDragStartEventHandler): this; + + /** Fired when dragging a vertex-marker. */ + on(type: 'pm:markerdrag', fn: PM.MarkerDragEventHandler): this; + once(type: 'pm:markerdrag', fn: PM.MarkerDragEventHandler): this; + off(type: 'pm:markerdrag', fn?: PM.MarkerDragEventHandler): this; + + /** Fired when dragging of a vertex-marker ends. */ + on(type: 'pm:markerdragend', fn: PM.MarkerDragEndEventHandler): this; + once(type: 'pm:markerdragend', fn: PM.MarkerDragEndEventHandler): this; + off(type: 'pm:markerdragend', fn?: PM.MarkerDragEndEventHandler): this; + + /** Fired when coords of a layer are reset. E.g. by self-intersection.. */ + on(type: 'pm:layerreset', fn: PM.LayerResetEventHandler): this; + once(type: 'pm:layerreset', fn: PM.LayerResetEventHandler): this; + off(type: 'pm:layerreset', fn?: PM.LayerResetEventHandler): this; + + /** When allowSelfIntersection: false, this event is fired as soon as a self-intersection is detected. */ + on(type: 'pm:intersect', fn: PM.IntersectEventHandler): this; + once(type: 'pm:intersect', fn: PM.IntersectEventHandler): this; + off(type: 'pm:intersect', fn?: PM.IntersectEventHandler): this; + + /****************************************** + * + * TODO: EDIT MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when Edit Mode is toggled. */ + on( + type: 'pm:globaleditmodetoggled', + fn: PM.GlobalEditModeToggledEventHandler + ): this; + once( + type: 'pm:globaleditmodetoggled', + fn: PM.GlobalEditModeToggledEventHandler + ): this; + off( + type: 'pm:globaleditmodetoggled', + fn?: PM.GlobalEditModeToggledEventHandler + ): this; + + /****************************************** + * + * TODO: DRAG MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when Drag Mode is toggled. */ + on( + type: 'pm:globaldragmodetoggled', + fn: PM.GlobalDragModeToggledEventHandler + ): this; + once( + type: 'pm:globaldragmodetoggled', + fn: PM.GlobalDragModeToggledEventHandler + ): this; + off( + type: 'pm:globaldragmodetoggled', + fn?: PM.GlobalDragModeToggledEventHandler + ): this; + + /****************************************** + * + * TODO: DRAG MODE EVENTS ON LAYER ONLY + * + ********************************************/ + + /** Fired when a layer starts being dragged. */ + on(type: 'pm:dragstart', fn: PM.DragStartEventHandler): this; + once(type: 'pm:dragstart', fn: PM.DragStartEventHandler): this; + off(type: 'pm:dragstart', fn?: PM.DragStartEventHandler): this; + + /** Fired when a layer is dragged. */ + on(type: 'pm:drag', fn: PM.DragEventHandler): this; + once(type: 'pm:drag', fn: PM.DragEventHandler): this; + off(type: 'pm:drag', fn?: PM.DragEventHandler): this; + + /** Fired when a layer stops being dragged. */ + on(type: 'pm:dragend', fn: PM.DragEndEventHandler): this; + once(type: 'pm:dragend', fn: PM.DragEndEventHandler): this; + off(type: 'pm:dragend', fn?: PM.DragEndEventHandler): this; + + /****************************************** + * + * TODO: REMOVE MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when Removal Mode is toggled. */ + on( + type: 'pm:globalremovalmodetoggled', + fn: PM.GlobalRemovalModeToggledEventHandler + ): this; + once( + type: 'pm:globalremovalmodetoggled', + fn: PM.GlobalRemovalModeToggledEventHandler + ): this; + off( + type: 'pm:globalremovalmodetoggled', + fn?: PM.GlobalRemovalModeToggledEventHandler + ): this; + + /****************************************** + * + * TODO: CUT MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when a layer is removed via Removal Mode. */ + on( + type: 'pm:globalcutmodetoggled', + fn: PM.GlobalCutModeToggledEventHandler + ): this; + once( + type: 'pm:globalcutmodetoggled', + fn: PM.GlobalCutModeToggledEventHandler + ): this; + off( + type: 'pm:globalcutmodetoggled', + fn?: PM.GlobalCutModeToggledEventHandler + ): this; + + /****************************************** + * + * TODO: ROTATE MODE EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when Rotate Mode is toggled. */ + on( + type: 'pm:globalrotatemodetoggled', + fn: PM.GlobalRotateModeToggledEventHandler + ): this; + once( + type: 'pm:globalrotatemodetoggled', + fn: PM.GlobalRotateModeToggledEventHandler + ): this; + off( + type: 'pm:globalrotatemodetoggled', + fn?: PM.GlobalRotateModeToggledEventHandler + ): this; + + /****************************************** + * + * TODO: TRANSLATION EVENTS ON MAP ONLY + * + ********************************************/ + + /** Standard Leaflet event. Fired when any layer is removed. */ + on(type: 'pm:langchange', fn: PM.LangChangeEventHandler): this; + once(type: 'pm:langchange', fn: PM.LangChangeEventHandler): this; + off(type: 'pm:langchange', fn?: PM.LangChangeEventHandler): this; + + /****************************************** + * + * TODO: CONTROL EVENTS ON MAP ONLY + * + ********************************************/ + + /** Fired when a Toolbar button is clicked. */ + on(type: 'pm:buttonclick', fn: PM.ButtonClickEventHandler): this; + once(type: 'pm:buttonclick', fn: PM.ButtonClickEventHandler): this; + off(type: 'pm:buttonclick', fn?: PM.ButtonClickEventHandler): this; + + /** Fired when a Toolbar action is clicked. */ + on(type: 'pm:actionclick', fn: PM.ActionClickEventHandler): this; + once(type: 'pm:actionclick', fn: PM.ActionClickEventHandler): this; + off(type: 'pm:actionclick', fn?: PM.ActionClickEventHandler): this; + + /****************************************** + * + * TODO: Keyboard EVENT ON MAP ONLY + * + ********************************************/ + + /** Fired when `keydown` or `keyup` on the document is fired. */ + on(type: 'pm:keyevent', fn: PM.KeyboardKeyEventHandler): this; + once(type: 'pm:keyevent', fn: PM.KeyboardKeyEventHandler): this; + off(type: 'pm:keyevent', fn?: PM.KeyboardKeyEventHandler): this; + } + + namespace PM { + /** Supported shape names. 'ImageOverlay' is in Edit Mode only. Also accepts custom shape name. */ + type SUPPORTED_SHAPES = + | 'Marker' + | 'Circle' + | 'Line' + | 'Rectangle' + | 'Polygon' + | 'Cut' + | 'CircleMarker' + | 'ImageOverlay' + | string; + + /** + * Changes default registration of leaflet-geoman on leaflet layers. + * + * @param optIn - if true, a layers pmIgnore property has to be set to false to get initiated. + */ + function setOptIn(optIn: boolean): void; + + /** + * Enable leaflet-geoman on an ignored layer. + * + * @param layer - re-reads layer.options.pmIgnore to initialize leaflet-geoman. + */ + function reInitLayer(layer: L.Layer): void; + + /** + * PM map interface. + */ + interface PMMap + extends PMDrawMap, + PMEditMap, + PMDragMap, + PMRemoveMap, + PMCutMap, + PMRotateMap { + Toolbar: PMMapToolbar; + + Keyboard: PMMapKeyboard; + + /** Adds the Toolbar to the map. */ + addControls(options?: ToolbarOptions): void; + + /** Toggle the visiblity of the Toolbar. */ + removeControls(): void; + + /** Returns true if the Toolbar is visible on the map. */ + controlsVisible(): boolean; + + /** Toggle the visiblity of the Toolbar. */ + toggleControls(): void; + + setLang( + lang: + | 'cz' + | 'da' + | 'de' + | 'el' + | 'en' + | 'es' + | 'fa' + | 'fr' + | 'hu' + | 'id' + | 'it' + | 'nl' + | 'no' + | 'pl' + | 'pt_br' + | 'ro' + | 'ru' + | 'sv' + | 'tr' + | 'ua' + | 'zh' + | 'zh_tw', + customTranslations?: Translations, + fallbackLanguage?: string + ): void; + + /** Set globalOptions and apply them. */ + setGlobalOptions(options: GlobalOptions): void; + + /** Apply the current globalOptions to all existing layers. */ + applyGlobalOptions(): void; + + /** Returns the globalOptions. */ + getGlobalOptions(): GlobalOptions; + } + + class Translations { + tooltips?: { + placeMarker?: string; + firstVertex?: string; + continueLine?: string; + finishLine?: string; + finishPoly?: string; + finishRect?: string; + startCircle?: string; + finishCircle?: string; + placeCircleMarker?: string; + }; + + actions?: { + finish?: string; + cancel?: string; + removeLastVertex?: string; + }; + + buttonTitles?: { + drawMarkerButton?: string; + drawPolyButton?: string; + drawLineButton?: string; + drawCircleButton?: string; + drawRectButton?: string; + editButton?: string; + dragButton?: string; + cutButton?: string; + deleteButton?: string; + drawCircleMarkerButton?: string; + }; + } + + type ACTION_NAMES = 'cancel' | 'removeLastVertex' | 'finish' | 'finishMode'; + + class Action { + text: string; + onClick?: (e: any) => void; + } + + type TOOLBAR_CONTROL_ORDER = + | 'drawMarker' + | 'drawCircleMarker' + | 'drawPolyline' + | 'drawRectangle' + | 'drawPolygon' + | 'drawCircle' + | 'editMode' + | 'dragMode' + | 'cutPolygon' + | 'removalMode' + | 'rotateMode' + | string; + + interface PMMapToolbar { + /** Pass an array of button names to reorder the buttons in the Toolbar. */ + changeControlOrder(order?: TOOLBAR_CONTROL_ORDER[]): void; + + /** Receive the current order with. */ + getControlOrder(): TOOLBAR_CONTROL_ORDER[]; + + /** The position of a block (draw, edit, custom, options⭐) in the Toolbar can be changed. If not set, the value */ + /** from position of the Toolbar is taken. */ + setBlockPosition( + block: 'draw' | 'edit' | 'custom' | 'options', + position: L.ControlPosition + ): void; + + /** Returns a Object with the positions for all blocks */ + getBlockPositions(): BlockPositions; + + /** To add a custom Control to the Toolbar */ + createCustomControl(options: CustomControlOptions): void; + + /** Creates a copy of a draw Control. Returns the drawInstance and the control. */ + copyDrawControl( + copyInstance: string, + options?: CustomControlOptions + ): void; + + /** Change the actions of an existing button. */ + changeActionsOfControl( + name: string, + actions: (ACTION_NAMES | Action)[] + ): void; + + /** Disable button by control name */ + setButtonDisabled(name: TOOLBAR_CONTROL_ORDER, state: boolean): void; + } + + type KEYBOARD_EVENT_TYPE = 'current' | 'keydown' | 'keyup'; + + interface PMMapKeyboard { + /** Pass an array of button names to reorder the buttons in the Toolbar. */ + getLastKeyEvent(type: KEYBOARD_EVENT_TYPE[]): KeyboardKeyEventHandler; + + /** Returns the current pressed key. [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). */ + getPressedKey(): string; + + /** Returns true if the `Shift` key is currently pressed. */ + isShiftKeyPressed(): boolean; + + /** Returns true if the `Alt` key is currently pressed. */ + isAltKeyPressed(): boolean; + + /** Returns true if the `Ctrl` key is currently pressed. */ + isCtrlKeyPressed(): boolean; + + /** Returns true if the `Meta` key is currently pressed. */ + isMetaKeyPressed(): boolean; + } + + interface Button { + /** Actions */ + actions: (ACTION_NAMES | Action)[]; + + /** Function fired after clicking the control. */ + afterClick: () => void; + + /** CSS class with the Icon. */ + className: string; + + /** If true, other buttons will be disabled on click (default: true) */ + disableOtherButtons: boolean; + + /** Control can be toggled. */ + doToggle: boolean; + + /** Extending Class f. ex. Line, Polygon, ... L.PM.Draw.EXTENDINGCLASS */ + jsClass: string; + + /** Function fired when clicking the control. */ + onClick: () => void; + + position: L.ControlPosition; + + /** Text showing when you hover the control. */ + title: string; + + /** Toggle state true -> enabled, false -> disabled (default: false) */ + toggleStatus: boolean; + + /** Block of the control. 'options' is ⭐ only. */ + tool?: 'draw' | 'edit' | 'custom' | 'options'; + } + + interface CustomControlOptions { + /** Name of the control */ + name: string; + + /** Block of the control. 'options' is ⭐ only. */ + block?: 'draw' | 'edit' | 'custom' | 'options'; + + /** Text showing when you hover the control. */ + title?: string; + + /** CSS class with the Icon. */ + className?: string; + + /** Function fired when clicking the control. */ + onClick?: () => void; + + /** Function fired after clicking the control. */ + afterClick?: () => void; + + /** Actions */ + actions?: (ACTION_NAMES | Action)[]; + + /** Control can be toggled. */ + toggle?: boolean; + + /** Control is disabled. */ + disabled?: boolean; + } + + type PANE = + | 'mapPane' + | 'tilePane' + | 'overlayPane' + | 'shadowPane' + | 'markerPane' + | 'tooltipPane' + | 'popupPane' + | string; + + interface GlobalOptions extends DrawModeOptions, EditModeOptions { + /** Add the created layers to a layergroup instead to the map. */ + layerGroup?: L.Map | L.LayerGroup; + + /** Prioritize the order of snapping. Default: ['Marker','CircleMarker','Circle','Line','Polygon','Rectangle']. */ + snappingOrder: SUPPORTED_SHAPES[]; + + /** Defines in which panes the layers and helper vertices are created. Default: */ + /** { vertexPane: 'markerPane', layerPane: 'overlayPane', markerPane: 'markerPane' } */ + panes: { vertexPane: PANE; layerPane: PANE; markerPane: PANE }; + } + + interface PMDrawMap { + + /** Draw */ + Draw: Draw; + /** Enable Draw Mode with the passed shape. */ + enableDraw(shape: SUPPORTED_SHAPES, options?: DrawModeOptions): void; + + /** Disable all drawing */ + disableDraw(shape?: SUPPORTED_SHAPES): void; + + /** Returns true if global Draw Mode is enabled. false when disabled. */ + globalDrawModeEnabled(): boolean; + + /** Customize the style of the drawn layer. Only for L.Path layers. */ + /** Shapes can be excluded with a ignoreShapes array or merged with the current style with merge: true in optionsModifier. */ + setPathOptions( + options: L.PathOptions, + optionsModifier?: { ignoreShapes?: SUPPORTED_SHAPES[]; merge?: boolean } + ): void; + + /** Returns all Geoman layers on the map as array. Pass true to get a L.FeatureGroup. */ + getGeomanLayers(asFeatureGroup?: boolean): L.FeatureGroup | L.Layer[]; + + /** Returns all Geoman draw layers on the map as array. Pass true to get a L.FeatureGroup. */ + getGeomanDrawLayers(asFeatureGroup?: boolean): L.FeatureGroup | L.Layer[]; + } + + interface PMEditMap { + /** Enables edit mode. The passed options are preserved, even when the mode is enabled via the Toolbar */ + enableGlobalEditMode(options?: EditModeOptions): void; + + /** Disables global edit mode. */ + disableGlobalEditMode(): void; + + /** Toggles global edit mode. */ + toggleGlobalEditMode(options?: EditModeOptions): void; + + /** Returns true if global edit mode is enabled. false when disabled. */ + globalEditModeEnabled(): boolean; + } + + interface PMDragMap { + /** Enables global drag mode. */ + enableGlobalDragMode(): void; + + /** Disables global drag mode. */ + disableGlobalDragMode(): void; + + /** Toggles global drag mode. */ + toggleGlobalDragMode(): void; + + /** Returns true if global drag mode is enabled. false when disabled. */ + globalDragModeEnabled(): boolean; + } + + interface PMRemoveMap { + /** Enables global removal mode. */ + enableGlobalRemovalMode(): void; + + /** Disables global removal mode. */ + disableGlobalRemovalMode(): void; + + /** Toggles global removal mode. */ + toggleGlobalRemovalMode(): void; + + /** Returns true if global removal mode is enabled. false when disabled. */ + globalRemovalModeEnabled(): boolean; + } + + interface PMCutMap { + /** Enables global cut mode. */ + enableGlobalCutMode(options?: CutModeOptions): void; + + /** Disables global cut mode. */ + disableGlobalCutMode(): void; + + /** Toggles global cut mode. */ + toggleGlobalCutMode(options?: CutModeOptions): void; + + /** Returns true if global cut mode is enabled. false when disabled. */ + globalCutModeEnabled(): boolean; + } + + interface PMRotateMap { + /** Enables global rotate mode. */ + enableGlobalRotateMode(): void; + + /** Disables global rotate mode. */ + disableGlobalRotateMode(): void; + + /** Toggles global rotate mode. */ + toggleGlobalRotateMode(): void; + + /** Returns true if global rotate mode is enabled. false when disabled. */ + globalRotateModeEnabled(): boolean; + } + + interface PMRotateLayer { + /** Enables rotate mode on the layer. */ + enableRotate(): void; + + /** Disables rotate mode on the layer. */ + disableRotate(): void; + + /** Toggles rotate mode on the layer. */ + rotateEnabled(): void; + + /** Rotates the layer by x degrees. */ + rotateLayer(degrees: number): void; + + /** Rotates the layer to x degrees. */ + rotateLayerToAngle(degrees: number): void; + + /** Returns the angle of the layer in degrees. */ + getAngle(): number; + } + + interface Draw { + /** Array of available shapes. */ + getShapes(): SUPPORTED_SHAPES[]; + + /** Returns the active shape. */ + getActiveShape(): SUPPORTED_SHAPES; + + /** Set path options */ + setPathOptions(options: L.PathOptions): void; + + /** Set options */ + setOptions(options: DrawModeOptions): void; + + /** Get options */ + getOptions(): DrawModeOptions; + } + + interface CutModeOptions { + allowSelfIntersection?: boolean; + } + + type VertexValidationHandler = (e: { + layer: L.Layer; + marker: L.Marker; + event: any; + }) => boolean; + + interface EditModeOptions { + /** Enable snapping to other layers vertices for precision drawing. Can be disabled by holding the ALT key (default:true). */ + snappable?: boolean; + + /** The distance to another vertex when a snap should happen (default:20). */ + snapDistance?: number; + + /** Allow self intersections (default:true). */ + allowSelfIntersection?: boolean; + + /** Allow self intersections (default:true). */ + allowSelfIntersectionEdit?: boolean; + + /** Disable the removal of markers via right click / vertices via removeVertexOn. (default:false). */ + preventMarkerRemoval?: boolean; + + /** If true, vertex removal that cause a layer to fall below their minimum required vertices will remove the entire layer. */ + /** If false, these vertices can't be removed. Minimum vertices are 2 for Lines and 3 for Polygons (default:true). */ + removeLayerBelowMinVertexCount?: boolean; + + /** Defines which layers should dragged with this layer together. */ + /** true syncs all layers in the same LayerGroup(s) or you pass an `Array` of layers to sync. (default:false). */ + syncLayersOnDrag?: L.Layer[] | boolean; + + /** Edit-Mode for the layer can disabled (`pm.enable()`). (default:true). */ + allowEditing?: boolean; + + /** Removing can be disabled for the layer. (default:true). */ + allowRemoval?: boolean; + + /** Layer can be prevented from cutting. (default:true). */ + allowCutting?: boolean; + + /** Layer can be prevented from rotation. (default:true). */ + allowRotation?: boolean; + + /** Dragging can be disabled for the layer. (default:true). */ + draggable?: boolean; + + /** Leaflet layer event to add a vertex to a Line or Polygon, like dblclick. (default:click). */ + addVertexOn?: + | 'click' + | 'dblclick' + | 'mousedown' + | 'mouseover' + | 'mouseout' + | 'contextmenu'; + + /** A function for validation if a vertex (of a Line / Polygon) is allowed to add. */ + /** It passes a object with `[layer, marker, event}`. For example to check if the layer */ + /** has a certain property or if the `Ctrl` key is pressed. (default:undefined). */ + addVertexValidation?: undefined | VertexValidationHandler; + + /** Leaflet layer event to remove a vertex from a Line or Polygon, like dblclick. (default:contextmenu). */ + removeVertexOn?: + | 'click' + | 'dblclick' + | 'mousedown' + | 'mouseover' + | 'mouseout' + | 'contextmenu'; + + /** A function for validation if a vertex (of a Line / Polygon) is allowed to remove. */ + /** It passes a object with `[layer, marker, event}`. For example to check if the layer has a certain property */ + /** or if the `Ctrl` key is pressed. */ + removeVertexValidation?: undefined | VertexValidationHandler; + + /** A function for validation if a vertex / helper-marker is allowed to move / drag. It passes a object with */ + /** `[layer, marker, event}`. For example to check if the layer has a certain property or if the `Ctrl` key is pressed. */ + moveVertexValidation?: undefined | VertexValidationHandler; + + /** Shows only n markers closest to the cursor. Use -1 for no limit (default:-1). */ + limitMarkersToCount?: number; + + /** Shows markers when under the given zoom level ⭐ */ + limitMarkersToZoom?: number; + + /** Shows only markers in the viewport ⭐ */ + limitMarkersToViewport?: boolean; + + /** Shows markers only after the layer was clicked ⭐ */ + limitMarkersToClick?: boolean; + + /** Pin shared vertices/markers together during edit ⭐ */ + pinning?: boolean; + + /** Hide the middle Markers in edit mode from Polyline and Polygon. */ + hideMiddleMarkers?: boolean; + } + + interface DrawModeOptions { + /** Enable snapping to other layers vertices for precision drawing. Can be disabled by holding the ALT key (default:true). */ + snappable?: boolean; + + /** The distance to another vertex when a snap should happen (default:20). */ + snapDistance?: number; + + /** Allow snapping in the middle of two vertices (middleMarker)(default:false). */ + snapMiddle?: boolean; + + /** Allow snapping between two vertices. (default: true) */ + snapSegment?: boolean; + + /** Require the last point of a shape to be snapped. (default: false). */ + requireSnapToFinish?: boolean; + + /** Show helpful tooltips for your user (default:true). */ + tooltips?: boolean; + + /** Allow self intersections (default:true). */ + allowSelfIntersection?: boolean; + + /** Leaflet path options for the lines between drawn vertices/markers. (default:{color:'red'}). */ + templineStyle?: L.PathOptions; + + /** Leaflet path options for the helper line between last drawn vertex and the cursor. (default:{color:'red',dashArray:[5,5]}). */ + hintlineStyle?: L.PathOptions; + + /** Leaflet path options for the drawn layer (Only for L.Path layers). (default:null). */ + pathOptions?: L.PathOptions; + + /** Leaflet marker options (only for drawing markers). (default:{draggable:true}). */ + markerStyle?: L.MarkerOptions; + + /** Show a marker at the cursor (default:true). */ + cursorMarker?: boolean; + + /** Leaflet layer event to finish the drawn shape (default:null). */ + finishOn?: + | null + | 'click' + | 'dblclick' + | 'mousedown' + | 'mouseover' + | 'mouseout' + | 'contextmenu' + | 'snap'; + + /** Hide the middle Markers in edit mode from Polyline and Polygon. (default:false). */ + hideMiddleMarkers?: boolean; + + /** Set the min radius of a Circle. (default:null). */ + minRadiusCircle?: number; + + /** Set the max radius of a Circle. (default:null). */ + maxRadiusCircle?: number; + + /** Set the min radius of a CircleMarker when editable is active. (default:null). */ + minRadiusCircleMarker?: number; + + /** Set the max radius of a CircleMarker when editable is active. (default:null). */ + maxRadiusCircleMarker?: number; + + /** Makes a CircleMarker editable like a Circle (default:false). */ + editable?: boolean; + + /** Markers and CircleMarkers are editable during the draw-session */ + /** (you can drag them around immediately after drawing them) (default:true). */ + markerEditable?: boolean; + + /** Draw-Mode stays enabled after finishing a layer to immediately draw the next layer. */ + /** Defaults to true for Markers and CircleMarkers and false for all other layers. */ + continueDrawing?: boolean; + + /** Angel of rectangle. */ + rectangleAngle?: number; + + /** Cut-Mode: Only the passed layers can be cut. Cutted layers are removed from the */ + /** Array until no layers are left anymore and cutting is working on all layers again. (Default: []) */ + layersToCut?: L.Layer[]; + } + + /** + * PM toolbar options. + */ + interface ToolbarOptions { + /** Toolbar position. */ + position?: L.ControlPosition; + + /** The position of each block can be customized. If not set, the value from position is taken. */ + positions?: BlockPositions; + + /** Adds button to draw Markers (default:true) */ + drawMarker?: boolean; + + /** Adds button to draw CircleMarkers (default:true) */ + drawCircleMarker?: boolean; + + /** Adds button to draw Line (default:true) */ + drawPolyline?: boolean; + + /** Adds button to draw Rectangle (default:true) */ + drawRectangle?: boolean; + + /** Adds button to draw Polygon (default:true) */ + drawPolygon?: boolean; + + /** Adds button to draw Circle (default:true) */ + drawCircle?: boolean; + + /** Adds button to toggle edit mode for all layers (default:true) */ + editMode?: boolean; + + /** Adds button to toggle drag mode for all layers (default:true) */ + dragMode?: boolean; + + /** Adds button to cut a hole in a polygon or line (default:true) */ + cutPolygon?: boolean; + + /** Adds a button to remove layers (default:true) */ + removalMode?: boolean; + + /** Adds a button to rotate layers (default:true) */ + rotateMode?: boolean; + + /** All buttons will be displayed as one block Customize Controls (default:false) */ + oneBlock?: boolean; + + /** Shows all draw buttons / buttons in the draw block (default:true) */ + drawControls?: boolean; + + /** Shows all edit buttons / buttons in the edit block (default:true) */ + editControls?: boolean; + + /** Shows all buttons in the custom block (default:true) */ + customControls?: boolean; + + /** Shows all options buttons / buttons in the option block ⭐ */ + optionsControls?: boolean; + + /** Adds a button to toggle the Pinning Option ⭐ */ + pinningOption?: boolean; + + /** Adds a button to toggle the Snapping Option ⭐ */ + snappingOption?: boolean; + } + + /** the position of each block. */ + interface BlockPositions { + /** Draw control position (default:''). '' also refers to this position. */ + draw?: L.ControlPosition; + + /** Edit control position (default:''). */ + edit?: L.ControlPosition; + + /** Custom control position (default:''). */ + custom?: L.ControlPosition; + + /** Options control position (default:'') ⭐ */ + options?: L.ControlPosition; + } + + interface PMEditLayer { + /** Enables edit mode. The passed options are preserved, even when the mode is enabled via the Toolbar */ + enable(options?: EditModeOptions): void; + + /** Sets layer options */ + setOptions(options?: EditModeOptions): void; + + /** Gets layer options */ + getOptions(): EditModeOptions; + + /** Disables edit mode. */ + disable(): void; + + /** Toggles edit mode. Passed options are preserved. */ + toggleEdit(options?: EditModeOptions): void; + + /** Returns true if edit mode is enabled. false when disabled. */ + enabled(): boolean; + + /** Returns true if Line or Polygon has a self intersection. */ + hasSelfIntersection(): boolean; + + /** Removes the layer with the same checks as GlobalRemovalMode. */ + remove(): void; + } + + interface PMDragLayer { + /** Enables dragging for the layer. */ + enableLayerDrag(): void; + + /** Disables dragging for the layer. */ + disableLayerDrag(): void; + + /** Returns if the layer is currently dragging. */ + dragging(): boolean; + + /** Returns if drag mode is enabled for the layer. */ + layerDragEnabled(): boolean; + } + + interface PMLayer extends PMRotateLayer, PMEditLayer, PMDragLayer { + /** Get shape of the layer. */ + getShape(): SUPPORTED_SHAPES; + } + + interface PMLayerGroup { + /** Enables edit mode for all child layers. The passed options are preserved, even when the mode is enabled via the Toolbar */ + enable(options?: EditModeOptions): void; + + /** Disable edit mode for all child layers. */ + disable(): void; + + /** Returns if minimum one layer is enabled. */ + enabled(): boolean; + + /** Toggle enable / disable on all layers. */ + toggleEdit(options?: EditModeOptions): void; + + /** Returns the layers of the LayerGroup. `deep=true` return also the children of LayerGroup children. */ + /** `filterGeoman=true` filter out layers that don't have Leaflet-Geoman or temporary stuff. */ + /** `filterGroupsOut=true` does not return the LayerGroup layers self. */ + /** (Default: `deep=false`,`filterGeoman=true`, `filterGroupsOut=true` ) */ + getLayers( + deep?: boolean, + filterGeoman?: boolean, + filterGroupsOut?: boolean + ): L.Layer[]; + + /** Apply Leaflet-Geoman options to all children. The passed options are preserved, even when the mode is enabled via the Toolbar */ + setOptions(options?: EditModeOptions): void; + + /** Returns the options of the LayerGroup. */ + getOptions(): EditModeOptions; + + /** Returns if currently a layer in the LayerGroup is dragging. */ + dragging(): boolean; + } + + namespace Utils { + /** Returns the translation of the passed path. path = json-string f.ex. tooltips.placeMarker */ + function getTranslation(path: string): string; + + /** Returns the middle LatLng between two LatLngs */ + function calcMiddleLatLng( + map: L.Map, + latlng1: L.LatLng, + latlng2: L.LatLng + ): L.LatLng; + + /** Returns all layers that are available for Geoman */ + function findLayers(map: L.Map): L.Layer[]; + + /** Converts a circle into a polygon with default 60 sides. For CRS.Simple maps `withBearing` needs to be false */ + function circleToPolygon( + circle: L.Circle, + sides?: number, + withBearing?: boolean + ): L.Polygon; + + /** Converts a px-radius (CircleMarker) to meter-radius (Circle). */ + /** The center LatLng is needed because the earth has different projections on different places. */ + function pxRadiusToMeterRadius( + radiusInPx: number, + map: L.Map, + center: L.LatLng + ): number; + } + + /** + * DRAW MODE MAP EVENT HANDLERS + */ + + export type GlobalDrawModeToggledEventHandler = (event: { + enabled: boolean; + shape: PM.SUPPORTED_SHAPES; + map: L.Map; + }) => void; + export type DrawStartEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + workingLayer: L.Layer; + }) => void; + export type DrawEndEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type CreateEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + }) => void; + + /** + * DRAW MODE LAYER EVENT HANDLERS + */ + + export type VertexAddedEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + workingLayer: L.Layer; + marker: L.Marker; + latlng: L.LatLng; + }) => void; + export type SnapEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + distance: number; + layer: L.Layer; + workingLayer: L.Layer; + marker: L.Marker; + layerInteractedWith: L.Layer; + segement: any; + snapLatLng: L.LatLng; + }) => void; + export type CenterPlacedEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + workingLayer: L.Layer; + latlng: L.LatLng; + }) => void; + + /** + * EDIT MODE LAYER EVENT HANDLERS + */ + + export type EditEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + }) => void; + export type UpdateEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + }) => void; + export type EnableEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + }) => void; + export type DisableEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + }) => void; + export type VertexAddedEventHandler2 = (e: { + layer: L.Layer; + indexPath: number; + latlng: L.LatLng; + marker: L.Marker; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type VertexRemovedEventHandler = (e: { + layer: L.Layer; + indexPath: number; + marker: L.Marker; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type VertexClickEventHandler = (e: { + layer: L.Layer; + indexPath: number; + markerEvent: any; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type MarkerDragStartEventHandler = (e: { + layer: L.Layer; + indexPath: number; + markerEvent: any; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type MarkerDragEventHandler = (e: { + layer: L.Layer; + indexPath: number; + markerEvent: any; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type MarkerDragEndEventHandler = (e: { + layer: L.Layer; + indexPath: number; + markerEvent: any; + shape: PM.SUPPORTED_SHAPES; + intersectionRest: boolean; + }) => void; + export type LayerResetEventHandler = (e: { + layer: L.Layer; + indexPath: number; + markerEvent: any; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type IntersectEventHandler = (e: { + shape: PM.SUPPORTED_SHAPES; + layer: L.Layer; + intersection: L.LatLng; + }) => void; + + /** + * EDIT MODE MAP EVENT HANDLERS + */ + export type GlobalEditModeToggledEventHandler = (event: { + enabled: boolean; + map: L.Map; + }) => void; + + /** + * DRAG MODE MAP EVENT HANDLERS + */ + export type GlobalDragModeToggledEventHandler = (event: { + enabled: boolean; + map: L.Map; + }) => void; + + /** + * DRAG MODE LAYER EVENT HANDLERS + */ + export type DragStartEventHandler = (e: { + layer: L.Layer; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type DragEventHandler = (e: { + layer: L.Layer; + containerPoint: any; + latlng: L.LatLng; + layerPoint: L.Point; + originalEvent: any; + shape: PM.SUPPORTED_SHAPES; + }) => void; + export type DragEndEventHandler = (e: { + layer: L.Layer; + shape: PM.SUPPORTED_SHAPES; + }) => void; + + /** + * REMOVE MODE LAYER EVENT HANDLERS + */ + + export type RemoveEventHandler = (e: { + layer: L.Layer; + shape: PM.SUPPORTED_SHAPES; + }) => void; + + /** + * REMOVE MODE MAP EVENT HANDLERS + */ + export type GlobalRemovalModeToggledEventHandler = (e: { + enabled: boolean; + map: L.Map; + }) => void; + + /** + * CUT MODE MAP EVENT HANDLERS + */ + export type GlobalCutModeToggledEventHandler = (e: { + enabled: boolean; + map: L.Map; + }) => void; + export type CutEventHandler = (e: { + layer: L.Layer; + originalLayer: L.Layer; + shape: PM.SUPPORTED_SHAPES; + }) => void; + + /** + * ROTATE MODE LAYER EVENT HANDLERS + */ + export type RotateEnableEventHandler = (e: { + layer: L.Layer; + helpLayer: L.Layer; + }) => void; + export type RotateDisableEventHandler = (e: { layer: L.Layer }) => void; + export type RotateStartEventHandler = (e: { + layer: L.Layer; + helpLayer: L.Layer; + startAngle: number; + originLatLngs: L.LatLng[]; + }) => void; + export type RotateEventHandler = (e: { + layer: L.Layer; + helpLayer: L.Layer; + startAngle: number; + angle: number; + angleDiff: number; + oldLatLngs: L.LatLng[]; + newLatLngs: L.LatLng[]; + }) => void; + export type RotateEndEventHandler = (e: { + layer: L.Layer; + helpLayer: L.Layer; + startAngle: number; + angle: number; + originLatLngs: L.LatLng[]; + newLatLngs: L.LatLng[]; + }) => void; + + /** + * ROTATE MODE MAP EVENT HANDLERS + */ + export type GlobalRotateModeToggledEventHandler = (e: { + enabled: boolean; + map: L.Map; + }) => void; + + /** + * TRANSLATION EVENT HANDLERS + */ + export type LangChangeEventHandler = (e: { + activeLang: string; + oldLang: string; + fallback: string; + translations: PM.Translations; + }) => void; + + /** + * CONTROL MAP EVENT HANDLERS + */ + export type ButtonClickEventHandler = (e: { + btnName: string; + button: PM.Button; + }) => void; + export type ActionClickEventHandler = (e: { + text: string; + action: string; + btnName: string; + button: PM.Button; + }) => void; + + /** + * KEYBOARD EVENT HANDLERS + */ + export type KeyboardKeyEventHandler = (e: { + focusOn: 'document' | 'map'; + eventType: 'keydown' | 'keyup'; + event: any; + }) => void; + } + + namespace PM { + interface PMMapToolbar { + toggleButton( + name: string, + status: boolean, + disableOthers?: boolean + ): void; + } + } +} diff --git a/ui-ngx/src/typings/rawloader.typings.d.ts b/ui-ngx/src/typings/rawloader.typings.d.ts new file mode 100644 index 0000000..e8a63c0 --- /dev/null +++ b/ui-ngx/src/typings/rawloader.typings.d.ts @@ -0,0 +1,20 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +declare module '!raw-loader!*' { + const contents: string; + export = contents; +} diff --git a/ui-ngx/src/typings/split.js.typings.d.ts b/ui-ngx/src/typings/split.js.typings.d.ts new file mode 100644 index 0000000..4556ac6 --- /dev/null +++ b/ui-ngx/src/typings/split.js.typings.d.ts @@ -0,0 +1,39 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +interface SplitOptions { + sizes?: number[]; + minSize?: number[] | number; + gutterSize?: number; + snapOffset?: number; + direction?: 'horizontal' | 'vertical'; + cursor?: 'col-resize' | 'row-resize'; + gutter?: (index: number, direction: string) => HTMLElement; + elementStyle?: (dimension: string, elementSize: number, gutterSize: number) => any; + gutterStyle?: (dimension: string, gutterSize: number) => any; + onDrag?: () => void; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +interface SplitObject { + setSizes: (sizes: number[]) => void; + getSizes: () => number[]; + collapse: (index: number) => void; + destroy: () => void; +} + +declare function Split(elements: HTMLElement | string[], options?: SplitOptions): SplitObject; diff --git a/ui-ngx/src/zone-flags.ts b/ui-ngx/src/zone-flags.ts new file mode 100644 index 0000000..d7abff6 --- /dev/null +++ b/ui-ngx/src/zone-flags.ts @@ -0,0 +1,19 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +(window as any).__Zone_disable_requestAnimationFrame = false; +(window as any).__Zone_disable_setTimeout = false; +(window as any).__Zone_disable_setInterval = false; diff --git a/ui-ngx/tsconfig.json b/ui-ngx/tsconfig.json new file mode 100644 index 0000000..07c6df4 --- /dev/null +++ b/ui-ngx/tsconfig.json @@ -0,0 +1,68 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "es2017", + "module": "es2020", + "emitDecoratorMetadata": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "typeRoots": [ + "node_modules/@types", + "src/typings/rawloader.typings.d.ts", + "src/typings/jquery.typings.d.ts", + "src/typings/jquery.flot.typings.d.ts", + "src/typings/jquery.jstree.typings.d.ts", + "src/typings/split.js.typings.d.ts", + "src/typings/leaflet-geoman-extend.d.ts", + "src/typings/leaflet-extend-tb.d.ts", + ], + "paths": { + "@app/*": ["src/app/*"], + "@env/*": [ + "src/environments/*" + ], + "@core/*": ["src/app/core/*"], + "@modules/*": ["src/app/modules/*"], + "@shared/*": ["src/app/shared/*"], + "@home/*": ["src/app/modules/home/*"], + "jszip": [ + "node_modules/jszip/dist/jszip.min.js" + ], + "ace": [ + "node_modules/ace-builds/src-noconflict/ace.js" + ], + "jquery": [ + "node_modules/jquery/dist/jquery.min.js" + ], + "jquery.terminal": [ + "node_modules/jquery.terminal/js/jquery.terminal.js" + ], + "tooltipster": [ + "node_modules/tooltipster/dist/js/tooltipster.bundle.min.js" + ], + "jstree": [ + "node_modules/jstree/dist/jstree.min.js" + ] + }, + "lib": [ + "es2020", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": false + } +} diff --git a/ui-ngx/tslint.json b/ui-ngx/tslint.json new file mode 100644 index 0000000..ba21b20 --- /dev/null +++ b/ui-ngx/tslint.json @@ -0,0 +1,139 @@ +{ + "extends": "tslint:recommended", + "rulesDirectory": [ + "codelyzer" + ], + "rules": { + "align": { + "options": [ + "parameters", + "statements" + ] + }, + "array-type": false, + "arrow-parens": false, + "arrow-return-shorthand": true, + "curly": true, + "deprecation": { + "severity": "warn" + }, + "eofline": true, + "import-blacklist": [ + true, + "rxjs/Rx", + "^.*/public-api$" + ], + "import-spacing": true, + "indent": { + "options": [ + "spaces" + ] + }, + "interface-name": false, + "max-classes-per-file": false, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-consecutive-blank-lines": false, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-empty": false, + "no-inferrable-types": [ + true, + "ignore-params" + ], + "no-non-null-assertion": true, + "no-redundant-jsdoc": true, + "no-switch-case-fall-through": true, + "no-var-requires": false, + "object-literal-key-quotes": [ + true, + "as-needed" + ], + "object-literal-sort-keys": false, + "ordered-imports": false, + "quotemark": [ + true, + "single" + ], + "semicolon": { + "options": [ + "always" + ] + }, + "space-before-function-paren": { + "options": { + "anonymous": "never", + "asyncArrow": "always", + "constructor": "never", + "method": "never", + "named": "never" + } + }, + "trailing-comma": false, + "no-output-on-prefix": true, + "typedef-whitespace": { + "options": [ + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "onespace", + "index-signature": "onespace", + "parameter": "onespace", + "property-declaration": "onespace", + "variable-declaration": "onespace" + } + ] + }, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + , "variable-name": { + "options": [ + "ban-keywords", + "check-format", + "allow-pascal-case" + ] + }, + "whitespace": { + "options": [ + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type", + "check-typecast" + ] + } +} +} diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock new file mode 100644 index 0000000..4027837 --- /dev/null +++ b/ui-ngx/yarn.lock @@ -0,0 +1,10420 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-1.0.1.tgz#1398e73e567c2a7992df6554c15bb94a89b68ba2" + integrity sha512-Ta9bMA3EtUHDaZJXqUoT5cn/EecwOp+SXpKJqxDbDuMbLvEMu6YTyDDuvTWeStODfdmXyfMo7LymQyPkN3BicA== + dependencies: + "@jridgewell/resolve-uri" "1.0.0" + sourcemap-codec "1.4.8" + +"@ampproject/remapping@^2.1.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" + integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== + dependencies: + "@jridgewell/gen-mapping" "^0.1.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@angular-builders/custom-webpack@~12.1.3": + version "12.1.3" + resolved "https://registry.yarnpkg.com/@angular-builders/custom-webpack/-/custom-webpack-12.1.3.tgz#3eda78f573dc6d8b5278bb2d0c20937a75dec0cc" + integrity sha512-CzOkwYnO2Xs+z4kMeJkUALeRjVE69SlrqbEsv2Tao5PsBmFCyT5EEVoSvwOuaxZmajuGaXtz7yBIeK2hYp25/A== + dependencies: + "@angular-devkit/architect" ">=0.1200.0 < 0.1300.0" + "@angular-devkit/build-angular" "^12.0.0" + "@angular-devkit/core" "^12.0.0" + lodash "^4.17.15" + ts-node "^10.0.0" + tsconfig-paths "^3.9.0" + webpack-merge "^5.7.3" + +"@angular-devkit/architect@0.1202.17", "@angular-devkit/architect@>=0.1200.0 < 0.1300.0": + version "0.1202.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.1202.17.tgz#c495b1cf5a695e73164149fca420dd5935ab258d" + integrity sha512-uUQcHcLbPvr9adALQSLU1MTDduVUR2kZAHi2e7SmL9ioel84pPVXBoD0WpSBeUMKwPiDs3TQDaxDB49hl0nBSQ== + dependencies: + "@angular-devkit/core" "12.2.17" + rxjs "6.6.7" + +"@angular-devkit/build-angular@^12.0.0", "@angular-devkit/build-angular@^12.2.17": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-12.2.17.tgz#c4c21f22dda4c5d0f0273d85336957e269234067" + integrity sha512-uc3HGHVQyatqQ/M53oxYBvhz0jx0hgdc7WT+L56GLHvgz7Ct2VEbpWaMfwHkFfE1F1iHkIgnTKHKWacJl1yQIg== + dependencies: + "@ampproject/remapping" "1.0.1" + "@angular-devkit/architect" "0.1202.17" + "@angular-devkit/build-optimizer" "0.1202.17" + "@angular-devkit/build-webpack" "0.1202.17" + "@angular-devkit/core" "12.2.17" + "@babel/core" "7.14.8" + "@babel/generator" "7.14.8" + "@babel/helper-annotate-as-pure" "7.14.5" + "@babel/plugin-proposal-async-generator-functions" "7.14.7" + "@babel/plugin-transform-async-to-generator" "7.14.5" + "@babel/plugin-transform-runtime" "7.14.5" + "@babel/preset-env" "7.14.8" + "@babel/runtime" "7.14.8" + "@babel/template" "7.14.5" + "@discoveryjs/json-ext" "0.5.3" + "@jsdevtools/coverage-istanbul-loader" "3.0.5" + "@ngtools/webpack" "12.2.17" + ansi-colors "4.1.1" + babel-loader "8.2.2" + browserslist "^4.9.1" + cacache "15.2.0" + caniuse-lite "^1.0.30001032" + circular-dependency-plugin "5.2.2" + copy-webpack-plugin "9.0.1" + core-js "3.16.0" + critters "0.0.12" + css-loader "6.2.0" + css-minimizer-webpack-plugin "3.0.2" + esbuild-wasm "0.13.8" + find-cache-dir "3.3.1" + glob "7.1.7" + https-proxy-agent "5.0.0" + inquirer "8.1.2" + karma-source-map-support "1.4.0" + less "4.1.1" + less-loader "10.0.1" + license-webpack-plugin "2.3.20" + loader-utils "2.0.0" + mini-css-extract-plugin "2.4.2" + minimatch "3.0.4" + open "8.2.1" + ora "5.4.1" + parse5-html-rewriting-stream "6.0.1" + piscina "3.1.0" + postcss "8.3.6" + postcss-import "14.0.2" + postcss-loader "6.1.1" + postcss-preset-env "6.7.0" + regenerator-runtime "0.13.9" + resolve-url-loader "4.0.0" + rxjs "6.6.7" + sass "1.36.0" + sass-loader "12.1.0" + semver "7.3.5" + source-map-loader "3.0.0" + source-map-support "0.5.19" + style-loader "3.2.1" + stylus "0.54.8" + stylus-loader "6.1.0" + terser "5.7.1" + terser-webpack-plugin "5.1.4" + text-table "0.2.0" + tree-kill "1.2.2" + tslib "2.3.0" + webpack "5.50.0" + webpack-dev-middleware "5.0.0" + webpack-dev-server "3.11.3" + webpack-merge "5.8.0" + webpack-subresource-integrity "1.5.2" + optionalDependencies: + esbuild "0.13.8" + +"@angular-devkit/build-optimizer@0.1202.17": + version "0.1202.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-optimizer/-/build-optimizer-0.1202.17.tgz#de55aff22843bc09db7e735af2817bdef0040a3d" + integrity sha512-1qWGWw7cCNADB4LZ/zjiSK0GLmr2kebYyNG0KutCE8GNVxv2h6w6dJP6t1C/BgskRuBPCAhvE+lEKN8ljSutag== + dependencies: + source-map "0.7.3" + tslib "2.3.0" + typescript "4.3.5" + +"@angular-devkit/build-webpack@0.1202.17": + version "0.1202.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.1202.17.tgz#a686c936317158837456f76cd45bc9cb40681244" + integrity sha512-z7FW43DJ4p8UZwbFRmMrh2ohqhI2Wtdg3+FZiTnl4opb3zYheGiNxPlTuiyKjG21JUkGCdthkkBLCNfaUU0U/Q== + dependencies: + "@angular-devkit/architect" "0.1202.17" + rxjs "6.6.7" + +"@angular-devkit/core@12.2.17", "@angular-devkit/core@^12.0.0": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-12.2.17.tgz#0bef00636efe624163486265e05c508587cb57f1" + integrity sha512-PyOY7LGUPPd6rakxUYbfQN6zAdOCMCouVp5tERY1WTdMdEiuULOtHsPee8kNbh75pD59KbJNU+fwozPRMuIm5g== + dependencies: + ajv "8.6.2" + ajv-formats "2.1.0" + fast-json-stable-stringify "2.1.0" + magic-string "0.25.7" + rxjs "6.6.7" + source-map "0.7.3" + +"@angular-devkit/schematics@12.2.17": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-12.2.17.tgz#e3c217465d04837f0589d6dfc286056be1b7c68f" + integrity sha512-c0eNu/nx1Mnu7KcZgYTYHP736H4Y9pSyLBSmLAHYZv3t3m0dIPbhifRcLQX7hHQ8fGT2ZFxmOpaQG5/DcIghSw== + dependencies: + "@angular-devkit/core" "12.2.17" + ora "5.4.1" + rxjs "6.6.7" + +"@angular/animations@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-12.2.16.tgz#16f1e0d05f026a83847235ff34acc36623830759" + integrity sha512-Kf6C7Ta+fCMq5DvT9JNVhBkcECrqFa3wumiC6ssGo5sNaEzXz+tlep9ZgEbqfxSn7gAN7L1DgsbS9u0O6tbUkg== + dependencies: + tslib "^2.2.0" + +"@angular/cdk@^12.2.13": + version "12.2.13" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-12.2.13.tgz#1fdbe814adfd6b4ff906c6d9c4c6df07b83f09d8" + integrity sha512-zSKRhECyFqhingIeyRInIyTvYErt4gWo+x5DQr0b7YLUbU8DZSwWnG4w76Ke2s4U8T7ry1jpJBHoX/e8YBpGMg== + dependencies: + tslib "^2.2.0" + optionalDependencies: + parse5 "^5.0.0" + +"@angular/cli@^12.2.17": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-12.2.17.tgz#aa05dc7b6572cd4bcc80cacd96ed890395cc6acd" + integrity sha512-mubRPp5hRIK/q0J8q6kVAqbYYuBUKMMBljUCqT4fHsl+qXYD27rgG3EqNzycKBMHUIlykotrDSdy47voD+atOg== + dependencies: + "@angular-devkit/architect" "0.1202.17" + "@angular-devkit/core" "12.2.17" + "@angular-devkit/schematics" "12.2.17" + "@schematics/angular" "12.2.17" + "@yarnpkg/lockfile" "1.1.0" + ansi-colors "4.1.1" + debug "4.3.2" + ini "2.0.0" + inquirer "8.1.2" + jsonc-parser "3.0.0" + npm-package-arg "8.1.5" + npm-pick-manifest "6.1.1" + open "8.2.1" + ora "5.4.1" + pacote "12.0.2" + resolve "1.20.0" + semver "7.3.5" + symbol-observable "4.0.0" + uuid "8.3.2" + +"@angular/common@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-12.2.16.tgz#dff620b1c81903059685be7e8887e9c720d53195" + integrity sha512-FEqTXTEsnbDInqV1yFlm97Tz1OFqZS5t0TUkm8gzXRgpIce/F/jLwAg0u1VQkgOsno6cNm0xTWPoZgu85NI4ug== + dependencies: + tslib "^2.2.0" + +"@angular/compiler-cli@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-12.2.16.tgz#6d7f20081a5c7c7ac82ac4a65bd14cad3480bdb9" + integrity sha512-tlalh8SJvdCWbUPRUR5GamaP+wSc/GuCsoUZpSbcczGKgSlbaEVXUYtVXm8/wuT6Slk2sSEbRs7tXGF2i7qxVw== + dependencies: + "@babel/core" "^7.8.6" + "@babel/types" "^7.8.6" + canonical-path "1.0.0" + chokidar "^3.0.0" + convert-source-map "^1.5.1" + dependency-graph "^0.11.0" + magic-string "^0.25.0" + minimist "^1.2.0" + reflect-metadata "^0.1.2" + semver "^7.0.0" + source-map "^0.6.1" + sourcemap-codec "^1.4.8" + tslib "^2.2.0" + yargs "^17.0.0" + +"@angular/compiler@9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-9.0.0.tgz#87e0bef4c369b6cadae07e3a4295778fc93799d5" + integrity sha512-ctjwuntPfZZT2mNj2NDIVu51t9cvbhl/16epc5xEwyzyDt76pX9UgwvY+MbXrf/C/FWwdtmNtfP698BKI+9leQ== + +"@angular/compiler@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-12.2.16.tgz#1aa9b3fbd3fe900118ab371d30c090fbc137a15f" + integrity sha512-nsYEw+yu8QyeqPf9nAmG419i1mtGM4v8+U+S3eQHQFXTgJzLymMykWHYu2ETdjUpNSLK6xcIQDBWtWnWSfJjAA== + dependencies: + tslib "^2.2.0" + +"@angular/core@9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-9.0.0.tgz#227dc53e1ac81824f998c6e76000b7efc522641e" + integrity sha512-6Pxgsrf0qF9iFFqmIcWmjJGkkCaCm6V5QNnxMy2KloO3SDq6QuMVRbN9RtC8Urmo25LP+eZ6ZgYqFYpdD8Hd9w== + +"@angular/core@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-12.2.16.tgz#ec1dd07526ab2b8f808d21e8d5ab50f1a06c6aa8" + integrity sha512-jsmvaRdAfng99z2a9mAmkfcsCE1wm+tBYVDxnc5JquSXznwtncjzcoc2X0J0dzrkCDvzFfpTsZ9vehylytBc+A== + dependencies: + tslib "^2.2.0" + +"@angular/flex-layout@^12.0.0-beta.35": + version "12.0.0-beta.35" + resolved "https://registry.yarnpkg.com/@angular/flex-layout/-/flex-layout-12.0.0-beta.35.tgz#b52c3c82608cbb92a119f8dcde2a5b98186fe558" + integrity sha512-nPi2MGDFuCacwWHqxF/G7lUJd2X99HbLjjUvKXnyLwyCIVgH1sfS52su2wYbVYWJRqAVAB2/VMlrtW8Khr8hDA== + dependencies: + tslib "^2.1.0" + +"@angular/forms@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-12.2.16.tgz#7c3395a558a89b509fbde0b891d93c1dd1840220" + integrity sha512-sb+gpNun5aN7CZfHXS6X7vJcd/0A1P/gRBZpYtQTzBYnqEFCOFIvR62eb05aHQ4JhgKaSPpIXrbz/bAwY/njZw== + dependencies: + tslib "^2.2.0" + +"@angular/language-service@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-12.2.16.tgz#f470a7884acec6080489300079feb6c316a01e00" + integrity sha512-eDOd46Lu+4Nc/UA9q4G1xUTeIT2JXDdpedSRCk1fM+trYUZm7Xy2FZasP3pUSdtz04wt0kV9Mi5i3oCxfqU2Wg== + +"@angular/material@^12.2.13": + version "12.2.13" + resolved "https://registry.yarnpkg.com/@angular/material/-/material-12.2.13.tgz#7f92f95002a2abaa8bb115ca8f0809bdc3f7d3fc" + integrity sha512-6g2GyN4qp2D+DqY2AwrQuPB3cd9gybvQVXvNRbTPXEulHr+LgGei00ySdFHFp6RvdGSMZ4i3LM1Fq3VkFxhCfQ== + dependencies: + tslib "^2.2.0" + +"@angular/platform-browser-dynamic@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-12.2.16.tgz#06adbf7a4dd3dfbc0baff5ed905ca4de70fb91cb" + integrity sha512-XGxoACAMW/bc3atiVRpaiYwU4LkobYwVzwlxTT/BxOfsdt8ILb5wU8Fx1TMKNECOQHSGdK0qqhch4pTBZ3cb2g== + dependencies: + tslib "^2.2.0" + +"@angular/platform-browser@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-12.2.16.tgz#129143eb12624d83ac89b54dbe461783bc0508e1" + integrity sha512-T855ppLeQO6hRHi7lGf5fwPoUVt+c0h2rgkV5jHElc3ylaGnhecmZc6fnWLX4pw82TMJUgUV88CY8JCFabJWwg== + dependencies: + tslib "^2.2.0" + +"@angular/router@^12.2.16": + version "12.2.16" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-12.2.16.tgz#e1bd5e2cd714824208ed597e731cc1f01d5e53bd" + integrity sha512-LuFXSMIvX/VrB4jbYhigG2Y2pGQ9ULsSBUwDWwQCf4kr0eVI37LBJ2Vr74GBEznjgQ0UmWE89E+XYI80UhERTw== + dependencies: + tslib "^2.2.0" + +"@assemblyscript/loader@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06" + integrity sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg== + +"@auth0/angular-jwt@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@auth0/angular-jwt/-/angular-jwt-5.0.2.tgz#0a23f240e8c6ed37c5c7a354ad79a755a217936e" + integrity sha512-rSamC9mu+gUxoR86AXcIo+KD7xRIro+/iu1F2Ld85YAZEVKlpB5vYG+g0yGaEOqjtQWP/i0H6fi6XMGPVHSYYQ== + dependencies: + tslib "^2.0.0" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" + integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== + dependencies: + "@babel/highlight" "^7.18.6" + +"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.14.7", "@babel/compat-data@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.6.tgz#8b37d24e88e8e21c499d4328db80577d8882fa53" + integrity sha512-tzulrgDT0QD6U7BJ4TKVk2SDDg7wlP39P9yAx1RfLy7vP/7rsDRlWVfbWxElslu56+r7QOhB2NSDsabYYruoZQ== + +"@babel/core@7.14.8": + version "7.14.8" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.14.8.tgz#20cdf7c84b5d86d83fac8710a8bc605a7ba3f010" + integrity sha512-/AtaeEhT6ErpDhInbXmjHcUQXH0L0TEgscfcxk1qbOvLuKCa5aZT0SOOtDKFY96/CLROwbLSKyFor6idgNaU4Q== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.8" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-module-transforms" "^7.14.8" + "@babel/helpers" "^7.14.8" + "@babel/parser" "^7.14.8" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.8" + "@babel/types" "^7.14.8" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.1.2" + semver "^6.3.0" + source-map "^0.5.0" + +"@babel/core@^7.7.5", "@babel/core@^7.8.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.6.tgz#54a107a3c298aee3fe5e1947a6464b9b6faca03d" + integrity sha512-cQbWBpxcbbs/IUredIPkHiAGULLV8iwgNRMFzvbhEXISp4f3rUUXE5+TIw6KwUWUR3DwyI6gmBRnmAtYaWehwQ== + dependencies: + "@ampproject/remapping" "^2.1.0" + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.18.6" + "@babel/helper-compilation-targets" "^7.18.6" + "@babel/helper-module-transforms" "^7.18.6" + "@babel/helpers" "^7.18.6" + "@babel/parser" "^7.18.6" + "@babel/template" "^7.18.6" + "@babel/traverse" "^7.18.6" + "@babel/types" "^7.18.6" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.1" + semver "^6.3.0" + +"@babel/generator@7.14.8": + version "7.14.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.8.tgz#bf86fd6af96cf3b74395a8ca409515f89423e070" + integrity sha512-cYDUpvIzhBVnMzRoY1fkSEhK/HmwEVwlyULYgn/tMQYd6Obag3ylCjONle3gdErfXBW61SVTlR9QR7uWlgeIkg== + dependencies: + "@babel/types" "^7.14.8" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/generator@^7.14.8", "@babel/generator@^7.18.6": + version "7.18.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.7.tgz#2aa78da3c05aadfc82dbac16c99552fc802284bd" + integrity sha512-shck+7VLlY72a2w9c3zYWuE1pwOKEiQHV7GTUbSnhyl5eu3i04t30tBY82ZRWrDfo3gkakCFtevExnxbkf2a3A== + dependencies: + "@babel/types" "^7.18.7" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + +"@babel/helper-annotate-as-pure@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61" + integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-annotate-as-pure@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" + integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.6.tgz#f14d640ed1ee9246fb33b8255f08353acfe70e6a" + integrity sha512-KT10c1oWEpmrIRYnthbzHgoOf6B+Xd6a5yhdbNtdhtG7aO1or5HViuf1TQR36xY/QprXA5nvxO6nAjhJ4y38jw== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.6.tgz#18d35bfb9f83b1293c22c55b3d576c1315b6ed96" + integrity sha512-vFjbfhNCzqdeAtZflUFrG5YIFqGTqsctrtkZ1D/NB0mDW9TwW3GmmUepYY4G9wCET5rY5ugz4OGTcLd614IzQg== + dependencies: + "@babel/compat-data" "^7.18.6" + "@babel/helper-validator-option" "^7.18.6" + browserslist "^4.20.2" + semver "^6.3.0" + +"@babel/helper-create-class-features-plugin@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.6.tgz#6f15f8459f3b523b39e00a99982e2c040871ed72" + integrity sha512-YfDzdnoxHGV8CzqHGyCbFvXg5QESPFkXlHtvdCkesLjjVMT2Adxe4FGUR5ChIb3DxSaXO12iIOCWoXdsUVwnqw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-function-name" "^7.18.6" + "@babel/helper-member-expression-to-functions" "^7.18.6" + "@babel/helper-optimise-call-expression" "^7.18.6" + "@babel/helper-replace-supers" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + +"@babel/helper-create-regexp-features-plugin@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c" + integrity sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + regexpu-core "^5.1.0" + +"@babel/helper-define-polyfill-provider@^0.2.2", "@babel/helper-define-polyfill-provider@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.4.tgz#8867aed79d3ea6cade40f801efb7ac5c66916b10" + integrity sha512-OrpPZ97s+aPi6h2n1OXzdhVis1SGSsMU2aMHgLcOKfsp4/v1NWpx3CWT3lBj5eeBq9cDkPkh+YCfdF7O12uNDQ== + dependencies: + "@babel/helper-compilation-targets" "^7.13.0" + "@babel/helper-module-imports" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/traverse" "^7.13.0" + debug "^4.1.1" + lodash.debounce "^4.0.8" + resolve "^1.14.2" + semver "^6.1.2" + +"@babel/helper-environment-visitor@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.6.tgz#b7eee2b5b9d70602e59d1a6cad7dd24de7ca6cd7" + integrity sha512-8n6gSfn2baOY+qlp+VSzsosjCVGFqWKmDF0cCWOybh52Dw3SEyoWR1KrhMJASjLwIEkkAufZ0xvr+SxLHSpy2Q== + +"@babel/helper-explode-assignable-expression@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096" + integrity sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-function-name@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.6.tgz#8334fecb0afba66e6d87a7e8c6bb7fed79926b83" + integrity sha512-0mWMxV1aC97dhjCah5U5Ua7668r5ZmSC2DLfH2EZnf9c3/dHZKiFa5pRLMH5tjSl471tY6496ZWk/kjNONBxhw== + dependencies: + "@babel/template" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helper-hoist-variables@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" + integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-member-expression-to-functions@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.6.tgz#44802d7d602c285e1692db0bad9396d007be2afc" + integrity sha512-CeHxqwwipekotzPDUuJOfIMtcIHBuc7WAzLmTYWctVigqS5RktNMQ5bEwQSuGewzYnCtTWa3BARXeiLxDTv+Ng== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5", "@babel/helper-module-imports@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" + integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-module-transforms@^7.14.8", "@babel/helper-module-transforms@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.6.tgz#57e3ca669e273d55c3cda55e6ebf552f37f483c8" + integrity sha512-L//phhB4al5uucwzlimruukHB3jRd5JGClwRMD/ROrVjXfLqovYnvQrK/JK36WYyVwGGO7OD3kMyVTjx+WVPhw== + dependencies: + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-simple-access" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/helper-validator-identifier" "^7.18.6" + "@babel/template" "^7.18.6" + "@babel/traverse" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helper-optimise-call-expression@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" + integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.6.tgz#9448974dd4fb1d80fefe72e8a0af37809cd30d6d" + integrity sha512-gvZnm1YAAxh13eJdkb9EWHBnF3eAub3XTLCZEehHT2kWxiKVRL64+ae5Y6Ivne0mVHmMYKT+xWgZO+gQhuLUBg== + +"@babel/helper-remap-async-to-generator@^7.14.5", "@babel/helper-remap-async-to-generator@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.6.tgz#fa1f81acd19daee9d73de297c0308783cd3cfc23" + integrity sha512-z5wbmV55TveUPZlCLZvxWHtrjuJd+8inFhk7DG0WW87/oJuGDcjDiu7HIvGcpf5464L6xKCg3vNkmlVVz9hwyQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-wrap-function" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helper-replace-supers@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.6.tgz#efedf51cfccea7b7b8c0f00002ab317e7abfe420" + integrity sha512-fTf7zoXnUGl9gF25fXCWE26t7Tvtyn6H4hkLSYhATwJvw2uYxd3aoXplMSe0g9XbwK7bmxNes7+FGO0rB/xC0g== + dependencies: + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-member-expression-to-functions" "^7.18.6" + "@babel/helper-optimise-call-expression" "^7.18.6" + "@babel/traverse" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helper-simple-access@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" + integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-skip-transparent-expression-wrappers@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.6.tgz#7dff00a5320ca4cf63270e5a0eca4b268b7380d9" + integrity sha512-4KoLhwGS9vGethZpAhYnMejWkX64wsnHPDwvOsKWU6Fg4+AlK2Jz3TyjQLMEPvz+1zemi/WBdkYxCD0bAfIkiw== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-split-export-declaration@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" + integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== + dependencies: + "@babel/types" "^7.18.6" + +"@babel/helper-validator-identifier@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" + integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== + +"@babel/helper-validator-option@^7.14.5", "@babel/helper-validator-option@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" + integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== + +"@babel/helper-wrap-function@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.18.6.tgz#ec44ea4ad9d8988b90c3e465ba2382f4de81a073" + integrity sha512-I5/LZfozwMNbwr/b1vhhuYD+J/mU+gfGAj5td7l5Rv9WYmH6i3Om69WGKNmlIpsVW/mF6O5bvTKbvDQZVgjqOw== + dependencies: + "@babel/helper-function-name" "^7.18.6" + "@babel/template" "^7.18.6" + "@babel/traverse" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/helpers@^7.14.8", "@babel/helpers@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.6.tgz#4c966140eaa1fcaa3d5a8c09d7db61077d4debfd" + integrity sha512-vzSiiqbQOghPngUYt/zWGvK3LAsPhz55vc9XNN0xAl2gV4ieShI2OQli5duxWHD+72PZPTKAcfcZDE1Cwc5zsQ== + dependencies: + "@babel/template" "^7.18.6" + "@babel/traverse" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/highlight@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" + integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== + dependencies: + "@babel/helper-validator-identifier" "^7.18.6" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.14.5", "@babel/parser@^7.14.8", "@babel/parser@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.6.tgz#845338edecad65ebffef058d3be851f1d28a63bc" + integrity sha512-uQVSa9jJUe/G/304lXspfWVpKpK4euFLgGiMQFOCpM/bgcAdeoHwi/OQz23O9GK2osz26ZiXRRV9aV+Yl1O8tw== + +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.6.tgz#b4e4dbc2cd1acd0133479918f7c6412961c9adb8" + integrity sha512-Udgu8ZRgrBrttVz6A0EVL0SJ1z+RLbIeqsu632SA1hf0awEppD6TvdznoH+orIF8wtFFAV/Enmw9Y+9oV8TQcw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.18.6" + "@babel/plugin-proposal-optional-chaining" "^7.18.6" + +"@babel/plugin-proposal-async-generator-functions@7.14.7": + version "7.14.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.7.tgz#784a48c3d8ed073f65adcf30b57bcbf6c8119ace" + integrity sha512-RK8Wj7lXLY3bqei69/cc25gwS5puEc3dknoFPFbqfy3XxYQBQFvu4ioWpafMBAB+L9NyptQK4nMOa5Xz16og8Q== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-remap-async-to-generator" "^7.14.5" + "@babel/plugin-syntax-async-generators" "^7.8.4" + +"@babel/plugin-proposal-async-generator-functions@^7.14.7": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.18.6.tgz#aedac81e6fc12bb643374656dd5f2605bf743d17" + integrity sha512-WAz4R9bvozx4qwf74M+sfqPMKfSqwM0phxPTR6iJIi8robgzXwkEgmeJG1gEKhm6sDqT/U9aV3lfcqybIpev8w== + dependencies: + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-remap-async-to-generator" "^7.18.6" + "@babel/plugin-syntax-async-generators" "^7.8.4" + +"@babel/plugin-proposal-class-properties@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" + integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-proposal-class-static-block@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020" + integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + +"@babel/plugin-proposal-dynamic-import@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94" + integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + +"@babel/plugin-proposal-export-namespace-from@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.6.tgz#1016f0aa5ab383bbf8b3a85a2dcaedf6c8ee7491" + integrity sha512-zr/QcUlUo7GPo6+X1wC98NJADqmy5QTFWWhqeQWiki4XHafJtLl/YMGkmRB2szDD2IYJCCdBTd4ElwhId9T7Xw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-proposal-json-strings@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b" + integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-json-strings" "^7.8.3" + +"@babel/plugin-proposal-logical-assignment-operators@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.6.tgz#3b9cac6f1ffc2aa459d111df80c12020dfc6b665" + integrity sha512-zMo66azZth/0tVd7gmkxOkOjs2rpHyhpcFo565PUP37hSp6hSd9uUKIfTDFMz58BwqgQKhJ9YxtM5XddjXVn+Q== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1" + integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + +"@babel/plugin-proposal-numeric-separator@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75" + integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-proposal-object-rest-spread@^7.14.7": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.6.tgz#ec93bba06bfb3e15ebd7da73e953d84b094d5daf" + integrity sha512-9yuM6wr4rIsKa1wlUAbZEazkCrgw2sMPEXCr4Rnwetu7cEW1NydkCWytLuYletbf8vFxdJxFhwEZqMpOx2eZyw== + dependencies: + "@babel/compat-data" "^7.18.6" + "@babel/helper-compilation-targets" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-transform-parameters" "^7.18.6" + +"@babel/plugin-proposal-optional-catch-binding@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb" + integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + +"@babel/plugin-proposal-optional-chaining@^7.14.5", "@babel/plugin-proposal-optional-chaining@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.6.tgz#46d4f2ffc20e87fad1d98bc4fa5d466366f6aa0b" + integrity sha512-PatI6elL5eMzoypFAiYDpYQyMtXTn+iMhuxxQt5mAXD4fEmKorpSI3PHd+i3JXBJN3xyA6MvJv7at23HffFHwA== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.18.6" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + +"@babel/plugin-proposal-private-methods@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea" + integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-proposal-private-property-in-object@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503" + integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-create-class-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + +"@babel/plugin-proposal-unicode-property-regex@^7.14.5", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e" + integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" + integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-export-namespace-from@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" + integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-arrow-functions@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe" + integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-async-to-generator@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67" + integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA== + dependencies: + "@babel/helper-module-imports" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-remap-async-to-generator" "^7.14.5" + +"@babel/plugin-transform-async-to-generator@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615" + integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag== + dependencies: + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-remap-async-to-generator" "^7.18.6" + +"@babel/plugin-transform-block-scoped-functions@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8" + integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-block-scoping@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.6.tgz#b5f78318914615397d86a731ef2cc668796a726c" + integrity sha512-pRqwb91C42vs1ahSAWJkxOxU1RHWDn16XAa6ggQ72wjLlWyYeAcLvTtE0aM8ph3KNydy9CQF2nLYcjq1WysgxQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-classes@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.6.tgz#3501a8f3f4c7d5697c27a3eedbee71d68312669f" + integrity sha512-XTg8XW/mKpzAF3actL554Jl/dOYoJtv3l8fxaEczpgz84IeeVf+T1u2CSvPHuZbt0w3JkIx4rdn/MRQI7mo0HQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-function-name" "^7.18.6" + "@babel/helper-optimise-call-expression" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-replace-supers" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.6.tgz#5d15eb90e22e69604f3348344c91165c5395d032" + integrity sha512-9repI4BhNrR0KenoR9vm3/cIc1tSBIo+u1WVjKCAynahj25O8zfbiE6JtAtHPGQSs4yZ+bA8mRasRP+qc+2R5A== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-destructuring@^7.14.7": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.6.tgz#a98b0e42c7ffbf5eefcbcf33280430f230895c6f" + integrity sha512-tgy3u6lRp17ilY8r1kP4i2+HDUwxlVqq3RTc943eAWSzGgpU1qhiKpqZ5CMyHReIYPHdo3Kg8v8edKtDqSVEyQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-dotall-regex@^7.14.5", "@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8" + integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-duplicate-keys@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.6.tgz#e6c94e8cd3c9dd8a88144f7b78ae22975a7ff473" + integrity sha512-NJU26U/208+sxYszf82nmGYqVF9QN8py2HFTblPT9hbawi8+1C5a9JubODLTGFuT0qlkqVinmkwOD13s0sZktg== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-exponentiation-operator@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd" + integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-for-of@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.6.tgz#e0fdb813be908e91ccc9ec87b30cc2eabf046f7c" + integrity sha512-WAjoMf4wIiSsy88KmG7tgj2nFdEK7E46tArVtcgED7Bkj6Fg/tG5SbvNIOKxbFS2VFgNh6+iaPswBeQZm4ox8w== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-function-name@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.6.tgz#6a7e4ae2893d336fd1b8f64c9f92276391d0f1b4" + integrity sha512-kJha/Gbs5RjzIu0CxZwf5e3aTTSlhZnHMT8zPWnJMjNpLOUgqevg+PN5oMH68nMCXnfiMo4Bhgxqj59KHTlAnA== + dependencies: + "@babel/helper-compilation-targets" "^7.18.6" + "@babel/helper-function-name" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-literals@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.6.tgz#9d6af353b5209df72960baf4492722d56f39a205" + integrity sha512-x3HEw0cJZVDoENXOp20HlypIHfl0zMIhMVZEBVTfmqbObIpsMxMbmU5nOEO8R7LYT+z5RORKPlTI5Hj4OsO9/Q== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-member-expression-literals@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e" + integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-modules-amd@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21" + integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg== + dependencies: + "@babel/helper-module-transforms" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-commonjs@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883" + integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q== + dependencies: + "@babel/helper-module-transforms" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-simple-access" "^7.18.6" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-systemjs@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.6.tgz#026511b7657d63bf5d4cf2fd4aeb963139914a54" + integrity sha512-UbPYpXxLjTw6w6yXX2BYNxF3p6QY225wcTkfQCy3OMnSlS/C3xGtwUjEzGkldb/sy6PWLiCQ3NbYfjWUTI3t4g== + dependencies: + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-module-transforms" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-validator-identifier" "^7.18.6" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-umd@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9" + integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ== + dependencies: + "@babel/helper-module-transforms" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.14.7": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.18.6.tgz#c89bfbc7cc6805d692f3a49bc5fc1b630007246d" + integrity sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-new-target@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8" + integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-object-super@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c" + integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-replace-supers" "^7.18.6" + +"@babel/plugin-transform-parameters@^7.14.5", "@babel/plugin-transform-parameters@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.6.tgz#cbe03d5a4c6385dd756034ac1baa63c04beab8dc" + integrity sha512-FjdqgMv37yVl/gwvzkcB+wfjRI8HQmc5EgOG9iGNvUY1ok+TjsoaMP7IqCDZBhkFcM5f3OPVMs6Dmp03C5k4/A== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-property-literals@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3" + integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-regenerator@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73" + integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + regenerator-transform "^0.15.0" + +"@babel/plugin-transform-reserved-words@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a" + integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-runtime@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.14.5.tgz#30491dad49c6059f8f8fa5ee8896a0089e987523" + integrity sha512-fPMBhh1AV8ZyneiCIA+wYYUH1arzlXR1UMcApjvchDhfKxhy2r2lReJv8uHEyihi4IFIGlr1Pdx7S5fkESDQsg== + dependencies: + "@babel/helper-module-imports" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + babel-plugin-polyfill-corejs2 "^0.2.2" + babel-plugin-polyfill-corejs3 "^0.2.2" + babel-plugin-polyfill-regenerator "^0.2.2" + semver "^6.3.0" + +"@babel/plugin-transform-shorthand-properties@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9" + integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-spread@^7.14.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.18.6.tgz#82b080241965f1689f0a60ecc6f1f6575dbdb9d6" + integrity sha512-ayT53rT/ENF8WWexIRg9AiV9h0aIteyWn5ptfZTZQrjk/+f3WdrJGCY4c9wcgl2+MKkKPhzbYp97FTsquZpDCw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.18.6" + +"@babel/plugin-transform-sticky-regex@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc" + integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-template-literals@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.6.tgz#b763f4dc9d11a7cce58cf9a490d82e80547db9c2" + integrity sha512-UuqlRrQmT2SWRvahW46cGSany0uTlcj8NYOS5sRGYi8FxPYPoLd5DDmMd32ZXEj2Jq+06uGVQKHxa/hJx2EzKw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-typeof-symbol@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.6.tgz#486bb39d5a18047358e0d04dc0d2f322f0b92e92" + integrity sha512-7m71iS/QhsPk85xSjFPovHPcH3H9qeyzsujhTc+vcdnsXavoWYJ74zx0lP5RhpC5+iDnVLO+PPMHzC11qels1g== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-unicode-escapes@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.6.tgz#0d01fb7fb2243ae1c033f65f6e3b4be78db75f27" + integrity sha512-XNRwQUXYMP7VLuy54cr/KS/WeL3AZeORhrmeZ7iewgu+X2eBqmpaLI/hzqr9ZxCeUoq0ASK4GUzSM0BDhZkLFw== + dependencies: + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/plugin-transform-unicode-regex@^7.14.5": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca" + integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + +"@babel/preset-env@7.14.8": + version "7.14.8" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.14.8.tgz#254942f5ca80ccabcfbb2a9f524c74bca574005b" + integrity sha512-a9aOppDU93oArQ51H+B8M1vH+tayZbuBqzjOhntGetZVa+4tTu5jp+XTwqHGG2lxslqomPYVSjIxQkFwXzgnxg== + dependencies: + "@babel/compat-data" "^7.14.7" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-validator-option" "^7.14.5" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.14.5" + "@babel/plugin-proposal-async-generator-functions" "^7.14.7" + "@babel/plugin-proposal-class-properties" "^7.14.5" + "@babel/plugin-proposal-class-static-block" "^7.14.5" + "@babel/plugin-proposal-dynamic-import" "^7.14.5" + "@babel/plugin-proposal-export-namespace-from" "^7.14.5" + "@babel/plugin-proposal-json-strings" "^7.14.5" + "@babel/plugin-proposal-logical-assignment-operators" "^7.14.5" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.14.5" + "@babel/plugin-proposal-numeric-separator" "^7.14.5" + "@babel/plugin-proposal-object-rest-spread" "^7.14.7" + "@babel/plugin-proposal-optional-catch-binding" "^7.14.5" + "@babel/plugin-proposal-optional-chaining" "^7.14.5" + "@babel/plugin-proposal-private-methods" "^7.14.5" + "@babel/plugin-proposal-private-property-in-object" "^7.14.5" + "@babel/plugin-proposal-unicode-property-regex" "^7.14.5" + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + "@babel/plugin-transform-arrow-functions" "^7.14.5" + "@babel/plugin-transform-async-to-generator" "^7.14.5" + "@babel/plugin-transform-block-scoped-functions" "^7.14.5" + "@babel/plugin-transform-block-scoping" "^7.14.5" + "@babel/plugin-transform-classes" "^7.14.5" + "@babel/plugin-transform-computed-properties" "^7.14.5" + "@babel/plugin-transform-destructuring" "^7.14.7" + "@babel/plugin-transform-dotall-regex" "^7.14.5" + "@babel/plugin-transform-duplicate-keys" "^7.14.5" + "@babel/plugin-transform-exponentiation-operator" "^7.14.5" + "@babel/plugin-transform-for-of" "^7.14.5" + "@babel/plugin-transform-function-name" "^7.14.5" + "@babel/plugin-transform-literals" "^7.14.5" + "@babel/plugin-transform-member-expression-literals" "^7.14.5" + "@babel/plugin-transform-modules-amd" "^7.14.5" + "@babel/plugin-transform-modules-commonjs" "^7.14.5" + "@babel/plugin-transform-modules-systemjs" "^7.14.5" + "@babel/plugin-transform-modules-umd" "^7.14.5" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.14.7" + "@babel/plugin-transform-new-target" "^7.14.5" + "@babel/plugin-transform-object-super" "^7.14.5" + "@babel/plugin-transform-parameters" "^7.14.5" + "@babel/plugin-transform-property-literals" "^7.14.5" + "@babel/plugin-transform-regenerator" "^7.14.5" + "@babel/plugin-transform-reserved-words" "^7.14.5" + "@babel/plugin-transform-shorthand-properties" "^7.14.5" + "@babel/plugin-transform-spread" "^7.14.6" + "@babel/plugin-transform-sticky-regex" "^7.14.5" + "@babel/plugin-transform-template-literals" "^7.14.5" + "@babel/plugin-transform-typeof-symbol" "^7.14.5" + "@babel/plugin-transform-unicode-escapes" "^7.14.5" + "@babel/plugin-transform-unicode-regex" "^7.14.5" + "@babel/preset-modules" "^0.1.4" + "@babel/types" "^7.14.8" + babel-plugin-polyfill-corejs2 "^0.2.2" + babel-plugin-polyfill-corejs3 "^0.2.2" + babel-plugin-polyfill-regenerator "^0.2.2" + core-js-compat "^3.15.0" + semver "^6.3.0" + +"@babel/preset-modules@^0.1.4": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9" + integrity sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" + "@babel/plugin-transform-dotall-regex" "^7.4.4" + "@babel/types" "^7.4.4" + esutils "^2.0.2" + +"@babel/runtime@7.14.8": + version "7.14.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.8.tgz#7119a56f421018852694290b9f9148097391b446" + integrity sha512-twj3L8Og5SaCRCErB4x4ajbvBIVV77CGeFglHpeg5WC5FF8TZzBWXtTJ4MqaD9QszLYTtr+IsaAL2rEUevb+eg== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.10.1", "@babel/runtime@^7.11.1", "@babel/runtime@^7.18.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580" + integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/template@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" + integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/parser" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/template@^7.14.5", "@babel/template@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" + integrity sha512-JoDWzPe+wgBsTTgdnIma3iHNFC7YVJoPssVBDjiHfNlyt4YcunDtcDOUmfVDfCK5MfdsaIoX9PkijPhjH3nYUw== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/parser" "^7.18.6" + "@babel/types" "^7.18.6" + +"@babel/traverse@^7.13.0", "@babel/traverse@^7.14.8", "@babel/traverse@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.6.tgz#a228562d2f46e89258efa4ddd0416942e2fd671d" + integrity sha512-zS/OKyqmD7lslOtFqbscH6gMLFYOfG1YPqCKfAW5KrTeolKqvB8UelR49Fpr6y93kYkW2Ik00mT1LOGiAGvizw== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.6" + "@babel/helper-function-name" "^7.18.6" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.18.6" + "@babel/types" "^7.18.6" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.18.6", "@babel/types@^7.18.7", "@babel/types@^7.4.4", "@babel/types@^7.8.6": + version "7.18.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.7.tgz#a4a2c910c15040ea52cdd1ddb1614a65c8041726" + integrity sha512-QG3yxTcTIBoAcQmkCs+wAPYZhu7Dk9rXKacINfNbdJDNERTbLQbHGyVG8q/YGMPeCJRIhSY0+fTc5+xuh6WPSQ== + dependencies: + "@babel/helper-validator-identifier" "^7.18.6" + to-fast-properties "^2.0.0" + +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@csstools/convert-colors@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" + integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== + +"@date-io/core@1.x": + version "1.3.13" + resolved "https://registry.yarnpkg.com/@date-io/core/-/core-1.3.13.tgz#90c71da493f20204b7a972929cc5c482d078b3fa" + integrity sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA== + +"@date-io/core@^2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.14.0.tgz#03e9b9b9fc8e4d561c32dd324df0f3ccd967ef14" + integrity sha512-qFN64hiFjmlDHJhu+9xMkdfDG2jLsggNxKXglnekUpXSq8faiqZgtHm2lsHCUuaPDTV6wuXHcCl8J1GQ5wLmPw== + +"@date-io/date-fns@^2.11.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.14.0.tgz#92ab150f488f294c135c873350d154803cebdbea" + integrity sha512-4fJctdVyOd5cKIKGaWUM+s3MUXMuzkZaHuTY15PH70kU1YTMrCoauA7hgQVx9qj0ZEbGrH9VSPYJYnYro7nKiA== + dependencies: + "@date-io/core" "^2.14.0" + +"@discoveryjs/json-ext@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.3.tgz#90420f9f9c6d3987f176a19a7d8e764271a2f55d" + integrity sha512-Fxt+AfXgjMoin2maPIYzFZnQjAXjAL0PHscM5pRTtatFqB+vZxAM9tLp2Optnuw3QOQC40jTNeGYFOMvyf7v9g== + +"@emotion/hash@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" + integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== + +"@flowjs/flow.js@^2.14.1": + version "2.14.1" + resolved "https://registry.yarnpkg.com/@flowjs/flow.js/-/flow.js-2.14.1.tgz#267d9f9d0958f32267ea5815c2a7cc09b9219304" + integrity sha512-99DWlPnksOOS8uHfo+bhSjvs8d2MfLTB/22JBDC2ONwz/OCdP+gL/iiM4puMSTE2wH4A2/+J0eMc7pKwusXunw== + +"@flowjs/ngx-flow@~0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@flowjs/ngx-flow/-/ngx-flow-0.4.6.tgz#ed11b6a7d2079cb2a7f8dca75fc0e8d082477a86" + integrity sha512-HJ7RKxINAdnTdVAVbkjseMi2Z9soQcVbPk8Ki4tjUHZkOKJE1TCvFCXSqmXt34noqaS4vNv5t/xmsPbqZoHCew== + dependencies: + "@types/flowjs" "2.13.3" + tslib "^1.9.0" + +"@gar/promisify@^1.0.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" + integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== + +"@geoman-io/leaflet-geoman-free@^2.13.0": + version "2.13.0" + resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.13.0.tgz#df0834485d5852419d9c51014ff4589b1fdf0197" + integrity sha512-8uVVcRSAgZLQPfaEIAGitZEoG1v++tmPJlJYVCbGx7FbJYP9jmErsamECO0dz4eMkWLusIaEDgADY9WzAapcEQ== + dependencies: + "@turf/boolean-contains" "^6.5.0" + "@turf/kinks" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/line-split" "^6.5.0" + lodash "4.17.21" + polygon-clipping "0.15.3" + +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jcubic/lily@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@jcubic/lily/-/lily-0.3.0.tgz#00b229aab69fe094a57fd37f27d32dd4f380c1d1" + integrity sha512-4z6p4jLGSthc8gQ7wu4nHfGYn/IgCKFr+7hjuf80VdXUs7sm029mZGGDpS8sb29PVZWUBvMMTBCVGFhH2nN4Vw== + +"@jridgewell/gen-mapping@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" + integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== + dependencies: + "@jridgewell/set-array" "^1.0.0" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" + integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/resolve-uri@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-1.0.0.tgz#3fdf5798f0b49e90155896f6291df186eac06c83" + integrity sha512-9oLAnygRMi8Q5QkYEU4XWK04B+nuoXoxjRvRxgjuChkLZFBja0YPSgdZ7dZtwhncLBcQe/I/E+fLuk5qxcYVJA== + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.14" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" + integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jsdevtools/coverage-istanbul-loader@3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@jsdevtools/coverage-istanbul-loader/-/coverage-istanbul-loader-3.0.5.tgz#2a4bc65d0271df8d4435982db4af35d81754ee26" + integrity sha512-EUCPEkaRPvmHjWAAZkWMT7JDzpw7FKB00WTISaiXsbNOd5hCHg77XLA8sLYLFDo1zepYLo2w7GstN8YBqRXZfA== + dependencies: + convert-source-map "^1.7.0" + istanbul-lib-instrument "^4.0.3" + loader-utils "^2.0.0" + merge-source-map "^1.1.0" + schema-utils "^2.7.0" + +"@juggle/resize-observer@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0" + integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw== + +"@mat-datetimepicker/core@~7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@mat-datetimepicker/core/-/core-7.0.1.tgz#dac547195f25d448cfeaa11e0384f59639065c5f" + integrity sha512-lTYFJYstVb5l5JuNwVVZeyMaDtkZIq+eKycUa+5aJBAPhjapwdJx6lHiaZODgydRNtzdw79pQcB00mufguv3ew== + dependencies: + tslib "^2.3.0" + +"@material-ui/core@4.12.3": + version "4.12.3" + resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.3.tgz#80d665caf0f1f034e52355c5450c0e38b099d3ca" + integrity sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/styles" "^4.11.4" + "@material-ui/system" "^4.12.1" + "@material-ui/types" "5.1.0" + "@material-ui/utils" "^4.11.2" + "@types/react-transition-group" "^4.2.0" + clsx "^1.0.4" + hoist-non-react-statics "^3.3.2" + popper.js "1.16.1-lts" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + react-transition-group "^4.4.0" + +"@material-ui/icons@4.11.2": + version "4.11.2" + resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" + integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== + dependencies: + "@babel/runtime" "^7.4.4" + +"@material-ui/pickers@3.3.10": + version "3.3.10" + resolved "https://registry.yarnpkg.com/@material-ui/pickers/-/pickers-3.3.10.tgz#f1b0f963348cc191645ef0bdeff7a67c6aa25485" + integrity sha512-hS4pxwn1ZGXVkmgD4tpFpaumUaAg2ZzbTrxltfC5yPw4BJV+mGkfnQOB4VpWEYZw2jv65Z0wLwDE/piQiPPZ3w== + dependencies: + "@babel/runtime" "^7.6.0" + "@date-io/core" "1.x" + "@types/styled-jsx" "^2.2.8" + clsx "^1.0.2" + react-transition-group "^4.0.0" + rifm "^0.7.0" + +"@material-ui/styles@^4.11.4": + version "4.11.5" + resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.5.tgz#19f84457df3aafd956ac863dbe156b1d88e2bbfb" + integrity sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA== + dependencies: + "@babel/runtime" "^7.4.4" + "@emotion/hash" "^0.8.0" + "@material-ui/types" "5.1.0" + "@material-ui/utils" "^4.11.3" + clsx "^1.0.4" + csstype "^2.5.2" + hoist-non-react-statics "^3.3.2" + jss "^10.5.1" + jss-plugin-camel-case "^10.5.1" + jss-plugin-default-unit "^10.5.1" + jss-plugin-global "^10.5.1" + jss-plugin-nested "^10.5.1" + jss-plugin-props-sort "^10.5.1" + jss-plugin-rule-value-function "^10.5.1" + jss-plugin-vendor-prefixer "^10.5.1" + prop-types "^15.7.2" + +"@material-ui/system@^4.12.1": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.12.2.tgz#f5c389adf3fce4146edd489bf4082d461d86aa8b" + integrity sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.11.3" + csstype "^2.5.2" + prop-types "^15.7.2" + +"@material-ui/types@5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2" + integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A== + +"@material-ui/utils@^4.11.2", "@material-ui/utils@^4.11.3": + version "4.11.3" + resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.11.3.tgz#232bd86c4ea81dab714f21edad70b7fdf0253942" + integrity sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg== + dependencies: + "@babel/runtime" "^7.4.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + +"@ngrx/effects@^12.5.1": + version "12.5.1" + resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-12.5.1.tgz#acd0ff86d8db514e47337508dde83cc98f7a3416" + integrity sha512-fVNGIIntYLRWW1XWe0os2XOv03L22S4WTkX0OPZ9O6ztwuaNq0yzxWN7UeAC6H385F+g0k76KwRV78zHyP0bfQ== + dependencies: + tslib "^2.0.0" + +"@ngrx/store-devtools@^12.5.1": + version "12.5.1" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-12.5.1.tgz#75f8ef9a4bf4a40d5343ff437651f0f3092914b5" + integrity sha512-SXMxVO3KzQUfB9G20gdNT5t/RcbtbaUySXLuH+b69z/eb34wH9AOYifdSdcEi8oqPjDrWYBq6a8Uh+yDHf9IfA== + dependencies: + tslib "^2.0.0" + +"@ngrx/store@^12.5.1": + version "12.5.1" + resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-12.5.1.tgz#a7c21d7df1d017d2cb7e77804b210cc14bcf8786" + integrity sha512-NLVkHLVeZc7IboXSDZlFoq1QrupmwYTYKRHS6se7ZasAv/lrIjHWsVVdICKSVRBsHZYu3+dmCXmu+YgulP7iHw== + dependencies: + tslib "^2.0.0" + +"@ngtools/webpack@12.2.17", "@ngtools/webpack@^12.2.17": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-12.2.17.tgz#0d7bee1da3c1a25a7b51901df016e1d0c8184135" + integrity sha512-uaS+2YZgPDW3VmUuwh4/yfIFV1KRVGWefc6xLWIqKRKs6mlRYs65m3ib9dX7CTS4kQMCbhxkxMbpBO2yXlzfvA== + +"@ngx-translate/core@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@ngx-translate/core/-/core-13.0.0.tgz#60547cb8a0845a2a0abfde6b0bf5ec6516a63fd6" + integrity sha512-+tzEp8wlqEnw0Gc7jtVRAJ6RteUjXw6JJR4O65KlnxOmJrCGPI0xjV/lKRnQeU0w4i96PQs/jtpL921Wrb7PWg== + dependencies: + tslib "^2.0.0" + +"@ngx-translate/http-loader@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@ngx-translate/http-loader/-/http-loader-6.0.0.tgz#041393ab5753f50ecf64262d624703046b8c7570" + integrity sha512-LCekn6qCbeXWlhESCxU1rAbZz33WzDG0lI7Ig0pYC1o5YxJWrkU9y3Y4tNi+jakQ7R6YhTR2D3ox6APxDtA0wA== + dependencies: + tslib "^2.0.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@npmcli/fs@^1.0.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" + integrity sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ== + dependencies: + "@gar/promisify" "^1.0.1" + semver "^7.3.5" + +"@npmcli/git@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.1.0.tgz#2fbd77e147530247d37f325930d457b3ebe894f6" + integrity sha512-/hBFX/QG1b+N7PZBFs0bi+evgRZcK9nWBxQKZkGoXUT5hJSwl5c4d7y8/hm+NQZRPhQ67RzFaj5UM9YeyKoryw== + dependencies: + "@npmcli/promise-spawn" "^1.3.2" + lru-cache "^6.0.0" + mkdirp "^1.0.4" + npm-pick-manifest "^6.1.1" + promise-inflight "^1.0.1" + promise-retry "^2.0.1" + semver "^7.3.5" + which "^2.0.2" + +"@npmcli/installed-package-contents@^1.0.6": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-1.0.7.tgz#ab7408c6147911b970a8abe261ce512232a3f4fa" + integrity sha512-9rufe0wnJusCQoLpV9ZPKIVP55itrM5BxOXs10DmdbRfgWtHy1LDyskbwRnBghuB0PrF7pNPOqREVtpz4HqzKw== + dependencies: + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + +"@npmcli/move-file@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" + integrity sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg== + dependencies: + mkdirp "^1.0.4" + rimraf "^3.0.2" + +"@npmcli/node-gyp@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-1.0.3.tgz#a912e637418ffc5f2db375e93b85837691a43a33" + integrity sha512-fnkhw+fmX65kiLqk6E3BFLXNC26rUhK90zVwe2yncPliVT/Qos3xjhTLE59Df8KnPlcwIERXKVlU1bXoUQ+liA== + +"@npmcli/promise-spawn@^1.2.0", "@npmcli/promise-spawn@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-1.3.2.tgz#42d4e56a8e9274fba180dabc0aea6e38f29274f5" + integrity sha512-QyAGYo/Fbj4MXeGdJcFzZ+FkDkomfRBrPM+9QYJSg+PxgAUL+LU3FneQk37rKR2/zjqkCV1BLHccX98wRXG3Sg== + dependencies: + infer-owner "^1.0.4" + +"@npmcli/run-script@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-2.0.0.tgz#9949c0cab415b17aaac279646db4f027d6f1e743" + integrity sha512-fSan/Pu11xS/TdaTpTB0MRn9guwGU8dye+x56mEVgBEd/QsybBbYcAL0phPXi8SGWFEChkQd6M9qL4y6VOpFig== + dependencies: + "@npmcli/node-gyp" "^1.0.2" + "@npmcli/promise-spawn" "^1.3.2" + node-gyp "^8.2.0" + read-package-json-fast "^2.0.1" + +"@schematics/angular@12.2.17": + version "12.2.17" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-12.2.17.tgz#37628479650bfe6fe269905b7ca29f9e5baec7a6" + integrity sha512-HM/4KkQu944KL5ebhIyy1Ot5OV6prHNW7kmGeMVeQefLSbbfMQCHLa1psB9UU9BoahwGhUBvleLylNSitOBCgg== + dependencies: + "@angular-devkit/core" "12.2.17" + "@angular-devkit/schematics" "12.2.17" + jsonc-parser "3.0.0" + +"@tootallnate/once@1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@turf/bbox@*", "@turf/bbox@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" + integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/bearing@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.5.0.tgz#462a053c6c644434bdb636b39f8f43fb0cd857b0" + integrity sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-contains@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz#f802e7432fb53109242d5bf57393ef2f53849bbf" + integrity sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/boolean-point-on-line" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-point-in-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" + integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-point-on-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz#a8efa7bad88760676f395afb9980746bc5b376e9" + integrity sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/destination@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-6.5.0.tgz#30a84702f9677d076130e0440d3223ae503fdae1" + integrity sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/distance@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.5.0.tgz#21f04d5f86e864d54e2abde16f35c15b4f36149a" + integrity sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/helpers@6.x", "@turf/helpers@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" + integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + +"@turf/invariant@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" + integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/kinks@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-6.5.0.tgz#80e7456367535365012f658cf1a988b39a2c920b" + integrity sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/line-intersect@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.5.0.tgz#dea48348b30c093715d2195d2dd7524aee4cf020" + integrity sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/meta" "^6.5.0" + geojson-rbush "3.x" + +"@turf/line-segment@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-6.5.0.tgz#ee73f3ffcb7c956203b64ed966d96af380a4dd65" + integrity sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/line-split@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-6.5.0.tgz#116d7fbf714457878225187f5820ef98db7b02c2" + integrity sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/nearest-point-on-line" "^6.5.0" + "@turf/square" "^6.5.0" + "@turf/truncate" "^6.5.0" + geojson-rbush "3.x" + +"@turf/meta@6.x", "@turf/meta@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" + integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/nearest-point-on-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz#8e1cd2cdc0b5acaf4c8d8b3b33bb008d3cb99e7b" + integrity sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/square@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" + integrity sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ== + dependencies: + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/truncate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-6.5.0.tgz#c3a16cad959f1be1c5156157d5555c64b19185d8" + integrity sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@types/ace-diff@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@types/ace-diff/-/ace-diff-2.1.1.tgz#1c08919aae8f9c429fcb139dc564c89dd093cbee" + integrity sha512-O27fCo2Y0njNslOFSewyRhTyXfLhVhleEU5aTI6ZqFTKENJ8L/LA+Y+ZfcHsHTtwrTWjBXqORmqEHH6Qytqw1w== + +"@types/canvas-gauges@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@types/canvas-gauges/-/canvas-gauges-2.1.4.tgz#063881264597d098e78cf5ad921e8ed20ae2ad16" + integrity sha512-JTvqQWrqcrgzCp/9+uzwUvPef2qAEnBJvm+bL9kvulzhXapDeNaGQXCIAZp+hOryusjOyndvP1za2HZooUV0XA== + +"@types/component-emitter@^1.2.10": + version "1.2.11" + resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.11.tgz#50d47d42b347253817a39709fef03ce66a108506" + integrity sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ== + +"@types/cookie@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" + integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== + +"@types/cors@^2.8.12": + version "2.8.12" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" + integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== + +"@types/eslint-scope@^3.7.0", "@types/eslint-scope@^3.7.3": + version "3.7.4" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" + integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "8.4.5" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.4.5.tgz#acdfb7dd36b91cc5d812d7c093811a8f3d9b31e4" + integrity sha512-dhsC09y1gpJWnK+Ff4SGvCuSnk9DaU0BJZSzOwa6GVSg65XtTugLBITDAAzRU5duGBoXBHpdR/9jHGxJjNflJQ== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*": + version "0.0.52" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.52.tgz#7f1f57ad5b741f3d5b210d3b1f145640d89bf8fe" + integrity sha512-BZWrtCU0bMVAIliIV+HJO1f1PR41M7NKjfxrFJwwhKI1KwhwOxYw1SXg9ao+CIMt774nFuGiG6eU+udtbEI9oQ== + +"@types/estree@^0.0.50": + version "0.0.50" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" + integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== + +"@types/estree@^0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + +"@types/flot@^0.0.32": + version "0.0.32" + resolved "https://registry.yarnpkg.com/@types/flot/-/flot-0.0.32.tgz#2ab260f2958dcab1acfb5c24b87898f1d22417d8" + integrity sha512-aturel4TWMY86N4Pkpc9pSoUd/p8c3BjGj4fTDkaZIpkRPzLH1VXZCAKGUywcFkTqgZMhPJFPWxd4pl87y8h/w== + dependencies: + "@types/jquery" "*" + +"@types/flowjs@2.13.3": + version "2.13.3" + resolved "https://registry.yarnpkg.com/@types/flowjs/-/flowjs-2.13.3.tgz#4f1ba77d9259f4be83ecaa985db96fa758b2fd22" + integrity sha512-VeWuL+Whk6lUSWX/g0LzLNyZywyTB5wZ2L6mPvD8/u5pgLF2HwyV7nZ1UArOifalJ5UE1CcJbPLKS+jc5+Z2ig== + +"@types/geojson@*", "@types/geojson@7946.0.8": + version "7946.0.8" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" + integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== + +"@types/glob@^7.1.1": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" + integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/hammerjs@^2.0.39": + version "2.0.41" + resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.41.tgz#f6ecf57d1b12d2befcce00e928a6a097c22980aa" + integrity sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA== + +"@types/jasmine@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-4.0.3.tgz#097ce710d70eb7f3662e96c1f75824dd22c27d5c" + integrity sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg== + +"@types/jasmine@~3.10.2": + version "3.10.6" + resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.10.6.tgz#8c8fe733d89813bf3dc3f9282d347fa1dbc90567" + integrity sha512-twY9adK/vz72oWxCWxzXaxoDtF9TpfEEsxvbc1ibjF3gMD/RThSuSud/GKUTR3aJnfbivAbC/vLqhY+gdWCHfA== + +"@types/jasminewd2@^2.0.10": + version "2.0.10" + resolved "https://registry.yarnpkg.com/@types/jasminewd2/-/jasminewd2-2.0.10.tgz#ae31c237aa6421bde30f1058b1d20f4577e54443" + integrity sha512-J7mDz7ovjwjc+Y9rR9rY53hFWKATcIkrr9DwQWmOas4/pnIPJTXawnzjwpHm3RSxz/e3ZVUvQ7cRbd5UQLo10g== + dependencies: + "@types/jasmine" "*" + +"@types/jquery@*", "@types/jquery@^3.5.14", "@types/jquery@^3.5.9": + version "3.5.14" + resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.14.tgz#ac8e11ee591e94d4d58da602cb3a5a8320dee577" + integrity sha512-X1gtMRMbziVQkErhTQmSe2jFwwENA/Zr+PprCkF63vFq+Yt5PZ4AlKqgmeNlwgn7dhsXEK888eIW2520EpC+xg== + dependencies: + "@types/sizzle" "*" + +"@types/js-beautify@^1.13.3": + version "1.13.3" + resolved "https://registry.yarnpkg.com/@types/js-beautify/-/js-beautify-1.13.3.tgz#53839bb5b766d0fb45e87386100bb3bcbb7dca9d" + integrity sha512-ucIPw5gmNyvRKi6mpeojlqp+T+6ZBJeU+kqMDnIEDlijEU4QhLTon90sZ3cz9HZr+QTwXILjNsMZImzA7+zuJA== + +"@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@types/jstree@^3.3.41": + version "3.3.41" + resolved "https://registry.yarnpkg.com/@types/jstree/-/jstree-3.3.41.tgz#820ce1f82bbb2441eaf9fb76451750e2810f5856" + integrity sha512-M4ia6tSKOAU/8v2Ir1Kyo3/XE4EWxw5ulnzbE/nmvw7YugxWhRfTIc6i2ubvJ3GQpdXzJ11bH+GT3VhJNcJadQ== + dependencies: + "@types/jquery" "*" + +"@types/leaflet-polylinedecorator@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@types/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.1.tgz#b6522f9dae52146bf73da249e4bedfbab200c6e4" + integrity sha512-9etweJ2U4SWqcV/AR3i0NdWJByeMn6+zMUNlO6jVbpL8UI6qrMKybu8v9/s6UR4oXvsV4lZT6vzAsNAAMq5Ssg== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet-providers@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/leaflet-providers/-/leaflet-providers-1.2.1.tgz#620669b828959740a2d8572e0c0288a2382d3564" + integrity sha512-uNyuXiNV2q3fmgNjQji2P6RjQISmL40bbOL91/3OAwiE3XhkLKPmSAtAcfe11MAIz45iEjdFZJWppq9QyfnPIw== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet.gridlayer.googlemutant@^0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@types/leaflet.gridlayer.googlemutant/-/leaflet.gridlayer.googlemutant-0.4.6.tgz#86d3ba9d432dec29b4796e37d815c233680e7fcb" + integrity sha512-L0J7NadcZp5bcKQrv4DVlsEbQ90xLsOKScckAMnxoghh/wogk0GVkauYOYHBKeKDkx9qkMRzTf8oO+fKeYD7oQ== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet.markercluster@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz#d039ada408a30bda733b19a24cba89b81f0ace4b" + integrity sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet@*", "@types/leaflet@^1.7.11": + version "1.7.11" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.7.11.tgz#48b33b7a15b015bbb1e8950399298a112c3220c8" + integrity sha512-VwAYom2pfIAf/pLj1VR5aLltd4tOtHyvfaJlNYCoejzP2nu52PrMi1ehsLRMUS+bgafmIIKBV1cMfKeS+uJ0Vg== + dependencies: + "@types/geojson" "*" + +"@types/lodash@^4.14.177": + version "4.14.182" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" + integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== + +"@types/marked@^4.0.2": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4" + integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg== + +"@types/minimatch@*": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" + integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== + +"@types/moment-timezone@^0.5.30": + version "0.5.30" + resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.30.tgz#340ed45fe3e715f4a011f5cfceb7cb52aad46fc7" + integrity sha512-aDVfCsjYnAQaV/E9Qc24C5Njx1CoDjXsEgkxtp9NyXDpYu4CCbmclb6QhWloS9UTU/8YROUEEdEkWI0D7DxnKg== + dependencies: + moment-timezone "*" + +"@types/mousetrap@^1.6.0": + version "1.6.9" + resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.9.tgz#f1ef9adbd1eac3466f21b6988b1c82c633a45340" + integrity sha512-HUAiN65VsRXyFCTicolwb5+I7FM6f72zjMWr+ajGk+YTvzBgXqa2A5U7d+rtsouAkunJ5U4Sb5lNJjo9w+nmXg== + +"@types/node@*", "@types/node@>=10.0.0": + version "18.0.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.3.tgz#463fc47f13ec0688a33aec75d078a0541a447199" + integrity sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ== + +"@types/node@~15.14.9": + version "15.14.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.14.9.tgz#bc43c990c3c9be7281868bbc7b8fdd6e2b57adfa" + integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + +"@types/prop-types@*": + version "15.7.5" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + +"@types/q@^0.0.32": + version "0.0.32" + resolved "https://registry.yarnpkg.com/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5" + integrity sha512-qYi3YV9inU/REEfxwVcGZzbS3KG/Xs90lv0Pr+lDtuVjBPGd1A+eciXzVSaRvLify132BfcvhvEjeVahrUl0Ug== + +"@types/raphael@^2.3.2": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@types/raphael/-/raphael-2.3.3.tgz#d264b148bc100ef401a5e13159fd97861cd69e17" + integrity sha512-Rhvq0q6wzyvipejki/9w87/pgapyE+s3gO66tdl1oD3qDrow+ek+4vVYAbRkeL58HCCK9EOZKwyjqYJ/TFkmtQ== + +"@types/react-dom@17.0.11": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" + integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== + dependencies: + "@types/react" "*" + +"@types/react-transition-group@^4.2.0": + version "4.4.5" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" + integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@17.0.37": + version "17.0.37" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.37.tgz#6884d0aa402605935c397ae689deed115caad959" + integrity sha512-2FS1oTqBGcH/s0E+CjrCCR9+JMpsu9b69RTFO+40ua43ZqP5MmQ4iUde/dMjWR909KxZwmOQIFq6AV6NjEG5xg== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + +"@types/selenium-webdriver@^3.0.0": + version "3.0.20" + resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-3.0.20.tgz#448771a0608ebf1c86cb5885914da6311e323c3a" + integrity sha512-6d8Q5fqS9DWOXEhMDiF6/2FjyHdmP/jSTAUyeQR7QwrFeNmYyzmvGxD5aLIHL445HjWgibs0eAig+KPnbaesXA== + +"@types/sizzle@*": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" + integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== + +"@types/source-list-map@*": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" + integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== + +"@types/styled-jsx@^2.2.8": + version "2.2.9" + resolved "https://registry.yarnpkg.com/@types/styled-jsx/-/styled-jsx-2.2.9.tgz#e50b3f868c055bcbf9bc353eca6c10fdad32a53f" + integrity sha512-W/iTlIkGEyTBGTEvZCey8EgQlQ5l0DwMqi3iOXlLs2kyBwYTXHKEiU6IZ5EwoRwngL8/dGYuzezSup89ttVHLw== + dependencies: + "@types/react" "*" + +"@types/systemjs@6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.1.1.tgz#eae17f2a080e867d01a2dd614f524ab227cf5a41" + integrity sha512-d1M6eDKBGWx7RbYy295VEFoOF9YDJkPI959QYnmzcmeaV+SP4D0xV7dEh3sN5XF3GvO3PhGzm+17Z598nvHQuQ== + +"@types/tinycolor2@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.3.tgz#ed4a0901f954b126e6a914b4839c77462d56e706" + integrity sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ== + +"@types/tooltipster@^0.0.31": + version "0.0.31" + resolved "https://registry.yarnpkg.com/@types/tooltipster/-/tooltipster-0.0.31.tgz#db6c78b5ad709fe5dc9c78cf15a6a2068b4f72b0" + integrity sha512-tDAxe2Q67VoQyeEW6oweNDfw4nNmodFGkHdPQdeBCCusf2d3qFbDLFkYnntgSwcD00Fkhh8mSguaP6w5muvZpg== + dependencies: + "@types/jquery" "*" + +"@types/webpack-sources@^0.1.5": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-0.1.9.tgz#da69b06eb34f6432e6658acb5a6893c55d983920" + integrity sha512-bvzMnzqoK16PQIC8AYHNdW45eREJQMd6WG/msQWX5V2+vZmODCOPb4TJcbgRljTZZTwTM4wUMcsI8FftNA7new== + dependencies: + "@types/node" "*" + "@types/source-list-map" "*" + source-map "^0.6.1" + +"@webassemblyjs/ast@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" + integrity sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + +"@webassemblyjs/floating-point-hex-parser@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz#f6c61a705f0fd7a6aecaa4e8198f23d9dc179e4f" + integrity sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ== + +"@webassemblyjs/helper-api-error@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz#1a63192d8788e5c012800ba6a7a46c705288fd16" + integrity sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg== + +"@webassemblyjs/helper-buffer@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz#832a900eb444884cde9a7cad467f81500f5e5ab5" + integrity sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA== + +"@webassemblyjs/helper-numbers@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz#64d81da219fbbba1e3bd1bfc74f6e8c4e10a62ae" + integrity sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.1" + "@webassemblyjs/helper-api-error" "1.11.1" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz#f328241e41e7b199d0b20c18e88429c4433295e1" + integrity sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q== + +"@webassemblyjs/helper-wasm-section@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz#21ee065a7b635f319e738f0dd73bfbda281c097a" + integrity sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/helper-buffer" "1.11.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + "@webassemblyjs/wasm-gen" "1.11.1" + +"@webassemblyjs/ieee754@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz#963929e9bbd05709e7e12243a099180812992614" + integrity sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz#ce814b45574e93d76bae1fb2644ab9cdd9527aa5" + integrity sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz#d1f8b764369e7c6e6bae350e854dec9a59f0a3ff" + integrity sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ== + +"@webassemblyjs/wasm-edit@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz#ad206ebf4bf95a058ce9880a8c092c5dec8193d6" + integrity sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/helper-buffer" "1.11.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + "@webassemblyjs/helper-wasm-section" "1.11.1" + "@webassemblyjs/wasm-gen" "1.11.1" + "@webassemblyjs/wasm-opt" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + "@webassemblyjs/wast-printer" "1.11.1" + +"@webassemblyjs/wasm-gen@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz#86c5ea304849759b7d88c47a32f4f039ae3c8f76" + integrity sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + "@webassemblyjs/ieee754" "1.11.1" + "@webassemblyjs/leb128" "1.11.1" + "@webassemblyjs/utf8" "1.11.1" + +"@webassemblyjs/wasm-opt@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz#657b4c2202f4cf3b345f8a4c6461c8c2418985f2" + integrity sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/helper-buffer" "1.11.1" + "@webassemblyjs/wasm-gen" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + +"@webassemblyjs/wasm-parser@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz#86ca734534f417e9bd3c67c7a1c75d8be41fb199" + integrity sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/helper-api-error" "1.11.1" + "@webassemblyjs/helper-wasm-bytecode" "1.11.1" + "@webassemblyjs/ieee754" "1.11.1" + "@webassemblyjs/leb128" "1.11.1" + "@webassemblyjs/utf8" "1.11.1" + +"@webassemblyjs/wast-printer@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz#d0c73beda8eec5426f10ae8ef55cee5e7084c2f0" + integrity sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg== + dependencies: + "@webassemblyjs/ast" "1.11.1" + "@xtuc/long" "4.2.2" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +"@yarnpkg/lockfile@1.1.0", "@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + +abab@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +ace-builds@1.4.13, ace-builds@^1.4.13: + version "1.4.13" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.4.13.tgz#186f42d3849ebcc6a48b93088a058489897514c1" + integrity sha512-SOLzdaQkY6ecPKYRDDg+MY1WoGgXA34cIvYJNNoBMGGUswHmlauU2Hy0UL96vW0Fs/LgFbMUjD+6vqzWTldIYQ== + +ace-diff@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/ace-diff/-/ace-diff-3.0.3.tgz#84f685ff3d0b1910539fc39259ac73d8b6581e28" + integrity sha512-CJaV9Oi6BWLWGL2Kj//h5BNXlRCRu1GYHPOT7o+ZSAuJv9PaL9FWr/cCf16IuSVDo7oj6VriO+qgoHR8G9McLA== + dependencies: + diff-match-patch "^1.0.5" + +acorn-import-assertions@^1.7.6: + version "1.8.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" + integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== + +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1, acorn@^8.5.0: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== + +adjust-sourcemap-loader@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99" + integrity sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A== + dependencies: + loader-utils "^2.0.0" + regex-parser "^2.2.11" + +adm-zip@^0.4.9: + version "0.4.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365" + integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== + +agent-base@6, agent-base@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + +agentkeepalive@^4.1.3: + version "4.2.1" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" + integrity sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA== + dependencies: + debug "^4.1.0" + depd "^1.1.2" + humanize-ms "^1.2.1" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-errors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + +ajv-formats@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.0.tgz#96eaf83e38d32108b66d82a9cb0cfa24886cdfeb" + integrity sha512-USH2jBb+C/hIpwD2iRjp0pe0k+MvzG0mlSn/FIdCgQhUb9ALPRjt2KIQdfZDS9r0ZIeUAg7gOu9KL0PFqGqr5Q== + dependencies: + ajv "^8.0.0" + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv-keywords@^3.1.0, ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv-keywords@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" + +ajv@8.6.2: + version "8.6.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.2.tgz#2fb45e0e5fcbc0813326c1c3da535d1881bb0571" + integrity sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +ajv@^6.1.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0, ajv@^8.8.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +angular-gridster2@~12.1.1: + version "12.1.1" + resolved "https://registry.yarnpkg.com/angular-gridster2/-/angular-gridster2-12.1.1.tgz#699cd0a2477b81b052f6bd7b336ba7cc2adc8bc3" + integrity sha512-HK7vf212LSn7mp8g4sNA6/X8TQN4wJyHupKemx+PUPmTs6FHDyquUMUFXYxANR47jdyLEMW/DxCDI0bEhIIChw== + dependencies: + tslib "^2.1.0" + +angular2-hotkeys@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/angular2-hotkeys/-/angular2-hotkeys-2.4.0.tgz#b814ccc43bb55eddff103d2ccf42afd832e3ff9d" + integrity sha512-m1UHmPBCKNUoGDPbyfLxUcUXd7vn1welfpfCLv7hEM2HIYfEtFo158xx7l4mAufEeE5179uFJggSQcVwJennfg== + dependencies: + "@types/mousetrap" "^1.6.0" + mousetrap "^1.6.0" + tslib "^2.0.0" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-colors@^3.0.0: + version "3.2.4" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" + integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-html-community@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" + integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== + +ansi-regex@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" + integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansidec@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/ansidec/-/ansidec-0.3.4.tgz#e12d267d6b1f122d2da5b98fe0de1f98d14ac62b" + integrity sha512-Ydgbey4zqUmmNN2i2OVeVHXig3PxHRbok2X6B2Sogmb92JzZUFfTL806dT7os6tBL1peXItfeFt76CP3zsoXUg== + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +app-root-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad" + integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz#ba20bd6b553e31d62fc8c31bd23d22b95734390d" + integrity sha512-0GWpv50YSOcLXaN6/FAKY3vfRbllXWV2xvfA/oKJF8pzFhWXPV+yjhJXDBbjscDYowv7Yw1A3uigpzn5iEGTyw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^1.0.7, argparse@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +aria-query@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" + integrity sha512-majUxHgLehQTeSA+hClx+DY09OVUqG3GtezWkF1krgLGNdlDu9l9V8DaqNMWbq4Eddc8wsyDA0hpDUtnYxQEXw== + dependencies: + ast-types-flow "0.0.7" + commander "^2.11.0" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA== + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== + +array-back@^3.0.1, array-back@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0" + integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q== + +array-back@^4.0.1, array-back@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e" + integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array-flatten@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" + integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng== + dependencies: + array-uniq "^1.0.1" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q== + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ== + +arrify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== + +asn1@~0.2.3: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw== + +ast-types-flow@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" + integrity sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag== + +async-each@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + +async@^2.6.2, async@~2.6.3: + version "2.6.4" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" + integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== + dependencies: + lodash "^4.17.14" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +attr-accept@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + +autoprefixer@^9.6.1: + version "9.8.8" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.8.tgz#fd4bd4595385fa6f06599de749a4d5f7a474957a" + integrity sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA== + dependencies: + browserslist "^4.12.0" + caniuse-lite "^1.0.30001109" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + picocolors "^0.2.1" + postcss "^7.0.32" + postcss-value-parser "^4.1.0" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== + +aws4@^1.8.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +axobject-query@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9" + integrity sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww== + dependencies: + ast-types-flow "0.0.7" + +babel-loader@8.2.2: + version "8.2.2" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" + integrity sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g== + dependencies: + find-cache-dir "^3.3.1" + loader-utils "^1.4.0" + make-dir "^3.1.0" + schema-utils "^2.6.5" + +babel-plugin-dynamic-import-node@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" + integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== + dependencies: + object.assign "^4.1.0" + +babel-plugin-polyfill-corejs2@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.3.tgz#6ed8e30981b062f8fe6aca8873a37ebcc8cc1c0f" + integrity sha512-NDZ0auNRzmAfE1oDDPW2JhzIMXUk+FFe2ICejmt5T4ocKgiQx3e0VCRx9NCAidcMtL2RUZaWtXnmjTCkx0tcbA== + dependencies: + "@babel/compat-data" "^7.13.11" + "@babel/helper-define-polyfill-provider" "^0.2.4" + semver "^6.1.1" + +babel-plugin-polyfill-corejs3@^0.2.2: + version "0.2.5" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.5.tgz#2779846a16a1652244ae268b1e906ada107faf92" + integrity sha512-ninF5MQNwAX9Z7c9ED+H2pGt1mXdP4TqzlHKyPIYmJIYz0N+++uwdM7RnJukklhzJ54Q84vA4ZJkgs7lu5vqcw== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.2" + core-js-compat "^3.16.2" + +babel-plugin-polyfill-regenerator@^0.2.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.3.tgz#2e9808f5027c4336c994992b48a4262580cb8d6d" + integrity sha512-JVE78oRZPKFIeUqFGrSORNzQnrDwZR16oiWeGM8ZyjBn2XAT5OjP+wXx5ESuo33nUsFUEJYjtklnsKbxW5L+7g== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.4" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-arraybuffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" + integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== + +base64-js@^1.2.0, base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +batch@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" + integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +blocking-proxy@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/blocking-proxy/-/blocking-proxy-1.0.1.tgz#81d6fd1fe13a4c0d6957df7f91b75e98dac40cb2" + integrity sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA== + dependencies: + minimist "^1.2.0" + +body-parser@1.20.0, body-parser@^1.19.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" + integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.10.3" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + +bonjour@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" + integrity sha512-RaVTblr+OnEli0r/ud8InrU7D+G0y6aJhlxaLa6Pwty4+xoxboF1BsUI45tujvRpbj9dQVoglChqonGAsjEBYg== + dependencies: + array-flatten "^2.1.0" + deep-equal "^1.0.1" + dns-equal "^1.0.0" + dns-txt "^2.0.2" + multicast-dns "^6.0.1" + multicast-dns-service-types "^1.1.0" + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.2, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.20.2, browserslist@^4.20.3, browserslist@^4.21.0, browserslist@^4.6.4, browserslist@^4.9.1: + version "4.21.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.1.tgz#c9b9b0a54c7607e8dc3e01a0d311727188011a00" + integrity sha512-Nq8MFCSrnJXSc88yliwlzQe3qNe3VntIjhsArW9IJOEPSHNx23FalwApUVbzAWABLhYJJ7y8AynWI/XM8OdfjQ== + dependencies: + caniuse-lite "^1.0.30001359" + electron-to-chromium "^1.4.172" + node-releases "^2.0.5" + update-browserslist-db "^1.0.4" + +browserstack@^1.5.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3" + integrity sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw== + dependencies: + https-proxy-agent "^2.2.1" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-indexof@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" + integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +builtin-modules@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + integrity sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ== + +builtins@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" + integrity sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ== + +bytes@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cacache@15.2.0: + version "15.2.0" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.2.0.tgz#73af75f77c58e72d8c630a7a2858cb18ef523389" + integrity sha512-uKoJSHmnrqXgthDFx/IU6ED/5xd+NNGe+Bb+kLZy7Ku4P+BaiWEUflAKPZ7eAzsYGcsAGASJZsybXp+quEcHTw== + dependencies: + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" + fs-minipass "^2.0.0" + glob "^7.1.4" + infer-owner "^1.0.4" + lru-cache "^6.0.0" + minipass "^3.1.1" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^1.0.3" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^8.0.1" + tar "^6.0.2" + unique-filename "^1.1.1" + +cacache@^15.0.5, cacache@^15.2.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" + integrity sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ== + dependencies: + "@npmcli/fs" "^1.0.0" + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" + fs-minipass "^2.0.0" + glob "^7.1.4" + infer-owner "^1.0.4" + lru-cache "^6.0.0" + minipass "^3.1.1" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^1.0.3" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^8.0.1" + tar "^6.0.2" + unique-filename "^1.1.1" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001032, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001359: + version "1.0.30001363" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001363.tgz#26bec2d606924ba318235944e1193304ea7c4f15" + integrity sha512-HpQhpzTGGPVMnCjIomjt+jvyUu8vNFo3TaDiZ/RcoTrlOq/5+tC8zHdsbgFB6MxmaY+jCpsH09aD80Bb4Ow3Sg== + +canonical-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/canonical-path/-/canonical-path-1.0.0.tgz#fcb470c23958def85081856be7a86e904f180d1d" + integrity sha512-feylzsbDxi1gPZ1IjystzIQZagYYLvfKrSuygUCgf7z6x790VEzze5QEkdSV1U58RA7Hi0+v6fv4K54atOzATg== + +canvas-gauges@^2.1.7: + version "2.1.7" + resolved "https://registry.yarnpkg.com/canvas-gauges/-/canvas-gauges-2.1.7.tgz#9f8d96960a19c64879083e72e66b773ed1ec8079" + integrity sha512-z9cXBVTZdaUIOh32g21NU8gwxEeaxpEMvkZr9t8Y0QDbZiCDq05SJ17aIt+DM12oTJAlWGluN21D+bQ0NCv5GA== + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== + +chalk@^1.1.1, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.1.0, chalk@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +"chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" + integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +chrome-trace-event@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" + integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +circular-dependency-plugin@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.2.2.tgz#39e836079db1d3cf2f988dc48c5188a44058b600" + integrity sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ== + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@2.x, classnames@^2.2.1, classnames@^2.2.6: + version "2.3.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" + integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.5.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" + integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + +clsx@^1.0.2, clsx@^1.0.4: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + +codelyzer@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/codelyzer/-/codelyzer-6.0.2.tgz#25d72eae641e8ff13ffd7d99b27c9c7ad5d7e135" + integrity sha512-v3+E0Ucu2xWJMOJ2fA/q9pDT/hlxHftHGPUay1/1cTgyPV5JTHFdO9hqo837Sx2s9vKBMTt5gO+lhF95PO6J+g== + dependencies: + "@angular/compiler" "9.0.0" + "@angular/core" "9.0.0" + app-root-path "^3.0.0" + aria-query "^3.0.0" + axobject-query "2.0.2" + css-selector-tokenizer "^0.7.1" + cssauron "^1.4.0" + damerau-levenshtein "^1.0.4" + rxjs "^6.5.3" + semver-dsl "^1.0.1" + source-map "^0.5.7" + sprintf-js "^1.1.2" + tslib "^1.10.0" + zone.js "~0.10.3" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw== + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-support@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +colord@^2.9.1: + version "2.9.2" + resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" + integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ== + +colorette@^1.2.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + +colors@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +command-line-args@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e" + integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg== + dependencies: + array-back "^3.1.0" + find-replace "^3.0.0" + lodash.camelcase "^4.3.0" + typical "^4.0.0" + +command-line-usage@^6.1.1: + version "6.1.3" + resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957" + integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw== + dependencies: + array-back "^4.0.2" + chalk "^2.4.2" + table-layout "^1.0.2" + typical "^5.2.0" + +commander@^2.11.0, commander@^2.12.1, commander@^2.19.0, commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + +commander@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + +component-emitter@^1.2.1, component-emitter@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +compressible@~2.0.16: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +compression-webpack-plugin@^9.0.1: + version "9.2.0" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-9.2.0.tgz#57fd539d17c5907eebdeb4e83dcfe2d7eceb9ef6" + integrity sha512-R/Oi+2+UHotGfu72fJiRoVpuRifZT0tTC6UqFD/DUo+mv8dbOow9rVOuTvDv5nPPm3GZhHL/fKkwxwIHnJ8Nyw== + dependencies: + schema-utils "^4.0.0" + serialize-javascript "^6.0.0" + +compression@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" + integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== + dependencies: + accepts "~1.3.5" + bytes "3.0.0" + compressible "~2.0.16" + debug "2.6.9" + on-headers "~1.0.2" + safe-buffer "5.1.2" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +connect-history-api-fallback@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" + integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== + +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + +console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +convert-source-map@^1.5.1, convert-source-map@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" + integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== + dependencies: + safe-buffer "~5.1.1" + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + +copy-anything@^2.0.1: + version "2.0.6" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-2.0.6.tgz#092454ea9584a7b7ad5573062b2a87f5900fc480" + integrity sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw== + dependencies: + is-what "^3.14.1" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw== + +copy-webpack-plugin@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-9.0.1.tgz#b71d21991599f61a4ee00ba79087b8ba279bbb59" + integrity sha512-14gHKKdYIxF84jCEgPgYXCPpldbwpxxLbCmA7LReY7gvbaT555DgeBWBgBZM116tv/fO6RRJrsivBqRyRlukhw== + dependencies: + fast-glob "^3.2.5" + glob-parent "^6.0.0" + globby "^11.0.3" + normalize-path "^3.0.0" + p-limit "^3.1.0" + schema-utils "^3.0.0" + serialize-javascript "^6.0.0" + +core-js-compat@^3.15.0, core-js-compat@^3.16.2: + version "3.23.3" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.23.3.tgz#7d8503185be76bb6d8d592c291a4457a8e440aa9" + integrity sha512-WSzUs2h2vvmKsacLHNTdpyOC9k43AEhcGoFlVgCY4L7aw98oSBKtPL6vD0/TqZjRWRQYdDSLkzZIni4Crbbiqw== + dependencies: + browserslist "^4.21.0" + semver "7.0.0" + +core-js@3.16.0: + version "3.16.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.0.tgz#1d46fb33720bc1fa7f90d20431f36a5540858986" + integrity sha512-5+5VxRFmSf97nM8Jr2wzOwLqRo6zphH2aX+7KsAUONObyzakDNq2G/bgbhinxB4PoV9L3aXQYhiDKyIKWd2c8g== + +core-js@^3.19.2: + version "3.23.3" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.23.3.tgz#3b977612b15da6da0c9cc4aec487e8d24f371112" + integrity sha512-oAKwkj9xcWNBAvGbT//WiCdOMpb9XQG92/Fe3ABFM/R16BsHgePG00mFOgKf7IsCtfj8tA1kHtf/VwErhriz5Q== + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cosmiconfig@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" + integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.2.1" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.10.0" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +critters@0.0.12: + version "0.0.12" + resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.12.tgz#32baa87526e053a41b67e19921673ed92264e2ab" + integrity sha512-ujxKtKc/mWpjrOKeaACTaQ1aP0O31M0ZPWhfl85jZF1smPU4Ivb9va5Ox2poif4zVJQQo0LCFlzGtEZAsCAPcw== + dependencies: + chalk "^4.1.0" + css-select "^4.1.3" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + postcss "^8.3.7" + pretty-bytes "^5.3.0" + +cross-spawn@^6.0.0, cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +css-blank-pseudo@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" + integrity sha512-LHz35Hr83dnFeipc7oqFDmsjHdljj3TQtxGGiNWSOsTLIAubSm4TEz8qCaKFpk7idaQ1GfWscF4E6mgpBysA1w== + dependencies: + postcss "^7.0.5" + +css-declaration-sorter@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.0.tgz#72ebd995c8f4532ff0036631f7365cce9759df14" + integrity sha512-OGT677UGHJTAVMRhPO+HJ4oKln3wkBTwtDFH0ojbqm+MJm6xuDMHp2nkhh/ThaBqq20IbraBQSWKfSLNHQO9Og== + +css-has-pseudo@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-0.10.0.tgz#3c642ab34ca242c59c41a125df9105841f6966ee" + integrity sha512-Z8hnfsZu4o/kt+AuFzeGpLVhFOGO9mluyHBaA2bA8aCGTwah5sT3WV/fTHH8UNZUytOIImuGPrl/prlb4oX4qQ== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^5.0.0-rc.4" + +css-line-break@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" + integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w== + dependencies: + utrie "^1.0.2" + +css-loader@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.2.0.tgz#9663d9443841de957a3cb9bcea2eda65b3377071" + integrity sha512-/rvHfYRjIpymZblf49w8jYcRo2y9gj6rV8UroHGmBxKrIyGLokpycyKzp9OkitvqT29ZSpzJ0Ic7SpnJX3sC8g== + dependencies: + icss-utils "^5.1.0" + postcss "^8.2.15" + postcss-modules-extract-imports "^3.0.0" + postcss-modules-local-by-default "^4.0.0" + postcss-modules-scope "^3.0.0" + postcss-modules-values "^4.0.0" + postcss-value-parser "^4.1.0" + semver "^7.3.5" + +css-minimizer-webpack-plugin@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.0.2.tgz#8fadbdf10128cb40227bff275a4bb47412534245" + integrity sha512-B3I5e17RwvKPJwsxjjWcdgpU/zqylzK1bPVghcmpFHRL48DXiBgrtqz1BJsn68+t/zzaLp9kYAaEDvQ7GyanFQ== + dependencies: + cssnano "^5.0.6" + jest-worker "^27.0.2" + p-limit "^3.0.2" + postcss "^8.3.5" + schema-utils "^3.0.0" + serialize-javascript "^6.0.0" + source-map "^0.6.1" + +css-parse@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/css-parse/-/css-parse-2.0.0.tgz#a468ee667c16d81ccf05c58c38d2a97c780dbfd4" + integrity sha512-UNIFik2RgSbiTwIW1IsFwXWn6vs+bYdq83LKTSOsx7NJR7WII9dxewkHLltfTLVppoUApHV0118a4RZRI9FLwA== + dependencies: + css "^2.0.0" + +css-prefers-color-scheme@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4" + integrity sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg== + dependencies: + postcss "^7.0.5" + +css-select@^4.1.3: + version "4.3.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" + integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== + dependencies: + boolbase "^1.0.0" + css-what "^6.0.1" + domhandler "^4.3.1" + domutils "^2.8.0" + nth-check "^2.0.1" + +css-selector-tokenizer@^0.7.1: + version "0.7.3" + resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz#735f26186e67c749aaf275783405cf0661fae8f1" + integrity sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg== + dependencies: + cssesc "^3.0.0" + fastparse "^1.1.2" + +css-tree@^1.1.2, css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-vendor@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.8.tgz#e47f91d3bd3117d49180a3c935e62e3d9f7f449d" + integrity sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ== + dependencies: + "@babel/runtime" "^7.8.3" + is-in-browser "^1.0.2" + +css-what@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +css@^2.0.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" + integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== + dependencies: + inherits "^2.0.3" + source-map "^0.6.1" + source-map-resolve "^0.5.2" + urix "^0.1.0" + +cssauron@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cssauron/-/cssauron-1.4.0.tgz#a6602dff7e04a8306dc0db9a551e92e8b5662ad8" + integrity sha512-Ht70DcFBh+/ekjVrYS2PlDMdSQEl3OFNmjK6lcn49HptBgilXf/Zwg4uFh9Xn0pX3Q8YOkSjIFOfK2osvdqpBw== + dependencies: + through X.X.X + +cssdb@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-4.4.0.tgz#3bf2f2a68c10f5c6a08abd92378331ee803cddb0" + integrity sha512-LsTAR1JPEM9TpGhl/0p3nQecC2LJ0kD8X5YARu1hk/9I1gril5vDtMZyNxcEpxxDj34YNck/ucjuoUd66K03oQ== + +cssesc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" + integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +cssnano-preset-default@^5.2.12: + version "5.2.12" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz#ebe6596ec7030e62c3eb2b3c09f533c0644a9a97" + integrity sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew== + dependencies: + css-declaration-sorter "^6.3.0" + cssnano-utils "^3.1.0" + postcss-calc "^8.2.3" + postcss-colormin "^5.3.0" + postcss-convert-values "^5.1.2" + postcss-discard-comments "^5.1.2" + postcss-discard-duplicates "^5.1.0" + postcss-discard-empty "^5.1.1" + postcss-discard-overridden "^5.1.0" + postcss-merge-longhand "^5.1.6" + postcss-merge-rules "^5.1.2" + postcss-minify-font-values "^5.1.0" + postcss-minify-gradients "^5.1.1" + postcss-minify-params "^5.1.3" + postcss-minify-selectors "^5.2.1" + postcss-normalize-charset "^5.1.0" + postcss-normalize-display-values "^5.1.0" + postcss-normalize-positions "^5.1.1" + postcss-normalize-repeat-style "^5.1.1" + postcss-normalize-string "^5.1.0" + postcss-normalize-timing-functions "^5.1.0" + postcss-normalize-unicode "^5.1.0" + postcss-normalize-url "^5.1.0" + postcss-normalize-whitespace "^5.1.1" + postcss-ordered-values "^5.1.3" + postcss-reduce-initial "^5.1.0" + postcss-reduce-transforms "^5.1.0" + postcss-svgo "^5.1.0" + postcss-unique-selectors "^5.1.1" + +cssnano-utils@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-3.1.0.tgz#95684d08c91511edfc70d2636338ca37ef3a6861" + integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA== + +cssnano@^5.0.6: + version "5.1.12" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.12.tgz#bcd0b64d6be8692de79332c501daa7ece969816c" + integrity sha512-TgvArbEZu0lk/dvg2ja+B7kYoD7BBCmn3+k58xD0qjrGHsFzXY/wKTo9M5egcUCabPol05e/PVoIu79s2JN4WQ== + dependencies: + cssnano-preset-default "^5.2.12" + lilconfig "^2.0.3" + yaml "^1.10.2" + +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + +csstype@^2.5.2: + version "2.6.20" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.20.tgz#9229c65ea0b260cf4d3d997cb06288e36a8d6dda" + integrity sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA== + +csstype@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" + integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== + +custom-event@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" + integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== + +damerau-levenshtein@^1.0.4: + version "1.0.8" + resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" + integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== + dependencies: + assert-plus "^1.0.0" + +date-fns@^2.26.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + +date-format@^4.0.10, date-format@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.11.tgz#ae0d1e069d7f0687938fd06f98c12f3a6276e526" + integrity sha512-VS20KRyorrbMCQmpdl2hg5KaOUsda1RbnsJg461FfrcyCUg+pkd0b40BSW4niQyTheww4DBXQnS7HwSrKkipLw== + +dayjs@^1.10.4: + version "1.11.3" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.3.tgz#4754eb694a624057b9ad2224b67b15d552589258" + integrity sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A== + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + +debug@^3.1.0, debug@^3.1.1, debug@^3.2.6, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== + +deep-equal@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + +deep-extend@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + integrity sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA== + +default-gateway@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" + integrity sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA== + dependencies: + execa "^1.0.0" + ip-regex "^2.1.0" + +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA== + dependencies: + clone "^1.0.2" + +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + +define-properties@^1.1.3: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" + integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== + dependencies: + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA== + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA== + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" + integrity sha512-Z4fzpbIRjOu7lO5jCETSWoqUDVe0IPOlfugBsF6suen2LKDlVb4QZpKEM9P+buNJ4KI1eN7I083w/pbKUpsrWQ== + dependencies: + globby "^5.0.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + rimraf "^2.2.8" + +del@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" + integrity sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ== + dependencies: + "@types/glob" "^7.1.1" + globby "^6.1.0" + is-path-cwd "^2.0.0" + is-path-in-cwd "^2.0.0" + p-map "^2.0.0" + pify "^4.0.1" + rimraf "^2.6.3" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@^1.1.2, depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +dependency-graph@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" + integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== + +di@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" + integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== + +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +dijkstrajs@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" + integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +directory-tree@^3.0.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/directory-tree/-/directory-tree-3.3.0.tgz#dd85c82e21b3f88dca5b97a28d0a36068fb96bfe" + integrity sha512-7NT+6BjkwRKvVP5dCkYhO/pRHQJ5Jw8ww1detHLiD9/IPdTzFz6Lz9aCm0zRgp2zfECXPxBoNdzR3VTEhljeHg== + dependencies: + command-line-args "^5.2.0" + command-line-usage "^6.1.1" + +dns-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" + integrity sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg== + +dns-packet@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.4.tgz#e3455065824a2507ba886c55a89963bb107dec6f" + integrity sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA== + dependencies: + ip "^1.1.0" + safe-buffer "^5.0.1" + +dns-txt@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" + integrity sha512-Ix5PrWjphuSoUXV/Zv5gaFHjnaJtb02F2+Si3Ht9dyJ87+Z/lMmy+dpNHtTGraNK958ndXq2i+GLkWsWHcKaBQ== + dependencies: + buffer-indexof "^1.0.0" + +dom-align@^1.7.0: + version "1.12.3" + resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.12.3.tgz#a36d02531dae0eefa2abb0c4db6595250526f103" + integrity sha512-Gj9hZN3a07cbR6zviMUBOMPdWxYhbMI+x+WS0NAIu2zFZmbK8ys9R79g+iG9qLnlCwpFoaB+fKy8Pdv470GsPA== + +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + +dom-serialize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" + integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ== + dependencies: + custom-event "~1.0.0" + ent "~2.2.0" + extend "^3.0.0" + void-elements "^2.0.0" + +dom-serializer@^1.0.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^4.2.0, domhandler@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + +domutils@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +editorconfig@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== + dependencies: + commander "^2.19.0" + lru-cache "^4.1.5" + semver "^5.6.0" + sigmund "^1.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.4.172: + version "1.4.182" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.182.tgz#5d59214ebfe90b36f23e81cd226a42732cd8c677" + integrity sha512-OpEjTADzGoXABjqobGhpy0D2YsTncAax7IkER68ycc4adaq0dqEG9//9aenKPy7BGA90bqQdLac0dPp6uMkcSg== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-toolkit@^6.5.0: + version "6.6.0" + resolved "https://registry.yarnpkg.com/emoji-toolkit/-/emoji-toolkit-6.6.0.tgz#e7287c43a96f940ec4c5428cd7100a40e57518f1" + integrity sha512-pEu0kow2p1N8zCKnn/L6H0F3rWUBB3P3hVjr/O5yl1fK7N9jU4vO4G7EFapC5Y3XwZLUCY0FZbOPyTkH+4V2eQ== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encoding@^0.1.12: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + +end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +engine.io-parser@~5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0" + integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg== + +engine.io@~6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.2.0.tgz#003bec48f6815926f2b1b17873e576acd54f41d0" + integrity sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg== + dependencies: + "@types/cookie" "^0.4.1" + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.4.1" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.0.3" + ws "~8.2.3" + +enhanced-resolve@^5.8.0, enhanced-resolve@^5.9.3: + version "5.10.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" + integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +ent@~2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" + integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA== + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== + +errno@^0.1.1, errno@^0.1.3: + version "0.1.8" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== + dependencies: + prr "~1.0.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-module-lexer@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.7.1.tgz#c2c8e0f46f2df06274cdaf0dd3f3b33e0a0b267d" + integrity sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw== + +es-module-lexer@^0.9.0: + version "0.9.3" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" + integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== + +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== + dependencies: + es6-promise "^4.0.3" + +esbuild-android-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.8.tgz#c20e875c3c98164b1ffba9b28637bdf96f5e9e7c" + integrity sha512-AilbChndywpk7CdKkNSZ9klxl+9MboLctXd9LwLo3b0dawmOF/i/t2U5d8LM6SbT1Xw36F8yngSUPrd8yPs2RA== + +esbuild-darwin-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.8.tgz#f46e6b471ddbf62265234808a6a1aa91df18a417" + integrity sha512-b6sdiT84zV5LVaoF+UoMVGJzR/iE2vNUfUDfFQGrm4LBwM/PWXweKpuu6RD9mcyCq18cLxkP6w/LD/w9DtX3ng== + +esbuild-darwin-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.8.tgz#a991157a6013facd4f2e14159b7da52626c90154" + integrity sha512-R8YuPiiJayuJJRUBG4H0VwkEKo6AvhJs2m7Tl0JaIer3u1FHHXwGhMxjJDmK+kXwTFPriSysPvcobXC/UrrZCQ== + +esbuild-freebsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.8.tgz#301601d2e443ad458960e359b402a17d9500be9d" + integrity sha512-zBn6urrn8FnKC+YSgDxdof9jhPCeU8kR/qaamlV4gI8R3KUaUK162WYM7UyFVAlj9N0MyD3AtB+hltzu4cysTw== + +esbuild-freebsd-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.8.tgz#039a63acc12ec0892006c147ea221e55f9125a9f" + integrity sha512-pWW2slN7lGlkx0MOEBoUGwRX5UgSCLq3dy2c8RIOpiHtA87xAUpDBvZK10MykbT+aMfXc0NI2lu1X+6kI34xng== + +esbuild-linux-32@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.8.tgz#c537b67d7e694b60bfa2786581412838c6ba0284" + integrity sha512-T0I0ueeKVO/Is0CAeSEOG9s2jeNNb8jrrMwG9QBIm3UU18MRB60ERgkS2uV3fZ1vP2F8i3Z2e3Zju4lg9dhVmw== + +esbuild-linux-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.8.tgz#0092fc8a064001a777bfa0e3b425bb8be8f96e6a" + integrity sha512-Bm8SYmFtvfDCIu9sjKppFXzRXn2BVpuCinU1ChTuMtdKI/7aPpXIrkqBNOgPTOQO9AylJJc1Zw6EvtKORhn64w== + +esbuild-linux-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.8.tgz#5cd3f2bb924212971482e8dbc25c4afd09b28110" + integrity sha512-X4pWZ+SL+FJ09chWFgRNO3F+YtvAQRcWh0uxKqZSWKiWodAB20flsW/OWFYLXBKiVCTeoGMvENZS/GeVac7+tQ== + +esbuild-linux-arm@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.8.tgz#ad634f96bf2975536907aeb9fdb75a3194f4ddce" + integrity sha512-4/HfcC40LJ4GPyboHA+db0jpFarTB628D1ifU+/5bunIgY+t6mHkJWyxWxAAE8wl/ZIuRYB9RJFdYpu1AXGPdg== + +esbuild-linux-mips64le@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.8.tgz#57857edfebf9bf65766dc8be1637f2179c990572" + integrity sha512-o7e0D+sqHKT31v+mwFircJFjwSKVd2nbkHEn4l9xQ1hLR+Bv8rnt3HqlblY3+sBdlrOTGSwz0ReROlKUMJyldA== + +esbuild-linux-ppc64le@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.8.tgz#fdb82a059a5b86bb10fb42091b4ebcf488b9cd46" + integrity sha512-eZSQ0ERsWkukJp2px/UWJHVNuy0lMoz/HZcRWAbB6reoaBw7S9vMzYNUnflfL3XA6WDs+dZn3ekHE4Y2uWLGig== + +esbuild-netbsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.8.tgz#d7879e7123d3b2c04754ece8bd061aa6866deeff" + integrity sha512-gZX4kP7gVvOrvX0ZwgHmbuHczQUwqYppxqtoyC7VNd80t5nBHOFXVhWo2Ad/Lms0E8b+wwgI/WjZFTCpUHOg9Q== + +esbuild-openbsd-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.8.tgz#88b280b6cb0a3f6adb60abf27fc506c506a35cf0" + integrity sha512-afzza308X4WmcebexbTzAgfEWt9MUkdTvwIa8xOu4CM2qGbl2LanqEl8/LUs8jh6Gqw6WsicEK52GPrS9wvkcw== + +esbuild-sunos-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.8.tgz#229ae7c7703196a58acd0f0291ad9bebda815d63" + integrity sha512-mWPZibmBbuMKD+LDN23LGcOZ2EawMYBONMXXHmbuxeT0XxCNwadbCVwUQ/2p5Dp5Kvf6mhrlIffcnWOiCBpiVw== + +esbuild-wasm@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.13.8.tgz#f34134c187ffcfc22d476e925917f70bab40f8b0" + integrity sha512-UbD+3nloiSpJWXTCInZQrqPe8Y+RLfDkY/5kEHiXsw/lmaEvibe69qTzQu16m5R9je/0bF7VYQ5jaEOq0z9lLA== + +esbuild-windows-32@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.8.tgz#892d093e32a21c0c9135e5a0ffdc380aeb70e763" + integrity sha512-QsZ1HnWIcnIEApETZWw8HlOhDSWqdZX2SylU7IzGxOYyVcX7QI06ety/aDcn437mwyO7Ph4RrbhB+2ntM8kX8A== + +esbuild-windows-64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.8.tgz#7defd8d79ae3bb7e6f53b65a7190be7daf901686" + integrity sha512-76Fb57B9eE/JmJi1QmUW0tRLQZfGo0it+JeYoCDTSlbTn7LV44ecOHIMJSSgZADUtRMWT9z0Kz186bnaB3amSg== + +esbuild-windows-arm64@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.8.tgz#e59ae004496fd8a5ab67bfc7945a2e47480d6fb9" + integrity sha512-HW6Mtq5eTudllxY2YgT62MrVcn7oq2o8TAoAvDUhyiEmRmDY8tPwAhb1vxw5/cdkbukM3KdMYtksnUhF/ekWeg== + +esbuild@0.13.8: + version "0.13.8" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.8.tgz#bd7cc51b881ab067789f88e17baca74724c1ec4f" + integrity sha512-A4af7G7YZLfG5OnARJRMtlpEsCkq/zHZQXewgPA864l9D6VjjbH1SuFYK/OSV6BtHwDGkdwyRrX0qQFLnMfUcw== + optionalDependencies: + esbuild-android-arm64 "0.13.8" + esbuild-darwin-64 "0.13.8" + esbuild-darwin-arm64 "0.13.8" + esbuild-freebsd-64 "0.13.8" + esbuild-freebsd-arm64 "0.13.8" + esbuild-linux-32 "0.13.8" + esbuild-linux-64 "0.13.8" + esbuild-linux-arm "0.13.8" + esbuild-linux-arm64 "0.13.8" + esbuild-linux-mips64le "0.13.8" + esbuild-linux-ppc64le "0.13.8" + esbuild-netbsd-64 "0.13.8" + esbuild-openbsd-64 "0.13.8" + esbuild-sunos-64 "0.13.8" + esbuild-windows-32 "0.13.8" + esbuild-windows-64 "0.13.8" + esbuild-windows-arm64 "0.13.8" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +eve-raphael@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" + integrity sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug== + +eventemitter-asyncresource@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b" + integrity sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ== + +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +eventsource@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA== + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +express@^4.17.1: + version "4.18.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" + integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.0" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.10.3" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q== + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0, extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== + +extsprintf@^1.2.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" + integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.5, fast-glob@^3.2.9: + version "3.2.11" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" + integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@2.1.0, fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fastparse@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" + integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ== + +fastq@^1.6.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" + integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== + dependencies: + reusify "^1.0.4" + +faye-websocket@^0.11.3, faye-websocket@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + +file-selector@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.4.0.tgz#59ec4f27aa5baf0841e9c6385c8386bef4d18b17" + integrity sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg== + dependencies: + tslib "^2.0.3" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ== + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-cache-dir@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-cache-dir@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-replace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" + integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ== + dependencies: + array-back "^3.0.1" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + +flatted@^3.2.5: + version "3.2.6" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.6.tgz#022e9218c637f9f3fc9c35ab9c9193f05add60b2" + integrity sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ== + +flatten@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" + integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== + +"flot.curvedlines@https://github.com/MichaelZinsmaier/CurvedLines.git#master": + version "1.1.1" + resolved "https://github.com/MichaelZinsmaier/CurvedLines.git#22ed1fc2a6ccafc816c2d07b36027cc123825c4b" + +"flot@https://github.com/thingsboard/flot.git#0.9-work": + version "0.9.0-alpha" + resolved "https://github.com/thingsboard/flot.git#0ff0c775db7c74e705f6c3c2bba92080a202ccd4" + +follow-redirects@^1.0.0: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + +font-awesome@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" + integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg== + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA== + dependencies: + map-cache "^0.2.2" + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-minipass@^2.0.0, fs-minipass@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs-monkey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" + integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^1.2.7: + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +functions-have-names@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +gauge@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.4.tgz#52ff0652f2bbf607a989793d53b751bef2328dce" + integrity sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.3" + console-control-strings "^1.1.0" + has-unicode "^2.0.1" + signal-exit "^3.0.7" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.5" + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +geojson-rbush@3.x: + version "3.2.0" + resolved "https://registry.yarnpkg.com/geojson-rbush/-/geojson-rbush-3.2.0.tgz#8b543cf0d56f99b78faf1da52bb66acad6dfc290" + integrity sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w== + dependencies: + "@turf/bbox" "*" + "@turf/helpers" "6.x" + "@turf/meta" "6.x" + "@types/geojson" "7946.0.8" + rbush "^3.0.1" + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" + integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.3" + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== + dependencies: + assert-plus "^1.0.0" + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA== + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@7.1.7: + version "7.1.7" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.3, glob@^7.0.6, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globby@^11.0.3: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +globby@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" + integrity sha512-HJRTIH2EeH44ka+LWig+EqT2ONSYpVlNfx6pyd592/VF1TbfljJ7elwie7oSwcViLGqOdWocSdu2txwBF9bjmQ== + dependencies: + array-union "^1.0.1" + arrify "^1.0.0" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + integrity sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw== + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + +hammerjs@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" + integrity sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ== + +handle-thing@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" + integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg== + dependencies: + ansi-regex "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + +has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" + integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== + dependencies: + has-symbols "^1.0.2" + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q== + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw== + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ== + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ== + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hdr-histogram-js@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5" + integrity sha512-Hkn78wwzWHNCp2uarhzQ2SGFLU3JY8SBDDd3TAABK4fc30wm+MuPOrg5QVFVfkKOQd6Bfz3ukJEI+q9sXEkK1g== + dependencies: + "@assemblyscript/loader" "^0.10.1" + base64-js "^1.2.0" + pako "^1.0.3" + +hdr-histogram-percentiles-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz#9409f4de0c2dda78e61de2d9d78b1e9f3cba283c" + integrity sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw== + +hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + +hosted-git-info@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" + integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== + dependencies: + lru-cache "^6.0.0" + +hpack.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" + integrity sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ== + dependencies: + inherits "^2.0.1" + obuf "^1.0.0" + readable-stream "^2.0.1" + wbuf "^1.1.0" + +html-entities@^1.3.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc" + integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA== + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +html2canvas@^1.3.3: + version "1.4.1" + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" + integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA== + dependencies: + css-line-break "^2.1.0" + text-segmentation "^1.0.3" + +http-cache-semantics@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" + integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + +http-deceiver@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" + integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-parser-js@>=0.5.1: + version "0.5.8" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" + integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== + +http-proxy-agent@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" + integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== + dependencies: + "@tootallnate/once" "1" + agent-base "6" + debug "4" + +http-proxy-middleware@0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a" + integrity sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q== + dependencies: + http-proxy "^1.17.0" + is-glob "^4.0.0" + lodash "^4.17.11" + micromatch "^3.1.10" + +http-proxy@^1.17.0, http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-proxy-agent@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + +https-proxy-agent@^2.2.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" + integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +hyphenate-style-name@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d" + integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== + +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +icss-utils@^5.0.0, icss-utils@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" + integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-walk@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-4.0.1.tgz#fc840e8346cf88a3a9380c5b17933cd8f4d39fa3" + integrity sha512-rzDQLaW4jQbh2YrOFlJdCtX8qgJTehFRYiUB2r1osqTeDzV/3+Jh8fz1oAPzUThf3iku8Ds4IDqawI5d8mUiQw== + dependencies: + minimatch "^3.0.4" + +ignore@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" + integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== + +image-size@~0.5.0: + version "0.5.5" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" + integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== + +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA== + +infer-owner@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + +ini@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +inquirer@8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.1.2.tgz#65b204d2cd7fb63400edd925dfe428bafd422e3d" + integrity sha512-DHLKJwLPNgkfwNmsuEUKSejJFbkv0FMO9SMiQbjI3n5NQuCrSIBqP66ggqyz2a6t2qEolKrMjhQ3+W/xXgUQ+Q== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.3.0" + run-async "^2.4.0" + rxjs "^7.2.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + +internal-ip@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" + integrity sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg== + dependencies: + default-gateway "^4.2.0" + ipaddr.js "^1.9.0" + +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + integrity sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw== + +ip@^1.1.0, ip@^1.1.5: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" + integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + +ipaddr.js@1.9.1, ipaddr.js@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A== + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q== + dependencies: + binary-extensions "^1.0.0" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-core-module@^2.2.0, is-core-module@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== + dependencies: + has "^1.0.3" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg== + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw== + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-in-browser@^1.0.2, is-in-browser@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" + integrity sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g== + +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg== + dependencies: + kind-of "^3.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + integrity sha512-cnS56eR9SPAscL77ik76ATVqoPARTqPIVkMDVxRaWH06zT+6+CzIroYRJ0VVvm0Z1zfAvxvz9i/D3Ppjaqt5Nw== + +is-path-cwd@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" + integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== + +is-path-in-cwd@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" + integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ== + dependencies: + is-path-inside "^1.0.0" + +is-path-in-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz#bfe2dca26c69f397265a4009963602935a053acb" + integrity sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ== + dependencies: + is-path-inside "^2.1.0" + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + integrity sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g== + dependencies: + path-is-inside "^1.0.1" + +is-path-inside@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" + integrity sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg== + dependencies: + path-is-inside "^1.0.2" + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-regex@^1.0.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-what@^3.14.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" + integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw== + +is-wsl@^2.1.1, is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isbinaryfile@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" + integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA== + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== + +istanbul-lib-coverage@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49" + integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA== + +istanbul-lib-coverage@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" + integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== + +istanbul-lib-instrument@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + dependencies: + "@babel/core" "^7.7.5" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-report@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" + integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^3.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz#284997c48211752ec486253da97e3879defba8c8" + integrity sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^2.0.5" + make-dir "^2.1.0" + rimraf "^2.6.3" + source-map "^0.6.1" + +istanbul-reports@^3.0.2: + version "3.1.4" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.4.tgz#1b6f068ecbc6c331040aab5741991273e609e40c" + integrity sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jasmine-core@^3.6.0: + version "3.99.1" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.99.1.tgz#5bfa4b2d76618868bfac4c8ff08bb26fffa4120d" + integrity sha512-Hu1dmuoGcZ7AfyynN3LsfruwMbxMALMka+YtZeGoLuDEySVmVAPaonkNoBRIw/ectu8b9tVQCJNgp4a4knp+tg== + +jasmine-core@~2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.8.0.tgz#bcc979ae1f9fd05701e45e52e65d3a5d63f1a24e" + integrity sha512-SNkOkS+/jMZvLhuSx1fjhcNWUC/KG6oVyFUGkSBEr9n1axSNduWU8GlI7suaHXr4yxjet6KjrUZxUTE5WzzWwQ== + +jasmine-core@~3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.10.1.tgz#7aa6fa2b834a522315c651a128d940eca553989a" + integrity sha512-ooZWSDVAdh79Rrj4/nnfklL3NQVra0BcuhcuWoAwwi+znLDoUeH87AFfeX8s+YeYi6xlv5nveRyaA1v7CintfA== + +jasmine-spec-reporter@~7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz#94b939448e63d4e2bd01668142389f20f0a8ea49" + integrity sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg== + dependencies: + colors "1.4.0" + +jasmine@2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.8.0.tgz#6b089c0a11576b1f16df11b80146d91d4e8b8a3e" + integrity sha512-KbdGQTf5jbZgltoHs31XGiChAPumMSY64OZMWLNYnEnMfG5uwGBhffePwuskexjT+/Jea/gU3qAU8344hNohSw== + dependencies: + exit "^0.1.2" + glob "^7.0.6" + jasmine-core "~2.8.0" + +jasminewd2@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jasminewd2/-/jasminewd2-2.2.0.tgz#e37cf0b17f199cce23bea71b2039395246b4ec4e" + integrity sha512-Rn0nZe4rfDhzA63Al3ZGh0E+JTmM6ESZYXJGKuqKGZObsAB9fwXPD03GjtIEvJBDOhN94T5MzbwZSqzFHSQPzg== + +jest-worker@^27.0.2, jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jquery.terminal@^2.29.4: + version "2.33.3" + resolved "https://registry.yarnpkg.com/jquery.terminal/-/jquery.terminal-2.33.3.tgz#ff91e36af55aac4e42094b909b11fb51d6f16e1d" + integrity sha512-FlqCWmMaygQZ1BbX3TswsMWH1Zh11o0s9brGG3Kwsc+Hav4KxrHyiZF7QJ2kE48DqTxb6fpdRn9g7olqp1XosQ== + dependencies: + "@jcubic/lily" "^0.3.0" + "@types/jquery" "^3.5.14" + ansidec "^0.3.4" + iconv-lite "^0.6.3" + jquery "^3.6.0" + prismjs "^1.27.0" + wcwidth "^1.0.1" + optionalDependencies: + fsevents "^2.3.2" + +jquery@>=1.9.1, jquery@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" + integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== + +js-beautify@^1.14.0: + version "1.14.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.4.tgz#187d600a835f84de67a6d09ceaf3f199b7284c82" + integrity sha512-+b4A9c3glceZEmxyIbxDOYB0ZJdReLvyU1077RqKsO4dZx9FUHjTOJn8VHwpg33QoucIykOiYbh7MfqBOghnrA== + dependencies: + config-chain "^1.1.13" + editorconfig "^0.15.3" + glob "^7.1.3" + nopt "^5.0.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== + +json-parse-better-errors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-defaults@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema-defaults/-/json-schema-defaults-0.4.0.tgz#b63ee7e7aa83f29b54cb31d31ecddeb056c3306c" + integrity sha512-UsUrkDVNvHTneyeQOYHH9ZHb3+6OjwYfJ831SdO0yjtXtYZ7Jh8BKWsuJYUQW7qckP5JhHawsg4GI6A5fMaR/Q== + dependencies: + argparse "^1.0.9" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +json5@^2.1.2, json5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" + integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== + +jsonc-parser@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.0.0.tgz#abdd785701c7e7eaca8a9ec8cf070ca51a745a22" + integrity sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA== + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +jsprim@^1.2.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" + integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + +jss-plugin-camel-case@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.9.0.tgz#4921b568b38d893f39736ee8c4c5f1c64670aaf7" + integrity sha512-UH6uPpnDk413/r/2Olmw4+y54yEF2lRIV8XIZyuYpgPYTITLlPOsq6XB9qeqv+75SQSg3KLocq5jUBXW8qWWww== + dependencies: + "@babel/runtime" "^7.3.1" + hyphenate-style-name "^1.0.3" + jss "10.9.0" + +jss-plugin-default-unit@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-default-unit/-/jss-plugin-default-unit-10.9.0.tgz#bb23a48f075bc0ce852b4b4d3f7582bc002df991" + integrity sha512-7Ju4Q9wJ/MZPsxfu4T84mzdn7pLHWeqoGd/D8O3eDNNJ93Xc8PxnLmV8s8ZPNRYkLdxZqKtm1nPQ0BM4JRlq2w== + dependencies: + "@babel/runtime" "^7.3.1" + jss "10.9.0" + +jss-plugin-global@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-global/-/jss-plugin-global-10.9.0.tgz#fc07a0086ac97aca174e37edb480b69277f3931f" + integrity sha512-4G8PHNJ0x6nwAFsEzcuVDiBlyMsj2y3VjmFAx/uHk/R/gzJV+yRHICjT4MKGGu1cJq2hfowFWCyrr/Gg37FbgQ== + dependencies: + "@babel/runtime" "^7.3.1" + jss "10.9.0" + +jss-plugin-nested@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-nested/-/jss-plugin-nested-10.9.0.tgz#cc1c7d63ad542c3ccc6e2c66c8328c6b6b00f4b3" + integrity sha512-2UJnDrfCZpMYcpPYR16oZB7VAC6b/1QLsRiAutOt7wJaaqwCBvNsosLEu/fUyKNQNGdvg2PPJFDO5AX7dwxtoA== + dependencies: + "@babel/runtime" "^7.3.1" + jss "10.9.0" + tiny-warning "^1.0.2" + +jss-plugin-props-sort@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-props-sort/-/jss-plugin-props-sort-10.9.0.tgz#30e9567ef9479043feb6e5e59db09b4de687c47d" + integrity sha512-7A76HI8bzwqrsMOJTWKx/uD5v+U8piLnp5bvru7g/3ZEQOu1+PjHvv7bFdNO3DwNPC9oM0a//KwIJsIcDCjDzw== + dependencies: + "@babel/runtime" "^7.3.1" + jss "10.9.0" + +jss-plugin-rule-value-function@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.9.0.tgz#379fd2732c0746fe45168011fe25544c1a295d67" + integrity sha512-IHJv6YrEf8pRzkY207cPmdbBstBaE+z8pazhPShfz0tZSDtRdQua5jjg6NMz3IbTasVx9FdnmptxPqSWL5tyJg== + dependencies: + "@babel/runtime" "^7.3.1" + jss "10.9.0" + tiny-warning "^1.0.2" + +jss-plugin-vendor-prefixer@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.9.0.tgz#aa9df98abfb3f75f7ed59a3ec50a5452461a206a" + integrity sha512-MbvsaXP7iiVdYVSEoi+blrW+AYnTDvHTW6I6zqi7JcwXdc6I9Kbm234nEblayhF38EftoenbM+5218pidmC5gA== + dependencies: + "@babel/runtime" "^7.3.1" + css-vendor "^2.0.8" + jss "10.9.0" + +jss@10.9.0, jss@^10.5.1: + version "10.9.0" + resolved "https://registry.yarnpkg.com/jss/-/jss-10.9.0.tgz#7583ee2cdc904a83c872ba695d1baab4b59c141b" + integrity sha512-YpzpreB6kUunQBbrlArlsMpXYyndt9JATbt95tajx0t4MTJJcCJdd4hdNpHmOIDiUJrF/oX5wtVFrS3uofWfGw== + dependencies: + "@babel/runtime" "^7.3.1" + csstype "^3.0.2" + is-in-browser "^1.1.3" + tiny-warning "^1.0.2" + +jstree-bootstrap-theme@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/jstree-bootstrap-theme/-/jstree-bootstrap-theme-1.0.1.tgz#7d5edc73a846e8da7f94f57a1cc5ddee9d9eab4b" + integrity sha512-H1F1NOwfPnsQAzsLPRBRR0zO4pfXD5tUHfRj9psT/2+eEMMotG1mYtU3gP5Lsr67TKbsE53M8HLv93EAL+zC2A== + dependencies: + jquery ">=1.9.1" + +jstree@^3.3.12: + version "3.3.12" + resolved "https://registry.yarnpkg.com/jstree/-/jstree-3.3.12.tgz#cf206bc85dcf4a4664ed6617eaae3bd5983d8601" + integrity sha512-vHNLWkUr02ZYH7RcIckvhtLUtneWCVEtIKpIp2G9WtRh01ITv18EoNtNQcFG3ozM+oK6wp1Z300gSLXNQWCqGA== + dependencies: + jquery ">=1.9.1" + +jszip@^3.1.3, jszip@^3.7.1: + version "3.10.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.0.tgz#faf3db2b4b8515425e34effcdbb086750a346061" + integrity sha512-LDfVtOLtOxb9RXkYOwPyNBTQDL4eUbqahtoY6x07GiDJHwSYvn8sHHIw8wINImV3MqbMNve2gSuM1DDqEKk09Q== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + +karma-chrome-launcher@~3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.1.tgz#baca9cc071b1562a1db241827257bfe5cab597ea" + integrity sha512-hsIglcq1vtboGPAN+DGCISCFOxW+ZVnIqhDQcCMqqCp+4dmJ0Qpq5QAjkbA0X2L9Mi6OBkHi2Srrbmm7pUKkzQ== + dependencies: + which "^1.2.1" + +karma-coverage-istanbul-reporter@~3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz#f3b5303553aadc8e681d40d360dfdc19bc7e9fe9" + integrity sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw== + dependencies: + istanbul-lib-coverage "^3.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^3.0.6" + istanbul-reports "^3.0.2" + minimatch "^3.0.4" + +karma-jasmine-html-reporter@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-1.7.0.tgz#52c489a74d760934a1089bfa5ea4a8fcb84cc28b" + integrity sha512-pzum1TL7j90DTE86eFt48/s12hqwQuiD+e5aXx2Dc9wDEn2LfGq6RoAxEZZjFiN0RDSCOnosEKRZWxbQ+iMpQQ== + +karma-jasmine@~4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-4.0.2.tgz#386db2a3e1acc0af5265c711f673f78f1e4938de" + integrity sha512-ggi84RMNQffSDmWSyyt4zxzh2CQGwsxvYYsprgyR1j8ikzIduEdOlcLvXjZGwXG/0j41KUXOWsUCBfbEHPWP9g== + dependencies: + jasmine-core "^3.6.0" + +karma-source-map-support@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz#58526ceccf7e8730e56effd97a4de8d712ac0d6b" + integrity sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A== + dependencies: + source-map-support "^0.5.5" + +karma@~6.3.9: + version "6.3.20" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.20.tgz#12f5c3b0e68f204607cb0a3a78d4751b42ef61a8" + integrity sha512-HRNQhMuKOwKpjYlWiJP0DUrJOh+QjaI/DTaD8b9rEm4Il3tJ8MijutVZH4ts10LuUFst/CedwTS6vieCN8yTSw== + dependencies: + "@colors/colors" "1.5.0" + body-parser "^1.19.0" + braces "^3.0.2" + chokidar "^3.5.1" + connect "^3.7.0" + di "^0.0.1" + dom-serialize "^2.2.1" + glob "^7.1.7" + graceful-fs "^4.2.6" + http-proxy "^1.18.1" + isbinaryfile "^4.0.8" + lodash "^4.17.21" + log4js "^6.4.1" + mime "^2.5.2" + minimatch "^3.0.4" + mkdirp "^0.5.5" + qjobs "^1.2.0" + range-parser "^1.2.1" + rimraf "^3.0.2" + socket.io "^4.4.1" + source-map "^0.6.1" + tmp "^0.2.1" + ua-parser-js "^0.7.30" + yargs "^16.1.1" + +katex@^0.13.0: + version "0.13.24" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.13.24.tgz#fe55455eb455698cb24b911a353d16a3c855d905" + integrity sha512-jZxYuKCma3VS5UuxOx/rFV1QyGSl3Uy/i0kTJF3HgQ5xMinCQVF8Zd4bMY/9aI9b9A2pjIBOsjSSm68ykTAr8w== + dependencies: + commander "^8.0.0" + +killable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" + integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw== + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + +klona@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" + integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== + +leaflet-polylinedecorator@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/leaflet-polylinedecorator/-/leaflet-polylinedecorator-1.6.0.tgz#9ef79fd1b5302d67b72efe959a8ecd2553f27266" + integrity sha512-kn3krmZRetgvN0wjhgYL8kvyLS0tUogAl0vtHuXQnwlYNjbl7aLQpkoFUo8UB8gVZoB0dhI4Tb55VdTJAcYzzQ== + dependencies: + leaflet-rotatedmarker "^0.2.0" + +leaflet-providers@^1.13.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/leaflet-providers/-/leaflet-providers-1.13.0.tgz#10c843a23d5823a65096d40ad53f27029e13434b" + integrity sha512-f/sN5wdgBbVA2jcCYzScIfYNxKdn2wBJP9bu+5cRX9Xj6g8Bt1G9Sr8WgJAt/ckIFIc3LVVxCBNFpSCfTuUElg== + +leaflet-rotatedmarker@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.0.tgz#4467f49f98d1bfd56959bd9c6705203dd2601277" + integrity sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg== + +leaflet.gridlayer.googlemutant@^0.13.5: + version "0.13.5" + resolved "https://registry.yarnpkg.com/leaflet.gridlayer.googlemutant/-/leaflet.gridlayer.googlemutant-0.13.5.tgz#7182c26f479e726bff8b85a7ebf9d3f44dd3ea57" + integrity sha512-DHUEXpo1t0WZ9tpdLUHHkrTK7LldCXr/gqkV5E/4hHidDJB2srceLLCZj4PV/pl0jPdTkVBT+Lr8nL9EZDB5hw== + +leaflet.markercluster@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz#9cdb52a4eab92671832e1ef9899669e80efc4056" + integrity sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA== + +leaflet@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.8.0.tgz#4615db4a22a304e8e692cae9270b983b38a2055e" + integrity sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA== + +less-loader@10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-10.0.1.tgz#c05aaba68d00400820275f21c2ad87cb9fa9923f" + integrity sha512-Crln//HpW9M5CbtdfWm3IO66Cvx1WhZQvNybXgfB2dD/6Sav9ppw+IWqs/FQKPBFO4B6X0X28Z0WNznshgwUzA== + dependencies: + klona "^2.0.4" + +less@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/less/-/less-4.1.1.tgz#15bf253a9939791dc690888c3ff424f3e6c7edba" + integrity sha512-w09o8tZFPThBscl5d0Ggp3RcrKIouBoQscnOMgFH3n5V3kN/CXGHNfCkRPtxJk6nKryDXaV9aHLK55RXuH4sAw== + dependencies: + copy-anything "^2.0.1" + parse-node-version "^1.0.1" + tslib "^1.10.0" + optionalDependencies: + errno "^0.1.1" + graceful-fs "^4.1.2" + image-size "~0.5.0" + make-dir "^2.1.0" + mime "^1.4.1" + needle "^2.5.2" + source-map "~0.6.0" + +libphonenumber-js@^1.10.4: + version "1.10.7" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.7.tgz#4c010b7b57e824c571ea4cdbf7aea6f3c408878c" + integrity sha512-jZXLCCWMe1b/HXkjiLeYt2JsytZMcqH26jLFIdzFDFF0xvSUWrYKyvPlyPG+XJzEyKUFbcZxLdWGMwQsWaHDxQ== + +license-webpack-plugin@2.3.20: + version "2.3.20" + resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.3.20.tgz#f51fb674ca31519dbedbe1c7aabc036e5a7f2858" + integrity sha512-AHVueg9clOKACSHkhmEI+PCC9x8+qsQVuKECZD3ETxETK5h/PCv5/MUzyG1gm8OMcip/s1tcNxqo9Qb7WhjGsg== + dependencies: + "@types/webpack-sources" "^0.1.5" + webpack-sources "^1.2.0" + +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + +lilconfig@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25" + integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +loader-runner@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" + integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== + +loader-utils@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +loader-utils@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" + integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^1.0.1" + +loader-utils@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129" + integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== + +lodash@4.17.21, lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21, lodash@~4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +log4js@^6.4.1: + version "6.6.0" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.6.0.tgz#e8fd00143d1e0ecf1d10959bb69b90b1b30137f3" + integrity sha512-3v8R7fd45UB6THucSht6wN2/7AZEruQbXdjygPZcxt5TA/msO6si9CN5MefUuKXbYnJHTBnYcx4famwcyQd+sA== + dependencies: + date-format "^4.0.11" + debug "^4.3.4" + flatted "^3.2.5" + rfdc "^1.3.0" + streamroller "^3.1.1" + +loglevel@^1.6.8: + version "1.8.0" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114" + integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA== + +loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +magic-string@0.25.7: + version "0.25.7" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + +magic-string@^0.25.0: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + +make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +make-fetch-happen@^9.0.1, make-fetch-happen@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz#53085a09e7971433e6765f7971bf63f4e05cb968" + integrity sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg== + dependencies: + agentkeepalive "^4.1.3" + cacache "^15.2.0" + http-cache-semantics "^4.1.0" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^6.0.0" + minipass "^3.1.3" + minipass-collect "^1.0.2" + minipass-fetch "^1.3.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.2" + promise-retry "^2.0.1" + socks-proxy-agent "^6.0.0" + ssri "^8.0.0" + +make-plural@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-4.3.0.tgz#f23de08efdb0cac2e0c9ba9f315b0dff6b4c2735" + integrity sha512-xTYd4JVHpSCW+aqDof6w/MebaMVNTVYBZhbB/vi513xXdiPT92JMVCo0Jq8W2UZnzYRFeVbQiQ+I25l13JuKvA== + optionalDependencies: + minimist "^1.2.0" + +map-age-cleaner@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w== + dependencies: + object-visit "^1.0.0" + +marked@^4.0.10: + version "4.0.17" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.17.tgz#1186193d85bb7882159cdcfc57d1dfccaffb3fe9" + integrity sha512-Wfk0ATOK5iPxM4ptrORkFemqroz0ZDxp5MWfYA7H/F+wO17NRWV5Ypxi6p3g2Xmw2bKeiYOl6oVnLHKxBA0VhA== + +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +mem@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/mem/-/mem-8.1.1.tgz#cf118b357c65ab7b7e0817bdf00c8062297c0122" + integrity sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA== + dependencies: + map-age-cleaner "^0.1.3" + mimic-fn "^3.1.0" + +memfs@^3.2.2: + version "3.4.7" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.7.tgz#e5252ad2242a724f938cb937e3c4f7ceb1f70e5a" + integrity sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw== + dependencies: + fs-monkey "^1.0.3" + +memory-fs@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + integrity sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ== + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +merge-source-map@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" + integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== + dependencies: + source-map "^0.6.1" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +messageformat-formatters@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/messageformat-formatters/-/messageformat-formatters-2.0.1.tgz#0492c1402a48775f751c9b17c0354e92be012b08" + integrity sha512-E/lQRXhtHwGuiQjI7qxkLp8AHbMD5r2217XNe/SREbBlSawe0lOqsFb7rflZJmlQFSULNLIqlcjjsCPlB3m3Mg== + +messageformat-parser@^4.1.2: + version "4.1.3" + resolved "https://registry.yarnpkg.com/messageformat-parser/-/messageformat-parser-4.1.3.tgz#b824787f57fcda7d50769f5b63e8d4fda68f5b9e" + integrity sha512-2fU3XDCanRqeOCkn7R5zW5VQHWf+T3hH65SzuqRvjatBK7r4uyFa5mEX+k6F9Bd04LVM5G4/BHBTUJsOdW7uyg== + +messageformat@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/messageformat/-/messageformat-2.3.0.tgz#de263c49029d5eae65d7ee25e0754f57f425ad91" + integrity sha512-uTzvsv0lTeQxYI2y1NPa1lItL5VRI8Gb93Y2K2ue5gBPyrbJxfDi/EYWxh2PKv5yO42AJeeqblS9MJSh/IEk4w== + dependencies: + make-plural "^4.3.0" + messageformat-formatters "^2.0.1" + messageformat-parser "^4.1.2" + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.2, micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0, mime@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^2.4.4, mime@^2.5.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-fn@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" + integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== + +mini-css-extract-plugin@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.4.2.tgz#b3508191ea479388a4715018c99dd3e6dd40d2d2" + integrity sha512-ZmqShkn79D36uerdED+9qdo1ZYG8C1YsWvXu0UMJxurZnSdgz7gQKO2EGv8T55MhDqG3DYmGtizZNpM/UbTlcA== + dependencies: + schema-utils "^3.1.0" + +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimatch@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + +minipass-collect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" + integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== + dependencies: + minipass "^3.0.0" + +minipass-fetch@^1.3.0, minipass-fetch@^1.3.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-1.4.1.tgz#d75e0091daac1b0ffd7e9d41629faff7d0c1f1b6" + integrity sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw== + dependencies: + minipass "^3.1.0" + minipass-sized "^1.0.3" + minizlib "^2.0.0" + optionalDependencies: + encoding "^0.1.12" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-json-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" + integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== + dependencies: + jsonparse "^1.3.1" + minipass "^3.0.0" + +minipass-pipeline@^1.2.2, minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0, minipass@^3.1.0, minipass@^3.1.1, minipass@^3.1.3: + version "3.3.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae" + integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw== + dependencies: + yallist "^4.0.0" + +minizlib@^2.0.0, minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3, mkdirp@^1.0.4, mkdirp@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +moment-timezone@*, moment-timezone@^0.5.34: + version "0.5.34" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" + integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.29.1: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + +mousetrap@^1.6.0: + version "1.6.5" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" + integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multicast-dns-service-types@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" + integrity sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ== + +multicast-dns@^6.0.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" + integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== + dependencies: + dns-packet "^1.3.1" + thunky "^1.0.2" + +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + +nan@^2.12.1: + version "2.16.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" + integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA== + +nanoid@^3.1.23, nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +needle@^2.5.2: + version "2.9.1" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684" + integrity sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +negotiator@0.6.3, negotiator@^0.6.2: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +ngrx-store-freeze@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/ngrx-store-freeze/-/ngrx-store-freeze-0.2.4.tgz#146687cdf7e21244eb9003c7e883f2125847076c" + integrity sha512-90awpbbMa/x2H81eWWYniyli3LJ1PZU/FaztL10d9Rp/4kw2+97pqyLjdxSPxcOv9St//m9kfuWZ7gyoVDjgcg== + dependencies: + deep-freeze-strict "^1.1.1" + +ngx-clipboard@^14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/ngx-clipboard/-/ngx-clipboard-14.0.2.tgz#4098b32499a75a41f8c05991f36d30d2a8ec8122" + integrity sha512-zJaFi09D2bq9X1RYvFixE0AfYOI7E8mUO8S0GXcq76HisuE816HRl+8A46G+NML5GPA3Lr5qaGnQJN533So+8g== + dependencies: + ngx-window-token ">=4.0.0 <6.0.0" + tslib "^2.0.0" + +ngx-color-picker@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/ngx-color-picker/-/ngx-color-picker-11.0.0.tgz#c1e5468505953bc579bf21014a135808820ea753" + integrity sha512-HyiFNPYLrCyYbFpLvZJaHC43RhjfDdFDij4pnvk9R46iH1scVtO6f2ibBgxRwBKKsT94KYvOH8wF8OrvztWdEw== + dependencies: + tslib "^2.0.0" + +ngx-daterangepicker-material@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/ngx-daterangepicker-material/-/ngx-daterangepicker-material-5.0.2.tgz#e3869dfc0aa1387616a35b1b67fa56ecae23b319" + integrity sha512-1NV47l5kzvSxwZpMv91hAbTXuvysgCocQeRhsdkk67szeCTgzdXMof3s+jW5uhRiN1+a7NI8X0mHFv8trBjceg== + dependencies: + dayjs "^1.10.4" + tslib "^2.0.0" + +ngx-drag-drop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ngx-drag-drop/-/ngx-drag-drop-2.0.0.tgz#65d970229964803726fb7b9af4aec24005c810c7" + integrity sha512-t+4/eiC8zaXKqU1ruNfFEfGs1GpMNwpffD0baopvZFKjQHCb5rhNqFilJ54wO4T0OwGp4/RnsVhlcxe1mX6UJg== + dependencies: + tslib "^1.9.0" + +"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/1.0.0": + version "0.0.1" + resolved "https://github.com/thingsboard/ngx-flowchart.git#aac96f7e0490a386d58864d7819873e7c63cbb48" + dependencies: + tslib "^2.2.0" + +ngx-hm-carousel@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ngx-hm-carousel/-/ngx-hm-carousel-2.0.1.tgz#29574c5128b26d8520b1ef6499eec7efca4e403f" + integrity sha512-iZxe1SVDkmXsSdZlU5nOtYV7hZGiRTEOij7Esnmc+PVgmMmIZe784uZv1RM/cKb3+hNesHjJuYyyNDkD+ryANw== + dependencies: + "@types/hammerjs" "^2.0.39" + hammerjs "^2.0.8" + tslib "^2.0.0" + +ngx-markdown@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-12.1.0.tgz#2dc92b1a7f3b4c22b043f1b7eca6032dba815cfd" + integrity sha512-Ut+CqLg+3UbYSix3/e+1PJBdFFbH9d9CyPnPUSnh7euVDbVOWw/cy/kwTW7uV3fX6HtTitmstc++nO38MLUr0Q== + dependencies: + "@types/marked" "^4.0.2" + emoji-toolkit "^6.5.0" + katex "^0.13.0" + marked "^4.0.10" + prismjs "^1.23.0" + tslib "^2.1.0" + +ngx-sharebuttons@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/ngx-sharebuttons/-/ngx-sharebuttons-9.0.0.tgz#6919ecb2c77ce6165b443cbc7c44884500186173" + integrity sha512-suoDgXnJwn2EHJzFDvm54KUnW6pGq+APC7VT54HNndwp1lcwXj5QbFgB5Vlk4IfN9VIBOE+IAwdFEdxmIK73Og== + dependencies: + tslib "^2.0.0" + +ngx-translate-messageformat-compiler@^4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/ngx-translate-messageformat-compiler/-/ngx-translate-messageformat-compiler-4.11.0.tgz#c9b71dd139ba5fcdcd809001e22622de589fd707" + integrity sha512-OdGfWV4fF3DhZqGIHcLmOnQDufugmZ+E90NYr1UPGRZgT10lilr9oLmIrisy3lW4THnZFNo9JXsX7+fX84LbDw== + dependencies: + tslib "^1.10.0" + +"ngx-window-token@>=4.0.0 <6.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/ngx-window-token/-/ngx-window-token-5.0.0.tgz#ca63a25038c9fdd73159857276ff67ec7f5b730b" + integrity sha512-DhigCrm9QO8R29lqJYzBC9aaTU0KiWgdnt8RNcTN/DvMaS7shfzAqyvUtxSIm/+JR4gW5JKqR/JODU8760nJMw== + dependencies: + tslib "^2.0.0" + +nice-napi@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" + integrity sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA== + dependencies: + node-addon-api "^3.0.0" + node-gyp-build "^4.2.2" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-addon-api@^3.0.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== + +node-gyp-build@^4.2.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40" + integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg== + +node-gyp@^8.2.0: + version "8.4.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937" + integrity sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.6" + make-fetch-happen "^9.1.0" + nopt "^5.0.0" + npmlog "^6.0.0" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.2" + which "^2.0.2" + +node-releases@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.5.tgz#280ed5bc3eba0d96ce44897d8aee478bfb3d9666" + integrity sha512-U9h1NLROZTq9uE1SNffn6WuPDg8icmi3ns4rEl/oTfIle4iLjTliCzgTsbaIFMq/Xn078/lfY/BL0GWZ+psK4Q== + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w== + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + +npm-bundled@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" + integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ== + dependencies: + npm-normalize-package-bin "^1.0.1" + +npm-install-checks@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-4.0.0.tgz#a37facc763a2fde0497ef2c6d0ac7c3fbe00d7b4" + integrity sha512-09OmyDkNLYwqKPOnbI8exiOZU2GVVmQp7tgez2BPi5OZC8M82elDAps7sxC4l//uSUtotWqoEIDwjRvWH4qz8w== + dependencies: + semver "^7.1.1" + +npm-normalize-package-bin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" + integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== + +npm-package-arg@8.1.5, npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-package-arg@^8.1.2: + version "8.1.5" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-8.1.5.tgz#3369b2d5fe8fdc674baa7f1786514ddc15466e44" + integrity sha512-LhgZrg0n0VgvzVdSm1oiZworPbTxYHUJCgtsJW8mGvlDpxTM1vSJc3m5QZeUkhAHIzbz3VCHd/R4osi1L1Tg/Q== + dependencies: + hosted-git-info "^4.0.1" + semver "^7.3.4" + validate-npm-package-name "^3.0.0" + +npm-packlist@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-3.0.0.tgz#0370df5cfc2fcc8f79b8f42b37798dd9ee32c2a9" + integrity sha512-L/cbzmutAwII5glUcf2DBRNY/d0TFd4e/FnaZigJV6JD85RHZXJFGwCndjMWiiViiWSsWt3tiOLpI3ByTnIdFQ== + dependencies: + glob "^7.1.6" + ignore-walk "^4.0.1" + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + +npm-pick-manifest@6.1.1, npm-pick-manifest@^6.0.0, npm-pick-manifest@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-6.1.1.tgz#7b5484ca2c908565f43b7f27644f36bb816f5148" + integrity sha512-dBsdBtORT84S8V8UTad1WlUyKIY9iMsAmqxHbLdeEeBNMLQDlDWWra3wYUx9EBEIiG/YwAy0XyNHDd2goAsfuA== + dependencies: + npm-install-checks "^4.0.0" + npm-normalize-package-bin "^1.0.1" + npm-package-arg "^8.1.2" + semver "^7.3.4" + +npm-registry-fetch@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-11.0.0.tgz#68c1bb810c46542760d62a6a965f85a702d43a76" + integrity sha512-jmlgSxoDNuhAtxUIG6pVwwtz840i994dL14FoNVZisrmZW5kWd63IUTNv1m/hyRSGSqWjCUp/YZlS1BJyNp9XA== + dependencies: + make-fetch-happen "^9.0.1" + minipass "^3.1.3" + minipass-fetch "^1.3.0" + minipass-json-stream "^1.0.1" + minizlib "^2.0.0" + npm-package-arg "^8.0.0" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw== + dependencies: + path-key "^2.0.0" + +npmlog@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830" + integrity sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg== + dependencies: + are-we-there-yet "^3.0.0" + console-control-strings "^1.1.0" + gauge "^4.0.3" + set-blocking "^2.0.0" + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + integrity sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ== + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@^1.9.0: + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== + +object-is@^1.0.1: + version "1.1.5" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" + integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA== + dependencies: + isobject "^3.0.0" + +object.assign@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ== + dependencies: + isobject "^3.0.1" + +objectpath@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/objectpath/-/objectpath-2.0.0.tgz#c4463123fcf00469be8282a2ea51704fb9469cc1" + integrity sha512-IWH9JOBUJz4HHBtXm1qqwoPiDAB8Qp+ZBE4PpXsOlXVEnxGa+fAgfAZFwN6L1cUYvzPpFeJ1HsY1WAhoOqQq7Q== + +obuf@^1.0.0, obuf@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +open@8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/open/-/open-8.2.1.tgz#82de42da0ccbf429bc12d099dad2e0975e14e8af" + integrity sha512-rXILpcQlkF/QuFez2BJDf3GsqpjGKbkUUToAIGo9A0Q6ZkoSGogZJulrUdwRkrAsoQvoZsrjCYt8+zblOk7JQQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + +open@^7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + +opn@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" + integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== + dependencies: + is-wsl "^1.1.0" + +ora@5.4.1, ora@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +os-tmpdir@~1.0.1, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw== + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + +p-limit@^2.0.0, p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +p-retry@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" + integrity sha512-XE6G4+YTTkT2a0UWb2kjZe8xNwf8bIbnqpc/IS/idOBVhyves0mK5OJgeocjx7q5pvX/6m23xuzVPYT1uGM73w== + dependencies: + retry "^0.12.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +pacote@12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-12.0.2.tgz#14ae30a81fe62ec4fc18c071150e6763e932527c" + integrity sha512-Ar3mhjcxhMzk+OVZ8pbnXdb0l8+pimvlsqBGRNkble2NVgyqOGE3yrCGi/lAYq7E7NRDMz89R1Wx5HIMCGgeYg== + dependencies: + "@npmcli/git" "^2.1.0" + "@npmcli/installed-package-contents" "^1.0.6" + "@npmcli/promise-spawn" "^1.2.0" + "@npmcli/run-script" "^2.0.0" + cacache "^15.0.5" + chownr "^2.0.0" + fs-minipass "^2.1.0" + infer-owner "^1.0.4" + minipass "^3.1.3" + mkdirp "^1.0.3" + npm-package-arg "^8.0.1" + npm-packlist "^3.0.0" + npm-pick-manifest "^6.0.0" + npm-registry-fetch "^11.0.0" + promise-retry "^2.0.1" + read-package-json-fast "^2.0.1" + rimraf "^3.0.2" + ssri "^8.0.1" + tar "^6.1.0" + +pako@^1.0.3, pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse-node-version@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" + integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== + +parse5-html-rewriting-stream@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-6.0.1.tgz#de1820559317ab4e451ea72dba05fddfd914480b" + integrity sha512-vwLQzynJVEfUlURxgnf51yAJDQTtVpNyGD8tKi2Za7m+akukNHxCcUQMAa/mUGLhCeicFdpy7Tlvj8ZNKadprg== + dependencies: + parse5 "^6.0.1" + parse5-sax-parser "^6.0.1" + +parse5-htmlparser2-tree-adapter@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5-sax-parser@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-6.0.1.tgz#98b4d366b5b266a7cd90b4b58906667af882daba" + integrity sha512-kXX+5S81lgESA0LsDuGjAlBybImAChYRMT+/uKCEXFBFOeEhS52qUCydGhU3qLRD8D9DVjaUo821WK7DM4iCeg== + dependencies: + parse5 "^6.0.1" + +parse5@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + +parse5@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + +parseurl@~1.3.2, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw== + +patch-package@^6.4.7: + version "6.4.7" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.4.7.tgz#2282d53c397909a0d9ef92dae3fdeb558382b148" + integrity sha512-S0vh/ZEafZ17hbhgqdnpunKDfzHQibQizx9g8yEf5dcVk3KOflOfdufRXQX8CSEkyOQwuM/bNz1GwKvFj54kaQ== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^2.4.2" + cross-spawn "^6.0.5" + find-yarn-workspace-root "^2.0.0" + fs-extra "^7.0.1" + is-ci "^2.0.0" + klaw-sync "^6.0.0" + minimist "^1.2.0" + open "^7.4.2" + rimraf "^2.6.3" + semver "^5.6.0" + slash "^2.0.0" + tmp "^0.0.33" + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q== + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-is-inside@^1.0.1, path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w== + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== + +path-parse@^1.0.6, path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== + +picocolors@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" + integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw== + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== + +piscina@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/piscina/-/piscina-3.1.0.tgz#2333636865b6cb69c5a370bbc499a98cabcf3e04" + integrity sha512-KTW4sjsCD34MHrUbx9eAAbuUSpVj407hQSgk/6Epkg0pbRBmv4a3UX7Sr8wxm9xYqQLnsN4mFOjqGDzHAdgKQg== + dependencies: + eventemitter-asyncresource "^1.0.0" + hdr-histogram-js "^2.0.1" + hdr-histogram-percentiles-obj "^3.0.0" + optionalDependencies: + nice-napi "^1.0.2" + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +pkg-dir@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + +polygon-clipping@0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.3.tgz#0215840438470ba2e9e6593625e4ea5c1087b4b7" + integrity sha512-ho0Xx5DLkgxRx/+n4O74XyJ67DcyN3Tu9bGYKsnTukGAW6ssnuak6Mwcyb1wHy9MZc9xsUWqIoiazkZB5weECg== + dependencies: + splaytree "^3.1.0" + +popper.js@1.16.1-lts: + version "1.16.1-lts" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05" + integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA== + +portfinder@^1.0.26: + version "1.0.28" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== + dependencies: + async "^2.6.2" + debug "^3.1.1" + mkdirp "^0.5.5" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== + +postcss-attribute-case-insensitive@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.2.tgz#d93e46b504589e94ac7277b0463226c68041a880" + integrity sha512-clkFxk/9pcdb4Vkn0hAHq3YnxBQ2p0CGD1dy24jN+reBck+EWxMbxSUqN4Yj7t0w8csl87K6p0gxBe1utkJsYA== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^6.0.2" + +postcss-calc@^8.2.3: + version "8.2.4" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5" + integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q== + dependencies: + postcss-selector-parser "^6.0.9" + postcss-value-parser "^4.2.0" + +postcss-color-functional-notation@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-functional-notation/-/postcss-color-functional-notation-2.0.1.tgz#5efd37a88fbabeb00a2966d1e53d98ced93f74e0" + integrity sha512-ZBARCypjEDofW4P6IdPVTLhDNXPRn8T2s1zHbZidW6rPaaZvcnCS2soYFIQJrMZSxiePJ2XIYTlcb2ztr/eT2g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-gray@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-color-gray/-/postcss-color-gray-5.0.0.tgz#532a31eb909f8da898ceffe296fdc1f864be8547" + integrity sha512-q6BuRnAGKM/ZRpfDascZlIZPjvwsRye7UDNalqVz3s7GDxMtqPY6+Q871liNxsonUw8oC61OG+PSaysYpl1bnw== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-color-hex-alpha@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-hex-alpha/-/postcss-color-hex-alpha-5.0.3.tgz#a8d9ca4c39d497c9661e374b9c51899ef0f87388" + integrity sha512-PF4GDel8q3kkreVXKLAGNpHKilXsZ6xuu+mOQMHWHLPNyjiUBOr75sp5ZKJfmv1MCus5/DWUGcK9hm6qHEnXYw== + dependencies: + postcss "^7.0.14" + postcss-values-parser "^2.0.1" + +postcss-color-mod-function@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/postcss-color-mod-function/-/postcss-color-mod-function-3.0.3.tgz#816ba145ac11cc3cb6baa905a75a49f903e4d31d" + integrity sha512-YP4VG+xufxaVtzV6ZmhEtc+/aTXH3d0JLpnYfxqTvwZPbJhWqp8bSY3nfNzNRFLgB4XSaBA82OE4VjOOKpCdVQ== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-color-rebeccapurple@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-4.0.1.tgz#c7a89be872bb74e45b1e3022bfe5748823e6de77" + integrity sha512-aAe3OhkS6qJXBbqzvZth2Au4V3KieR5sRQ4ptb2b2O8wgvB3SJBsdG+jsn2BZbbwekDG8nTfcCNKcSfe/lEy8g== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-colormin@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.0.tgz#3cee9e5ca62b2c27e84fce63affc0cfb5901956a" + integrity sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg== + dependencies: + browserslist "^4.16.6" + caniuse-api "^3.0.0" + colord "^2.9.1" + postcss-value-parser "^4.2.0" + +postcss-convert-values@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz#31586df4e184c2e8890e8b34a0b9355313f503ab" + integrity sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g== + dependencies: + browserslist "^4.20.3" + postcss-value-parser "^4.2.0" + +postcss-custom-media@^7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-7.0.8.tgz#fffd13ffeffad73621be5f387076a28b00294e0c" + integrity sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg== + dependencies: + postcss "^7.0.14" + +postcss-custom-properties@^8.0.11: + version "8.0.11" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-8.0.11.tgz#2d61772d6e92f22f5e0d52602df8fae46fa30d97" + integrity sha512-nm+o0eLdYqdnJ5abAJeXp4CEU1c1k+eB2yMCvhgzsds/e0umabFrN6HoTy/8Q4K5ilxERdl/JD1LO5ANoYBeMA== + dependencies: + postcss "^7.0.17" + postcss-values-parser "^2.0.1" + +postcss-custom-selectors@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-custom-selectors/-/postcss-custom-selectors-5.1.2.tgz#64858c6eb2ecff2fb41d0b28c9dd7b3db4de7fba" + integrity sha512-DSGDhqinCqXqlS4R7KGxL1OSycd1lydugJ1ky4iRXPHdBRiozyMHrdu0H3o7qNOCiZwySZTUI5MV0T8QhCLu+w== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-dir-pseudo-class@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-5.0.0.tgz#6e3a4177d0edb3abcc85fdb6fbb1c26dabaeaba2" + integrity sha512-3pm4oq8HYWMZePJY+5ANriPs3P07q+LW6FAdTlkFH2XqDdP4HeeJYMOzn0HYLhRSjBO3fhiqSwwU9xEULSrPgw== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-discard-comments@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696" + integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ== + +postcss-discard-duplicates@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848" + integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw== + +postcss-discard-empty@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c" + integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A== + +postcss-discard-overridden@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e" + integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw== + +postcss-double-position-gradients@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postcss-double-position-gradients/-/postcss-double-position-gradients-1.0.0.tgz#fc927d52fddc896cb3a2812ebc5df147e110522e" + integrity sha512-G+nV8EnQq25fOI8CH/B6krEohGWnF5+3A6H/+JEpOncu5dCnkS1QQ6+ct3Jkaepw1NGVqqOZH6lqrm244mCftA== + dependencies: + postcss "^7.0.5" + postcss-values-parser "^2.0.0" + +postcss-env-function@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-env-function/-/postcss-env-function-2.0.2.tgz#0f3e3d3c57f094a92c2baf4b6241f0b0da5365d7" + integrity sha512-rwac4BuZlITeUbiBq60h/xbLzXY43qOsIErngWa4l7Mt+RaSkT7QBjXVGTcBHupykkblHMDrBFh30zchYPaOUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-focus-visible@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-visible/-/postcss-focus-visible-4.0.0.tgz#477d107113ade6024b14128317ade2bd1e17046e" + integrity sha512-Z5CkWBw0+idJHSV6+Bgf2peDOFf/x4o+vX/pwcNYrWpXFrSfTkQ3JQ1ojrq9yS+upnAlNRHeg8uEwFTgorjI8g== + dependencies: + postcss "^7.0.2" + +postcss-focus-within@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-focus-within/-/postcss-focus-within-3.0.0.tgz#763b8788596cee9b874c999201cdde80659ef680" + integrity sha512-W0APui8jQeBKbCGZudW37EeMCjDeVxKgiYfIIEo8Bdh5SpB9sxds/Iq8SEuzS0Q4YFOlG7EPFulbbxujpkrV2w== + dependencies: + postcss "^7.0.2" + +postcss-font-variant@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-font-variant/-/postcss-font-variant-4.0.1.tgz#42d4c0ab30894f60f98b17561eb5c0321f502641" + integrity sha512-I3ADQSTNtLTTd8uxZhtSOrTCQ9G4qUVKPjHiDk0bV75QSxXjVWiJVJ2VLdspGUi9fbW9BcjKJoRvxAH1pckqmA== + dependencies: + postcss "^7.0.2" + +postcss-gap-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-gap-properties/-/postcss-gap-properties-2.0.0.tgz#431c192ab3ed96a3c3d09f2ff615960f902c1715" + integrity sha512-QZSqDaMgXCHuHTEzMsS2KfVDOq7ZFiknSpkrPJY6jmxbugUPTuSzs/vuE5I3zv0WAS+3vhrlqhijiprnuQfzmg== + dependencies: + postcss "^7.0.2" + +postcss-image-set-function@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/postcss-image-set-function/-/postcss-image-set-function-3.0.1.tgz#28920a2f29945bed4c3198d7df6496d410d3f288" + integrity sha512-oPTcFFip5LZy8Y/whto91L9xdRHCWEMs3e1MdJxhgt4jy2WYXfhkng59fH5qLXSCPN8k4n94p1Czrfe5IOkKUw== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-import@14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.0.2.tgz#60eff77e6be92e7b67fe469ec797d9424cae1aa1" + integrity sha512-BJ2pVK4KhUyMcqjuKs9RijV5tatNzNa73e/32aBVE/ejYPe37iH+6vAu9WvqUkB5OAYgLHzbSvzHnorybJCm9g== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-initial@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/postcss-initial/-/postcss-initial-3.0.4.tgz#9d32069a10531fe2ecafa0b6ac750ee0bc7efc53" + integrity sha512-3RLn6DIpMsK1l5UUy9jxQvoDeUN4gP939tDcKUHD/kM8SGSKbFAnvkpFpj3Bhtz3HGk1jWY5ZNWX6mPta5M9fg== + dependencies: + postcss "^7.0.2" + +postcss-lab-function@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-lab-function/-/postcss-lab-function-2.0.1.tgz#bb51a6856cd12289ab4ae20db1e3821ef13d7d2e" + integrity sha512-whLy1IeZKY+3fYdqQFuDBf8Auw+qFuVnChWjmxm/UhHWqNHZx+B99EwxTvGYmUBqe3Fjxs4L1BoZTJmPu6usVg== + dependencies: + "@csstools/convert-colors" "^1.4.0" + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-loader@6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-6.1.1.tgz#58dd0a3accd9bc87cc52eff75244db578d11301a" + integrity sha512-lBmJMvRh1D40dqpWKr9Rpygwxn8M74U9uaCSeYGNKLGInbk9mXBt1ultHf2dH9Ghk6Ue4UXlXWwGMH9QdUJ5ug== + dependencies: + cosmiconfig "^7.0.0" + klona "^2.0.4" + semver "^7.3.5" + +postcss-logical@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-logical/-/postcss-logical-3.0.0.tgz#2495d0f8b82e9f262725f75f9401b34e7b45d5b5" + integrity sha512-1SUKdJc2vuMOmeItqGuNaC+N8MzBWFWEkAnRnLpFYj1tGGa7NqyVBujfRtgNa2gXR+6RkGUiB2O5Vmh7E2RmiA== + dependencies: + postcss "^7.0.2" + +postcss-media-minmax@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-media-minmax/-/postcss-media-minmax-4.0.0.tgz#b75bb6cbc217c8ac49433e12f22048814a4f5ed5" + integrity sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw== + dependencies: + postcss "^7.0.2" + +postcss-merge-longhand@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz#f378a8a7e55766b7b644f48e5d8c789ed7ed51ce" + integrity sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw== + dependencies: + postcss-value-parser "^4.2.0" + stylehacks "^5.1.0" + +postcss-merge-rules@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz#7049a14d4211045412116d79b751def4484473a5" + integrity sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ== + dependencies: + browserslist "^4.16.6" + caniuse-api "^3.0.0" + cssnano-utils "^3.1.0" + postcss-selector-parser "^6.0.5" + +postcss-minify-font-values@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz#f1df0014a726083d260d3bd85d7385fb89d1f01b" + integrity sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-minify-gradients@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c" + integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw== + dependencies: + colord "^2.9.1" + cssnano-utils "^3.1.0" + postcss-value-parser "^4.2.0" + +postcss-minify-params@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz#ac41a6465be2db735099bbd1798d85079a6dc1f9" + integrity sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg== + dependencies: + browserslist "^4.16.6" + cssnano-utils "^3.1.0" + postcss-value-parser "^4.2.0" + +postcss-minify-selectors@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6" + integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg== + dependencies: + postcss-selector-parser "^6.0.5" + +postcss-modules-extract-imports@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" + integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== + +postcss-modules-local-by-default@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" + integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== + dependencies: + icss-utils "^5.0.0" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" + +postcss-modules-scope@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" + integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== + dependencies: + postcss-selector-parser "^6.0.4" + +postcss-modules-values@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" + integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== + dependencies: + icss-utils "^5.0.0" + +postcss-nesting@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-7.0.1.tgz#b50ad7b7f0173e5b5e3880c3501344703e04c052" + integrity sha512-FrorPb0H3nuVq0Sff7W2rnc3SmIcruVC6YwpcS+k687VxyxO33iE1amna7wHuRVzM8vfiYofXSBHNAZ3QhLvYg== + dependencies: + postcss "^7.0.2" + +postcss-normalize-charset@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz#9302de0b29094b52c259e9b2cf8dc0879879f0ed" + integrity sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg== + +postcss-normalize-display-values@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz#72abbae58081960e9edd7200fcf21ab8325c3da8" + integrity sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-positions@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92" + integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-repeat-style@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2" + integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-string@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz#411961169e07308c82c1f8c55f3e8a337757e228" + integrity sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-timing-functions@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz#d5614410f8f0b2388e9f240aa6011ba6f52dafbb" + integrity sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-normalize-unicode@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz#3d23aede35e160089a285e27bf715de11dc9db75" + integrity sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ== + dependencies: + browserslist "^4.16.6" + postcss-value-parser "^4.2.0" + +postcss-normalize-url@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz#ed9d88ca82e21abef99f743457d3729a042adcdc" + integrity sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew== + dependencies: + normalize-url "^6.0.1" + postcss-value-parser "^4.2.0" + +postcss-normalize-whitespace@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa" + integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-ordered-values@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38" + integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ== + dependencies: + cssnano-utils "^3.1.0" + postcss-value-parser "^4.2.0" + +postcss-overflow-shorthand@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-overflow-shorthand/-/postcss-overflow-shorthand-2.0.0.tgz#31ecf350e9c6f6ddc250a78f0c3e111f32dd4c30" + integrity sha512-aK0fHc9CBNx8jbzMYhshZcEv8LtYnBIRYQD5i7w/K/wS9c2+0NSR6B3OVMu5y0hBHYLcMGjfU+dmWYNKH0I85g== + dependencies: + postcss "^7.0.2" + +postcss-page-break@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-page-break/-/postcss-page-break-2.0.0.tgz#add52d0e0a528cabe6afee8b46e2abb277df46bf" + integrity sha512-tkpTSrLpfLfD9HvgOlJuigLuk39wVTbbd8RKcy8/ugV2bNBUW3xU+AIqyxhDrQr1VUj1RmyJrBn1YWrqUm9zAQ== + dependencies: + postcss "^7.0.2" + +postcss-place@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-place/-/postcss-place-4.0.1.tgz#e9f39d33d2dc584e46ee1db45adb77ca9d1dcc62" + integrity sha512-Zb6byCSLkgRKLODj/5mQugyuj9bvAAw9LqJJjgwz5cYryGeXfFZfSXoP1UfveccFmeq0b/2xxwcTEVScnqGxBg== + dependencies: + postcss "^7.0.2" + postcss-values-parser "^2.0.0" + +postcss-preset-env@6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-6.7.0.tgz#c34ddacf8f902383b35ad1e030f178f4cdf118a5" + integrity sha512-eU4/K5xzSFwUFJ8hTdTQzo2RBLbDVt83QZrAvI07TULOkmyQlnYlpwep+2yIK+K+0KlZO4BvFcleOCCcUtwchg== + dependencies: + autoprefixer "^9.6.1" + browserslist "^4.6.4" + caniuse-lite "^1.0.30000981" + css-blank-pseudo "^0.1.4" + css-has-pseudo "^0.10.0" + css-prefers-color-scheme "^3.1.1" + cssdb "^4.4.0" + postcss "^7.0.17" + postcss-attribute-case-insensitive "^4.0.1" + postcss-color-functional-notation "^2.0.1" + postcss-color-gray "^5.0.0" + postcss-color-hex-alpha "^5.0.3" + postcss-color-mod-function "^3.0.3" + postcss-color-rebeccapurple "^4.0.1" + postcss-custom-media "^7.0.8" + postcss-custom-properties "^8.0.11" + postcss-custom-selectors "^5.1.2" + postcss-dir-pseudo-class "^5.0.0" + postcss-double-position-gradients "^1.0.0" + postcss-env-function "^2.0.2" + postcss-focus-visible "^4.0.0" + postcss-focus-within "^3.0.0" + postcss-font-variant "^4.0.0" + postcss-gap-properties "^2.0.0" + postcss-image-set-function "^3.0.1" + postcss-initial "^3.0.0" + postcss-lab-function "^2.0.1" + postcss-logical "^3.0.0" + postcss-media-minmax "^4.0.0" + postcss-nesting "^7.0.0" + postcss-overflow-shorthand "^2.0.0" + postcss-page-break "^2.0.0" + postcss-place "^4.0.1" + postcss-pseudo-class-any-link "^6.0.0" + postcss-replace-overflow-wrap "^3.0.0" + postcss-selector-matches "^4.0.0" + postcss-selector-not "^4.0.0" + +postcss-pseudo-class-any-link@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-6.0.0.tgz#2ed3eed393b3702879dec4a87032b210daeb04d1" + integrity sha512-lgXW9sYJdLqtmw23otOzrtbDXofUdfYzNm4PIpNE322/swES3VU9XlXHeJS46zT2onFO7V1QFdD4Q9LiZj8mew== + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^5.0.0-rc.3" + +postcss-reduce-initial@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz#fc31659ea6e85c492fb2a7b545370c215822c5d6" + integrity sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw== + dependencies: + browserslist "^4.16.6" + caniuse-api "^3.0.0" + +postcss-reduce-transforms@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz#333b70e7758b802f3dd0ddfe98bb1ccfef96b6e9" + integrity sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-replace-overflow-wrap@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-3.0.0.tgz#61b360ffdaedca84c7c918d2b0f0d0ea559ab01c" + integrity sha512-2T5hcEHArDT6X9+9dVSPQdo7QHzG4XKclFT8rU5TzJPDN7RIRTbO9c4drUISOVemLj03aezStHCR2AIcr8XLpw== + dependencies: + postcss "^7.0.2" + +postcss-selector-matches@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-matches/-/postcss-selector-matches-4.0.0.tgz#71c8248f917ba2cc93037c9637ee09c64436fcff" + integrity sha512-LgsHwQR/EsRYSqlwdGzeaPKVT0Ml7LAT6E75T8W8xLJY62CE4S/l03BWIt3jT8Taq22kXP08s2SfTSzaraoPww== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-not@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.1.tgz#263016eef1cf219e0ade9a913780fc1f48204cbf" + integrity sha512-YolvBgInEK5/79C+bdFMyzqTg6pkYqDbzZIST/PDMqa/o3qtXenD05apBG2jLgT0/BQ77d4U2UK12jWpilqMAQ== + dependencies: + balanced-match "^1.0.0" + postcss "^7.0.2" + +postcss-selector-parser@^5.0.0-rc.3, postcss-selector-parser@^5.0.0-rc.4: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" + integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + dependencies: + cssesc "^2.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: + version "6.0.10" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-svgo@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d" + integrity sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA== + dependencies: + postcss-value-parser "^4.2.0" + svgo "^2.7.0" + +postcss-unique-selectors@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6" + integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA== + dependencies: + postcss-selector-parser "^6.0.5" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss-values-parser@^2.0.0, postcss-values-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/postcss-values-parser/-/postcss-values-parser-2.0.1.tgz#da8b472d901da1e205b47bdc98637b9e9e550e5f" + integrity sha512-2tLuBsA6P4rYTNKCXYG/71C7j1pU6pK503suYOmn4xYrQIzW+opD+7FAFNuGSdZC/3Qfy334QbeMu7MEb8gOxg== + dependencies: + flatten "^1.0.2" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss@8.3.6: + version "8.3.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.6.tgz#2730dd76a97969f37f53b9a6096197be311cc4ea" + integrity sha512-wG1cc/JhRgdqB6WHEuyLTedf3KIRuD0hG6ldkFEZNCjRxiC+3i6kkWUUbiJQayP28iwG35cEmAbe98585BYV0A== + dependencies: + colorette "^1.2.2" + nanoid "^3.1.23" + source-map-js "^0.6.2" + +postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.32, postcss@^7.0.35, postcss@^7.0.5, postcss@^7.0.6: + version "7.0.39" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309" + integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA== + dependencies: + picocolors "^0.2.1" + source-map "^0.6.1" + +postcss@^8.2.15, postcss@^8.3.5, postcss@^8.3.7: + version "8.4.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" + integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +postinstall-prepare@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postinstall-prepare/-/postinstall-prepare-2.0.0.tgz#2a6867c1a13a05502aa115d0495efbbd778769cb" + integrity sha512-lLFwEKdnGLAaRAm8OpXP6HwrXRW+b8Hh9vRhVHZKmCdobd+D21YM38BCDsi3zrePLSe8Tt0H/mbYkh7/ySQQMg== + +prettier@^2.5.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== + +pretty-bytes@^5.3.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== + +prismjs@^1.23.0, prismjs@^1.27.0: + version "1.28.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.28.0.tgz#0d8f561fa0f7cf6ebca901747828b149147044b6" + integrity sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== + +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== + dependencies: + err-code "^2.0.2" + retry "^0.12.0" + +prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +protractor@~7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/protractor/-/protractor-7.0.0.tgz#c3e263608bd72e2c2dc802b11a772711a4792d03" + integrity sha512-UqkFjivi4GcvUQYzqGYNe0mLzfn5jiLmO8w9nMhQoJRLhy2grJonpga2IWhI6yJO30LibWXJJtA4MOIZD2GgZw== + dependencies: + "@types/q" "^0.0.32" + "@types/selenium-webdriver" "^3.0.0" + blocking-proxy "^1.0.0" + browserstack "^1.5.1" + chalk "^1.1.3" + glob "^7.0.3" + jasmine "2.8.0" + jasminewd2 "^2.1.0" + q "1.4.1" + saucelabs "^1.5.0" + selenium-webdriver "3.6.0" + source-map-support "~0.4.0" + webdriver-js-extender "2.1.0" + webdriver-manager "^12.1.7" + yargs "^15.3.1" + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw== + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== + +psl@^1.1.28: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw== + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +q@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" + integrity sha512-/CdEdaw49VZVmyIDGUQKDDT53c7qBkO6g5CefWz91Ae+l4+cRtcDYwMTXh6me4O8TMldeGHG3N2Bl84V78Ywbg== + +q@^1.4.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== + +qjobs@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" + integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== + +qrcode@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b" + integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + +qs@6.10.3: + version "6.10.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== + dependencies: + side-channel "^1.0.4" + +qs@~6.5.2: + version "6.5.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" + integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +quickselect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" + integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.1, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raphael@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/raphael/-/raphael-2.3.0.tgz#eabeb09dba861a1d4cee077eaafb8c53f3131f89" + integrity sha512-w2yIenZAQnp257XUWGni4bLMVxpUpcIl7qgxEgDIXtmSypYtlNxfXWpOBxs7LBTps5sDwhRnrToJrMUrivqNTQ== + dependencies: + eve-raphael "0.5.0" + +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +raw-loader@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" + integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + +rbush@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf" + integrity sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w== + dependencies: + quickselect "^2.0.0" + +rc-align@^4.0.0: + version "4.0.12" + resolved "https://registry.yarnpkg.com/rc-align/-/rc-align-4.0.12.tgz#065b5c68a1cc92a00800c9239320d9fdf5f16207" + integrity sha512-3DuwSJp8iC/dgHzwreOQl52soj40LchlfUHtgACOUtwGuoFIOVh6n/sCpfqCU8kO5+iz6qR0YKvjgB8iPdE3aQ== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "2.x" + dom-align "^1.7.0" + lodash "^4.17.21" + rc-util "^5.3.0" + resize-observer-polyfill "^1.5.1" + +rc-motion@^2.0.0, rc-motion@^2.0.1: + version "2.6.0" + resolved "https://registry.yarnpkg.com/rc-motion/-/rc-motion-2.6.0.tgz#c60c3e7f15257f55a8cd7794a539f0e2cc751399" + integrity sha512-1MDWA9+i174CZ0SIDenSYm2Wb9YbRkrexjZWR0CUFu7D6f23E8Y0KsTgk9NGOLJsGak5ELZK/Y5lOlf5wQdzbw== + dependencies: + "@babel/runtime" "^7.11.1" + classnames "^2.2.1" + rc-util "^5.21.0" + +rc-overflow@^1.0.0: + version "1.2.6" + resolved "https://registry.yarnpkg.com/rc-overflow/-/rc-overflow-1.2.6.tgz#e99fabea04ce4fb13f0dd8835aef4e4cdd4c15a2" + integrity sha512-YqbocgzuQxfq2wZy72vdAgrgzzEuM/5d4gF9TBEodCpXPbUeXGrUXNm1J6G1MSkCU2N0ePIgCEu5qD/0Ldi63Q== + dependencies: + "@babel/runtime" "^7.11.1" + classnames "^2.2.1" + rc-resize-observer "^1.0.0" + rc-util "^5.19.2" + +rc-resize-observer@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/rc-resize-observer/-/rc-resize-observer-1.2.0.tgz#9f46052f81cdf03498be35144cb7c53fd282c4c7" + integrity sha512-6W+UzT3PyDM0wVCEHfoW3qTHPTvbdSgiA43buiy8PzmeMnfgnDeb9NjdimMXMl3/TcrvvWl5RRVdp+NqcR47pQ== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.1" + rc-util "^5.15.0" + resize-observer-polyfill "^1.5.1" + +rc-select@13.2.1: + version "13.2.1" + resolved "https://registry.yarnpkg.com/rc-select/-/rc-select-13.2.1.tgz#d69675f8bc72622a8f3bc024fa21bfee8d56257d" + integrity sha512-L2cJFAjVEeDiNVa/dlOVKE79OUb0J7sUBvWN3Viav3XHcjvv9Ovn4D8J9QhBSlDXeGuczZ81CZI3BbdHD25+Gg== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "2.x" + rc-motion "^2.0.1" + rc-overflow "^1.0.0" + rc-trigger "^5.0.4" + rc-util "^5.9.8" + rc-virtual-list "^3.2.0" + +rc-trigger@^5.0.4: + version "5.3.1" + resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-5.3.1.tgz#acafadf3eaf384e7f466c303bfa0f34c8137d7b8" + integrity sha512-5gaFbDkYSefZ14j2AdzucXzlWgU2ri5uEjkHvsf1ynRhdJbKxNOnw4PBZ9+FVULNGFiDzzlVF8RJnR9P/xrnKQ== + dependencies: + "@babel/runtime" "^7.18.3" + classnames "^2.2.6" + rc-align "^4.0.0" + rc-motion "^2.0.0" + rc-util "^5.19.2" + +rc-util@^5.15.0, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.3.0, rc-util@^5.9.8: + version "5.22.5" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.22.5.tgz#d4d6d886c5ecb6a2a51dde1840d780a2b70f5179" + integrity sha512-awD2TGMGU97OZftT2R3JwrHWjR8k/xIwqjwcivPskciweUdgXE7QsyXkBKVSBHXS+c17AWWMDWuKWsJSheQy8g== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^16.12.0" + shallowequal "^1.1.0" + +rc-virtual-list@^3.2.0: + version "3.4.8" + resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.4.8.tgz#c24c10c6940546b7e2a5e9809402c6716adfd26c" + integrity sha512-qSN+Rv4i/E7RCTvTMr1uZo7f3crJJg/5DekoCagydo9zsXrxj07zsFSxqizqW+ldGA16lwa8So/bIbV9Ofjddg== + dependencies: + classnames "^2.2.6" + rc-resize-observer "^1.0.0" + rc-util "^5.15.0" + +react-ace@9.5.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-9.5.0.tgz#b6c32b70d404dd821a7e01accc2d76da667ff1f7" + integrity sha512-4l5FgwGh6K7A0yWVMQlPIXDItM4Q9zzXRqOae8KkCl6MkOob7sC1CzHxZdOGvV+QioKWbX2p5HcdOVUv6cAdSg== + dependencies: + ace-builds "^1.4.13" + diff-match-patch "^1.0.5" + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + prop-types "^15.7.2" + +react-dom@17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + +react-dropzone@^11.4.2: + version "11.7.1" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.7.1.tgz#3851bb75b26af0bf1b17ce1449fd980e643b9356" + integrity sha512-zxCMwhfPy1olUEbw3FLNPLhAm/HnaYH5aELIEglRbqabizKAdHs0h+WuyOpmA+v1JXn0++fpQDdNfUagWt5hJQ== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.4.0" + prop-types "^15.8.1" + +react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +"react-is@^16.8.0 || ^17.0.0": + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-transition-group@^4.0.0, react-transition-group@^4.4.0: + version "4.4.2" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" + integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + +react@17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +reactcss@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +read-package-json-fast@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz#323ca529630da82cb34b36cc0b996693c98c2b83" + integrity sha512-W/BKtbL+dUjTuRL2vziuYhp76s5HZ9qQhd/dKfWIZveD0O40453QNyZhC0e63lqZrAQ4jiOapVoeJ7JrszenQQ== + dependencies: + json-parse-even-better-errors "^2.3.0" + npm-normalize-package-bin "^1.0.1" + +readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.0.6, readable-stream@^3.4.0, readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +reduce-flatten@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27" + integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w== + +reflect-metadata@^0.1.2: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + +regenerate-unicode-properties@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" + integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw== + dependencies: + regenerate "^1.4.2" + +regenerate@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" + integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== + +regenerator-runtime@0.13.9, regenerator-runtime@^0.13.4: + version "0.13.9" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" + integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== + +regenerator-transform@^0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" + integrity sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg== + dependencies: + "@babel/runtime" "^7.8.4" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regex-parser@^2.2.11: + version "2.2.11" + resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.11.tgz#3b37ec9049e19479806e878cabe7c1ca83ccfe58" + integrity sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q== + +regexp.prototype.flags@^1.2.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" + integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + functions-have-names "^1.2.2" + +regexpu-core@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d" + integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA== + dependencies: + regenerate "^1.4.2" + regenerate-unicode-properties "^10.0.1" + regjsgen "^0.6.0" + regjsparser "^0.8.2" + unicode-match-property-ecmascript "^2.0.0" + unicode-match-property-value-ecmascript "^2.0.0" + +regjsgen@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d" + integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA== + +regjsparser@^0.8.2: + version "0.8.4" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f" + integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA== + dependencies: + jsesc "~0.5.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw== + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + +request@^2.87.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha512-ccu8zQTrzVr954472aUVPLEcB3YpKSYR3cg/3lo1okzobPBM+1INXBbBZlDbnI/hbEocnf8j0QVo43hQKrbchg== + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-url-loader@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz#d50d4ddc746bb10468443167acf800dcd6c3ad57" + integrity sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA== + dependencies: + adjust-sourcemap-loader "^4.0.0" + convert-source-map "^1.7.0" + loader-utils "^2.0.0" + postcss "^7.0.35" + source-map "0.6.1" + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== + +resolve@1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +resolve@^1.1.7, resolve@^1.14.2, resolve@^1.3.2: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rfdc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + +rifm@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.7.0.tgz#debe951a9c83549ca6b33e5919f716044c2230be" + integrity sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ== + dependencies: + "@babel/runtime" "^7.3.1" + +rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@6.6.7, rxjs@^6.5.3, rxjs@~6.6.7: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + +rxjs@^7.2.0: + version "7.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f" + integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw== + dependencies: + tslib "^2.1.0" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg== + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@^2.1.2, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sass-loader@12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-12.1.0.tgz#b73324622231009da6fba61ab76013256380d201" + integrity sha512-FVJZ9kxVRYNZTIe2xhw93n3xJNYZADr+q69/s98l9nTCrWASo+DR2Ot0s5xTKQDDEosUkatsGeHxcH4QBp5bSg== + dependencies: + klona "^2.0.4" + neo-async "^2.6.2" + +sass@1.36.0: + version "1.36.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.36.0.tgz#5912ef9d5d16714171ba11cb17edb274c4bbc07e" + integrity sha512-fQzEjipfOv5kh930nu3Imzq3ie/sGDc/4KtQMJlt7RRdrkQSfe37Bwi/Rf/gfuYHsIuE1fIlDMvpyMcEwjnPvg== + dependencies: + chokidar ">=3.0.0 <4.0.0" + +saucelabs@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/saucelabs/-/saucelabs-1.5.0.tgz#9405a73c360d449b232839919a86c396d379fd9d" + integrity sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ== + dependencies: + https-proxy-agent "^2.2.1" + +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +schema-inspector@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/schema-inspector/-/schema-inspector-2.0.1.tgz#6f3ae9763414439bf30b17b7fc53553884d489ef" + integrity sha512-lqR4tOVfoqf9Z8cgX/zvXuWPnTWCqrc4WSgeSPDDc1bWbMABaqdSTY98xj7iRKHOIRtKjc4M8EWCgUu5ASlHkg== + dependencies: + async "~2.6.3" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +schema-utils@^2.6.5, schema-utils@^2.7.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" + integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== + dependencies: + "@types/json-schema" "^7.0.5" + ajv "^6.12.4" + ajv-keywords "^3.5.2" + +schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" + integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +schema-utils@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.0.tgz#60331e9e3ae78ec5d16353c467c34b3a0a1d3df7" + integrity sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.8.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.0.0" + +screenfull@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.2.tgz#3dbe4b8c4f8f49fb8e33caa8f69d0bca730ab238" + integrity sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw== + +select-hose@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" + integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== + +selenium-webdriver@3.6.0, selenium-webdriver@^3.0.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-3.6.0.tgz#2ba87a1662c020b8988c981ae62cb2a01298eafc" + integrity sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q== + dependencies: + jszip "^3.1.3" + rimraf "^2.5.4" + tmp "0.0.30" + xml2js "^0.4.17" + +selfsigned@^1.10.8: + version "1.10.14" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.14.tgz#ee51d84d9dcecc61e07e4aba34f229ab525c1574" + integrity sha512-lkjaiAye+wBZDCBsu5BGi0XiLRxeUlsGod5ZP924CRSEoGuZAw/f7y9RKu28rwTfiHVhdavhB0qH0INV6P1lEA== + dependencies: + node-forge "^0.10.0" + +semver-dsl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/semver-dsl/-/semver-dsl-1.0.1.tgz#d3678de5555e8a61f629eed025366ae5f27340a0" + integrity sha512-e8BOaTo007E3dMuQQTnPdalbKTABKNS7UxoBIDnwOqRa+QwMrCPjynB8zAlPF6xlqUfdLPPLIJ13hJNmhtq8Ng== + dependencies: + semver "^5.3.0" + +semver@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + +semver@7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + +semver@^5.3.0, semver@^5.5.0, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@^7.0.0, semver@^7.1.1, semver@^7.3.4, semver@^7.3.5: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + dependencies: + lru-cache "^6.0.0" + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serialize-javascript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +serve-index@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" + integrity sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw== + dependencies: + accepts "~1.3.4" + batch "0.6.1" + debug "2.6.9" + escape-html "~1.0.3" + http-errors "~1.6.2" + mime-types "~2.1.17" + parseurl "~1.3.2" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + +shallowequal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +sigmund@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== + +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +socket.io-adapter@~2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz#b50a4a9ecdd00c34d4c8c808224daa1a786152a6" + integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg== + +socket.io-parser@~4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.5.tgz#cb404382c32324cc962f27f3a44058cf6e0552df" + integrity sha512-sNjbT9dX63nqUFIOv95tTVm6elyIU4RvB1m8dOeZt+IgWwcWklFDOdmGcfo3zSiRsnR/3pJkjY5lfoGqEe4Eig== + dependencies: + "@types/component-emitter" "^1.2.10" + component-emitter "~1.3.0" + debug "~4.3.1" + +socket.io@^4.4.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.5.1.tgz#aa7e73f8a6ce20ee3c54b2446d321bbb6b1a9029" + integrity sha512-0y9pnIso5a9i+lJmsCdtmTTgJFFSvNQKDnPQRz28mGNnxbmqYg2QPtJTLFxhymFZhAIn50eHAKzJeiNaKr+yUQ== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + debug "~4.3.2" + engine.io "~6.2.0" + socket.io-adapter "~2.4.0" + socket.io-parser "~4.0.4" + +sockjs-client@^1.5.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.6.1.tgz#350b8eda42d6d52ddc030c39943364c11dcad806" + integrity sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw== + dependencies: + debug "^3.2.7" + eventsource "^2.0.2" + faye-websocket "^0.11.4" + inherits "^2.0.4" + url-parse "^1.5.10" + +sockjs@^0.3.21: + version "0.3.24" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" + integrity sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ== + dependencies: + faye-websocket "^0.11.3" + uuid "^8.3.2" + websocket-driver "^0.7.4" + +socks-proxy-agent@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz#2687a31f9d7185e38d530bef1944fe1f1496d6ce" + integrity sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ== + dependencies: + agent-base "^6.0.2" + debug "^4.3.3" + socks "^2.6.2" + +socks@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" + integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== + dependencies: + ip "^1.1.5" + smart-buffer "^4.2.0" + +source-list-map@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" + integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== + +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +source-map-loader@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-3.0.0.tgz#f2a04ee2808ad01c774dea6b7d2639839f3b3049" + integrity sha512-GKGWqWvYr04M7tn8dryIWvb0s8YM41z82iQv01yBtIylgxax0CwvSy6gc2Y02iuXwEfGWRlMicH0nvms9UZphw== + dependencies: + abab "^2.0.5" + iconv-lite "^0.6.2" + source-map-js "^0.6.2" + +source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-support@^0.5.5, source-map-support@~0.5.19, source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-support@~0.4.0: + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" + integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== + dependencies: + source-map "^0.5.6" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + +source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + +source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + +source-map@^0.7.3, source-map@~0.7.2: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== + +sourcemap-codec@1.4.8, sourcemap-codec@^1.4.4, sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +spdy-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" + integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== + dependencies: + debug "^4.1.0" + detect-node "^2.0.4" + hpack.js "^2.1.6" + obuf "^1.1.2" + readable-stream "^3.0.6" + wbuf "^1.7.3" + +spdy@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" + integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + +splaytree@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.1.1.tgz#e1bc8e68e64ef5a9d5f09d36e6d9f3621795a438" + integrity sha512-9FaQ18FF0+sZc/ieEeXHt+Jw2eSpUgUtTLDYB/HXKWvhYVyOc7h1hzkn5MMO3GPib9MmXG1go8+OsBBzs/NMww== + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +split.js@^1.6.4: + version "1.6.5" + resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300" + integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw== + +sprintf-js@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" + integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +sshpk@^1.7.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" + integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +ssri@^8.0.0, ssri@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" + integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== + dependencies: + minipass "^3.1.1" + +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g== + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.4.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +streamroller@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.1.tgz#679aae10a4703acdf2740755307df0a05ad752e6" + integrity sha512-iPhtd9unZ6zKdWgMeYGfSBuqCngyJy1B/GPi/lTpwGpa3bajuX30GjUVd0/Tn/Xhg0mr4DOSENozz9Y06qyonQ== + dependencies: + date-format "^4.0.10" + debug "^4.3.4" + fs-extra "^10.1.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== + +style-loader@3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.2.1.tgz#63cb920ec145c8669e9a50e92961452a1ef5dcde" + integrity sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg== + +stylehacks@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.0.tgz#a40066490ca0caca04e96c6b02153ddc39913520" + integrity sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q== + dependencies: + browserslist "^4.16.6" + postcss-selector-parser "^6.0.4" + +stylus-loader@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/stylus-loader/-/stylus-loader-6.1.0.tgz#7a3a719a27cb2b9617896d6da28fda94c3ed9762" + integrity sha512-qKO34QCsOtSJrXxQQmXsPeaVHh6hMumBAFIoJTcsSr2VzrA6o/CW9HCGR8spCjzJhN8oKQHdj/Ytx0wwXyElkw== + dependencies: + fast-glob "^3.2.5" + klona "^2.0.4" + normalize-path "^3.0.0" + +stylus@0.54.8: + version "0.54.8" + resolved "https://registry.yarnpkg.com/stylus/-/stylus-0.54.8.tgz#3da3e65966bc567a7b044bfe0eece653e099d147" + integrity sha512-vr54Or4BZ7pJafo2mpf0ZcwA74rpuYCZbxrHBsH8kbcXOwSfvBFwsRfpGO5OD5fhG5HDCFW737PKaawI7OqEAg== + dependencies: + css-parse "~2.0.0" + debug "~3.1.0" + glob "^7.1.6" + mkdirp "~1.0.4" + safer-buffer "^2.1.2" + sax "~1.2.4" + semver "^6.3.0" + source-map "^0.7.3" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +svgo@^2.7.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + +symbol-observable@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" + integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== + +systemjs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.11.0.tgz#8df8e74fc05822e6c40170aa409b9ca64833315f" + integrity sha512-7YPIY44j+BoY+E6cGBSw0oCU8SNTTIHKZgftcBdwWkDzs/M86Fdlr21FrzAyph7Zo8r3CFGscyFe4rrBtixrBg== + +table-layout@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04" + integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A== + dependencies: + array-back "^4.0.1" + deep-extend "~0.6.0" + typical "^5.2.0" + wordwrapjs "^4.0.0" + +tapable@^2.1.1, tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + +tar@^6.0.2, tar@^6.1.0, tar@^6.1.2: + version "6.1.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" + integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +terser-webpack-plugin@5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.4.tgz#c369cf8a47aa9922bd0d8a94fe3d3da11a7678a1" + integrity sha512-C2WkFwstHDhVEmsmlCxrXUtVklS+Ir1A7twrYzrDrQQOIMOaVAYykaoo/Aq1K0QRkMoY2hhvDQY1cm4jnIMFwA== + dependencies: + jest-worker "^27.0.2" + p-limit "^3.1.0" + schema-utils "^3.0.0" + serialize-javascript "^6.0.0" + source-map "^0.6.1" + terser "^5.7.0" + +terser-webpack-plugin@^5.1.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.3.tgz#8033db876dd5875487213e87c627bca323e5ed90" + integrity sha512-Fx60G5HNYknNTNQnzQ1VePRuu89ZVYWfjRAeT5rITuCY/1b08s49e5kSQwHDirKZWuoKOBRFS98EUUoZ9kLEwQ== + dependencies: + "@jridgewell/trace-mapping" "^0.3.7" + jest-worker "^27.4.5" + schema-utils "^3.1.1" + serialize-javascript "^6.0.0" + terser "^5.7.2" + +terser@5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.7.1.tgz#2dc7a61009b66bb638305cb2a824763b116bf784" + integrity sha512-b3e+d5JbHAe/JSjwsC3Zn55wsBIM7AsHLjKxT31kGCldgbpFePaFo+PiddtO6uwRZWRw7sPXmAN8dTW61xmnSg== + dependencies: + commander "^2.20.0" + source-map "~0.7.2" + source-map-support "~0.5.19" + +terser@^5.7.0, terser@^5.7.2: + version "5.14.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.1.tgz#7c95eec36436cb11cf1902cc79ac564741d19eca" + integrity sha512-+ahUAE+iheqBTDxXhTisdA8hgvbEG1hHOQ9xmNjeUJSoi6DU/gMrKNcfZjHkyY6Alnuyc+ikYJaxxfHkT3+WuQ== + dependencies: + "@jridgewell/source-map" "^0.3.2" + acorn "^8.5.0" + commander "^2.20.0" + source-map-support "~0.5.20" + +text-segmentation@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943" + integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw== + dependencies: + utrie "^1.0.2" + +text-table@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +through@X.X.X, through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +thunky@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" + integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== + +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + +tinycolor2@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + +tmp@0.0.30: + version "0.0.30" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed" + integrity sha512-HXdTB7lvMwcb55XFfrTM8CPr/IYREk4hVBFaQ4b/6nInrluSL86hfHm7vu0luYKCfyBZp2trCjpc8caC3vVM3w== + dependencies: + os-tmpdir "~1.0.1" + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +tmp@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" + integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== + dependencies: + rimraf "^3.0.0" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg== + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg== + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tooltipster@^4.2.8: + version "4.2.8" + resolved "https://registry.yarnpkg.com/tooltipster/-/tooltipster-4.2.8.tgz#ad1970dd71ad853034e64e3fdd1745f7f3485071" + integrity sha512-Znmbt5UMzaiFCRlVaRtfRZYQqxrmNlj1+3xX/aT0OiA3xkQZhXYGbLJmZPigx0YiReYZpO7Lm2XKbUxXsiU/pg== + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tree-kill@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +ts-node@^10.0.0, ts-node@^10.4.0: + version "10.8.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.8.2.tgz#3185b75228cef116bf82ffe8762594f54b2a23f2" + integrity sha512-LYdGnoGddf1D6v8REPtIH+5iq/gTDuZqv2/UJUU7tKjuEU8xVZorBM+buCGNjj+pGEud+sOoM4CX3/YzINpENA== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +ts-transformer-keys@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/ts-transformer-keys/-/ts-transformer-keys-0.4.3.tgz#d62389a40f430c00ef98fb9575fb6778a196e3ed" + integrity sha512-pOTLlet1SnAvhKNr9tMAFwuv5283OkUNiq1fXTEK+vrSv+kxU3e2Ijr/UkqyX2vuMmvcNHdpXC31hob7ljH//g== + +tsconfig-paths@^3.9.0: + version "3.14.1" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" + integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + +tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + +tslint@~6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/tslint/-/tslint-6.1.3.tgz#5c23b2eccc32487d5523bd3a470e9aa31789d904" + integrity sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg== + dependencies: + "@babel/code-frame" "^7.0.0" + builtin-modules "^1.1.1" + chalk "^2.3.0" + commander "^2.12.1" + diff "^4.0.1" + glob "^7.1.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + mkdirp "^0.5.3" + resolve "^1.3.2" + semver "^5.3.0" + tslib "^1.13.0" + tsutils "^2.29.0" + +tsutils@^2.29.0: + version "2.29.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" + integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== + dependencies: + tslib "^1.8.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + +tv4@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/tv4/-/tv4-1.3.0.tgz#d020c846fadd50c855abb25ebaecc68fc10f7963" + integrity sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw== + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typeface-roboto@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-1.1.13.tgz#9c4517cb91e311706c74823e857b4bac9a764ae5" + integrity sha512-YXvbd3a1QTREoD+FJoEkl0VQNJoEjewR2H11IjVv4bp6ahuIcw0yyw/3udC4vJkHw3T3cUh85FTg8eWef3pSaw== + +typescript@4.3.5, typescript@~4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" + integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== + +typical@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" + integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw== + +typical@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" + integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== + +ua-parser-js@^0.7.30: + version "0.7.31" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6" + integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ== + +unicode-canonical-property-names-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" + integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ== + +unicode-match-property-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3" + integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== + dependencies: + unicode-canonical-property-names-ecmascript "^2.0.0" + unicode-property-aliases-ecmascript "^2.0.0" + +unicode-match-property-value-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714" + integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw== + +unicode-property-aliases-ecmascript@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" + integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA== + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== + dependencies: + imurmurhash "^0.1.4" + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ== + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +upath@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + +update-browserslist-db@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz#dbfc5a789caa26b1db8990796c2c8ebbce304824" + integrity sha512-jnmO2BEGUjsMOe/Fg9u0oczOe/ppIDZPebzccl1yDWGLFP16Pa1/RM5wEoKYPG2zstNcDuAStejyxsOuKINdGA== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg== + +url-parse@^1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ== + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +utrie@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" + integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw== + dependencies: + base64-arraybuffer "^1.0.2" + +uuid@8.3.2, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +validate-npm-package-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e" + integrity sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw== + dependencies: + builtins "^1.0.3" + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +void-elements@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" + integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== + +watchpack@^2.2.0, watchpack@^2.3.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +wbuf@^1.1.0, wbuf@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" + integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== + dependencies: + minimalistic-assert "^1.0.0" + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + +webdriver-js-extender@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz#57d7a93c00db4cc8d556e4d3db4b5db0a80c3bb7" + integrity sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ== + dependencies: + "@types/selenium-webdriver" "^3.0.0" + selenium-webdriver "^3.0.1" + +webdriver-manager@^12.1.7: + version "12.1.8" + resolved "https://registry.yarnpkg.com/webdriver-manager/-/webdriver-manager-12.1.8.tgz#5e70e73eaaf53a0767d5745270addafbc5905fd4" + integrity sha512-qJR36SXG2VwKugPcdwhaqcLQOD7r8P2Xiv9sfNbfZrKBnX243iAkOueX1yAmeNgIKhJ3YAT/F2gq6IiEZzahsg== + dependencies: + adm-zip "^0.4.9" + chalk "^1.1.1" + del "^2.2.0" + glob "^7.0.3" + ini "^1.3.4" + minimist "^1.2.0" + q "^1.4.1" + request "^2.87.0" + rimraf "^2.5.2" + semver "^5.3.0" + xml2js "^0.4.17" + +webpack-dev-middleware@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.0.0.tgz#0abe825275720e0a339978aea5f0b03b140c1584" + integrity sha512-9zng2Z60pm6A98YoRcA0wSxw1EYn7B7y5owX/Tckyt9KGyULTkLtiavjaXlWqOMkM0YtqGgL3PvMOFgyFLq8vw== + dependencies: + colorette "^1.2.2" + mem "^8.1.1" + memfs "^3.2.2" + mime-types "^2.1.31" + range-parser "^1.2.1" + schema-utils "^3.0.0" + +webpack-dev-middleware@^3.7.2: + version "3.7.3" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" + integrity sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ== + dependencies: + memory-fs "^0.4.1" + mime "^2.4.4" + mkdirp "^0.5.1" + range-parser "^1.2.1" + webpack-log "^2.0.0" + +webpack-dev-server@3.11.3: + version "3.11.3" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.3.tgz#8c86b9d2812bf135d3c9bce6f07b718e30f7c3d3" + integrity sha512-3x31rjbEQWKMNzacUZRE6wXvUFuGpH7vr0lIEbYpMAG9BOxi0928QU1BBswOAP3kg3H1O4hiS+sq4YyAn6ANnA== + dependencies: + ansi-html-community "0.0.8" + bonjour "^3.5.0" + chokidar "^2.1.8" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" + debug "^4.1.1" + del "^4.1.1" + express "^4.17.1" + html-entities "^1.3.1" + http-proxy-middleware "0.19.1" + import-local "^2.0.0" + internal-ip "^4.3.0" + ip "^1.1.5" + is-absolute-url "^3.0.3" + killable "^1.0.1" + loglevel "^1.6.8" + opn "^5.5.0" + p-retry "^3.0.1" + portfinder "^1.0.26" + schema-utils "^1.0.0" + selfsigned "^1.10.8" + semver "^6.3.0" + serve-index "^1.9.1" + sockjs "^0.3.21" + sockjs-client "^1.5.0" + spdy "^4.0.2" + strip-ansi "^3.0.1" + supports-color "^6.1.0" + url "^0.11.0" + webpack-dev-middleware "^3.7.2" + webpack-log "^2.0.0" + ws "^6.2.1" + yargs "^13.3.2" + +webpack-log@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" + integrity sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== + dependencies: + ansi-colors "^3.0.0" + uuid "^3.3.2" + +webpack-merge@5.8.0, webpack-merge@^5.7.3: + version "5.8.0" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61" + integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q== + dependencies: + clone-deep "^4.0.1" + wildcard "^2.0.0" + +webpack-sources@^1.2.0, webpack-sources@^1.3.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack-sources@^3.2.0, webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + +webpack-subresource-integrity@1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/webpack-subresource-integrity/-/webpack-subresource-integrity-1.5.2.tgz#e40b6578d3072e2d24104975249c52c66e9a743e" + integrity sha512-GBWYBoyalbo5YClwWop9qe6Zclp8CIXYGIz12OPclJhIrSplDxs1Ls1JDMH8xBPPrg1T6ISaTW9Y6zOrwEiAzw== + dependencies: + webpack-sources "^1.3.0" + +webpack@5.50.0: + version "5.50.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.50.0.tgz#5562d75902a749eb4d75131f5627eac3a3192527" + integrity sha512-hqxI7t/KVygs0WRv/kTgUW8Kl3YC81uyWQSo/7WUs5LsuRw0htH/fCwbVBGCuiX/t4s7qzjXFcf41O8Reiypag== + dependencies: + "@types/eslint-scope" "^3.7.0" + "@types/estree" "^0.0.50" + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/wasm-edit" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + acorn "^8.4.1" + acorn-import-assertions "^1.7.6" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.8.0" + es-module-lexer "^0.7.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.4" + json-parse-better-errors "^1.0.2" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.1.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.1.3" + watchpack "^2.2.0" + webpack-sources "^3.2.0" + +webpack@^5.64.4: + version "5.73.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" + integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^0.0.51" + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/wasm-edit" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + acorn "^8.4.1" + acorn-import-assertions "^1.7.6" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.9.3" + es-module-lexer "^0.9.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.1.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.1.3" + watchpack "^2.3.1" + webpack-sources "^3.2.3" + +websocket-driver@>=0.5.1, websocket-driver@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== + +which@^1.2.1, which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +wildcard@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" + integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== + +wordwrapjs@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f" + integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA== + dependencies: + reduce-flatten "^2.0.0" + typical "^5.2.0" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" + integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw== + dependencies: + async-limiter "~1.0.0" + +ws@~8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" + integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== + +xml2js@^0.4.17: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^1.10.0, yaml@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-parser@^21.0.0: + version "21.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.1.tgz#0267f286c877a4f0f728fceb6f8a3e4cb95c6e35" + integrity sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg== + +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yargs@^16.1.1: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^17.0.0: + version "17.5.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" + integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.0.0" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zone.js@~0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.10.3.tgz#3e5e4da03c607c9dcd92e37dd35687a14a140c16" + integrity sha512-LXVLVEq0NNOqK/fLJo3d0kfzd4sxwn2/h67/02pjCjfKDxgx1i9QqpvtHD8CrBnSSwMw5+dy11O7FRX5mkO7Cg== + +zone.js@~0.11.4: + version "0.11.6" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.11.6.tgz#c7cacfc298fe24bb585329ca04a44d9e2e840e74" + integrity sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg== + dependencies: + tslib "^2.3.0" -- GitLab